跳转至

kb 骨架源码(只读展示)

说明与验收见 kb README;实际编码请在 VS Code 里打开 projects/kb/

.env.example

projects/kb/.env.example
1
2
3
4
# 复制为 .env 后按需修改;.env 已在 .gitignore 里,永远不要提交
KB_DB_PATH=./kb.db
KB_LOG_LEVEL=INFO
# KB_LOG_FILE=./kb.log

pyproject.toml

projects/kb/pyproject.toml
[project]
name = "kb"
version = "0.1.0"
description = "第 2 周小项目:知识库 CLI(Pydantic + SQLite + typer + logging)"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
    "pydantic>=2.12",
    "pydantic-settings>=2.10",
    "typer>=0.16",
    "rich>=14.0",
]

[project.scripts]
kb = "kb.cli:app"

[build-system]
requires = ["uv_build>=0.9,<0.10"]
build-backend = "uv_build"

[tool.ruff.lint]
# 小项目在仓库规则之外再开:N 命名、D docstring(Google 风格)、SIM 简化
extend-select = ["N", "D", "SIM"]
# D400/D415 要求首行以英文句号结尾,中文 docstring 用"。",故忽略
ignore = ["D400", "D403", "D415"]
pydocstyle = { convention = "google" }
per-file-ignores = { "tests/*" = ["D"] }

src/kb/__init__.py

projects/kb/src/kb/__init__.py
1
2
3
"""kb —— 第 2 周小项目:命令行知识库。"""

__version__ = "0.1.0"

src/kb/__main__.py

projects/kb/src/kb/__main__.py
1
2
3
4
5
"""让 `python -m kb` 可用。"""

from kb.cli import app

app()

src/kb/cli.py

projects/kb/src/kb/cli.py
"""命令行入口(typer)。只有这里允许 print / 读参数;业务全部在 service。"""

from pathlib import Path
from typing import Annotated

import typer
from rich.console import Console
from rich.table import Table

from kb import __version__
from kb.logging_config import setup_logging
from kb.models import Note
from kb.repository import SqliteNoteRepository
from kb.service import KBError, NoteService
from kb.settings import get_settings

app = typer.Typer(help="kb:命令行知识库(第 2 周小项目)", no_args_is_help=True)
console = Console()


def get_service() -> NoteService:
    """按配置创建服务(每条命令调用一次,测试里用环境变量指向临时 db)。"""
    settings = get_settings()
    setup_logging(settings.log_level, settings.log_file)
    return NoteService(SqliteNoteRepository(settings.db_path))


def render(notes: list[Note]) -> Table:
    """把笔记列表渲染成 rich 表格。"""
    table = Table(title=f"{len(notes)} 条笔记")
    table.add_column("ID", justify="right")
    table.add_column("标题")
    table.add_column("标签")
    table.add_column("创建时间")
    for n in notes:
        table.add_row(
            str(n.id), n.title, ", ".join(n.tags), n.created_at.strftime("%m-%d %H:%M")
        )
    return table


@app.callback()
def main() -> None:
    """kb:命令行知识库。有了这个回调,typer 才把每个 @app.command 当作子命令。"""


@app.command()
def hello(name: Annotated[str, typer.Argument(help="名字")] = "世界") -> None:
    """打招呼——用来验证安装与入口点是否正常。"""
    typer.echo(f"你好,{name}!kb 已就位。")


@app.command()
def version() -> None:
    """打印版本号。"""
    typer.echo(__version__)


@app.command()
def add(
    title: Annotated[str, typer.Argument(help="标题")],
    body: Annotated[str, typer.Option("--body", "-b", help="正文")] = "",
    tag: Annotated[
        list[str] | None, typer.Option("--tag", "-t", help="标签,可重复")
    ] = None,
) -> None:
    """新增一条笔记。"""
    try:
        note = get_service().add(title, body, tag or [])
    except (KBError, ValueError) as e:
        console.print(f"[red]失败:{e}[/red]")
        raise typer.Exit(code=1) from None
    console.print(f"已添加 #{note.id} {note.title}")


