feat(explore): U4 kallsted 2 - hostet explore_prompt, og whitelisten blir en TREDELING (ORDRE 20260823T204216Z) [skip-docs]
Kallsted (2) av oerkt 57s fire. explore_prompt + explore_contract whitelistes paa den hostede flaten. De er ikke run_project-parametre - utforskningen kjoerer FOERST og gir run_project et Mandate - saa de KONSUMERES i stedet for aa videresendes, og whitelisten er dermed en TREDELING (_REQUIRED / _OPTIONAL / _CONSUMED). Ett sted (_run_kwargs) avgjoer hvilke felt som naar signaturen: et konsumert felt som blir liggende i kwargs er et argument run_project ikke har, som containeren svarer 500 paa for det som egentlig er en wiring-feil. FASE 4e-REGELEN UTVIDET MED EN NEGATIV HALVDEL, og det var paakrevd: 4e-testen sender HVERT whitelistet felt gjennom den EKTE run_project og asserterer dekning mot _ALLOWED_FIELDS - en assert et konsumert felt ALDRI kan oppfylle. Den positive halvdelen dekker naa _REQUIRED|_OPTIONAL (hvert felt maalt mot inspect.signature(run_project)), og den negative at _CONSUMED er DISJUNKT fra samme signatur. Uten den ville et felt som glir fra konsumert til videresendt vaere nettopp driften 4e finnes for. Fire nekter, alle ved navn og alle paa KALLERENS kanal (400): explore_contract uten explore_prompt - explore_prompt uten explore_contract - explore_prompt uten bundle_dir - enable_plan_review=true. Den siste er nektet HER og ikke i explore(), som ogsaa nekter den: ExplorationError er en RuntimeError, saa aa overlate den til sloeyfa ville svart en kallers konfigurasjonsfeil paa KRASJ-kanalen (500) - samme sammenblanding BudgetExceeded fikk sin egen 429 for aa avslutte. U13-doera er dessuten SYNKRON: den blokkerer sloeyfa paa et menneske, og en HTTP-forespoersel har ingen - invocationen ville hengt i stedet for aa svare. TracingConfigError er derimot en ValueError, saa 400- armen dekket den alt (verifisert, ikke antatt). Load-bearing MAALT (tests/test_explore_callsites_loadbearing.py, 6 nye tester), fire mutasjoner alle roede mot HELE suiten + groenn kontroll 996/5: videresend de konsumerte feltene igjen (2 roede) - detach mandate=-wiringen (1 roed) - detach enable_plan_review-nekten (1) - detach bundle_dir-kravet (1). Golden-transkriptet byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f). mypy + ruff rene. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRZhBJcxqTcqWyMW6hBttx
This commit is contained in:
parent
8ba824c96f
commit
234c8d138a
3 changed files with 242 additions and 14 deletions
|
|
@ -66,9 +66,11 @@ import json
|
|||
import os
|
||||
import signal
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from portfolio_optimiser.budget import BudgetExceeded
|
||||
from portfolio_optimiser.explore import ExplorationContract, explore
|
||||
from portfolio_optimiser.outbox import outcome_payload
|
||||
from portfolio_optimiser.run import RunResult, run_project
|
||||
from portfolio_optimiser.tracing import configure_tracing, tracing_notice
|
||||
|
|
@ -77,7 +79,13 @@ DEFAULT_PORT = 8088
|
|||
_HOSTED_DEFAULT_PROFILE = "azure"
|
||||
_REQUIRED_FIELDS = ("project_id", "docs_dir", "verdict_input")
|
||||
_OPTIONAL_FIELDS = ("bundle_dir", "profile", "max_rounds", "max_tokens", "top_k")
|
||||
_ALLOWED_FIELDS = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS)
|
||||
#: Fields this surface CONSUMES rather than forwards (U4). They are not ``run_project``
|
||||
#: parameters — the exploration runs first and hands ``run_project`` a ``Mandate`` — so passing one
|
||||
#: through would be a ``TypeError`` answered as a 500. The whitelist is therefore a THREE-way
|
||||
#: partition, and the Fase 4e proof gained a negative half to match: every forwarded field must be
|
||||
#: a real ``run_project`` parameter, and every consumed field must not be.
|
||||
_CONSUMED_FIELDS = ("explore_prompt", "explore_contract")
|
||||
_ALLOWED_FIELDS = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS + _CONSUMED_FIELDS)
|
||||
_REASONS = {
|
||||
200: "OK",
|
||||
400: "Bad Request",
|
||||
|
|
@ -99,10 +107,15 @@ def resolve_port() -> int:
|
|||
return int(os.environ.get("PORT") or DEFAULT_PORT)
|
||||
|
||||
|
||||
def _run_kwargs(payload: Any) -> tuple[str, dict[str, Any]]:
|
||||
def _run_kwargs(payload: Any) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
||||
"""Whitelist the JSON payload onto ``run_project``'s signature. Everything not named in
|
||||
the whitelist — including server-side seams like ``outbox_dir``, ``client_factory`` or
|
||||
``verdict_dir`` — is refused by name, never silently dropped."""
|
||||
``verdict_dir`` — is refused by name, never silently dropped.
|
||||
|
||||
Returns ``(project_id, forwarded_kwargs, consumed)``. The consumed half is split out HERE
|
||||
rather than filtered at the call site so there is one place that decides which fields reach
|
||||
``run_project``: a consumed field left in ``kwargs`` is an argument the signature does not have,
|
||||
which the container answers as a 500 for what is really a wiring mistake."""
|
||||
if not isinstance(payload, dict):
|
||||
raise InvocationRefused("body must be a JSON object")
|
||||
unknown = sorted(set(payload) - _ALLOWED_FIELDS)
|
||||
|
|
@ -111,9 +124,58 @@ def _run_kwargs(payload: Any) -> tuple[str, dict[str, Any]]:
|
|||
missing = [field for field in _REQUIRED_FIELDS if field not in payload]
|
||||
if missing:
|
||||
raise InvocationRefused(f"missing required field(s): {', '.join(missing)}")
|
||||
kwargs: dict[str, Any] = {k: payload[k] for k in payload if k != "project_id"}
|
||||
consumed = {k: payload[k] for k in _CONSUMED_FIELDS if k in payload}
|
||||
kwargs: dict[str, Any] = {
|
||||
k: payload[k] for k in payload if k != "project_id" and k not in _CONSUMED_FIELDS
|
||||
}
|
||||
kwargs.setdefault("profile", _HOSTED_DEFAULT_PROFILE)
|
||||
return payload["project_id"], kwargs
|
||||
return payload["project_id"], kwargs, consumed
|
||||
|
||||
|
||||
async def _shaped_mandate(consumed: Mapping[str, Any], kwargs: Mapping[str, Any]) -> Any:
|
||||
"""Run the U4 exploration this invocation asked for and return the mandate it shaped.
|
||||
|
||||
Every refusal here is the CALLER's error and therefore a ``ValueError`` (the 400 arm), by name.
|
||||
That placement is deliberate rather than incidental: ``explore()`` refuses two of these itself,
|
||||
but ``ExplorationError`` is a ``RuntimeError``, so leaving them to the loop would answer a
|
||||
caller's configuration mistake on the crash channel — the same conflation ``BudgetExceeded``
|
||||
was given its own 429 to end.
|
||||
|
||||
``enable_plan_review`` is refused outright. The U13 door is SYNCHRONOUS: it blocks the loop on
|
||||
a human or persona, and an HTTP request has neither — the invocation would hang rather than
|
||||
answer. The library API is where that door opens."""
|
||||
prompt = consumed.get("explore_prompt")
|
||||
raw_contract = consumed.get("explore_contract")
|
||||
if prompt is None:
|
||||
raise InvocationRefused(
|
||||
"explore_contract without explore_prompt: the bounds describe an exploration that "
|
||||
"would never run"
|
||||
)
|
||||
if raw_contract is None:
|
||||
raise InvocationRefused(
|
||||
"explore_prompt without explore_contract: an exploration's bounds are never defaulted "
|
||||
"(an omitted cap falls back to an unbounded loop)"
|
||||
)
|
||||
if not kwargs.get("bundle_dir"):
|
||||
raise InvocationRefused(
|
||||
"explore_prompt without bundle_dir: the exploration navigates knowledge bases, and "
|
||||
"with none configured it would spend its budget reading nothing"
|
||||
)
|
||||
if not isinstance(raw_contract, dict):
|
||||
raise InvocationRefused("explore_contract must be a JSON object")
|
||||
contract = ExplorationContract(**raw_contract) # ValidationError subclasses ValueError -> 400
|
||||
if contract.enable_plan_review:
|
||||
raise InvocationRefused(
|
||||
"explore_contract sets enable_plan_review, but this surface has no reviewer to answer "
|
||||
"it: the synchronous plan review would block the request on nobody"
|
||||
)
|
||||
result = await explore(
|
||||
str(prompt),
|
||||
contract=contract,
|
||||
bundle_dirs=(kwargs["bundle_dir"],),
|
||||
profile=kwargs["profile"],
|
||||
)
|
||||
return result.mandate
|
||||
|
||||
|
||||
def _response_payload(result: RunResult) -> dict[str, Any]:
|
||||
|
|
@ -132,8 +194,14 @@ def _response_payload(result: RunResult) -> dict[str, Any]:
|
|||
async def invoke(payload: Any) -> dict[str, Any]:
|
||||
"""One invocation: validate → ``run_project`` → outbox-shaped response payload.
|
||||
``run_project`` is resolved through this module's namespace at call time (the test
|
||||
seam). ``live_dry_run`` is not on the whitelist, so the union narrows to RunResult."""
|
||||
project_id, kwargs = _run_kwargs(payload)
|
||||
seam). ``live_dry_run`` is not on the whitelist, so the union narrows to RunResult.
|
||||
|
||||
With ``explore_prompt`` the exploration runs FIRST and its mandate is what the pipeline then
|
||||
evaluates — level 2 and 3 of the guarantee table are unchanged, and the exploration itself
|
||||
still writes nothing."""
|
||||
project_id, kwargs, consumed = _run_kwargs(payload)
|
||||
if consumed:
|
||||
kwargs["mandate"] = await _shaped_mandate(consumed, kwargs)
|
||||
result = await run_project(project_id, **kwargs)
|
||||
assert isinstance(result, RunResult)
|
||||
return _response_payload(result)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue