Files
董宏凯 1aeee86984 fix(maf): harden stage-4 quality gate (P0/P1)
P0-1 migration: detect the same-named stage-3 execution_stage CHECK by sqltext (must contain rework + manager_review), SQLite rebuild / PG-MySQL drop+add, PG boolean default TRUE, validate_schema checks constraint content + seat_tasks status CHECK/unique/FKs; tests start from a real stage-3 schema.

P0-2: a missing/invalid manager review (timeout/exception/empty/invalid JSON/missing per-seat decision) is never an acceptance - system rework under the cap, contract fallback (reassign/controlled_gap/ask_user) afterwards, source=system events.

P0-3: unaccepted deliveries are isolated from the fact ledger, cross-review and final report (deferred merge, delivery_json envelope, accepted_task_results in checkpoint, accepted-only synthesis, no turn_results fallback).

P0-4: seat-task init is atomic get-or-create over the full expected set; recovery fills missing tasks without overwriting frozen contracts; quality gate blocks missing required tasks.

P1: zero config values honoured; duplicate detection persists SHA-256 conclusion digests in history (survives restarts); findings never satisfy evidence, evidence_refs must resolve to trusted ledger refs (unverified_evidence_ref); conversation SSE projection tests added and manager/finalizer messages are no longer mislabelled as seat.delivery.submitted.
2026-08-07 00:04:55 +08:00

749 lines
33 KiB
Python

