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:
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,
|
||||
|
||||
Reference in new issue
Block a user