Merge branch 'wt-tr-article' into wt-tr-int

This commit is contained in:
mrwnslz 2026-11-09 09:39:08 +01:00
commit d059ed82c8
6 changed files with 571 additions and 10 deletions

View file

@ -43,6 +43,8 @@ kept by the server as a blob it can't read:
(`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
answers become plain German messages.
- `ask` also takes `onProgress(words)` (words written so far while streaming; OpenRouter is not streamed and reports once at the end)
and `signal` (an `AbortSignal`), both optional; `DioraAI.modelName()` is the model of the chosen provider. Used by the podcast articles.
- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
changes until the user ticks and presses "Übernehmen".
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close

View file

@ -43,7 +43,8 @@ Transcript and article are stored in plain text on the server, like all podcast
Tapping it opens diora at the episode.
4. A device with a key (`static/js/podcast-article.js`) picks up the open wishes, reserves one,
writes the article and saves it. It then says "Artikel fertig" (on Android as a system
notification through the bridge) and opens it on a tap.
notification through the bridge, `DioraNative.notify(title, text, target)` with target
`episode:<id>` or `item:<id>`; elsewhere a toast) and opens it on a tap.
5. The article is the first tab of the episode ("Artikel") and an item of the favourite blog
"Podcast-Artikel", linking back to the episode.

View file

@ -7,7 +7,7 @@
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
// authors, folder names …). Nothing is changed without the user ticking it in review().
//
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema, effort, maxTokens, onProgress, signal}), review(…)}
// ---------------------------------------------------------------------------
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
@ -116,12 +116,17 @@ function parseAnswer(text) {
}
}
// Words in a text so far (for onProgress).
const countWords = t => { const m = String(t || '').match(/\S+/g); return m ? m.length : 0; };
// One request with a JSON answer of `schema` (structured outputs).
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
// Optional: onProgress(words) while the answer is written (throttled; OpenRouter is not streamed,
// it reports once at the end), signal (an AbortSignal) to give up.
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, onProgress, signal}) {
const v = await loadVault();
const key = keyOf(v);
if (!key) throw fail('Kein API-Key hinterlegt.');
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal});
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
const stream = client(key).beta.messages.stream({
model: PROVIDERS.anthropic.model,
@ -131,8 +136,18 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
system,
messages: [{role: 'user', content: prompt}],
output_config: {effort, format: {type: 'json_schema', schema}},
});
}, signal ? {signal} : undefined);
if (onProgress) {
let last = 0;
stream.on('text', (delta, snapshot) => {
const now = Date.now();
if (now - last < 400) return;
last = now;
try { onProgress(countWords(snapshot)); } catch (e) { /* the caller's display must not break the call */ }
});
}
const msg = await stream.finalMessage();
if (onProgress) { try { onProgress(countWords(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''))); } catch (e) { /* see above */ } }
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
@ -149,8 +164,8 @@ async function openRouterFetch(key, path, init) {
return r.json();
}
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) {
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({
model: PROVIDERS.openrouter.model,
max_tokens: maxTokens,
reasoning: {effort},
@ -161,7 +176,9 @@ async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.');
if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
return parseAnswer((choice.message && choice.message.content) || '');
const text = (choice.message && choice.message.content) || '';
if (onProgress) { try { onProgress(countWords(text)); } catch (e) { /* the caller's display must not break the call */ } }
return parseAnswer(text);
}
// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
@ -361,6 +378,8 @@ window.__dioraBack = function () {
return back ? back.apply(this, arguments) : false;
};
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
const modelName = () => PROVIDERS[providerOf(vault)].model;
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), modelName, settings, withKey, ask, review, progress, showError,
close, toast, explain};
window.dispatchEvent(new Event('diora-ai'));

View file

@ -0,0 +1,366 @@
// ---------------------------------------------------------------------------
// Podcast articles on the device (docs/transcription.md): the transcript of an episode goes to
// Claude with the user's own key (DioraAI, docs/ai.md) and comes back as an article that is saved
// to the episode and to the blog "Podcast-Artikel". Also the picker-up of open wishes: the server
// only remembers that an article was wanted; a device with a key writes it.
//
// An ES module like ai.js. window.DioraPodcastArticle = {write, pickUp, watch, ...}; the helpers
// are exported too (tools/tests/podcast-article.test.mjs runs them under Node).
// ---------------------------------------------------------------------------
export const MAX_TOKENS_IN = 120000; // about nine hours of speech; more stops with a message
export const GROUP_SECONDS = 30;
const SHOWNOTES_MAX = 6000; // characters of the shownotes handed to Claude
const POLL_MS = 5 * 60 * 1000;
const ai = () => globalThis.DioraAI;
export const SYSTEM = `You turn the transcript of a podcast episode into an article that reads as if the people
speaking had written it themselves instead of recording the episode.
The reader wants to decide whether this episode still has something to teach them. They do not
want a summary; they want to understand what is argued here well enough to judge it for
themselves. Write for that reader.
Perspective. Write from the speakers' standpoint and state their positions directly, as their
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
is one person speaking, write as that person argues, in the third person or impersonal voice,
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
soften what is said.
Conversations. Interviews, conversations and discussions are condensed into their core theses;
the form of question and answer disappears. Where people hold different positions, keep them
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
minutes. Questions from a host matter only where they shape the argument. When the transcript
has no speaker names, infer them from the context (introductions, the shownotes, how people
address each other); if you can't, do not invent names.
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
title. Make clear what is new, distinctive or surprising in the episode and where it restates
what is commonly said, by how you weight and order it, without grading it.
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
lead nowhere.
Form. Write in the language of the episode. \`title\`: a title for the article, not the episode
title repeated. \`summary\`: three to six sentences up front that state the central thesis and how
the episode gets there. \`sections\`: as many as the content needs (usually four to ten), each
with a heading that says something (not "Einleitung"), \`start\` = the second in the episode where
its content begins (from the [mm:ss] marks), and \`body_markdown\` in plain paragraphs; lists only
where the speakers list things themselves. Follow the order of the episode unless a different
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
roughly a tenth of the transcript, at most about 5,000 words.
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
using the metadata, and don't quote garbled passages.`;
export const SCHEMA = {
"type": "object",
"additionalProperties": false,
"required": [
"title",
"summary",
"kind",
"speakers",
"sections"
],
"properties": {
"title": {
"type": "string"
},
"summary": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"Interview",
"Gespräch",
"Monolog",
"Diskussion"
]
},
"speakers": {
"type": "array",
"items": {
"type": "string"
}
},
"sections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"heading",
"start",
"body_markdown"
],
"properties": {
"heading": {
"type": "string"
},
"start": {
"type": "integer"
},
"body_markdown": {
"type": "string"
}
}
}
}
}
};
// ---- the input text -------------------------------------------------------------------------
export const pad = n => String(n).padStart(2, '0');
// mm:ss (minutes keep counting past 59: "125:03"), the form of the [mm:ss] marks Claude reads.
export function mmss(sec) {
const s = Math.max(0, Math.floor(Number(sec) || 0));
return pad(Math.floor(s / 60)) + ':' + pad(s % 60);
}
// h:mm:ss from an hour on, else mm:ss: the label of the "▶ ab …" links.
export function clock(sec) {
const s = Math.max(0, Math.floor(Number(sec) || 0));
return s >= 3600 ? Math.floor(s / 3600) + ':' + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60) : mmss(s);
}
// One line per group of about GROUP_SECONDS: "[mm:ss] Sprecher: text", or "[mm:ss] text" without
// speakers. A new speaker always starts a new line.
export function transcriptToText(segments) {
const lines = [];
let cur = null;
for (const seg of segments || []) {
const text = String((seg && seg.text) || '').replace(/\s+/g, ' ').trim();
if (!text) continue;
const start = Number(seg.start) || 0;
const speaker = String(seg.speaker || '').trim();
if (!cur || speaker !== cur.speaker || start - cur.start >= GROUP_SECONDS) {
cur = {start, speaker, parts: []};
lines.push(cur);
}
cur.parts.push(text);
}
return lines.map(l => '[' + mmss(l.start) + '] ' + (l.speaker ? l.speaker + ': ' : '') + l.parts.join(' ')).join('\n');
}
export const estimateTokens = text => Math.ceil(String(text || '').length / 4);
// HTML (the shownotes) as plain text.
export function htmlToText(html) {
return String(html || '')
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6]|blockquote|tr)\s*>/gi, '\n')
.replace(/<li[^>]*>/gi, '- ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, '&')
.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
export function metaBlock(ep) {
const rows = [];
const feed = ep.feed || {};
if (feed.title) rows.push('Podcast: ' + feed.title);
if (ep.author || feed.author) rows.push('Autor: ' + (ep.author || feed.author));
rows.push('Folge: ' + (ep.title || ''));
if (ep.pub_date) rows.push('Datum: ' + String(ep.pub_date).slice(0, 10));
if (ep.duration_seconds) rows.push('Dauer: ' + clock(ep.duration_seconds));
let notes = htmlToText(ep.description);
if (notes.length > SHOWNOTES_MAX) notes = notes.slice(0, SHOWNOTES_MAX) + ' …';
if (notes) rows.push('Shownotes:\n' + notes);
const chapters = (ep.chapters || []).filter(c => c && c.title);
if (chapters.length) rows.push('Kapitel:\n' + chapters.map(c => '[' + mmss(c.start) + '] ' + c.title).join('\n'));
return rows.join('\n');
}
function userError(message) {
const e = new Error(message);
e.diora = true;
return e;
}
// The prompt (user turn), or an Error with a German message when it is too long.
export function buildPrompt(ep, transcript) {
const text = transcriptToText(transcript && transcript.segments);
if (!text) throw userError('Das Transkript ist leer.');
const prompt = metaBlock(ep) + '\n\nTranskript:\n' + text;
const tokens = estimateTokens(prompt + SYSTEM);
if (tokens > MAX_TOKENS_IN) {
throw userError('Dieses Transkript ist für einen Artikel zu lang (etwa ' + Math.round(tokens / 1000) +
'.000 Tokens, mehr als ' + MAX_TOKENS_IN / 1000 + '.000 gehen nicht, das sind rund neun Stunden).');
}
return prompt;
}
// ---- markdown to HTML (paragraphs, emphasis, lists, block quotes; everything else is escaped) ---
export const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Escaped first, then the emphasis marks: model output can't bring its own tags.
function inline(s) {
return esc(s)
.replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*\w])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
.replace(/(^|[^_\w])_(?=[^\s_])(.+?)(?<=[^\s_])_(?![_\w])/g, '$1<em>$2</em>');
}
export function markdownToHtml(md) {
const blocks = String(md || '').replace(/\r\n?/g, '\n').split(/\n\s*\n/);
const out = [];
for (const block of blocks) {
const lines = block.split('\n').map(l => l.replace(/\s+$/, '')).filter(l => l.trim());
if (!lines.length) continue;
if (lines.every(l => /^\s*>/.test(l))) {
out.push('<blockquote><p>' + inline(lines.map(l => l.replace(/^\s*>\s?/, '')).join(' ')) + '</p></blockquote>');
} else if (lines.every(l => /^\s*([-*+]|\d+[.)])\s+/.test(l))) {
const tag = /^\s*\d/.test(lines[0]) ? 'ol' : 'ul';
out.push('<' + tag + '>' + lines.map(l => '<li>' + inline(l.replace(/^\s*([-*+]|\d+[.)])\s+/, '')) + '</li>').join('') + '</' + tag + '>');
} else {
out.push('<p>' + inline(lines.map(l => l.trim()).join(' ')) + '</p>');
}
}
return out.join('\n');
}
// The article: the summary as a lead paragraph, per section a heading, the "▶ ab 12:34" link
// (feeds.js plays the item's episode from `#t=`) and the body.
export function articleHtml(answer) {
const parts = [];
if (answer.summary) parts.push(markdownToHtml(answer.summary));
for (const s of answer.sections || []) {
const t = Math.max(0, Math.floor(Number(s.start) || 0));
parts.push('<h2>' + esc(s.heading) + '</h2>');
parts.push('<p><a href="#t=' + t + '">▶ ab ' + clock(t) + '</a></p>');
parts.push(markdownToHtml(s.body_markdown));
}
return parts.filter(Boolean).join('\n');
}
// ---- talking to diora ---------------------------------------------------------------------------
function csrf() {
try {
if (typeof getCsrfToken === 'function') return getCsrfToken();
} catch (e) { /* fall through */ }
const m = typeof document !== 'undefined' && /(?:^|; )csrftoken=([^;]+)/.exec(document.cookie || '');
return m ? decodeURIComponent(m[1]) : '';
}
const send = (url, method, body) => fetch(url, {
method, headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: body === undefined ? undefined : JSON.stringify(body),
});
async function getJson(url) {
const r = await fetch(url, {cache: 'no-store'});
return {status: r.status, ok: r.ok, data: r.ok ? await r.json() : null};
}
// Write the article of an episode and save it. Resolves {item_id, title}.
// onState({phase: 'loading'|'writing'|'saving', text, words}); signal aborts.
export async function write(episodeId, {onState, signal} = {}) {
const say = (phase, text, words) => { if (onState) { try { onState({phase, text, words}); } catch (e) { /* display only */ } } };
const AI = ai();
if (!AI || !AI.available()) throw userError('Die KI-Funktionen sind auf diesem Gerät nicht verfügbar.');
say('loading', 'Lade Transkript …');
const base = '/podcasts/episodes/' + encodeURIComponent(episodeId) + '/';
const [detail, tr] = await Promise.all([getJson(base), getJson(base + 'transcript/')]);
if (!detail.ok) throw userError('Die Folge ließ sich nicht laden (' + detail.status + ').');
if (!tr.ok) throw userError(tr.status === 404 ? 'Zu dieser Folge gibt es noch kein Transkript.' : 'Das Transkript ließ sich nicht laden (' + tr.status + ').');
const prompt = buildPrompt(detail.data, tr.data);
const answer = await new Promise((resolve, reject) => {
// The key first (the settings open when there is none, as for every KI feature).
AI.withKey(() => AI.ask({
system: SYSTEM, prompt, schema: SCHEMA, effort: 'medium', maxTokens: 32000, signal,
onProgress: words => say('writing', 'Schreibe Artikel … ' + words.toLocaleString('de-DE') + ' Wörter', words),
}).then(resolve, reject));
});
if (!answer || !Array.isArray(answer.sections) || !answer.sections.length) throw userError('Die Antwort enthielt keinen Artikel.');
say('saving', 'Speichere Artikel …');
const title = answer.title || detail.data.title;
const r = await send(base + 'article/', 'POST', {
title,
summary: answer.summary || '',
body_html: articleHtml(answer),
model: AI.modelName ? AI.modelName() : 'claude-opus-5-5',
kind: answer.kind || '',
});
if (!r.ok) throw userError('Der Artikel ließ sich nicht speichern (' + r.status + ').');
const saved = await r.json();
return {item_id: saved.item_id, title};
}
// "Artikel fertig": a system notification on Android (the bridge), else a toast in the page.
export function notifyDone(title, episodeId) {
const N = globalThis.DioraNative;
try {
if (N && typeof N.notify === 'function') { N.notify('Artikel fertig', title, 'episode:' + episodeId); return; }
} catch (e) { /* fall back to the toast */ }
const AI = ai();
if (AI && AI.toast) AI.toast('Artikel fertig: ' + title);
}
let running = false;
const gaveUp = new Set(); // wishes that failed in this visit: not tried again until diora is reopened
// The open wishes (docs/transcription.md): reserve one, write it, save it. Nothing without a key.
export async function pickUp({onState} = {}) {
if (running) return {busy: true};
const AI = ai();
if (!AI || !AI.available()) return {done: 0};
running = true;
const result = {done: 0, failed: 0, skipped: 0};
try {
if (!(await AI.hasKey())) return result;
let pending;
try {
const r = await getJson('/podcasts/transcription/pending/');
if (!r.ok || !Array.isArray(r.data)) return result;
pending = r.data;
} catch (e) { return result; }
for (const wish of pending) {
if (gaveUp.has(wish.episode_id)) { result.skipped++; continue; }
let claim;
try { claim = await send('/podcasts/episodes/' + wish.episode_id + '/article/claim/', 'POST', {}); } catch (e) { return result; }
if (!claim.ok) { result.skipped++; continue; } // 409: another device has it
try {
const saved = await write(wish.episode_id, {onState});
result.done++;
notifyDone(saved.title || wish.title, wish.episode_id);
} catch (e) {
result.failed++;
gaveUp.add(wish.episode_id);
try { await send('/podcasts/episodes/' + wish.episode_id + '/article/claim/', 'DELETE'); } catch (e2) { /* it runs out in 15 minutes */ }
}
}
} finally {
running = false;
}
return result;
}
// While diora is open and a job of its own runs, ask every five minutes (watch(true) when one starts,
// watch(false) when none does).
let timer = null;
export function watch(on) {
if (on && !timer) timer = setInterval(() => { pickUp(); }, POLL_MS);
else if (!on && timer) { clearInterval(timer); timer = null; }
}
export const _reset = () => { running = false; gaveUp.clear(); };
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.DioraPodcastArticle = {write, pickUp, watch, notifyDone, markdownToHtml, transcriptToText};
window.dispatchEvent(new Event('diora-podcast-article'));
// At the start of diora.
setTimeout(() => { pickUp(); }, 4000);
}

View file

@ -400,7 +400,8 @@
<script src="/static/js/app/80-radio-sidebar.js"></script>
<script src="/static/js/app/99-init.js"></script>
{% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #}
<script type="module" src="/static/js/ai.js"></script>{% endif %}
<script type="module" src="/static/js/ai.js"></script>
<script type="module" src="/static/js/podcast-article.js"></script>{% endif %}
<script src="/static/js/podcasts-web.js"></script>
<script src="/static/js/home.js"></script>
<script src="/static/js/feeds.js"></script>

View file

@ -0,0 +1,172 @@
// Tests of static/js/podcast-article.js under Node: node --test tools/tests/
// No network, no LLM: fetch and DioraAI are stubbed.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as pa from '../../static/js/podcast-article.js';
test('transcriptToText groups 30 seconds and splits on speaker change', () => {
const segs = [
{start: 0, end: 5, speaker: '', text: 'Hallo zusammen.'},
{start: 20, end: 29, speaker: '', text: 'Heute geht es um X.'},
{start: 31, end: 40, speaker: '', text: 'Zweite Gruppe.'},
{start: 3725, end: 3730, speaker: '', text: 'Spät.'},
{start: 3726, end: 3730, speaker: '', text: ' '},
];
assert.equal(pa.transcriptToText(segs),
'[00:00] Hallo zusammen. Heute geht es um X.\n[00:31] Zweite Gruppe.\n[62:05] Spät.');
const sp = [
{start: 0, speaker: 'Anna', text: 'Eins.'},
{start: 4, speaker: 'Anna', text: 'Zwei.'},
{start: 8, speaker: 'Bernd', text: 'Drei.'},
];
assert.equal(pa.transcriptToText(sp), '[00:00] Anna: Eins. Zwei.\n[00:08] Bernd: Drei.');
assert.equal(pa.transcriptToText([]), '');
});
test('clock and mmss', () => {
assert.equal(pa.mmss(754), '12:34');
assert.equal(pa.clock(754), '12:34');
assert.equal(pa.clock(3725), '1:02:05');
});
test('token estimate and the limit message', () => {
assert.equal(pa.estimateTokens('a'.repeat(400)), 100);
const big = {segments: Array.from({length: 20000}, (_, i) => ({start: i * 31, speaker: '', text: 'x'.repeat(30)}))};
assert.throws(() => pa.buildPrompt({title: 'T'}, big), e => e.diora && /zu lang/.test(e.message));
const ok = pa.buildPrompt({title: 'T', feed: {title: 'P'}, description: '<p>Notes &amp; mehr</p>', duration_seconds: 3725,
chapters: [{start: 60, title: 'Kap 1'}]}, {segments: [{start: 0, text: 'Hallo'}]});
assert.match(ok, /Podcast: P\nFolge: T\nDauer: 1:02:05\nShownotes:\nNotes & mehr\nKapitel:\n\[01:00\] Kap 1\n\nTranskript:\n\[00:00\] Hallo$/);
assert.throws(() => pa.buildPrompt({title: 'T'}, {segments: []}), /leer/);
});
test('system prompt is the one of the contract', async () => {
const fs = await import('node:fs');
const doc = fs.readFileSync(new URL('../../docs/transcription.md', import.meta.url), 'utf8').split('\r\n').join('\n');
assert.ok(doc.includes(pa.SYSTEM));
assert.deepEqual(pa.SCHEMA.required, ['title', 'summary', 'kind', 'speakers', 'sections']);
});
test('markdownToHtml: paragraphs, emphasis, lists, quotes, escaping', () => {
assert.equal(pa.markdownToHtml('Eins *kursiv* und **fett**.\nWeiter.\n\nZwei.'),
'<p>Eins <em>kursiv</em> und <strong>fett</strong>. Weiter.</p>\n<p>Zwei.</p>');
assert.equal(pa.markdownToHtml('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
assert.equal(pa.markdownToHtml('1. a\n2. b'), '<ol><li>a</li><li>b</li></ol>');
assert.equal(pa.markdownToHtml('> „Zitat“\n> mehr'), '<blockquote><p>„Zitat“ mehr</p></blockquote>');
const evil = pa.markdownToHtml('<script>alert(1)</script> **<img src=x onerror=y>** [l](javascript:x) "q"');
assert.ok(!/<script|<img/.test(evil));
assert.ok(evil.includes('&lt;script&gt;') && evil.includes('<strong>&lt;img'));
assert.equal(pa.markdownToHtml('2 * 3 * 4'), '<p>2 * 3 * 4</p>');
});
test('articleHtml: summary, heading, time link, body', () => {
const h = pa.articleHtml({summary: 'Kurz & gut.', sections: [
{heading: 'Erste <b>Idee</b>', start: 754.9, body_markdown: 'Text.'}]});
assert.equal(h, '<p>Kurz &amp; gut.</p>\n<h2>Erste &lt;b&gt;Idee&lt;/b&gt;</h2>\n<p><a href="#t=754">▶ ab 12:34</a></p>\n<p>Text.</p>');
});
// ---- stubs ---------------------------------------------------------------------------------
function stubWorld({pending, claims = {}, hasKey = true, askImpl, saveStatus = 200}) {
const calls = [];
const notices = [];
globalThis.DioraNative = undefined;
globalThis.DioraAI = {
available: () => true,
hasKey: async () => hasKey,
withKey: fn => fn(),
modelName: () => 'test-model',
toast: t => notices.push(t),
ask: askImpl || (async opts => {
opts.onProgress && opts.onProgress(1200);
return {title: 'Titel', summary: 'Sum.', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]};
}),
};
globalThis.fetch = async (url, init = {}) => {
const method = init.method || 'GET';
calls.push(method + ' ' + url + (init.body ? ' ' + init.body : ''));
const json = (status, data) => ({status, ok: status < 400, json: async () => data});
if (url === '/podcasts/transcription/pending/') return json(200, pending);
let m = /^\/podcasts\/episodes\/(\d+)\/article\/claim\/$/.exec(url);
if (m) return method === 'POST' ? json(claims[m[1]] || 200, {}) : json(200, {});
m = /^\/podcasts\/episodes\/(\d+)\/transcript\/$/.exec(url);
if (m) return m[1] === '3' ? json(404, {error: 'no transcript'}) : json(200, {segments: [{start: 0, speaker: '', text: 'Hallo'}]});
m = /^\/podcasts\/episodes\/(\d+)\/article\/$/.exec(url);
if (m) return json(saveStatus, {item_id: 900 + Number(m[1])});
m = /^\/podcasts\/episodes\/(\d+)\/$/.exec(url);
if (m) return json(200, {id: Number(m[1]), title: 'Folge ' + m[1], description: '', chapters: [], feed: {title: 'P'}});
return json(404, {});
};
return {calls, notices};
}
test('write: loads, asks with the contract options, saves the HTML', async () => {
const w = stubWorld({pending: []});
let opts;
globalThis.DioraAI.ask = async o => { opts = o; o.onProgress(5); return {title: 'T', summary: 'S', kind: 'Monolog', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]}; };
const states = [];
const r = await pa.write(7, {onState: s => states.push(s.phase)});
assert.deepEqual(r, {item_id: 907, title: 'T'});
assert.equal(opts.effort, 'medium');
assert.equal(opts.maxTokens, 32000);
assert.equal(opts.system, pa.SYSTEM);
assert.match(opts.prompt, /Transkript:\n\[00:00\] Hallo$/);
assert.deepEqual(states, ['loading', 'writing', 'saving']);
const post = w.calls.find(c => c.startsWith('POST /podcasts/episodes/7/article/ '));
const body = JSON.parse(post.slice(post.indexOf('{')));
assert.equal(body.model, 'test-model');
assert.equal(body.kind, 'Monolog');
assert.match(body.body_html, /<a href="#t=61">▶ ab 01:01<\/a>/);
});
test('write: no transcript gives a German message', async () => {
stubWorld({pending: []});
await assert.rejects(pa.write(3), /noch kein Transkript/);
});
test('pickUp: claim, 409 skipped, failure releases the claim, notifier per success', async () => {
pa._reset();
const w = stubWorld({
pending: [{episode_id: 1, title: 'Eins'}, {episode_id: 2, title: 'Zwei'}, {episode_id: 4, title: 'Vier'}],
claims: {2: 409},
askImpl: async o => {
if (o.prompt.includes('Folge 4')) throw new Error('kaputt');
return {title: 'A', summary: 'S', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 0, body_markdown: 'B'}]};
},
});
const r = await pa.pickUp();
assert.deepEqual(r, {done: 1, failed: 1, skipped: 1});
assert.ok(w.calls.includes('POST /podcasts/episodes/2/article/claim/ {}'));
assert.ok(!w.calls.some(c => c.startsWith('GET /podcasts/episodes/2/'))); // 409: not written
assert.ok(w.calls.includes('DELETE /podcasts/episodes/4/article/claim/')); // failed: released
assert.ok(!w.calls.includes('DELETE /podcasts/episodes/1/article/claim/'));
assert.deepEqual(w.notices, ['Artikel fertig: A']);
// The failed one is not tried again in this visit.
const w2 = stubWorld({pending: [{episode_id: 4, title: 'Vier'}]});
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 1});
assert.ok(!w2.calls.some(c => c.includes('claim')));
});
test('pickUp: android bridge notifies instead of the toast', async () => {
pa._reset();
const w = stubWorld({pending: [{episode_id: 1, title: 'Eins'}]});
const got = [];
globalThis.DioraNative = {notify: (...a) => got.push(a)};
await pa.pickUp();
assert.deepEqual(got, [['Artikel fertig', 'Titel', 'episode:1']]);
assert.deepEqual(w.notices, []);
globalThis.DioraNative = undefined;
});
test('pickUp: no key does nothing, not even a request; no second run at the same time', async () => {
pa._reset();
const w = stubWorld({pending: [{episode_id: 1, title: 'x'}], hasKey: false});
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 0});
assert.deepEqual(w.calls, []);
const w2 = stubWorld({pending: [{episode_id: 1, title: 'x'}]});
const a = pa.pickUp();
const b = await pa.pickUp();
assert.deepEqual(b, {busy: true});
assert.equal((await a).done, 1);
assert.equal(w2.calls.filter(c => c.startsWith('POST') && c.endsWith('/claim/ {}')).length, 1);
});