feat(knowledge): 出处标记渲染 + 相关笔记按标签精确匹配 + LLM 回退落草稿

A) 前端把 ^[inferred]/^[ambiguous] 渲染成「推测」「存疑」小角标(原先原样显示文本)
B) 相关笔记只按"区分性标签"精确匹配(去掉标题/摘要关键词检索——中文分词导致
   助手/知识图谱/页面 等泛词误关联,如「AI助手打招呼」被错误拉进 Related);
   无区分性标签则不关联、不渲染 ## Related 段
C) LLM 提炼启用但失败时不再静默回退成"原文堆砌":
   - 回退笔记落为 draft(草稿)而非 approved,便于复核
   - capture_thread 记 WARNING 并返回 llm_fallback;router 透出该标志
   - llm_extractor 在 JSON 解析失败/无 notes 时补充可诊断的 WARNING 日志

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-08 22:22:28 +08:00
1 parent e7ff07debd
commit 4aef81284a
4 files changed
+80 -36

No files matched your search

@@ -174,7 +174,20 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
},
);
}
// 2) Numeric [[123]] citation badges.
// 2) wiki-capture provenance markers ^[inferred] / ^[ambiguous] → small badges
// (otherwise they render as raw text). They mark model-inferred / uncertain
// statements per the wiki-capture spec.
processed = processed
.replace(
/\s*\^\[inferred\]/g,
' <sup title="模型推断,非原文直接得出" class="ml-0.5 rounded bg-amber-50 px-1 text-[10px] font-medium text-amber-600 align-super dark:bg-amber-500/15 dark:text-amber-300">推测</sup>',
)
.replace(
/\s*\^\[ambiguous\]/g,
' <sup title="不确定 / 存在冲突" class="ml-0.5 rounded bg-rose-50 px-1 text-[10px] font-medium text-rose-600 align-super dark:bg-rose-500/15 dark:text-rose-300">存疑</sup>',
);
// 3) Numeric [[123]] citation badges.
const contentWithCitationBadge = processed.replace(
/\[\[(\d+)\]\]/g,
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
@@ -227,7 +227,12 @@ async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Requ
)
except EmptyThreadError:
raise HTTPException(status_code=400, detail="Thread has no sediment-able content")
return {"note": result["note"], "created": result["created"], "notes": result.get("notes", [result["note"]])}
return {
"note": result["note"],
"created": result["created"],
"notes": result.get("notes", [result["note"]]),
"llm_fallback": result.get("llm_fallback", False),
}
@router.post("/ingest/threads/batch")
@@ -252,10 +252,17 @@ async def extract_thread_llm_multi(
return []
if not data:
logger.warning("Knowledge LLM extraction for thread %s returned no parsable JSON; using rule-based fallback", thread_id)
return []
note_dicts = _notes_from_data(data)
if not note_dicts:
logger.warning(
"Knowledge LLM extraction for thread %s produced no notes (should_ingest=%s, reason=%r); using rule-based fallback",
thread_id,
data.get("should_ingest"),
str(data.get("reason") or "")[:200],
)
return []
if not allow_multi:
note_dicts = note_dicts[:1]
@@ -94,46 +94,40 @@ class KnowledgeService:
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.
"""Find existing notes to cross-link under ## Related — by **shared tags**.
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.
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 []
# 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", "zncm") 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)
if not distinctive:
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:
# 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
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
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."""
@@ -250,7 +244,9 @@ class KnowledgeService:
# 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:
llm_attempted = self.config.llm_extract_enabled
llm_fallback = False
if llm_attempted:
drafts = await llm_extractor.extract_thread_llm_multi(
messages,
thread_id=thread_id,
@@ -265,6 +261,12 @@ class KnowledgeService:
max_input_chars=self.config.extract_max_input_chars,
allow_multi=self.config.multi_note_enabled,
)
if not drafts:
# The LLM path was supposed to run but produced nothing usable
# (API error / non-JSON / empty). 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,
@@ -281,7 +283,24 @@ class KnowledgeService:
if not drafts:
raise EmptyThreadError(f"Thread {thread_id} has no sediment-able content")
normalized = normalize_status(status)
# 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 capture for thread %s fell back to rule-based extraction "
"(LLM distillation produced nothing usable); landing as draft. "
"Check extraction model '%s' / earlier 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
# Cross-link sibling notes from the same conversation by title, so the
@@ -294,7 +313,7 @@ class KnowledgeService:
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}
return {"note": notes[0], "created": True, "notes": notes, "llm_fallback": llm_fallback}
async def capture_search(
self,