Apply the image decision tree to missing hall portraits
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled

A hall visual that stays on the general branch uses the Efficient model
from the 2026-10-08 intelligence-radar tree. render-hall writes only
portraits whose files are still absent.

Assistant: grok
Assistant-Session: 01a11ab6-1534-7d63-8606-662b92a80ed6
This commit is contained in:
tegwick 2026-10-08 16:44:43 +02:00
parent 7073d2543c
commit e1bab67bd1
10 changed files with 675 additions and 11 deletions

View file

@ -72,6 +72,9 @@ 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
# 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,8 +43,12 @@ 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. Advisory selection guidance, outside this
contract: [`docs/image-model-selection.md`](../../docs/image-model-selection.md).
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.
## CLI

View file

@ -1,8 +1,9 @@
# Choosing an image model for `llm_connect.images`
`llm_connect.images` has no default image model and does not choose one (see
[`contracts/functional/images.md`](../contracts/functional/images.md)). This
guide is advisory input for the caller. It does not change that contract.
`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.
**Source:** intelligence-radar topic digest
`digests/topics/2026-10-08-1527_Image_Generation_with_Generative_AI_Digest.md`

View file

@ -51,6 +51,8 @@ from llm_connect.grading import (
LLMJudge,
PairedGrader,
)
from llm_connect.hall_portraits import HallPortraitJob, plan_missing_portraits
from llm_connect.image_selection import ImageSelection, choose_image_model, selection_for_prompt
from llm_connect.images import (
ImageReceipt,
ImageRenderRequest,
@ -161,6 +163,11 @@ __all__ = [
"OpenRouterImageClient",
"create_image_client",
"render_image",
"ImageSelection",
"choose_image_model",
"selection_for_prompt",
"HallPortraitJob",
"plan_missing_portraits",
"ModelRate",
"ModelRateRegistry",
"CostEstimate",

View file

@ -23,6 +23,7 @@ from llm_connect.exceptions import (
)
from llm_connect.factory import create_adapter
from llm_connect.fx import resolve_fx_rate
from llm_connect.hall_portraits import plan_missing_portraits, reveal_portrait
from llm_connect.images import (
ImageRenderRequest,
ImageSpendGate,
@ -182,6 +183,36 @@ def _build_parser() -> argparse.ArgumentParser:
image_render.add_argument("--json", action="store_true", help="Emit receipt JSON")
image_render.set_defaults(func=_image_render)
image_plan = image_commands.add_parser(
"plan",
help="Choose models for hall entries that have no portrait file yet",
)
image_plan.add_argument("--hall", type=Path, required=True, help="Hall of Helix checkout")
image_plan.add_argument(
"--tier",
default=None,
help="Override the general-branch tier (default efficient)",
)
image_plan.add_argument("--json", action="store_true", help="Emit the plan as JSON")
image_plan.set_defaults(func=_image_plan)
image_hall = image_commands.add_parser(
"render-hall",
help="Render missing hall portraits using the image decision tree",
)
image_hall.add_argument("--hall", type=Path, required=True, help="Hall of Helix checkout")
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("--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")
image_hall.add_argument("--eur-per-usd", type=float, default=None, help="Override FX rate")
image_hall.add_argument("--json", action="store_true", help="Emit receipt JSON")
image_hall.set_defaults(func=_image_render_hall)
return parser
@ -235,6 +266,138 @@ def _image_render(args: argparse.Namespace) -> int:
return exit_code
def _image_plan(args: argparse.Namespace) -> int:
try:
jobs = plan_missing_portraits(args.hall, tier=args.tier)
except LLMError as exc:
print(str(exc), file=sys.stderr)
return 1
payload = {
"count": len(jobs),
"quote_usd": round(sum(job.selection.quote_usd for job in jobs), 4),
"max_batch_cost_usd": round(sum(job.selection.max_cost_usd for job in jobs), 4),
"by_model": _hall_model_counts(jobs),
"jobs": [job.to_dict() for job in jobs],
}
print(json.dumps(payload, indent=2 if args.json else None, sort_keys=True))
return 0
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)
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))
return 0
if args.dry_run:
return _image_plan(args) if args.limit is None else _print_hall_jobs(jobs, json_indent=args.json)
fx = resolve_fx_rate(args.eur_per_usd)
groups: dict[tuple[str, float, float], list[Any]] = {}
for job in jobs:
key = (job.selection.model, job.selection.quote_usd, job.selection.max_cost_usd)
groups.setdefault(key, []).append(job)
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)
try:
client = create_image_client(args.provider, model=model)
gate = ImageSpendGate(
ImageSpendPolicy(
max_cost_usd=cap,
max_batch_cost_usd=batch_cap,
quote_usd=quote,
)
)
except LLMError as exc:
print(str(exc), file=sys.stderr)
exit_code = 1
break
for job in group:
try:
result = render_image(
client,
ImageRenderRequest(
prompt=job.prompt,
output_format="jpeg" if job.output_path.suffix.lower() in {".jpg", ".jpeg"} else "png",
output_path=job.output_path,
),
gate=gate,
fx=fx,
force=args.force,
)
except LLMBudgetExceededError as exc:
print(str(exc), file=sys.stderr)
exit_code = 2
break
except LLMError as exc:
print(str(exc), file=sys.stderr)
exit_code = 1
break
if not args.no_ledger:
ledger_path = args.ledger or default_usage_ledger_path()
UsageLedger(ledger_path).append(
usage_event_from_receipt(result.receipt, source="cli-hall")
)
record = {
"entry_id": job.entry_id,
"branch": job.selection.branch,
"tier": job.selection.tier,
"receipt": result.receipt.to_dict(),
}
receipt_dir = job.output_path.parent / "receipts"
receipt_dir.mkdir(parents=True, exist_ok=True)
receipt_path = receipt_dir / f"{job.output_path.name}.json"
receipt_path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8")
updated = reveal_portrait(
job.entry_path.read_text(encoding="utf-8"),
job.relative_path,
job.alt,
)
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)
exit_code = 2
break
if exit_code:
break
indent = 2 if args.json else None
print(json.dumps({"count": len(receipts), "receipts": receipts}, indent=indent, sort_keys=True))
return exit_code
def _print_hall_jobs(jobs: list[Any], *, json_indent: bool) -> int:
payload = {
"count": len(jobs),
"quote_usd": round(sum(job.selection.quote_usd for job in jobs), 4),
"max_batch_cost_usd": round(sum(job.selection.max_cost_usd for job in jobs), 4),
"by_model": _hall_model_counts(jobs),
"jobs": [job.to_dict() for job in jobs],
}
print(json.dumps(payload, indent=2 if json_indent else None, sort_keys=True))
return 0
def _hall_model_counts(jobs: list[Any]) -> dict[str, int]:
counts: dict[str, int] = {}
for job in jobs:
counts[job.selection.model] = counts.get(job.selection.model, 0) + 1
return counts
def _image_jobs(args: argparse.Namespace) -> list[ImageRenderRequest]:
if bool(args.prompt) == bool(args.batch):
raise SystemExit("Pass exactly one of --prompt or --batch")

View file

@ -0,0 +1,179 @@
"""Find Hall of Helix seats whose visual prompt has no portrait file yet."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from llm_connect.exceptions import LLMConfigurationError
from llm_connect.image_selection import ImageSelection, selection_for_prompt
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.S)
_HEADING_RE = re.compile(r"^## Visual prompt\s*$", re.M)
_NEXT_HEADING_RE = re.compile(r"^## ", re.M)
_COMMENT_RE = re.compile(r"<!--\s*!\[([^\]]*)\]\(([^)]+)\)\s*-->")
_IMAGE_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")
@dataclass(frozen=True)
class HallPortraitJob:
"""One missing portrait and the model the decision tree assigns to it."""
entry_path: Path
entry_id: str
prompt: str
output_path: Path
relative_path: str
alt: str
selection: ImageSelection
def to_dict(self) -> dict[str, object]:
payload = self.selection.to_dict()
payload.update(
{
"entry_id": self.entry_id,
"entry_path": str(self.entry_path),
"output_path": str(self.output_path),
"relative_path": self.relative_path,
"alt": self.alt,
"prompt_chars": len(self.prompt),
}
)
return payload
def plan_missing_portraits(hall_root: Path, *, tier: 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.
"""
entries = hall_root / "entries"
visuals = hall_root / "visuals"
if not entries.is_dir() or not visuals.is_dir():
raise LLMConfigurationError(
"Hall root must contain entries/ and visuals/",
context={"hall_root": str(hall_root)},
)
jobs: list[HallPortraitJob] = []
for path in sorted(entries.glob("*.md")):
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)
return jobs
def reveal_portrait(text: str, relative_path: str, alt: str) -> str:
"""Turn the commented portrait line into a real markdown image.
The entry status is left alone. A draft can carry its portrait before
the rest of the seat is ready to leave draft.
"""
live = f"![{alt}]({relative_path})"
comment = re.compile(
rf"<!--\s*!\[[^\]]*\]\({re.escape(relative_path)}\)\s*-->"
)
if comment.search(text):
return comment.sub(live, text, count=1)
if live in text:
return text
needle = "\n## Handoff"
if needle in text:
return text.replace(needle, f"\n{live}\n{needle}", 1)
return text.rstrip() + f"\n\n{live}\n"
def _job_for_entry(
hall_root: Path,
path: Path,
text: str,
*,
tier: str | None,
) -> HallPortraitJob | None:
prompt = _visual_prompt(text)
if not prompt:
return None
relative, alt = _intended_portrait(path, text)
output = (path.parent / relative).resolve()
visuals = (hall_root / "visuals").resolve()
if visuals not in output.parents:
raise LLMConfigurationError(
"Portrait path must stay under visuals/",
context={"entry": path.name, "relative_path": relative},
)
if output.is_file():
return None
if _referenced_file_exists(path, text):
return None
return HallPortraitJob(
entry_path=path,
entry_id=_frontmatter_value(text, "id") or path.stem,
prompt=prompt,
output_path=output,
relative_path=relative,
alt=alt,
selection=selection_for_prompt(prompt, tier=tier),
)
def _visual_prompt(text: str) -> str:
match = _HEADING_RE.search(text)
if match is None:
return ""
rest = text[match.end() :]
nxt = _NEXT_HEADING_RE.search(rest)
section = rest[: nxt.start()] if nxt else rest
if "supplied no visual brief" in section.lower():
return ""
quoted = [
line.strip()[1:].strip()
for line in section.splitlines()
if line.strip().startswith(">")
]
quoted_prompt = " ".join(part for part in quoted if part).strip()
if quoted_prompt:
return quoted_prompt
lines: list[str] = []
for line in section.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("<!--") or stripped.startswith("!["):
continue
if stripped.startswith("_") or (stripped.startswith("*") and stripped.endswith("*")):
continue
lines.append(stripped.strip("*").strip())
return " ".join(lines).strip()
def _intended_portrait(path: Path, text: str) -> tuple[str, str]:
commented = _COMMENT_RE.search(text)
if commented:
alt = commented.group(1).strip() or "Portrait"
relative = commented.group(2).strip()
return relative, alt
entry_id = _frontmatter_value(text, "id") or path.stem
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", entry_id).strip("-") or "portrait"
return f"../visuals/{safe}.jpg", "Portrait"
def _referenced_file_exists(path: Path, text: str) -> bool:
for relative in _IMAGE_RE.findall(text):
if "visuals/" not in relative:
continue
# Ignore a path that exists only inside an HTML comment. The image
# regex also matches those, so require the file itself.
target = (path.parent / relative).resolve()
if target.is_file():
return True
return False
def _frontmatter_value(text: str, key: str) -> str:
match = _FRONTMATTER_RE.match(text)
if match is None:
return ""
for line in match.group(1).splitlines():
if line.startswith(f"{key}:"):
return line.split(":", 1)[1].strip().strip('"').strip("'")
return ""

View file

@ -0,0 +1,157 @@
"""OpenRouter image-model decision tree.
The tree and the prices come from the intelligence-radar digest dated
2026-10-08 (``2026-10-08-1527_Image_Generation_with_Generative_AI_Digest``),
recorded for callers in ``docs/image-model-selection.md``. Ranks and prices
move. This module is the machine-readable copy of that snapshot, not a live
leaderboard.
Hall of Helix portraits are general generation: square technical illustration,
no logos, no readable text, and many of them. Quality still matters, so the
hall tier is Efficient, not Frontier and not bulk.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from llm_connect.exceptions import LLMConfigurationError
# branch, tier -> model, approximate USD, quote USD, per-call cap USD
_CHOICES: dict[tuple[str, str], tuple[str, float, float, float]] = {
("vector", "styles"): ("recraft/recraft-v4-styles-vector", 0.05, 0.06, 0.10),
("vector", "default"): ("recraft/recraft-v4.1-vector", 0.08, 0.09, 0.15),
("vector", "pro"): ("recraft/recraft-v4.1-pro-vector", 0.30, 0.32, 0.40),
("editing", "frontier"): ("openai/gpt-image-2.5-sunburst", 0.21, 0.25, 0.30),
("editing", "efficient"): ("microsoft/mai-image-2.6", 0.039, 0.045, 0.08),
("editing", "budget"): ("meta/muse-image", 0.010, 0.012, 0.03),
("text_heavy", "frontier"): ("openai/gpt-image-2.5-sunburst", 0.21, 0.25, 0.30),
("text_heavy", "grounded"): ("google/gemini-nano-banana-2.1", 0.034, 0.04, 0.08),
("brand", "default"): ("recraft/recraft-v4-styles", 0.035, 0.04, 0.08),
("brand", "pro"): ("recraft/recraft-v4-styles-pro", 0.10, 0.12, 0.16),
("general", "frontier"): ("openai/gpt-image-2.5-sunburst", 0.21, 0.25, 0.30),
("general", "frontier_fast"): ("openai/gpt-image-2.5-flare", 0.21, 0.25, 0.30),
("general", "efficient"): ("google/gemini-nano-banana-2.1", 0.034, 0.04, 0.08),
("general", "efficient_fallback"): ("microsoft/mai-image-2.6", 0.039, 0.045, 0.08),
("general", "budget"): ("meta/muse-image", 0.010, 0.012, 0.03),
("general", "bulk"): ("recraft/recraft-v4.1-flash", 0.007, 0.008, 0.02),
}
_DEFAULT_TIER = {
"vector": "default",
"editing": "frontier",
"text_heavy": "frontier",
"brand": "default",
"general": "efficient",
}
# Many hall portraits, and the portrait is what the public post shows.
HALL_GENERAL_TIER = "efficient"
_SELF_HOST_RE = re.compile(r"\b(self-host(?:ed|ing)?|open weights|on our own hardware)\b", re.I)
_VECTOR_RE = re.compile(r"\b(svg|vector output|vector-style|as a vector)\b", re.I)
_EDIT_RE = re.compile(r"\b(edit the existing|inpaint|image-to-image|change the attached image)\b", re.I)
_BRAND_RE = re.compile(r"\b(brand kit|brand style|style system)\b", re.I)
_NEGATED_TEXT_RE = re.compile(
r"\b(?:no|not|without|zero)\b(?:\s+\w+){0,5}\s+(?:readable\s+)?(?:text|lettering|glyphs?)\b",
re.I,
)
_TEXT_RE = re.compile(
r"\b(readable text|typography|caption|headline|lettering|signage|the words)\b",
re.I,
)
@dataclass(frozen=True)
class ImageSelection:
"""One branch of the OpenRouter image tree."""
branch: str
tier: str
model: str
approx_usd: float
quote_usd: float
max_cost_usd: float
reason: str
def to_dict(self) -> dict[str, Any]:
return {
"branch": self.branch,
"tier": self.tier,
"model": self.model,
"approx_usd": self.approx_usd,
"quote_usd": self.quote_usd,
"max_cost_usd": self.max_cost_usd,
"reason": self.reason,
}
def classify_image_branch(prompt: str) -> str:
"""Walk the digest tree in order. The first match wins."""
if _SELF_HOST_RE.search(prompt):
return "self_host"
if _VECTOR_RE.search(prompt):
return "vector"
if _EDIT_RE.search(prompt):
return "editing"
without_negations = _NEGATED_TEXT_RE.sub(" ", prompt)
# A prompt that forbids readable text is not the text-heavy branch, even
# when it mentions words as something the picture must not contain.
forbids_text = _NEGATED_TEXT_RE.search(prompt) is not None
if _TEXT_RE.search(without_negations) and not forbids_text:
return "text_heavy"
if _BRAND_RE.search(prompt):
return "brand"
return "general"
def choose_image_model(branch: str, tier: str | None = None) -> ImageSelection:
"""Return the priced OpenRouter model for one branch and tier."""
if branch == "self_host":
raise LLMConfigurationError(
"Self-hosted or open-weight image generation is not available through OpenRouter",
context={"branch": branch},
)
if branch not in _DEFAULT_TIER:
known = ", ".join(sorted(_DEFAULT_TIER))
raise LLMConfigurationError(
f"Unknown image branch {branch!r}. Choose from: {known}",
context={"branch": branch},
)
selected_tier = tier or _DEFAULT_TIER[branch]
choice = _CHOICES.get((branch, selected_tier))
if choice is None:
known = ", ".join(sorted(item[1] for item in _CHOICES if item[0] == branch))
raise LLMConfigurationError(
f"Unknown tier {selected_tier!r} for branch {branch!r}. Choose from: {known}",
context={"branch": branch, "tier": selected_tier},
)
model, approx, quote, cap = choice
reason = (
f"{branch}/{selected_tier} from the 2026-10-08 OpenRouter image tree; "
f"quote {quote:.3f} USD against about {approx:.3f} USD per 1024 square image"
)
return ImageSelection(
branch=branch,
tier=selected_tier,
model=model,
approx_usd=approx,
quote_usd=quote,
max_cost_usd=cap,
reason=reason,
)
def selection_for_prompt(prompt: str, *, tier: str | None = None) -> ImageSelection:
"""Classify a prompt, then price it.
``tier`` overrides only the general branch. Specialist branches keep the
tree's own default, because a hall portrait that asks for readable text
is no longer an Efficient general image.
"""
branch = classify_image_branch(prompt)
if branch == "general":
return choose_image_model(branch, tier or HALL_GENERAL_TIER)
return choose_image_model(branch, None)

View file

@ -0,0 +1,141 @@
"""Decision tree and hall portrait planning."""
import json
from pathlib import Path
from llm_connect.cli import main
from llm_connect.hall_portraits import plan_missing_portraits, reveal_portrait
from llm_connect.image_selection import classify_image_branch, selection_for_prompt
HALL_PROMPT = (
"Constellation dialect. Square, dark indigo. A gold-wire helix. "
"Fine technical-illustration linework, no logos, no readable text."
)
def test_hall_house_prompt_is_general_efficient() -> None:
selection = selection_for_prompt(HALL_PROMPT)
assert classify_image_branch(HALL_PROMPT) == "general"
assert selection.tier == "efficient"
assert selection.model == "google/gemini-nano-banana-2.1"
assert selection.quote_usd == 0.04
assert selection.max_cost_usd == 0.08
def test_negated_text_does_not_become_text_heavy() -> None:
assert classify_image_branch("no logos, no readable text, pale gold") == "general"
def test_abstract_marks_stay_on_the_general_branch() -> None:
prompt = (
"a ledger glows with the words rendered as abstract marks, not readable text. "
"no logos, no lettering"
)
assert classify_image_branch(prompt) == "general"
def test_requested_words_take_the_text_branch() -> None:
selection = selection_for_prompt("A poster whose headline is the words HELIX")
assert selection.branch == "text_heavy"
assert selection.model == "openai/gpt-image-2.5-sunburst"
def test_vector_and_self_host_follow_the_tree() -> None:
assert selection_for_prompt("Export as a vector svg icon").model.startswith("recraft/")
try:
selection_for_prompt("self-host the open weights on our own hardware")
except Exception as exc:
assert "OpenRouter" in str(exc)
else:
raise AssertionError("self-host branch should refuse")
def _entry(visual: str, comment: str | None, entry_id: str = "hall-worker-ada-1") -> str:
commented = f"<!-- ![{comment}](../visuals/ada.jpg) -->\n" if comment else ""
return f"""---
id: {entry_id}
status: draft
---
# Ada — a seat
## Visual prompt
> {visual}
{commented}
## Handoff
Next.
"""
def test_plan_skips_a_portrait_that_is_already_on_disk(tmp_path: Path) -> 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")
(visuals / "bea-file.jpg").write_bytes(b"x")
# two.md has no comment, so its derived file is the id. Put that file on disk.
(visuals / "hall-worker-bea-2.jpg").write_bytes(b"x")
jobs = plan_missing_portraits(tmp_path)
assert [job.entry_id for job in jobs] == ["hall-worker-ada-1"]
assert jobs[0].output_path == visuals / "ada.jpg"
assert jobs[0].relative_path == "../visuals/ada.jpg"
assert jobs[0].alt == "Ada"
assert "no readable text" in jobs[0].prompt
def test_reveal_portrait_uncomments_the_intended_line() -> None:
text = _entry(HALL_PROMPT, "Ada")
updated = reveal_portrait(text, "../visuals/ada.jpg", "Ada")
assert "<!--" not in updated
assert "![Ada](../visuals/ada.jpg)" in updated
assert "status: draft" in updated
def test_plan_reads_a_prompt_that_is_not_a_blockquote(tmp_path: Path) -> None:
entries = tmp_path / "entries"
visuals = tmp_path / "visuals"
entries.mkdir()
visuals.mkdir()
body = """---
id: hall-worker-cy-3
status: draft
---
# Cy — a seat
## Visual prompt
**Dialect:** Constellation. Square, no logos, no readable text.
A gold-wire orrery suspended in dark indigo.
## Handoff
Next.
"""
(entries / "three.md").write_text(body, encoding="utf-8")
jobs = plan_missing_portraits(tmp_path)
assert len(jobs) == 1
assert "orrery" in jobs[0].prompt
assert jobs[0].output_path.name == "hall-worker-cy-3.jpg"
def test_cli_plan_is_dry_and_names_the_efficient_model(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")
code = main(["image", "plan", "--hall", str(tmp_path), "--json"])
assert code == 0
payload = json.loads(capsys.readouterr().out)
assert payload["count"] == 1
assert payload["jobs"][0]["model"] == "google/gemini-nano-banana-2.1"
assert payload["max_batch_cost_usd"] == 0.08
assert not (visuals / "ada.jpg").exists()

View file

@ -73,6 +73,11 @@ def test_image_render_primitives_are_exported_from_package_root():
"OpenRouterImageClient",
"create_image_client",
"render_image",
"ImageSelection",
"choose_image_model",
"selection_for_prompt",
"HallPortraitJob",
"plan_missing_portraits",
]
for name in expected_names:

View file

@ -4,7 +4,7 @@ type: workplan
title: "Text-to-image rendering for square portraits"
domain: agents
repo: llm-connect
status: blocked
status: active
flavor: implementation
owner: grok
topic_slug: text-to-image-rendering
@ -68,15 +68,19 @@ The process-lifetime batch cap is shared by concurrent requests.
```task
id: LLM-WP-0010-T04
status: wait
status: progress
priority: medium
blocking_reason: "The facility is in place and covered by fake-provider tests. A paid Hall of Helix backfill still needs the founder to name the image model, the per-call quote, and the batch cap. fluid-telegram asked not to have that chosen for them."
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.
2026-10-08 follow-up: T01–T03 are the whole of the work this repository can
finish. No model, quote, or batch cap has been named, and no newer inbox
message supplies one. The workplan is `blocked` while T04 stays `wait`.
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
`visuals/receipts/`, and uncomments the portrait line. It does not change
`status`. Text-heavy prompts stay on the tree's Frontier row.