A failed plan item gets a Retry button on its card and in the preview. It asks the engine for a fresh quote for that item and starts it again, while the engine still allows another attempt (three per item). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
17 KiB
TypeScript
344 lines
17 KiB
TypeScript
// 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 { request } from '../api/client'
|
|
import { t } from '../i18n'
|
|
import { kindLabel, type ContentKind, type Rule } from './parse'
|
|
import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, 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
|
|
/** The plan item to produce again, when the engine allows another attempt. */
|
|
retry?: { planId: number; itemId: number }
|
|
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<string, unknown>
|
|
|
|
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<PlanItem['status']>(['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<Material[]> {
|
|
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<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`),
|
|
productionStatus(agent.id).catch(() => null),
|
|
])
|
|
const items = new Map<number, PlanItem & { planId: number }>()
|
|
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<number, RawMaterial[]>()
|
|
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 }: Schedule): Promise<Material[]> {
|
|
const drafts = await request<RawVideo[]>(`${AGENTS}/${agent.id}/video-drafts`)
|
|
return drafts.map(d => {
|
|
const status = videoStatus(d.status)
|
|
return {
|
|
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<FoundSource[]> {
|
|
const drafts = await request<RawSource[]>(`${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,
|
|
}))
|
|
}
|
|
|
|
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
|
|
const schedules = await expressAgents({ archived: true })
|
|
const [materials, sources] = await Promise.all([
|
|
Promise.all(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
|
|
Promise.all(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
|
|
])
|
|
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
|
|
return { materials: materials.flat().sort(newest), sources: sources.flat().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<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
|
|
|
|
// Every part waiting for review gets the same decision, so a SCORM module is approved as one.
|
|
export const retryMaterial = (m: Material) => retryItem(m.agentId, m.retry!.planId, m.retry!.itemId)
|
|
|
|
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<string, unknown>
|
|
const options = list(q.options ?? q.choices).map(o => (typeof o === 'object' && o ? str((o as Record<string, unknown>).text ?? (o as Record<string, unknown>).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<string, unknown>
|
|
const c = ((s.content ?? s) as Record<string, unknown>) ?? {}
|
|
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<string, unknown>[]) 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(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>${PRINT_CSS}</style></head><body>${bodyHtml}</body></html>`)
|
|
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}
|
|
`
|