Live LLM-thinking stream, fix resolver garbage names, safer rules (v1.12.95)
The LLM Reading card splits into thinking-stream (left) and passage (right), fed by a new SSE endpoint that shares prompt-building and parsing with the blocking one and falls back to it on any stream failure (inactivity timeout, not overall). The deterministic resolver no longer invents speakers from scenery nouns (PLATZ/GESICHTER/ KLEINIGKEIT) — person-noun whitelist plus a clause-subject pattern — and gains the impersonal post-quote formula and strict two-person alternation with colon/page/window guards. All reported failure cases verified against the exact book sentences. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d1969b7609
commit
3503f5bda6
11
CHANGELOG.md
11
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
|
## [1.12.94] — 2026-07-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -934,16 +934,10 @@ async def character_generate_prompts(request: Request):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/attribute-dialogue")
|
def _attribution_prepare(data: dict) -> dict:
|
||||||
async def attribute_dialogue(request: Request):
|
"""Resolve settings and build the chat payload for one attribution request.
|
||||||
"""Split a prose passage into attributed segments for a multi-voice audiobook.
|
Shared by the blocking endpoint and the streaming (watch-the-LLM-think)
|
||||||
|
endpoint so the prompt logic can never drift between the two."""
|
||||||
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()
|
|
||||||
text: str = (data.get("text") or "").strip()
|
text: str = (data.get("text") or "").strip()
|
||||||
known: list = data.get("known_characters") or []
|
known: list = data.get("known_characters") or []
|
||||||
recent: str = (data.get("recent") or "").strip() # last few attributed lines, for continuity
|
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:
|
if model:
|
||||||
payload["model"] = 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:
|
try:
|
||||||
async with _attribution_llm_lock:
|
async with _attribution_llm_lock:
|
||||||
resp = await asyncio.to_thread(
|
resp = await asyncio.to_thread(
|
||||||
_post_llm_chat_completion,
|
_post_llm_chat_completion,
|
||||||
llm_url, payload,
|
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 (
|
if (
|
||||||
resp.status_code >= 400
|
resp.status_code >= 400
|
||||||
and _is_router_model_alias(model)
|
and _is_router_model_alias(model)
|
||||||
@ -1074,7 +1090,7 @@ async def attribute_dialogue(request: Request):
|
|||||||
resp = await asyncio.to_thread(
|
resp = await asyncio.to_thread(
|
||||||
_post_llm_chat_completion,
|
_post_llm_chat_completion,
|
||||||
llm_url, retry_payload,
|
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
|
payload = retry_payload
|
||||||
if resp.status_code >= 400:
|
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}")
|
print(f"[attribute-dialogue] LLM failed, using deterministic fallback: {e}")
|
||||||
return _fallback_attribute_response(text)
|
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"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||||
segments = []
|
segments = []
|
||||||
|
|
||||||
block = _extract_json_block(content)
|
block = _extract_json_block(content)
|
||||||
candidates = [content, block]
|
candidates = [content, block]
|
||||||
if block:
|
if block:
|
||||||
# If the JSON was truncated by token limits, appending ]} to the last complete object often salvages it.
|
# If the JSON was truncated by token limits, appending ]} to the last complete object often salvages it.
|
||||||
candidates.append(block + "]}")
|
candidates.append(block + "]}")
|
||||||
|
|
||||||
for cand in candidates:
|
for cand in candidates:
|
||||||
if not cand:
|
if not cand:
|
||||||
continue
|
continue
|
||||||
@ -1110,7 +1131,7 @@ async def attribute_dialogue(request: Request):
|
|||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
# If the LLM completely failed to output JSON despite the strict system prompt,
|
# If the LLM completely failed to output JSON despite the strict system prompt,
|
||||||
# it means it dropped into a conversational/reasoning hallucination.
|
# it means it dropped into a conversational/reasoning hallucination.
|
||||||
@ -1136,6 +1157,89 @@ async def attribute_dialogue(request: Request):
|
|||||||
return {"segments": clean, "characters": chars}
|
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 ─────────────────────────────────────────────────────────────
|
# ── Audio effects ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
|
def _apply_audio_effects(audio_bytes: bytes, effects: list) -> bytes:
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.12.94">
|
<meta name="app-version" content="1.12.95">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.12.94">
|
<link rel="stylesheet" href="/static/style.css?v=1.12.95">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.12.94"></script>
|
<script src="/static/loader.js?v=1.12.95"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -59,6 +59,66 @@ function audiobookTimeoutSeconds(timeoutMs) {
|
|||||||
return Math.max(5, Math.round(timeoutMs / 1000));
|
return Math.max(5, Math.round(timeoutMs / 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streamed attribution: reads the SSE feed from /api/attribute-dialogue/stream,
|
||||||
|
// pushing each delta into view.thinking() so the user can watch the LLM work,
|
||||||
|
// and resolves with the final parsed result. Uses an INACTIVITY timeout (reset
|
||||||
|
// on every received chunk) rather than an overall one — a passage legitimately
|
||||||
|
// takes minutes on a slow model, but silence that long means the stream died.
|
||||||
|
// Throws on any failure; callers fall back to the blocking endpoint. A user
|
||||||
|
// Stop (outer signal) propagates as AbortError; a stalled stream does not.
|
||||||
|
async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const onAbort = () => ctl.abort();
|
||||||
|
if (outerSignal) {
|
||||||
|
if (outerSignal.aborted) ctl.abort();
|
||||||
|
else outerSignal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
|
let idleTimer = null;
|
||||||
|
const armIdle = () => {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
armIdle();
|
||||||
|
const r = await fetch('/api/attribute-dialogue/stream', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: ctl.signal, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '', result = null;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
armIdle();
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
let at;
|
||||||
|
while ((at = buf.indexOf('\n\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, at).trim();
|
||||||
|
buf = buf.slice(at + 2);
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
let d;
|
||||||
|
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
|
||||||
|
if (d.t && view?.thinking) view.thinking(d.t);
|
||||||
|
if (d.error) throw new Error(d.error);
|
||||||
|
if (d.done) result = d.result || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result) throw new Error('stream ended without result');
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// An idle-timeout abort is a stream failure (fall back), not a user Stop.
|
||||||
|
if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) {
|
||||||
|
throw new Error('stream idle timeout');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function audiobookDialogueKey(text) {
|
function audiobookDialogueKey(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
@ -565,17 +625,33 @@ const AB_NOTNAME = new Set([
|
|||||||
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
|
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
|
||||||
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
|
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
|
||||||
|
|
||||||
// Deterministic Unknown-resolution — the LLM keeps missing two mechanical
|
// Person/role nouns that are plausible speakers when no proper name is found.
|
||||||
|
// A generic "der/die + any capitalized noun" fallback assigned scenery words
|
||||||
|
// as characters (PLATZ from "auf dem Platz", GESICHTER, KLEINIGKEIT) — a
|
||||||
|
// closed whitelist can't make that class of mistake.
|
||||||
|
const AB_PERSON_NOUNS = new Set(['Mann','Frau','Junge','Mädchen','Alte','Alter','Fremde','Fremder','Krieger','Kriegerin',
|
||||||
|
'Wächter','Wache','Soldat','Hauptmann','Ork','Ritter','Magier','Magierin','Zwerg','Elf','Elfe','Händler','Wirt','Wirtin',
|
||||||
|
'Bauer','Priester','Priesterin','Nachbar','Nachbarin','Sklave','Sklavin','Verweser','Inquisitor','General','König','Königin',
|
||||||
|
'Prinz','Prinzessin','Fürst','Fürstin','Baron','Baronin','Bote','Diener','Dienerin','Knabe','Kind','Reiter','Reiterin',
|
||||||
|
'Bogenschütze','Schmied','Schmiedin','Heiler','Heilerin','Gelehrte','Gelehrter','Kapitän','Anführer','Anführerin']);
|
||||||
|
|
||||||
|
// Deterministic Unknown-resolution — the LLM keeps missing mechanical
|
||||||
// patterns no matter how explicitly the prompt spells them out (measured:
|
// patterns no matter how explicitly the prompt spells them out (measured:
|
||||||
// ~44% Unknown on a full book WITH the rules in the prompt), so apply them
|
// ~44% Unknown on a full book WITH the rules in the prompt), so apply them
|
||||||
// in code where they cannot be ignored:
|
// in code where they cannot be ignored:
|
||||||
// A. Colon rule: the narration right before a quote ends with ":" → its
|
// A. Colon rule: the narration right before a quote ends with ":" → its
|
||||||
// last-mentioned character/role speaks ("… und rief in die Runde:").
|
// last-mentioned character (roster name, whitelisted person noun, or the
|
||||||
|
// "Name … und sagte:" subject) speaks.
|
||||||
// B. Post-quote inquit: the narration right after a quote starts with a
|
// B. Post-quote inquit: the narration right after a quote starts with a
|
||||||
// speech verb + Name (or "Der X, der gesprochen hatte") → X spoke.
|
// speech verb + Name, "Der X, der gesprochen hatte", or the impersonal
|
||||||
|
// "ertönte es … . Name …" formula → that character spoke.
|
||||||
|
// C. Two-person alternation: an Unknown whose two nearest preceding dialogue
|
||||||
|
// lines have two different known speakers gets the earlier of the two
|
||||||
|
// (strict turn-taking) — only when both neighbours are unambiguous.
|
||||||
// Only fills segments still Unknown; never overrides an LLM attribution.
|
// Only fills segments still Unknown; never overrides an LLM attribution.
|
||||||
function audiobookResolveUnknowns(segs, prevTail, roster) {
|
function audiobookResolveUnknowns(segs, prevTail, roster) {
|
||||||
const isUnknown = s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker));
|
const isUnknown = s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker));
|
||||||
|
const isNamed = s => s?.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt|Narrator$/i.test(s.speaker);
|
||||||
const names = (roster || []).filter(n => n && !/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a, b) => b.length - a.length);
|
const names = (roster || []).filter(n => n && !/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a, b) => b.length - a.length);
|
||||||
const lastNameIn = (text) => {
|
const lastNameIn = (text) => {
|
||||||
let best = null, bestAt = -1;
|
let best = null, bestAt = -1;
|
||||||
@ -584,11 +660,13 @@ function audiobookResolveUnknowns(segs, prevTail, roster) {
|
|||||||
if (at > bestAt) { bestAt = at; best = n; }
|
if (at > bestAt) { bestAt = at; best = n; }
|
||||||
}
|
}
|
||||||
if (best) return best;
|
if (best) return best;
|
||||||
// fallback: last "der/die <Capitalized>" role noun — skipping non-agent
|
// "Marcian unterdrückte seinen Ärger und sagte:" — subject of the final
|
||||||
// nouns that commonly sit right before the colon ("rief in die Runde:").
|
// speech clause, provided it isn't a sentence-opener adverb/article.
|
||||||
const NON_AGENT = /^(Runde|Stimme|Stimmen|Menge|Ferne|Höhe|Größe|Richtung|Seite|Stadt|Tür|Luft|Hand|Hände|Augen|Worte|Wort|Frage|Antwort|Dunkelheit|Stille|Nacht|Morgen|Abend|Erde|Himmel|Boden|Wand|Mauer)$/;
|
const clause = text.match(new RegExp(_AB_NAME + "[^.!?:]{0,80}\\b(?:und\\s+)?(?:" + AB_SPEECH_VERBS + ")[^:]{0,60}:\\s*$"));
|
||||||
|
if (clause && !AB_NOTNAME.has(clause[1]) && !AB_PERSON_NOUNS.has(clause[1])) return clause[1];
|
||||||
|
// whitelisted person nouns only — never arbitrary capitalized nouns
|
||||||
const m = [...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)]
|
const m = [...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)]
|
||||||
.map(x => x[1]).filter(n => !NON_AGENT.test(n));
|
.map(x => x[1]).filter(n => AB_PERSON_NOUNS.has(n));
|
||||||
return m.length ? m[m.length - 1] : null;
|
return m.length ? m[m.length - 1] : null;
|
||||||
};
|
};
|
||||||
const all = [...(prevTail || []), ...segs];
|
const all = [...(prevTail || []), ...segs];
|
||||||
@ -606,7 +684,27 @@ function audiobookResolveUnknowns(segs, prevTail, roster) {
|
|||||||
const nt = String(next.text || '').trim();
|
const nt = String(next.text || '').trim();
|
||||||
let m = nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:der|die)?\\s*' + _AB_NAME));
|
let m = nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:der|die)?\\s*' + _AB_NAME));
|
||||||
if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte'));
|
if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte'));
|
||||||
if (m) who = m[1];
|
// "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula,
|
||||||
|
// the next sentence's subject is the speaker.
|
||||||
|
if (!m) m = nt.match(new RegExp('^\\W{0,3}(?:ertönte|erklang|tönte|drang|kam)\\b[^.!?]{0,60}\\bes\\b[^.!?]{0,60}[.!?]\\s*' + _AB_NAME));
|
||||||
|
if (m && !AB_NOTNAME.has(m[1])) who = m[1];
|
||||||
|
}
|
||||||
|
const prevEndsColon = prev?.type === 'narration' && /:\s*$/.test(String(prev.text || '').trim());
|
||||||
|
if (!who && !prevEndsColon) {
|
||||||
|
// Two-person alternation, strictly: the two nearest preceding dialogue
|
||||||
|
// lines carry two DIFFERENT known names → this line belongs to the one
|
||||||
|
// who didn't just speak. Guards: never when the preceding narration
|
||||||
|
// ends with ":" (that colon introduces someone the rules above already
|
||||||
|
// failed to identify — alternation would be a guess, not a deduction),
|
||||||
|
// never across page boundaries, and only within a short window.
|
||||||
|
const prevDialogues = [];
|
||||||
|
for (let j = i - 1; j >= 0 && j >= i - 6 && prevDialogues.length < 2; j--) {
|
||||||
|
if (all[j]?.page != null && s.page != null && all[j].page !== s.page) break;
|
||||||
|
if (all[j]?.type !== 'dialogue') continue;
|
||||||
|
if (!isNamed(all[j])) { prevDialogues.length = 0; break; } // an unresolved line breaks the chain
|
||||||
|
prevDialogues.push(all[j].speaker);
|
||||||
|
}
|
||||||
|
if (prevDialogues.length === 2 && prevDialogues[0] !== prevDialogues[1]) who = prevDialogues[1];
|
||||||
}
|
}
|
||||||
if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) {
|
if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) {
|
||||||
s.speaker = who;
|
s.speaker = who;
|
||||||
@ -2688,12 +2786,24 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
this._procRow.className = 'ab-cv-row is-processing ab-cv-llm-row';
|
this._procRow.className = 'ab-cv-row is-processing ab-cv-llm-row';
|
||||||
const isLong = text.length > 160;
|
const isLong = text.length > 160;
|
||||||
const preview = escHtml(text.slice(0, 160)) + (isLong ? '…' : '');
|
const preview = escHtml(text.slice(0, 160)) + (isLong ? '…' : '');
|
||||||
|
// Split view while the LLM works: left = its live thinking stream (fed
|
||||||
|
// by view.thinking via the SSE endpoint), right = the passage it reads.
|
||||||
this._procRow.innerHTML = `
|
this._procRow.innerHTML = `
|
||||||
<div class="ab-cv-llm-head">
|
<div class="ab-cv-llm-head">
|
||||||
<span class="ab-cv-spk" style="color:var(--blue)"><span class="mdi mdi-loading mdi-spin"></span> LLM Reading…</span>
|
<span class="ab-cv-spk" style="color:var(--blue)"><span class="mdi mdi-loading mdi-spin"></span> LLM Reading…</span>
|
||||||
${isLong ? '<button class="ab-cv-expand-btn" title="Show full passage"><span class="mdi mdi-chevron-down"></span></button>' : ''}
|
${isLong ? '<button class="ab-cv-expand-btn" title="Show full passage"><span class="mdi mdi-chevron-down"></span></button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="ab-cv-llm-preview">${preview}</div>
|
<div class="ab-cv-llm-preview">${preview}</div>
|
||||||
|
<div class="ab-cv-llm-split" hidden>
|
||||||
|
<div class="ab-cv-llm-col">
|
||||||
|
<div class="ab-cv-llm-col-label"><span class="mdi mdi-head-dots-horizontal-outline"></span> LLM Thinking…</div>
|
||||||
|
<pre class="ab-cv-think"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="ab-cv-llm-col">
|
||||||
|
<div class="ab-cv-llm-col-label"><span class="mdi mdi-text-long"></span> Passage</div>
|
||||||
|
<pre class="ab-cv-llm-pre">${escHtml(text)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
${isLong ? `<div class="ab-cv-llm-full" hidden><pre class="ab-cv-llm-pre">${escHtml(text)}</pre></div>` : ''}
|
${isLong ? `<div class="ab-cv-llm-full" hidden><pre class="ab-cv-llm-pre">${escHtml(text)}</pre></div>` : ''}
|
||||||
`;
|
`;
|
||||||
if (isLong) {
|
if (isLong) {
|
||||||
@ -2707,6 +2817,22 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
_abPage().appendChild(this._procRow);
|
_abPage().appendChild(this._procRow);
|
||||||
trim();
|
trim();
|
||||||
},
|
},
|
||||||
|
// Live LLM output for the passage currently processing. First delta swaps
|
||||||
|
// the plain preview for the split thinking/passage view; subsequent deltas
|
||||||
|
// append and keep the thinking pane scrolled to the newest text.
|
||||||
|
thinking(delta) {
|
||||||
|
if (!this._procRow || !delta) return;
|
||||||
|
const split = this._procRow.querySelector('.ab-cv-llm-split');
|
||||||
|
const think = this._procRow.querySelector('.ab-cv-think');
|
||||||
|
if (!split || !think) return;
|
||||||
|
if (split.hidden) {
|
||||||
|
split.hidden = false;
|
||||||
|
const prev = this._procRow.querySelector('.ab-cv-llm-preview');
|
||||||
|
if (prev) prev.hidden = true;
|
||||||
|
}
|
||||||
|
think.textContent = (think.textContent + delta).slice(-20000); // cap runaway output
|
||||||
|
think.scrollTop = think.scrollHeight;
|
||||||
|
},
|
||||||
clearProcessing() {
|
clearProcessing() {
|
||||||
if (this._procRow) { this._procRow.remove(); this._procRow = null; }
|
if (this._procRow) { this._procRow.remove(); this._procRow = null; }
|
||||||
},
|
},
|
||||||
@ -3153,22 +3279,29 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
|
|||||||
view.processing(passageText.trim());
|
view.processing(passageText.trim());
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
|
const recastBody = audiobookAttributeBody({
|
||||||
|
text: passageText.trim(),
|
||||||
|
known_characters: _audiobook.roster.slice(-40),
|
||||||
|
recent: recastCtx.recent || '',
|
||||||
|
language,
|
||||||
|
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
||||||
|
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||||||
|
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)
|
||||||
|
}, promptOverride);
|
||||||
try {
|
try {
|
||||||
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
// Streaming first (live thinking view); blocking endpoint as fallback.
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
try {
|
||||||
signal: ac.signal,
|
data = await audiobookAttributeStream(recastBody, view, ac.signal, AUDIOBOOK_RECAST_TIMEOUT_MS);
|
||||||
body: JSON.stringify(audiobookAttributeBody({
|
} catch (streamErr) {
|
||||||
text: passageText.trim(),
|
if (streamErr.name === 'AbortError') throw streamErr;
|
||||||
known_characters: _audiobook.roster.slice(-40),
|
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||||||
recent: recastCtx.recent || '',
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
language,
|
signal: ac.signal,
|
||||||
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
body: JSON.stringify(recastBody)
|
||||||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
}, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000);
|
||||||
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
|
||||||
}, promptOverride))
|
data = await r.json();
|
||||||
}, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000);
|
}
|
||||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
|
|
||||||
data = await r.json();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') { _audiobook.cancel = true; break; }
|
if (err.name === 'AbortError') { _audiobook.cancel = true; break; }
|
||||||
// statusText is empty on HTTP/2, which used to leave this note blank after the colon
|
// statusText is empty on HTTP/2, which used to leave this note blank after the colon
|
||||||
@ -3479,13 +3612,22 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
|
|||||||
view.processing(chunks[i]);
|
view.processing(chunks[i]);
|
||||||
// ── Helper: run one attribution call and return parsed segments (or null on error) ──
|
// ── Helper: run one attribution call and return parsed segments (or null on error) ──
|
||||||
const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => {
|
const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => {
|
||||||
|
const body = { text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) };
|
||||||
|
// Streaming first — shows the LLM's live thinking in the processing
|
||||||
|
// card. Any stream failure falls through to the blocking endpoint.
|
||||||
|
try {
|
||||||
|
const d = await audiobookAttributeStream(body, view, ac.signal, timeoutMs);
|
||||||
|
return Array.isArray(d.segments) ? d.segments : null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') throw err; // propagate cancel
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
body: JSON.stringify({ text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) }),
|
body: JSON.stringify(body),
|
||||||
}, timeoutMs + 5000);
|
}, timeoutMs + 5000);
|
||||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
return Array.isArray(d.segments) ? d.segments : null;
|
return Array.isArray(d.segments) ? d.segments : null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -5331,6 +5331,19 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
/* Every word in the narration/dialogue text is individually clickable — the
|
/* Every word in the narration/dialogue text is individually clickable — the
|
||||||
basis for "click a name in the text to assign", including names the app
|
basis for "click a name in the text to assign", including names the app
|
||||||
doesn't already know (drag across a multi-word one to select it all). */
|
doesn't already know (drag across a multi-word one to select it all). */
|
||||||
|
/* Live LLM view while a passage is processing: thinking stream left, the
|
||||||
|
passage it reads right. Swapped in by view.thinking() on the first delta. */
|
||||||
|
.ab-cv-llm-split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 6px; }
|
||||||
|
.ab-cv-llm-col { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.ab-cv-llm-col-label { font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); display: flex; align-items: center; gap: 5px; }
|
||||||
|
.ab-cv-think {
|
||||||
|
margin: 0; max-height: 240px; overflow-y: auto; white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere; font-size: 11.5px; line-height: 1.5; color: var(--text);
|
||||||
|
background: rgba(37,99,235,.05); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.ab-cv-llm-split .ab-cv-llm-pre { max-height: 240px; overflow-y: auto; font-size: 11.5px; border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; background: var(--panel); }
|
||||||
|
@media (max-width: 900px) { .ab-cv-llm-split { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* Heading-like narration (recovered chapter titles, OCR headline bands):
|
/* Heading-like narration (recovered chapter titles, OCR headline bands):
|
||||||
bold, larger, centered so titles read as titles inside the paper page. */
|
bold, larger, centered so titles read as titles inside the paper page. */
|
||||||
.ab-cv-row.is-heading .ab-cv-txt {
|
.ab-cv-row.is-heading .ab-cv-txt {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user