← Files Creative ProductionARCHIVED FILE

skills/produce/helpers/offers/scripts/build_offer_explorer.py

21.1 KB · Oct 5, 2026 · 18:03 UTC

↓ Download file

#!/usr/bin/env python3
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
PLUGIN_ROOT = SCRIPT_DIR.parents[4]
if str(PLUGIN_ROOT) not in sys.path:
    sys.path.insert(0, str(PLUGIN_ROOT))

from scripts import review_renderer  # noqa: E402

DEFAULT_CORE_PACK = (
    PLUGIN_ROOT / "assets" / "offer-library" / "packs" / "cross-industry-product-ad-archetypes.json"
)
SUPPLEMENTAL_PACKS_DIR = SKILL_DIR / "references" / "packs"
DEFAULT_IMAGE_GENERATION_RUNNER = None
PROMPT_SCALE_DEFAULT_PACKS = set()


def slugify(value):
    value = value.lower()
    value = re.sub(r"[^a-z0-9]+", "-", value)
    return value.strip("-") or "offer"


def read_offer_brief(args):
    parts = []
    offer_brief = args.offer_brief
    offer_brief_file = args.offer_brief_file
    if offer_brief:
        parts.append(offer_brief.strip())
    if offer_brief_file:
        file_text = Path(offer_brief_file).read_text().strip()
        lines = file_text.splitlines()
        if lines and lines[0].strip().lower() == args.offer_name.strip().lower():
            file_text = "\n".join(lines[1:]).strip()
        parts.append(file_text)
    brief = "\n".join(part for part in parts if part)
    if not brief:
        raise SystemExit("Provide --offer-brief or --offer-brief-file.")
    return brief


def use_case_for(category):
    if "Text" in category or "Research" in category:
        return "infographic-diagram"
    if "Paid" in category or "Product" in category:
        return "product-mockup"
    return "stylized-concept"


def existing_default_family_map():
    return DEFAULT_CORE_PACK


def read_json_file(path, label):
    path = Path(path)
    if not path.exists():
        raise SystemExit(f"{label} not found: {path}")
    return json.loads(path.read_text())


def normalize_pack(pack, fallback_id=None, fallback_title=None):
    pack = dict(pack)
    if "families" not in pack and isinstance(pack.get("archetypes"), list):
        pack["families"] = pack["archetypes"]
    pack["id"] = pack.get("id") or fallback_id or "pack"
    pack["title"] = pack.get("title") or fallback_title or pack["id"].replace("-", " ").title()
    if not isinstance(pack.get("families"), list) or not pack["families"]:
        raise SystemExit(f"Pack '{pack['id']}' must include families.")
    return pack


def family_map_for_pack(pack):
    return {
        "version": pack.get("version", 1),
        "routes": pack.get("routes", []),
        "families": pack["families"],
        "review": pack.get("review", {}),
    }


def load_pack_from_file(path, pack_id=None, label="pack"):
    data = read_json_file(path, label)
    if isinstance(data.get("packs"), list):
        if pack_id:
            pack = next((item for item in data["packs"] if item.get("id") == pack_id), None)
            if not pack:
                available = ", ".join(item.get("id", "<missing-id>") for item in data["packs"])
                raise SystemExit(
                    f"Pack '{pack_id}' not found in {path}. Available packs: {available}"
                )
        elif len(data["packs"]) == 1:
            pack = data["packs"][0]
        else:
            available = ", ".join(item.get("id", "<missing-id>") for item in data["packs"])
            raise SystemExit(f"Choose --pack for {path}. Available packs: {available}")
        pack = {**pack, "version": pack.get("version", data.get("version", 1))}
        return normalize_pack(pack, fallback_id=pack_id)
    return normalize_pack(data, fallback_id=pack_id)


def load_checked_in_packs():
    if not SUPPLEMENTAL_PACKS_DIR.exists():
        return []
    packs = []
    for path in sorted(SUPPLEMENTAL_PACKS_DIR.glob("*.json")):
        packs.append(load_pack_from_file(path, path.stem, "supplemental pack"))
    return packs


def load_checked_in_pack(pack_id):
    return next((pack for pack in load_checked_in_packs() if pack["id"] == pack_id), None)


def load_scale_map(args):
    if args.pack == "core":
        core_pack = load_pack_from_file(args.family_map, label="core offer library")
        return family_map_for_pack(core_pack), {
            **core_pack,
            "id": "core",
            "title": "Core Offer Explorer",
        }

    supplemental_pack = load_checked_in_pack(args.pack)
    if supplemental_pack:
        return family_map_for_pack(supplemental_pack), supplemental_pack

    if not args.expansion_map:
        supplemental_ids = [item["id"] for item in load_checked_in_packs()]
        available = ", ".join(["core"] + supplemental_ids)
        raise SystemExit(
            f"Unknown pack '{args.pack}'. Available checked-in packs: {available}. "
            "Pass --expansion-map with a packaged library file to use another pack."
        )

    pack = load_pack_from_file(args.expansion_map, args.pack, "expansion pack")
    return family_map_for_pack(pack), pack


def default_scale_for_pack(pack_id):
    if pack_id in PROMPT_SCALE_DEFAULT_PACKS:
        return "prompt"
    return "family"


def resolve_scale(args, pack):
    return args.scale or default_scale_for_pack(pack["id"])


def subject_labels(subject_kind):
    normalized = (subject_kind or "product").strip().lower()
    labels = {
        "product": {
            "anchor": "Product anchor",
            "facts": "Product facts to preserve",
            "noun": "product",
            "recognizable": "keep the product recognizable",
            "category": "brand or product category",
        },
        "service": {
            "anchor": "Service anchor",
            "facts": "Service facts to preserve",
            "noun": "service",
            "recognizable": "keep the service moment recognizable",
            "category": "brand, service, or category",
        },
        "venue": {
            "anchor": "Venue anchor",
            "facts": "Venue and experience facts to preserve",
            "noun": "venue experience",
            "recognizable": "keep the venue, service style, and dining occasion recognizable",
            "category": "brand, venue, restaurant category, or unsupported offer",
        },
        "experience": {
            "anchor": "Experience anchor",
            "facts": "Experience facts to preserve",
            "noun": "experience",
            "recognizable": "keep the experience recognizable",
            "category": "brand, experience, or category",
        },
        "digital-product": {
            "anchor": "Digital product anchor",
            "facts": "Product UI and workflow facts to preserve",
            "noun": "digital product",
            "recognizable": "keep the product UI, workflow, and product-proof cues recognizable",
            "category": "digital product, app, SaaS workflow, or UI category",
            "guidance": [
                "Digital product proof: make the product UI, workflow state, and interface feedback the visual evidence.",
                "UI fidelity: use sparse, plausible labels and placeholder data only; do not invent real customer names, private data, or unsupported metrics.",
                "Do not drift into generic abstract SaaS imagery when UI/product proof is requested.",
            ],
        },
    }
    return labels.get(normalized, labels["product"])


