From 4aef81284a3110da6c73eabdaa93f818104ff177 Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 8 Jun 2026 22:22:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E5=87=BA=E5=A4=84=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E6=B8=B2=E6=9F=93=20+=20=E7=9B=B8=E5=85=B3=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=E6=8C=89=E6=A0=87=E7=AD=BE=E7=B2=BE=E7=A1=AE=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=20+=20LLM=20=E5=9B=9E=E9=80=80=E8=90=BD=E8=8D=89?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../strategy-components/MarkdownRenderer.tsx | 15 +++- .../backend/app/gateway/routers/knowledge.py | 7 +- .../deerflow/knowledge/llm_extractor.py | 7 ++ .../harness/deerflow/knowledge/service.py | 87 +++++++++++-------- 4 files changed, 80 insertions(+), 36 deletions(-) diff --git a/frontend-web/src/strategy-components/MarkdownRenderer.tsx b/frontend-web/src/strategy-components/MarkdownRenderer.tsx index 7f8ae49e..42e5879a 100644 --- a/frontend-web/src/strategy-components/MarkdownRenderer.tsx +++ b/frontend-web/src/strategy-components/MarkdownRenderer.tsx @@ -174,7 +174,20 @@ const MarkdownRenderer: React.FC = ({ }, ); } - // 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, + ' 推测', + ) + .replace( + /\s*\^\[ambiguous\]/g, + ' 存疑', + ); + + // 3) Numeric [[123]] citation badges. const contentWithCitationBadge = processed.replace( /\[\[(\d+)\]\]/g, '', diff --git a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py index 8b031a31..a9b947c8 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py +++ b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py index 75688a6d..bf596b1e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/llm_extractor.py @@ -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] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py index fc342be2..5440191e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py @@ -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,