feat(fase3): run_portfolio sequential orchestrator + PortfolioResult aggregate
This commit is contained in:
parent
c7c42eeb75
commit
52f6f65b7d
4 changed files with 239 additions and 3 deletions
|
|
@ -93,6 +93,76 @@ def make_client_factory() -> Callable[..., Callable[[str], BaseChatClient]]:
|
|||
return _make
|
||||
|
||||
|
||||
# A generic VALID SavingsProposal reply for any project not present in a portfolio reply map:
|
||||
# affected total = 1 x 100_000 = 100_000, P90 = 0.30 x 100_000 = 30_000, claimed 20_000 <= both
|
||||
# (Pydantic affected-total invariant and the validator P90 gate) -> always validates.
|
||||
_PORTFOLIO_DEFAULT_REPLY = (
|
||||
'{"measure":"Reduce scope","affected_items":'
|
||||
'[{"code":"01.1","quantity":1,"unit_cost":100000}],"claimed_saving_nok":20000}'
|
||||
)
|
||||
|
||||
|
||||
class _ProjectAwareUsageChatClient(SyntheticUsageChatClient):
|
||||
"""A ``SyntheticUsageChatClient`` that selects its reply by scanning the incoming prompt for
|
||||
a known ``project_id`` substring (the prompt embeds ``project.id`` at run.py:162 and
|
||||
generate.py:48), falling back to a default valid proposal. This keeps ``run_portfolio``'s
|
||||
single ``client_factory`` production-shaped while letting tests vary the proposal per
|
||||
project."""
|
||||
|
||||
def __init__(
|
||||
self, replies: dict[str, str], *, default_reply: str, tokens_per_reply: int = 8
|
||||
) -> None:
|
||||
super().__init__(default_reply=default_reply, tokens_per_reply=tokens_per_reply)
|
||||
self._replies = dict(replies)
|
||||
|
||||
def _inner_get_response(
|
||||
self, *, messages: Sequence[Message], stream: bool, options: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
blob = " ".join(getattr(m, "text", "") or "" for m in messages)
|
||||
reply = next((r for pid, r in self._replies.items() if pid in blob), self._default)
|
||||
self.call_count += 1
|
||||
usage = UsageDetails(total_token_count=self._tokens)
|
||||
if stream:
|
||||
|
||||
async def _agen() -> Any:
|
||||
yield ChatResponseUpdate(
|
||||
role="assistant", contents=[{"type": "text", "text": reply}]
|
||||
)
|
||||
|
||||
return self._build_response_stream(_agen())
|
||||
|
||||
async def _coro() -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=[reply])],
|
||||
response_id="synthetic",
|
||||
usage_details=usage,
|
||||
)
|
||||
|
||||
return _coro()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_portfolio_client_factory() -> Callable[..., Callable[[str], BaseChatClient]]:
|
||||
"""Return a maker that builds a single project-aware client factory: every client it
|
||||
produces picks its reply from ``replies`` by scanning the prompt for the project id, so one
|
||||
factory serves the whole portfolio (matching ``run_portfolio``'s single-factory seam)."""
|
||||
|
||||
def _make(
|
||||
replies: dict[str, str],
|
||||
*,
|
||||
default_reply: str = _PORTFOLIO_DEFAULT_REPLY,
|
||||
tokens: int = 8,
|
||||
) -> Callable[[str], BaseChatClient]:
|
||||
def factory(role: str) -> BaseChatClient:
|
||||
return _ProjectAwareUsageChatClient(
|
||||
replies, default_reply=default_reply, tokens_per_reply=tokens
|
||||
)
|
||||
|
||||
return factory
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fresh_store() -> VerdictStore:
|
||||
return VerdictStore(verdicts=[])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue