diff --git a/src/express/results.ts b/src/express/results.ts index 621fe8b..1acc30a 100644 --- a/src/express/results.ts +++ b/src/express/results.ts @@ -5,7 +5,7 @@ import { request } from '../api/client' import { t } from '../i18n' import { kindLabel, type ContentKind, type Rule } from './parse' -import { expressAgents, PLAN_ACTIVE, productionStatus, type PlanItem, type Schedule } from './schedules' +import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, type PlanItem, type Schedule } from './schedules' const AGENTS = '/organizations/current/agents' @@ -39,6 +39,8 @@ export interface Material { 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 } } @@ -93,9 +95,9 @@ async function materialsOf(schedule: Schedule): Promise { request(`${AGENTS}/${agent.id}/material-drafts`), productionStatus(agent.id).catch(() => null), ]) - const items = new Map() + 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) + 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() @@ -104,11 +106,13 @@ async function materialsOf(schedule: Schedule): Promise { // 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), items.get(d.run_id)?.status), revision: d.revision, parts, - planTitle: items.get(d.run_id)?.title ?? null, + 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, } }) @@ -192,6 +196,8 @@ export async function materialContent(m: Material): Promise<{ content: MaterialC export const exportPptx = (deckId: number) => request(`/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 } }) diff --git a/src/express/schedules.ts b/src/express/schedules.ts index 77e5bbb..ad0bfaf 100644 --- a/src/express/schedules.ts +++ b/src/express/schedules.ts @@ -273,6 +273,7 @@ export interface PlanItem { id: number status: 'proposed' | 'approved' | 'queued' | 'generating' | 'ready' | 'blocked' | 'failed' | 'skipped' | 'cancelled' | 'rejected' run_id: number | null + attempt_count: number title: string required_outputs: { kind: ContentKind }[] } @@ -301,6 +302,20 @@ export const quoteProduction = (id: number, target: number) => export const confirmProduction = (id: number, plan: ContentPlan) => request(`${AGENTS}/${id}/content/plans/${plan.id}/confirm-planning`, { method: 'POST', json: { revision: plan.revision } }) +// Retrying a failed item = a fresh generation quote for that item alone, then starting it. The engine +// allows three attempts per item and only by the person who started the first one. +export const MAX_ATTEMPTS = 3 +export async function retryItem(agentId: number, planId: number, itemId: number) { + const plan = (await productionStatus(agentId)).plans.find(p => p.id === planId) + if (!plan) throw new Error('The production plan is no longer available') + const base = `${AGENTS}/${agentId}/content/plans/${planId}` + const quote = await request<{ revision: number; quote_signature: string }>(`${base}/items/${itemId}/retry-quote`, { method: 'POST', json: { revision: plan.revision } }) + return request(`${base}/start`, { + method: 'POST', headers: { 'Idempotency-Key': opKey(`retry-${itemId}`) }, + json: { revision: quote.revision, quote_signature: quote.quote_signature }, + }) +} + export const PLAN_ACTIVE = new Set(['planning', 'review', 'approved', 'running']) export interface RunSummary { diff --git a/src/i18n.ts b/src/i18n.ts index 8cf5139..44af812 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -287,6 +287,9 @@ const en = { 'res.creditsUsed': 'Credits used', 'res.accepted': 'Approved.', 'res.rejected': 'Rejected.', + 'res.retry': 'Retry', + 'res.retrying': 'Retrying…', + 'res.retried': 'Production started again.', 'res.audioNotice': 'Played with this device’s voice, which can differ between devices. Download the script to record it or use it in a TTS tool.', 'res.fullScript': 'Show the full script', 'res.scriptTxt': 'Script (.txt)', @@ -621,6 +624,9 @@ const id: Record = { 'res.creditsUsed': 'Kredit terpakai', 'res.accepted': 'Disetujui.', 'res.rejected': 'Ditolak.', + 'res.retry': 'Coba lagi', + 'res.retrying': 'Mencoba lagi…', + 'res.retried': 'Produksi dimulai lagi.', 'res.audioNotice': 'Diputar dengan suara perangkat ini, yang bisa berbeda antarperangkat. Unduh naskahnya untuk direkam atau dipakai di alat TTS.', 'res.fullScript': 'Lihat naskah lengkap', 'res.scriptTxt': 'Naskah (.txt)', diff --git a/src/pages/Results.tsx b/src/pages/Results.tsx index 117d6e2..6710ea0 100644 --- a/src/pages/Results.tsx +++ b/src/pages/Results.tsx @@ -8,7 +8,7 @@ import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../ import { toast } from '../components/Toast' import { describeRule, kindLabel, type ContentKind } from '../express/parse' import { - decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, materialKey, questionsOf, reviewSource, slidesOf, textOf, + decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf, titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus, } from '../express/results' import { MASTERY, scormPackage } from '../express/scorm' @@ -132,13 +132,34 @@ function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () = {describeRule(m.rule)} {working ? {m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')} - : } + :
+ {m.retry && } + +
} ) } +// A failed plan item is produced again with a fresh quote; the result then shows as preparing. +function RetryButton({ material: m, className }: { material: Material; className: string }) { + const queryClient = useQueryClient() + const retry = useMutation({ + mutationFn: () => retryMaterial(m), + onSuccess: () => { + toast(t('res.retried')) + queryClient.invalidateQueries({ queryKey: ['results'] }) + }, + onError: e => toast(e.message, { error: true }), + }) + return ( + + ) +} + function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) { const ref = useRef(null) const queryClient = useQueryClient() @@ -190,6 +211,7 @@ function Preview({ material: m, onClose }: { material: Material; onClose: () =>
{decide.error ? {decide.error.message} : {t('res.footNote')}} {m.composeSetId && {t('res.openStudio')}} + {m.retry && } {content.data?.content && } {m.status === 'ready' && ( <> diff --git a/src/styles/app.css b/src/styles/app.css index 4bc53f4..d55c85c 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -61,6 +61,7 @@ textarea{resize:vertical;line-height:1.6} .content-cover>small{max-width:calc(100% - 24px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .content-title:disabled{cursor:default;color:#8b97a9} .content-card-foot>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.content-card-actions{display:flex;gap:12px;flex-shrink:0} .research-row .status{margin-left:0} .research-row h3 a{color:var(--ink)} .research-row h3 a:hover{color:var(--blue)}