@app.command(name="list")
def list_notes(
    tag: Annotated[str | None, typer.Option("--tag", "-t", help="只看这个标签")] = None,
) -> None:
    """列出笔记。"""
    console.print(render(get_service().list(tag)))


@app.command()
def search(
    pattern: Annotated[str, typer.Argument(help="关键词或正则")],
    regex: Annotated[bool, typer.Option("--regex", help="按正则搜索")] = False,
) -> None:
    """按关键词或正则搜索标题与正文。"""
    console.print(render(get_service().search(pattern, regex=regex)))


@app.command()
def delete(note_id: Annotated[int, typer.Argument(help="笔记 ID")]) -> None:
    """删除一条笔记。"""
    try:
        get_service().delete(note_id)
    except KBError as e:
        console.print(f"[red]{e}[/red]")
        raise typer.Exit(code=1) from None
    console.print(f"已删除 #{note_id}")


@app.command()
def export(path: Annotated[Path, typer.Argument(help="输出 .json 路径")]) -> None:
    """把全部笔记导出为 JSON。"""
    count = get_service().export_json(path)
    console.print(f"已导出 {count} 条到 {path}")


@app.command(name="import")
def import_md(root: Annotated[Path, typer.Argument(help="Markdown 目录")]) -> None:
    """从目录批量导入 Markdown 文件。"""
    if not root.is_dir():
        console.print(f"[red]{root} 不是目录[/red]")
        raise typer.Exit(code=2)
    report = get_service().import_markdown(root)
    console.print(f"导入成功 {report.imported} 条,失败 {len(report.failed)} 条")
    for line in report.failed:
        console.print(f"  [yellow]{line}[/yellow]")


if __name__ == "__main__":
    app()

src/kb/logging_config.py

projects/kb/src/kb/logging_config.py
"""日志配置:控制台用 rich,文件用 JSON(UTF-8、按大小轮转)。

只有程序入口(cli.py)调用 setup_logging;其他模块只 `logging.getLogger(__name__)`。
"""

import json
import logging
import logging.handlers
from datetime import UTC, datetime
from pathlib import Path

_CONFIGURED_FLAG = "_kb_configured"


class JsonFormatter(logging.Formatter):
    """一行一个 JSON 对象,便于机器解析。"""

    def format(self, record: logging.LogRecord) -> str:
        """format。"""
        payload = {
            "time": datetime.fromtimestamp(record.created, UTC).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "line": f"{record.module}:{record.lineno}",
        }
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)


def setup_logging(level: str = "INFO", log_file: Path | None = None) -> logging.Logger:
    """配置根 logger:rich 控制台 +(可选)JSON 轮转文件;重复调用不重复加 handler。"""
    # TODO: 在这里实现
    raise NotImplementedError

src/kb/markdown_import.py

projects/kb/src/kb/markdown_import.py
"""从 Markdown 文件提取笔记:一级标题作 title,`#tag` 作标签,其余作正文。"""

import re
from collections.abc import Iterator
from pathlib import Path

from kb.models import Note

HEADING = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
HASHTAG = re.compile(r"(?<![\w#])#([\w\u4e00-\u9fff-]+)")
CODE_BLOCK = re.compile(r"```.*?```", re.DOTALL)


def iter_markdown(root: Path) -> Iterator[Path]:
    """惰性、按路径排序地产出 root 下全部 .md 文件。"""
    yield from sorted(p for p in root.rglob("*.md") if p.is_file())


def parse_markdown(text: str, fallback_title: str) -> Note:
    """把一段 Markdown 变成 Note:

    - title:第一个一级标题;没有则用 fallback_title(通常是文件名)
    - tags:正文(代码块除外)里的 #tag,去重小写
    - body:去掉标题行后的全文(保留代码块)
    """
    # TODO: 在这里实现
    raise NotImplementedError


def import_directory(root: Path) -> Iterator[tuple[Path, Note | None, str | None]]:
    """逐个文件产出 (路径, Note 或 None, 错误信息或 None);单个文件失败不中断。"""
    # TODO: 在这里实现
    raise NotImplementedError

