Initial commit: Learning Lab Express
Includes fixes found while reviewing the app: - Results: open each result by a type+agent+id key, so a video and a material draft with the same id no longer open each other's popup - AuthGate: clear the query cache during render, before pages build their queries, and remount pages on organization switch (pages no longer stay on skeletons for up to a minute after sign-in or refresh) - Instruction: keep only failed rules after a partial approve, so retrying does not create the successful schedules twice; drop the monthly note once count or frequency is edited - Parser/schedules: never schedule a count below one - Audio: ignore the end event of a cancelled utterance when changing speed - Downloads: revoke the object URL later so Safari/Firefox can read it - Results: refetch SCORM content when any part's revision changes; show a failed video as failed instead of "still rendering" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
331
src/express/results.ts
Normal file
331
src/express/results.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
// 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, PLAN_ACTIVE, productionStatus, 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
|
||||
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'
|
||||
|
||||
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 titles = new Map<number, string>()
|
||||
for (const plan of status?.plans ?? []) {
|
||||
for (const item of plan.items ?? []) if (item.run_id) titles.set(item.run_id, item.title)
|
||||
}
|
||||
// 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 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: combined(parts), revision: d.revision, parts,
|
||||
planTitle: titles.get(d.run_id) ?? null,
|
||||
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 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}
|
||||
`
|
||||
Reference in New Issue
Block a user