Add Retry for failed results
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>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
import { request } from '../api/client'
|
import { request } from '../api/client'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import { kindLabel, type ContentKind, type Rule } from './parse'
|
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'
|
const AGENTS = '/organizations/current/agents'
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ export interface Material {
|
|||||||
sources: string[]
|
sources: string[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
rule: Rule
|
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 }
|
video?: { url: string | null; progress: number; credits: number | null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +95,9 @@ async function materialsOf(schedule: Schedule): Promise<Material[]> {
|
|||||||
request<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`),
|
request<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`),
|
||||||
productionStatus(agent.id).catch(() => null),
|
productionStatus(agent.id).catch(() => null),
|
||||||
])
|
])
|
||||||
const items = new Map<number, PlanItem>()
|
const items = new Map<number, PlanItem & { planId: number }>()
|
||||||
for (const plan of status?.plans ?? []) {
|
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).
|
// 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[]>()
|
const byRun = new Map<number, RawMaterial[]>()
|
||||||
@@ -104,11 +106,13 @@ async function materialsOf(schedule: Schedule): Promise<Material[]> {
|
|||||||
// The lesson comes first: it carries the title, sources and Compose set of the whole module.
|
// 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'))
|
group.sort((a, b) => Number(b.output_kind === 'summary') - Number(a.output_kind === 'summary'))
|
||||||
const [d] = group
|
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 }))
|
const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status }))
|
||||||
return {
|
return {
|
||||||
id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind,
|
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,
|
status: withPlanItem(combined(parts), item?.status), revision: d.revision, parts,
|
||||||
planTitle: items.get(d.run_id)?.title ?? null,
|
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,
|
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<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
|
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.
|
// 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') {
|
export async function decideMaterial(m: Material, decision: 'accept' | 'reject') {
|
||||||
for (const p of m.parts.filter(p => p.status === 'ready')) {
|
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 } })
|
if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } })
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export interface PlanItem {
|
|||||||
id: number
|
id: number
|
||||||
status: 'proposed' | 'approved' | 'queued' | 'generating' | 'ready' | 'blocked' | 'failed' | 'skipped' | 'cancelled' | 'rejected'
|
status: 'proposed' | 'approved' | 'queued' | 'generating' | 'ready' | 'blocked' | 'failed' | 'skipped' | 'cancelled' | 'rejected'
|
||||||
run_id: number | null
|
run_id: number | null
|
||||||
|
attempt_count: number
|
||||||
title: string
|
title: string
|
||||||
required_outputs: { kind: ContentKind }[]
|
required_outputs: { kind: ContentKind }[]
|
||||||
}
|
}
|
||||||
@@ -301,6 +302,20 @@ export const quoteProduction = (id: number, target: number) =>
|
|||||||
export const confirmProduction = (id: number, plan: ContentPlan) =>
|
export const confirmProduction = (id: number, plan: ContentPlan) =>
|
||||||
request<ContentPlan>(`${AGENTS}/${id}/content/plans/${plan.id}/confirm-planning`, { method: 'POST', json: { revision: plan.revision } })
|
request<ContentPlan>(`${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<ContentPlan>(`${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<ContentPlan['status']>(['planning', 'review', 'approved', 'running'])
|
export const PLAN_ACTIVE = new Set<ContentPlan['status']>(['planning', 'review', 'approved', 'running'])
|
||||||
|
|
||||||
export interface RunSummary {
|
export interface RunSummary {
|
||||||
|
|||||||
@@ -287,6 +287,9 @@ const en = {
|
|||||||
'res.creditsUsed': 'Credits used',
|
'res.creditsUsed': 'Credits used',
|
||||||
'res.accepted': 'Approved.',
|
'res.accepted': 'Approved.',
|
||||||
'res.rejected': 'Rejected.',
|
'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.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.fullScript': 'Show the full script',
|
||||||
'res.scriptTxt': 'Script (.txt)',
|
'res.scriptTxt': 'Script (.txt)',
|
||||||
@@ -621,6 +624,9 @@ const id: Record<Key, string> = {
|
|||||||
'res.creditsUsed': 'Kredit terpakai',
|
'res.creditsUsed': 'Kredit terpakai',
|
||||||
'res.accepted': 'Disetujui.',
|
'res.accepted': 'Disetujui.',
|
||||||
'res.rejected': 'Ditolak.',
|
'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.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.fullScript': 'Lihat naskah lengkap',
|
||||||
'res.scriptTxt': 'Naskah (.txt)',
|
'res.scriptTxt': 'Naskah (.txt)',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../
|
|||||||
import { toast } from '../components/Toast'
|
import { toast } from '../components/Toast'
|
||||||
import { describeRule, kindLabel, type ContentKind } from '../express/parse'
|
import { describeRule, kindLabel, type ContentKind } from '../express/parse'
|
||||||
import {
|
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,
|
titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus,
|
||||||
} from '../express/results'
|
} from '../express/results'
|
||||||
import { MASTERY, scormPackage } from '../express/scorm'
|
import { MASTERY, scormPackage } from '../express/scorm'
|
||||||
@@ -132,13 +132,34 @@ function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () =
|
|||||||
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
|
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
|
||||||
{working
|
{working
|
||||||
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
|
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
|
||||||
: <button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>}
|
: <div className="content-card-actions">
|
||||||
|
{m.retry && <RetryButton material={m} className="text-btn" />}
|
||||||
|
<button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<button className={className} disabled={retry.isPending} onClick={() => retry.mutate()}>
|
||||||
|
{retry.isPending ? t('res.retrying') : t('res.retry')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
|
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
|
||||||
const ref = useRef<HTMLDialogElement>(null)
|
const ref = useRef<HTMLDialogElement>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -190,6 +211,7 @@ function Preview({ material: m, onClose }: { material: Material; onClose: () =>
|
|||||||
<div className="modal-foot">
|
<div className="modal-foot">
|
||||||
{decide.error ? <small className="error">{decide.error.message}</small> : <small>{t('res.footNote')}</small>}
|
{decide.error ? <small className="error">{decide.error.message}</small> : <small>{t('res.footNote')}</small>}
|
||||||
{m.composeSetId && <a className="btn" href={`${STUDIO_URL}/compose/${m.composeSetId}`} target="_blank" rel="noreferrer">{t('res.openStudio')}</a>}
|
{m.composeSetId && <a className="btn" href={`${STUDIO_URL}/compose/${m.composeSetId}`} target="_blank" rel="noreferrer">{t('res.openStudio')}</a>}
|
||||||
|
{m.retry && <RetryButton material={m} className="btn primary" />}
|
||||||
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
|
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
|
||||||
{m.status === 'ready' && (
|
{m.status === 'ready' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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-cover>small{max-width:calc(100% - 24px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.content-title:disabled{cursor:default;color:#8b97a9}
|
.content-title:disabled{cursor:default;color:#8b97a9}
|
||||||
.content-card-foot>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.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 .status{margin-left:0}
|
||||||
.research-row h3 a{color:var(--ink)}
|
.research-row h3 a{color:var(--ink)}
|
||||||
.research-row h3 a:hover{color:var(--blue)}
|
.research-row h3 a:hover{color:var(--blue)}
|
||||||
|
|||||||
Reference in New Issue
Block a user