fix(knowledge): 模型判定 should_ingest=false 时直接跳过,不再落草稿
日志显示寒暄/无效会话并非 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
4aef81284a
commit
76320ca5a3
3 files changed
+66
-33
No files matched your search
@@ -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):**
|
||||
|
||||
+42
-15
@@ -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
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user