"""Conversation playground, LLM refinement, audio effects, export/import, speak, MCP.""" from __future__ import annotations import asyncio import base64 import contextlib import io import json import re import threading import time import uuid import wave from pathlib import Path import requests from typing import Optional from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import Response, StreamingResponse from core.config import _load_settings, _save_settings, _clean_preview_backend from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES from core.registry import _registry_get, TEMP_DIR from core.validation import _copy_limited from core.audio import _to_wav_16k from core.voice import ( _AUDIO_EXTS, _UPLOAD_EXTS, _PICTURE_EXTS, _find_voice_audio, _load_meta, _active_voices_dir, _voice_audio_files, _is_internal_voice_file, ) from core.voice_index import rebuild_voice_index from core.tts_helpers import _preview_request_audio from routes.stt import _transcribe_audio, _clean_stt_backend router = APIRouter() # Serializes outbound calls to the audiobook-attribution LLM. A client-side # timeout aborts the browser fetch but can't kill the backend's blocking # requests.post thread, so a slow/cold model leaves "ghost" requests still # occupying the LLM's single processing slot. Without this lock, the next # chunk (or the retry-in-halves) fires into that busy slot and the LLM # answers with 429s that cascade until the ghosts drain. # threading.Lock (not asyncio.Lock) because the streaming endpoint's SSE # generator runs in a plain thread (StreamingResponse iterates a sync # generator via a threadpool), so both the blocking and streaming endpoints # must share ONE lock object to actually serialize on the LLM's single slot — # two separate locks would let a stream call and its own blocking fallback # fire into that slot concurrently, which is exactly the "ghost request" # scenario this lock exists to prevent. _attribution_llm_lock = threading.Lock() # ── STT hallucination filter ────────────────────────────────────────────────── # Whisper commonly hallucinates these phrases on silence/noise. # Treat them as "no speech detected" rather than passing them to the LLM. _HALLUCINATIONS: frozenset[str] = frozenset([ "reich", "danke", "danke schön", "danke schoen", "vielen dank", "thank you", "thank you.", "thanks", "thanks.", "you", "you.", "copyright", "abonnieren", "untertitel", "subscribe", "subscribing", ]) def _is_hallucination(text: str) -> bool: t = text.strip().lower().rstrip(".!?,;:-").strip() return len(t) <= 2 or t in _HALLUCINATIONS # ── Sentence-boundary helpers for pipelined TTS ─────────────────────────────── _SENT_RE = re.compile(r'(?<=[.!?])\s+') _MIN_SENTENCE = 30 # min chars in buffer before we split def _sentence_split(buf: str) -> int: """Return the index after the first sentence boundary, or -1.""" if len(buf) < _MIN_SENTENCE: return -1 for m in _SENT_RE.finditer(buf): if m.end() >= _MIN_SENTENCE: return m.end() return -1 # ── LLM helpers ─────────────────────────────────────────────────────────────── def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str: """Inline synchronous persona rewrite; raises RuntimeError on failure.""" settings = _load_settings() system = ( f"Rephrase the user's text as if spoken by this character: {persona}\n" "Keep the same meaning but adapt vocabulary, tone, and style to the character. " "Return ONLY the rephrased text — no quotes, no explanation." ) payload: dict = { "messages": [ {"role": "system", "content": system}, {"role": "user", "content": text}, ], "temperature": 0.3, "max_tokens": 512, } if model: payload["model"] = model resp = requests.post( f"{llm_url.rstrip('/')}/chat/completions", json=payload, headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=60, ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() if result.startswith('"') and result.endswith('"'): result = result[1:-1].strip() return result def _request_timeout_seconds(value, default: float = 600.0, minimum: float = 5.0, maximum: float = 600.0) -> float: """Clamp caller-provided LLM timeouts so UI recovery cannot hang indefinitely.""" try: timeout = float(value) except Exception: timeout = default return max(minimum, min(maximum, timeout)) def _is_router_model_alias(model: str) -> bool: return bool(re.match(r"^\s*auto[-_ ]?router", model or "", re.I)) def _response_error_text(resp) -> str: try: text = resp.text or "" except Exception: text = "" return text.strip()[:2000] or getattr(resp, "reason", "") or "Unknown upstream error" def _llm_chat_completion_urls(llm_url: str) -> list[str]: base = (llm_url or "http://localhost:11434/v1").rstrip("/") urls: list[str] = [] if not re.search(r"/v\d+(?:\.\d+)?$", base): urls.append(f"{base}/v1/chat/completions") urls.append(f"{base}/chat/completions") return list(dict.fromkeys(urls)) def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeout: float): last_resp = None last_exc = None for url in _llm_chat_completion_urls(llm_url): try: resp = requests.post(url, json=payload, headers=headers, timeout=timeout) last_resp = resp if resp.status_code in (404, 405) and url != _llm_chat_completion_urls(llm_url)[-1]: continue return resp except Exception as exc: last_exc = exc if last_resp is not None: return last_resp if last_exc: raise last_exc raise RuntimeError("LLM request failed") def _fallback_attribute_response(text: str) -> dict: """Deterministic fallback for attribution when an upstream LLM fails.""" quote_re = re.compile( r"»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|\"([^\"]+)\"|「([^」]+)」|『([^』]+)』", re.DOTALL, ) segments = [] pos = 0 for match in quote_re.finditer(text): if match.start() > pos: narr = text[pos:match.start()].strip() if narr: segments.append({"speaker": "Narrator", "type": "narration", "text": narr, "emotion": ""}) spoken = next((g for g in match.groups() if g), "") if spoken.strip(): segments.append({"speaker": "Unknown", "type": "dialogue", "text": spoken.strip(), "emotion": ""}) pos = match.end() tail = text[pos:].strip() if tail: segments.append({"speaker": "Narrator", "type": "narration", "text": tail, "emotion": ""}) if not segments: segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}] chars = [] for seg in segments: sp = seg.get("speaker") if seg.get("type") == "dialogue" and sp and sp not in ("Narrator", "Unknown") and sp not in chars: chars.append(sp) return {"segments": segments, "characters": chars, "fallback": True} def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) -> str: if explicit_voice: return explicit_voice bindings: dict = settings.get("client_voice_bindings") or {} if client_id and client_id in bindings: return bindings[client_id] return settings.get("captures_default_voice") or "" # ── Routes ──────────────────────────────────────────────────────────────────── @router.post("/api/refine-text") async def refine_text(request: Request): """Clean up raw STT transcription using a local OpenAI-compatible LLM.""" data = await request.json() settings = _load_settings() text: str = (data.get("text") or "").strip() llm_url: str = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") model: str = (data.get("model") or settings.get("refine_model") or settings.get("llm_model") or "").strip() toggles: dict = data.get("toggles") or {} if not text: raise HTTPException(400, "No text to refine") rules = [] if toggles.get("fillers", True): rules.append("Remove filler words (um, uh, like, you know, basically, literally, I mean, so, right, etc.)") if toggles.get("repetitions", True): rules.append("Remove repeated words and false starts (e.g. 'the the dog' → 'the dog', 'I was- I was going' → 'I was going')") if toggles.get("corrections", True): rules.append("Remove self-corrections and restarts, keeping only the final intended phrasing") if toggles.get("punctuation", True): rules.append("Fix punctuation, capitalisation, and sentence boundaries") if not rules: return {"text": text, "original": text} system = ( "You are a transcription cleanup assistant. " "Apply ONLY the following rules to the user's text. " "Return ONLY the cleaned text — no explanations, no quotes, no markdown:\n" + "\n".join(f"- {r}" for r in rules) ) payload: dict = { "messages": [ {"role": "system", "content": system}, {"role": "user", "content": text}, ], "temperature": 0.1, "max_tokens": 2048, } if model: payload["model"] = model try: resp = requests.post( f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=60, ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] refined = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() if refined.startswith('"') and refined.endswith('"'): refined = refined[1:-1].strip() return {"text": refined, "original": text} except Exception as e: raise HTTPException(502, f"LLM refinement failed: {e}") @router.post("/api/rewrite-with-persona") async def rewrite_with_persona(request: Request): """Rewrite user text in a voice persona's character using a local LLM.""" data = await request.json() settings = _load_settings() text: str = (data.get("text") or "").strip() persona: str = (data.get("persona") or "").strip() llm_url: str = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") model: str = (data.get("model") or settings.get("llm_model") or "").strip() mode: str = (data.get("mode") or "rewrite").strip() if not persona: raise HTTPException(400, "No persona defined for this voice") if not text and mode != "compose": raise HTTPException(400, "No text provided") if mode == "compose": system = ( f"You are a voice assistant with this character: {persona}\n" "Write a single natural utterance in this character's voice about the topic given. " "Return ONLY the utterance — no quotes, no explanation." ) user_msg = text or "Introduce yourself briefly." temp = 0.9 else: system = ( f"Rephrase the user's text as if spoken by this character: {persona}\n" "Keep the same meaning but adapt vocabulary, tone, and style to the character. " "Return ONLY the rephrased text — no quotes, no explanation." ) user_msg = text temp = 0.3 payload: dict = { "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user_msg}, ], "temperature": temp, "max_tokens": 512, } if model: payload["model"] = model try: resp = requests.post( f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] result = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() if result.startswith('"') and result.endswith('"'): result = result[1:-1].strip() return {"text": result, "original": text, "persona": persona} except Exception as e: raise HTTPException(502, f"LLM persona rewrite failed: {e}") @router.post("/api/analyze-characters") async def analyze_characters(request: Request): """Analyze a script with an LLM and return per-character voice descriptions. Used by the Script Rehearser to auto-design voices that match each role. Returns: {"characters": [{name, gender, language, age, description}, ...]} """ data = await request.json() script: str = (data.get("script") or "").strip() names: list = data.get("names") or [] language: str = (data.get("language") or "").strip() _settings = _load_settings() llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") model: str = (data.get("model") or _settings.get("llm_model") or "").strip() if not script: raise HTTPException(400, "No script provided") if not names: raise HTTPException(400, "No character names provided") # Truncate script to leave room for output within typical model context windows. # German/non-English text tokenises at ~2.5–3 chars/token, so 6000 chars ≈ 2000–2400 tokens. if len(script) > 6000: script = script[:6000] lang_hint = f" The script language is {language}." if language else "" def _build_system() -> str: return ( "You are a casting director and TTS voice-design expert. " "Your job is to read a script, understand each character deeply from their " "dialogue, role, and context, then write a voice description that a " "text-to-speech model can use to generate a matching voice.\n" f"{lang_hint}\n" "For EVERY character in the provided list output:\n" "- name: exact name as given\n" "- gender: M, F, or N\n" "- language: spoken language of this character\n" "- age: estimated age range (e.g. 20s, 40s, elderly)\n" "- description: 2–3 sentences covering pitch (high/mid/low), pace, " "timbre, accent/dialect, emotional default, and any distinctive speech trait " "that fits the character's personality and role.\n" "Characters with few lines: infer from their role name and context.\n" "Respond with STRICT JSON only — no markdown, no explanation:\n" '{"characters":[{"name":"NAME","gender":"M|F|N","language":"LANG",' '"age":"30s","description":"voice description"}]}\n' "Use the exact character names provided.\n/no-think" ) def _call_llm(batch: list[str]) -> list[dict]: """Call the LLM for one batch of character names; return list of character dicts.""" user_msg = ( "Character names: " + ", ".join(str(n) for n in batch) + "\n\n" "Script:\n" + script ) # 150 tokens per character output, capped at 3072 to stay within 16K context mt = min(3072, max(512, len(batch) * 150)) payload: dict = { "messages": [ {"role": "system", "content": _build_system()}, {"role": "user", "content": user_msg}, ], "temperature": 0.4, "max_tokens": mt, } if model: payload["model"] = model resp = requests.post( f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw for candidate in (content, _extract_json_block(content)): if not candidate: continue try: parsed = json.loads(candidate) if isinstance(parsed, dict) and "characters" in parsed: return parsed["characters"] except Exception: continue return [] # Process cast in batches of 10 to stay well within 16 K context BATCH = 10 all_characters: list[dict] = [] try: for i in range(0, len(names), BATCH): batch = names[i:i + BATCH] all_characters.extend(_call_llm(batch)) except Exception as e: raise HTTPException(502, f"LLM character analysis failed: {e}") if not all_characters: raise HTTPException(502, "LLM did not return valid character JSON") return {"characters": all_characters} @router.post("/api/match-characters-voices") async def match_characters_voices(request: Request): """Pick the best EXISTING library voice for each character (instead of designing new ones). Body: {script, names:[...], voices:[{id, gender, language, tags, description}], llm_url, model, language} Returns: {"assignments": [{name, voice_id, reason}]} """ data = await request.json() script: str = (data.get("script") or "").strip() names: list = data.get("names") or [] voices: list = data.get("voices") or [] language: str = (data.get("language") or "").strip() _settings = _load_settings() llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") model: str = (data.get("model") or _settings.get("llm_model") or "").strip() if not names: raise HTTPException(400, "No character names provided") if not voices: raise HTTPException(400, "No candidate voices provided") if len(script) > 5000: script = script[:5000] def _vline(v: dict) -> str: meta = [str(v.get(k)) for k in ("gender", "language") if v.get(k)] if v.get("tags"): meta.append("tags:" + str(v["tags"])) desc = str(v.get("description") or v.get("name") or "")[:120] return f"- {v.get('id')} [{', '.join(meta)}] {desc}".rstrip() catalogue = "\n".join(_vline(v) for v in voices[:300]) valid_ids = {str(v.get("id")) for v in voices if v.get("id")} system = ( "You are a casting director assigning existing TTS voices to script characters. " f"{('Script language: ' + language + '. ') if language else ''}" "For EACH character, choose the single BEST voice_id from the CATALOGUE. " "RULE 1 — GENDER FIRST: the voice's gender MUST match the character's gender whenever the " "character's gender is clear from the script; only pick a different gender if no same-gender " "voice exists in the catalogue. " "RULE 2 — then match apparent age, language, personality, and the voice's description/tags. " "You MUST choose a voice_id that appears verbatim in the catalogue — never invent one. " "Reuse a voice for two characters only if no better distinct option exists. " "Respond with STRICT JSON only, no markdown:\n" '{"assignments":[{"name":"NAME","voice_id":"ID","reason":"short reason"}]}\n/no-think' ) user = ( "Characters to cast: " + ", ".join(str(n) for n in names) + "\n\n" "CATALOGUE (voice_id [gender, language, tags] description):\n" + catalogue + "\n\n" "Script excerpt:\n" + script ) payload: dict = { "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "temperature": 0.3, "max_tokens": min(2048, max(512, len(names) * 60)), } if model: payload["model"] = model import time as _time raw = None last_err: Exception | None = None for attempt in range(3): try: resp = requests.post( f"{llm_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() break except requests.exceptions.ConnectionError as e: # llama-swap (and similar) often drop the first request while swapping/loading # the model — wait and retry rather than failing the cast. last_err = e _time.sleep(4 + attempt * 3) except Exception as e: last_err = e break if raw is None: raise HTTPException(502, f"LLM voice matching failed: {last_err}") content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw assignments: list = [] for cand in (content, _extract_json_block(content)): if not cand: continue try: parsed = json.loads(cand) if isinstance(parsed, dict) and isinstance(parsed.get("assignments"), list): assignments = parsed["assignments"] break except Exception: continue # Keep only assignments that reference a real catalogue voice clean = [a for a in assignments if isinstance(a, dict) and str(a.get("voice_id")) in valid_ids] return {"assignments": clean} def _extract_json_block(text: str) -> str: """Pull the first {...} JSON object out of an LLM response.""" text = re.sub(r".*?", "", text, flags=re.DOTALL).strip() if text.startswith("```"): text = re.sub(r"^```[a-zA-Z]*\n?", "", text) text = re.sub(r"\n?```$", "", text).strip() start = text.find("{") end = text.rfind("}") if start != -1 and end != -1 and end > start: return text[start:end + 1] return "" def _charsheets_prepare(data: dict) -> dict: """Resolve settings and build the chat payload for one character-sheets extraction request. Shared by the blocking endpoint and the streaming (watch-it-fill-out) endpoint so the prompt can never drift between them.""" text: str = (data.get("text") or "").strip() known: list = data.get("known_characters") or [] target_mode: bool = bool(data.get("target_mode")) existing: str = (data.get("existing") or "").strip() # partial sheets so far (progressive fill) language: str = (data.get("language") or "").strip() _settings = _load_settings() llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") model: str = (data.get("model") or _settings.get("llm_model") or "").strip() timeout_seconds = _request_timeout_seconds(data.get("timeout_seconds"), 600.0) if not text: raise HTTPException(400, "No text provided") lang_instruction = ( f"⚠️ SPRACHE / LANGUAGE — OBLIGATORISCH / MANDATORY:\n" f"The source text is in {language}.\n" f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n" f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, " f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, " f"conflict_style, win_condition, archetype, aliases, first_name, last_name, full_name, title.\n" f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n" f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n" f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n" ) if language else "" target_instruction = ( "CAST TARGET MODE:\n" "- The known-characters list is the already recognized cast roster from the audiobook pass. " "Treat it as TARGET PROFILES to fill, not as a list of aliases and not as proof that those names are the same person.\n" "- Prefer outputting sheets for those target names when this passage reveals usable profile details. " "Do not output a blank sheet just because a target is listed.\n" "- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name " "and put only the proven alternate form in aliases/title/full_name.\n" "- You may add a brand-new character only when the passage clearly introduces one that is not in the target roster.\n" "- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n" ) if target_mode and known else "" system = ( f"{lang_instruction}" f"{target_instruction}" "You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich " "character sheets passage by passage as a book is read. Extract playable, action-oriented sheets " "an actor can use to immediately know how to PLAY the character.\n" "PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any " "NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or " "blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty " "if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue " "and actions when reasonable, and mark any deduced value with a trailing ' *'.\n" "For each character output these fields:\n" "- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n" "- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n" "- first_name, last_name, full_name, title: split the character identity when known. Leave unknown parts empty. Put noble/office/role labels in title (e.g. 'Henker', 'Vampir', 'Graf').\n" "- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n" "- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.\n" "- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n" "- clothing: distinctive clothing, armour, accessories — as observed in the text\n" "- alignment: strict moral code + the one line they will never cross\n" "- moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous\n" "- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'\n" "- arc_note: one sentence explaining the arc or moral position visible so far\n" "- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility…)\n" "- skills: what they are demonstrably good at in the story\n" "- capabilities: combat, magic, social, technical, or other demonstrated abilities\n" "- backstory: origin, formative background and history revealed in the text\n" "- relationships: key allies, family, rivals and enemies — name them and how they relate\n" "- motivation: the inner drive — WHY they pursue what they pursue (distinct from the win condition)\n" "- fears: their deepest fears, phobias or dread\n" "- mannerisms: habitual gestures, tics, body language, habits and quirks\n" "- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n" "- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.\n" "- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n" "- inventory: 1-3 defining items/props/clothing (array of short strings)\n" "- secret: dark secret or fatal flaw\n" "- conflict_style: fight, flight, or manipulate — how they act when cornered\n" "- win_condition: the specific event that would make them feel they have won\n" "- tier: 'main' or 'supporting'\n" "- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, " "a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). " "Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.\n" "IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). " "Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. " "Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.\n" "Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. " "Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n" "Respond with STRICT JSON only:\n" '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","gender":"","physical":"","clothing":"",' '"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",' '"attribute_high":"","attribute_low":"","skills":"","capabilities":"",' '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",' '"inventory":[],"secret":"","conflict_style":"","win_condition":"",' '"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think' ) lang_reminder = ( f"⚠️ WICHTIG: Alle beschreibenden Feldwerte MÜSSEN auf {language} geschrieben werden. " f"Kein einziges beschreibendes Feld darf auf Englisch sein. Nur JSON-Schlüssel bleiben Englisch.\n\n" ) if language else "" user = ( ("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "") + ("Sheets so far (fill gaps / refine; keep solid facts):\n" + existing + "\n\n" if existing else "") + lang_reminder + "Passage:\n" + text ) payload: dict = { "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], "temperature": 0.4, "max_tokens": 4096, } if model: payload["model"] = model return { "payload": payload, "llm_url": llm_url, "model": model, "api_key": _settings.get("llm_api_key") or "sk-dummy-key", "timeout_seconds": timeout_seconds, } def _charsheets_parse(raw: str) -> dict: """Turn the LLM's raw answer into normalised {sheets, characters}.""" content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw sheets = [] for cand in (content, _extract_json_block(content)): if not cand: continue try: parsed = json.loads(cand) if isinstance(parsed, dict) and isinstance(parsed.get("sheets"), list): sheets = parsed["sheets"] break except Exception: continue clean, names = [], [] alias_splitter = re.compile(r"[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b", re.I) for s in sheets: if not isinstance(s, dict): continue name = str(s.get("name") or "").strip() if not name: continue alias_raw = str(s.get("aliases") or "").strip() alias_parts = [p.strip() for p in alias_splitter.split(alias_raw) if p.strip()] alias_parts = [p for p in alias_parts if len(p) <= 80 and not re.match(r"^(needs?:|complete$)", p, re.I)] if len(alias_raw) > 500 or len(alias_parts) > 12: aliases = "" else: aliases = ", ".join(alias_parts[:6]) inv = s.get("inventory") if isinstance(inv, str): inv = [x.strip() for x in inv.split(",") if x.strip()] elif not isinstance(inv, list): inv = [] src_raw = s.get("sources") if isinstance(s.get("sources"), list) else [] src = [] for item in src_raw: if not isinstance(item, dict): continue quote = str(item.get("quote") or "").strip() if not quote: continue try: page = int(item.get("page")) if item.get("page") is not None else None except (TypeError, ValueError): page = None src.append({ "page": page, "quote": quote[:240], "line_hint": str(item.get("line_hint") or "").strip()[:60], }) # Clamp moral alignment score try: mas = int(s.get("moral_alignment_score") or 50) mas = max(0, min(100, mas)) except (TypeError, ValueError): mas = 50 arc = str(s.get("arc_direction") or "neutral").strip() if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"): arc = "neutral" gender = str(s.get("gender") or "").strip().lower() if gender not in ("male", "female", "nonbinary"): gender = "" s.update({ "name": name, "aliases": aliases, "inventory": inv[:3], "tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting", "sources": src[:12], "moral_alignment_score": mas, "arc_direction": arc, "gender": gender, }) clean.append(s) names.append(name) return {"sheets": clean, "characters": names} @router.post("/api/character-sheets") async def character_sheets(request: Request): """Extract actor-facing RPG-style character sheets from a passage. Body: {text, known_characters:[...], language, llm_url, model} The text may contain "[p.N]" page markers so the model can cite sources. Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment, attribute_high, attribute_low, skills, inventory:[...], secret, conflict_style, win_condition, tier:"main"|"supporting", sources:[{page, quote}]}], characters:[names]} Deduced (not explicit) values are marked with a trailing " *". """ data = await request.json() prep = _charsheets_prepare(data) try: if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, prep["timeout_seconds"]): raise HTTPException(503, "Attribution engine busy") try: resp = await asyncio.to_thread( _post_llm_chat_completion, prep["llm_url"], prep["payload"], {"Authorization": f"Bearer {prep['api_key']}"}, prep["timeout_seconds"], ) finally: _attribution_llm_lock.release() resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip() except HTTPException: raise except Exception as e: raise HTTPException(502, f"LLM character-sheet generation failed: {e}") return _charsheets_parse(raw) @router.post("/api/character-sheets/stream") async def character_sheets_stream(request: Request): """Same job as /api/character-sheets, but streams the LLM's raw output (its JSON answer being written field by field) as SSE `{"t": "..."}` events, ending with `{"done": true, "result": {...}}` — lets the UI show the sheet actually filling out passage by passage instead of just a progress bar. On upstream failure it emits `{"error": "..."}` and the client falls back to the blocking endpoint.""" data = await request.json() prep = _charsheets_prepare(data) payload = dict(prep["payload"]) payload["stream"] = True headers = {"Authorization": f"Bearer {prep['api_key']}"} def gen(): if not _attribution_llm_lock.acquire(timeout=prep["timeout_seconds"]): yield 'data: {"error": "Attribution engine busy"}\n\n' return upstream = None try: urls = _llm_chat_completion_urls(prep["llm_url"]) last_err = None for u in urls: try: upstream = requests.post( u, json=payload, headers=headers, timeout=(15, prep["timeout_seconds"]), stream=True, ) if upstream.status_code in (404, 405) and u != urls[-1]: upstream.close() upstream = None continue break except Exception as exc: last_err = exc upstream = None if upstream is None: print(f"[character-sheets/stream] LLM connect failed: {last_err}") yield f'data: {json.dumps({"error": str(last_err or "LLM connect failed")})}\n\n' return if upstream.status_code >= 400: print( f"[character-sheets/stream] LLM failed ({upstream.status_code}) for model " f"{payload.get('model') or '(default)'}: {_response_error_text(upstream)[:300]}" ) yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n' return raw_parts = [] upstream.encoding = "utf-8" for line in upstream.iter_lines(decode_unicode=True): if not line or not line.startswith("data:"): continue chunk = line[5:].strip() if chunk == "[DONE]": break try: delta = json.loads(chunk)["choices"][0]["delta"] except Exception: continue t = delta.get("reasoning_content") or delta.get("content") or "" if delta.get("content"): raw_parts.append(delta["content"]) if t: yield f'data: {json.dumps({"t": t})}\n\n' result = _charsheets_parse("".join(raw_parts)) yield f'data: {json.dumps({"done": True, "result": result})}\n\n' except GeneratorExit: raise except Exception as e: yield f'data: {json.dumps({"error": str(e)})}\n\n' finally: try: if upstream is not None: upstream.close() except Exception: pass _attribution_llm_lock.release() return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) @router.post("/api/character-deep-analysis") async def character_deep_analysis(request: Request): """Run a deep 5-area psychological analysis of a single character. Body: {name, role, goal, summary, text_excerpt, language, llm_url, model} Returns: {analysis: {core_flaw, agency, dialogue_voice, narrative_arc, paradox}} """ data = await request.json() name: str = (data.get("name") or "").strip() role: str = (data.get("role") or "unknown").strip() goal: str = (data.get("goal") or "").strip() summary: str = (data.get("summary") or "").strip() excerpt: str = (data.get("text_excerpt") or "").strip() language: str = (data.get("language") or "").strip() _settings = _load_settings() llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") model: str = (data.get("model") or _settings.get("llm_model") or "").strip() if not name: raise HTTPException(400, "Character name required") lang_note = f" Write the analysis in {language}." if language else "" system = ( f"You are an expert developmental editor, literary coach, and psychologist specializing in " f"profound character studies.{lang_note} Provide a deep, multi-layered psychological and " f"narrative analysis of the character in exactly this JSON structure:\n" '{"core_flaw":"",' '"agency":"",' '"dialogue_voice":"