def prompt_for(offer_name, offer_brief, family, route=None, subject_kind="product"):
    labels = subject_labels(subject_kind)
    offer_facts = offer_brief.rstrip(".")
    lines = [
        f"Create one polished business visual review image for the prompt family: {family['title']}.",
        f"{labels['anchor']}: {offer_name}.",
        f"{labels['facts']}: {offer_facts}.",
        f"The visual must be clearly about this {labels['noun']} and should not introduce another {labels['category']}.",
        *(
            [f"Scene archetype: {family['sceneArchetype']}."]
            if family.get("sceneArchetype")
            else []
        ),
        *(
            [f"Reusable placeholders: {', '.join(family['placeholders'])}."]
            if family.get("placeholders")
            else []
        ),
        f"Family intent: {family['summary']}",
        f"Business goal: {family['businessGoal']}.",
        f"Scene: {family['scene']}.",
        *([f"Ad format: {family['format']}."] if family.get("format") else []),
        *([f"Human presence: {family['humanPresence']}."] if family.get("humanPresence") else []),
        *([f"Rendering mode: {family['renderingMode']}."] if family.get("renderingMode") else []),
        *([f"Channel fit: {', '.join(family['channelFit'])}."] if family.get("channelFit") else []),
        *([f"Scene shape: {family['sceneShape']}."] if family.get("sceneShape") else []),
        *(
            [f"Subject placement: {family['subjectPlacement']}."]
            if family.get("subjectPlacement")
            else []
        ),
        *(
            [f"Subject slot: {format_subject_slot(family['subjectSlot'])}."]
            if family.get("subjectSlot")
            else []
        ),
        *(labels.get("guidance") or []),
        f"Focus: {family['focus']}.",
        f"Decision being supported: {family['decision']}.",
    ]
    prompt_template = family.get("promptTemplate") or family.get("templatePrompt")
    if prompt_template:
        lines.append(
            "Reusable prompt pattern: "
            + instantiate_prompt_template(prompt_template, offer_name, labels, offer_facts)
        )
    if family.get("copyRules"):
        lines.append(f"Copy rules: {format_copy_rules(family['copyRules'])}.")
    if route:
        lines.extend(
            [
                f"Creative route: {route['name']} ({route['channel']}).",
                f"Route style: {route['style']}.",
                f"Route composition: {route['composition']}.",
                f"Route lighting: {route['lighting']}.",
                f"Route emphasis: {route['emphasis']}.",
            ]
        )
    lines.extend(
        [
            f"Include: {', '.join(family['elements'])}.",
            (
                "Constraints: "
                + "; ".join(family["constraints"])
                + "; keep text sparse; use short labels, UI placeholder blocks, or visual markers "
                f"only when needed; {labels['recognizable']}."
            ),
            (
                f"Avoid: {family['avoid']}; fake endorsements; unsupported claims; misspelled "
                "large text; watermarks; clutter; other brands."
            ),
        ]
    )
    return "\n".join(lines)


def format_copy_rules(copy_rules):
    if not isinstance(copy_rules, dict):
        return str(copy_rules)
    parts = []
    for key, value in copy_rules.items():
        if value:
            parts.append(f"{key}: {value}")
    return "; ".join(parts)


def instantiate_prompt_template(template, offer_name, labels, offer_facts):
    replacements = {
        "[SUBJECT_REFERENCE]": offer_name,
        "[PRODUCT_REFERENCE]": offer_name,
        "[SUBJECT_KIND]": labels["noun"],
        "[PRODUCT_CATEGORY]": labels["category"],
        "[AUDIENCE]": "the target audience described in the product facts",
        "[PRIMARY_BENEFIT]": "the approved primary benefit in the product facts",
        "[BRAND_PALETTE]": "the supplied brand palette and material cues",
        "[HEADLINE]": "the exact supplied headline in the product facts, if any",
        "[CTA_OR_CONTACT]": "the exact supplied CTA or contact detail in the product facts, or no CTA if none is supplied",
        "[CHANNEL]": "the requested channel or review context",
        "[AVOID]": "the avoid list and fidelity risks in the product facts",
        "[PRIMARY_ENVIRONMENT]": "a category-appropriate environment from the product facts",
        "[SURFACE_OR_BASE]": "a realistic surface or base that supports the product",
        "[LIGHTING_STYLE]": "a lighting style that supports the selected ad family",
        "[SUPPORTING_CUES]": "truthful supporting cues from the product facts",
        "[MOOD]": "the mood implied by the selected ad family and product facts",
        "[COPY_SPACE]": "clean copy space when the ad family calls for it",
    }
    rendered = template
    for placeholder, value in replacements.items():
        rendered = rendered.replace(placeholder, value)
    if offer_facts:
        rendered += f" Product facts: {offer_facts}."
    return rendered


def format_subject_slot(slot):
    if not isinstance(slot, dict):
        return str(slot)
    parts = []
    for key in (
        "slotType",
        "placement",
        "scale",
        "interaction",
        "fallbackIfService",
        "fallbackIfDigital",
        "preserve",
    ):
        value = slot.get(key)
        if not value:
            continue
        if isinstance(value, list):
            value = ", ".join(str(item) for item in value)
        parts.append(f"{key}: {value}")
    return "; ".join(parts)


