diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8da7e79..c9eaa5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.12.95] — 2026-07-05
+
+### Added
+- **Watch the LLM think, live** — the "LLM Reading…" card now splits into two panes the moment the model starts responding: its raw output stream (reasoning + the answer JSON as it's written) on the left, the passage it's reading on the right. Backed by a new streaming endpoint (`/api/attribute-dialogue/stream`, SSE) that forwards both `reasoning_content` (thinking models) and `content` deltas; the prompt-building and answer-parsing are shared with the blocking endpoint so the two can never drift. Any stream failure falls back to the blocking endpoint automatically — with an inactivity timeout (reset on every received chunk) instead of an overall one, since a slow model legitimately takes minutes per passage but long silence means the stream died. Works in the main cast, Recast unknown, and 2nd Quality Run.
+
+### Fixed
+- **Resolver invented characters from scenery words** — the deterministic colon-rule's fallback accepted any "der/die + capitalized noun", turning "auf dem Platz", "die Gesichter", "eine Kleinigkeit" into speakers PLATZ/GESICHTER/KLEINIGKEIT. The fallback is now a closed whitelist of person/role nouns, plus a clause-subject pattern ("Marcian unterdrückte seinen Ärger und sagte:" → Marcian).
+- **More Unknowns resolved, safely** — two new deterministic rules: the impersonal post-quote formula ("ertönte es plötzlich über ihm. Karyla hatte…" → Karyla), and strict two-person alternation (both nearest preceding dialogue lines named and different → the one who didn't just speak), guarded to never fire across page boundaries, beyond a short window, or when the preceding narration ends with ":" (that colon introduces someone the other rules couldn't name — alternation would be a guess, not a deduction). All screenshot failure cases verified against the exact book sentences.
+
+---
+
## [1.12.94] — 2026-07-04
### Fixed
diff --git a/VERSION b/VERSION
index 7c2b16b..a65ccb9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.12.94
+1.12.95
diff --git a/routes/conversation.py b/routes/conversation.py
index a130425..4faf0ef 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -934,16 +934,10 @@ async def character_generate_prompts(request: Request):
return out
-@router.post("/api/attribute-dialogue")
-async def attribute_dialogue(request: Request):
- """Split a prose passage into attributed segments for a multi-voice audiobook.
-
- Body: {text, known_characters:[...], language, llm_url, model}
- Returns: {segments:[{speaker, type:"narration"|"dialogue", text, emotion}], characters:[names]}
- The frontend calls this per chunk, passing the running character roster so the
- same speaker keeps the same name across the whole book.
- """
- data = await request.json()
+def _attribution_prepare(data: dict) -> dict:
+ """Resolve settings and build the chat payload for one attribution request.
+ Shared by the blocking endpoint and the streaming (watch-the-LLM-think)
+ endpoint so the prompt logic can never drift between the two."""
text: str = (data.get("text") or "").strip()
known: list = data.get("known_characters") or []
recent: str = (data.get("recent") or "").strip() # last few attributed lines, for continuity
@@ -1054,14 +1048,36 @@ async def attribute_dialogue(request: Request):
}
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, "text": text,
+ "fallback_model": (_settings.get("llm_model") or "").strip(),
+ }
+
+
+@router.post("/api/attribute-dialogue")
+async def attribute_dialogue(request: Request):
+ """Split a prose passage into attributed segments for a multi-voice audiobook.
+
+ Body: {text, known_characters:[...], language, llm_url, model}
+ Returns: {segments:[{speaker, type:"narration"|"dialogue", text, emotion}], characters:[names]}
+ The frontend calls this per chunk, passing the running character roster so the
+ same speaker keeps the same name across the whole book.
+ """
+ data = await request.json()
+ prep = _attribution_prepare(data)
+ payload, llm_url, model = prep["payload"], prep["llm_url"], prep["model"]
+ timeout_seconds, text = prep["timeout_seconds"], prep["text"]
+ api_key = prep["api_key"]
try:
async with _attribution_llm_lock:
resp = await asyncio.to_thread(
_post_llm_chat_completion,
llm_url, payload,
- {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds,
+ {"Authorization": f"Bearer {api_key}"}, timeout_seconds,
)
- fallback_model = (_settings.get("llm_model") or "").strip()
+ fallback_model = prep["fallback_model"]
if (
resp.status_code >= 400
and _is_router_model_alias(model)
@@ -1074,7 +1090,7 @@ async def attribute_dialogue(request: Request):
resp = await asyncio.to_thread(
_post_llm_chat_completion,
llm_url, retry_payload,
- {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds,
+ {"Authorization": f"Bearer {api_key}"}, timeout_seconds,
)
payload = retry_payload
if resp.status_code >= 400:
@@ -1091,15 +1107,20 @@ async def attribute_dialogue(request: Request):
print(f"[attribute-dialogue] LLM failed, using deterministic fallback: {e}")
return _fallback_attribute_response(text)
+ return _attribution_parse(raw, text)
+
+
+def _attribution_parse(raw: str, text: str) -> dict:
+ """Turn the LLM's raw answer into normalised {segments, characters}."""
content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw
segments = []
-
+
block = _extract_json_block(content)
candidates = [content, block]
if block:
# If the JSON was truncated by token limits, appending ]} to the last complete object often salvages it.
candidates.append(block + "]}")
-
+
for cand in candidates:
if not cand:
continue
@@ -1110,7 +1131,7 @@ async def attribute_dialogue(request: Request):
break
except Exception:
continue
-
+
if not segments:
# If the LLM completely failed to output JSON despite the strict system prompt,
# it means it dropped into a conversational/reasoning hallucination.
@@ -1136,6 +1157,89 @@ async def attribute_dialogue(request: Request):
return {"segments": clean, "characters": chars}
+# Streaming attribution runs in a plain thread (StreamingResponse with a sync
+# generator), so it serializes on a threading.Lock rather than the asyncio one.
+_attribution_stream_tlock = threading.Lock()
+
+
+@router.post("/api/attribute-dialogue/stream")
+async def attribute_dialogue_stream(request: Request):
+ """Same job as /api/attribute-dialogue, but streams the LLM's live output
+ (reasoning + the JSON being written) as SSE `{"t": "..."}` events so the UI
+ can show what the model is thinking, ending with `{"done": true, "result"}`.
+ On upstream failure it emits `{"error": "..."}` — the client then falls
+ back to the blocking endpoint."""
+ data = await request.json()
+ prep = _attribution_prepare(data)
+ payload = dict(prep["payload"])
+ payload["stream"] = True
+ headers = {"Authorization": f"Bearer {prep['api_key']}"}
+
+ def gen():
+ if not _attribution_stream_tlock.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:
+ yield f'data: {json.dumps({"error": str(last_err or "LLM connect failed")})}\n\n'
+ return
+ if upstream.status_code >= 400:
+ yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n'
+ return
+ raw_parts = []
+ 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
+ # Reasoning models emit thoughts via reasoning_content; the
+ # answer JSON arrives via content. Forward both to the viewer,
+ # but only content counts toward the parseable answer.
+ 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 = _attribution_parse("".join(raw_parts), prep["text"])
+ 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_stream_tlock.release()
+
+ return StreamingResponse(gen(), media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+
+
# ── Audio effects ─────────────────────────────────────────────────────────────
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
diff --git a/static/index.html b/static/index.html
index b8b4cab..2a20f81 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,7 @@
-
+
@@ -27,7 +27,7 @@
-
+
@@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
-
+