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>
);
}
@@ -143,6 +143,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.ai_writing_sessions import (
make_ai_writing_session_store,
)
from deerflow.persistence.ai_writing_sessions import (
register_default_store as _register_ai_writing_default_store,
)
@@ -163,6 +165,21 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.knowledge_service = make_knowledge_service(sf, config.knowledge)
# Background auto-sedimentation queue (phase 3) — drains finished
# conversations off the chat path. Worker only does work when
# ``knowledge.auto_ingest_enabled`` is set; the queue is always wired so
# the middleware can enqueue without a hard dependency.
from deerflow.knowledge.auto_ingest import AutoIngestQueue, set_auto_ingest_queue
if app.state.knowledge_service is not None:
_auto_ingest_queue = AutoIngestQueue(
lambda: getattr(app.state, "knowledge_service", None),
min_chars=config.knowledge.auto_ingest_min_chars,
)
_auto_ingest_queue.start()
set_auto_ingest_queue(_auto_ingest_queue)
app.state.knowledge_auto_ingest_queue = _auto_ingest_queue
from deerflow.persistence.user_prompts import make_user_prompt_store
app.state.user_prompt_store = make_user_prompt_store(sf)
@@ -185,6 +202,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
)
from deerflow.persistence.roundtable_jobs import (
register_default_store as _register_roundtable_job_default_store,
)
@@ -242,6 +261,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.notifications import set_notification_store
set_notification_store(None)
queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
if queue is not None:
from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
await queue.stop()
set_auto_ingest_queue(None)
await close_engine()
@@ -304,6 +304,30 @@ async def search(body: SearchRequest, request: Request) -> dict[str, Any]:
return {"items": items}
class MergeRequest(BaseModel):
primary_id: str
duplicate_ids: list[str] = Field(default_factory=list)
@router.get("/duplicates")
@require_auth
async def list_duplicates(request: Request) -> dict[str, Any]:
"""Return suggested duplicate groups (phase 3, heuristic)."""
service = get_knowledge_service(request)
return {"groups": await service.find_duplicates()}
@router.post("/merge")
@require_auth
async def merge_notes(body: MergeRequest, request: Request) -> dict[str, Any]:
"""Merge duplicate notes into a primary note (sources moved, dups archived)."""
service = get_knowledge_service(request)
note = await service.merge_notes(body.primary_id, body.duplicate_ids, updated_by=get_effective_user_id())
if note is None:
raise HTTPException(status_code=404, detail="Primary note not found")
return {"note": note}
@router.post("/reindex")
@require_auth
async def reindex_embeddings(request: Request) -> dict[str, Any]:
@@ -320,6 +344,14 @@ async def list_note_versions(note_id: str, request: Request) -> dict[str, Any]:
return {"items": await service.list_versions(note_id)}
@router.get("/graph")
@require_auth
async def get_graph(request: Request) -> dict[str, Any]:
"""Return product knowledge-graph data (notes + entities + relations)."""
service = get_knowledge_service(request)
return await service.get_graph()
@router.post("/export/graph")
@require_auth
async def export_graph(request: Request) -> dict[str, Any]:
@@ -363,6 +363,9 @@ knowledge:
max_source_items: 20
rag_enabled: false
rag_limit: 5
# 阶段三:写入前脱敏(token/密钥/手机号/身份证/邮箱);重复检测相似度阈值。
sensitive_redaction_enabled: true
dedup_similarity_threshold: 0.82
export:
enabled: true
graph_html_enabled: true
@@ -21,6 +21,7 @@ from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import HumanMessage
from langgraph.runtime import Runtime
logger = logging.getLogger(__name__)
@@ -132,3 +133,58 @@ class KnowledgeRagMiddleware(AgentMiddleware[AgentState]):
if msg is not None:
request = request.override(messages=[*request.messages, msg])
return handler(request)
# ---- after_agent: enqueue background auto-sedimentation -------------
def _resolve_thread_id(self, runtime: Runtime | None) -> str | None:
if runtime is not None and getattr(runtime, "context", None):
tid = runtime.context.get("thread_id")
if tid:
return str(tid)
try:
from langgraph.config import get_config
return get_config().get("configurable", {}).get("thread_id")
except Exception:
return None
@override
def after_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
"""When auto-ingest is on, hand the finished turn to the background queue."""
try:
from deerflow.config import get_app_config
knowledge_cfg = getattr(get_app_config(), "knowledge", None)
if knowledge_cfg is None or not knowledge_cfg.enabled or not knowledge_cfg.auto_ingest_enabled:
return None
from deerflow.knowledge.auto_ingest import AutoIngestItem, get_auto_ingest_queue
queue = get_auto_ingest_queue()
if queue is None:
return None
messages = state.get("messages") if isinstance(state, dict) else getattr(state, "messages", None)
if not messages:
return None
from deerflow.runtime.references import build_reference_batches
from deerflow.runtime.serialization import serialize_lc_object
from deerflow.runtime.user_context import get_effective_user_id
serialized = [serialize_lc_object(m) for m in messages]
thread_id = self._resolve_thread_id(runtime)
if not thread_id:
return None
title = state.get("title") if isinstance(state, dict) else getattr(state, "title", None)
queue.enqueue(
AutoIngestItem(
thread_id=thread_id,
user_id=get_effective_user_id(),
messages=serialized,
thread_title=title if isinstance(title, str) else None,
reference_batches=build_reference_batches(serialized),
)
)
except Exception:
logger.exception("KnowledgeRagMiddleware.after_agent enqueue failed (non-fatal)")
return None
@@ -64,6 +64,9 @@ class KnowledgeConfig(BaseModel):
max_source_items: int = Field(default=20, description="Maximum source items recorded per note.")
rag_enabled: bool = Field(default=False, description="Enable RAG injection before agent runs (phase 2).")
rag_limit: int = Field(default=5, description="Maximum knowledge snippets injected per query.")
sensitive_redaction_enabled: bool = Field(default=True, description="Redact secrets/PII (tokens, keys, phone, id-card, email) before persisting notes (phase 3).")
dedup_similarity_threshold: float = Field(default=0.82, description="Title/summary similarity above which two notes are flagged as duplicates (phase 3).")
entity_extraction_enabled: bool = Field(default=True, description="Extract entities/relations during LLM distillation and feed them into the graph (phase 4).")
export: KnowledgeExportConfig = Field(default_factory=KnowledgeExportConfig, description="Graph export configuration.")
query: KnowledgeQueryConfig = Field(default_factory=KnowledgeQueryConfig, description="Layered query configuration.")
embedding: KnowledgeEmbeddingConfig = Field(default_factory=KnowledgeEmbeddingConfig, description="Embedding configuration.")
@@ -0,0 +1,125 @@
"""Background auto-sediment queue (phase 3).
A single asyncio worker drains a bounded queue of finished conversations and
sediments the worthwhile ones into the knowledge base — entirely off the chat
request path, so it never adds latency to a reply. Enqueue happens from
``KnowledgeRagMiddleware.after_agent`` when ``knowledge.auto_ingest_enabled``.
Value judgment is intentionally cheap (min assistant chars + dedup via
``skip_existing``); the LLM still distills the kept conversations via the normal
``capture_thread`` path.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
_QUEUE: AutoIngestQueue | None = None
@dataclass
class _QueueUser:
"""Minimal CurrentUser stand-in (only ``.id`` is needed for context)."""
id: str
@dataclass
class AutoIngestItem:
thread_id: str
user_id: str
messages: list[dict[str, Any]]
thread_title: str | None = None
reference_batches: list[dict[str, Any]] = field(default_factory=list)
class AutoIngestQueue:
"""Bounded background queue + single worker for auto-sedimentation."""
def __init__(self, service_provider: Callable[[], Any], *, min_chars: int = 300, maxsize: int = 256) -> None:
self._provider = service_provider
self._min_chars = min_chars
self._queue: asyncio.Queue[AutoIngestItem] = asyncio.Queue(maxsize=maxsize)
self._task: asyncio.Task | None = None
def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._worker(), name="knowledge-auto-ingest")
logger.info("Knowledge auto-ingest worker started")
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
def enqueue(self, item: AutoIngestItem) -> None:
try:
self._queue.put_nowait(item)
except asyncio.QueueFull:
logger.warning("Knowledge auto-ingest queue full; dropping thread %s", item.thread_id)
def _worth_ingesting(self, item: AutoIngestItem) -> bool:
chars = 0
for m in item.messages:
if str(m.get("type") or m.get("role") or "").lower() == "ai":
content = m.get("content")
chars += len(content) if isinstance(content, str) else len(str(content or ""))
return chars >= self._min_chars
async def _process(self, item: AutoIngestItem) -> None:
service = self._provider()
if service is None:
return
if not self._worth_ingesting(item):
logger.debug("Auto-ingest skipped (below min chars) thread=%s", item.thread_id)
return
from deerflow.runtime.user_context import reset_current_user, set_current_user
token = set_current_user(_QueueUser(id=item.user_id))
try:
result = await service.capture_thread(
thread_id=item.thread_id,
messages=item.messages,
thread_title=item.thread_title,
status="draft", # auto-sedimented notes land as drafts for review
include_sources=True,
reference_batches=item.reference_batches,
created_by=item.user_id,
skip_existing=True,
)
if result.get("created"):
logger.info("Auto-ingested thread %s -> note %s", item.thread_id, result["note"]["id"])
except Exception:
logger.exception("Auto-ingest failed for thread %s", item.thread_id)
finally:
reset_current_user(token)
async def _worker(self) -> None:
while True:
item = await self._queue.get()
try:
await self._process(item)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Auto-ingest worker error")
finally:
self._queue.task_done()
def set_auto_ingest_queue(queue: AutoIngestQueue | None) -> None:
global _QUEUE
_QUEUE = queue
def get_auto_ingest_queue() -> AutoIngestQueue | None:
return _QUEUE
@@ -0,0 +1,75 @@
"""Duplicate detection for knowledge notes (phase 3).
First-release heuristic: token Jaccard over ``title + summary + tags`` plus an
exact ``content_md`` hash match. Cheap, deterministic and embedding-free so it
works on offline deployments. Returns *suggestions*; merging is an explicit,
user-driven action.
"""
from __future__ import annotations
import re
from typing import Any
from deerflow.knowledge.manifest import content_hash
_TOKEN_RE = re.compile(r"[\w一-鿿]+", re.UNICODE)
def _tokens(*texts: str) -> set[str]:
out: set[str] = set()
for t in texts:
out.update(m.lower() for m in _TOKEN_RE.findall(t or ""))
return out
def _jaccard(a: set[str], b: set[str]) -> float:
if not a or not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
def find_duplicate_groups(notes: list[dict[str, Any]], *, threshold: float = 0.82, max_notes: int = 800) -> list[dict[str, Any]]:
"""Return groups ``{primary, duplicates:[{id,title,score,reason}]}``.
``primary`` is the most recently updated note in each cluster.
"""
notes = notes[:max_notes]
feats: list[tuple[dict[str, Any], set[str], str]] = []
for n in notes:
toks = _tokens(n.get("title") or "", n.get("summary") or "", " ".join(n.get("tags") or []))
feats.append((n, toks, content_hash(n.get("content_md") or "")))
used: set[str] = set()
groups: list[dict[str, Any]] = []
for i in range(len(feats)):
ni, ti, hi = feats[i]
if ni["id"] in used:
continue
dups: list[dict[str, Any]] = []
for j in range(i + 1, len(feats)):
nj, tj, hj = feats[j]
if nj["id"] in used:
continue
if hi and hi == hj:
score, reason = 1.0, "identical-content"
else:
score = _jaccard(ti, tj)
reason = "similar-title-summary"
if score >= threshold:
dups.append({"id": nj["id"], "title": nj.get("title"), "score": round(score, 4), "reason": reason})
used.add(nj["id"])
if dups:
used.add(ni["id"])
# Primary = newest by updated_at.
cluster = [ni, *[next(f[0] for f in feats if f[0]["id"] == d["id"]) for d in dups]]
primary = max(cluster, key=lambda x: x.get("updated_at") or "")
others = [
{"id": c["id"], "title": c.get("title"), "score": next((d["score"] for d in dups if d["id"] == c["id"]), 1.0)}
for c in cluster
if c["id"] != primary["id"]
]
groups.append({"primary": {"id": primary["id"], "title": primary.get("title")}, "duplicates": others})
return groups
@@ -25,16 +25,33 @@ def _vault_key(vault_path: str | None) -> str | None:
return key
def build_graph(notes: list[dict[str, Any]]) -> dict[str, Any]:
"""Return ``{nodes, edges}`` from note dicts (id, title, tags, vault_path, content_md)."""
def build_graph(
notes: list[dict[str, Any]],
entities: list[dict[str, Any]] | None = None,
relations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Return ``{nodes, edges}`` from notes (+ optional entities/relations).
Note nodes come from ``notes`` (id, title, tags, vault_path, content_md);
when ``entities``/``relations`` are supplied (phase 4), entity nodes and
note→entity / entity→entity edges are added on top of the wikilink and
shared-tag edges.
"""
nodes = []
by_key: dict[str, str] = {}
note_ids: set[str] = set()
for n in notes:
nodes.append({"id": n["id"], "type": "note", "label": n.get("title") or n["id"], "category": n.get("source_type")})
note_ids.add(n["id"])
key = _vault_key(n.get("vault_path"))
if key:
by_key[key] = n["id"]
entity_ids: set[str] = set()
for ent in entities or []:
nodes.append({"id": ent["id"], "type": "entity", "label": ent.get("name") or ent["id"], "category": ent.get("entity_type")})
entity_ids.add(ent["id"])
edges: list[dict[str, Any]] = []
edge_seen: set[tuple[str, str, str]] = set()
@@ -66,6 +83,14 @@ def build_graph(notes: list[dict[str, Any]]) -> dict[str, Any]:
for j in range(i + 1, len(ids)):
add_edge(ids[i], ids[j], "related", 1)
# Entity / relation edges (phase 4) — only when both endpoints exist as nodes.
valid = note_ids | entity_ids
for rel in relations or []:
src = rel.get("from_note_id") or rel.get("from_entity_id")
tgt = rel.get("to_note_id") or rel.get("to_entity_id")
if src in valid and tgt in valid:
add_edge(src, tgt, rel.get("relation_type") or "related", float(rel.get("weight") or 1))
return {"nodes": nodes, "edges": edges}
@@ -109,13 +134,15 @@ def export_all(
vault_root: Path | str,
notes: list[dict[str, Any]],
*,
entities: list[dict[str, Any]] | None = None,
relations: list[dict[str, Any]] | None = None,
write_html: bool = True,
write_json: bool = True,
) -> dict[str, Any]:
"""Generate graph artifacts under ``<vault>/wiki-export/`` and return stats."""
out_dir = Path(vault_root).resolve() / "wiki-export"
out_dir.mkdir(parents=True, exist_ok=True)
graph = build_graph(notes)
graph = build_graph(notes, entities, relations)
written: list[str] = []
if write_json:
(out_dir / "graph.json").write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -52,6 +52,8 @@ references(外部来源汇总)、decision(架构/设计决策)、journal
"reasoning": "为什么成立、取舍、不确定性",
"implications": ["后续建议/风险/下一步", "..."],
"tags": ["标签1", "标签2"],
"entities": [{"name": "实体名", "type": "technology|concept|person|org|product|other"}],
"relations": [{"from": "实体A", "to": "实体B", "type": "uses|relates_to|part_of|depends_on"}],
"confidence": 0.0
}"""
@@ -192,6 +194,9 @@ async def extract_thread_llm(
except (TypeError, ValueError):
confidence = 0.7
entities = data.get("entities") if isinstance(data.get("entities"), list) else []
relations = data.get("relations") if isinstance(data.get("relations"), list) else []
return NoteDraft(
title=final_title,
summary=summary or final_title,
@@ -203,5 +208,7 @@ async def extract_thread_llm(
confidence=max(0.0, min(confidence, 1.0)),
related=related,
sources=sources,
entities=[e for e in entities if isinstance(e, dict) and e.get("name")],
relations=[r for r in relations if isinstance(r, dict) and r.get("from") and r.get("to")],
source_key=f"thread:{thread_id}",
)
@@ -143,9 +143,23 @@ class ObsidianWikiAdapter:
self._remove_from_index(note_id)
self._append_log("ARCHIVE", note_id, "", updated)
def export_graph(self, notes: list[dict[str, Any]], *, updated: str) -> dict[str, Any]:
def export_graph(
self,
notes: list[dict[str, Any]],
*,
entities: list[dict[str, Any]] | None = None,
relations: list[dict[str, Any]] | None = None,
updated: str,
) -> dict[str, Any]:
self.ensure_ready()
result = exporter.export_all(self.root, notes, write_html=self.export_html, write_json=self.export_json)
result = exporter.export_all(
self.root,
notes,
entities=entities,
relations=relations,
write_html=self.export_html,
write_json=self.export_json,
)
self._append_log("EXPORT", "graph", f"nodes={result['stats']['nodes']} edges={result['stats']['edges']}", updated)
return result
@@ -53,6 +53,10 @@ class NoteDraft:
actions: list[str] = field(default_factory=list)
related: list[str] = field(default_factory=list)
sources: list[SourceDraft] = field(default_factory=list)
# Phase 4: extracted graph signal. entities=[{name,type}],
# relations=[{from,to,type}] (names refer to entities or this note's title).
entities: list[dict] = field(default_factory=list)
relations: list[dict] = field(default_factory=list)
# Stable identity hint used for manifest dedup (e.g. ``thread:thread_id``).
source_key: str | None = None
@@ -0,0 +1,54 @@
"""Sensitive-information redaction for knowledge content (phase 3).
Best-effort masking of secrets / PII before a note is persisted: API keys,
bearer tokens, generic ``key=secret`` assignments, Chinese mobile numbers,
ID-card numbers and email addresses. Conservative by design — it favours not
mangling legitimate prose over catching every possible secret.
"""
from __future__ import annotations
import re
_MASK = "[REDACTED]"
# Order matters: more specific patterns first.
_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("openai_key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b")),
("aws_key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
("bearer", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{16,}")),
# key/secret/password/token assignment: keep the field name, mask the value.
("assignment", re.compile(r"(?i)\b(api[_-]?key|secret|password|passwd|token|access[_-]?token)\b\s*[:=]\s*[\"']?[^\s\"']{6,}[\"']?")),
("cn_id_card", re.compile(r"\b\d{17}[\dXx]\b")),
("cn_mobile", re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")),
("email", re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")),
]
def redact(text: str | None) -> tuple[str, list[str]]:
"""Return ``(redacted_text, hit_kinds)``.
``hit_kinds`` lists which detectors fired (for logging/audit). When ``text``
is falsy it is returned unchanged with no hits.
"""
if not text:
return text or "", []
hits: list[str] = []
out = text
for kind, pattern in _PATTERNS:
def _sub(m: re.Match[str], _kind: str = kind) -> str:
# For assignments keep the field name + delimiter, mask only the value.
if _kind == "assignment":
head = re.split(r"[:=]", m.group(0), maxsplit=1)[0]
sep = ":" if ":" in m.group(0) else "="
return f"{head}{sep} {_MASK}"
if _kind == "bearer":
return f"Bearer {_MASK}"
return _MASK
new_out, n = pattern.subn(_sub, out)
if n:
hits.append(kind)
out = new_out
return out, hits
@@ -21,7 +21,7 @@ from deerflow.knowledge.embeddings import EmbeddingClient
from deerflow.knowledge.markdown_vault import MarkdownVault
from deerflow.knowledge.obsidian_adapter import ObsidianWikiAdapter
from deerflow.knowledge.repository import KnowledgeRepository
from deerflow.knowledge.schemas import NoteDraft, normalize_source_type, normalize_status
from deerflow.knowledge.schemas import NoteDraft, SourceDraft, normalize_source_type, normalize_status
from deerflow.knowledge.search import search_notes
from deerflow.knowledge.sources import from_reference_batches, from_search_results
@@ -60,8 +60,24 @@ class KnowledgeService:
logger.exception("Failed to initialize knowledge vault at %s", vault_root)
# -- internal helpers -------------------------------------------------
def _redact_draft(self, draft: NoteDraft) -> NoteDraft:
"""Mask secrets/PII in a draft before persistence (phase 3)."""
if not self.config.sensitive_redaction_enabled:
return draft
from deerflow.knowledge.sensitive import redact
draft.content_md, body_hits = redact(draft.content_md)
draft.summary, _ = redact(draft.summary)
for src in draft.sources:
if src.snippet:
src.snippet, _ = redact(src.snippet)
if body_hits:
logger.info("Knowledge redaction applied to note '%s': %s", draft.title, ",".join(sorted(set(body_hits))))
return draft
async def _persist_draft(self, draft: NoteDraft, *, created_by: str | None) -> dict[str, Any]:
"""Create a DB row, write the vault file, attach sources, return note."""
draft = self._redact_draft(draft)
note = await self.repo.create_note(
title=draft.title,
summary=draft.summary,
@@ -83,8 +99,32 @@ class KnowledgeService:
if draft.sources:
await self.repo.add_sources(note_id, draft.sources, created_by=created_by)
await self._reindex_embeddings(note_id, draft.content_md)
await self._store_entities(note_id, draft)
return await self.repo.get_note(note_id) or note
async def _store_entities(self, note_id: str, draft: NoteDraft) -> None:
"""Persist extracted entities/relations and link them to the note (phase 4)."""
if not self.config.entity_extraction_enabled or not draft.entities:
return
try:
await self.repo.clear_note_relations(note_id)
name_to_id: dict[str, str] = {}
for ent in draft.entities:
name = str(ent.get("name") or "").strip()
if not name:
continue
row = await self.repo.get_or_create_entity(name, ent.get("type"))
name_to_id[name.lower()] = row["id"]
# note -> entity "mentions"
await self.repo.add_relation(from_note_id=note_id, to_entity_id=row["id"], relation_type="mentions")
for rel in draft.relations:
a = name_to_id.get(str(rel.get("from") or "").strip().lower())
b = name_to_id.get(str(rel.get("to") or "").strip().lower())
if a and b and a != b:
await self.repo.add_relation(from_entity_id=a, to_entity_id=b, relation_type=str(rel.get("type") or "relates_to"))
except Exception:
logger.exception("Failed to store entities/relations for note %s", note_id)
async def _reindex_embeddings(self, note_id: str, content_md: str) -> None:
"""Best-effort: (re)build the per-chunk embedding index for a note."""
if not self.embedding_client.is_enabled():
@@ -343,6 +383,56 @@ class KnowledgeService:
lines.append("")
return {"text": "\n".join(lines).strip(), "items": items}
# -- dedup & merge (phase 3) -----------------------------------------
async def find_duplicates(self) -> list[dict[str, Any]]:
from deerflow.knowledge.dedup import find_duplicate_groups
notes = await self.repo.list_all()
return find_duplicate_groups(notes, threshold=self.config.dedup_similarity_threshold)
async def merge_notes(self, primary_id: str, duplicate_ids: list[str], *, updated_by: str | None = None) -> dict[str, Any] | None:
"""Merge ``duplicate_ids`` into ``primary_id``.
Sources from each duplicate are re-attached to the primary, a wikilink
+ "Merged from" note is appended to the primary body, the duplicates are
archived (soft delete), and a ``merged_into`` relation is recorded.
"""
primary = await self.repo.get_note(primary_id)
if primary is None:
return None
merged_titles: list[str] = []
for dup_id in duplicate_ids:
if dup_id == primary_id:
continue
dup = await self.repo.get_note(dup_id)
if dup is None:
continue
# Move sources.
for src in dup.get("sources") or []:
await self.repo.add_sources(
primary_id,
[
SourceDraft(
source_type=src.get("source_type") or "tool",
thread_id=src.get("thread_id"),
message_id=src.get("message_id"),
tool_name=src.get("tool_name"),
title=src.get("title"),
url=src.get("url"),
snippet=src.get("snippet"),
)
],
created_by=updated_by,
)
await self.repo.add_relation(from_note_id=dup_id, to_note_id=primary_id, relation_type="merged_into")
await self.delete_note(dup_id, updated_by=updated_by)
merged_titles.append(dup.get("title") or dup_id)
if merged_titles:
appended = primary["content_md"].rstrip() + "\n\n## Merged\n\n" + "\n".join(f"- {t}" for t in merged_titles) + "\n"
await self.update_note(primary_id, content_md=appended, updated_by=updated_by)
return await self.repo.get_note(primary_id)
async def reindex_all(self) -> dict[str, int]:
"""Rebuild embeddings for every non-archived note (admin/maintenance)."""
notes = await self.repo.list_all()
@@ -356,8 +446,19 @@ class KnowledgeService:
from deerflow.utils.time import now_iso
notes = await self.repo.list_all()
result = self.adapter.export_graph(notes, updated=now_iso())
entities = await self.repo.list_entities() if self.config.entity_extraction_enabled else []
relations = await self.repo.list_relations() if self.config.entity_extraction_enabled else []
result = self.adapter.export_graph(notes, entities=entities, relations=relations, updated=now_iso())
return {"files": result["files"], "stats": result["stats"]}
async def get_graph(self) -> dict[str, Any]:
"""Return product graph data ``{nodes, edges}`` (notes + entities)."""
from deerflow.knowledge import exporter
notes = await self.repo.list_all()
entities = await self.repo.list_entities() if self.config.entity_extraction_enabled else []
relations = await self.repo.list_relations() if self.config.entity_extraction_enabled else []
return exporter.build_graph(notes, entities, relations)
def export_file_path(self, filename: str) -> Path | None:
return self.adapter.export_file_path(filename)