feat(mandate): a domain expert can commission WHICH approaches a run evaluates

Operator feedback: fagpersoner must be able to name the approaches a run shall
evaluate for a project, and/or ask the system for its own. Today the hypothesis
prompt is hardcoded ("Propose ONE concrete cost-saving measure") and the only
expert-facing lever, --dimension-config, FILTERS what may pass the scoping gate
rather than DIRECTING what is spent attempts on. This is the input that was
missing.

`mandate.py` is the typed commission + a fail-fast loader (mirrors
`load_dimension`/`load_goal_config`): missing or malformed refuses, because a run
must never proceed on a silently degraded commission — the coverage report would
then describe work nobody ordered. Stdlib + pydantic only, so it joins
`_MAF_FREE_MODULES` and can be mirrored to the D7 sibling.

Two refusals carry real defect classes: an EMPTY commission (no approaches and no
own proposals) is a caller error, not a result; and a duplicate approach id — or
one claiming the reserved OWN_PROPOSAL_ID — would collapse two coverage rows onto
one key (the S3.2 key-collision class), which is exactly the silence the coverage
report exists to prevent.

The numeric target is deliberately NOT duplicated here: it already lives in
GoalContract, and two copies of one number drift apart ((p) precedent). The
mandate carries intent; `announce` merely restates the figure.

`_build_messages(approach=...)` switches the opening instruction from *find one*
to *quantify THIS one*, carrying the expert's label and description VERBATIM —
the description is the reason the approach is worth trying, the one part the model
cannot infer from cost data. `approach=None` is byte-identical to the previous
prompt, so every existing run and golden is untouched.

The gate is unmoved: `validate_proposal` is called exactly as before. A
commissioned approach gets no discount — the expert directs what is EVALUATED,
never what is APPROVED.

Load-bearing MEASURED against the whole 695-test suite, four mutations all red:
detach the approach injection (2 red, control stayed green) · let a commissioned
approach bypass the validator · make the mandate loader tolerant · drop the
empty-commission refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULCqjLF61rehj5cZmdUoR3
This commit is contained in:
Kjell Tore Guttormsen 2026-08-05 15:43:02 +02:00
commit fa1fa5aafd
5 changed files with 526 additions and 4 deletions

View file

@ -29,6 +29,7 @@ from pydantic import ValidationError
from portfolio_optimiser.budget import TokenMeter
from portfolio_optimiser.ir import CostBaseline, SavingsProposal
from portfolio_optimiser.mandate import Approach
from portfolio_optimiser.reference_domain import Project
from portfolio_optimiser.validator import (
Rejection,
@ -43,16 +44,43 @@ class GenerationError(RuntimeError):
def _build_messages(
project: Project, context: str, prior_rejection: Rejection | None = None
project: Project,
context: str,
prior_rejection: Rejection | None = None,
*,
approach: Approach | None = None,
) -> list[Message]:
"""Build the hypothesis prompt. When ``prior_rejection`` is set (Step 5, målbilde §5/§7),
append a revision block carrying ONLY the falsification *reason* verbatim never the prior
proposal JSON (minimal honest payload: the model must address the falsification, not parrot
the rejected candidate back). ``None`` -> the byte-identical base prompt, so attempt 1 is
unchanged. The reason carries only the rejected claim/feasible figures, which deliberately
do not collide with other load-bearing prompt markers."""
do not collide with other load-bearing prompt markers.
When ``approach`` is set (Trekk A3, krav 1) the opening instruction switches from *find one*
to *quantify THIS one*: the domain expert has already decided what shall be evaluated, and the
model's job is the numbers, not the direction. The expert's ``label`` and ``description`` are
carried VERBATIM the description is the reason the approach is worth trying, which is
exactly the part the model cannot infer from the cost data. ``None`` -> the byte-identical
base prompt, so an un-commissioned run is untouched (mirrors ``prior_rejection``).
The two are composable: a commissioned approach that the validator rejects is refined through
the SAME informed-refinement block, still bound to that approach.
"""
if approach is None:
head = "Propose ONE concrete cost-saving measure for this project.\n"
else:
head = (
"A domain expert has commissioned ONE specific approach for this project. "
"Quantify THAT approach as a concrete cost-saving measure — do not substitute a "
"different measure. If it does not apply to this project, say so through the "
"numbers rather than proposing something else.\n"
f"Approach: {approach.label}\n"
)
if approach.description:
head += f"Why the expert wants it evaluated: {approach.description}\n"
prompt = (
"Propose ONE concrete cost-saving measure for this project.\n"
f"{head}"
f"Project: {project.id} - {project.name}\n"
f"Context (prior verdicts / cited cost docs):\n{context}\n\n"
"Respond with ONLY a JSON object for a SavingsProposal with keys: project_id, "
@ -111,6 +139,7 @@ async def generate_via_llm(
*,
max_attempts: int = 3,
baseline: CostBaseline | None = None,
approach: Approach | None = None,
) -> ValidatedProposal | Rejection:
"""Async LLM path: non-streaming chat -> parse -> validate, with TWO bounded retry kinds,
the meter checked in this loop:
@ -126,6 +155,13 @@ async def generate_via_llm(
checker is a run-level, one-shot signal (run.py, before generation); seeding generation
with the checker critique is separately scoped and NOT done here.
``approach`` (Trekk A3, krav 1) binds every attempt of this call to ONE expert-commissioned
approach. It changes only the prompt: ``validate_proposal`` is called exactly as before, so a
commissioned approach gets **no discount at the deterministic gate** the expert directs what
is evaluated, never what is approved. A commissioned proposal that fails is refined through the
same informed-refinement path, still bound to that approach, and returns a typed ``Rejection``
when the attempt budget runs out.
``baseline`` (S4.0) is handed straight to ``validate_proposal``, so a fabricated cost line is
falsified per ATTEMPT like any other rejection and its reason feeds the next attempt's prompt
through the SAME informed-refinement path (Step 5), which is why no new loop appears here.
@ -150,7 +186,7 @@ async def generate_via_llm(
# attempt's prompt. ``last`` is None on attempt 1 -> the unchanged base prompt; it is
# overwritten each round -> only the most-recent falsification ("forrige"), never an
# accumulated history (bounded prompt growth).
messages = _build_messages(project, context, prior_rejection=last)
messages = _build_messages(project, context, prior_rejection=last, approach=approach)
candidate = await _fetch_parsed(messages)
result = validate_proposal(candidate, baseline=baseline)
if isinstance(result, ValidatedProposal):

View file

@ -0,0 +1,161 @@
"""Typed IR for a run MANDATE — what a domain expert commissions a run to evaluate, and what
that run is meant to achieve (Trekk A1).
Pure module imports **only** ``pydantic`` + stdlib. No ``agent_framework``, and deliberately
no ``contracts`` (which pulls MAF in transitively via ``backends``), so this module stays
D7-portable and is guarded by ``tests/test_okf.py::test_okf_is_maf_free`` alongside ``okf.py``
and ``dimension.py``.
**A mandate is not a dimension.** ``dimension.admits`` FILTERS what may pass the scoping gate;
a mandate DIRECTS what the run shall actually spend its attempts on. The two compose: a run may
be commissioned to evaluate three approaches *and* be scoped to one cost axis.
**A mandate is not a goal, either.** The numeric target has exactly one home already
(``contracts.GoalContract`` / ``--goals``); duplicating it here would put the same number in two
places, and two copies of one number drift apart (the ``(p)`` precedent one quantisation order,
one source). The mandate carries the run's *intent* in plain language; the figure is read from
the goal config and merely RESTATED in the run announcement.
Two refusals are load-bearing, both at construction time:
* an **empty commission** (no approaches and no own proposals) a run with nothing to do is a
caller error, not a result (mirrors ``budget.BudgetRefused``'s startup refusal: a pass that can
afford zero projects is refused before anything loads);
* a **duplicate approach id**, including one claiming the reserved ``OWN_PROPOSAL_ID`` ``id`` is
the key each row of the coverage report is written under, so two rows sharing one key would
silently collapse into one. That is the S3.2 key-collision class, and silence is exactly what
the coverage report exists to prevent.
"""
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel, Field, model_validator
#: Coverage-report id for the run's OWN (non-commissioned) proposal. Reserved: an expert approach
#: may not claim it, because the two rows would collapse onto one key.
OWN_PROPOSAL_ID = "own-proposal"
class Approach(BaseModel):
"""One approach a domain expert wants evaluated for a project.
``description`` is the expert's own prose reason for wanting it tried; it is fed to the
proposer VERBATIM, because the reason is the part the model cannot infer from the cost data.
"""
id: str = Field(min_length=1)
label: str = Field(min_length=1)
description: str = ""
class Mandate(BaseModel):
"""The commission for one run: what to evaluate, and what the run is for.
``allow_own_proposals`` defaults to ``True`` so that naming approaches never silently forbids
the system from adding its own the operator's requirement is "these **and/or** your own",
and the permissive half is the one that matches today's behaviour.
"""
objective: str = Field(min_length=1)
approaches: tuple[Approach, ...] = ()
allow_own_proposals: bool = True
success_criteria: str = ""
@model_validator(mode="after")
def _commission_is_not_empty(self) -> Mandate:
if not self.approaches and not self.allow_own_proposals:
raise ValueError(
"empty commission: a mandate with no approaches and allow_own_proposals=false "
"gives the run nothing to evaluate"
)
return self
@model_validator(mode="after")
def _approach_ids_are_unique_and_unreserved(self) -> Mandate:
seen: set[str] = set()
for approach in self.approaches:
if approach.id == OWN_PROPOSAL_ID:
raise ValueError(
f"approach id {OWN_PROPOSAL_ID!r} is reserved for the run's own proposal"
)
if approach.id in seen:
raise ValueError(f"duplicate approach id: {approach.id!r}")
seen.add(approach.id)
return self
def load_mandate(path: str | Path) -> Mandate:
"""Fail-fast standalone loader for a run mandate (mirrors ``dimension.load_dimension``).
A mandate is *authoritative startup input*, so loading is fail-fast: a missing file raises
``FileNotFoundError`` and malformed/invalid content raises ``pydantic.ValidationError``. This
is the deliberate contrast to the tolerant verdict-inbox RAW layer
(``verdicts.load_verdicts_from_dir``), which skips bad files rather than raising a run must
never proceed on a *silently degraded* commission, because the coverage report would then
describe work nobody ordered.
:raises FileNotFoundError: ``path`` does not point at an existing file.
:raises pydantic.ValidationError: the content is not JSON, or violates the ``Mandate`` schema.
"""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"mandate not found: {str(path)!r}")
return Mandate.model_validate_json(p.read_text(encoding="utf-8"))
def announce(
mandate: Mandate,
*,
project_id: str,
max_rounds: int,
max_tokens: int,
dimension_label: str | None = None,
goal_nok: float | None = None,
external_services: tuple[str, ...] = (),
) -> str:
"""Render the run announcement: what this run will do, BEFORE the first (paid) model call.
Deterministic and byte-stable no wall clock, and nothing ordered by set iteration so it
can be golden-tested. English, like every other line this CLI prints; the Norwegian
explanation of what the block means belongs in ``docs/bestille-en-kjoring.md``, next to the
domain expert who reads it.
Optional lines are OMITTED rather than rendered empty: an announcement that printed
``Scoped to: -`` would imply a scope decision nobody made.
``external_services`` is the egress declaration. It is empty until a run is given MCP servers
(Trekk B); when it is non-empty, every server named here may be contacted and a run never
reaches a service it did not announce. The "no external services" wording is deliberate: an
omitted line reads the same as an unchecked one.
"""
lines = [
f"Run mandate for {project_id}",
f" Objective: {mandate.objective}",
]
if mandate.approaches:
suffix = " + the system's own proposals" if mandate.allow_own_proposals else " (only these)"
lines.append(
f" Evaluates: {len(mandate.approaches)} expert-proposed approach(es){suffix}"
)
lines.extend(
f" {n}. {a.id}{a.label}"
for n, a in enumerate(mandate.approaches, start=1)
)
else:
lines.append(" Evaluates: the system's own proposals (none were commissioned)")
if dimension_label is not None:
lines.append(f" Scoped to: {dimension_label}")
if goal_nok is not None:
lines.append(f" Target: >= {goal_nok:.0f} NOK (restated from the goal config)")
lines.append(f" Stops at: {max_rounds} rounds / {max_tokens} tokens")
lines.append(
" Contacts: "
+ (", ".join(external_services) if external_services else "no external services")
)
if mandate.success_criteria:
lines.append(f" Success: {mandate.success_criteria}")
return "\n".join(lines)

224
tests/test_mandate.py Normal file
View file

@ -0,0 +1,224 @@
"""Unit tests for the run MANDATE IR + its fail-fast loader (Trekk A1).
The mandate is what a domain expert commissions a run with: what shall be evaluated
(named approaches, and/or the system's own proposals) and what the run is meant to
achieve (``objective`` / ``success_criteria``). It is *authoritative startup config*
so loading mirrors ``dimension.load_dimension`` / ``contracts.load_goal_config``
(missing -> ``FileNotFoundError``, malformed -> ``ValidationError``), NOT the tolerant
RAW verdict-inbox layer.
Two refusals carry real defect classes and are tested explicitly:
* an EMPTY commission (no approaches AND no own proposals) a run with nothing to do
is a caller error, not a result (mirrors ``BudgetRefused``'s startup refusal);
* a DUPLICATE approach id ``id`` is the coverage-report key, so two rows sharing one
key would silently collapse into one (the S3.2 key-collision class), and the reserved
``OWN_PROPOSAL_ID`` would collide with the system's own row the same way.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from portfolio_optimiser.mandate import (
OWN_PROPOSAL_ID,
Approach,
Mandate,
announce,
load_mandate,
)
def _mandate(**overrides: object) -> Mandate:
kwargs: dict[str, object] = {
"objective": "Find operating measures that can be executed in 2026 without rebuilding.",
"approaches": (
Approach(id="led-retrofit", label="LED retrofit of office lighting"),
Approach(id="service-contract", label="Renegotiate the service contract"),
),
}
kwargs.update(overrides)
return Mandate(**kwargs) # type: ignore[arg-type]
def test_mandate_defaults_allow_own_proposals() -> None:
"""``allow_own_proposals`` defaults to True: naming approaches never silently forbids
the system from adding its own (the operator's 'og/eller')."""
assert _mandate().allow_own_proposals is True
def test_mandate_accepts_named_approaches_only() -> None:
"""'Evaluate ONLY these' is a legal commission."""
m = _mandate(allow_own_proposals=False)
assert [a.id for a in m.approaches] == ["led-retrofit", "service-contract"]
assert m.allow_own_proposals is False
def test_mandate_accepts_own_proposals_only() -> None:
"""'Find your own' with no named approaches is a legal commission (today's behaviour,
now stated rather than implied)."""
m = _mandate(approaches=())
assert m.approaches == ()
assert m.allow_own_proposals is True
def test_mandate_empty_commission_refused() -> None:
"""No approaches AND no own proposals = a run with nothing to do -> refused at
construction, never started."""
with pytest.raises(ValidationError):
_mandate(approaches=(), allow_own_proposals=False)
def test_mandate_duplicate_approach_id_refused() -> None:
"""``id`` keys the coverage report; two rows on one key would collapse silently."""
with pytest.raises(ValidationError):
_mandate(
approaches=(
Approach(id="led-retrofit", label="LED retrofit"),
Approach(id="led-retrofit", label="LED retrofit, second take"),
)
)
def test_mandate_reserved_own_proposal_id_refused() -> None:
"""The system's own row is reported under ``OWN_PROPOSAL_ID``; an expert approach
claiming that id would collide with it."""
with pytest.raises(ValidationError):
_mandate(approaches=(Approach(id=OWN_PROPOSAL_ID, label="Sneaky"),))
def test_mandate_requires_an_objective() -> None:
"""An empty objective defeats the whole point of the mandate (krav 2): a run must say
what it is for."""
with pytest.raises(ValidationError):
_mandate(objective="")
def test_approach_requires_id_and_label() -> None:
"""Both the coverage key and its human label must be non-empty."""
with pytest.raises(ValidationError):
Approach(id="", label="No id")
with pytest.raises(ValidationError):
Approach(id="no-label", label="")
# --- fail-fast loader (mirrors load_dimension / load_goal_config) --------------------------------
def test_load_mandate_round_trip(tmp_path) -> None:
"""A valid mandate JSON round-trips through ``load_mandate`` (accepts str | Path)."""
m = _mandate()
p = tmp_path / "mandate.json"
p.write_text(m.model_dump_json(), encoding="utf-8")
assert load_mandate(p) == m
assert load_mandate(str(p)) == m
def test_load_mandate_missing_file_raises(tmp_path) -> None:
"""Missing file fails fast — authoritative startup config, not a tolerant RAW inbox."""
with pytest.raises(FileNotFoundError):
load_mandate(tmp_path / "does-not-exist.json")
def test_load_mandate_malformed_shape_raises(tmp_path) -> None:
"""Malformed content (missing ``objective``) fails fast with ``ValidationError``."""
bad = tmp_path / "mandate.json"
bad.write_text('{"approaches": []}', encoding="utf-8")
with pytest.raises(ValidationError):
load_mandate(bad)
def test_load_mandate_not_json_raises(tmp_path) -> None:
"""Non-JSON content fails fast too (never read as 'no mandate')."""
bad = tmp_path / "mandate.json"
bad.write_text("this is not json", encoding="utf-8")
with pytest.raises(ValidationError):
load_mandate(bad)
# --- the run announcement (Trekk A2): what this run will do, before the first paid call ----------
def _announce(m: Mandate | None = None, **overrides: object) -> str:
kwargs: dict[str, object] = {
"project_id": "BYGG-KONTOR-NORD",
"max_rounds": 3,
"max_tokens": 100_000,
}
kwargs.update(overrides)
return announce(m if m is not None else _mandate(), **kwargs) # type: ignore[arg-type]
def test_announce_names_every_commissioned_approach() -> None:
"""Every approach the expert commissioned is named BEFORE the run starts — the operator must
be able to see what was ordered without reading the config file back."""
text = _announce()
assert "led-retrofit" in text
assert "service-contract" in text
assert "LED retrofit of office lighting" in text
def test_announce_states_the_objective_and_success_criteria() -> None:
"""Krav 2: the run says what it is for, and what would count as success."""
m = _mandate(success_criteria="At least one measure that passes the validator.")
text = _announce(m)
assert "without rebuilding" in text
assert "At least one measure that passes the validator." in text
def test_announce_distinguishes_only_these_from_these_plus_own() -> None:
"""The 'og/eller' choice is visible in the announcement, not buried in the config."""
both = _announce(_mandate(allow_own_proposals=True))
only = _announce(_mandate(allow_own_proposals=False))
assert "own proposals" in both
assert "only these" in only
assert both != only
def test_announce_without_approaches_says_so() -> None:
"""A 'find your own' commission announces that too — never a blank line where the list was."""
text = _announce(_mandate(approaches=()))
assert "own proposals" in text
def test_announce_omits_optional_lines_when_absent() -> None:
"""Dimension and target lines appear ONLY when the run actually has them — an announcement
must not imply a scope or a target that was never configured."""
text = _announce()
assert "Scoped to" not in text
assert "Target" not in text
def test_announce_includes_scope_and_target_when_given() -> None:
"""...and they ARE stated when configured (the target is restated from the goal config, which
remains its one home)."""
text = _announce(dimension_label="energi (cost codes ENERGI-*)", goal_nok=150_000.0)
assert "energi (cost codes ENERGI-*)" in text
assert "150000" in text
def test_announce_declares_no_egress_by_default() -> None:
"""The egress declaration is present and explicit even when nothing is contacted — silence
would read the same as 'not checked' (repo invariant: no silent egress)."""
assert "no external services" in _announce()
def test_announce_names_every_external_service() -> None:
"""Trekk B's egress declaration: every server that may be contacted is named up front."""
text = _announce(external_services=("prisregister", "maalerdata"))
assert "prisregister" in text
assert "maalerdata" in text
assert "no external services" not in text
def test_announce_states_the_caps() -> None:
"""Stop criteria + budget cap are part of 'what this run will do' (fail-fast invariant)."""
text = _announce()
assert "3 rounds" in text
assert "100000 tokens" in text
def test_announce_is_deterministic() -> None:
"""Byte-stable: no wall clock, no set-iteration ordering — so it can be golden-tested."""
assert _announce() == _announce()

View file

@ -0,0 +1,100 @@
"""Load-bearing: a COMMISSIONED approach reaches the proposer, and gets no discount from the
deterministic validator (Trekk A3).
The seam this pins is the whole point of krav 1: a domain expert names an approach, and the
hypothesis prompt must actually carry it otherwise the run merely *claims* to evaluate what was
ordered. Two detach points, and each must go RED on its own:
* drop the approach block from ``_build_messages`` -> the expert's label/description never reaches
the model (``test_commissioned_approach_reaches_the_proposer``);
* let a commissioned approach bypass ``validate_proposal`` -> an expert's wish would outrank the
deterministic gate (``test_commissioned_approach_gets_no_validator_discount``).
The control (``test_no_approach_keeps_the_base_prompt_byte_identical``) proves the addition is
inert when no mandate is given: a test that can only go green proves nothing.
"""
from __future__ import annotations
import pytest
from spikes._harness import FakeChatClient, message_texts
from portfolio_optimiser.budget import Budget, TokenMeter
from portfolio_optimiser.generate import _build_messages, generate_via_llm
from portfolio_optimiser.mandate import Approach
from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser.validator import Rejection, ValidatedProposal
_VALID = (
'{"project_id":"FV42-GSV-E1","measure":"Reduce scope",'
'"affected_items":[{"code":"05.2","quantity":4300,"unit_cost":215},'
'{"code":"03.1","quantity":1800,"unit_cost":310}],"claimed_saving_nok":200000}'
)
#: Same shape and a value the IR itself accepts, but above the method cap (30% of the ~1.48M
#: affected total = ~445k) -> the DETERMINISTIC VALIDATOR is what must reject it, not the parser.
_INFEASIBLE = (
'{"project_id":"FV42-GSV-E1","measure":"Reduce scope",'
'"affected_items":[{"code":"05.2","quantity":4300,"unit_cost":215},'
'{"code":"03.1","quantity":1800,"unit_cost":310}],"claimed_saving_nok":800000}'
)
_APPROACH = Approach(
id="led-retrofit",
label="LED retrofit of office lighting",
description="Operations believes the fixtures are original and run far past their rated life.",
)
@pytest.fixture(scope="module")
def project():
return load_reference_projects()[0] # FV42-GSV-E1
def _meter() -> TokenMeter:
return TokenMeter(Budget(max_tokens=10**9, max_rounds=20))
def test_commissioned_approach_reaches_the_proposer(project) -> None:
"""The expert's own words — label AND description — reach the hypothesis prompt VERBATIM.
The description is the part the model cannot infer from cost data (it is the expert's reason
for wanting this tried), so paraphrasing it away would quietly discard the domain knowledge.
"""
text = message_texts(_build_messages(project, "ctx", approach=_APPROACH))[0]
assert _APPROACH.label in text
assert _APPROACH.description in text
def test_no_approach_keeps_the_base_prompt_byte_identical(project) -> None:
"""CONTROL: without a mandate the prompt is byte-identical to the pre-Trekk-A one, so every
existing run and golden is untouched (mirrors ``prior_rejection=None``)."""
base = message_texts(_build_messages(project, "ctx"))[0]
assert base == message_texts(_build_messages(project, "ctx", approach=None))[0]
assert "Propose ONE concrete cost-saving measure for this project." in base
assert _APPROACH.label not in base
async def test_commissioned_approach_gets_no_validator_discount(project) -> None:
"""A commissioned approach whose numbers do not hold is REJECTED — the deterministic gate is
blocking, and being asked for by a domain expert is not a reason to pass it.
This is the one rule krav 1 must not be allowed to erode: the expert directs *what is
evaluated*, never *what is approved*.
"""
client = FakeChatClient(scripted=[_INFEASIBLE], default_reply=_INFEASIBLE)
result = await generate_via_llm(
client, project, "", _meter(), max_attempts=1, approach=_APPROACH
)
assert isinstance(result, Rejection)
async def test_commissioned_approach_still_validates_when_the_numbers_hold(project) -> None:
"""...and the same commissioned path DOES produce a validated proposal when the numbers are
feasible so the rejection above is the validator working, not the approach path being
broken end to end."""
client = FakeChatClient(scripted=[_VALID], default_reply=_VALID)
result = await generate_via_llm(
client, project, "", _meter(), max_attempts=1, approach=_APPROACH
)
assert isinstance(result, ValidatedProposal)
assert _APPROACH.label in client.received_texts[0][0] # it went through the commissioned prompt

View file

@ -22,6 +22,7 @@ from portfolio_optimiser import okf
_MAF_FREE_MODULES = [
"okf.py",
"dimension.py",
"mandate.py",
"outbox.py",
"costsim.py",
"hitl.py",