feat(knowledge): 阶段三 知识治理 + 阶段四 图谱增强

阶段三(治理):
- sensitive.py:写入前脱敏 token/密钥/手机号/身份证/邮箱(sensitive_redaction_enabled)
- 版本快照:更新前写 knowledge_note_versions;GET /notes/{id}/versions
- dedup.py:标题/摘要 Jaccard + 内容哈希查重;GET /duplicates、POST /merge(来源并入主条目、重复项归档)
- auto_ingest.py:独立后台队列/线程沉淀,不阻塞问答;中间件 after_agent 在 auto_ingest_enabled 时入队;价值判断(最小字符)+ skip_existing 去重;自动沉淀落 draft 待审

阶段四(图谱):
- LLM 抽取 entities/relations;service 存 knowledge_entities/knowledge_relations 并连边
- exporter.build_graph 支持实体节点 + note→entity/entity→entity 边;graph.html/json 增强
- GET /api/knowledge/graph 返回产品图谱数据

前端:知识库页「查重合并」对话框 + 相关 api/hooks
配置:sensitive_redaction_enabled / dedup_similarity_threshold / entity_extraction_enabled
测试:tests/test_knowledge.py 20 passed

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 18:31:33 +08:00
1 parent 683cc6d76e
commit 81e4fd92ce
16 files changed
+691 -7

No files matched your search

+58
View File
@@ -131,3 +131,61 @@ export async function exportKnowledgeGraph() {
export function knowledgeExportFileUrl(filename: string) {
return `${baseUrl()}/export/files/${encodeURIComponent(filename)}`;
}
// --- phase 2/3/4 ---
export interface KnowledgeVersion {
id: string;
note_id: string;
title: string;
summary?: string | null;
content_md: string;
status: string;
tags: string[];
edited_by?: string | null;
created_at?: string | null;
}
export async function listNoteVersions(noteId: string) {
const res = await parseResponse<{ items: KnowledgeVersion[] }>(
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}/versions`),
);
return res.items;
}
export interface DuplicateGroup {
primary: { id: string; title: string | null };
duplicates: Array<{ id: string; title: string | null; score: number }>;
}
export async function listDuplicates() {
const res = await parseResponse<{ groups: DuplicateGroup[] }>(
await apiFetch(`${baseUrl()}/duplicates`),
);
return res.groups;
}
export async function mergeKnowledgeNotes(primaryId: string, duplicateIds: string[]) {
return parseResponse<{ note: KnowledgeNote }>(
await apiFetch(`${baseUrl()}/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: duplicateIds }),
}),
);
}
export async function reindexKnowledge() {
return parseResponse<{ reindexed: number; embedding_enabled: number }>(
await apiFetch(`${baseUrl()}/reindex`, { method: "POST" }),
);
}
export interface KnowledgeGraph {
nodes: Array<{ id: string; type: string; label: string; category?: string | null }>;
edges: Array<{ id: string; source: string; target: string; label: string; weight: number }>;
}
export async function getKnowledgeGraph() {
return parseResponse<KnowledgeGraph>(await apiFetch(`${baseUrl()}/graph`));
}
+16
View File
@@ -12,7 +12,9 @@ import {
getKnowledgeNote,
ingestThread,
ingestThreadsBatch,
listDuplicates,
listKnowledgeNotes,
mergeKnowledgeNotes,
searchKnowledge,
updateKnowledgeNote,
} from "./api";
@@ -85,5 +87,19 @@ export function useKnowledgeActions() {
onSuccess: invalidate,
}),
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
merge: useMutation({
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
mergeKnowledgeNotes(primaryId, duplicateIds),
onSuccess: invalidate,
}),
};
}
export function useKnowledgeDuplicates(enabled: boolean) {
return useQuery({
queryKey: [...ROOT_KEY, "duplicates"],
queryFn: listDuplicates,
enabled,
refetchOnWindowFocus: false,
});
}
@@ -1,5 +1,6 @@
import {
BookOpen,
Copy,
ExternalLink,
Loader2,
Network,
@@ -37,6 +38,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import {
useKnowledgeActions,
useKnowledgeDuplicates,
useKnowledgeNote,
useKnowledgeNotes,
} from "@/core/knowledge/hooks";
@@ -270,6 +272,83 @@ function BatchImportDialog({
);
}
function DedupDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { data: groups = [], isLoading } = useKnowledgeDuplicates(open);
const { merge } = useKnowledgeActions();
const handleMerge = async (primaryId: string, duplicateIds: string[]) => {
try {
await merge.mutateAsync({ primaryId, duplicateIds });
toast.success("已合并重复知识");
} catch (error) {
toast.error(error instanceof Error ? error.message : "合并失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>查重与合并</DialogTitle>
<DialogDescription>
基于标题/摘要相似度与内容哈希检测重复知识;合并会把重复项归档并把来源并入主条目。
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto">
{isLoading ? (
<div className="text-muted-foreground p-4 text-sm">检测中...</div>
) : groups.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
未发现明显重复的知识。
</div>
) : (
<ul className="flex flex-col gap-3">
{groups.map((g) => (
<li key={g.primary.id} className="rounded-md border p-3">
<div className="mb-2 text-sm font-medium">
保留:{g.primary.title || g.primary.id}
</div>
<ul className="mb-2 flex flex-col gap-1">
{g.duplicates.map((d) => (
<li key={d.id} className="text-muted-foreground text-xs">
合并:{d.title || d.id}(相似度 {Math.round(d.score * 100)}%)
</li>
))}
</ul>
<Button
size="sm"
variant="outline"
onClick={() =>
void handleMerge(
g.primary.id,
g.duplicates.map((d) => d.id),
)
}
disabled={merge.isPending}
>
合并到主条目
</Button>
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NoteDetail({ noteId }: { noteId: string }) {
const navigate = useNavigate();
const { data: note, isLoading } = useKnowledgeNote(noteId);
@@ -479,6 +558,7 @@ export default function KnowledgeBasePage() {
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const params = useMemo(
() => ({
@@ -535,6 +615,9 @@ export default function KnowledgeBasePage() {
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
<Copy className="size-4" /> 查重
</Button>
<Button
variant="outline"
size="sm"
@@ -592,6 +675,7 @@ export default function KnowledgeBasePage() {
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
</div>
);
}