portfolio-optimiser/src/portfolio_optimiser/datasource.py
Kjell Tore Guttormsen 7aa06d581f feat(prepass): the debate is handed the declared cut and the navigator tools are withdrawn [skip-docs]
[skip-docs]: CLI-flagget og README-blokka kommer i steg 6/7; `prepass_payload` er
foreloepig bare naabar for en bibliotek-kaller.

`run_project(prepass_payload=...)` forgrener bundle-armen. UTEN payload er hver linje
uendret -- pekeren, de fire verktoeyene, siteringer over hele den navigerte basen. MED
et payload faar debatten et DEKLARERT KUTT og verktoeyene trekkes (SS 2.2: kontekst
pre-passet holdt tilbake ble holdt tilbake med vilje; en debatt som holder BEGGE er fri
til aa gaa rundt kuttet den nettopp erklaerte).

Nekten PROPAGERER, aldri en stille degradering tilbake til pekeren -- `load_mandate`s
regel. Maalt paa NULL modellkall, ikke paa exit-koden.

`delivered == 0` nektes ved NAVN foer debatten, med nevnerne, spoersmaalet og ref-en
sitert: maalt er den tilstanden bare naabar naar hvert konsept feilet leksikalsk (den
andre tomme saken nekter produsenten selv), altsaa bevis for FRAVAER. Uten den falt
kjoeringen gjennom til `run.py`s siteringsvakt, hvis melding navngir `docs_dir` -- som
er `None` paa denne stien.

`bundle_excerpt_citations` (datasource) siterer de LEVERTE konseptene alene: et stempel
som siterer hele korpuset for et forslag som saa fire, gjenoppfinner den uerklaerte
paastanden sømmen finnes for. Kroppen tas fra den NAVIGERTE `BundleFile`, ikke fra
payloadets `text` -- den leverte teksten er NFC-normalisert med strippet hale, saa en
locator over den ville ikke indeksert fila den navngir. Deler dermed ogsaa
`bundle_citations`' verdict-eksklusjon i stedet for aa gjenta den.

MCP-appenden ligger BEVISST under forgreningen: dette trekker navigatoerverktoeyene,
ikke verktoeylista. Maalt: en tom liste naar traaden som `tools: None`, saa ingen
uproevd tom-array-form innfoeres.

`RunResult.prepass` og `DryRunReport.prepass` DEFAULTER (`skipped_links`-halvdelen:
`None` er det sanne utsagnet "ingen payload ble gitt"), bundet i BEGGE grener saa ingen
`NameError` venter paa veg-stien. `ProvenanceStamp` er BEVISST urørt -- stempelet
beskriver gaten som doemte EN kandidat, dette er et RUN-nivaa-faktum om hva kjoeringen
i det hele tatt fikk lese.

Tilbaketrekkingen asserteres paa `fresh_workflow(tools=...)`, ALDRI paa
`debate_tool_calls`: maalt er det sporet allerede tomt MED alle fire verktoeyene, fordi
en `ScriptedChatClient` aldri emitterer et verktoeykall. En arm skrevet paa det kan
ikke skille de to implementasjonene. GATE, IKKE VEGG: en egen arm beviser at den gatede
ExpeL-folden fortsatt naar hypotese-prompten (0.82) under et payload.

1440 passed / 5 skipped (fra 1425/5, +15, 0 fjernet). ruff + mypy rene. Golden
`shasum -a 1` av INNHOLDET = ea8c534773acdbe41ae68f2c55724d69aaf8be4f, BYTE-UENDRET.

Co-Authored-By: Claude <claude-opus-5>
2026-09-07 11:10:54 +02:00

131 lines
5.5 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 collections.abc import Sequence
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 bundle_excerpt_citations(bundle: Bundle, concept_ids: Sequence[str]) -> list[Citation]:
"""Citations for exactly the concepts a pre-pass DELIVERED (order 20260907T080223Z).
``bundle_citations`` above cites the whole navigated base. Under a declared cut that would
have the run's stamp claim every concept for a proposal that saw a handful — the same
undeclared claim the pre-pass seam exists to remove, one artefact over.
The body comes from the NAVIGATED ``BundleFile``, never from the payload's ``text``: the
delivered text is NFC-normalised with trailing whitespace stripped, so a locator over it would
not index the file it names. Reusing ``context_files`` also means this shares
``bundle_citations``' verdict-layer exclusion rather than restating it.
An id the navigation did not reach raises: ``prepass.verify_against_bundle`` has already
resolved every one of them on disk, so a miss here means the two disagree about the base, and
a citation list that silently drops entries would under-report the very denominator this seam
publishes.
"""
by_name = {f.name: f for f in bundle.context_files}
citations: list[Citation] = []
for concept_id in concept_ids:
name = concept_id + ".md"
try:
file = by_name[name]
except KeyError as error:
raise ValueError(
f"the pre-pass delivered {concept_id!r}, which this bundle's navigation does not "
"reach; the payload and the navigated base disagree"
) from error
citations.append(
Citation(
file=name,
locator=TextSpan(start_index=0, end_index=len(file.body)),
snippet=file.body,
)
)
return citations
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