diff --git a/src/portfolio_optimiser/run.py b/src/portfolio_optimiser/run.py index f3f75f5..f8e6753 100644 --- a/src/portfolio_optimiser/run.py +++ b/src/portfolio_optimiser/run.py @@ -27,6 +27,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from agent_framework import BaseChatClient, SessionContext @@ -56,13 +57,35 @@ from portfolio_optimiser.workflow import fresh_workflow @dataclass(frozen=True) class RunResult: """The outcome of one project run: the validated/rejected proposal, its first-class - provenance, the captured (Layer-2) verdict, the ExpeL hits surfaced for it, and the store.""" + provenance, the captured (Layer-2) verdict, the ExpeL hits surfaced for it, the store, and + the debate's converged output that the candidate was generated from (F1 traceability).""" outcome: ValidatedProposal | Rejection provenance: ProvenanceStamp verdict: Verdict retrieved: list[Verdict] store: VerdictStore + debate_output: str + + +# The GroupChat orchestrator emits this terminal notice as a participant output; it is NOT the +# debate's substantive result, so the F1 extractor filters it out (verified against MAF 1.9.0). +_ORCH_NOTICE = "reached the maximum number of rounds" + + +def _debate_text(result: Any) -> str: + """Extract the proposer's converged output from a ``WorkflowRunResult`` (surfaced by + ``output_from=[proposer]``), excluding the orchestrator's termination notice. Returns ``""`` + when no substantive participant text is present.""" + texts: list[str] = [] + for out in result.get_outputs(): + text = out if isinstance(out, str) else getattr(out, "text", None) + if not text: + continue + if getattr(out, "author_name", None) == "group_chat_orchestrator" or _ORCH_NOTICE in text: + continue + texts.append(text) + return texts[-1] if texts else "" def _project_by_id(project_id: str) -> Project: @@ -136,10 +159,14 @@ async def run_project( tools=[retrieval_tool], middleware=[budget_mw], ) - await debate.run(f"Find a cost-saving measure for {project.id}.\nContext:\n{context}") + result = await debate.run(f"Find a cost-saving measure for {project.id}.\nContext:\n{context}") + # F1: the candidate must derive from the DEBATE. Feed the proposer's converged output into + # generation (retrieval context is the last-resort fallback only). + debate_output = _debate_text(result) + gen_context = debate_output or context # 5. Structured candidate -> blocking validation; token bound = the meter in this loop. - outcome = await generate_via_llm(factory("proposer"), project, context, meter) + outcome = await generate_via_llm(factory("proposer"), project, gen_context, meter) proposal = outcome.proposal # 6. First-class provenance stamp (authoritative; independent of MAF Annotation). @@ -168,7 +195,12 @@ async def run_project( notify(verdict) return RunResult( - outcome=outcome, provenance=stamp, verdict=verdict, retrieved=retrieved, store=store + outcome=outcome, + provenance=stamp, + verdict=verdict, + retrieved=retrieved, + store=store, + debate_output=debate_output, ) diff --git a/tests/test_vertical_slice_e2e.py b/tests/test_vertical_slice_e2e.py index d60b1ae..80af0b9 100644 --- a/tests/test_vertical_slice_e2e.py +++ b/tests/test_vertical_slice_e2e.py @@ -138,6 +138,37 @@ async def test_wiring_budget_middleware_and_retrieval_tool( assert any(isinstance(t, FunctionTool) for t in tools) +async def test_g_validated_proposal_derives_from_debate( + docs_dir, make_client_factory, fresh_store, monkeypatch +) -> None: + """F1: the candidate fed to generation derives from the DEBATE, not just retrieval. Spy + the context passed to generate_via_llm; the proposer's converged output ('Reduce scope', + present in _VALID but NOT in the docs_dir fixture) must reach generation. Deleting the + debate->generation wiring makes this fail.""" + from portfolio_optimiser import run as run_mod + + captured: dict[str, str] = {} + real_generate = run_mod.generate_via_llm + + async def spy_generate(chat_client, project, context, meter, **kw): + captured["context"] = context + return await real_generate(chat_client, project, context, meter, **kw) + + monkeypatch.setattr(run_mod, "generate_via_llm", spy_generate) + result = await run_project( + "FV42-GSV-E1", + "local", + docs_dir=docs_dir, + verdict_input=_VI, + client_factory=make_client_factory(_VALID), + store=fresh_store, + ) + debate_marker = "Reduce scope" # in _VALID (proposer output); NOT in docs_dir + assert result.debate_output and debate_marker in result.debate_output + assert debate_marker in captured["context"] # debate output reached generation + assert isinstance(result.outcome, ValidatedProposal) + + async def test_f_malformed_contract_raises_before_any_chat(docs_dir, make_client_factory) -> None: calls = {"n": 0} base = make_client_factory(_VALID)