def build_jobs(offer_name, offer_brief, family_map, size, quality, scale, subject_kind="product"):
    jobs = []
    manifest = []
    routes = family_map.get("routes") or []
    if scale == "prompt" and not routes:
        raise SystemExit("Prompt run requires routes in the family map.")

    entries = []
    if scale == "family":
        entries = [(family, None) for family in family_map["families"]]
    else:
        for family in family_map["families"]:
            for route in routes:
                entries.append((family, route))

    width = 3 if len(entries) >= 100 else 2
    for index, (family, route) in enumerate(entries, start=1):
        suffix = family["id"]
        title = family["title"]
        if route:
            suffix = f"{suffix}-{route['code'].lower()}-{slugify(route['name'])}"
            title = f"{title} / {route['name']}"
        out_name = f"{index:0{width}d}-{suffix}.png"
        prompt = prompt_for(offer_name, offer_brief, family, route, subject_kind)
        job_size = family.get("size") or size
        jobs.append(
            {
                "id": f"{index:0{width}d}-{suffix}",
                "prompt": prompt,
                "use_case": use_case_for(family["category"]),
                "size": job_size,
                "quality": quality,
                "output_format": "png",
                "out": out_name,
                "metadata": {
                    "id": family["id"],
                    "title": family["title"],
                    "category": family["category"],
                    "size": job_size,
                    "route": route["code"] if route else None,
                    "route_name": route["name"] if route else None,
                },
            }
        )
        manifest.append(
            {
                "index": index,
                "id": family["id"],
                "title": title,
                "familyTitle": family["title"],
                "category": family["category"],
                "size": job_size,
                "route": route["code"] if route else None,
                "routeName": route["name"] if route else None,
                "output": out_name,
                "prompt": prompt,
            }
        )
    return jobs, manifest


def write_batch(out_dir, jobs, manifest, offer_name, offer_brief):
    out_dir.mkdir(parents=True, exist_ok=True)
    job_file = out_dir / "jobs.jsonl"
    manifest_file = out_dir / "prompts-manifest.json"
    brief_file = out_dir / "offer-brief.txt"
    job_file.write_text("\n".join(json.dumps(job) for job in jobs) + "\n")
    manifest_file.write_text(json.dumps(manifest, indent=2) + "\n")
    brief_file.write_text(f"{offer_name}\n\n{offer_brief}\n")
    return job_file, manifest_file, brief_file


def write_scale_metadata(out_dir, args, pack, family_map):
    metadata_file = out_dir / "visual-explorer-metadata.json"
    metadata = {
        "scale": args.scale,
        "pack": pack["id"],
        "packTitle": pack["title"],
        "subjectKind": args.subject_kind,
        "families": len(family_map["families"]),
        "routes": len(family_map.get("routes") or []),
        "items": len(family_map["families"])
        * (len(family_map.get("routes") or []) if args.scale == "prompt" else 1),
    }
    for key in ("summary", "whenToUse", "requiredInputs", "riskGates", "review"):
        if pack.get(key):
            metadata[key] = pack[key]
    metadata_file.write_text(json.dumps(metadata, indent=2) + "\n")
    return metadata_file


def run_imagegen(args, job_file, out_dir):
    if args.image_generation_runner is None:
        raise SystemExit(
            "Image generation is chat-managed in this plugin. Run without --generate "
            "and let the Creative Production coordinator generate and ingest the images."
        )
    cmd = [
        sys.executable,
        str(args.image_generation_runner),
        "--input",
        str(job_file),
        "--out-dir",
        str(out_dir),
        "--workspace",
        str(args.workspace),
        "--max-concurrency",
        str(args.max_concurrency),
        "--max-attempts",
        str(args.max_attempts),
        "--timeout-seconds",
        str(args.timeout_seconds),
    ]
    subprocess.run(cmd, check=True)


def image_files(out_dir):
    return review_renderer.image_files(out_dir)


def build_review_html(out_dir, manifest, review_options=None):
    return review_renderer.build_review_html(out_dir, manifest, review_options)


