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`):
|
**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.
|
- **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`.
|
- **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):**
|
**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"}
|
_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 = """\
|
_SYSTEM_PROMPT = """\
|
||||||
你是知识库整理专家,遵循 obsidian-wiki 的 wiki-capture 规范,把一次对话提炼成\
|
你是知识库整理专家,遵循 obsidian-wiki 的 wiki-capture 规范,把一次对话提炼成\
|
||||||
**陈述式知识(declarative knowledge)**,而不是聊天流水账。
|
**陈述式知识(declarative knowledge)**,而不是聊天流水账。
|
||||||
@@ -257,11 +274,18 @@ async def extract_thread_llm_multi(
|
|||||||
|
|
||||||
note_dicts = _notes_from_data(data)
|
note_dicts = _notes_from_data(data)
|
||||||
if not note_dicts:
|
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(
|
logger.warning(
|
||||||
"Knowledge LLM extraction for thread %s produced no notes (should_ingest=%s, reason=%r); using rule-based fallback",
|
"Knowledge LLM extraction for thread %s produced no notes (should_ingest=%s, reason=%r); using rule-based fallback",
|
||||||
thread_id,
|
thread_id,
|
||||||
data.get("should_ingest"),
|
data.get("should_ingest"),
|
||||||
str(data.get("reason") or "")[:200],
|
reason,
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
if not allow_multi:
|
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
|
Thin wrapper over :func:`extract_thread_llm_multi` that returns the first
|
||||||
note, or ``None`` when nothing was produced.
|
note, or ``None`` when nothing was produced.
|
||||||
"""
|
"""
|
||||||
drafts = await extract_thread_llm_multi(
|
try:
|
||||||
messages,
|
drafts = await extract_thread_llm_multi(
|
||||||
thread_id=thread_id,
|
messages,
|
||||||
thread_title=thread_title,
|
thread_id=thread_id,
|
||||||
mode=mode,
|
thread_title=thread_title,
|
||||||
title=title,
|
mode=mode,
|
||||||
tags=tags,
|
title=title,
|
||||||
project_name=project_name,
|
tags=tags,
|
||||||
sources=sources,
|
project_name=project_name,
|
||||||
max_messages=max_messages,
|
sources=sources,
|
||||||
model_name=model_name,
|
max_messages=max_messages,
|
||||||
max_input_chars=max_input_chars,
|
model_name=model_name,
|
||||||
allow_multi=False,
|
max_input_chars=max_input_chars,
|
||||||
)
|
allow_multi=False,
|
||||||
|
)
|
||||||
|
except LLMRejectedIngest:
|
||||||
|
return None
|
||||||
return drafts[0] if drafts else None
|
return drafts[0] if drafts else None
|
||||||
@@ -247,25 +247,30 @@ class KnowledgeService:
|
|||||||
llm_attempted = self.config.llm_extract_enabled
|
llm_attempted = self.config.llm_extract_enabled
|
||||||
llm_fallback = False
|
llm_fallback = False
|
||||||
if llm_attempted:
|
if llm_attempted:
|
||||||
drafts = await llm_extractor.extract_thread_llm_multi(
|
try:
|
||||||
messages,
|
drafts = await llm_extractor.extract_thread_llm_multi(
|
||||||
thread_id=thread_id,
|
messages,
|
||||||
thread_title=thread_title,
|
thread_id=thread_id,
|
||||||
mode=mode,
|
thread_title=thread_title,
|
||||||
title=title,
|
mode=mode,
|
||||||
tags=tags,
|
title=title,
|
||||||
project_name=self.project_name,
|
tags=tags,
|
||||||
sources=sources,
|
project_name=self.project_name,
|
||||||
max_messages=self.config.max_thread_messages,
|
sources=sources,
|
||||||
model_name=self.config.extract_model_name,
|
max_messages=self.config.max_thread_messages,
|
||||||
max_input_chars=self.config.extract_max_input_chars,
|
model_name=self.config.extract_model_name,
|
||||||
allow_multi=self.config.multi_note_enabled,
|
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:
|
if not drafts:
|
||||||
# The LLM path was supposed to run but produced nothing usable
|
# The LLM path was supposed to run but produced nothing usable
|
||||||
# (API error / non-JSON / empty). We will fall back to the
|
# (API error / non-JSON). We will fall back to the rule-based
|
||||||
# rule-based extractor, which yields a low-quality verbatim dump
|
# extractor, which yields a low-quality verbatim dump with no
|
||||||
# with no entities/tags — flag it so it lands as a draft.
|
# entities/tags — flag it so it lands as a draft.
|
||||||
llm_fallback = True
|
llm_fallback = True
|
||||||
if not drafts:
|
if not drafts:
|
||||||
single = extractor.extract_thread(
|
single = extractor.extract_thread(
|
||||||
|
|||||||
Reference in new issue
Block a user