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
209 lines
7 KiB
Python
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""
|
|
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 ""
|