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 <noreply@anthropic.com>
203 lines
8.4 KiB
TypeScript
203 lines
8.4 KiB
TypeScript
// Typed engine calls used by Express Mode. Ported from prototype/engine.js.
|
|
import { EngineError, opKey, request, sleep } from './client'
|
|
import { t } from '../i18n'
|
|
|
|
// ── Documents (Brain) ──
|
|
|
|
export type KnowledgeStatus = 'not_built' | 'awaiting_confirmation' | 'needs_credits' | 'building' | 'ready' | 'failed' | string
|
|
|
|
export interface EngineDocument {
|
|
id: number
|
|
filename: string
|
|
original_filename?: string | null
|
|
file_type?: string | null
|
|
status: string
|
|
knowledge_status?: KnowledgeStatus | null
|
|
progress_percentage?: number | null
|
|
processing_stage?: string | null
|
|
error?: string | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface Source {
|
|
docId: number
|
|
title: string
|
|
originalName: string
|
|
format: string
|
|
createdAt: string
|
|
status: string
|
|
knowledge: KnowledgeStatus | null
|
|
progress: number | null
|
|
stage: string | null
|
|
}
|
|
|
|
function asSource(d: EngineDocument): Source {
|
|
const name = d.original_filename || d.filename
|
|
return {
|
|
docId: d.id,
|
|
title: name.replace(/\.[^.]+$/, ''),
|
|
originalName: name,
|
|
format: (d.file_type || name.split('.').pop() || '').toUpperCase(),
|
|
createdAt: d.created_at,
|
|
status: d.status,
|
|
knowledge: d.knowledge_status ?? null,
|
|
progress: d.progress_percentage ?? null,
|
|
stage: d.processing_stage ?? null,
|
|
}
|
|
}
|
|
|
|
export const listDocuments = async () => (await request<EngineDocument[]>('/upload/list')).map(asSource)
|
|
export const documentStatus = (id: number) => request<EngineDocument>(`/upload/${id}`)
|
|
export const deleteDocument = (id: number) => request<null>(`/upload/${id}`, { method: 'DELETE' })
|
|
export const documentFile = (id: number) => request<Blob>(`/upload/${id}/file`, { as: 'blob' })
|
|
|
|
export async function supportedExtensions(): Promise<Set<string>> {
|
|
const f = await request<{ extensions: string[] }>('/upload/supported-formats')
|
|
return new Set(f.extensions)
|
|
}
|
|
|
|
export async function uploadDocument(file: File): Promise<number> {
|
|
const form = new FormData()
|
|
form.append('file', file, file.name)
|
|
const body = await request<{ id?: number; document_id?: number }>('/upload/', { method: 'POST', body: form })
|
|
return (body.id ?? body.document_id)!
|
|
}
|
|
|
|
// After processing, the engine builds the knowledge base itself when the price is small.
|
|
// Otherwise it stops in one of these states and waits for the user.
|
|
export const NEEDS_ANSWER = new Set(['not_built', 'awaiting_confirmation', 'needs_credits', 'awaiting_version_answer', 'failed'])
|
|
|
|
export interface KnowledgeDetail {
|
|
status: KnowledgeStatus
|
|
document_status: string
|
|
stage: string | null
|
|
quote: { credits: number; activity_key: string } | null
|
|
candidate: { id: number; filename: string; created_at: string | null } | null
|
|
error: string | null
|
|
}
|
|
|
|
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/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.
|
|
export const answerVersion = (id: number, isNewVersion: boolean) =>
|
|
request<{ status: string }>(`/upload/${id}/versions/${isNewVersion ? 'link' : 'decline'}`, { method: 'POST', json: isNewVersion ? {} : undefined })
|
|
|
|
// Polls until the document's knowledge base is built (what generation needs).
|
|
export async function waitReady(id: number, onProgress?: (d: EngineDocument) => void, timeoutMs = 10 * 60 * 1000) {
|
|
const t0 = Date.now()
|
|
for (;;) {
|
|
const s = await documentStatus(id)
|
|
onProgress?.(s)
|
|
if (s.error || s.status === 'failed') throw new EngineError(s.error || t('err.docFailed'), 422)
|
|
if (s.knowledge_status === 'ready') return s
|
|
if (Date.now() - t0 > timeoutMs) throw new EngineError(t('err.timeout'), 408)
|
|
await sleep(2500)
|
|
}
|
|
}
|
|
|
|
// ── Workspace wallet ──
|
|
|
|
// The organization's credit balance: Express spends workspace credits, never personal ones.
|
|
export const workspaceCredits = async () =>
|
|
(await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null
|
|
|
|
// ── Compose (text content) ──
|
|
|
|
export interface ComposeSet { id: number; name: string }
|
|
|
|
export async function generateText(docIds: number[], name: string) {
|
|
const set = await request<ComposeSet>('/compose/sets', { method: 'POST', json: { name, mode: 'knowledge_documents', source_document_ids: docIds } })
|
|
const summary = await request<unknown>(`/compose/sets/${set.id}/generate/summary`, { method: 'POST', json: { operation_key: opKey(`s-${set.id}`) } })
|
|
return { setId: set.id, summary }
|
|
}
|
|
|
|
export const generateQuiz = (setId: number, n = 5) =>
|
|
request<unknown>(`/compose/sets/${setId}/generate/quiz`, { method: 'POST', json: { num_questions: n, question_type: 'mixed', operation_key: opKey(`q-${setId}`) } })
|
|
|
|
// ── Agents (organization-scoped): research runs and recurring schedule ──
|
|
|
|
const AGENTS = '/organizations/current/agents'
|
|
export const AGENT_NAME = 'Learning Lab Express'
|
|
|
|
export interface RunSchedule { cadence: 'off' | 'daily' | 'weekly'; hour?: number; minute?: number; weekday?: number }
|
|
|
|
export interface Agent {
|
|
id: number
|
|
name: string
|
|
goal: string
|
|
status: string
|
|
instructions?: string | null
|
|
run_schedule?: RunSchedule | null
|
|
}
|
|
|
|
export interface AgentRun { id: number; status: string; safe_error_code?: string | null }
|
|
export interface SourceDraft { id: number; run_id: number; [key: string]: unknown }
|
|
|
|
let agentCache: Agent | null = null
|
|
export const resetAgentCache = () => { agentCache = null }
|
|
|
|
export async function ensureAgent(): Promise<Agent> {
|
|
if (agentCache) return agentCache
|
|
let agent = (await request<Agent[]>(AGENTS)).find(a => a.name === AGENT_NAME && a.status !== 'archived')
|
|
agent ??= await request<Agent>(AGENTS, {
|
|
method: 'POST',
|
|
json: {
|
|
name: AGENT_NAME,
|
|
goal: 'Riset topik pembelajaran dan temukan sumber publik baru untuk Brain tim LND.',
|
|
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url'],
|
|
research_options: { max_results: 5 },
|
|
timezone: 'Asia/Jakarta',
|
|
run_budget: 50,
|
|
monthly_budget: 1000,
|
|
},
|
|
})
|
|
if (agent.status !== 'active') agent = await request<Agent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
|
|
return (agentCache = agent)
|
|
}
|
|
|
|
export async function attachSources(agentId: number, docIds: number[]) {
|
|
for (const id of docIds) {
|
|
try {
|
|
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } })
|
|
} catch (e) {
|
|
if (!(e instanceof EngineError && e.status === 404)) throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
// Server-side recurring run. weekday follows the engine: 0 = Monday.
|
|
export async function setSchedule(schedule: RunSchedule) {
|
|
const agent = await ensureAgent()
|
|
agentCache = await request<Agent>(`${AGENTS}/${agent.id}`, { method: 'PATCH', json: { run_schedule: schedule } })
|
|
return agentCache
|
|
}
|
|
|
|
export async function research(
|
|
{ topic, prompt, urls = [], docIds = [] }: { topic?: string; prompt?: string; urls?: string[]; docIds?: number[] },
|
|
onStatus?: (run: AgentRun) => void,
|
|
) {
|
|
const agent = await ensureAgent()
|
|
await attachSources(agent.id, docIds)
|
|
const focus = [topic && `Topik: ${topic}`, prompt, urls.length && `Referensi: ${urls.join(' ')}`].filter(Boolean).join('\n')
|
|
await request(`${AGENTS}/${agent.id}`, {
|
|
method: 'PATCH',
|
|
json: { goal: (topic ? `Riset topik: ${topic}` : agent.goal).slice(0, 4000), instructions: focus || null },
|
|
})
|
|
const run = await request<AgentRun>(`${AGENTS}/${agent.id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`run-${agent.id}`) }, json: {} })
|
|
let detail = run
|
|
for (;;) {
|
|
onStatus?.(detail)
|
|
if (['completed', 'failed', 'cancelled'].includes(detail.status)) break
|
|
await sleep(3000)
|
|
detail = await request<AgentRun>(`${AGENTS}/${agent.id}/runs/${run.id}`)
|
|
}
|
|
if (detail.status !== 'completed') throw new EngineError(t('err.researchFailed', { code: detail.safe_error_code || detail.status }), 422)
|
|
const drafts = (await request<SourceDraft[]>(`${AGENTS}/${agent.id}/source-drafts`)).filter(d => d.run_id === run.id)
|
|
return { run: detail, drafts }
|
|
}
|