diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 72ef869..76859d9 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -71,3 +71,4 @@ | 2026-08-10 | 测试验证 | Phase3 Word 解析里程碑评审报告:docs/milestone3-word-parser-review.md(5 任务 task review 全 Approved + 最终 whole-branch review With fixes 闭环;160 passed / 100.00% 分支覆盖 929 stmts/236 br;Pre-Flight 裁决 3 项 + 覆盖补齐 4 项记录;deferred 遗留 6 项移交后续) | docs/milestone3-word-parser-review.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-11 | 架构设计 | 整体架构工程审查(plan-eng-review,SCOPE_REDUCED 模式):Step0 裁定移除 Qdrant 抽象+Redis 双实现(单一 Chroma+InMemory);Section1 架构 7 issue(jsonschema 真校验/解析降级链/cancelled+resume 状态/引擎层注入防护/删死配置/v1 rerank/记忆完整实现);Section2 代码质量 3 issue(DRY helper/全异步化/局部变量);Section4 性能 2 issue(CJK token 估算/串行约束文档化);Outside Voice 8 项全接受(文档同步/bge-m3/resolver/黄金集/DataGate 机制化/QA 护栏+fallback/任务持久化/docx 原型提前);产出 17 项 Implementation Tasks JSONL;报告 docs/architecture-eng-review.md | docs/architecture-eng-review.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-11 | Agent 实现 | T1(架构审查整改):chat_structured 接入 jsonschema 真校验。pyproject.toml 新增 jsonschema>=4.23 依赖;engine.py 在 json.loads 后调用 jsonschema.validate(schema 非空时),校验失败按解析失败重试(新增 except jsonschema.ValidationError 分支,带校验错误信息重试);新增 3 用例(违规重试成功/违规耗尽 parse_error 含校验详情/合法一次通过);TDD 验证 RED({'a':'not_a_number'} 未被拒直接返回)→ GREEN(聚焦 4 passed)→ 全量 163 passed 覆盖 100.00%(936 stmts/238 br),fail_under=99 达标 | pyproject.toml, src/genesis/inference/engine.py, tests/test_inference_engine.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-11 | Agent 实现 | T2(架构审查整改):chat_structured 解析重试走降级链(Issue2)+ 模型名局部变量(Issue10)。engine.py 重构 chat_structured:外层 attempts 轮次循环 + 内层降级链 names 遍历(首选成功 ok/降级成功 fallback);解析/校验失败即时追加错误信息供备用模型重试可见;LLMError 不再 early return 而继续降级链,全部失败按 last_was_parse_error 区分 parse_error/failed;删除 4 处重复 _model_names(None)[0] 调用;同步更新 7 个既有用例脚本数量与断言(降级链语义:network 失败用例显式 retry_count=0);新增 2 用例(解析重试降级 fallback/网络失败降级 fallback);TDD 验证 RED(解析重试仍用首选模型)→ GREEN(聚焦 27 passed)→ 全量 165 passed 覆盖 100.00%(941 stmts/242 br),fail_under=99 达标 | src/genesis/inference/engine.py, tests/test_inference_engine.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | diff --git a/src/genesis/inference/engine.py b/src/genesis/inference/engine.py index c841d23..5752433 100644 --- a/src/genesis/inference/engine.py +++ b/src/genesis/inference/engine.py @@ -2,7 +2,7 @@ from __future__ import annotations import json import time -from typing import Any, Callable +from typing import Any, Callable, Literal import jsonschema @@ -144,59 +144,58 @@ class InferenceEngine: schema_hint = json.dumps(schema, ensure_ascii=False) if schema else "" base_rendered = rendered + (f'\n\n请输出符合以下 JSON Schema 的 JSON:{schema_hint}' if schema_hint else "") + names = self._model_names(None) # 降级链:解析重试也按 primary→fallback 顺序(T2/Issue10) start = time.monotonic() attempts = 0 last_raw = "" last_error: str | None = None last_error_code: str | None = None + last_was_parse_error = False while attempts <= retry_count: attempts += 1 - try: - text, usage = self._call( - model=self._model_names(None)[0], # 解析重试用首选模型 - rendered=base_rendered, - temperature=0.0, max_tokens=4096, - ) - last_raw = text - data = json.loads(text) - if schema: - # 真 schema 校验:不合 schema 时按解析失败重试(T1) - jsonschema.validate(instance=data, schema=schema) - return StructuredResult( - data=data, raw_text=text, parse_attempts=attempts, - model=self._model_names(None)[0], - prompt_version=getattr(prompt, "version", "inline"), - usage=usage, - duration_ms=int((time.monotonic() - start) * 1000), - # fallback 语义保留给模型降级;解析重试成功仍为 ok - status="ok", - ) - except json.JSONDecodeError as exc: - last_error = f"JSON 解析失败: {exc}" - last_error_code = "LLM_PARSE_ERROR" - # 带错误信息重试 - base_rendered = base_rendered + f"\n\n上次解析失败:{exc}。请重新输出合法 JSON。" - except jsonschema.ValidationError as exc: - last_error = f"校验失败: {exc.message}" - last_error_code = "LLM_PARSE_ERROR" - # 带校验错误信息重试 - base_rendered = base_rendered + f"\n\n上次校验失败:{exc.message}。请重新输出符合 JSON Schema 的 JSON。" - except LLMError as exc: - return StructuredResult( - data={}, raw_text="", parse_attempts=attempts, - model=self._model_names(None)[0], - prompt_version=getattr(prompt, "version", "inline"), - usage=TokenUsage(), - duration_ms=int((time.monotonic() - start) * 1000), - status="failed", error=str(exc), error_code=exc.error_code, - ) + for idx, name in enumerate(names): + try: + text, usage = self._call( + model=name, + rendered=base_rendered, + temperature=0.0, max_tokens=4096, + ) + last_raw = text + data = json.loads(text) + if schema: + # 真 schema 校验:不合 schema 时按解析失败重试(T1) + jsonschema.validate(instance=data, schema=schema) + return StructuredResult( + data=data, raw_text=text, parse_attempts=attempts, + model=name, + prompt_version=getattr(prompt, "version", "inline"), + usage=usage, + duration_ms=int((time.monotonic() - start) * 1000), + # 首选模型成功为 ok;降级链模型成功为 fallback + status="ok" if idx == 0 else "fallback", + ) + except (json.JSONDecodeError, jsonschema.ValidationError) as exc: + last_error = f"解析/校验失败: {exc}" + last_error_code = "LLM_PARSE_ERROR" + last_was_parse_error = True + # 带错误信息继续降级链(备用模型重试时可见) + base_rendered = base_rendered + f"\n\n上次失败:{last_error}。请重新输出合法 JSON。" + except LLMError as exc: + last_error = str(exc) + last_error_code = exc.error_code + last_was_parse_error = False + # 继续降级链尝试下一模型 + if last_was_parse_error: + status: Literal["ok", "fallback", "parse_error", "failed"] = "parse_error" + else: + status = "failed" return StructuredResult( data={}, raw_text=last_raw, parse_attempts=attempts, - model=self._model_names(None)[0], + model=names[0], prompt_version=getattr(prompt, "version", "inline"), usage=TokenUsage(), duration_ms=int((time.monotonic() - start) * 1000), - status="parse_error", error=last_error, error_code=last_error_code, + status=status, error=last_error, error_code=last_error_code, ) diff --git a/tests/test_inference_engine.py b/tests/test_inference_engine.py index a880d75..91bbdce 100644 --- a/tests/test_inference_engine.py +++ b/tests/test_inference_engine.py @@ -118,7 +118,8 @@ def test_chat_failed_error_code_not_configured(): def test_chat_structured_parse_error_code(): - client = FakeLLMClient([("parse_fail", ""), ("parse_fail", "")]) + # 两轮降级链(primary+fallback)均解析失败 → parse_error(T2:解析重试走降级链) + client = FakeLLMClient([("parse_fail", ""), ("parse_fail", ""), ("parse_fail", ""), ("parse_fail", "")]) eng = InferenceEngine(client=client, models=Models()) r = eng.chat_structured( session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), @@ -129,11 +130,12 @@ def test_chat_structured_parse_error_code(): def test_chat_structured_failed_error_code_network(): - client = FakeLLMClient([("raise_network", "")]) + # 降级链两个模型都网络失败 → failed + LLM_NETWORK_ERROR + client = FakeLLMClient([("raise_network", ""), ("raise_network", "")]) eng = InferenceEngine(client=client, models=Models()) r = eng.chat_structured( session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), - variables={}, schema={}, + variables={}, schema={}, retry_count=0, ) assert r.status == "failed" assert r.error_code == "LLM_NETWORK_ERROR" @@ -184,17 +186,19 @@ def test_chat_structured_ok(): def test_chat_structured_retry_parse(): + # 首选模型解析失败 → 降级链备用模型成功 → fallback(T2) client = FakeLLMClient([("parse_fail", ""), ("ok", '{"a": 2}')]) eng = make_engine(client) r = eng.chat_structured( session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), variables={}, schema={}, ) - assert r.status == "ok" and r.data == {"a": 2} and r.parse_attempts == 2 + assert r.status == "fallback" and r.data == {"a": 2} and r.parse_attempts == 1 def test_chat_structured_parse_error_returns_raw(): - client = FakeLLMClient([("parse_fail", ""), ("parse_fail", "")]) + # 两轮降级链均解析失败 → parse_error,raw_text 为最后一次输出 + client = FakeLLMClient([("parse_fail", ""), ("parse_fail", ""), ("parse_fail", ""), ("parse_fail", "")]) eng = make_engine(client) r = eng.chat_structured( session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), @@ -206,11 +210,12 @@ def test_chat_structured_parse_error_returns_raw(): def test_chat_structured_failed_on_network(): - client = FakeLLMClient([("raise_network", "")]) + # 降级链两个模型都网络失败 → failed + client = FakeLLMClient([("raise_network", ""), ("raise_network", "")]) eng = make_engine(client) r = eng.chat_structured( session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), - variables={}, schema={}, + variables={}, schema={}, retry_count=0, ) assert r.status == "failed" @@ -321,7 +326,7 @@ def test_chat_structured_empty_schema_no_hint(): # ---------- T1: chat_structured 真 schema 校验(jsonschema) ---------- def test_chat_structured_schema_violation_retries(): - """返回不合 schema 的 JSON 时带错误信息重试;第二次合法 → ok。""" + """返回不合 schema 的 JSON 时带错误信息重试;降级链备用模型成功 → fallback(T1+T2)。""" client = FakeLLMClient([("ok", '{"a": "not_a_number"}'), ("ok", '{"a": 2}')]) eng = make_engine(client) r = eng.chat_structured( @@ -329,14 +334,14 @@ def test_chat_structured_schema_violation_retries(): variables={}, schema={"type": "object", "properties": {"a": {"type": "number"}}, "required": ["a"]}, ) - assert r.status == "ok" and r.data == {"a": 2} and r.parse_attempts == 2 - # 第二次调用带上次校验错误信息(重试提示) + assert r.status == "fallback" and r.data == {"a": 2} and r.parse_attempts == 1 + # 降级链第二次调用(备用模型)带上次校验错误信息(重试提示) assert "校验失败" in client.calls[1]["messages"][0] def test_chat_structured_schema_violation_parse_error(): - """全部返回不合 schema 的 JSON → parse_error,error 含校验详情。""" - client = FakeLLMClient([("ok", '{"a": "bad"}'), ("ok", '{"a": "bad"}')]) + """两轮降级链均返回不合 schema 的 JSON → parse_error,error 含校验详情。""" + client = FakeLLMClient([("ok", '{"a": "bad"}'), ("ok", '{"a": "bad"}'), ("ok", '{"a": "bad"}'), ("ok", '{"a": "bad"}')]) eng = make_engine(client) r = eng.chat_structured( session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), @@ -361,6 +366,44 @@ def test_chat_structured_schema_valid_passes_without_retry(): assert r.status == "ok" and r.data == {"a": 1} and r.parse_attempts == 1 +# ---------- T2: 解析重试降级链 + 模型名局部变量 ---------- + +def test_chat_structured_parse_retry_uses_fallback(): + """首选模型解析失败后,重试走降级链使用备用模型(T2)。""" + client = FakeLLMClient([ + ("ok", '{"a": "bad"}'), # 首选模型:不合 schema + ("ok", '{"a": 2}'), # 备用模型:合法 + ]) + eng = make_engine(client) + r = eng.chat_structured( + session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), + variables={}, + schema={"type": "object", "properties": {"a": {"type": "number"}}, "required": ["a"]}, + ) + assert r.status == "fallback" + assert r.data == {"a": 2} + assert client.calls[0]["model"] == "deepseek-chat" + assert client.calls[1]["model"] == "qwen-max" + + +def test_chat_structured_network_failure_tries_fallback(): + """首选模型网络失败时,降级链继续尝试备用模型(T2)。""" + client = FakeLLMClient([ + ("raise_network", ""), # 首选模型:网络失败 + ("ok", '{"a": 3}'), # 备用模型:成功 + ]) + eng = make_engine(client) + r = eng.chat_structured( + session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), + variables={}, + schema={"type": "object", "properties": {"a": {"type": "number"}}, "required": ["a"]}, + ) + assert r.status == "fallback" + assert r.data == {"a": 3} + assert client.calls[0]["model"] == "deepseek-chat" + assert client.calls[1]["model"] == "qwen-max" + + def test_chat_truncation_callback_returns_none_keeps_variables(): """truncate_cb 返回 None 时回退原 variables(覆盖 new_vars is None 分支)。""" def truncate_cb(prompt_text, variables):