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(/
'); + } 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(lines.map(l => l.replace(/^\s*>\s?/, '')).join(' ')) + '
' + 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('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.
\nZwei.
'); + assert.equal(pa.markdownToHtml('- a\n- b'), ''); + const evil = pa.markdownToHtml(' **„Zitat“ mehr