"""Stage 4 (R3-Q) runner-level tests: seat task quality loop + phase gate.
Drives the REAL workflow (QualitySeatTasksExecutor → QualityGateExecutor →
CrossReviewExecutor → ApprovalGateExecutor → FinalizerExecutor) through
WorkflowRunner.execute() with a fake adapter, proving the acceptance
criteria end-to-end against the real database:
- a good delivery is accepted and the run completes with quality_decision=accept;
- a short / evidence-free delivery is sent back, reworked up to the frozen cap,
reassigned to a backup seat once, and finally ends as a traceable
controlled_gap that is carried into the final report (never as a fact);
- an ``ask_user`` fallback pauses the run in waiting_input;
- a required task left non-terminal blocks the phase quality gate;
- a missing/invalid manager review NEVER auto-accepts: the server forces
system reworks and exhausts into a controlled gap (source=system events).
"""
from __future__ import annotations
import json
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.deerflow_agent_adapter import SeatResponse
from app.maf_roundtable.workflow_executors import QualityGateExecutor, RoundtableState
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
Base,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
MANAGER = "manager-1"
SEATS = ["seat-a", "seat-b", "seat-c"]
# ``@TOOL_REF@`` is replaced per-turn with ``tool:kb_search:<turn>`` — the
# trusted evidence ref created by the seat's (fake) successful tool call, so a
# good delivery cites ONLY evidence the fact ledger actually backs (P1).
GOOD_DELIVERY = json.dumps({
"summary": "结论:该市场存在明确增长机会,建议优先进入华东区域并配套本地化运营资源投入,同时预留华南试点预算",
"findings": ["依据一:华东区销售数据连续三个月增长", "依据二:竞品在华东投放低于均值"],
"evidence_refs": ["@TOOL_REF@"],
"open_questions": [],
"self_check": "已覆盖全部验收标准",
})
SHORT_DELIVERY = json.dumps({"summary": "好", "findings": []})
DISPATCH_PARALLEL = {
"intent_type": "parallel_consultation",
"selected_seat_agent_ids": ["seat-a", "seat-b"],
"assignments": [
{"agent_id": "seat-a", "task_goal": "市场增长机会分析", "delivery_requirements": ["证据", "优先级"], "order": 1},
{"agent_id": "seat-b", "task_goal": "竞品定价分析", "delivery_requirements": ["证据", "建议"], "order": 2},
],
"execution_mode": "parallel",
"convergence_path": ["manager synthesis"],
"reason": "需要两位专家",
}
@pytest_asyncio.fixture
async def q_svc(tmp_path):
"""Service with a V2 session: manager + 3 candidates, quality gate ON."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'q4.db'}", echo=False)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
service = make_maf_service(store)
now = datetime(2026, 1, 1)
async with sf() as session:
session.add(MafRoundtableSessionRow(
id="q-sess", user_id="u1", title="q", task_text="分析市场趋势",
mode="structured", status="pending", write_policy="auto",
manager_agent_id=MANAGER, max_rounds=8, created_at=now, updated_at=now,
task_payload_json={
"maf_v2": {
"dispatch_contract_enabled": True,
"quality_gate_enabled": True,
"candidate_set": {
"manager_agent_id": MANAGER,
"candidate_seat_agent_ids": list(SEATS),
"mode": "structured",
"max_rounds": 8,
},
},
},
))
session.add(MafRoundtableRunRow(
id="q-run", session_id="q-sess", status="pending",
created_at=now, updated_at=now,
))
for i, agent_id in enumerate([MANAGER, *SEATS]):
session.add(MafRoundtableParticipantRow(
id=f"qp-{i}", session_id="q-sess", agent_id=agent_id,
role="manager" if agent_id == MANAGER else "seat",
sort_order=i, created_at=now, updated_at=now,
))
await session.commit()
yield service
finally:
await engine.dispose()
def _make_q_runner(
q_svc,
monkeypatch,
*,
deliveries: dict[str, list[str]],
review_plan: list[dict[str, dict[str, object]]],
fallback_policy: str = "reassign_then_gap",
cross_review_ok: bool = True,
emit_real: bool = False,
config_overrides: dict | None = None,
):
"""Runner with a fake adapter driven by explicit delivery/review plans.
``deliveries``: agent_id → list of JSON payloads, one per delivery attempt.
``review_plan``: one entry per quality round → {agent_id: {"decision", ...}}.
"""
from app.maf_roundtable.workflow_runner import WorkflowRunner
app = SimpleNamespace(state=SimpleNamespace(maf_roundtable_service=q_svc, checkpointer=None))
runner = WorkflowRunner(app, service=q_svc, user_id="u1")
calls: list[tuple[str, str, int]] = []
stage_messages: dict[str, list[str]] = {}
delivery_idx: dict[str, int] = {}
review_round: list[int] = [0]
cfg_values = {
"seat_timeout_seconds": 5,
"schema_repair_enabled": True,
"quality_gate_enabled": True,
"max_rework_attempts": 2,
"max_reassignments": 1,
"min_delivery_chars": 40,
"min_delivery_evidence": 1,
"default_fallback_policy": fallback_policy,
}
cfg_values.update(config_overrides or {})
fake_cfg = SimpleNamespace(maf_roundtable=SimpleNamespace(**cfg_values))
monkeypatch.setattr("deerflow.config.get_app_config", lambda: fake_cfg)
async def _run_seat(**kwargs):
stage = kwargs.get("execution_stage", "")
agent_id = kwargs["agent_id"]
turn_index = kwargs["turn_index"]
calls.append((stage, agent_id, turn_index))
stage_messages.setdefault(stage, []).append(str(kwargs.get("message") or ""))
if stage == "manager_dispatch":
return SeatResponse(agent_id=agent_id, turn_index=0, content=json.dumps(DISPATCH_PARALLEL), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
if stage in ("expert", "rework"):
seq = deliveries.get(agent_id) or [GOOD_DELIVERY]
idx = delivery_idx.get(agent_id, 0)
delivery_idx[agent_id] = idx + 1
content = seq[min(idx, len(seq) - 1)]
# The seat ran a real (fake) tool this turn: its successful result
# becomes the trusted evidence ref the delivery may cite.
content = content.replace("@TOOL_REF@", f"tool:kb_search:{turn_index}")
return SeatResponse(
agent_id=agent_id,
turn_index=turn_index,
content=content,
tool_calls=[{"id": f"tc-{agent_id}-{turn_index}", "name": "kb_search"}],
usage={"input_tokens": 1, "output_tokens": 1},
)
if stage == "manager_review":
rnd = review_round[0]
review_round[0] = rnd + 1
plan = review_plan[rnd] if rnd < len(review_plan) else {}
decisions = [
{
"agent_id": agent,
"decision": d.get("decision", "accept"),
"reason": str(d.get("reason") or "r")[:1000],
"revision_requirements": list(d.get("requirements") or []),
}
for agent, d in plan.items()
]
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content=json.dumps({"decisions": decisions}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
if stage == "cross_review":
content = (
json.dumps({"summary": "ok", "evidence_ok": True, "confirmed_candidates": []})
if cross_review_ok
else json.dumps({"summary": "有缺口", "evidence_ok": False, "gaps": ["证据不足"], "confirmed_candidates": []})
)
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content=content, tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
if stage == "finalizer":
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content="final report", tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content=GOOD_DELIVERY, tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
ensure_threads_calls: list[list[str]] = []
async def _ensure_threads(session_id, run_id, participants):
agent_ids = [p["agent_id"] for p in participants]
ensure_threads_calls.append(agent_ids)
result = {}
for participant in participants:
agent_id = participant["agent_id"]
thread_id = f"thread-{agent_id}"
await q_svc.store.participants.bind_thread(session_id, agent_id, thread_id=thread_id)
result[agent_id] = thread_id
return result
monkeypatch.setattr(runner._adapter, "run_seat_turn", AsyncMock(side_effect=_run_seat))
monkeypatch.setattr(runner._adapter, "ensure_threads", AsyncMock(side_effect=_ensure_threads))
if emit_real:
real_emit_critical = runner._adapter._emit_critical
monkeypatch.setattr(runner._adapter, "_emit_critical", AsyncMock(side_effect=real_emit_critical))
else:
monkeypatch.setattr(runner._adapter, "_emit_critical", AsyncMock())
monkeypatch.setattr(runner._adapter, "_emit", AsyncMock())
runner.stage_messages = stage_messages
return runner, calls, ensure_threads_calls
async def _run(q_svc, monkeypatch, **kwargs):
runner, calls, ensure_threads_calls = _make_q_runner(q_svc, monkeypatch, **kwargs)
session = await q_svc.store.sessions.get_session("q-sess", user_id="u1")
run = await q_svc.store.runs.get_run("q-run")
await runner.execute(session, run)
final_run = await q_svc.store.runs.get_run("q-run")
final_session = await q_svc.store.sessions.get_session("q-sess", user_id="u1")
return runner, calls, ensure_threads_calls, final_run, final_session
@pytest.mark.asyncio
async def test_runner_quality_accepts_good_deliveries(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
_, calls, _, final_run, final_session = await _run(
q_svc, monkeypatch,
deliveries={"seat-a": [GOOD_DELIVERY], "seat-b": [GOOD_DELIVERY]},
review_plan=[{"seat-a": {"decision": "accept"}, "seat-b": {"decision": "accept"}}],
)
assert final_run["status"] == "completed"
assert final_session["status"] == "completed"
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
by_key = {t["task_key"]: t for t in tasks}
assert set(by_key) == {"01:seat-a", "02:seat-b"}
assert all(t["status"] == "accepted" for t in tasks)
assert all(t["attempt_count"] == 1 for t in tasks)
assert all(t["rework_count"] == 0 for t in tasks)
checkpoint = final_run.get("maf_checkpoint") or {}
assert checkpoint.get("seat_task_statuses", {}).get("01:seat-a", {}).get("status") == "accepted"
assert checkpoint.get("controlled_gaps") == []
result = final_session.get("final_result") or {}
assert result.get("quality_decision") == "accept"
assert result.get("controlled_gaps") == []
review_stages = [s for s, a, t in calls if s == "manager_review"]
assert len(review_stages) == 1, "one batched manager review round"
# ── 2. short answer → reworks → reassign → controlled gap ───────────────────
@pytest.mark.asyncio
async def test_runner_quality_rework_reassign_controlled_gap(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
# seat-a stays short across every attempt; seat-b is always good.
# Manager keeps returning return_for_rework for seat-a (cap 2), then seat-a
# is reassigned to seat-c whose delivery also fails → controlled gap.
_, calls, ensure_threads_calls, final_run, final_session = await _run(
q_svc, monkeypatch,
deliveries={
"seat-a": [SHORT_DELIVERY, SHORT_DELIVERY, SHORT_DELIVERY, SHORT_DELIVERY],
"seat-b": [GOOD_DELIVERY],
"seat-c": [SHORT_DELIVERY],
},
review_plan=[
{"seat-a": {"decision": "return_for_rework", "reason": "太短", "requirements": ["请补充证据"]}, "seat-b": {"decision": "accept"}},
{"seat-a": {"decision": "return_for_rework", "reason": "仍太短", "requirements": ["请展开分析"]}},
{"seat-a": {"decision": "return_for_rework", "reason": "第三次不合格"}},
{"seat-c": {"decision": "return_for_rework", "reason": "新席位也短"}},
],
)
# Run completes via the gap path (controlled gaps do NOT block the phase).
assert final_run["status"] == "completed"
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
by_key = {t["task_key"]: t for t in tasks}
task_a = by_key["01:seat-a"]
assert task_a["status"] == "controlled_gap"
assert task_a["attempt_count"] == 4 # seat-a 3 + seat-c 1
assert task_a["rework_count"] == 2 # capped at max_rework_attempts
assert task_a["reassign_count"] == 1 # exactly one reassignment
assert task_a["agent_id"] == "seat-c" # reassigned to backup
assert task_a["original_agent_id"] == "seat-a"
assert task_a["gap"] is not None
assert "不得作为已验证事实" in task_a["gap"]["risk"]
task_b = by_key["02:seat-b"]
assert task_b["status"] == "accepted"
assert task_b["attempt_count"] == 1
# seat-c received a thread (backup handover) and delivered exactly once.
assert "seat-c" in [aid for call in ensure_threads_calls for aid in call]
seat_c_turns = [(s, a, t) for s, a, t in calls if a == "seat-c" and s in ("expert", "rework")]
assert len(seat_c_turns) == 1
# The quality gate passed with gaps and the final report carries them.
checkpoint = final_run.get("maf_checkpoint") or {}
gaps = checkpoint.get("controlled_gaps") or []
assert len(gaps) == 1
assert gaps[0]["task_key"] == "01:seat-a"
result = final_session.get("final_result") or {}
assert result.get("quality_decision") == "controlled_gap"
assert len(result.get("controlled_gaps") or []) == 1
assert result.get("degraded") is True
# Manager review was batched once per quality round.
review_stages = [t for s, a, t in calls if s == "manager_review"]
assert len(review_stages) == 4
# ── 3. ask_user fallback pauses in waiting_input ────────────────────────────
@pytest.mark.asyncio
async def test_runner_quality_ask_user_waits_for_input(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
_, calls, _, final_run, final_session = await _run(
q_svc, monkeypatch,
deliveries={"seat-a": [SHORT_DELIVERY, SHORT_DELIVERY, SHORT_DELIVERY], "seat-b": [GOOD_DELIVERY]},
review_plan=[
{"seat-a": {"decision": "return_for_rework", "reason": "太短"}, "seat-b": {"decision": "accept"}},
{"seat-a": {"decision": "return_for_rework", "reason": "仍太短"}},
{"seat-a": {"decision": "return_for_rework", "reason": "第三次不合格"}},
],
fallback_policy="ask_user",
)
assert final_run["status"] == "waiting_input"
assert final_session["status"] == "waiting_input"
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
by_key = {t["task_key"]: t for t in tasks}
assert by_key["01:seat-a"]["status"] == "exhausted"
assert by_key["02:seat-b"]["status"] == "accepted"
# The finalizer must never run on the ask_user path.
finalizer_stages = [s for s, a, t in calls if s == "finalizer"]
assert finalizer_stages == []
# No finished_at for a non-terminal pause.
assert final_run.get("finished_at") is None
# ── 4. phase gate blocks on non-terminal required task ──────────────────────
@pytest.mark.asyncio
async def test_quality_gate_blocks_non_terminal_required_task(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
state = RoundtableState(
session_id="q-sess", run_id="q-run", task_text="t",
mode="structured", model_name=None, reasoning_effort=None,
max_rounds=8, write_policy="auto", manager_agent_id=MANAGER,
seat_agent_ids=["seat-a"], thread_map={}, capability_snapshot=None,
seat_timeout_seconds=5, schema_repair_enabled=True,
intent_type="parallel_consultation",
dispatch_plan={"execution_mode": "parallel"},
dispatch_contract_enabled=True, quality_gate_enabled=True,
)
state.seat_task_statuses = {
"01:seat-a": {
"task_key": "01:seat-a", "status": "submitted",
"agent_id": "seat-a", "required": True,
},
}
adapter = SimpleNamespace(
_service=SimpleNamespace(store=SimpleNamespace(seat_tasks=SimpleNamespace())),
_emit_critical=AsyncMock(),
)
gate = QualityGateExecutor(adapter, state)
ctx = SimpleNamespace(send_message=AsyncMock(), yield_output=AsyncMock())
with pytest.raises(RuntimeError, match="quality gate blocked"):
await gate.handle({}, ctx)
# The blocked gate must not masquerade as passed.
assert state.status != "completed"
# ── 5. invalid/missing manager review NEVER auto-accepts ───────────────────
def _good_variant(n: int) -> str:
"""A deterministic-pass delivery whose conclusion differs per attempt, so
duplicate detection never interferes with the missing-review behaviour."""
return json.dumps({
"summary": (
f"结论{n}:该市场存在明确增长机会,建议优先进入华东区域并配套本地化运营资源投入,"
f"同时预留华南试点预算,第{n}次补充细化执行路径与里程碑安排"
),
"findings": [f"依据{n}:华东区销售数据连续三个月增长", "依据二:竞品在华东投放低于均值"],
"evidence_refs": ["@TOOL_REF@"],
"open_questions": [],
"self_check": "已覆盖全部验收标准",
})
@pytest.mark.asyncio
async def test_runner_quality_missing_review_never_accepts(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
# The manager review returns garbage (empty decisions) every round. Even
# though every delivery passes the deterministic length/format/evidence
# checks, NO task may be accepted without a valid manager review: the
# server forces system reworks, then exhausts into controlled gaps.
variants_a = [_good_variant(i) for i in range(1, 7)]
variants_b = [_good_variant(i + 10) for i in range(1, 7)]
_, calls, _, final_run, final_session = await _run(
q_svc, monkeypatch,
deliveries={
"seat-a": variants_a + [_good_variant(90)],
"seat-b": variants_b + [_good_variant(91)],
"seat-c": [_good_variant(80)],
},
review_plan=[{}],
emit_real=True,
)
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
assert tasks, "seat tasks must exist"
for task in tasks:
assert task["status"] != "accepted", (
f"task {task['task_key']} was accepted without a valid manager review"
)
assert task["status"] == "controlled_gap"
assert task["rework_count"] == 2
assert task["reassign_count"] == 1
decision = task.get("review_decision") or {}
assert decision.get("source") == "system"
assert decision.get("reason") == "manager_review_missing_or_invalid"
# The run still terminates (gaps do not block the phase) — but only as a
# degraded controlled-gap result, never as a clean accept.
assert final_run["status"] == "completed"
result = final_session.get("final_result") or {}
assert result.get("quality_decision") == "controlled_gap"
assert len(result.get("controlled_gaps") or []) == len(tasks)
# The persisted review events carry source=system + the safe reason.
events = await q_svc.store.events.list_events(run_id="q-run")
review_events = [e for e in events if e["event_type"] == "manager.review.decision"]
assert review_events
system_events = [e for e in review_events if e["payload"].get("source") == "system"]
assert system_events
assert all(
e["payload"].get("reason") == "manager_review_missing_or_invalid"
for e in system_events
)
# ── 6. unaccepted deliveries never reach facts / review / final report ─────
PLAUSIBLE_GAP_DELIVERY = json.dumps({
# Fails below_min_length on purpose, but carries "plausible" findings and
# an invented evidence ref — none of it may survive into the ledger, the
# cross-review prompt or the final synthesis.
"summary": "好",
"findings": ["PLAUSIBLE_FINDING: 已核实华东区增长数据并确认竞品全面撤离"],
"evidence_refs": ["plausible-ref-001"],
"open_questions": [],
"self_check": "ok",
})
@pytest.mark.asyncio
async def test_runner_quality_gap_content_isolated_from_facts_and_report(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
runner, calls, _, final_run, final_session = await _run(
q_svc, monkeypatch,
deliveries={
"seat-a": [PLAUSIBLE_GAP_DELIVERY],
"seat-b": [GOOD_DELIVERY],
"seat-c": [PLAUSIBLE_GAP_DELIVERY],
},
review_plan=[
{"seat-a": {"decision": "return_for_rework", "reason": "太短"}, "seat-b": {"decision": "accept"}},
{"seat-a": {"decision": "return_for_rework", "reason": "仍太短"}},
{"seat-a": {"decision": "return_for_rework", "reason": "第三次不合格"}},
{"seat-c": {"decision": "return_for_rework", "reason": "新席位也不合格"}},
],
)
assert final_run["status"] == "completed"
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
by_key = {t["task_key"]: t for t in tasks}
assert by_key["01:seat-a"]["status"] == "controlled_gap"
assert by_key["02:seat-b"]["status"] == "accepted"
# 1) Fact ledger: the gapped seat's claims/refs are absent; the accepted
# seat's findings ARE present (merge happens on acceptance).
ledger = final_session.get("fact_ledger") or {}
candidate_texts = [
str(c.get("text") or "") for c in ledger.get("candidate_facts") or []
]
confirmed_texts = [
str(f.get("text") or "") for f in ledger.get("confirmed_facts") or []
]
evidence_texts = [
str(e.get("ref") or e.get("text") or "") for e in ledger.get("evidence") or []
]
assert not any("PLAUSIBLE_FINDING" in t for t in candidate_texts)
assert not any("PLAUSIBLE_FINDING" in t for t in confirmed_texts)
assert not any("plausible-ref-001" in t for t in evidence_texts)
assert any("华东区销售数据连续三个月增长" in t for t in candidate_texts)
# 2) Cross-review prompt: accepted seat only, never the gapped content.
cross_review_msgs = runner.stage_messages.get("cross_review") or []
assert cross_review_msgs
assert all("PLAUSIBLE_FINDING" not in m for m in cross_review_msgs)
assert all("plausible-ref-001" not in m for m in cross_review_msgs)
assert any("华东区销售数据连续三个月增长" in m for m in cross_review_msgs)
# 3) Final synthesis: accepted seat only + traceable gap risk section.
finalizer_msgs = runner.stage_messages.get("finalizer") or []
assert finalizer_msgs
assert all("PLAUSIBLE_FINDING" not in m for m in finalizer_msgs)
assert all("plausible-ref-001" not in m for m in finalizer_msgs)
assert any("01:seat-a" in m for m in finalizer_msgs), "gap must be traceable"
assert any("受控缺口" in m for m in finalizer_msgs)
# 4) Checkpoint: exactly one accepted result (the accepted seat).
checkpoint = final_run.get("maf_checkpoint") or {}
accepted_results = checkpoint.get("accepted_task_results") or []
assert len(accepted_results) == 1
assert accepted_results[0]["task_key"] == "02:seat-b"
assert accepted_results[0]["accepted"] is True
assert "PLAUSIBLE_FINDING" not in str(accepted_results)
# 5) Final result carries the gap as risk, never as a verified fact.
result = final_session.get("final_result") or {}
assert result.get("quality_decision") == "controlled_gap"
gaps = result.get("controlled_gaps") or []
assert len(gaps) == 1
assert gaps[0]["task_key"] == "01:seat-a"
assert "不得作为已验证事实" in gaps[0]["risk"]
@pytest.mark.asyncio
async def test_runner_quality_events_persisted_and_projected(q_svc, monkeypatch):
"""The Stage-4 audit events persist to maf_roundtable_events."""
pytest.importorskip("agent_framework")
_, calls, _, final_run, final_session = await _run(
q_svc, monkeypatch,
deliveries={"seat-a": [SHORT_DELIVERY], "seat-b": [GOOD_DELIVERY]},
review_plan=[{"seat-a": {"decision": "return_for_rework", "requirements": ["补证据"]}, "seat-b": {"decision": "accept"}}],
emit_real=True,
)
events = await q_svc.store.events.list_events(run_id="q-run")
types = {e["event_type"] for e in events}
assert "seat.delivery.submitted" in types
assert "manager.review.decision" in types
assert "seat.rework.assigned" in types
assert "stage.quality_gate" in types
# The short delivery was flagged deterministically in the persisted event.
deliveries = [e for e in events if e["event_type"] == "seat.delivery.submitted"]
seat_a = [e for e in deliveries if e.get("agent_id") == "seat-a"]
assert seat_a and seat_a[0]["payload"].get("deterministic_passed") is False
# ── 7. P1: zero config values are honoured, not coerced to defaults ────────
def test_quality_settings_honour_zero_config_values(monkeypatch):
from app.maf_roundtable.workflow_runner import WorkflowRunner
fake_cfg = SimpleNamespace(maf_roundtable=SimpleNamespace(
quality_gate_enabled=True,
max_rework_attempts=0,
max_reassignments=0,
min_delivery_chars=0,
min_delivery_evidence=0,
default_fallback_policy="controlled_gap",
))
monkeypatch.setattr("deerflow.config.get_app_config", lambda: fake_cfg)
runner = WorkflowRunner.__new__(WorkflowRunner) # no __init__ side effects
settings = runner._quality_settings()
assert settings["max_rework_attempts"] == 0
assert settings["max_reassignments"] == 0
assert settings["min_delivery_chars"] == 0
assert settings["min_delivery_evidence"] == 0
assert settings["fallback_policy"] == "controlled_gap"
def test_quality_settings_none_falls_back_to_defaults(monkeypatch):
from app.maf_roundtable.workflow_runner import WorkflowRunner
# Attributes absent entirely (getattr → None) must fall back to defaults.
fake_cfg = SimpleNamespace(maf_roundtable=SimpleNamespace(
quality_gate_enabled=True,
))
monkeypatch.setattr("deerflow.config.get_app_config", lambda: fake_cfg)
runner = WorkflowRunner.__new__(WorkflowRunner)
settings = runner._quality_settings()
assert settings["max_rework_attempts"] == 2
assert settings["max_reassignments"] == 1
assert settings["min_delivery_chars"] == 40
assert settings["min_delivery_evidence"] == 1
assert settings["fallback_policy"] == "reassign_then_gap"
@pytest.mark.asyncio
async def test_runner_quality_zero_rework_cap_no_rework_granted(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
# max_rework_attempts=0 must be honoured: a failing delivery is NOT sent
# back for rework — it exhausts straight into the fallback (controlled gap
# here, no backup reassignment budget either).
_, calls, _, final_run, final_session = await _run(
q_svc, monkeypatch,
deliveries={"seat-a": [SHORT_DELIVERY], "seat-b": [GOOD_DELIVERY]},
review_plan=[
{"seat-a": {"decision": "return_for_rework", "reason": "太短"}, "seat-b": {"decision": "accept"}},
],
config_overrides={"max_rework_attempts": 0, "max_reassignments": 0},
)
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
by_key = {t["task_key"]: t for t in tasks}
task_a = by_key["01:seat-a"]
assert task_a["status"] == "controlled_gap"
assert task_a["attempt_count"] == 1, "no rework delivery may happen with a 0 cap"
assert task_a["rework_count"] == 0
assert task_a["reassign_count"] == 0
# Only the initial delivery + the (ignored) rework request — no rework turn.
expert_turns_a = [s for s, a, _t in calls if a == "seat-a" and s in ("expert", "rework")]
assert len(expert_turns_a) == 1
result = final_session.get("final_result") or {}
assert result.get("quality_decision") == "controlled_gap"
@pytest.mark.asyncio
async def test_runner_quality_zero_min_evidence_accepts_refless_delivery(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
# min_delivery_evidence=0 must be honoured: a conclusion-only delivery with
# no evidence refs passes the deterministic evidence gate.
no_ref_delivery = json.dumps({
"summary": "结论:该市场存在明确增长机会,建议优先进入华东区域并配套本地化运营资源投入,同时预留华南试点预算",
"findings": [],
"evidence_refs": [],
"open_questions": [],
"self_check": "ok",
})
_, _calls, _, final_run, _final_session = await _run(
q_svc, monkeypatch,
deliveries={"seat-a": [no_ref_delivery], "seat-b": [no_ref_delivery]},
review_plan=[{"seat-a": {"decision": "accept"}, "seat-b": {"decision": "accept"}}],
config_overrides={"min_delivery_evidence": 0},
)
assert final_run["status"] == "completed"
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
assert all(t["status"] == "accepted" for t in tasks)
# ── 8. P1: restart recovery re-submission hits duplicate via digest ────────
@pytest.mark.asyncio
async def test_runner_quality_restart_resubmission_hits_duplicate_digest(q_svc, monkeypatch):
pytest.importorskip("agent_framework")
from app.maf_roundtable.quality_contracts import build_task_contract, conclusion_digest
good = json.loads(GOOD_DELIVERY.replace("@TOOL_REF@", "tool:kb_search:1"))
digest = conclusion_digest(good["summary"])
# Simulate the pre-restart state: seat-a's first delivery was rejected and
# its conclusion digest was persisted to the task history; then the process
# restarted. The task row (with history) survives in the DB.
contract = build_task_contract(
task_goal="市场增长机会分析", delivery_requirements=["证据", "优先级"],
max_rework_attempts=2, fallback_policy="reassign_then_gap",
)
seeded = await q_svc.store.seat_tasks.create_task(
session_id="q-sess", run_id="q-run", task_key="01:seat-a",
agent_id="seat-a", contract=contract.model_dump(), required=True,
)
await q_svc.store.seat_tasks.update_task(seeded["id"], {
"status": "rework",
"attempt_count": 1,
"rework_count": 1,
"history": [{
"attempt": 1, "agent_id": "seat-a", "round": 1,
"schema_status": "ok", "failure_codes": ["below_min_length"],
"conclusion_sha256": digest,
}],
})
# After the restart the seat re-submits the SAME conclusion. It must be
# flagged duplicate_content from the persisted digest — even though the
# in-memory prior_conclusions starts empty on a fresh process.
_, _calls, _, final_run, _final_session = await _run(
q_svc, monkeypatch,
deliveries={"seat-a": [GOOD_DELIVERY], "seat-b": [GOOD_DELIVERY]},
review_plan=[
{"seat-a": {"decision": "accept"}, "seat-b": {"decision": "accept"}},
{"seat-a": {"decision": "accept"}},
{"seat-a": {"decision": "accept"}},
{"seat-a": {"decision": "accept"}},
],
)
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
by_key = {t["task_key"]: t for t in tasks}
task_a = by_key["01:seat-a"]
# The re-submission was deterministically flagged as a duplicate.
check = task_a.get("deterministic_check") or {}
history_codes = [
code
for h in (task_a.get("history") or [])
for code in (h.get("failure_codes") or [])
]
assert "duplicate_content" in (check.get("failure_codes") or []) or "duplicate_content" in history_codes
# And a duplicate can never be accepted, even with the manager saying yes.
assert task_a["status"] != "accepted"
assert final_run["status"] == "completed"