def build_contact_sheet(out_dir, manifest, review_options=None):
    return review_renderer.build_contact_sheet(out_dir, manifest, review_options)


def main():
    parser = argparse.ArgumentParser(description="Build an offer explorer run.")
    parser.add_argument("--offer-name")
    parser.add_argument("--offer-brief")
    parser.add_argument("--offer-brief-file")
    parser.add_argument(
        "--subject-kind",
        choices=["product", "service", "venue", "experience", "digital-product"],
        default="product",
        help="What kind of subject the explorer should preserve.",
    )
    parser.add_argument("--out-dir")
    parser.add_argument(
        "--scale",
        choices=["family", "prompt"],
        default=None,
        help=(
            "family = one item per prompt family; prompt = every family crossed with the route prompts. "
            "Defaults to prompt for hero/web modules and family otherwise."
        ),
    )
    parser.add_argument(
        "--pack",
        default="core",
        help="Prompt pack id. Use 'core' for the default offer explorer run.",
    )
    parser.add_argument("--family-map", type=Path, default=existing_default_family_map())
    parser.add_argument("--expansion-map", type=Path)
    parser.add_argument(
        "--image-generation-runner", type=Path, default=DEFAULT_IMAGE_GENERATION_RUNNER
    )
    parser.add_argument("--workspace", type=Path, default=PLUGIN_ROOT.parents[1])
    parser.add_argument("--size", default="1024x1024")
    parser.add_argument("--quality", default="medium")
    parser.add_argument("--max-concurrency", type=int, default=64)
    parser.add_argument("--max-attempts", type=int, default=2)
    parser.add_argument("--timeout-seconds", type=float, default=600)
    parser.add_argument("--generate", action="store_true")
    parser.add_argument("--review-only", action="store_true")
    parser.add_argument("--force", action="store_true")
    args = parser.parse_args()

    if not args.offer_name:
        raise SystemExit("Provide --offer-name.")

    offer_brief = read_offer_brief(args)
    family_map, pack = load_scale_map(args)
    args.scale = resolve_scale(args, pack)
    if args.scale == "prompt" and args.pack == "core":
        default_suffix = "100-offer-explorer"
    elif args.scale == "prompt":
        default_suffix = "offer-explorer"
    else:
        default_suffix = "offer-explorer"
    if args.pack == "core":
        default_out = f"outputs/imagegen/{slugify(args.offer_name)}-{default_suffix}"
    else:
        default_out = (
            f"outputs/imagegen/{slugify(args.offer_name)}-{slugify(args.pack)}-{default_suffix}"
        )
    out_dir = Path(args.out_dir or default_out)
    jobs, manifest = build_jobs(
        args.offer_name,
        offer_brief,
        family_map,
        args.size,
        args.quality,
        args.scale,
        args.subject_kind,
    )
    job_file, manifest_file, brief_file = write_batch(
        out_dir, jobs, manifest, args.offer_name, offer_brief
    )
    metadata_file = write_scale_metadata(out_dir, args, pack, family_map)

    if args.generate and not args.review_only:
        run_imagegen(args, job_file, out_dir)

    review_options = pack.get("review", {})
    review_file = build_review_html(out_dir, manifest, review_options)
    contact_sheet = build_contact_sheet(out_dir, manifest, review_options)
    print(
        json.dumps(
            {
                "scale": args.scale,
                "pack": pack["id"],
                "packTitle": pack["title"],
                "subjectKind": args.subject_kind,
                "families": len(family_map["families"]),
                "items": len(manifest),
                "jobs": str(job_file.resolve()),
                "manifest": str(manifest_file.resolve()),
                "metadata": str(metadata_file.resolve()),
                "offer_brief": str(brief_file.resolve()),
                "review_html": str(review_file.resolve()),
                "contact_sheet": str(contact_sheet.resolve()) if contact_sheet else None,
                "images_found": len(image_files(out_dir)),
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()

SHA-256: 9c83d7dea5cc1cd478605002ee84ba22c2b24160a3f902fc5563378bfb2dc3e9