366 lines
16 KiB
JavaScript
366 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(/ /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);
|
|
}
|