Files
NotesAgentic/backend/app/repository.py
T

300 lines
9.4 KiB
Python

"""SQLite Repository:笔记元数据、Block 与 FTS5 的读写。
向量(vec_blocks)不在这里处理,交给 Retrieval 基础设施层的 VectorStore(见
app/retrieval/vectorstore.py)。本层只负责 notes / blocks / blocks_fts 三张表的访问,
返回领域记录(NoteRecord / BlockHit / FtsHit),不负责业务编排。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime
from app.contracts import NoteBlock
from app.database.db import connect, transaction
from app.textutils import segment
def _iso(dt: datetime) -> str:
return dt.isoformat()
def _parse_dt(value: str) -> datetime:
return datetime.fromisoformat(value)
@dataclass
class NoteRecord:
note_id: str
title: str
file_path: str
folder: str
tags: list[str]
created_at: datetime
updated_at: datetime
blocks: list[NoteBlock] = field(default_factory=list)
@dataclass
class BlockHit:
"""检索时返回的完整 Block 上下文,用于组装 Citation 与 metadata 过滤。"""
block_id: str
note_id: str
title: str
file_path: str
folder: str
heading_path: list[str]
content: str
start_offset: int
end_offset: int
tags: list[str]
created_at: datetime
updated_at: datetime
@dataclass
class FtsHit:
block_id: str
note_id: str
bm25: float
def replace_note_metadata(
*,
note_id: str,
title: str,
file_path: str,
folder: str,
tags: list[str],
created_at: datetime,
updated_at: datetime,
blocks: list[NoteBlock],
) -> None:
"""整体替换一条笔记的元数据、Block 与 FTS5 索引(单事务)。"""
conn = connect()
try:
with transaction(conn):
conn.execute(
"""
INSERT INTO notes (note_id, title, file_path, folder, tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(note_id) DO UPDATE SET
title = excluded.title,
file_path = excluded.file_path,
folder = excluded.folder,
tags = excluded.tags,
updated_at = excluded.updated_at
""",
(note_id, title, file_path, folder, json.dumps(tags, ensure_ascii=False),
_iso(created_at), _iso(updated_at)),
)
conn.execute("DELETE FROM blocks WHERE note_id = ?", (note_id,))
conn.execute("DELETE FROM blocks_fts WHERE note_id = ?", (note_id,))
for position, block in enumerate(blocks):
conn.execute(
"""
INSERT INTO blocks
(block_id, note_id, heading_path, start_offset, end_offset,
content, content_hash, token_count, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(block.block_id, note_id, json.dumps(block.heading_path, ensure_ascii=False),
block.start_offset, block.end_offset, block.content,
block.content_hash, block.token_count, position),
)
# FTS5 存分词后的可检索文本;原文仍由 blocks.content 保留用于展示
conn.execute(
"INSERT INTO blocks_fts (block_id, note_id, heading_path, content) VALUES (?, ?, ?, ?)",
(block.block_id, note_id, segment(" ".join(block.heading_path)), segment(block.content)),
)
finally:
conn.close()
def delete_note(note_id: str) -> list[str]:
"""删除笔记及其 Block、FTS5 索引;返回被删除的 block_id 供向量层清理。"""
conn = connect()
try:
block_ids = [
row["block_id"]
for row in conn.execute("SELECT block_id FROM blocks WHERE note_id = ?", (note_id,))
]
with transaction(conn):
conn.execute("DELETE FROM blocks_fts WHERE note_id = ?", (note_id,))
conn.execute("DELETE FROM notes WHERE note_id = ?", (note_id,)) # blocks 级联删除
return block_ids
finally:
conn.close()
def get_note_record(note_id: str) -> NoteRecord | None:
conn = connect()
try:
row = conn.execute("SELECT * FROM notes WHERE note_id = ?", (note_id,)).fetchone()
if row is None:
return None
blocks = [
_block_from_row(b)
for b in conn.execute("SELECT * FROM blocks WHERE note_id = ? ORDER BY position", (note_id,))
]
return NoteRecord(
note_id=row["note_id"],
title=row["title"],
file_path=row["file_path"],
folder=row["folder"],
tags=json.loads(row["tags"] or "[]"),
created_at=_parse_dt(row["created_at"]),
updated_at=_parse_dt(row["updated_at"]),
blocks=blocks,
)
finally:
conn.close()
def list_note_summaries(
*, limit: int = 50, offset: int = 0, folder: str | None = None, tag: str | None = None
) -> tuple[list, int]:
conn = connect()
try:
where: list[str] = []
params: list[str] = []
if folder:
where.append("folder = ?")
params.append(folder)
if tag:
where.append("EXISTS (SELECT 1 FROM json_each(notes.tags) AS j WHERE j.value = ?)")
params.append(tag)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
total = conn.execute(f"SELECT COUNT(*) FROM notes {where_sql}", params).fetchone()[0]
rows = conn.execute(
f"SELECT * FROM notes {where_sql} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
items = [
{
"note_id": r["note_id"],
"title": r["title"],
"file_path": r["file_path"],
"tags": json.loads(r["tags"] or "[]"),
"created_at": _parse_dt(r["created_at"]),
"updated_at": _parse_dt(r["updated_at"]),
}
for r in rows
]
return items, total
finally:
conn.close()
def fts_search(match: str, limit: int = 100) -> list[FtsHit]:
conn = connect()
try:
rows = conn.execute(
"""
SELECT block_id, note_id, bm25(blocks_fts) AS rank
FROM blocks_fts
WHERE blocks_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(match, limit),
).fetchall()
return [FtsHit(block_id=r["block_id"], note_id=r["note_id"], bm25=r["rank"]) for r in rows]
finally:
conn.close()
def get_block_hits(block_ids: list[str]) -> list[BlockHit]:
if not block_ids:
return []
conn = connect()
try:
placeholders = ",".join("?" * len(block_ids))
rows = conn.execute(
f"""
SELECT b.block_id, b.note_id, b.heading_path, b.start_offset, b.end_offset, b.content,
n.title, n.file_path, n.folder, n.tags, n.created_at, n.updated_at
FROM blocks b
JOIN notes n ON n.note_id = b.note_id
WHERE b.block_id IN ({placeholders})
""",
block_ids,
).fetchall()
return [_block_hit_from_row(r) for r in rows]
finally:
conn.close()
def set_index_meta(kv: dict[str, str]) -> None:
conn = connect()
try:
with transaction(conn):
for key, value in kv.items():
conn.execute("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", (key, value))
finally:
conn.close()
def get_index_meta() -> dict[str, str]:
conn = connect()
try:
return {r["key"]: r["value"] for r in conn.execute("SELECT key, value FROM index_meta")}
finally:
conn.close()
def clear_all() -> None:
"""清空元数据、Block 与 FTS5(重建索引用,向量由 VectorStore.clear 处理)。"""
conn = connect()
try:
with transaction(conn):
conn.execute("DELETE FROM blocks_fts")
conn.execute("DELETE FROM blocks")
conn.execute("DELETE FROM notes")
finally:
conn.close()
def stats() -> dict[str, int]:
conn = connect()
try:
notes = conn.execute("SELECT COUNT(*) AS c FROM notes").fetchone()["c"]
blocks = conn.execute("SELECT COUNT(*) AS c FROM blocks").fetchone()["c"]
return {"notes": notes, "blocks": blocks}
finally:
conn.close()
def _block_from_row(row) -> NoteBlock:
return NoteBlock(
block_id=row["block_id"],
note_id=row["note_id"],
heading_path=json.loads(row["heading_path"] or "[]"),
start_offset=row["start_offset"],
end_offset=row["end_offset"],
content=row["content"],
content_hash=row["content_hash"],
token_count=row["token_count"],
)
def _block_hit_from_row(row) -> BlockHit:
return BlockHit(
block_id=row["block_id"],
note_id=row["note_id"],
title=row["title"],
file_path=row["file_path"],
folder=row["folder"],
heading_path=json.loads(row["heading_path"] or "[]"),
content=row["content"],
start_offset=row["start_offset"],
end_offset=row["end_offset"],
tags=json.loads(row["tags"] or "[]"),
created_at=_parse_dt(row["created_at"]),
updated_at=_parse_dt(row["updated_at"]),
)