From d1492b9c76ed62ef4752827913e3c861f95287d1 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Wed, 28 Oct 2026 15:13:03 +0700 Subject: [PATCH] Explain blocked schedule runs and let a failed document be processed again Schedule history now shows a readable reason instead of the engine's raw code, and under the newest run stopped by knowledge_not_ready it lists the Library material that blocks it, with a link to fix it in Learning Studio. The list refreshes while that material is still processing or building. Upload gets a "Process again" button for a failed document, using the engine's new reprocess endpoint; a knowledge base the document already has is kept. A document being processed again no longer shows as "In the Brain". Co-Authored-By: Claude Opus 5.5 --- src/api/engine.ts | 2 ++ src/express/schedules.ts | 14 +++++++++ src/i18n.ts | 66 ++++++++++++++++++++++++++++++++++++++-- src/pages/Brain.tsx | 32 +++++++++++++++---- src/pages/Schedule.tsx | 54 +++++++++++++++++++++++++++++--- src/styles/app.css | 4 +++ 6 files changed, 159 insertions(+), 13 deletions(-) diff --git a/src/api/engine.ts b/src/api/engine.ts index ded8112..d94386a 100644 --- a/src/api/engine.ts +++ b/src/api/engine.ts @@ -79,6 +79,8 @@ export interface KnowledgeDetail { export const knowledgeDetail = (id: number) => request(`/upload/${id}/knowledge`) // Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short). +// Reads a failed document's stored file again, for free. A knowledge base it already has is kept. +export const reprocessDocument = (id: number) => request<{ id: number; status: string }>(`/upload/${id}/reprocess`, { method: 'POST' }) export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' }) // The engine asks whether an upload is the next version of a similar document before building it. diff --git a/src/express/schedules.ts b/src/express/schedules.ts index 548495f..ff93446 100644 --- a/src/express/schedules.ts +++ b/src/express/schedules.ts @@ -355,8 +355,22 @@ export interface RunSummary { trigger_kind: string safe_error_code: string | null budget_spent: number | null + has_readiness_issues: boolean created_at: string finished_at: string | null } 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. +export interface ReadinessIssue { + kind: 'document' | 'compose_set' + resource_id: number + title: string + reason: string + action_url: string +} + +export const knowledgeReadiness = (id: number) => + request<{ ready: boolean; issues: ReadinessIssue[] }>(`${AGENTS}/${id}/knowledge-readiness`) diff --git a/src/i18n.ts b/src/i18n.ts index 1f1c5d2..c6d6b54 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -126,7 +126,11 @@ 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 removed: delete it and upload it again.', + '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.reprocess': 'Process again', + 'brain.reprocessing': 'Starting…', 'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.', 'brain.confirmCreditsFallback': 'credits', 'brain.addToBrain': 'Add to Brain', @@ -244,6 +248,33 @@ const en = { 'sch.scheduled': 'Scheduled', '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.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.', + 'runErr.organization_wallet': 'The organization does not have enough credits.', + 'runErr.run_cap': 'This run would exceed the per-run credit limit.', + 'runErr.monthly_cap': 'The monthly credit limit for this schedule is reached.', + 'runErr.reservation_cap': 'Too many credits are already reserved by other runs.', + 'runErr.price_changed': 'The price changed. Try again.', + 'runErr.video_price_changed': 'The video price changed. Try again.', + 'runErr.agent_not_active': 'The schedule is paused or deleted.', + 'runErr.run_access_revoked': 'You no longer have permission to run this schedule.', + 'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.', + 'sch.blockedBy': 'Blocking material:', + 'sch.blockedNone': 'All material is ready now. Try again.', + 'sch.fixInStudio': 'Open in Learning Studio', + 'issue.document_processing': 'still processing', + 'issue.document_failed': 'failed to process; reprocess or delete it', + 'issue.graph_building': 'knowledge graph still building', + 'issue.graph_not_ready': 'knowledge graph not prepared yet', + 'issue.legacy_set_needs_conversion': 'old Compose set; convert it', + 'issue.no_knowledge_sources': 'Compose set has no sources', + 'issue.source_not_accessible': 'Compose set uses a document you cannot access', + 'issue.source_graph_not_ready': 'a source document is not ready', + 'issue.source_version_changed': 'a source document changed; refresh the set', + 'issue.snapshot_not_ready': 'Compose graph not prepared yet', // Results 'res.title': 'Results', @@ -584,7 +615,11 @@ 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 file ini dihapus: hapus lalu unggah ulang.', + '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.reprocess': 'Proses ulang', + 'brain.reprocessing': 'Memulai…', 'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.', 'brain.confirmCreditsFallback': 'kredit', 'brain.addToBrain': 'Masukkan ke Otak', @@ -699,6 +734,33 @@ const id: Record = { 'sch.scheduled': 'Terjadwal', '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.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.', + 'runErr.organization_wallet': 'Kredit organisasi tidak cukup.', + 'runErr.run_cap': 'Run ini melebihi batas kredit per run.', + 'runErr.monthly_cap': 'Batas kredit bulanan jadwal ini sudah tercapai.', + 'runErr.reservation_cap': 'Terlalu banyak kredit sudah dipesan oleh run lain.', + 'runErr.price_changed': 'Harga berubah. Coba lagi.', + 'runErr.video_price_changed': 'Harga video berubah. Coba lagi.', + 'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.', + 'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.', + 'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.', + 'sch.blockedBy': 'Materi yang menahan:', + 'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.', + 'sch.fixInStudio': 'Buka di Learning Studio', + 'issue.document_processing': 'masih diproses', + 'issue.document_failed': 'gagal diproses; proses ulang atau hapus', + 'issue.graph_building': 'knowledge graph masih dibangun', + 'issue.graph_not_ready': 'knowledge graph belum disiapkan', + 'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi', + 'issue.no_knowledge_sources': 'set Compose tidak punya sumber', + 'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses', + 'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap', + 'issue.source_version_changed': 'dokumen sumber berubah; perbarui set-nya', + 'issue.snapshot_not_ready': 'graph Compose belum disiapkan', 'res.title': 'Hasil', 'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.', diff --git a/src/pages/Brain.tsx b/src/pages/Brain.tsx index 2c82cfc..960f89b 100644 --- a/src/pages/Brain.tsx +++ b/src/pages/Brain.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { - answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument, + answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, reprocessDocument, supportedExtensions, uploadDocument, type Source, } from '../api/engine' import { Icon } from '../components/Icon' @@ -15,8 +15,10 @@ 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. 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' if (s.knowledge === 'ready') return 'ready' - if (s.knowledge === 'building' || s.status !== 'completed') return 'working' + if (s.knowledge === 'building') return 'working' return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working' } @@ -298,8 +300,26 @@ function KnowledgeAction({ doc }: { doc: Source }) { const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] }) const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh }) const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh }) - const error = build.error ?? version.error - const busy = build.isPending || version.isPending + const reprocess = useMutation({ mutationFn: () => reprocessDocument(doc.docId), onSuccess: refresh }) + const error = build.error ?? version.error ?? reprocess.error + const busy = build.isPending || version.isPending || reprocess.isPending + + // A failed file is read again, free; a knowledge base it already has is kept, so Compose sets + // that pin it (and block deleting it) keep working. Video goes back through upload instead. + if (doc.status === 'failed' && (doc.knowledge === 'ready' || doc.knowledge === 'not_built' || doc.knowledge === null) + && doc.format.toLowerCase() !== 'mp4') { + return ( +
+ {t(doc.knowledge === 'ready' ? 'brain.failedAfterBuild' : 'brain.failedProcessing')} +
+ +
+ {error && {error.message}} +
+ ) + } const d = detail.data if (detail.isPending) return
@@ -323,8 +343,8 @@ function KnowledgeAction({ doc }: { doc: Source }) { actions = break case 'ready': - // Processing failed after the build: building again is refused, only removing it unblocks schedules. - text = t('brain.failedAfterBuild') + // Processing failed after the build. Documents are reprocessed above; this is a video. + text = t('brain.failedVideoAfterBuild') break case 'failed': text = d.error || t('brain.failed') diff --git a/src/pages/Schedule.tsx b/src/pages/Schedule.tsx index bdff82d..9b411cc 100644 --- a/src/pages/Schedule.tsx +++ b/src/pages/Schedule.tsx @@ -7,16 +7,31 @@ import { toast } from '../components/Toast' import { describeRule } from '../express/parse' import { locale, t, type Key } from '../i18n' import { - archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule, - planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule, + archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule, + planProgress, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule, } from '../express/schedules' +const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL + const fmt = (iso: string | null | undefined) => (iso ? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) : '–') -const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled']) +const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled', 'paused_configuration', 'paused_budget']) const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s) +const RUN_ERRORS = new Set([ + 'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed', + 'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed', + 'agent_not_active', 'run_access_revoked', 'required_tool_unavailable', +]) +const ISSUE_REASONS = new Set([ + 'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion', + 'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready', +]) +const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready']) +const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code) +const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason) +const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues export function SchedulePage() { const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 }) @@ -206,17 +221,46 @@ function RunHistory({ agentId }: { agentId: number }) { if (runs.isPending) return
if (runs.isError) return
{runs.error.message}
if (!runs.data.length) return
{t('sch.neverRan')}
+ const shown = runs.data.slice(0, 8) + // Readiness is today's state, so it is shown once, under the newest run it stopped. + const blockedId = shown.find(isNotReady)?.id return (
    - {runs.data.slice(0, 8).map(r => ( + {shown.map(r => (
  • {fmt(r.created_at)} {r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')} {runStatus(r.status)} {r.budget_spent != null && {t('common.credits', { n: r.budget_spent })}} - {r.safe_error_code && {r.safe_error_code}} + {r.safe_error_code && {runError(r.safe_error_code)}} + {r.id === blockedId && }
  • ))}
) } + +function ReadinessIssues({ agentId }: { agentId: number }) { + const readiness = useQuery({ + queryKey: ['readiness', agentId], + queryFn: () => knowledgeReadiness(agentId), + // Processing and building finish on their own; keep the list current until they do. + refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false), + }) + if (readiness.isPending) return
+ if (readiness.isError) return
{readiness.error.message}
+ if (!readiness.data.issues.length) return
{t('sch.blockedNone')}
+ return ( +
+ {t('sch.blockedBy')} +
    + {readiness.data.issues.map(issue => ( +
  • + {issue.title} · {issueReason(issue.reason)} + {t('sch.fixInStudio')} +
  • + ))} +
+
+ ) +} diff --git a/src/styles/app.css b/src/styles/app.css index 8a4d2d7..ddcd042 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -49,6 +49,10 @@ textarea{resize:vertical;line-height:1.6} .run-history li{display:flex;gap:12px;align-items:center;flex-wrap:wrap} .run-history .status{margin-left:0} .run-history .error{margin:0;font-size:11px} +.run-issues{flex-basis:100%;padding:8px 10px;border-radius:8px;background:#fff7ed;color:#7a4b1f;display:grid;gap:4px} +.run-issues ul{list-style:none;margin:0;padding:0;display:grid;gap:4px} +.run-issues li{display:flex;gap:10px;flex-wrap:wrap;align-items:center} +.run-issues a{color:var(--blue);font-weight:600} @media(max-width:700px){.schedule-actions,.run-history{padding-left:0;margin-left:0}.rule-approve{flex-direction:column;align-items:stretch}} .lang-switch{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:white} .lang-switch button{border:0;background:transparent;padding:5px 9px;font-size:10px;font-weight:650;color:#8b97a9}