Render a hall portrait only for the entry about to be published
Some checks are pending
CI Smoke / container-smoke (push) Waiting to run
CI Smoke / host-smoke (push) Waiting to run

render-hall requires --entry and no longer walks the corpus. image plan
without --entry stays an inventory. LLM-WP-0010 records that existing seats
are historic and that the cooldown publisher comes later.

Assistant: grok
Assistant-Session: 01a11ab6-1534-7d63-8606-662b92a80ed6
This commit is contained in:
tegwick 2026-10-08 16:57:26 +02:00
parent 9bc86b8960
commit c0b7e54ed1
7 changed files with 144 additions and 43 deletions

View file

@ -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/<file>.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" \

View file

@ -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

View file

@ -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`

View file

@ -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:

View file

@ -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.

View file

@ -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"

View file

@ -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 <hall-of-helix>` 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 <hall-of-helix> --entry entries/<file>.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.