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:
1 parent
683cc6d76e
commit
81e4fd92ce
16 files changed
+691
-7
No files matched your search
@@ -131,3 +131,61 @@ export async function exportKnowledgeGraph() {
|
|||||||
export function knowledgeExportFileUrl(filename: string) {
|
export function knowledgeExportFileUrl(filename: string) {
|
||||||
return `${baseUrl()}/export/files/${encodeURIComponent(filename)}`;
|
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`));
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
getKnowledgeNote,
|
getKnowledgeNote,
|
||||||
ingestThread,
|
ingestThread,
|
||||||
ingestThreadsBatch,
|
ingestThreadsBatch,
|
||||||
|
listDuplicates,
|
||||||
listKnowledgeNotes,
|
listKnowledgeNotes,
|
||||||
|
mergeKnowledgeNotes,
|
||||||
searchKnowledge,
|
searchKnowledge,
|
||||||
updateKnowledgeNote,
|
updateKnowledgeNote,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@@ -85,5 +87,19 @@ export function useKnowledgeActions() {
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
}),
|
}),
|
||||||
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
|
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 {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
Network,
|
Network,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
useKnowledgeActions,
|
useKnowledgeActions,
|
||||||
|
useKnowledgeDuplicates,
|
||||||
useKnowledgeNote,
|
useKnowledgeNote,
|
||||||
useKnowledgeNotes,
|
useKnowledgeNotes,
|
||||||
} from "@/core/knowledge/hooks";
|
} 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 }) {
|
function NoteDetail({ noteId }: { noteId: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
||||||
@@ -479,6 +558,7 @@ export default function KnowledgeBasePage() {
|
|||||||
const [status, setStatus] = useState("all");
|
const [status, setStatus] = useState("all");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [batchOpen, setBatchOpen] = useState(false);
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
|
const [dedupOpen, setDedupOpen] = useState(false);
|
||||||
|
|
||||||
const params = useMemo(
|
const params = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -535,6 +615,9 @@ export default function KnowledgeBasePage() {
|
|||||||
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
|
||||||
<RefreshCw className="size-4" /> 批量导入
|
<RefreshCw className="size-4" /> 批量导入
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
|
||||||
|
<Copy className="size-4" /> 查重
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -592,6 +675,7 @@ export default function KnowledgeBasePage() {
|
|||||||
|
|
||||||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||||
|
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,6 +143,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
from deerflow.persistence.ai_writing_sessions import (
|
from deerflow.persistence.ai_writing_sessions import (
|
||||||
make_ai_writing_session_store,
|
make_ai_writing_session_store,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.ai_writing_sessions import (
|
||||||
register_default_store as _register_ai_writing_default_store,
|
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)
|
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
|
from deerflow.persistence.user_prompts import make_user_prompt_store
|
||||||
|
|
||||||
app.state.user_prompt_store = make_user_prompt_store(sf)
|
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 (
|
from deerflow.persistence.roundtable_jobs import (
|
||||||
make_roundtable_job_store,
|
make_roundtable_job_store,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.roundtable_jobs import (
|
||||||
register_default_store as _register_roundtable_job_default_store,
|
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
|
from deerflow.persistence.notifications import set_notification_store
|
||||||
|
|
||||||
set_notification_store(None)
|
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()
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,30 @@ async def search(body: SearchRequest, request: Request) -> dict[str, Any]:
|
|||||||
return {"items": items}
|
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")
|
@router.post("/reindex")
|
||||||
@require_auth
|
@require_auth
|
||||||
async def reindex_embeddings(request: Request) -> dict[str, Any]:
|
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)}
|
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")
|
@router.post("/export/graph")
|
||||||
@require_auth
|
@require_auth
|
||||||
async def export_graph(request: Request) -> dict[str, Any]:
|
async def export_graph(request: Request) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ knowledge:
|
|||||||
max_source_items: 20
|
max_source_items: 20
|
||||||
rag_enabled: false
|
rag_enabled: false
|
||||||
rag_limit: 5
|
rag_limit: 5
|
||||||
|
# 阶段三:写入前脱敏(token/密钥/手机号/身份证/邮箱);重复检测相似度阈值。
|
||||||
|
sensitive_redaction_enabled: true
|
||||||
|
dedup_similarity_threshold: 0.82
|
||||||
export:
|
export:
|
||||||
enabled: true
|
enabled: true
|
||||||
graph_html_enabled: true
|
graph_html_enabled: true
|
||||||
|
|||||||
+56
@@ -21,6 +21,7 @@ from typing import Any, override
|
|||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -132,3 +133,58 @@ class KnowledgeRagMiddleware(AgentMiddleware[AgentState]):
|
|||||||
if msg is not None:
|
if msg is not None:
|
||||||
request = request.override(messages=[*request.messages, msg])
|
request = request.override(messages=[*request.messages, msg])
|
||||||
return handler(request)
|
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.")
|
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_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.")
|
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.")
|
export: KnowledgeExportConfig = Field(default_factory=KnowledgeExportConfig, description="Graph export configuration.")
|
||||||
query: KnowledgeQueryConfig = Field(default_factory=KnowledgeQueryConfig, description="Layered query configuration.")
|
query: KnowledgeQueryConfig = Field(default_factory=KnowledgeQueryConfig, description="Layered query configuration.")
|
||||||
embedding: KnowledgeEmbeddingConfig = Field(default_factory=KnowledgeEmbeddingConfig, description="Embedding 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
|
return key
|
||||||
|
|
||||||
|
|
||||||
def build_graph(notes: list[dict[str, Any]]) -> dict[str, Any]:
|
def build_graph(
|
||||||
"""Return ``{nodes, edges}`` from note dicts (id, title, tags, vault_path, content_md)."""
|
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 = []
|
nodes = []
|
||||||
by_key: dict[str, str] = {}
|
by_key: dict[str, str] = {}
|
||||||
|
note_ids: set[str] = set()
|
||||||
for n in notes:
|
for n in notes:
|
||||||
nodes.append({"id": n["id"], "type": "note", "label": n.get("title") or n["id"], "category": n.get("source_type")})
|
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"))
|
key = _vault_key(n.get("vault_path"))
|
||||||
if key:
|
if key:
|
||||||
by_key[key] = n["id"]
|
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]] = []
|
edges: list[dict[str, Any]] = []
|
||||||
edge_seen: set[tuple[str, str, str]] = set()
|
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)):
|
for j in range(i + 1, len(ids)):
|
||||||
add_edge(ids[i], ids[j], "related", 1)
|
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}
|
return {"nodes": nodes, "edges": edges}
|
||||||
|
|
||||||
|
|
||||||
@@ -109,13 +134,15 @@ def export_all(
|
|||||||
vault_root: Path | str,
|
vault_root: Path | str,
|
||||||
notes: list[dict[str, Any]],
|
notes: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
entities: list[dict[str, Any]] | None = None,
|
||||||
|
relations: list[dict[str, Any]] | None = None,
|
||||||
write_html: bool = True,
|
write_html: bool = True,
|
||||||
write_json: bool = True,
|
write_json: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Generate graph artifacts under ``<vault>/wiki-export/`` and return stats."""
|
"""Generate graph artifacts under ``<vault>/wiki-export/`` and return stats."""
|
||||||
out_dir = Path(vault_root).resolve() / "wiki-export"
|
out_dir = Path(vault_root).resolve() / "wiki-export"
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
graph = build_graph(notes)
|
graph = build_graph(notes, entities, relations)
|
||||||
written: list[str] = []
|
written: list[str] = []
|
||||||
if write_json:
|
if write_json:
|
||||||
(out_dir / "graph.json").write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8")
|
(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": "为什么成立、取舍、不确定性",
|
"reasoning": "为什么成立、取舍、不确定性",
|
||||||
"implications": ["后续建议/风险/下一步", "..."],
|
"implications": ["后续建议/风险/下一步", "..."],
|
||||||
"tags": ["标签1", "标签2"],
|
"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
|
"confidence": 0.0
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
@@ -192,6 +194,9 @@ async def extract_thread_llm(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
confidence = 0.7
|
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(
|
return NoteDraft(
|
||||||
title=final_title,
|
title=final_title,
|
||||||
summary=summary or final_title,
|
summary=summary or final_title,
|
||||||
@@ -203,5 +208,7 @@ async def extract_thread_llm(
|
|||||||
confidence=max(0.0, min(confidence, 1.0)),
|
confidence=max(0.0, min(confidence, 1.0)),
|
||||||
related=related,
|
related=related,
|
||||||
sources=sources,
|
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}",
|
source_key=f"thread:{thread_id}",
|
||||||
)
|
)
|
||||||
+16
-2
@@ -143,9 +143,23 @@ class ObsidianWikiAdapter:
|
|||||||
self._remove_from_index(note_id)
|
self._remove_from_index(note_id)
|
||||||
self._append_log("ARCHIVE", note_id, "", updated)
|
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()
|
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)
|
self._append_log("EXPORT", "graph", f"nodes={result['stats']['nodes']} edges={result['stats']['edges']}", updated)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class NoteDraft:
|
|||||||
actions: list[str] = field(default_factory=list)
|
actions: list[str] = field(default_factory=list)
|
||||||
related: list[str] = field(default_factory=list)
|
related: list[str] = field(default_factory=list)
|
||||||
sources: list[SourceDraft] = 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``).
|
# Stable identity hint used for manifest dedup (e.g. ``thread:thread_id``).
|
||||||
source_key: str | None = None
|
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.markdown_vault import MarkdownVault
|
||||||
from deerflow.knowledge.obsidian_adapter import ObsidianWikiAdapter
|
from deerflow.knowledge.obsidian_adapter import ObsidianWikiAdapter
|
||||||
from deerflow.knowledge.repository import KnowledgeRepository
|
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.search import search_notes
|
||||||
from deerflow.knowledge.sources import from_reference_batches, from_search_results
|
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)
|
logger.exception("Failed to initialize knowledge vault at %s", vault_root)
|
||||||
|
|
||||||
# -- internal helpers -------------------------------------------------
|
# -- 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]:
|
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."""
|
"""Create a DB row, write the vault file, attach sources, return note."""
|
||||||
|
draft = self._redact_draft(draft)
|
||||||
note = await self.repo.create_note(
|
note = await self.repo.create_note(
|
||||||
title=draft.title,
|
title=draft.title,
|
||||||
summary=draft.summary,
|
summary=draft.summary,
|
||||||
@@ -83,8 +99,32 @@ class KnowledgeService:
|
|||||||
if draft.sources:
|
if draft.sources:
|
||||||
await self.repo.add_sources(note_id, draft.sources, created_by=created_by)
|
await self.repo.add_sources(note_id, draft.sources, created_by=created_by)
|
||||||
await self._reindex_embeddings(note_id, draft.content_md)
|
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
|
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:
|
async def _reindex_embeddings(self, note_id: str, content_md: str) -> None:
|
||||||
"""Best-effort: (re)build the per-chunk embedding index for a note."""
|
"""Best-effort: (re)build the per-chunk embedding index for a note."""
|
||||||
if not self.embedding_client.is_enabled():
|
if not self.embedding_client.is_enabled():
|
||||||
@@ -343,6 +383,56 @@ class KnowledgeService:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
return {"text": "\n".join(lines).strip(), "items": items}
|
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]:
|
async def reindex_all(self) -> dict[str, int]:
|
||||||
"""Rebuild embeddings for every non-archived note (admin/maintenance)."""
|
"""Rebuild embeddings for every non-archived note (admin/maintenance)."""
|
||||||
notes = await self.repo.list_all()
|
notes = await self.repo.list_all()
|
||||||
@@ -356,8 +446,19 @@ class KnowledgeService:
|
|||||||
from deerflow.utils.time import now_iso
|
from deerflow.utils.time import now_iso
|
||||||
|
|
||||||
notes = await self.repo.list_all()
|
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"]}
|
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:
|
def export_file_path(self, filename: str) -> Path | None:
|
||||||
return self.adapter.export_file_path(filename)
|
return self.adapter.export_file_path(filename)
|
||||||
Reference in new issue
Block a user