diff --git a/src/express/schedules.test.ts b/src/express/schedules.test.ts index bdf30fe..033bea8 100644 --- a/src/express/schedules.test.ts +++ b/src/express/schedules.test.ts @@ -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') + }) +}) diff --git a/src/express/schedules.ts b/src/express/schedules.ts index ff93446..a313cfe 100644 --- a/src/express/schedules.ts +++ b/src/express/schedules.ts @@ -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(`${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 diff --git a/src/i18n.ts b/src/i18n.ts index c6d6b54..1576ee0 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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 = { '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 = { '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 = { '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 diff --git a/src/pages/Brain.tsx b/src/pages/Brain.tsx index 960f89b..b53daa8 100644 --- a/src/pages/Brain.tsx +++ b/src/pages/Brain.tsx @@ -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' diff --git a/src/pages/Instruction.tsx b/src/pages/Instruction.tsx index 733c198..71d9872 100644 --- a/src/pages/Instruction.tsx +++ b/src/pages/Instruction.tsx @@ -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() @@ -201,8 +204,9 @@ export function InstructionPage() { {chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')} {t('ins.approveNote')} + {tooFewSources && {t(chosen.length ? 'ins.tooFewChosen' : 'ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}} - diff --git a/src/pages/Schedule.tsx b/src/pages/Schedule.tsx index 9b411cc..f346104 100644 --- a/src/pages/Schedule.tsx +++ b/src/pages/Schedule.tsx @@ -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 setCount(Number(e.target.value) || 1)} /> )} - {error && {error.message}} + {error && {productionError(error.message)}}
{!running && remaining > 0 && (quote.data ?