show-run-errors-and-reprocess #2

Merged
iqbal merged 4 commits from show-run-errors-and-reprocess into main 2026-10-28 09:40:06 +00:00
6 changed files with 51 additions and 18 deletions
Showing only changes of commit a73a5a3fdf - Show all commits

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse' import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules' import { budgetFor, canSchedule, configFor, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules'
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule => const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule =>
({ type: 'content', kind, count, per, topic, language }) ({ type: 'content', kind, count, per, topic, language })
@@ -108,3 +108,17 @@ describe('canSchedule', () => {
expect(canSchedule(rule('summary', 0))).toBe(false) expect(canSchedule(rule('summary', 0))).toBe(false)
}) })
}) })
describe('production sources', () => {
it('needs two sources for planned content, not for video or research', () => {
expect(needsTwoSources(rule('quiz'))).toBe(true)
expect(needsTwoSources(rule('video', 1))).toBe(false)
expect(needsTwoSources({ type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: false })).toBe(false)
})
it("explains the engine's refusal for too few sources and passes others through", () => {
expect(productionError('At least two ready sources with complementary evidence are required')).toMatch(/2/)
expect(productionError('At least two ready sources with complementary evidence are required')).not.toMatch(/complementary/)
expect(productionError('Something else')).toBe('Something else')
})
})

View File

@@ -3,7 +3,7 @@
// use run_schedule (one run per day or week; a video run renders one video). // use run_schedule (one run per day or week; a video run renders one video).
import { opKey, request } from '../api/client' import { opKey, request } from '../api/client'
import { listDocuments, type Agent, type RunSchedule } from '../api/engine' import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
import { getLang } from '../i18n' import { getLang, t } from '../i18n'
import { import {
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget, KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule, type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
@@ -110,6 +110,15 @@ export const listSchedules = () => expressAgents()
/** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */ /** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */
export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video' export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video'
// The engine plans each production piece from a pair of documents, so a content schedule
// with fewer ready sources than this cannot plan anything. Video and research runs need one.
export const MIN_PRODUCTION_SOURCES = 2
export const needsTwoSources = (rule: Rule) => !isRunSchedule(rule)
const TOO_FEW_SOURCES = 'At least two ready sources with complementary evidence are required'
/** The engine's planner refusal, said in the UI language; any other message as it came. */
export const productionError = (message: string) =>
(message.includes(TOO_FEW_SOURCES) ? t('sch.tooFewSources', { n: MIN_PRODUCTION_SOURCES }) : message)
// Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene). // Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene).
export const VIDEO = { scenes: 4, seconds: 40 } export const VIDEO = { scenes: 4, seconds: 40 }
@@ -362,8 +371,8 @@ export interface RunSummary {
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`) export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
// What stops a run with knowledge_not_ready. The engine checks the whole Library, so a blocking // What stops a run with knowledge_not_ready: the schedule's own material that is not ready (the whole
// document need not be part of the schedule's chosen material. This is the current state, not the run's. // Library only for an agent with no material). This is the current state, not the run's.
export interface ReadinessIssue { export interface ReadinessIssue {
kind: 'document' | 'compose_set' kind: 'document' | 'compose_set'
resource_id: number resource_id: number

View File

@@ -126,9 +126,9 @@ const en = {
'brain.needsCreditsNeed': ' (needs {credits})', 'brain.needsCreditsNeed': ' (needs {credits})',
'brain.failed': 'Could not add this to the Brain.', 'brain.failed': 'Could not add this to the Brain.',
'brain.retryWith': 'Try again ({credits})', 'brain.retryWith': 'Try again ({credits})',
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is fixed. Process it again: its knowledge is kept.', 'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use it stop until it is fixed. Process it again: its knowledge is kept.',
'brain.failedProcessing': 'This file could not be processed. Schedules that use the whole Brain stop until it is fixed. Process it again, or delete it.', 'brain.failedProcessing': 'This file could not be processed. Schedules that use it stop until it is fixed. Process it again, or delete it.',
'brain.failedVideoAfterBuild': 'Processing this video failed after its knowledge was built. Schedules that use the whole Brain stop until it is fixed: upload it again.', 'brain.failedVideoAfterBuild': 'Processing this video failed after its knowledge was built. Schedules that use it stop until it is fixed: upload it again.',
'brain.reprocess': 'Process again', 'brain.reprocess': 'Process again',
'brain.reprocessing': 'Starting…', 'brain.reprocessing': 'Starting…',
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.', 'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
@@ -249,7 +249,7 @@ const en = {
'sch.manual': 'Manual', 'sch.manual': 'Manual',
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled', 'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
'run.paused_configuration': 'Needs attention', 'run.paused_budget': 'Paused: credits', 'run.paused_configuration': 'Needs attention', 'run.paused_budget': 'Paused: credits',
'runErr.knowledge_not_ready': 'Not all material in the Library is ready, so the engine will not start.', 'runErr.knowledge_not_ready': 'Some material this schedule uses is not ready, so the engine will not start.',
'runErr.video_sources_unavailable': 'No ready material matched this schedule for a video.', 'runErr.video_sources_unavailable': 'No ready material matched this schedule for a video.',
'runErr.material_sources_unavailable': 'No ready material matched this schedule.', 'runErr.material_sources_unavailable': 'No ready material matched this schedule.',
'runErr.knowledge_search_failed': 'Searching the material failed. Try again.', 'runErr.knowledge_search_failed': 'Searching the material failed. Try again.',
@@ -469,6 +469,9 @@ const en = {
'brainPanel.notReady': 'Can be chosen once it is in the Brain', 'brainPanel.notReady': 'Can be chosen once it is in the Brain',
'ins.sourcesChosen': 'Source: chosen material ({n})', 'ins.sourcesChosen': 'Source: chosen material ({n})',
'ins.sourcesAll': 'Source: the whole Brain', 'ins.sourcesAll': 'Source: the whole Brain',
'ins.tooFewChosen': ' Content schedules (summary, quiz and the like) need at least {n} materials. Choose one more in the Brain panel.',
'ins.tooFewInBrain': ' Content schedules (summary, quiz and the like) need at least {n} ready materials in the Brain. Upload another one first.',
'sch.tooFewSources': 'This schedule has fewer than {n} ready materials, and content is planned from pairs of materials. Delete it and make it again with {n} or more.',
'brainPanel.upload': 'Upload material', 'brainPanel.upload': 'Upload material',
// Guide // Guide
@@ -615,9 +618,9 @@ const id: Record<Key, string> = {
'brain.needsCreditsNeed': ' (butuh {credits})', 'brain.needsCreditsNeed': ' (butuh {credits})',
'brain.failed': 'Gagal memasukkan ke Otak.', 'brain.failed': 'Gagal memasukkan ke Otak.',
'brain.retryWith': 'Coba lagi ({credits})', 'brain.retryWith': 'Coba lagi ({credits})',
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki. Proses ulang: pengetahuannya tetap disimpan.', 'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang: pengetahuannya tetap disimpan.',
'brain.failedProcessing': 'File ini gagal diproses. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki. Proses ulang, atau hapus.', 'brain.failedProcessing': 'File ini gagal diproses. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang, atau hapus.',
'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki: unggah ulang.', 'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki: unggah ulang.',
'brain.reprocess': 'Proses ulang', 'brain.reprocess': 'Proses ulang',
'brain.reprocessing': 'Memulai…', 'brain.reprocessing': 'Memulai…',
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.', 'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
@@ -735,7 +738,7 @@ const id: Record<Key, string> = {
'sch.manual': 'Manual', 'sch.manual': 'Manual',
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan', 'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
'run.paused_configuration': 'Perlu tindakan', 'run.paused_budget': 'Tertahan: kredit', 'run.paused_configuration': 'Perlu tindakan', 'run.paused_budget': 'Tertahan: kredit',
'runErr.knowledge_not_ready': 'Belum semua materi di Library siap, jadi engine tidak mau mulai.', 'runErr.knowledge_not_ready': 'Sebagian materi yang dipakai jadwal ini belum siap, jadi engine tidak mau mulai.',
'runErr.video_sources_unavailable': 'Tidak ada materi siap yang cocok untuk video jadwal ini.', 'runErr.video_sources_unavailable': 'Tidak ada materi siap yang cocok untuk video jadwal ini.',
'runErr.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.', 'runErr.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.',
'runErr.knowledge_search_failed': 'Pencarian materi gagal. Coba lagi.', 'runErr.knowledge_search_failed': 'Pencarian materi gagal. Coba lagi.',
@@ -954,6 +957,9 @@ const id: Record<Key, string> = {
'brainPanel.notReady': 'Bisa dipilih setelah masuk Brain', 'brainPanel.notReady': 'Bisa dipilih setelah masuk Brain',
'ins.sourcesChosen': 'Sumber: {n} materi dipilih', 'ins.sourcesChosen': 'Sumber: {n} materi dipilih',
'ins.sourcesAll': 'Sumber: seluruh Brain', 'ins.sourcesAll': 'Sumber: seluruh Brain',
'ins.tooFewChosen': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi. Pilih satu lagi di panel Brain.',
'ins.tooFewInBrain': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi siap di Brain. Unggah satu lagi dulu.',
'sch.tooFewSources': 'Jadwal ini punya kurang dari {n} materi siap, padahal konten direncanakan dari pasangan materi. Hapus lalu buat ulang dengan {n} materi atau lebih.',
'brainPanel.upload': 'Upload materi', 'brainPanel.upload': 'Upload materi',
// Panduan // Panduan

View File

@@ -13,7 +13,7 @@ import { locale, t } from '../i18n'
type Phase = 'ready' | 'working' | 'answer' | 'failed' type Phase = 'ready' | 'working' | 'answer' | 'failed'
function phaseOf(s: Source): Phase { function phaseOf(s: Source): Phase {
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base. // A failed document blocks every schedule that uses it, even with a built knowledge base.
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed' if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
// A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules. // A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules.
if (s.status !== 'completed') return 'working' if (s.status !== 'completed') return 'working'

View File

@@ -10,7 +10,7 @@ import {
} from '../express/parse' } from '../express/parse'
import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent' import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
import { inLang, t, useLang, type Key, type Lang } from '../i18n' import { inLang, t, useLang, type Key, type Lang } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules' import { budgetFor, canSchedule, createSchedule, MIN_PRODUCTION_SOURCES, needsTwoSources, VIDEO, videoQuote } from '../express/schedules'
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5'] const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
const THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3'] const THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3']
@@ -59,6 +59,9 @@ export function InstructionPage() {
const view: AgentState = { ...agent, sources } const view: AgentState = { ...agent, sources }
const question = nextQuestion(view) const question = nextQuestion(view)
const ready = agent.items.filter(i => canSchedule(i.rule)) const ready = agent.items.filter(i => canSchedule(i.rule))
// Without a choice the whole Brain is attached, so every ready document counts.
const sourceCount = chosen.length || choosable(docs.data).length
const tooFewSources = sourceCount < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
const hasContent = agent.items.some(i => i.rule.type === 'content') const hasContent = agent.items.some(i => i.rule.type === 'content')
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it. // The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
const live = inLang(lang, () => saving const live = inLang(lang, () => saving
@@ -108,7 +111,7 @@ export function InstructionPage() {
async function approve(state = agent, replyLang = lang) { async function approve(state = agent, replyLang = lang) {
const items = state.items.filter(i => canSchedule(i.rule)) const items = state.items.filter(i => canSchedule(i.rule))
if (!items.length) return if (!items.length || tooFewSources) return
setSaving(true) setSaving(true)
const done: Outcome[] = [] const done: Outcome[] = []
const created = new Set<number>() const created = new Set<number>()
@@ -201,8 +204,9 @@ export function InstructionPage() {
<small> <small>
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b> <b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
{t('ins.approveNote')} {t('ins.approveNote')}
{tooFewSources && <span className="error">{t(chosen.length ? 'ins.tooFewChosen' : 'ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
</small> </small>
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}> <button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })} <Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button> </button>
</div> </div>

View File

@@ -8,7 +8,7 @@ import { describeRule } from '../express/parse'
import { locale, t, type Key } from '../i18n' import { locale, t, type Key } from '../i18n'
import { import {
archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule, archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule, planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule,
} from '../express/schedules' } from '../express/schedules'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
@@ -201,7 +201,7 @@ function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedu
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} /> <input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
</label> </label>
)} )}
{error && <span className="error">{error.message}</span>} {error && <span className="error">{productionError(error.message)}</span>}
<div className="produce-actions"> <div className="produce-actions">
{!running && remaining > 0 && (quote.data {!running && remaining > 0 && (quote.data
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button> ? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>