feat: QA 护栏 + fallback 路由(T15 架构审查整改收官,OV6)

- 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 项架构审查整改全部完成
This commit is contained in:
lhl
2026-08-12 23:24:15 +08:00
parent a1336f6dd3
commit 0b704fb07e
5 changed files with 182 additions and 0 deletions
+1
View File
@@ -84,3 +84,4 @@
| 2026-08-11 | Agent 实现 | T7(架构审查整改,P2):提取 _validate_path helperI8 DRY)。source_aggregator.py 三处重复校验(扩展名白名单 + 存在性)提取为模块级 _validate_path(path, allowed_exts) → Path(保留 ValueError/FileNotFoundError 语义与错误信息),parse 三分支改用 helper;外部契约(未知扩展名→ValueError、不存在→FileNotFoundError)不变;新增 3 用例直接测 helper(坏扩展名/缺失/返回 Path);TDD 验证 REDhelper 不存在)→ 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.pyDataGate.load(source, selector):子集加载 + 规模保护 max_total_rows=500 无 selector 拒绝全量 + token 预算 max_total_tokens=8000 复用 CJK 保守估算 + 未知表容错)+ task_queue.pyTaskQueue ABC + PersistentTaskQueue SQLite 落盘:enqueue/poll/update_status/get/cancel/recover/close + 幂等去重 §5.3 + recover 将 running→failed、pending 保留);新建 tests/test_datagate.py8 用例:子集/规模保护/1000 行 Excel selector 放行/token 预算/未知表/空 selector+ tests/test_task_queue.py11 用例:CRUD/幂等缓存/重开不丢/recover 语义/防御分支);同步 agent-runtime-design.md(§4.2 原则→机制 + §3.5/3.6 任务级恢复已实现)、api-design.md §5.2/5.3、design.md §8.4.1TDD 验证 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(架构审查整改收官,P2OV6):QA 护栏 + fallback 路由。新建 src/genesis/qa/ 包与 guardrails.pyresolve_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。TDDREDModuleNotFoundError)→ GREEN9 passedguardrails 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 |
+10
View File
@@ -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/`)。
+1
View File
@@ -0,0 +1 @@
"""QA Agent 模块:质量校验与护栏(T15,OV6)。"""
+56
View File
@@ -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
+114
View File
@@ -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.InferenceModelsprimary=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_modelOV6:强制 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
# ---------- QALoopControllerOV6:循环边界防无限) ----------
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