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
+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