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;
|
created_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeEntity {
|
||||||
|
name: string;
|
||||||
|
entity_type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KnowledgeNote {
|
export interface KnowledgeNote {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -37,6 +42,7 @@ export interface KnowledgeNote {
|
|||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
entities?: KnowledgeEntity[];
|
||||||
sources?: KnowledgeSource[];
|
sources?: KnowledgeSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,25 @@ function actorLabel(name?: string | null, id?: string | null): string {
|
|||||||
return "—";
|
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 {
|
function useDebounced<T>(value: T, delay = 350): T {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -542,17 +561,44 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h1 className="mb-2 text-xl font-semibold">{note.title}</h1>
|
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
|
||||||
<div className="mb-3 flex flex-wrap gap-1">
|
|
||||||
{note.tags.map((tag) => (
|
{note.tags.length > 0 ? (
|
||||||
<span
|
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||||
key={tag}
|
{note.tags.map((tag) => (
|
||||||
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs"
|
<span
|
||||||
>
|
key={tag}
|
||||||
#{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"
|
||||||
</span>
|
>
|
||||||
))}
|
#{tag}
|
||||||
</div>
|
</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
|
<MarkdownRenderer
|
||||||
content={note.content_md}
|
content={note.content_md}
|
||||||
wikiLinks={wikiLinks}
|
wikiLinks={wikiLinks}
|
||||||
|
|||||||
@@ -47,8 +47,18 @@ def build_graph(
|
|||||||
if key:
|
if key:
|
||||||
by_key[key] = n["id"]
|
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()
|
entity_ids: set[str] = set()
|
||||||
for ent in entities or []:
|
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")})
|
nodes.append({"id": ent["id"], "type": "entity", "label": ent.get("name") or ent["id"], "category": ent.get("entity_type")})
|
||||||
entity_ids.add(ent["id"])
|
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.execute(delete(KnowledgeRelationRow).where(KnowledgeRelationRow.from_note_id == note_id))
|
||||||
await session.commit()
|
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 def list_entities(self) -> list[dict[str, Any]]:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
rows = (await session.execute(select(KnowledgeEntityRow))).scalars()
|
rows = (await session.execute(select(KnowledgeEntityRow))).scalars()
|
||||||
|
|||||||
@@ -412,7 +412,10 @@ class KnowledgeService:
|
|||||||
return {"items": items, "total": total}
|
return {"items": items, "total": total}
|
||||||
|
|
||||||
async def get_note(self, note_id: str) -> dict[str, Any] | None:
|
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(
|
async def update_note(
|
||||||
self,
|
self,
|
||||||
@@ -486,6 +489,15 @@ class KnowledgeService:
|
|||||||
await self.repo.delete_embeddings(note_id)
|
await self.repo.delete_embeddings(note_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to archive knowledge note %s in vault", note_id)
|
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
|
return True
|
||||||
|
|
||||||
# -- wikilinks --------------------------------------------------------
|
# -- wikilinks --------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user