"""Template-intelligent PPTX layout catalog and per-slide AI selection.
Pandoc's PPTX writer only ever reaches the ~7 named layouts its bundled
``template.pptx`` ships (``Title Slide``, ``Section Header``, ``Title and
Content``, ...); it has no notion of a *designer* template's much richer
layout vocabulary (a real marketing deck template routinely ships 20-40 named
layouts: covers, section dividers, big-statement slides, photo heroes,
multi-column splits, ...). This module is the missing primitive: given any
designer PPTX template, it builds a **layout catalog** (every distinct named
layout, its placeholder geometry, and — when a vision model is available — a
short caption of what kind of slide it is), then **selects** the best-fitting
catalog layout for each slide of an authored Markdown deck.
Selection is two-staged, mirroring how a human designer would actually work:
1. **Text reasoning** (LLM): read each slide's Markdown content plus the
catalog (names + captions) and choose the layout whose *role* best fits —
a cover, a section divider, a photo hero, bulleted content, ... This stage
alone is cheap and works from captions only, never looking at a candidate
render.
2. **Visual confirmation** (VLM): the text stage never actually *looks* at
the candidate layouts, so it can pick a layout whose caption sounds right
but whose actual composition does not suit the slide (a "photo-hero"
caption on a layout whose picture placeholder is tiny, say). This stage
shows the vision model the slide's short-listed candidate thumbnails
(the text pick plus a couple of archetype-diverse alternates) side by side
and asks which one *actually* carries the content best, overriding the
text pick on disagreement. This is the dimension a pure-LLM, caption-only
selector cannot provide.
Both stages, and the template-page renderer they rely on, are threaded
through injectable seams (``chat`` / ``render``) so the whole pipeline is
unit-testable offline with fakes, exactly like :mod:`md2star.reverse_diagrams`.
Every call degrades gracefully: with no reachable engine the catalog still
builds (deterministic layout/placeholder extraction never needs AI) and
selection falls back to a safe, rule-based default layout per slide.
Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import tempfile
import zipfile
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from pathlib import Path
from xml.etree import ElementTree as ET
import os_helper as osh
from best_engine_ai_helper import llm
from ._engine import engine
from .cache import cache_dir
from .logging import get_logger
logger = get_logger(__name__)
# ── transport seams ──────────────────────────────────────────────────────────
# A model call: prompt text, optional raw image bytes, optional JSON schema for
# structured output -> the parsed dict (when a schema is given), a plain str,
# or None on any failure. The real implementation routes through md2star's
# resolved engine; tests inject a fake so nothing here needs a live daemon.
ChatFn = Callable[[str, "list[bytes] | None", "dict | None"], "dict | str | None"]
# A template-page renderer: template path, 1-based page number -> PNG bytes, or
# None if the page could not be rendered (no LibreOffice/poppler, bad page).
RenderFn = Callable[[Path, int], "bytes | None"]
_NS = {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
# Small, role-oriented vocabulary both the captioner and the selector target.
# Kept short on purpose: a coarse, reliable archetype beats a large taxonomy an
# 8-14B local model cannot use reliably.
ARCHETYPES: tuple[str, ...] = (
"cover",
"section-divider",
"big-statement",
"big-number",
"bulleted-content",
"two-column-split",
"photo-hero",
"process-steps",
"quote",
"closing",
"reference-non-content",
)
# Layout names whose role is obviously "designer reference material" (colour
# palettes, logo sheets, icon grids), never a home for authored content. Used
# only as the *fallback* heuristic when no vision model is available to judge
# ``is_content_layout`` properly.
_REFERENCE_NAME_HINTS = ("colour", "color", "palette", "logo", "icon", "brand guide")
# ── data model ────────────────────────────────────────────────────────────────
[docs]
@dataclass
class Placeholder:
"""One placeholder shape on a layout: its role and its box in EMU."""
idx: int
kind: str # pandoc/OOXML placeholder type: title, body, pic, ctrTitle, ...
emu: tuple[int, int, int, int] | None = None # left, top, width, height
[docs]
@dataclass
class LayoutInfo:
"""One distinct named layout the designer template exposes."""
name: str
example_pages: list[int] = field(default_factory=list)
placeholders: list[Placeholder] = field(default_factory=list)
archetype: str = "bulleted-content"
background: str = ""
caption: str = ""
is_content_layout: bool = True
@property
def rep_page(self) -> int | None:
"""The first example slide page this layout appears on, if any."""
return self.example_pages[0] if self.example_pages else None
[docs]
def has_kind(self, kind: str) -> bool:
return any(p.kind == kind for p in self.placeholders)
[docs]
@dataclass
class Slide:
"""One Markdown slide plus the cheap structural cues a selector keys on."""
index: int
title: str
body: str = ""
features: list[str] = field(default_factory=list)
chosen_layout: str | None = None
chosen_confidence: float | None = None
chosen_why: str = ""
# Bounded per-slide adjustments the target-matching eyeball loop
# (:mod:`md2star.pptx_assemble`) writes back after judging a render against
# the chosen layout's thumbnail: font_delta (pt, title/body), bullets (max
# bullet count), img_grow (fractional image box growth). Empty = no change.
overrides: dict = field(default_factory=dict)
# ── deterministic catalog extraction (no AI, always available) ──────────────
def _layout_example_pages(template: Path) -> dict[str, list[int]]:
"""Map each designer layout name to the slide pages that use it.
A layout is captioned from *several* of its own examples (not just the
first) so the caption describes the reusable design, not one example
slide's filler content.
"""
pages: dict[str, list[int]] = {}
with zipfile.ZipFile(template) as z:
names = set(z.namelist())
n_slides = sum(1 for n in names if re.fullmatch(r"ppt/slides/slide\d+\.xml", n))
for n in range(1, n_slides + 1):
rel = f"ppt/slides/_rels/slide{n}.xml.rels"
if rel not in names:
continue
m = re.search(r"slideLayouts/(slideLayout\d+)\.xml", z.read(rel).decode("utf-8"))
if not m:
continue
lay_xml = z.read(f"ppt/slideLayouts/{m.group(1)}.xml").decode("utf-8")
nm = re.search(r'<p:cSld name="([^"]*)"', lay_xml)
pages.setdefault(nm.group(1) if nm else m.group(1), []).append(n)
return pages
def _all_layout_files(template: Path) -> list[str]:
"""Every ``slideLayoutN.xml`` part in *template*, regardless of usage."""
with zipfile.ZipFile(template) as z:
return sorted(
n for n in z.namelist() if re.fullmatch(r"ppt/slideLayouts/slideLayout\d+\.xml", n)
)
def _layout_name(template: Path, layout_part: str) -> str:
"""The designer-authored ``<p:cSld name="...">`` for one layout part."""
with zipfile.ZipFile(template) as z:
xml = z.read(layout_part).decode("utf-8")
m = re.search(r'<p:cSld name="([^"]*)"', xml)
return m.group(1) if m else Path(layout_part).stem
def _placeholder_kind(sp: ET.Element) -> str | None:
# <p:ph> with no ``type`` attribute is OOXML's own shorthand for "body"
# (the spec's default), not "untyped" — so we resolve it here rather than
# leaving callers to special-case an empty string.
ph = sp.find("./p:nvSpPr/p:nvPr/p:ph", _NS)
if ph is None:
return None
return ph.get("type") or "body"
def _placeholder_idx(sp: ET.Element) -> int:
ph = sp.find("./p:nvSpPr/p:nvPr/p:ph", _NS)
idx = ph.get("idx") if ph is not None else None
try:
return int(idx) if idx is not None else 0
except ValueError:
# A non-numeric idx would mean malformed XML from a third-party
# export tool; degrade to placeholder 0 rather than raise, since a
# slightly wrong idx only affects placement-matching, never crashes.
return 0
def _placeholder_emu(sp: ET.Element) -> tuple[int, int, int, int] | None:
xfrm = sp.find("./p:spPr/a:xfrm", _NS)
if xfrm is None:
# No explicit <a:xfrm> is normal, not an error: the shape then
# inherits its box from the corresponding placeholder on the slide
# master. We don't resolve that inheritance chain here — callers
# (pptx_assemble) fall back to a fractional default box instead.
return None
off, ext = xfrm.find("a:off", _NS), xfrm.find("a:ext", _NS)
if off is None or ext is None:
return None
try:
return (int(off.get("x")), int(off.get("y")), int(ext.get("cx")), int(ext.get("cy")))
except (TypeError, ValueError):
return None
def _layout_placeholders(template: Path, layout_file: str) -> list[Placeholder]:
"""Parse one ``slideLayoutN.xml``'s ``<p:sp>`` placeholders (type/idx/box)."""
with zipfile.ZipFile(template) as z:
xml = z.read(f"ppt/slideLayouts/{layout_file}")
root = ET.fromstring(xml) # noqa: S314 — trusted local template file, not untrusted input
out: list[Placeholder] = []
# Fully-qualified tag name: ElementTree's ``iter`` doesn't accept the
# ``_NS`` prefix map used by ``find``/``findall``, so the namespace has to
# be spelled out in Clark notation here.
for sp in root.iter("{http://schemas.openxmlformats.org/presentationml/2006/main}sp"):
kind = _placeholder_kind(sp)
if kind is None:
continue # a decorative (non-placeholder) shape — not tracked
out.append(Placeholder(idx=_placeholder_idx(sp), kind=kind, emu=_placeholder_emu(sp)))
return out
[docs]
def layout_pages(template: Path) -> dict[str, list[int]]:
"""Public wrapper over :func:`_layout_example_pages` (used by callers/tests)."""
return _layout_example_pages(template)
# ── template page rendering (LibreOffice + poppler; optional) ───────────────
def _find_soffice() -> str | None:
"""Locate ``soffice``, reusing the CLI's resolver to avoid drift."""
try:
from .cli import _find_soffice as _cli_find_soffice # noqa: PLC0415
return _cli_find_soffice()
except ImportError:
path = shutil.which("soffice") or shutil.which("libreoffice")
if path:
return path
mac_app = "/Applications/LibreOffice.app/Contents/MacOS/soffice"
return mac_app if Path(mac_app).exists() else None
def _render_all_pages(template: Path, *, dpi: int = 110) -> dict[int, bytes]:
"""Render every page of *template* to PNG bytes, keyed by 1-based page.
``soffice --headless --convert-to pdf`` then ``pdftoppm -png``. Returns an
empty dict (never raises) when LibreOffice or ``pdftoppm`` is absent, or the
conversion otherwise fails — callers degrade to caption-less catalogs.
"""
soffice = _find_soffice()
pdftoppm = shutil.which("pdftoppm")
if soffice is None or pdftoppm is None:
logger.debug("template rendering unavailable (soffice=%s, pdftoppm=%s)", soffice, pdftoppm)
return {}
try:
# A fresh temp dir per call: soffice writes its PDF next to nothing we
# control (it derives the output name from the input stem), so an
# isolated dir is the simplest way to avoid collisions across calls.
with tempfile.TemporaryDirectory() as d:
subprocess.run(
[soffice, "--headless", "--convert-to", "pdf", "--outdir", d, str(template)],
check=True,
capture_output=True,
timeout=180,
)
pdf = Path(d) / (template.stem + ".pdf")
if not pdf.exists():
return {}
# 110dpi is the PoC's own validated choice: enough detail for a
# VLM caption/compare pass, small enough that a 20-40 layout
# template renders and uploads quickly.
subprocess.run(
[pdftoppm, "-png", "-r", str(dpi), str(pdf), f"{d}/p"],
check=True,
capture_output=True,
timeout=180,
)
out: dict[int, bytes] = {}
for f in sorted(Path(d).glob("p-*.png")):
out[int(f.stem.split("-")[-1])] = f.read_bytes()
return out
except (subprocess.SubprocessError, OSError) as exc:
# A timed-out or crashed soffice/pdftoppm must not be fatal — the
# caller's catalog build just proceeds without captions/thumbnails.
logger.debug("template render failed: %s", exc)
return {}
[docs]
def render_deck_pages(path: Path, *, dpi: int = 110) -> dict[int, bytes]:
"""Render every page of a PPTX/PDF *path* to PNG bytes, keyed by 1-based page.
Public wrapper over :func:`_render_all_pages`, used by
:mod:`md2star.pptx_assemble`'s eyeball loop to render its own assembled
output (as opposed to :func:`default_render`, which caches per *template*
page and is meant for the catalog/tie-break stages).
"""
return _render_all_pages(path, dpi=dpi)
[docs]
def default_render() -> RenderFn:
"""Build the real page renderer, caching all pages per template (by hash)."""
# Keyed by content hash (not path) so the same template opened from two
# different paths still shares one render pass, and an edited-in-place
# template correctly triggers a fresh one. One soffice invocation renders
# every page of the template at once, so caching per-template rather than
# per-page avoids re-running it for each of a catalog's 20-40 layouts.
_cache: dict[str, dict[int, bytes]] = {}
def _render(template: Path, page: int) -> bytes | None:
key = osh.hashfile(str(template))
if key not in _cache:
_cache[key] = _render_all_pages(template)
return _cache[key].get(page)
return _render
[docs]
def default_chat(*, kind: str, model: str | None = None) -> ChatFn:
"""Build the real engine-backed transport for *kind* (``"llm"`` or ``"vlm"``).
Routes through ``best_engine_ai_helper.llm.chat`` using md2star's resolved
brief -> engine descriptor, exactly like :mod:`md2star.reverse_diagrams`.
Returns ``None`` on any failure so callers degrade gracefully.
"""
def _call(
prompt: str, images: list[bytes] | None, json_schema: dict | None
) -> dict | str | None:
try:
# temperature=0.0: every call here is a classification/selection
# decision with a strict schema, not creative prose — determinism
# matters more than variety, and it makes re-runs reproducible.
return llm.chat(
prompt,
engine=engine(),
kind=kind,
images=images,
json_schema=json_schema,
temperature=0.0,
model=model,
)
except Exception as exc: # noqa: BLE001 — any failure -> caller falls back
logger.debug("%s transport failed: %s", kind, exc)
return None
return _call
[docs]
def smart_layout_available() -> bool:
"""Return ``True`` when the full AI-assisted pipeline (LLM + VLM) can run.
Mirrors :func:`md2star.reverse_diagrams.diagrams_available`: a light "does
the engine resolve and expose both model kinds?" probe, cheap enough to
call before offering ``--smart-layout`` in the CLI. The deterministic
catalog extraction and a rule-based selection fallback work regardless.
"""
try:
eng = engine()
except Exception as exc: # noqa: BLE001 — unresolved engine -> AI stage off
logger.debug("smart-layout unavailable: engine did not resolve: %s", exc)
return False
# Both models are required, not either/or: select_layouts() needs the LLM
# and build_catalog()/visual_confirm() need the VLM, and offering
# --smart-layout with only one resolved would fail midway through a run.
return bool(eng.get("llm", {}).get("model")) and bool(eng.get("vlm", {}).get("model"))
# ── catalog: deterministic geometry + optional VLM captions ─────────────────
_CAPTION_SCHEMA = {
"type": "object",
"properties": {
"archetype": {"type": "string", "enum": list(ARCHETYPES)},
"background": {"type": "string"},
"is_content_layout": {"type": "boolean"},
"one_line": {"type": "string"},
},
"required": ["archetype", "is_content_layout", "one_line"],
}
_CAPTION_PROMPT = (
"These images are DIFFERENT slides that all use the SAME PowerPoint "
"template layout, only the placeholder content differs. Describe the "
"REUSABLE layout, NOT the specific words or pictures:\n"
"- archetype: its role, one of: " + ", ".join(ARCHETYPES) + ". Use "
"'reference-non-content' for a brand reference page (colour palette, logo "
"sheet, icon grid) that is NOT meant to hold authored content.\n"
"- background: white / black / green / gradient / photo (one word/phrase).\n"
"- is_content_layout: false ONLY for brand-reference pages.\n"
"- one_line: a single sentence a picker can match a slide against.\n"
"Reply as JSON."
)
[docs]
def build_catalog(
template: Path,
*,
chat: ChatFn | None = None,
render: RenderFn | None = None,
use_vision: bool = True,
cache_path: Path | None = None,
refresh: bool = False,
) -> list[LayoutInfo]:
"""Build the full layout catalog for *template*: geometry + (optional) captions.
Parameters
----------
template : Path
The designer PPTX template to catalog.
chat : ChatFn, optional
Vision transport for captioning; defaults to the real engine-backed
one. Ignored when *use_vision* is ``False``.
render : RenderFn, optional
Template-page renderer; defaults to the real LibreOffice+poppler one.
use_vision : bool, default True
When ``False`` (or when rendering/chat is unavailable), every layout
gets a rule-based caption instead of a VLM one — the catalog still
builds, just without visual captions.
cache_path : Path, optional
Where to persist/read the catalog JSON. Defaults to a content-hashed
path under md2star's cache dir, so re-running on the same template is
free.
refresh : bool, default False
Force a rebuild even if a cache file exists.
Returns
-------
list[LayoutInfo]
One entry per distinct designer layout name.
"""
if cache_path is None:
# Content-hashed, not path-hashed: two callers pointing at the same
# template bytes (e.g. a shared brand asset referenced from different
# project dirs) share one cached catalog instead of rebuilding it.
cache_path = cache_dir("pptx_layout") / f"{osh.hashfile(str(template))}.json"
if cache_path.exists() and not refresh:
try:
raw = json.loads(cache_path.read_text(encoding="utf-8"))
return [
LayoutInfo(
**{**item, "placeholders": [Placeholder(**p) for p in item["placeholders"]]}
)
for item in raw
]
except (json.JSONDecodeError, KeyError, TypeError) as exc:
# A corrupt or schema-mismatched cache file (e.g. from an older
# md2star version) should never break the build — just rebuild.
logger.debug("pptx layout cache unreadable, rebuilding: %s", exc)
catalog = extract_layouts(template)
if use_vision:
render = render or default_render()
chat = chat or default_chat(kind="vlm")
for info in catalog:
# Nothing to caption for a reference page (never offered to the
# selector anyway) or a layout with no rendered example.
if not info.is_content_layout or not info.example_pages:
continue
# Up to 3 examples, per the PoC's own finding: captioning from
# several instances describes the *reusable* layout instead of
# one example's filler content, and more than 3 rarely changes
# the caption while tripling the upload cost.
images = [render(template, p) for p in info.example_pages[:3]]
images = [img for img in images if img]
if not images:
continue # render unavailable (no soffice/pdftoppm) -> skip
out = chat(_CAPTION_PROMPT, images, _CAPTION_SCHEMA)
data = (
out
if isinstance(out, dict)
else (json.loads(out) if isinstance(out, str) else None)
)
if not data:
continue # transport failure or unparseable reply -> keep the rule-based defaults
info.archetype = data.get("archetype", info.archetype)
info.background = str(data.get("background", "")).strip()
info.caption = str(data.get("one_line", "")).strip()
# The VLM can override the name-based reference-page heuristic
# (extract_layouts) with an actual look at the rendered slide —
# more reliable for oddly-named layouts.
info.is_content_layout = bool(data.get("is_content_layout", info.is_content_layout))
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(
json.dumps([_layout_info_dict(i) for i in catalog], indent=2, ensure_ascii=False),
encoding="utf-8",
)
return catalog
def _layout_info_dict(info: LayoutInfo) -> dict:
# asdict() recurses into the nested Placeholder dataclasses too, so the
# result is plain-JSON-safe in one call, no per-field serialisation.
return asdict(info)
# ── deck segmentation ────────────────────────────────────────────────────────
_HR_RE = re.compile(r"(?m)^[ \t]*(\*\*\*+|---+|___+)[ \t]*$")
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
_HEADING_RE = re.compile(r"(?m)^#{1,2}\s+(.*)$")
def _features(body: str) -> list[str]:
"""Detect the structural cues that hint at a layout (regex, no AI)."""
f: list[str] = []
if re.search(r"::: *column", body):
f.append("two-column")
if re.search(r"!\[.*?\]\(", body):
f.append("image")
if re.search(r"^\s*\|.*\|", body, re.MULTILINE):
f.append("table")
# mermaid checked BEFORE the generic fence: a ```mermaid block would also
# match the plain ```-fence regex, and mermaid is the more specific,
# more useful signal for layout selection (a diagram, not "some code").
if "```mermaid" in body:
f.append("mermaid")
elif re.search(r"^```", body, re.MULTILINE):
f.append("code")
if re.search(r"\{\.big\}", body):
f.append("big-statement")
# ordered-list before bullets for the same reason: check the more
# specific pattern first so a slide isn't double-tagged confusingly.
if re.search(r"^\s*\d+\.\s", body, re.MULTILINE):
f.append("ordered-list")
elif re.search(r"^\s*[-*] ", body, re.MULTILINE):
f.append("bullets")
if re.search(r"^>\s", body, re.MULTILINE):
f.append("quote")
return f
def _slide_from_chunk(index: int, chunk: str) -> Slide | None:
visible = _HTML_COMMENT_RE.sub("", chunk).strip()
if not visible:
return None
m = _HEADING_RE.search(visible)
title = re.sub(r"\s*\{\.[^}]*\}\s*$", "", m.group(1)).strip() if m else "(untitled)"
return Slide(index=index, title=title, body=visible, features=_features(visible))
[docs]
def segment(markdown: str) -> list[Slide]:
"""Split *markdown* into slides.
Prefers explicit horizontal-rule slide breaks (``***`` / ``---`` / ``___``
on their own line, the common reveal.js/Marp/this-project convention);
when the document has none, falls back to splitting on every H1/H2
heading, mirroring Pandoc's own default slide-level behaviour.
"""
chunks = _HR_RE.split(markdown)
# re.split with a capturing group also yields the separators; drop them.
chunks = [
c
for c in chunks
if c not in ("***", "---", "___") and not re.fullmatch(r"\*+|-+|_+", c or "")
]
if len(chunks) <= 1:
# <=1 chunk means the HR split found no separators at all (a single
# leftover chunk is just the whole document) — fall back to headings.
# Reconstruct chunks by splitting on each heading line's start offset.
starts = [m.start() for m in _HEADING_RE.finditer(markdown)]
if len(starts) > 1:
chunks = [
markdown[a:b] for a, b in zip(starts, starts[1:] + [len(markdown)], strict=True)
]
else:
chunks = [markdown]
slides: list[Slide] = []
for chunk in chunks:
s = _slide_from_chunk(len(slides) + 1, chunk)
if s is not None:
slides.append(s)
return slides
# ── stage A: LLM text-based selection ────────────────────────────────────────
_SELECT_SCHEMA = {
"type": "object",
"properties": {
"choices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"index": {"type": "integer"},
"layout_name": {"type": "string"},
"confidence": {"type": "number"},
"why": {"type": "string"},
},
"required": ["index", "layout_name", "why"],
},
}
},
"required": ["choices"],
}
_SELECT_PROMPT_HEADER = (
"You map Markdown slides onto a designer's PowerPoint template. For EACH "
"Markdown slide, choose the ONE template layout whose design best carries "
"its role. Match by ARCHETYPE:\n"
"- a lone title / the deck opener -> cover\n"
"- a part/section header ('Part N ...', or a {.big} heading with little "
"body) -> section-divider\n"
"- a short punchy one-liner ({.big}) -> big-statement\n"
"- a slide whose whole point is one figure/percentage -> big-number\n"
"- two-column content -> two-column-split\n"
"- an image-dominant slide (one figure/screenshot) -> photo-hero\n"
"- an ordered list of steps -> process-steps\n"
"- normal bullet/paragraph content -> bulleted-content\n"
"- the final thanks/closing slide -> closing\n"
"Prefer VARIETY: do not send most slides to one generic layout. Reserve "
"cover/closing/section-divider for those roles.\n\n"
)
def _catalog_block(content: list[LayoutInfo]) -> str:
return "\n".join(f"- {c.name} [{c.archetype}] {c.caption}".rstrip() for c in content)
def _archetype_fallback(features: list[str]) -> str:
"""A cheap rule-based archetype guess, used when the LLM stage is unusable."""
if "big-statement" in features:
return "big-statement"
if "mermaid" in features or "image" in features:
return "photo-hero"
if "ordered-list" in features:
return "process-steps"
if "two-column" in features:
return "two-column-split"
return "bulleted-content"
[docs]
def select_layouts(
slides: list[Slide],
catalog: list[LayoutInfo],
*,
chat: ChatFn | None = None,
batch: int = 12,
) -> None:
"""Fill ``slide.chosen_layout`` for every slide via batched LLM text calls.
Only *content* layouts are offered (brand-reference pages are dropped),
each tagged with its archetype so the model matches by role. On any
transport failure, slides in that batch fall back to the best
archetype-matching content layout (never left unassigned).
"""
content = [c for c in catalog if c.is_content_layout]
if not content:
return
# First-seen wins per archetype: catalog order runs most-used layout
# first (see extract_layouts' final sort), so this favours the
# template's more common/representative layout for each role.
by_archetype: dict[str, str] = {}
for c in content:
by_archetype.setdefault(c.archetype, c.name)
names = {c.name for c in content}
fallback = by_archetype.get("bulleted-content", content[0].name)
cat_txt = _catalog_block(content)
chat = chat or default_chat(kind="llm")
# Batched, not one call per slide: keeps the prompt (and cost) bounded for
# a long deck while still giving the model several slides' worth of
# context at once to reason about variety across the batch.
for start in range(0, len(slides), batch):
group = slides[start : start + batch]
listing = "\n".join(
f"[{s.index}] title: {s.title!r} | features: {', '.join(s.features) or 'plain text'}"
for s in group
)
prompt = (
f"{_SELECT_PROMPT_HEADER}TEMPLATE LAYOUT CATALOG (content layouts only):\n{cat_txt}\n\n"
f"MARKDOWN SLIDES:\n{listing}\n\n"
'Reply as JSON {"choices":[{index, layout_name, confidence, why}]} '
"with one entry per slide. layout_name MUST be one of the catalog "
"names; why <= 12 words."
)
out = chat(prompt, None, _SELECT_SCHEMA)
try:
data = (
out if isinstance(out, dict) else (json.loads(out) if isinstance(out, str) else {})
)
choices = data.get("choices", [])
except (json.JSONDecodeError, AttributeError):
# A transport failure (None) or a malformed reply both land here;
# an empty choices list means every slide in this batch falls
# through to the archetype-fallback branch below.
choices = []
by_index = {c.get("index"): c for c in choices}
for s in group:
c = by_index.get(s.index) or {}
# Reject a hallucinated layout name outright — better a sensible
# archetype fallback than a KeyError deep in the assembler later.
name = c.get("layout_name") if c.get("layout_name") in names else None
if name is None:
name = by_archetype.get(_archetype_fallback(s.features), fallback)
s.chosen_layout = name
s.chosen_confidence = c.get("confidence")
s.chosen_why = c.get("why", "") or ""
# ── stage B: VLM visual tie-break ────────────────────────────────────────────
_VISUAL_SCHEMA = {
"type": "object",
"properties": {
"best": {"type": "string"},
"why": {"type": "string"},
},
"required": ["best"],
}
_VISUAL_PROMPT = (
"You are choosing which PowerPoint template layout will best PRESENT the "
"following slide content (shown below as text). Each image below is one "
"candidate layout, in this order: {names}. Look at where each candidate "
"places its title/body/image regions and judge which one actually suits "
"this content best, not just which name sounds right.\n\n"
"SLIDE:\ntitle: {title!r}\nfeatures: {features}\n\n"
'Reply as JSON {{"best": <one of {names}>, "why": <<=12 words>}}.'
)
def _alternates_for(s: Slide, catalog_by_name: dict[str, LayoutInfo], k: int) -> list[LayoutInfo]:
"""Pick up to *k* candidate layouts for the visual tie-break: the current
pick plus archetype-diverse alternates (so the VLM sees a real choice, not
near-duplicates)."""
chosen = catalog_by_name.get(s.chosen_layout) if s.chosen_layout else None
# Only layouts with a rendered example are eligible: without one there is
# no thumbnail to show the vision model, so it could never be picked.
pool = [c for c in catalog_by_name.values() if c.is_content_layout and c.example_pages]
out: list[LayoutInfo] = [chosen] if chosen else []
seen_archetypes = {chosen.archetype} if chosen else set()
for c in pool:
if len(out) >= k:
break
if c.name in {o.name for o in out}:
continue
# One layout per archetype: two "bulleted-content" layouts would look
# nearly identical in a thumbnail comparison and waste a slot that
# could show a genuinely different composition instead.
if c.archetype in seen_archetypes:
continue
out.append(c)
seen_archetypes.add(c.archetype)
return out
[docs]
def visual_confirm(
slides: list[Slide],
catalog: list[LayoutInfo],
*,
chat: ChatFn | None = None,
render: RenderFn | None = None,
template: Path | None = None,
k: int = 3,
only_low_confidence: bool = False,
confidence_threshold: float = 0.7,
) -> None:
"""Visually confirm (or override) each slide's text-stage layout pick.
This is the VLM dimension the pure-text :func:`select_layouts` cannot
provide: it renders each candidate's example page, shows the vision model
the actual thumbnails side by side with the slide's content, and asks
which one genuinely carries that content best. Disagreements override
``slide.chosen_layout``; any transport/render failure leaves the text
stage's pick untouched (best-effort, never load-bearing).
Parameters
----------
only_low_confidence : bool, default False
When ``True``, only re-check slides whose text-stage confidence is
missing or below *confidence_threshold* (cheaper, fewer VLM calls);
when ``False`` (default) every slide gets a visual check.
"""
if template is None:
return
render = render or default_render()
chat = chat or default_chat(kind="vlm")
catalog_by_name = {c.name: c for c in catalog}
for s in slides:
if only_low_confidence:
conf = s.chosen_confidence
# A missing confidence (transport failure -> archetype fallback)
# is treated as "worth checking", not skipped — it's exactly the
# case where the text stage had the least signal to go on.
if conf is not None and conf >= confidence_threshold:
continue
candidates = _alternates_for(s, catalog_by_name, k)
if len(candidates) < 2:
continue # nothing to tie-break against
images: list[bytes] = []
usable: list[LayoutInfo] = []
for c in candidates:
page = c.rep_page
if page is None:
continue
png = render(template, page)
if png is None:
continue
# images/usable stay index-aligned: the prompt's "in this order"
# listing must match exactly what the model is shown.
images.append(png)
usable.append(c)
if len(usable) < 2:
continue # a render/no-example gap left too few options to compare
names = [c.name for c in usable]
prompt = _VISUAL_PROMPT.format(
names=", ".join(names),
title=s.title,
features=", ".join(s.features) or "plain text",
)
out = chat(prompt, images, _VISUAL_SCHEMA)
data = out if isinstance(out, dict) else (json.loads(out) if isinstance(out, str) else None)
if not data:
continue
best = data.get("best")
if best in names and best != s.chosen_layout:
logger.info(
"visual tie-break overrides slide %d: %s -> %s (%s)",
s.index,
s.chosen_layout,
best,
data.get("why", ""),
)
s.chosen_layout = best
s.chosen_why = str(data.get("why", "")) or s.chosen_why