Apply the image decision tree to missing hall portraits
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:
parent
7073d2543c
commit
e1bab67bd1
10 changed files with 675 additions and 11 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
179
llm_connect/hall_portraits.py
Normal file
179
llm_connect/hall_portraits.py
Normal 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""
|
||||
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 ""
|
||||
157
llm_connect/image_selection.py
Normal file
157
llm_connect/image_selection.py
Normal 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)
|
||||
141
tests/test_image_selection.py
Normal file
141
tests/test_image_selection.py
Normal 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"<!--  -->\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 "" 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue