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(
|
const contentWithCitationBadge = processed.replace(
|
||||||
/\[\[(\d+)\]\]/g,
|
/\[\[(\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>',
|
'<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:
|
except EmptyThreadError:
|
||||||
raise HTTPException(status_code=400, detail="Thread has no sediment-able content")
|
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")
|
@router.post("/ingest/threads/batch")
|
||||||
|
|||||||
@@ -252,10 +252,17 @@ async def extract_thread_llm_multi(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
|
logger.warning("Knowledge LLM extraction for thread %s returned no parsable JSON; using rule-based fallback", thread_id)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
note_dicts = _notes_from_data(data)
|
note_dicts = _notes_from_data(data)
|
||||||
if not note_dicts:
|
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 []
|
return []
|
||||||
if not allow_multi:
|
if not allow_multi:
|
||||||
note_dicts = note_dicts[:1]
|
note_dicts = note_dicts[:1]
|
||||||
|
|||||||
@@ -94,46 +94,40 @@ class KnowledgeService:
|
|||||||
return draft
|
return draft
|
||||||
|
|
||||||
async def _find_related_notes(self, draft: NoteDraft) -> list[dict[str, Any]]:
|
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
|
Matching is strictly tag-based: a candidate must share at least one of the
|
||||||
draft's own note does not exist yet, so we exclude same-title hits to
|
draft's *distinctive* tags (the per-note project / ``knowledge-base``
|
||||||
avoid self-links when re-capturing or deduping. Best-effort: any failure
|
defaults are dropped, else everything matches everything). Free-text
|
||||||
yields no links rather than blocking the capture.
|
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:
|
if not self.config.related_notes_enabled:
|
||||||
return []
|
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}
|
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]
|
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 distinctive:
|
||||||
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 []
|
return []
|
||||||
own_title = (draft.title or "").strip().lower()
|
own_title = (draft.title or "").strip().lower()
|
||||||
out: list[dict[str, Any]] = []
|
# Rank candidates by how many distinctive tags they share with the draft.
|
||||||
seen: set[str] = set()
|
shared: dict[str, dict[str, Any]] = {}
|
||||||
for it in items:
|
for tag in distinctive:
|
||||||
nid = it.get("note_id")
|
try:
|
||||||
title = (it.get("title") or "").strip()
|
items, _total = await self.repo.list_notes(tag=tag, limit=50, offset=0)
|
||||||
if not nid or nid in seen or not title:
|
except Exception:
|
||||||
|
logger.exception("Related-notes tag lookup failed for tag '%s'", tag)
|
||||||
continue
|
continue
|
||||||
if title.lower() == own_title: # the draft itself / a duplicate
|
for it in items:
|
||||||
continue
|
nid = it.get("id")
|
||||||
seen.add(nid)
|
title = (it.get("title") or "").strip()
|
||||||
out.append({"id": nid, "title": title})
|
if not nid or not title or title.lower() == own_title:
|
||||||
if len(out) >= limit:
|
continue
|
||||||
break
|
entry = shared.setdefault(nid, {"id": nid, "title": title, "count": 0})
|
||||||
return out
|
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]:
|
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."""
|
||||||
@@ -250,7 +244,9 @@ class KnowledgeService:
|
|||||||
# knowledge, not raw chat. A single conversation may split into several
|
# knowledge, not raw chat. A single conversation may split into several
|
||||||
# cross-linked notes. Falls back to the rule-based extractor (always one
|
# cross-linked notes. Falls back to the rule-based extractor (always one
|
||||||
# note) when the LLM is disabled/unavailable or returns nothing usable.
|
# 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(
|
drafts = await llm_extractor.extract_thread_llm_multi(
|
||||||
messages,
|
messages,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
@@ -265,6 +261,12 @@ class KnowledgeService:
|
|||||||
max_input_chars=self.config.extract_max_input_chars,
|
max_input_chars=self.config.extract_max_input_chars,
|
||||||
allow_multi=self.config.multi_note_enabled,
|
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:
|
if not drafts:
|
||||||
single = extractor.extract_thread(
|
single = extractor.extract_thread(
|
||||||
messages,
|
messages,
|
||||||
@@ -281,7 +283,24 @@ class KnowledgeService:
|
|||||||
if not drafts:
|
if not drafts:
|
||||||
raise EmptyThreadError(f"Thread {thread_id} has no sediment-able content")
|
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:
|
for draft in drafts:
|
||||||
draft.status = normalized
|
draft.status = normalized
|
||||||
# Cross-link sibling notes from the same conversation by title, so the
|
# 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]))
|
draft.related = list(dict.fromkeys([*siblings, *draft.related]))
|
||||||
|
|
||||||
notes = [await self._persist_draft(draft, created_by=created_by) for draft in drafts]
|
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(
|
async def capture_search(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Reference in new issue
Block a user