From 76320ca5a35bbf482e3d6c14564fb1041bde9130 Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 8 Jun 2026 22:49:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(knowledge):=20=E6=A8=A1=E5=9E=8B=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=20should=5Fingest=3Dfalse=20=E6=97=B6=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=B7=B3=E8=BF=87=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=90=BD?= =?UTF-8?q?=E8=8D=89=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日志显示寒暄/无效会话并非 LLM 调用失败,而是模型正确返回 should_ingest=false。 上一版把这种情况也当失败回退、堆成草稿,反而把无价值对话存进了库。 - llm_extractor: 模型显式 should_ingest=false 且无 notes → 抛 LLMRejectedIngest - capture_thread: 捕获后抛 EmptyThreadError → 整条跳过、不入库(寒暄/总结此页这类不再进库) - 仅当真正失败(API 报错 / 非 JSON)才回退规则抽取并落草稿 - 兼容包装 extract_thread_llm 吞掉该异常返回 None - 更新测试(should_ingest=false 跳过)+ CLAUDE.md Co-Authored-By: Claude Opus 4.8 (1M context) --- offline-backend-20260512/backend/CLAUDE.md | 3 +- .../deerflow/knowledge/llm_extractor.py | 57 ++++++++++++++----- .../harness/deerflow/knowledge/service.py | 39 +++++++------ 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index c1b8af5b..803383b3 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -286,7 +286,8 @@ Self-contained product knowledge base. **All knowledge capability lives in this **Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on; `project_name` defaults to `zncm`): - **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed. -- **Real related notes only (no structural meta-links)** — `extractor.related_links()` now returns `[]`: notes no longer auto-link `index` / `_meta/taxonomy` / a project hub (they were noise in every `## Related`). On capture, `service._find_related_notes` runs keyword/hybrid search over the draft's title + summary + *distinctive* tags (project/`knowledge-base` defaults excluded) and `_inject_related_notes` writes real `[[Title]]` cross-links into `## Related` (plus note→note `related` graph edges); the renderers omit the `## Related` section entirely when there are no related notes and no sources. The vault's own `index.md`/`_meta/taxonomy.md` still exist (obsidian scaffold) and remain resolvable for manually-written links. The router enriches `GET /notes/{id}` with `created_by_name`/`updated_by_name` (auth-provider email lookup) so the UI shows a person, not a UUID. +- **Real related notes only (no structural meta-links)** — `extractor.related_links()` now returns `[]`: notes no longer auto-link `index` / `_meta/taxonomy` / a project hub (they were noise in every `## Related`). On capture, `service._find_related_notes` cross-links **only by shared *distinctive* tags** (project/`knowledge-base` defaults excluded; free-text title/summary matching was removed because CJK tokenization linked unrelated notes via generic words like 助手/知识图谱) and `_inject_related_notes` writes real `[[Title]]` links into `## Related` (plus note→note `related` graph edges); no distinctive tags → no related notes. The renderers omit the `## Related` section entirely when there are no related notes and no sources. The vault's own `index.md`/`_meta/taxonomy.md` still exist (obsidian scaffold) and remain resolvable for manually-written links. The router enriches `GET /notes/{id}` with `created_by_name`/`updated_by_name` (auth-provider email lookup) so the UI shows a person, not a UUID. The frontend `MarkdownRenderer` renders wiki-capture provenance markers `^[inferred]`/`^[ambiguous]` as small "推测"/"存疑" badges. +- **LLM-extraction outcomes** — capture distinguishes three LLM outcomes: (1) **success** → declarative note(s); (2) **deliberate rejection** — the model returns `should_ingest:false` with no notes (greeting / meta chatter) → `extract_thread_llm_multi` raises `LLMRejectedIngest` and `capture_thread` raises `EmptyThreadError` (skip — nothing persisted, so worthless chats never enter the KB); (3) **genuine failure** — API error / non-JSON → returns `[]`, capture falls back to the rule-based verbatim extractor and lands the note as a **`draft`** (not `approved`), logs a WARNING, and returns `llm_fallback:true` (surfaced by the router). `llm_extractor` logs an actionable WARNING/INFO for each non-success path so extraction issues are diagnosable. - **Multi-note split** — `llm_extractor.extract_thread_llm_multi` lets one conversation distill into 1–4 cross-linked notes (`notes:[…]` JSON array; legacy single-object output still accepted). `capture_thread` persists each, sibling-cross-links them by title, and returns `{note, created, notes:[…]}`. Gated by `multi_note_enabled` (false → first note only). Tests: `tests/test_knowledge_wikilinks.py`. **Phases 2–4 (all config-gated, defaults safe):** 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 bf596b1e..fb7bbb63 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 @@ -27,6 +27,23 @@ logger = logging.getLogger(__name__) _VALID_CATEGORIES = {"synthesis", "concepts", "references", "decision", "journal"} + +class LLMRejectedIngest(Exception): + """The LLM judged the conversation as having no knowledge value. + + Raised (instead of returning ``[]``) when the model explicitly sets + ``should_ingest: false`` with no notes — so callers can *skip* ingestion + entirely rather than falling back to the rule-based verbatim dump. + """ + + +def _is_falsey(value: Any) -> bool: + if value is False: + return True + if isinstance(value, str): + return value.strip().lower() in ("false", "0", "no") + return False + _SYSTEM_PROMPT = """\ 你是知识库整理专家,遵循 obsidian-wiki 的 wiki-capture 规范,把一次对话提炼成\ **陈述式知识(declarative knowledge)**,而不是聊天流水账。 @@ -257,11 +274,18 @@ async def extract_thread_llm_multi( note_dicts = _notes_from_data(data) if not note_dicts: + reason = str(data.get("reason") or "")[:200] + if _is_falsey(data.get("should_ingest")): + # The model deliberately judged this conversation worthless (greeting, + # meta chatter, no findings). Skip ingestion entirely — do NOT fall + # back to the rule-based verbatim dump. + logger.info("Knowledge LLM judged thread %s not worth ingesting: %s", thread_id, reason) + raise LLMRejectedIngest(reason or "no knowledge value") 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], + reason, ) return [] if not allow_multi: @@ -310,18 +334,21 @@ async def extract_thread_llm( 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, - max_messages=max_messages, - model_name=model_name, - max_input_chars=max_input_chars, - allow_multi=False, - ) + try: + 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, + max_messages=max_messages, + model_name=model_name, + max_input_chars=max_input_chars, + allow_multi=False, + ) + except LLMRejectedIngest: + return None return drafts[0] if drafts else None 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 5440191e..ec2453fb 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py @@ -247,25 +247,30 @@ class KnowledgeService: 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, - thread_title=thread_title, - mode=mode, - title=title, - tags=tags, - project_name=self.project_name, - sources=sources, - 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, - ) + try: + drafts = await llm_extractor.extract_thread_llm_multi( + messages, + thread_id=thread_id, + thread_title=thread_title, + mode=mode, + title=title, + tags=tags, + project_name=self.project_name, + sources=sources, + 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, + ) + except llm_extractor.LLMRejectedIngest as exc: + # The model judged this conversation worthless (greeting / meta + # chatter). Skip ingestion entirely — do NOT dump the raw chat. + raise EmptyThreadError(f"Thread {thread_id} judged not worth ingesting: {exc}") from exc 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. + # (API error / non-JSON). 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(