llm-connect/llm_connect/hall_portraits.py
tegwick c0b7e54ed1
Some checks are pending
CI Smoke / container-smoke (push) Waiting to run
CI Smoke / host-smoke (push) Waiting to run
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
2026-10-08 16:57:26 +02:00

209 lines
7 KiB
Python

"""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,
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"
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)},
)
paths = [_resolve_entry(entries, entry)] if entry is not None else sorted(entries.glob("*.md"))
jobs: list[HallPortraitJob] = []
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.
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 ""