fix(knowledge): 保证笔记创建与索引更新一致性
This commit is contained in:
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -235,13 +236,18 @@ def get_block_hits(block_ids: list[str]) -> list[BlockHit]:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def set_index_meta(kv: dict[str, str]) -> None:
|
def set_index_meta(
|
||||||
conn = connect()
|
kv: dict[str, str], *, conn: sqlite3.Connection | None = None
|
||||||
|
) -> None:
|
||||||
|
"""写入索引元信息;传入连接时加入调用方现有事务。"""
|
||||||
|
owns = conn is None
|
||||||
|
conn = conn or connect()
|
||||||
try:
|
try:
|
||||||
with transaction(conn):
|
with transaction(conn) if owns else nullcontext():
|
||||||
for key, value in kv.items():
|
for key, value in kv.items():
|
||||||
conn.execute("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", (key, value))
|
conn.execute("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", (key, value))
|
||||||
finally:
|
finally:
|
||||||
|
if owns:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ def _write_markdown(rel_path: str, markdown: str) -> None:
|
|||||||
path.write_text(markdown, encoding="utf-8")
|
path.write_text(markdown, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_markdown(rel_path: str, markdown: str) -> None:
|
||||||
|
"""排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。"""
|
||||||
|
path = _abs_path(rel_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with path.open("x", encoding="utf-8") as handle:
|
||||||
|
handle.write(markdown)
|
||||||
|
except FileExistsError as exc:
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"RESOURCE_CONFLICT",
|
||||||
|
"a note already exists at this path",
|
||||||
|
{"file_path": rel_path},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _delete_markdown(rel_path: str) -> None:
|
def _delete_markdown(rel_path: str) -> None:
|
||||||
path = _abs_path(rel_path)
|
path = _abs_path(rel_path)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -123,9 +139,12 @@ async def index_note(parsed: ParsedNote) -> None:
|
|||||||
if block.block_id in missing_ids
|
if block.block_id in missing_ids
|
||||||
]
|
]
|
||||||
await vector_store.upsert(records, conn=conn)
|
await vector_store.upsert(records, conn=conn)
|
||||||
|
repository.set_index_meta(
|
||||||
|
{"embedding_model": embedding.model_id, "embedding_dim": str(embedding.dim)},
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
repository.set_index_meta({"embedding_model": embedding.model_id, "embedding_dim": str(embedding.dim)})
|
|
||||||
|
|
||||||
|
|
||||||
async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note:
|
async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note:
|
||||||
@@ -137,7 +156,14 @@ async def create_note(*, title: str, markdown: str, folder: str | None, tags: li
|
|||||||
created_at=now, updated_at=now,
|
created_at=now, updated_at=now,
|
||||||
)
|
)
|
||||||
parsed.title = title # 显式传入的 title 优先于正文推导(与 update_note 保持一致)
|
parsed.title = title # 显式传入的 title 优先于正文推导(与 update_note 保持一致)
|
||||||
_write_markdown(rel_path, markdown)
|
if repository.get_note_record(parsed.note_id) is not None:
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"RESOURCE_CONFLICT",
|
||||||
|
"a note already exists at this path",
|
||||||
|
{"note_id": parsed.note_id, "file_path": rel_path},
|
||||||
|
)
|
||||||
|
_create_markdown(rel_path, markdown)
|
||||||
try:
|
try:
|
||||||
await index_note(parsed)
|
await index_note(parsed)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
|
|||||||
@@ -281,6 +281,49 @@ def test_update_note_rolls_back_file_on_index_error(vault, monkeypatch) -> None:
|
|||||||
assert path.read_text(encoding="utf-8") == before # 文件已回滚,无部分提交
|
assert path.read_text(encoding="utf-8") == before # 文件已回滚,无部分提交
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_note_rolls_back_index_when_index_meta_fails(vault, monkeypatch) -> None:
|
||||||
|
"""索引元信息失败时,Markdown 与完整索引都保持旧版本。"""
|
||||||
|
from app import repository
|
||||||
|
from app.services import note_service
|
||||||
|
|
||||||
|
note = asyncio.run(
|
||||||
|
note_service.create_note(title="原子更新", markdown="旧正文", folder="", tags=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
def _boom(*_args, **_kwargs):
|
||||||
|
raise RuntimeError("index meta failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(repository, "set_index_meta", _boom)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
asyncio.run(note_service.update_note(note.note_id, markdown="新正文"))
|
||||||
|
|
||||||
|
got = asyncio.run(note_service.get_note(note.note_id))
|
||||||
|
record = repository.get_note_record(note.note_id)
|
||||||
|
assert got is not None and got.markdown == "旧正文"
|
||||||
|
assert record is not None
|
||||||
|
assert [block.content for block in record.blocks] == ["旧正文"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_note_rejects_existing_path_without_overwrite(vault) -> None:
|
||||||
|
"""POST 同目录同标题返回 409,且不改动已有 Markdown 和索引。"""
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.services import note_service
|
||||||
|
|
||||||
|
original = asyncio.run(
|
||||||
|
note_service.create_note(title="不能覆盖", markdown="原始正文", folder="测试", tags=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ApiError) as exc:
|
||||||
|
asyncio.run(
|
||||||
|
note_service.create_note(title="不能覆盖", markdown="替换正文", folder="测试", tags=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
assert exc.value.code == "RESOURCE_CONFLICT"
|
||||||
|
got = asyncio.run(note_service.get_note(original.note_id))
|
||||||
|
assert got is not None and got.markdown == "原始正文"
|
||||||
|
|
||||||
|
|
||||||
def test_update_removes_stale_vectors(vault) -> None:
|
def test_update_removes_stale_vectors(vault) -> None:
|
||||||
from app.database.db import connect
|
from app.database.db import connect
|
||||||
from app.services import note_service
|
from app.services import note_service
|
||||||
|
|||||||
Reference in New Issue
Block a user