From b70ed38d51267feee1d27ba7ac7215e3f9398119 Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 8 Jun 2026 23:39:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E5=9B=BE=E8=B0=B1=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F=E5=88=A0=E9=99=A4=20+=20=E7=AC=94=E8=AE=B0=E9=A1=B5?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=AE=9E=E4=BD=93=20+=20=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E7=BE=8E=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图谱不随删除变化的根因:实体/关系删除时从不清理,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) --- frontend-web/src/core/knowledge/types.ts | 6 ++ frontend-web/src/pages/KnowledgeBasePage.tsx | 68 ++++++++++++++++--- .../harness/deerflow/knowledge/exporter.py | 10 +++ .../harness/deerflow/knowledge/repository.py | 33 +++++++++ .../harness/deerflow/knowledge/service.py | 14 +++- 5 files changed, 119 insertions(+), 12 deletions(-) diff --git a/frontend-web/src/core/knowledge/types.ts b/frontend-web/src/core/knowledge/types.ts index ee05a5fb..941799ac 100644 --- a/frontend-web/src/core/knowledge/types.ts +++ b/frontend-web/src/core/knowledge/types.ts @@ -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[]; } diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx index a16ed2f1..e4f51090 100644 --- a/frontend-web/src/pages/KnowledgeBasePage.tsx +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -72,6 +72,25 @@ function actorLabel(name?: string | null, id?: string | null): string { return "—"; } +const ENTITY_TYPE_META: Record = { + 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(value: T, delay = 350): T { const [debounced, setDebounced] = useState(value); useEffect(() => { @@ -542,17 +561,44 @@ function NoteDetail({ noteId }: { noteId: string }) { ) : ( <> -

{note.title}

-
- {note.tags.map((tag) => ( - - #{tag} - - ))} -
+

{note.title}

+ + {note.tags.length > 0 ? ( +
+ {note.tags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null} + + {note.entities && note.entities.length > 0 ? ( +
+ 实体 + {note.entities.map((ent) => { + const meta = entityMeta(ent.entity_type); + return ( + + + {ent.name} + {meta.label} + + ); + })} +
+ ) : null} + 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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py index 7d41ae9b..83fb2a88 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py @@ -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 --------------------------------------------------------