From 0b704fb07ee8214a1f89e3b5db6d4fb96e201aed Mon Sep 17 00:00:00 2001 From: lhl Date: Wed, 12 Aug 2026 23:24:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20QA=20=E6=8A=A4=E6=A0=8F=20+=20fallback?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1=EF=BC=88T15=20=E6=9E=B6=E6=9E=84=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=95=B4=E6=94=B9=E6=94=B6=E5=AE=98=EF=BC=8COV6?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/genesis/qa/guardrails.py: resolve_qa_model() 强制 QA 走 fallback 模型(防 DeepSeek 校验 DeepSeek 自校验盲区);QALoopController(max_rounds=3) 约束 「QA→Writer修正→重校验」循环边界防无限 - 集成测试验证 QA 调用实际落到 fallback 而非 primary(FakeLLMClient 记录 model) - design.md §7.4 补 QA 护栏说明(循环边界 + 独立校验模型) - TDD:257 passed / 100.00% 覆盖,17 项架构审查整改全部完成 --- _AI_USAGE_LOG.md | 1 + docs/design.md | 10 +++ src/genesis/qa/__init__.py | 1 + src/genesis/qa/guardrails.py | 56 +++++++++++++++++ tests/test_qa_guardrails.py | 114 +++++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/genesis/qa/__init__.py create mode 100644 src/genesis/qa/guardrails.py create mode 100644 tests/test_qa_guardrails.py diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 3cb6238..cd635cc 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -84,3 +84,4 @@ | 2026-08-11 | Agent 实现 | T7(架构审查整改,P2):提取 _validate_path helper(I8 DRY)。source_aggregator.py 三处重复校验(扩展名白名单 + 存在性)提取为模块级 _validate_path(path, allowed_exts) → Path(保留 ValueError/FileNotFoundError 语义与错误信息),parse 三分支改用 helper;外部契约(未知扩展名→ValueError、不存在→FileNotFoundError)不变;新增 3 用例直接测 helper(坏扩展名/缺失/返回 Path);TDD 验证 RED(helper 不存在)→ GREEN(聚焦 13 passed)→ 全量 248 passed 覆盖 100.00%(1356 stmts/332 br),fail_under=99 达标 | src/genesis/parsers/source_aggregator.py, tests/test_source_aggregator.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-11 | Agent 实现 | T10(架构审查整改,P3):Writer 串行约束写回文档(I14)。纯文档任务:design.md §6.8 后新增 §6.8.1 串行生成约束(理由:章间引用依赖前章 WriterState、并行收益低复杂度高、Token 友好;落地点:编排层 POST /generate 严格按模板顺序串行、UI 展示预估总时长与逐章进度、禁止并发多章);api-design §4.3 补串行消费说明(对应 design §6.8.1);web-ui-design 进度 UI 补串行语义(预计=章数×单章 3-5 分)与禁止并发说明。无代码/测试变更,全量 248 passed 覆盖 100.00% 不回归 | docs/design.md, docs/api-design.md, docs/web-ui-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-11 | Agent 实现 | T14+T16(架构审查整改,Lane A):DataGate 机制化 + 任务级持久化(OV5 + OV7)。新建 src/genesis/orchestrator/ 包:datagate.py(DataGate.load(source, selector):子集加载 + 规模保护 max_total_rows=500 无 selector 拒绝全量 + token 预算 max_total_tokens=8000 复用 CJK 保守估算 + 未知表容错)+ task_queue.py(TaskQueue ABC + PersistentTaskQueue SQLite 落盘:enqueue/poll/update_status/get/cancel/recover/close + 幂等去重 §5.3 + recover 将 running→failed、pending 保留);新建 tests/test_datagate.py(8 用例:子集/规模保护/1000 行 Excel selector 放行/token 预算/未知表/空 selector)+ tests/test_task_queue.py(11 用例:CRUD/幂等缓存/重开不丢/recover 语义/防御分支);同步 agent-runtime-design.md(§4.2 原则→机制 + §3.5/3.6 任务级恢复已实现)、api-design.md §5.2/5.3、design.md §8.4.1;TDD 验证 RED(模块缺失/错误消息不匹配)→ GREEN(聚焦 8+8 passed)→ 全量 218 passed 覆盖 100.00%(1140 stmts/278 br),fail_under=99 达标 | src/genesis/orchestrator/__init__.py, src/genesis/orchestrator/datagate.py, src/genesis/orchestrator/task_queue.py, tests/test_datagate.py, tests/test_task_queue.py, docs/agent-runtime-design.md, docs/api-design.md, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-11 | Agent 实现 | T15(架构审查整改收官,P2,OV6):QA 护栏 + fallback 路由。新建 src/genesis/qa/ 包与 guardrails.py:resolve_qa_model(models) 强制返回 fallback 模型名(QA 不走 primary,防 DeepSeek 校验 DeepSeek 自校验盲区;无 fallback/name 空时返回 None 迫使显式指定独立模型);QALoopController(max_rounds=DEFAULT_MAX_QA_ROUNDS=3) 约束「QA→Writer修正→重校验」循环边界防无限;集成测试用 FakeLLMClient 验证 QA 调用实际落到 fallback 而非 primary。TDD:RED(ModuleNotFoundError)→ GREEN(9 passed,guardrails 100%)→ 全量 257 passed 覆盖 100.00%(1380 stmts/338 br),fail_under=99 达标;design.md §7.4 补 QA 护栏说明(循环边界 + 独立校验模型) | src/genesis/qa/__init__.py, src/genesis/qa/guardrails.py, tests/test_qa_guardrails.py, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | diff --git a/docs/design.md b/docs/design.md index 8cbf75f..6e10272 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1023,6 +1023,16 @@ QA 输出: → Writer 只修正错误章节(不重新生成全部) → 重新 QA 校验 +> **QA 护栏(T15 机制化,对应审查 OV6)**: +> 1. **循环边界**:「QA 校验 → Writer 修正 → 重新校验」循环受 `QALoopController` +> (`DEFAULT_MAX_QA_ROUNDS=3`)约束,超过上限必须停止并上报人工/降级处理, +> 禁止无限重试(`src/genesis/qa/guardrails.py`)。 +> 2. **独立校验模型(防自校验盲区)**:QA 校验强制走 `resolve_qa_model()` 返回的 +> **fallback 模型**(如 qwen-max),不得与生成主模型(deepseek-chat)同源; +> 否则「DeepSeek 生成 + DeepSeek 校验」会形成同族模型盲点,难以发现自身偏误。 +> 无 fallback 配置时返回 None,迫使调用方显式指定独立校验模型,而非静默回退主模型。 +> 实现调用示例:`engine.chat(session_id=..., prompt=..., model=resolve_qa_model(models))`。 + ### 7.5 黄金集与评分器(T13 机制化,OV4) > OV4 裁定:成功标准须有量度 → 建立黄金集 + 评分器(已实现于 `src/genesis/eval/`)。 diff --git a/src/genesis/qa/__init__.py b/src/genesis/qa/__init__.py new file mode 100644 index 0000000..46e6935 --- /dev/null +++ b/src/genesis/qa/__init__.py @@ -0,0 +1 @@ +"""QA Agent 模块:质量校验与护栏(T15,OV6)。""" diff --git a/src/genesis/qa/guardrails.py b/src/genesis/qa/guardrails.py new file mode 100644 index 0000000..14533a3 --- /dev/null +++ b/src/genesis/qa/guardrails.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# QA 校验反馈循环的最大轮次上限(OV6:防止「QA→Writer修正→重新QA」无限循环) +DEFAULT_MAX_QA_ROUNDS = 3 + + +def resolve_qa_model(models: Any | None) -> str | None: + """解析 QA 校验应使用的模型名。 + + OV6 修复:QA 校验必须走 fallback 模型(独立模型族),避免「主模型自校验盲区」 + (如 DeepSeek 生成、DeepSeek 校验 → 同族模型盲点,难以发现自身偏误)。 + + 返回 fallback 模型名;若无 fallback 配置则返回 None,迫使调用方显式指定 + 一个独立于生成模型的校验模型(而非静默回退到 primary)。 + """ + if models is None: + return None + fallback = getattr(models, "fallback", None) + if fallback is not None: + name = getattr(fallback, "name", None) + if name: + return name + return None + + +@dataclass +class QALoopController: + """QA 反馈循环护栏:约束「QA校验→Writer修正→重新校验」的最大轮次。 + + 防止 OV6 指出的无边界循环风险:超过 max_rounds 后必须停止循环, + 转为上报人工或降级处理,不得无限重试。 + """ + + max_rounds: int = DEFAULT_MAX_QA_ROUNDS + _round: int = field(default=0, init=False) + + @property + def round(self) -> int: + """当前已执行的轮次(0 表示尚未开始)。""" + return self._round + + def can_continue(self) -> bool: + """是否还能进入下一轮 QA 校验。""" + return self._round < self.max_rounds + + def advance(self) -> int: + """推进一轮,返回推进后的当前轮次(从 1 开始计数)。""" + self._round += 1 + return self._round + + def is_exhausted(self) -> bool: + """已达上限:必须停止循环并上报,禁止继续重试。""" + return self._round >= self.max_rounds diff --git a/tests/test_qa_guardrails.py b/tests/test_qa_guardrails.py new file mode 100644 index 0000000..91a67ca --- /dev/null +++ b/tests/test_qa_guardrails.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import types as t + +import pytest + +from genesis.inference.engine import InferenceEngine +from genesis.inference.token import approximate_token_count +from genesis.qa.guardrails import ( + DEFAULT_MAX_QA_ROUNDS, + QALoopController, + resolve_qa_model, +) +from tests.inference_helpers import FakeLLMClient + + +class QAModels: + """模拟 config.InferenceModels:primary=deepseek / fallback=qwen。""" + + def __init__(self): + self.primary = t.SimpleNamespace(name="deepseek-chat", provider="deepseek") + self.fallback = t.SimpleNamespace(name="qwen-max", provider="qwen") + + +class QAModelsNoFallback: + """无 fallback 配置(覆盖 None 分支)。""" + + def __init__(self): + self.primary = t.SimpleNamespace(name="deepseek-chat") + self.fallback = None + + +class QAModelsFallbackEmptyName: + """fallback 存在但 name 为空字符串(覆盖 name 假值分支)。""" + + def __init__(self): + self.primary = t.SimpleNamespace(name="deepseek-chat") + self.fallback = t.SimpleNamespace(name="") + + +# ---------- resolve_qa_model(OV6:强制 fallback,防自校验盲区) ---------- + +def test_resolve_qa_model_returns_fallback_name(): + assert resolve_qa_model(QAModels()) == "qwen-max" + + +def test_resolve_qa_model_never_returns_primary(): + """关键护栏:QA 不得返回主模型名(否则 DeepSeek 校验 DeepSeek 盲区)。""" + models = QAModels() + assert resolve_qa_model(models) != getattr(models.primary, "name", None) + + +def test_resolve_qa_model_no_fallback_returns_none(): + """无 fallback 时返回 None,迫使调用方显式指定独立校验模型。""" + assert resolve_qa_model(QAModelsNoFallback()) is None + + +def test_resolve_qa_model_fallback_empty_name_returns_none(): + """fallback 存在但 name 为空时返回 None(不静默回退到 primary)。""" + assert resolve_qa_model(QAModelsFallbackEmptyName()) is None + + +def test_resolve_qa_model_none_models_returns_none(): + assert resolve_qa_model(None) is None + + +# ---------- QALoopController(OV6:循环边界防无限) ---------- + +def test_loop_default_max_rounds(): + ctrl = QALoopController() + assert ctrl.max_rounds == DEFAULT_MAX_QA_ROUNDS + + +def test_loop_stops_at_max_rounds(): + ctrl = QALoopController(max_rounds=3) + assert ctrl.can_continue() + ctrl.advance() + ctrl.advance() + ctrl.advance() + assert ctrl.round == 3 + assert ctrl.is_exhausted() + assert not ctrl.can_continue() + + +def test_loop_exhausted_cannot_continue(): + ctrl = QALoopController(max_rounds=1) + ctrl.advance() + assert ctrl.is_exhausted() + assert not ctrl.can_continue() + + +# ---------- 集成:QA 调用强制走 fallback 模型 ---------- + +@pytest.mark.anyio +async def test_qa_call_uses_fallback_not_primary(): + client = FakeLLMClient([("ok", "QA 通过")]) + models = QAModels() + eng = InferenceEngine( + client=client, + models=models, + estimator=approximate_token_count, + ) + qa_model = resolve_qa_model(models) + assert qa_model == "qwen-max" + await eng.chat( + session_id="qa1", + prompt="请校验以下章节", + variables={}, + model=qa_model, + ) + called_models = [c["model"] for c in client.calls] + # 实际 LLM 调用必须用 fallback,绝不可落到 primary + assert called_models == ["qwen-max"] + assert "deepseek-chat" not in called_models