Files
UmbraAudio/umbraaudio/analysis/llm_segmenter.py
B.D. Murphy 0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
Two groups of changes, committed together as the follow-up to the initial push:

1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork
   (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub,
   so a clone actually gets the UmbraAudio branding/integration commit
   012e452 rather than vanilla upstream.

2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge,
   remote_credential_broker (Keychain-backed, secrets read at runtime and
   generated via read_secret/random_secret — no hardcoded credentials), the
   enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus
   backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control
   plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the
   discovery/attribution/profiling modules, config, chapter_builder, emotion,
   pronunciation, qwen_tts and routes.

Also adds *.db to .gitignore: SQLite project databases are runtime state and no
.db file was tracked before.

Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and
the committed copy has zero secret-looking values; the broker ships only
config.example.json.
2026-10-06 06:57:41 -07:00

256 lines
8.2 KiB
Python

from __future__ import annotations
import json
import logging
import re
import time
from pathlib import Path
from typing import Any
from openai import OpenAI
from umbraaudio.config import LLMConfig
from umbraaudio.voices.lmstudio_gate import queued_chat_completion
logger = logging.getLogger(__name__)
PROMPT_PATH = Path(__file__).parent / "CHARACTER_SEGMENTATION_PROMPT.md"
CHUNK_SIZE = 15000
class LLMSegmenter:
def __init__(self, config: LLMConfig, model: str | None = None, data_dir: Path | None = None):
self.config = config
self.model = model or config.lmstudio_model
self.data_dir = data_dir or Path(".umbra")
def _get_client(self) -> OpenAI:
return OpenAI(
base_url=self.config.lmstudio_base_url,
api_key="lm-studio",
)
def _load_prompt(self) -> str:
return PROMPT_PATH.read_text(encoding="utf-8")
def _load_character_profiles(self) -> str:
speakers_path = self.data_dir / "speakers.json"
if not speakers_path.exists():
return "No character profiles available."
with open(speakers_path) as f:
data = json.load(f)
speakers = data.get("speakers", [])
if not speakers:
return "No character profiles available."
lines = []
for s in speakers:
name = s.get("primary_name", "Unknown")
demos = s.get("demographics", {})
dtype = demos.get("type", "Human")
gender = demos.get("gender", "Unknown")
traits = ", ".join(s.get("vocal_traits", [])) or "none"
aliases = ", ".join(s.get("aliases", [])) or "none"
lines.append(f"- {name} ({dtype}, {gender}): traits=[{traits}], aliases=[{aliases}]")
return "\n".join(lines)
def segment_chapter(
self,
chapter_text: str,
chapter_id: int,
) -> list[dict[str, Any]]:
scenes = self._split_into_scenes(chapter_text)
all_utterances: list[dict[str, Any]] = []
for scene_idx, scene_text in enumerate(scenes):
logger.info(
"Chapter %d, scene %d/%d: %d chars",
chapter_id,
scene_idx + 1,
len(scenes),
len(scene_text),
)
if len(scene_text) <= CHUNK_SIZE:
utterances = self._call_llm(scene_text, chapter_id)
all_utterances.extend(utterances)
else:
paragraphs = self._split_into_paragraphs(scene_text)
chunk_utterances: list[dict[str, Any]] = []
current_chunk = ""
for para in paragraphs:
if len(current_chunk) + len(para) > CHUNK_SIZE and current_chunk:
utterances = self._call_llm(current_chunk, chapter_id)
chunk_utterances.extend(utterances)
current_chunk = ""
time.sleep(1)
current_chunk += para + "\n\n"
if current_chunk.strip():
utterances = self._call_llm(current_chunk, chapter_id)
chunk_utterances.extend(utterances)
all_utterances.extend(chunk_utterances)
if scene_idx < len(scenes) - 1:
all_utterances.append(
{
"speaker": "scene_break",
"text": "<<<>>>",
"type": "narration",
}
)
time.sleep(1)
all_utterances = self._renumber(all_utterances)
logger.info(
"Chapter %d complete: %d utterances",
chapter_id,
len(all_utterances),
)
return all_utterances
def _split_into_scenes(self, text: str) -> list[str]:
parts = re.split(r"\n\s*<<<>>>\s*\n", text)
return [p.strip() for p in parts if p.strip()]
def _split_into_paragraphs(self, text: str) -> list[str]:
return [p.strip() for p in text.split("\n\n") if p.strip()]
def _call_llm(self, text: str, chapter_id: int) -> list[dict[str, Any]]:
prompt_template = self._load_prompt()
profiles = self._load_character_profiles()
prompt = prompt_template.replace("{chapter_text}", text)
prompt = prompt.replace("{character_profiles}", profiles)
client = self._get_client()
try:
response = queued_chat_completion(
client,
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=16384,
)
except Exception as e:
logger.error("LLM call failed: %s", e)
return []
content = response.choices[0].message.content or ""
content = content.strip()
if not content:
logger.warning("Empty LLM response")
return []
utterances = self._parse_response(content)
utterances = self._validate(utterances)
return utterances
def _parse_response(self, content: str) -> list[dict[str, Any]]:
stripped = content.strip()
if stripped.startswith("```"):
first_newline = stripped.find("\n")
if first_newline > 0:
stripped = stripped[first_newline + 1 :]
if stripped.endswith("```"):
stripped = stripped[:-3].strip()
utterances = self._try_json_parse(stripped)
if utterances is not None:
return utterances
json_start = stripped.find("[")
json_end = stripped.rfind("]") + 1
if json_start >= 0 and json_end > json_start:
candidate = stripped[json_start:json_end]
utterances = self._try_json_parse(candidate)
if utterances is not None:
return utterances
last_brace = candidate.rfind("}")
if last_brace > 0:
candidate = candidate[: last_brace + 1]
open_brackets = candidate.count("[") - candidate.count("]")
candidate += "]" * open_brackets
utterances = self._try_json_parse(candidate)
if utterances is not None:
return utterances
logger.error("Failed to parse LLM segmentation response")
return []
def _try_json_parse(self, text: str) -> list[dict[str, Any]] | None:
try:
result = json.loads(text)
if isinstance(result, list):
return result
if isinstance(result, dict) and "utterances" in result:
return result["utterances"]
return None
except json.JSONDecodeError:
pass
fixed = text
for _ in range(10):
try:
result = json.loads(fixed)
if isinstance(result, list):
return result
return None
except json.JSONDecodeError:
last_brace = fixed.rfind("}")
last_bracket = fixed.rfind("]")
last_comma = fixed.rfind(",")
cut = max(last_brace, last_bracket, last_comma)
if cut > 0:
fixed = fixed[:cut]
if fixed.endswith(","):
fixed = fixed[:-1]
else:
break
return None
def _validate(self, utterances: list[dict[str, Any]]) -> list[dict[str, Any]]:
valid = []
for u in utterances:
if not isinstance(u, dict):
continue
speaker = u.get("speaker", "narrator")
text = u.get("text", "").strip()
if not text:
continue
valid.append(
{
"speaker": speaker,
"text": text,
"type": self._utterance_type(speaker),
}
)
return valid
def _utterance_type(self, speaker: str) -> str:
if speaker in ("narrator", "scene_break", "title"):
return "narration"
return "dialogue"
def _renumber(self, utterances: list[dict[str, Any]]) -> list[dict[str, Any]]:
for i, u in enumerate(utterances):
u["index"] = i
return utterances