Require two materials for content schedules and scope readiness copy

The engine plans each production piece from a pair of documents, so a
summary, quiz or similar schedule with one material can never plan. Approving
such a plan is now blocked with a note to choose or upload another material,
and the engine's refusal on an existing schedule is explained in plain
language. Video and research schedules still work with one material.

The engine now checks only a schedule's own material before a run, so the
run error and Upload texts no longer say the whole Library or Brain is
checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-28 16:37:56 +07:00
parent d1492b9c76
commit a73a5a3fdf
6 changed files with 51 additions and 18 deletions

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>