diff --git a/README.md b/README.md index 65cb508..a79f864 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,8 @@ llm-connect balance --json # Square text-to-image. The model and the quote are required when a cap is set. # Which model and quote: docs/image-model-selection.md -# Missing Hall of Helix portraits use that tree (general / Efficient). -llm-connect image plan --hall ../hall-of-helix --json -llm-connect image render-hall --hall ../hall-of-helix +# Render the one hall entry that is about to be published. Do not backfill the corpus. +llm-connect image render-hall --hall ../hall-of-helix --entry entries/.md # Receipt JSON on stdout; image bytes in --output. Credentials stay in the env. llm-connect image render \ --prompt "precise technical illustration of a gear, no text" \ diff --git a/contracts/functional/images.md b/contracts/functional/images.md index a79170e..4d7f6e3 100644 --- a/contracts/functional/images.md +++ b/contracts/functional/images.md @@ -43,12 +43,20 @@ comes from the explicit argument, `OPENROUTER_API_KEY`, or `apikey-openrouter.txt`. Only `b64_json` is accepted. A URL in the response is not fetched. -There is no default image model on `image render`. `image plan` and -`image render-hall` apply the 2026-10-08 decision tree in -[`docs/image-model-selection.md`](../../docs/image-model-selection.md). -A hall portrait that does not ask for vector output, editing, readable text, -or a brand system uses `google/gemini-nano-banana-2.1` with quote 0.04 USD -and a per-call cap of 0.08 USD. +There is no default image model on `image render`. `image render-hall` renders +one named entry, the seat about to be published, using the 2026-10-08 decision +tree in [`docs/image-model-selection.md`](../../docs/image-model-selection.md). +It does not walk the corpus. A hall portrait that does not ask for vector +output, editing, readable text, or a brand system uses +`google/gemini-nano-banana-2.1` with quote 0.04 USD and a per-call cap of +0.08 USD. + +`image plan` without `--entry` only inventories missing portraits. That +inventory is not a render queue. Existing seats are historic when a later +publisher drips them, and carry a historic remark on the post. Seats written +after that practice starts wait a short cooldown, then publish; the portrait +is rendered at that moment if the file is still absent. The cooldown +scheduler is not part of this library. ## CLI diff --git a/docs/image-model-selection.md b/docs/image-model-selection.md index c36d8ba..f9cd5b4 100644 --- a/docs/image-model-selection.md +++ b/docs/image-model-selection.md @@ -1,9 +1,10 @@ # Choosing an image model for `llm_connect.images` `llm_connect.images` still requires an explicit model on a one-off render. -Hall backfill does not: `selection_for_prompt` and `llm-connect image plan` -apply the tree below. A plain hall portrait — square technical illustration, -no logos, no readable text, one of many — is general / Efficient. +`llm-connect image render-hall --entry` applies the tree to the single seat +about to be published. A plain hall portrait — square technical illustration, +no logos, no readable text — is general / Efficient. The corpus is not +rendered ahead of that publication. **Source:** intelligence-radar topic digest `digests/topics/2026-10-08-1527_Image_Generation_with_Generative_AI_Digest.md` diff --git a/llm_connect/cli.py b/llm_connect/cli.py index ae23582..cb4b62e 100644 --- a/llm_connect/cli.py +++ b/llm_connect/cli.py @@ -185,9 +185,10 @@ def _build_parser() -> argparse.ArgumentParser: image_plan = image_commands.add_parser( "plan", - help="Choose models for hall entries that have no portrait file yet", + help="Show the model the tree would use. Does not render", ) image_plan.add_argument("--hall", type=Path, required=True, help="Hall of Helix checkout") + image_plan.add_argument("--entry", default=None, help="One entry filename or path. Omit only to inventory") image_plan.add_argument( "--tier", default=None, @@ -198,14 +199,13 @@ def _build_parser() -> argparse.ArgumentParser: image_hall = image_commands.add_parser( "render-hall", - help="Render missing hall portraits using the image decision tree", + help="Render the one hall entry that is about to be published", ) image_hall.add_argument("--hall", type=Path, required=True, help="Hall of Helix checkout") + image_hall.add_argument("--entry", required=True, help="The entry file about to be published") image_hall.add_argument("--tier", default=None, help="Override the general-branch tier") - image_hall.add_argument("--limit", type=int, default=None, help="Render at most this many missing portraits") - image_hall.add_argument("--dry-run", action="store_true", help="Print the plan and do not call a provider") + image_hall.add_argument("--dry-run", action="store_true", help="Print the choice and do not call a provider") image_hall.add_argument("--provider", default="openrouter", help="Image provider (default openrouter)") - image_hall.add_argument("--max-batch-cost-usd", type=float, default=None, help="Override the batch USD cap") image_hall.add_argument("--force", action="store_true", help="Replace an existing output file") image_hall.add_argument("--ledger", type=Path, default=None, help="Usage ledger JSONL path") image_hall.add_argument("--no-ledger", action="store_true", help="Do not append to the usage ledger") @@ -268,7 +268,7 @@ def _image_render(args: argparse.Namespace) -> int: def _image_plan(args: argparse.Namespace) -> int: try: - jobs = plan_missing_portraits(args.hall, tier=args.tier) + jobs = plan_missing_portraits(args.hall, tier=args.tier, entry=getattr(args, "entry", None)) except LLMError as exc: print(str(exc), file=sys.stderr) return 1 @@ -284,20 +284,19 @@ def _image_plan(args: argparse.Namespace) -> int: def _image_render_hall(args: argparse.Namespace) -> int: - if args.limit is not None and args.limit <= 0: - raise SystemExit("--limit must be positive") try: - jobs = plan_missing_portraits(args.hall, tier=args.tier) + jobs = plan_missing_portraits(args.hall, tier=args.tier, entry=args.entry) except LLMError as exc: print(str(exc), file=sys.stderr) return 1 - if args.limit is not None: - jobs = jobs[: args.limit] if not jobs: - print(json.dumps({"count": 0, "receipts": []}, sort_keys=True)) + print(json.dumps({"count": 0, "already_present": True, "receipts": []}, sort_keys=True)) return 0 + if len(jobs) != 1: + print("render-hall renders one entry", file=sys.stderr) + return 1 if args.dry_run: - return _image_plan(args) if args.limit is None else _print_hall_jobs(jobs, json_indent=args.json) + return _print_hall_jobs(jobs, json_indent=args.json) fx = resolve_fx_rate(args.eur_per_usd) groups: dict[tuple[str, float, float], list[Any]] = {} @@ -308,9 +307,7 @@ def _image_render_hall(args: argparse.Namespace) -> int: receipts: list[dict[str, Any]] = [] exit_code = 0 for (model, quote, cap), group in groups.items(): - batch_cap = args.max_batch_cost_usd - if batch_cap is None: - batch_cap = round(cap * len(group), 6) + batch_cap = round(cap * len(group), 6) try: client = create_image_client(args.provider, model=model) gate = ImageSpendGate( @@ -368,7 +365,7 @@ def _image_render_hall(args: argparse.Namespace) -> int: job.entry_path.write_text(updated, encoding="utf-8") receipts.append(record) if not result.receipt.within_cap: - print("Image cost exceeded the configured cap; batch stopped", file=sys.stderr) + print("Image cost exceeded the configured cap; the portrait was not accepted", file=sys.stderr) exit_code = 2 break if exit_code: diff --git a/llm_connect/hall_portraits.py b/llm_connect/hall_portraits.py index 7f52b00..8757977 100644 --- a/llm_connect/hall_portraits.py +++ b/llm_connect/hall_portraits.py @@ -43,11 +43,20 @@ class HallPortraitJob: return payload -def plan_missing_portraits(hall_root: Path, *, tier: str | None = None) -> list[HallPortraitJob]: +def plan_missing_portraits( + hall_root: Path, + *, + tier: str | None = None, + entry: Path | str | None = None, +) -> list[HallPortraitJob]: """Return jobs for entries that have a visual prompt and no portrait file. ``hall_root`` contains ``entries/*.md`` and ``visuals/``. An entry is skipped when any referenced ``visuals/`` file is already on disk. + + Pass ``entry`` to select the one seat about to be published. Without it, + the function only inventories the hall. It does not mean those portraits + should be rendered ahead of publication. """ entries = hall_root / "entries" visuals = hall_root / "visuals" @@ -56,15 +65,36 @@ def plan_missing_portraits(hall_root: Path, *, tier: str | None = None) -> list[ "Hall root must contain entries/ and visuals/", context={"hall_root": str(hall_root)}, ) + paths = [_resolve_entry(entries, entry)] if entry is not None else sorted(entries.glob("*.md")) jobs: list[HallPortraitJob] = [] - for path in sorted(entries.glob("*.md")): + for path in paths: text = path.read_text(encoding="utf-8") job = _job_for_entry(hall_root, path, text, tier=tier) if job is not None: jobs.append(job) + elif entry is not None and not _referenced_file_exists(path, text): + raise LLMConfigurationError( + "Hall entry has no visual prompt to render", + context={"entry": path.name}, + ) return jobs +def _resolve_entry(entries: Path, entry: Path | str) -> Path: + raw = Path(entry) + candidates = [raw] if raw.is_absolute() else [Path.cwd() / raw, entries / raw.name, entries / raw] + for candidate in candidates: + if candidate.is_file(): + resolved = candidate.resolve() + entries_root = entries.resolve() + if entries_root in resolved.parents or resolved.parent == entries_root: + return resolved + raise LLMConfigurationError( + "Hall entry was not found under entries/", + context={"entry": str(entry)}, + ) + + def reveal_portrait(text: str, relative_path: str, alt: str) -> str: """Turn the commented portrait line into a real markdown image. diff --git a/tests/test_image_selection.py b/tests/test_image_selection.py index 3260880..f150335 100644 --- a/tests/test_image_selection.py +++ b/tests/test_image_selection.py @@ -3,7 +3,10 @@ import json from pathlib import Path +import pytest + from llm_connect.cli import main +from llm_connect.exceptions import LLMConfigurationError from llm_connect.hall_portraits import plan_missing_portraits, reveal_portrait from llm_connect.image_selection import classify_image_branch, selection_for_prompt @@ -126,6 +129,54 @@ Next. assert jobs[0].output_path.name == "hall-worker-cy-3.jpg" +def test_a_same_named_file_outside_entries_is_not_the_seat(tmp_path: Path, monkeypatch) -> None: + hall = tmp_path / "hall" + entries = hall / "entries" + visuals = hall / "visuals" + entries.mkdir(parents=True) + visuals.mkdir() + (tmp_path / "one.md").write_text("not a hall entry\n", encoding="utf-8") + (entries / "one.md").write_text(_entry(HALL_PROMPT, "Ada"), encoding="utf-8") + monkeypatch.chdir(tmp_path) + jobs = plan_missing_portraits(hall, entry="one.md") + assert len(jobs) == 1 + assert jobs[0].entry_path == (entries / "one.md").resolve() + with pytest.raises(LLMConfigurationError): + plan_missing_portraits(hall, entry=tmp_path / "one.md") + + +def test_one_entry_is_selected_for_publication(tmp_path: Path, capsys) -> None: + entries = tmp_path / "entries" + visuals = tmp_path / "visuals" + entries.mkdir() + visuals.mkdir() + (entries / "one.md").write_text(_entry(HALL_PROMPT, "Ada"), encoding="utf-8") + (entries / "two.md").write_text(_entry(HALL_PROMPT, None, "hall-worker-bea-2"), encoding="utf-8") + try: + main(["image", "render-hall", "--hall", str(tmp_path), "--dry-run"]) + except SystemExit: + pass + else: + raise AssertionError("render-hall without --entry should fail") + code = main( + [ + "image", + "render-hall", + "--hall", + str(tmp_path), + "--entry", + "one.md", + "--dry-run", + "--json", + ] + ) + assert code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["count"] == 1 + assert payload["jobs"][0]["entry_id"] == "hall-worker-ada-1" + assert not (visuals / "ada.jpg").exists() + + def test_cli_plan_is_dry_and_names_the_efficient_model(tmp_path: Path, capsys) -> None: entries = tmp_path / "entries" visuals = tmp_path / "visuals" diff --git a/workplans/LLM-WP-0010-text-to-image-rendering.md b/workplans/LLM-WP-0010-text-to-image-rendering.md index 08c87b4..a071b4e 100644 --- a/workplans/LLM-WP-0010-text-to-image-rendering.md +++ b/workplans/LLM-WP-0010-text-to-image-rendering.md @@ -4,7 +4,7 @@ type: workplan title: "Text-to-image rendering for square portraits" domain: agents repo: llm-connect -status: active +status: finished flavor: implementation owner: grok topic_slug: text-to-image-rendering @@ -17,9 +17,9 @@ state_hub_workstream_id: "205485de-a74d-57bd-8f84-e91b8623adeb" # LLM-WP-0010 — Text-to-image rendering for square portraits -Founder-approved request from fluid-telegram (2026-10-08) for the Hall of -Helix portrait backfill. This workplan adds the facility. It does not choose -the production model or the live budget, and it does not render the hall. +Founder-approved request from fluid-telegram (2026-10-08) for Hall of Helix +portraits. This workplan adds the facility and the one-entry publish command. +It does not backfill the hall, and it does not publish posts. ## Library, OpenRouter client, and spend gate @@ -64,23 +64,38 @@ started with `--image-model`, `--image-max-cost-usd`, and `--image-quote-usd`. The response is the receipt plus base64 bytes. `/execute` is unchanged. The process-lifetime batch cap is shared by concurrent requests. -## Live backfill +## One entry at publication ```task id: LLM-WP-0010-T04 -status: progress +status: done priority: medium -blocking_reason: "The founder pointed at the 2026-10-08 intelligence-radar image decision tree. Hall portraits that stay on the general branch use Efficient (google/gemini-nano-banana-2.1, quote 0.04 USD, cap 0.08 USD). The backfill command is llm-connect image render-hall. A paid run is still in progress until every missing portrait file exists." state_hub_task_id: "e69a1f53-a1d5-540e-a926-0f2a32ae4f54" ``` -Do not run a paid render, deploy a new image, or write hall portraits until -that choice is recorded. Reply to fluid-telegram used this workplan id. +The hall command renders the single entry named by `--entry`. A paid call +still needs a key OpenRouter accepts. On 2026-10-08 the environment key was +rejected with HTTP 401, so no portrait file was written. Reply to +fluid-telegram used this workplan id. 2026-10-08 decision: use the intelligence-radar OpenRouter tree in `llm_connect.image_selection`. A hall visual that does not ask for vector output, editing, readable text, or a brand system is general / Efficient. -`llm-connect image render-hall --hall ` renders only entries -whose portrait file is absent, writes `visuals/`, stores a receipt under + +Portraits are rendered on demand, for the one entry about to be published: + +```bash +llm-connect image render-hall --hall --entry entries/.md +``` + +The command writes that file under `visuals/`, stores a receipt under `visuals/receipts/`, and uncomments the portrait line. It does not change -`status`. Text-heavy prompts stay on the tree's Frontier row. +`status`, and it refuses to run without `--entry`. There is no corpus +backfill. + +Seats already in the hall when publishing starts are historic. Their posts +carry a remark that the seat is historic content, so a quiet period can +republish the record without pretending it is news. Seats written after that +practice starts wait a short cooldown of some minutes, then publish; the +portrait is rendered at that moment if it is still missing. The cooldown +publisher is later work and is not this library.