src/kb/models.py

projects/kb/src/kb/models.py
"""数据模型:Note(Pydantic,负责校验与序列化)。"""

from datetime import UTC, datetime

from pydantic import BaseModel, ConfigDict, Field, field_validator

MAX_TAGS = 5


class Note(BaseModel):
    """一条笔记。

    例:Note(title=" 标题 ", tags=["Python", "python", "AI"]).tags -> ["python", "ai"]
    """

    model_config = ConfigDict(str_strip_whitespace=True)

    id: int | None = None
    title: str = Field(min_length=1, max_length=100)
    body: str = ""
    tags: list[str] = Field(default_factory=list)
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

    @field_validator("tags")
    @classmethod
    def normalize_tags(cls, tags: list[str]) -> list[str]:
        """标签统一小写、去首尾空白、去重(保持顺序)、最多 MAX_TAGS 个。"""
        # TODO: 在这里实现
        raise NotImplementedError

src/kb/repository.py

projects/kb/src/kb/repository.py
"""仓储层:NoteRepository 协议 + SQLite 实现 + 内存实现(测试/范例用)。"""

import logging
import re
import sqlite3
from pathlib import Path
from typing import Protocol

from kb.models import Note

logger = logging.getLogger(__name__)

SCHEMA = """
CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    body TEXT NOT NULL DEFAULT '',
    tags TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_title ON notes(title);
"""


class NoteRepository(Protocol):
    """存储层接口:服务层只依赖它,不关心背后是 SQLite 还是内存。"""

    def add(self, note: Note) -> Note:
        """保存并返回带 id 的副本。"""
        ...

    def get(self, note_id: int) -> Note | None:
        """按 id 取一条,不存在返回 None。"""
        ...

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序列出(可按标签过滤)。"""
        ...

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词或正则搜索。"""
        ...

    def delete(self, note_id: int) -> bool:
        """按 id 删除,返回是否删掉。"""
        ...


def _matches(note: Note, pattern: str, regex: bool) -> bool:
    """关键词(不分大小写)或正则是否命中标题/正文。"""
    haystack = f"{note.title}\n{note.body}"
    if regex:
        return re.search(pattern, haystack, re.IGNORECASE) is not None
    return pattern.lower() in haystack.lower()


class InMemoryNoteRepository:
    """字典实现:给测试与范例用(已完整实现,请对照着写 SQLite 版)。"""

    def __init__(self) -> None:
        """初始化。"""
        self._notes: dict[int, Note] = {}
        self._next_id = 1

    def add(self, note: Note) -> Note:
        """新增一条笔记,返回带 id 的副本。"""
        stored = note.model_copy(update={"id": self._next_id})
        self._notes[self._next_id] = stored
        self._next_id += 1
        return stored

    def get(self, note_id: int) -> Note | None:
        """按 id 取一条,不存在返回 None。"""
        return self._notes.get(note_id)

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序返回全部(可按标签过滤)。"""
        notes = sorted(self._notes.values(), key=lambda n: n.id or 0)
        if tag is not None:
            notes = [n for n in notes if tag.lower() in n.tags]
        return notes

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词(或正则)搜索。"""
        return [n for n in self.list() if _matches(n, pattern, regex)]

    def delete(self, note_id: int) -> bool:
        """按 id 删除,返回是否真的删掉了。"""
        return self._notes.pop(note_id, None) is not None


class SqliteNoteRepository:
    """SQLite 实现:参数化查询、`with conn:` 事务、Row 工厂、幂等建表。"""

    def __init__(self, db_path: Path) -> None:
        """初始化。"""
        db_path.parent.mkdir(parents=True, exist_ok=True)
        self.conn = sqlite3.connect(db_path)
        self.conn.row_factory = sqlite3.Row
        with self.conn:
            self.conn.executescript(SCHEMA)
        logger.debug("已连接数据库 %s", db_path)

    def close(self) -> None:
        """关闭数据库连接。"""
        self.conn.close()

    def add(self, note: Note) -> Note:
        """新增一条笔记,返回带 id 的副本。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def get(self, note_id: int) -> Note | None:
        """按 id 取一条,不存在返回 None。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序返回全部(可按标签过滤)。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词(或正则)搜索。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def delete(self, note_id: int) -> bool:
        """按 id 删除,返回是否真的删掉了。"""
        # TODO: 在这里实现
        raise NotImplementedError

    @staticmethod
    def _to_note(row: sqlite3.Row) -> Note:
        """_to_note。"""
        # TODO: 在这里实现
        raise NotImplementedError

src/kb/service.py

projects/kb/src/kb/service.py
"""服务层:业务用例。只依赖 NoteRepository 协议,不 print、不读键盘。"""

import logging
from dataclasses import dataclass
from pathlib import Path

from kb.models import Note
from kb.repository import NoteRepository

logger = logging.getLogger(__name__)


class KBError(Exception):
    """kb 的业务错误基类。"""


class NoteNotFoundError(KBError):
    """找不到笔记。"""


@dataclass(frozen=True)
class ImportReport:
    """一次导入的结果。"""

    imported: int
    failed: list[str]


class NoteService:
    """增、查、搜、删、导出、导入。"""

    def __init__(self, repo: NoteRepository) -> None:
        """初始化。"""
        self.repo = repo

    def add(self, title: str, body: str = "", tags: list[str] | None = None) -> Note:
        """新增一条笔记,返回带 id 的副本。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def get(self, note_id: int) -> Note:
        """按 id 取一条,不存在返回 None。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序返回全部(可按标签过滤)。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词(或正则)搜索。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def delete(self, note_id: int) -> None:
        """按 id 删除,返回是否真的删掉了。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def export_json(self, path: Path) -> int:
        """原子写:先写 .tmp 再 replace;返回导出条数。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def import_markdown(self, root: Path) -> ImportReport:
        """从目录批量导入 Markdown,单个文件失败不中断,返回报告。"""
        # TODO: 在这里实现
        raise NotImplementedError

src/kb/settings.py

projects/kb/src/kb/settings.py
"""配置:环境变量 / .env → Settings(pydantic-settings)。

优先级:环境变量 > .env 文件 > 默认值。变量名前缀 KB_,例如 KB_DB_PATH=./kb.db。
"""

from functools import lru_cache
from pathlib import Path

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """kb 的全部可配置项。"""

    model_config = SettingsConfigDict(
        env_prefix="KB_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
    )

    db_path: Path = Path("kb.db")
    log_level: str = "INFO"
    log_file: Path | None = None


@lru_cache
def get_settings() -> Settings:
    """进程内只解析一次配置;测试里用 get_settings.cache_clear() 重置。"""
    return Settings()

tests/conftest.py

projects/kb/tests/conftest.py
"""共享 fixture:临时数据库的仓储 / 服务 / CLI runner。"""

from pathlib import Path

import pytest
from typer.testing import CliRunner

from kb.repository import InMemoryNoteRepository, SqliteNoteRepository
from kb.service import NoteService
from kb.settings import get_settings


@pytest.fixture
def sqlite_repo(tmp_path: Path):
    repo = SqliteNoteRepository(tmp_path / "kb.db")
    yield repo
    repo.close()


@pytest.fixture
def memory_repo() -> InMemoryNoteRepository:
    return InMemoryNoteRepository()


@pytest.fixture
def service(memory_repo: InMemoryNoteRepository) -> NoteService:
    return NoteService(memory_repo)


@pytest.fixture
def runner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CliRunner:
    """让 CLI 用临时目录里的数据库,并清掉 Settings 缓存。"""
    monkeypatch.setenv("KB_DB_PATH", str(tmp_path / "cli.db"))
    monkeypatch.setenv("KB_LOG_LEVEL", "WARNING")
    monkeypatch.delenv("KB_LOG_FILE", raising=False)
    get_settings.cache_clear()
    yield CliRunner()
    get_settings.cache_clear()

tests/test_kb_cli.py

projects/kb/tests/test_kb_cli.py
import json
from pathlib import Path

from typer.testing import CliRunner

from kb.cli import app


def test_help_lists_commands(runner: CliRunner):
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    for cmd in ["add", "list", "search", "delete", "export", "import", "version"]:
        assert cmd in result.output


def test_add_list_search_delete_flow(runner: CliRunner):
    assert (
        runner.invoke(
            app, ["add", "Python 笔记", "-t", "Python", "-b", "正文"]
        ).exit_code
        == 0
    )
    assert runner.invoke(app, ["add", "另一条"]).exit_code == 0
    listed = runner.invoke(app, ["list", "--tag", "python"])
    assert (
        listed.exit_code == 0
        and "Python 笔记" in listed.output
        and "另一条" not in listed.output
    )
    found = runner.invoke(app, ["search", "^py", "--regex"])
    assert "Python 笔记" in found.output
    assert runner.invoke(app, ["delete", "1"]).exit_code == 0
    assert runner.invoke(app, ["delete", "1"]).exit_code == 1


def test_add_invalid_title_exits_1(runner: CliRunner):
    result = runner.invoke(app, ["add", "   "])
    assert result.exit_code == 1 and "失败" in result.output


def test_export_and_import(runner: CliRunner, tmp_path: Path):
    docs = tmp_path / "docs"
    docs.mkdir()
    (docs / "a.md").write_text("# 导入的\n\n#kb", encoding="utf-8")
    assert runner.invoke(app, ["import", str(docs)]).exit_code == 0
    out = tmp_path / "notes.json"
    result = runner.invoke(app, ["export", str(out)])
    assert result.exit_code == 0
    assert json.loads(out.read_text(encoding="utf-8"))[0]["title"] == "导入的"
    assert runner.invoke(app, ["import", str(tmp_path / "missing")]).exit_code == 2

tests/test_markdown_import.py

projects/kb/tests/test_markdown_import.py
from kb.markdown_import import parse_markdown


def test_parse_title_tags_body():
    text = "# 我的标题\n\n正文 #Python #python #ai\n```\n#not-a-tag\n```\n"
    note = parse_markdown(text, fallback_title="fallback")
    assert note.title == "我的标题"
    assert note.tags == ["python", "ai"]
    assert note.body.startswith("正文") and "```" in note.body


def test_parse_without_heading_uses_fallback():
    note = parse_markdown("只有正文 #tag", fallback_title="file-name")
    assert note.title == "file-name" and note.tags == ["tag"]


def test_parse_ignores_heading_hashes_as_tags():
    note = parse_markdown("# 标题\n## 二级 #real", fallback_title="f")
    assert note.tags == ["real"]

tests/test_models.py

projects/kb/tests/test_models.py
import pytest
from pydantic import ValidationError

from kb.models import Note


def test_note_defaults_and_strip():
    note = Note(title="  标题  ")
    assert note.title == "标题" and note.id is None and note.tags == []
    assert note.created_at.tzinfo is not None


def test_tags_normalized_lower_dedup_order():
    note = Note(title="t", tags=["Python", " python ", "AI", "", "ai"])
    assert note.tags == ["python", "ai"]


def test_too_many_tags_rejected():
    with pytest.raises(ValidationError) as info:
        Note(title="t", tags=list("abcdef"))
    assert "标签最多" in str(info.value)


@pytest.mark.parametrize("title", ["", "   ", "x" * 101])
def test_invalid_title(title: str):
    with pytest.raises(ValidationError):
        Note(title=title)


def test_json_roundtrip():
    note = Note(id=3, title="t", tags=["a"])
    data = note.model_dump(mode="json")
    assert isinstance(data["created_at"], str)
    assert Note.model_validate(data) == note

tests/test_repository.py

projects/kb/tests/test_repository.py
from pathlib import Path

import pytest

from kb.models import Note
from kb.repository import InMemoryNoteRepository, NoteRepository, SqliteNoteRepository


@pytest.fixture(params=["memory", "sqlite"])
def repo(request, tmp_path: Path):
    """同一套测试跑两种实现——这就是 Protocol 的价值。"""
    if request.param == "memory":
        yield InMemoryNoteRepository()
    else:
        r = SqliteNoteRepository(tmp_path / "r.db")
        yield r
        r.close()


def test_add_assigns_incrementing_ids(repo: NoteRepository):
    a = repo.add(Note(title="a"))
    b = repo.add(Note(title="b"))
    assert (a.id, b.id) == (1, 2)
    assert repo.get(1) is not None and repo.get(1).title == "a"
    assert repo.get(99) is None


def test_list_and_tag_filter(repo: NoteRepository):
    repo.add(Note(title="py", tags=["Python"]))
    repo.add(Note(title="re", tags=["regex"]))
    assert [n.title for n in repo.list()] == ["py", "re"]
    assert [n.title for n in repo.list(tag="PYTHON")] == ["py"]


def test_search_keyword_and_regex(repo: NoteRepository):
    repo.add(Note(title="正则表达式", body="re 模块"))
    repo.add(Note(title="Pydantic", body="校验"))
    assert [n.title for n in repo.search("正则")] == ["正则表达式"]
    assert [n.title for n in repo.search("^py", regex=True)] == ["Pydantic"]
    assert repo.search("无") == []


def test_delete(repo: NoteRepository):
    note = repo.add(Note(title="x"))
    assert repo.delete(note.id) is True
    assert repo.delete(note.id) is False


def test_sqlite_persists_across_connections(tmp_path: Path):
    path = tmp_path / "p.db"
    r1 = SqliteNoteRepository(path)
    r1.add(Note(title="持久", tags=["a", "b"]))
    r1.close()
    r2 = SqliteNoteRepository(path)
    notes = r2.list()
    r2.close()
    assert len(notes) == 1 and notes[0].tags == ["a", "b"]


def test_sqlite_is_injection_safe(sqlite_repo: SqliteNoteRepository):
    evil = "x'; DROP TABLE notes; --"
    sqlite_repo.add(Note(title=evil))
    assert sqlite_repo.search(evil)[0].title == evil
    assert len(sqlite_repo.list()) == 1

tests/test_service.py

projects/kb/tests/test_service.py
import json
from pathlib import Path

import pytest

from kb.service import NoteNotFoundError, NoteService


def test_add_get_delete(service: NoteService):
    note = service.add("标题", "正文", ["A"])
    assert note.id == 1 and note.tags == ["a"]
    assert service.get(1).title == "标题"
    service.delete(1)
    with pytest.raises(NoteNotFoundError):
        service.get(1)
    with pytest.raises(NoteNotFoundError):
        service.delete(1)


def test_add_rejects_invalid_title(service: NoteService):
    with pytest.raises(ValueError):
        service.add("")


def test_export_json_is_utf8_atomic(service: NoteService, tmp_path: Path):
    service.add("中文标题")
    target = tmp_path / "out" / "notes.json"
    assert service.export_json(target) == 1
    text = target.read_text(encoding="utf-8")
    assert "中文标题" in text and not list(target.parent.glob("*.tmp"))
    assert json.loads(text)[0]["id"] == 1


def test_import_markdown_reports_failures(service: NoteService, tmp_path: Path):
    (tmp_path / "a.md").write_text("# 第一篇\n\n内容 #python #Test\n", encoding="utf-8")
    (tmp_path / "b.md").write_text("没有标题的文件 #x", encoding="utf-8")
    (tmp_path / "bad.md").write_bytes(b"\xff\xfe bad")
    report = service.import_markdown(tmp_path)
    assert report.imported == 2 and len(report.failed) == 1
    titles = {n.title: n for n in service.list()}
    assert titles["第一篇"].tags == ["python", "test"]
    assert "b" in titles and titles["b"].tags == ["x"]


def test_service_logs(service: NoteService, caplog: pytest.LogCaptureFixture):
    with caplog.at_level("INFO", logger="kb.service"):
        service.add("日志")
    assert any("新增笔记" in r.message for r in caplog.records)