Show an approval at once, even when the next read is a remembered one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { EngineError } from '../api/client'
|
import { EngineError } from '../api/client'
|
||||||
import {
|
import {
|
||||||
correctIndex, forgetReads, isStuckVideo, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, videoStatus, withPlanItem, type Material,
|
correctIndex, decideMaterial, forgetReads, isStuckVideo, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, videoStatus, withDecisions, withPlanItem, type Material,
|
||||||
} from './results'
|
} from './results'
|
||||||
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
|
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
|
||||||
import type { Schedule } from './schedules'
|
import type { Schedule } from './schedules'
|
||||||
@@ -160,3 +160,23 @@ describe('videoStatus', () => {
|
|||||||
expect(videoStatus({ status: 'cancelled', latest_error: 'clip_outcome_unknown' })).toBe('failed')
|
expect(videoStatus({ status: 'cancelled', latest_error: 'clip_outcome_unknown' })).toBe('failed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('withDecisions', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
const scorm = (status: Material['status']) => ({
|
||||||
|
id: 7, agentId: 3, kind: 'scorm', status, revision: 1, createdAt: '2026-10-29T00:00:00Z', sources: [],
|
||||||
|
parts: [{ id: 7, revision: 1, kind: 'summary', status }, { id: 8, revision: 1, kind: 'quiz', status }],
|
||||||
|
}) as unknown as Material
|
||||||
|
|
||||||
|
it('shows a decision before any read says so, and drops it once one does', async () => {
|
||||||
|
vi.stubGlobal('fetch', async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||||
|
await decideMaterial(scorm('ready'), 'accept')
|
||||||
|
// A remembered read from before the decision still says ready.
|
||||||
|
const shown = withDecisions(scorm('ready'))
|
||||||
|
expect(shown.status).toBe('accepted')
|
||||||
|
expect(shown.parts.map(p => p.status)).toEqual(['accepted', 'accepted'])
|
||||||
|
// The engine's own answer wins from then on, even if the material is later edited back to ready.
|
||||||
|
expect(withDecisions(scorm('accepted')).status).toBe('accepted')
|
||||||
|
expect(withDecisions(scorm('ready')).status).toBe('ready')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export async function listResults(): Promise<{ materials: Material[]; sources: F
|
|||||||
const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, readMaterials)
|
const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, readMaterials)
|
||||||
const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, readSources)
|
const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, readSources)
|
||||||
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
|
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
|
||||||
return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) }
|
return { materials: keepSettled(materials).map(withDecisions).sort(newest), sources: keepSettled(sources).sort(newest) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftContent = (m: Material, id: number) =>
|
const draftContent = (m: Material, id: number) =>
|
||||||
@@ -297,11 +297,29 @@ export const cancelVideo = (m: Material) => request(`${AGENTS}/${m.agentId}/vide
|
|||||||
export const retryMaterial = ({ agentId, retry }: Material) =>
|
export const retryMaterial = ({ agentId, retry }: Material) =>
|
||||||
retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId)
|
retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId)
|
||||||
|
|
||||||
|
// A decision the engine took, kept until a read of its part says so too: the read right after it
|
||||||
|
// may be the last poll's (a busy engine) and still say ready, which left Approve looking undone.
|
||||||
|
const decided = new Map<string, MaterialStatus>()
|
||||||
|
const partKey = (m: Material, p: Part) => `${materialKey(m)}-${p.id}`
|
||||||
|
|
||||||
|
export function withDecisions(m: Material): Material {
|
||||||
|
let changed = false
|
||||||
|
const parts = m.parts.map(p => {
|
||||||
|
const status = decided.get(partKey(m, p))
|
||||||
|
if (!status) return p
|
||||||
|
if (p.status !== 'ready') { decided.delete(partKey(m, p)); return p }
|
||||||
|
changed = true
|
||||||
|
return { ...p, status }
|
||||||
|
})
|
||||||
|
return changed ? { ...m, parts, status: combined(parts) } : m
|
||||||
|
}
|
||||||
|
|
||||||
// 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 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 } })
|
||||||
else await request(`${AGENTS}/${m.agentId}/material-drafts/${p.id}/decision`, { method: 'POST', json: { expected_revision: p.revision, decision } })
|
else await request(`${AGENTS}/${m.agentId}/material-drafts/${p.id}/decision`, { method: 'POST', json: { expected_revision: p.revision, decision } })
|
||||||
|
decided.set(partKey(m, p), decision === 'accept' ? 'accepted' : 'rejected')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
canCancelVideo, cancelVideo, decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf,
|
canCancelVideo, cancelVideo, decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf, withDecisions,
|
||||||
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'
|
||||||
@@ -194,6 +194,9 @@ export function Preview({ material: m, onClose }: { material: Material; onClose:
|
|||||||
mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision),
|
mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision),
|
||||||
onSuccess: (_, decision) => {
|
onSuccess: (_, decision) => {
|
||||||
toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected'))
|
toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected'))
|
||||||
|
// Shown at once; reading every schedule again takes a while, longer on a busy engine.
|
||||||
|
queryClient.setQueryData<Awaited<ReturnType<typeof listResults>>>(['results'], old =>
|
||||||
|
old && { ...old, materials: old.materials.map(withDecisions) })
|
||||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user