// Results = everything the Express agents produced: Compose materials (material-drafts, titled by the // content plan item that ordered them), rendered videos (video-drafts) and web sources found by // research (source-drafts). Audio and SCORM are Express kinds built from Compose outputs: an audio // lesson is a narration summary, a SCORM module is the summary + quiz pair one plan item produced. import { EngineError, request, sleep } from '../api/client' import { t } from '../i18n' import { kindLabel, type ContentKind, type Rule } from './parse' import { expressAgents, forgetProductionReads, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, runNow, type PlanItem, type Schedule } from './schedules' const AGENTS = '/organizations/current/agents' export type MaterialStatus = 'preparing' | 'generating' | 'ready' | 'failed' | 'accepted' | 'rejected' interface RawMaterial { id: number agent_id: number run_id: number output_kind: ContentKind status: MaterialStatus revision: number compose_set_id: number | null sources: { document_id: number; title: string }[] created_at: string } /** One engine draft behind a result; a SCORM module has two (lesson and quiz). */ export interface Part { id: number; revision: number; kind: ContentKind; status: MaterialStatus } export interface Material { id: number agentId: number /** What the user asked for, which for audio and SCORM is not the engine's output kind. */ kind: ContentKind status: MaterialStatus revision: number parts: Part[] planTitle: string | null composeSetId: number | null sources: string[] createdAt: string rule: Rule /** How to produce it again, when the engine allows another attempt: the plan item, or (video) a new run. */ retry?: { planId: number; itemId: number } | { run: true } video?: { url: string | null; progress: number; credits: number | null } } // Labels are resolved at render time so they follow the UI language. export const titleOf = (m: Material) => m.planTitle ?? [kindLabel(m.kind), m.rule.topic].filter(Boolean).join(' · ') // Ids come from separate engine tables (material drafts, video drafts, plan items) and repeat across them. export const materialKey = (m: Material) => `${m.kind === 'video' ? 'video' : 'material'}-${m.agentId}-${m.id}` export type MaterialContent = Record export interface FoundSource { id: number agentId: number title: string url: string | null status: 'pending' | 'accepted' | 'rejected' | string revision: number preview: string documentId: number | null createdAt: string rule: Rule } interface RawSource { id: number agent_id: number title: string source_url: string | null status: string revision: number preview: string document_id: number | null captured_at: string } // The least finished part decides: a module is ready only when both its lesson and quiz are. const ORDER: MaterialStatus[] = ['failed', 'rejected', 'preparing', 'generating', 'ready', 'accepted'] const combined = (parts: Part[]) => ORDER.find(s => parts.some(p => p.status === s)) ?? 'preparing' // The engine can stop a plan item without updating its drafts (e.g. a run paused on a changed // quote leaves the draft "preparing" for good), so a stopped item overrides a draft still at work. const STOPPED = new Set(['failed', 'blocked', 'cancelled', 'skipped']) export const withPlanItem = (status: MaterialStatus, item: PlanItem['status'] | undefined): MaterialStatus => item && STOPPED.has(item) && (status === 'preparing' || status === 'generating') ? 'failed' : status async function materialsOf(schedule: Schedule): Promise { if (schedule.rule.type === 'content' && schedule.rule.kind === 'video') return videosOf(schedule) const { agent, rule } = schedule const kind = rule.type === 'content' ? rule.kind : 'summary' const [drafts, status] = await Promise.all([ request(`${AGENTS}/${agent.id}/material-drafts`), // Without its plan a schedule still has drafts; a busy engine fails the whole read instead, so // the last complete one is shown rather than drafts missing their titles and planned pieces. productionStatus(agent.id).catch(e => { if (passing(e)) throw e; return null }), ]) const items = new Map() for (const plan of status?.plans ?? []) { for (const item of plan.items ?? []) if (item.run_id) items.set(item.run_id, { ...item, planId: plan.id }) } // One run makes one result; its drafts are its parts (one, or the lesson + quiz of a SCORM module). const byRun = new Map() for (const d of drafts) byRun.set(d.run_id, [...(byRun.get(d.run_id) ?? []), d]) const made = [...byRun.values()].map((group): Material => { // The lesson comes first: it carries the title, sources and Compose set of the whole module. group.sort((a, b) => Number(b.output_kind === 'summary') - Number(a.output_kind === 'summary')) const [d] = group const item = items.get(d.run_id) const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status })) return { id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind, status: withPlanItem(combined(parts), item?.status), revision: d.revision, parts, planTitle: item?.title ?? null, retry: item?.status === 'failed' && item.attempt_count < MAX_ATTEMPTS ? { planId: item.planId, itemId: item.id } : undefined, composeSetId: d.compose_set_id, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule, } }) // Planned pieces have no draft until generation starts; show them as in progress so a running // production is visible here from the moment the topics are chosen. const started = new Set(drafts.map(d => d.run_id)) const planned = (status?.plans ?? []).filter(p => PLAN_ACTIVE.has(p.status)).flatMap(p => p.items) .filter(i => ['proposed', 'approved', 'queued', 'generating'].includes(i.status) && !(i.run_id && started.has(i.run_id))) .map((i): Material => ({ id: -i.id, agentId: agent.id, kind, status: 'preparing', revision: 0, parts: [], planTitle: i.title, composeSetId: null, sources: [], createdAt: new Date().toISOString(), rule, })) return [...planned, ...made] } interface RawVideo { id: number run_id: number status: string title: string | null progress_percentage: number video_url: string | null sources: { document_id: number; title: string }[] actual_credits: number | null created_at: string } // Video drafts move through script, storyboard, clips and render before they are ready to review. function videoStatus(s: string): MaterialStatus { if (s === 'ready' || s === 'accepted' || s === 'rejected') return s if (s === 'failed' || s === 'cancelled') return 'failed' return 'generating' } async function videosOf({ agent, rule, active }: Schedule): Promise { const drafts = await request(`${AGENTS}/${agent.id}/video-drafts`) // A video draft cannot be retried itself; a new run of its schedule makes it again. Offered on the // newest draft only, so older failures don't each start another (paid) run. const newest = Math.max(...drafts.map(d => d.id)) return drafts.map(d => { const status = videoStatus(d.status) return { retry: status === 'failed' && active && d.id === newest ? { run: true } as const : undefined, id: d.id, agentId: agent.id, kind: 'video', status, revision: 0, parts: [{ id: d.id, revision: 0, kind: 'video', status }], planTitle: d.title, composeSetId: null, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule, video: { url: d.video_url, progress: d.progress_percentage, credits: d.actual_credits }, } }) } async function sourcesOf({ agent, rule }: Schedule): Promise { const drafts = await request(`${AGENTS}/${agent.id}/source-drafts`) return drafts.map(d => ({ id: d.id, agentId: agent.id, title: d.title, url: d.source_url, status: d.status, revision: d.revision, preview: d.preview, documentId: d.document_id, createdAt: d.captured_at, rule, })) } // One schedule the engine cannot answer for (e.g. an archived agent) leaves its results out // instead of failing them all; only when every schedule fails is that an error. export function keepSettled(results: PromiseSettledResult[]): T[] { const kept = results.flatMap(r => (r.status === 'fulfilled' ? r.value : [])) const failed = results.filter(r => r.status === 'rejected') if (failed.length) console.warn(`${failed.length} of ${results.length} schedules could not be loaded`, failed.map(r => r.reason)) if (results.length && failed.length === results.length) throw failed[0].reason return kept } // Promise.allSettled over a function, running at most `limit` at a time. Results are loaded one // request per schedule and polled; firing them all at once floods the engine, which then answers // some with 503 and drops those schedules' results until the next poll. export async function settleLimited(items: T[], limit: number, fn: (item: T) => Promise): Promise[]> { const out: PromiseSettledResult[] = new Array(items.length) let next = 0 const worker = async () => { while (next < items.length) { const i = next++ try { out[i] = { status: 'fulfilled', value: await fn(items[i]) } } catch (reason) { out[i] = { status: 'rejected', reason } } } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) return out } const PER_SCHEDULE_LIMIT = 4 // The proxy's rate limit (503/429), a 5xx or no answer at all passes; a 4xx (e.g. a deleted agent) does not. const passing = (e: unknown) => e instanceof EngineError && (e.status === 0 || e.status === 429 || e.status >= 500) const settledReads: Set[] = [] /** After any action: an archived schedule's results (a decision on one) and production plans * (a started or retried piece) may have changed, so the next poll reads them again. */ export function forgetReads() { for (const s of settledReads) s.clear() forgetProductionReads() } /** Wraps a per-schedule read so a passing failure does not change what the page shows: a busy * engine is asked once more after a pause, and if it still fails, the schedule's results from the * last poll that read them are used. Dropping them instead made every count on the page dip for * one poll and come back on the next. */ export function remembering(fn: (s: Schedule) => Promise, pause = 3000) { const last = new Map() const settled = new Set() settledReads.push(settled) return async (s: Schedule): Promise => { // An archived schedule makes nothing new, so after one read it costs nothing until an action. const archived = s.agent.status === 'archived' if (archived && settled.has(s.agent.id)) return last.get(s.agent.id)! try { const read = await fn(s).catch(async e => { if (!(e instanceof EngineError && (e.status === 503 || e.status === 429))) throw e await sleep(pause) return fn(s) }) last.set(s.agent.id, read) if (archived) settled.add(s.agent.id) return read } catch (e) { const kept = last.get(s.agent.id) if (kept && passing(e)) return kept throw e } } } const readMaterials = remembering(materialsOf) const readSources = remembering(sourcesOf) // Results cost one request per schedule (every video schedule's drafts), so they are read once a // minute, and a page opened or refocused within that minute reuses what was read. An action on a // result or schedule still refreshes them at once. export const RESULTS_POLL = { refetchInterval: 60_000, staleTime: 60_000 } export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> { const schedules = await expressAgents({ archived: true }) const content = schedules.filter(s => s.rule.type === 'content') const research = schedules.filter(s => s.rule.type === 'research') // One shared budget: research runs after content rather than alongside it. const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, readMaterials) const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, readSources) const newest = (a: T, b: T) => b.createdAt.localeCompare(a.createdAt) return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) } } const draftContent = (m: Material, id: number) => request<{ content?: MaterialContent; output_id?: number }>(`${AGENTS}/${m.agentId}/material-drafts/${id}`) // outputId is the generated Compose output (e.g. the SlideDeck id), which owns the file exports. // A SCORM module merges its parts: the lesson's title and text with the quiz's questions. export async function materialContent(m: Material): Promise<{ content: MaterialContent | null; outputId: number | null }> { if (m.kind === 'video') return { content: { title: m.planTitle ?? '' }, outputId: null } const [first, ...rest] = await Promise.all(m.parts.map(p => draftContent(m, p.id))) if (!first?.content) return { content: null, outputId: null } const content = { ...first.content } for (const r of rest) if (r.content?.questions) content.questions = r.content.questions return { content, outputId: first.output_id ?? null } } // The engine renders slide decks to PPTX itself; other kinds have no file export there. export const exportPptx = (deckId: number) => request(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' }) export const retryMaterial = ({ agentId, retry }: Material) => retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId) // Every part waiting for review gets the same decision, so a SCORM module is approved as one. export async function decideMaterial(m: Material, decision: 'accept' | 'reject') { for (const p of m.parts.filter(p => p.status === 'ready')) { if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } }) else await request(`${AGENTS}/${m.agentId}/material-drafts/${p.id}/decision`, { method: 'POST', json: { expected_revision: p.revision, decision } }) } } // Accepting a source only adds it to the Library; its knowledge build is confirmed on the Brain page. export const reviewSource = (s: FoundSource, decision: 'accept' | 'reject') => request(`${AGENTS}/${s.agentId}/source-drafts/${s.id}/review`, { method: 'POST', json: { expected_revision: s.revision, decision } }) export const isWorking = (m: Material) => m.status === 'preparing' || m.status === 'generating' // ── Export ── const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v)) const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v]) export interface Question { question: string options: string[] answer: string explanation: string /** Index of the correct option, or -1 when the answer is not one of the options (an essay). */ correct: number } // The engine gives the answer as the option text, its letter ("B", "B. …") or its index. export function correctIndex(options: string[], answer: string): number { const exact = options.findIndex(o => o.trim().toLowerCase() === answer.trim().toLowerCase()) if (exact >= 0) return exact const letter = answer.trim().match(/^([A-Ha-h])(?:[.)\s:]|$)/)?.[1] if (letter) { const i = letter.toUpperCase().charCodeAt(0) - 65 if (i < options.length) return i } const index = /^\d+$/.test(answer.trim()) ? Number(answer) : -1 return index >= 0 && index < options.length ? index : -1 } export function questionsOf(content: MaterialContent): Question[] { return list(content.questions).map(raw => { const q = (raw ?? {}) as Record const options = list(q.options ?? q.choices).map(o => (typeof o === 'object' && o ? str((o as Record).text ?? (o as Record).label) : str(o))) const answer = str(q.correct_answer ?? q.answer) return { question: str(q.question ?? q.text ?? q.prompt), options, answer, explanation: str(q.explanation), correct: correctIndex(options, answer) } }) } export interface Slide { title: string; bullets: string[]; notes: string } export function slidesOf(content: MaterialContent): Slide[] { return list(content.slides).map(raw => { const s = (raw ?? {}) as Record const c = ((s.content ?? s) as Record) ?? {} return { title: str(c.title ?? s.title), bullets: list(c.bullets).map(str), notes: str(s.speaker_notes ?? s.notes) } }) } export const textOf = str export const listOf = list export function toMarkdown(m: Material, content: MaterialContent): string { const out = [`# ${str(content.title) || titleOf(m)}`, ''] if (m.kind === 'scorm') out.push(str(content.text), '', `## ${t('res.quiz')}`, '') if (m.kind === 'summary' || m.kind === 'audio') out.push(str(content.text)) else if (m.kind === 'quiz' || m.kind === 'scorm') { questionsOf(content).forEach((q, i) => { out.push(`${i + 1}. ${q.question}`, ...q.options.map(o => ` - ${o}`)) if (q.answer) out.push(` ${t('res.answer')}: ${q.answer}`) if (q.explanation) out.push(` ${q.explanation}`) out.push('') }) } else if (m.kind === 'flashcards') { for (const c of list(content.cards) as Record[]) out.push(`**${str(c.front)}**`, str(c.back), '') } else if (m.kind === 'slide_deck') { if (content.description) out.push(str(content.description), '') slidesOf(content).forEach((s, i) => out.push(`## ${i + 1}. ${s.title}`, ...s.bullets.map(b => `- ${b}`), '')) } else { for (const [key, value] of Object.entries(content)) { if (key === 'title') continue out.push(`## ${key.replace(/_/g, ' ')}`, ...list(value).map(v => (Array.isArray(value) ? `- ${str(v)}` : str(v))), '') } } if (m.sources.length) out.push('', '---', `${t('res.sources')}: ${m.sources.join(', ')}`) return out.join('\n').trim() + '\n' } export function download(name: string, data: string | Blob) { const url = URL.createObjectURL(typeof data === 'string' ? new Blob([data], { type: 'text/markdown;charset=utf-8' }) : data) const a = Object.assign(document.createElement('a'), { href: url, download: name }) a.click() // Revoked later: Safari and Firefox may not have started reading the file when click() returns. setTimeout(() => URL.revokeObjectURL(url), 60_000) } export const fileName = (m: Material, ext: 'md' | 'pptx' | 'txt' | 'zip' | 'mp4') => `${titleOf(m).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || m.kind}.${ext}` // PDF without a PDF library: print a standalone page from a hidden iframe, so the browser's own // "Save as PDF" lays out the text (real, selectable text with proper fonts and page breaks). export function printPdf(title: string, bodyHtml: string) { const frame = Object.assign(document.createElement('iframe'), { title: 'print' }) frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0' document.body.append(frame) const doc = frame.contentDocument! doc.open() doc.write(`${escapeHtml(title)}${bodyHtml}`) doc.close() const win = frame.contentWindow! // Remove the frame once the print dialog is done; the timeout covers browsers without afterprint. const cleanup = () => setTimeout(() => frame.remove(), 500) win.addEventListener('afterprint', cleanup, { once: true }) setTimeout(() => { win.focus(); win.print(); cleanup() }, 100) } const escapeHtml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!) const PRINT_CSS = ` @page{size:A4;margin:18mm 16mm} *{box-sizing:border-box} body{font:11pt/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1d2a3b;margin:0} header{border-bottom:2px solid #2563eb;padding-bottom:10px;margin-bottom:18px} header small{display:block;font-size:8.5pt;letter-spacing:.08em;text-transform:uppercase;color:#2563eb;font-weight:600} header p{margin:6px 0 0;font-size:9pt;color:#6b7a90} h1{font-size:20pt;line-height:1.3;margin:4px 0 0} article>h1{display:none} h2{font-size:13pt;margin:18px 0 6px;break-after:avoid} p{margin:0 0 9px} ul,ol{margin:0 0 10px;padding-left:20px} li{margin-bottom:4px} .quiz-list>li{margin-bottom:14px;break-inside:avoid} .quiz-list li.correct{font-weight:600;color:#127a55} .quiz-list li.correct:after{content:" ✓"} .muted{color:#6b7a90;font-size:10pt} .flashcards{display:grid;grid-template-columns:1fr 1fr;gap:10px} .flashcard{border:1px solid #d9e2ef;border-radius:8px;padding:10px 12px;break-inside:avoid;display:flex;flex-direction:column;gap:6px} .flashcard span{color:#4d5d73} .slide-preview{border:1px solid #d9e2ef;border-radius:8px;padding:12px 14px;margin-bottom:10px;break-inside:avoid} .slide-preview small{font-size:8pt;letter-spacing:.08em;text-transform:uppercase;color:#8a97a8} .slide-preview h2{margin-top:2px} footer{margin-top:24px;padding-top:8px;border-top:1px solid #e3e9f2;font-size:8.5pt;color:#8a97a8} `