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 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 =>
({ type: 'content', kind, count, per, topic, language })
@@ -108,3 +108,17 @@ describe('canSchedule', () => {
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).
import { opKey, request } from '../api/client'
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
import { getLang } from '../i18n'
import { getLang, t } from '../i18n'
import {
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
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. */
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).
export const VIDEO = { scenes: 4, seconds: 40 }
@@ -362,8 +371,8 @@ export interface RunSummary {
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
// document need not be part of the schedule's chosen material. This is the current state, not the run's.
// What stops a run with knowledge_not_ready: the schedule's own material that is not ready (the whole
// Library only for an agent with no material). This is the current state, not the run's.
export interface ReadinessIssue {
kind: 'document' | 'compose_set'
resource_id: number

View File

@@ -126,9 +126,9 @@ const en = {
'brain.needsCreditsNeed': ' (needs {credits})',
'brain.failed': 'Could not add this to the Brain.',
'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.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.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.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 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 it stop until it is fixed: upload it again.',
'brain.reprocess': 'Process again',
'brain.reprocessing': 'Starting…',
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
@@ -249,7 +249,7 @@ const en = {
'sch.manual': 'Manual',
'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',
'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.material_sources_unavailable': 'No ready material matched this schedule.',
'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',
'ins.sourcesChosen': 'Source: chosen material ({n})',
'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',
// Guide
@@ -615,9 +618,9 @@ const id: Record<Key, string> = {
'brain.needsCreditsNeed': ' (butuh {credits})',
'brain.failed': 'Gagal memasukkan ke Otak.',
'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.failedProcessing': 'File ini gagal diproses. Jadwal yang memakai seluruh Otak 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.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 memakainya berhenti sampai ini diperbaiki. Proses ulang, atau hapus.',
'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki: unggah ulang.',
'brain.reprocess': 'Proses ulang',
'brain.reprocessing': 'Memulai…',
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
@@ -735,7 +738,7 @@ const id: Record<Key, string> = {
'sch.manual': 'Manual',
'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',
'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.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.',
'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',
'ins.sourcesChosen': 'Sumber: {n} materi dipilih',
'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',
// Panduan

View File

@@ -13,7 +13,7 @@ import { locale, t } from '../i18n'
type Phase = 'ready' | 'working' | 'answer' | 'failed'
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'
// A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules.
if (s.status !== 'completed') return 'working'

View File

@@ -10,7 +10,7 @@ import {
} from '../express/parse'
import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
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 THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3']
@@ -59,6 +59,9 @@ export function InstructionPage() {
const view: AgentState = { ...agent, sources }
const question = nextQuestion(view)
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')
// 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
@@ -108,7 +111,7 @@ export function InstructionPage() {
async function approve(state = agent, replyLang = lang) {
const items = state.items.filter(i => canSchedule(i.rule))
if (!items.length) return
if (!items.length || tooFewSources) return
setSaving(true)
const done: Outcome[] = []
const created = new Set<number>()
@@ -201,8 +204,9 @@ export function InstructionPage() {
<small>
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
{t('ins.approveNote')}
{tooFewSources && <span className="error">{t(chosen.length ? 'ins.tooFewChosen' : 'ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
</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 })}
</button>
</div>

View File

@@ -8,7 +8,7 @@ import { describeRule } from '../express/parse'
import { locale, t, type Key } from '../i18n'
import {
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'
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)} />
</label>
)}
{error && <span className="error">{error.message}</span>}
{error && <span className="error">{productionError(error.message)}</span>}
<div className="produce-actions">
{!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>