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:
@@ -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