deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py
2026-09-07 18:24:55 +08:00

937 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""KnowledgeService — high-level orchestration for the knowledge base.
Ties together the repository (DB mirror), the :class:`ObsidianWikiAdapter`
(Markdown vault + index/log/hot/manifest + graph export) and the extractor
(thread/search → :class:`NoteDraft`). The Gateway router only talks to this
service; everything obsidian-wiki specific stays inside the ``knowledge``
module.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.runtime_paths import runtime_home
from deerflow.knowledge import extractor, llm_extractor
from deerflow.knowledge.chunking import chunk_markdown
from deerflow.knowledge.embeddings import EmbeddingClient
from deerflow.knowledge.markdown_vault import MarkdownVault, VaultPathError, extract_wikilinks, normalize_wikilink_target
from deerflow.knowledge.obsidian_adapter import ObsidianWikiAdapter
from deerflow.knowledge.repository import KnowledgeRepository
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
logger = logging.getLogger(__name__)
class EmptyThreadError(ValueError):
"""Raised when a thread has no sediment-able content."""
def _resolve_vault_dir(vault_dir: str) -> Path:
path = Path(vault_dir)
if path.is_absolute():
return path.resolve()
return (runtime_home() / vault_dir).resolve()
def _inject_related_notes(body: str, links: list[str]) -> str:
"""Insert real related-note ``[[wikilinks]]`` into the Related block.
Notes carry only content-specific related notes now (the structural meta-links
were dropped), so this is the sole writer of ``## Related`` links. When the
body has no ``## Related`` section yet, one is appended.
"""
if not links:
return body
bullets = "\n".join(f"- {link}" for link in links)
marker = "## Related\n"
idx = body.find(marker)
if idx == -1:
return body.rstrip() + "\n\n## Related\n\n" + bullets + "\n"
insert_at = idx + len(marker)
return body[:insert_at] + "\n" + bullets + "\n" + body[insert_at:]
class KnowledgeService:
"""Application-facing knowledge base API."""
def __init__(self, repo: KnowledgeRepository, config: KnowledgeConfig) -> None:
self.repo = repo
self.config = config
self.project_name = config.project_name
vault_root = _resolve_vault_dir(config.vault_dir)
self.adapter = ObsidianWikiAdapter(
vault_root,
project_name=config.project_name,
export_html=config.export.graph_html_enabled,
export_json=config.export.graph_json_enabled,
)
self.embedding_client = EmbeddingClient(config.embedding)
try:
self.adapter.ensure_ready()
except Exception: # pragma: no cover - vault init best-effort
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 _find_related_notes(self, draft: NoteDraft) -> list[dict[str, Any]]:
"""Find existing notes to cross-link under ## Related — by **shared tags**.
Matching is strictly tag-based: a candidate must share at least one of the
draft's *distinctive* tags (the per-note project / ``knowledge-base``
defaults are dropped, else everything matches everything). Free-text
title/summary matching was removed because CJK tokenization made generic
words (助手 / 知识图谱 / 页面) link unrelated notes. No distinctive tags →
no related notes (better empty than noisy). Best-effort.
"""
if not self.config.related_notes_enabled:
return []
default_tags = {t.lower() for t in (self.project_name or "", "knowledge-base", "zncm") if t}
distinctive = [t for t in (draft.tags or []) if t and t.lower() not in default_tags]
if not distinctive:
return []
own_title = (draft.title or "").strip().lower()
# Rank candidates by how many distinctive tags they share with the draft.
shared: dict[str, dict[str, Any]] = {}
for tag in distinctive:
try:
items, _total = await self.repo.list_notes(tag=tag, limit=50, offset=0)
except Exception:
logger.exception("Related-notes tag lookup failed for tag '%s'", tag)
continue
for it in items:
nid = it.get("id")
title = (it.get("title") or "").strip()
if not nid or not title or title.lower() == own_title:
continue
entry = shared.setdefault(nid, {"id": nid, "title": title, "count": 0})
entry["count"] += 1
ranked = sorted(shared.values(), key=lambda e: e["count"], reverse=True)
limit = max(1, self.config.related_notes_limit)
return [{"id": e["id"], "title": e["title"]} for e in ranked[:limit]]
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)
related_notes = await self._find_related_notes(draft)
if related_notes:
real_links = [f"[[{n['title']}]]" for n in related_notes]
draft.content_md = _inject_related_notes(draft.content_md, real_links)
draft.related = list(dict.fromkeys([*real_links, *draft.related]))
note = await self.repo.create_note(
title=draft.title,
summary=draft.summary,
content_md=draft.content_md,
source_type=draft.source_type,
source_id=draft.source_id,
status=draft.status,
confidence=draft.confidence,
folder=draft.folder,
tags=draft.tags,
created_by=created_by,
)
note_id = note["id"]
try:
rel = self.adapter.write_note(note_id, draft, created=note["created_at"], updated=note["updated_at"], status=draft.status)
await self.repo.set_vault_path(note_id, rel)
note["vault_path"] = rel
except Exception:
logger.exception("Failed to write knowledge note %s to vault", note_id)
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)
# Record note→note "related" edges (after _store_entities, which clears
# this note's relations) so the graph reflects the cross-links too.
for rel_note in related_notes:
try:
await self.repo.add_relation(from_note_id=note_id, to_note_id=rel_note["id"], relation_type="related")
except Exception:
logger.exception("Failed to link related note %s -> %s", note_id, rel_note["id"])
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():
return
try:
chunks = chunk_markdown(content_md)
if not chunks:
await self.repo.delete_embeddings(note_id)
return
vectors = await self.embedding_client.embed_texts(chunks)
if not vectors or len(vectors) != len(chunks):
return
await self.repo.replace_embeddings(note_id, [(i, c, v) for i, (c, v) in enumerate(zip(chunks, vectors, strict=False))])
except Exception:
logger.exception("Failed to (re)index embeddings for note %s", note_id)
# -- capture ----------------------------------------------------------
async def capture_thread(
self,
*,
thread_id: str,
messages: list[dict[str, Any]],
thread_title: str | None = None,
mode: str = "summary",
title: str | None = None,
status: str = "approved",
tags: list[str] | None = None,
include_sources: bool = True,
reference_batches: list[dict[str, Any]] | None = None,
created_by: str | None = None,
skip_existing: bool = False,
model_name: str | None = None,
template_id: str | None = None,
folder: str | None = None,
) -> dict[str, Any]:
"""Sediment a thread into a knowledge note.
``model_name`` optionally overrides the configured extraction model for
this one capture (falls back to ``config.extract_model_name``).
``template_id`` selects an operator-edited extraction prompt template
(falls back to the ``thread`` default, then the built-in prompt).
``folder`` files the resulting note(s) under a user directory.
Returns ``{"note": <note>, "created": bool}``. Raises
:class:`EmptyThreadError` when there is nothing worth keeping.
"""
extract_prompt = await self._resolve_extract_prompt(template_id, "thread")
# Always-on entry log: proves a capture was triggered for this thread,
# regardless of how it ends (success / skip / fallback). Lets you tell
# "capture never ran" apart from "logs not visible" via one grep.
logger.info(
"[knowledge] capturing thread %s (msgs=%d, llm_extract=%s, skip_existing=%s)",
thread_id,
len(messages or []),
self.config.llm_extract_enabled,
skip_existing,
)
if skip_existing:
existing = await self.repo.find_by_source("thread", thread_id)
if existing is None and self.adapter.manifest.has_source(f"thread:{thread_id}"):
existing = await self.repo.find_by_source("thread", thread_id)
if existing:
logger.info("[knowledge] thread %s already captured (note %s); skipping", thread_id, existing.get("id"))
return {"note": existing, "created": False}
sources = []
if include_sources and reference_batches:
sources = from_reference_batches(reference_batches, thread_id=thread_id, max_items=self.config.max_source_items)
drafts: list[NoteDraft] = []
# Prefer LLM distillation (wiki-capture style) so notes are declarative
# knowledge, not raw chat. A single conversation may split into several
# cross-linked notes. Falls back to the rule-based extractor (always one
# note) when the LLM is disabled/unavailable or returns nothing usable.
llm_attempted = self.config.llm_extract_enabled
llm_fallback = False
if llm_attempted:
try:
drafts = await llm_extractor.extract_thread_llm_multi(
messages,
thread_id=thread_id,
thread_title=thread_title,
mode=mode,
title=title,
tags=tags,
project_name=self.project_name,
sources=sources,
max_messages=self.config.max_thread_messages,
model_name=model_name or self.config.extract_model_name,
max_input_chars=self.config.extract_max_input_chars,
allow_multi=self.config.multi_note_enabled,
system_prompt=extract_prompt,
)
except llm_extractor.LLMRejectedIngest as exc:
# The model judged this conversation worthless (greeting / meta
# chatter). Skip ingestion entirely — do NOT dump the raw chat.
raise EmptyThreadError(f"Thread {thread_id} judged not worth ingesting: {exc}") from exc
if not drafts:
# The LLM path was supposed to run but produced nothing usable
# (API error / non-JSON). We will fall back to the rule-based
# extractor, which yields a low-quality verbatim dump with no
# entities/tags — flag it so it lands as a draft.
llm_fallback = True
if not drafts:
single = extractor.extract_thread(
messages,
thread_id=thread_id,
thread_title=thread_title,
mode=mode,
title=title,
tags=tags,
project_name=self.project_name,
sources=sources,
max_messages=self.config.max_thread_messages,
)
drafts = [single] if single else []
if not drafts:
logger.info("[knowledge] thread %s has no sediment-able content; skipping", thread_id)
raise EmptyThreadError(f"Thread {thread_id} has no sediment-able content")
# When the LLM was enabled but silently fell back to rule-based extraction,
# the note is a verbatim dump (no distillation / entities / tags). Land it
# as a *draft* for review instead of silently approving it, and log loudly
# so the extraction failure is diagnosable (see also the WARNING/EXCEPTION
# logged inside llm_extractor.extract_thread_llm_multi).
effective_status = status
if llm_fallback:
logger.warning(
"[knowledge] thread %s — capture fell back to rule-based extraction "
"(LLM distillation produced nothing usable); landing as DRAFT. "
"Check extraction model '%s' / earlier [knowledge] logs for the cause.",
thread_id,
self.config.extract_model_name or "models[0]",
)
if normalize_status(status) == "approved":
effective_status = "draft"
normalized = normalize_status(effective_status)
for draft in drafts:
draft.status = normalized
if folder:
draft.folder = folder
# Cross-link sibling notes from the same conversation by title, so the
# split pages reference each other (resolved once all are persisted).
if len(drafts) > 1:
for i, draft in enumerate(drafts):
siblings = [f"[[{o.title}]]" for j, o in enumerate(drafts) if j != i and o.title]
if siblings:
draft.content_md = _inject_related_notes(draft.content_md, siblings)
draft.related = list(dict.fromkeys([*siblings, *draft.related]))
notes = [await self._persist_draft(draft, created_by=created_by) for draft in drafts]
logger.info(
"[knowledge] thread %s captured → %d note(s) [%s], status=%s, llm_fallback=%s",
thread_id,
len(notes),
", ".join(n.get("title", "") for n in notes),
normalized,
llm_fallback,
)
return {"note": notes[0], "created": True, "notes": notes, "llm_fallback": llm_fallback}
async def capture_search(
self,
*,
query: str,
results: list[dict[str, Any]] | None,
thread_id: str | None = None,
message_id: str | None = None,
title: str | None = None,
tags: list[str] | None = None,
status: str = "approved",
created_by: str | None = None,
) -> dict[str, Any]:
"""Sediment search/tool results into a reference note."""
sources = from_search_results(query, results, thread_id=thread_id, message_id=message_id, max_items=self.config.max_source_items)
draft = extractor.extract_search(query, sources, thread_id=thread_id, project_name=self.project_name, title=title, tags=tags)
draft.status = normalize_status(status)
return await self._persist_draft(draft, created_by=created_by)
async def create_manual_note(
self,
*,
title: str,
content_md: str,
tags: list[str] | None = None,
status: str = "approved",
summary: str | None = None,
folder: str | None = None,
created_by: str | None = None,
) -> dict[str, Any]:
"""Create a note directly from product-edited Markdown."""
# Strip any frontmatter the user may have pasted; vault writer adds its own.
_, body = MarkdownVault.parse(content_md)
body = body or content_md
base_tags = list(tags or [])
for default in (self.project_name, "knowledge-base"):
if default and default not in base_tags:
base_tags.append(default)
draft = NoteDraft(
title=title.strip() or "知识笔记",
summary=(summary or body.strip().splitlines()[0] if body.strip() else "")[:280],
content_md=body,
category="synthesis",
source_type="manual",
source_id=None,
status=normalize_status(status),
folder=(folder or None),
tags=base_tags,
confidence=0.5,
related=extractor.related_links(self.project_name),
)
return await self._persist_draft(draft, created_by=created_by)
def _verbatim_file_draft(self, body: str, filename: str, *, title: str | None, tags: list[str] | None, doc_id: str) -> NoteDraft:
"""Build a verbatim fallback note from a file when LLM distillation fails.
Lands as a ``draft`` (like the thread fallback) since it is the raw
document, not distilled knowledge.
"""
base_tags = list(tags or [])
for default in (self.project_name, "knowledge-base"):
if default and default not in base_tags:
base_tags.append(default)
name = (title or Path(filename).stem or "文档知识").strip() or "文档知识"
summary = next((ln.strip() for ln in body.splitlines() if ln.strip()), name)[:280]
return NoteDraft(
title=name,
summary=summary,
content_md=body,
category="references",
source_type="file",
source_id=doc_id,
status="draft",
tags=base_tags,
confidence=0.4,
related=extractor.related_links(self.project_name),
source_key=f"file:{doc_id}",
)
async def capture_file(
self,
*,
content_md: str,
filename: str,
title: str | None = None,
tags: list[str] | None = None,
status: str = "approved",
created_by: str | None = None,
model_name: str | None = None,
skip_existing: bool = False,
template_id: str | None = None,
folder: str | None = None,
) -> dict[str, Any]:
"""Import a file (already converted to Markdown/text) into knowledge notes.
Runs the same wiki-capture LLM distillation as thread capture, so an
uploaded document becomes one or more declarative wiki notes in the vault.
Falls back to a verbatim ``draft`` note when the LLM is disabled or returns
nothing usable. Returns ``{"note", "created", "notes", "llm_fallback"}``;
raises :class:`EmptyThreadError` when there is nothing to keep.
"""
import hashlib
text = (content_md or "").strip()
if not text:
raise EmptyThreadError(f"File {filename} is empty")
# Strip any frontmatter the document carries; the vault writer adds its own.
_, parsed = MarkdownVault.parse(text)
body = (parsed or text).strip()
if not body:
raise EmptyThreadError(f"File {filename} has no body content")
# Stable id from name + size so re-importing the same file can be skipped.
doc_id = hashlib.sha1(f"{filename}:{len(body)}".encode("utf-8")).hexdigest()[:16]
logger.info(
"[knowledge] importing file '%s' (chars=%d, doc_id=%s, llm_extract=%s, skip_existing=%s)",
filename,
len(body),
doc_id,
self.config.llm_extract_enabled,
skip_existing,
)
if skip_existing:
existing = await self.repo.find_by_source("file", doc_id)
if existing:
logger.info("[knowledge] file '%s' already imported (note %s); skipping", filename, existing.get("id"))
return {"note": existing, "created": False, "notes": [existing], "llm_fallback": False}
extract_prompt = await self._resolve_extract_prompt(template_id, "document")
drafts: list[NoteDraft] = []
llm_fallback = False
if self.config.llm_extract_enabled:
try:
drafts = await llm_extractor.extract_document_llm_multi(
body,
doc_id=doc_id,
doc_title=title or Path(filename).stem,
title=title,
tags=tags,
project_name=self.project_name,
model_name=model_name or self.config.extract_model_name,
max_input_chars=self.config.extract_max_input_chars,
allow_multi=self.config.multi_note_enabled,
system_prompt=extract_prompt,
)
except llm_extractor.LLMRejectedIngest as exc:
raise EmptyThreadError(f"File {filename} judged not worth ingesting: {exc}") from exc
if not drafts:
llm_fallback = True
if not drafts:
drafts = [self._verbatim_file_draft(body, filename, title=title, tags=tags, doc_id=doc_id)]
effective_status = status
if llm_fallback and normalize_status(status) == "approved":
effective_status = "draft"
normalized = normalize_status(effective_status)
for draft in drafts:
draft.status = normalized
if folder:
draft.folder = folder
# Cross-link sibling notes split from the same document (resolved by title
# once all are persisted), mirroring multi-note thread capture.
if len(drafts) > 1:
for i, draft in enumerate(drafts):
siblings = [f"[[{o.title}]]" for j, o in enumerate(drafts) if j != i and o.title]
if siblings:
draft.content_md = _inject_related_notes(draft.content_md, siblings)
draft.related = list(dict.fromkeys([*siblings, *draft.related]))
notes = [await self._persist_draft(draft, created_by=created_by) for draft in drafts]
logger.info(
"[knowledge] file '%s' imported → %d note(s) [%s], status=%s, llm_fallback=%s",
filename,
len(notes),
", ".join(n.get("title", "") for n in notes),
normalized,
llm_fallback,
)
return {"note": notes[0], "created": True, "notes": notes, "llm_fallback": llm_fallback}
# -- CRUD -------------------------------------------------------------
async def list_notes(
self,
*,
keyword: str | None = None,
tag: str | None = None,
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
folder: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
items, total = await self.repo.list_notes(
keyword=keyword,
tag=tag,
entity=entity,
source_type=normalize_source_type(source_type) if source_type else None,
status=status,
folder=folder,
limit=limit,
offset=offset,
)
return {"items": items, "total": total}
async def get_note(self, note_id: str) -> dict[str, Any] | None:
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,
note_id: str,
*,
title: str | None = None,
content_md: str | None = None,
tags: list[str] | None = None,
status: str | None = None,
summary: str | None = None,
folder: str | None = None,
set_folder: bool = False,
updated_by: str | None = None,
) -> dict[str, Any] | None:
current = await self.repo.get_note(note_id, with_sources=False)
if current is None:
return None
# Snapshot the pre-edit state for version history (phase 3).
try:
await self.repo.add_version(current, edited_by=updated_by)
except Exception:
logger.exception("Failed to snapshot version for note %s", note_id)
body = content_md
if body is not None:
_, parsed = MarkdownVault.parse(body)
body = parsed or body
note = await self.repo.update_note(
note_id,
title=title,
summary=summary,
content_md=body,
status=normalize_status(status) if status else None,
tags=tags,
folder=folder,
set_folder=set_folder,
updated_by=updated_by,
)
if note is None:
return None
try:
rel = self.adapter.update_note_file(
note_id,
current.get("vault_path"),
title=note["title"],
content_md=note["content_md"],
tags=note.get("tags") or [],
status=note["status"],
summary=note.get("summary") or "",
category="synthesis",
created=note["created_at"],
updated=note["updated_at"],
)
if rel and rel != current.get("vault_path"):
await self.repo.set_vault_path(note_id, rel)
except Exception:
logger.exception("Failed to update knowledge note %s in vault", note_id)
if note.get("content_md") is not None:
await self._reindex_embeddings(note_id, note["content_md"])
return await self.repo.get_note(note_id)
async def list_versions(self, note_id: str) -> list[dict[str, Any]]:
return await self.repo.list_versions(note_id)
# -- folders ----------------------------------------------------------
async def list_folders(self) -> list[dict[str, Any]]:
return await self.repo.list_folders()
async def create_folder(self, path: str, *, created_by: str | None = None) -> dict[str, Any]:
return await self.repo.create_folder(path, created_by=created_by)
async def rename_folder(self, folder_id: str, new_path: str) -> dict[str, Any] | None:
return await self.repo.rename_folder(folder_id, new_path)
async def delete_folder(self, folder_id: str, *, reassign_to: str | None = None) -> bool:
return await self.repo.delete_folder(folder_id, reassign_to=reassign_to)
# -- extraction templates ---------------------------------------------
async def _resolve_extract_prompt(self, template_id: str | None, scope: str) -> str | None:
"""Return the system prompt for a capture, or ``None`` for the built-in default.
Resolution: explicit ``template_id`` → the scope's default template →
``None`` (the extractor then uses its hard-coded prompt). Best-effort: any
lookup failure degrades to the built-in prompt so capture never breaks.
"""
try:
if template_id:
tpl = await self.repo.get_template(template_id)
if tpl and tpl.get("enabled") and (tpl.get("system_prompt") or "").strip():
return tpl["system_prompt"]
default = await self.repo.get_default_template(scope)
if default and (default.get("system_prompt") or "").strip():
return default["system_prompt"]
except Exception:
logger.exception("Failed to resolve knowledge extraction template (id=%s, scope=%s)", template_id, scope)
return None
async def list_extract_templates(self, *, scope: str | None = None, enabled_only: bool = False) -> list[dict[str, Any]]:
return await self.repo.list_templates(scope=scope, enabled_only=enabled_only)
async def get_extract_template(self, template_id: str) -> dict[str, Any] | None:
return await self.repo.get_template(template_id)
async def create_extract_template(self, **kwargs: Any) -> dict[str, Any]:
return await self.repo.create_template(**kwargs)
async def update_extract_template(self, template_id: str, **kwargs: Any) -> dict[str, Any] | None:
return await self.repo.update_template(template_id, **kwargs)
async def delete_extract_template(self, template_id: str) -> bool:
return await self.repo.delete_template(template_id)
async def template_name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
return await self.repo.template_name_taken(name, exclude_id=exclude_id)
async def seed_default_templates(self, *, created_by: str | None = None) -> int:
"""Seed the two built-in distillation prompts as editable templates (idempotent).
Runs once on startup so an operator immediately sees — and can edit — the
prompts that drive 对话 / 文档 extraction. Existing templates with the same
name are left untouched. Returns the number of templates created.
"""
from deerflow.knowledge import llm_extractor
guidance = llm_extractor.WIKI_CAPTURE_GUIDANCE
seeds = [
("默认 · 对话提炼", "thread", llm_extractor._SYSTEM_PROMPT + "\n\n" + guidance, "对话沉淀的默认提炼提示词(wiki-capture 规范)。"),
("默认 · 文档提炼", "document", llm_extractor._DOC_SYSTEM_PROMPT + "\n\n" + guidance, "文档导入的默认提炼提示词(wiki-capture 规范)。"),
]
created = 0
for name, scope, prompt, desc in seeds:
try:
if await self.repo.template_name_taken(name):
continue
await self.repo.create_template(name=name, system_prompt=prompt, scope=scope, description=desc, is_default=True, enabled=True, created_by=created_by)
created += 1
except Exception:
logger.exception("Failed to seed knowledge extraction template '%s'", name)
if created:
logger.info("[knowledge] seeded %d default extraction template(s)", created)
return created
async def delete_note(self, note_id: str, *, updated_by: str | None = None, hard: bool = False) -> bool:
"""Soft delete (archive) by default; hard delete removes the vault file."""
current = await self.repo.get_note(note_id, with_sources=False)
if current is None:
return False
note = await self.repo.set_status(note_id, "archived", updated_by=updated_by)
try:
self.adapter.archive_note(note_id, current.get("vault_path"), updated=(note or current)["updated_at"])
if hard and current.get("vault_path"):
self.adapter.vault.delete_note(current["vault_path"])
self.adapter.manifest.remove_page(note_id)
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 --------------------------------------------------------
async def resolve_wikilink(self, target: str) -> dict[str, Any]:
"""Resolve a single ``[[wikilink]]`` target to a navigable destination.
Returns one of:
- ``{"kind": "note", "note_id", "title", "exists": True}`` — a knowledge note
- ``{"kind": "vault", "vault_path", "title", "exists": True}`` — a vault meta/hub page
- ``{"kind": "missing", "exists": False}`` — nothing matches
Resolution order: note id → note vault_path → note title → vault file.
"""
norm = normalize_wikilink_target(target)
base = {"target": target, "normalized": norm}
if not norm:
return {**base, "kind": "missing", "exists": False}
# 1) Direct note id (e.g. "kb_abc...").
if norm.startswith("kb_"):
note = await self.repo.get_note(norm, with_sources=False)
if note and note.get("status") != "archived":
return {**base, "kind": "note", "note_id": note["id"], "title": note["title"], "exists": True}
# 2) Note by vault path (links from index.md / hot.md / structural hub).
note = await self.repo.find_by_vault_path(norm)
if note:
return {**base, "kind": "note", "note_id": note["id"], "title": note["title"], "exists": True}
# 3) Note by title (the real related-note cross-links use [[Title]]).
note = await self.repo.find_by_title(norm) or await self.repo.find_by_title(norm.split("/")[-1])
if note:
return {**base, "kind": "note", "note_id": note["id"], "title": note["title"], "exists": True}
# 4) A vault meta/hub page that has no DB note (index, _meta/taxonomy, hub…).
page = self.read_vault_page(norm)
if page is not None:
return {**base, "kind": "vault", "vault_path": page["path"], "title": page["title"], "exists": True}
return {**base, "kind": "missing", "exists": False}
async def resolve_wikilinks(self, targets: list[str]) -> dict[str, dict[str, Any]]:
"""Resolve many targets at once; returns ``{original_target: resolution}``."""
out: dict[str, dict[str, Any]] = {}
for target in dict.fromkeys(t for t in targets if t and t.strip()):
out[target] = await self.resolve_wikilink(target)
return out
async def resolve_note_wikilinks(self, note_id: str) -> dict[str, dict[str, Any]] | None:
"""Extract every ``[[wikilink]]`` in a note's body and resolve them.
Returns ``{target: resolution}`` or ``None`` when the note is missing.
Numeric ``[[123]]`` citation badges are skipped (handled by the renderer).
"""
note = await self.repo.get_note(note_id, with_sources=False)
if note is None:
return None
targets = [t for t in extract_wikilinks(note.get("content_md") or "") if not t.isdigit()]
return await self.resolve_wikilinks(targets)
def read_vault_page(self, path: str) -> dict[str, Any] | None:
"""Read a raw vault Markdown page (meta/hub pages without a DB note).
Returns ``{"path", "title", "content_md"}`` or ``None`` if the page does
not exist or the path escapes the vault root.
"""
norm = normalize_wikilink_target(path)
if not norm:
return None
rel = f"{norm}.md"
try:
parsed = self.adapter.vault.read_note(rel)
except VaultPathError:
return None
if parsed is None:
return None
fm, body = parsed
title = str(fm.get("title") or "").strip() or norm.split("/")[-1]
return {"path": norm, "title": title, "content_md": body}
# -- search & graph ---------------------------------------------------
async def search(self, query: str, *, mode: str = "keyword", limit: int = 8) -> list[dict[str, Any]]:
return await search_notes(self.repo, query, mode=mode, limit=limit, embedding_client=self.embedding_client)
async def build_rag_context(self, query: str, *, limit: int = 5) -> dict[str, Any]:
"""Retrieve top knowledge for ``query`` and format an injectable block.
Returns ``{"text": <system context or "">, "items": [...]}``. Uses hybrid
search when embeddings are available, else keyword (wiki-query style).
"""
mode = "hybrid" if self.embedding_client.is_enabled() else "keyword"
items = await search_notes(self.repo, query, mode=mode, limit=limit, embedding_client=self.embedding_client)
if not items:
return {"text": "", "items": []}
lines = [
"以下是产品知识库中与本问题相关的资料。回答时优先参考;若使用其中信息,请在回答中标注来源(note_id)。",
"",
]
for i, it in enumerate(items, 1):
lines.append(f"[知识 {i}] {it.get('title') or ''}")
if it.get("snippet"):
lines.append(it["snippet"])
lines.append(f"来源:{it['note_id']}")
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()
count = 0
for note in notes:
await self._reindex_embeddings(note["id"], note.get("content_md") or "")
count += 1
return {"reindexed": count, "embedding_enabled": int(self.embedding_client.is_enabled())}
async def export_graph(self) -> dict[str, Any]:
from deerflow.utils.time import now_iso
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 []
blacklist = await self._graph_blacklist_set()
result = self.adapter.export_graph(notes, entities=entities, relations=relations, updated=now_iso(), blacklist=blacklist)
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 []
blacklist = await self._graph_blacklist_set()
return exporter.build_graph(notes, entities, relations, blacklist=blacklist)
# -- graph blacklist ---------------------------------------------------
async def _graph_blacklist_set(self) -> set[str]:
from deerflow.knowledge import exporter
items = await self.repo.list_graph_blacklist()
return exporter.normalize_blacklist([item["label"] for item in items])
async def list_graph_blacklist(self) -> list[dict[str, Any]]:
"""Return blacklist entries (node labels hidden from the graph)."""
return await self.repo.list_graph_blacklist()
async def add_graph_blacklist(self, labels: list[str], *, created_by: str | None = None) -> int:
"""Blacklist node labels (entity names / note titles). Returns the count added."""
return await self.repo.add_graph_blacklist(labels, created_by=created_by)
async def remove_graph_blacklist(self, entry_id: str) -> bool:
return await self.repo.remove_graph_blacklist(entry_id)
def export_file_path(self, filename: str) -> Path | None:
return self.adapter.export_file_path(filename)