feat(knowledge): 图谱跟随删除 + 笔记页展示实体 + 标签美化

图谱不随删除变化的根因:实体/关系删除时从不清理,build_graph 又把所有实体
都当节点,导致已删笔记的实体(六福珠宝/COMEX/CM助手…)永久残留。

- build_graph 只保留被「活笔记」提及的实体,孤儿实体节点不再出现(render 层兜底,
  历史脏数据无需迁移即生效)
- delete_note 删除时 clear_note_relations + prune_orphan_entities,DB 同步收敛
- get_note 返回 entities(repo.list_note_entities),笔记详情页展示实体
- 前端:实体按类型彩色 chip 展示(人物/机构/概念/产品/技术/地区…),标签改为 pill 样式
- 测试:get_note 返回实体 + 删除后图谱与实体表同步收敛

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-08 23:39:25 +08:00
1 parent 70bb2783b9
commit b70ed38d51
5 files changed
+119 -12

No files matched your search

+6
View File
@@ -20,6 +20,11 @@ export interface KnowledgeSource {
created_at?: string | null;
}
export interface KnowledgeEntity {
name: string;
entity_type?: string | null;
}
export interface KnowledgeNote {
id: string;
title: string;
@@ -37,6 +42,7 @@ export interface KnowledgeNote {
created_at?: string | null;
updated_at?: string | null;
tags: string[];
entities?: KnowledgeEntity[];
sources?: KnowledgeSource[];
}
+57 -11
View File
@@ -72,6 +72,25 @@ function actorLabel(name?: string | null, id?: string | null): string {
return "—";
}
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
};
function entityMeta(type?: string | null) {
return (
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
label: "其他",
className: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
}
);
}
function useDebounced<T>(value: T, delay = 350): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
@@ -542,17 +561,44 @@ function NoteDetail({ noteId }: { noteId: string }) {
</div>
) : (
<>
<h1 className="mb-2 text-xl font-semibold">{note.title}</h1>
<div className="mb-3 flex flex-wrap gap-1">
{note.tags.map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs"
>
#{tag}
</span>
))}
</div>
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
{note.tags.length > 0 ? (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
{note.tags.map((tag) => (
<span
key={tag}
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
>
#{tag}
</span>
))}
</div>
) : null}
{note.entities && note.entities.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground mr-0.5 text-xs">实体</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
meta.className,
)}
title={meta.label}
>
<span className="size-1.5 rounded-full bg-current opacity-60" />
{ent.name}
<span className="opacity-60">{meta.label}</span>
</span>
);
})}
</div>
) : null}
<MarkdownRenderer
content={note.content_md}
wikiLinks={wikiLinks}
@@ -47,8 +47,18 @@ def build_graph(
if key:
by_key[key] = n["id"]
# Only surface entities that are still mentioned by a LIVE note. Entities whose
# only referencing notes were deleted/archived would otherwise linger forever
# as orphan nodes ("graph doesn't follow my deletions").
mentioned: set[str] = set()
for rel in relations or []:
if rel.get("from_note_id") in note_ids and rel.get("to_entity_id"):
mentioned.add(rel["to_entity_id"])
entity_ids: set[str] = set()
for ent in entities or []:
if ent["id"] not in mentioned:
continue
nodes.append({"id": ent["id"], "type": "entity", "label": ent.get("name") or ent["id"], "category": ent.get("entity_type")})
entity_ids.add(ent["id"])
@@ -447,6 +447,39 @@ class KnowledgeRepository:
await session.execute(delete(KnowledgeRelationRow).where(KnowledgeRelationRow.from_note_id == note_id))
await session.commit()
async def list_note_entities(self, note_id: str) -> list[dict[str, Any]]:
"""Return the entities mentioned by a note (for note-detail display)."""
async with self._sf() as session:
stmt = (
select(KnowledgeEntityRow.name, KnowledgeEntityRow.entity_type)
.join(KnowledgeRelationRow, KnowledgeRelationRow.to_entity_id == KnowledgeEntityRow.id)
.where(KnowledgeRelationRow.from_note_id == note_id)
.order_by(KnowledgeEntityRow.name)
)
seen: set[str] = set()
out: list[dict[str, Any]] = []
for name, etype in (await session.execute(stmt)).all():
key = (name or "").lower()
if not name or key in seen:
continue
seen.add(key)
out.append({"name": name, "entity_type": etype})
return out
async def prune_orphan_entities(self) -> int:
"""Delete entity rows no longer referenced by any relation. Returns count."""
async with self._sf() as session:
referenced: set[str] = set()
for col in (KnowledgeRelationRow.from_entity_id, KnowledgeRelationRow.to_entity_id):
for (eid,) in (await session.execute(select(col).where(col.isnot(None)))).all():
referenced.add(eid)
all_ids = list((await session.execute(select(KnowledgeEntityRow.id))).scalars())
orphans = [eid for eid in all_ids if eid not in referenced]
if orphans:
await session.execute(delete(KnowledgeEntityRow).where(KnowledgeEntityRow.id.in_(orphans)))
await session.commit()
return len(orphans)
async def list_entities(self) -> list[dict[str, Any]]:
async with self._sf() as session:
rows = (await session.execute(select(KnowledgeEntityRow))).scalars()
@@ -412,7 +412,10 @@ class KnowledgeService:
return {"items": items, "total": total}
async def get_note(self, note_id: str) -> dict[str, Any] | None:
return await self.repo.get_note(note_id)
note = await self.repo.get_note(note_id)
if note is not None:
note["entities"] = await self.repo.list_note_entities(note_id)
return note
async def update_note(
self,
@@ -486,6 +489,15 @@ class KnowledgeService:
await self.repo.delete_embeddings(note_id)
except Exception:
logger.exception("Failed to archive knowledge note %s in vault", note_id)
# Drop this note's entity mentions + any now-orphaned entities so the graph
# follows the deletion (no lingering nodes from removed notes).
try:
await self.repo.clear_note_relations(note_id)
pruned = await self.repo.prune_orphan_entities()
if pruned:
logger.info("[knowledge] deleted note %s → pruned %d orphan entities", note_id, pruned)
except Exception:
logger.exception("Failed to prune relations/entities for deleted note %s", note_id)
return True
# -- wikilinks --------------------------------------------------------