import { useState, useRef, useCallback, useEffect } from "react"; import { api } from "../api/client"; // ───────────────────────────────────────────────────────────────────────────── // Narrative translator: maps raw action → human-readable cause→effect story // ───────────────────────────────────────────────────────────────────────────── function mapActionToStory(actionType, payload, reward, backlogDelta, slaDelta, fairnessDelta) { let title = "Standard Processing Cycle"; let desc = "The system advanced one cycle and continued normal queue processing."; let reason = "No override was required, so routine processing continued."; let icon = "schedule"; let type = reward > 0 ? "success" : "info"; const changes = []; if (backlogDelta < 0) changes.push(`backlog improved by ${Math.abs(backlogDelta)} case(s)`); else if (backlogDelta > 0) changes.push(`backlog increased by ${backlogDelta} case(s)`); else changes.push("backlog stayed stable"); if (slaDelta > 0) changes.push(`${slaDelta} new SLA breach(es) occurred`); else if (slaDelta < 0) changes.push(`${Math.abs(slaDelta)} SLA breach(es) recovered`); if (Number.isFinite(Number(fairnessDelta)) && Number(fairnessDelta) !== 0) { const v = Number(fairnessDelta); changes.push(`fairness gap ${v > 0 ? "worsened" : "improved"} by ${Math.abs(v).toFixed(3)}`); } const effectClause = `${changes.join(", ")}.`; if (slaDelta > 0) type = "error"; switch (actionType) { case "assign_capacity": title = "Capacity Assigned"; desc = `Officers were assigned to '${payload.service_target ?? payload.service ?? "target queue"}'; ${effectClause}`; reason = "The agent detected staffing pressure and increased capacity where it could reduce delay."; icon = "group_add"; break; case "reallocate_officers": title = "Staff Reallocated"; desc = `Officers were reallocated toward higher-pressure services; ${effectClause}`; reason = `The agent shifted staffing to reduce bottlenecks in '${payload.service_target ?? "priority"}' services.`; icon = "compare_arrows"; break; case "request_missing_documents": title = "Documents Requested"; desc = `Missing documents were requested to unblock pending files; ${effectClause}`; reason = "The agent prioritized document blockers to avoid queue stagnation."; icon = "rule_folder"; type = type !== "error" ? "success" : type; break; case "escalate_service": title = "Service Escalated"; desc = `At-risk services were escalated for faster handling; ${effectClause}`; reason = "Escalation was used to protect SLA-critical cases."; icon = "warning"; type = "warning"; break; case "set_priority_mode": title = "Priority Mode Updated"; desc = `Priority mode switched to '${payload.priority_mode ?? "balanced"}'; ${effectClause}`; reason = "The agent changed queue strategy to better match current workload pressure."; icon = "model_training"; break; default: desc = `Routine processing executed; ${effectClause}`; break; } if (reward < 0 && type === "info") type = "warning"; const isHighReward = reward >= 1.0; const isHugeImpact = backlogDelta <= -5; return { title, desc, reason, icon, type, isHighReward, isHugeImpact }; } // Determines the simulation phase label from step index and total function getPhase(step, maxSteps) { const pct = step / Math.max(maxSteps, 1); if (pct < 0.33) return "early"; if (pct < 0.67) return "middle"; return "late"; } // Detect if a step is a "key decision" turning point function isKeyDecision(s, backlogDelta) { return ( Math.abs(Number(s.reward)) >= 1.0 || // high reward magnitude (backlogDelta !== 0 && Math.abs(backlogDelta) >= 5) || // large backlog swing Boolean(s.invalid_action) // failed action = notable event ); } // ───────────────────────────────────────────────────────────────────────────── // Hook // ───────────────────────────────────────────────────────────────────────────── export function useStorySimulation({ defaultTask }) { const [taskId, setTaskId] = useState(defaultTask || "district_backlog_easy"); const [maxSteps, setMaxSteps] = useState(40); const [agentMode, setAgentMode] = useState("trained_rl"); const [policyName, setPolicyName] = useState("backlog_clearance"); const [modelPath, setModelPath] = useState(""); const [modelType, setModelType] = useState("maskable"); const [availablePolicies, setAvailablePolicies] = useState([]); const [availableModels, setAvailableModels] = useState([]); const [configError, setConfigError] = useState(""); const [running, setRunning] = useState(false); const [starting, setStarting] = useState(false); const [runId, setRunId] = useState(""); const [kpis, setKpis] = useState({ backlog: 0, backlogDelta: 0, slaBreaches: 0, slaDelta: 0, fairness: 0, fairnessDelta: 0, }); const [timeline, setTimeline] = useState([]); const [resources, setResources] = useState([]); // Progress tracking const [currentStep, setCurrentStep] = useState(0); // Before vs after journey stats const [journeyStats, setJourneyStats] = useState(null); // null = not yet done // Internal refs const lastState = useRef({ backlog: 0, sla: 0, fairness: 0 }); const initialSnapshot = useRef(null); // captured on first real step const stepCount = useRef(0); const maxStepsRef = useRef(40); useEffect(() => { let mounted = true; (async () => { try { const [policiesRes, modelsV1Res, modelsV2Res] = await Promise.allSettled([ api("/agents"), api("/rl_models"), api("/rl/models"), ]); if (!mounted) return; const policyRows = policiesRes.status === "fulfilled" && Array.isArray(policiesRes.value) ? policiesRes.value : []; setAvailablePolicies(policyRows); if (policyRows.length > 0 && !policyRows.includes(policyName)) { setPolicyName(policyRows[0]); } const modelRowsV1 = modelsV1Res.status === "fulfilled" && Array.isArray(modelsV1Res.value?.models) ? modelsV1Res.value.models : []; const modelRowsV2 = modelsV2Res.status === "fulfilled" && Array.isArray(modelsV2Res.value) ? modelsV2Res.value.map((row) => ({ label: row?.model_path ? String(row.model_path).split(/[\\/]/).pop() : "model", path: row?.model_path ? (String(row.model_path).toLowerCase().endsWith(".zip") ? row.model_path : `${row.model_path}.zip`) : "", exists: Boolean(row?.exists), model_type: "maskable", })) : []; const dedupe = new Map(); for (const m of [...modelRowsV1, ...modelRowsV2]) { const key = String(m?.path || "").replace(/\\/g, "/").toLowerCase(); if (!key || dedupe.has(key)) continue; dedupe.set(key, m); } const existingModels = Array.from(dedupe.values()).filter((m) => Boolean(m?.exists)); setAvailableModels(existingModels); const preferred = existingModels.find((m) => String(m.path || "").toLowerCase().includes("phase2_final")) || existingModels[0]; if (preferred?.path) { setModelPath(preferred.path); setModelType(preferred.model_type || "maskable"); setAgentMode((prev) => (prev === "baseline_policy" ? "trained_rl" : prev)); } } catch (err) { if (!mounted) return; setConfigError(err?.message || "Failed to load simulation options."); } })(); return () => { mounted = false; }; }, []); const startSimulation = async () => { setStarting(true); setConfigError(""); setJourneyStats(null); setCurrentStep(0); initialSnapshot.current = null; stepCount.current = 0; maxStepsRef.current = maxSteps; try { const payload = { task_id: taskId, agent_mode: agentMode, max_steps: maxSteps, policy_name: policyName, model_path: modelPath || null, model_type: modelType, }; const started = await api("/simulation/live/start", { method: "POST", body: JSON.stringify(payload), }); setRunId(started.run_id); setTimeline([{ id: "start", time: "Step 0", title: "Simulation Initialized", desc: `Scenario locked: ${taskId.replace(/_/g, " ")}. Agent mode '${agentMode}' engaged — agent begins resolving backlog.`, impact: 0, type: "info", icon: "rocket_launch", phase: "early", key: false, }]); setResources([]); lastState.current = { backlog: 0, sla: 0, fairness: 0 }; setRunning(true); } catch (err) { console.error("Start failed:", err); setTimeline([{ id: "error", time: "—", title: "Initialization Failed", desc: `Backend error: ${err.message || "Cannot start simulation."}`, impact: 0, type: "error", icon: "error", phase: "early", key: false, }]); setConfigError(err?.message || "Cannot start simulation."); } finally { setStarting(false); } }; const stopSimulation = async () => { if (!runId) return; try { await api(`/simulation/live/${runId}/stop`, { method: "POST" }); } catch (err) { console.error(err); } finally { setRunning(false); } }; // Polling loop — runs while running=true const runLoop = useCallback(async (rid, cancelled) => { if (cancelled.v) return; try { const res = await api("/simulation/live/step", { method: "POST", body: JSON.stringify({ run_id: rid }), }); if (cancelled.v) return; if (res.step) { const s = res.step; stepCount.current += 1; const stepNum = Number(s.step ?? stepCount.current); setCurrentStep(stepNum); const currentBacklog = Number(s.backlog ?? 0); const currentSla = Number(s.sla_breaches ?? 0); const currentFairness = Number(s.fairness_gap ?? 0); // Capture initial snapshot from step 1 if (initialSnapshot.current === null) { initialSnapshot.current = { backlog: currentBacklog, sla: currentSla, fairness: currentFairness, }; } const backlogDelta = currentBacklog - lastState.current.backlog; const slaDelta = currentSla - lastState.current.sla; const fairnessDelta = currentFairness - lastState.current.fairness; setKpis({ backlog: currentBacklog, backlogDelta, slaBreaches: currentSla, slaDelta, fairness: currentFairness, fairnessDelta, }); lastState.current = { backlog: currentBacklog, sla: currentSla, fairness: currentFairness }; const payload = typeof s.action_payload === "string" ? (() => { try { return JSON.parse(s.action_payload); } catch { return {}; } })() : (s.action_payload || {}); const story = mapActionToStory( s.action_type || "advance_time", payload, Number(s.reward), backlogDelta, slaDelta, fairnessDelta ); const phase = getPhase(stepNum, maxStepsRef.current); const key = isKeyDecision(s, backlogDelta); const improvesBacklog = backlogDelta < 0; const worsensBacklog = backlogDelta > 0; const worsensSla = slaDelta > 0; const improvesSla = slaDelta < 0; const outcomeLabel = improvesBacklog || improvesSla ? "Improvement" : worsensBacklog || worsensSla ? "Degradation" : "Stable"; const outcomeType = outcomeLabel === "Improvement" ? "success" : outcomeLabel === "Degradation" ? "warning" : "info"; const newEvent = { id: `step-${stepNum}`, time: `Step ${stepNum}`, title: s.invalid_action ? "Action Blocked" : story.title, desc: s.invalid_action ? "This action was blocked by environment constraints; the agent adapts on the next step." : story.desc, reason: s.invalid_action ? "The attempted operation violated environment constraints (e.g. over-assignment)." : story.reason, impact: Number(s.reward), type: s.invalid_action ? "error" : story.type, icon: s.invalid_action ? "block" : story.icon, isHighReward: story.isHighReward && !s.invalid_action, isHugeImpact: story.isHugeImpact && !s.invalid_action, phase, key, outcomeLabel, outcomeType, backlogDelta, // Used for phase summary }; // Collapse consecutive identical titles (deduplication for repeated events) setTimeline((prev) => { const [top, ...rest] = prev; if ( top && top.title === newEvent.title && top.phase === newEvent.phase && !top.key && !newEvent.key ) { // Merge: bump count, accumulate reward and backlog diff const merged = { ...top, id: newEvent.id, time: `${top.time?.split("–")[0]?.trim()}–${newEvent.time}`, desc: top.desc, impact: Number(top.impact) + Number(newEvent.impact), backlogDelta: (top.backlogDelta || 0) + backlogDelta, _count: (top._count || 1) + 1, }; return [merged, ...rest].slice(0, 30); } return [newEvent, ...prev].slice(0, 30); }); // Update queue monitors if (Array.isArray(s.queue_rows) && s.queue_rows.length > 0) { const maxCases = Math.max(...s.queue_rows.map((q) => q.active_cases ?? 0), 1); setResources(s.queue_rows.map((q) => ({ name: (q.service ?? q.service_type ?? "unknown").replace(/_/g, " ").toUpperCase(), activeCases: q.active_cases ?? 0, percentage: Math.min(100, Math.floor(((q.active_cases ?? 0) / maxCases) * 100)), }))); } } // Episode done if (res.done || res.step?.done) { const finalBacklog = lastState.current.backlog; const initSnap = initialSnapshot.current ?? { backlog: finalBacklog, sla: 0, fairness: 0 }; const backlogImprovement = initSnap.backlog > 0 ? Math.round(((initSnap.backlog - finalBacklog) / initSnap.backlog) * 100) : 0; setJourneyStats({ initialBacklog: initSnap.backlog, finalBacklog, backlogImprovement, initialSla: initSnap.sla, finalSla: lastState.current.sla, totalSteps: stepCount.current, finalScore: res.score ?? null, totalReward: res.total_reward ?? null, }); setTimeline((prev) => [{ id: "end", time: "Final", title: "Episode Complete", desc: `Resolution finished in ${stepCount.current} steps. Final score: ${res.score != null ? (res.score * 100).toFixed(1) + "%" : "N/A"}. Backlog ${finalBacklog < initSnap.backlog ? "reduced" : "unchanged"} — SLAs verified.`, impact: res.total_reward ?? 0, type: "success", icon: "verified", phase: "late", key: true, }, ...prev]); setRunning(false); return; } setTimeout(() => runLoop(rid, cancelled), 1000); } catch (err) { if (!cancelled.v) { setRunning(false); setTimeline((prev) => [{ id: `error-${Date.now()}`, time: "Halted", title: "System Error Detected", desc: `Backend synchronization failed: ${err.message}`, impact: 0, type: "error", icon: "warning", phase: "late", key: false, }, ...prev]); } } }, []); // Start/stop the polling loop reactively const cancelRef = useRef({ v: false }); useEffect(() => { if (!running || !runId) { cancelRef.current.v = true; return undefined; } cancelRef.current = { v: false }; const boot = setTimeout(() => { if (!cancelRef.current.v) { runLoop(runId, cancelRef.current); } }, 100); return () => { clearTimeout(boot); cancelRef.current.v = true; }; }, [running, runId, runLoop]); return { taskId, setTaskId, maxSteps, setMaxSteps, agentMode, setAgentMode, policyName, setPolicyName, modelPath, setModelPath, modelType, setModelType, availablePolicies, availableModels, configError, running, starting, currentStep, kpis, timeline, resources, journeyStats, startSimulation, stopSimulation, }; }