| |
| |
| |
|
|
| from __future__ import annotations |
|
|
| import json |
| import re |
| from dataclasses import replace |
| from typing import Any |
| from urllib.parse import urlparse |
|
|
| from puppet_theater.backends import ( |
| HFAPIBackend, |
| HF_API_BACKDROP_URL_MAX_TOKENS, |
| HF_API_SHOW_BIBLE_MAX_TOKENS, |
| HF_API_SUMMON_ACTOR_MAX_TOKENS, |
| LocalGGUFActorBackend, |
| LocalLoRAActorBackend, |
| OpenBMBTransformersBackend, |
| _run_with_timeout, |
| format_chatml, |
| get_backend, |
| ) |
| from puppet_theater.models import Actor, TheaterSession |
| from puppet_theater.tools import ALLOWED_TOOL_NAMES |
|
|
| _SHOW_BIBLE_INSTRUCTIONS = """You are casting a three-character puppet improv for "AI Puppet Theater". |
| Return ONE compact JSON object only. No markdown. No keys beyond the schema. |
| |
| Schema: |
| { |
| "show_title": "short catchy title, max 8 words", |
| "setting": "one vivid sentence describing the stage backdrop and mood", |
| "backdrop_description": "two short sentences (see Rules)", |
| "director": { |
| "name": "director puppet name (include a playful title)", |
| "avatar": "single emoji", |
| "avatar_image_url": "https portrait URL or empty string", |
| "goal": "one sentence", |
| "secret": "one sentence, playful", |
| "speaking_style": "short phrase", |
| "tools": ["subset of: change_lighting, consult_stage_oracle, inspect_prop"] |
| }, |
| "puppet_actors": [ |
| { |
| "name": "...", |
| "avatar": "emoji", |
| "avatar_image_url": "https or empty", |
| "goal": "...", |
| "secret": "...", |
| "speaking_style": "...", |
| "tools": ["..."] |
| }, |
| { |
| "name": "...", |
| "avatar": "emoji", |
| "avatar_image_url": "https or empty", |
| "goal": "...", |
| "secret": "...", |
| "speaking_style": "...", |
| "tools": ["..."] |
| } |
| ] |
| } |
| |
| Rules: |
| - director.tools must include change_lighting or consult_stage_oracle (at least one). |
| - puppet_actors must have **at least two** entries (exactly two preferred). Never put the director inside puppet_actors; never add a third puppet there if you already have a top-level director. Extra puppets beyond two are ignored by the app. |
| - Names must be unique across all three characters. |
| - Keep every string field under 200 characters (backdrop_description may be up to ~320 characters). |
| - backdrop_description: REQUIRED. Describe a **minimal** wide-stage photographic background for puppets: soft light, simple shapes or gentle gradients, lots of calm empty space in the **center** for characters, **low detail**, **no crowds or faces**, **no text or logos**, **no busy fine patterns**. Mood may echo the premise but stay restrained so puppets stay readable. Do not include URLs here. |
| - avatar_image_url may be https://api.dicebear.com/7.x/avataaars/svg?seed=ENCODED_NAME or empty. |
| |
| Premise: |
| """ |
|
|
| SHOW_BIBLE_SYSTEM_MESSAGE = ( |
| "You are generating show metadata for AI Puppet Theater. " |
| "Return only one valid JSON object. No markdown. No commentary." |
| ) |
|
|
| BACKDROP_URL_SYSTEM_MESSAGE = ( |
| "You return one valid JSON object for AI Puppet Theater: only the key backdrop_image_url. " |
| "No markdown. No commentary." |
| ) |
|
|
| |
| _LLM_CAST_BACKENDS = frozenset({"hf_api", "openbmb", "local_lora", "local_gguf"}) |
|
|
|
|
| def llm_backend_order(director_mode: str, backend_name: str) -> list[str]: |
| """Prefer director LLM first, then actor backend, deduplicated.""" |
| order: list[str] = [] |
| for mode in (director_mode, backend_name): |
| key = (mode or "").strip().lower() |
| if key in _LLM_CAST_BACKENDS and key not in order: |
| order.append(key) |
| return order |
|
|
|
|
| |
| _HTTP_TO_HTTPS_IMAGE_HOSTS = frozenset( |
| { |
| "images.unsplash.com", |
| "plus.unsplash.com", |
| "cdn.pixabay.com", |
| "upload.wikimedia.org", |
| } |
| ) |
|
|
|
|
| def _netloc_host(netloc: str) -> str: |
| host = netloc.split("@")[-1] |
| return host.split(":")[0].lower() |
|
|
|
|
| def _strip_trailing_junk(url: str) -> str: |
| u = url.strip() |
| junk = ').,];}\'" \n\t' |
| while u and u[-1] in junk: |
| u = u[:-1] |
| return u |
|
|
|
|
| def validate_https_url(raw: Any) -> str | None: |
| if raw is None: |
| return None |
| url = _strip_trailing_junk(str(raw).strip()) |
| if not url: |
| return None |
| lower = url.lower() |
| if lower.startswith("http://"): |
| host = _netloc_host(urlparse(url).netloc) |
| if host in _HTTP_TO_HTTPS_IMAGE_HOSTS: |
| url = "https://" + url[7:] |
| parsed = urlparse(url) |
| if parsed.scheme != "https" or not parsed.netloc: |
| return None |
| if "@" in parsed.netloc: |
| return None |
| return url |
|
|
|
|
| def _salvage_show_bible_backdrop_url(raw_text: str) -> str | None: |
| """ |
| Recover a backdrop URL when the JSON field is missing/invalid but the model |
| still emitted a usable https image link in the completion. |
| """ |
| patterns = ( |
| r"https://images\.unsplash\.com/photo-[\w-]+(?:\?[\w%.&=+-]*)?", |
| r"https://plus\.unsplash\.com/premium_photo-[\w-]+(?:\?[\w%.&=+-]*)?", |
| r"https://cdn\.pixabay\.com/photo/[\w/.-]+(?:\?[\w%.&=+-]*)?", |
| r"https://upload\.wikimedia\.org/wikipedia/commons/[\w/.%-]+", |
| ) |
| for pat in patterns: |
| m = re.search(pat, raw_text) |
| if m: |
| got = validate_https_url(m.group(0)) |
| if got: |
| return got |
| return None |
|
|
|
|
| def _extract_json_object(text: str) -> dict[str, Any] | None: |
| cleaned = text.strip() |
| fence = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, re.IGNORECASE) |
| if fence: |
| cleaned = fence.group(1).strip() |
| start = cleaned.find("{") |
| end = cleaned.rfind("}") |
| if start == -1 or end <= start: |
| return None |
| try: |
| data = json.loads(cleaned[start : end + 1]) |
| except json.JSONDecodeError: |
| return None |
| return data if isinstance(data, dict) else None |
|
|
|
|
| def _filter_tools(raw: Any) -> list[str]: |
| if not isinstance(raw, list): |
| return [] |
| out: list[str] = [] |
| for item in raw: |
| name = str(item).strip() |
| if name in ALLOWED_TOOL_NAMES and name not in out: |
| out.append(name) |
| return out |
|
|
|
|
| def _clip_avatar_emoji(s: str) -> str: |
| t = " ".join(s.strip().split()) |
| if not t: |
| return "🎭" |
| return t[:16] |
|
|
|
|
| def _actor_from_dict(blob: Any, default_tools: list[str]) -> Actor | None: |
| if not isinstance(blob, dict): |
| return None |
| name = " ".join(str(blob.get("name", "")).strip().split()) |
| if not name: |
| return None |
| goal = " ".join(str(blob.get("goal", "")).strip().split()) or "Stay in character." |
| secret = " ".join(str(blob.get("secret", "")).strip().split()) or "Has a tiny backstage secret." |
| style = " ".join(str(blob.get("speaking_style", "")).strip().split()) or "playful and theatrical" |
| tools = _filter_tools(blob.get("tools")) |
| if not tools: |
| tools = list(default_tools) |
| avatar = _clip_avatar_emoji(str(blob.get("avatar", "🎭"))) |
| img = validate_https_url(blob.get("avatar_image_url")) |
| return Actor( |
| name=name, |
| avatar=avatar, |
| goal=goal[:220], |
| secret=secret[:220], |
| speaking_style=style[:160], |
| tools=tools, |
| avatar_image_url=img, |
| ) |
|
|
|
|
| def build_backdrop_url_user_content( |
| premise: str, |
| show_title: str, |
| setting: str, |
| backdrop_description: str, |
| ) -> str: |
| prem = " ".join(premise.strip().split())[:900] |
| tit = " ".join(show_title.strip().split())[:120] |
| st = " ".join(setting.strip().split())[:400] |
| desc = " ".join(backdrop_description.strip().split())[:500] |
| return ( |
| "Choose one real https image URL for a puppet stage backdrop (wide landscape).\n\n" |
| "Follow this art direction closely (minimal, readable behind puppets):\n" |
| f"{desc}\n\n" |
| "Context:\n" |
| f"- Premise: {prem}\n" |
| f"- Show title: {tit}\n" |
| f"- Setting (narrative): {st}\n\n" |
| "Rules for the image:\n" |
| "- https only; prefer images.unsplash.com or plus.unsplash.com; also allowed: cdn.pixabay.com, upload.wikimedia.org.\n" |
| "- Calm, uncluttered, generous empty space toward the center for puppets; no crowds, faces, signage, or busy textures.\n" |
| "- Return ONE JSON object only, no markdown, no extra keys:\n" |
| ' {"backdrop_image_url":"https://images.unsplash.com/photo-..."}\n' |
| "- Use a genuine URL pattern; prefer well-known generic landscape/studio/sky photos if unsure." |
| ) |
|
|
|
|
| def parse_backdrop_url_response(raw_text: str) -> str | None: |
| data = _extract_json_object(raw_text) |
| if data: |
| u = validate_https_url(data.get("backdrop_image_url")) |
| if u: |
| return u |
| for alt in ("backdrop_url", "background_image_url"): |
| u = validate_https_url(data.get(alt)) |
| if u: |
| return u |
| return _salvage_show_bible_backdrop_url(raw_text) |
|
|
|
|
| def resolve_backdrop_image_url_via_llm( |
| backend_name: str, |
| premise: str, |
| show_title: str, |
| setting: str, |
| backdrop_description: str, |
| *, |
| max_new_tokens: int | None = None, |
| temperature: float | None = None, |
| ) -> tuple[str | None, dict[str, object]]: |
| """ |
| Second LLM step: turn minimal backdrop_description (+ premise/setting) into a validated image URL. |
| """ |
| meta: dict[str, object] = {} |
| desc = " ".join(backdrop_description.strip().split()) |
| if not desc: |
| desc = " ".join(setting.strip().split()) |
| meta["description_fallback"] = "setting" |
| else: |
| meta["description_fallback"] = None |
| try: |
| raw = invoke_backdrop_image_url_llm( |
| backend_name, |
| premise=premise, |
| show_title=show_title, |
| setting=setting, |
| backdrop_description=desc, |
| max_new_tokens=max_new_tokens, |
| temperature=temperature, |
| ) |
| meta["raw_char_len"] = len(raw) |
| meta["raw_preview"] = raw[:400] |
| url = parse_backdrop_url_response(raw) |
| meta["parsed_ok"] = bool(url) |
| return url, meta |
| except Exception as exc: |
| meta["error"] = str(exc)[:500] |
| return None, meta |
|
|
|
|
| def parse_show_bible_response(raw_text: str) -> tuple[str, str, str | None, list[Actor]] | None: |
| data = _extract_json_object(raw_text) |
| if not data: |
| return None |
| title = " ".join(str(data.get("show_title", "")).strip().split()) |
| setting = " ".join(str(data.get("setting", "")).strip().split()) |
| if not title or not setting: |
| return None |
| raw_desc: Any = data.get("backdrop_description") or data.get("stage_backdrop_description") |
| backdrop_description = " ".join(str(raw_desc or "").strip().split()) or None |
| if backdrop_description and len(backdrop_description) > 360: |
| backdrop_description = backdrop_description[:360].rsplit(" ", 1)[0].rstrip(",;:") or backdrop_description[:360] |
| director_blob: Any |
| puppets: Any |
| raw_actors = data.get("actors") |
| if isinstance(raw_actors, list) and len(raw_actors) == 3 and all(isinstance(x, dict) for x in raw_actors): |
| director_blob = raw_actors[0] |
| puppets = raw_actors[1:3] |
| else: |
| director_blob = data.get("director") |
| puppets = data.get("puppet_actors") |
| if not isinstance(director_blob, dict) or not isinstance(puppets, list) or len(puppets) < 2: |
| return None |
| |
| if len(puppets) > 2: |
| puppets = puppets[:2] |
| director = _actor_from_dict(director_blob, ["change_lighting", "consult_stage_oracle"]) |
| p0 = _actor_from_dict(puppets[0], ["consult_stage_oracle", "change_lighting"]) |
| p1 = _actor_from_dict(puppets[1], ["inspect_prop", "change_lighting"]) |
| if director is None or p0 is None or p1 is None: |
| return None |
| names = {director.name.lower(), p0.name.lower(), p1.name.lower()} |
| if len(names) != 3: |
| return None |
| actors = [director, p0, p1] |
| return title[:120], setting[:400], backdrop_description, actors |
|
|
|
|
| def invoke_show_bible_llm( |
| backend_name: str, |
| premise: str, |
| *, |
| max_new_tokens: int | None = None, |
| temperature: float | None = None, |
| extra_user_suffix: str = "", |
| ) -> str: |
| """Run one completion; raises on failure.""" |
| user_content = _SHOW_BIBLE_INSTRUCTIONS + premise.strip() + extra_user_suffix |
| budget = max_new_tokens if max_new_tokens is not None else 256 |
| temp = float(temperature) if temperature is not None else 0.55 |
|
|
| backend = get_backend(backend_name) |
| if isinstance(backend, HFAPIBackend): |
| return backend._generate_text( |
| user_content, |
| max_tokens=HF_API_SHOW_BIBLE_MAX_TOKENS, |
| temperature=0.35 if temperature is None else temp, |
| ) |
|
|
| if isinstance(backend, OpenBMBTransformersBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| ot = max(96, min(budget, 160)) |
| backend.configure(max_new_tokens=ot, temperature=temp) |
| try: |
| return backend._generate_text(user_content) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| if isinstance(backend, LocalLoRAActorBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| mt = max(200, min(budget, 320)) |
| backend.configure(max_new_tokens=mt, temperature=temp) |
| messages = [ |
| {"role": "system", "content": SHOW_BIBLE_SYSTEM_MESSAGE}, |
| {"role": "user", "content": user_content}, |
| ] |
| try: |
| backend._load() |
| return _run_with_timeout( |
| lambda: backend._generate_text(messages), |
| backend.timeout_seconds, |
| "Local LoRA show bible generation timed out", |
| ) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| if isinstance(backend, LocalGGUFActorBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| mt = max(200, min(budget, 320)) |
| backend.configure(max_new_tokens=mt, temperature=temp) |
| messages = [ |
| {"role": "system", "content": SHOW_BIBLE_SYSTEM_MESSAGE}, |
| {"role": "user", "content": user_content}, |
| ] |
| prompt = format_chatml(messages) |
| try: |
| backend._load() |
| return _run_with_timeout( |
| lambda: backend._generate_text(prompt), |
| backend.timeout_seconds, |
| "Local GGUF show bible generation timed out", |
| ) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| raise RuntimeError(f"Backend {backend_name!r} cannot run show bible LLM") |
|
|
|
|
| def invoke_backdrop_image_url_llm( |
| backend_name: str, |
| *, |
| premise: str, |
| show_title: str, |
| setting: str, |
| backdrop_description: str, |
| max_new_tokens: int | None = None, |
| temperature: float | None = None, |
| ) -> str: |
| """One completion: JSON with backdrop_image_url only. Raises on failure.""" |
| user_content = build_backdrop_url_user_content(premise, show_title, setting, backdrop_description) |
| budget = max_new_tokens if max_new_tokens is not None else 256 |
| temp = float(temperature) if temperature is not None else 0.45 |
|
|
| backend = get_backend(backend_name) |
|
|
| if isinstance(backend, HFAPIBackend): |
| return backend._generate_text( |
| user_content, |
| max_tokens=HF_API_BACKDROP_URL_MAX_TOKENS, |
| system_message=BACKDROP_URL_SYSTEM_MESSAGE, |
| temperature=0.25 if temperature is None else min(float(temp), 0.55), |
| ) |
|
|
| if isinstance(backend, OpenBMBTransformersBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| ot = max(64, min(budget, 140)) |
| backend.configure(max_new_tokens=ot, temperature=min(temp, 0.45)) |
| try: |
| return backend._generate_text( |
| f"{BACKDROP_URL_SYSTEM_MESSAGE}\n\n{user_content}", |
| ) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| if isinstance(backend, LocalLoRAActorBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| mt = max(96, min(budget, 220)) |
| backend.configure(max_new_tokens=mt, temperature=min(temp, 0.45)) |
| messages = [ |
| {"role": "system", "content": BACKDROP_URL_SYSTEM_MESSAGE}, |
| {"role": "user", "content": user_content}, |
| ] |
| try: |
| backend._load() |
| return _run_with_timeout( |
| lambda: backend._generate_text(messages), |
| backend.timeout_seconds, |
| "Local LoRA backdrop URL generation timed out", |
| ) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| if isinstance(backend, LocalGGUFActorBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| mt = max(96, min(budget, 220)) |
| backend.configure(max_new_tokens=mt, temperature=min(temp, 0.45)) |
| messages = [ |
| {"role": "system", "content": BACKDROP_URL_SYSTEM_MESSAGE}, |
| {"role": "user", "content": user_content}, |
| ] |
| prompt = format_chatml(messages) |
| try: |
| backend._load() |
| return _run_with_timeout( |
| lambda: backend._generate_text(prompt), |
| backend.timeout_seconds, |
| "Local GGUF backdrop URL generation timed out", |
| ) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| raise RuntimeError(f"Backend {backend_name!r} cannot run backdrop URL LLM") |
|
|
|
|
| SUMMON_ACTOR_SYSTEM_MESSAGE = ( |
| "You are casting one new puppet for AI Puppet Theater. " |
| "Return only one valid JSON object. No markdown. No commentary." |
| ) |
|
|
| _SUMMON_ACTOR_SCHEMA = """ |
| Return ONE compact JSON object only. No markdown. Allowed top-level keys only: |
| { |
| "name": "unique puppet stage name (must not match any name already on stage)", |
| "avatar": "single emoji", |
| "avatar_image_url": "https portrait URL or empty string", |
| "goal": "one sentence", |
| "secret": "one sentence, playful", |
| "speaking_style": "short phrase", |
| "tools": ["subset of: change_lighting, consult_stage_oracle, inspect_prop"] |
| } |
| |
| Rules: |
| - tools must be non-empty and only use allowed tool names. |
| - name must be different from every name in "Names already on stage". |
| - Incorporate the audience's suggested name or spirit, but you may refine it for the stage. |
| - Keep every string field under 200 characters. |
| - avatar_image_url may be https://api.dicebear.com/7.x/avataaars/svg?seed=ENCODED_NAME or empty. |
| |
| Show context: |
| """ |
|
|
| _SUMMON_REMINDER_SUFFIX = ( |
| "\n\nReminder: respond with one JSON object only, keys " |
| "name, avatar, avatar_image_url, goal, secret, speaking_style, tools — no intent/line/emotion keys." |
| ) |
|
|
|
|
| def build_summon_actor_user_content(session: TheaterSession, audience_suggested_name: str) -> str: |
| cast = ", ".join(a.name for a in session.actors) or "(none)" |
| label = audience_suggested_name.strip() or "Mystery Guest" |
| return ( |
| f"{_SUMMON_ACTOR_SCHEMA}" |
| f"premise: {session.premise}\n" |
| f"show_title: {session.show_title}\n" |
| f"setting: {session.setting}\n" |
| f"Names already on stage: {cast}\n" |
| f"Audience asked to summon (suggested name): {label}\n" |
| ) |
|
|
|
|
| def parse_summoned_actor_response(raw_text: str) -> Actor | None: |
| data = _extract_json_object(raw_text) |
| if not data: |
| return None |
| blob: Any = data.get("summoned_actor") |
| if blob is None: |
| blob = data.get("actor") |
| if blob is None and isinstance(data.get("name"), str): |
| blob = data |
| if not isinstance(blob, dict): |
| return None |
| return _actor_from_dict(blob, ["consult_stage_oracle", "inspect_prop", "change_lighting"]) |
|
|
|
|
| def _unique_actor_name(actor: Actor, taken_lower: set[str], audience_label: str) -> Actor: |
| if actor.name.lower() not in taken_lower: |
| return actor |
| base = " ".join(audience_label.split()) or actor.name |
| for i in range(2, 14): |
| candidate = f"{base} {i}" |
| if candidate.lower() not in taken_lower: |
| return replace(actor, name=candidate) |
| return replace(actor, name=f"{base} the Wanderer") |
|
|
|
|
| def invoke_summon_actor_llm( |
| backend_name: str, |
| user_content: str, |
| *, |
| max_new_tokens: int | None = None, |
| temperature: float | None = None, |
| extra_user_suffix: str = "", |
| ) -> str: |
| """One completion for summoned-actor JSON; raises on failure.""" |
| text = user_content + extra_user_suffix |
| budget = max_new_tokens if max_new_tokens is not None else 256 |
| temp = float(temperature) if temperature is not None else 0.55 |
| backend = get_backend(backend_name) |
|
|
| if isinstance(backend, HFAPIBackend): |
| return backend._generate_text( |
| text, |
| max_tokens=HF_API_SUMMON_ACTOR_MAX_TOKENS, |
| temperature=0.35 if temperature is None else temp, |
| ) |
|
|
| if isinstance(backend, OpenBMBTransformersBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| ot = max(96, min(budget, 160)) |
| backend.configure(max_new_tokens=ot, temperature=temp) |
| try: |
| return backend._generate_text(text) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| if isinstance(backend, LocalLoRAActorBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| mt = max(180, min(budget, 320)) |
| backend.configure(max_new_tokens=mt, temperature=temp) |
| messages = [ |
| {"role": "system", "content": SUMMON_ACTOR_SYSTEM_MESSAGE}, |
| {"role": "user", "content": text}, |
| ] |
| try: |
| backend._load() |
| return _run_with_timeout( |
| lambda: backend._generate_text(messages), |
| backend.timeout_seconds, |
| "Local LoRA summon actor generation timed out", |
| ) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| if isinstance(backend, LocalGGUFActorBackend): |
| prev_tokens = backend.max_new_tokens |
| prev_temp = backend.temperature |
| mt = max(180, min(budget, 320)) |
| backend.configure(max_new_tokens=mt, temperature=temp) |
| messages = [ |
| {"role": "system", "content": SUMMON_ACTOR_SYSTEM_MESSAGE}, |
| {"role": "user", "content": text}, |
| ] |
| prompt = format_chatml(messages) |
| try: |
| backend._load() |
| return _run_with_timeout( |
| lambda: backend._generate_text(prompt), |
| backend.timeout_seconds, |
| "Local GGUF summon actor generation timed out", |
| ) |
| finally: |
| backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp) |
|
|
| raise RuntimeError(f"Backend {backend_name!r} cannot run summon actor LLM") |
|
|
|
|
| def resolve_summoned_actor_via_llm_or_default( |
| session: TheaterSession, |
| audience_suggested_name: str, |
| ) -> tuple[Actor, str | None, bool]: |
| """ |
| Returns (actor, llm_backend_used_or_none, summon_llm_fallback_used). |
| |
| summon_llm_fallback_used is True when an LLM backend was tried and parsing failed for all attempts. |
| """ |
| label = " ".join(audience_suggested_name.strip().split()) or "Mystery Guest" |
| taken_lower = {a.name.lower() for a in session.actors} |
| default = Actor( |
| name=label, |
| avatar="✨", |
| goal="Make the scene stranger without derailing the finale.", |
| secret="Arrived with one completely unexplained cue.", |
| speaking_style="fresh, eager, and just a little too dramatic", |
| tools=["consult_stage_oracle"], |
| ) |
|
|
| candidates = llm_backend_order(session.director_mode, session.backend_name) |
| if not candidates: |
| return default, None, False |
|
|
| user = build_summon_actor_user_content(session, label) |
| for mode in candidates: |
| suffixes: tuple[str, ...] = ( |
| ("", _SUMMON_REMINDER_SUFFIX) if mode in {"local_lora", "local_gguf"} else ("",) |
| ) |
| for suffix in suffixes: |
| try: |
| raw = invoke_summon_actor_llm( |
| mode, |
| user, |
| max_new_tokens=session.backend_max_new_tokens, |
| temperature=session.backend_temperature, |
| extra_user_suffix=suffix, |
| ) |
| actor = parse_summoned_actor_response(raw) |
| if actor is None: |
| continue |
| actor = _unique_actor_name(actor, taken_lower, label) |
| if actor.name.lower() in taken_lower: |
| continue |
| return actor, mode, False |
| except Exception: |
| continue |
|
|
| return default, None, True |
|
|