diff --git a/README.md b/README.md index a0d3b66..65cb508 100644 --- a/README.md +++ b/README.md @@ -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" \ diff --git a/contracts/functional/images.md b/contracts/functional/images.md index b5db4ad..a79170e 100644 --- a/contracts/functional/images.md +++ b/contracts/functional/images.md @@ -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 diff --git a/docs/image-model-selection.md b/docs/image-model-selection.md index 2e1f2e7..c36d8ba 100644 --- a/docs/image-model-selection.md +++ b/docs/image-model-selection.md @@ -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` diff --git a/llm_connect/__init__.py b/llm_connect/__init__.py index e6c7e65..4d4ca1c 100644 --- a/llm_connect/__init__.py +++ b/llm_connect/__init__.py @@ -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", diff --git a/llm_connect/cli.py b/llm_connect/cli.py index 0a16e3d..ae23582 100644 --- a/llm_connect/cli.py +++ b/llm_connect/cli.py @@ -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") diff --git a/llm_connect/hall_portraits.py b/llm_connect/hall_portraits.py new file mode 100644 index 0000000..7f52b00 --- /dev/null +++ b/llm_connect/hall_portraits.py @@ -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"") +_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"" + ) + 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("\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 "