- 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 项架构审查整改全部完成
115 lines
3.4 KiB
Python
115 lines
3.4 KiB
Python
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
|