diora-web/static/js/podcast-article.js
2026-11-09 10:03:19 +01:00

380 lines
16 KiB
JavaScript

// ---------------------------------------------------------------------------
// 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),
});
// This device's name for reserving a wish (/article/claim/), kept per browser or app.
let memDevice = '';
export function deviceId() {
try {
let id = localStorage.getItem('diora-device-id');
if (!id) { id = 'd-' + Math.random().toString(36).slice(2, 12); localStorage.setItem('diora-device-id', id); }
return id;
} catch (e) {
return memDevice || (memDevice = 'd-' + Math.random().toString(36).slice(2, 12));
}
}
export const claimWish = id => send('/podcasts/episodes/' + id + '/article/claim/', 'POST', {device: deviceId()});
export const release = id => send('/podcasts/episodes/' + id + '/article/claim/', 'DELETE', {device: deviceId()});
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 claimWish(wish.episode_id); } 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 release(wish.episode_id); } 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, claim: claimWish, release, deviceId, markdownToHtml, transcriptToText};
window.dispatchEvent(new Event('diora-podcast-article'));
// At the start of diora.
setTimeout(() => { pickUp(); }, 4000);
}