feat(mcp): the bundle's map, and a working method that reads it first

C5. `bundlemap.build_map` lists a bundle in its own words: one line per
source document -- its name, then the titles of its concepts in document
order -- and documents whose names differ only in their numbers (a changelog
per release, a note per week) as ONE line: the name with every number as `#`,
the count, the first and last by natural order, and the titles across the
series that are words. `SERIES_MIN` = 5, at most `TITLES_PER_LINE` = 24 titles
a line, the lines capped at `MAP_MAX_BYTES` = 48 000 together with
`lines_truncated` counting the rest. Derived on every call, never stored.

The card (`okf card`, `okf_describe`) carries it as `map` and no longer
carries `source_files`: that list named every document a second time with no
series collapsed, a quarter of the reply on a large bundle, for names the map
already carries. Chose removal over keeping both because the describe reply
has to fit a client's tool-reply limit and the map says more.

The working method now reads: take the map first (`okf card`, or
`okf_describe`), write two to four sub-questions in its words, and send them
in ONE call (`--question` repeated, or `okf_ask` `questions`). Changed in the
skill template, the generated `skills/okf-consume`, and the server
instructions (held under the 2 KB a client keeps). The regeneration recipe for
`skills/okf-consume` gains `--for-bundle`: since v1.1 the generator writes the
generic skill by default, so the recipe as published produced the other file.

A test holds a four-sub-question `okf_ask` over concepts far over the passage
size under 50 000 bytes of reply text (25 000 tokens at a pessimistic two
bytes a token). The real-collection measurements are kept in local state.

Suite on a clean tree after `git add`: 2429 passed, 2 skipped, 4 xfailed.
ruff, ruff format, mypy --strict clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-21 08:37:12 +02:00
commit da6faf8776
9 changed files with 450 additions and 64 deletions

View file

@ -247,3 +247,39 @@ def test_okf_ask_refuses_both_forms_at_once(bundle: Path) -> None:
with pytest.raises(mcp_server.ToolError) as raised:
mcp_server.call_ask(surface, {"question": HEATING, "questions": [ENGINE]})
assert raised.value.code == "question_ambiguous"
def test_four_subquestions_over_large_concepts_stay_under_a_tool_reply(tmp_path: Path) -> None:
"""The worst case the passage cut exists for: every delivered concept is far
over `PASSAGE_CHARS`. A client keeps a tool reply of 25 000 tokens; at a
pessimistic two bytes a token that is 50 000 bytes of text."""
words = ("stove", "engine", "apples", "roof")
spec = retrieval.BundleSpec(
"large-concepts",
tuple(
retrieval.DocumentSpec(
f"doc-{word}",
f"doc-{word}.md",
tuple(
retrieval.ConceptSpec(
slug=f"part-{n}",
title=f"{word.title()} part {n}",
body=f"The {word} is described here in detail. ",
repeat=400,
)
for n in range(3)
),
)
for word in words
),
)
bundle = retrieval.build_bundle(tmp_path / "bundle", spec)
surface = mcp_server.build_surface(bundle=bundle, roots=())
result = mcp_server.call_ask(
surface, {"questions": [f"How is the {w} described?" for w in words]}
)
payload = result["answers"][0]["payload"]
assert len(payload["excerpts"]) == consume.DEFAULT_K
assert all(len(excerpt["text"]) > consume.PASSAGE_CHARS // 2 for excerpt in payload["excerpts"])
text = mcp_server._tool_result(result)["content"][0]["text"]
assert len(text.encode("utf-8")) < 50_000