feat(fase2): wire Step-1 ExpeL retrieval into the hypothesis prompt
Closes maalbilde §5 gap #1 (the one missing "feedback-into-prompt" dataflow) for the OKF-bundle path. Before, ExpeL was computed AFTER generation into a discarded SessionContext, so a prior verdict could not influence any hypothesis (context_providers=0). - New okf.py: framework-neutral OKF bundle navigation (index + frontmatter + cross-links), pure stdlib, no agent_framework/mcp (D7-portable), enforced by test_okf_is_maf_free. - verdicts.py: seed_store_from_bundle + bundle_candidate_features build the ExpeL substrate + the pre-hypothesis query key from a bundle. - run_project(bundle_dir=...): folds the candidate's prior verdicts into the generation context BEFORE generate_via_llm; the road path is unchanged. Load-bearing (maalbilde §7): test_step1_expel_loadbearing proves a prior verdict reaches the hypothesis prompt and goes RED when the fold is detached (shown via TDD red->green). The marker is the minted verdict id (content hash) because docs_dir==bundle_dir lets keyword chunk-stuffing leak the realization rate; clean layer separation is Fase 2b. Suite 121->133 passed; mypy + ruff check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
This commit is contained in:
parent
2b1a1832ef
commit
d6d83d42b5
9 changed files with 529 additions and 7 deletions
|
|
@ -44,11 +44,13 @@ from portfolio_optimiser.ir import SavingsProposal
|
|||
from portfolio_optimiser.provenance import ProvenanceStamp
|
||||
from portfolio_optimiser.reference_domain import Project, load_reference_projects
|
||||
from portfolio_optimiser.validator import Rejection, ValidatedProposal
|
||||
from portfolio_optimiser import okf
|
||||
from portfolio_optimiser.verdicts import (
|
||||
ExpeLContextProvider,
|
||||
ProposalFeatures,
|
||||
Verdict,
|
||||
VerdictStore,
|
||||
bundle_candidate_features,
|
||||
capture_verdict,
|
||||
)
|
||||
from portfolio_optimiser.workflow import fresh_workflow
|
||||
|
|
@ -114,6 +116,34 @@ def _project_by_id(project_id: str) -> Project:
|
|||
raise ValueError(f"unknown project_id: {project_id!r}")
|
||||
|
||||
|
||||
def _project_from_bundle(bundle_dir: str, project_id: str) -> Project:
|
||||
"""Derive a minimal ``Project`` from an OKF bundle (so a bundle the loop runs need NOT be a
|
||||
road reference-domain project). Only ``id`` + ``name`` reach the generation prompt
|
||||
(``generate._build_messages``), so ``cost_items`` is empty and ``verdict_input`` is unused here
|
||||
(the Layer-2 decision flows via the ``verdict_input`` argument). Fail-fast: the bundle's IR
|
||||
``project_id`` must match the requested id."""
|
||||
ir = okf.load_ir_projection(bundle_dir)
|
||||
if ir["project_id"] != project_id:
|
||||
raise ValueError(f"bundle project_id {ir['project_id']!r} != requested {project_id!r}")
|
||||
project_file = next(
|
||||
(f for f in okf.navigate_bundle(bundle_dir).files if f.type == "project"), None
|
||||
)
|
||||
name = (
|
||||
project_file.frontmatter.get("title", project_id).strip('"')
|
||||
if project_file is not None
|
||||
else project_id
|
||||
)
|
||||
return Project(
|
||||
id=project_id,
|
||||
name=name,
|
||||
description="",
|
||||
currency="NOK",
|
||||
cost_items=(),
|
||||
docs_dir=bundle_dir,
|
||||
verdict_input={},
|
||||
)
|
||||
|
||||
|
||||
def _features_of(proposal: SavingsProposal) -> ProposalFeatures:
|
||||
return ProposalFeatures(
|
||||
affected_codes=frozenset(item.code for item in proposal.affected_items),
|
||||
|
|
@ -136,6 +166,7 @@ async def run_project(
|
|||
*,
|
||||
docs_dir: str,
|
||||
verdict_input: dict[str, str],
|
||||
bundle_dir: str | None = None,
|
||||
store: VerdictStore | None = None,
|
||||
client_factory: Callable[[str], BaseChatClient] | None = None,
|
||||
max_rounds: int = 3,
|
||||
|
|
@ -147,8 +178,11 @@ async def run_project(
|
|||
) -> RunResult:
|
||||
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
|
||||
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
|
||||
(Layer-2). Raises ``pydantic.ValidationError`` on a bad contract and ``BudgetExceeded``
|
||||
when the token/round cap is crossed."""
|
||||
(Layer-2). ``bundle_dir`` (Fase 2a) makes the run OKF-bundle-driven: the project is derived
|
||||
from the bundle and, before generation, the candidate's prior verdicts in ``store`` are folded
|
||||
into the hypothesis prompt (Step-1 ExpeL wiring, målbilde §5/§7). Raises
|
||||
``pydantic.ValidationError`` on a bad contract and ``BudgetExceeded`` when the token/round cap
|
||||
is crossed."""
|
||||
# 1. Fail-fast: validate ALL contracts (incl. the verdict-feedback shape) before any client.
|
||||
load_contracts(
|
||||
{"docs_dir": docs_dir, "top_k": top_k},
|
||||
|
|
@ -156,8 +190,13 @@ async def run_project(
|
|||
verdict_input,
|
||||
)
|
||||
|
||||
# 2-3. Project + cited chunks (first-class provenance citations).
|
||||
project = _project_by_id(project_id)
|
||||
# 2-3. Project + cited chunks (first-class provenance citations). An OKF-bundle run derives its
|
||||
# project from the bundle (need not be a road reference-domain project).
|
||||
project = (
|
||||
_project_from_bundle(bundle_dir, project_id)
|
||||
if bundle_dir is not None
|
||||
else _project_by_id(project_id)
|
||||
)
|
||||
chunks = retrieve_chunks("cost saving measure", docs_dir, top_k)
|
||||
citations = [chunk_dict_to_citation(c) for c in chunks]
|
||||
if not citations:
|
||||
|
|
@ -189,6 +228,17 @@ async def run_project(
|
|||
debate_output = _debate_text(result)
|
||||
gen_context = debate_output or context
|
||||
|
||||
# Step-1 ExpeL wiring (Fase 2a, målbilde §5/§7): fold the candidate's prior verdicts INTO the
|
||||
# hypothesis context BEFORE generation, keyed on the OKF bundle's candidate features (available
|
||||
# pre-hypothesis). THIS is the one missing dataflow — previously ExpeL was computed
|
||||
# post-generation into a discarded SessionContext (step 7 below), so a prior verdict could not
|
||||
# reach the next hypothesis. Bundle-driven path with a populated store only; the road path is
|
||||
# untouched (its post-hoc, proposal-keyed retrieval below is unchanged).
|
||||
if bundle_dir is not None and store is not None and store.verdicts:
|
||||
expel_query = bundle_candidate_features(bundle_dir)
|
||||
fewshot = ExpeLContextProvider(store, expel_query, k=top_k).format_fewshot()
|
||||
gen_context = f"{fewshot}\n\n{gen_context}"
|
||||
|
||||
# 5. Structured candidate -> blocking validation; token bound = the meter in this loop.
|
||||
proposer_client = factory("proposer")
|
||||
outcome = await generate_via_llm(proposer_client, project, gen_context, meter)
|
||||
|
|
@ -211,8 +261,11 @@ async def run_project(
|
|||
token_usage=meter.tokens,
|
||||
)
|
||||
|
||||
# 7. ExpeL: surface prior verdicts for this proposal (exercises the two-arg
|
||||
# extend_instructions injection on a real SessionContext — the learning loop).
|
||||
# 7. ExpeL (regression guard + traceability): exercises the two-arg extend_instructions
|
||||
# injection on a REAL SessionContext (the Critical Fase-1 GA-signature guard), and surfaces
|
||||
# the proposal-keyed retrieval for RunResult.retrieved. On the bundle path the load-bearing
|
||||
# ExpeL->prompt dataflow already happened pre-generation (above); this block's SessionContext
|
||||
# is NOT what reaches the prompt.
|
||||
store = store if store is not None else VerdictStore(verdicts=[])
|
||||
features = _features_of(proposal)
|
||||
provider = ExpeLContextProvider(store, features, k=top_k)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue