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:
1 parent
70bb2783b9
commit
b70ed38d51
5 files changed
+119
-12
No files matched your search
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 --------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user