feat(fase2b): OKF-navigated bundle context replaces chunk-stuffing
Closes the honest Fase 2a limitation: docs_dir==bundle_dir let keyword
chunk-stuffing leak the verdict's realization rate ("0.82") into the debate /
generation prompt regardless of the ExpeL fold (it surfaced from both
verdict-led-fro.md AND golden.json). The realization signal now reaches the
hypothesis prompt ONLY via the gated ExpeL fold.
- okf.py: bundle_context() + Bundle.context_files render the navigated bundle
(index + frontmatter + cross-links) as the agent read-context, EXCLUDING
type: verdict (maalbilde §2/§4). Pure stdlib, still MAF-free.
- datasource.py: bundle_citations() derives first-class citations from the
navigated non-verdict files.
- run_project: on the bundle path context + citations + debate tools come from
navigation (tools=[]; navigation replaces query-time RAG); the road path keeps
chunk-stuffing unchanged.
Load-bearing (maalbilde §7): the marker is upgraded from the minted verdict id
to the realization signal itself. The empty-store control now asserts "0.82"
reaches NO prompt — RED against the pre-2b chunk-stuffing path, green after
navigation (TDD red->green). New okf-level test_bundle_context_excludes_verdict_layer
guards the seam directly.
Suite 133->134 passed, 4 skipped; mypy + ruff check clean. Reverted unrelated
ruff-format drift (backends/budget/verdicts/test_contracts) to keep the diff
surgical.
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
812db2363e
commit
8814a698c2
7 changed files with 117 additions and 29 deletions
|
|
@ -17,6 +17,7 @@ from typing import Any
|
|||
|
||||
from agent_framework import FunctionTool, tool
|
||||
|
||||
from portfolio_optimiser.okf import Bundle
|
||||
from portfolio_optimiser.provenance import Citation
|
||||
from portfolio_optimiser.retrieval import RetrievedChunk, TextSpan, retrieve
|
||||
|
||||
|
|
@ -36,6 +37,21 @@ def retrieve_chunks(query: str, docs_dir: str, top_k: int = 3) -> list[dict[str,
|
|||
return [_chunk_to_dict(c) for c in retrieve(query, docs_dir, top_k)]
|
||||
|
||||
|
||||
def bundle_citations(bundle: Bundle) -> list[Citation]:
|
||||
"""First-class citations for the OKF-navigated bundle context: one ``Citation`` per non-verdict
|
||||
concept file (exactly the files ``okf.bundle_context`` renders). Whole-file locators, exact by
|
||||
construction (``snippet == body[start:end]``). The verdict layer is deliberately uncited — it is
|
||||
not cost documentation; it enters only via the gated ExpeL fold."""
|
||||
return [
|
||||
Citation(
|
||||
file=f.name,
|
||||
locator=TextSpan(start_index=0, end_index=len(f.body)),
|
||||
snippet=f.body,
|
||||
)
|
||||
for f in bundle.context_files
|
||||
]
|
||||
|
||||
|
||||
def chunk_dict_to_citation(d: dict[str, Any]) -> Citation:
|
||||
"""Map a structuredContent chunk dict into a first-class ``provenance.Citation``."""
|
||||
loc = d["locator"]
|
||||
|
|
|
|||
|
|
@ -84,6 +84,13 @@ class Bundle:
|
|||
"""Every ``type: verdict`` file (the ExpeL seeds the Step-1 wiring retrieves)."""
|
||||
return [f for f in self.files if f.type == "verdict"]
|
||||
|
||||
@property
|
||||
def context_files(self) -> list[BundleFile]:
|
||||
"""The non-index, non-``verdict`` concept files — the bodies that form the agent context.
|
||||
The verdict layer is deliberately EXCLUDED: prior verdicts reach the hypothesis prompt only
|
||||
through the gated ExpeL fold, never by stuffing them into the read-context (målbilde §4)."""
|
||||
return [f for f in self.files if f.name != _INDEX_NAME and f.type != "verdict"]
|
||||
|
||||
@property
|
||||
def hypothesis(self) -> BundleFile | None:
|
||||
"""The candidate ``type: hypothesis`` file, if present."""
|
||||
|
|
@ -123,6 +130,19 @@ def navigate_bundle(bundle_dir: str) -> Bundle:
|
|||
return Bundle(dir=bundle_dir, files=tuple(files))
|
||||
|
||||
|
||||
def bundle_context(bundle: Bundle) -> str:
|
||||
"""Render a navigated bundle as agent read-context via progressive disclosure: the ``index.md``
|
||||
summary, then each concept file as ``## {type}: {title}\\n{body}``. ``type: verdict`` files are
|
||||
EXCLUDED (målbilde §2 step 1 / §4: navigation, not chunk-stuffing — the verdict layer folds in
|
||||
only via the gated ExpeL retrieval). Deterministic: index first, then context files in
|
||||
navigation order; empty sections are dropped."""
|
||||
sections = [bundle.index_summary]
|
||||
for f in bundle.context_files:
|
||||
title = f.frontmatter.get("title", f.name).strip('"')
|
||||
sections.append(f"## {f.type or 'document'}: {title}\n{f.body}")
|
||||
return "\n\n".join(s for s in sections if s.strip())
|
||||
|
||||
|
||||
def load_ir_projection(bundle_dir: str, name: str = _IR_PROJECTION) -> dict[str, Any]:
|
||||
"""Load the bundle's IR projection (``validator-input.json`` by default): the candidate
|
||||
measure's cost-IR (``measure``, ``affected_items``, ``claimed_saving_nok``) — the
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from portfolio_optimiser.backends import Profile, get_backend, resolve_model
|
|||
from portfolio_optimiser.budget import Budget, BudgetMiddleware, TokenMeter
|
||||
from portfolio_optimiser.contracts import load_contracts
|
||||
from portfolio_optimiser.datasource import (
|
||||
bundle_citations,
|
||||
chunk_dict_to_citation,
|
||||
make_retrieval_tool,
|
||||
retrieve_chunks,
|
||||
|
|
@ -116,18 +117,20 @@ 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:
|
||||
def _project_from_bundle(
|
||||
bundle_dir: str, project_id: str, *, bundle: okf.Bundle | None = None
|
||||
) -> 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."""
|
||||
``project_id`` must match the requested id. ``bundle`` reuses an already-navigated bundle to
|
||||
avoid a second navigation."""
|
||||
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
|
||||
)
|
||||
nav = bundle if bundle is not None else okf.navigate_bundle(bundle_dir)
|
||||
project_file = next((f for f in nav.files if f.type == "project"), None)
|
||||
name = (
|
||||
project_file.frontmatter.get("title", project_id).strip('"')
|
||||
if project_file is not None
|
||||
|
|
@ -190,23 +193,29 @@ async def run_project(
|
|||
verdict_input,
|
||||
)
|
||||
|
||||
# 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]
|
||||
# 2-3. Project + agent read-context + first-class citations. A bundle run derives ALL THREE from
|
||||
# the navigated OKF bundle via progressive disclosure (verdict layer EXCLUDED — målbilde §2/§4),
|
||||
# NOT keyword chunk-stuffing; the road path keeps the chunk-retrieval data source. ``debate_tools``
|
||||
# is the query-time retrieval surface — empty on the bundle path (navigation already placed the
|
||||
# curated context in the prompt, and a docs_dir==bundle_dir tool would re-leak the verdict layer).
|
||||
if bundle_dir is not None:
|
||||
bundle = okf.navigate_bundle(bundle_dir)
|
||||
project = _project_from_bundle(bundle_dir, project_id, bundle=bundle)
|
||||
context = okf.bundle_context(bundle)
|
||||
citations = bundle_citations(bundle)
|
||||
debate_tools: list[Any] = []
|
||||
else:
|
||||
project = _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]
|
||||
context = "\n".join(c["snippet"] for c in chunks)
|
||||
debate_tools = [make_retrieval_tool(docs_dir, top_k=top_k)]
|
||||
if not citations:
|
||||
raise ValueError(f"no citable content in docs_dir: {docs_dir!r}")
|
||||
context = "\n".join(c["snippet"] for c in chunks)
|
||||
|
||||
# 4. Budget + maker-checker debate (round-capped; Layer-1 HITL optional).
|
||||
# The shared meter is driven on the debate's chat calls by the BudgetMiddleware
|
||||
# (the brief's named short-circuit mechanism); the retrieval tool exposes the
|
||||
# citation-bearing data source to the agents (no longer string-stuffed out-of-band).
|
||||
# 4. Budget + maker-checker debate (round-capped; Layer-1 HITL optional). The shared meter is
|
||||
# driven on the debate's chat calls by the BudgetMiddleware (the brief's named short-circuit
|
||||
# mechanism); ``debate_tools`` exposes the citation-bearing data source on the road path.
|
||||
meter = (
|
||||
meter
|
||||
if meter is not None
|
||||
|
|
@ -214,12 +223,11 @@ async def run_project(
|
|||
)
|
||||
factory = client_factory if client_factory is not None else _default_factory(profile)
|
||||
budget_mw = BudgetMiddleware(meter)
|
||||
retrieval_tool = make_retrieval_tool(docs_dir, top_k=top_k)
|
||||
debate = fresh_workflow(
|
||||
factory,
|
||||
max_rounds=max_rounds,
|
||||
enable_layer1_hitl=enable_layer1_hitl,
|
||||
tools=[retrieval_tool],
|
||||
tools=debate_tools,
|
||||
middleware=[budget_mw],
|
||||
)
|
||||
result = await debate.run(f"Find a cost-saving measure for {project.id}.\nContext:\n{context}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue