Files
NotesAgentic/backend/app/services/note_service.py
T

368 lines
14 KiB
Python

"""Note 服务:Markdown 文件读写 + 解析 + 索引编排。
Markdown 文件是笔记正文的持久化载体(Vault),SQLite/FTS5/向量是可重建索引。
本服务负责在两者之间保持一致:写文件后解析并写入元数据、FTS5 与向量。
"""
from __future__ import annotations
import sqlite3
from contextlib import nullcontext
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from app import repository
from app.contracts import Note, NoteBlock, NoteSummary
from app.database.db import connect, transaction
from app.errors import ApiError
from app.knowledge.parser import ParsedNote, parse_note
from app.local_models.runtime import LocalEmbedding
from app.retrieval import routed_vectors
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
from app.services.coordination import serialized_vault_mutation
from app.services.vault_paths import (
normalize_entry_name,
normalize_folder,
resolve_in_vault,
safe_note_filename,
)
# 真实模型接口不在 API 进程加载权重;测试可显式替换该实例。
embedding = LocalEmbedding()
vector_store = SqliteVecStore()
def _rel_path(folder: str | None, title: str) -> tuple[str, str]:
"""由 folder + title 生成安全的相对路径,返回 (rel_path, 清洗后的 folder)。"""
clean_folder = normalize_folder(folder)
name = safe_note_filename(title)
rel = f"{clean_folder}/{name}" if clean_folder else name
return rel, clean_folder
def _read_markdown(rel_path: str) -> str:
path = resolve_in_vault(rel_path)
return path.read_text(encoding="utf-8") if path.exists() else ""
def _write_markdown(rel_path: str, markdown: str) -> None:
path = resolve_in_vault(rel_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
def _create_markdown(rel_path: str, markdown: str) -> None:
"""排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。"""
path = resolve_in_vault(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:
path = resolve_in_vault(rel_path)
if path.exists():
path.unlink()
PreparedIndex = tuple[list[list[float]], routed_vectors.RemoteEmbeddings | None]
async def prepare_note_index(parsed: ParsedNote) -> PreparedIndex:
"""Compute vectors before opening a write transaction (including API I/O)."""
texts = [block.content for block in parsed.blocks]
if isinstance(embedding, LocalEmbedding):
# One routed invocation: API first, validated local fallback. No hash vectors.
remote = await routed_vectors.embed_remote(texts, accept_local=True)
return [], remote
vectors = await embedding.embed_documents(texts)
remote = await routed_vectors.embed_remote(texts)
return vectors, remote
async def index_note(
parsed: ParsedNote, *, prepared: PreparedIndex | None = None,
conn: sqlite3.Connection | None = None,
) -> None:
"""把解析结果写入元数据 + FTS5 + 向量(三层可重建索引),单事务保证原子性。
元数据与向量在同一连接、同一事务内提交,避免「新元数据已提交、向量写入失败」的
半提交状态。替换元数据时拿到旧 block_id:清理已删除/内容变化的旧向量,只为新增
block 写向量(内容未变的 block 其向量仍有效,无需重复写入)。
"""
if conn is not None and prepared is None:
raise ValueError("Prepare embeddings before supplying a write connection")
vectors, remote = prepared if prepared is not None else await prepare_note_index(parsed)
owns = conn is None
conn = conn or connect()
try:
with transaction(conn) if owns else nullcontext():
old_block_ids = repository.replace_note_metadata(
conn=conn,
note_id=parsed.note_id,
title=parsed.title,
file_path=parsed.file_path,
folder=parsed.folder,
tags=parsed.tags,
created_at=parsed.created_at,
updated_at=parsed.updated_at,
blocks=parsed.blocks,
)
old_ids = set(old_block_ids)
new_ids = {block.block_id for block in parsed.blocks}
stale_ids = [bid for bid in old_ids if bid not in new_ids]
if stale_ids:
await vector_store.delete(stale_ids, conn=conn)
missing_ids = [bid for bid in new_ids if bid not in old_ids]
records = [
VectorRecord(id=block.block_id, vector=vector)
for block, vector in zip(parsed.blocks, vectors)
if block.block_id in missing_ids
]
await vector_store.upsert(records, conn=conn)
routed_vectors.store_remote(conn, [block.block_id for block in parsed.blocks], remote)
repository.set_index_meta(
{"embedding_model": remote.space_id if remote and isinstance(embedding, LocalEmbedding) else embedding.model_id,
"embedding_dim": str(remote.dimensions if remote and isinstance(embedding, LocalEmbedding) else embedding.dim)},
conn=conn,
)
finally:
if owns:
conn.close()
@serialized_vault_mutation
async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note:
rel_path, clean_folder = _rel_path(folder, title)
now = datetime.now(timezone.utc)
parsed = parse_note(
# 创建时空标签视为「未显式指定」,由 frontmatter 推导(创建无「清空」语义)
markdown=markdown, file_path=rel_path, folder=clean_folder, tags=tags or None,
created_at=now, updated_at=now,
)
parsed.title = title # 显式传入的 title 优先于正文推导(与 update_note 保持一致)
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:
await index_note(parsed)
except BaseException:
_delete_markdown(rel_path) # 索引失败时回滚,避免「文件已写、索引缺失」的部分提交
raise
return _build_note(parsed.note_id, parsed.title, parsed.file_path, parsed.tags,
parsed.created_at, parsed.updated_at, parsed.blocks, markdown)
async def get_note(note_id: str) -> Note | None:
record = repository.get_note_record(note_id)
if record is None:
return None
markdown = _read_markdown(record.file_path)
return _build_note(record.note_id, record.title, record.file_path, record.tags,
record.created_at, record.updated_at, record.blocks, markdown)
@serialized_vault_mutation
async def update_note(
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None
) -> Note:
record = repository.get_note_record(note_id)
if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
old_md = _read_markdown(record.file_path)
new_md = old_md if markdown is None else markdown
# PATCH 语义:tags=None 保持原标签;[] 清空;非空列表替换(区别于 create 的 frontmatter 推导)
effective_tags = record.tags if tags is None else tags
_write_markdown(record.file_path, new_md)
now = datetime.now(timezone.utc)
try:
parsed = parse_note(
markdown=new_md, file_path=record.file_path, folder=record.folder, tags=effective_tags,
created_at=record.created_at, updated_at=now,
)
if title is not None:
parsed.title = title # 显式传入的 title 覆盖正文推导结果
await index_note(parsed)
except BaseException:
_write_markdown(record.file_path, old_md) # 索引失败时回滚正文,避免部分提交
raise
return _build_note(parsed.note_id, parsed.title, parsed.file_path, parsed.tags,
parsed.created_at, parsed.updated_at, parsed.blocks, new_md)
@serialized_vault_mutation
async def move_note(note_id: str, *, folder: str) -> Note:
record = repository.get_note_record(note_id)
if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
clean_folder = normalize_folder(folder)
filename = Path(record.file_path).name
new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename
if new_rel_path == record.file_path:
note = await get_note(note_id)
assert note is not None
return note
source = resolve_in_vault(record.file_path)
target = resolve_in_vault(new_rel_path)
if not source.is_file():
raise ApiError(
409, "NOTE_FILE_MISSING", "note file is missing from the Vault",
{"note_id": note_id, "file_path": record.file_path},
)
if target.exists():
raise ApiError(
409, "RESOURCE_CONFLICT", "a note already exists at the target path",
{"note_id": note_id, "file_path": new_rel_path},
)
markdown = source.read_text(encoding="utf-8")
target.parent.mkdir(parents=True, exist_ok=True)
source.replace(target)
try:
parsed = parse_note(
markdown=markdown,
file_path=new_rel_path,
folder=clean_folder,
tags=record.tags,
created_at=record.created_at,
updated_at=datetime.now(timezone.utc),
note_id=record.note_id,
)
parsed.title = record.title
await index_note(parsed)
except BaseException:
target.replace(source)
raise
return _build_note(
parsed.note_id, parsed.title, parsed.file_path, parsed.tags,
parsed.created_at, parsed.updated_at, parsed.blocks, markdown,
)
@serialized_vault_mutation
async def rename_note(note_id: str, *, file_name: str) -> Note:
"""重命名 Markdown 文件并保留 note_id、Block 与向量身份。"""
record = repository.get_note_record(note_id)
if record is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
normalized = normalize_entry_name(file_name, markdown=True)
source = resolve_in_vault(record.file_path)
folder = normalize_folder(record.folder)
new_file_path = f"{folder}/{normalized}" if folder else normalized
target = resolve_in_vault(new_file_path)
if new_file_path == record.file_path:
note = await get_note(note_id)
assert note is not None
return note
if not source.is_file():
raise ApiError(
409,
"NOTE_FILE_MISSING",
"note file is missing from the Vault",
{"note_id": note_id, "file_path": record.file_path},
)
if target.exists():
raise ApiError(
409,
"RESOURCE_CONFLICT",
"a note already exists with the requested file name",
{"note_id": note_id, "file_path": new_file_path},
)
source.replace(target)
now = datetime.now(timezone.utc)
conn = connect()
try:
with transaction(conn):
repository.update_note_location(
conn=conn,
note_id=note_id,
title=Path(normalized).stem,
file_path=new_file_path,
folder=folder,
updated_at=now,
)
except BaseException:
target.replace(source)
raise
finally:
conn.close()
note = await get_note(note_id)
assert note is not None
return note
@serialized_vault_mutation
async def delete_note(note_id: str) -> bool:
record = repository.get_note_record(note_id)
if record is None:
return False
path = resolve_in_vault(record.file_path)
tombstone = path.with_name(f".{path.name}.{uuid4().hex}.deleting") if path.exists() else None
if tombstone is not None:
path.replace(tombstone)
conn = connect()
try:
with transaction(conn):
block_ids = repository.delete_note(note_id, conn=conn)
await vector_store.delete(block_ids, conn=conn)
except BaseException:
if tombstone is not None and tombstone.exists():
tombstone.replace(path)
raise
finally:
conn.close()
if tombstone is not None:
# 数据库已提交后,tombstone 即不再属于 Vault;清理失败不应把成功删除报告为失败。
try:
tombstone.unlink(missing_ok=True)
except OSError:
pass
return True
def list_notes(*, limit: int, offset: int, folder: str | None, tag: str | None) -> tuple[list[NoteSummary], int]:
items, total = repository.list_note_summaries(limit=limit, offset=offset, folder=folder, tag=tag)
return [NoteSummary(**item) for item in items], total
def _build_note(
note_id: str, title: str, file_path: str, tags: list[str],
created_at: datetime, updated_at: datetime, blocks: list[NoteBlock], markdown: str,
) -> Note:
return Note(
note_id=note_id,
title=title,
file_path=file_path,
tags=tags,
created_at=created_at,
updated_at=updated_at,
markdown=markdown,
blocks=blocks,
)