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"`
|
||||
(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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
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/99-init.js"></script>
|
||||
{% 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/home.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