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:
@@ -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 |
|
||||
|
||||
@@ -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/`)。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""QA Agent 模块:质量校验与护栏(T15,OV6)。"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user