feat(knowledge): 可跳转 wikilink + 真实相关 note + 一对话多页
- [[wikilink]] 解析器:note id / vault_path / 标题 / vault 元页 / missing
- 前端 MarkdownRenderer 把非数字 [[...]] 渲染成可点链接,元页弹窗查看,缺失置灰
- vault 脚手架补建项目 hub 页,三个结构性链接(index/taxonomy/hub)始终可跳
- capture 时按标题+摘要+区分性标签检索同主题 note,注入真实 [[标题]] 到 Related + 记 related 图谱边
- LLM 提取支持 notes[] 数组,一次对话拆 1~4 页并按标题互链(multi_note_enabled 开关)
- 新增接口 POST /resolve、GET /notes/{id}/wikilinks、GET /vault-page
- 新增配置 related_notes_enabled / related_notes_limit / multi_note_enabled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
92a46003f8
commit
df3fac1032
13 files changed
+668
-72
No files matched your search
@@ -62,6 +62,9 @@ class KnowledgeConfig(BaseModel):
|
||||
extract_max_input_chars: int = Field(default=12000, description="Max characters of conversation transcript fed to the extraction LLM.")
|
||||
max_thread_messages: int = Field(default=80, description="Maximum thread messages to scan when capturing.")
|
||||
max_source_items: int = Field(default=20, description="Maximum source items recorded per note.")
|
||||
related_notes_enabled: bool = Field(default=True, description="On capture, find existing notes on the same topic and link them under ## Related (real cross-references instead of placeholders).")
|
||||
related_notes_limit: int = Field(default=3, description="Maximum real related notes linked into each captured note.")
|
||||
multi_note_enabled: bool = Field(default=True, description="Allow the LLM extractor to split one conversation into multiple cross-linked knowledge notes.")
|
||||
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).")
|
||||
|
||||
@@ -105,10 +105,18 @@ def _summary(turns: list[dict[str, str]], *, limit: int = 280) -> str:
|
||||
|
||||
|
||||
def related_links(project_name: str | None) -> list[str]:
|
||||
"""At least two wikilinks per note (wiki-capture requirement)."""
|
||||
"""Structural wikilinks every note carries (wiki-capture requirement).
|
||||
|
||||
These point at vault meta/hub pages (``index`` / ``_meta/taxonomy`` / the
|
||||
project hub) that always exist, so they resolve to navigable pages. Content-
|
||||
specific "related notes" are injected separately by the service layer (which
|
||||
has DB access) on top of these.
|
||||
"""
|
||||
from deerflow.knowledge.markdown_vault import slugify
|
||||
|
||||
links = ["[[index]]", "[[_meta/taxonomy]]"]
|
||||
if project_name:
|
||||
links.append(f"[[projects/{project_name}/synthesis/product-knowledge-base]]")
|
||||
links.append(f"[[projects/{slugify(project_name)}/synthesis/product-knowledge-base]]")
|
||||
return links
|
||||
|
||||
|
||||
|
||||
+161
-52
@@ -40,21 +40,30 @@ _SYSTEM_PROMPT = """\
|
||||
references(外部来源汇总)、decision(架构/设计决策)、journal(会话纪要)。
|
||||
- 用中文输出。
|
||||
|
||||
一次对话可能只讲一个主题,也可能覆盖多个相互独立的主题。请把它拆成 1~4 页知识:
|
||||
- 默认只产出 **1 页**;只有当对话明确包含**多个彼此独立、可各自成文**的主题时才拆成多页。
|
||||
- 每一页聚焦单一主题,标题与 summary 必须彼此区分,不要把同一结论拆到多页。
|
||||
- 不值得沉淀时返回空的 notes 数组。
|
||||
|
||||
只输出一个 JSON 对象,不要任何额外文字或 Markdown 代码块,结构如下:
|
||||
{
|
||||
"should_ingest": true,
|
||||
"reason": "为什么值得/不值得沉淀",
|
||||
"title": "简洁的知识标题(不超过 40 字)",
|
||||
"summary": "1-2 句话概括这页知识",
|
||||
"category": "synthesis",
|
||||
"context": "问题背景或触发场景,1-3 句",
|
||||
"findings": ["结论/要点(陈述式,可含 ^[inferred])", "..."],
|
||||
"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
|
||||
"notes": [
|
||||
{
|
||||
"title": "简洁的知识标题(不超过 40 字)",
|
||||
"summary": "1-2 句话概括这页知识",
|
||||
"category": "synthesis",
|
||||
"context": "问题背景或触发场景,1-3 句",
|
||||
"findings": ["结论/要点(陈述式,可含 ^[inferred])", "..."],
|
||||
"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
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
@@ -128,7 +137,78 @@ def _render_body(data: dict[str, Any], title: str, related: list[str], sources:
|
||||
)
|
||||
|
||||
|
||||
async def extract_thread_llm(
|
||||
def _notes_from_data(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Normalize the LLM JSON into a list of per-note dicts.
|
||||
|
||||
Accepts the multi-note shape ``{"notes": [...]}`` as well as the legacy
|
||||
single-note shape (``{"title": ..., "findings": ...}``) for backward
|
||||
compatibility. Notes without any title/findings are dropped.
|
||||
"""
|
||||
raw_notes = data.get("notes")
|
||||
if isinstance(raw_notes, list):
|
||||
candidates = [n for n in raw_notes if isinstance(n, dict)]
|
||||
elif data.get("title") or data.get("findings") or data.get("context"):
|
||||
candidates = [data] # legacy single-object output
|
||||
else:
|
||||
candidates = []
|
||||
return [n for n in candidates if str(n.get("title") or "").strip() or n.get("findings")]
|
||||
|
||||
|
||||
def _build_note_draft(
|
||||
note_data: dict[str, Any],
|
||||
*,
|
||||
thread_id: str,
|
||||
thread_title: str | None,
|
||||
turns: list[dict[str, str]],
|
||||
title_override: str | None,
|
||||
tags: list[str] | None,
|
||||
project_name: str | None,
|
||||
sources: list[SourceDraft],
|
||||
source_key_suffix: str = "",
|
||||
) -> NoteDraft:
|
||||
"""Render one note dict into a :class:`NoteDraft`."""
|
||||
category = str(note_data.get("category") or "synthesis").strip().lower()
|
||||
if category not in _VALID_CATEGORIES:
|
||||
category = "synthesis"
|
||||
|
||||
final_title = (title_override or "").strip() or str(note_data.get("title") or "").strip() or extractor._auto_title(thread_title, turns)
|
||||
summary = str(note_data.get("summary") or "").strip()
|
||||
related = extractor.related_links(project_name)
|
||||
body = _render_body(note_data, final_title, related, sources)
|
||||
|
||||
base_tags = list(tags or [])
|
||||
llm_tags = note_data.get("tags") if isinstance(note_data.get("tags"), list) else []
|
||||
for tag in [*llm_tags, project_name or "deerflow", "knowledge-base"]:
|
||||
tag = str(tag).strip()
|
||||
if tag and tag not in base_tags:
|
||||
base_tags.append(tag)
|
||||
|
||||
try:
|
||||
confidence = float(note_data.get("confidence") or 0.7)
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.7
|
||||
|
||||
entities = note_data.get("entities") if isinstance(note_data.get("entities"), list) else []
|
||||
relations = note_data.get("relations") if isinstance(note_data.get("relations"), list) else []
|
||||
|
||||
return NoteDraft(
|
||||
title=final_title,
|
||||
summary=summary or final_title,
|
||||
content_md=body,
|
||||
category=category,
|
||||
source_type="thread",
|
||||
source_id=thread_id,
|
||||
tags=base_tags,
|
||||
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}{source_key_suffix}",
|
||||
)
|
||||
|
||||
|
||||
async def extract_thread_llm_multi(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
thread_id: str,
|
||||
@@ -141,15 +221,19 @@ async def extract_thread_llm(
|
||||
max_messages: int = 80,
|
||||
model_name: str | None = None,
|
||||
max_input_chars: int = 12000,
|
||||
) -> NoteDraft | None:
|
||||
"""Distill a thread into a :class:`NoteDraft` via the LLM.
|
||||
allow_multi: bool = True,
|
||||
max_notes: int = 4,
|
||||
) -> list[NoteDraft]:
|
||||
"""Distill a thread into one *or several* cross-linkable knowledge notes.
|
||||
|
||||
Returns ``None`` when there is nothing to sediment **or** the LLM path
|
||||
fails — callers should fall back to the rule-based extractor.
|
||||
The LLM may split a conversation that covers multiple independent topics into
|
||||
several declarative notes. Returns ``[]`` when there is nothing to sediment
|
||||
or the LLM path fails — callers should fall back to the rule-based extractor.
|
||||
When ``allow_multi`` is false only the first note is kept.
|
||||
"""
|
||||
turns = extractor.split_turns(messages, max_messages=max_messages)
|
||||
if not turns:
|
||||
return None
|
||||
return []
|
||||
sources = sources or []
|
||||
|
||||
try:
|
||||
@@ -168,47 +252,72 @@ async def extract_thread_llm(
|
||||
data = _parse_json(raw)
|
||||
except Exception:
|
||||
logger.exception("Knowledge LLM extraction failed for thread %s; using rule-based fallback", thread_id)
|
||||
return None
|
||||
return []
|
||||
|
||||
if not data:
|
||||
return None
|
||||
return []
|
||||
|
||||
category = str(data.get("category") or "synthesis").strip().lower()
|
||||
if category not in _VALID_CATEGORIES:
|
||||
category = "synthesis"
|
||||
note_dicts = _notes_from_data(data)
|
||||
if not note_dicts:
|
||||
return []
|
||||
if not allow_multi:
|
||||
note_dicts = note_dicts[:1]
|
||||
else:
|
||||
note_dicts = note_dicts[: max(1, max_notes)]
|
||||
|
||||
final_title = (title or "").strip() or str(data.get("title") or "").strip() or extractor._auto_title(thread_title, turns)
|
||||
summary = str(data.get("summary") or "").strip()
|
||||
related = extractor.related_links(project_name)
|
||||
body = _render_body(data, final_title, related, sources)
|
||||
drafts: list[NoteDraft] = []
|
||||
for i, nd in enumerate(note_dicts):
|
||||
# The user-supplied title override only applies to a single-note result;
|
||||
# multi-note splits keep each note's own LLM title.
|
||||
override = title if (len(note_dicts) == 1) else None
|
||||
suffix = "" if len(note_dicts) == 1 else f"#{i}"
|
||||
drafts.append(
|
||||
_build_note_draft(
|
||||
nd,
|
||||
thread_id=thread_id,
|
||||
thread_title=thread_title,
|
||||
turns=turns,
|
||||
title_override=override,
|
||||
tags=tags,
|
||||
project_name=project_name,
|
||||
sources=sources,
|
||||
source_key_suffix=suffix,
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
base_tags = list(tags or [])
|
||||
llm_tags = data.get("tags") if isinstance(data.get("tags"), list) else []
|
||||
for tag in [*llm_tags, project_name or "deerflow", "knowledge-base"]:
|
||||
tag = str(tag).strip()
|
||||
if tag and tag not in base_tags:
|
||||
base_tags.append(tag)
|
||||
|
||||
try:
|
||||
confidence = float(data.get("confidence") or 0.7)
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.7
|
||||
async def extract_thread_llm(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
thread_id: str,
|
||||
thread_title: str | None = None,
|
||||
mode: str = "summary",
|
||||
title: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
project_name: str | None = "deerflow",
|
||||
sources: list[SourceDraft] | None = None,
|
||||
max_messages: int = 80,
|
||||
model_name: str | None = None,
|
||||
max_input_chars: int = 12000,
|
||||
) -> NoteDraft | None:
|
||||
"""Distill a thread into a single :class:`NoteDraft` (backward-compatible).
|
||||
|
||||
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,
|
||||
content_md=body,
|
||||
category=category,
|
||||
source_type="thread",
|
||||
source_id=thread_id,
|
||||
tags=base_tags,
|
||||
confidence=max(0.0, min(confidence, 1.0)),
|
||||
related=related,
|
||||
Thin wrapper over :func:`extract_thread_llm_multi` that returns the first
|
||||
note, or ``None`` when nothing was produced.
|
||||
"""
|
||||
drafts = await extract_thread_llm_multi(
|
||||
messages,
|
||||
thread_id=thread_id,
|
||||
thread_title=thread_title,
|
||||
mode=mode,
|
||||
title=title,
|
||||
tags=tags,
|
||||
project_name=project_name,
|
||||
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}",
|
||||
max_messages=max_messages,
|
||||
model_name=model_name,
|
||||
max_input_chars=max_input_chars,
|
||||
allow_multi=False,
|
||||
)
|
||||
return drafts[0] if drafts else None
|
||||
+32
@@ -52,6 +52,23 @@ def extract_wikilinks(text: str) -> list[str]:
|
||||
return seen
|
||||
|
||||
|
||||
def normalize_wikilink_target(target: str) -> str:
|
||||
"""Canonicalize a ``[[wikilink]]`` target for resolution/comparison.
|
||||
|
||||
Drops any ``|alias`` part, normalizes slashes, strips a leading ``/`` and a
|
||||
trailing ``.md`` — so ``[[index]]``, ``index.md`` and ``/index`` all map to
|
||||
the same canonical ``index`` key used by the index/graph/resolver layers.
|
||||
"""
|
||||
t = (target or "").strip()
|
||||
if not t:
|
||||
return ""
|
||||
t = t.split("|", 1)[0].strip()
|
||||
t = t.replace("\\", "/").lstrip("/")
|
||||
if t.lower().endswith(".md"):
|
||||
t = t[:-3]
|
||||
return t
|
||||
|
||||
|
||||
class VaultPathError(ValueError):
|
||||
"""Raised when a requested vault path escapes the vault root."""
|
||||
|
||||
@@ -99,6 +116,21 @@ class MarkdownVault:
|
||||
base = self.root / "projects" / slugify(project_name)
|
||||
for d in ("concepts", "references", "synthesis", "journal"):
|
||||
(base / d).mkdir(parents=True, exist_ok=True)
|
||||
# Project hub page — the structural target every note links to under
|
||||
# ``## Related`` (``[[projects/<project>/synthesis/product-knowledge-base]]``).
|
||||
# Materialize it so that wikilink is always navigable, not a dangling
|
||||
# placeholder.
|
||||
hub = base / "synthesis" / "product-knowledge-base.md"
|
||||
if not hub.exists():
|
||||
hub.write_text(
|
||||
f"---\ntitle: 产品知识库总览\ncategory: synthesis\n---\n\n"
|
||||
f"# 产品知识库总览\n\n"
|
||||
f"本页是 **{project_name}** 项目知识库的入口枢纽(hub)。每条知识都会在 `## Related` 中链接回这里。\n\n"
|
||||
"- [[index]] — 全部页面索引\n"
|
||||
"- [[_meta/taxonomy]] — 分类体系\n\n"
|
||||
"## 主题\n\n_随着知识沉淀,这里会逐步链接到各主题页面。_\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Base vault files.
|
||||
index = self.root / "index.md"
|
||||
if not index.exists():
|
||||
|
||||
@@ -216,6 +216,48 @@ class KnowledgeRepository:
|
||||
d["sources"] = [self._source_to_dict(r) for r in (await session.execute(stmt)).scalars()]
|
||||
return d
|
||||
|
||||
async def find_by_vault_path(self, vault_path: str) -> dict[str, Any] | None:
|
||||
"""Return the first (non-archived) note whose vault_path matches.
|
||||
|
||||
``vault_path`` is matched both with and without the ``.md`` suffix, since
|
||||
wikilink targets are stored without it while ``vault_path`` keeps it.
|
||||
"""
|
||||
stripped = vault_path[:-3] if vault_path.endswith(".md") else vault_path
|
||||
candidates = {vault_path, stripped, f"{stripped}.md"}
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(KnowledgeNoteRow)
|
||||
.where(KnowledgeNoteRow.vault_path.in_(list(candidates)))
|
||||
.where(KnowledgeNoteRow.status != "archived")
|
||||
.limit(1)
|
||||
)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
d = self._note_to_dict(row)
|
||||
d["tags"] = await self._tags_for(session, row.id)
|
||||
return d
|
||||
|
||||
async def find_by_title(self, title: str) -> dict[str, Any] | None:
|
||||
"""Return the first (non-archived) note with a case-insensitive title match."""
|
||||
name = (title or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(KnowledgeNoteRow)
|
||||
.where(func.lower(KnowledgeNoteRow.title) == name.lower())
|
||||
.where(KnowledgeNoteRow.status != "archived")
|
||||
.order_by(KnowledgeNoteRow.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
d = self._note_to_dict(row)
|
||||
d["tags"] = await self._tags_for(session, row.id)
|
||||
return d
|
||||
|
||||
async def find_by_source(self, source_type: str, source_id: str) -> dict[str, Any] | None:
|
||||
"""Return the first (non-archived) note for a (source_type, source_id)."""
|
||||
async with self._sf() as session:
|
||||
|
||||
@@ -18,7 +18,7 @@ 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
|
||||
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
|
||||
@@ -39,6 +39,24 @@ def _resolve_vault_dir(vault_dir: str) -> Path:
|
||||
return (runtime_home() / vault_dir).resolve()
|
||||
|
||||
|
||||
def _inject_related_notes(body: str, links: list[str]) -> str:
|
||||
"""Insert real related-note ``[[wikilinks]]`` at the top of the Related block.
|
||||
|
||||
Keeps the structural meta-links (index / taxonomy / hub) that the extractor
|
||||
already rendered, but surfaces the content-specific related notes first. When
|
||||
there is no ``## Related`` section, 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."""
|
||||
|
||||
@@ -75,9 +93,56 @@ class KnowledgeService:
|
||||
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 on the same topic to cross-link under ## Related.
|
||||
|
||||
Uses the existing keyword/hybrid search over the title (+ summary). The
|
||||
draft's own note does not exist yet, so we exclude same-title hits to
|
||||
avoid self-links when re-capturing or deduping. Best-effort: any failure
|
||||
yields no links rather than blocking the capture.
|
||||
"""
|
||||
if not self.config.related_notes_enabled:
|
||||
return []
|
||||
# Build a topical query from the title + summary + the note's *distinctive*
|
||||
# tags (the per-note project/knowledge-base defaults are dropped, else
|
||||
# every note would match every other). CJK runs tokenize as a single
|
||||
# token, so shared distinctive tags are what make cross-topic matches work.
|
||||
default_tags = {t.lower() for t in (self.project_name or "", "knowledge-base", "deerflow") if t}
|
||||
distinctive = [t for t in (draft.tags or []) if t and t.lower() not in default_tags]
|
||||
query = " ".join(p for p in [draft.title or "", draft.summary or "", *distinctive] if p).strip()
|
||||
if not query:
|
||||
return []
|
||||
limit = max(1, self.config.related_notes_limit)
|
||||
mode = "hybrid" if self.embedding_client.is_enabled() else "keyword"
|
||||
try:
|
||||
items = await search_notes(self.repo, query, mode=mode, limit=limit + 3, embedding_client=self.embedding_client)
|
||||
except Exception:
|
||||
logger.exception("Failed to find related notes for '%s'", draft.title)
|
||||
return []
|
||||
own_title = (draft.title or "").strip().lower()
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for it in items:
|
||||
nid = it.get("note_id")
|
||||
title = (it.get("title") or "").strip()
|
||||
if not nid or nid in seen or not title:
|
||||
continue
|
||||
if title.lower() == own_title: # the draft itself / a duplicate
|
||||
continue
|
||||
seen.add(nid)
|
||||
out.append({"id": nid, "title": title})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
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,
|
||||
@@ -100,6 +165,13 @@ class KnowledgeService:
|
||||
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:
|
||||
@@ -173,12 +245,13 @@ class KnowledgeService:
|
||||
if include_sources and reference_batches:
|
||||
sources = from_reference_batches(reference_batches, thread_id=thread_id, max_items=self.config.max_source_items)
|
||||
|
||||
draft: NoteDraft | None = None
|
||||
drafts: list[NoteDraft] = []
|
||||
# Prefer LLM distillation (wiki-capture style) so notes are declarative
|
||||
# knowledge, not raw chat. Falls back to the rule-based extractor when
|
||||
# the LLM is disabled/unavailable or returns something unusable.
|
||||
# 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.
|
||||
if self.config.llm_extract_enabled:
|
||||
draft = await llm_extractor.extract_thread_llm(
|
||||
drafts = await llm_extractor.extract_thread_llm_multi(
|
||||
messages,
|
||||
thread_id=thread_id,
|
||||
thread_title=thread_title,
|
||||
@@ -190,9 +263,10 @@ class KnowledgeService:
|
||||
max_messages=self.config.max_thread_messages,
|
||||
model_name=self.config.extract_model_name,
|
||||
max_input_chars=self.config.extract_max_input_chars,
|
||||
allow_multi=self.config.multi_note_enabled,
|
||||
)
|
||||
if draft is None:
|
||||
draft = extractor.extract_thread(
|
||||
if not drafts:
|
||||
single = extractor.extract_thread(
|
||||
messages,
|
||||
thread_id=thread_id,
|
||||
thread_title=thread_title,
|
||||
@@ -203,11 +277,24 @@ class KnowledgeService:
|
||||
sources=sources,
|
||||
max_messages=self.config.max_thread_messages,
|
||||
)
|
||||
if draft is None:
|
||||
drafts = [single] if single else []
|
||||
if not drafts:
|
||||
raise EmptyThreadError(f"Thread {thread_id} has no sediment-able content")
|
||||
draft.status = normalize_status(status)
|
||||
note = await self._persist_draft(draft, created_by=created_by)
|
||||
return {"note": note, "created": True}
|
||||
|
||||
normalized = normalize_status(status)
|
||||
for draft in drafts:
|
||||
draft.status = normalized
|
||||
# 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]
|
||||
return {"note": notes[0], "created": True, "notes": notes}
|
||||
|
||||
async def capture_search(
|
||||
self,
|
||||
@@ -357,6 +444,84 @@ class KnowledgeService:
|
||||
logger.exception("Failed to archive knowledge note %s in vault", 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)
|
||||
|
||||
Reference in new issue
Block a user