feat(card): a folder shows every bundle under it, as the server does
`okf card <folder>` prints `okf_list` and `okf_describe` with no bundle named, joined by `mcp_server.overview`, and computes nothing of its own: one source, two doors. Chosen over a separate `--root` flag because the skill's first step must be the same command whether it was pointed at a bundle or at a folder of them; the rule that decides is discovery's own (a directory carrying an `index.md` is a bundle). A bundle path prints its card as before. v1.1 order F, part F1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3d149f955a
commit
df83c65e32
5 changed files with 212 additions and 4 deletions
|
|
@ -1523,6 +1523,15 @@ R761 **8** (S1-S6 + KP + KN), vegnormal **32** questions / **43**
|
||||||
rather than answering from stale numbers — safe to keep, not enough to keep
|
rather than answering from stale numbers — safe to keep, not enough to keep
|
||||||
default. Both forms now carry `## Working method` and `## Answer form`,
|
default. Both forms now carry `## Working method` and `## Answer form`,
|
||||||
required by `okf check` and by the contract's SS 2.5/2.6.
|
required by `okf check` and by the contract's SS 2.5/2.6.
|
||||||
|
- **`okf card <folder>` SEES EVERY BUNDLE UNDER A FOLDER (v1.1 F1,
|
||||||
|
2026-09-21).** Until then only the server (`okf mcp --root`) could; the
|
||||||
|
generic skill read the one bundle it was pointed at. A path that carries no
|
||||||
|
`index.md` is a folder (`mcp_server.is_bundle`, discovery's own rule), and the
|
||||||
|
command prints `mcp_server.overview`: `okf_list` and `okf_describe` with no
|
||||||
|
bundle named, joined, computing nothing of its own -- one source, two doors,
|
||||||
|
held by a test comparing the printed bytes against the two functions. A
|
||||||
|
bundle path prints its card exactly as before. Tests over two invented
|
||||||
|
bundles: `tests/test_folder_of_bundles.py`.
|
||||||
- **`okf card <bundle>` and the generic skill are the one-to-many form.** The card is one bundle's identity, concept count,
|
- **`okf card <bundle>` and the generic skill are the one-to-many form.** The card is one bundle's identity, concept count,
|
||||||
conditional-field counts and whole-bundle cost as JSON, **DERIVED on every run
|
conditional-field counts and whole-bundle cost as JSON, **DERIVED on every run
|
||||||
and never written into the bundle** -- storing it would move the bytes of all
|
and never written into the bundle** -- storing it would move the bytes of all
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -1397,6 +1397,21 @@ sub-questions in. The lines are capped at 48 000 bytes together
|
||||||
(`lines_truncated` counts what a larger bundle leaves out), and a line lists at
|
(`lines_truncated` counts what a larger bundle leaves out), and a line lists at
|
||||||
most 24 titles. The map replaced the card's flat `source_files` list.
|
most 24 titles. The map replaced the card's flat `source_files` list.
|
||||||
|
|
||||||
|
**Point it at a folder and it sees every bundle under it** (since v1.1 F):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
okf card ~/okf # every bundle under the folder, each with its card
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints what the server's `okf_list` and `okf_describe` give with no bundle
|
||||||
|
named -- the listing (id, ref, concept count, directory), the directories that
|
||||||
|
look like a bundle and cannot be read as one, and every bundle's card -- and it
|
||||||
|
computes nothing of its own: the command calls the server's two functions. A
|
||||||
|
bundle added or rebuilt under the folder is in the next run's answer with
|
||||||
|
nothing regenerated. Pointed at one bundle, it prints that bundle's card as
|
||||||
|
before; the command decides which it was given by the same rule discovery uses
|
||||||
|
(a directory carrying an `index.md` is a bundle).
|
||||||
|
|
||||||
`okf skill <bundle> --for-bundle` still writes the per-bundle form, with the
|
`okf skill <bundle> --for-bundle` still writes the per-bundle form, with the
|
||||||
identity and the numbers measured into the text — which is exactly what makes
|
identity and the numbers measured into the text — which is exactly what makes
|
||||||
that file stale the moment the bundle is rebuilt. It refuses out loud when it
|
that file stale the moment the bundle is rebuilt. It refuses out loud when it
|
||||||
|
|
|
||||||
|
|
@ -512,6 +512,35 @@ def call_describe(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, A
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_bundle(path: Path) -> bool:
|
||||||
|
"""Whether `path` IS a bundle rather than a folder that may hold some.
|
||||||
|
|
||||||
|
The rule discovery already uses to stop descending: a directory carrying
|
||||||
|
an `index.md`. The command line's two doors ask it to decide which shape
|
||||||
|
they were pointed at, so a reader never has to say which one it holds.
|
||||||
|
"""
|
||||||
|
return (path / "index.md").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def overview(surface: Surface) -> dict[str, Any]:
|
||||||
|
"""Every bundle under the roots and each one's card, as the command line
|
||||||
|
prints it for a FOLDER (`okf card <folder>`).
|
||||||
|
|
||||||
|
It is `okf_list` and `okf_describe` with no bundle named, joined, and it
|
||||||
|
computes nothing of its own: one source, two doors. The listing carries
|
||||||
|
what the cards do not -- the directory each bundle sits in, and the
|
||||||
|
directories that look like a bundle and cannot be read as one.
|
||||||
|
"""
|
||||||
|
listing = call_list(surface, {})
|
||||||
|
described = call_describe(surface, {})
|
||||||
|
return {
|
||||||
|
"shape": listing["shape"],
|
||||||
|
"bundles": listing["bundles"],
|
||||||
|
"unreadable": listing["unreadable"],
|
||||||
|
"cards": described["cards"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _questions(arguments: Mapping[str, Any]) -> list[str]:
|
def _questions(arguments: Mapping[str, Any]) -> list[str]:
|
||||||
"""`question` (one string) or `questions` (a list), never both.
|
"""`question` (one string) or `questions` (a list), never both.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1003,15 +1003,31 @@ def card_main(argv: list[str] | None = None) -> int:
|
||||||
prog="okf card",
|
prog="okf card",
|
||||||
description=(
|
description=(
|
||||||
"Print one bundle's identity, concept count, conditional-field counts "
|
"Print one bundle's identity, concept count, conditional-field counts "
|
||||||
"and whole-bundle cost as JSON. Derived from the bundle on every run."
|
"and whole-bundle cost as JSON -- or, for a folder, every bundle under "
|
||||||
|
"it with its card. Derived from the bundles on every run."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"bundle",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"the OKF bundle to describe, or a FOLDER: then every bundle under it "
|
||||||
|
"is listed with its card, as the server's `okf_list` and "
|
||||||
|
"`okf_describe` give them"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument("bundle", type=Path, help="the OKF bundle to describe")
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
from .mcp_server import card as build_card
|
from . import mcp_server
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = build_card(args.bundle.resolve(), profile=okf_consume.DEFAULT_PROFILE)
|
if args.bundle.is_dir() and not mcp_server.is_bundle(args.bundle):
|
||||||
|
surface = mcp_server.build_surface(bundle=None, roots=[args.bundle])
|
||||||
|
payload = mcp_server.overview(surface)
|
||||||
|
else:
|
||||||
|
payload = mcp_server.card(args.bundle.resolve(), profile=okf_consume.DEFAULT_PROFILE)
|
||||||
|
except mcp_server.ToolError as exc:
|
||||||
|
print(f"refused ({exc.code}): {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
except okf_consume.ConsumeError as exc:
|
except okf_consume.ConsumeError as exc:
|
||||||
print(f"refused ({exc.code}): {exc}", file=sys.stderr)
|
print(f"refused ({exc.code}): {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
139
tests/test_folder_of_bundles.py
Normal file
139
tests/test_folder_of_bundles.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""One folder, every bundle under it: the command line's own door (v1.1 F).
|
||||||
|
|
||||||
|
The server has read a FOLDER of bundles since `okf mcp --root`; the generic
|
||||||
|
skill read one bundle at a time, the one it was pointed at, and could not see
|
||||||
|
the others. These tests hold the two command-line entries the skill now uses
|
||||||
|
-- `okf card <folder>` and `okf consume <folder>` -- to the server's OWN
|
||||||
|
functions: one source, two doors. Both corpora are invented here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from llm_ingestion_okf import mcp_server
|
||||||
|
from llm_ingestion_okf.cli import build
|
||||||
|
|
||||||
|
BAKERY = {
|
||||||
|
"surdeig.md": (
|
||||||
|
"# Surdeig\n\n"
|
||||||
|
"## Heving\n\n"
|
||||||
|
"Surdeigen hever i tolv timer ved romtemperatur før den formes.\n\n"
|
||||||
|
"## Steking\n\n"
|
||||||
|
"Brødet stekes i førti minutter på to hundre og tretti grader.\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
GARDEN = {
|
||||||
|
"tomater.md": (
|
||||||
|
"# Tomater\n\n"
|
||||||
|
"## Vanning\n\n"
|
||||||
|
"Tomatene vannes hver morgen, og jorda skal aldri tørke helt ut.\n\n"
|
||||||
|
"## Oppbinding\n\n"
|
||||||
|
"Plantene bindes opp til en stokk når de er tretti centimeter høye.\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle(tmp_path: Path, folder: Path, name: str, documents: dict[str, str]) -> Path:
|
||||||
|
source = tmp_path / f"src-{name}"
|
||||||
|
source.mkdir()
|
||||||
|
for file_name, text in documents.items():
|
||||||
|
(source / file_name).write_text(text, encoding="utf-8")
|
||||||
|
target = folder / name
|
||||||
|
build(source, target, bundle_id=name, okf_version="0.2")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def folder(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
tmp_path = tmp_path_factory.mktemp("folder-of-bundles")
|
||||||
|
root = tmp_path / "samlinger"
|
||||||
|
root.mkdir()
|
||||||
|
_bundle(tmp_path, root, "bakeri", BAKERY)
|
||||||
|
_bundle(tmp_path, root, "hage", GARDEN)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _okf(*argv: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "llm_ingestion_okf.cli", *argv],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _surface(folder: Path) -> mcp_server.Surface:
|
||||||
|
return mcp_server.build_surface(bundle=None, roots=[folder])
|
||||||
|
|
||||||
|
|
||||||
|
# --- F1: the overview -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_card_of_a_folder_names_every_bundle_under_it(folder: Path) -> None:
|
||||||
|
run = _okf("card", str(folder))
|
||||||
|
assert run.returncode == 0, run.stderr
|
||||||
|
overview = json.loads(run.stdout)
|
||||||
|
assert [card["bundle_id"] for card in overview["cards"]] == ["bakeri", "hage"]
|
||||||
|
assert [entry["bundle_id"] for entry in overview["bundles"]] == ["bakeri", "hage"]
|
||||||
|
assert overview["unreadable"] == []
|
||||||
|
assert overview["shape"] == "one-to-many"
|
||||||
|
# Each card carries the map, which is what the working method reads first.
|
||||||
|
assert all(card["map"] for card in overview["cards"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_card_of_a_folder_is_the_servers_own_listing_and_description(folder: Path) -> None:
|
||||||
|
"""One source: the bytes the command prints are the server's two replies."""
|
||||||
|
surface = _surface(folder)
|
||||||
|
listing = mcp_server.call_list(surface, {})
|
||||||
|
described = mcp_server.call_describe(surface, {})
|
||||||
|
overview = json.loads(_okf("card", str(folder)).stdout)
|
||||||
|
assert overview["bundles"] == listing["bundles"]
|
||||||
|
assert overview["unreadable"] == listing["unreadable"]
|
||||||
|
assert overview["cards"] == described["cards"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_card_of_one_bundle_is_unchanged(folder: Path) -> None:
|
||||||
|
"""Pointed at one bundle, the command prints that bundle's card, as before."""
|
||||||
|
bundle = folder / "bakeri"
|
||||||
|
run = _okf("card", str(bundle))
|
||||||
|
assert run.returncode == 0, run.stderr
|
||||||
|
card = json.loads(run.stdout)
|
||||||
|
assert card == mcp_server.card(bundle.resolve(), profile=mcp_server.okf_consume.DEFAULT_PROFILE)
|
||||||
|
assert "cards" not in card
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_folder_holding_no_bundle_is_refused_rather_than_empty(tmp_path: Path) -> None:
|
||||||
|
empty = tmp_path / "tom"
|
||||||
|
empty.mkdir()
|
||||||
|
run = _okf("card", str(empty))
|
||||||
|
assert run.returncode == 1
|
||||||
|
assert "bundle_none_served" in run.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_bundle_under_the_folder_is_reported(folder: Path, tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "med-feil"
|
||||||
|
root.mkdir()
|
||||||
|
for name in ("bakeri", "hage"):
|
||||||
|
(root / name).symlink_to(folder / name) # never followed: not listed
|
||||||
|
broken = root / "odelagt"
|
||||||
|
broken.mkdir()
|
||||||
|
(broken / "index.md").write_text("---\ntitle: x\n---\n", encoding="utf-8")
|
||||||
|
real = root / "ekte"
|
||||||
|
real.mkdir()
|
||||||
|
for source in (folder / "hage").rglob("*"):
|
||||||
|
target = real / source.relative_to(folder / "hage")
|
||||||
|
if source.is_dir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
target.write_bytes(source.read_bytes())
|
||||||
|
overview = json.loads(_okf("card", str(root)).stdout)
|
||||||
|
assert [card["bundle_id"] for card in overview["cards"]] == ["hage"]
|
||||||
|
assert overview["unreadable"] == [
|
||||||
|
{"directory": "odelagt", "reason": "index.md declares no bundle_id"}
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue