diff --git a/docs/ai.md b/docs/ai.md index e488831..da7d474 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -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 diff --git a/static/js/ai.js b/static/js/ai.js index e18a56f..7fcc82f 100644 --- a/static/js/ai.js +++ b/static/js/ai.js @@ -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')); diff --git a/static/js/podcast-article.js b/static/js/podcast-article.js new file mode 100644 index 0000000..1141c86 --- /dev/null +++ b/static/js/podcast-article.js @@ -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(/]*>/gi, '- ') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .replace(/'|'/g, "'").replace(/&/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, '&').replace(//g, '>').replace(/"/g, '"'); + +// Escaped first, then the emphasis marks: model output can't bring its own tags. +function inline(s) { + return esc(s) + .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '$1') + .replace(/(^|[^*\w])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![*\w])/g, '$1$2') + .replace(/(^|[^_\w])_(?=[^\s_])(.+?)(?<=[^\s_])_(?![_\w])/g, '$1$2'); +} + +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('

' + inline(lines.map(l => l.replace(/^\s*>\s?/, '')).join(' ')) + '

'); + } 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 => '
  • ' + inline(l.replace(/^\s*([-*+]|\d+[.)])\s+/, '')) + '
  • ').join('') + ''); + } else { + out.push('

    ' + inline(lines.map(l => l.trim()).join(' ')) + '

    '); + } + } + 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('

    ' + esc(s.heading) + '

    '); + parts.push('

    ▶ ab ' + clock(t) + '

    '); + 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); +} diff --git a/templates/radio/player.html b/templates/radio/player.html index 0673de7..eef6506 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -400,7 +400,8 @@ {% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #} -{% endif %} + +{% endif %} diff --git a/tools/tests/podcast-article.test.mjs b/tools/tests/podcast-article.test.mjs new file mode 100644 index 0000000..4a689af --- /dev/null +++ b/tools/tests/podcast-article.test.mjs @@ -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: '

    Notes & mehr

    ', 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.'), + '

    Eins kursiv und fett. Weiter.

    \n

    Zwei.

    '); + assert.equal(pa.markdownToHtml('- a\n- b'), ''); + assert.equal(pa.markdownToHtml('1. a\n2. b'), '
    1. a
    2. b
    '); + assert.equal(pa.markdownToHtml('> „Zitat“\n> mehr'), '

    „Zitat“ mehr

    '); + const evil = pa.markdownToHtml(' **** [l](javascript:x) "q"'); + assert.ok(!/<img')); + assert.equal(pa.markdownToHtml('2 * 3 * 4'), '

    2 * 3 * 4

    '); +}); + +test('articleHtml: summary, heading, time link, body', () => { + const h = pa.articleHtml({summary: 'Kurz & gut.', sections: [ + {heading: 'Erste Idee', start: 754.9, body_markdown: 'Text.'}]}); + assert.equal(h, '

    Kurz & gut.

    \n

    Erste <b>Idee</b>

    \n

    ▶ ab 12:34

    \n

    Text.

    '); +}); + +// ---- 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, /▶ 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); +});