Podcast-Artikel auf dem Gerät: podcast-article.js schreibt den Artikel mit dem eigenen Key und holt offene Wünsche ab; DioraAI.ask mit onProgress und signal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f242605a97
commit
dbd4fb6e3a
5 changed files with 569 additions and 9 deletions
|
|
@ -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"`
|
(`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
|
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
|
||||||
answers become plain German messages.
|
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
|
- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
|
||||||
changes until the user ticks and presses "Übernehmen".
|
changes until the user ticks and presses "Übernehmen".
|
||||||
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
|
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
|
// 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().
|
// 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';
|
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).
|
// 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 v = await loadVault();
|
||||||
const key = keyOf(v);
|
const key = keyOf(v);
|
||||||
if (!key) throw fail('Kein API-Key hinterlegt.');
|
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.
|
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
|
||||||
const stream = client(key).beta.messages.stream({
|
const stream = client(key).beta.messages.stream({
|
||||||
model: PROVIDERS.anthropic.model,
|
model: PROVIDERS.anthropic.model,
|
||||||
|
|
@ -131,8 +136,18 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
|
||||||
system,
|
system,
|
||||||
messages: [{role: 'user', content: prompt}],
|
messages: [{role: 'user', content: prompt}],
|
||||||
output_config: {effort, format: {type: 'json_schema', schema}},
|
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();
|
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 === '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.');
|
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(''));
|
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();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
|
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) {
|
||||||
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
|
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({
|
||||||
model: PROVIDERS.openrouter.model,
|
model: PROVIDERS.openrouter.model,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
reasoning: {effort},
|
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) throw fail('Die Antwort ließ sich nicht lesen.');
|
||||||
if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.');
|
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.');
|
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.)
|
// 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;
|
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};
|
close, toast, explain};
|
||||||
window.dispatchEvent(new Event('diora-ai'));
|
window.dispatchEvent(new Event('diora-ai'));
|
||||||
|
|
|
||||||
366
static/js/podcast-article.js
Normal file
366
static/js/podcast-article.js
Normal 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(/ /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, '>').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, '<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);
|
||||||
|
}
|
||||||
|
|
@ -400,7 +400,8 @@
|
||||||
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
||||||
<script src="/static/js/app/99-init.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. #}
|
{% 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/podcasts-web.js"></script>
|
||||||
<script src="/static/js/home.js"></script>
|
<script src="/static/js/home.js"></script>
|
||||||
<script src="/static/js/feeds.js"></script>
|
<script src="/static/js/feeds.js"></script>
|
||||||
|
|
|
||||||
172
tools/tests/podcast-article.test.mjs
Normal file
172
tools/tests/podcast-article.test.mjs
Normal 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 & 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('<script>') && evil.includes('<strong><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 & gut.</p>\n<h2>Erste <b>Idee</b></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);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue