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
92 lines
3.7 KiB
Python
92 lines
3.7 KiB
Python
"""Expose the framework-agnostic retriever to the agents as a citation-bearing data source.
|
|
|
|
**MVP (GA-safe) path:** an in-process GA ``FunctionTool`` over ``retrieval.retrieve()`` whose
|
|
chunks the orchestrator maps into ``provenance.Citation`` — zero new runtime dependency,
|
|
D7-portable. Path-security lives in ``retrieval.py`` (Step 5).
|
|
|
|
Because ``mcp`` resolved as a GA release in Step 1, this module ALSO exposes a thin custom
|
|
**stdio MCP server** (FastMCP) returning the SAME chunks as ``structuredContent`` — honoring
|
|
the CLAUDE.md "data access via MCP" convention. Both paths wrap the identical ``retrieve()``
|
|
core via ``retrieve_chunks``, so the citation seam (``{file, locator, snippet, score}``) is
|
|
byte-identical whether the agents reach it in-process or over stdio.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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
|
|
|
|
|
|
def _chunk_to_dict(c: RetrievedChunk) -> dict[str, Any]:
|
|
return {
|
|
"file": c.file,
|
|
"locator": {"start_index": c.locator.start_index, "end_index": c.locator.end_index},
|
|
"snippet": c.snippet,
|
|
"score": c.score,
|
|
}
|
|
|
|
|
|
def retrieve_chunks(query: str, docs_dir: str, top_k: int = 3) -> list[dict[str, Any]]:
|
|
"""The shared data-source call: retrieve citation-ready chunks as plain dicts (the
|
|
``structuredContent`` shape). Identical on the in-process tool path and the MCP path."""
|
|
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"]
|
|
return Citation(
|
|
file=d["file"],
|
|
locator=TextSpan(start_index=loc["start_index"], end_index=loc["end_index"]),
|
|
snippet=d["snippet"],
|
|
)
|
|
|
|
|
|
def make_retrieval_tool(docs_dir: str, *, top_k: int = 3) -> FunctionTool:
|
|
"""Build the GA in-process data-source tool bound to a docs folder. The agents call it;
|
|
the orchestrator maps the returned chunks into ``provenance.Citation``."""
|
|
|
|
@tool(
|
|
name="retrieve_cost_docs",
|
|
description="Retrieve cited snippets from the project's cost documentation.",
|
|
)
|
|
def retrieve_cost_docs(query: str) -> list[dict[str, Any]]:
|
|
return retrieve_chunks(query, docs_dir, top_k)
|
|
|
|
return retrieve_cost_docs
|
|
|
|
|
|
def build_mcp_server(docs_dir: str, *, top_k: int = 3) -> Any:
|
|
"""Thin custom stdio MCP server (FastMCP) exposing the same ``retrieve()`` core as
|
|
``structuredContent``. Run via ``server.run()`` for stdio; consumed by an
|
|
``MCPStdioTool``. The tool delegates to ``retrieve_chunks`` so its shape matches the
|
|
in-process path exactly."""
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
server = FastMCP("portfolio-optimiser-docs")
|
|
|
|
@server.tool()
|
|
def retrieve_cost_docs(query: str) -> list[dict[str, Any]]:
|
|
return retrieve_chunks(query, docs_dir, top_k)
|
|
|
|
return server
|