Render a hall portrait only for the entry about to be published
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:
parent
9bc86b8960
commit
c0b7e54ed1
7 changed files with 144 additions and 43 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue