diff --git a/.env.example b/.env.example index 64c6a58..e53b39f 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,36 @@ -# Copy this file to `.env` for local development. -# Do not commit real credentials. - -# Core LLM toggle -CHAINCOPILOT_LLM_ENABLED=true -CHAINCOPILOT_LLM_TIMEOUT_S=60 -CHAINCOPILOT_LLM_RETRY_ATTEMPTS=1 -CHAINCOPILOT_LLM_MODEL=gemma-4-31B-it - -# Planner mode -# - hybrid: use LLM for candidate drafts, then deterministic scoring/selection -# - deterministic: skip planner LLM calls entirely -CHAINCOPILOT_PLANNER_MODE=hybrid - -# Provider selection -# - gemini: Gemini Developer API -# - vertex: Vertex AI Gemini via aiplatform.googleapis.com -CHAINCOPILOT_LLM_PROVIDER=fpt - -# Gemini Developer API -CHAINCOPILOT_LLM_API_KEY= - -# Vertex AI Gemini -# Switch CHAINCOPILOT_LLM_PROVIDER=vertex when these are set correctly. -# VERTEX_AI_API_KEY=REPLACE_WITH_VERTEX_API_KEY -# VERTEX_AI_PROJECT_ID=REPLACE_WITH_GCP_PROJECT_ID -# VERTEX_AI_REGION=global - -# Optional fallbacks recognized by the runtime -# GEMINI_API_KEY=REPLACE_WITH_GEMINI_API_KEY -# GOOGLE_CLOUD_PROJECT=REPLACE_WITH_GCP_PROJECT_ID -# GOOGLE_CLOUD_LOCATION=global - - - - +# Copy this file to `.env` for local development. +# Do not commit real credentials. + +# Core LLM toggle +CHAINCOPILOT_LLM_ENABLED=true +CHAINCOPILOT_LLM_TIMEOUT_S=60 +CHAINCOPILOT_LLM_RETRY_ATTEMPTS=1 +CHAINCOPILOT_LLM_MODEL=gemma-4-31B-it + +# Planner mode +# - hybrid: use LLM for candidate drafts, then deterministic scoring/selection +# - deterministic: skip planner LLM calls entirely +CHAINCOPILOT_PLANNER_MODE=hybrid + +# Provider selection +# - gemini: Gemini Developer API +# - vertex: Vertex AI Gemini via aiplatform.googleapis.com +CHAINCOPILOT_LLM_PROVIDER=fpt + +# Gemini Developer API +CHAINCOPILOT_LLM_API_KEY= + +# Vertex AI Gemini +# Switch CHAINCOPILOT_LLM_PROVIDER=vertex when these are set correctly. +# VERTEX_AI_API_KEY=REPLACE_WITH_VERTEX_API_KEY +# VERTEX_AI_PROJECT_ID=REPLACE_WITH_GCP_PROJECT_ID +# VERTEX_AI_REGION=global + +# Optional fallbacks recognized by the runtime +# GEMINI_API_KEY=REPLACE_WITH_GEMINI_API_KEY +# GOOGLE_CLOUD_PROJECT=REPLACE_WITH_GCP_PROJECT_ID +# GOOGLE_CLOUD_LOCATION=global + + + + diff --git a/.streamlit/config.toml b/.streamlit/config.toml index 4afb1cb..991878e 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -1,10 +1,10 @@ -[theme] -primaryColor = "#4F46E5" -backgroundColor = "#FFFFFF" -secondaryBackgroundColor = "#F8FAFC" -textColor = "#1E293B" -font = "sans serif" -base = "light" - -[server] -headless = true +[theme] +primaryColor = "#4F46E5" +backgroundColor = "#FFFFFF" +secondaryBackgroundColor = "#F8FAFC" +textColor = "#1E293B" +font = "sans serif" +base = "light" + +[server] +headless = true diff --git a/README.md b/README.md index e1cf1ac..3285d96 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,42 @@ -# ChainCopilot - -ChainCopilot is a hackathon MVP for an autonomous resilient supply chain control tower. -It implements a stateful agentic loop: - -- Sense -- Analyze -- Plan -- Act -- Learn - -The MVP includes: - -- 6-layer control-tower architecture -- Demand, Inventory, Supplier, Logistics, Risk, and Planner agents -- Normal and crisis modes with deterministic policy switching -- Decision logs with explainability -- What-if scenario simulation -- Streamlit dashboard and FastAPI service - -## Quickstart - -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -pytest -q -streamlit run ui/dashboard.py -``` - -## API - -```bash -python run_api.py -``` - -## Scenarios - -- supplier_delay -- demand_spike -- route_blockage -- compound_disruption +# ChainCopilot + +ChainCopilot is a hackathon MVP for an autonomous resilient supply chain control tower. +It implements a stateful agentic loop: + +- Sense +- Analyze +- Plan +- Act +- Learn + +The MVP includes: + +- 6-layer control-tower architecture +- Demand, Inventory, Supplier, Logistics, Risk, and Planner agents +- Normal and crisis modes with deterministic policy switching +- Decision logs with explainability +- What-if scenario simulation +- Streamlit dashboard and FastAPI service + +## Quickstart + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +pytest -q +streamlit run ui/dashboard.py +``` + +## API + +```bash +python run_api.py +``` + +## Scenarios + +- supplier_delay +- demand_spike +- route_blockage +- compound_disruption diff --git a/actions/switch_supplier.py b/actions/switch_supplier.py index cf14439..f93cec4 100644 --- a/actions/switch_supplier.py +++ b/actions/switch_supplier.py @@ -1,26 +1,26 @@ -from core.models import Action, SystemState - - -def apply_switch_supplier(state: SystemState, action: Action) -> None: - item = state.inventory.get(action.target_id) - if item is None: - return - - supplier_id = action.parameters.get("supplier_id") - if not supplier_id: - return - - supplier = _resolve_supplier(state, supplier_id, item.sku) - if supplier is None: - return - - item.preferred_supplier_id = supplier_id - item.unit_cost = supplier.unit_cost - state.extra_cost += max(action.estimated_cost_delta, 0.0) - - -def _resolve_supplier(state: SystemState, supplier_id: str, sku: str): - return ( - state.suppliers.get(f"{supplier_id}_{sku}") - or state.suppliers.get(supplier_id) - ) +from core.models import Action, SystemState + + +def apply_switch_supplier(state: SystemState, action: Action) -> None: + item = state.inventory.get(action.target_id) + if item is None: + return + + supplier_id = action.parameters.get("supplier_id") + if not supplier_id: + return + + supplier = _resolve_supplier(state, supplier_id, item.sku) + if supplier is None: + return + + item.preferred_supplier_id = supplier_id + item.unit_cost = supplier.unit_cost + state.extra_cost += max(action.estimated_cost_delta, 0.0) + + +def _resolve_supplier(state: SystemState, supplier_id: str, sku: str): + return ( + state.suppliers.get(f"{supplier_id}_{sku}") + or state.suppliers.get(supplier_id) + ) diff --git a/agents/base.py b/agents/base.py index 31c857b..3809c55 100644 --- a/agents/base.py +++ b/agents/base.py @@ -1,34 +1,34 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod - -from core.models import AgentProposal, Event, SystemState - - -class BaseAgent(ABC): - name: str - custom_system_prompt: str | None = None - - @abstractmethod - def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: - raise NotImplementedError - - def enrich_with_llm( - self, - *, - state: SystemState, - event: Event | None, - proposal: AgentProposal, - state_slice: dict, - ) -> AgentProposal: - from llm.service import enrich_specialist_proposal - - enrich_specialist_proposal( - agent_name=self.name, - state=state, - event=event, - proposal=proposal, - state_slice=state_slice, - custom_prompt=self.custom_system_prompt, - ) - return proposal +from __future__ import annotations + +from abc import ABC, abstractmethod + +from core.models import AgentProposal, Event, SystemState + + +class BaseAgent(ABC): + name: str + custom_system_prompt: str | None = None + + @abstractmethod + def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: + raise NotImplementedError + + def enrich_with_llm( + self, + *, + state: SystemState, + event: Event | None, + proposal: AgentProposal, + state_slice: dict, + ) -> AgentProposal: + from llm.service import enrich_specialist_proposal + + enrich_specialist_proposal( + agent_name=self.name, + state=state, + event=event, + proposal=proposal, + state_slice=state_slice, + custom_prompt=self.custom_system_prompt, + ) + return proposal diff --git a/agents/critic.py b/agents/critic.py index eb1e0a1..79d942f 100644 --- a/agents/critic.py +++ b/agents/critic.py @@ -3,6 +3,7 @@ from agents.base import BaseAgent from core.models import AgentProposal, Event, SystemState from llm.service import critique_candidate_plans +from policies.explainability import build_critic_review class CriticAgent(BaseAgent): @@ -21,6 +22,11 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: selected_plan=state.latest_plan, evaluations=decision_log.candidate_evaluations, ) + if not summary and not findings: + summary, findings = build_critic_review( + state.latest_plan, + decision_log.candidate_evaluations, + ) state.latest_plan.critic_summary = summary decision_log.critic_summary = summary decision_log.critic_findings = findings @@ -32,11 +38,12 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: proposal.notes_for_planner = summary if findings: proposal.downstream_impacts = findings + proposal.tradeoffs = findings[:2] proposal.llm_used = used proposal.llm_error = error proposal.observations.append( - "critic reviewed candidate plans" + "Critic reviewed candidate plans" if used or summary or findings - else "critic unavailable; deterministic selection retained" + else "Critic unavailable; deterministic selection retained" ) return proposal diff --git a/agents/demand.py b/agents/demand.py index 4a6860b..2f38731 100644 --- a/agents/demand.py +++ b/agents/demand.py @@ -1,99 +1,99 @@ -from __future__ import annotations - -from collections import defaultdict - -import pandas as pd - -from agents.base import BaseAgent -from core.enums import ActionType, EventType -from core.models import Action, AgentProposal, Event, SystemState - - -class DemandAgent(BaseAgent): - name = "demand" - custom_system_prompt = """You are the Demand Agent for a supply chain management system. -Your role is to analyze demand spikes, historical demand patterns, and forecast future demand. -When a disruption or event occurs (like a DEMAND_SPIKE), you evaluate how it impacts the required inventory levels. -Provide a concise, professional summary (under 4 sentences) of your demand analysis, the specific SKU affected, and your recommendations. -Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary.""" - - def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: - proposal = AgentProposal(agent=self.name) - grouped: dict[str, list[tuple[int, int]]] = defaultdict(list) - for demand in state.demands: - grouped[demand.sku].append((demand.day_index, demand.quantity)) - - multiplier = 1.0 - event_sku = None - if event and event.type == EventType.DEMAND_SPIKE: - multiplier = float(event.payload.get("multiplier", 1.5)) - event_sku = event.payload.get("sku") - proposal.observations.append( - f"demand spike multiplier applied: {multiplier:.2f}" - ) - - for sku, points in grouped.items(): - df = pd.DataFrame(points, columns=["day_index", "quantity"]).sort_values( - "day_index" - ) - df_30 = df.tail(30) - - avg_d = df_30["quantity"].mean() - std_d = df_30["quantity"].std() - - forecast = int(round(avg_d)) if pd.notna(avg_d) else 0 - std_d_val = float(std_d) if pd.notna(std_d) else 0.0 - - if sku == event_sku: - forecast = int(round(forecast * multiplier)) - if sku in state.inventory: - state.inventory[sku].forecast_qty = max(0, forecast) - state.inventory[sku].std_demand = std_d_val - proposal.observations.append( - f"{sku} historical demand (30 days): Avg = {forecast}, Std Dev = {std_d_val:.2f}" - ) - - if ( - event - and event.type == EventType.DEMAND_SPIKE - and event_sku in state.inventory - ): - item = state.inventory[event_sku] - proposal.proposals.append( - Action( - action_id=f"act_demand_rebalance_{event_sku}", - action_type=ActionType.REBALANCE, - target_id=event_sku, - parameters={"quantity": max(item.forecast_qty - item.on_hand, 0)}, - estimated_cost_delta=40.0, - estimated_service_delta=0.08, - estimated_risk_delta=-0.10, - estimated_recovery_hours=8.0, - reason=f"pre-position stock for {event_sku} demand spike", - priority=0.75, - ) - ) - - affected_inventory = { - sku: { - "on_hand": item.on_hand, - "incoming_qty": item.incoming_qty, - "forecast_qty": item.forecast_qty, - "reorder_point": item.reorder_point, - "safety_stock": item.safety_stock, - } - for sku, item in state.inventory.items() - if event_sku is None or sku == event_sku - } - if event is not None or proposal.proposals: - self.enrich_with_llm( - state=state, - event=event, - proposal=proposal, - state_slice={ - "event_sku": event_sku, - "multiplier": multiplier, - "affected_inventory": affected_inventory, - }, - ) - return proposal +from __future__ import annotations + +from collections import defaultdict + +import pandas as pd + +from agents.base import BaseAgent +from core.enums import ActionType, EventType +from core.models import Action, AgentProposal, Event, SystemState + + +class DemandAgent(BaseAgent): + name = "demand" + custom_system_prompt = """You are the Demand Agent for a supply chain management system. +Your role is to analyze demand spikes, historical demand patterns, and forecast future demand. +When a disruption or event occurs (like a DEMAND_SPIKE), you evaluate how it impacts the required inventory levels. +Provide a concise, professional summary (under 4 sentences) of your demand analysis, the specific SKU affected, and your recommendations. +Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary.""" + + def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: + proposal = AgentProposal(agent=self.name) + grouped: dict[str, list[tuple[int, int]]] = defaultdict(list) + for demand in state.demands: + grouped[demand.sku].append((demand.day_index, demand.quantity)) + + multiplier = 1.0 + event_sku = None + if event and event.type == EventType.DEMAND_SPIKE: + multiplier = float(event.payload.get("multiplier", 1.5)) + event_sku = event.payload.get("sku") + proposal.observations.append( + f"demand spike multiplier applied: {multiplier:.2f}" + ) + + for sku, points in grouped.items(): + df = pd.DataFrame(points, columns=["day_index", "quantity"]).sort_values( + "day_index" + ) + df_30 = df.tail(30) + + avg_d = df_30["quantity"].mean() + std_d = df_30["quantity"].std() + + forecast = int(round(avg_d)) if pd.notna(avg_d) else 0 + std_d_val = float(std_d) if pd.notna(std_d) else 0.0 + + if sku == event_sku: + forecast = int(round(forecast * multiplier)) + if sku in state.inventory: + state.inventory[sku].forecast_qty = max(0, forecast) + state.inventory[sku].std_demand = std_d_val + proposal.observations.append( + f"{sku} historical demand (30 days): Avg = {forecast}, Std Dev = {std_d_val:.2f}" + ) + + if ( + event + and event.type == EventType.DEMAND_SPIKE + and event_sku in state.inventory + ): + item = state.inventory[event_sku] + proposal.proposals.append( + Action( + action_id=f"act_demand_rebalance_{event_sku}", + action_type=ActionType.REBALANCE, + target_id=event_sku, + parameters={"quantity": max(item.forecast_qty - item.on_hand, 0)}, + estimated_cost_delta=40.0, + estimated_service_delta=0.08, + estimated_risk_delta=-0.10, + estimated_recovery_hours=8.0, + reason=f"pre-position stock for {event_sku} demand spike", + priority=0.75, + ) + ) + + affected_inventory = { + sku: { + "on_hand": item.on_hand, + "incoming_qty": item.incoming_qty, + "forecast_qty": item.forecast_qty, + "reorder_point": item.reorder_point, + "safety_stock": item.safety_stock, + } + for sku, item in state.inventory.items() + if event_sku is None or sku == event_sku + } + if event is not None or proposal.proposals: + self.enrich_with_llm( + state=state, + event=event, + proposal=proposal, + state_slice={ + "event_sku": event_sku, + "multiplier": multiplier, + "affected_inventory": affected_inventory, + }, + ) + return proposal diff --git a/agents/inventory.py b/agents/inventory.py index 1f52d4f..07fbc87 100644 --- a/agents/inventory.py +++ b/agents/inventory.py @@ -1,71 +1,199 @@ from __future__ import annotations import math +from collections import Counter from agents.base import BaseAgent from core.enums import ActionType from core.models import Action, AgentProposal, Event, SystemState - - -def approximate_z_score(service_level: float) -> float: - """Approximation of the inverse CDF of the normal distribution.""" - p = max(0.5001, min(0.9999, service_level)) - t = math.sqrt(-2.0 * math.log(1.0 - p)) - c0, c1, c2 = 2.515517, 0.802853, 0.010328 - d1, d2, d3 = 1.432788, 0.189269, 0.001308 - z = t - ((c2 * t + c1) * t + c0) / (((d3 * t + d2) * t + d1) * t + 1.0) - return max(0.0, z) - - + + +def approximate_z_score(service_level: float) -> float: + """Approximation of the inverse CDF of the normal distribution.""" + p = max(0.5001, min(0.9999, service_level)) + t = math.sqrt(-2.0 * math.log(1.0 - p)) + c0, c1, c2 = 2.515517, 0.802853, 0.010328 + d1, d2, d3 = 1.432788, 0.189269, 0.001308 + z = t - ((c2 * t + c1) * t + c0) / (((d3 * t + d2) * t + d1) * t + 1.0) + return max(0.0, z) + + class InventoryAgent(BaseAgent): name = "inventory" + custom_system_prompt = """You are the Inventory Agent for a supply chain control tower. +Explain inventory risk like an operations manager would. +Be specific about stock position, expected demand, days of cover, reorder point, safety stock, and the business impact of inaction. +If replenishment is proposed, explain why that action is better than waiting. +Do not invent inventory math, customer orders, or supplier facts that are not in the provided state.""" def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: proposal = AgentProposal(agent=self.name) + sku_order_counts = Counter(order.sku for order in state.orders) + risk_rows: list[dict[str, object]] = [] for sku, item in state.inventory.items(): # --- 1. Calculate parameters from Demand and Supplier --- avg_d = item.forecast_qty std_d = getattr(item, "std_demand", 0.0) - - supplier_key = f"{item.preferred_supplier_id}_{sku}" - supplier = state.suppliers.get(supplier_key) - - lead_time = supplier.lead_time_days if supplier else 0 - reliability = supplier.reliability if supplier else 0.95 - - z_score = approximate_z_score(reliability) - - # --- 2. Calculate LTD, SS, ROP --- - ltd = avg_d * lead_time - ss = z_score * std_d * math.sqrt(lead_time) if lead_time > 0 else 0.0 - rop = ltd + ss - - # --- 3. Save back to InventoryItem --- + + supplier_key = f"{item.preferred_supplier_id}_{sku}" + supplier = state.suppliers.get(supplier_key) + + lead_time = supplier.lead_time_days if supplier else 0 + reliability = supplier.reliability if supplier else 0.95 + + z_score = approximate_z_score(reliability) + + # --- 2. Calculate LTD, SS, ROP --- + ltd = avg_d * lead_time + ss = z_score * std_d * math.sqrt(lead_time) if lead_time > 0 else 0.0 + rop = ltd + ss + + # --- 3. Save back to InventoryItem --- item.reorder_point = int(round(rop)) item.safety_stock = int(round(ss)) - proposal.observations.append( - f"{sku}: Lead Time={lead_time}d, Z={z_score:.2f} (Rel={reliability:.2f}). " - f"LTD={ltd:.1f}, SS={ss:.1f}, new ROP={item.reorder_point}" + projected = item.on_hand + item.incoming_qty - item.forecast_qty + daily_demand = max(float(avg_d), 0.0) + available_stock = item.on_hand + item.incoming_qty + days_of_cover = ( + available_stock / daily_demand + if daily_demand > 0 + else float("inf") ) + order_count = sku_order_counts.get(sku, 0) + root_cause = [] + if event is not None: + root_cause.append(event.type.value.replace("_", " ")) + if lead_time >= 5: + root_cause.append("long replenishment lead time") + if reliability < 0.9: + root_cause.append("below-target supplier reliability") + if daily_demand > max(item.on_hand, 1): + root_cause.append("demand running ahead of current on-hand stock") + cause_text = ", ".join(root_cause) if root_cause else "normal replenishment cycle" - projected = item.on_hand + item.incoming_qty - item.forecast_qty - proposal.observations.append(f"{sku} projected stock: {projected}") + if projected < item.safety_stock: + inventory_status = "stockout risk" + elif projected < item.reorder_point: + inventory_status = "reorder risk" + elif available_stock > max(item.reorder_point * 1.8, daily_demand * 10): + inventory_status = "overstock" + else: + inventory_status = "stable" + + risk_rows.append( + { + "sku": sku, + "name": item.name, + "inventory_status": inventory_status, + "on_hand": item.on_hand, + "incoming_qty": item.incoming_qty, + "forecast_qty": item.forecast_qty, + "projected_stock": projected, + "reorder_point": item.reorder_point, + "safety_stock": item.safety_stock, + "days_of_cover": None if days_of_cover == float("inf") else round(days_of_cover, 1), + "lead_time_days": lead_time, + "supplier_reliability": reliability, + "pending_orders": order_count, + "root_cause": cause_text, + } + ) if projected < item.reorder_point: quantity = max(item.reorder_point + item.safety_stock - projected, 0) proposal.proposals.append( Action( - action_id=f"act_reorder_{sku}", - action_type=ActionType.REORDER, - target_id=sku, - parameters={"quantity": int(quantity)}, - estimated_cost_delta=quantity * item.unit_cost * 1.05, - estimated_service_delta=0.12, - estimated_risk_delta=-0.15, - estimated_recovery_hours=24.0, - reason=f"projected stock ({projected}) for {sku} falls below ROP ({item.reorder_point})", + action_id=f"act_reorder_{sku}", + action_type=ActionType.REORDER, + target_id=sku, + parameters={"quantity": int(quantity)}, + estimated_cost_delta=quantity * item.unit_cost * 1.05, + estimated_service_delta=0.12, + estimated_risk_delta=-0.15, + estimated_recovery_hours=24.0, + reason=f"projected stock ({projected}) for {sku} falls below ROP ({item.reorder_point})", priority=0.8 if projected < item.safety_stock else 0.6, ) ) + ranked_rows = sorted( + risk_rows, + key=lambda row: ( + 0 if row["inventory_status"] == "stockout risk" else 1 if row["inventory_status"] == "reorder risk" else 2, + row["days_of_cover"] if row["days_of_cover"] is not None else 9999, + ), + ) + critical_rows = [ + row for row in ranked_rows if row["inventory_status"] in {"stockout risk", "reorder risk"} + ] + if critical_rows: + top = critical_rows[0] + pending_orders = int(top["pending_orders"]) + order_text = ( + f" and may affect {pending_orders} pending customer orders" + if pending_orders + else "" + ) + coverage_text = ( + f"{top['days_of_cover']:.1f} days of cover" + if top["days_of_cover"] is not None + else "limited demand visibility" + ) + proposal.domain_summary = ( + f"Inventory pressure is concentrated on {top['name']} ({top['sku']}). " + f"Projected stock falls to {int(top['projected_stock'])} units versus a reorder point of {int(top['reorder_point'])} " + f"and safety stock of {int(top['safety_stock'])}. At the current demand pace, that leaves {coverage_text}. " + f"The main driver is {top['root_cause']}; if no action is taken, stock availability could tighten within the next replenishment cycle{order_text}." + ) + proposal.risks.append( + f"{len(critical_rows)} SKUs are below reorder point; {sum(1 for row in critical_rows if row['inventory_status'] == 'stockout risk')} are already below safety stock." + ) + for row in critical_rows[:3]: + coverage_text = ( + f"{row['days_of_cover']:.1f} days of cover" + if row["days_of_cover"] is not None + else "no reliable cover estimate" + ) + proposal.downstream_impacts.append( + f"{row['sku']} is in {row['inventory_status']} with projected stock {int(row['projected_stock'])}, reorder point {int(row['reorder_point'])}, and {coverage_text}." + ) + proposal.tradeoffs.append( + "Replenishing now protects service continuity but increases near-term inventory and working-capital cost." + ) + proposal.notes_for_planner = ( + f"Prioritize SKUs already below safety stock or with fewer than " + f"{critical_rows[0]['days_of_cover'] or 0:.1f} days of cover." + ) + elif ranked_rows: + stable_count = sum(1 for row in ranked_rows if row["inventory_status"] == "stable") + proposal.domain_summary = ( + f"Inventory is broadly stable. {stable_count} SKUs remain above reorder point after applying current demand, lead time, and safety stock assumptions." + ) + proposal.tradeoffs.append( + "Holding current inventory avoids unnecessary replenishment cost, but coverage should still be refreshed as demand and supplier conditions change." + ) + proposal.notes_for_planner = "No urgent inventory-led intervention is required; keep the plan efficiency-focused." + + if event is not None or proposal.proposals: + self.enrich_with_llm( + state=state, + event=event, + proposal=proposal, + state_slice={ + "inventory_focus": ranked_rows[:5], + "critical_sku_count": len(critical_rows), + "proposed_reorders": [ + { + "action_id": action.action_id, + "target_id": action.target_id, + "quantity": action.parameters.get("quantity"), + "reason": action.reason, + "estimated_cost_delta": action.estimated_cost_delta, + "estimated_service_delta": action.estimated_service_delta, + "estimated_risk_delta": action.estimated_risk_delta, + } + for action in proposal.proposals[:5] + ], + }, + ) return proposal diff --git a/agents/logistics.py b/agents/logistics.py index 7658a8a..9584abf 100644 --- a/agents/logistics.py +++ b/agents/logistics.py @@ -1,66 +1,66 @@ -from __future__ import annotations - -from agents.base import BaseAgent -from core.enums import ActionType, EventType -from core.models import Action, AgentProposal, Event, SystemState - - -class LogisticsAgent(BaseAgent): - name = "logistics" - custom_system_prompt = """You are the Logistics and Routing Agent for a supply chain system. -When transportation routes are blocked or delayed, your responsibility is to find alternative shipping routes that minimize risk, transit time, and cost. -Provide a concise, professional summary (under 4 sentences) of the routing issues, your analysis of alternative routes, and your specific rerouting recommendations. -Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary.""" - - def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: - proposal = AgentProposal(agent=self.name) - blocked_route = event.payload.get("route_id") if event and event.type == EventType.ROUTE_BLOCKAGE else None - available_routes = [route for route in state.routes.values() if route.status != "blocked"] - ranked = sorted(available_routes, key=lambda route: (route.risk_score, route.transit_days, route.cost)) - - for sku, item in state.inventory.items(): - current_route = state.routes.get(item.preferred_route_id) - if not current_route: - continue - best = ranked[0] if ranked else current_route - if best.route_id == blocked_route and len(ranked) > 1: - best = ranked[1] - if best.route_id != current_route.route_id and (current_route.route_id == blocked_route or best.risk_score < current_route.risk_score): - proposal.proposals.append( - Action( - action_id=f"act_reroute_{sku}_{best.route_id}", - action_type=ActionType.REROUTE, - target_id=sku, - parameters={"route_id": best.route_id}, - estimated_cost_delta=max(best.cost - current_route.cost, 0.0), - estimated_service_delta=0.04, - estimated_risk_delta=-0.10, - estimated_recovery_hours=float(best.transit_days * 8), - reason=f"reroute {sku} from {current_route.route_id} to {best.route_id}", - priority=0.85 if current_route.route_id == blocked_route else 0.45, - ) - ) - proposal.observations.append(f"{sku} reroute candidate: {best.route_id}") - if event is not None or proposal.proposals: - route_snapshot = [ - { - "route_id": route.route_id, - "origin": route.origin, - "destination": route.destination, - "transit_days": route.transit_days, - "cost": route.cost, - "risk_score": route.risk_score, - "status": route.status, - } - for route in ranked - ] - self.enrich_with_llm( - state=state, - event=event, - proposal=proposal, - state_slice={ - "blocked_route": blocked_route, - "route_options": route_snapshot, - }, - ) - return proposal +from __future__ import annotations + +from agents.base import BaseAgent +from core.enums import ActionType, EventType +from core.models import Action, AgentProposal, Event, SystemState + + +class LogisticsAgent(BaseAgent): + name = "logistics" + custom_system_prompt = """You are the Logistics and Routing Agent for a supply chain system. +When transportation routes are blocked or delayed, your responsibility is to find alternative shipping routes that minimize risk, transit time, and cost. +Provide a concise, professional summary (under 4 sentences) of the routing issues, your analysis of alternative routes, and your specific rerouting recommendations. +Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary.""" + + def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: + proposal = AgentProposal(agent=self.name) + blocked_route = event.payload.get("route_id") if event and event.type == EventType.ROUTE_BLOCKAGE else None + available_routes = [route for route in state.routes.values() if route.status != "blocked"] + ranked = sorted(available_routes, key=lambda route: (route.risk_score, route.transit_days, route.cost)) + + for sku, item in state.inventory.items(): + current_route = state.routes.get(item.preferred_route_id) + if not current_route: + continue + best = ranked[0] if ranked else current_route + if best.route_id == blocked_route and len(ranked) > 1: + best = ranked[1] + if best.route_id != current_route.route_id and (current_route.route_id == blocked_route or best.risk_score < current_route.risk_score): + proposal.proposals.append( + Action( + action_id=f"act_reroute_{sku}_{best.route_id}", + action_type=ActionType.REROUTE, + target_id=sku, + parameters={"route_id": best.route_id}, + estimated_cost_delta=max(best.cost - current_route.cost, 0.0), + estimated_service_delta=0.04, + estimated_risk_delta=-0.10, + estimated_recovery_hours=float(best.transit_days * 8), + reason=f"reroute {sku} from {current_route.route_id} to {best.route_id}", + priority=0.85 if current_route.route_id == blocked_route else 0.45, + ) + ) + proposal.observations.append(f"{sku} reroute candidate: {best.route_id}") + if event is not None or proposal.proposals: + route_snapshot = [ + { + "route_id": route.route_id, + "origin": route.origin, + "destination": route.destination, + "transit_days": route.transit_days, + "cost": route.cost, + "risk_score": route.risk_score, + "status": route.status, + } + for route in ranked + ] + self.enrich_with_llm( + state=state, + event=event, + proposal=proposal, + state_slice={ + "blocked_route": blocked_route, + "route_options": route_snapshot, + }, + ) + return proposal diff --git a/agents/planner.py b/agents/planner.py index 1eb5877..ccd290b 100644 --- a/agents/planner.py +++ b/agents/planner.py @@ -18,11 +18,11 @@ ) from core.logger import get_logger from llm.service import enrich_plan_and_decision, generate_candidate_plan_drafts -from policies.explainability import ( - build_plan_summary, - build_winning_factors, - explain_rejected_actions, -) +from policies.explainability import ( + build_plan_summary, + build_winning_factors, + explain_rejected_actions, +) from policies.constraints import evaluate_plan_constraints, mode_rationale, evaluate_hard_constraints, evaluate_soft_constraints from policies.guardrails import approval_required from policies.scoring import compute_score @@ -36,7 +36,7 @@ logger = get_logger(__name__) -STRATEGY_ORDER = ("cost_first", "balanced", "resilience_first") +STRATEGY_ORDER = ("cost_first", "balanced", "resilience_first") def _dedupe_actions(actions: list[Action], limit: int) -> list[Action]: @@ -84,10 +84,10 @@ def _strategy_reason(strategy_label: str) -> str: return "fallback balanced strategy blends specialist priority, risk reduction, and service protection" -def _ensure_actions(candidate_actions: list[Action]) -> list[Action]: - ensured = list(candidate_actions) - if any(action.action_type == ActionType.NO_OP for action in ensured): - return ensured +def _ensure_actions(candidate_actions: list[Action]) -> list[Action]: + ensured = list(candidate_actions) + if any(action.action_type == ActionType.NO_OP for action in ensured): + return ensured ensured.append( Action( action_id="act_no_op", @@ -96,12 +96,46 @@ def _ensure_actions(candidate_actions: list[Action]) -> list[Action]: reason="no action required", priority=0.1, ) - ) - return ensured - - -def _action_limit_for_mode(state: SystemState) -> int: - return 3 if state.mode.value == "crisis" else 2 + ) + return ensured + + +def _drop_no_op_when_actions_exist(candidate_actions: list[Action]) -> list[Action]: + real_actions = [ + action for action in candidate_actions if action.action_type != ActionType.NO_OP + ] + return real_actions if real_actions else candidate_actions + + +def _action_limit_for_mode(state: SystemState, candidate_count: int) -> int: + del state + # Do not hard-cap plan size; let strategy/feasibility logic decide action breadth. + return max(candidate_count, 1) + + +def _inventory_pressure_summary(state: SystemState) -> dict[str, int]: + critical = 0 + below_safety = 0 + for item in state.inventory.values(): + projected = item.on_hand + item.incoming_qty - item.forecast_qty + if projected < item.reorder_point: + critical += 1 + if projected < item.safety_stock: + below_safety += 1 + return { + "critical_skus": critical, + "below_safety_stock": below_safety, + } + + +def _should_suppress_no_op(state: SystemState) -> bool: + pressure = _inventory_pressure_summary(state) + return ( + state.kpis.service_level < 0.95 + or state.kpis.stockout_risk >= 0.05 + or pressure["critical_skus"] >= 8 + or pressure["below_safety_stock"] >= 2 + ) def _fallback_drafts(candidate_actions: list[Action], action_limit: int) -> list[CandidatePlanDraft]: @@ -120,11 +154,11 @@ def _fallback_drafts(candidate_actions: list[Action], action_limit: int) -> list return drafts -def _normalize_drafts( - drafts: list[CandidatePlanDraft], - candidate_actions: list[Action], - action_limit: int, -) -> tuple[list[CandidatePlanDraft], int]: +def _normalize_drafts( + drafts: list[CandidatePlanDraft], + candidate_actions: list[Action], + action_limit: int, +) -> tuple[list[CandidatePlanDraft], int]: allowed_ids = {action.action_id for action in candidate_actions} by_id = {action.action_id: action for action in candidate_actions} normalized: list[CandidatePlanDraft] = [] @@ -153,12 +187,141 @@ def _normalize_drafts( action_ids=[action.action_id for action in selected_actions], rationale=draft.rationale or _strategy_reason(strategy_label), llm_used=draft.llm_used, - ) - ) - return normalized, repaired_count - - -def _evaluate_candidate( + ) + ) + return normalized, repaired_count + + +def _action_signature(action_ids: list[str]) -> tuple[str, ...]: + return tuple(sorted({action_id for action_id in action_ids if action_id})) + + +def _build_distinct_repair_draft( + *, + strategy_label: str, + candidate_actions: list[Action], + action_limit: int, + used_signatures: set[tuple[str, ...]], +) -> CandidatePlanDraft | None: + ranked = sorted( + candidate_actions, + key=lambda action: _fallback_sort_key(strategy_label, action), + ) + if not ranked: + return None + search_depth = min(len(ranked), max(action_limit * 4, 8)) + for offset in range(search_depth): + rotated = ranked[offset:] + ranked[:offset] + selected = _dedupe_actions(rotated, action_limit) + signature = _action_signature([action.action_id for action in selected]) + if selected and signature not in used_signatures: + return CandidatePlanDraft( + strategy_label=strategy_label, + action_ids=[action.action_id for action in selected], + rationale=( + f"deterministic diversity repair applied to keep the {strategy_label} option " + "operationally distinct from other candidate plans" + ), + llm_used=False, + ) + return None + + +def _enforce_distinct_drafts( + drafts: list[CandidatePlanDraft], + candidate_actions: list[Action], + action_limit: int, +) -> tuple[list[CandidatePlanDraft], int]: + distinct: list[CandidatePlanDraft] = [] + used_signatures: set[tuple[str, ...]] = set() + repaired_count = 0 + for draft in drafts: + signature = _action_signature(draft.action_ids) + if signature and signature not in used_signatures: + distinct.append(draft) + used_signatures.add(signature) + continue + repaired = _build_distinct_repair_draft( + strategy_label=draft.strategy_label, + candidate_actions=candidate_actions, + action_limit=action_limit, + used_signatures=used_signatures, + ) + if repaired is not None: + distinct.append(repaired) + used_signatures.add(_action_signature(repaired.action_ids)) + repaired_count += 1 + else: + distinct.append(draft) + if signature: + used_signatures.add(signature) + return distinct, repaired_count + + +def _inventory_gap_map(state: SystemState) -> dict[str, float]: + gap_by_sku: dict[str, float] = {} + for sku, item in state.inventory.items(): + projected = item.on_hand + item.incoming_qty - item.forecast_qty + reorder_gap = max(float(item.reorder_point - projected), 0.0) + if reorder_gap <= 0: + continue + safety_gap = max(float(item.safety_stock - projected), 0.0) + gap_by_sku[sku] = reorder_gap + (safety_gap * 1.5) + return gap_by_sku + + +def _coverage_metrics( + evaluation: CandidatePlanEvaluation, + *, + gap_by_sku: dict[str, float], + by_id: dict[str, Action], +) -> dict[str, float]: + targeted_skus = { + by_id[action_id].target_id + for action_id in evaluation.action_ids + if action_id in by_id and by_id[action_id].action_type != ActionType.NO_OP + } + covered_gap = sum(gap_by_sku.get(sku, 0.0) for sku in targeted_skus) + total_gap = sum(gap_by_sku.values()) + coverage_fraction = (covered_gap / total_gap) if total_gap > 0 else 0.0 + critical_covered = sum(1 for sku in targeted_skus if sku in gap_by_sku) + unresolved_critical = max(len(gap_by_sku) - critical_covered, 0) + return { + "coverage_fraction": coverage_fraction, + "critical_covered": float(critical_covered), + "unresolved_critical": float(unresolved_critical), + } + + +def _decision_priority_score( + evaluation: CandidatePlanEvaluation, + *, + gap_by_sku: dict[str, float], + by_id: dict[str, Action], + mode: str, + state: SystemState, +) -> float: + coverage = _coverage_metrics(evaluation, gap_by_sku=gap_by_sku, by_id=by_id) + pressure = _inventory_pressure_summary(state) + stressed_normal = ( + mode == "normal" + and ( + state.kpis.stockout_risk >= 0.05 + or pressure["critical_skus"] >= 8 + or pressure["below_safety_stock"] >= 2 + ) + ) + coverage_weight = 0.016 if mode == "crisis" else (0.075 if stressed_normal else 0.012) + unresolved_penalty = 0.0012 if mode == "crisis" else (0.0025 if stressed_normal else 0.0008) + return round( + evaluation.score + + (coverage["coverage_fraction"] * coverage_weight) + - (coverage["unresolved_critical"] * unresolved_penalty), + 6, + ) + + +def _evaluate_candidate( *, state: SystemState, event: Event | None, @@ -217,10 +380,10 @@ def _evaluate_candidate( status=PlanStatus.PROPOSED, ) needs_approval, reason = approval_required(transient_plan, before_kpis, simulated.kpis, event) - return CandidatePlanEvaluation( - strategy_label=strategy_label, - action_ids=[action.action_id for action in actions], - score=score, + return CandidatePlanEvaluation( + strategy_label=strategy_label, + action_ids=[action.action_id for action in actions], + score=score, score_breakdown=breakdown, projected_kpis=simulated.kpis, feasible=True, @@ -229,50 +392,404 @@ def _evaluate_candidate( approval_required=needs_approval, approval_reason=reason if needs_approval else "no approval required: thresholds not triggered", rationale=rationale, - llm_used=llm_used, - ) - - -def _select_best_evaluation(evaluations: list[CandidatePlanEvaluation]) -> CandidatePlanEvaluation: - feasible_evaluations = [item for item in evaluations if item.feasible] - pool = feasible_evaluations or evaluations - return max( - pool, - key=lambda item: ( - item.score, - -item.projected_kpis.disruption_risk, - item.projected_kpis.service_level, - -item.projected_kpis.total_cost, - 1 if item.strategy_label == "balanced" else 0, - ), + llm_used=llm_used, + ) + + +def _adaptive_scope_bounds( + *, + state: SystemState, + event: Event | None, + available_actions: int, +) -> tuple[int, int]: + if available_actions <= 0: + return 0, 0 + min_actions = 1 + if state.mode.value == "crisis" and available_actions >= 2: + min_actions = 2 + if event is None and _should_suppress_no_op(state) and available_actions >= 2: + min_actions = max(min_actions, 2) + return min_actions, available_actions + + +def _action_count_penalty( + *, + action_count: int, + state: SystemState, + event: Event | None, +) -> float: + if action_count <= 1: + return 0.0 + if state.mode.value == "crisis": + return (action_count - 1) * 0.00008 + if event is not None: + return (action_count - 1) * 0.00012 + return (action_count - 1) * 0.00025 + + +def _target_coverage_fraction( + *, + state: SystemState, + event: Event | None, + gap_by_sku: dict[str, float], +) -> float: + if not gap_by_sku: + return 0.0 + pressure = _inventory_pressure_summary(state) + if state.mode.value == "crisis": + if event is not None and event.severity >= 0.85: + return 0.95 + if event is not None and event.severity >= 0.65: + return 0.90 + return 0.80 + if pressure["critical_skus"] >= 20: + return 0.75 + if pressure["critical_skus"] >= 12: + return 0.65 + if pressure["critical_skus"] >= 8: + return 0.55 + return 0.40 + + +def _coverage_approval_guardrail( + *, + selected: CandidatePlanEvaluation, + state: SystemState, + event: Event | None, + gap_by_sku: dict[str, float], +) -> tuple[bool, str]: + if event is None: + return False, "" + if not gap_by_sku: + return False, "" + + target_coverage = _target_coverage_fraction( + state=state, + event=event, + gap_by_sku=gap_by_sku, + ) + crisis_floor = max(target_coverage - 0.05, 0.60) + + if event.severity >= 0.65 and selected.unresolved_critical > 0: + return ( + True, + ( + f"selected plan leaves {selected.unresolved_critical} critical SKU(s) unresolved " + f"under disruption severity {event.severity:.2f}" + ), + ) + if state.mode.value == "crisis" and selected.coverage_fraction < crisis_floor: + return ( + True, + ( + f"selected plan covers {selected.coverage_fraction:.0%} of critical exposure, " + f"below the crisis guardrail floor ({crisis_floor:.0%})" + ), + ) + return False, "" + + +def _expand_selected_for_coverage( + *, + selected: CandidatePlanEvaluation, + state: SystemState, + event: Event | None, + before_kpis, + by_id: dict[str, Action], + gap_by_sku: dict[str, float], +) -> CandidatePlanEvaluation: + if event is None or event.severity < 0.65: + return selected + if not gap_by_sku or selected.unresolved_critical <= 0: + return selected + + target_coverage = _target_coverage_fraction( + state=state, + event=event, + gap_by_sku=gap_by_sku, + ) + max_unresolved = max(int(len(gap_by_sku) * (1.0 - target_coverage)), 0) + + selected_action_ids = [action_id for action_id in selected.action_ids if action_id in by_id] + selected_actions = [by_id[action_id] for action_id in selected_action_ids] + selected_targeted = { + action.target_id + for action in selected_actions + if action.action_type != ActionType.NO_OP + } + total_gap = sum(gap_by_sku.values()) or 1.0 + selected_covered_gap = sum(gap_by_sku.get(sku, 0.0) for sku in selected_targeted) + selected_coverage = selected_covered_gap / total_gap + selected_unresolved = max(len(gap_by_sku) - len(selected_targeted & set(gap_by_sku)), 0) + if selected_coverage >= target_coverage and selected_unresolved <= max_unresolved: + return selected + + additional_actions = [ + action + for action in by_id.values() + if action.action_id not in selected_action_ids + and action.action_type != ActionType.NO_OP + and action.target_id in gap_by_sku + ] + additional_actions.sort( + key=lambda action: ( + gap_by_sku.get(action.target_id, 0.0), + action.priority, + -action.estimated_risk_delta, + action.estimated_service_delta, + ), + reverse=True, + ) + + expanded_action_ids = list(selected_action_ids) + expanded_actions = list(selected_actions) + targeted = set(selected_targeted) + covered_gap = selected_covered_gap + + for action in additional_actions: + expanded_action_ids.append(action.action_id) + expanded_actions.append(action) + if action.target_id not in targeted: + targeted.add(action.target_id) + covered_gap += gap_by_sku.get(action.target_id, 0.0) + coverage_fraction = covered_gap / total_gap + unresolved = max(len(gap_by_sku) - len(targeted & set(gap_by_sku)), 0) + if coverage_fraction >= target_coverage and unresolved <= max_unresolved: + break + + if len(expanded_action_ids) == len(selected_action_ids): + return selected + + expanded_evaluation = _evaluate_candidate( + state=state, + event=event, + before_kpis=before_kpis, + strategy_label=selected.strategy_label, + actions=expanded_actions, + rationale=selected.rationale, + llm_used=selected.llm_used, + ) + if not expanded_evaluation.feasible: + return selected + + expanded_coverage = _coverage_metrics( + expanded_evaluation, + gap_by_sku=gap_by_sku, + by_id=by_id, + ) + expanded_evaluation.coverage_fraction = round(expanded_coverage["coverage_fraction"], 4) + expanded_evaluation.critical_covered = int(expanded_coverage["critical_covered"]) + expanded_evaluation.unresolved_critical = int(expanded_coverage["unresolved_critical"]) + + if ( + expanded_evaluation.unresolved_critical < selected.unresolved_critical + or expanded_evaluation.coverage_fraction > selected.coverage_fraction + ): + expanded_evaluation.approval_required = True + expanded_evaluation.approval_reason = ( + "coverage guardrail expanded the selected strategy to reduce unresolved critical SKUs" + ) + return expanded_evaluation + return selected + + +def _optimize_draft_scope( + *, + draft: CandidatePlanDraft, + by_id: dict[str, Action], + state: SystemState, + event: Event | None, + before_kpis, + gap_by_sku: dict[str, float], +) -> CandidatePlanEvaluation: + ordered_actions = [by_id[action_id] for action_id in draft.action_ids if action_id in by_id] + min_actions, max_actions = _adaptive_scope_bounds( + state=state, + event=event, + available_actions=len(ordered_actions), + ) + if max_actions == 0: + return _evaluate_candidate( + state=state, + event=event, + before_kpis=before_kpis, + strategy_label=draft.strategy_label, + actions=[], + rationale=draft.rationale, + llm_used=draft.llm_used, + ) + + best_evaluation: CandidatePlanEvaluation | None = None + best_adjusted = float("-inf") + best_priority = float("-inf") + candidates: list[tuple[CandidatePlanEvaluation, float, float]] = [] + for action_count in range(min_actions, max_actions + 1): + evaluation = _evaluate_candidate( + state=state, + event=event, + before_kpis=before_kpis, + strategy_label=draft.strategy_label, + actions=ordered_actions[:action_count], + rationale=draft.rationale, + llm_used=draft.llm_used, + ) + priority_score = _decision_priority_score( + evaluation, + gap_by_sku=gap_by_sku, + by_id=by_id, + mode=state.mode.value, + state=state, + ) + adjusted_score = priority_score - _action_count_penalty( + action_count=action_count, + state=state, + event=event, + ) + coverage = _coverage_metrics( + evaluation, + gap_by_sku=gap_by_sku, + by_id=by_id, + ) + candidates.append((evaluation, adjusted_score, coverage["coverage_fraction"])) + if ( + adjusted_score > best_adjusted + or ( + adjusted_score == best_adjusted + and ( + priority_score > best_priority + or ( + priority_score == best_priority + and best_evaluation is not None + and len(evaluation.action_ids) < len(best_evaluation.action_ids) + ) + ) + ) + ): + best_adjusted = adjusted_score + best_priority = priority_score + best_evaluation = evaluation + + target_coverage = _target_coverage_fraction( + state=state, + event=event, + gap_by_sku=gap_by_sku, + ) + if candidates and target_coverage > 0.0: + near_best_threshold = best_adjusted - 0.035 + eligible = [ + (evaluation, adjusted_score, coverage) + for evaluation, adjusted_score, coverage in candidates + if coverage >= target_coverage and adjusted_score >= near_best_threshold + ] + if eligible: + eligible.sort( + key=lambda item: ( + len(item[0].action_ids), + -item[1], + -item[2], + ) + ) + best_evaluation = eligible[0][0] + + assert best_evaluation is not None + return best_evaluation + + +def _select_best_evaluation( + evaluations: list[CandidatePlanEvaluation], + *, + state: SystemState, + by_id: dict[str, Action], +) -> CandidatePlanEvaluation: + feasible_evaluations = [item for item in evaluations if item.feasible] + pool = feasible_evaluations or evaluations + gap_by_sku = _inventory_gap_map(state) + return max( + pool, + key=lambda item: ( + _decision_priority_score( + item, + gap_by_sku=gap_by_sku, + by_id=by_id, + mode=state.mode.value, + state=state, + ), + item.score, + -item.projected_kpis.disruption_risk, + item.projected_kpis.service_level, + -item.projected_kpis.total_cost, + 1 if item.strategy_label == "balanced" else 0, + ), ) -def _selection_reason(selected: CandidatePlanEvaluation, evaluations: list[CandidatePlanEvaluation]) -> str: - infeasible_count = sum(1 for item in evaluations if not item.feasible) - ordered = sorted( - [item for item in evaluations if item.feasible] or evaluations, - key=lambda item: ( - item.score, - -item.projected_kpis.disruption_risk, - item.projected_kpis.service_level, - -item.projected_kpis.total_cost, - ), +def _selection_reason( + selected: CandidatePlanEvaluation, + evaluations: list[CandidatePlanEvaluation], + *, + state: SystemState, + by_id: dict[str, Action], +) -> str: + infeasible_count = sum(1 for item in evaluations if not item.feasible) + gap_by_sku = _inventory_gap_map(state) + selected_priority_score = _decision_priority_score( + selected, + gap_by_sku=gap_by_sku, + by_id=by_id, + mode=state.mode.value, + state=state, + ) + ordered = sorted( + [item for item in evaluations if item.feasible] or evaluations, + key=lambda item: ( + _decision_priority_score( + item, + gap_by_sku=gap_by_sku, + by_id=by_id, + mode=state.mode.value, + state=state, + ), + item.score, + -item.projected_kpis.disruption_risk, + item.projected_kpis.service_level, + -item.projected_kpis.total_cost, + ), reverse=True, - ) - runner_up = ordered[1] if len(ordered) > 1 else None - base = ( - f"selected {selected.strategy_label} because it produced the strongest deterministic score " - f"({selected.score:.4f})" - ) - if infeasible_count: - base = f"{base} after excluding {infeasible_count} infeasible candidate plan(s)" - if runner_up is None: - return base - return ( - f"{base} over {runner_up.strategy_label} ({runner_up.score:.4f}) after applying score, " - "risk, service-level, and cost tie-breaks" - ) + ) + runner_up = ordered[1] if len(ordered) > 1 else None + base = ( + f"Selected {selected.strategy_label} as the lead recommendation because it produced the strongest overall priority score " + f"({selected_priority_score:.4f})" + ) + if infeasible_count: + base = f"{base} after excluding {infeasible_count} infeasible candidate plan(s)" + if runner_up is None: + return base + runner_up_priority_score = _decision_priority_score( + runner_up, + gap_by_sku=gap_by_sku, + by_id=by_id, + mode=state.mode.value, + state=state, + ) + selected_coverage = _coverage_metrics(selected, gap_by_sku=gap_by_sku, by_id=by_id) + runner_up_coverage = _coverage_metrics( + runner_up, + gap_by_sku=gap_by_sku, + by_id=by_id, + ) + coverage_note = "" + if selected_coverage["critical_covered"] > runner_up_coverage["critical_covered"]: + coverage_note = ( + f" It also covered more at-risk SKUs ({int(selected_coverage['critical_covered'])} vs " + f"{int(runner_up_coverage['critical_covered'])})." + ) + elif selected_coverage["coverage_fraction"] > runner_up_coverage["coverage_fraction"]: + coverage_note = " It addressed a larger share of current inventory exposure." + return ( + f"{base}. It ranked ahead of {runner_up.strategy_label} ({runner_up_priority_score:.4f}) after comparing " + f"service protection, cost, disruption risk, and recovery speed.{coverage_note}" + ) def _safe_hold_evaluation( @@ -315,14 +832,18 @@ class PlannerAgent(BaseAgent): def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: proposal = AgentProposal(agent=self.name) - - candidate_actions = _ensure_actions(state.candidate_actions) - action_limit = _action_limit_for_mode(state) - - feasible_candidates = [] - infeasible_reasons = {} - for act in candidate_actions: - dummy_plan = Plan(plan_id="tmp", mode=state.mode, score=0, score_breakdown={}, actions=[act]) + + candidate_actions = _ensure_actions(state.candidate_actions) + if event is not None: + candidate_actions = _drop_no_op_when_actions_exist(candidate_actions) + suppress_no_op = event is None and _should_suppress_no_op(state) + if suppress_no_op: + candidate_actions = _drop_no_op_when_actions_exist(candidate_actions) + + feasible_candidates = [] + infeasible_reasons = {} + for act in candidate_actions: + dummy_plan = Plan(plan_id="tmp", mode=state.mode, score=0, score_breakdown={}, actions=[act]) is_feas, vios = evaluate_hard_constraints(dummy_plan, state) if is_feas: feasible_candidates.append(act) @@ -331,11 +852,12 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: if not feasible_candidates and candidate_actions: feasible_candidates = [ - Action( - action_id="act_no_op_fallback", action_type=ActionType.NO_OP, target_id="system", - reason="all candidate actions violated hard constraints", priority=0.0 - ) - ] + Action( + action_id="act_no_op_fallback", action_type=ActionType.NO_OP, target_id="system", + reason="all candidate actions violated hard constraints", priority=0.0 + ) + ] + action_limit = _action_limit_for_mode(state, len(feasible_candidates)) effective_event = event or (state.active_events[-1] if state.active_events else None) historical_cases = retrieve_relevant_cases(effective_event, state.memory, top_k=3) @@ -350,72 +872,159 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: state=state, event=event, candidate_actions=feasible_candidates ) logger.info( - "planner candidate generation result llm_drafts=%s planner_error=%s feasible_candidates=%s", + "planner candidate generation result llm_drafts=%s planner_error=%s feasible_candidates=%s suppress_no_op=%s action_limit=%s", len(llm_drafts), planner_error or "none", len(feasible_candidates), + suppress_no_op, + action_limit, ) - drafts, repaired_count = _normalize_drafts(llm_drafts, feasible_candidates, action_limit) - fallback_used = repaired_count > 0 - if fallback_used and not planner_error: - planner_error = "planner returned incomplete or invalid candidate plans" - if fallback_used: - logger.warning( - "planner fallback activated repaired_count=%s total_strategies=%s reason=%s", - repaired_count, - len(STRATEGY_ORDER), - planner_error or "unknown_reason", + if planner_error == "llm_disabled": + drafts = _fallback_drafts(feasible_candidates, action_limit) + repaired_count = 0 + fallback_used = False + logger.info( + "planner deterministic path used because llm is disabled action_limit=%s", + action_limit, ) else: - logger.info("planner used llm candidate drafts without repair") + drafts, repaired_count = _normalize_drafts(llm_drafts, feasible_candidates, action_limit) + drafts, duplicate_repair_count = _enforce_distinct_drafts( + drafts, + feasible_candidates, + action_limit, + ) + repaired_count += duplicate_repair_count + fallback_used = repaired_count > 0 + if fallback_used and not planner_error: + planner_error = "planner returned duplicate, incomplete, or invalid candidate plans" + if fallback_used: + logger.warning( + "planner fallback activated repaired_count=%s total_strategies=%s reason=%s", + repaired_count, + len(STRATEGY_ORDER), + planner_error or "unknown_reason", + ) + else: + logger.info("planner used llm candidate drafts without repair") by_id = {action.action_id: action for action in feasible_candidates} - evaluations: list[CandidatePlanEvaluation] = [] - for draft in drafts: - actions = [by_id[action_id] for action_id in draft.action_ids if action_id in by_id] - evaluations.append( - _evaluate_candidate( - state=state, event=event, before_kpis=before_kpis, - strategy_label=draft.strategy_label, actions=actions, - rationale=draft.rationale, llm_used=draft.llm_used, - ) - ) - - if not any(item.feasible for item in evaluations): - safe_action = next(action for action in feasible_candidates if action.action_type == ActionType.NO_OP) - evaluations.append( - _safe_hold_evaluation( + gap_by_sku = _inventory_gap_map(state) + evaluations: list[CandidatePlanEvaluation] = [] + for draft in drafts: + evaluations.append( + _optimize_draft_scope( + draft=draft, + by_id=by_id, + state=state, + event=event, + before_kpis=before_kpis, + gap_by_sku=gap_by_sku, + ) + ) + + for evaluation in evaluations: + coverage = _coverage_metrics( + evaluation, + gap_by_sku=gap_by_sku, + by_id=by_id, + ) + evaluation.coverage_fraction = round(coverage["coverage_fraction"], 4) + evaluation.critical_covered = int(coverage["critical_covered"]) + evaluation.unresolved_critical = int(coverage["unresolved_critical"]) + + if not any(item.feasible for item in evaluations): + safe_action = next(action for action in feasible_candidates if action.action_type == ActionType.NO_OP) + evaluations.append( + _safe_hold_evaluation( state=state, event=event, before_kpis=before_kpis, safe_action=safe_action, infeasible_count=len(evaluations), ) ) - selected_evaluation = _select_best_evaluation(evaluations) - selected_actions = [by_id[action_id] for action_id in selected_evaluation.action_ids if action_id in by_id] - - summary = build_plan_summary(before_kpis, selected_evaluation.projected_kpis, selected_evaluation.score_breakdown) - if selected_evaluation.mode_rationale: - summary = f"{summary} Mode rationale: {selected_evaluation.mode_rationale}." + selected_evaluation = _select_best_evaluation( + evaluations, + state=state, + by_id=by_id, + ) + guardrail_event = event or (state.active_events[-1] if state.active_events else None) + selected_evaluation = _expand_selected_for_coverage( + selected=selected_evaluation, + state=state, + event=guardrail_event, + before_kpis=before_kpis, + by_id=by_id, + gap_by_sku=gap_by_sku, + ) + selected_actions = [by_id[action_id] for action_id in selected_evaluation.action_ids if action_id in by_id] + requires_manual_review, manual_review_reason = _coverage_approval_guardrail( + selected=selected_evaluation, + state=state, + event=guardrail_event, + gap_by_sku=gap_by_sku, + ) + if requires_manual_review: + selected_evaluation.approval_required = True + prior_reason = (selected_evaluation.approval_reason or "").strip() + if not prior_reason or prior_reason.startswith("no approval required"): + selected_evaluation.approval_reason = manual_review_reason + elif manual_review_reason not in prior_reason: + selected_evaluation.approval_reason = ( + f"{prior_reason}; {manual_review_reason}" + ) + + ordered_evaluations = sorted( + [item for item in evaluations if item.feasible] or evaluations, + key=lambda item: ( + item.score, + -item.projected_kpis.disruption_risk, + item.projected_kpis.service_level, + -item.projected_kpis.total_cost, + ), + reverse=True, + ) + runner_up = next( + (item for item in ordered_evaluations if item.strategy_label != selected_evaluation.strategy_label), + None, + ) + summary = build_plan_summary( + before_kpis, + selected_evaluation.projected_kpis, + selected_evaluation.score_breakdown, + selected_actions=selected_actions, + strategy_label=selected_evaluation.strategy_label, + mode=state.mode, + runner_up=runner_up, + mode_rationale=selected_evaluation.mode_rationale, + ) final_plan = Plan( plan_id=f"plan_{uuid4().hex[:8]}", mode=state.mode, trigger_event_ids=[event.event_id] if event else [], actions=selected_actions, - score=selected_evaluation.score, - score_breakdown=selected_evaluation.score_breakdown, - feasible=selected_evaluation.feasible, - violations=selected_evaluation.violations, - mode_rationale=selected_evaluation.mode_rationale, - strategy_label=selected_evaluation.strategy_label, - generated_by="llm_planner" if repaired_count == 0 else ("llm_planner_repaired" if repaired_count < len(STRATEGY_ORDER) else "hybrid_fallback"), - approval_required=selected_evaluation.approval_required, - approval_reason=selected_evaluation.approval_reason if selected_evaluation.approval_required else "", - planner_reasoning=summary, - status=PlanStatus.PROPOSED, - ) + score=selected_evaluation.score, + score_breakdown=selected_evaluation.score_breakdown, + feasible=selected_evaluation.feasible, + violations=selected_evaluation.violations, + mode_rationale=selected_evaluation.mode_rationale, + strategy_label=selected_evaluation.strategy_label, + generated_by=( + "deterministic_planner" + if planner_error == "llm_disabled" + else ( + "llm_planner" + if repaired_count == 0 + else ("llm_planner_repaired" if repaired_count < len(STRATEGY_ORDER) else "hybrid_fallback") + ) + ), + approval_required=selected_evaluation.approval_required, + approval_reason=selected_evaluation.approval_reason if selected_evaluation.approval_required else "", + planner_reasoning=summary, + status=PlanStatus.PROPOSED, + ) is_hard_feas, hard_violations = evaluate_hard_constraints(final_plan, state) soft_violations = evaluate_soft_constraints(final_plan, state) @@ -432,8 +1041,13 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: strategic_prompt=strategic_prompt ) - winning_factors = build_winning_factors(selected_actions, before_kpis, selected_evaluation.projected_kpis, selected_evaluation.score_breakdown) - rejection_reasons = explain_rejected_actions(candidate_actions, selected_actions, action_limit) + winning_factors = build_winning_factors( + selected_actions, + before_kpis, + selected_evaluation.projected_kpis, + selected_evaluation.score_breakdown, + ) + rejection_reasons = explain_rejected_actions(candidate_actions, selected_actions, action_limit) decision_log = DecisionLog( decision_id=f"dec_{uuid4().hex[:8]}", @@ -445,10 +1059,15 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: rejected_actions=rejection_reasons, score_breakdown=selected_evaluation.score_breakdown, mode_rationale=selected_evaluation.mode_rationale, - rationale=summary, - candidate_evaluations=evaluations, - selection_reason=_selection_reason(selected_evaluation, evaluations), - winning_factors=winning_factors, + rationale=summary, + candidate_evaluations=evaluations, + selection_reason=_selection_reason( + selected_evaluation, + evaluations, + state=state, + by_id=by_id, + ), + winning_factors=winning_factors, approval_required=selected_evaluation.approval_required, approval_reason=selected_evaluation.approval_reason, approval_status=ApprovalStatus.PENDING if selected_evaluation.approval_required else ApprovalStatus.AUTO_APPLIED, @@ -463,13 +1082,26 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: state.pending_plan = final_plan if final_plan.approval_required else None state.decision_logs.append(decision_log) - proposal.observations.append(f"built {final_plan.plan_id} using {final_plan.strategy_label} with score {final_plan.score:.4f}") - if fallback_used and planner_error: - proposal.risks.append(f"planner {'repair' if repaired_count < len(STRATEGY_ORDER) else 'fallback'} used: {planner_error}") - proposal.domain_summary = final_plan.generated_by or "" - proposal.notes_for_planner = decision_log.selection_reason + proposal.observations.append( + f"built {final_plan.plan_id} using {final_plan.strategy_label} with score {final_plan.score:.4f}" + ) + if fallback_used and planner_error: + proposal.risks.append( + f"planner {'repair' if repaired_count < len(STRATEGY_ORDER) else 'fallback'} used: {planner_error}" + ) + proposal.domain_summary = final_plan.llm_planner_narrative or final_plan.planner_reasoning + proposal.notes_for_planner = decision_log.selection_reason + proposal.downstream_impacts = winning_factors[:3] + if runner_up is not None: + proposal.tradeoffs.append( + f"The chosen plan beat {runner_up.strategy_label} by trading off service, cost, risk, and recovery priorities more effectively for the current mode." + ) + if suppress_no_op: + proposal.observations.append( + "Planner suppressed the no-action option because current inventory stress already exceeds the normal operating tolerance." + ) proposal.llm_used = any(evaluation.llm_used for evaluation in evaluations) - proposal.llm_error = planner_error if fallback_used else None + proposal.llm_error = planner_error if (fallback_used and planner_error != "llm_disabled") else None logger.info( "planner final selection strategy=%s generated_by=%s llm_used=%s llm_error=%s", final_plan.strategy_label, diff --git a/agents/supplier.py b/agents/supplier.py index b8bd5a8..cb4f916 100644 --- a/agents/supplier.py +++ b/agents/supplier.py @@ -1,97 +1,97 @@ -from __future__ import annotations - -from collections import defaultdict - -from agents.base import BaseAgent -from core.enums import ActionType, EventType -from core.models import Action, AgentProposal, Event, SystemState - - -class SupplierAgent(BaseAgent): - name = "supplier" - custom_system_prompt = """You are the Supplier Management Agent for a supply chain system. -When supplier delays or disruptions occur, your job is to analyze alternative suppliers, factoring in reliability, cost, and lead times. -Provide a concise, professional summary (under 4 sentences) of the supplier issues, your evaluation of the alternatives, and the recommended supplier switch. -Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary.""" - - def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: - proposal = AgentProposal(agent=self.name) - by_sku: dict[str, list] = defaultdict(list) - for supplier in state.suppliers.values(): - by_sku[supplier.sku].append(supplier) - - delayed_supplier = ( - event.payload.get("supplier_id") - if event and event.type == EventType.SUPPLIER_DELAY - else None - ) - for sku, item in state.inventory.items(): - current = item.preferred_supplier_id - current_supplier = state.suppliers.get(f"{current}_{sku}") - if current_supplier is None: - current_supplier = state.suppliers.get(current) - ranked = sorted( - by_sku.get(sku, []), - key=lambda supplier: ( - -supplier.reliability, - supplier.unit_cost, - supplier.lead_time_days, - ), - ) - if not ranked: - proposal.risks.append(f"no suppliers available for {sku}") - continue - best = ranked[0] - if best.supplier_id == delayed_supplier and len(ranked) > 1: - best = ranked[1] - if current_supplier and best.supplier_id != current_supplier.supplier_id: - cost_delta = best.unit_cost - current_supplier.unit_cost - proposal.proposals.append( - Action( - action_id=f"act_supplier_{sku}_{best.supplier_id}", - action_type=ActionType.SWITCH_SUPPLIER, - target_id=sku, - parameters={"supplier_id": best.supplier_id}, - estimated_cost_delta=max( - cost_delta * max(item.forecast_qty, 1), 0.0 - ), - estimated_service_delta=0.06 - if best.reliability >= current_supplier.reliability - else 0.02, - estimated_risk_delta=-0.12 - if best.supplier_id != delayed_supplier - else -0.02, - estimated_recovery_hours=float(best.lead_time_days * 12), - reason=f"switch {sku} from {current_supplier.supplier_id} to {best.supplier_id}", - priority=0.9 - if delayed_supplier == current_supplier.supplier_id - else 0.55, - ) - ) - proposal.observations.append( - f"{sku} best supplier candidate: {best.supplier_id}" - ) - if event is not None or proposal.proposals: - supplier_snapshot = { - sku: [ - { - "supplier_id": supplier.supplier_id, - "unit_cost": supplier.unit_cost, - "lead_time_days": supplier.lead_time_days, - "reliability": supplier.reliability, - "status": supplier.status, - } - for supplier in ranked_suppliers - ] - for sku, ranked_suppliers in by_sku.items() - } - self.enrich_with_llm( - state=state, - event=event, - proposal=proposal, - state_slice={ - "delayed_supplier": delayed_supplier, - "supplier_options": supplier_snapshot, - }, - ) - return proposal +from __future__ import annotations + +from collections import defaultdict + +from agents.base import BaseAgent +from core.enums import ActionType, EventType +from core.models import Action, AgentProposal, Event, SystemState + + +class SupplierAgent(BaseAgent): + name = "supplier" + custom_system_prompt = """You are the Supplier Management Agent for a supply chain system. +When supplier delays or disruptions occur, your job is to analyze alternative suppliers, factoring in reliability, cost, and lead times. +Provide a concise, professional summary (under 4 sentences) of the supplier issues, your evaluation of the alternatives, and the recommended supplier switch. +Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary.""" + + def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: + proposal = AgentProposal(agent=self.name) + by_sku: dict[str, list] = defaultdict(list) + for supplier in state.suppliers.values(): + by_sku[supplier.sku].append(supplier) + + delayed_supplier = ( + event.payload.get("supplier_id") + if event and event.type == EventType.SUPPLIER_DELAY + else None + ) + for sku, item in state.inventory.items(): + current = item.preferred_supplier_id + current_supplier = state.suppliers.get(f"{current}_{sku}") + if current_supplier is None: + current_supplier = state.suppliers.get(current) + ranked = sorted( + by_sku.get(sku, []), + key=lambda supplier: ( + -supplier.reliability, + supplier.unit_cost, + supplier.lead_time_days, + ), + ) + if not ranked: + proposal.risks.append(f"no suppliers available for {sku}") + continue + best = ranked[0] + if best.supplier_id == delayed_supplier and len(ranked) > 1: + best = ranked[1] + if current_supplier and best.supplier_id != current_supplier.supplier_id: + cost_delta = best.unit_cost - current_supplier.unit_cost + proposal.proposals.append( + Action( + action_id=f"act_supplier_{sku}_{best.supplier_id}", + action_type=ActionType.SWITCH_SUPPLIER, + target_id=sku, + parameters={"supplier_id": best.supplier_id}, + estimated_cost_delta=max( + cost_delta * max(item.forecast_qty, 1), 0.0 + ), + estimated_service_delta=0.06 + if best.reliability >= current_supplier.reliability + else 0.02, + estimated_risk_delta=-0.12 + if best.supplier_id != delayed_supplier + else -0.02, + estimated_recovery_hours=float(best.lead_time_days * 12), + reason=f"switch {sku} from {current_supplier.supplier_id} to {best.supplier_id}", + priority=0.9 + if delayed_supplier == current_supplier.supplier_id + else 0.55, + ) + ) + proposal.observations.append( + f"{sku} best supplier candidate: {best.supplier_id}" + ) + if event is not None or proposal.proposals: + supplier_snapshot = { + sku: [ + { + "supplier_id": supplier.supplier_id, + "unit_cost": supplier.unit_cost, + "lead_time_days": supplier.lead_time_days, + "reliability": supplier.reliability, + "status": supplier.status, + } + for supplier in ranked_suppliers + ] + for sku, ranked_suppliers in by_sku.items() + } + self.enrich_with_llm( + state=state, + event=event, + proposal=proposal, + state_slice={ + "delayed_supplier": delayed_supplier, + "supplier_options": supplier_snapshot, + }, + ) + return proposal diff --git a/api.py b/api.py index c29b191..3c110eb 100644 --- a/api.py +++ b/api.py @@ -1,152 +1,152 @@ -from __future__ import annotations - -from fastapi import FastAPI, HTTPException, Request -from fastapi.encoders import jsonable_encoder -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse -from fastapi.middleware.cors import CORSMiddleware - -from app_api.routers import create_router -from app_api.schemas import ErrorResponse -from app_api.services import ControlTowerRuntime, make_runtime -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner -from execution.dispatch_service import ActionDispatchService - - -def create_runtime(store: SQLiteStore | None = None) -> ControlTowerRuntime: - """Creates the main engine instance.""" - return make_runtime(store=store) - - -# Global runtime instance -RUNTIME = create_runtime() - -# Legacy globals for backward compatibility -STORE = RUNTIME.store -STATE = RUNTIME.state -GRAPH = RUNTIME.graph -RUNNER = RUNTIME.runner - - -def sync_legacy_globals() -> None: - """Maintains alignment between the runtime and legacy global variables.""" - global STORE, STATE, GRAPH, RUNNER - STORE = RUNTIME.store - STATE = RUNTIME.state - GRAPH = RUNTIME.graph - RUNNER = RUNTIME.runner - - -def replace_runtime( - *, - store: SQLiteStore | None = None, - state=None, - graph=None, - runner=None, - dispatch_service=None, -) -> ControlTowerRuntime: - """Reconfigures the service with fresh components.""" - global RUNTIME - selected_store = store or SQLiteStore() +from __future__ import annotations + +from fastapi import FastAPI, HTTPException, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware + +from app_api.routers import create_router +from app_api.schemas import ErrorResponse +from app_api.services import ControlTowerRuntime, make_runtime +from core.memory import SQLiteStore +from core.state import load_initial_state, refresh_operational_baseline +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner +from execution.dispatch_service import ActionDispatchService + + +def create_runtime(store: SQLiteStore | None = None) -> ControlTowerRuntime: + """Creates the main engine instance.""" + return make_runtime(store=store) + + +# Global runtime instance +RUNTIME = create_runtime() + +# Legacy globals for backward compatibility +STORE = RUNTIME.store +STATE = RUNTIME.state +GRAPH = RUNTIME.graph +RUNNER = RUNTIME.runner + + +def sync_legacy_globals() -> None: + """Maintains alignment between the runtime and legacy global variables.""" + global STORE, STATE, GRAPH, RUNNER + STORE = RUNTIME.store + STATE = RUNTIME.state + GRAPH = RUNTIME.graph + RUNNER = RUNTIME.runner + + +def replace_runtime( + *, + store: SQLiteStore | None = None, + state=None, + graph=None, + runner=None, + dispatch_service=None, +) -> ControlTowerRuntime: + """Reconfigures the service with fresh components.""" + global RUNTIME + selected_store = store or SQLiteStore() RUNTIME = ControlTowerRuntime( store=selected_store, - state=state or load_initial_state(), + state=state or refresh_operational_baseline(load_initial_state()), graph=graph or build_graph(), runner=runner or ScenarioRunner(store=selected_store), dispatch_service=dispatch_service or ActionDispatchService(), - ) - sync_legacy_globals() - return RUNTIME - - -def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI: - """Application factory for the ChainCopilot API.""" - if runtime is not None: - replace_runtime( - store=runtime.store, - state=runtime.state, - graph=runtime.graph, - runner=runtime.runner, - dispatch_service=runtime.dispatch_service, - ) - instance = FastAPI(title="ChainCopilot API", version="0.2.0") - - # Configure CORS middleware - instance.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - # Modular routers - Includes all /api/v1 endpoints - instance.include_router(create_router(lambda: RUNTIME)) - - register_error_handlers(instance) - return instance - - -def _error_code_for_status(status_code: int) -> str: - return { - 404: "not_found", - 409: "conflict", - 422: "validation_error", - 500: "system_error", - }.get(status_code, "request_error") - - -def _error_response(status_code: int, detail) -> JSONResponse: - if isinstance(detail, ErrorResponse): - payload = detail - elif isinstance(detail, dict): - payload = ErrorResponse( - code=str(detail.get("code") or _error_code_for_status(status_code)), - message=str(detail.get("message") or "request failed"), - details=detail.get("details", {}), - retryable=bool(detail.get("retryable", False)), - correlation_id=detail.get("correlation_id"), - ) - else: - payload = ErrorResponse( - code=_error_code_for_status(status_code), - message=str(detail or "request failed"), - ) - return JSONResponse(status_code=status_code, content=jsonable_encoder(payload)) - - -def register_error_handlers(instance: FastAPI) -> None: - @instance.exception_handler(HTTPException) - async def _http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: - return _error_response(exc.status_code, exc.detail) - - @instance.exception_handler(RequestValidationError) - async def _validation_exception_handler(_: Request, exc: RequestValidationError) -> JSONResponse: - return _error_response( - 422, - { - "code": "validation_error", - "message": "request validation failed", - "details": {"errors": exc.errors()}, - "retryable": False, - }, - ) - - @instance.exception_handler(Exception) - async def _unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse: - return _error_response( - 500, - { - "code": "system_error", - "message": "internal server error", - "details": {"exception_type": exc.__class__.__name__}, - "retryable": False, - }, - ) - - -sync_legacy_globals() -app = create_app(RUNTIME) + ) + sync_legacy_globals() + return RUNTIME + + +def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI: + """Application factory for the ChainCopilot API.""" + if runtime is not None: + replace_runtime( + store=runtime.store, + state=runtime.state, + graph=runtime.graph, + runner=runtime.runner, + dispatch_service=runtime.dispatch_service, + ) + instance = FastAPI(title="ChainCopilot API", version="0.2.0") + + # Configure CORS middleware + instance.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Modular routers - Includes all /api/v1 endpoints + instance.include_router(create_router(lambda: RUNTIME)) + + register_error_handlers(instance) + return instance + + +def _error_code_for_status(status_code: int) -> str: + return { + 404: "not_found", + 409: "conflict", + 422: "validation_error", + 500: "system_error", + }.get(status_code, "request_error") + + +def _error_response(status_code: int, detail) -> JSONResponse: + if isinstance(detail, ErrorResponse): + payload = detail + elif isinstance(detail, dict): + payload = ErrorResponse( + code=str(detail.get("code") or _error_code_for_status(status_code)), + message=str(detail.get("message") or "request failed"), + details=detail.get("details", {}), + retryable=bool(detail.get("retryable", False)), + correlation_id=detail.get("correlation_id"), + ) + else: + payload = ErrorResponse( + code=_error_code_for_status(status_code), + message=str(detail or "request failed"), + ) + return JSONResponse(status_code=status_code, content=jsonable_encoder(payload)) + + +def register_error_handlers(instance: FastAPI) -> None: + @instance.exception_handler(HTTPException) + async def _http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: + return _error_response(exc.status_code, exc.detail) + + @instance.exception_handler(RequestValidationError) + async def _validation_exception_handler(_: Request, exc: RequestValidationError) -> JSONResponse: + return _error_response( + 422, + { + "code": "validation_error", + "message": "request validation failed", + "details": {"errors": exc.errors()}, + "retryable": False, + }, + ) + + @instance.exception_handler(Exception) + async def _unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse: + return _error_response( + 500, + { + "code": "system_error", + "message": "internal server error", + "details": {"exception_type": exc.__class__.__name__}, + "retryable": False, + }, + ) + + +sync_legacy_globals() +app = create_app(RUNTIME) diff --git a/app_api/__init__.py b/app_api/__init__.py index cda444c..afdde46 100644 --- a/app_api/__init__.py +++ b/app_api/__init__.py @@ -1,2 +1,2 @@ -"""Control tower API app-layer package.""" - +"""Control tower API app-layer package.""" + diff --git a/app_api/routers.py b/app_api/routers.py index fa7bb68..51d6b1d 100644 --- a/app_api/routers.py +++ b/app_api/routers.py @@ -1,615 +1,633 @@ -from __future__ import annotations - -from collections.abc import Callable - -from fastapi import APIRouter, HTTPException - +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import APIRouter, HTTPException + from app_api.schemas import ( ActionExecutionRecordView, + ApprovalAlternativeRequest, ApprovalCommandResultResponse, ApprovalDetailResponse, ApprovalCommandRequest, - ControlTowerStateResponse, - ControlTowerSummaryResponse, - DecisionLogDetailResponse, - DecisionLogListResponse, - DispatchModeRequest, - EventIngestRequest, - EventIngestResponse, - EventListResponse, - EventRequest, - ExecutionDetailResponse, - ExecutionListResponse, - ErrorResponse, - InventoryListResponse, - LegacyApprovalRequest, - PendingApprovalResponse, - PlanDetailResponse, - PlanDispatchResponse, - ProgressRequest, - ReflectionListResponse, - RunDetailResponse, - RunListResponse, - RunStateResponse, - ScenarioRequest, - ServiceRuntimeResponse, - SupplierListResponse, - TraceResponse, - WhatIfRequest, -) -from app_api.services import ( - action_execution_record_view, - approval_command_result_view, - approval_detail_view, - ControlTowerRuntime, - build_event_from_request, - control_tower_state, - control_tower_summary, - decision_detail_view, - decision_summary_view, - event_envelope_view, - execution_record_view, - inventory_rows, - latest_trace_view, - pending_approval_view, - plan_view, - raise_not_found, - reflection_views, - run_record_view, - run_record_list_view, - scenario_outcomes, - service_runtime_view, - supplier_rows, - trace_view_from_record, - historical_control_tower_state, -) -from core.models import DecisionLog, OrchestrationTrace -from core.runtime_records import RunRecord - - -MOCK_ENVIRONMENT = "normal" - - -def create_router(runtime_getter: Callable[[], ControlTowerRuntime]) -> APIRouter: - router = APIRouter( - prefix="/api/v1", - responses={ - 404: {"model": ErrorResponse}, - 409: {"model": ErrorResponse}, - 422: {"model": ErrorResponse}, - 500: {"model": ErrorResponse}, - }, - ) - - @router.get("/mock/weather") - def mock_weather() -> dict: - global MOCK_ENVIRONMENT - if MOCK_ENVIRONMENT in ["route_blockage", "compound_disruption"]: - return { - "coord": {"lon": 105.8412, "lat": 21.0245}, - "weather": [ - { - "id": 501, - "main": "Rain", - "description": "heavy intensity rain and localized flooding", - "icon": "10d", - }, - { - "id": 202, - "main": "Thunderstorm", - "description": "thunderstorm with heavy rain", - "icon": "11d", - }, - ], - "base": "stations", - "main": { - "temp": 298.48, - "feels_like": 298.74, - "temp_min": 297.56, - "temp_max": 300.05, - "pressure": 1015, - "humidity": 89, - "sea_level": 1015, - "grnd_level": 1013, - }, - "visibility": 2000, - "wind": {"speed": 12.5, "deg": 140, "gust": 18.2}, - "clouds": {"all": 90}, - "dt": 1661870592, - "sys": { - "type": 2, - "id": 2004688, - "country": "VN", - "sunrise": 1661834187, - "sunset": 1661879324, - }, - "timezone": 25200, - "id": 1581130, - "name": "Hanoi", - "cod": 200, - "alerts": [ - { - "sender_name": "National Hydro-Meteorological Service", - "event": "Severe Flooding", - "start": 1661870592, - "end": 1661956992, - "description": "Expect heavy localized flooding. Avoid low-lying areas.", - "affected_zones": [ - {"lat": 20.938611, "lng": 106.314444, "radius_km": 15}, - {"lat": 20.850000, "lng": 106.450000, "radius_km": 10}, - ], - } - ], - } - return { - "coord": {"lon": 105.8412, "lat": 21.0245}, - "weather": [ - {"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"} - ], - "base": "stations", - "main": { - "temp": 302.15, - "feels_like": 305.82, - "temp_min": 301.15, - "temp_max": 303.15, - "pressure": 1012, - "humidity": 65, - }, - "visibility": 10000, - "wind": {"speed": 3.6, "deg": 120}, - "clouds": {"all": 0}, - "name": "Hanoi", - "cod": 200, - } - - @router.get("/mock/routes") - def mock_routes() -> dict: - global MOCK_ENVIRONMENT - if MOCK_ENVIRONMENT in ["route_blockage", "compound_disruption"]: - return { - "geocoded_waypoints": [ - { - "geocoder_status": "OK", - "place_id": "ChIJ_zX0nVQoNTERr2gQWwP1WlA", - "types": ["locality", "political"], - }, - { - "geocoder_status": "OK", - "place_id": "ChIJIQBpAG2ocg0RsT57aPz_v0w", - "types": ["locality", "political"], - }, - ], - "routes": [ - { - "route_id": "R1", - "summary": "QL5 and AH14", - "legs": [ - { - "distance": {"text": "104 km", "value": 104320}, - "duration": {"text": "3 hours 45 mins", "value": 13500}, - "duration_in_traffic": { - "text": "4 hours 10 mins", - "value": 15000, - }, - "steps": [], - "traffic_speed_entry": [], - } - ], - "warnings": [ - "Severe flooding detected on QL5 near Hai Duong.", - "Road closure ahead due to submerged lane.", - "Expect heavy delays and consider alternative routing.", - ], - "status": "Blocked", - "closure_probability": 0.95, - "incidents": [ - { - "incident_id": "INC_001", - "type": "ROAD_CLOSED", - "description": "Lane submerged due to heavy flood", - "location": {"lat": 20.938611, "lng": 106.314444}, - }, - { - "incident_id": "INC_002", - "type": "ROAD_CLOSED", - "description": "Traffic accident associated with weather", - "location": {"lat": 20.942150, "lng": 106.321890}, - }, - ], - } - ], - "status": "OK", - } - return { - "geocoded_waypoints": [], - "routes": [ - { - "route_id": "R1", - "summary": "QL5 and AH14", - "legs": [ - { - "distance": {"text": "104 km", "value": 104320}, - "duration": {"text": "1 hour 50 mins", "value": 6600}, - "duration_in_traffic": { - "text": "1 hour 55 mins", - "value": 6900, - }, - } - ], - "warnings": [], - "status": "Normal", - "closure_probability": 0.05, - } - ], - "status": "OK", - } - - @router.get("/mock/suppliers") - def mock_suppliers() -> dict: - global MOCK_ENVIRONMENT - if MOCK_ENVIRONMENT in ["supplier_delay", "compound_disruption"]: - return { - "vendor_api_version": "v2.1", - "timestamp": "2026-04-13T08:00:00Z", - "vendors": [ - { - "vendor_id": "SUP_A", - "company_name": "Alpha Manufacturing Ltd.", - "region": "Shenzhen, CN", - "compliance_score": 0.92, - "facilities": [ - { - "facility_id": "FAC_A1", - "status": "Operational", - "capacity_utilization": 0.85, - }, - { - "facility_id": "FAC_A2", - "status": "Degraded", - "capacity_utilization": 0.30, - "active_alerts": [ - { - "alert_code": "CRIT_LABOR_SHORTAGE", - "severity": "HIGH", - "message": "Significant labor shortage impacting production line 3.", - }, - { - "alert_code": "MATERIAL_DELAY", - "severity": "MEDIUM", - "message": "Raw material shipments delayed at local port.", - }, - ], - }, - ], - "fulfillment_metrics": { - "on_time_delivery_rate": 0.65, - "average_delay_hours": 48.5, - "defect_rate": 0.015, - }, - "overall_status": "Delayed", - } - ], - } - return { - "vendor_api_version": "v2.1", - "timestamp": "2026-04-13T08:00:00Z", - "vendors": [ - { - "vendor_id": "SUP_A", - "company_name": "Alpha Manufacturing Ltd.", - "region": "Shenzhen, CN", - "compliance_score": 0.94, - "facilities": [ - { - "facility_id": "FAC_A1", - "status": "Operational", - "capacity_utilization": 0.78, - "active_alerts": [], - } - ], - "fulfillment_metrics": { - "on_time_delivery_rate": 0.98, - "average_delay_hours": 2.1, - "defect_rate": 0.005, - }, - "overall_status": "Normal", - } - ], - } - - @router.get("/control-tower/summary", response_model=ControlTowerSummaryResponse) - def get_control_tower_summary() -> ControlTowerSummaryResponse: - runtime = runtime_getter() - return control_tower_summary(runtime.state) - - @router.get("/control-tower/state", response_model=ControlTowerStateResponse) - def get_control_tower_state() -> ControlTowerStateResponse: - runtime = runtime_getter() - return control_tower_state(runtime.state) - - @router.get("/service/runtime", response_model=ServiceRuntimeResponse) - def get_service_runtime() -> ServiceRuntimeResponse: - runtime = runtime_getter() - return ServiceRuntimeResponse(item=service_runtime_view(runtime)) - - @router.get("/inventory", response_model=InventoryListResponse) - def get_inventory( - search: str | None = None, status: str | None = None - ) -> InventoryListResponse: - runtime = runtime_getter() - items = inventory_rows(runtime.state, search=search, status=status) - return InventoryListResponse(items=items, total=len(items)) - - @router.get("/suppliers", response_model=SupplierListResponse) - def get_suppliers( - sku: str | None = None, status: str | None = None - ) -> SupplierListResponse: - runtime = runtime_getter() - items = supplier_rows(runtime.state, sku=sku, status=status) - return SupplierListResponse(items=items, total=len(items)) - - @router.get("/events", response_model=EventListResponse) - def get_events(limit: int = 20) -> EventListResponse: - runtime = runtime_getter() - items = [ - item.model_copy(deep=True) - for item in control_tower_summary(runtime.state).active_events[ - : max(limit, 0) - ] - ] - return EventListResponse(items=items, total=len(runtime.state.active_events)) - - @router.get("/plans/latest", response_model=PlanDetailResponse) - def get_latest_plan() -> PlanDetailResponse: - runtime = runtime_getter() - return PlanDetailResponse( - item=plan_view(runtime.state.pending_plan or runtime.state.latest_plan) - ) - - @router.get("/approvals/pending", response_model=PendingApprovalResponse) - def get_pending_approval() -> PendingApprovalResponse: - runtime = runtime_getter() - return PendingApprovalResponse(item=pending_approval_view(runtime.state)) - - @router.get("/approvals/{decision_id}", response_model=ApprovalDetailResponse) - def get_approval(decision_id: str) -> ApprovalDetailResponse: - runtime = runtime_getter() - return ApprovalDetailResponse( - item=approval_detail_view(runtime.state, decision_id) - ) - - @router.post( - "/approvals/{decision_id}", response_model=ApprovalCommandResultResponse - ) + ControlTowerStateResponse, + ControlTowerSummaryResponse, + DecisionLogDetailResponse, + DecisionLogListResponse, + DispatchModeRequest, + EventIngestRequest, + EventIngestResponse, + EventListResponse, + EventRequest, + ExecutionDetailResponse, + ExecutionListResponse, + ErrorResponse, + InventoryListResponse, + LegacyApprovalRequest, + PendingApprovalResponse, + PlanDetailResponse, + PlanDispatchResponse, + ProgressRequest, + ReflectionListResponse, + RunDetailResponse, + RunListResponse, + RunStateResponse, + ScenarioRequest, + ServiceRuntimeResponse, + SupplierListResponse, + TraceResponse, + WhatIfRequest, +) +from app_api.services import ( + action_execution_record_view, + approval_command_result_view, + approval_detail_view, + ControlTowerRuntime, + build_event_from_request, + control_tower_state, + control_tower_summary, + decision_detail_view, + decision_summary_view, + event_envelope_view, + execution_record_view, + inventory_rows, + latest_trace_view, + pending_approval_view, + plan_view, + raise_not_found, + reflection_views, + run_record_view, + run_record_list_view, + scenario_outcomes, + service_runtime_view, + supplier_rows, + trace_view_from_record, + historical_control_tower_state, +) +from core.models import DecisionLog, OrchestrationTrace +from core.runtime_records import RunRecord + + +MOCK_ENVIRONMENT = "normal" + + +def create_router(runtime_getter: Callable[[], ControlTowerRuntime]) -> APIRouter: + router = APIRouter( + prefix="/api/v1", + responses={ + 404: {"model": ErrorResponse}, + 409: {"model": ErrorResponse}, + 422: {"model": ErrorResponse}, + 500: {"model": ErrorResponse}, + }, + ) + + @router.get("/mock/weather") + def mock_weather() -> dict: + global MOCK_ENVIRONMENT + if MOCK_ENVIRONMENT in ["route_blockage", "compound_disruption"]: + return { + "coord": {"lon": 105.8412, "lat": 21.0245}, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "heavy intensity rain and localized flooding", + "icon": "10d", + }, + { + "id": 202, + "main": "Thunderstorm", + "description": "thunderstorm with heavy rain", + "icon": "11d", + }, + ], + "base": "stations", + "main": { + "temp": 298.48, + "feels_like": 298.74, + "temp_min": 297.56, + "temp_max": 300.05, + "pressure": 1015, + "humidity": 89, + "sea_level": 1015, + "grnd_level": 1013, + }, + "visibility": 2000, + "wind": {"speed": 12.5, "deg": 140, "gust": 18.2}, + "clouds": {"all": 90}, + "dt": 1661870592, + "sys": { + "type": 2, + "id": 2004688, + "country": "VN", + "sunrise": 1661834187, + "sunset": 1661879324, + }, + "timezone": 25200, + "id": 1581130, + "name": "Hanoi", + "cod": 200, + "alerts": [ + { + "sender_name": "National Hydro-Meteorological Service", + "event": "Severe Flooding", + "start": 1661870592, + "end": 1661956992, + "description": "Expect heavy localized flooding. Avoid low-lying areas.", + "affected_zones": [ + {"lat": 20.938611, "lng": 106.314444, "radius_km": 15}, + {"lat": 20.850000, "lng": 106.450000, "radius_km": 10}, + ], + } + ], + } + return { + "coord": {"lon": 105.8412, "lat": 21.0245}, + "weather": [ + {"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"} + ], + "base": "stations", + "main": { + "temp": 302.15, + "feels_like": 305.82, + "temp_min": 301.15, + "temp_max": 303.15, + "pressure": 1012, + "humidity": 65, + }, + "visibility": 10000, + "wind": {"speed": 3.6, "deg": 120}, + "clouds": {"all": 0}, + "name": "Hanoi", + "cod": 200, + } + + @router.get("/mock/routes") + def mock_routes() -> dict: + global MOCK_ENVIRONMENT + if MOCK_ENVIRONMENT in ["route_blockage", "compound_disruption"]: + return { + "geocoded_waypoints": [ + { + "geocoder_status": "OK", + "place_id": "ChIJ_zX0nVQoNTERr2gQWwP1WlA", + "types": ["locality", "political"], + }, + { + "geocoder_status": "OK", + "place_id": "ChIJIQBpAG2ocg0RsT57aPz_v0w", + "types": ["locality", "political"], + }, + ], + "routes": [ + { + "route_id": "R1", + "summary": "QL5 and AH14", + "legs": [ + { + "distance": {"text": "104 km", "value": 104320}, + "duration": {"text": "3 hours 45 mins", "value": 13500}, + "duration_in_traffic": { + "text": "4 hours 10 mins", + "value": 15000, + }, + "steps": [], + "traffic_speed_entry": [], + } + ], + "warnings": [ + "Severe flooding detected on QL5 near Hai Duong.", + "Road closure ahead due to submerged lane.", + "Expect heavy delays and consider alternative routing.", + ], + "status": "Blocked", + "closure_probability": 0.95, + "incidents": [ + { + "incident_id": "INC_001", + "type": "ROAD_CLOSED", + "description": "Lane submerged due to heavy flood", + "location": {"lat": 20.938611, "lng": 106.314444}, + }, + { + "incident_id": "INC_002", + "type": "ROAD_CLOSED", + "description": "Traffic accident associated with weather", + "location": {"lat": 20.942150, "lng": 106.321890}, + }, + ], + } + ], + "status": "OK", + } + return { + "geocoded_waypoints": [], + "routes": [ + { + "route_id": "R1", + "summary": "QL5 and AH14", + "legs": [ + { + "distance": {"text": "104 km", "value": 104320}, + "duration": {"text": "1 hour 50 mins", "value": 6600}, + "duration_in_traffic": { + "text": "1 hour 55 mins", + "value": 6900, + }, + } + ], + "warnings": [], + "status": "Normal", + "closure_probability": 0.05, + } + ], + "status": "OK", + } + + @router.get("/mock/suppliers") + def mock_suppliers() -> dict: + global MOCK_ENVIRONMENT + if MOCK_ENVIRONMENT in ["supplier_delay", "compound_disruption"]: + return { + "vendor_api_version": "v2.1", + "timestamp": "2026-04-13T08:00:00Z", + "vendors": [ + { + "vendor_id": "SUP_A", + "company_name": "Alpha Manufacturing Ltd.", + "region": "Shenzhen, CN", + "compliance_score": 0.92, + "facilities": [ + { + "facility_id": "FAC_A1", + "status": "Operational", + "capacity_utilization": 0.85, + }, + { + "facility_id": "FAC_A2", + "status": "Degraded", + "capacity_utilization": 0.30, + "active_alerts": [ + { + "alert_code": "CRIT_LABOR_SHORTAGE", + "severity": "HIGH", + "message": "Significant labor shortage impacting production line 3.", + }, + { + "alert_code": "MATERIAL_DELAY", + "severity": "MEDIUM", + "message": "Raw material shipments delayed at local port.", + }, + ], + }, + ], + "fulfillment_metrics": { + "on_time_delivery_rate": 0.65, + "average_delay_hours": 48.5, + "defect_rate": 0.015, + }, + "overall_status": "Delayed", + } + ], + } + return { + "vendor_api_version": "v2.1", + "timestamp": "2026-04-13T08:00:00Z", + "vendors": [ + { + "vendor_id": "SUP_A", + "company_name": "Alpha Manufacturing Ltd.", + "region": "Shenzhen, CN", + "compliance_score": 0.94, + "facilities": [ + { + "facility_id": "FAC_A1", + "status": "Operational", + "capacity_utilization": 0.78, + "active_alerts": [], + } + ], + "fulfillment_metrics": { + "on_time_delivery_rate": 0.98, + "average_delay_hours": 2.1, + "defect_rate": 0.005, + }, + "overall_status": "Normal", + } + ], + } + + @router.get("/control-tower/summary", response_model=ControlTowerSummaryResponse) + def get_control_tower_summary() -> ControlTowerSummaryResponse: + runtime = runtime_getter() + return control_tower_summary(runtime.state) + + @router.get("/control-tower/state", response_model=ControlTowerStateResponse) + def get_control_tower_state() -> ControlTowerStateResponse: + runtime = runtime_getter() + return control_tower_state(runtime.state) + + @router.get("/service/runtime", response_model=ServiceRuntimeResponse) + def get_service_runtime() -> ServiceRuntimeResponse: + runtime = runtime_getter() + return ServiceRuntimeResponse(item=service_runtime_view(runtime)) + + @router.get("/inventory", response_model=InventoryListResponse) + def get_inventory( + search: str | None = None, status: str | None = None + ) -> InventoryListResponse: + runtime = runtime_getter() + items = inventory_rows(runtime.state, search=search, status=status) + return InventoryListResponse(items=items, total=len(items)) + + @router.get("/suppliers", response_model=SupplierListResponse) + def get_suppliers( + sku: str | None = None, status: str | None = None + ) -> SupplierListResponse: + runtime = runtime_getter() + items = supplier_rows(runtime.state, sku=sku, status=status) + return SupplierListResponse(items=items, total=len(items)) + + @router.get("/events", response_model=EventListResponse) + def get_events(limit: int = 20) -> EventListResponse: + runtime = runtime_getter() + items = [ + item.model_copy(deep=True) + for item in control_tower_summary(runtime.state).active_events[ + : max(limit, 0) + ] + ] + return EventListResponse(items=items, total=len(runtime.state.active_events)) + + @router.get("/plans/latest", response_model=PlanDetailResponse) + def get_latest_plan() -> PlanDetailResponse: + runtime = runtime_getter() + return PlanDetailResponse( + item=plan_view(runtime.state.pending_plan or runtime.state.latest_plan) + ) + + @router.get("/approvals/pending", response_model=PendingApprovalResponse) + def get_pending_approval() -> PendingApprovalResponse: + runtime = runtime_getter() + return PendingApprovalResponse(item=pending_approval_view(runtime.state)) + + @router.get("/approvals/{decision_id}", response_model=ApprovalDetailResponse) + def get_approval(decision_id: str) -> ApprovalDetailResponse: + runtime = runtime_getter() + return ApprovalDetailResponse( + item=approval_detail_view(runtime.state, decision_id) + ) + + @router.post( + "/approvals/{decision_id}", response_model=ApprovalCommandResultResponse + ) def approval_command( decision_id: str, request: ApprovalCommandRequest ) -> ApprovalCommandResultResponse: runtime = runtime_getter() runtime.approval_command(decision_id, request.action) - resolved_decision_id = runtime.current_decision_id() or decision_id - return approval_command_result_view( - runtime.state, - decision_id=resolved_decision_id, + resolved_decision_id = runtime.current_decision_id() or decision_id + return approval_command_result_view( + runtime.state, + decision_id=resolved_decision_id, action=request.action, execution=runtime.latest_execution(), ) - @router.get("/decision-logs", response_model=DecisionLogListResponse) - def get_decision_logs() -> DecisionLogListResponse: - runtime = runtime_getter() - return DecisionLogListResponse( - items=[ - decision_summary_view(DecisionLog.model_validate(item)) - for item in runtime.store.list_decision_logs() - ] - ) - - @router.get( - "/decision-logs/{decision_id}", response_model=DecisionLogDetailResponse - ) - def get_decision_log_detail(decision_id: str) -> DecisionLogDetailResponse: - runtime = runtime_getter() - payload = runtime.store.get_decision_log(decision_id) - if payload is None: - raise_not_found("decision", decision_id) - return DecisionLogDetailResponse( - item=decision_detail_view(DecisionLog.model_validate(payload)) - ) - - @router.get("/trace/latest", response_model=TraceResponse) - def get_latest_trace() -> TraceResponse: - runtime = runtime_getter() - return TraceResponse(item=latest_trace_view(runtime.state)) - - @router.get("/reflections", response_model=ReflectionListResponse) - def get_reflections() -> ReflectionListResponse: - runtime = runtime_getter() - memory = runtime.state.memory - return ReflectionListResponse( - items=reflection_views(runtime.state), - scenarios=scenario_outcomes(runtime.state), - pattern_tag_counts={} if memory is None else memory.pattern_tag_counts, - ) - - @router.get("/state") - def get_state() -> dict: - runtime = runtime_getter() - return { - "summary": control_tower_summary(runtime.state).model_dump(mode="json"), - "state": runtime.state.model_dump(mode="json"), - } - - @router.post("/reset") - def reset_state() -> dict: - global MOCK_ENVIRONMENT - MOCK_ENVIRONMENT = "normal" - runtime = runtime_getter() - runtime.reset() - return { - "summary": control_tower_summary(runtime.state).model_dump(mode="json"), - "state": runtime.state.model_dump(mode="json"), - } - - @router.post("/plan/daily") - def daily_plan() -> dict: - runtime = runtime_getter() - try: - runtime.run_daily() - except HTTPException as exc: - if exc.status_code == 409 and isinstance(exc.detail, dict): - raise HTTPException( - status_code=409, detail=exc.detail["message"] - ) from exc - raise - return runtime.legacy_response_payload() - - @router.post("/events") - def ingest_event(request: EventRequest) -> dict: - runtime = runtime_getter() - event = build_event_from_request( - event_type=request.type, - severity=request.severity, - source=request.source, - entity_ids=request.entity_ids, - payload=request.payload, - ) - runtime.ingest_event(event) - return runtime.legacy_response_payload() - - @router.post("/events/ingest", response_model=EventIngestResponse) - def ingest_envelope(request: EventIngestRequest) -> EventIngestResponse: - runtime = runtime_getter() - envelope, run_record, execution = runtime.ingest_envelope(request) - return EventIngestResponse( - event=event_envelope_view(envelope), - accepted=True, - run_id=None if run_record is None else run_record.run_id, - execution_id=None if execution is None else execution.execution_id, - ) - - @router.get("/runs/{run_id}", response_model=RunDetailResponse) - def get_run(run_id: str) -> RunDetailResponse: - runtime = runtime_getter() - payload = runtime.store.get_run_record(run_id) - if payload is None: - raise_not_found("run", run_id) - return RunDetailResponse(item=run_record_view(payload)) - - @router.get("/runs", response_model=RunListResponse) - def list_runs(limit: int = 20) -> RunListResponse: - runtime = runtime_getter() - items = runtime.store.list_run_records(limit=max(limit, 0)) - return RunListResponse( - items=run_record_list_view(items), - total=len(runtime.store.list_run_records(limit=None)), - ) - - @router.get("/runs/{run_id}/trace", response_model=TraceResponse) - def get_run_trace(run_id: str) -> TraceResponse: - runtime = runtime_getter() - trace_payload = runtime.store.get_trace(run_id) - if trace_payload is None: - raise_not_found("trace", run_id) - run_payload = runtime.store.get_run_record(run_id) - trace = OrchestrationTrace.model_validate(trace_payload) - run = RunRecord.model_validate(run_payload) if run_payload is not None else None - return TraceResponse(item=trace_view_from_record(trace, run=run)) - - @router.get("/runs/{run_id}/state", response_model=RunStateResponse) - def get_run_state(run_id: str) -> RunStateResponse: - runtime = runtime_getter() - run_payload = runtime.store.get_run_record(run_id) - if run_payload is None: - raise_not_found("run", run_id) - state_payload = runtime.store.get_state_snapshot(run_id) - if state_payload is None: - raise_not_found("state_snapshot", run_id) - return RunStateResponse( - run=run_record_view(run_payload), - state=historical_control_tower_state(state_payload), - ) - - @router.get("/execution", response_model=ExecutionListResponse) - def list_executions(limit: int = 50) -> ExecutionListResponse: - runtime = runtime_getter() - items = runtime.list_executions(limit=limit) - return ExecutionListResponse(items=items, total=len(items)) - - @router.get("/execution/{execution_id}", response_model=ExecutionDetailResponse) - def get_execution(execution_id: str) -> ExecutionDetailResponse: - runtime = runtime_getter() - payload = runtime.store.get_execution_record(execution_id) - if payload is not None: - return ExecutionDetailResponse(item=execution_record_view(payload)) - - # Try granular physical action records (prefix exec_) - action_payload = runtime.store.get_action_execution_record(execution_id) - if action_payload is not None: - return ExecutionDetailResponse( - item=action_execution_record_view(action_payload) - ) - - raise_not_found("execution", execution_id) - - @router.post("/execution/{plan_id}/dispatch", response_model=PlanDispatchResponse) - def dispatch_plan( - plan_id: str, request: DispatchModeRequest - ) -> PlanDispatchResponse: - runtime = runtime_getter() - return PlanDispatchResponse(**runtime.dispatch_plan(plan_id, request.mode)) - - @router.post( - "/execution/{execution_id}/progress", response_model=ActionExecutionRecordView - ) - def update_execution_progress( - execution_id: str, request: ProgressRequest - ) -> ActionExecutionRecordView: - runtime = runtime_getter() - return runtime.update_execution_progress(execution_id, request.percentage) - @router.post( - "/execution/{execution_id}/complete", response_model=ActionExecutionRecordView + "/approvals/{decision_id}/select-alternative", + response_model=ApprovalCommandResultResponse, ) - def complete_execution(execution_id: str) -> ActionExecutionRecordView: - runtime = runtime_getter() - return runtime.complete_execution(execution_id) - - @router.post("/scenarios/run") - def run_scenario(request: ScenarioRequest) -> dict: - global MOCK_ENVIRONMENT - MOCK_ENVIRONMENT = request.scenario_name - runtime = runtime_getter() - try: - runtime.run_scenario(request.scenario_name, seed=request.seed) - except HTTPException as exc: - if exc.status_code == 409 and isinstance(exc.detail, dict): - raise HTTPException( - status_code=409, detail=exc.detail["message"] - ) from exc - raise - payload = runtime.legacy_response_payload() - payload["scenario_history_count"] = len(runtime.state.scenario_history) - return payload - - @router.post("/decisions/{decision_id}/approve") - def approve_decision(decision_id: str, request: LegacyApprovalRequest) -> dict: + def select_approval_alternative( + decision_id: str, request: ApprovalAlternativeRequest + ) -> ApprovalCommandResultResponse: runtime = runtime_getter() - runtime.approval_command( - decision_id, "approve" if request.approve else "reject" + runtime.select_approval_alternative(decision_id, request.strategy_label) + resolved_decision_id = runtime.current_decision_id() or decision_id + return approval_command_result_view( + runtime.state, + decision_id=resolved_decision_id, + action="select_alternative", + execution=runtime.latest_execution(), ) - payload = runtime.legacy_response_payload() - payload["approved"] = request.approve - return payload - - @router.post("/decisions/{decision_id}/safer-plan") - def safer_plan(decision_id: str) -> dict: - runtime = runtime_getter() - runtime.approval_command(decision_id, "safer_plan") - return runtime.legacy_response_payload() - - @router.post("/what-if") - def what_if(request: WhatIfRequest) -> dict: - runtime = runtime_getter() - return runtime.what_if(request.scenario_name, seed=request.seed) - - return router + + @router.get("/decision-logs", response_model=DecisionLogListResponse) + def get_decision_logs() -> DecisionLogListResponse: + runtime = runtime_getter() + return DecisionLogListResponse( + items=[ + decision_summary_view(DecisionLog.model_validate(item)) + for item in runtime.store.list_decision_logs() + ] + ) + + @router.get( + "/decision-logs/{decision_id}", response_model=DecisionLogDetailResponse + ) + def get_decision_log_detail(decision_id: str) -> DecisionLogDetailResponse: + runtime = runtime_getter() + payload = runtime.store.get_decision_log(decision_id) + if payload is None: + raise_not_found("decision", decision_id) + return DecisionLogDetailResponse( + item=decision_detail_view(DecisionLog.model_validate(payload)) + ) + + @router.get("/trace/latest", response_model=TraceResponse) + def get_latest_trace() -> TraceResponse: + runtime = runtime_getter() + return TraceResponse(item=latest_trace_view(runtime.state)) + + @router.get("/reflections", response_model=ReflectionListResponse) + def get_reflections() -> ReflectionListResponse: + runtime = runtime_getter() + memory = runtime.state.memory + return ReflectionListResponse( + items=reflection_views(runtime.state), + scenarios=scenario_outcomes(runtime.state), + pattern_tag_counts={} if memory is None else memory.pattern_tag_counts, + ) + + @router.get("/state") + def get_state() -> dict: + runtime = runtime_getter() + return { + "summary": control_tower_summary(runtime.state).model_dump(mode="json"), + "state": runtime.state.model_dump(mode="json"), + } + + @router.post("/reset") + def reset_state() -> dict: + global MOCK_ENVIRONMENT + MOCK_ENVIRONMENT = "normal" + runtime = runtime_getter() + runtime.reset() + return { + "summary": control_tower_summary(runtime.state).model_dump(mode="json"), + "state": runtime.state.model_dump(mode="json"), + } + + @router.post("/plan/daily") + def daily_plan() -> dict: + runtime = runtime_getter() + try: + runtime.run_daily() + except HTTPException as exc: + if exc.status_code == 409 and isinstance(exc.detail, dict): + raise HTTPException( + status_code=409, detail=exc.detail["message"] + ) from exc + raise + return runtime.legacy_response_payload() + + @router.post("/events") + def ingest_event(request: EventRequest) -> dict: + runtime = runtime_getter() + event = build_event_from_request( + event_type=request.type, + severity=request.severity, + source=request.source, + entity_ids=request.entity_ids, + payload=request.payload, + ) + runtime.ingest_event(event) + return runtime.legacy_response_payload() + + @router.post("/events/ingest", response_model=EventIngestResponse) + def ingest_envelope(request: EventIngestRequest) -> EventIngestResponse: + runtime = runtime_getter() + envelope, run_record, execution = runtime.ingest_envelope(request) + return EventIngestResponse( + event=event_envelope_view(envelope), + accepted=True, + run_id=None if run_record is None else run_record.run_id, + execution_id=None if execution is None else execution.execution_id, + ) + + @router.get("/runs/{run_id}", response_model=RunDetailResponse) + def get_run(run_id: str) -> RunDetailResponse: + runtime = runtime_getter() + payload = runtime.store.get_run_record(run_id) + if payload is None: + raise_not_found("run", run_id) + return RunDetailResponse(item=run_record_view(payload)) + + @router.get("/runs", response_model=RunListResponse) + def list_runs(limit: int = 20) -> RunListResponse: + runtime = runtime_getter() + items = runtime.store.list_run_records(limit=max(limit, 0)) + return RunListResponse( + items=run_record_list_view(items), + total=len(runtime.store.list_run_records(limit=None)), + ) + + @router.get("/runs/{run_id}/trace", response_model=TraceResponse) + def get_run_trace(run_id: str) -> TraceResponse: + runtime = runtime_getter() + trace_payload = runtime.store.get_trace(run_id) + if trace_payload is None: + raise_not_found("trace", run_id) + run_payload = runtime.store.get_run_record(run_id) + trace = OrchestrationTrace.model_validate(trace_payload) + run = RunRecord.model_validate(run_payload) if run_payload is not None else None + return TraceResponse(item=trace_view_from_record(trace, run=run)) + + @router.get("/runs/{run_id}/state", response_model=RunStateResponse) + def get_run_state(run_id: str) -> RunStateResponse: + runtime = runtime_getter() + run_payload = runtime.store.get_run_record(run_id) + if run_payload is None: + raise_not_found("run", run_id) + state_payload = runtime.store.get_state_snapshot(run_id) + if state_payload is None: + raise_not_found("state_snapshot", run_id) + return RunStateResponse( + run=run_record_view(run_payload), + state=historical_control_tower_state(state_payload), + ) + + @router.get("/execution", response_model=ExecutionListResponse) + def list_executions(limit: int = 50) -> ExecutionListResponse: + runtime = runtime_getter() + items = runtime.list_executions(limit=limit) + return ExecutionListResponse(items=items, total=len(items)) + + @router.get("/execution/{execution_id}", response_model=ExecutionDetailResponse) + def get_execution(execution_id: str) -> ExecutionDetailResponse: + runtime = runtime_getter() + payload = runtime.store.get_execution_record(execution_id) + if payload is not None: + return ExecutionDetailResponse(item=execution_record_view(payload)) + + # Try granular physical action records (prefix exec_) + action_payload = runtime.store.get_action_execution_record(execution_id) + if action_payload is not None: + return ExecutionDetailResponse( + item=action_execution_record_view(action_payload) + ) + + raise_not_found("execution", execution_id) + + @router.post("/execution/{plan_id}/dispatch", response_model=PlanDispatchResponse) + def dispatch_plan( + plan_id: str, request: DispatchModeRequest + ) -> PlanDispatchResponse: + runtime = runtime_getter() + return PlanDispatchResponse(**runtime.dispatch_plan(plan_id, request.mode)) + + @router.post( + "/execution/{execution_id}/progress", response_model=ActionExecutionRecordView + ) + def update_execution_progress( + execution_id: str, request: ProgressRequest + ) -> ActionExecutionRecordView: + runtime = runtime_getter() + return runtime.update_execution_progress(execution_id, request.percentage) + + @router.post( + "/execution/{execution_id}/complete", response_model=ActionExecutionRecordView + ) + def complete_execution(execution_id: str) -> ActionExecutionRecordView: + runtime = runtime_getter() + return runtime.complete_execution(execution_id) + + @router.post("/scenarios/run") + def run_scenario(request: ScenarioRequest) -> dict: + global MOCK_ENVIRONMENT + MOCK_ENVIRONMENT = request.scenario_name + runtime = runtime_getter() + try: + runtime.run_scenario(request.scenario_name, seed=request.seed) + except HTTPException as exc: + if exc.status_code == 409 and isinstance(exc.detail, dict): + raise HTTPException( + status_code=409, detail=exc.detail["message"] + ) from exc + raise + payload = runtime.legacy_response_payload() + payload["scenario_history_count"] = len(runtime.state.scenario_history) + return payload + + @router.post("/decisions/{decision_id}/approve") + def approve_decision(decision_id: str, request: LegacyApprovalRequest) -> dict: + runtime = runtime_getter() + runtime.approval_command( + decision_id, "approve" if request.approve else "reject" + ) + payload = runtime.legacy_response_payload() + payload["approved"] = request.approve + return payload + + @router.post("/decisions/{decision_id}/safer-plan") + def safer_plan(decision_id: str) -> dict: + runtime = runtime_getter() + runtime.approval_command(decision_id, "safer_plan") + return runtime.legacy_response_payload() + + @router.post("/what-if") + def what_if(request: WhatIfRequest) -> dict: + runtime = runtime_getter() + return runtime.what_if(request.scenario_name, seed=request.seed) + + return router diff --git a/app_api/schemas.py b/app_api/schemas.py index 3c211a0..29dab33 100644 --- a/app_api/schemas.py +++ b/app_api/schemas.py @@ -1,583 +1,591 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any, Literal - -from pydantic import BaseModel, Field - -from core.enums import EventType -from core.runtime_records import DispatchMode, EventClass, ExecutionStatus, RunStatus, RunType - - -class ScenarioRequest(BaseModel): - scenario_name: str - seed: int = 7 - - -class WhatIfRequest(BaseModel): - scenario_name: str - seed: int = 7 - - -class EventRequest(BaseModel): - type: EventType - severity: float = Field(ge=0.0, le=1.0) - source: str = "api" - entity_ids: list[str] = Field(default_factory=list) - payload: dict[str, Any] = Field(default_factory=dict) - - -class LegacyApprovalRequest(BaseModel): - approve: bool = True - - -class DispatchModeRequest(BaseModel): - mode: str = Field(default="dry_run", pattern="^(dry_run|commit)$") - - -class ProgressRequest(BaseModel): - percentage: float = Field(ge=0.0, le=100.0) - - +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from core.enums import EventType +from core.runtime_records import DispatchMode, EventClass, ExecutionStatus, RunStatus, RunType + + +class ScenarioRequest(BaseModel): + scenario_name: str + seed: int = 7 + + +class WhatIfRequest(BaseModel): + scenario_name: str + seed: int = 7 + + +class EventRequest(BaseModel): + type: EventType + severity: float = Field(ge=0.0, le=1.0) + source: str = "api" + entity_ids: list[str] = Field(default_factory=list) + payload: dict[str, Any] = Field(default_factory=dict) + + +class LegacyApprovalRequest(BaseModel): + approve: bool = True + + +class DispatchModeRequest(BaseModel): + mode: str = Field(default="dry_run", pattern="^(dry_run|commit)$") + + +class ProgressRequest(BaseModel): + percentage: float = Field(ge=0.0, le=100.0) + + class ApprovalCommandRequest(BaseModel): action: Literal["approve", "reject", "safer_plan"] -class EventIngestRequest(BaseModel): - event_class: EventClass - event_type: str - source: str = "api" - occurred_at: datetime | None = None - correlation_id: str | None = None - causation_id: str | None = None - idempotency_key: str | None = None - severity: float = Field(default=0.0, ge=0.0, le=1.0) - entity_ids: list[str] = Field(default_factory=list) - payload: dict[str, Any] = Field(default_factory=dict) - - -class KPIView(BaseModel): - service_level: float - total_cost: float - disruption_risk: float - recovery_speed: float - stockout_risk: float - decision_latency_ms: float - - -class AlertView(BaseModel): - level: Literal["info", "warning", "critical"] - title: str - message: str - source: str - event_type: str | None = None - entity_ids: list[str] = Field(default_factory=list) - - -class EventView(BaseModel): - event_id: str - type: str - severity: float - source: str - entity_ids: list[str] = Field(default_factory=list) - occurred_at: datetime - detected_at: datetime - payload: dict[str, Any] = Field(default_factory=dict) - - -class EventEnvelopeView(BaseModel): - event_id: str - event_class: str - event_type: str - source: str - occurred_at: datetime - ingested_at: datetime - correlation_id: str - causation_id: str | None = None - idempotency_key: str - severity: float - entity_ids: list[str] = Field(default_factory=list) - payload: dict[str, Any] = Field(default_factory=dict) - - -class ActionView(BaseModel): - action_id: str - action_type: str - target_id: str - reason: str - priority: float - estimated_cost_delta: float - estimated_service_delta: float - estimated_risk_delta: float - estimated_recovery_hours: float - parameters: dict[str, Any] = Field(default_factory=dict) - - -class ConstraintViolationView(BaseModel): - code: str - message: str - action_id: str | None = None - severity: str = "hard" +class ApprovalAlternativeRequest(BaseModel): + strategy_label: str +class EventIngestRequest(BaseModel): + event_class: EventClass + event_type: str + source: str = "api" + occurred_at: datetime | None = None + correlation_id: str | None = None + causation_id: str | None = None + idempotency_key: str | None = None + severity: float = Field(default=0.0, ge=0.0, le=1.0) + entity_ids: list[str] = Field(default_factory=list) + payload: dict[str, Any] = Field(default_factory=dict) + + +class KPIView(BaseModel): + service_level: float + total_cost: float + disruption_risk: float + recovery_speed: float + stockout_risk: float + decision_latency_ms: float + + +class AlertView(BaseModel): + level: Literal["info", "warning", "critical"] + title: str + message: str + source: str + event_type: str | None = None + entity_ids: list[str] = Field(default_factory=list) + + +class EventView(BaseModel): + event_id: str + type: str + severity: float + source: str + entity_ids: list[str] = Field(default_factory=list) + occurred_at: datetime + detected_at: datetime + payload: dict[str, Any] = Field(default_factory=dict) + + +class EventEnvelopeView(BaseModel): + event_id: str + event_class: str + event_type: str + source: str + occurred_at: datetime + ingested_at: datetime + correlation_id: str + causation_id: str | None = None + idempotency_key: str + severity: float + entity_ids: list[str] = Field(default_factory=list) + payload: dict[str, Any] = Field(default_factory=dict) + + +class ActionView(BaseModel): + action_id: str + action_type: str + target_id: str + reason: str + priority: float + estimated_cost_delta: float + estimated_service_delta: float + estimated_risk_delta: float + estimated_recovery_hours: float + parameters: dict[str, Any] = Field(default_factory=dict) + + +class ConstraintViolationView(BaseModel): + code: str + message: str + action_id: str | None = None + severity: str = "hard" + + class CandidateEvaluationView(BaseModel): strategy_label: str action_ids: list[str] = Field(default_factory=list) score: float score_breakdown: dict[str, float] = Field(default_factory=dict) - projected_kpis: KPIView - feasible: bool = True - violations: list[ConstraintViolationView] = Field(default_factory=list) - mode_rationale: str = "" + projected_kpis: KPIView + feasible: bool = True + violations: list[ConstraintViolationView] = Field(default_factory=list) + mode_rationale: str = "" approval_required: bool approval_reason: str rationale: str llm_used: bool = False - - -class PlanView(BaseModel): - plan_id: str - decision_id: str | None = None - mode: str - status: str - score: float - score_breakdown: dict[str, float] = Field(default_factory=dict) - feasible: bool = True - violations: list[ConstraintViolationView] = Field(default_factory=list) - mode_rationale: str = "" - strategy_label: str | None = None - generated_by: str | None = None - approval_required: bool = False - approval_reason: str = "" - approval_status: str = "not_required" - planner_reasoning: str = "" - llm_planner_narrative: str | None = None - critic_summary: str | None = None - trigger_event_ids: list[str] = Field(default_factory=list) - actions: list[ActionView] = Field(default_factory=list) - metadata: dict[str, Any] = Field(default_factory=dict) - - -class PendingApprovalView(BaseModel): - decision_id: str - approval_status: str - approval_reason: str - allowed_actions: list[str] = Field(default_factory=lambda: ["approve", "reject", "safer_plan"]) - blocking_operations: list[str] = Field(default_factory=lambda: ["plan_daily", "scenario_run"]) - selection_reason: str = "" - selected_actions: list[str] = Field(default_factory=list) - before_kpis: KPIView - projected_kpis: KPIView - candidate_count: int = 0 - plan: PlanView - - -class ApprovalDetailView(BaseModel): - decision_id: str - plan_id: str - approval_required: bool - approval_status: str - approval_reason: str - is_pending: bool = False - allowed_actions: list[str] = Field(default_factory=list) - selection_reason: str = "" - selected_actions: list[str] = Field(default_factory=list) - event_ids: list[str] = Field(default_factory=list) - before_kpis: KPIView - after_kpis: KPIView - candidate_count: int = 0 - plan: PlanView - - -class ApprovalCommandResultResponse(BaseModel): - decision_id: str - action: str - approval_status: str - message: str - latest_plan: PlanView | None = None - pending_approval: PendingApprovalView | None = None - execution: ExecutionRecordView | None = None - latest_trace: TraceView | None = None - summary: ControlTowerSummaryResponse | None = None - - -class DecisionLogSummaryView(BaseModel): - decision_id: str - plan_id: str - approval_status: str - approval_required: bool - approval_reason: str - selection_reason: str - selected_actions: list[str] = Field(default_factory=list) - event_ids: list[str] = Field(default_factory=list) - llm_used: bool = False - - -class DecisionLogDetailView(BaseModel): - decision_id: str - plan_id: str - approval_status: str - approval_required: bool - approval_reason: str - rationale: str - selection_reason: str - mode_rationale: str = "" - winning_factors: list[str] = Field(default_factory=list) - score_breakdown: dict[str, float] = Field(default_factory=dict) - selected_actions: list[str] = Field(default_factory=list) - rejected_actions: list[dict[str, str]] = Field(default_factory=list) - candidate_evaluations: list[CandidateEvaluationView] = Field(default_factory=list) - critic_summary: str | None = None - critic_findings: list[str] = Field(default_factory=list) - llm_used: bool = False - llm_provider: str | None = None - llm_model: str | None = None - llm_error: str | None = None - before_kpis: KPIView - after_kpis: KPIView - - -class InventoryRowView(BaseModel): - sku: str - name: str | None = None - warehouse_id: str - on_hand: int - incoming_qty: int - forecast_qty: int - reorder_point: int - safety_stock: int - unit_cost: float - status: str - preferred_supplier_id: str - preferred_route_id: str - - -class SupplierRowView(BaseModel): - supplier_id: str - sku: str - unit_cost: float - lead_time_days: int - reliability: float - is_primary: bool - status: str - tradeoff: str - - -class AgentStepView(BaseModel): - step_id: str | None = None - sequence: int = 0 - agent: str - node_type: str - status: str - started_at: datetime - completed_at: datetime | None = None - duration_ms: float | None = None - mode_snapshot: str - summary: str - reasoning_source: str = "" - input_snapshot: dict[str, Any] = Field(default_factory=dict) - output_snapshot: dict[str, Any] = Field(default_factory=dict) - observations: list[str] = Field(default_factory=list) - risks: list[str] = Field(default_factory=list) - downstream_impacts: list[str] = Field(default_factory=list) - recommended_action_ids: list[str] = Field(default_factory=list) - tradeoffs: list[str] = Field(default_factory=list) - llm_used: bool = False - llm_error: str | None = None - fallback_used: bool = False - fallback_reason: str | None = None - - -class RouteDecisionView(BaseModel): - from_node: str - outcome: str - to_node: str - reason: str = "" - - -class TraceView(BaseModel): - run_id: str | None = None - trace_id: str | None = None - status: str = "idle" - started_at: datetime | None = None - completed_at: datetime | None = None - mode_before: str | None = None - mode_after: str | None = None - mode: str - current_branch: str - terminal_stage: str | None = None - event: EventView | None = None - route_decisions: list[RouteDecisionView] = Field(default_factory=list) - steps: list[AgentStepView] = Field(default_factory=list) - latest_plan: PlanView | None = None - decision_id: str | None = None - selected_strategy: str | None = None - candidate_count: int = 0 - selection_reason: str | None = None - candidate_evaluations: list[CandidateEvaluationView] = Field(default_factory=list) - approval_pending: bool = False - approval_reason: str = "" - execution_status: str | None = None - critic_summary: str | None = None - - -class SelectedPlanSummaryView(BaseModel): - plan_id: str - strategy_label: str | None = None - generated_by: str | None = None - approval_required: bool = False - approval_reason: str = "" - score: float = 0.0 - action_ids: list[str] = Field(default_factory=list) - - -class ExecutionSummaryView(BaseModel): - status: str - dispatch_mode: str - action_ids: list[str] = Field(default_factory=list) - - -class RunView(BaseModel): - run_id: str - run_type: RunType - parent_run_id: str | None = None - correlation_id: str - trigger_event_id: str | None = None - input_event_ids: list[str] = Field(default_factory=list) - mode_before: str - mode_after: str - status: RunStatus - started_at: datetime - completed_at: datetime | None = None - duration_ms: float = 0.0 - decision_id: str | None = None - selected_plan_id: str | None = None - execution_id: str | None = None - approval_status: str | None = None - llm_fallback_used: bool = False - llm_fallback_reason: str | None = None - selected_plan_summary: SelectedPlanSummaryView | None = None - execution_summary: ExecutionSummaryView | None = None - - -class ExecutionReceiptView(BaseModel): - receipt_id: str - action_id: str - status: str - detail: str = "" - - -class ExecutionTransitionView(BaseModel): - status: str - timestamp: datetime - reason: str = "" - - -class ExecutionRecordView(BaseModel): - execution_id: str - run_id: str - decision_id: str | None = None - plan_id: str | None = None - status: ExecutionStatus - dispatch_mode: DispatchMode - dry_run: bool = False - target_system: str - action_ids: list[str] = Field(default_factory=list) - receipts: list[ExecutionReceiptView] = Field(default_factory=list) - status_history: list[ExecutionTransitionView] = Field(default_factory=list) - failure_reason: str | None = None - created_at: datetime - updated_at: datetime - - + coverage_fraction: float = 0.0 + critical_covered: int = 0 + unresolved_critical: int = 0 + + +class PlanView(BaseModel): + plan_id: str + decision_id: str | None = None + mode: str + status: str + score: float + score_breakdown: dict[str, float] = Field(default_factory=dict) + feasible: bool = True + violations: list[ConstraintViolationView] = Field(default_factory=list) + mode_rationale: str = "" + strategy_label: str | None = None + generated_by: str | None = None + approval_required: bool = False + approval_reason: str = "" + approval_status: str = "not_required" + planner_reasoning: str = "" + llm_planner_narrative: str | None = None + critic_summary: str | None = None + trigger_event_ids: list[str] = Field(default_factory=list) + actions: list[ActionView] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class PendingApprovalView(BaseModel): + decision_id: str + approval_status: str + approval_reason: str + allowed_actions: list[str] = Field(default_factory=lambda: ["approve", "reject", "safer_plan"]) + blocking_operations: list[str] = Field(default_factory=lambda: ["plan_daily", "scenario_run"]) + selection_reason: str = "" + selected_actions: list[str] = Field(default_factory=list) + before_kpis: KPIView + projected_kpis: KPIView + candidate_count: int = 0 + plan: PlanView + + +class ApprovalDetailView(BaseModel): + decision_id: str + plan_id: str + approval_required: bool + approval_status: str + approval_reason: str + is_pending: bool = False + allowed_actions: list[str] = Field(default_factory=list) + selection_reason: str = "" + selected_actions: list[str] = Field(default_factory=list) + event_ids: list[str] = Field(default_factory=list) + before_kpis: KPIView + after_kpis: KPIView + candidate_count: int = 0 + plan: PlanView + + +class ApprovalCommandResultResponse(BaseModel): + decision_id: str + action: str + approval_status: str + message: str + latest_plan: PlanView | None = None + pending_approval: PendingApprovalView | None = None + execution: ExecutionRecordView | None = None + latest_trace: TraceView | None = None + summary: ControlTowerSummaryResponse | None = None + + +class DecisionLogSummaryView(BaseModel): + decision_id: str + plan_id: str + approval_status: str + approval_required: bool + approval_reason: str + selection_reason: str + selected_actions: list[str] = Field(default_factory=list) + event_ids: list[str] = Field(default_factory=list) + llm_used: bool = False + + +class DecisionLogDetailView(BaseModel): + decision_id: str + plan_id: str + approval_status: str + approval_required: bool + approval_reason: str + rationale: str + selection_reason: str + mode_rationale: str = "" + winning_factors: list[str] = Field(default_factory=list) + score_breakdown: dict[str, float] = Field(default_factory=dict) + selected_actions: list[str] = Field(default_factory=list) + rejected_actions: list[dict[str, str]] = Field(default_factory=list) + candidate_evaluations: list[CandidateEvaluationView] = Field(default_factory=list) + critic_summary: str | None = None + critic_findings: list[str] = Field(default_factory=list) + llm_used: bool = False + llm_provider: str | None = None + llm_model: str | None = None + llm_error: str | None = None + before_kpis: KPIView + after_kpis: KPIView + + +class InventoryRowView(BaseModel): + sku: str + name: str | None = None + warehouse_id: str + on_hand: int + incoming_qty: int + forecast_qty: int + reorder_point: int + safety_stock: int + unit_cost: float + status: str + preferred_supplier_id: str + preferred_route_id: str + + +class SupplierRowView(BaseModel): + supplier_id: str + sku: str + unit_cost: float + lead_time_days: int + reliability: float + is_primary: bool + status: str + tradeoff: str + + +class AgentStepView(BaseModel): + step_id: str | None = None + sequence: int = 0 + agent: str + node_type: str + status: str + started_at: datetime + completed_at: datetime | None = None + duration_ms: float | None = None + mode_snapshot: str + summary: str + reasoning_source: str = "" + input_snapshot: dict[str, Any] = Field(default_factory=dict) + output_snapshot: dict[str, Any] = Field(default_factory=dict) + observations: list[str] = Field(default_factory=list) + risks: list[str] = Field(default_factory=list) + downstream_impacts: list[str] = Field(default_factory=list) + recommended_action_ids: list[str] = Field(default_factory=list) + tradeoffs: list[str] = Field(default_factory=list) + llm_used: bool = False + llm_error: str | None = None + fallback_used: bool = False + fallback_reason: str | None = None + + +class RouteDecisionView(BaseModel): + from_node: str + outcome: str + to_node: str + reason: str = "" + + +class TraceView(BaseModel): + run_id: str | None = None + trace_id: str | None = None + status: str = "idle" + started_at: datetime | None = None + completed_at: datetime | None = None + mode_before: str | None = None + mode_after: str | None = None + mode: str + current_branch: str + terminal_stage: str | None = None + event: EventView | None = None + route_decisions: list[RouteDecisionView] = Field(default_factory=list) + steps: list[AgentStepView] = Field(default_factory=list) + latest_plan: PlanView | None = None + decision_id: str | None = None + selected_strategy: str | None = None + candidate_count: int = 0 + selection_reason: str | None = None + candidate_evaluations: list[CandidateEvaluationView] = Field(default_factory=list) + approval_pending: bool = False + approval_reason: str = "" + execution_status: str | None = None + critic_summary: str | None = None + + +class SelectedPlanSummaryView(BaseModel): + plan_id: str + strategy_label: str | None = None + generated_by: str | None = None + approval_required: bool = False + approval_reason: str = "" + score: float = 0.0 + action_ids: list[str] = Field(default_factory=list) + + +class ExecutionSummaryView(BaseModel): + status: str + dispatch_mode: str + action_ids: list[str] = Field(default_factory=list) + + +class RunView(BaseModel): + run_id: str + run_type: RunType + parent_run_id: str | None = None + correlation_id: str + trigger_event_id: str | None = None + input_event_ids: list[str] = Field(default_factory=list) + mode_before: str + mode_after: str + status: RunStatus + started_at: datetime + completed_at: datetime | None = None + duration_ms: float = 0.0 + decision_id: str | None = None + selected_plan_id: str | None = None + execution_id: str | None = None + approval_status: str | None = None + llm_fallback_used: bool = False + llm_fallback_reason: str | None = None + selected_plan_summary: SelectedPlanSummaryView | None = None + execution_summary: ExecutionSummaryView | None = None + + +class ExecutionReceiptView(BaseModel): + receipt_id: str + action_id: str + status: str + detail: str = "" + + +class ExecutionTransitionView(BaseModel): + status: str + timestamp: datetime + reason: str = "" + + +class ExecutionRecordView(BaseModel): + execution_id: str + run_id: str + decision_id: str | None = None + plan_id: str | None = None + status: ExecutionStatus + dispatch_mode: DispatchMode + dry_run: bool = False + target_system: str + action_ids: list[str] = Field(default_factory=list) + receipts: list[ExecutionReceiptView] = Field(default_factory=list) + status_history: list[ExecutionTransitionView] = Field(default_factory=list) + failure_reason: str | None = None + created_at: datetime + updated_at: datetime + + class ActionExecutionRecordView(BaseModel): execution_id: str plan_id: str + dispatch_mode: str = "dry_run" action_id: str action_type: str - target_system: str - payload: dict[str, Any] = Field(default_factory=dict) - idempotency_key: str - status: str - receipt: dict[str, Any] = Field(default_factory=dict) - failure_reason: str | None = None - is_retryable: bool = False - created_at: datetime - dispatched_at: datetime | None = None - applied_at: datetime | None = None - estimated_completion_at: datetime | None = None - progress_percentage: float = 0.0 - - -class PlanDispatchResponse(BaseModel): - plan_id: str - dispatch_mode: str - plan_execution_status: str - overall_progress: float - records: list[ActionExecutionRecordView] - compensation_hints: list[str] = Field(default_factory=list) - - -class ReflectionView(BaseModel): - note_id: str - run_id: str - scenario_id: str - plan_id: str | None = None - mode: str - approval_status: str - summary: str - lessons: list[str] = Field(default_factory=list) - pattern_tags: list[str] = Field(default_factory=list) - follow_up_checks: list[str] = Field(default_factory=list) - llm_used: bool = False - llm_error: str | None = None - - -class ScenarioOutcomeView(BaseModel): - scenario_id: str - runs: int - latest_run_id: str | None = None - latest_plan_id: str | None = None - latest_approval_status: str | None = None - latest_reflection_status: str | None = None - latest_kpis: dict[str, Any] = Field(default_factory=dict) - history: list[dict[str, Any]] = Field(default_factory=list) - - -class ControlTowerSummaryResponse(BaseModel): - mode: str - kpis: KPIView - alerts: list[AlertView] = Field(default_factory=list) - active_events: list[EventView] = Field(default_factory=list) - latest_plan: PlanView | None = None - pending_approval: PendingApprovalView | None = None - decision_count: int = 0 - scenario_history_count: int = 0 - - -class ControlTowerStateResponse(BaseModel): - summary: ControlTowerSummaryResponse - inventory: list[InventoryRowView] = Field(default_factory=list) - suppliers: list[SupplierRowView] = Field(default_factory=list) - reflections: list[ReflectionView] = Field(default_factory=list) - latest_trace: TraceView | None = None - - -class InventoryListResponse(BaseModel): - items: list[InventoryRowView] - total: int - - -class SupplierListResponse(BaseModel): - items: list[SupplierRowView] - total: int - - -class EventListResponse(BaseModel): - items: list[EventView] - total: int - - -class EventIngestResponse(BaseModel): - event: EventEnvelopeView - accepted: bool = True - run_id: str | None = None - execution_id: str | None = None - - -class PlanDetailResponse(BaseModel): - item: PlanView | None = None - - -class PendingApprovalResponse(BaseModel): - item: PendingApprovalView | None = None - - -class ApprovalDetailResponse(BaseModel): - item: ApprovalDetailView - - -class DecisionLogListResponse(BaseModel): - items: list[DecisionLogSummaryView] - - -class DecisionLogDetailResponse(BaseModel): - item: DecisionLogDetailView - - -class TraceResponse(BaseModel): - item: TraceView - - -class RunDetailResponse(BaseModel): - item: RunView - - -class RunListResponse(BaseModel): - items: list[RunView] - total: int - - -class RunStateResponse(BaseModel): - run: RunView - state: ControlTowerStateResponse - - -class ExecutionDetailResponse(BaseModel): - item: ExecutionRecordView | ActionExecutionRecordView - - -class ExecutionListResponse(BaseModel): - items: list[ActionExecutionRecordView] - total: int - - -class ReflectionListResponse(BaseModel): - items: list[ReflectionView] - scenarios: list[ScenarioOutcomeView] - pattern_tag_counts: dict[str, int] = Field(default_factory=dict) - - -class ServiceFlagsView(BaseModel): - llm_enabled: bool - llm_provider: str - llm_model: str - llm_timeout_s: float - llm_retry_attempts: int - planner_mode: str - dispatch_mode: str - degraded_mode: str = "deterministic_fallback" - - -class ServiceMetricsView(BaseModel): - total_runs: int - completed_runs: int - failed_runs: int - total_events: int - total_executions: int - avg_run_duration_ms: float - avg_agent_step_duration_ms: float - llm_fallback_rate: float - approval_rate: float - execution_failure_rate: float - latest_run_id: str | None = None - - -class ServiceRuntimeView(BaseModel): - flags: ServiceFlagsView - metrics: ServiceMetricsView - - -class ServiceRuntimeResponse(BaseModel): - item: ServiceRuntimeView - - -class ErrorResponse(BaseModel): - code: str - message: str - details: dict[str, Any] = Field(default_factory=dict) - retryable: bool = False - correlation_id: str | None = None - - -ApprovalCommandResultResponse.model_rebuild() + target_system: str + payload: dict[str, Any] = Field(default_factory=dict) + idempotency_key: str + status: str + receipt: dict[str, Any] = Field(default_factory=dict) + failure_reason: str | None = None + is_retryable: bool = False + created_at: datetime + dispatched_at: datetime | None = None + applied_at: datetime | None = None + estimated_completion_at: datetime | None = None + progress_percentage: float = 0.0 + + +class PlanDispatchResponse(BaseModel): + plan_id: str + dispatch_mode: str + plan_execution_status: str + overall_progress: float + records: list[ActionExecutionRecordView] + compensation_hints: list[str] = Field(default_factory=list) + + +class ReflectionView(BaseModel): + note_id: str + run_id: str + scenario_id: str + plan_id: str | None = None + mode: str + approval_status: str + summary: str + lessons: list[str] = Field(default_factory=list) + pattern_tags: list[str] = Field(default_factory=list) + follow_up_checks: list[str] = Field(default_factory=list) + llm_used: bool = False + llm_error: str | None = None + + +class ScenarioOutcomeView(BaseModel): + scenario_id: str + runs: int + latest_run_id: str | None = None + latest_plan_id: str | None = None + latest_approval_status: str | None = None + latest_reflection_status: str | None = None + latest_kpis: dict[str, Any] = Field(default_factory=dict) + history: list[dict[str, Any]] = Field(default_factory=list) + + +class ControlTowerSummaryResponse(BaseModel): + mode: str + kpis: KPIView + alerts: list[AlertView] = Field(default_factory=list) + active_events: list[EventView] = Field(default_factory=list) + latest_plan: PlanView | None = None + pending_approval: PendingApprovalView | None = None + decision_count: int = 0 + scenario_history_count: int = 0 + + +class ControlTowerStateResponse(BaseModel): + summary: ControlTowerSummaryResponse + inventory: list[InventoryRowView] = Field(default_factory=list) + suppliers: list[SupplierRowView] = Field(default_factory=list) + reflections: list[ReflectionView] = Field(default_factory=list) + latest_trace: TraceView | None = None + + +class InventoryListResponse(BaseModel): + items: list[InventoryRowView] + total: int + + +class SupplierListResponse(BaseModel): + items: list[SupplierRowView] + total: int + + +class EventListResponse(BaseModel): + items: list[EventView] + total: int + + +class EventIngestResponse(BaseModel): + event: EventEnvelopeView + accepted: bool = True + run_id: str | None = None + execution_id: str | None = None + + +class PlanDetailResponse(BaseModel): + item: PlanView | None = None + + +class PendingApprovalResponse(BaseModel): + item: PendingApprovalView | None = None + + +class ApprovalDetailResponse(BaseModel): + item: ApprovalDetailView + + +class DecisionLogListResponse(BaseModel): + items: list[DecisionLogSummaryView] + + +class DecisionLogDetailResponse(BaseModel): + item: DecisionLogDetailView + + +class TraceResponse(BaseModel): + item: TraceView + + +class RunDetailResponse(BaseModel): + item: RunView + + +class RunListResponse(BaseModel): + items: list[RunView] + total: int + + +class RunStateResponse(BaseModel): + run: RunView + state: ControlTowerStateResponse + + +class ExecutionDetailResponse(BaseModel): + item: ExecutionRecordView | ActionExecutionRecordView + + +class ExecutionListResponse(BaseModel): + items: list[ActionExecutionRecordView] + total: int + + +class ReflectionListResponse(BaseModel): + items: list[ReflectionView] + scenarios: list[ScenarioOutcomeView] + pattern_tag_counts: dict[str, int] = Field(default_factory=dict) + + +class ServiceFlagsView(BaseModel): + llm_enabled: bool + llm_provider: str + llm_model: str + llm_timeout_s: float + llm_retry_attempts: int + planner_mode: str + dispatch_mode: str + degraded_mode: str = "deterministic_fallback" + + +class ServiceMetricsView(BaseModel): + total_runs: int + completed_runs: int + failed_runs: int + total_events: int + total_executions: int + avg_run_duration_ms: float + avg_agent_step_duration_ms: float + llm_fallback_rate: float + approval_rate: float + execution_failure_rate: float + latest_run_id: str | None = None + + +class ServiceRuntimeView(BaseModel): + flags: ServiceFlagsView + metrics: ServiceMetricsView + + +class ServiceRuntimeResponse(BaseModel): + item: ServiceRuntimeView + + +class ErrorResponse(BaseModel): + code: str + message: str + details: dict[str, Any] = Field(default_factory=dict) + retryable: bool = False + correlation_id: str | None = None + + +ApprovalCommandResultResponse.model_rebuild() diff --git a/app_api/services.py b/app_api/services.py index af2c3c8..f9e6e0a 100644 --- a/app_api/services.py +++ b/app_api/services.py @@ -1,439 +1,497 @@ from __future__ import annotations from dataclasses import dataclass +import math from typing import Any from uuid import uuid4 - -from fastapi import HTTPException - -from core.enums import ApprovalStatus, EventType -from core.memory import SQLiteStore -from core.models import Action, ConstraintViolation, DecisionLog, Event, OrchestrationTrace, Plan, SystemState -from core.runtime_records import ( - EventClass, - EventEnvelope, - ExecutionRecord, - ExecutionStatus, - RunRecord, - RunStatus, - RunType, -) -from core.runtime_tracking import ( - advance_execution_record, - build_execution_record, - build_run_record, - clone_trace_for_run, - new_correlation_id, - new_event_envelope_id, - new_run_id, + +from fastapi import HTTPException + +from core.enums import ApprovalStatus, EventType +from core.memory import SQLiteStore +from core.models import Action, ConstraintViolation, DecisionLog, Event, OrchestrationTrace, Plan, SystemState +from core.runtime_records import ( + EventClass, + EventEnvelope, + ExecutionRecord, + ExecutionStatus, + RunRecord, + RunStatus, + RunType, +) +from core.runtime_tracking import ( + advance_execution_record, + build_execution_record, + build_run_record, + clone_trace_for_run, + new_correlation_id, + new_event_envelope_id, + new_run_id, +) +from core.state import ( + clone_state, + load_initial_state, + recompute_kpis, + refresh_operational_baseline, + state_summary, + utc_now, ) -from core.state import clone_state, load_initial_state, recompute_kpis, state_summary, utc_now -from llm.config import load_settings - -from orchestrator.graph import build_graph +from llm.config import load_settings + +from orchestrator.graph import build_graph from orchestrator.service import ( PendingApprovalError, approve_pending_plan, request_safer_plan, reset_runtime, run_daily_plan, + select_pending_alternative_plan, ) -from simulation.runner import ScenarioRunner -from simulation.scenarios import get_scenario_events, list_scenarios -from execution.dispatch_service import ActionDispatchService +from simulation.runner import ScenarioRunner +from simulation.scenarios import get_scenario_events, list_scenarios +from execution.dispatch_service import ActionDispatchService from execution.models import ( ExecutionRecord as ActionExecutionRecord, ) - -from app_api.schemas import ( - ActionView, - ActionExecutionRecordView, - ApprovalCommandResultResponse, - ApprovalDetailView, - AlertView, - AgentStepView, - CandidateEvaluationView, - ConstraintViolationView, - ControlTowerStateResponse, - ControlTowerSummaryResponse, - DecisionLogDetailView, - DecisionLogSummaryView, - EventEnvelopeView, - EventIngestRequest, - EventView, - ExecutionRecordView, - InventoryRowView, - KPIView, - PendingApprovalView, - PlanView, - ReflectionView, - RunView, - RouteDecisionView, - ScenarioOutcomeView, - ServiceFlagsView, - ServiceMetricsView, - ServiceRuntimeView, - SupplierRowView, - TraceView, -) - - -def _error_detail( - code: str, - message: str, - *, - details: dict[str, Any] | None = None, - retryable: bool = False, - correlation_id: str | None = None, -) -> dict[str, Any]: - return { - "code": code, - "message": message, - "details": details or {}, - "retryable": retryable, - "correlation_id": correlation_id, - } - - -def raise_not_found(resource: str, resource_id: str) -> None: - raise HTTPException( - status_code=404, - detail=_error_detail( - code=f"{resource}_not_found", - message=f"{resource} not found", - details={"id": resource_id}, - ), - ) - - -def raise_conflict(message: str, *, code: str = "invalid_state") -> None: - raise HTTPException( - status_code=409, - detail=_error_detail(code=code, message=message, retryable=False), - ) - - -@dataclass -class ControlTowerRuntime: - store: SQLiteStore - state: SystemState - graph: Any - runner: ScenarioRunner - dispatch_service: ActionDispatchService - - @classmethod - def create(cls, store: SQLiteStore | None = None) -> "ControlTowerRuntime": +from execution.state_machine import compute_overall_progress, compute_plan_execution_status + +from app_api.schemas import ( + ActionView, + ActionExecutionRecordView, + ApprovalCommandResultResponse, + ApprovalDetailView, + AlertView, + AgentStepView, + CandidateEvaluationView, + ConstraintViolationView, + ControlTowerStateResponse, + ControlTowerSummaryResponse, + DecisionLogDetailView, + DecisionLogSummaryView, + EventEnvelopeView, + EventIngestRequest, + EventView, + ExecutionRecordView, + InventoryRowView, + KPIView, + PendingApprovalView, + PlanView, + ReflectionView, + RunView, + RouteDecisionView, + ScenarioOutcomeView, + ServiceFlagsView, + ServiceMetricsView, + ServiceRuntimeView, + SupplierRowView, + TraceView, +) + + +def _error_detail( + code: str, + message: str, + *, + details: dict[str, Any] | None = None, + retryable: bool = False, + correlation_id: str | None = None, +) -> dict[str, Any]: + return { + "code": code, + "message": message, + "details": details or {}, + "retryable": retryable, + "correlation_id": correlation_id, + } + + +def raise_not_found(resource: str, resource_id: str) -> None: + raise HTTPException( + status_code=404, + detail=_error_detail( + code=f"{resource}_not_found", + message=f"{resource} not found", + details={"id": resource_id}, + ), + ) + + +def raise_conflict(message: str, *, code: str = "invalid_state") -> None: + raise HTTPException( + status_code=409, + detail=_error_detail(code=code, message=message, retryable=False), + ) + + +@dataclass +class ControlTowerRuntime: + store: SQLiteStore + state: SystemState + graph: Any + runner: ScenarioRunner + dispatch_service: ActionDispatchService + + @classmethod + def create(cls, store: SQLiteStore | None = None) -> "ControlTowerRuntime": local_store = store or SQLiteStore() return cls( store=local_store, - state=load_initial_state(), + state=refresh_operational_baseline(load_initial_state()), graph=build_graph(), runner=ScenarioRunner(store=local_store), dispatch_service=ActionDispatchService(), ) - + def reinitialize(self, store: SQLiteStore | None = None) -> None: self.store = store or self.store - self.state = load_initial_state() + self.state = refresh_operational_baseline(load_initial_state()) self.graph = build_graph() self.runner = ScenarioRunner(store=self.store) self.dispatch_service = ActionDispatchService() - + def reset(self) -> SystemState: - self.state = reset_runtime(self.store) + self.state = refresh_operational_baseline(reset_runtime(self.store)) self.graph = build_graph() self.runner = ScenarioRunner(store=self.store) self.dispatch_service = ActionDispatchService() - return self.state - - def current_decision_id(self) -> str | None: - if not self.state.decision_logs: - return None - return self.state.decision_logs[-1].decision_id - - def _latest_decision(self) -> DecisionLog | None: - return self.state.decision_logs[-1] if self.state.decision_logs else None - - def _prepare_approval_trace(self, run_id: str) -> None: - now = utc_now() - self.state.run_id = run_id - self.state.latest_trace = OrchestrationTrace( - trace_id=f"trace_{uuid4().hex[:8]}", - run_id=run_id, - started_at=now, - mode_before=self.state.mode.value, - current_branch="approval", - ) - - def _parent_execution_id(self, parent_run_id: str | None) -> str | None: - if parent_run_id is None: - return None - payload = self.store.get_run_record(parent_run_id) - if payload is None: - return None - return payload.get("execution_id") - - def _execution_for_id(self, execution_id: str | None) -> ExecutionRecord | None: - if execution_id is None: - return None - payload = self.store.get_execution_record(execution_id) - if payload is None: - return None - return ExecutionRecord.model_validate(payload) - - def _latest_execution_for_decision(self, decision_id: str) -> ExecutionRecord | None: - matching_runs = [ - item - for item in self.store.list_run_records(limit=None) - if item.get("decision_id") == decision_id and item.get("execution_id") - ] - if not matching_runs: - return None - matching_runs.sort(key=lambda item: item.get("started_at", ""), reverse=True) - return self._execution_for_id(matching_runs[0].get("execution_id")) - - def _persist_runtime_artifacts( - self, - *, - run_id: str, - run_type: RunType, - started_at, - parent_run_id: str | None, - envelope: EventEnvelope | None = None, - execution_record: ExecutionRecord | None = None, - ) -> tuple[RunRecord, ExecutionRecord | None]: - decision = self._latest_decision() - execution = execution_record or build_execution_record( - run_id=run_id, - state=self.state, - decision=decision, - ) - if execution is not None: - self.store.save_execution_record(execution) - run_record = build_run_record( - run_id=run_id, - run_type=run_type, - state=self.state, - started_at=started_at, - parent_run_id=parent_run_id, - envelope=envelope, - execution_id=execution.execution_id if execution is not None else None, - ) - self.store.save_run_record(run_record) - trace = clone_trace_for_run(self.state.latest_trace, run_id) - if trace is not None: - self.store.save_trace(run_id, trace) - self.store.save_state(self.state) - if decision is not None: - self.store.save_decision_log(decision) - return run_record, execution - - def latest_execution(self) -> ExecutionRecord | None: - latest_run = self.store.get_run_record(self.state.run_id) - if latest_run is None: - return None - return self._execution_for_id(latest_run.get("execution_id")) - - def legacy_response_payload(self) -> dict[str, Any]: - return { - "summary": state_summary(self.state), - "latest_plan": self.state.latest_plan.model_dump(mode="json") if self.state.latest_plan else None, - "pending_plan": self.state.pending_plan.model_dump(mode="json") if self.state.pending_plan else None, - "decision_id": self.current_decision_id(), - } - - def run_daily(self) -> SystemState: - started_at = utc_now() - parent_run_id = self.state.run_id - run_id = new_run_id() - try: - self.state = run_daily_plan(self.state, self.store, graph=self.graph, run_id=run_id) - except PendingApprovalError as exc: - raise_conflict(str(exc), code="pending_approval") - self._persist_runtime_artifacts( - run_id=run_id, - run_type=RunType.DAILY_CYCLE, - started_at=started_at, - parent_run_id=parent_run_id, - ) - return self.state - - def ingest_event(self, event: Event) -> SystemState: - started_at = utc_now() - parent_run_id = self.state.run_id - run_id = new_run_id() - envelope = EventEnvelope( - event_id=event.event_id, - event_class=EventClass.DOMAIN, - event_type=event.type.value, - source=event.source, - occurred_at=event.occurred_at, - ingested_at=event.detected_at, - correlation_id=new_correlation_id("event"), - causation_id=None, - idempotency_key=event.dedupe_key, - severity=event.severity, - entity_ids=event.entity_ids, - payload=event.payload, - ) - self.store.save_event_envelope(envelope) - self.state = self.graph.invoke(self.state, event, run_id=run_id) - self._persist_runtime_artifacts( - run_id=run_id, - run_type=RunType.EVENT_RESPONSE, - started_at=started_at, - parent_run_id=parent_run_id, - envelope=envelope, - ) - return self.state - - def ingest_envelope(self, request: EventIngestRequest) -> tuple[EventEnvelope, RunRecord | None, ExecutionRecord | None]: - occurred_at = request.occurred_at or utc_now() - ingested_at = utc_now() - correlation_id = request.correlation_id or new_correlation_id(request.event_class.value) - envelope = EventEnvelope( - event_id=new_event_envelope_id(), - event_class=request.event_class, - event_type=request.event_type, - source=request.source, - occurred_at=occurred_at, - ingested_at=ingested_at, - correlation_id=correlation_id, - causation_id=request.causation_id, - idempotency_key=request.idempotency_key or f"{request.event_type}:{request.entity_ids}:{request.payload}", - severity=request.severity, - entity_ids=request.entity_ids, - payload=request.payload, - ) - self.store.save_event_envelope(envelope) - started_at = ingested_at - parent_run_id = self.state.run_id - if request.event_class == EventClass.SYSTEM: - return envelope, None, None - if request.event_class == EventClass.COMMAND: - if request.event_type != "plan_requested": + return self.state + + def current_decision_id(self) -> str | None: + if not self.state.decision_logs: + return None + return self.state.decision_logs[-1].decision_id + + def _latest_decision(self) -> DecisionLog | None: + return self.state.decision_logs[-1] if self.state.decision_logs else None + + def _prepare_approval_trace(self, run_id: str) -> None: + now = utc_now() + self.state.run_id = run_id + self.state.latest_trace = OrchestrationTrace( + trace_id=f"trace_{uuid4().hex[:8]}", + run_id=run_id, + started_at=now, + mode_before=self.state.mode.value, + current_branch="approval", + ) + + def _parent_execution_id(self, parent_run_id: str | None) -> str | None: + if parent_run_id is None: + return None + payload = self.store.get_run_record(parent_run_id) + if payload is None: + return None + return payload.get("execution_id") + + def _execution_for_id(self, execution_id: str | None) -> ExecutionRecord | None: + if execution_id is None: + return None + payload = self.store.get_execution_record(execution_id) + if payload is None: + return None + return ExecutionRecord.model_validate(payload) + + def _latest_execution_for_decision(self, decision_id: str) -> ExecutionRecord | None: + matching_runs = [ + item + for item in self.store.list_run_records(limit=None) + if item.get("decision_id") == decision_id and item.get("execution_id") + ] + if not matching_runs: + return None + matching_runs.sort(key=lambda item: item.get("started_at", ""), reverse=True) + return self._execution_for_id(matching_runs[0].get("execution_id")) + + def _persist_runtime_artifacts( + self, + *, + run_id: str, + run_type: RunType, + started_at, + parent_run_id: str | None, + envelope: EventEnvelope | None = None, + execution_record: ExecutionRecord | None = None, + ) -> tuple[RunRecord, ExecutionRecord | None]: + decision = self._latest_decision() + execution = execution_record or build_execution_record( + run_id=run_id, + state=self.state, + decision=decision, + ) + if execution is not None: + self.store.save_execution_record(execution) + run_record = build_run_record( + run_id=run_id, + run_type=run_type, + state=self.state, + started_at=started_at, + parent_run_id=parent_run_id, + envelope=envelope, + execution_id=execution.execution_id if execution is not None else None, + ) + self.store.save_run_record(run_record) + trace = clone_trace_for_run(self.state.latest_trace, run_id) + if trace is not None: + self.store.save_trace(run_id, trace) + self.store.save_state(self.state) + if decision is not None: + self.store.save_decision_log(decision) + return run_record, execution + + def latest_execution(self) -> ExecutionRecord | None: + latest_run = self.store.get_run_record(self.state.run_id) + if latest_run is None: + return None + return self._execution_for_id(latest_run.get("execution_id")) + + def legacy_response_payload(self) -> dict[str, Any]: + return { + "summary": state_summary(self.state), + "latest_plan": self.state.latest_plan.model_dump(mode="json") if self.state.latest_plan else None, + "pending_plan": self.state.pending_plan.model_dump(mode="json") if self.state.pending_plan else None, + "decision_id": self.current_decision_id(), + } + + def run_daily(self) -> SystemState: + started_at = utc_now() + parent_run_id = self.state.run_id + run_id = new_run_id() + try: + self.state = run_daily_plan(self.state, self.store, graph=self.graph, run_id=run_id) + except PendingApprovalError as exc: + raise_conflict(str(exc), code="pending_approval") + self._persist_runtime_artifacts( + run_id=run_id, + run_type=RunType.DAILY_CYCLE, + started_at=started_at, + parent_run_id=parent_run_id, + ) + return self.state + + def ingest_event(self, event: Event) -> SystemState: + started_at = utc_now() + parent_run_id = self.state.run_id + run_id = new_run_id() + envelope = EventEnvelope( + event_id=event.event_id, + event_class=EventClass.DOMAIN, + event_type=event.type.value, + source=event.source, + occurred_at=event.occurred_at, + ingested_at=event.detected_at, + correlation_id=new_correlation_id("event"), + causation_id=None, + idempotency_key=event.dedupe_key, + severity=event.severity, + entity_ids=event.entity_ids, + payload=event.payload, + ) + self.store.save_event_envelope(envelope) + self.state = self.graph.invoke(self.state, event, run_id=run_id) + self._persist_runtime_artifacts( + run_id=run_id, + run_type=RunType.EVENT_RESPONSE, + started_at=started_at, + parent_run_id=parent_run_id, + envelope=envelope, + ) + return self.state + + def ingest_envelope(self, request: EventIngestRequest) -> tuple[EventEnvelope, RunRecord | None, ExecutionRecord | None]: + occurred_at = request.occurred_at or utc_now() + ingested_at = utc_now() + correlation_id = request.correlation_id or new_correlation_id(request.event_class.value) + envelope = EventEnvelope( + event_id=new_event_envelope_id(), + event_class=request.event_class, + event_type=request.event_type, + source=request.source, + occurred_at=occurred_at, + ingested_at=ingested_at, + correlation_id=correlation_id, + causation_id=request.causation_id, + idempotency_key=request.idempotency_key or f"{request.event_type}:{request.entity_ids}:{request.payload}", + severity=request.severity, + entity_ids=request.entity_ids, + payload=request.payload, + ) + self.store.save_event_envelope(envelope) + started_at = ingested_at + parent_run_id = self.state.run_id + if request.event_class == EventClass.SYSTEM: + return envelope, None, None + if request.event_class == EventClass.COMMAND: + if request.event_type != "plan_requested": + raise HTTPException( + status_code=422, + detail=_error_detail( + code="unsupported_command_event", + message="command event_type must be plan_requested", + correlation_id=correlation_id, + ), + ) + run_id = new_run_id() + try: + self.state = run_daily_plan(self.state, self.store, graph=self.graph, run_id=run_id) + except PendingApprovalError as exc: + raise_conflict(str(exc), code="pending_approval") + return (envelope, *self._persist_runtime_artifacts( + run_id=run_id, + run_type=RunType.DAILY_CYCLE, + started_at=started_at, + parent_run_id=parent_run_id, + envelope=envelope, + )) + try: + event = build_event_from_envelope(envelope) + except ValueError as exc: + raise HTTPException( + status_code=422, + detail=_error_detail( + code="unsupported_domain_event", + message=str(exc), + correlation_id=correlation_id, + ), + ) from exc + run_id = new_run_id() + self.state = self.graph.invoke(self.state, event, run_id=run_id) + return (envelope, *self._persist_runtime_artifacts( + run_id=run_id, + run_type=RunType.EVENT_RESPONSE, + started_at=started_at, + parent_run_id=parent_run_id, + envelope=envelope, + )) + + def run_scenario(self, scenario_name: str, seed: int) -> SystemState: + if scenario_name not in list_scenarios(): + raise_not_found("scenario", scenario_name) + try: + self.state = self.runner.run(self.state, scenario_name, seed=seed) + except PendingApprovalError as exc: + raise_conflict(str(exc), code="pending_approval") + return self.state + + def approval_command(self, decision_id: str, action: str) -> SystemState: + started_at = utc_now() + parent_run_id = self.state.run_id + run_id = new_run_id() + self._prepare_approval_trace(run_id) + parent_execution = self._latest_execution_for_decision(decision_id) + if parent_execution is None: + parent_execution = self.latest_execution() + if parent_execution is None: + parent_execution = self._execution_for_id(self._parent_execution_id(parent_run_id)) + execution_record: ExecutionRecord | None = None + try: + if action == "approve": + self.state = approve_pending_plan(self.state, self.store, decision_id, True, run_id=run_id) + decision = self._latest_decision() + if parent_execution is not None: + execution_record = advance_execution_record( + parent_execution, + run_id=run_id, + decision_id=decision.decision_id if decision else decision_id, + plan=self.state.latest_plan, + status=ExecutionStatus.APPROVED, + reason="operator approved execution; awaiting dispatch", + ) + elif action == "reject": + self.state = approve_pending_plan(self.state, self.store, decision_id, False, run_id=run_id) + decision = self._latest_decision() + if parent_execution is not None: + execution_record = advance_execution_record( + parent_execution, + run_id=run_id, + decision_id=decision.decision_id if decision else decision_id, + plan=self.state.latest_plan, + status=ExecutionStatus.CANCELLED, + reason="operator rejected the pending execution", + ) + elif action == "safer_plan": + self.state = request_safer_plan(self.state, self.store, decision_id, run_id=run_id) + decision = self._latest_decision() + if parent_execution is not None: + cancelled = advance_execution_record( + parent_execution, + run_id=run_id, + decision_id=decision_id, + plan=self.state.latest_plan, + status=ExecutionStatus.CANCELLED, + reason="pending execution superseded by safer plan request", + ) + self.store.save_execution_record(cancelled) + execution_record = build_execution_record( + run_id=run_id, + state=self.state, + decision=decision, + ) + else: raise HTTPException( status_code=422, detail=_error_detail( - code="unsupported_command_event", - message="command event_type must be plan_requested", - correlation_id=correlation_id, + code="invalid_approval_action", + message="approval action must be approve, reject, or safer_plan", ), ) - run_id = new_run_id() - try: - self.state = run_daily_plan(self.state, self.store, graph=self.graph, run_id=run_id) - except PendingApprovalError as exc: - raise_conflict(str(exc), code="pending_approval") - return (envelope, *self._persist_runtime_artifacts( - run_id=run_id, - run_type=RunType.DAILY_CYCLE, - started_at=started_at, - parent_run_id=parent_run_id, - envelope=envelope, - )) - try: - event = build_event_from_envelope(envelope) - except ValueError as exc: - raise HTTPException( - status_code=422, - detail=_error_detail( - code="unsupported_domain_event", - message=str(exc), - correlation_id=correlation_id, - ), - ) from exc - run_id = new_run_id() - self.state = self.graph.invoke(self.state, event, run_id=run_id) - return (envelope, *self._persist_runtime_artifacts( + except ValueError: + raise_not_found("decision", decision_id) + except RuntimeError as exc: + raise_conflict(str(exc), code="approval_conflict") + self._persist_runtime_artifacts( run_id=run_id, - run_type=RunType.EVENT_RESPONSE, + run_type=RunType.APPROVAL_RESOLUTION, started_at=started_at, parent_run_id=parent_run_id, - envelope=envelope, - )) - - def run_scenario(self, scenario_name: str, seed: int) -> SystemState: - if scenario_name not in list_scenarios(): - raise_not_found("scenario", scenario_name) - try: - self.state = self.runner.run(self.state, scenario_name, seed=seed) - except PendingApprovalError as exc: - raise_conflict(str(exc), code="pending_approval") + execution_record=execution_record, + ) return self.state - def approval_command(self, decision_id: str, action: str) -> SystemState: + def select_approval_alternative(self, decision_id: str, strategy_label: str) -> SystemState: started_at = utc_now() parent_run_id = self.state.run_id run_id = new_run_id() self._prepare_approval_trace(run_id) + parent_execution = self._latest_execution_for_decision(decision_id) if parent_execution is None: parent_execution = self.latest_execution() if parent_execution is None: parent_execution = self._execution_for_id(self._parent_execution_id(parent_run_id)) + execution_record: ExecutionRecord | None = None try: - if action == "approve": - self.state = approve_pending_plan(self.state, self.store, decision_id, True, run_id=run_id) - decision = self._latest_decision() - if parent_execution is not None: - approved = advance_execution_record( - parent_execution, - run_id=run_id, - decision_id=decision.decision_id if decision else decision_id, - plan=self.state.latest_plan, - status=ExecutionStatus.APPROVED, - reason="operator approved execution in simulation", - ) - execution_record = advance_execution_record( - approved, - run_id=run_id, - decision_id=decision.decision_id if decision else decision_id, - plan=self.state.latest_plan, - status=ExecutionStatus.APPLIED, - reason="approved execution applied in simulation", - ) - elif action == "reject": - self.state = approve_pending_plan(self.state, self.store, decision_id, False, run_id=run_id) - decision = self._latest_decision() - if parent_execution is not None: - execution_record = advance_execution_record( - parent_execution, - run_id=run_id, - decision_id=decision.decision_id if decision else decision_id, - plan=self.state.latest_plan, - status=ExecutionStatus.CANCELLED, - reason="operator rejected the pending execution", - ) - elif action == "safer_plan": - self.state = request_safer_plan(self.state, self.store, decision_id, run_id=run_id) - decision = self._latest_decision() - if parent_execution is not None: - cancelled = advance_execution_record( - parent_execution, - run_id=run_id, - decision_id=decision_id, - plan=self.state.latest_plan, - status=ExecutionStatus.CANCELLED, - reason="pending execution superseded by safer plan request", - ) - self.store.save_execution_record(cancelled) - execution_record = build_execution_record( + self.state = select_pending_alternative_plan( + self.state, + self.store, + decision_id, + strategy_label, + run_id=run_id, + ) + decision = self._latest_decision() + if parent_execution is not None: + cancelled = advance_execution_record( + parent_execution, run_id=run_id, - state=self.state, - decision=decision, + decision_id=decision_id, + plan=self.state.latest_plan, + status=ExecutionStatus.CANCELLED, + reason=f"pending execution superseded by selected alternative strategy ({strategy_label})", ) - else: - raise HTTPException( - status_code=422, - detail=_error_detail( - code="invalid_approval_action", - message="approval action must be approve, reject, or safer_plan", - ), - ) - except ValueError: + self.store.save_execution_record(cancelled) + execution_record = build_execution_record( + run_id=run_id, + state=self.state, + decision=decision, + ) + except ValueError as exc: + message = str(exc) + if message.startswith("candidate strategy not found"): + raise_not_found("candidate_strategy", strategy_label) raise_not_found("decision", decision_id) + except RuntimeError as exc: + raise_conflict(str(exc), code="approval_conflict") + self._persist_runtime_artifacts( run_id=run_id, run_type=RunType.APPROVAL_RESOLUTION, @@ -442,20 +500,20 @@ def approval_command(self, decision_id: str, action: str) -> SystemState: execution_record=execution_record, ) return self.state - - def what_if(self, scenario_name: str, seed: int) -> dict[str, Any]: - del seed - if scenario_name not in list_scenarios(): - raise_not_found("scenario", scenario_name) - simulated = clone_state(self.state) - for event in get_scenario_events(scenario_name): - simulated = self.graph.invoke(simulated, event) - return { - "scenario_name": scenario_name, - "summary": state_summary(simulated), - "latest_plan": simulated.latest_plan.model_dump(mode="json") if simulated.latest_plan else None, - } - + + def what_if(self, scenario_name: str, seed: int) -> dict[str, Any]: + del seed + if scenario_name not in list_scenarios(): + raise_not_found("scenario", scenario_name) + simulated = clone_state(self.state) + for event in get_scenario_events(scenario_name): + simulated = self.graph.invoke(simulated, event) + return { + "scenario_name": scenario_name, + "summary": state_summary(simulated), + "latest_plan": simulated.latest_plan.model_dump(mode="json") if simulated.latest_plan else None, + } + def dispatch_plan(self, plan_id: str, mode: str) -> dict[str, Any]: plan = None if self.state.latest_plan and self.state.latest_plan.plan_id == plan_id: @@ -466,441 +524,537 @@ def dispatch_plan(self, plan_id: str, mode: str) -> dict[str, Any]: if plan is None: raise_not_found("plan", plan_id) - summary = self.dispatch_service.dispatch_plan(plan, mode=mode) - - # PERSIST granular records for polling - for record in summary.records: - self.store.save_action_execution_record(record.execution_id, record.model_dump(mode="json")) - - # Mapping to PlanDispatchResponse format - return { - "plan_id": summary.plan_id, - "dispatch_mode": summary.dispatch_mode, - "plan_execution_status": summary.plan_execution_status.value, - "overall_progress": summary.overall_progress, - "records": [action_execution_record_view(r) for r in summary.records], - "compensation_hints": summary.compensation_hints, - } - - def update_execution_progress(self, execution_id: str, percentage: float) -> ActionExecutionRecordView: - payload = self.store.get_action_execution_record(execution_id) - if payload is None: - # Fallback for old/missing records - record = ActionExecutionRecord( - execution_id=execution_id, - plan_id="manual", - action_id="manual", - action_type="REORDER", - target_system="ERP", - idempotency_key=f"manual_{execution_id}", - ) - else: - record = ActionExecutionRecord.model_validate(payload) + if mode == "commit": + existing_records = [ + ActionExecutionRecord.model_validate(item) + for item in self.store.list_action_execution_records(limit=None) + if item.get("plan_id") == plan_id and item.get("dispatch_mode", "dry_run") == mode + ] + if existing_records: + existing_records.sort(key=lambda record: record.created_at) + return { + "plan_id": plan_id, + "dispatch_mode": mode, + "plan_execution_status": compute_plan_execution_status(existing_records).value, + "overall_progress": compute_overall_progress(existing_records), + "records": [action_execution_record_view(r) for r in existing_records], + "compensation_hints": [], + } - record.set_progress(percentage) - if percentage >= 100.0: - record.mark_completed() + summary = self.dispatch_service.dispatch_plan(plan, mode=mode) - self.store.save_action_execution_record(record.execution_id, record.model_dump(mode="json")) - return action_execution_record_view(record) - - def complete_execution(self, execution_id: str) -> ActionExecutionRecordView: - return self.update_execution_progress(execution_id, 100.0) - - def list_executions(self, limit: int = 20) -> list[ActionExecutionRecordView]: - records = self.store.list_action_execution_records(limit=limit) - return [action_execution_record_view(r) for r in records] - - -def make_runtime(store: SQLiteStore | None = None) -> ControlTowerRuntime: - return ControlTowerRuntime.create(store=store) - - -def _average(values: list[float]) -> float: - if not values: - return 0.0 - return round(sum(values) / len(values), 2) - - -def service_flags_view() -> ServiceFlagsView: - settings = load_settings() - return ServiceFlagsView( - llm_enabled=settings.enabled, - llm_provider=settings.provider, - llm_model=settings.model, - llm_timeout_s=settings.timeout_s, - llm_retry_attempts=settings.retry_attempts, - planner_mode=settings.planner_mode, - dispatch_mode=settings.dispatch_mode, - ) - - -def service_metrics_view(runtime: ControlTowerRuntime) -> ServiceMetricsView: - runs = [RunRecord.model_validate(item) for item in runtime.store.list_run_records(limit=None)] - traces = [OrchestrationTrace.model_validate(item) for item in runtime.store.list_traces(limit=None)] - executions = [ExecutionRecord.model_validate(item) for item in runtime.store.list_execution_records(limit=None)] - total_runs = len(runs) - completed_runs = sum(1 for item in runs if item.status == RunStatus.COMPLETED) - failed_runs = sum(1 for item in runs if item.status == RunStatus.FAILED) - run_durations = [item.duration_ms for item in runs if item.duration_ms > 0.0] - step_durations = [ - step.duration_ms - for trace in traces - for step in trace.steps - if step.duration_ms is not None and step.duration_ms >= 0.0 - ] - approval_count = sum( - 1 - for item in runs - if item.approval_status in { - ApprovalStatus.PENDING.value, - ApprovalStatus.APPROVED.value, - ApprovalStatus.REJECTED.value, - } - ) - execution_failures = sum( - 1 for item in executions if item.status in {ExecutionStatus.FAILED, ExecutionStatus.ROLLED_BACK} - ) - return ServiceMetricsView( - total_runs=total_runs, - completed_runs=completed_runs, - failed_runs=failed_runs, - total_events=len(runtime.store.list_event_envelopes(limit=None)), - total_executions=len(executions), - avg_run_duration_ms=_average(run_durations), - avg_agent_step_duration_ms=_average(step_durations), - llm_fallback_rate=round( - sum(1 for item in runs if item.llm_fallback_used) / total_runs, - 4, - ) - if total_runs - else 0.0, - approval_rate=round(approval_count / total_runs, 4) if total_runs else 0.0, - execution_failure_rate=round(execution_failures / len(executions), 4) if executions else 0.0, - latest_run_id=runs[0].run_id if runs else None, - ) - - -def service_runtime_view(runtime: ControlTowerRuntime) -> ServiceRuntimeView: - return ServiceRuntimeView( - flags=service_flags_view(), - metrics=service_metrics_view(runtime), - ) - - -def kpi_view(kpis) -> KPIView: - return KPIView(**kpis.model_dump()) - - + # Persist only commit-mode records; dry-run is preview-only. + if mode == "commit": + for record in summary.records: + self.store.save_action_execution_record(record.execution_id, record.model_dump(mode="json")) + + # Mapping to PlanDispatchResponse format + return { + "plan_id": summary.plan_id, + "dispatch_mode": summary.dispatch_mode, + "plan_execution_status": summary.plan_execution_status.value, + "overall_progress": summary.overall_progress, + "records": [action_execution_record_view(r) for r in summary.records], + "compensation_hints": summary.compensation_hints, + } + + def update_execution_progress(self, execution_id: str, percentage: float) -> ActionExecutionRecordView: + payload = self.store.get_action_execution_record(execution_id) + if payload is None: + # Fallback for old/missing records + record = ActionExecutionRecord( + execution_id=execution_id, + plan_id="manual", + action_id="manual", + action_type="REORDER", + target_system="ERP", + idempotency_key=f"manual_{execution_id}", + ) + else: + record = ActionExecutionRecord.model_validate(payload) + + record.set_progress(percentage) + if percentage >= 100.0: + record.mark_completed() + + self.store.save_action_execution_record(record.execution_id, record.model_dump(mode="json")) + return action_execution_record_view(record) + + def complete_execution(self, execution_id: str) -> ActionExecutionRecordView: + return self.update_execution_progress(execution_id, 100.0) + + def list_executions(self, limit: int = 20) -> list[ActionExecutionRecordView]: + records = self.store.list_action_execution_records(limit=limit) + return [action_execution_record_view(r) for r in records] + + +def make_runtime(store: SQLiteStore | None = None) -> ControlTowerRuntime: + return ControlTowerRuntime.create(store=store) + + +def _average(values: list[float]) -> float: + if not values: + return 0.0 + return round(sum(values) / len(values), 2) + + +def service_flags_view() -> ServiceFlagsView: + settings = load_settings() + return ServiceFlagsView( + llm_enabled=settings.enabled, + llm_provider=settings.provider, + llm_model=settings.model, + llm_timeout_s=settings.timeout_s, + llm_retry_attempts=settings.retry_attempts, + planner_mode=settings.planner_mode, + dispatch_mode=settings.dispatch_mode, + ) + + +def service_metrics_view(runtime: ControlTowerRuntime) -> ServiceMetricsView: + runs = [RunRecord.model_validate(item) for item in runtime.store.list_run_records(limit=None)] + traces = [OrchestrationTrace.model_validate(item) for item in runtime.store.list_traces(limit=None)] + executions = [ExecutionRecord.model_validate(item) for item in runtime.store.list_execution_records(limit=None)] + total_runs = len(runs) + completed_runs = sum(1 for item in runs if item.status == RunStatus.COMPLETED) + failed_runs = sum(1 for item in runs if item.status == RunStatus.FAILED) + run_durations = [item.duration_ms for item in runs if item.duration_ms > 0.0] + step_durations = [ + step.duration_ms + for trace in traces + for step in trace.steps + if step.duration_ms is not None and step.duration_ms >= 0.0 + ] + approval_count = sum( + 1 + for item in runs + if item.approval_status in { + ApprovalStatus.PENDING.value, + ApprovalStatus.APPROVED.value, + ApprovalStatus.REJECTED.value, + } + ) + execution_failures = sum( + 1 for item in executions if item.status in {ExecutionStatus.FAILED, ExecutionStatus.ROLLED_BACK} + ) + return ServiceMetricsView( + total_runs=total_runs, + completed_runs=completed_runs, + failed_runs=failed_runs, + total_events=len(runtime.store.list_event_envelopes(limit=None)), + total_executions=len(executions), + avg_run_duration_ms=_average(run_durations), + avg_agent_step_duration_ms=_average(step_durations), + llm_fallback_rate=round( + sum(1 for item in runs if item.llm_fallback_used) / total_runs, + 4, + ) + if total_runs + else 0.0, + approval_rate=round(approval_count / total_runs, 4) if total_runs else 0.0, + execution_failure_rate=round(execution_failures / len(executions), 4) if executions else 0.0, + latest_run_id=runs[0].run_id if runs else None, + ) + + +def service_runtime_view(runtime: ControlTowerRuntime) -> ServiceRuntimeView: + return ServiceRuntimeView( + flags=service_flags_view(), + metrics=service_metrics_view(runtime), + ) + + +def kpi_view(kpis) -> KPIView: + return KPIView(**kpis.model_dump()) + + def event_envelope_view(item: EventEnvelope | dict) -> EventEnvelopeView: payload = item if isinstance(item, EventEnvelope) else EventEnvelope.model_validate(item) - if isinstance(payload, EventEnvelope): - payload = payload return EventEnvelopeView(**payload.model_dump(mode="json")) def run_record_view(item: RunRecord | dict) -> RunView: payload = item if isinstance(item, RunRecord) else RunRecord.model_validate(item) - if isinstance(payload, RunRecord): - payload = payload return RunView(**payload.model_dump(mode="json")) + + +def run_record_list_view(items: list[RunRecord | dict]) -> list[RunView]: + return [run_record_view(item) for item in items] + + +def execution_record_view(item: ExecutionRecord | dict) -> ExecutionRecordView: + payload = item if isinstance(item, ExecutionRecord) else ExecutionRecord.model_validate(item) + return ExecutionRecordView(**payload.model_dump(mode="json")) + + +def action_execution_record_view(item: ActionExecutionRecord | dict) -> ActionExecutionRecordView: + payload = item if isinstance(item, ActionExecutionRecord) else ActionExecutionRecord.model_validate(item) + return ActionExecutionRecordView(**payload.model_dump(mode="json")) + + +def historical_control_tower_state(state_snapshot: SystemState | dict) -> ControlTowerStateResponse: + state = state_snapshot if isinstance(state_snapshot, SystemState) else SystemState.model_validate(state_snapshot) + return control_tower_state(state) + + +def _decision_for_plan(state: SystemState, plan: Plan | None) -> DecisionLog | None: + if plan is None: + return None + for item in reversed(state.decision_logs): + if item.plan_id == plan.plan_id: + return item + return None + + +def action_view(action: Action) -> ActionView: + return ActionView( + action_id=action.action_id, + action_type=action.action_type.value, + target_id=action.target_id, + reason=action.reason, + priority=action.priority, + estimated_cost_delta=action.estimated_cost_delta, + estimated_service_delta=action.estimated_service_delta, + estimated_risk_delta=action.estimated_risk_delta, + estimated_recovery_hours=action.estimated_recovery_hours, + parameters=action.parameters, + ) + + +def constraint_violation_view(item: ConstraintViolation) -> ConstraintViolationView: + return ConstraintViolationView(**item.model_dump(mode="json")) + + +def plan_view(plan: Plan | None, decision: DecisionLog | None = None) -> PlanView | None: + if plan is None: + return None + return PlanView( + plan_id=plan.plan_id, + decision_id=decision.decision_id if decision else None, + mode=plan.mode.value, + status=plan.status.value, + score=plan.score, + score_breakdown=plan.score_breakdown, + feasible=plan.feasible, + violations=[constraint_violation_view(item) for item in plan.violations], + mode_rationale=plan.mode_rationale, + strategy_label=plan.strategy_label, + generated_by=plan.generated_by, + approval_required=plan.approval_required, + approval_reason=plan.approval_reason, + approval_status=decision.approval_status.value if decision else ApprovalStatus.NOT_REQUIRED.value, + planner_reasoning=plan.planner_reasoning, + llm_planner_narrative=plan.llm_planner_narrative, + critic_summary=plan.critic_summary, + trigger_event_ids=plan.trigger_event_ids, + actions=[action_view(item) for item in plan.actions], + metadata=plan.metadata.model_dump(mode="json") if plan.metadata else {}, + ) + + +def latest_plan_view(state: SystemState) -> PlanView | None: + plan = state.pending_plan or state.latest_plan + return plan_view(plan, _decision_for_plan(state, plan)) + + +def event_view(event: Event) -> EventView: + return EventView( + event_id=event.event_id, + type=event.type.value, + severity=event.severity, + source=event.source, + entity_ids=event.entity_ids, + occurred_at=event.occurred_at, + detected_at=event.detected_at, + payload=event.payload, + ) + + +def candidate_evaluation_view(item) -> CandidateEvaluationView: + return CandidateEvaluationView( + strategy_label=item.strategy_label, + action_ids=item.action_ids, + score=item.score, + score_breakdown=item.score_breakdown, + projected_kpis=kpi_view(item.projected_kpis), + feasible=item.feasible, + violations=[constraint_violation_view(violation) for violation in item.violations], + mode_rationale=item.mode_rationale, + approval_required=item.approval_required, + approval_reason=item.approval_reason, + rationale=item.rationale, + llm_used=item.llm_used, + coverage_fraction=item.coverage_fraction, + critical_covered=item.critical_covered, + unresolved_critical=item.unresolved_critical, + ) + + +def decision_summary_view(item: DecisionLog) -> DecisionLogSummaryView: + return DecisionLogSummaryView( + decision_id=item.decision_id, + plan_id=item.plan_id, + approval_status=item.approval_status.value, + approval_required=item.approval_required, + approval_reason=item.approval_reason, + selection_reason=item.selection_reason, + selected_actions=item.selected_actions, + event_ids=item.event_ids, + llm_used=item.llm_used, + ) + + +def decision_detail_view(item: DecisionLog) -> DecisionLogDetailView: + return DecisionLogDetailView( + decision_id=item.decision_id, + plan_id=item.plan_id, + approval_status=item.approval_status.value, + approval_required=item.approval_required, + approval_reason=item.approval_reason, + rationale=item.rationale, + selection_reason=item.selection_reason, + mode_rationale=item.mode_rationale, + winning_factors=item.winning_factors, + score_breakdown=item.score_breakdown, + selected_actions=item.selected_actions, + rejected_actions=item.rejected_actions, + candidate_evaluations=[candidate_evaluation_view(eval_item) for eval_item in item.candidate_evaluations], + critic_summary=item.critic_summary, + critic_findings=item.critic_findings, + llm_used=item.llm_used, + llm_provider=item.llm_provider, + llm_model=item.llm_model, + llm_error=item.llm_error, + before_kpis=kpi_view(item.before_kpis), + after_kpis=kpi_view(item.after_kpis), + ) -def run_record_list_view(items: list[RunRecord | dict]) -> list[RunView]: - return [run_record_view(item) for item in items] - - -def execution_record_view(item: ExecutionRecord | dict) -> ExecutionRecordView: - payload = item if isinstance(item, ExecutionRecord) else ExecutionRecord.model_validate(item) - return ExecutionRecordView(**payload.model_dump(mode="json")) - - -def action_execution_record_view(item: ActionExecutionRecord | dict) -> ActionExecutionRecordView: - payload = item if isinstance(item, ActionExecutionRecord) else ActionExecutionRecord.model_validate(item) - return ActionExecutionRecordView(**payload.model_dump(mode="json")) +def _approximate_z_score(service_level: float) -> float: + # Acklam-style approximation is sufficient for deterministic policy scoring. + p = max(0.5001, min(0.9999, service_level)) + t = math.sqrt(-2.0 * math.log(1.0 - p)) + c0, c1, c2 = 2.515517, 0.802853, 0.010328 + d1, d2, d3 = 1.432788, 0.189269, 0.001308 + z = t - ((c2 * t + c1) * t + c0) / (((d3 * t + d2) * t + d1) * t + 1.0) + return max(0.0, z) -def historical_control_tower_state(state_snapshot: SystemState | dict) -> ControlTowerStateResponse: - state = state_snapshot if isinstance(state_snapshot, SystemState) else SystemState.model_validate(state_snapshot) - return control_tower_state(state) +def _supplier_for_item(state: SystemState, item) -> Any | None: + return state.suppliers.get(f"{item.preferred_supplier_id}_{item.sku}") -def _decision_for_plan(state: SystemState, plan: Plan | None) -> DecisionLog | None: - if plan is None: - return None - for item in reversed(state.decision_logs): - if item.plan_id == plan.plan_id: - return item - return None +def _route_factor(state: SystemState, item) -> float: + route = state.routes.get(item.preferred_route_id) + if route is None: + return 1.0 + if route.status == "blocked": + return 0.0 + # High-risk lanes reduce expected usable inbound, but avoid over-penalizing + # because route/supplier uncertainty is already reflected in safety stock. + return max(0.85, 1.0 - (route.risk_score * 0.15)) -def action_view(action: Action) -> ActionView: - return ActionView( - action_id=action.action_id, - action_type=action.action_type.value, - target_id=action.target_id, - reason=action.reason, - priority=action.priority, - estimated_cost_delta=action.estimated_cost_delta, - estimated_service_delta=action.estimated_service_delta, - estimated_risk_delta=action.estimated_risk_delta, - estimated_recovery_hours=action.estimated_recovery_hours, - parameters=action.parameters, - ) +def _lead_time_days(state: SystemState, item) -> int: + supplier = _supplier_for_item(state, item) + return max(int(supplier.lead_time_days), 1) if supplier is not None else 1 -def constraint_violation_view(item: ConstraintViolation) -> ConstraintViolationView: - return ConstraintViolationView(**item.model_dump(mode="json")) +def _expected_inbound_within_lt(state: SystemState, item) -> float: + return max(item.incoming_qty * _route_factor(state, item), 0.0) -def plan_view(plan: Plan | None, decision: DecisionLog | None = None) -> PlanView | None: - if plan is None: - return None - return PlanView( - plan_id=plan.plan_id, - decision_id=decision.decision_id if decision else None, - mode=plan.mode.value, - status=plan.status.value, - score=plan.score, - score_breakdown=plan.score_breakdown, - feasible=plan.feasible, - violations=[constraint_violation_view(item) for item in plan.violations], - mode_rationale=plan.mode_rationale, - strategy_label=plan.strategy_label, - generated_by=plan.generated_by, - approval_required=plan.approval_required, - approval_reason=plan.approval_reason, - approval_status=decision.approval_status.value if decision else ApprovalStatus.NOT_REQUIRED.value, - planner_reasoning=plan.planner_reasoning, - llm_planner_narrative=plan.llm_planner_narrative, - critic_summary=plan.critic_summary, - trigger_event_ids=plan.trigger_event_ids, - actions=[action_view(item) for item in plan.actions], - metadata=plan.metadata.model_dump(mode="json") if plan.metadata else {}, +def _committed_qty_within_lt(state: SystemState, item) -> int: + lead_time = _lead_time_days(state, item) + committed = sum( + order.quantity + for order in state.orders + if order.sku == item.sku and order.day_index <= lead_time ) + # Prevent double-counting with forecast-based lead-time demand. + return min(committed, max(item.forecast_qty, 0)) -def latest_plan_view(state: SystemState) -> PlanView | None: - plan = state.pending_plan or state.latest_plan - return plan_view(plan, _decision_for_plan(state, plan)) +def _inventory_position(state: SystemState, item) -> float: + return item.on_hand + _expected_inbound_within_lt(state, item) - _committed_qty_within_lt(state, item) -def event_view(event: Event) -> EventView: - return EventView( - event_id=event.event_id, - type=event.type.value, - severity=event.severity, - source=event.source, - entity_ids=event.entity_ids, - occurred_at=event.occurred_at, - detected_at=event.detected_at, - payload=event.payload, - ) +def _dynamic_safety_stock(state: SystemState, item) -> float: + supplier = _supplier_for_item(state, item) + reliability = supplier.reliability if supplier is not None else 0.9 + lead_time = _lead_time_days(state, item) + demand_std = max(float(getattr(item, "std_demand", 0.0)), 0.0) + demand_mean = max(float(item.forecast_qty), 0.0) + lead_time_std = max((1.0 - reliability) * lead_time, 0.0) + service_target = 0.95 if reliability >= 0.85 else 0.97 + z = _approximate_z_score(service_target) + variance = (demand_std**2 * lead_time) + ((demand_mean**2) * (lead_time_std**2)) + dynamic_ss = z * (variance**0.5) + return max(float(item.safety_stock), dynamic_ss) -def candidate_evaluation_view(item) -> CandidateEvaluationView: - return CandidateEvaluationView( - strategy_label=item.strategy_label, - action_ids=item.action_ids, - score=item.score, - score_breakdown=item.score_breakdown, - projected_kpis=kpi_view(item.projected_kpis), - feasible=item.feasible, - violations=[constraint_violation_view(violation) for violation in item.violations], - mode_rationale=item.mode_rationale, - approval_required=item.approval_required, - approval_reason=item.approval_reason, - rationale=item.rationale, - llm_used=item.llm_used, - ) +def _dynamic_reorder_point(state: SystemState, item) -> float: + lead_time = _lead_time_days(state, item) + lead_time_demand = max(float(item.forecast_qty), 0.0) * lead_time + return lead_time_demand + _dynamic_safety_stock(state, item) -def decision_summary_view(item: DecisionLog) -> DecisionLogSummaryView: - return DecisionLogSummaryView( - decision_id=item.decision_id, - plan_id=item.plan_id, - approval_status=item.approval_status.value, - approval_required=item.approval_required, - approval_reason=item.approval_reason, - selection_reason=item.selection_reason, - selected_actions=item.selected_actions, - event_ids=item.event_ids, - llm_used=item.llm_used, - ) - +def _inventory_status(state: SystemState, item) -> str: + inventory_position = _inventory_position(state, item) + dynamic_ss = _dynamic_safety_stock(state, item) + dynamic_rop = _dynamic_reorder_point(state, item) + net_available = item.on_hand - _committed_qty_within_lt(state, item) -def decision_detail_view(item: DecisionLog) -> DecisionLogDetailView: - return DecisionLogDetailView( - decision_id=item.decision_id, - plan_id=item.plan_id, - approval_status=item.approval_status.value, - approval_required=item.approval_required, - approval_reason=item.approval_reason, - rationale=item.rationale, - selection_reason=item.selection_reason, - mode_rationale=item.mode_rationale, - winning_factors=item.winning_factors, - score_breakdown=item.score_breakdown, - selected_actions=item.selected_actions, - rejected_actions=item.rejected_actions, - candidate_evaluations=[candidate_evaluation_view(eval_item) for eval_item in item.candidate_evaluations], - critic_summary=item.critic_summary, - critic_findings=item.critic_findings, - llm_used=item.llm_used, - llm_provider=item.llm_provider, - llm_model=item.llm_model, - llm_error=item.llm_error, - before_kpis=kpi_view(item.before_kpis), - after_kpis=kpi_view(item.after_kpis), - ) - - -def _inventory_status(item) -> str: - available = item.on_hand + item.incoming_qty - if available <= 0: + if net_available <= 0 and inventory_position <= 0: return "out_of_stock" - if available <= item.reorder_point: - return "low" - if available <= item.safety_stock: + if inventory_position <= dynamic_ss: return "at_risk" + # Small guard-band prevents borderline rounding noise from flooding low alerts. + if inventory_position <= (dynamic_rop * 0.97): + return "low" return "in_stock" def inventory_rows(state: SystemState, *, search: str | None = None, status: str | None = None) -> list[InventoryRowView]: - needle = (search or "").strip().lower() + needle = (search or "").strip().lower() status_filter = (status or "").strip().lower() rows: list[InventoryRowView] = [] for item in state.inventory.values(): - item_status = _inventory_status(item) - matches_needle = ( - not needle - or needle in item.sku.lower() - or needle in item.preferred_supplier_id.lower() - or needle in item.name.lower() - ) - if not matches_needle: - continue - if status_filter and item_status != status_filter: - continue - rows.append( - InventoryRowView( - sku=item.sku, - name=item.name, - warehouse_id=item.warehouse_id, - on_hand=item.on_hand, - incoming_qty=item.incoming_qty, - forecast_qty=item.forecast_qty, - reorder_point=item.reorder_point, - safety_stock=item.safety_stock, - unit_cost=item.unit_cost, - status=item_status, - preferred_supplier_id=item.preferred_supplier_id, - preferred_route_id=item.preferred_route_id, - ) - ) - rows.sort(key=lambda row: (row.status, row.sku)) - return rows - - -def _supplier_tradeoff(state: SystemState, supplier_id: str, sku: str) -> str: - current = state.suppliers.get(f"{supplier_id}_{sku}") - if current is None: - current = state.suppliers.get(supplier_id) - if current is None: - return "unknown" - peers = [item for item in state.suppliers.values() if item.sku == sku] - if not peers: - return "no comparison available" - fastest = min(peer.lead_time_days for peer in peers) - cheapest = min(peer.unit_cost for peer in peers) - if current.lead_time_days == fastest and current.unit_cost > cheapest: - return "fast but expensive" - if current.unit_cost == cheapest and current.lead_time_days > fastest: - return "cheap but slower" - return "balanced option" - - -def supplier_rows(state: SystemState, *, sku: str | None = None, status: str | None = None) -> list[SupplierRowView]: - target_sku = (sku or "").strip().lower() - target_status = (status or "").strip().lower() - items: list[SupplierRowView] = [] - for supplier in state.suppliers.values(): - if target_sku and supplier.sku.lower() != target_sku: - continue - if target_status and supplier.status.lower() != target_status: - continue - items.append( - SupplierRowView( - supplier_id=supplier.supplier_id, - sku=supplier.sku, - unit_cost=supplier.unit_cost, - lead_time_days=supplier.lead_time_days, - reliability=supplier.reliability, - is_primary=supplier.is_primary, - status=supplier.status, - tradeoff=_supplier_tradeoff(state, supplier.supplier_id, supplier.sku), - ) - ) - items.sort(key=lambda row: (row.sku, -row.reliability, row.lead_time_days)) - return items - - -def alerts(state: SystemState) -> list[AlertView]: - items: list[AlertView] = [] - for event in state.active_events: - level = "critical" if event.severity >= 0.8 else "warning" - items.append( - AlertView( - level=level, - title=event.type.value.replace("_", " ").title(), - message=f"Detected from {event.source} with severity {event.severity:.2f}", - source="event", - event_type=event.type.value, - entity_ids=event.entity_ids, - ) - ) - for row in inventory_rows(state): - if row.status not in {"low", "out_of_stock"}: - continue - items.append( - AlertView( - level="critical" if row.status == "out_of_stock" else "warning", - title=f"{row.sku} inventory {row.status.replace('_', ' ')}", - message=( - f"On hand {row.on_hand}, incoming {row.incoming_qty}, reorder point {row.reorder_point}" - ), - source="inventory", - entity_ids=[row.sku], - ) - ) - if len(items) >= 6: - break - return items[:6] - - + item_status = _inventory_status(state, item) + matches_needle = ( + not needle + or needle in item.sku.lower() + or needle in item.preferred_supplier_id.lower() + or needle in item.name.lower() + ) + if not matches_needle: + continue + if status_filter and item_status != status_filter: + continue + rows.append( + InventoryRowView( + sku=item.sku, + name=item.name, + warehouse_id=item.warehouse_id, + on_hand=item.on_hand, + incoming_qty=item.incoming_qty, + forecast_qty=item.forecast_qty, + reorder_point=item.reorder_point, + safety_stock=item.safety_stock, + unit_cost=item.unit_cost, + status=item_status, + preferred_supplier_id=item.preferred_supplier_id, + preferred_route_id=item.preferred_route_id, + ) + ) + rows.sort(key=lambda row: (row.status, row.sku)) + return rows + + +def _supplier_tradeoff(state: SystemState, supplier_id: str, sku: str) -> str: + current = state.suppliers.get(f"{supplier_id}_{sku}") + if current is None: + current = state.suppliers.get(supplier_id) + if current is None: + return "unknown" + peers = [item for item in state.suppliers.values() if item.sku == sku] + if not peers: + return "no comparison available" + fastest = min(peer.lead_time_days for peer in peers) + cheapest = min(peer.unit_cost for peer in peers) + if current.lead_time_days == fastest and current.unit_cost > cheapest: + return "fast but expensive" + if current.unit_cost == cheapest and current.lead_time_days > fastest: + return "cheap but slower" + return "balanced option" + + +def supplier_rows(state: SystemState, *, sku: str | None = None, status: str | None = None) -> list[SupplierRowView]: + target_sku = (sku or "").strip().lower() + target_status = (status or "").strip().lower() + items: list[SupplierRowView] = [] + for supplier in state.suppliers.values(): + if target_sku and supplier.sku.lower() != target_sku: + continue + if target_status and supplier.status.lower() != target_status: + continue + items.append( + SupplierRowView( + supplier_id=supplier.supplier_id, + sku=supplier.sku, + unit_cost=supplier.unit_cost, + lead_time_days=supplier.lead_time_days, + reliability=supplier.reliability, + is_primary=supplier.is_primary, + status=supplier.status, + tradeoff=_supplier_tradeoff(state, supplier.supplier_id, supplier.sku), + ) + ) + items.sort(key=lambda row: (row.sku, -row.reliability, row.lead_time_days)) + return items + + +def alerts(state: SystemState) -> list[AlertView]: + items: list[AlertView] = [] + for event in state.active_events: + level = "critical" if event.severity >= 0.8 else "warning" + items.append( + AlertView( + level=level, + title=event.type.value.replace("_", " ").title(), + message=f"Detected from {event.source} with severity {event.severity:.2f}", + source="event", + event_type=event.type.value, + entity_ids=event.entity_ids, + ) + ) + for row in inventory_rows(state): + if row.status not in {"low", "out_of_stock"}: + continue + items.append( + AlertView( + level="critical" if row.status == "out_of_stock" else "warning", + title=f"{row.sku} inventory {row.status.replace('_', ' ')}", + message=( + f"On hand {row.on_hand}, incoming {row.incoming_qty}, reorder point {row.reorder_point}" + ), + source="inventory", + entity_ids=[row.sku], + ) + ) + if len(items) >= 6: + break + return items[:6] + + def pending_approval_view(state: SystemState) -> PendingApprovalView | None: if state.pending_plan is None or not state.decision_logs: return None decision = state.decision_logs[-1] + can_request_safer = state.pending_plan.generated_by != "operator_safer_request" + allowed_actions = ["approve", "reject"] + (["safer_plan"] if can_request_safer else []) return PendingApprovalView( decision_id=decision.decision_id, approval_status=decision.approval_status.value, approval_reason=decision.approval_reason, selection_reason=decision.selection_reason, selected_actions=decision.selected_actions, + allowed_actions=allowed_actions, before_kpis=kpi_view(decision.before_kpis), projected_kpis=kpi_view(decision.after_kpis), candidate_count=len(decision.candidate_evaluations), plan=plan_view(state.pending_plan, decision), ) - - -def approval_detail_view(state: SystemState, decision_id: str) -> ApprovalDetailView: - decision = find_decision(state, decision_id) - plan = state.pending_plan if state.pending_plan and state.pending_plan.plan_id == decision.plan_id else state.latest_plan + + +def approval_detail_view(state: SystemState, decision_id: str) -> ApprovalDetailView: + decision = find_decision(state, decision_id) + plan = state.pending_plan if state.pending_plan and state.pending_plan.plan_id == decision.plan_id else state.latest_plan if plan is None or plan.plan_id != decision.plan_id: plan = None pending = state.pending_plan is not None and state.pending_plan.plan_id == decision.plan_id + can_request_safer = not pending or state.pending_plan.generated_by != "operator_safer_request" + allowed_actions = ["approve", "reject"] + (["safer_plan"] if can_request_safer else []) return ApprovalDetailView( decision_id=decision.decision_id, plan_id=decision.plan_id, @@ -908,319 +1062,320 @@ def approval_detail_view(state: SystemState, decision_id: str) -> ApprovalDetail approval_status=decision.approval_status.value, approval_reason=decision.approval_reason, is_pending=pending, - allowed_actions=["approve", "reject", "safer_plan"] if pending else [], - selection_reason=decision.selection_reason, - selected_actions=decision.selected_actions, - event_ids=decision.event_ids, - before_kpis=kpi_view(decision.before_kpis), - after_kpis=kpi_view(decision.after_kpis), - candidate_count=len(decision.candidate_evaluations), - plan=plan_view(plan, decision) - if plan is not None - else PlanView( - plan_id=decision.plan_id, - decision_id=decision.decision_id, - mode=state.mode.value, - status="unknown", - score=0.0, - score_breakdown=decision.score_breakdown, - approval_required=decision.approval_required, - approval_reason=decision.approval_reason, - approval_status=decision.approval_status.value, - ), - ) - - -def approval_command_result_view( - state: SystemState, - *, - decision_id: str, - action: str, - execution: ExecutionRecord | None = None, -) -> ApprovalCommandResultResponse: + allowed_actions=allowed_actions if pending else [], + selection_reason=decision.selection_reason, + selected_actions=decision.selected_actions, + event_ids=decision.event_ids, + before_kpis=kpi_view(decision.before_kpis), + after_kpis=kpi_view(decision.after_kpis), + candidate_count=len(decision.candidate_evaluations), + plan=plan_view(plan, decision) + if plan is not None + else PlanView( + plan_id=decision.plan_id, + decision_id=decision.decision_id, + mode=state.mode.value, + status="unknown", + score=0.0, + score_breakdown=decision.score_breakdown, + approval_required=decision.approval_required, + approval_reason=decision.approval_reason, + approval_status=decision.approval_status.value, + ), + ) + + +def approval_command_result_view( + state: SystemState, + *, + decision_id: str, + action: str, + execution: ExecutionRecord | None = None, +) -> ApprovalCommandResultResponse: message_map = { - "approve": "Pending plan approved and applied.", + "approve": "Pending plan approved and queued for dispatch.", "reject": "Pending plan rejected.", "safer_plan": "Safer plan requested.", + "select_alternative": "Alternative strategy selected and queued for approval.", } - decision = find_decision(state, decision_id) - return ApprovalCommandResultResponse( - decision_id=decision.decision_id, - action=action, - approval_status=decision.approval_status.value, - message=message_map.get(action, "Approval action processed."), - latest_plan=latest_plan_view(state), - pending_approval=pending_approval_view(state), - execution=execution_record_view(execution) if execution is not None else None, - latest_trace=latest_trace_view(state), - summary=control_tower_summary(state), - ) - - -def latest_trace_view(state: SystemState) -> TraceView: - trace = state.latest_trace - latest_decision = state.decision_logs[-1] if state.decision_logs else None - latest_event = state.active_events[-1] if state.active_events else None - if trace is None: - return TraceView( - run_id=state.run_id, - mode=state.mode.value, - current_branch=state.mode.value, - event=event_view(latest_event) if latest_event else None, - latest_plan=latest_plan_view(state), - decision_id=latest_decision.decision_id if latest_decision else None, - selected_strategy=state.latest_plan.strategy_label if state.latest_plan else None, - candidate_count=len(latest_decision.candidate_evaluations) if latest_decision else 0, - selection_reason=latest_decision.selection_reason if latest_decision else None, - candidate_evaluations=( - [candidate_evaluation_view(item) for item in latest_decision.candidate_evaluations] - if latest_decision - else [] - ), - approval_pending=state.pending_plan is not None, - approval_reason=latest_decision.approval_reason if latest_decision else "", - critic_summary=latest_decision.critic_summary if latest_decision else None, - ) - - steps = [ - AgentStepView( - step_id=step.step_id, - sequence=step.sequence, - agent=step.node_key, - node_type=step.node_type, - status=step.status, - started_at=step.started_at, - completed_at=step.completed_at, - duration_ms=step.duration_ms, - mode_snapshot=step.mode_snapshot, - summary=step.summary, - reasoning_source=step.reasoning_source, - input_snapshot=step.input_snapshot, - output_snapshot=step.output_snapshot, - observations=step.observations, - risks=step.risks, - downstream_impacts=step.downstream_impacts, - recommended_action_ids=step.recommended_action_ids, - tradeoffs=step.tradeoffs, - llm_used=step.llm_used, - llm_error=step.llm_error, - fallback_used=step.fallback_used, - fallback_reason=step.fallback_reason, - ) - for step in trace.steps - ] - return TraceView( - run_id=trace.run_id, - trace_id=trace.trace_id, - status=trace.status, - started_at=trace.started_at, - completed_at=trace.completed_at, - mode_before=trace.mode_before, - mode_after=trace.mode_after, - mode=state.mode.value, - current_branch=trace.current_branch, - terminal_stage=trace.terminal_stage, - event=event_view(trace.event) if trace.event else event_view(latest_event) if latest_event else None, - route_decisions=[ - RouteDecisionView( - from_node=item.from_node, - outcome=item.outcome, - to_node=item.to_node, - reason=item.reason, - ) - for item in trace.route_decisions - ], - steps=steps, - latest_plan=latest_plan_view(state), - decision_id=trace.decision_id or latest_decision.decision_id if latest_decision else trace.decision_id, - selected_strategy=trace.selected_strategy or (state.latest_plan.strategy_label if state.latest_plan else None), - candidate_count=trace.candidate_count, - selection_reason=trace.selection_reason or (latest_decision.selection_reason if latest_decision else None), - candidate_evaluations=( - [candidate_evaluation_view(item) for item in latest_decision.candidate_evaluations] - if latest_decision - else [] - ), - approval_pending=trace.approval_pending, - approval_reason=trace.approval_reason, - execution_status=trace.execution_status, - critic_summary=trace.critic_summary or (latest_decision.critic_summary if latest_decision else None), - ) - - -def trace_view_from_record(trace: OrchestrationTrace, run: RunRecord | None = None) -> TraceView: - steps = [ - AgentStepView( - step_id=step.step_id, - sequence=step.sequence, - agent=step.node_key, - node_type=step.node_type, - status=step.status, - started_at=step.started_at, - completed_at=step.completed_at, - duration_ms=step.duration_ms, - mode_snapshot=step.mode_snapshot, - summary=step.summary, - reasoning_source=step.reasoning_source, - input_snapshot=step.input_snapshot, - output_snapshot=step.output_snapshot, - observations=step.observations, - risks=step.risks, - downstream_impacts=step.downstream_impacts, - recommended_action_ids=step.recommended_action_ids, - tradeoffs=step.tradeoffs, - llm_used=step.llm_used, - llm_error=step.llm_error, - fallback_used=step.fallback_used, - fallback_reason=step.fallback_reason, - ) - for step in trace.steps - ] - return TraceView( - run_id=trace.run_id, - trace_id=trace.trace_id, - status=trace.status, - started_at=trace.started_at, - completed_at=trace.completed_at, - mode_before=trace.mode_before, - mode_after=trace.mode_after, - mode=trace.mode_after or trace.mode_before, - current_branch=trace.current_branch, - terminal_stage=trace.terminal_stage, - event=event_view(trace.event) if trace.event else None, - route_decisions=[ - RouteDecisionView( - from_node=item.from_node, - outcome=item.outcome, - to_node=item.to_node, - reason=item.reason, - ) - for item in trace.route_decisions - ], - steps=steps, - latest_plan=None, - decision_id=run.decision_id if run else trace.decision_id, - selected_strategy=trace.selected_strategy or (run.selected_plan_summary.strategy_label if run and run.selected_plan_summary else None), - candidate_count=trace.candidate_count, - selection_reason=trace.selection_reason, - candidate_evaluations=[], - approval_pending=trace.approval_pending, - approval_reason=trace.approval_reason, - execution_status=trace.execution_status, - critic_summary=trace.critic_summary, - ) - - -def reflection_views(state: SystemState) -> list[ReflectionView]: - if state.memory is None: - return [] - items = [ - ReflectionView( - note_id=item.note_id, - run_id=item.run_id, - scenario_id=item.scenario_id, - plan_id=item.plan_id, - mode=item.mode, - approval_status=item.approval_status, - summary=item.summary, - lessons=item.lessons, - pattern_tags=item.pattern_tags, - follow_up_checks=item.follow_up_checks, - llm_used=item.llm_used, - llm_error=item.llm_error, - ) - for item in state.memory.reflection_notes - ] - return list(reversed(items)) - - -def scenario_outcomes(state: SystemState) -> list[ScenarioOutcomeView]: - if state.memory is None: - return [] - items: list[ScenarioOutcomeView] = [] - for scenario_id, payload in sorted(state.memory.scenario_outcomes.items()): - items.append( - ScenarioOutcomeView( - scenario_id=scenario_id, - runs=int(payload.get("runs", 0)), - latest_run_id=payload.get("latest_run_id"), - latest_plan_id=payload.get("latest_plan_id"), - latest_approval_status=payload.get("latest_approval_status"), - latest_reflection_status=payload.get("latest_reflection_status"), - latest_kpis=payload.get("latest_kpis", {}), - history=payload.get("history", []), - ) - ) - return items - - -def control_tower_summary(state: SystemState) -> ControlTowerSummaryResponse: - if state.kpis.decision_latency_ms < 0.0: - state.kpis = recompute_kpis(state) - return ControlTowerSummaryResponse( - mode=state.mode.value, - kpis=kpi_view(state.kpis), - alerts=alerts(state), - active_events=[event_view(event) for event in state.active_events], - latest_plan=latest_plan_view(state), - pending_approval=pending_approval_view(state), - decision_count=len(state.decision_logs), - scenario_history_count=len(state.scenario_history), - ) - - -def control_tower_state(state: SystemState) -> ControlTowerStateResponse: - return ControlTowerStateResponse( - summary=control_tower_summary(state), - inventory=inventory_rows(state), - suppliers=supplier_rows(state), - reflections=reflection_views(state), - latest_trace=latest_trace_view(state), - ) - - -def find_decision(state: SystemState, decision_id: str) -> DecisionLog: - for item in reversed(state.decision_logs): - if item.decision_id == decision_id: - return item - raise_not_found("decision", decision_id) - - -def build_event_from_request( - *, - event_type, - severity: float, - source: str, - entity_ids: list[str], - payload: dict[str, Any], -) -> Event: - timestamp = utc_now() - return Event( - event_id=f"evt_api_{event_type.value}_{int(timestamp.timestamp())}", - type=event_type, - source=source, - severity=severity, - entity_ids=entity_ids, - occurred_at=timestamp, - detected_at=timestamp, - payload=payload, - dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", - ) - - -def build_event_from_envelope(envelope: EventEnvelope) -> Event: - try: - event_type = EventType(envelope.event_type) - except ValueError as exc: - raise ValueError(f"unsupported domain event type: {envelope.event_type}") from exc - return Event( - event_id=envelope.event_id, - type=event_type, - source=envelope.source, - severity=envelope.severity, - entity_ids=envelope.entity_ids, - occurred_at=envelope.occurred_at, - detected_at=envelope.ingested_at, - payload=envelope.payload, - dedupe_key=envelope.idempotency_key, - ) + decision = find_decision(state, decision_id) + return ApprovalCommandResultResponse( + decision_id=decision.decision_id, + action=action, + approval_status=decision.approval_status.value, + message=message_map.get(action, "Approval action processed."), + latest_plan=latest_plan_view(state), + pending_approval=pending_approval_view(state), + execution=execution_record_view(execution) if execution is not None else None, + latest_trace=latest_trace_view(state), + summary=control_tower_summary(state), + ) + + +def latest_trace_view(state: SystemState) -> TraceView: + trace = state.latest_trace + latest_decision = state.decision_logs[-1] if state.decision_logs else None + latest_event = state.active_events[-1] if state.active_events else None + if trace is None: + return TraceView( + run_id=state.run_id, + mode=state.mode.value, + current_branch=state.mode.value, + event=event_view(latest_event) if latest_event else None, + latest_plan=latest_plan_view(state), + decision_id=latest_decision.decision_id if latest_decision else None, + selected_strategy=state.latest_plan.strategy_label if state.latest_plan else None, + candidate_count=len(latest_decision.candidate_evaluations) if latest_decision else 0, + selection_reason=latest_decision.selection_reason if latest_decision else None, + candidate_evaluations=( + [candidate_evaluation_view(item) for item in latest_decision.candidate_evaluations] + if latest_decision + else [] + ), + approval_pending=state.pending_plan is not None, + approval_reason=latest_decision.approval_reason if latest_decision else "", + critic_summary=latest_decision.critic_summary if latest_decision else None, + ) + + steps = [ + AgentStepView( + step_id=step.step_id, + sequence=step.sequence, + agent=step.node_key, + node_type=step.node_type, + status=step.status, + started_at=step.started_at, + completed_at=step.completed_at, + duration_ms=step.duration_ms, + mode_snapshot=step.mode_snapshot, + summary=step.summary, + reasoning_source=step.reasoning_source, + input_snapshot=step.input_snapshot, + output_snapshot=step.output_snapshot, + observations=step.observations, + risks=step.risks, + downstream_impacts=step.downstream_impacts, + recommended_action_ids=step.recommended_action_ids, + tradeoffs=step.tradeoffs, + llm_used=step.llm_used, + llm_error=step.llm_error, + fallback_used=step.fallback_used, + fallback_reason=step.fallback_reason, + ) + for step in trace.steps + ] + return TraceView( + run_id=trace.run_id, + trace_id=trace.trace_id, + status=trace.status, + started_at=trace.started_at, + completed_at=trace.completed_at, + mode_before=trace.mode_before, + mode_after=trace.mode_after, + mode=state.mode.value, + current_branch=trace.current_branch, + terminal_stage=trace.terminal_stage, + event=event_view(trace.event) if trace.event else event_view(latest_event) if latest_event else None, + route_decisions=[ + RouteDecisionView( + from_node=item.from_node, + outcome=item.outcome, + to_node=item.to_node, + reason=item.reason, + ) + for item in trace.route_decisions + ], + steps=steps, + latest_plan=latest_plan_view(state), + decision_id=trace.decision_id or latest_decision.decision_id if latest_decision else trace.decision_id, + selected_strategy=trace.selected_strategy or (state.latest_plan.strategy_label if state.latest_plan else None), + candidate_count=trace.candidate_count, + selection_reason=trace.selection_reason or (latest_decision.selection_reason if latest_decision else None), + candidate_evaluations=( + [candidate_evaluation_view(item) for item in latest_decision.candidate_evaluations] + if latest_decision + else [] + ), + approval_pending=trace.approval_pending, + approval_reason=trace.approval_reason, + execution_status=trace.execution_status, + critic_summary=trace.critic_summary or (latest_decision.critic_summary if latest_decision else None), + ) + + +def trace_view_from_record(trace: OrchestrationTrace, run: RunRecord | None = None) -> TraceView: + steps = [ + AgentStepView( + step_id=step.step_id, + sequence=step.sequence, + agent=step.node_key, + node_type=step.node_type, + status=step.status, + started_at=step.started_at, + completed_at=step.completed_at, + duration_ms=step.duration_ms, + mode_snapshot=step.mode_snapshot, + summary=step.summary, + reasoning_source=step.reasoning_source, + input_snapshot=step.input_snapshot, + output_snapshot=step.output_snapshot, + observations=step.observations, + risks=step.risks, + downstream_impacts=step.downstream_impacts, + recommended_action_ids=step.recommended_action_ids, + tradeoffs=step.tradeoffs, + llm_used=step.llm_used, + llm_error=step.llm_error, + fallback_used=step.fallback_used, + fallback_reason=step.fallback_reason, + ) + for step in trace.steps + ] + return TraceView( + run_id=trace.run_id, + trace_id=trace.trace_id, + status=trace.status, + started_at=trace.started_at, + completed_at=trace.completed_at, + mode_before=trace.mode_before, + mode_after=trace.mode_after, + mode=trace.mode_after or trace.mode_before, + current_branch=trace.current_branch, + terminal_stage=trace.terminal_stage, + event=event_view(trace.event) if trace.event else None, + route_decisions=[ + RouteDecisionView( + from_node=item.from_node, + outcome=item.outcome, + to_node=item.to_node, + reason=item.reason, + ) + for item in trace.route_decisions + ], + steps=steps, + latest_plan=None, + decision_id=run.decision_id if run else trace.decision_id, + selected_strategy=trace.selected_strategy or (run.selected_plan_summary.strategy_label if run and run.selected_plan_summary else None), + candidate_count=trace.candidate_count, + selection_reason=trace.selection_reason, + candidate_evaluations=[], + approval_pending=trace.approval_pending, + approval_reason=trace.approval_reason, + execution_status=trace.execution_status, + critic_summary=trace.critic_summary, + ) + + +def reflection_views(state: SystemState) -> list[ReflectionView]: + if state.memory is None: + return [] + items = [ + ReflectionView( + note_id=item.note_id, + run_id=item.run_id, + scenario_id=item.scenario_id, + plan_id=item.plan_id, + mode=item.mode, + approval_status=item.approval_status, + summary=item.summary, + lessons=item.lessons, + pattern_tags=item.pattern_tags, + follow_up_checks=item.follow_up_checks, + llm_used=item.llm_used, + llm_error=item.llm_error, + ) + for item in state.memory.reflection_notes + ] + return list(reversed(items)) + + +def scenario_outcomes(state: SystemState) -> list[ScenarioOutcomeView]: + if state.memory is None: + return [] + items: list[ScenarioOutcomeView] = [] + for scenario_id, payload in sorted(state.memory.scenario_outcomes.items()): + items.append( + ScenarioOutcomeView( + scenario_id=scenario_id, + runs=int(payload.get("runs", 0)), + latest_run_id=payload.get("latest_run_id"), + latest_plan_id=payload.get("latest_plan_id"), + latest_approval_status=payload.get("latest_approval_status"), + latest_reflection_status=payload.get("latest_reflection_status"), + latest_kpis=payload.get("latest_kpis", {}), + history=payload.get("history", []), + ) + ) + return items + + +def control_tower_summary(state: SystemState) -> ControlTowerSummaryResponse: + if state.kpis.decision_latency_ms < 0.0: + state.kpis = recompute_kpis(state) + return ControlTowerSummaryResponse( + mode=state.mode.value, + kpis=kpi_view(state.kpis), + alerts=alerts(state), + active_events=[event_view(event) for event in state.active_events], + latest_plan=latest_plan_view(state), + pending_approval=pending_approval_view(state), + decision_count=len(state.decision_logs), + scenario_history_count=len(state.scenario_history), + ) + + +def control_tower_state(state: SystemState) -> ControlTowerStateResponse: + return ControlTowerStateResponse( + summary=control_tower_summary(state), + inventory=inventory_rows(state), + suppliers=supplier_rows(state), + reflections=reflection_views(state), + latest_trace=latest_trace_view(state), + ) + + +def find_decision(state: SystemState, decision_id: str) -> DecisionLog: + for item in reversed(state.decision_logs): + if item.decision_id == decision_id: + return item + raise_not_found("decision", decision_id) + + +def build_event_from_request( + *, + event_type, + severity: float, + source: str, + entity_ids: list[str], + payload: dict[str, Any], +) -> Event: + timestamp = utc_now() + return Event( + event_id=f"evt_api_{event_type.value}_{int(timestamp.timestamp())}", + type=event_type, + source=source, + severity=severity, + entity_ids=entity_ids, + occurred_at=timestamp, + detected_at=timestamp, + payload=payload, + dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", + ) + + +def build_event_from_envelope(envelope: EventEnvelope) -> Event: + try: + event_type = EventType(envelope.event_type) + except ValueError as exc: + raise ValueError(f"unsupported domain event type: {envelope.event_type}") from exc + return Event( + event_id=envelope.event_id, + type=event_type, + source=envelope.source, + severity=envelope.severity, + entity_ids=envelope.entity_ids, + occurred_at=envelope.occurred_at, + detected_at=envelope.ingested_at, + payload=envelope.payload, + dedupe_key=envelope.idempotency_key, + ) diff --git a/core/enums.py b/core/enums.py index 290d90a..26a7fed 100644 --- a/core/enums.py +++ b/core/enums.py @@ -1,72 +1,72 @@ -from enum import Enum - - -class Mode(str, Enum): - NORMAL = "normal" - CRISIS = "crisis" - APPROVAL = "approval" - - -class EventType(str, Enum): - SUPPLIER_DELAY = "supplier_delay" - DEMAND_SPIKE = "demand_spike" - ROUTE_BLOCKAGE = "route_blockage" - COMPOUND = "compound" - APPROVAL = "approval" - - -class ActionType(str, Enum): - NO_OP = "no_op" - REORDER = "reorder" - SWITCH_SUPPLIER = "switch_supplier" - REROUTE = "reroute" - REBALANCE = "rebalance" - - -class PlanStatus(str, Enum): - PROPOSED = "proposed" - APPROVED = "approved" - APPLIED = "applied" - REJECTED = "rejected" - - -class ApprovalStatus(str, Enum): - NOT_REQUIRED = "not_required" - PENDING = "pending" - APPROVED = "approved" - AUTO_APPLIED = "auto_applied" - REJECTED = "rejected" - - -class ConstraintViolationCode(str, Enum): - SUPPLIER_NOT_FOUND = "supplier_not_found" - SUPPLIER_BLOCKED = "supplier_blocked" - SUPPLIER_CAPACITY_EXCEEDED = "supplier_capacity_exceeded" - ROUTE_NOT_FOUND = "route_not_found" - ROUTE_BLOCKED = "route_blocked" - CAPACITY_EXCEEDED = "warehouse_capacity_exceeded" - MOQ_NOT_MET = "moq_not_met" - INVENTORY_SHORTAGE = "inventory_shortage" - SLA_VIOLATED = "sla_violated" - RISK_HIGH = "risk_high" - RELIABILITY_LOW = "reliability_low" - - -class ExecutionStatus(str, Enum): - PLANNED = "planned" - APPROVAL_PENDING = "approval_pending" - APPROVED = "approved" - DISPATCHED = "dispatched" - APPLIED = "applied" - IN_PROGRESS = "in_progress" - COMPLETED = "completed" - FAILED = "failed" - ROLLED_BACK = "rolled_back" - - -class PlanExecutionStatus(str, Enum): - PENDING = "pending" - COMPLETED = "completed" - PARTIALLY_APPLIED = "partially_applied" - COMPLETED_WITH_ERRORS = "completed_with_errors" - REQUIRES_MANUAL_INTERVENTION = "requires_manual_intervention" +from enum import Enum + + +class Mode(str, Enum): + NORMAL = "normal" + CRISIS = "crisis" + APPROVAL = "approval" + + +class EventType(str, Enum): + SUPPLIER_DELAY = "supplier_delay" + DEMAND_SPIKE = "demand_spike" + ROUTE_BLOCKAGE = "route_blockage" + COMPOUND = "compound" + APPROVAL = "approval" + + +class ActionType(str, Enum): + NO_OP = "no_op" + REORDER = "reorder" + SWITCH_SUPPLIER = "switch_supplier" + REROUTE = "reroute" + REBALANCE = "rebalance" + + +class PlanStatus(str, Enum): + PROPOSED = "proposed" + APPROVED = "approved" + APPLIED = "applied" + REJECTED = "rejected" + + +class ApprovalStatus(str, Enum): + NOT_REQUIRED = "not_required" + PENDING = "pending" + APPROVED = "approved" + AUTO_APPLIED = "auto_applied" + REJECTED = "rejected" + + +class ConstraintViolationCode(str, Enum): + SUPPLIER_NOT_FOUND = "supplier_not_found" + SUPPLIER_BLOCKED = "supplier_blocked" + SUPPLIER_CAPACITY_EXCEEDED = "supplier_capacity_exceeded" + ROUTE_NOT_FOUND = "route_not_found" + ROUTE_BLOCKED = "route_blocked" + CAPACITY_EXCEEDED = "warehouse_capacity_exceeded" + MOQ_NOT_MET = "moq_not_met" + INVENTORY_SHORTAGE = "inventory_shortage" + SLA_VIOLATED = "sla_violated" + RISK_HIGH = "risk_high" + RELIABILITY_LOW = "reliability_low" + + +class ExecutionStatus(str, Enum): + PLANNED = "planned" + APPROVAL_PENDING = "approval_pending" + APPROVED = "approved" + DISPATCHED = "dispatched" + APPLIED = "applied" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + ROLLED_BACK = "rolled_back" + + +class PlanExecutionStatus(str, Enum): + PENDING = "pending" + COMPLETED = "completed" + PARTIALLY_APPLIED = "partially_applied" + COMPLETED_WITH_ERRORS = "completed_with_errors" + REQUIRES_MANUAL_INTERVENTION = "requires_manual_intervention" diff --git a/core/memory.py b/core/memory.py index a5d0141..08ef957 100644 --- a/core/memory.py +++ b/core/memory.py @@ -1,269 +1,269 @@ -from __future__ import annotations - -import json -import sqlite3 -from pathlib import Path - -from core.models import DecisionLog, OrchestrationTrace, ScenarioRun, SystemState -from core.runtime_records import EventEnvelope, ExecutionRecord, RunRecord - - -class SQLiteStore: - def __init__(self, path: str | Path = "chaincopilot.db") -> None: - self.path = str(path) - self._init_schema() - - def _connect(self) -> sqlite3.Connection: - return sqlite3.connect(self.path) - - def _init_schema(self) -> None: - with self._connect() as conn: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS decision_logs ( - decision_id TEXT PRIMARY KEY, - payload TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS state_snapshots ( - run_id TEXT PRIMARY KEY, - payload TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS scenario_runs ( - scenario_id TEXT PRIMARY KEY, - payload TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS event_log ( - event_id TEXT PRIMARY KEY, - payload TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS run_records ( - run_id TEXT PRIMARY KEY, - payload TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS traces ( - run_id TEXT PRIMARY KEY, - trace_id TEXT, - payload TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS execution_records ( - execution_id TEXT PRIMARY KEY, - payload TEXT NOT NULL - ) - """ - ) - - conn.execute( - """ - CREATE TABLE IF NOT EXISTS action_execution_records ( - execution_id TEXT PRIMARY KEY, - payload TEXT NOT NULL - ) - """ - ) - - def save_state(self, state: SystemState) -> None: - payload = json.dumps(state.model_dump(mode="json"), ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO state_snapshots(run_id, payload) VALUES(?, ?)", - (state.run_id, payload), - ) - - def save_decision_log(self, decision_log: DecisionLog) -> None: - payload = json.dumps(decision_log.model_dump(mode="json"), ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO decision_logs(decision_id, payload) VALUES(?, ?)", - (decision_log.decision_id, payload), - ) - - def list_decision_logs(self) -> list[dict]: - with self._connect() as conn: - rows = conn.execute( - "SELECT payload FROM decision_logs ORDER BY decision_id DESC" - ).fetchall() - return [json.loads(row[0]) for row in rows] - - def get_decision_log(self, decision_id: str) -> dict | None: - with self._connect() as conn: - row = conn.execute( - "SELECT payload FROM decision_logs WHERE decision_id = ?", - (decision_id,), - ).fetchone() - return None if row is None else json.loads(row[0]) - - def save_scenario_run(self, run: ScenarioRun) -> None: - payload = json.dumps(run.model_dump(mode="json"), ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO scenario_runs(scenario_id, payload) VALUES(?, ?)", - (run.run_id, payload), - ) - - def save_event_envelope(self, event: EventEnvelope) -> None: - payload = json.dumps(event.model_dump(mode="json"), ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO event_log(event_id, payload) VALUES(?, ?)", - (event.event_id, payload), - ) - - def get_event_envelope(self, event_id: str) -> dict | None: - with self._connect() as conn: - row = conn.execute( - "SELECT payload FROM event_log WHERE event_id = ?", - (event_id,), - ).fetchone() - return None if row is None else json.loads(row[0]) - - def list_event_envelopes(self, limit: int | None = None) -> list[dict]: - with self._connect() as conn: - rows = conn.execute("SELECT payload FROM event_log").fetchall() - items = [json.loads(row[0]) for row in rows] - items.sort(key=lambda item: item.get("ingested_at", ""), reverse=True) - if limit is None or limit < 0: - return items - return items[:limit] - - def save_run_record(self, run: RunRecord) -> None: - payload = json.dumps(run.model_dump(mode="json"), ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO run_records(run_id, payload) VALUES(?, ?)", - (run.run_id, payload), - ) - - def get_run_record(self, run_id: str) -> dict | None: - with self._connect() as conn: - row = conn.execute( - "SELECT payload FROM run_records WHERE run_id = ?", - (run_id,), - ).fetchone() - return None if row is None else json.loads(row[0]) - - def list_run_records(self, limit: int | None = None) -> list[dict]: - with self._connect() as conn: - rows = conn.execute("SELECT payload FROM run_records").fetchall() - items = [json.loads(row[0]) for row in rows] - items.sort(key=lambda item: item.get("started_at", ""), reverse=True) - if limit is None or limit < 0: - return items - return items[:limit] - - def get_state_snapshot(self, run_id: str) -> dict | None: - with self._connect() as conn: - row = conn.execute( - "SELECT payload FROM state_snapshots WHERE run_id = ?", - (run_id,), - ).fetchone() - return None if row is None else json.loads(row[0]) - - def save_trace(self, run_id: str, trace: OrchestrationTrace) -> None: - payload = json.dumps(trace.model_dump(mode="json"), ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO traces(run_id, trace_id, payload) VALUES(?, ?, ?)", - (run_id, trace.trace_id, payload), - ) - - def get_trace(self, run_id: str) -> dict | None: - with self._connect() as conn: - row = conn.execute( - "SELECT payload FROM traces WHERE run_id = ?", - (run_id,), - ).fetchone() - return None if row is None else json.loads(row[0]) - - def list_traces(self, limit: int | None = None) -> list[dict]: - with self._connect() as conn: - rows = conn.execute("SELECT payload FROM traces").fetchall() - items = [json.loads(row[0]) for row in rows] - items.sort(key=lambda item: item.get("started_at", ""), reverse=True) - if limit is None or limit < 0: - return items - return items[:limit] - - def save_execution_record(self, execution: ExecutionRecord) -> None: - payload = json.dumps(execution.model_dump(mode="json"), ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO execution_records(execution_id, payload) VALUES(?, ?)", - (execution.execution_id, payload), - ) - - def get_execution_record(self, execution_id: str) -> dict | None: - with self._connect() as conn: - row = conn.execute( - "SELECT payload FROM execution_records WHERE execution_id = ?", - (execution_id,), - ).fetchone() - return None if row is None else json.loads(row[0]) - - def list_execution_records(self, limit: int | None = None) -> list[dict]: - with self._connect() as conn: - rows = conn.execute("SELECT payload FROM execution_records").fetchall() - items = [json.loads(row[0]) for row in rows] - items.sort(key=lambda item: item.get("updated_at", ""), reverse=True) - if limit is None or limit < 0: - return items - return items[:limit] - - def save_action_execution_record(self, execution_id: str, payload_dict: dict) -> None: - payload = json.dumps(payload_dict, ensure_ascii=True) - with self._connect() as conn: - conn.execute( - "INSERT OR REPLACE INTO action_execution_records(execution_id, payload) VALUES(?, ?)", - (execution_id, payload), - ) - - def get_action_execution_record(self, execution_id: str) -> dict | None: - with self._connect() as conn: - row = conn.execute( - "SELECT payload FROM action_execution_records WHERE execution_id = ?", - (execution_id,), - ).fetchone() - return None if row is None else json.loads(row[0]) - - def list_action_execution_records(self, limit: int | None = None) -> list[dict]: - with self._connect() as conn: - rows = conn.execute("SELECT payload FROM action_execution_records").fetchall() - items = [json.loads(row[0]) for row in rows] - items.sort(key=lambda item: item.get("created_at", ""), reverse=True) - if limit is None or limit < 0: - return items - return items[:limit] - - def clear_all(self) -> None: - with self._connect() as conn: - conn.execute("DELETE FROM decision_logs") - conn.execute("DELETE FROM state_snapshots") - conn.execute("DELETE FROM scenario_runs") - conn.execute("DELETE FROM event_log") - conn.execute("DELETE FROM run_records") - conn.execute("DELETE FROM traces") - conn.execute("DELETE FROM execution_records") - conn.execute("DELETE FROM action_execution_records") +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +from core.models import DecisionLog, OrchestrationTrace, ScenarioRun, SystemState +from core.runtime_records import EventEnvelope, ExecutionRecord, RunRecord + + +class SQLiteStore: + def __init__(self, path: str | Path = "chaincopilot.db") -> None: + self.path = str(path) + self._init_schema() + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.path) + + def _init_schema(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS decision_logs ( + decision_id TEXT PRIMARY KEY, + payload TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS state_snapshots ( + run_id TEXT PRIMARY KEY, + payload TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS scenario_runs ( + scenario_id TEXT PRIMARY KEY, + payload TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS event_log ( + event_id TEXT PRIMARY KEY, + payload TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS run_records ( + run_id TEXT PRIMARY KEY, + payload TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS traces ( + run_id TEXT PRIMARY KEY, + trace_id TEXT, + payload TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS execution_records ( + execution_id TEXT PRIMARY KEY, + payload TEXT NOT NULL + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS action_execution_records ( + execution_id TEXT PRIMARY KEY, + payload TEXT NOT NULL + ) + """ + ) + + def save_state(self, state: SystemState) -> None: + payload = json.dumps(state.model_dump(mode="json"), ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO state_snapshots(run_id, payload) VALUES(?, ?)", + (state.run_id, payload), + ) + + def save_decision_log(self, decision_log: DecisionLog) -> None: + payload = json.dumps(decision_log.model_dump(mode="json"), ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO decision_logs(decision_id, payload) VALUES(?, ?)", + (decision_log.decision_id, payload), + ) + + def list_decision_logs(self) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT payload FROM decision_logs ORDER BY decision_id DESC" + ).fetchall() + return [json.loads(row[0]) for row in rows] + + def get_decision_log(self, decision_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload FROM decision_logs WHERE decision_id = ?", + (decision_id,), + ).fetchone() + return None if row is None else json.loads(row[0]) + + def save_scenario_run(self, run: ScenarioRun) -> None: + payload = json.dumps(run.model_dump(mode="json"), ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO scenario_runs(scenario_id, payload) VALUES(?, ?)", + (run.run_id, payload), + ) + + def save_event_envelope(self, event: EventEnvelope) -> None: + payload = json.dumps(event.model_dump(mode="json"), ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO event_log(event_id, payload) VALUES(?, ?)", + (event.event_id, payload), + ) + + def get_event_envelope(self, event_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload FROM event_log WHERE event_id = ?", + (event_id,), + ).fetchone() + return None if row is None else json.loads(row[0]) + + def list_event_envelopes(self, limit: int | None = None) -> list[dict]: + with self._connect() as conn: + rows = conn.execute("SELECT payload FROM event_log").fetchall() + items = [json.loads(row[0]) for row in rows] + items.sort(key=lambda item: item.get("ingested_at", ""), reverse=True) + if limit is None or limit < 0: + return items + return items[:limit] + + def save_run_record(self, run: RunRecord) -> None: + payload = json.dumps(run.model_dump(mode="json"), ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO run_records(run_id, payload) VALUES(?, ?)", + (run.run_id, payload), + ) + + def get_run_record(self, run_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload FROM run_records WHERE run_id = ?", + (run_id,), + ).fetchone() + return None if row is None else json.loads(row[0]) + + def list_run_records(self, limit: int | None = None) -> list[dict]: + with self._connect() as conn: + rows = conn.execute("SELECT payload FROM run_records").fetchall() + items = [json.loads(row[0]) for row in rows] + items.sort(key=lambda item: item.get("started_at", ""), reverse=True) + if limit is None or limit < 0: + return items + return items[:limit] + + def get_state_snapshot(self, run_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload FROM state_snapshots WHERE run_id = ?", + (run_id,), + ).fetchone() + return None if row is None else json.loads(row[0]) + + def save_trace(self, run_id: str, trace: OrchestrationTrace) -> None: + payload = json.dumps(trace.model_dump(mode="json"), ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO traces(run_id, trace_id, payload) VALUES(?, ?, ?)", + (run_id, trace.trace_id, payload), + ) + + def get_trace(self, run_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload FROM traces WHERE run_id = ?", + (run_id,), + ).fetchone() + return None if row is None else json.loads(row[0]) + + def list_traces(self, limit: int | None = None) -> list[dict]: + with self._connect() as conn: + rows = conn.execute("SELECT payload FROM traces").fetchall() + items = [json.loads(row[0]) for row in rows] + items.sort(key=lambda item: item.get("started_at", ""), reverse=True) + if limit is None or limit < 0: + return items + return items[:limit] + + def save_execution_record(self, execution: ExecutionRecord) -> None: + payload = json.dumps(execution.model_dump(mode="json"), ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO execution_records(execution_id, payload) VALUES(?, ?)", + (execution.execution_id, payload), + ) + + def get_execution_record(self, execution_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload FROM execution_records WHERE execution_id = ?", + (execution_id,), + ).fetchone() + return None if row is None else json.loads(row[0]) + + def list_execution_records(self, limit: int | None = None) -> list[dict]: + with self._connect() as conn: + rows = conn.execute("SELECT payload FROM execution_records").fetchall() + items = [json.loads(row[0]) for row in rows] + items.sort(key=lambda item: item.get("updated_at", ""), reverse=True) + if limit is None or limit < 0: + return items + return items[:limit] + + def save_action_execution_record(self, execution_id: str, payload_dict: dict) -> None: + payload = json.dumps(payload_dict, ensure_ascii=True) + with self._connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO action_execution_records(execution_id, payload) VALUES(?, ?)", + (execution_id, payload), + ) + + def get_action_execution_record(self, execution_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT payload FROM action_execution_records WHERE execution_id = ?", + (execution_id,), + ).fetchone() + return None if row is None else json.loads(row[0]) + + def list_action_execution_records(self, limit: int | None = None) -> list[dict]: + with self._connect() as conn: + rows = conn.execute("SELECT payload FROM action_execution_records").fetchall() + items = [json.loads(row[0]) for row in rows] + items.sort(key=lambda item: item.get("created_at", ""), reverse=True) + if limit is None or limit < 0: + return items + return items[:limit] + + def clear_all(self) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM decision_logs") + conn.execute("DELETE FROM state_snapshots") + conn.execute("DELETE FROM scenario_runs") + conn.execute("DELETE FROM event_log") + conn.execute("DELETE FROM run_records") + conn.execute("DELETE FROM traces") + conn.execute("DELETE FROM execution_records") + conn.execute("DELETE FROM action_execution_records") diff --git a/core/models.py b/core/models.py index 3a363c5..0833eaa 100644 --- a/core/models.py +++ b/core/models.py @@ -1,367 +1,370 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from pydantic import BaseModel, Field, field_validator, model_validator - -from core.enums import ( - ActionType, - ApprovalStatus, - ConstraintViolationCode, - EventType, - Mode, - PlanStatus, -) - - -class KPIState(BaseModel): - service_level: float = Field(ge=0.0, le=1.0) - total_cost: float = Field(ge=0.0) - disruption_risk: float = Field(ge=0.0, le=1.0) - recovery_speed: float = Field(ge=0.0, le=1.0) - stockout_risk: float = Field(ge=0.0, le=1.0) - decision_latency_ms: float = Field(default=0.0, ge=0.0) - - -class DemandRecord(BaseModel): - demand_id: str - sku: str - day_index: int = Field(ge=0) - quantity: int = Field(ge=0) - - -class OrderRecord(BaseModel): - order_id: str - sku: str - day_index: int = Field(ge=0) - quantity: int = Field(ge=0) - warehouse_id: str - - -class InventoryItem(BaseModel): - sku: str - name: str = "Unknown SKU" - on_hand: int = Field(ge=0) - incoming_qty: int = Field(default=0, ge=0) - reorder_point: int = Field(default=0, ge=0) - safety_stock: int = Field(default=0, ge=0) - unit_cost: float = Field(ge=0.0) - preferred_supplier_id: str - preferred_route_id: str - warehouse_id: str - forecast_qty: int = Field(default=0, ge=0) - std_demand: float = Field(default=0.0, ge=0.0) - - -class SupplierRecord(BaseModel): - supplier_id: str - sku: str - unit_cost: float = Field(ge=0.0) - lead_time_days: int = Field(ge=0) - reliability: float = Field(ge=0.0, le=1.0) - is_primary: bool = False - status: str = "active" - capacity: int = Field(default=1_000_000, ge=0) - - -class RouteRecord(BaseModel): - route_id: str - origin: str - destination: str - transit_days: int = Field(ge=0) - cost: float = Field(ge=0.0) - risk_score: float = Field(ge=0.0, le=1.0) - status: str = "active" - - -class WarehouseRecord(BaseModel): - warehouse_id: str - name: str - capacity: int = Field(ge=0) - region: str - - -class Event(BaseModel): - event_id: str - type: EventType - source: str - severity: float = Field(ge=0.0, le=1.0) - entity_ids: list[str] = Field(default_factory=list) - occurred_at: datetime - detected_at: datetime - payload: dict[str, Any] = Field(default_factory=dict) - dedupe_key: str - - @model_validator(mode="after") - def validate_times(self) -> "Event": - if self.detected_at < self.occurred_at: - raise ValueError("detected_at must be greater than or equal to occurred_at") - return self - - -class Action(BaseModel): - action_id: str - action_type: ActionType - target_id: str - parameters: dict[str, Any] = Field(default_factory=dict) - estimated_cost_delta: float = 0.0 - estimated_service_delta: float = 0.0 - estimated_risk_delta: float = 0.0 - estimated_recovery_hours: float = 0.0 - reason: str = "" - priority: float = 0.0 - - -class AgentProposal(BaseModel): - agent: str - observations: list[str] = Field(default_factory=list) - proposals: list[Action] = Field(default_factory=list) - risks: list[str] = Field(default_factory=list) - domain_summary: str = "" - downstream_impacts: list[str] = Field(default_factory=list) - recommended_action_ids: list[str] = Field(default_factory=list) - tradeoffs: list[str] = Field(default_factory=list) - notes_for_planner: str = "" - llm_used: bool = False - llm_error: str | None = None - - -class CandidatePlanDraft(BaseModel): - strategy_label: str - action_ids: list[str] = Field(default_factory=list) - rationale: str = "" - llm_used: bool = False - - -class ConstraintViolation(BaseModel): - code: ConstraintViolationCode - message: str - action_id: str | None = None - severity: str = "hard" - - +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, field_validator, model_validator + +from core.enums import ( + ActionType, + ApprovalStatus, + ConstraintViolationCode, + EventType, + Mode, + PlanStatus, +) + + +class KPIState(BaseModel): + service_level: float = Field(ge=0.0, le=1.0) + total_cost: float = Field(ge=0.0) + disruption_risk: float = Field(ge=0.0, le=1.0) + recovery_speed: float = Field(ge=0.0, le=1.0) + stockout_risk: float = Field(ge=0.0, le=1.0) + decision_latency_ms: float = Field(default=0.0, ge=0.0) + + +class DemandRecord(BaseModel): + demand_id: str + sku: str + day_index: int = Field(ge=0) + quantity: int = Field(ge=0) + + +class OrderRecord(BaseModel): + order_id: str + sku: str + day_index: int = Field(ge=0) + quantity: int = Field(ge=0) + warehouse_id: str + + +class InventoryItem(BaseModel): + sku: str + name: str = "Unknown SKU" + on_hand: int = Field(ge=0) + incoming_qty: int = Field(default=0, ge=0) + reorder_point: int = Field(default=0, ge=0) + safety_stock: int = Field(default=0, ge=0) + unit_cost: float = Field(ge=0.0) + preferred_supplier_id: str + preferred_route_id: str + warehouse_id: str + forecast_qty: int = Field(default=0, ge=0) + std_demand: float = Field(default=0.0, ge=0.0) + + +class SupplierRecord(BaseModel): + supplier_id: str + sku: str + unit_cost: float = Field(ge=0.0) + lead_time_days: int = Field(ge=0) + reliability: float = Field(ge=0.0, le=1.0) + is_primary: bool = False + status: str = "active" + capacity: int = Field(default=1_000_000, ge=0) + + +class RouteRecord(BaseModel): + route_id: str + origin: str + destination: str + transit_days: int = Field(ge=0) + cost: float = Field(ge=0.0) + risk_score: float = Field(ge=0.0, le=1.0) + status: str = "active" + + +class WarehouseRecord(BaseModel): + warehouse_id: str + name: str + capacity: int = Field(ge=0) + region: str + + +class Event(BaseModel): + event_id: str + type: EventType + source: str + severity: float = Field(ge=0.0, le=1.0) + entity_ids: list[str] = Field(default_factory=list) + occurred_at: datetime + detected_at: datetime + payload: dict[str, Any] = Field(default_factory=dict) + dedupe_key: str + + @model_validator(mode="after") + def validate_times(self) -> "Event": + if self.detected_at < self.occurred_at: + raise ValueError("detected_at must be greater than or equal to occurred_at") + return self + + +class Action(BaseModel): + action_id: str + action_type: ActionType + target_id: str + parameters: dict[str, Any] = Field(default_factory=dict) + estimated_cost_delta: float = 0.0 + estimated_service_delta: float = 0.0 + estimated_risk_delta: float = 0.0 + estimated_recovery_hours: float = 0.0 + reason: str = "" + priority: float = 0.0 + + +class AgentProposal(BaseModel): + agent: str + observations: list[str] = Field(default_factory=list) + proposals: list[Action] = Field(default_factory=list) + risks: list[str] = Field(default_factory=list) + domain_summary: str = "" + downstream_impacts: list[str] = Field(default_factory=list) + recommended_action_ids: list[str] = Field(default_factory=list) + tradeoffs: list[str] = Field(default_factory=list) + notes_for_planner: str = "" + llm_used: bool = False + llm_error: str | None = None + + +class CandidatePlanDraft(BaseModel): + strategy_label: str + action_ids: list[str] = Field(default_factory=list) + rationale: str = "" + llm_used: bool = False + + +class ConstraintViolation(BaseModel): + code: ConstraintViolationCode + message: str + action_id: str | None = None + severity: str = "hard" + + class CandidatePlanEvaluation(BaseModel): strategy_label: str action_ids: list[str] = Field(default_factory=list) score: float score_breakdown: dict[str, float] - projected_kpis: KPIState - feasible: bool = True - violations: list[ConstraintViolation] = Field(default_factory=list) - mode_rationale: str = "" + projected_kpis: KPIState + feasible: bool = True + violations: list[ConstraintViolation] = Field(default_factory=list) + mode_rationale: str = "" approval_required: bool = False approval_reason: str = "" rationale: str = "" llm_used: bool = False - - -class HistoricalCase(BaseModel): - case_id: str - event_type: str - event_severity: float = 0.0 - actions_taken: list[str] = Field(default_factory=list) - outcome_kpis: dict[str, float] = Field(default_factory=dict) - reflection_notes: str = "" - similarity_score: float = Field(default=0.0, ge=0.0, le=1.0) - - -class PlanMetadata(BaseModel): - referenced_cases: list[HistoricalCase] = Field(default_factory=list) - memory_influence_score: float = Field(default=0.0, ge=0.0, le=1.0) - strategy_rationale: str = "" - strategic_prompt: str = "" - - -class Plan(BaseModel): - plan_id: str - mode: Mode - trigger_event_ids: list[str] = Field(default_factory=list) - actions: list[Action] = Field(default_factory=list) - score: float - score_breakdown: dict[str, float] - strategy_label: str | None = None - generated_by: str | None = None - approval_required: bool = False - approval_reason: str = "" - planner_reasoning: str = "" - llm_planner_narrative: str | None = None - critic_summary: str | None = None - status: PlanStatus = PlanStatus.PROPOSED - feasible: bool = True - violations: list[ConstraintViolation] = Field(default_factory=list) - mode_rationale: str = "" - metadata: PlanMetadata = Field(default_factory=PlanMetadata) - - -class DecisionLog(BaseModel): - decision_id: str - plan_id: str - event_ids: list[str] = Field(default_factory=list) - before_kpis: KPIState - after_kpis: KPIState - selected_actions: list[str] = Field(default_factory=list) - rejected_actions: list[dict[str, str]] = Field(default_factory=list) - score_breakdown: dict[str, float] - rationale: str - candidate_evaluations: list[CandidatePlanEvaluation] = Field(default_factory=list) - selection_reason: str = "" - winning_factors: list[str] = Field(default_factory=list) - approval_required: bool = False - approval_reason: str = "" - planner_error: str | None = None - llm_operator_explanation: str | None = None - llm_approval_summary: str | None = None - llm_used: bool = False - llm_provider: str | None = None - llm_model: str | None = None - llm_error: str | None = None - critic_summary: str | None = None - critic_findings: list[str] = Field(default_factory=list) - critic_used: bool = False - critic_error: str | None = None - approval_status: ApprovalStatus = ApprovalStatus.NOT_REQUIRED - feasible: bool = True - violations: list[ConstraintViolation] = Field(default_factory=list) - mode_rationale: str = "" - metadata: PlanMetadata = Field(default_factory=PlanMetadata) - - -class ReflectionNote(BaseModel): - note_id: str - run_id: str - scenario_id: str - plan_id: str | None = None - event_types: list[str] = Field(default_factory=list) - mode: str - approval_status: str - summary: str - lessons: list[str] = Field(default_factory=list) - pattern_tags: list[str] = Field(default_factory=list) - follow_up_checks: list[str] = Field(default_factory=list) - llm_used: bool = False - llm_error: str | None = None - - -class TraceRouteDecision(BaseModel): - from_node: str - outcome: str - to_node: str - reason: str = "" - - -class TraceStep(BaseModel): - step_id: str | None = None - sequence: int = 0 - node_key: str - node_type: str - status: str = "running" - started_at: datetime - completed_at: datetime | None = None - duration_ms: float | None = Field(default=None, ge=0.0) - mode_snapshot: str - input_snapshot: dict[str, Any] = Field(default_factory=dict) - output_snapshot: dict[str, Any] = Field(default_factory=dict) - summary: str = "" - reasoning_source: str = "" - observations: list[str] = Field(default_factory=list) - risks: list[str] = Field(default_factory=list) - downstream_impacts: list[str] = Field(default_factory=list) - recommended_action_ids: list[str] = Field(default_factory=list) - tradeoffs: list[str] = Field(default_factory=list) - llm_used: bool = False - llm_error: str | None = None - fallback_used: bool = False - fallback_reason: str | None = None - - -class OrchestrationTrace(BaseModel): - trace_id: str - run_id: str - started_at: datetime - completed_at: datetime | None = None - mode_before: str - mode_after: str | None = None - current_branch: str = "normal" - terminal_stage: str | None = None - status: str = "running" - event: Event | None = None - route_decisions: list[TraceRouteDecision] = Field(default_factory=list) - steps: list[TraceStep] = Field(default_factory=list) - decision_id: str | None = None - selected_plan_id: str | None = None - selected_strategy: str | None = None - candidate_count: int = 0 - selection_reason: str | None = None - approval_pending: bool = False - approval_reason: str = "" - execution_status: str | None = None - critic_summary: str | None = None - - -class MemorySnapshot(BaseModel): - snapshot_id: str - timestamp: datetime - supplier_reliability: dict[str, float] = Field(default_factory=dict) - route_disruption_priors: dict[str, float] = Field(default_factory=dict) - scenario_outcomes: dict[str, dict[str, Any]] = Field(default_factory=dict) - last_approved_plan_ids: list[str] = Field(default_factory=list) - reflection_notes: list[ReflectionNote] = Field(default_factory=list) - pattern_tag_counts: dict[str, int] = Field(default_factory=dict) - historical_cases: list[HistoricalCase] = Field(default_factory=list) - - -class ScenarioRun(BaseModel): - run_id: str - scenario_id: str - seed: int - events: list[Event] - result_plan_id: str | None = None - decision_id: str | None = None - result_kpis: KPIState | None = None - outcome_summary: dict[str, Any] = Field(default_factory=dict) - approval_status: str | None = None - reflection_status: str = "pending" - reflection_note_id: str | None = None - duration_ms: float = Field(default=0.0, ge=0.0) - status: str = "pending" - - -class SystemState(BaseModel): - run_id: str - mode: Mode = Mode.NORMAL - timestamp: datetime - inventory: dict[str, InventoryItem] = Field(default_factory=dict) - suppliers: dict[str, SupplierRecord] = Field(default_factory=dict) - routes: dict[str, RouteRecord] = Field(default_factory=dict) - warehouses: dict[str, WarehouseRecord] = Field(default_factory=dict) - orders: list[OrderRecord] = Field(default_factory=list) - demands: list[DemandRecord] = Field(default_factory=list) - kpis: KPIState - active_events: list[Event] = Field(default_factory=list) - latest_plan_id: str | None = None - latest_plan: Plan | None = None - pending_plan: Plan | None = None - decision_logs: list[DecisionLog] = Field(default_factory=list) - candidate_actions: list[Action] = Field(default_factory=list) - agent_outputs: dict[str, AgentProposal] = Field(default_factory=dict) - latest_trace: OrchestrationTrace | None = None - memory: MemorySnapshot | None = None - scenario_history: list[ScenarioRun] = Field(default_factory=list) - extra_cost: float = Field(default=0.0, ge=0.0) - - @field_validator("active_events") - @classmethod - def dedupe_events(cls, value: list[Event]) -> list[Event]: - seen: set[str] = set() - result: list[Event] = [] - for event in value: - if event.dedupe_key in seen: - continue - seen.add(event.dedupe_key) - result.append(event) - return result - - -# Alias for backward compatibility after consolidation -Violation = ConstraintViolation + coverage_fraction: float = 0.0 + critical_covered: int = 0 + unresolved_critical: int = 0 + + +class HistoricalCase(BaseModel): + case_id: str + event_type: str + event_severity: float = 0.0 + actions_taken: list[str] = Field(default_factory=list) + outcome_kpis: dict[str, float] = Field(default_factory=dict) + reflection_notes: str = "" + similarity_score: float = Field(default=0.0, ge=0.0, le=1.0) + + +class PlanMetadata(BaseModel): + referenced_cases: list[HistoricalCase] = Field(default_factory=list) + memory_influence_score: float = Field(default=0.0, ge=0.0, le=1.0) + strategy_rationale: str = "" + strategic_prompt: str = "" + + +class Plan(BaseModel): + plan_id: str + mode: Mode + trigger_event_ids: list[str] = Field(default_factory=list) + actions: list[Action] = Field(default_factory=list) + score: float + score_breakdown: dict[str, float] + strategy_label: str | None = None + generated_by: str | None = None + approval_required: bool = False + approval_reason: str = "" + planner_reasoning: str = "" + llm_planner_narrative: str | None = None + critic_summary: str | None = None + status: PlanStatus = PlanStatus.PROPOSED + feasible: bool = True + violations: list[ConstraintViolation] = Field(default_factory=list) + mode_rationale: str = "" + metadata: PlanMetadata = Field(default_factory=PlanMetadata) + + +class DecisionLog(BaseModel): + decision_id: str + plan_id: str + event_ids: list[str] = Field(default_factory=list) + before_kpis: KPIState + after_kpis: KPIState + selected_actions: list[str] = Field(default_factory=list) + rejected_actions: list[dict[str, str]] = Field(default_factory=list) + score_breakdown: dict[str, float] + rationale: str + candidate_evaluations: list[CandidatePlanEvaluation] = Field(default_factory=list) + selection_reason: str = "" + winning_factors: list[str] = Field(default_factory=list) + approval_required: bool = False + approval_reason: str = "" + planner_error: str | None = None + llm_operator_explanation: str | None = None + llm_approval_summary: str | None = None + llm_used: bool = False + llm_provider: str | None = None + llm_model: str | None = None + llm_error: str | None = None + critic_summary: str | None = None + critic_findings: list[str] = Field(default_factory=list) + critic_used: bool = False + critic_error: str | None = None + approval_status: ApprovalStatus = ApprovalStatus.NOT_REQUIRED + feasible: bool = True + violations: list[ConstraintViolation] = Field(default_factory=list) + mode_rationale: str = "" + metadata: PlanMetadata = Field(default_factory=PlanMetadata) + + +class ReflectionNote(BaseModel): + note_id: str + run_id: str + scenario_id: str + plan_id: str | None = None + event_types: list[str] = Field(default_factory=list) + mode: str + approval_status: str + summary: str + lessons: list[str] = Field(default_factory=list) + pattern_tags: list[str] = Field(default_factory=list) + follow_up_checks: list[str] = Field(default_factory=list) + llm_used: bool = False + llm_error: str | None = None + + +class TraceRouteDecision(BaseModel): + from_node: str + outcome: str + to_node: str + reason: str = "" + + +class TraceStep(BaseModel): + step_id: str | None = None + sequence: int = 0 + node_key: str + node_type: str + status: str = "running" + started_at: datetime + completed_at: datetime | None = None + duration_ms: float | None = Field(default=None, ge=0.0) + mode_snapshot: str + input_snapshot: dict[str, Any] = Field(default_factory=dict) + output_snapshot: dict[str, Any] = Field(default_factory=dict) + summary: str = "" + reasoning_source: str = "" + observations: list[str] = Field(default_factory=list) + risks: list[str] = Field(default_factory=list) + downstream_impacts: list[str] = Field(default_factory=list) + recommended_action_ids: list[str] = Field(default_factory=list) + tradeoffs: list[str] = Field(default_factory=list) + llm_used: bool = False + llm_error: str | None = None + fallback_used: bool = False + fallback_reason: str | None = None + + +class OrchestrationTrace(BaseModel): + trace_id: str + run_id: str + started_at: datetime + completed_at: datetime | None = None + mode_before: str + mode_after: str | None = None + current_branch: str = "normal" + terminal_stage: str | None = None + status: str = "running" + event: Event | None = None + route_decisions: list[TraceRouteDecision] = Field(default_factory=list) + steps: list[TraceStep] = Field(default_factory=list) + decision_id: str | None = None + selected_plan_id: str | None = None + selected_strategy: str | None = None + candidate_count: int = 0 + selection_reason: str | None = None + approval_pending: bool = False + approval_reason: str = "" + execution_status: str | None = None + critic_summary: str | None = None + + +class MemorySnapshot(BaseModel): + snapshot_id: str + timestamp: datetime + supplier_reliability: dict[str, float] = Field(default_factory=dict) + route_disruption_priors: dict[str, float] = Field(default_factory=dict) + scenario_outcomes: dict[str, dict[str, Any]] = Field(default_factory=dict) + last_approved_plan_ids: list[str] = Field(default_factory=list) + reflection_notes: list[ReflectionNote] = Field(default_factory=list) + pattern_tag_counts: dict[str, int] = Field(default_factory=dict) + historical_cases: list[HistoricalCase] = Field(default_factory=list) + + +class ScenarioRun(BaseModel): + run_id: str + scenario_id: str + seed: int + events: list[Event] + result_plan_id: str | None = None + decision_id: str | None = None + result_kpis: KPIState | None = None + outcome_summary: dict[str, Any] = Field(default_factory=dict) + approval_status: str | None = None + reflection_status: str = "pending" + reflection_note_id: str | None = None + duration_ms: float = Field(default=0.0, ge=0.0) + status: str = "pending" + + +class SystemState(BaseModel): + run_id: str + mode: Mode = Mode.NORMAL + timestamp: datetime + inventory: dict[str, InventoryItem] = Field(default_factory=dict) + suppliers: dict[str, SupplierRecord] = Field(default_factory=dict) + routes: dict[str, RouteRecord] = Field(default_factory=dict) + warehouses: dict[str, WarehouseRecord] = Field(default_factory=dict) + orders: list[OrderRecord] = Field(default_factory=list) + demands: list[DemandRecord] = Field(default_factory=list) + kpis: KPIState + active_events: list[Event] = Field(default_factory=list) + latest_plan_id: str | None = None + latest_plan: Plan | None = None + pending_plan: Plan | None = None + decision_logs: list[DecisionLog] = Field(default_factory=list) + candidate_actions: list[Action] = Field(default_factory=list) + agent_outputs: dict[str, AgentProposal] = Field(default_factory=dict) + latest_trace: OrchestrationTrace | None = None + memory: MemorySnapshot | None = None + scenario_history: list[ScenarioRun] = Field(default_factory=list) + extra_cost: float = Field(default=0.0, ge=0.0) + + @field_validator("active_events") + @classmethod + def dedupe_events(cls, value: list[Event]) -> list[Event]: + seen: set[str] = set() + result: list[Event] = [] + for event in value: + if event.dedupe_key in seen: + continue + seen.add(event.dedupe_key) + result.append(event) + return result + + +# Alias for backward compatibility after consolidation +Violation = ConstraintViolation diff --git a/core/runtime_records.py b/core/runtime_records.py index fa41c11..4413c6a 100644 --- a/core/runtime_records.py +++ b/core/runtime_records.py @@ -1,127 +1,127 @@ -from __future__ import annotations - -from datetime import datetime -from enum import Enum -from typing import Any - -from pydantic import BaseModel, Field - - -class RunType(str, Enum): - DAILY_CYCLE = "daily_cycle" - EVENT_RESPONSE = "event_response" - SCENARIO_STEP = "scenario_step" - APPROVAL_RESOLUTION = "approval_resolution" - - -class RunStatus(str, Enum): - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - - -class EventClass(str, Enum): - COMMAND = "command" - DOMAIN = "domain" - SYSTEM = "system" - - -class ExecutionStatus(str, Enum): - PLANNED = "planned" - APPROVAL_PENDING = "approval_pending" - APPROVED = "approved" - DISPATCHED = "dispatched" - APPLIED = "applied" - COMPLETED = "completed" - FAILED = "failed" - ROLLED_BACK = "rolled_back" - CANCELLED = "cancelled" - - -class DispatchMode(str, Enum): - SIMULATION = "simulation" - PRODUCTION = "production" - - -class EventEnvelope(BaseModel): - event_id: str - event_class: EventClass - event_type: str - source: str - occurred_at: datetime - ingested_at: datetime - correlation_id: str - causation_id: str | None = None - idempotency_key: str - severity: float = Field(default=0.0, ge=0.0, le=1.0) - entity_ids: list[str] = Field(default_factory=list) - payload: dict[str, Any] = Field(default_factory=dict) - - -class SelectedPlanSummary(BaseModel): - plan_id: str - strategy_label: str | None = None - generated_by: str | None = None - approval_required: bool = False - approval_reason: str = "" - score: float = 0.0 - action_ids: list[str] = Field(default_factory=list) - - -class ExecutionSummary(BaseModel): - status: ExecutionStatus - dispatch_mode: DispatchMode = DispatchMode.SIMULATION - action_ids: list[str] = Field(default_factory=list) - - -class ExecutionReceipt(BaseModel): - receipt_id: str - action_id: str - status: str - detail: str = "" - - -class ExecutionTransition(BaseModel): - status: ExecutionStatus - timestamp: datetime - reason: str = "" - - -class ExecutionRecord(BaseModel): - execution_id: str - run_id: str - decision_id: str | None = None - plan_id: str | None = None - status: ExecutionStatus - dispatch_mode: DispatchMode = DispatchMode.SIMULATION - dry_run: bool = False - target_system: str = "digital_twin" - action_ids: list[str] = Field(default_factory=list) - receipts: list[ExecutionReceipt] = Field(default_factory=list) - status_history: list[ExecutionTransition] = Field(default_factory=list) - failure_reason: str | None = None - created_at: datetime - updated_at: datetime - - -class RunRecord(BaseModel): - run_id: str - run_type: RunType - parent_run_id: str | None = None - correlation_id: str - trigger_event_id: str | None = None - input_event_ids: list[str] = Field(default_factory=list) - mode_before: str - mode_after: str - status: RunStatus - started_at: datetime - completed_at: datetime | None = None - duration_ms: float = Field(default=0.0, ge=0.0) - decision_id: str | None = None - selected_plan_id: str | None = None - execution_id: str | None = None - approval_status: str | None = None - llm_fallback_used: bool = False - llm_fallback_reason: str | None = None - selected_plan_summary: SelectedPlanSummary | None = None - execution_summary: ExecutionSummary | None = None +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class RunType(str, Enum): + DAILY_CYCLE = "daily_cycle" + EVENT_RESPONSE = "event_response" + SCENARIO_STEP = "scenario_step" + APPROVAL_RESOLUTION = "approval_resolution" + + +class RunStatus(str, Enum): + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class EventClass(str, Enum): + COMMAND = "command" + DOMAIN = "domain" + SYSTEM = "system" + + +class ExecutionStatus(str, Enum): + PLANNED = "planned" + APPROVAL_PENDING = "approval_pending" + APPROVED = "approved" + DISPATCHED = "dispatched" + APPLIED = "applied" + COMPLETED = "completed" + FAILED = "failed" + ROLLED_BACK = "rolled_back" + CANCELLED = "cancelled" + + +class DispatchMode(str, Enum): + SIMULATION = "simulation" + PRODUCTION = "production" + + +class EventEnvelope(BaseModel): + event_id: str + event_class: EventClass + event_type: str + source: str + occurred_at: datetime + ingested_at: datetime + correlation_id: str + causation_id: str | None = None + idempotency_key: str + severity: float = Field(default=0.0, ge=0.0, le=1.0) + entity_ids: list[str] = Field(default_factory=list) + payload: dict[str, Any] = Field(default_factory=dict) + + +class SelectedPlanSummary(BaseModel): + plan_id: str + strategy_label: str | None = None + generated_by: str | None = None + approval_required: bool = False + approval_reason: str = "" + score: float = 0.0 + action_ids: list[str] = Field(default_factory=list) + + +class ExecutionSummary(BaseModel): + status: ExecutionStatus + dispatch_mode: DispatchMode = DispatchMode.SIMULATION + action_ids: list[str] = Field(default_factory=list) + + +class ExecutionReceipt(BaseModel): + receipt_id: str + action_id: str + status: str + detail: str = "" + + +class ExecutionTransition(BaseModel): + status: ExecutionStatus + timestamp: datetime + reason: str = "" + + +class ExecutionRecord(BaseModel): + execution_id: str + run_id: str + decision_id: str | None = None + plan_id: str | None = None + status: ExecutionStatus + dispatch_mode: DispatchMode = DispatchMode.SIMULATION + dry_run: bool = False + target_system: str = "digital_twin" + action_ids: list[str] = Field(default_factory=list) + receipts: list[ExecutionReceipt] = Field(default_factory=list) + status_history: list[ExecutionTransition] = Field(default_factory=list) + failure_reason: str | None = None + created_at: datetime + updated_at: datetime + + +class RunRecord(BaseModel): + run_id: str + run_type: RunType + parent_run_id: str | None = None + correlation_id: str + trigger_event_id: str | None = None + input_event_ids: list[str] = Field(default_factory=list) + mode_before: str + mode_after: str + status: RunStatus + started_at: datetime + completed_at: datetime | None = None + duration_ms: float = Field(default=0.0, ge=0.0) + decision_id: str | None = None + selected_plan_id: str | None = None + execution_id: str | None = None + approval_status: str | None = None + llm_fallback_used: bool = False + llm_fallback_reason: str | None = None + selected_plan_summary: SelectedPlanSummary | None = None + execution_summary: ExecutionSummary | None = None diff --git a/core/runtime_tracking.py b/core/runtime_tracking.py index e1aeefb..caf9b8b 100644 --- a/core/runtime_tracking.py +++ b/core/runtime_tracking.py @@ -1,83 +1,85 @@ -from __future__ import annotations - +from __future__ import annotations + from datetime import datetime from uuid import uuid4 from llm.config import load_settings +from core.enums import PlanStatus from core.models import DecisionLog, OrchestrationTrace, Plan, SystemState -from core.runtime_records import ( - DispatchMode, - EventEnvelope, - ExecutionRecord, - ExecutionReceipt, - ExecutionStatus, - ExecutionSummary, - ExecutionTransition, - RunRecord, - RunStatus, - RunType, - SelectedPlanSummary, -) -from core.state import utc_now - - -def new_run_id() -> str: - return f"run_{uuid4().hex[:8]}" - - -def new_execution_id() -> str: - return f"exec_{uuid4().hex[:8]}" - - -def new_event_envelope_id() -> str: - return f"evt_env_{uuid4().hex[:8]}" - - -def new_correlation_id(prefix: str = "corr") -> str: - return f"{prefix}_{uuid4().hex[:8]}" - - -def derive_fallback_reason(state: SystemState) -> str | None: - settings = load_settings() - if not settings.enabled: - return "llm_disabled" - reasons: list[str] = [] - if state.latest_trace is not None: - for step in state.latest_trace.steps: - if step.fallback_reason: - reasons.append(step.fallback_reason) - latest_decision = state.decision_logs[-1] if state.decision_logs else None - if latest_decision is not None: - for candidate in ( - latest_decision.planner_error, - latest_decision.llm_error, - latest_decision.critic_error, - ): - if candidate: - reasons.append(candidate) - return reasons[0] if reasons else None - - -def plan_summary(plan: Plan | None) -> SelectedPlanSummary | None: - if plan is None: - return None - return SelectedPlanSummary( - plan_id=plan.plan_id, - strategy_label=plan.strategy_label, - generated_by=plan.generated_by, - approval_required=plan.approval_required, - approval_reason=plan.approval_reason, - score=plan.score, - action_ids=[action.action_id for action in plan.actions], - ) - - -def execution_status_from_state(state: SystemState, decision: DecisionLog | None = None) -> ExecutionStatus: - latest_trace = state.latest_trace - if latest_trace is not None: +from core.runtime_records import ( + DispatchMode, + EventEnvelope, + ExecutionRecord, + ExecutionReceipt, + ExecutionStatus, + ExecutionSummary, + ExecutionTransition, + RunRecord, + RunStatus, + RunType, + SelectedPlanSummary, +) +from core.state import utc_now + + +def new_run_id() -> str: + return f"run_{uuid4().hex[:8]}" + + +def new_execution_id() -> str: + return f"exec_{uuid4().hex[:8]}" + + +def new_event_envelope_id() -> str: + return f"evt_env_{uuid4().hex[:8]}" + + +def new_correlation_id(prefix: str = "corr") -> str: + return f"{prefix}_{uuid4().hex[:8]}" + + +def derive_fallback_reason(state: SystemState) -> str | None: + settings = load_settings() + if not settings.enabled: + return "llm_disabled" + reasons: list[str] = [] + if state.latest_trace is not None: + for step in state.latest_trace.steps: + if step.fallback_reason: + reasons.append(step.fallback_reason) + latest_decision = state.decision_logs[-1] if state.decision_logs else None + if latest_decision is not None: + for candidate in ( + latest_decision.planner_error, + latest_decision.llm_error, + latest_decision.critic_error, + ): + if candidate: + reasons.append(candidate) + return reasons[0] if reasons else None + + +def plan_summary(plan: Plan | None) -> SelectedPlanSummary | None: + if plan is None: + return None + return SelectedPlanSummary( + plan_id=plan.plan_id, + strategy_label=plan.strategy_label, + generated_by=plan.generated_by, + approval_required=plan.approval_required, + approval_reason=plan.approval_reason, + score=plan.score, + action_ids=[action.action_id for action in plan.actions], + ) + + +def execution_status_from_state(state: SystemState, decision: DecisionLog | None = None) -> ExecutionStatus: + latest_trace = state.latest_trace + if latest_trace is not None: status_map = { "pending_approval": ExecutionStatus.APPROVAL_PENDING, + "approved_pending_dispatch": ExecutionStatus.APPROVED, "approved_and_applied": ExecutionStatus.APPLIED, "auto_applied": ExecutionStatus.APPLIED, "rejected": ExecutionStatus.CANCELLED, @@ -90,160 +92,164 @@ def execution_status_from_state(state: SystemState, decision: DecisionLog | None if state.pending_plan is not None: return ExecutionStatus.APPROVAL_PENDING if state.latest_plan is not None: + if state.latest_plan.status == PlanStatus.APPROVED: + return ExecutionStatus.APPROVED return ExecutionStatus.APPLIED - if decision is not None and decision.approval_status.value == "rejected": - return ExecutionStatus.CANCELLED - return ExecutionStatus.PLANNED - - -def _transition(status: ExecutionStatus, reason: str, timestamp: datetime | None = None) -> ExecutionTransition: - return ExecutionTransition( - status=status, - timestamp=timestamp or utc_now(), - reason=reason, - ) - - -def _receipts_for_status(plan: Plan | None, status: ExecutionStatus) -> list[ExecutionReceipt]: - if plan is None: - return [] - if status not in {ExecutionStatus.APPLIED, ExecutionStatus.APPROVED, ExecutionStatus.DISPATCHED}: - return [] - detail = "simulated action applied" if status == ExecutionStatus.APPLIED else "simulated action acknowledged" - return [ - ExecutionReceipt( - receipt_id=f"rcpt_{uuid4().hex[:8]}", - action_id=action.action_id, - status=status.value, - detail=detail, - ) - for action in plan.actions - ] - - + if decision is not None and decision.approval_status.value == "rejected": + return ExecutionStatus.CANCELLED + return ExecutionStatus.PLANNED + + +def _transition(status: ExecutionStatus, reason: str, timestamp: datetime | None = None) -> ExecutionTransition: + return ExecutionTransition( + status=status, + timestamp=timestamp or utc_now(), + reason=reason, + ) + + +def _receipts_for_status(plan: Plan | None, status: ExecutionStatus) -> list[ExecutionReceipt]: + if plan is None: + return [] + if status not in {ExecutionStatus.APPLIED, ExecutionStatus.APPROVED, ExecutionStatus.DISPATCHED}: + return [] + detail = "simulated action applied" if status == ExecutionStatus.APPLIED else "simulated action acknowledged" + return [ + ExecutionReceipt( + receipt_id=f"rcpt_{uuid4().hex[:8]}", + action_id=action.action_id, + status=status.value, + detail=detail, + ) + for action in plan.actions + ] + + def initial_execution_history(plan: Plan | None, status: ExecutionStatus) -> list[ExecutionTransition]: if plan is None: return [_transition(ExecutionStatus.PLANNED, "execution placeholder created")] history = [_transition(ExecutionStatus.PLANNED, "plan selected for execution lifecycle")] if status == ExecutionStatus.APPROVAL_PENDING: history.append(_transition(ExecutionStatus.APPROVAL_PENDING, "execution waiting for operator approval")) + elif status == ExecutionStatus.APPROVED: + history.append(_transition(ExecutionStatus.APPROVED, "execution approved and ready for dispatch")) elif status == ExecutionStatus.APPLIED: history.append(_transition(ExecutionStatus.APPLIED, "execution auto-applied in simulation")) elif status == ExecutionStatus.CANCELLED: history.append(_transition(ExecutionStatus.CANCELLED, "execution cancelled")) return history - - -def build_execution_record( - *, - run_id: str, - state: SystemState, - decision: DecisionLog | None = None, - execution_id: str | None = None, - created_at: datetime | None = None, -) -> ExecutionRecord | None: - plan = state.pending_plan or state.latest_plan - if plan is None and decision is None: - return None - now = created_at or utc_now() - summary = execution_summary(state, decision=decision) - status = summary.status - return ExecutionRecord( - execution_id=execution_id or new_execution_id(), - run_id=run_id, - decision_id=decision.decision_id if decision else None, - plan_id=plan.plan_id if plan else None, - status=status, - dispatch_mode=summary.dispatch_mode, - dry_run=False, - target_system="digital_twin", - action_ids=summary.action_ids, - receipts=_receipts_for_status(plan, status), - status_history=initial_execution_history(plan, status), - failure_reason=None, - created_at=now, - updated_at=now, - ) - - -def execution_summary(state: SystemState, decision: DecisionLog | None = None) -> ExecutionSummary | None: - plan = state.pending_plan or state.latest_plan - if plan is None and decision is None: - return None - actions = [action.action_id for action in plan.actions] if plan else [] - settings = load_settings() - return ExecutionSummary( - status=execution_status_from_state(state, decision=decision), - dispatch_mode=DispatchMode(settings.dispatch_mode), - action_ids=actions, - ) - - -def build_run_record( - *, - run_id: str, - run_type: RunType, - state: SystemState, - started_at: datetime, - parent_run_id: str | None = None, - envelope: EventEnvelope | None = None, - execution_id: str | None = None, - status: RunStatus = RunStatus.COMPLETED, -) -> RunRecord: - completed_at = utc_now() - latest_decision = state.decision_logs[-1] if state.decision_logs else None - fallback_reason = derive_fallback_reason(state) - return RunRecord( - run_id=run_id, - run_type=run_type, - parent_run_id=parent_run_id, - correlation_id=envelope.correlation_id if envelope else new_correlation_id("run"), - trigger_event_id=envelope.event_id if envelope else None, - input_event_ids=[event.event_id for event in state.active_events], - mode_before=state.latest_trace.mode_before if state.latest_trace else state.mode.value, - mode_after=state.mode.value, - status=status, - started_at=started_at, - completed_at=completed_at, - duration_ms=round(max((completed_at - started_at).total_seconds() * 1000.0, 0.0), 2), - decision_id=latest_decision.decision_id if latest_decision else None, - selected_plan_id=state.latest_plan_id, - execution_id=execution_id, - approval_status=latest_decision.approval_status.value if latest_decision else None, - llm_fallback_used=fallback_reason is not None, - llm_fallback_reason=fallback_reason, - selected_plan_summary=plan_summary(state.pending_plan or state.latest_plan), - execution_summary=execution_summary(state, decision=latest_decision), - ) - - -def clone_trace_for_run(trace: OrchestrationTrace | None, run_id: str) -> OrchestrationTrace | None: - if trace is None: - return None - copied = trace.model_copy(deep=True) - copied.run_id = run_id - return copied - - -def advance_execution_record( - execution: ExecutionRecord, - *, - run_id: str, - decision_id: str | None, - plan: Plan | None, - status: ExecutionStatus, - reason: str, -) -> ExecutionRecord: - updated = execution.model_copy(deep=True) - updated.run_id = run_id - updated.decision_id = decision_id - updated.plan_id = plan.plan_id if plan else updated.plan_id - updated.action_ids = [action.action_id for action in plan.actions] if plan else updated.action_ids - updated.status = status - updated.updated_at = utc_now() - updated.status_history.append(_transition(status, reason, updated.updated_at)) - if status == ExecutionStatus.APPLIED: - updated.receipts = _receipts_for_status(plan, status) - if status == ExecutionStatus.CANCELLED: - updated.failure_reason = reason - return updated + + +def build_execution_record( + *, + run_id: str, + state: SystemState, + decision: DecisionLog | None = None, + execution_id: str | None = None, + created_at: datetime | None = None, +) -> ExecutionRecord | None: + plan = state.pending_plan or state.latest_plan + if plan is None and decision is None: + return None + now = created_at or utc_now() + summary = execution_summary(state, decision=decision) + status = summary.status + return ExecutionRecord( + execution_id=execution_id or new_execution_id(), + run_id=run_id, + decision_id=decision.decision_id if decision else None, + plan_id=plan.plan_id if plan else None, + status=status, + dispatch_mode=summary.dispatch_mode, + dry_run=False, + target_system="digital_twin", + action_ids=summary.action_ids, + receipts=_receipts_for_status(plan, status), + status_history=initial_execution_history(plan, status), + failure_reason=None, + created_at=now, + updated_at=now, + ) + + +def execution_summary(state: SystemState, decision: DecisionLog | None = None) -> ExecutionSummary | None: + plan = state.pending_plan or state.latest_plan + if plan is None and decision is None: + return None + actions = [action.action_id for action in plan.actions] if plan else [] + settings = load_settings() + return ExecutionSummary( + status=execution_status_from_state(state, decision=decision), + dispatch_mode=DispatchMode(settings.dispatch_mode), + action_ids=actions, + ) + + +def build_run_record( + *, + run_id: str, + run_type: RunType, + state: SystemState, + started_at: datetime, + parent_run_id: str | None = None, + envelope: EventEnvelope | None = None, + execution_id: str | None = None, + status: RunStatus = RunStatus.COMPLETED, +) -> RunRecord: + completed_at = utc_now() + latest_decision = state.decision_logs[-1] if state.decision_logs else None + fallback_reason = derive_fallback_reason(state) + return RunRecord( + run_id=run_id, + run_type=run_type, + parent_run_id=parent_run_id, + correlation_id=envelope.correlation_id if envelope else new_correlation_id("run"), + trigger_event_id=envelope.event_id if envelope else None, + input_event_ids=[event.event_id for event in state.active_events], + mode_before=state.latest_trace.mode_before if state.latest_trace else state.mode.value, + mode_after=state.mode.value, + status=status, + started_at=started_at, + completed_at=completed_at, + duration_ms=round(max((completed_at - started_at).total_seconds() * 1000.0, 0.0), 2), + decision_id=latest_decision.decision_id if latest_decision else None, + selected_plan_id=state.latest_plan_id, + execution_id=execution_id, + approval_status=latest_decision.approval_status.value if latest_decision else None, + llm_fallback_used=fallback_reason is not None, + llm_fallback_reason=fallback_reason, + selected_plan_summary=plan_summary(state.pending_plan or state.latest_plan), + execution_summary=execution_summary(state, decision=latest_decision), + ) + + +def clone_trace_for_run(trace: OrchestrationTrace | None, run_id: str) -> OrchestrationTrace | None: + if trace is None: + return None + copied = trace.model_copy(deep=True) + copied.run_id = run_id + return copied + + +def advance_execution_record( + execution: ExecutionRecord, + *, + run_id: str, + decision_id: str | None, + plan: Plan | None, + status: ExecutionStatus, + reason: str, +) -> ExecutionRecord: + updated = execution.model_copy(deep=True) + updated.run_id = run_id + updated.decision_id = decision_id + updated.plan_id = plan.plan_id if plan else updated.plan_id + updated.action_ids = [action.action_id for action in plan.actions] if plan else updated.action_ids + updated.status = status + updated.updated_at = utc_now() + updated.status_history.append(_transition(status, reason, updated.updated_at)) + if status == ExecutionStatus.APPLIED: + updated.receipts = _receipts_for_status(plan, status) + if status == ExecutionStatus.CANCELLED: + updated.failure_reason = reason + return updated diff --git a/core/state.py b/core/state.py index edb093b..ad726e3 100644 --- a/core/state.py +++ b/core/state.py @@ -1,286 +1,331 @@ -from __future__ import annotations - +from __future__ import annotations + +from collections import defaultdict from datetime import datetime, timezone -from pathlib import Path - -import pandas as pd - -from core.enums import Mode -from core.models import ( - DemandRecord, - InventoryItem, - KPIState, - MemorySnapshot, - OrderRecord, - RouteRecord, - SupplierRecord, - SystemState, - WarehouseRecord, -) - - -DATA_DIR = Path(__file__).resolve().parent.parent.absolute() / "data" - - -def utc_now() -> datetime: - return datetime.now(timezone.utc) - - -def _read_csv(name: str, data_dir: Path | None = None) -> pd.DataFrame: - base = data_dir or DATA_DIR - return pd.read_csv(base / name) - - -def _validate_seed_data( - *, - inventory_df: pd.DataFrame, - suppliers_df: pd.DataFrame, - routes_df: pd.DataFrame, - warehouses_df: pd.DataFrame, - orders_df: pd.DataFrame, -) -> None: - inventory_skus = set(inventory_df["sku"].astype(str)) - supplier_skus = set(suppliers_df["sku"].astype(str)) - route_ids = set(routes_df["route_id"].astype(str)) - warehouse_ids = set(warehouses_df["warehouse_id"].astype(str)) - supplier_pairs = { - (str(row["supplier_id"]), str(row["sku"])) - for _, row in suppliers_df.iterrows() - } - - errors: list[str] = [] - - missing_inventory_routes = sorted( - { - str(row["preferred_route_id"]) - for _, row in inventory_df.iterrows() - if str(row["preferred_route_id"]) not in route_ids - } - ) - if missing_inventory_routes: - errors.append( - f"inventory references unknown routes: {', '.join(missing_inventory_routes)}" - ) - - missing_inventory_warehouses = sorted( - { - str(row["warehouse_id"]) - for _, row in inventory_df.iterrows() - if str(row["warehouse_id"]) not in warehouse_ids - } +import math +from pathlib import Path + +import pandas as pd + +from core.enums import Mode +from core.models import ( + DemandRecord, + InventoryItem, + KPIState, + MemorySnapshot, + OrderRecord, + RouteRecord, + SupplierRecord, + SystemState, + WarehouseRecord, +) + + +DATA_DIR = Path(__file__).resolve().parent.parent.absolute() / "data" + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _read_csv(name: str, data_dir: Path | None = None) -> pd.DataFrame: + base = data_dir or DATA_DIR + return pd.read_csv(base / name) + + +def _validate_seed_data( + *, + inventory_df: pd.DataFrame, + suppliers_df: pd.DataFrame, + routes_df: pd.DataFrame, + warehouses_df: pd.DataFrame, + orders_df: pd.DataFrame, +) -> None: + inventory_skus = set(inventory_df["sku"].astype(str)) + supplier_skus = set(suppliers_df["sku"].astype(str)) + route_ids = set(routes_df["route_id"].astype(str)) + warehouse_ids = set(warehouses_df["warehouse_id"].astype(str)) + supplier_pairs = { + (str(row["supplier_id"]), str(row["sku"])) + for _, row in suppliers_df.iterrows() + } + + errors: list[str] = [] + + missing_inventory_routes = sorted( + { + str(row["preferred_route_id"]) + for _, row in inventory_df.iterrows() + if str(row["preferred_route_id"]) not in route_ids + } + ) + if missing_inventory_routes: + errors.append( + f"inventory references unknown routes: {', '.join(missing_inventory_routes)}" + ) + + missing_inventory_warehouses = sorted( + { + str(row["warehouse_id"]) + for _, row in inventory_df.iterrows() + if str(row["warehouse_id"]) not in warehouse_ids + } + ) + if missing_inventory_warehouses: + errors.append( + "inventory references unknown warehouses: " + + ", ".join(missing_inventory_warehouses) + ) + + missing_preferred_suppliers = sorted( + { + f"{row['preferred_supplier_id']}:{row['sku']}" + for _, row in inventory_df.iterrows() + if (str(row["preferred_supplier_id"]), str(row["sku"])) not in supplier_pairs + } + ) + if missing_preferred_suppliers: + errors.append( + "inventory preferred suppliers missing from suppliers.csv: " + + ", ".join(missing_preferred_suppliers) + ) + + missing_supplier_inventory = sorted(supplier_skus - inventory_skus) + if missing_supplier_inventory: + errors.append( + "suppliers reference unknown SKUs: " + ", ".join(missing_supplier_inventory) + ) + + missing_order_inventory = sorted( + { + str(row["sku"]) + for _, row in orders_df.iterrows() + if str(row["sku"]) not in inventory_skus + } + ) + if missing_order_inventory: + errors.append( + "orders reference unknown SKUs: " + ", ".join(missing_order_inventory) + ) + + missing_order_warehouses = sorted( + { + str(row["warehouse_id"]) + for _, row in orders_df.iterrows() + if str(row["warehouse_id"]) not in warehouse_ids + } + ) + if missing_order_warehouses: + errors.append( + "orders reference unknown warehouses: " + ", ".join(missing_order_warehouses) + ) + + if errors: + raise ValueError("; ".join(errors)) + + +def _load_historical_cases(data_dir: Path | None = None) -> list: + """Load historical cases from CSV into HistoricalCase models.""" + from core.models import HistoricalCase + + try: + df = _read_csv("historical_cases.csv", data_dir) + cases = [] + for _, row in df.iterrows(): + actions = ( + str(row["actions_taken"]).split("|") + if pd.notna(row["actions_taken"]) + else [] + ) + cases.append( + HistoricalCase( + case_id=row["case_id"], + event_type=row["event_type"], + event_severity=float(row["event_severity"]), + actions_taken=[a.strip() for a in actions], + outcome_kpis={ + "service_level": float(row["outcome_service_level"]), + "total_cost": float(row["outcome_total_cost"]), + "disruption_risk": float(row["outcome_disruption_risk"]), + "recovery_speed": float(row["outcome_recovery_speed"]), + }, + reflection_notes=str(row["reflection_notes"]), + ) + ) + return cases + except FileNotFoundError: + return [] + + +def default_memory(data_dir: Path | None = None) -> MemorySnapshot: + return MemorySnapshot( + snapshot_id="mem_0", + timestamp=utc_now(), + supplier_reliability={}, + route_disruption_priors={}, + scenario_outcomes={}, + last_approved_plan_ids=[], + historical_cases=_load_historical_cases(data_dir), ) - if missing_inventory_warehouses: - errors.append( - "inventory references unknown warehouses: " - + ", ".join(missing_inventory_warehouses) - ) - missing_preferred_suppliers = sorted( - { - f"{row['preferred_supplier_id']}:{row['sku']}" - for _, row in inventory_df.iterrows() - if (str(row["preferred_supplier_id"]), str(row["sku"])) not in supplier_pairs - } - ) - if missing_preferred_suppliers: - errors.append( - "inventory preferred suppliers missing from suppliers.csv: " - + ", ".join(missing_preferred_suppliers) - ) - missing_supplier_inventory = sorted(supplier_skus - inventory_skus) - if missing_supplier_inventory: - errors.append( - "suppliers reference unknown SKUs: " + ", ".join(missing_supplier_inventory) - ) +def _approximate_z_score(service_level: float) -> float: + p = max(0.5001, min(0.9999, service_level)) + t = math.sqrt(-2.0 * math.log(1.0 - p)) + c0, c1, c2 = 2.515517, 0.802853, 0.010328 + d1, d2, d3 = 1.432788, 0.189269, 0.001308 + z = t - ((c2 * t + c1) * t + c0) / (((d3 * t + d2) * t + d1) * t + 1.0) + return max(0.0, z) - missing_order_inventory = sorted( - { - str(row["sku"]) - for _, row in orders_df.iterrows() - if str(row["sku"]) not in inventory_skus - } - ) - if missing_order_inventory: - errors.append( - "orders reference unknown SKUs: " + ", ".join(missing_order_inventory) - ) - missing_order_warehouses = sorted( - { - str(row["warehouse_id"]) - for _, row in orders_df.iterrows() - if str(row["warehouse_id"]) not in warehouse_ids - } - ) - if missing_order_warehouses: - errors.append( - "orders reference unknown warehouses: " + ", ".join(missing_order_warehouses) - ) +def refresh_operational_baseline(state: SystemState) -> SystemState: + grouped: dict[str, list[tuple[int, int]]] = defaultdict(list) + for demand in state.demands: + grouped[demand.sku].append((demand.day_index, demand.quantity)) - if errors: - raise ValueError("; ".join(errors)) - - -def _load_historical_cases(data_dir: Path | None = None) -> list: - """Load historical cases from CSV into HistoricalCase models.""" - from core.models import HistoricalCase - - try: - df = _read_csv("historical_cases.csv", data_dir) - cases = [] - for _, row in df.iterrows(): - actions = ( - str(row["actions_taken"]).split("|") - if pd.notna(row["actions_taken"]) - else [] + for sku, item in state.inventory.items(): + points = grouped.get(sku, []) + if points: + df = pd.DataFrame(points, columns=["day_index", "quantity"]).sort_values( + "day_index" ) - cases.append( - HistoricalCase( - case_id=row["case_id"], - event_type=row["event_type"], - event_severity=float(row["event_severity"]), - actions_taken=[a.strip() for a in actions], - outcome_kpis={ - "service_level": float(row["outcome_service_level"]), - "total_cost": float(row["outcome_total_cost"]), - "disruption_risk": float(row["outcome_disruption_risk"]), - "recovery_speed": float(row["outcome_recovery_speed"]), - }, - reflection_notes=str(row["reflection_notes"]), - ) - ) - return cases - except FileNotFoundError: - return [] - - -def default_memory(data_dir: Path | None = None) -> MemorySnapshot: - return MemorySnapshot( - snapshot_id="mem_0", - timestamp=utc_now(), - supplier_reliability={}, - route_disruption_priors={}, - scenario_outcomes={}, - last_approved_plan_ids=[], - historical_cases=_load_historical_cases(data_dir), - ) - - -def recompute_kpis(state: SystemState, recovery_speed: float | None = None) -> KPIState: - demand = sum(max(item.forecast_qty, 0) for item in state.inventory.values()) - shortage = sum( - max(item.forecast_qty - (item.on_hand + item.incoming_qty), 0) - for item in state.inventory.values() - ) - holding_cost = sum( - (item.on_hand + item.incoming_qty) * item.unit_cost - for item in state.inventory.values() - ) - route_cost = sum( - state.routes[item.preferred_route_id].cost - for item in state.inventory.values() - if item.preferred_route_id in state.routes - ) - total_cost = holding_cost + route_cost + state.extra_cost - service_level = ( - 1.0 if demand == 0 else max(0.0, min(1.0, 1.0 - (shortage / demand))) - ) - stockout_risk = 0.0 if demand == 0 else max(0.0, min(1.0, shortage / demand)) - active_risk = [event.severity for event in state.active_events] - route_risk = [ - route.risk_score for route in state.routes.values() if route.status != "blocked" - ] - disruption_risk = 0.0 - if active_risk or route_risk: - disruption_risk = sum(active_risk + route_risk[:2]) / max( - len(active_risk + route_risk[:2]), 1 + df_30 = df.tail(30) + avg_d = df_30["quantity"].mean() + std_d = df_30["quantity"].std() + item.forecast_qty = max(0, int(round(avg_d))) if pd.notna(avg_d) else 0 + item.std_demand = float(std_d) if pd.notna(std_d) else 0.0 + else: + item.std_demand = getattr(item, "std_demand", 0.0) + + supplier = state.suppliers.get(f"{item.preferred_supplier_id}_{sku}") + lead_time = supplier.lead_time_days if supplier else 0 + reliability = supplier.reliability if supplier else 0.95 + z_score = _approximate_z_score(reliability) + ltd = item.forecast_qty * lead_time + safety_stock = ( + z_score * item.std_demand * math.sqrt(lead_time) if lead_time > 0 else 0.0 ) - disruption_risk = min(disruption_risk, 1.0) - if recovery_speed is None: - recovery_speed = 0.85 if state.mode == Mode.NORMAL else 0.55 - return KPIState( - service_level=round(service_level, 4), - total_cost=round(total_cost, 2), - disruption_risk=round(disruption_risk, 4), - recovery_speed=round(max(0.0, min(1.0, recovery_speed)), 4), - stockout_risk=round(stockout_risk, 4), - decision_latency_ms=state.kpis.decision_latency_ms - if getattr(state, "kpis", None) - else 0.0, - ) - - -def load_initial_state(data_dir: Path | None = None) -> SystemState: - inventory_df = _read_csv("inventory.csv", data_dir) - suppliers_df = _read_csv("suppliers.csv", data_dir) - routes_df = _read_csv("routes.csv", data_dir) - warehouses_df = _read_csv("warehouses.csv", data_dir) - orders_df = _read_csv("orders.csv", data_dir) - _validate_seed_data( - inventory_df=inventory_df, - suppliers_df=suppliers_df, - routes_df=routes_df, - warehouses_df=warehouses_df, - orders_df=orders_df, - ) - try: - demands_df = _read_csv("demands.csv", data_dir) - demands = [DemandRecord(**row.to_dict()) for _, row in demands_df.iterrows()] - except Exception: - demands = [] + item.safety_stock = int(round(safety_stock)) + item.reorder_point = int(round(ltd + safety_stock)) - inventory = { - row["sku"]: InventoryItem(**row.to_dict()) for _, row in inventory_df.iterrows() - } - suppliers = { - f"{row['supplier_id']}_{row['sku']}": SupplierRecord(**row.to_dict()) - for _, row in suppliers_df.iterrows() - } - routes = { - row["route_id"]: RouteRecord(**row.to_dict()) for _, row in routes_df.iterrows() - } - warehouses = { - row["warehouse_id"]: WarehouseRecord(**row.to_dict()) - for _, row in warehouses_df.iterrows() - } - orders = [OrderRecord(**row.to_dict()) for _, row in orders_df.iterrows()] - - state = SystemState( - run_id="run_0", - timestamp=utc_now(), - inventory=inventory, - suppliers=suppliers, - routes=routes, - warehouses=warehouses, - orders=orders, - demands=demands, - kpis=KPIState( - service_level=1.0, - total_cost=0.0, - disruption_risk=0.0, - recovery_speed=0.85, - stockout_risk=0.0, - decision_latency_ms=0.0, - ), - memory=default_memory(), - ) state.kpis = recompute_kpis(state) return state - - -def clone_state(state: SystemState) -> SystemState: - return state.model_copy(deep=True) - - -def state_summary(state: SystemState) -> dict[str, object]: - return { - "mode": state.mode.value, - "active_events": [event.type.value for event in state.active_events], - "inventory_items": len(state.inventory), - "suppliers": len(state.suppliers), - "routes": len(state.routes), - "kpis": state.kpis.model_dump(), - "latest_plan_id": state.latest_plan_id, - "pending_plan_id": state.pending_plan.plan_id if state.pending_plan else None, - } + + +def recompute_kpis(state: SystemState, recovery_speed: float | None = None) -> KPIState: + demand = sum(max(item.forecast_qty, 0) for item in state.inventory.values()) + shortage = sum( + max(item.forecast_qty - (item.on_hand + item.incoming_qty), 0) + for item in state.inventory.values() + ) + holding_cost = sum( + (item.on_hand + item.incoming_qty) * item.unit_cost + for item in state.inventory.values() + ) + route_cost = sum( + state.routes[item.preferred_route_id].cost + for item in state.inventory.values() + if item.preferred_route_id in state.routes + ) + total_cost = holding_cost + route_cost + state.extra_cost + service_level = ( + 1.0 if demand == 0 else max(0.0, min(1.0, 1.0 - (shortage / demand))) + ) + stockout_risk = 0.0 if demand == 0 else max(0.0, min(1.0, shortage / demand)) + active_risk = [event.severity for event in state.active_events] + route_risk = [ + route.risk_score for route in state.routes.values() if route.status != "blocked" + ] + disruption_risk = 0.0 + if active_risk or route_risk: + disruption_risk = sum(active_risk + route_risk[:2]) / max( + len(active_risk + route_risk[:2]), 1 + ) + disruption_risk = min(disruption_risk, 1.0) + if recovery_speed is None: + recovery_speed = 0.85 if state.mode == Mode.NORMAL else 0.55 + return KPIState( + service_level=round(service_level, 4), + total_cost=round(total_cost, 2), + disruption_risk=round(disruption_risk, 4), + recovery_speed=round(max(0.0, min(1.0, recovery_speed)), 4), + stockout_risk=round(stockout_risk, 4), + decision_latency_ms=state.kpis.decision_latency_ms + if getattr(state, "kpis", None) + else 0.0, + ) + + +def load_initial_state(data_dir: Path | None = None) -> SystemState: + inventory_df = _read_csv("inventory.csv", data_dir) + suppliers_df = _read_csv("suppliers.csv", data_dir) + routes_df = _read_csv("routes.csv", data_dir) + warehouses_df = _read_csv("warehouses.csv", data_dir) + orders_df = _read_csv("orders.csv", data_dir) + _validate_seed_data( + inventory_df=inventory_df, + suppliers_df=suppliers_df, + routes_df=routes_df, + warehouses_df=warehouses_df, + orders_df=orders_df, + ) + try: + demands_df = _read_csv("demands.csv", data_dir) + demands = [DemandRecord(**row.to_dict()) for _, row in demands_df.iterrows()] + except Exception: + demands = [] + + inventory = { + row["sku"]: InventoryItem(**row.to_dict()) for _, row in inventory_df.iterrows() + } + suppliers = { + f"{row['supplier_id']}_{row['sku']}": SupplierRecord(**row.to_dict()) + for _, row in suppliers_df.iterrows() + } + routes = { + row["route_id"]: RouteRecord(**row.to_dict()) for _, row in routes_df.iterrows() + } + warehouses = { + row["warehouse_id"]: WarehouseRecord(**row.to_dict()) + for _, row in warehouses_df.iterrows() + } + orders = [OrderRecord(**row.to_dict()) for _, row in orders_df.iterrows()] + + state = SystemState( + run_id="run_0", + timestamp=utc_now(), + inventory=inventory, + suppliers=suppliers, + routes=routes, + warehouses=warehouses, + orders=orders, + demands=demands, + kpis=KPIState( + service_level=1.0, + total_cost=0.0, + disruption_risk=0.0, + recovery_speed=0.85, + stockout_risk=0.0, + decision_latency_ms=0.0, + ), + memory=default_memory(), + ) + state.kpis = recompute_kpis(state) + return state + + +def clone_state(state: SystemState) -> SystemState: + return state.model_copy(deep=True) + + +def state_summary(state: SystemState) -> dict[str, object]: + return { + "mode": state.mode.value, + "active_events": [event.type.value for event in state.active_events], + "inventory_items": len(state.inventory), + "suppliers": len(state.suppliers), + "routes": len(state.routes), + "kpis": state.kpis.model_dump(), + "latest_plan_id": state.latest_plan_id, + "pending_plan_id": state.pending_plan.plan_id if state.pending_plan else None, + } diff --git a/data/historical_cases.csv b/data/historical_cases.csv index e2d932b..e3bc265 100644 --- a/data/historical_cases.csv +++ b/data/historical_cases.csv @@ -1,10 +1,10 @@ -case_id,event_type,event_severity,actions_taken,outcome_service_level,outcome_total_cost,outcome_disruption_risk,outcome_recovery_speed,reflection_notes -CASE_2024_001,supplier_delay,0.8,"REORDER SKU_1 from backup SUP_B|REROUTE via R4",0.95,4800.0,0.22,0.78,Switching to backup supplier with priority reorder prevented stockout within 48h. High cost but acceptable service level maintained. -CASE_2024_002,route_blockage,0.7,"REROUTE all SKUs from R3 to R1|REORDER SKU_2 safety buffer",0.98,5200.0,0.18,0.85,Rerouting early before stockout reduced risk significantly. Building safety buffer for high-demand SKUs proved effective. -CASE_2024_003,demand_spike,0.65,"REORDER SKU_2 x50 emergency|SWITCH_SUPPLIER to high-capacity SUP_D",0.88,6100.0,0.30,0.60,Demand spike required immediate large reorder. Switching to high-capacity supplier avoided stockout but at higher unit cost. -CASE_2024_004,supplier_delay,0.5,"REORDER from primary supplier with extended lead time buffer",0.92,3900.0,0.25,0.70,Moderate delay handled by increasing order quantity from primary. No supplier switch needed — cost-effective resolution. -CASE_2024_005,compound,0.9,"SWITCH_SUPPLIER all SKUs|REROUTE via secondary routes|REORDER emergency buffer x2",0.82,8500.0,0.45,0.50,Compound disruption required full mobilization. Despite high cost service level held above 80%. Prior early warning improved response time. -CASE_2024_006,route_blockage,0.4,"REROUTE SKU_1 to backup route|monitor only",0.99,3200.0,0.12,0.90,Low-severity blockage resolved by simple reroute. No inventory action needed. Fast recovery at minimal cost. -CASE_2024_007,demand_spike,0.85,"REORDER all SKUs x2|SWITCH_SUPPLIER to SUP_B|add buffer stock",0.90,7200.0,0.35,0.65,Extreme demand spike required multi-SKU emergency reorder. Margin compressed but service level held. -CASE_2024_008,supplier_delay,0.95,"EMERGENCY SWITCH_SUPPLIER|expedite REORDER|REROUTE to fastest route",0.80,9100.0,0.40,0.55,Critical supplier failure at peak season. Required full emergency protocol. Cost was very high but stockout was avoided. -CASE_2025_MEGA_DISASTER,compound,0.98,"FULL REDIRECT|SWITCH_SUPPLIER to backup network|REBALANCE global inventory",0.85,15000.0,0.50,0.40,Matched a severe regional blackout + port strike. Using backup suppliers immediately and accepting 3x cost was the only way to maintain 85% service level. +case_id,event_type,event_severity,actions_taken,outcome_service_level,outcome_total_cost,outcome_disruption_risk,outcome_recovery_speed,reflection_notes +CASE_2024_001,supplier_delay,0.8,"REORDER SKU_1 from backup SUP_B|REROUTE via R4",0.95,4800.0,0.22,0.78,Switching to backup supplier with priority reorder prevented stockout within 48h. High cost but acceptable service level maintained. +CASE_2024_002,route_blockage,0.7,"REROUTE all SKUs from R3 to R1|REORDER SKU_2 safety buffer",0.98,5200.0,0.18,0.85,Rerouting early before stockout reduced risk significantly. Building safety buffer for high-demand SKUs proved effective. +CASE_2024_003,demand_spike,0.65,"REORDER SKU_2 x50 emergency|SWITCH_SUPPLIER to high-capacity SUP_D",0.88,6100.0,0.30,0.60,Demand spike required immediate large reorder. Switching to high-capacity supplier avoided stockout but at higher unit cost. +CASE_2024_004,supplier_delay,0.5,"REORDER from primary supplier with extended lead time buffer",0.92,3900.0,0.25,0.70,Moderate delay handled by increasing order quantity from primary. No supplier switch needed — cost-effective resolution. +CASE_2024_005,compound,0.9,"SWITCH_SUPPLIER all SKUs|REROUTE via secondary routes|REORDER emergency buffer x2",0.82,8500.0,0.45,0.50,Compound disruption required full mobilization. Despite high cost service level held above 80%. Prior early warning improved response time. +CASE_2024_006,route_blockage,0.4,"REROUTE SKU_1 to backup route|monitor only",0.99,3200.0,0.12,0.90,Low-severity blockage resolved by simple reroute. No inventory action needed. Fast recovery at minimal cost. +CASE_2024_007,demand_spike,0.85,"REORDER all SKUs x2|SWITCH_SUPPLIER to SUP_B|add buffer stock",0.90,7200.0,0.35,0.65,Extreme demand spike required multi-SKU emergency reorder. Margin compressed but service level held. +CASE_2024_008,supplier_delay,0.95,"EMERGENCY SWITCH_SUPPLIER|expedite REORDER|REROUTE to fastest route",0.80,9100.0,0.40,0.55,Critical supplier failure at peak season. Required full emergency protocol. Cost was very high but stockout was avoided. +CASE_2025_MEGA_DISASTER,compound,0.98,"FULL REDIRECT|SWITCH_SUPPLIER to backup network|REBALANCE global inventory",0.85,15000.0,0.50,0.40,Matched a severe regional blackout + port strike. Using backup suppliers immediately and accepting 3x cost was the only way to maintain 85% service level. diff --git a/data/inventory.csv b/data/inventory.csv index a18fb57..b402c3b 100644 --- a/data/inventory.csv +++ b/data/inventory.csv @@ -1,51 +1,51 @@ sku,name,on_hand,incoming_qty,reorder_point,safety_stock,unit_cost,preferred_supplier_id,preferred_route_id,warehouse_id,forecast_qty -SKU_001,High-Performance Processor V3,118,28,43,13,404.03,SUP_A,R1,WH_WEST,61 -SKU_002,Advanced Cooling System V6,77,20,20,19,281.62,SUP_D,R4,WH_WEST,26 -SKU_003,Precision Motherboard V6,55,37,35,17,297.18,SUP_A,R1,WH_NORTH,53 -SKU_004,High-Speed RAM V9,104,7,32,14,86.95,SUP_B,R1,WH_EAST,83 -SKU_005,Solid State Drive V2,123,32,26,10,416.89,SUP_D,R1,WH_EAST,35 -SKU_006,Hard Disk Drive V1,18,31,34,15,62.29,SUP_A,R2,WH_WEST,32 -SKU_007,Power Supply Unit V9,55,5,42,14,329.05,SUP_B,R3,WH_SOUTH,75 -SKU_008,Graphics Processing Unit V4,93,8,20,10,431.64,SUP_C,R1,WH_EAST,56 -SKU_009,Full Tower Case V4,116,11,49,12,77.87,SUP_A,R3,WH_WEST,74 -SKU_010,Liquid Cooling Kit V9,128,22,48,20,150.92,SUP_D,R3,WH_EAST,86 -SKU_011,Mechanical Keyboard V3,111,19,21,11,19.39,SUP_E,R3,WH_EAST,28 -SKU_012,Optical Gaming Mouse V5,69,9,43,10,334.63,SUP_D,R4,WH_WEST,77 -SKU_013,4K Monitor V5,75,16,47,20,245.19,SUP_D,R4,WH_SOUTH,73 -SKU_014,Wireless Router V3,31,38,36,13,212.37,SUP_E,R4,WH_SOUTH,69 -SKU_015,Network Switch V2,109,26,37,14,190.86,SUP_D,R1,WH_EAST,55 -SKU_016,External Hard Drive V1,13,24,36,18,411.1,SUP_B,R4,WH_EAST,74 -SKU_017,USB-C Hub V2,141,35,41,19,192.35,SUP_C,R3,WH_EAST,32 -SKU_018,HDMI 2.1 Cable V8,36,14,34,17,254.55,SUP_E,R3,WH_NORTH,69 -SKU_019,Thermal Interface Material V5,100,12,21,16,398.84,SUP_E,R4,WH_EAST,73 +SKU_001,High-Performance Processor V3,133,28,43,13,404.03,SUP_A,R1,WH_WEST,61 +SKU_002,Advanced Cooling System V6,72,20,20,19,281.62,SUP_D,R4,WH_WEST,26 +SKU_003,Precision Motherboard V6,345,37,35,17,297.18,SUP_A,R1,WH_NORTH,53 +SKU_004,High-Speed RAM V9,175,7,32,14,86.95,SUP_B,R1,WH_EAST,83 +SKU_005,Solid State Drive V2,148,32,26,10,416.89,SUP_D,R1,WH_EAST,35 +SKU_006,Hard Disk Drive V1,371,31,34,15,62.29,SUP_A,R2,WH_WEST,32 +SKU_007,Power Supply Unit V9,160,5,42,14,329.05,SUP_B,R3,WH_SOUTH,75 +SKU_008,Graphics Processing Unit V4,304,8,20,10,431.64,SUP_C,R1,WH_EAST,56 +SKU_009,Full Tower Case V4,161,11,49,12,77.87,SUP_A,R3,WH_WEST,74 +SKU_010,Liquid Cooling Kit V9,269,22,48,20,150.92,SUP_D,R3,WH_EAST,86 +SKU_011,Mechanical Keyboard V3,232,19,21,11,19.39,SUP_E,R3,WH_EAST,28 +SKU_012,Optical Gaming Mouse V5,88,9,43,10,334.63,SUP_D,R4,WH_WEST,77 +SKU_013,4K Monitor V5,140,16,47,20,245.19,SUP_D,R4,WH_SOUTH,73 +SKU_014,Wireless Router V3,310,38,36,13,212.37,SUP_E,R4,WH_SOUTH,69 +SKU_015,Network Switch V2,203,26,37,14,190.86,SUP_D,R1,WH_EAST,55 +SKU_016,External Hard Drive V1,47,24,36,18,411.1,SUP_B,R4,WH_EAST,74 +SKU_017,USB-C Hub V2,225,35,41,19,192.35,SUP_C,R3,WH_EAST,32 +SKU_018,HDMI 2.1 Cable V8,382,14,34,17,254.55,SUP_E,R3,WH_NORTH,69 +SKU_019,Thermal Interface Material V5,360,12,21,16,398.84,SUP_E,R4,WH_EAST,73 SKU_020,Gaming Headset V5,110,27,46,15,405.41,SUP_A,R3,WH_EAST,74 SKU_021,High-Performance Processor V1,109,30,44,13,393.9,SUP_C,R4,WH_WEST,47 -SKU_022,Advanced Cooling System V6,95,37,30,13,266.92,SUP_D,R3,WH_SOUTH,58 -SKU_023,Precision Motherboard V2,26,8,20,14,95.06,SUP_A,R1,WH_EAST,26 -SKU_024,High-Speed RAM V9,84,24,46,12,300.94,SUP_E,R2,WH_EAST,93 -SKU_025,Solid State Drive V5,132,15,30,18,351.85,SUP_A,R1,WH_SOUTH,98 +SKU_022,Advanced Cooling System V6,137,37,30,13,266.92,SUP_D,R3,WH_SOUTH,58 +SKU_023,Precision Motherboard V2,294,8,20,14,95.06,SUP_A,R1,WH_EAST,26 +SKU_024,High-Speed RAM V9,464,24,46,12,300.94,SUP_E,R2,WH_EAST,93 +SKU_025,Solid State Drive V5,293,15,30,18,351.85,SUP_A,R1,WH_SOUTH,98 SKU_026,Hard Disk Drive V2,128,34,39,14,397.99,SUP_E,R2,WH_SOUTH,56 -SKU_027,Power Supply Unit V3,133,16,40,15,362.96,SUP_D,R1,WH_WEST,92 -SKU_028,Graphics Processing Unit V3,38,33,38,16,267.76,SUP_B,R2,WH_EAST,68 -SKU_029,Full Tower Case V8,135,27,43,17,203.51,SUP_B,R3,WH_SOUTH,99 -SKU_030,Liquid Cooling Kit V6,78,24,26,13,367.75,SUP_C,R2,WH_WEST,66 -SKU_031,Mechanical Keyboard V7,70,3,46,12,374.0,SUP_D,R2,WH_EAST,78 -SKU_032,Optical Gaming Mouse V6,27,26,35,15,370.44,SUP_D,R1,WH_NORTH,24 -SKU_033,4K Monitor V7,41,7,42,12,128.07,SUP_D,R3,WH_SOUTH,97 +SKU_027,Power Supply Unit V3,148,16,40,15,362.96,SUP_D,R1,WH_WEST,92 +SKU_028,Graphics Processing Unit V3,142,33,38,16,267.76,SUP_B,R2,WH_EAST,68 +SKU_029,Full Tower Case V8,155,27,43,17,203.51,SUP_B,R3,WH_SOUTH,99 +SKU_030,Liquid Cooling Kit V6,156,24,26,13,367.75,SUP_C,R2,WH_WEST,66 +SKU_031,Mechanical Keyboard V7,183,3,46,12,374.0,SUP_D,R2,WH_EAST,78 +SKU_032,Optical Gaming Mouse V6,241,26,35,15,370.44,SUP_D,R1,WH_NORTH,24 +SKU_033,4K Monitor V7,203,7,42,12,128.07,SUP_D,R3,WH_SOUTH,97 SKU_034,Wireless Router V9,128,23,26,16,430.23,SUP_D,R4,WH_SOUTH,15 -SKU_035,Network Switch V2,105,7,40,10,290.51,SUP_D,R1,WH_SOUTH,33 -SKU_036,External Hard Drive V2,47,40,42,12,383.08,SUP_D,R2,WH_SOUTH,18 -SKU_037,USB-C Hub V2,92,29,27,11,147.23,SUP_A,R4,WH_WEST,45 -SKU_038,HDMI 2.1 Cable V5,147,0,50,11,203.83,SUP_A,R3,WH_SOUTH,61 -SKU_039,Thermal Interface Material V3,134,29,50,14,376.87,SUP_E,R4,WH_EAST,97 -SKU_040,Gaming Headset V9,47,2,25,12,143.11,SUP_C,R4,WH_SOUTH,29 -SKU_041,High-Performance Processor V9,110,14,47,10,153.91,SUP_A,R1,WH_NORTH,52 -SKU_042,Advanced Cooling System V2,128,33,29,19,161.89,SUP_D,R1,WH_SOUTH,36 -SKU_043,Precision Motherboard V4,104,22,36,17,50.71,SUP_D,R1,WH_WEST,42 -SKU_044,High-Speed RAM V5,29,8,31,13,202.95,SUP_B,R3,WH_NORTH,58 -SKU_045,Solid State Drive V7,145,10,28,16,419.47,SUP_E,R3,WH_EAST,41 -SKU_046,Hard Disk Drive V9,122,6,35,16,425.73,SUP_D,R1,WH_EAST,17 -SKU_047,Power Supply Unit V9,66,4,39,15,147.07,SUP_E,R4,WH_NORTH,60 -SKU_048,Graphics Processing Unit V3,85,2,28,19,279.53,SUP_D,R3,WH_SOUTH,96 -SKU_049,Full Tower Case V3,14,12,39,20,100.15,SUP_A,R4,WH_NORTH,35 -SKU_050,Liquid Cooling Kit V1,12,18,29,15,417.7,SUP_C,R1,WH_SOUTH,70 +SKU_035,Network Switch V2,110,7,40,10,290.51,SUP_D,R1,WH_SOUTH,33 +SKU_036,External Hard Drive V2,80,40,42,12,383.08,SUP_D,R2,WH_SOUTH,18 +SKU_037,USB-C Hub V2,75,29,27,11,147.23,SUP_A,R4,WH_WEST,45 +SKU_038,HDMI 2.1 Cable V5,215,0,50,11,203.83,SUP_A,R3,WH_SOUTH,61 +SKU_039,Thermal Interface Material V3,169,29,50,14,376.87,SUP_E,R4,WH_EAST,97 +SKU_040,Gaming Headset V9,292,2,25,12,143.11,SUP_C,R4,WH_SOUTH,29 +SKU_041,High-Performance Processor V9,211,14,47,10,153.91,SUP_A,R1,WH_NORTH,52 +SKU_042,Advanced Cooling System V2,121,33,29,19,161.89,SUP_D,R1,WH_SOUTH,36 +SKU_043,Precision Motherboard V4,116,22,36,17,50.71,SUP_D,R1,WH_WEST,42 +SKU_044,High-Speed RAM V5,141,8,31,13,202.95,SUP_B,R3,WH_NORTH,58 +SKU_045,Solid State Drive V7,159,10,28,16,419.47,SUP_E,R3,WH_EAST,41 +SKU_046,Hard Disk Drive V9,219,6,35,16,425.73,SUP_D,R1,WH_EAST,17 +SKU_047,Power Supply Unit V9,108,4,39,15,147.07,SUP_E,R4,WH_NORTH,60 +SKU_048,Graphics Processing Unit V3,247,2,28,19,279.53,SUP_D,R3,WH_SOUTH,96 +SKU_049,Full Tower Case V3,219,12,39,20,100.15,SUP_A,R4,WH_NORTH,35 +SKU_050,Liquid Cooling Kit V1,104,18,29,15,417.7,SUP_C,R1,WH_SOUTH,70 diff --git a/data/orders.csv b/data/orders.csv index fe2d797..b998f1b 100644 --- a/data/orders.csv +++ b/data/orders.csv @@ -1,13 +1,13 @@ -order_id,sku,day_index,quantity,warehouse_id -O1,SKU_001,1,52,WH_WEST -O2,SKU_001,2,59,WH_WEST -O3,SKU_001,3,61,WH_WEST -O4,SKU_003,1,44,WH_NORTH -O5,SKU_003,2,49,WH_NORTH -O6,SKU_003,3,53,WH_NORTH -O7,SKU_024,1,38,WH_EAST -O8,SKU_024,2,42,WH_EAST -O9,SKU_024,3,47,WH_EAST -O10,SKU_032,1,18,WH_NORTH -O11,SKU_032,2,22,WH_NORTH -O12,SKU_032,3,24,WH_NORTH +order_id,sku,day_index,quantity,warehouse_id +O1,SKU_001,1,52,WH_WEST +O2,SKU_001,2,59,WH_WEST +O3,SKU_001,3,61,WH_WEST +O4,SKU_003,1,44,WH_NORTH +O5,SKU_003,2,49,WH_NORTH +O6,SKU_003,3,53,WH_NORTH +O7,SKU_024,1,38,WH_EAST +O8,SKU_024,2,42,WH_EAST +O9,SKU_024,3,47,WH_EAST +O10,SKU_032,1,18,WH_NORTH +O11,SKU_032,2,22,WH_NORTH +O12,SKU_032,3,24,WH_NORTH diff --git a/data/routes.csv b/data/routes.csv index c0dc800..37482b7 100644 --- a/data/routes.csv +++ b/data/routes.csv @@ -1,7 +1,7 @@ -route_id,origin,destination,transit_days,cost,risk_score,status -R1,Hanoi,Haiphong,2,120.0,0.30,active -R2,HCMC,CanTho,1,95.0,0.18,active -R3,Hanoi,DaNang,3,150.0,0.28,blocked -R4,Hanoi,Haiphong,1,155.0,0.12,blocked -R5,HCMC,CanTho,2,80.0,0.25,blocked -R6_RISK,HCMC,Haiphong,5,300.0,0.85,active +route_id,origin,destination,transit_days,cost,risk_score,status +R1,Hanoi,Haiphong,2,120.0,0.30,active +R2,HCMC,CanTho,1,95.0,0.18,active +R3,Hanoi,DaNang,3,150.0,0.28,blocked +R4,Hanoi,Haiphong,1,155.0,0.12,blocked +R5,HCMC,CanTho,2,80.0,0.25,blocked +R6_RISK,HCMC,Haiphong,5,300.0,0.85,active diff --git a/data/warehouses.csv b/data/warehouses.csv index b909337..3edfbd2 100644 --- a/data/warehouses.csv +++ b/data/warehouses.csv @@ -1,5 +1,5 @@ warehouse_id,name,capacity,region -WH_NORTH,North Hub,1600,north -WH_SOUTH,South Hub,3200,south -WH_EAST,East Distribution Hub,3600,east -WH_WEST,West Distribution Hub,2600,west +WH_NORTH,North Hub,2400,north +WH_SOUTH,South Hub,4200,south +WH_EAST,East Distribution Hub,5200,east +WH_WEST,West Distribution Hub,3000,west diff --git a/docs/demo_script.md b/docs/demo_script.md index 4c77eff..19ddd84 100644 --- a/docs/demo_script.md +++ b/docs/demo_script.md @@ -1,9 +1,9 @@ -# Demo Script - -1. Open `Overview` and show the control-tower mode banner, baseline KPIs, and guided demo flow. -2. Click `Run daily plan` and show the normal-mode optimized plan, selected action highlight, and baseline-versus-plan KPI comparison. -3. Open `Scenarios` and trigger `supplier_delay` to simulate disruption detection. -4. Return to `Overview` and show the mode switch into crisis/approval handling, the replanned action set, and the pending approval banner. -5. Click `Request safer plan` to show an autonomous safer replan with a new decision id and updated selected action. -6. Approve the safer plan and confirm the blocked state clears and execution completes. -7. Open `Decision Log` and point out the score breakdown, approval reasoning, rejected alternatives, and learned memory updates. +# Demo Script + +1. Open `Overview` and show the control-tower mode banner, baseline KPIs, and guided demo flow. +2. Click `Run daily plan` and show the normal-mode optimized plan, selected action highlight, and baseline-versus-plan KPI comparison. +3. Open `Scenarios` and trigger `supplier_delay` to simulate disruption detection. +4. Return to `Overview` and show the mode switch into crisis/approval handling, the replanned action set, and the pending approval banner. +5. Click `Request safer plan` to show an autonomous safer replan with a new decision id and updated selected action. +6. Approve the safer plan and confirm the blocked state clears and execution completes. +7. Open `Decision Log` and point out the score breakdown, approval reasoning, rejected alternatives, and learned memory updates. diff --git a/docs/internal/branch-plan.md b/docs/internal/branch-plan.md index b4542cf..4f56968 100644 --- a/docs/internal/branch-plan.md +++ b/docs/internal/branch-plan.md @@ -1,33 +1,33 @@ -# Branch Plan - -## Recommended Sequence - -1. `feat/foundation-run-ledger` -2. `feat/execution-dispatch-lifecycle` -3. `feat/policy-constraint-hooks` -4. `feat/memory-retrieval-hooks` - -## Parallel Workstreams - -### Backend foundation - -- runtime records -- persistence -- ingest/run/trace/execution APIs - -### Frontend integration - -- consume run and execution contracts -- historical trace views -- event ingest workflow - -### Decision engine preparation - -- add placeholders for feasibility and policy metadata once run contracts are stable - -## Merge Order - -1. foundation contracts and persistence -2. execution lifecycle enrichment -3. policy outputs -4. memory retrieval +# Branch Plan + +## Recommended Sequence + +1. `feat/foundation-run-ledger` +2. `feat/execution-dispatch-lifecycle` +3. `feat/policy-constraint-hooks` +4. `feat/memory-retrieval-hooks` + +## Parallel Workstreams + +### Backend foundation + +- runtime records +- persistence +- ingest/run/trace/execution APIs + +### Frontend integration + +- consume run and execution contracts +- historical trace views +- event ingest workflow + +### Decision engine preparation + +- add placeholders for feasibility and policy metadata once run contracts are stable + +## Merge Order + +1. foundation contracts and persistence +2. execution lifecycle enrichment +3. policy outputs +4. memory retrieval diff --git a/docs/internal/contracts.md b/docs/internal/contracts.md index f41d620..256d1dc 100644 --- a/docs/internal/contracts.md +++ b/docs/internal/contracts.md @@ -1,204 +1,204 @@ -# Contracts - -## RunRecord - -```json -{ - "run_id": "run_1234abcd", - "run_type": "event_response", - "parent_run_id": "run_prev123", - "correlation_id": "corr_supplier_delay_1", - "trigger_event_id": "evt_env_001", - "input_event_ids": ["evt_supplier_delay_001"], - "mode_before": "normal", - "mode_after": "crisis", - "status": "completed", - "started_at": "2026-04-11T10:00:00Z", - "completed_at": "2026-04-11T10:00:02Z", - "duration_ms": 2000.0, - "decision_id": "dec_1234abcd", - "selected_plan_id": "plan_1234abcd", - "execution_id": "exec_1234abcd", - "approval_status": "pending", - "llm_fallback_used": true, - "llm_fallback_reason": "llm_disabled", - "selected_plan_summary": { - "plan_id": "plan_1234abcd", - "strategy_label": "balanced", - "generated_by": "hybrid_fallback", - "approval_required": true, - "approval_reason": "event severity exceeds approval threshold", - "score": 0.271, - "action_ids": ["act_supplier_A", "act_reroute_B"] - }, - "execution_summary": { - "status": "approval_pending", - "dispatch_mode": "simulation", - "action_ids": ["act_supplier_A", "act_reroute_B"] - } -} -``` - -## EventEnvelope - -```json -{ - "event_id": "evt_env_1234abcd", - "event_class": "domain", - "event_type": "supplier_delay", - "source": "api", - "occurred_at": "2026-04-11T10:00:00Z", - "ingested_at": "2026-04-11T10:00:01Z", - "correlation_id": "corr_supplier_delay_1", - "causation_id": null, - "idempotency_key": "supplier_delay:sku_1:sup_a", - "severity": 0.9, - "entity_ids": ["SKU_1", "SUP_A"], - "payload": { - "supplier_id": "SUP_A", - "sku": "SKU_1", - "delay_hours": 48 - } -} -``` - -## TraceStep - -```json -{ - "step_id": "step_1234abcd", - "sequence": 3, - "node_key": "supplier", - "node_type": "agent", - "status": "completed", - "started_at": "2026-04-11T10:00:01Z", - "completed_at": "2026-04-11T10:00:01.400Z", - "duration_ms": 400.0, - "mode_snapshot": "crisis", - "summary": "supplier options ranked for SKU_1", - "reasoning_source": "ai_assisted_reasoning", - "input_snapshot": {}, - "output_snapshot": {}, - "observations": [], - "risks": [], - "downstream_impacts": [], - "recommended_action_ids": ["act_supplier_SKU_1_SUP_B"], - "tradeoffs": [], - "llm_used": true, - "llm_error": null, - "fallback_used": false, - "fallback_reason": null -} -``` - -## ExecutionRecord - -```json -{ - "execution_id": "exec_1234abcd", - "run_id": "run_1234abcd", - "decision_id": "dec_1234abcd", - "plan_id": "plan_1234abcd", - "status": "approval_pending", - "dispatch_mode": "simulation", - "dry_run": false, - "target_system": "digital_twin", - "action_ids": ["act_supplier_A", "act_reroute_B"], - "receipts": [], - "failure_reason": null, - "created_at": "2026-04-11T10:00:02Z", - "updated_at": "2026-04-11T10:00:02Z" -} -``` - -## API Examples - -### `POST /api/v1/events/ingest` - -Request: - -```json -{ - "event_class": "domain", - "event_type": "supplier_delay", - "source": "api", - "severity": 0.9, - "entity_ids": ["SKU_1", "SUP_A"], - "payload": { - "supplier_id": "SUP_A", - "sku": "SKU_1", - "delay_hours": 48 - } -} -``` - -Response: - -```json -{ - "event": { - "event_id": "evt_env_1234abcd", - "event_class": "domain", - "event_type": "supplier_delay", - "source": "api", - "occurred_at": "2026-04-11T10:00:00Z", - "ingested_at": "2026-04-11T10:00:01Z", - "correlation_id": "corr_supplier_delay_1", - "causation_id": null, - "idempotency_key": "supplier_delay:sku_1:sup_a", - "severity": 0.9, - "entity_ids": ["SKU_1", "SUP_A"], - "payload": { - "supplier_id": "SUP_A", - "sku": "SKU_1", - "delay_hours": 48 - } - }, - "run_id": "run_1234abcd", - "accepted": true, - "execution_id": "exec_1234abcd" -} -``` - -### `GET /api/v1/runs/{run_id}` - -Response: - -```json -{ - "item": { - "run_id": "run_1234abcd", - "run_type": "event_response", - "status": "completed" - } -} -``` - -### `GET /api/v1/runs/{run_id}/trace` - -Response: - -```json -{ - "item": { - "run_id": "run_1234abcd", - "trace_id": "trace_1234abcd", - "status": "completed", - "steps": [] - } -} -``` - -### `GET /api/v1/execution/{execution_id}` - -Response: - -```json -{ - "item": { - "execution_id": "exec_1234abcd", - "status": "approval_pending", - "dispatch_mode": "simulation" - } -} -``` +# Contracts + +## RunRecord + +```json +{ + "run_id": "run_1234abcd", + "run_type": "event_response", + "parent_run_id": "run_prev123", + "correlation_id": "corr_supplier_delay_1", + "trigger_event_id": "evt_env_001", + "input_event_ids": ["evt_supplier_delay_001"], + "mode_before": "normal", + "mode_after": "crisis", + "status": "completed", + "started_at": "2026-04-11T10:00:00Z", + "completed_at": "2026-04-11T10:00:02Z", + "duration_ms": 2000.0, + "decision_id": "dec_1234abcd", + "selected_plan_id": "plan_1234abcd", + "execution_id": "exec_1234abcd", + "approval_status": "pending", + "llm_fallback_used": true, + "llm_fallback_reason": "llm_disabled", + "selected_plan_summary": { + "plan_id": "plan_1234abcd", + "strategy_label": "balanced", + "generated_by": "hybrid_fallback", + "approval_required": true, + "approval_reason": "event severity exceeds approval threshold", + "score": 0.271, + "action_ids": ["act_supplier_A", "act_reroute_B"] + }, + "execution_summary": { + "status": "approval_pending", + "dispatch_mode": "simulation", + "action_ids": ["act_supplier_A", "act_reroute_B"] + } +} +``` + +## EventEnvelope + +```json +{ + "event_id": "evt_env_1234abcd", + "event_class": "domain", + "event_type": "supplier_delay", + "source": "api", + "occurred_at": "2026-04-11T10:00:00Z", + "ingested_at": "2026-04-11T10:00:01Z", + "correlation_id": "corr_supplier_delay_1", + "causation_id": null, + "idempotency_key": "supplier_delay:sku_1:sup_a", + "severity": 0.9, + "entity_ids": ["SKU_1", "SUP_A"], + "payload": { + "supplier_id": "SUP_A", + "sku": "SKU_1", + "delay_hours": 48 + } +} +``` + +## TraceStep + +```json +{ + "step_id": "step_1234abcd", + "sequence": 3, + "node_key": "supplier", + "node_type": "agent", + "status": "completed", + "started_at": "2026-04-11T10:00:01Z", + "completed_at": "2026-04-11T10:00:01.400Z", + "duration_ms": 400.0, + "mode_snapshot": "crisis", + "summary": "supplier options ranked for SKU_1", + "reasoning_source": "ai_assisted_reasoning", + "input_snapshot": {}, + "output_snapshot": {}, + "observations": [], + "risks": [], + "downstream_impacts": [], + "recommended_action_ids": ["act_supplier_SKU_1_SUP_B"], + "tradeoffs": [], + "llm_used": true, + "llm_error": null, + "fallback_used": false, + "fallback_reason": null +} +``` + +## ExecutionRecord + +```json +{ + "execution_id": "exec_1234abcd", + "run_id": "run_1234abcd", + "decision_id": "dec_1234abcd", + "plan_id": "plan_1234abcd", + "status": "approval_pending", + "dispatch_mode": "simulation", + "dry_run": false, + "target_system": "digital_twin", + "action_ids": ["act_supplier_A", "act_reroute_B"], + "receipts": [], + "failure_reason": null, + "created_at": "2026-04-11T10:00:02Z", + "updated_at": "2026-04-11T10:00:02Z" +} +``` + +## API Examples + +### `POST /api/v1/events/ingest` + +Request: + +```json +{ + "event_class": "domain", + "event_type": "supplier_delay", + "source": "api", + "severity": 0.9, + "entity_ids": ["SKU_1", "SUP_A"], + "payload": { + "supplier_id": "SUP_A", + "sku": "SKU_1", + "delay_hours": 48 + } +} +``` + +Response: + +```json +{ + "event": { + "event_id": "evt_env_1234abcd", + "event_class": "domain", + "event_type": "supplier_delay", + "source": "api", + "occurred_at": "2026-04-11T10:00:00Z", + "ingested_at": "2026-04-11T10:00:01Z", + "correlation_id": "corr_supplier_delay_1", + "causation_id": null, + "idempotency_key": "supplier_delay:sku_1:sup_a", + "severity": 0.9, + "entity_ids": ["SKU_1", "SUP_A"], + "payload": { + "supplier_id": "SUP_A", + "sku": "SKU_1", + "delay_hours": 48 + } + }, + "run_id": "run_1234abcd", + "accepted": true, + "execution_id": "exec_1234abcd" +} +``` + +### `GET /api/v1/runs/{run_id}` + +Response: + +```json +{ + "item": { + "run_id": "run_1234abcd", + "run_type": "event_response", + "status": "completed" + } +} +``` + +### `GET /api/v1/runs/{run_id}/trace` + +Response: + +```json +{ + "item": { + "run_id": "run_1234abcd", + "trace_id": "trace_1234abcd", + "status": "completed", + "steps": [] + } +} +``` + +### `GET /api/v1/execution/{execution_id}` + +Response: + +```json +{ + "item": { + "execution_id": "exec_1234abcd", + "status": "approval_pending", + "dispatch_mode": "simulation" + } +} +``` diff --git a/docs/internal/feat-agent-trace.md b/docs/internal/feat-agent-trace.md index 1e0aaab..e002d42 100644 --- a/docs/internal/feat-agent-trace.md +++ b/docs/internal/feat-agent-trace.md @@ -1,47 +1,47 @@ -# Feature Plan: Agent Trace - -## Objective - -Persist a real orchestration trace for each LangGraph run so the backend can expose agent order, branch decisions, planner selection, approval routing, and execution outcome to the external UI. - -## Scope - -- [x] Add a persisted orchestration trace model to shared state -- [x] Capture node-level execution details directly inside the LangGraph lifecycle -- [x] Record routing decisions for normal, crisis, approval, and execution branches -- [x] Expose the richer trace through `GET /api/v1/trace/latest` -- [x] Add tests for normal flow, crisis flow, and approval-required flow - -## Files To Modify - -- [x] `core/models.py` -- [ ] `core/state.py` -- [x] `orchestrator/graph.py` -- [x] `app_api/schemas.py` -- [x] `app_api/services.py` -- [x] `tests/test_backend_api_alignment.py` - -## Implementation Steps - -- [x] Add trace models for orchestration cycle, step records, and route decisions -- [x] Add `latest_trace` to `SystemState` -- [x] Initialize a fresh trace at the start of each graph invocation -- [x] Record step start/completion for risk, demand, inventory, supplier, logistics, planner, critic, approval, and execution -- [x] Capture selected branch after risk and after critic -- [x] Capture planner decision metadata: - - [x] selected strategy - - [x] selected plan id - - [x] candidate count - - [x] approval-required flag -- [x] Capture execution or approval outcome at the end of the cycle -- [x] Update trace API view to use persisted trace data instead of inferring from `agent_outputs` alone -- [x] Add tests for: - - [x] normal daily plan trace - - [x] crisis scenario trace - - [x] pending approval trace - -## Testing Approach - -- [x] Run targeted pytest coverage for backend trace contracts -- [x] Run existing LangGraph path tests to confirm orchestration behavior is unchanged -- [x] Manually verify `/api/v1/trace/latest` after a daily plan and after a disruption scenario +# Feature Plan: Agent Trace + +## Objective + +Persist a real orchestration trace for each LangGraph run so the backend can expose agent order, branch decisions, planner selection, approval routing, and execution outcome to the external UI. + +## Scope + +- [x] Add a persisted orchestration trace model to shared state +- [x] Capture node-level execution details directly inside the LangGraph lifecycle +- [x] Record routing decisions for normal, crisis, approval, and execution branches +- [x] Expose the richer trace through `GET /api/v1/trace/latest` +- [x] Add tests for normal flow, crisis flow, and approval-required flow + +## Files To Modify + +- [x] `core/models.py` +- [ ] `core/state.py` +- [x] `orchestrator/graph.py` +- [x] `app_api/schemas.py` +- [x] `app_api/services.py` +- [x] `tests/test_backend_api_alignment.py` + +## Implementation Steps + +- [x] Add trace models for orchestration cycle, step records, and route decisions +- [x] Add `latest_trace` to `SystemState` +- [x] Initialize a fresh trace at the start of each graph invocation +- [x] Record step start/completion for risk, demand, inventory, supplier, logistics, planner, critic, approval, and execution +- [x] Capture selected branch after risk and after critic +- [x] Capture planner decision metadata: + - [x] selected strategy + - [x] selected plan id + - [x] candidate count + - [x] approval-required flag +- [x] Capture execution or approval outcome at the end of the cycle +- [x] Update trace API view to use persisted trace data instead of inferring from `agent_outputs` alone +- [x] Add tests for: + - [x] normal daily plan trace + - [x] crisis scenario trace + - [x] pending approval trace + +## Testing Approach + +- [x] Run targeted pytest coverage for backend trace contracts +- [x] Run existing LangGraph path tests to confirm orchestration behavior is unchanged +- [x] Manually verify `/api/v1/trace/latest` after a daily plan and after a disruption scenario diff --git a/docs/internal/feat-ai-agent-visualization-ui.md b/docs/internal/feat-ai-agent-visualization-ui.md index 083b8b8..c025b3c 100644 --- a/docs/internal/feat-ai-agent-visualization-ui.md +++ b/docs/internal/feat-ai-agent-visualization-ui.md @@ -1,74 +1,74 @@ -# Feature Plan: AI Agent Visualization UI - -## Objective - -Make the system's AI coordination visible in the dashboard so judges can immediately understand how agents collaborate, how plans are chosen, and how the workflow moves from normal to crisis, approval, execution, and learning. - -## Scope - -- Add a dedicated visualization page for AI agent transparency. -- Render a node-based pipeline view showing agent flow and branching. -- Add a per-node reasoning panel driven by existing state and decision-log data. -- Surface candidate plans, selected strategy, critic output, and reflection memory. -- Reuse existing backend data; avoid new orchestration logic. - -## Files To Modify - -- [x] `docs/internal/feat-ai-agent-visualization-ui.md` -- [x] `ui/dashboard.py` -- [x] `ui/components.py` -- [x] `ui/agent_visualization.py` -- [x] `tests/test_agent_visualization_ui.py` - -No direct code changes were required in `tests/test_demo_presenter.py`; compatibility was verified by running it unchanged. - -## Implementation Steps - -- [x] Add an `AI Agents` page to the dashboard navigation. -- [x] Build a Graphviz-based pipeline view with nodes for: - - Risk - - Demand - - Inventory - - Supplier - - Logistics - - Planner - - Critic - - Decision Engine - - Approval Gate - - Execution - - Reflection / Memory -- [x] Color and highlight nodes by mode and execution status: - - normal - - crisis - - approval pending - - executed - - learning complete -- [x] Add a node selector and per-node reasoning panel showing: - - input state summary - - output summary - - recommended actions / key factors - - deterministic or AI reasoning availability -- [x] Add candidate-plan comparison showing: - - strategy label - - selected / rejected status - - score - - KPI projections - - approval flag - - selection reason -- [x] Add critic summary and findings section when available. -- [x] Add reflection memory section with: - - latest note - - lessons - - pattern tags - - history list -- [x] Keep the current demo flow pages working unchanged. - -## Testing Approach - -- [x] Add tests for pipeline graph content and node status mapping. -- [x] Add tests for candidate-plan visualization data. -- [x] Add tests for reflection-memory visualization data. -- [x] Verify presenter compatibility by running `tests/test_demo_presenter.py`. -- [x] Run `./.venv/bin/ruff check ui tests` -- [x] Run `./.venv/bin/pytest -q tests/test_agent_visualization_ui.py tests/test_demo_presenter.py` -- [x] Run `./.venv/bin/pytest -q` +# Feature Plan: AI Agent Visualization UI + +## Objective + +Make the system's AI coordination visible in the dashboard so judges can immediately understand how agents collaborate, how plans are chosen, and how the workflow moves from normal to crisis, approval, execution, and learning. + +## Scope + +- Add a dedicated visualization page for AI agent transparency. +- Render a node-based pipeline view showing agent flow and branching. +- Add a per-node reasoning panel driven by existing state and decision-log data. +- Surface candidate plans, selected strategy, critic output, and reflection memory. +- Reuse existing backend data; avoid new orchestration logic. + +## Files To Modify + +- [x] `docs/internal/feat-ai-agent-visualization-ui.md` +- [x] `ui/dashboard.py` +- [x] `ui/components.py` +- [x] `ui/agent_visualization.py` +- [x] `tests/test_agent_visualization_ui.py` + +No direct code changes were required in `tests/test_demo_presenter.py`; compatibility was verified by running it unchanged. + +## Implementation Steps + +- [x] Add an `AI Agents` page to the dashboard navigation. +- [x] Build a Graphviz-based pipeline view with nodes for: + - Risk + - Demand + - Inventory + - Supplier + - Logistics + - Planner + - Critic + - Decision Engine + - Approval Gate + - Execution + - Reflection / Memory +- [x] Color and highlight nodes by mode and execution status: + - normal + - crisis + - approval pending + - executed + - learning complete +- [x] Add a node selector and per-node reasoning panel showing: + - input state summary + - output summary + - recommended actions / key factors + - deterministic or AI reasoning availability +- [x] Add candidate-plan comparison showing: + - strategy label + - selected / rejected status + - score + - KPI projections + - approval flag + - selection reason +- [x] Add critic summary and findings section when available. +- [x] Add reflection memory section with: + - latest note + - lessons + - pattern tags + - history list +- [x] Keep the current demo flow pages working unchanged. + +## Testing Approach + +- [x] Add tests for pipeline graph content and node status mapping. +- [x] Add tests for candidate-plan visualization data. +- [x] Add tests for reflection-memory visualization data. +- [x] Verify presenter compatibility by running `tests/test_demo_presenter.py`. +- [x] Run `./.venv/bin/ruff check ui tests` +- [x] Run `./.venv/bin/pytest -q tests/test_agent_visualization_ui.py tests/test_demo_presenter.py` +- [x] Run `./.venv/bin/pytest -q` diff --git a/docs/internal/feat-ai-planner-critic.md b/docs/internal/feat-ai-planner-critic.md index 29e485b..0e91bb0 100644 --- a/docs/internal/feat-ai-planner-critic.md +++ b/docs/internal/feat-ai-planner-critic.md @@ -1,57 +1,57 @@ -# Feature Plan: AI Planner + Critic - -## Objective - -Move plan generation into an AI-centered planner and critic loop while keeping deterministic plan simulation, score computation, approval gating, and execution guards authoritative. - -## Scope - -- Generate exactly three candidate plan strategies: - - `cost_first` - - `balanced` - - `resilience_first` -- Planner may only reference known action ids produced by existing specialist agents. -- Add an optional critic review stage after candidate evaluation and final deterministic selection. -- Preserve `latest_plan`, `pending_plan`, approval endpoints, and current UI flow compatibility. -- Keep deterministic fallback when planner or critic output is unavailable or invalid. - -## Files To Modify - -- [x] `core/models.py` -- [x] `agents/planner.py` -- [x] `agents/critic.py` -- [x] `orchestrator/graph.py` -- [x] `orchestrator/router.py` -- [x] `llm/service.py` -- [x] `orchestrator/prompts.py` -- [x] `tests/test_ai_planner_critic.py` - -No direct code changes were required in `tests/test_llm_planner_explanations.py` or `tests/test_langgraph_paths.py`; compatibility was verified by running them unchanged. - -## Implementation Steps - -- [x] Add structured candidate-plan and critic-review models: - - `CandidatePlanDraft` - - `CandidatePlanEvaluation` -- [x] Extend `Plan` with optional strategy metadata and critic summary. -- [x] Extend `DecisionLog` with candidate evaluations, deterministic selection reason, planner fallback diagnostics, and critic metadata. -- [x] Add planner prompt and JSON schema for exactly three strategies referencing known action ids only. -- [x] Add deterministic fallback candidate-plan generation for the same three strategies. -- [x] Simulate and score each candidate deterministically. -- [x] Run deterministic approval guardrails on each candidate. -- [x] Select the final plan deterministically using score and fixed tie-breaks. -- [x] Add a critic prompt and review schema for candidate-plan blind spots and cautions. -- [x] Add a LangGraph critic node after planner. -- [x] Preserve approval and execution routing after critic. -- [x] Keep the existing plan/explanation enrichment path compatible with the new selected plan. - -## Testing Approach - -- [x] Add test that normal mode produces three candidate evaluations and one selected plan. -- [x] Add test that crisis mode can select the resilience-first plan. -- [x] Add test that approval routing still works for a high-risk selected plan through the unchanged LangGraph path tests. -- [x] Add test that invalid planner output falls back to deterministic candidate generation. -- [x] Add test that critic failure does not break planning or execution. -- [x] Run `./.venv/bin/ruff check agents core llm orchestrator tests` -- [x] Run `./.venv/bin/pytest -q tests/test_ai_planner_critic.py tests/test_langgraph_paths.py tests/test_llm_planner_explanations.py` -- [x] Run `./.venv/bin/pytest -q` +# Feature Plan: AI Planner + Critic + +## Objective + +Move plan generation into an AI-centered planner and critic loop while keeping deterministic plan simulation, score computation, approval gating, and execution guards authoritative. + +## Scope + +- Generate exactly three candidate plan strategies: + - `cost_first` + - `balanced` + - `resilience_first` +- Planner may only reference known action ids produced by existing specialist agents. +- Add an optional critic review stage after candidate evaluation and final deterministic selection. +- Preserve `latest_plan`, `pending_plan`, approval endpoints, and current UI flow compatibility. +- Keep deterministic fallback when planner or critic output is unavailable or invalid. + +## Files To Modify + +- [x] `core/models.py` +- [x] `agents/planner.py` +- [x] `agents/critic.py` +- [x] `orchestrator/graph.py` +- [x] `orchestrator/router.py` +- [x] `llm/service.py` +- [x] `orchestrator/prompts.py` +- [x] `tests/test_ai_planner_critic.py` + +No direct code changes were required in `tests/test_llm_planner_explanations.py` or `tests/test_langgraph_paths.py`; compatibility was verified by running them unchanged. + +## Implementation Steps + +- [x] Add structured candidate-plan and critic-review models: + - `CandidatePlanDraft` + - `CandidatePlanEvaluation` +- [x] Extend `Plan` with optional strategy metadata and critic summary. +- [x] Extend `DecisionLog` with candidate evaluations, deterministic selection reason, planner fallback diagnostics, and critic metadata. +- [x] Add planner prompt and JSON schema for exactly three strategies referencing known action ids only. +- [x] Add deterministic fallback candidate-plan generation for the same three strategies. +- [x] Simulate and score each candidate deterministically. +- [x] Run deterministic approval guardrails on each candidate. +- [x] Select the final plan deterministically using score and fixed tie-breaks. +- [x] Add a critic prompt and review schema for candidate-plan blind spots and cautions. +- [x] Add a LangGraph critic node after planner. +- [x] Preserve approval and execution routing after critic. +- [x] Keep the existing plan/explanation enrichment path compatible with the new selected plan. + +## Testing Approach + +- [x] Add test that normal mode produces three candidate evaluations and one selected plan. +- [x] Add test that crisis mode can select the resilience-first plan. +- [x] Add test that approval routing still works for a high-risk selected plan through the unchanged LangGraph path tests. +- [x] Add test that invalid planner output falls back to deterministic candidate generation. +- [x] Add test that critic failure does not break planning or execution. +- [x] Run `./.venv/bin/ruff check agents core llm orchestrator tests` +- [x] Run `./.venv/bin/pytest -q tests/test_ai_planner_critic.py tests/test_langgraph_paths.py tests/test_llm_planner_explanations.py` +- [x] Run `./.venv/bin/pytest -q` diff --git a/docs/internal/feat-ai-reflection-memory.md b/docs/internal/feat-ai-reflection-memory.md index 6b71822..6be50ab 100644 --- a/docs/internal/feat-ai-reflection-memory.md +++ b/docs/internal/feat-ai-reflection-memory.md @@ -1,55 +1,55 @@ -# Feature Plan: AI Reflection Memory - -## Objective - -Add structured AI reflection notes after actual scenario outcomes while preserving deterministic supplier reliability updates, route-prior updates, and scenario history persistence. - -## Scope - -- Keep numeric learning rule-based and deterministic. -- Add qualitative reflection notes from actual run outcomes. -- Persist lessons, pattern tags, and follow-up checks in memory snapshots. -- Defer reflection-note creation when a scenario ends with pending approval. -- Finalize deferred reflection when approval is resolved and the outcome becomes real. -- Do not change scenario definitions, scoring, or approval thresholds. - -## Files To Modify - -- [x] `core/models.py` -- [x] `simulation/learning.py` -- [x] `simulation/runner.py` -- [x] `orchestrator/service.py` -- [x] `llm/service.py` -- [x] `orchestrator/prompts.py` -- [x] `tests/test_ai_reflection_memory.py` -- [x] `tests/test_explainability_learning.py` - -## Implementation Steps - -- [x] Add structured reflection models: - - `ReflectionNote` -- [x] Extend `MemorySnapshot` with: - - `reflection_notes` - - `pattern_tag_counts` -- [x] Extend `ScenarioRun` with reflection status/metadata so pending approval runs can be finalized later. -- [x] Keep deterministic numeric updates to: - - supplier reliability - - route disruption priors - - scenario outcome history -- [x] Add an LLM reflection prompt and schema based on actual events, selected plan, KPI deltas, and approval outcome. -- [x] Add deterministic fallback reflection generation if LLM is unavailable or returns invalid output. -- [x] Create reflection notes immediately for auto-applied or already-approved runs. -- [x] Defer reflection notes for pending-approval runs. -- [x] Finalize deferred reflection when approval resolves through the existing approval service path. -- [x] Persist updated memory and scenario history to SQLite state snapshots. - -## Testing Approach - -- [x] Add test that auto-applied scenario runs persist a reflection note. -- [x] Add test that pending-approval runs defer reflection. -- [x] Add test that approving a pending scenario finalizes reflection. -- [x] Add test that repeated runs accumulate reflection notes and pattern tag counts. -- [x] Add test that LLM failure falls back to deterministic reflection notes. -- [x] Run `./.venv/bin/ruff check core llm orchestrator simulation tests` -- [x] Run `./.venv/bin/pytest -q tests/test_ai_reflection_memory.py tests/test_explainability_learning.py` -- [x] Run `./.venv/bin/pytest -q` +# Feature Plan: AI Reflection Memory + +## Objective + +Add structured AI reflection notes after actual scenario outcomes while preserving deterministic supplier reliability updates, route-prior updates, and scenario history persistence. + +## Scope + +- Keep numeric learning rule-based and deterministic. +- Add qualitative reflection notes from actual run outcomes. +- Persist lessons, pattern tags, and follow-up checks in memory snapshots. +- Defer reflection-note creation when a scenario ends with pending approval. +- Finalize deferred reflection when approval is resolved and the outcome becomes real. +- Do not change scenario definitions, scoring, or approval thresholds. + +## Files To Modify + +- [x] `core/models.py` +- [x] `simulation/learning.py` +- [x] `simulation/runner.py` +- [x] `orchestrator/service.py` +- [x] `llm/service.py` +- [x] `orchestrator/prompts.py` +- [x] `tests/test_ai_reflection_memory.py` +- [x] `tests/test_explainability_learning.py` + +## Implementation Steps + +- [x] Add structured reflection models: + - `ReflectionNote` +- [x] Extend `MemorySnapshot` with: + - `reflection_notes` + - `pattern_tag_counts` +- [x] Extend `ScenarioRun` with reflection status/metadata so pending approval runs can be finalized later. +- [x] Keep deterministic numeric updates to: + - supplier reliability + - route disruption priors + - scenario outcome history +- [x] Add an LLM reflection prompt and schema based on actual events, selected plan, KPI deltas, and approval outcome. +- [x] Add deterministic fallback reflection generation if LLM is unavailable or returns invalid output. +- [x] Create reflection notes immediately for auto-applied or already-approved runs. +- [x] Defer reflection notes for pending-approval runs. +- [x] Finalize deferred reflection when approval resolves through the existing approval service path. +- [x] Persist updated memory and scenario history to SQLite state snapshots. + +## Testing Approach + +- [x] Add test that auto-applied scenario runs persist a reflection note. +- [x] Add test that pending-approval runs defer reflection. +- [x] Add test that approving a pending scenario finalizes reflection. +- [x] Add test that repeated runs accumulate reflection notes and pattern tag counts. +- [x] Add test that LLM failure falls back to deterministic reflection notes. +- [x] Run `./.venv/bin/ruff check core llm orchestrator simulation tests` +- [x] Run `./.venv/bin/pytest -q tests/test_ai_reflection_memory.py tests/test_explainability_learning.py` +- [x] Run `./.venv/bin/pytest -q` diff --git a/docs/internal/feat-ai-specialist-agents.md b/docs/internal/feat-ai-specialist-agents.md index 87deb15..e440071 100644 --- a/docs/internal/feat-ai-specialist-agents.md +++ b/docs/internal/feat-ai-specialist-agents.md @@ -1,65 +1,65 @@ -# Feature Plan: AI Specialist Agents - -## Objective - -Introduce structured LLM specialist reasoning for risk, demand, supplier, and logistics while keeping deterministic state, action generation, scoring, and execution safety unchanged. - -## Scope - -- Keep the existing LangGraph flow and agent classes. -- Extend specialist outputs with structured AI fields. -- Build deterministic domain action catalogs first, then let LLM agents interpret and rank those safe options. -- Preserve deterministic fallback when LLM is disabled, times out, or returns invalid action IDs. -- Do not change approval thresholds, KPI math, or action execution logic. - -## Files To Modify - -- [x] `core/models.py` -- [x] `agents/base.py` -- [x] `agents/risk.py` -- [x] `agents/demand.py` -- [x] `agents/supplier.py` -- [x] `agents/logistics.py` -- [x] `llm/service.py` -- [x] `orchestrator/prompts.py` -- [x] `tests/test_ai_specialist_agents.py` - -No changes were required in `agents/inventory.py` or `tests/test_langgraph_paths.py`; compatibility was verified through the existing test suite. - -## Implementation Steps - -- [x] Extend `AgentProposal` with optional structured fields: - - `domain_summary` - - `downstream_impacts` - - `recommended_action_ids` - - `tradeoffs` - - `llm_used` - - `llm_error` -- [x] Refactor each specialist agent to build its deterministic observations and safe candidate `Action` list first. -- [x] Add specialist LLM prompt builders that take: - - mode - - triggering event - - relevant state slice - - current KPI snapshot - - safe candidate action catalog -- [x] Define a shared structured JSON schema for specialist reasoning: - - risk returns interpretation and downstream concerns - - demand/supplier/logistics return summary, ranked action IDs, tradeoffs, and planner notes -- [x] Validate returned action IDs strictly against the deterministic action catalog. -- [x] Reorder `proposal.proposals` and rebalance priorities using valid AI-ranked action IDs. -- [x] Fall back to current deterministic proposal ordering if: - - LLM disabled - - LLM error - - empty or invalid output -- [x] Keep inventory agent deterministic while using the extended `AgentProposal` shape unchanged. -- [x] Preserve `state.agent_outputs` and `state.candidate_actions` contracts. - -## Testing Approach - -- [x] Add tests for specialist AI ranking success path. -- [x] Add tests for invalid returned action IDs falling back safely. -- [x] Add tests for disabled LLM preserving current deterministic behavior. -- [x] Verify LangGraph normal/crisis/approval routing still works after specialist changes. -- [x] Run `./.venv/bin/ruff check agents core llm orchestrator tests` -- [x] Run `./.venv/bin/pytest -q tests/test_ai_specialist_agents.py tests/test_langgraph_paths.py` -- [x] Run `./.venv/bin/pytest -q` +# Feature Plan: AI Specialist Agents + +## Objective + +Introduce structured LLM specialist reasoning for risk, demand, supplier, and logistics while keeping deterministic state, action generation, scoring, and execution safety unchanged. + +## Scope + +- Keep the existing LangGraph flow and agent classes. +- Extend specialist outputs with structured AI fields. +- Build deterministic domain action catalogs first, then let LLM agents interpret and rank those safe options. +- Preserve deterministic fallback when LLM is disabled, times out, or returns invalid action IDs. +- Do not change approval thresholds, KPI math, or action execution logic. + +## Files To Modify + +- [x] `core/models.py` +- [x] `agents/base.py` +- [x] `agents/risk.py` +- [x] `agents/demand.py` +- [x] `agents/supplier.py` +- [x] `agents/logistics.py` +- [x] `llm/service.py` +- [x] `orchestrator/prompts.py` +- [x] `tests/test_ai_specialist_agents.py` + +No changes were required in `agents/inventory.py` or `tests/test_langgraph_paths.py`; compatibility was verified through the existing test suite. + +## Implementation Steps + +- [x] Extend `AgentProposal` with optional structured fields: + - `domain_summary` + - `downstream_impacts` + - `recommended_action_ids` + - `tradeoffs` + - `llm_used` + - `llm_error` +- [x] Refactor each specialist agent to build its deterministic observations and safe candidate `Action` list first. +- [x] Add specialist LLM prompt builders that take: + - mode + - triggering event + - relevant state slice + - current KPI snapshot + - safe candidate action catalog +- [x] Define a shared structured JSON schema for specialist reasoning: + - risk returns interpretation and downstream concerns + - demand/supplier/logistics return summary, ranked action IDs, tradeoffs, and planner notes +- [x] Validate returned action IDs strictly against the deterministic action catalog. +- [x] Reorder `proposal.proposals` and rebalance priorities using valid AI-ranked action IDs. +- [x] Fall back to current deterministic proposal ordering if: + - LLM disabled + - LLM error + - empty or invalid output +- [x] Keep inventory agent deterministic while using the extended `AgentProposal` shape unchanged. +- [x] Preserve `state.agent_outputs` and `state.candidate_actions` contracts. + +## Testing Approach + +- [x] Add tests for specialist AI ranking success path. +- [x] Add tests for invalid returned action IDs falling back safely. +- [x] Add tests for disabled LLM preserving current deterministic behavior. +- [x] Verify LangGraph normal/crisis/approval routing still works after specialist changes. +- [x] Run `./.venv/bin/ruff check agents core llm orchestrator tests` +- [x] Run `./.venv/bin/pytest -q tests/test_ai_specialist_agents.py tests/test_langgraph_paths.py` +- [x] Run `./.venv/bin/pytest -q` diff --git a/docs/internal/feat-approval-flow.md b/docs/internal/feat-approval-flow.md index f7b8928..179c430 100644 --- a/docs/internal/feat-approval-flow.md +++ b/docs/internal/feat-approval-flow.md @@ -1,46 +1,46 @@ -# Feature Plan: Approval Flow - -## Objective - -Make the approval workflow explicit and UI-ready by exposing richer pending approval details, typed approval command results, and consistent approval state propagation across summary, plan, and trace views. - -## Scope - -- [x] Add a richer pending approval read model with allowed actions and plan/decision context -- [x] Add a typed approval command response for approve, reject, and safer-plan actions -- [x] Propagate approval status consistently into summary, latest plan, pending approval, and trace views -- [x] Update trace state after approval actions so the latest workflow outcome is visible -- [x] Add tests for pending, approved, rejected, and safer-plan transitions - -## Files To Modify - -- [ ] `core/models.py` -- [x] `orchestrator/service.py` -- [x] `app_api/schemas.py` -- [x] `app_api/services.py` -- [x] `app_api/routers.py` -- [x] `tests/test_backend_api_alignment.py` -- [ ] `tests/test_demo_flow_api.py` - -## Implementation Steps - -- [x] Add approval metadata to plan and pending approval DTOs -- [x] Add a dedicated approval detail/result schema -- [x] Expose `GET /api/v1/approvals/{decision_id}` for a specific approval item -- [x] Return a typed result from `POST /api/v1/approvals/{decision_id}` -- [x] Update approval actions to keep latest trace synchronized: - - [x] approve - - [x] reject - - [x] safer_plan -- [x] Ensure summary/latest plan reflect approval status after each action -- [x] Add tests covering: - - [x] pending approval detail - - [x] approve transition - - [x] reject transition - - [x] safer plan transition - -## Testing Approach - -- [x] Run targeted pytest coverage for approval-flow contracts -- [x] Run existing demo/API tests to confirm compatibility -- [x] Manually verify pending approval, approve, reject, and safer-plan responses via FastAPI +# Feature Plan: Approval Flow + +## Objective + +Make the approval workflow explicit and UI-ready by exposing richer pending approval details, typed approval command results, and consistent approval state propagation across summary, plan, and trace views. + +## Scope + +- [x] Add a richer pending approval read model with allowed actions and plan/decision context +- [x] Add a typed approval command response for approve, reject, and safer-plan actions +- [x] Propagate approval status consistently into summary, latest plan, pending approval, and trace views +- [x] Update trace state after approval actions so the latest workflow outcome is visible +- [x] Add tests for pending, approved, rejected, and safer-plan transitions + +## Files To Modify + +- [ ] `core/models.py` +- [x] `orchestrator/service.py` +- [x] `app_api/schemas.py` +- [x] `app_api/services.py` +- [x] `app_api/routers.py` +- [x] `tests/test_backend_api_alignment.py` +- [ ] `tests/test_demo_flow_api.py` + +## Implementation Steps + +- [x] Add approval metadata to plan and pending approval DTOs +- [x] Add a dedicated approval detail/result schema +- [x] Expose `GET /api/v1/approvals/{decision_id}` for a specific approval item +- [x] Return a typed result from `POST /api/v1/approvals/{decision_id}` +- [x] Update approval actions to keep latest trace synchronized: + - [x] approve + - [x] reject + - [x] safer_plan +- [x] Ensure summary/latest plan reflect approval status after each action +- [x] Add tests covering: + - [x] pending approval detail + - [x] approve transition + - [x] reject transition + - [x] safer plan transition + +## Testing Approach + +- [x] Run targeted pytest coverage for approval-flow contracts +- [x] Run existing demo/API tests to confirm compatibility +- [x] Manually verify pending approval, approve, reject, and safer-plan responses via FastAPI diff --git a/docs/internal/feat-backend-api-alignment.md b/docs/internal/feat-backend-api-alignment.md index b84bb8a..e5132ef 100644 --- a/docs/internal/feat-backend-api-alignment.md +++ b/docs/internal/feat-backend-api-alignment.md @@ -1,56 +1,56 @@ -# Feature Plan: Backend API Alignment - -## Objective - -Introduce a Control Tower app layer on top of the existing FastAPI backend so the external UI can consume stable, typed, screen-oriented contracts without coupling to raw internal state. - -## Scope - -- [x] Add typed API schemas for control tower summary, inventory, suppliers, plans, approvals, trace, decision logs, and reflections -- [x] Add an application runtime/service layer that owns state, graph, runner, and persistence access -- [x] Add query helpers that translate `SystemState` into UI-facing response models -- [x] Add router structure under an app-layer package and keep existing routes compatible where practical -- [x] Add a unified approval command endpoint while preserving current approval behavior -- [x] Add API contract tests for read endpoints and approval state transitions - -## Files To Modify - -- [x] `api.py` -- [ ] `orchestrator/service.py` -- [ ] `core/models.py` -- [x] `tests/test_demo_flow_api.py` - -## Files To Add - -- [ ] `app_runtime.py` -- [x] `app_api/schemas.py` -- [x] `app_api/services.py` -- [x] `app_api/routers.py` -- [x] `tests/test_backend_api_alignment.py` - -## Implementation Steps - -- [x] Create a runtime wrapper for store, state, graph, and scenario runner -- [x] Create Pydantic response models for dashboard/state/read endpoints -- [x] Add query functions to build summaries, alerts, event feed, inventory rows, supplier rows, plan detail, approval detail, decision detail, trace, and reflection history -- [x] Refactor FastAPI route handlers to use the runtime/service layer -- [x] Add new endpoints: - - [x] `GET /api/v1/control-tower/summary` - - [x] `GET /api/v1/control-tower/state` - - [x] `GET /api/v1/inventory` - - [x] `GET /api/v1/suppliers` - - [x] `GET /api/v1/events` - - [x] `GET /api/v1/plans/latest` - - [x] `GET /api/v1/approvals/pending` - - [x] `GET /api/v1/decision-logs/{decision_id}` - - [x] `GET /api/v1/trace/latest` - - [x] `GET /api/v1/reflections` - - [x] `POST /api/v1/approvals/{decision_id}` -- [x] Preserve existing command endpoints used by current tests and demo flows -- [x] Add tests for new contracts and state transitions - -## Testing Approach - -- [x] Run targeted pytest coverage for the new API contract tests -- [x] Run existing demo/API tests to confirm backward compatibility -- [x] Manually verify `GET /api/v1/control-tower/summary` and approval flow responses with TestClient +# Feature Plan: Backend API Alignment + +## Objective + +Introduce a Control Tower app layer on top of the existing FastAPI backend so the external UI can consume stable, typed, screen-oriented contracts without coupling to raw internal state. + +## Scope + +- [x] Add typed API schemas for control tower summary, inventory, suppliers, plans, approvals, trace, decision logs, and reflections +- [x] Add an application runtime/service layer that owns state, graph, runner, and persistence access +- [x] Add query helpers that translate `SystemState` into UI-facing response models +- [x] Add router structure under an app-layer package and keep existing routes compatible where practical +- [x] Add a unified approval command endpoint while preserving current approval behavior +- [x] Add API contract tests for read endpoints and approval state transitions + +## Files To Modify + +- [x] `api.py` +- [ ] `orchestrator/service.py` +- [ ] `core/models.py` +- [x] `tests/test_demo_flow_api.py` + +## Files To Add + +- [ ] `app_runtime.py` +- [x] `app_api/schemas.py` +- [x] `app_api/services.py` +- [x] `app_api/routers.py` +- [x] `tests/test_backend_api_alignment.py` + +## Implementation Steps + +- [x] Create a runtime wrapper for store, state, graph, and scenario runner +- [x] Create Pydantic response models for dashboard/state/read endpoints +- [x] Add query functions to build summaries, alerts, event feed, inventory rows, supplier rows, plan detail, approval detail, decision detail, trace, and reflection history +- [x] Refactor FastAPI route handlers to use the runtime/service layer +- [x] Add new endpoints: + - [x] `GET /api/v1/control-tower/summary` + - [x] `GET /api/v1/control-tower/state` + - [x] `GET /api/v1/inventory` + - [x] `GET /api/v1/suppliers` + - [x] `GET /api/v1/events` + - [x] `GET /api/v1/plans/latest` + - [x] `GET /api/v1/approvals/pending` + - [x] `GET /api/v1/decision-logs/{decision_id}` + - [x] `GET /api/v1/trace/latest` + - [x] `GET /api/v1/reflections` + - [x] `POST /api/v1/approvals/{decision_id}` +- [x] Preserve existing command endpoints used by current tests and demo flows +- [x] Add tests for new contracts and state transitions + +## Testing Approach + +- [x] Run targeted pytest coverage for the new API contract tests +- [x] Run existing demo/API tests to confirm backward compatibility +- [x] Manually verify `GET /api/v1/control-tower/summary` and approval flow responses with TestClient diff --git a/docs/internal/feat-demo-hardening-polish.md b/docs/internal/feat-demo-hardening-polish.md index 2a51f00..60d4dbc 100644 --- a/docs/internal/feat-demo-hardening-polish.md +++ b/docs/internal/feat-demo-hardening-polish.md @@ -1,55 +1,55 @@ -# Feature Plan: Demo Hardening + Polish - -## Objective - -Increase demo reliability by fixing brittle reset behavior, blocking invalid actions during pending approval, and polishing the guided demo flow so judges can clearly follow normal mode, disruption, replanning, approval, and updated state. - -## Scope - -- Add clean reset behavior for API and Streamlit demo restarts. -- Block daily-plan and scenario execution while approval is pending. -- Improve visibility of the current approval-blocked state in the UI. -- Add guided demo-flow presentation for mode, selected plan, and state progression. -- Add focused tests for reset, approval edge cases, and demo presentation helpers. - -## Files To Modify - -- [x] `core/memory.py` -- [x] `orchestrator/service.py` -- [x] `simulation/runner.py` -- [x] `api.py` -- [x] `ui/dashboard.py` -- [x] `ui/overview.py` -- [x] `ui/scenarios.py` -- [x] `ui/decision_log.py` -- [x] `ui/components.py` -- [x] `docs/demo_script.md` -- [x] `tests/test_demo_flow_api.py` -- [x] `tests/test_scenarios.py` -- [x] `tests/test_demo_hardening.py` -- [x] `tests/test_demo_presenter.py` - -## Implementation Steps - -- [x] Add store reset support for decision logs, snapshots, and scenario runs. -- [x] Add a shared pending-approval guard for runtime actions. -- [x] Block daily-plan execution while a decision is pending approval. -- [x] Block scenario execution while a decision is pending approval. -- [x] Add an API reset endpoint for clean demo restarts. -- [x] Update Streamlit reset to clear both session and persisted state. -- [x] Surface blocked-state messaging in Overview and Scenarios. -- [x] Improve Decision Log empty/blocking state clarity. -- [x] Add a guided control-tower status section for mode, selected plan, and demo progress. -- [x] Improve scenario descriptions and latest-event presentation for judges. -- [x] Replace raw JSON plan/KPI dumps with friendlier plan and KPI views. -- [x] Replace deprecated Streamlit width usage and raw persisted-log table with a friendlier detail flow. -- [x] Move persisted-log details into per-row table actions with a modal popup. -- [x] Update the demo script to match the polished flow. -- [x] Add hardening tests for reset, blocking, and reject behavior. -- [x] Add helper tests for demo-flow presentation logic. - -## Testing Approach - -- [x] Run `ruff check api.py core orchestrator simulation ui tests` -- [x] Run `pytest -q tests/test_demo_flow_api.py tests/test_demo_hardening.py tests/test_scenarios.py` -- [x] Run `pytest -q` +# Feature Plan: Demo Hardening + Polish + +## Objective + +Increase demo reliability by fixing brittle reset behavior, blocking invalid actions during pending approval, and polishing the guided demo flow so judges can clearly follow normal mode, disruption, replanning, approval, and updated state. + +## Scope + +- Add clean reset behavior for API and Streamlit demo restarts. +- Block daily-plan and scenario execution while approval is pending. +- Improve visibility of the current approval-blocked state in the UI. +- Add guided demo-flow presentation for mode, selected plan, and state progression. +- Add focused tests for reset, approval edge cases, and demo presentation helpers. + +## Files To Modify + +- [x] `core/memory.py` +- [x] `orchestrator/service.py` +- [x] `simulation/runner.py` +- [x] `api.py` +- [x] `ui/dashboard.py` +- [x] `ui/overview.py` +- [x] `ui/scenarios.py` +- [x] `ui/decision_log.py` +- [x] `ui/components.py` +- [x] `docs/demo_script.md` +- [x] `tests/test_demo_flow_api.py` +- [x] `tests/test_scenarios.py` +- [x] `tests/test_demo_hardening.py` +- [x] `tests/test_demo_presenter.py` + +## Implementation Steps + +- [x] Add store reset support for decision logs, snapshots, and scenario runs. +- [x] Add a shared pending-approval guard for runtime actions. +- [x] Block daily-plan execution while a decision is pending approval. +- [x] Block scenario execution while a decision is pending approval. +- [x] Add an API reset endpoint for clean demo restarts. +- [x] Update Streamlit reset to clear both session and persisted state. +- [x] Surface blocked-state messaging in Overview and Scenarios. +- [x] Improve Decision Log empty/blocking state clarity. +- [x] Add a guided control-tower status section for mode, selected plan, and demo progress. +- [x] Improve scenario descriptions and latest-event presentation for judges. +- [x] Replace raw JSON plan/KPI dumps with friendlier plan and KPI views. +- [x] Replace deprecated Streamlit width usage and raw persisted-log table with a friendlier detail flow. +- [x] Move persisted-log details into per-row table actions with a modal popup. +- [x] Update the demo script to match the polished flow. +- [x] Add hardening tests for reset, blocking, and reject behavior. +- [x] Add helper tests for demo-flow presentation logic. + +## Testing Approach + +- [x] Run `ruff check api.py core orchestrator simulation ui tests` +- [x] Run `pytest -q tests/test_demo_flow_api.py tests/test_demo_hardening.py tests/test_scenarios.py` +- [x] Run `pytest -q` diff --git a/docs/internal/feat-execution-dispatch-lifecycle.md b/docs/internal/feat-execution-dispatch-lifecycle.md index 8df4678..cec89e7 100644 --- a/docs/internal/feat-execution-dispatch-lifecycle.md +++ b/docs/internal/feat-execution-dispatch-lifecycle.md @@ -1,41 +1,41 @@ -# Execution Dispatch Lifecycle - -## Objective - -Turn execution records from one-shot summaries into a coherent simulation-backed lifecycle that survives approval transitions and safer-plan replacements. - -## Scope - -- [x] Extend execution contracts with transition history and receipts -- [x] Reuse and update the pending execution record through approval resolution -- [x] Cancel superseded executions when a safer plan is requested -- [x] Expose latest execution details in approval command responses -- [x] Add tests for auto-apply, approve, reject, and safer-plan flows - -## Files To Modify - -- [x] `docs/internal/feat-execution-dispatch-lifecycle.md` -- [x] `core/runtime_records.py` -- [x] `core/runtime_tracking.py` -- [x] `core/memory.py` -- [x] `app_api/schemas.py` -- [x] `app_api/services.py` -- [x] `tests/test_execution_dispatch_lifecycle.py` - -## Implementation Steps - -- [x] Add execution transition model and store it in `ExecutionRecord` -- [x] Build helpers to create, advance, and cancel execution records -- [x] On initial plan selection, create execution with `planned -> approval_pending` or `planned -> applied` -- [x] On approval, advance the same execution to `approved -> applied` -- [x] On rejection, advance the same execution to `cancelled` -- [x] On safer plan, cancel the old execution and create a new one for the replacement plan -- [x] Surface execution details in API payloads without breaking existing fields - -## Testing Approach - -- [x] Daily plan creates an applied execution with history -- [x] Crisis approval run creates approval-pending execution -- [x] Approve updates the existing execution instead of creating an unrelated one -- [x] Reject cancels the existing execution -- [x] Safer plan cancels the old execution and issues a new one +# Execution Dispatch Lifecycle + +## Objective + +Turn execution records from one-shot summaries into a coherent simulation-backed lifecycle that survives approval transitions and safer-plan replacements. + +## Scope + +- [x] Extend execution contracts with transition history and receipts +- [x] Reuse and update the pending execution record through approval resolution +- [x] Cancel superseded executions when a safer plan is requested +- [x] Expose latest execution details in approval command responses +- [x] Add tests for auto-apply, approve, reject, and safer-plan flows + +## Files To Modify + +- [x] `docs/internal/feat-execution-dispatch-lifecycle.md` +- [x] `core/runtime_records.py` +- [x] `core/runtime_tracking.py` +- [x] `core/memory.py` +- [x] `app_api/schemas.py` +- [x] `app_api/services.py` +- [x] `tests/test_execution_dispatch_lifecycle.py` + +## Implementation Steps + +- [x] Add execution transition model and store it in `ExecutionRecord` +- [x] Build helpers to create, advance, and cancel execution records +- [x] On initial plan selection, create execution with `planned -> approval_pending` or `planned -> applied` +- [x] On approval, advance the same execution to `approved -> applied` +- [x] On rejection, advance the same execution to `cancelled` +- [x] On safer plan, cancel the old execution and create a new one for the replacement plan +- [x] Surface execution details in API payloads without breaking existing fields + +## Testing Approach + +- [x] Daily plan creates an applied execution with history +- [x] Crisis approval run creates approval-pending execution +- [x] Approve updates the existing execution instead of creating an unrelated one +- [x] Reject cancels the existing execution +- [x] Safer plan cancels the old execution and issues a new one diff --git a/docs/internal/feat-explainability-learning.md b/docs/internal/feat-explainability-learning.md index ae10128..430de6f 100644 --- a/docs/internal/feat-explainability-learning.md +++ b/docs/internal/feat-explainability-learning.md @@ -1,41 +1,41 @@ -# Feature Plan: Explainability + Learn Phase - -## Objective - -Close the remaining gap in the MVP loop by adding grounded, deterministic decision explanations and simple deterministic learning updates after scenario runs. - -## Scope - -- Derive explanations from real `score_breakdown`, KPI deltas, selected actions, and rejected alternatives. -- Log whether approval was required and why. -- Persist scenario outcomes into memory after each run. -- Update supplier reliability and route disruption priors with simple rule-based heuristics. -- Preserve repeated-run history for the same scenario. -- Add tests for explanation completeness and memory persistence. - -## Files To Modify - -- [x] `core/models.py` -- [x] `policies/explainability.py` -- [x] `agents/planner.py` -- [x] `simulation/runner.py` -- [x] `core/memory.py` -- [x] `ui/components.py` -- [x] `ui/decision_log.py` -- [x] `tests/test_explainability_learning.py` - -## Implementation Steps - -- [x] Extend `DecisionLog` and `ScenarioRun` with fields needed for grounded explanations and repeated-run traceability. -- [x] Add deterministic explainability helpers for score breakdown, KPI deltas, selected-action wins, and rejection reasons. -- [x] Update the planner to write explanation and approval metadata into the decision log. -- [x] Add deterministic learning updates to scenario execution for supplier reliability and route disruption priors. -- [x] Persist repeated scenario runs without overwriting previous runs. -- [x] Surface the new explanation fields in the existing decision-log UI without redesigning the app. -- [x] Add focused tests for explanation completeness, memory persistence, and repeated scenario updates. - -## Testing Approach - -- [x] Run `pytest -q tests/test_explainability_learning.py` -- [x] Run `pytest -q` -- [x] Run `ruff check agents core policies simulation ui tests` +# Feature Plan: Explainability + Learn Phase + +## Objective + +Close the remaining gap in the MVP loop by adding grounded, deterministic decision explanations and simple deterministic learning updates after scenario runs. + +## Scope + +- Derive explanations from real `score_breakdown`, KPI deltas, selected actions, and rejected alternatives. +- Log whether approval was required and why. +- Persist scenario outcomes into memory after each run. +- Update supplier reliability and route disruption priors with simple rule-based heuristics. +- Preserve repeated-run history for the same scenario. +- Add tests for explanation completeness and memory persistence. + +## Files To Modify + +- [x] `core/models.py` +- [x] `policies/explainability.py` +- [x] `agents/planner.py` +- [x] `simulation/runner.py` +- [x] `core/memory.py` +- [x] `ui/components.py` +- [x] `ui/decision_log.py` +- [x] `tests/test_explainability_learning.py` + +## Implementation Steps + +- [x] Extend `DecisionLog` and `ScenarioRun` with fields needed for grounded explanations and repeated-run traceability. +- [x] Add deterministic explainability helpers for score breakdown, KPI deltas, selected-action wins, and rejection reasons. +- [x] Update the planner to write explanation and approval metadata into the decision log. +- [x] Add deterministic learning updates to scenario execution for supplier reliability and route disruption priors. +- [x] Persist repeated scenario runs without overwriting previous runs. +- [x] Surface the new explanation fields in the existing decision-log UI without redesigning the app. +- [x] Add focused tests for explanation completeness, memory persistence, and repeated scenario updates. + +## Testing Approach + +- [x] Run `pytest -q tests/test_explainability_learning.py` +- [x] Run `pytest -q` +- [x] Run `ruff check agents core policies simulation ui tests` diff --git a/docs/internal/feat-langgraph-orchestration.md b/docs/internal/feat-langgraph-orchestration.md index cb72c24..72b0206 100644 --- a/docs/internal/feat-langgraph-orchestration.md +++ b/docs/internal/feat-langgraph-orchestration.md @@ -1,40 +1,40 @@ -# Feature Plan: LangGraph Orchestration - -## Objective - -Replace the custom orchestrator with a real LangGraph-based state graph while preserving the current `build_graph().invoke(state, event)` contract used by the API, simulation runner, and UI. - -## Scope - -- Real LangGraph state graph and node wiring -- Nodes for risk, demand, inventory, supplier, logistics, planner, approval, and execution -- Routing for normal, crisis, and approval-required paths -- Minimal adapter layer to preserve current callers -- Integration tests for normal, crisis, and approval paths - -## Files to Modify - -- [x] `orchestrator/graph.py` -- [x] `orchestrator/router.py` -- [ ] `simulation/runner.py` (no change needed; adapter contract preserved) -- [x] `tests/test_scenarios.py` -- [x] `tests/test_langgraph_paths.py` - -## Implementation Steps - -- [x] Define a LangGraph wrapper state around `SystemState` and `Event` -- [x] Implement risk, demand, inventory, supplier, logistics, planner, approval, and execution nodes -- [x] Add conditional routing for normal / crisis after risk -- [x] Add conditional routing for approval-required path after planner -- [x] Preserve the current adapter contract with `build_graph().invoke(state, event)` -- [x] Update scenario runner only if adapter usage changes -- [x] Add tests for normal path -- [x] Add tests for crisis path -- [x] Add tests for approval path - -## Testing Approach - -- Run targeted pytest coverage for new graph path tests and existing scenario tests -- Verify that daily normal invocation with `event=None` yields an applied plan -- Verify that a disruption scenario still produces crisis or approval behavior -- Verify that a high-risk scenario leaves a pending approval plan instead of auto-applying +# Feature Plan: LangGraph Orchestration + +## Objective + +Replace the custom orchestrator with a real LangGraph-based state graph while preserving the current `build_graph().invoke(state, event)` contract used by the API, simulation runner, and UI. + +## Scope + +- Real LangGraph state graph and node wiring +- Nodes for risk, demand, inventory, supplier, logistics, planner, approval, and execution +- Routing for normal, crisis, and approval-required paths +- Minimal adapter layer to preserve current callers +- Integration tests for normal, crisis, and approval paths + +## Files to Modify + +- [x] `orchestrator/graph.py` +- [x] `orchestrator/router.py` +- [ ] `simulation/runner.py` (no change needed; adapter contract preserved) +- [x] `tests/test_scenarios.py` +- [x] `tests/test_langgraph_paths.py` + +## Implementation Steps + +- [x] Define a LangGraph wrapper state around `SystemState` and `Event` +- [x] Implement risk, demand, inventory, supplier, logistics, planner, approval, and execution nodes +- [x] Add conditional routing for normal / crisis after risk +- [x] Add conditional routing for approval-required path after planner +- [x] Preserve the current adapter contract with `build_graph().invoke(state, event)` +- [x] Update scenario runner only if adapter usage changes +- [x] Add tests for normal path +- [x] Add tests for crisis path +- [x] Add tests for approval path + +## Testing Approach + +- Run targeted pytest coverage for new graph path tests and existing scenario tests +- Verify that daily normal invocation with `event=None` yields an applied plan +- Verify that a disruption scenario still produces crisis or approval behavior +- Verify that a high-risk scenario leaves a pending approval plan instead of auto-applying diff --git a/docs/internal/feat-llm-planner-explanations.md b/docs/internal/feat-llm-planner-explanations.md index d5744c2..cdc560c 100644 --- a/docs/internal/feat-llm-planner-explanations.md +++ b/docs/internal/feat-llm-planner-explanations.md @@ -1,44 +1,44 @@ -# Feature Plan: LLM Planner + Explanations - -## Objective - -Add a thin optional Gemini-backed LLM layer for planner narrative, operator explanation, and approval summary while keeping deterministic planning, scoring, approval, and learning logic unchanged. - -## Scope - -- Add opt-in LLM configuration and Gemini client adapter. -- Generate LLM planner narrative after deterministic planning is complete. -- Generate LLM operator explanation and approval summary from actual plan and score data. -- Persist LLM outputs in separate model fields with deterministic fallback. -- Surface LLM output in the existing Overview and Decision Log UI. -- Add tests for disabled mode, success path, approval path, and failure fallback. - -## Files To Modify - -- [x] `core/models.py` -- [x] `agents/planner.py` -- [x] `orchestrator/service.py` -- [x] `orchestrator/prompts.py` -- [x] `ui/overview.py` -- [x] `ui/decision_log.py` -- [x] `llm/config.py` -- [x] `llm/gemini_client.py` -- [x] `llm/service.py` -- [x] `tests/test_llm_planner_explanations.py` - -## Implementation Steps - -- [x] Add LLM config loader with opt-in env vars and deterministic disabled default. -- [x] Add Gemini client using the official `generateContent` REST shape with standard-library HTTP. -- [x] Add a single enrichment service that builds planner narrative, operator explanation, and approval summary from deterministic context only. -- [x] Extend plan and decision-log models with separate LLM fields and metadata. -- [x] Enrich planner-generated decisions without changing deterministic selection or scoring. -- [x] Enrich safer-plan decisions through the existing orchestrator service path. -- [x] Surface optional LLM explanations in Overview and Decision Log with deterministic fallback messaging. -- [x] Add tests for disabled config, successful enrichment, approval summary generation, safer-plan enrichment, and client failure fallback. - -## Testing Approach - -- [x] Run `ruff check agents core llm orchestrator ui tests` -- [x] Run `pytest -q tests/test_llm_planner_explanations.py` -- [x] Run `pytest -q` +# Feature Plan: LLM Planner + Explanations + +## Objective + +Add a thin optional Gemini-backed LLM layer for planner narrative, operator explanation, and approval summary while keeping deterministic planning, scoring, approval, and learning logic unchanged. + +## Scope + +- Add opt-in LLM configuration and Gemini client adapter. +- Generate LLM planner narrative after deterministic planning is complete. +- Generate LLM operator explanation and approval summary from actual plan and score data. +- Persist LLM outputs in separate model fields with deterministic fallback. +- Surface LLM output in the existing Overview and Decision Log UI. +- Add tests for disabled mode, success path, approval path, and failure fallback. + +## Files To Modify + +- [x] `core/models.py` +- [x] `agents/planner.py` +- [x] `orchestrator/service.py` +- [x] `orchestrator/prompts.py` +- [x] `ui/overview.py` +- [x] `ui/decision_log.py` +- [x] `llm/config.py` +- [x] `llm/gemini_client.py` +- [x] `llm/service.py` +- [x] `tests/test_llm_planner_explanations.py` + +## Implementation Steps + +- [x] Add LLM config loader with opt-in env vars and deterministic disabled default. +- [x] Add Gemini client using the official `generateContent` REST shape with standard-library HTTP. +- [x] Add a single enrichment service that builds planner narrative, operator explanation, and approval summary from deterministic context only. +- [x] Extend plan and decision-log models with separate LLM fields and metadata. +- [x] Enrich planner-generated decisions without changing deterministic selection or scoring. +- [x] Enrich safer-plan decisions through the existing orchestrator service path. +- [x] Surface optional LLM explanations in Overview and Decision Log with deterministic fallback messaging. +- [x] Add tests for disabled config, successful enrichment, approval summary generation, safer-plan enrichment, and client failure fallback. + +## Testing Approach + +- [x] Run `ruff check agents core llm orchestrator ui tests` +- [x] Run `pytest -q tests/test_llm_planner_explanations.py` +- [x] Run `pytest -q` diff --git a/docs/internal/feat-llm-planner-hardening.md b/docs/internal/feat-llm-planner-hardening.md index f399c61..08eb31c 100644 --- a/docs/internal/feat-llm-planner-hardening.md +++ b/docs/internal/feat-llm-planner-hardening.md @@ -1,56 +1,56 @@ -# Feature Plan: LLM Planner Hardening - -## Objective - -Reduce planner fallback frequency by making candidate-plan prompting and parsing more robust, while keeping deterministic scoring, approval thresholds, and execution guards unchanged. - -## Scope - -- Harden only the planner path. -- Improve structured prompt guidance for candidate plans. -- Tolerate common strategy-label and action-reference variations from the LLM. -- Repair partial planner output where possible before full fallback. -- Preserve deterministic fallback when output is still unusable. - -## Files To Modify - -- [x] `docs/internal/feat-llm-planner-hardening.md` -- [x] `llm/service.py` -- [x] `orchestrator/prompts.py` -- [x] `agents/planner.py` -- [x] `tests/test_llm_planner_hardening.py` - -No direct code changes were required in `tests/test_ai_planner_critic.py`; compatibility was verified by running it unchanged. - -## Implementation Steps - -- [x] Strengthen the planner prompt with stricter output guidance and an explicit strategy/action-id contract. -- [x] Add tolerant strategy-label normalization for variants such as: - - `cost first` - - `balanced plan` - - `resilience first` - - `plan a / b / c` -- [x] Add tolerant action-reference normalization using deterministic aliases derived from candidate actions. -- [x] Accept planner items that reference: - - `action_ids` - - `recommended_action_ids` - - action objects containing `action_id` -- [x] Repair partial planner output by: - - keeping valid candidate plans - - filling only missing strategies from deterministic fallback - - avoiding full fallback when partial recovery is possible -- [x] Improve planner diagnostics so the UI/logs can distinguish: - - full LLM planner - - repaired hybrid planner - - full fallback -- [x] Keep deterministic scoring, tie-breaks, approval gating, and final selection unchanged. - -## Testing Approach - -- [x] Add tests for strategy-label alias normalization. -- [x] Add tests for action-id alias normalization. -- [x] Add tests for partial candidate-plan recovery without full fallback. -- [x] Verify irreparable planner output still uses deterministic fallback via the existing planner-critic coverage. -- [x] Run `./.venv/bin/ruff check agents llm orchestrator tests` -- [x] Run `./.venv/bin/pytest -q tests/test_llm_planner_hardening.py tests/test_ai_planner_critic.py` -- [x] Run `./.venv/bin/pytest -q` +# Feature Plan: LLM Planner Hardening + +## Objective + +Reduce planner fallback frequency by making candidate-plan prompting and parsing more robust, while keeping deterministic scoring, approval thresholds, and execution guards unchanged. + +## Scope + +- Harden only the planner path. +- Improve structured prompt guidance for candidate plans. +- Tolerate common strategy-label and action-reference variations from the LLM. +- Repair partial planner output where possible before full fallback. +- Preserve deterministic fallback when output is still unusable. + +## Files To Modify + +- [x] `docs/internal/feat-llm-planner-hardening.md` +- [x] `llm/service.py` +- [x] `orchestrator/prompts.py` +- [x] `agents/planner.py` +- [x] `tests/test_llm_planner_hardening.py` + +No direct code changes were required in `tests/test_ai_planner_critic.py`; compatibility was verified by running it unchanged. + +## Implementation Steps + +- [x] Strengthen the planner prompt with stricter output guidance and an explicit strategy/action-id contract. +- [x] Add tolerant strategy-label normalization for variants such as: + - `cost first` + - `balanced plan` + - `resilience first` + - `plan a / b / c` +- [x] Add tolerant action-reference normalization using deterministic aliases derived from candidate actions. +- [x] Accept planner items that reference: + - `action_ids` + - `recommended_action_ids` + - action objects containing `action_id` +- [x] Repair partial planner output by: + - keeping valid candidate plans + - filling only missing strategies from deterministic fallback + - avoiding full fallback when partial recovery is possible +- [x] Improve planner diagnostics so the UI/logs can distinguish: + - full LLM planner + - repaired hybrid planner + - full fallback +- [x] Keep deterministic scoring, tie-breaks, approval gating, and final selection unchanged. + +## Testing Approach + +- [x] Add tests for strategy-label alias normalization. +- [x] Add tests for action-id alias normalization. +- [x] Add tests for partial candidate-plan recovery without full fallback. +- [x] Verify irreparable planner output still uses deterministic fallback via the existing planner-critic coverage. +- [x] Run `./.venv/bin/ruff check agents llm orchestrator tests` +- [x] Run `./.venv/bin/pytest -q tests/test_llm_planner_hardening.py tests/test_ai_planner_critic.py` +- [x] Run `./.venv/bin/pytest -q` diff --git a/docs/internal/feat-merge-and-integration.md b/docs/internal/feat-merge-and-integration.md index 2b65c48..35c77ec 100644 --- a/docs/internal/feat-merge-and-integration.md +++ b/docs/internal/feat-merge-and-integration.md @@ -1,317 +1,317 @@ -# Merge And Integration Strategy - -## Branch Inventory - -### `feat/chaincopilot-mvp` - -- Current baseline branch and current `feat/merge-and-integration` `HEAD` -- Old MVP backend only -- Contains legacy FastAPI app in `api.py` -- Does not include the `app_api/` service layer in the checked-out source tree -- Does not include run ledger, trace replay, or service observability source files - -### `feat/service-observability-reliability` - -- Real implementation of Task Group A, B, and G foundation work -- Adds the control tower service layer: - - `app_api/__init__.py` - - `app_api/routers.py` - - `app_api/schemas.py` - - `app_api/services.py` -- Adds run and trace persistence: - - `core/runtime_records.py` - - `core/runtime_tracking.py` -- Adds orchestration service integration: - - `orchestrator/service.py` -- Adds policy hooks and explainability support: - - `policies/constraints.py` - - `policies/explainability.py` -- Adds service-level tests for: - - foundation run ledger - - run history replay - - approval flow - - agent trace - - service observability and reliability -- Provides the stable backend contract closest to the current UI hook: - - `/api/v1/control-tower/summary` - - `/api/v1/trace/latest` - - `/api/v1/runs` - - `/api/v1/runs/{run_id}` - - `/api/v1/runs/{run_id}/trace` - - `/api/v1/runs/{run_id}/state` - - `/api/v1/execution/{execution_id}` - - `/api/v1/decision-logs/{decision_id}` - - `/api/v1/approvals/{decision_id}` - - `/api/v1/approvals/pending` - - `/api/v1/plan/daily` - - `/api/v1/scenarios/run` - - `/api/v1/what-if` - -### `feat/api-execution-dispatch` - -- Builds on top of the service-observability stack -- Includes the `feat/service-observability-reliability` history and the policy hooks history -- Adds Task Group E1 execution lifecycle and dispatch implementation: - - `execution/dispatch_service.py` - - `execution/state_machine.py` - - `execution/models.py` - - `execution/adapters/base.py` - - `execution/adapters/stubs.py` -- Extends service layer for execution-oriented APIs: - - `/api/v1/execution` - - `/api/v1/execution/{plan_id}/dispatch` - - `/api/v1/execution/{execution_id}/progress` - - `/api/v1/execution/{execution_id}/complete` -- Also modifies planner, runtime records, state, policies, and service views to surface execution lifecycle data -- Contains unwanted artifacts that must not be merged as-is: - - `node_modules/` - - `package-lock.json` - - `package.json` - - `yarn.lock` - - `.streamlit/config.toml` - - additional Streamlit/UI-only files unrelated to backend integration - -### `origin/forecast-demand` - -- Based on the service-observability stack, not on raw MVP -- Does not add a separate service framework -- Changes a focused set of files related to forecasting and demand realism: - - `app_api/schemas.py` - - `app_api/services.py` - - `core/models.py` - - `core/state.py` - - `agents/supplier.py` - - `actions/switch_supplier.py` - - `policies/constraints.py` - - `simulation/learning.py` - - `data/inventory.csv` - - `data/suppliers.csv` -- Main effects: - - richer SKU and supplier mock data - - inventory schema/data changes - - demand and replenishment-related state/service adjustments - - learning updates tied to supplier reliability -- This branch should not be merged wholesale after execution-dispatch without review because it touches overlapping service, state, and policy files - -## Recommended Base Branch - -Use `feat/api-execution-dispatch` as the integration base. - -Reasoning: - -- It already includes the A/B/G foundation from `feat/service-observability-reliability` -- It already includes the E1 execution lifecycle work the UI will need next -- It is the closest backend branch to the control tower product shape: - - event ingestion - - run ledger - - decision log access - - replay trace - - execution lifecycle -- It reduces rework because execution support is harder to layer in later than forecast data refinements - -Important caveat: - -- Do not merge `feat/api-execution-dispatch` blindly into the target branch -- Port backend-only changes and exclude vendor and UI/package artifacts - -## Merge Order - -1. Create or reset `feat/merge-and-integration` from `feat/api-execution-dispatch` - -- This makes the base branch immediately include A/B/G + E1 -- It avoids replaying several historical merges manually - -2. Remove accidental non-backend artifacts from the integrated branch - -- Delete: - - `node_modules/` - - `package-lock.json` - - `package.json` - - `yarn.lock` -- Keep `.streamlit/config.toml` only if the Streamlit fallback demo still needs it - -3. Validate the service contract against the current UI client - -- Confirm these endpoints remain stable: - - `/api/v1/control-tower/summary` - - `/api/v1/trace/latest` - - `/api/v1/runs` - - `/api/v1/runs/{run_id}` - - `/api/v1/runs/{run_id}/trace` - - `/api/v1/runs/{run_id}/state` - - `/api/v1/execution/{execution_id}` - - `/api/v1/decision-logs/{decision_id}` - - `/api/v1/approvals/pending` - - `/api/v1/approvals/{decision_id}` - - `/api/v1/plan/daily` - - `/api/v1/scenarios/run` - - `/api/v1/what-if` - -4. Port forecasting changes selectively from `origin/forecast-demand` - -- Cherry-pick or file-port only the backend forecasting and data realism work -- Start with: - - `core/state.py` - - `core/models.py` - - `simulation/learning.py` - - `agents/supplier.py` - - `actions/switch_supplier.py` - - `data/inventory.csv` - - `data/suppliers.csv` -- Merge `app_api/services.py`, `app_api/schemas.py`, and `policies/constraints.py` manually, not wholesale - -5. Reconcile API payloads after forecast data integration - -- Ensure run, state, plan, approval, and execution views still match the UI-presenter expectations -- Preserve run history and execution contracts from the execution branch - -6. Run the backend and current UI client together for end-to-end verification - -- Focus on: - - past runs - - run details - - replay trace - - before/after run state - - approval detail - - execution detail - -## Conflict Resolution Strategy - -### Source-of-truth priority - -When conflicts occur, resolve in this order: - -1. `feat/api-execution-dispatch` for: - - runtime records - - execution lifecycle - - dispatch service - - execution-related API schema -2. `feat/service-observability-reliability` for: - - run ledger contract stability - - trace and replay structure - - service observability -3. `origin/forecast-demand` for: - - inventory and supplier mock data realism - - replenishment inputs - - learning improvements tied to the richer dataset - -### File-specific rules - -- `app_api/services.py` - - keep run/event/execution flow from execution-dispatch - - manually fold in forecast-demand data shaping and learning updates -- `app_api/schemas.py` - - preserve execution and run response shapes already used by UI - - add only forecast-related fields that are additive and non-breaking -- `core/state.py` - - accept forecast-demand data-loading and KPI/reorder improvements - - preserve runtime/run-id compatibility from the service stack -- `policies/constraints.py` - - preserve policy hooks already used in the planner - - merge only additive forecast/data logic -- `data/*.csv` - - prefer forecast-demand versions for realism if schema stays compatible - -### Safety rules - -- Never reintroduce the old MVP-only `api.py` as the primary contract -- Do not duplicate service logic across `api.py` and `app_api/*` -- Keep legacy endpoints only as compatibility shims if still needed by demos - -## UI Alignment Requirements - -Current UI client on `supply-chain-management/scm-demo` expects these backend capabilities: - -- control tower summary -- latest trace -- run history -- run detail -- run trace -- run state replay -- decision detail by `decision_id` -- pending approval detail -- approval submit -- execution detail by `execution_id` -- daily plan trigger -- scenario run -- what-if preview - -Required backend adjustments after merge: - -1. Keep the service-layer endpoints above as the canonical contract -2. Ensure `decision_id` persists for historical runs so `/decision-logs/{decision_id}` works for old run rows -3. Ensure `execution_id` is stored on `RunRecord` whenever execution exists -4. Ensure `/runs/{run_id}/state` returns a historical snapshot, not only current live state -5. Ensure `/trace/latest` and `/runs/{run_id}/trace` use the same step schema so the UI can reuse one presenter -6. Keep approval responses rich enough for cards: - - selected plan summary - - selected actions - - before/after KPIs - - approval reason - - candidate count -7. Treat `/api/v1/execution`, `/dispatch`, `/progress`, and `/complete` as secondary APIs - - useful for execution console work - - not the first blocker for the current UI hook - -## Final Integrated Architecture - -The integrated backend should resolve to this flow: - -1. UI issues a command: - - daily planning - - scenario run - - approval action - -2. Control tower service layer receives the request through `app_api/routers.py` - -3. Runtime service in `app_api/services.py`: - - creates or updates an event envelope - - creates a unique run record - - executes orchestration against the digital twin state - - persists trace - - persists state snapshot - - persists decision log - - persists execution record if actions are dispatchable - -4. Deterministic policy and constraint code: - - filters infeasible plans - - scores candidates - - sets approval requirements - - keeps final action safety out of the LLM layer - -5. Execution lifecycle layer: - - creates execution records - - supports dispatch progression and status transitions - -6. Query endpoints expose: - - current summary - - latest trace - - historical runs - - run-level trace and state replay - - approval details - - decision details - - execution detail - -## Remaining Gaps After Integration - -### Must fix soon - -- Normalize the branch name mismatch in team communication: - - requested `feat/service-observability` - - actual branch is `feat/service-observability-reliability` -- Remove accidental backend/UI coupling artifacts from the execution branch -- Verify old decision logs can still be resolved after replay and reload -- Reconcile any schema drift introduced by richer inventory and supplier data - -### Partially implemented - -- Execution lifecycle exists, but UI usage is still partial -- Forecasting/data realism exists, but it is not yet cleanly merged into the execution-capable backend -- Legacy endpoints and new service endpoints coexist; they should be rationalized after integration stabilizes - -### Still missing or future work - -- Full async event bus beyond in-process persistence -- richer execution receipts and real adapters -- stronger forecast models beyond current demand/data improvements -- end-to-end integration tests spanning UI contract plus execution transitions +# Merge And Integration Strategy + +## Branch Inventory + +### `feat/chaincopilot-mvp` + +- Current baseline branch and current `feat/merge-and-integration` `HEAD` +- Old MVP backend only +- Contains legacy FastAPI app in `api.py` +- Does not include the `app_api/` service layer in the checked-out source tree +- Does not include run ledger, trace replay, or service observability source files + +### `feat/service-observability-reliability` + +- Real implementation of Task Group A, B, and G foundation work +- Adds the control tower service layer: + - `app_api/__init__.py` + - `app_api/routers.py` + - `app_api/schemas.py` + - `app_api/services.py` +- Adds run and trace persistence: + - `core/runtime_records.py` + - `core/runtime_tracking.py` +- Adds orchestration service integration: + - `orchestrator/service.py` +- Adds policy hooks and explainability support: + - `policies/constraints.py` + - `policies/explainability.py` +- Adds service-level tests for: + - foundation run ledger + - run history replay + - approval flow + - agent trace + - service observability and reliability +- Provides the stable backend contract closest to the current UI hook: + - `/api/v1/control-tower/summary` + - `/api/v1/trace/latest` + - `/api/v1/runs` + - `/api/v1/runs/{run_id}` + - `/api/v1/runs/{run_id}/trace` + - `/api/v1/runs/{run_id}/state` + - `/api/v1/execution/{execution_id}` + - `/api/v1/decision-logs/{decision_id}` + - `/api/v1/approvals/{decision_id}` + - `/api/v1/approvals/pending` + - `/api/v1/plan/daily` + - `/api/v1/scenarios/run` + - `/api/v1/what-if` + +### `feat/api-execution-dispatch` + +- Builds on top of the service-observability stack +- Includes the `feat/service-observability-reliability` history and the policy hooks history +- Adds Task Group E1 execution lifecycle and dispatch implementation: + - `execution/dispatch_service.py` + - `execution/state_machine.py` + - `execution/models.py` + - `execution/adapters/base.py` + - `execution/adapters/stubs.py` +- Extends service layer for execution-oriented APIs: + - `/api/v1/execution` + - `/api/v1/execution/{plan_id}/dispatch` + - `/api/v1/execution/{execution_id}/progress` + - `/api/v1/execution/{execution_id}/complete` +- Also modifies planner, runtime records, state, policies, and service views to surface execution lifecycle data +- Contains unwanted artifacts that must not be merged as-is: + - `node_modules/` + - `package-lock.json` + - `package.json` + - `yarn.lock` + - `.streamlit/config.toml` + - additional Streamlit/UI-only files unrelated to backend integration + +### `origin/forecast-demand` + +- Based on the service-observability stack, not on raw MVP +- Does not add a separate service framework +- Changes a focused set of files related to forecasting and demand realism: + - `app_api/schemas.py` + - `app_api/services.py` + - `core/models.py` + - `core/state.py` + - `agents/supplier.py` + - `actions/switch_supplier.py` + - `policies/constraints.py` + - `simulation/learning.py` + - `data/inventory.csv` + - `data/suppliers.csv` +- Main effects: + - richer SKU and supplier mock data + - inventory schema/data changes + - demand and replenishment-related state/service adjustments + - learning updates tied to supplier reliability +- This branch should not be merged wholesale after execution-dispatch without review because it touches overlapping service, state, and policy files + +## Recommended Base Branch + +Use `feat/api-execution-dispatch` as the integration base. + +Reasoning: + +- It already includes the A/B/G foundation from `feat/service-observability-reliability` +- It already includes the E1 execution lifecycle work the UI will need next +- It is the closest backend branch to the control tower product shape: + - event ingestion + - run ledger + - decision log access + - replay trace + - execution lifecycle +- It reduces rework because execution support is harder to layer in later than forecast data refinements + +Important caveat: + +- Do not merge `feat/api-execution-dispatch` blindly into the target branch +- Port backend-only changes and exclude vendor and UI/package artifacts + +## Merge Order + +1. Create or reset `feat/merge-and-integration` from `feat/api-execution-dispatch` + +- This makes the base branch immediately include A/B/G + E1 +- It avoids replaying several historical merges manually + +2. Remove accidental non-backend artifacts from the integrated branch + +- Delete: + - `node_modules/` + - `package-lock.json` + - `package.json` + - `yarn.lock` +- Keep `.streamlit/config.toml` only if the Streamlit fallback demo still needs it + +3. Validate the service contract against the current UI client + +- Confirm these endpoints remain stable: + - `/api/v1/control-tower/summary` + - `/api/v1/trace/latest` + - `/api/v1/runs` + - `/api/v1/runs/{run_id}` + - `/api/v1/runs/{run_id}/trace` + - `/api/v1/runs/{run_id}/state` + - `/api/v1/execution/{execution_id}` + - `/api/v1/decision-logs/{decision_id}` + - `/api/v1/approvals/pending` + - `/api/v1/approvals/{decision_id}` + - `/api/v1/plan/daily` + - `/api/v1/scenarios/run` + - `/api/v1/what-if` + +4. Port forecasting changes selectively from `origin/forecast-demand` + +- Cherry-pick or file-port only the backend forecasting and data realism work +- Start with: + - `core/state.py` + - `core/models.py` + - `simulation/learning.py` + - `agents/supplier.py` + - `actions/switch_supplier.py` + - `data/inventory.csv` + - `data/suppliers.csv` +- Merge `app_api/services.py`, `app_api/schemas.py`, and `policies/constraints.py` manually, not wholesale + +5. Reconcile API payloads after forecast data integration + +- Ensure run, state, plan, approval, and execution views still match the UI-presenter expectations +- Preserve run history and execution contracts from the execution branch + +6. Run the backend and current UI client together for end-to-end verification + +- Focus on: + - past runs + - run details + - replay trace + - before/after run state + - approval detail + - execution detail + +## Conflict Resolution Strategy + +### Source-of-truth priority + +When conflicts occur, resolve in this order: + +1. `feat/api-execution-dispatch` for: + - runtime records + - execution lifecycle + - dispatch service + - execution-related API schema +2. `feat/service-observability-reliability` for: + - run ledger contract stability + - trace and replay structure + - service observability +3. `origin/forecast-demand` for: + - inventory and supplier mock data realism + - replenishment inputs + - learning improvements tied to the richer dataset + +### File-specific rules + +- `app_api/services.py` + - keep run/event/execution flow from execution-dispatch + - manually fold in forecast-demand data shaping and learning updates +- `app_api/schemas.py` + - preserve execution and run response shapes already used by UI + - add only forecast-related fields that are additive and non-breaking +- `core/state.py` + - accept forecast-demand data-loading and KPI/reorder improvements + - preserve runtime/run-id compatibility from the service stack +- `policies/constraints.py` + - preserve policy hooks already used in the planner + - merge only additive forecast/data logic +- `data/*.csv` + - prefer forecast-demand versions for realism if schema stays compatible + +### Safety rules + +- Never reintroduce the old MVP-only `api.py` as the primary contract +- Do not duplicate service logic across `api.py` and `app_api/*` +- Keep legacy endpoints only as compatibility shims if still needed by demos + +## UI Alignment Requirements + +Current UI client on `supply-chain-management/scm-demo` expects these backend capabilities: + +- control tower summary +- latest trace +- run history +- run detail +- run trace +- run state replay +- decision detail by `decision_id` +- pending approval detail +- approval submit +- execution detail by `execution_id` +- daily plan trigger +- scenario run +- what-if preview + +Required backend adjustments after merge: + +1. Keep the service-layer endpoints above as the canonical contract +2. Ensure `decision_id` persists for historical runs so `/decision-logs/{decision_id}` works for old run rows +3. Ensure `execution_id` is stored on `RunRecord` whenever execution exists +4. Ensure `/runs/{run_id}/state` returns a historical snapshot, not only current live state +5. Ensure `/trace/latest` and `/runs/{run_id}/trace` use the same step schema so the UI can reuse one presenter +6. Keep approval responses rich enough for cards: + - selected plan summary + - selected actions + - before/after KPIs + - approval reason + - candidate count +7. Treat `/api/v1/execution`, `/dispatch`, `/progress`, and `/complete` as secondary APIs + - useful for execution console work + - not the first blocker for the current UI hook + +## Final Integrated Architecture + +The integrated backend should resolve to this flow: + +1. UI issues a command: + - daily planning + - scenario run + - approval action + +2. Control tower service layer receives the request through `app_api/routers.py` + +3. Runtime service in `app_api/services.py`: + - creates or updates an event envelope + - creates a unique run record + - executes orchestration against the digital twin state + - persists trace + - persists state snapshot + - persists decision log + - persists execution record if actions are dispatchable + +4. Deterministic policy and constraint code: + - filters infeasible plans + - scores candidates + - sets approval requirements + - keeps final action safety out of the LLM layer + +5. Execution lifecycle layer: + - creates execution records + - supports dispatch progression and status transitions + +6. Query endpoints expose: + - current summary + - latest trace + - historical runs + - run-level trace and state replay + - approval details + - decision details + - execution detail + +## Remaining Gaps After Integration + +### Must fix soon + +- Normalize the branch name mismatch in team communication: + - requested `feat/service-observability` + - actual branch is `feat/service-observability-reliability` +- Remove accidental backend/UI coupling artifacts from the execution branch +- Verify old decision logs can still be resolved after replay and reload +- Reconcile any schema drift introduced by richer inventory and supplier data + +### Partially implemented + +- Execution lifecycle exists, but UI usage is still partial +- Forecasting/data realism exists, but it is not yet cleanly merged into the execution-capable backend +- Legacy endpoints and new service endpoints coexist; they should be rationalized after integration stabilizes + +### Still missing or future work + +- Full async event bus beyond in-process persistence +- richer execution receipts and real adapters +- stronger forecast models beyond current demand/data improvements +- end-to-end integration tests spanning UI contract plus execution transitions diff --git a/docs/internal/feat-mock-data-alignment.md b/docs/internal/feat-mock-data-alignment.md index ba4f2e6..6238a80 100644 --- a/docs/internal/feat-mock-data-alignment.md +++ b/docs/internal/feat-mock-data-alignment.md @@ -1,23 +1,23 @@ -# Feature Plan: Mock Data Alignment - -- [x] Inspect enriched mock data diffs and identify broken references. -- [x] Align scenario seed events with the enriched inventory and supplier IDs. -- [x] Align supporting seed tables (`orders.csv`, `warehouses.csv`) with the enriched inventory. -- [x] Add deterministic loader validation for seed-data referential integrity. -- [x] Update brittle tests that hardcode the old 3-SKU and 8-case seed assumptions. -- [x] Run targeted backend tests against the enriched dataset. -- [x] Update this checklist after verification. - -## Files Expected To Change - -- `docs/internal/feat-mock-data-alignment.md` -- `core/state.py` -- `data/historical_cases.csv` -- `data/inventory.csv` -- `data/suppliers.csv` -- `simulation/scenarios.py` -- `data/orders.csv` -- `data/warehouses.csv` -- `tests/test_strategic_prompt.py` -- `tests/test_explainability_learning.py` -- `tests/test_seed_data_alignment.py` +# Feature Plan: Mock Data Alignment + +- [x] Inspect enriched mock data diffs and identify broken references. +- [x] Align scenario seed events with the enriched inventory and supplier IDs. +- [x] Align supporting seed tables (`orders.csv`, `warehouses.csv`) with the enriched inventory. +- [x] Add deterministic loader validation for seed-data referential integrity. +- [x] Update brittle tests that hardcode the old 3-SKU and 8-case seed assumptions. +- [x] Run targeted backend tests against the enriched dataset. +- [x] Update this checklist after verification. + +## Files Expected To Change + +- `docs/internal/feat-mock-data-alignment.md` +- `core/state.py` +- `data/historical_cases.csv` +- `data/inventory.csv` +- `data/suppliers.csv` +- `simulation/scenarios.py` +- `data/orders.csv` +- `data/warehouses.csv` +- `tests/test_strategic_prompt.py` +- `tests/test_explainability_learning.py` +- `tests/test_seed_data_alignment.py` diff --git a/docs/internal/feat-normal-approval-demo-flow.md b/docs/internal/feat-normal-approval-demo-flow.md index 0a443ce..3aad989 100644 --- a/docs/internal/feat-normal-approval-demo-flow.md +++ b/docs/internal/feat-normal-approval-demo-flow.md @@ -1,47 +1,47 @@ -# Feature Plan: Normal + Approval Demo Flow - -## Objective - -Make the judged demo path explicit in the product by adding a normal daily planning flow, visible before/after KPI comparison, pending approval controls, and minimal API wiring that reuses the existing LangGraph orchestration and deterministic planner. - -## Scope - -- Add a daily plan execution path for normal mode. -- Surface before/after baseline for the latest daily or scenario-driven plan. -- Show pending approval details in the dashboard. -- Add approve, reject, and request-safer-plan actions. -- Wire matching API endpoints for demo and test coverage. -- Update the demo script to match the new flow. - -## Files To Modify - -- [x] `api.py` -- [x] `orchestrator/service.py` -- [x] `ui/dashboard.py` -- [x] `ui/overview.py` -- [x] `ui/scenarios.py` -- [x] `ui/decision_log.py` -- [x] `ui/components.py` -- [x] `docs/demo_script.md` -- [x] `tests/test_demo_flow_api.py` - -## Implementation Steps - -- [x] Add a shared service layer for daily-plan execution and approval actions. -- [x] Implement a deterministic daily-plan path that runs through the LangGraph graph without introducing a new planner path. -- [x] Add API endpoint for daily plan execution. -- [x] Add API endpoint for safer-plan request using stricter guardrails on the current pending plan. -- [x] Update approval endpoint to reuse the shared service layer. -- [x] Add overview UI action to run daily plan. -- [x] Render baseline vs projected KPI comparison for the latest decision. -- [x] Render pending approval panel with approve, reject, and safer-plan controls. -- [x] Surface approval state more clearly in scenario and decision-log views. -- [x] Update the demo script to match the reviewed flow. - -## Testing Approach - -- [x] Add API tests for daily-plan execution. -- [x] Add API tests for approve and reject flows. -- [x] Add API tests for safer-plan flow on an approval-required scenario. -- [x] Run `ruff check api.py orchestrator ui tests`. -- [x] Run `pytest -q`. +# Feature Plan: Normal + Approval Demo Flow + +## Objective + +Make the judged demo path explicit in the product by adding a normal daily planning flow, visible before/after KPI comparison, pending approval controls, and minimal API wiring that reuses the existing LangGraph orchestration and deterministic planner. + +## Scope + +- Add a daily plan execution path for normal mode. +- Surface before/after baseline for the latest daily or scenario-driven plan. +- Show pending approval details in the dashboard. +- Add approve, reject, and request-safer-plan actions. +- Wire matching API endpoints for demo and test coverage. +- Update the demo script to match the new flow. + +## Files To Modify + +- [x] `api.py` +- [x] `orchestrator/service.py` +- [x] `ui/dashboard.py` +- [x] `ui/overview.py` +- [x] `ui/scenarios.py` +- [x] `ui/decision_log.py` +- [x] `ui/components.py` +- [x] `docs/demo_script.md` +- [x] `tests/test_demo_flow_api.py` + +## Implementation Steps + +- [x] Add a shared service layer for daily-plan execution and approval actions. +- [x] Implement a deterministic daily-plan path that runs through the LangGraph graph without introducing a new planner path. +- [x] Add API endpoint for daily plan execution. +- [x] Add API endpoint for safer-plan request using stricter guardrails on the current pending plan. +- [x] Update approval endpoint to reuse the shared service layer. +- [x] Add overview UI action to run daily plan. +- [x] Render baseline vs projected KPI comparison for the latest decision. +- [x] Render pending approval panel with approve, reject, and safer-plan controls. +- [x] Surface approval state more clearly in scenario and decision-log views. +- [x] Update the demo script to match the reviewed flow. + +## Testing Approach + +- [x] Add API tests for daily-plan execution. +- [x] Add API tests for approve and reject flows. +- [x] Add API tests for safer-plan flow on an approval-required scenario. +- [x] Run `ruff check api.py orchestrator ui tests`. +- [x] Run `pytest -q`. diff --git a/docs/internal/feat-policy-constraint-hooks.md b/docs/internal/feat-policy-constraint-hooks.md index 762b173..3a26553 100644 --- a/docs/internal/feat-policy-constraint-hooks.md +++ b/docs/internal/feat-policy-constraint-hooks.md @@ -1,39 +1,39 @@ -# Policy Constraint Hooks - -## Objective - -Add a thin deterministic policy hook layer so candidate plans expose feasibility, violations, and mode rationale without rewriting the planner or optimizer. - -## Scope - -- [x] Add constraint violation and mode rationale models to planner outputs -- [x] Evaluate candidate plan feasibility before scoring and final selection -- [x] Expose feasibility and violation details through API responses -- [x] Preserve a safe deterministic fallback when all generated candidates are infeasible -- [x] Run focused regression coverage for planner, API, replay, and execution flows - -## Files To Modify - -- [x] `docs/internal/feat-policy-constraint-hooks.md` -- [x] `agents/planner.py` -- [x] `app_api/schemas.py` -- [x] `app_api/services.py` -- [x] `core/models.py` -- [x] `policies/constraints.py` -- [x] `tests/test_policy_constraint_hooks.py` - -## Implementation Steps - -- [x] Introduce `ConstraintViolation` and thread feasibility fields through plan and decision models -- [x] Add deterministic constraint evaluation helpers for blocked routes, delayed suppliers, invalid reorder, and rebalance edge cases -- [x] Add mode rationale output alongside candidate evaluation details -- [x] Filter planner selection toward feasible candidates first -- [x] Synthesize a safe hold-position plan when every generated candidate fails hard constraints -- [x] Verify lint and targeted regression tests - -## Testing Approach - -- [x] Constraint violations are surfaced for blocked route and capacity overflow cases -- [x] Planner excludes infeasible candidates and falls back to a feasible safe-hold plan -- [x] API responses include feasibility, violations, and mode rationale -- [x] Existing execution, run history, and demo API flows remain green +# Policy Constraint Hooks + +## Objective + +Add a thin deterministic policy hook layer so candidate plans expose feasibility, violations, and mode rationale without rewriting the planner or optimizer. + +## Scope + +- [x] Add constraint violation and mode rationale models to planner outputs +- [x] Evaluate candidate plan feasibility before scoring and final selection +- [x] Expose feasibility and violation details through API responses +- [x] Preserve a safe deterministic fallback when all generated candidates are infeasible +- [x] Run focused regression coverage for planner, API, replay, and execution flows + +## Files To Modify + +- [x] `docs/internal/feat-policy-constraint-hooks.md` +- [x] `agents/planner.py` +- [x] `app_api/schemas.py` +- [x] `app_api/services.py` +- [x] `core/models.py` +- [x] `policies/constraints.py` +- [x] `tests/test_policy_constraint_hooks.py` + +## Implementation Steps + +- [x] Introduce `ConstraintViolation` and thread feasibility fields through plan and decision models +- [x] Add deterministic constraint evaluation helpers for blocked routes, delayed suppliers, invalid reorder, and rebalance edge cases +- [x] Add mode rationale output alongside candidate evaluation details +- [x] Filter planner selection toward feasible candidates first +- [x] Synthesize a safe hold-position plan when every generated candidate fails hard constraints +- [x] Verify lint and targeted regression tests + +## Testing Approach + +- [x] Constraint violations are surfaced for blocked route and capacity overflow cases +- [x] Planner excludes infeasible candidates and falls back to a feasible safe-hold plan +- [x] API responses include feasibility, violations, and mode rationale +- [x] Existing execution, run history, and demo API flows remain green diff --git a/docs/internal/feat-run-history-replay.md b/docs/internal/feat-run-history-replay.md index 705008f..55dddfd 100644 --- a/docs/internal/feat-run-history-replay.md +++ b/docs/internal/feat-run-history-replay.md @@ -1,36 +1,36 @@ -# Run History Replay - -## Objective - -Close the remaining read-only Task A gap by exposing historical run listing and state snapshot lookup for replay and audit analysis. - -## Scope - -- [x] Add `GET /api/v1/runs` -- [x] Add `GET /api/v1/runs/{run_id}/state` -- [x] Add store helpers for run listing and state snapshot retrieval -- [x] Add typed API response models for run history and historical state -- [x] Add tests for run ordering and state replay lookup - -## Files To Modify - -- [x] `docs/internal/feat-run-history-replay.md` -- [x] `core/memory.py` -- [x] `app_api/schemas.py` -- [x] `app_api/services.py` -- [x] `app_api/routers.py` -- [x] `tests/test_run_history_replay.py` - -## Implementation Steps - -- [x] Persisted state snapshots stay unchanged; expose read APIs on top -- [x] List run records sorted by `started_at` descending -- [x] Load state snapshot by run id and render it through existing control-tower state views -- [x] Return 404 for missing run/state artifacts -- [x] Keep endpoints read-only and replay-oriented - -## Testing Approach - -- [x] Create at least two runs and confirm `GET /runs` ordering -- [x] Fetch historical state by run id and verify it differs across runs -- [x] Verify missing run ids return 404 +# Run History Replay + +## Objective + +Close the remaining read-only Task A gap by exposing historical run listing and state snapshot lookup for replay and audit analysis. + +## Scope + +- [x] Add `GET /api/v1/runs` +- [x] Add `GET /api/v1/runs/{run_id}/state` +- [x] Add store helpers for run listing and state snapshot retrieval +- [x] Add typed API response models for run history and historical state +- [x] Add tests for run ordering and state replay lookup + +## Files To Modify + +- [x] `docs/internal/feat-run-history-replay.md` +- [x] `core/memory.py` +- [x] `app_api/schemas.py` +- [x] `app_api/services.py` +- [x] `app_api/routers.py` +- [x] `tests/test_run_history_replay.py` + +## Implementation Steps + +- [x] Persisted state snapshots stay unchanged; expose read APIs on top +- [x] List run records sorted by `started_at` descending +- [x] Load state snapshot by run id and render it through existing control-tower state views +- [x] Return 404 for missing run/state artifacts +- [x] Keep endpoints read-only and replay-oriented + +## Testing Approach + +- [x] Create at least two runs and confirm `GET /runs` ordering +- [x] Fetch historical state by run id and verify it differs across runs +- [x] Verify missing run ids return 404 diff --git a/docs/internal/feat-service-observability-reliability.md b/docs/internal/feat-service-observability-reliability.md index b947e1c..f17e2a0 100644 --- a/docs/internal/feat-service-observability-reliability.md +++ b/docs/internal/feat-service-observability-reliability.md @@ -1,44 +1,44 @@ -# Service Observability And Reliability - -## Objective - -Finish the minimum production-service slice of Task G by exposing stable service flags and metrics, standardizing error envelopes, and hardening LLM reliability with bounded retries and deterministic degradation. - -## Scope - -- [x] Inspect existing service contracts, runtime records, and LLM fallback behavior -- [x] Add typed service runtime contracts for feature flags and observability metrics -- [x] Standardize API error envelopes for validation, application, and system failures -- [x] Add bounded LLM retry behavior while preserving deterministic fallback paths -- [x] Expose runtime flags and service metrics through API -- [x] Add focused tests for flags, metrics, retries, and standardized errors - -## Files To Modify - -- [x] `docs/internal/feat-service-observability-reliability.md` -- [x] `api.py` -- [x] `app_api/routers.py` -- [x] `app_api/schemas.py` -- [x] `app_api/services.py` -- [x] `core/memory.py` -- [x] `core/runtime_tracking.py` -- [x] `llm/config.py` -- [x] `llm/service.py` -- [x] `tests/test_service_observability_reliability.py` - -## Implementation Steps - -- [x] Add service settings and API view models for planner mode, dispatch mode, and LLM flags -- [x] Add store helpers and service aggregation for run metrics, agent latency, fallback rate, approval rate, and execution failure rate -- [x] Register FastAPI exception handlers that always return the typed error envelope -- [x] Add limited LLM retry attempts before returning deterministic fallback behavior -- [x] Add a service runtime endpoint exposing flags and metrics -- [x] Verify existing API and run-ledger flows stay green - -## Testing Approach - -- [x] Service runtime endpoint returns stable flags and computed metrics -- [x] Missing resources and validation errors return the standardized error envelope -- [x] Planner deterministic mode skips planner LLM calls safely -- [x] LLM retry attempts are bounded and still degrade predictably on failure -- [x] Existing backend alignment, run history, execution lifecycle, and policy tests remain green +# Service Observability And Reliability + +## Objective + +Finish the minimum production-service slice of Task G by exposing stable service flags and metrics, standardizing error envelopes, and hardening LLM reliability with bounded retries and deterministic degradation. + +## Scope + +- [x] Inspect existing service contracts, runtime records, and LLM fallback behavior +- [x] Add typed service runtime contracts for feature flags and observability metrics +- [x] Standardize API error envelopes for validation, application, and system failures +- [x] Add bounded LLM retry behavior while preserving deterministic fallback paths +- [x] Expose runtime flags and service metrics through API +- [x] Add focused tests for flags, metrics, retries, and standardized errors + +## Files To Modify + +- [x] `docs/internal/feat-service-observability-reliability.md` +- [x] `api.py` +- [x] `app_api/routers.py` +- [x] `app_api/schemas.py` +- [x] `app_api/services.py` +- [x] `core/memory.py` +- [x] `core/runtime_tracking.py` +- [x] `llm/config.py` +- [x] `llm/service.py` +- [x] `tests/test_service_observability_reliability.py` + +## Implementation Steps + +- [x] Add service settings and API view models for planner mode, dispatch mode, and LLM flags +- [x] Add store helpers and service aggregation for run metrics, agent latency, fallback rate, approval rate, and execution failure rate +- [x] Register FastAPI exception handlers that always return the typed error envelope +- [x] Add limited LLM retry attempts before returning deterministic fallback behavior +- [x] Add a service runtime endpoint exposing flags and metrics +- [x] Verify existing API and run-ledger flows stay green + +## Testing Approach + +- [x] Service runtime endpoint returns stable flags and computed metrics +- [x] Missing resources and validation errors return the standardized error envelope +- [x] Planner deterministic mode skips planner LLM calls safely +- [x] LLM retry attempts are bounded and still degrade predictably on failure +- [x] Existing backend alignment, run history, execution lifecycle, and policy tests remain green diff --git a/docs/internal/fix-windows-uvicorn-reload.md b/docs/internal/fix-windows-uvicorn-reload.md index f2e1684..2051caa 100644 --- a/docs/internal/fix-windows-uvicorn-reload.md +++ b/docs/internal/fix-windows-uvicorn-reload.md @@ -1,31 +1,31 @@ -# Fix Windows Uvicorn Reload - -## Objective - -Prevent local FastAPI development startup from failing on Windows when Uvicorn reload scans the repository root and hits the `.venv/lib64` symlink. - -## Scope - -- [x] Add a dedicated backend dev runner with scoped reload directories -- [x] Keep application behavior unchanged -- [x] Update local startup instructions to use the safer command -- [x] Verify the new command starts without scanning `.venv` - -## Files To Modify - -- [x] `run_api.py` -- [x] `README.md` -- [x] `docs/internal/fix-windows-uvicorn-reload.md` - -## Implementation Steps - -- [x] Define the backend directories that should trigger reload -- [x] Add a small Python runner that calls `uvicorn.run(...)` -- [x] Point README API instructions to the new runner -- [x] Smoke test local startup - -## Testing Approach - -- [x] Start the API with the new command -- [x] Confirm no `.venv/lib64` reload crash occurs -- [x] Confirm the API process boots successfully +# Fix Windows Uvicorn Reload + +## Objective + +Prevent local FastAPI development startup from failing on Windows when Uvicorn reload scans the repository root and hits the `.venv/lib64` symlink. + +## Scope + +- [x] Add a dedicated backend dev runner with scoped reload directories +- [x] Keep application behavior unchanged +- [x] Update local startup instructions to use the safer command +- [x] Verify the new command starts without scanning `.venv` + +## Files To Modify + +- [x] `run_api.py` +- [x] `README.md` +- [x] `docs/internal/fix-windows-uvicorn-reload.md` + +## Implementation Steps + +- [x] Define the backend directories that should trigger reload +- [x] Add a small Python runner that calls `uvicorn.run(...)` +- [x] Point README API instructions to the new runner +- [x] Smoke test local startup + +## Testing Approach + +- [x] Start the API with the new command +- [x] Confirm no `.venv/lib64` reload crash occurs +- [x] Confirm the API process boots successfully diff --git a/docs/internal/foundation-plan.md b/docs/internal/foundation-plan.md index b42131b..ae0faff 100644 --- a/docs/internal/foundation-plan.md +++ b/docs/internal/foundation-plan.md @@ -1,54 +1,54 @@ -# Foundation Plan - -## Dependency Order - -1. Lock shared contracts: - - RunRecord - - EventEnvelope - - TraceStep extensions - - ExecutionRecord - - minimum error contract -2. Add persistence for: - - run ledger - - event log - - traces - - execution records -3. Refactor runtime lifecycle: - - unique run id per orchestration - - run metadata persisted after each orchestration/approval cycle -4. Expose team-unblocking APIs: - - `POST /api/v1/events/ingest` - - `GET /api/v1/runs/{run_id}` - - `GET /api/v1/runs/{run_id}/trace` - - `GET /api/v1/execution/{execution_id}` - -## Implementing Now - -- Stable typed runtime records and schemas -- SQLite-backed persistence for the new records -- Unique `run_id` generation for daily plan, event response, scenario steps, and approval resolution -- Historical trace lookup by run -- Simulation-backed execution records -- Minimum observability: - - run duration - - step duration - - fallback flags - -## Deferred - -- Full `GET /runs` listing and richer history filters -- Replay engine beyond historical lookup -- Constraint engine and feasibility outputs -- Memory retrieval injection -- Real external execution adapters -- Async event queue / broker -- Advanced feature-flag and retry subsystems - -## Why - -This slice is the minimum backbone that: - -- gives frontend and other contributors stable contracts -- separates live runtime state from persisted historical artifacts -- preserves the current demo flow -- avoids premature infrastructure and integration work +# Foundation Plan + +## Dependency Order + +1. Lock shared contracts: + - RunRecord + - EventEnvelope + - TraceStep extensions + - ExecutionRecord + - minimum error contract +2. Add persistence for: + - run ledger + - event log + - traces + - execution records +3. Refactor runtime lifecycle: + - unique run id per orchestration + - run metadata persisted after each orchestration/approval cycle +4. Expose team-unblocking APIs: + - `POST /api/v1/events/ingest` + - `GET /api/v1/runs/{run_id}` + - `GET /api/v1/runs/{run_id}/trace` + - `GET /api/v1/execution/{execution_id}` + +## Implementing Now + +- Stable typed runtime records and schemas +- SQLite-backed persistence for the new records +- Unique `run_id` generation for daily plan, event response, scenario steps, and approval resolution +- Historical trace lookup by run +- Simulation-backed execution records +- Minimum observability: + - run duration + - step duration + - fallback flags + +## Deferred + +- Full `GET /runs` listing and richer history filters +- Replay engine beyond historical lookup +- Constraint engine and feasibility outputs +- Memory retrieval injection +- Real external execution adapters +- Async event queue / broker +- Advanced feature-flag and retry subsystems + +## Why + +This slice is the minimum backbone that: + +- gives frontend and other contributors stable contracts +- separates live runtime state from persisted historical artifacts +- preserves the current demo flow +- avoids premature infrastructure and integration work diff --git a/docs/internal/team-unblock.md b/docs/internal/team-unblock.md index b28277d..0226cb8 100644 --- a/docs/internal/team-unblock.md +++ b/docs/internal/team-unblock.md @@ -1,29 +1,29 @@ -# Team Unblock - -## Frontend Can Mock Against Now - -- `RunRecord` -- `EventEnvelope` -- `TraceStep` with timing and fallback fields -- `ExecutionRecord` -- `ErrorResponse` -- API contracts: - - `POST /api/v1/events/ingest` - - `GET /api/v1/runs/{run_id}` - - `GET /api/v1/runs/{run_id}/trace` - - `GET /api/v1/execution/{execution_id}` - -## Stable Backend Modules - -- `core/runtime_records.py` -- `core/memory.py` new runtime persistence methods -- `app_api/schemas.py` runtime API contracts -- `app_api/routers.py` new foundation endpoints - -## Still Provisional - -- replay APIs beyond trace lookup -- event listing/query APIs -- constraint and feasibility outputs -- memory retrieval influence payloads -- real execution dispatch adapters +# Team Unblock + +## Frontend Can Mock Against Now + +- `RunRecord` +- `EventEnvelope` +- `TraceStep` with timing and fallback fields +- `ExecutionRecord` +- `ErrorResponse` +- API contracts: + - `POST /api/v1/events/ingest` + - `GET /api/v1/runs/{run_id}` + - `GET /api/v1/runs/{run_id}/trace` + - `GET /api/v1/execution/{execution_id}` + +## Stable Backend Modules + +- `core/runtime_records.py` +- `core/memory.py` new runtime persistence methods +- `app_api/schemas.py` runtime API contracts +- `app_api/routers.py` new foundation endpoints + +## Still Provisional + +- replay APIs beyond trace lookup +- event listing/query APIs +- constraint and feasibility outputs +- memory retrieval influence payloads +- real execution dispatch adapters diff --git a/execution/__init__.py b/execution/__init__.py index 162c826..61ab38f 100644 --- a/execution/__init__.py +++ b/execution/__init__.py @@ -1 +1 @@ -# execution package — Task E1: Action Dispatch & Execution Engine +# execution package — Task E1: Action Dispatch & Execution Engine diff --git a/execution/adapters/__init__.py b/execution/adapters/__init__.py index 8571602..11ceb3b 100644 --- a/execution/adapters/__init__.py +++ b/execution/adapters/__init__.py @@ -1 +1 @@ -# adapters package +# adapters package diff --git a/execution/adapters/base.py b/execution/adapters/base.py index 6c78474..670e9e5 100644 --- a/execution/adapters/base.py +++ b/execution/adapters/base.py @@ -1,38 +1,38 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod - -from execution.models import DispatchResult, ExecutionRecord - - -class BaseAdapter(ABC): - """ - Abstract contract every system adapter must implement. - Concrete implementations: ERPAdapter, WMSAdapter, TMSAdapter. - """ - - @abstractmethod - def dispatch(self, record: ExecutionRecord, dry_run: bool) -> DispatchResult: - """ - Send the action payload to the target system. - - dry_run=True → validate-only, no side effects. - - dry_run=False → commit the transaction. - Must be idempotent: check get_receipt() first. - """ - ... - - @abstractmethod - def rollback(self, record: ExecutionRecord) -> DispatchResult: - """ - Attempt to compensate / undo a previously dispatched action. - In real supply-chain contexts this is often advisory only. - """ - ... - - @abstractmethod - def get_receipt(self, idempotency_key: str) -> DispatchResult | None: - """ - Return the cached receipt if this key was already processed. - Returns None if the key is unseen. - """ - ... +from __future__ import annotations + +from abc import ABC, abstractmethod + +from execution.models import DispatchResult, ExecutionRecord + + +class BaseAdapter(ABC): + """ + Abstract contract every system adapter must implement. + Concrete implementations: ERPAdapter, WMSAdapter, TMSAdapter. + """ + + @abstractmethod + def dispatch(self, record: ExecutionRecord, dry_run: bool) -> DispatchResult: + """ + Send the action payload to the target system. + - dry_run=True → validate-only, no side effects. + - dry_run=False → commit the transaction. + Must be idempotent: check get_receipt() first. + """ + ... + + @abstractmethod + def rollback(self, record: ExecutionRecord) -> DispatchResult: + """ + Attempt to compensate / undo a previously dispatched action. + In real supply-chain contexts this is often advisory only. + """ + ... + + @abstractmethod + def get_receipt(self, idempotency_key: str) -> DispatchResult | None: + """ + Return the cached receipt if this key was already processed. + Returns None if the key is unseen. + """ + ... diff --git a/execution/adapters/stubs.py b/execution/adapters/stubs.py index 17a30cd..729b060 100644 --- a/execution/adapters/stubs.py +++ b/execution/adapters/stubs.py @@ -1,119 +1,119 @@ -from __future__ import annotations - -import random -from datetime import datetime, timezone -from uuid import uuid4 - -from execution.adapters.base import BaseAdapter -from execution.models import DispatchResult, ExecutionRecord - - -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -class _StubAdapter(BaseAdapter): - """ - Simulated adapter shared by ERP / WMS / TMS stubs. - Behaviour: - dry_run → always success, no side-effects. - commit → 85% success | 8% permanent failure | 7% transient failure - Receipt cache ensures idempotency per key. - """ - - def __init__(self, system_name: str, failure_rate: float = 0.15, transient_ratio: float = 0.07): - self._system = system_name - self._failure_rate = failure_rate # total failure probability - self._transient_ratio = transient_ratio # fraction that is retryable - self._receipt_cache: dict[str, DispatchResult] = {} - - # ------------------------------------------------------------------ - def get_receipt(self, idempotency_key: str) -> DispatchResult | None: - return self._receipt_cache.get(idempotency_key) - - # ------------------------------------------------------------------ - def dispatch(self, record: ExecutionRecord, dry_run: bool) -> DispatchResult: - # --- Idempotency check first --- - cached = self.get_receipt(record.idempotency_key) - if cached is not None: - return cached - - if dry_run: - result = DispatchResult( - success=True, - receipt={ - "ref_id": f"{self._system}-DRY-{uuid4().hex[:6].upper()}", - "status": "DRY_RUN_OK", - "system": self._system, - "timestamp": _now_iso(), - }, - ) - # dry-run results are NOT cached (not idempotent by design) - return result - - # --- Commit mode: simulate failure distribution --- - roll = random.random() - if roll < self._transient_ratio: - result = DispatchResult( - success=False, - error_code="NETWORK_TIMEOUT", - error_message=f"{self._system}: connection timed out. Safe to retry.", - is_retryable=True, - ) - elif roll < self._failure_rate: - result = DispatchResult( - success=False, - error_code="BUSINESS_RULE_VIOLATED", - error_message=f"{self._system}: action rejected by downstream policy. Do not retry.", - is_retryable=False, - ) - else: - result = DispatchResult( - success=True, - receipt={ - "ref_id": f"{self._system}-{uuid4().hex[:8].upper()}", - "status": "COMMITTED", - "system": self._system, - "action_id": record.action_id, - "timestamp": _now_iso(), - }, - ) - - # Cache successful (and permanent-failed) results to enforce idempotency - if result.success or not result.is_retryable: - self._receipt_cache[record.idempotency_key] = result - - return result - - # ------------------------------------------------------------------ - def rollback(self, record: ExecutionRecord) -> DispatchResult: - return DispatchResult( - success=True, - receipt={ - "ref_id": f"{self._system}-ROLLBACK-{uuid4().hex[:6].upper()}", - "status": "COMPENSATION_LOGGED", - "system": self._system, - "original_action_id": record.action_id, - "note": "Manual intervention may still be required.", - "timestamp": _now_iso(), - }, - ) - - -# --------------------------------------------------------------------------- -# Public stub instances — one per target system -# --------------------------------------------------------------------------- - -class ERPAdapter(_StubAdapter): - def __init__(self) -> None: - super().__init__("ERP") - - -class WMSAdapter(_StubAdapter): - def __init__(self) -> None: - super().__init__("WMS") - - -class TMSAdapter(_StubAdapter): - def __init__(self) -> None: - super().__init__("TMS") +from __future__ import annotations + +import random +from datetime import datetime, timezone +from uuid import uuid4 + +from execution.adapters.base import BaseAdapter +from execution.models import DispatchResult, ExecutionRecord + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class _StubAdapter(BaseAdapter): + """ + Simulated adapter shared by ERP / WMS / TMS stubs. + Behaviour: + dry_run → always success, no side-effects. + commit → 85% success | 8% permanent failure | 7% transient failure + Receipt cache ensures idempotency per key. + """ + + def __init__(self, system_name: str, failure_rate: float = 0.15, transient_ratio: float = 0.07): + self._system = system_name + self._failure_rate = failure_rate # total failure probability + self._transient_ratio = transient_ratio # fraction that is retryable + self._receipt_cache: dict[str, DispatchResult] = {} + + # ------------------------------------------------------------------ + def get_receipt(self, idempotency_key: str) -> DispatchResult | None: + return self._receipt_cache.get(idempotency_key) + + # ------------------------------------------------------------------ + def dispatch(self, record: ExecutionRecord, dry_run: bool) -> DispatchResult: + # --- Idempotency check first --- + cached = self.get_receipt(record.idempotency_key) + if cached is not None: + return cached + + if dry_run: + result = DispatchResult( + success=True, + receipt={ + "ref_id": f"{self._system}-DRY-{uuid4().hex[:6].upper()}", + "status": "DRY_RUN_OK", + "system": self._system, + "timestamp": _now_iso(), + }, + ) + # dry-run results are NOT cached (not idempotent by design) + return result + + # --- Commit mode: simulate failure distribution --- + roll = random.random() + if roll < self._transient_ratio: + result = DispatchResult( + success=False, + error_code="NETWORK_TIMEOUT", + error_message=f"{self._system}: connection timed out. Safe to retry.", + is_retryable=True, + ) + elif roll < self._failure_rate: + result = DispatchResult( + success=False, + error_code="BUSINESS_RULE_VIOLATED", + error_message=f"{self._system}: action rejected by downstream policy. Do not retry.", + is_retryable=False, + ) + else: + result = DispatchResult( + success=True, + receipt={ + "ref_id": f"{self._system}-{uuid4().hex[:8].upper()}", + "status": "COMMITTED", + "system": self._system, + "action_id": record.action_id, + "timestamp": _now_iso(), + }, + ) + + # Cache successful (and permanent-failed) results to enforce idempotency + if result.success or not result.is_retryable: + self._receipt_cache[record.idempotency_key] = result + + return result + + # ------------------------------------------------------------------ + def rollback(self, record: ExecutionRecord) -> DispatchResult: + return DispatchResult( + success=True, + receipt={ + "ref_id": f"{self._system}-ROLLBACK-{uuid4().hex[:6].upper()}", + "status": "COMPENSATION_LOGGED", + "system": self._system, + "original_action_id": record.action_id, + "note": "Manual intervention may still be required.", + "timestamp": _now_iso(), + }, + ) + + +# --------------------------------------------------------------------------- +# Public stub instances — one per target system +# --------------------------------------------------------------------------- + +class ERPAdapter(_StubAdapter): + def __init__(self) -> None: + super().__init__("ERP") + + +class WMSAdapter(_StubAdapter): + def __init__(self) -> None: + super().__init__("WMS") + + +class TMSAdapter(_StubAdapter): + def __init__(self) -> None: + super().__init__("TMS") diff --git a/execution/dispatch_service.py b/execution/dispatch_service.py index 80cce67..0e1a510 100644 --- a/execution/dispatch_service.py +++ b/execution/dispatch_service.py @@ -1,140 +1,139 @@ -from __future__ import annotations - -import time -import threading -from typing import Literal -from uuid import uuid4 - -from core.enums import ActionType, ExecutionStatus -from core.models import Action, Plan -from execution.adapters.base import BaseAdapter -from execution.adapters.stubs import ERPAdapter, TMSAdapter, WMSAdapter -from execution.models import ( - DispatchResult, - ExecutionRecord, - PlanDispatchSummary, - make_idempotency_key, - utc_now, -) -from execution.state_machine import ( - InvalidTransitionError, - compute_overall_progress, - compute_plan_execution_status, - transition, -) - - -# --------------------------------------------------------------------------- -# Routing table: ActionType → target system + adapter class -# --------------------------------------------------------------------------- - -_ROUTING: dict[ActionType, tuple[str, type[BaseAdapter]]] = { - ActionType.REORDER: ("ERP", ERPAdapter), - ActionType.SWITCH_SUPPLIER: ("ERP", ERPAdapter), - ActionType.REBALANCE: ("WMS", WMSAdapter), - ActionType.REROUTE: ("TMS", TMSAdapter), -} - -_MAX_RETRIES = 3 -_BACKOFF_SECONDS = [1, 2, 4] # Exponential backoff steps - - -def _build_payload(action: Action) -> dict: - return { - "action_type": action.action_type.value, - "target_id": action.target_id, - "parameters": action.parameters, - "estimated_cost_delta": action.estimated_cost_delta, - "estimated_recovery_hours": action.estimated_recovery_hours, - } - - -def _compensation_hint(record: ExecutionRecord) -> str: - """Provides human-readable suggestions when a physical action fails.""" - hints = { - "reorder": f"Manually place reorder for {record.payload.get('target_id')} in ERP.", - "switch_supplier": f"Contact procurement to manually switch supplier for {record.payload.get('target_id')}.", - "reroute": f"Maintain current route; escalate TMS alert for {record.payload.get('target_id')}.", - "rebalance": "Trigger manual warehouse rebalance via WMS portal.", - } - return hints.get(record.action_type, "Manual intervention required.") - - -# --------------------------------------------------------------------------- -# ActionDispatchService -# --------------------------------------------------------------------------- - -class ActionDispatchService: - """ - Central coordinator responsible for: - 1. Building ExecutionRecords for each action in a Plan. - 2. Routing records to the appropriate adapter (ERP / WMS / TMS). - 3. Mandatory Idempotency, Retry logic, and State Machine transitions. - 4. Aggregating action-level results into a PlanDispatchSummary. - """ - - def __init__(self) -> None: - # Adapter pool — one instance per system (persistent receipt cache) - self._adapters: dict[str, BaseAdapter] = { - "ERP": ERPAdapter(), - "WMS": WMSAdapter(), - "TMS": TMSAdapter(), - } - # State Locking Mechanism to prevent race conditions during concurrent dispatch - self._lock = threading.Lock() - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - +from __future__ import annotations + +import time +import threading +from typing import Literal +from uuid import uuid4 + +from core.enums import ActionType, ExecutionStatus +from core.models import Action, Plan +from execution.adapters.base import BaseAdapter +from execution.adapters.stubs import ERPAdapter, TMSAdapter, WMSAdapter +from execution.models import ( + DispatchResult, + ExecutionRecord, + PlanDispatchSummary, + make_idempotency_key, + utc_now, +) +from execution.state_machine import ( + InvalidTransitionError, + compute_overall_progress, + compute_plan_execution_status, + transition, +) + + +# --------------------------------------------------------------------------- +# Routing table: ActionType → target system + adapter class +# --------------------------------------------------------------------------- + +_ROUTING: dict[ActionType, tuple[str, type[BaseAdapter]]] = { + ActionType.REORDER: ("ERP", ERPAdapter), + ActionType.SWITCH_SUPPLIER: ("ERP", ERPAdapter), + ActionType.REBALANCE: ("WMS", WMSAdapter), + ActionType.REROUTE: ("TMS", TMSAdapter), +} + +_MAX_RETRIES = 3 +_BACKOFF_SECONDS = [1, 2, 4] # Exponential backoff steps + + +def _build_payload(action: Action) -> dict: + return { + "action_type": action.action_type.value, + "target_id": action.target_id, + "parameters": action.parameters, + "estimated_cost_delta": action.estimated_cost_delta, + "estimated_recovery_hours": action.estimated_recovery_hours, + } + + +def _compensation_hint(record: ExecutionRecord) -> str: + """Provides human-readable suggestions when a physical action fails.""" + hints = { + "reorder": f"Manually place reorder for {record.payload.get('target_id')} in ERP.", + "switch_supplier": f"Contact procurement to manually switch supplier for {record.payload.get('target_id')}.", + "reroute": f"Maintain current route; escalate TMS alert for {record.payload.get('target_id')}.", + "rebalance": "Trigger manual warehouse rebalance via WMS portal.", + } + return hints.get(record.action_type, "Manual intervention required.") + + +# --------------------------------------------------------------------------- +# ActionDispatchService +# --------------------------------------------------------------------------- + +class ActionDispatchService: + """ + Central coordinator responsible for: + 1. Building ExecutionRecords for each action in a Plan. + 2. Routing records to the appropriate adapter (ERP / WMS / TMS). + 3. Mandatory Idempotency, Retry logic, and State Machine transitions. + 4. Aggregating action-level results into a PlanDispatchSummary. + """ + + def __init__(self) -> None: + # Adapter pool — one instance per system (persistent receipt cache) + self._adapters: dict[str, BaseAdapter] = { + "ERP": ERPAdapter(), + "WMS": WMSAdapter(), + "TMS": TMSAdapter(), + } + # State Locking Mechanism to prevent race conditions during concurrent dispatch + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def dispatch_plan( self, plan: Plan, mode: Literal["dry_run", "commit"] = "dry_run", - ) -> PlanDispatchSummary: - """ - Executes all actions within a plan. - Returns a summary containing per-action ExecutionRecords and aggregate progress. - """ - approval_ts = utc_now().isoformat() + ) -> PlanDispatchSummary: + """ + Executes all actions within a plan. + Returns a summary containing per-action ExecutionRecords and aggregate progress. + """ records: list[ExecutionRecord] = [] compensation_hints: list[str] = [] - - for action in plan.actions: - if action.action_type == ActionType.NO_OP: - continue - - record = self._build_record(plan.plan_id, action, approval_ts) + + for action in plan.actions: + if action.action_type == ActionType.NO_OP: + continue + + record = self._build_record(plan.plan_id, action, mode) record = self._run_action(record, mode=mode) records.append(record) - - # If the action failed or was rolled back, append a manual recovery hint - if record.status in {ExecutionStatus.FAILED, ExecutionStatus.ROLLED_BACK}: - compensation_hints.append(_compensation_hint(record)) - - plan_status = compute_plan_execution_status(records) - overall_progress = compute_overall_progress(records) - - return PlanDispatchSummary( - plan_id=plan.plan_id, - dispatch_mode=mode, - plan_execution_status=plan_status, - overall_progress=overall_progress, - records=records, - compensation_hints=compensation_hints, - ) - - def get_record(self, execution_id: str, records: list[ExecutionRecord]) -> ExecutionRecord | None: - """Helper to find a specific record by its ID.""" - return next((r for r in records if r.execution_id == execution_id), None) - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _build_record(self, plan_id: str, action: Action, approval_ts: str) -> ExecutionRecord: + + # If the action failed or was rolled back, append a manual recovery hint + if record.status in {ExecutionStatus.FAILED, ExecutionStatus.ROLLED_BACK}: + compensation_hints.append(_compensation_hint(record)) + + plan_status = compute_plan_execution_status(records) + overall_progress = compute_overall_progress(records) + + return PlanDispatchSummary( + plan_id=plan.plan_id, + dispatch_mode=mode, + plan_execution_status=plan_status, + overall_progress=overall_progress, + records=records, + compensation_hints=compensation_hints, + ) + + def get_record(self, execution_id: str, records: list[ExecutionRecord]) -> ExecutionRecord | None: + """Helper to find a specific record by its ID.""" + return next((r for r in records if r.execution_id == execution_id), None) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_record(self, plan_id: str, action: Action, mode: Literal["dry_run", "commit"]) -> ExecutionRecord: target_system, _ = _ROUTING.get(action.action_type, ("ERP", ERPAdapter)) - idem_key = make_idempotency_key(plan_id, action.action_id, approval_ts) + idem_key = make_idempotency_key(plan_id, action.action_id, mode) return ExecutionRecord( execution_id=f"exec_{uuid4().hex[:8]}", plan_id=plan_id, @@ -143,73 +142,74 @@ def _build_record(self, plan_id: str, action: Action, approval_ts: str) -> Execu target_system=target_system, payload=_build_payload(action), idempotency_key=idem_key, + dispatch_mode=mode, status=ExecutionStatus.PLANNED, ) - - def _run_action( - self, - record: ExecutionRecord, - mode: Literal["dry_run", "commit"], - ) -> ExecutionRecord: - """Drives a single record through the state machine — Thread-Safe Execution.""" - dry_run = mode == "dry_run" - adapter = self._adapters[record.target_system] - - with self._lock: # State Locking starts here - # PLANNED → APPROVED - transition(record, ExecutionStatus.APPROVED) - - # APPROVED → DISPATCHED - transition(record, ExecutionStatus.DISPATCHED) - record.dispatched_at = utc_now() - - result = self._call_with_retry(adapter, record, dry_run) - - if result.success: - transition(record, ExecutionStatus.APPLIED) - record.receipt = result.receipt - record.applied_at = utc_now() - - # Calculate physical ETA based on action lead time - duration_hours = record.payload.get("estimated_recovery_hours", 0.0) - from datetime import timedelta - record.estimated_completion_at = record.applied_at + timedelta(hours=duration_hours) - - # Initial progress set to 10% upon digital success - record.progress_percentage = 10.0 - else: - record.failure_reason = result.error_message - record.is_retryable = result.is_retryable - try: - transition(record, ExecutionStatus.FAILED) - except InvalidTransitionError: - pass - - if not result.is_retryable: - # Attempt advisory compensation/rollback - rollback_result = adapter.rollback(record) - if rollback_result.success: - try: - transition(record, ExecutionStatus.ROLLED_BACK) - record.receipt = rollback_result.receipt - except InvalidTransitionError: - pass - # Lock released automatically after the 'with' block - - return record - - def _call_with_retry( - self, - adapter: BaseAdapter, - record: ExecutionRecord, - dry_run: bool, - ) -> DispatchResult: - """Retry transient errors up to _MAX_RETRIES times with exponential backoff.""" - for attempt in range(_MAX_RETRIES): - result = adapter.dispatch(record, dry_run=dry_run) - if result.success or not result.is_retryable: - return result - # Transient error — wait and retry - if attempt < _MAX_RETRIES - 1: - time.sleep(_BACKOFF_SECONDS[attempt]) - return result # return last failed result after exhausting retries + + def _run_action( + self, + record: ExecutionRecord, + mode: Literal["dry_run", "commit"], + ) -> ExecutionRecord: + """Drives a single record through the state machine — Thread-Safe Execution.""" + dry_run = mode == "dry_run" + adapter = self._adapters[record.target_system] + + with self._lock: # State Locking starts here + # PLANNED → APPROVED + transition(record, ExecutionStatus.APPROVED) + + # APPROVED → DISPATCHED + transition(record, ExecutionStatus.DISPATCHED) + record.dispatched_at = utc_now() + + result = self._call_with_retry(adapter, record, dry_run) + + if result.success: + transition(record, ExecutionStatus.APPLIED) + record.receipt = result.receipt + record.applied_at = utc_now() + + # Calculate physical ETA based on action lead time + duration_hours = record.payload.get("estimated_recovery_hours", 0.0) + from datetime import timedelta + record.estimated_completion_at = record.applied_at + timedelta(hours=duration_hours) + + # Initial progress set to 10% upon digital success + record.progress_percentage = 10.0 + else: + record.failure_reason = result.error_message + record.is_retryable = result.is_retryable + try: + transition(record, ExecutionStatus.FAILED) + except InvalidTransitionError: + pass + + if not result.is_retryable: + # Attempt advisory compensation/rollback + rollback_result = adapter.rollback(record) + if rollback_result.success: + try: + transition(record, ExecutionStatus.ROLLED_BACK) + record.receipt = rollback_result.receipt + except InvalidTransitionError: + pass + # Lock released automatically after the 'with' block + + return record + + def _call_with_retry( + self, + adapter: BaseAdapter, + record: ExecutionRecord, + dry_run: bool, + ) -> DispatchResult: + """Retry transient errors up to _MAX_RETRIES times with exponential backoff.""" + for attempt in range(_MAX_RETRIES): + result = adapter.dispatch(record, dry_run=dry_run) + if result.success or not result.is_retryable: + return result + # Transient error — wait and retry + if attempt < _MAX_RETRIES - 1: + time.sleep(_BACKOFF_SECONDS[attempt]) + return result # return last failed result after exhausting retries diff --git a/execution/models.py b/execution/models.py index 1e0ec64..3821b47 100644 --- a/execution/models.py +++ b/execution/models.py @@ -1,98 +1,99 @@ -from __future__ import annotations - -import hashlib -from datetime import datetime, timezone -from typing import Any - -from pydantic import BaseModel, Field - -from core.enums import ExecutionStatus, PlanExecutionStatus - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def utc_now() -> datetime: - return datetime.now(timezone.utc) - - -def make_idempotency_key(plan_id: str, action_id: str, approval_timestamp: str) -> str: - """Deterministic key — safe to retry as many times as needed.""" - raw = f"{plan_id}:{action_id}:{approval_timestamp}" +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +from typing import Any + +from pydantic import BaseModel, Field + +from core.enums import ExecutionStatus, PlanExecutionStatus + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def make_idempotency_key(plan_id: str, action_id: str, dispatch_scope: str) -> str: + """Deterministic key for a plan/action within one dispatch scope.""" + raw = f"{plan_id}:{action_id}:{dispatch_scope}" return hashlib.sha256(raw.encode()).hexdigest()[:16] - - -# --------------------------------------------------------------------------- -# DispatchResult — returned by every Adapter -# --------------------------------------------------------------------------- - -class DispatchResult(BaseModel): - success: bool - receipt: dict[str, Any] = Field(default_factory=dict) - error_code: str | None = None - error_message: str | None = None - # True → transient error (network timeout, rate-limit) → retry allowed - # False → permanent error (invalid SKU, business rule) → escalate immediately - is_retryable: bool = False - - -# --------------------------------------------------------------------------- -# ExecutionRecord — the "receipt" for every dispatched action -# --------------------------------------------------------------------------- - + + +# --------------------------------------------------------------------------- +# DispatchResult — returned by every Adapter +# --------------------------------------------------------------------------- + +class DispatchResult(BaseModel): + success: bool + receipt: dict[str, Any] = Field(default_factory=dict) + error_code: str | None = None + error_message: str | None = None + # True → transient error (network timeout, rate-limit) → retry allowed + # False → permanent error (invalid SKU, business rule) → escalate immediately + is_retryable: bool = False + + +# --------------------------------------------------------------------------- +# ExecutionRecord — the "receipt" for every dispatched action +# --------------------------------------------------------------------------- + class ExecutionRecord(BaseModel): - execution_id: str - plan_id: str - action_id: str - action_type: str - target_system: str # "ERP" | "WMS" | "TMS" + execution_id: str + plan_id: str + action_id: str + action_type: str + target_system: str # "ERP" | "WMS" | "TMS" payload: dict[str, Any] = Field(default_factory=dict) idempotency_key: str - - status: ExecutionStatus = ExecutionStatus.PLANNED - receipt: dict[str, Any] = Field(default_factory=dict) - failure_reason: str | None = None - is_retryable: bool = False # copied from last DispatchResult - - created_at: datetime = Field(default_factory=utc_now) - dispatched_at: datetime | None = None - applied_at: datetime | None = None - - # --- Physical Tracking Fields (Added for Manual Confirmation) --- - estimated_completion_at: datetime | None = None - progress_percentage: float = 0.0 # Manual updates (0-100) - - def mark_completed(self) -> None: - """Called when a person (Warehouse Staff) manually confirms arrival.""" - self.status = ExecutionStatus.COMPLETED - self.progress_percentage = 100.0 - self.applied_at = utc_now() # Final arrival time - - def set_progress(self, percentage: float) -> None: - """Manual progress update by staff/systems.""" - self.progress_percentage = round(min(max(percentage, 0.0), 99.9), 1) - if self.progress_percentage > 0: - self.status = ExecutionStatus.IN_PROGRESS - - -# --------------------------------------------------------------------------- -# DispatchRequest — input to ActionDispatchService -# --------------------------------------------------------------------------- - -class DispatchRequest(BaseModel): - plan_id: str - mode: str = "dry_run" # "dry_run" | "commit" - - -# --------------------------------------------------------------------------- -# PlanDispatchSummary — aggregate result for the whole plan -# --------------------------------------------------------------------------- - -class PlanDispatchSummary(BaseModel): - plan_id: str - dispatch_mode: str - plan_execution_status: PlanExecutionStatus - overall_progress: float = 0.0 # Aggregate progress of all actions - records: list[ExecutionRecord] - compensation_hints: list[str] = Field(default_factory=list) + dispatch_mode: str = "dry_run" + + status: ExecutionStatus = ExecutionStatus.PLANNED + receipt: dict[str, Any] = Field(default_factory=dict) + failure_reason: str | None = None + is_retryable: bool = False # copied from last DispatchResult + + created_at: datetime = Field(default_factory=utc_now) + dispatched_at: datetime | None = None + applied_at: datetime | None = None + + # --- Physical Tracking Fields (Added for Manual Confirmation) --- + estimated_completion_at: datetime | None = None + progress_percentage: float = 0.0 # Manual updates (0-100) + + def mark_completed(self) -> None: + """Called when a person (Warehouse Staff) manually confirms arrival.""" + self.status = ExecutionStatus.COMPLETED + self.progress_percentage = 100.0 + self.applied_at = utc_now() # Final arrival time + + def set_progress(self, percentage: float) -> None: + """Manual progress update by staff/systems.""" + self.progress_percentage = round(min(max(percentage, 0.0), 99.9), 1) + if self.progress_percentage > 0: + self.status = ExecutionStatus.IN_PROGRESS + + +# --------------------------------------------------------------------------- +# DispatchRequest — input to ActionDispatchService +# --------------------------------------------------------------------------- + +class DispatchRequest(BaseModel): + plan_id: str + mode: str = "dry_run" # "dry_run" | "commit" + + +# --------------------------------------------------------------------------- +# PlanDispatchSummary — aggregate result for the whole plan +# --------------------------------------------------------------------------- + +class PlanDispatchSummary(BaseModel): + plan_id: str + dispatch_mode: str + plan_execution_status: PlanExecutionStatus + overall_progress: float = 0.0 # Aggregate progress of all actions + records: list[ExecutionRecord] + compensation_hints: list[str] = Field(default_factory=list) diff --git a/execution/state_machine.py b/execution/state_machine.py index 2960bb6..8642017 100644 --- a/execution/state_machine.py +++ b/execution/state_machine.py @@ -1,78 +1,78 @@ -from __future__ import annotations - -from core.enums import ExecutionStatus, PlanExecutionStatus -from execution.models import ExecutionRecord - - -# --------------------------------------------------------------------------- -# Custom exceptions -# --------------------------------------------------------------------------- - -class InvalidTransitionError(Exception): - """Raised when an illegal state transition is attempted.""" - - -# --------------------------------------------------------------------------- -# Allowed transitions (Action-level) -# --------------------------------------------------------------------------- - -_ALLOWED: dict[ExecutionStatus, set[ExecutionStatus]] = { - ExecutionStatus.PLANNED: {ExecutionStatus.APPROVAL_PENDING, ExecutionStatus.APPROVED}, - ExecutionStatus.APPROVAL_PENDING: {ExecutionStatus.APPROVED, ExecutionStatus.FAILED}, - ExecutionStatus.APPROVED: {ExecutionStatus.DISPATCHED}, - ExecutionStatus.DISPATCHED: {ExecutionStatus.APPLIED, ExecutionStatus.FAILED}, - ExecutionStatus.APPLIED: {ExecutionStatus.IN_PROGRESS, ExecutionStatus.COMPLETED}, - ExecutionStatus.IN_PROGRESS: {ExecutionStatus.COMPLETED, ExecutionStatus.FAILED}, - ExecutionStatus.FAILED: {ExecutionStatus.ROLLED_BACK}, - ExecutionStatus.COMPLETED: set(), # Physical Terminal - ExecutionStatus.ROLLED_BACK: set(), # Terminal -} - - -def transition(record: ExecutionRecord, new_status: ExecutionStatus) -> ExecutionRecord: - """Mutates record.status if the transition is valid, otherwise raises.""" - if record.status == new_status: - return record # No change - - allowed = _ALLOWED.get(record.status, set()) - if new_status not in allowed: - raise InvalidTransitionError( - f"Cannot transition {record.execution_id} " - f"from {record.status} → {new_status}. " - f"Allowed: {[s.value for s in allowed]}" - ) - record.status = new_status - return record - - -# --------------------------------------------------------------------------- -# Plan-level aggregation -# --------------------------------------------------------------------------- - -def compute_plan_execution_status(records: list[ExecutionRecord]) -> PlanExecutionStatus: - """Derives the overall plan execution status from individual records.""" - if not records: - return PlanExecutionStatus.PENDING - - statuses = {r.status for r in records} - finished_success = {ExecutionStatus.APPLIED, ExecutionStatus.COMPLETED} - failed_states = {ExecutionStatus.FAILED, ExecutionStatus.ROLLED_BACK} - - if statuses and statuses.issubset(finished_success): - return PlanExecutionStatus.COMPLETED - - if statuses & failed_states: - return PlanExecutionStatus.REQUIRES_MANUAL_INTERVENTION - - if statuses & {ExecutionStatus.IN_PROGRESS, ExecutionStatus.APPLIED, ExecutionStatus.DISPATCHED}: - return PlanExecutionStatus.PARTIALLY_APPLIED - - return PlanExecutionStatus.PENDING - - -def compute_overall_progress(records: list[ExecutionRecord]) -> float: - """Calculates the average physical progress of all actions in the plan.""" - if not records: - return 0.0 - total = sum(r.progress_percentage for r in records) - return round(total / len(records), 1) +from __future__ import annotations + +from core.enums import ExecutionStatus, PlanExecutionStatus +from execution.models import ExecutionRecord + + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + +class InvalidTransitionError(Exception): + """Raised when an illegal state transition is attempted.""" + + +# --------------------------------------------------------------------------- +# Allowed transitions (Action-level) +# --------------------------------------------------------------------------- + +_ALLOWED: dict[ExecutionStatus, set[ExecutionStatus]] = { + ExecutionStatus.PLANNED: {ExecutionStatus.APPROVAL_PENDING, ExecutionStatus.APPROVED}, + ExecutionStatus.APPROVAL_PENDING: {ExecutionStatus.APPROVED, ExecutionStatus.FAILED}, + ExecutionStatus.APPROVED: {ExecutionStatus.DISPATCHED}, + ExecutionStatus.DISPATCHED: {ExecutionStatus.APPLIED, ExecutionStatus.FAILED}, + ExecutionStatus.APPLIED: {ExecutionStatus.IN_PROGRESS, ExecutionStatus.COMPLETED}, + ExecutionStatus.IN_PROGRESS: {ExecutionStatus.COMPLETED, ExecutionStatus.FAILED}, + ExecutionStatus.FAILED: {ExecutionStatus.ROLLED_BACK}, + ExecutionStatus.COMPLETED: set(), # Physical Terminal + ExecutionStatus.ROLLED_BACK: set(), # Terminal +} + + +def transition(record: ExecutionRecord, new_status: ExecutionStatus) -> ExecutionRecord: + """Mutates record.status if the transition is valid, otherwise raises.""" + if record.status == new_status: + return record # No change + + allowed = _ALLOWED.get(record.status, set()) + if new_status not in allowed: + raise InvalidTransitionError( + f"Cannot transition {record.execution_id} " + f"from {record.status} → {new_status}. " + f"Allowed: {[s.value for s in allowed]}" + ) + record.status = new_status + return record + + +# --------------------------------------------------------------------------- +# Plan-level aggregation +# --------------------------------------------------------------------------- + +def compute_plan_execution_status(records: list[ExecutionRecord]) -> PlanExecutionStatus: + """Derives the overall plan execution status from individual records.""" + if not records: + return PlanExecutionStatus.PENDING + + statuses = {r.status for r in records} + finished_success = {ExecutionStatus.APPLIED, ExecutionStatus.COMPLETED} + failed_states = {ExecutionStatus.FAILED, ExecutionStatus.ROLLED_BACK} + + if statuses and statuses.issubset(finished_success): + return PlanExecutionStatus.COMPLETED + + if statuses & failed_states: + return PlanExecutionStatus.REQUIRES_MANUAL_INTERVENTION + + if statuses & {ExecutionStatus.IN_PROGRESS, ExecutionStatus.APPLIED, ExecutionStatus.DISPATCHED}: + return PlanExecutionStatus.PARTIALLY_APPLIED + + return PlanExecutionStatus.PENDING + + +def compute_overall_progress(records: list[ExecutionRecord]) -> float: + """Calculates the average physical progress of all actions in the plan.""" + if not records: + return 0.0 + total = sum(r.progress_percentage for r in records) + return round(total / len(records), 1) diff --git a/llm/__init__.py b/llm/__init__.py index b2fdd6c..0aae8fb 100644 --- a/llm/__init__.py +++ b/llm/__init__.py @@ -1 +1 @@ -# Optional LLM integrations for user-facing explanations. +# Optional LLM integrations for user-facing explanations. diff --git a/llm/service.py b/llm/service.py index dbf1559..1b30c1f 100644 --- a/llm/service.py +++ b/llm/service.py @@ -406,10 +406,14 @@ def _build_planner_candidates_prompt( from orchestrator.prompts import CRISIS_MODE_PROMPT instructions.append(CRISIS_MODE_PROMPT.strip()) - instructions.append( - "Return JSON with candidate_plans containing exactly these strategy_label values: " - "cost_first, balanced, resilience_first. Only use action ids from candidate_actions." - ) + instructions.append( + "Return JSON with candidate_plans containing exactly these strategy_label values: " + "cost_first, balanced, resilience_first. Only use action ids from candidate_actions." + ) + instructions.append( + "Choose a right-sized action_ids list for each strategy. " + "Avoid selecting all candidate actions unless truly necessary for that strategy." + ) instructions.append( "Allowed planner output shape example: " '{"candidate_plans":[{"strategy_label":"cost_first","action_ids":["act_x"],"rationale":"..."},' diff --git a/llm/vertex_client.py b/llm/vertex_client.py index 93d0ce7..7ad06cd 100644 --- a/llm/vertex_client.py +++ b/llm/vertex_client.py @@ -1,91 +1,91 @@ -from __future__ import annotations - -import json -import socket -from urllib import error, request - -from llm.config import LLMSettings - - -class VertexClientError(RuntimeError): - pass - - -class VertexGeminiClient: - def __init__(self, settings: LLMSettings) -> None: - self.settings = settings - - def generate_json( - self, - *, - prompt: str, - schema: dict, - temperature: float = 0.2, - ) -> dict: - if not self.settings.api_key: - raise VertexClientError("missing Vertex AI API key") - if not self.settings.vertex_project_id: - raise VertexClientError("missing Vertex AI project id") - - endpoint = ( - "https://aiplatform.googleapis.com/v1/projects/" - f"{self.settings.vertex_project_id}/locations/{self.settings.vertex_region}" - f"/publishers/google/models/{self.settings.model}:generateContent" - ) - payload = { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": prompt, - } - ] - } - ], - "generationConfig": { - "temperature": temperature, - "responseMimeType": "application/json", - "responseJsonSchema": schema, - }, - } - body = json.dumps(payload).encode("utf-8") - http_request = request.Request( - endpoint, - data=body, - headers={ - "Content-Type": "application/json", - "x-goog-api-key": self.settings.api_key, - }, - method="POST", - ) - try: - with request.urlopen(http_request, timeout=self.settings.timeout_s) as response: - raw = json.loads(response.read().decode("utf-8")) - except error.HTTPError as exc: - detail = exc.read().decode("utf-8", errors="ignore") - if exc.code in {401, 403}: - raise VertexClientError(f"vertex auth error {exc.code}: {detail}") from exc - if exc.code == 429: - raise VertexClientError(f"vertex quota error 429: {detail}") from exc - raise VertexClientError(f"vertex http error {exc.code}: {detail}") from exc - except error.URLError as exc: - raise VertexClientError(f"vertex connection error: {exc.reason}") from exc - except (TimeoutError, socket.timeout) as exc: - raise VertexClientError("vertex request timed out") from exc - except OSError as exc: - raise VertexClientError(f"vertex transport error: {exc}") from exc - except json.JSONDecodeError as exc: - raise VertexClientError("vertex returned invalid json") from exc - - candidates = raw.get("candidates", []) - if not candidates: - raise VertexClientError("vertex returned no candidates") - parts = candidates[0].get("content", {}).get("parts", []) - text = "".join(part.get("text", "") for part in parts).strip() - if not text: - raise VertexClientError("vertex returned empty content") - try: - return json.loads(text) - except json.JSONDecodeError as exc: - raise VertexClientError("vertex content was not valid json") from exc +from __future__ import annotations + +import json +import socket +from urllib import error, request + +from llm.config import LLMSettings + + +class VertexClientError(RuntimeError): + pass + + +class VertexGeminiClient: + def __init__(self, settings: LLMSettings) -> None: + self.settings = settings + + def generate_json( + self, + *, + prompt: str, + schema: dict, + temperature: float = 0.2, + ) -> dict: + if not self.settings.api_key: + raise VertexClientError("missing Vertex AI API key") + if not self.settings.vertex_project_id: + raise VertexClientError("missing Vertex AI project id") + + endpoint = ( + "https://aiplatform.googleapis.com/v1/projects/" + f"{self.settings.vertex_project_id}/locations/{self.settings.vertex_region}" + f"/publishers/google/models/{self.settings.model}:generateContent" + ) + payload = { + "contents": [ + { + "role": "user", + "parts": [ + { + "text": prompt, + } + ] + } + ], + "generationConfig": { + "temperature": temperature, + "responseMimeType": "application/json", + "responseJsonSchema": schema, + }, + } + body = json.dumps(payload).encode("utf-8") + http_request = request.Request( + endpoint, + data=body, + headers={ + "Content-Type": "application/json", + "x-goog-api-key": self.settings.api_key, + }, + method="POST", + ) + try: + with request.urlopen(http_request, timeout=self.settings.timeout_s) as response: + raw = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore") + if exc.code in {401, 403}: + raise VertexClientError(f"vertex auth error {exc.code}: {detail}") from exc + if exc.code == 429: + raise VertexClientError(f"vertex quota error 429: {detail}") from exc + raise VertexClientError(f"vertex http error {exc.code}: {detail}") from exc + except error.URLError as exc: + raise VertexClientError(f"vertex connection error: {exc.reason}") from exc + except (TimeoutError, socket.timeout) as exc: + raise VertexClientError("vertex request timed out") from exc + except OSError as exc: + raise VertexClientError(f"vertex transport error: {exc}") from exc + except json.JSONDecodeError as exc: + raise VertexClientError("vertex returned invalid json") from exc + + candidates = raw.get("candidates", []) + if not candidates: + raise VertexClientError("vertex returned no candidates") + parts = candidates[0].get("content", {}).get("parts", []) + text = "".join(part.get("text", "") for part in parts).strip() + if not text: + raise VertexClientError("vertex returned empty content") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise VertexClientError("vertex content was not valid json") from exc diff --git a/orchestrator/graph.py b/orchestrator/graph.py index 1931579..0231e7f 100644 --- a/orchestrator/graph.py +++ b/orchestrator/graph.py @@ -1,598 +1,598 @@ -from __future__ import annotations - -import time -from typing import TypedDict -from uuid import uuid4 - -from actions.executor import apply_plan -from agents.critic import CriticAgent -from agents.demand import DemandAgent -from agents.inventory import InventoryAgent -from agents.logistics import LogisticsAgent -from agents.planner import PlannerAgent -from agents.risk import RiskAgent -from agents.supplier import SupplierAgent -from core.enums import ApprovalStatus, Mode, PlanStatus -from core.models import ( - AgentProposal, - Event, - OrchestrationTrace, - SystemState, - TraceRouteDecision, - TraceStep, -) -from core.runtime_tracking import new_run_id -from core.state import recompute_kpis, utc_now -from langgraph.graph import END, START, StateGraph -from orchestrator.router import ( - route_after_demand, - route_after_inventory, - route_after_logistics, - route_after_critic, - route_after_planner, - route_after_risk, - route_after_supplier, -) - - -class OrchestrationState(TypedDict): - state: SystemState - event: Event | None - started_at: float - - -class LangGraphControlTower: - def __init__(self) -> None: - self.risk_agent = RiskAgent() - self.demand_agent = DemandAgent() - self.inventory_agent = InventoryAgent() - self.supplier_agent = SupplierAgent() - self.logistics_agent = LogisticsAgent() - self.planner_agent = PlannerAgent() - self.critic_agent = CriticAgent() - self.graph = self._compile() - - def _reset_cycle(self, state: SystemState) -> None: - state.candidate_actions = [] - state.agent_outputs = {} - state.latest_trace = None - - def _begin_trace(self, state: SystemState, event: Event | None) -> None: - state.latest_trace = OrchestrationTrace( - trace_id=f"trace_{uuid4().hex[:8]}", - run_id=state.run_id, - started_at=utc_now(), - mode_before=state.mode.value, - current_branch=state.mode.value, - event=event.model_copy(deep=True) if event else None, - ) - - def _base_input_snapshot( - self, state: SystemState, event: Event | None - ) -> dict[str, object]: - return { - "mode": state.mode.value, - "active_event_count": len(state.active_events), - "candidate_action_count": len(state.candidate_actions), - "pending_plan_id": state.pending_plan.plan_id - if state.pending_plan - else None, - "event_type": event.type.value if event else None, - "event_severity": event.severity if event else None, - } - - def _start_step( - self, - state: SystemState, - node_key: str, - node_type: str, - event: Event | None, - ) -> None: - if state.latest_trace is None: - return - state.latest_trace.steps.append( - TraceStep( - step_id=f"step_{uuid4().hex[:8]}", - sequence=len(state.latest_trace.steps) + 1, - node_key=node_key, - node_type=node_type, - started_at=utc_now(), - mode_snapshot=state.mode.value, - input_snapshot=self._base_input_snapshot(state, event), - ) - ) - - def _complete_step( - self, - state: SystemState, - *, - node_key: str, - summary: str, - reasoning_source: str, - observations: list[str] | None = None, - risks: list[str] | None = None, - downstream_impacts: list[str] | None = None, - recommended_action_ids: list[str] | None = None, - tradeoffs: list[str] | None = None, - llm_used: bool = False, - llm_error: str | None = None, - output_snapshot: dict[str, object] | None = None, - ) -> None: - if state.latest_trace is None: - return - for step in reversed(state.latest_trace.steps): - if step.node_key != node_key or step.completed_at is not None: - continue - step.completed_at = utc_now() - step.status = "completed" - step.duration_ms = round( - max( - (step.completed_at - step.started_at).total_seconds() * 1000.0, 0.0 - ), - 2, - ) - step.summary = summary - step.reasoning_source = reasoning_source - step.observations = observations or [] - step.risks = risks or [] - step.downstream_impacts = downstream_impacts or [] - step.recommended_action_ids = recommended_action_ids or [] - step.tradeoffs = tradeoffs or [] - step.llm_used = llm_used - step.llm_error = llm_error - step.fallback_used = bool(llm_error) - step.fallback_reason = llm_error - step.output_snapshot = output_snapshot or {} - return - - def _complete_agent_step( - self, state: SystemState, node_key: str, output: AgentProposal - ) -> None: - summary = ( - output.domain_summary - or output.notes_for_planner - or "; ".join(output.observations[:2]) - or "node executed" - ) - reasoning_source = ( - "ai_assisted_reasoning" if output.llm_used else "deterministic_or_fallback" - ) - self._complete_step( - state, - node_key=node_key, - summary=summary, - reasoning_source=reasoning_source, - observations=output.observations, - risks=output.risks, - downstream_impacts=output.downstream_impacts, - recommended_action_ids=output.recommended_action_ids, - tradeoffs=output.tradeoffs, - llm_used=output.llm_used, - llm_error=output.llm_error, - output_snapshot={ - "proposal_count": len(output.proposals), - "recommended_action_count": len(output.recommended_action_ids), - }, - ) - - def _record_route( - self, - state: SystemState, - from_node: str, - outcome: str, - to_node: str, - reason: str, - ) -> None: - if state.latest_trace is None: - return - state.latest_trace.route_decisions.append( - TraceRouteDecision( - from_node=from_node, - outcome=outcome, - to_node=to_node, - reason=reason, - ) - ) - - def _update_trace_from_plan(self, state: SystemState) -> None: - if state.latest_trace is None: - return - latest_decision = state.decision_logs[-1] if state.decision_logs else None - state.latest_trace.selected_plan_id = state.latest_plan_id - state.latest_trace.selected_strategy = ( - state.latest_plan.strategy_label if state.latest_plan else None - ) - state.latest_trace.candidate_count = ( - len(latest_decision.candidate_evaluations) if latest_decision else 0 - ) - state.latest_trace.decision_id = ( - latest_decision.decision_id if latest_decision else None - ) - state.latest_trace.selection_reason = ( - latest_decision.selection_reason if latest_decision else None - ) - state.latest_trace.approval_pending = state.pending_plan is not None - state.latest_trace.approval_reason = ( - latest_decision.approval_reason if latest_decision else "" - ) - state.latest_trace.critic_summary = ( - latest_decision.critic_summary if latest_decision else None - ) - - def _risk_route_reason(self, outcome: str) -> str: - if outcome == "approval": - return "pending approval already exists or the system is already in approval mode" - return f"dynamic routing assigned the next agent as {outcome}" - - def _critic_route_reason(self, state: SystemState, outcome: str) -> str: - if outcome == "approval": - return ( - state.latest_plan.approval_reason - if state.latest_plan is not None - else "selected plan requires approval" - ) - return "selected plan cleared deterministic approval guardrails and can execute" - - def _handoff_reason(self, from_node: str, to_node: str) -> str: - reasons = { - ("demand", "inventory"): "demand analysis updates forecast and hands replenishment to inventory planning", - ("inventory", "planner"): "inventory planning completed and handed feasible replenishment options to the planner", - ("supplier", "logistics"): "supplier mitigation options were prepared and routing alternatives are needed before final planning", - ("supplier", "planner"): "supplier review completed and the planner can evaluate the candidate actions", - ("logistics", "supplier"): "routing disruption analysis completed and supplier mitigation is needed before planning", - ("logistics", "planner"): "routing analysis completed and the planner can score the candidate actions", - } - return reasons.get((from_node, to_node), f"{from_node} forwarded the workflow to {to_node}") - - def _record_output(self, state: SystemState, output) -> None: - state.agent_outputs[output.agent] = output - state.candidate_actions.extend(output.proposals) - - def _finalize(self, state: SystemState, started_at: float) -> SystemState: - state.timestamp = utc_now() - state.kpis = recompute_kpis(state, recovery_speed=state.kpis.recovery_speed) - state.kpis.decision_latency_ms = round( - (time.perf_counter() - started_at) * 1000.0, 2 - ) - if state.latest_trace is not None: - state.latest_trace.completed_at = state.timestamp - state.latest_trace.mode_after = state.mode.value - state.latest_trace.status = "completed" - return state - - def risk_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - event = graph_state["event"] - self._reset_cycle(state) - self._begin_trace(state, event) - self._start_step(state, "risk", "agent", event) - if event is not None: - if event.dedupe_key not in { - item.dedupe_key for item in state.active_events - }: - state.active_events.append(event) - output = self.risk_agent.run(state, event) - self._record_output(state, output) - self._complete_agent_step(state, "risk", output) - risk_route = route_after_risk({"state": state}) - self._record_route( - state, - "risk", - risk_route, - risk_route, - self._risk_route_reason(risk_route), - ) - if state.latest_trace is not None: - state.latest_trace.current_branch = state.mode.value - return {"state": state, "event": event, "started_at": graph_state["started_at"]} - - def demand_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - self._start_step(state, "demand", "agent", graph_state["event"]) - output = self.demand_agent.run(state, graph_state["event"]) - self._record_output(state, output) - self._complete_agent_step(state, "demand", output) - next_node = route_after_demand({"state": state}) - self._record_route( - state, - "demand", - next_node, - next_node, - self._handoff_reason("demand", next_node), - ) - return graph_state - - def inventory_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - self._start_step(state, "inventory", "agent", graph_state["event"]) - output = self.inventory_agent.run(state, graph_state["event"]) - self._record_output(state, output) - self._complete_agent_step(state, "inventory", output) - next_node = route_after_inventory({"state": state}) - self._record_route( - state, - "inventory", - next_node, - next_node, - self._handoff_reason("inventory", next_node), - ) - return graph_state - - def supplier_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - self._start_step(state, "supplier", "agent", graph_state["event"]) - output = self.supplier_agent.run(state, graph_state["event"]) - self._record_output(state, output) - self._complete_agent_step(state, "supplier", output) - next_node = route_after_supplier({"state": state}) - self._record_route( - state, - "supplier", - next_node, - next_node, - self._handoff_reason("supplier", next_node), - ) - return graph_state - - def logistics_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - self._start_step(state, "logistics", "agent", graph_state["event"]) - output = self.logistics_agent.run(state, graph_state["event"]) - self._record_output(state, output) - self._complete_agent_step(state, "logistics", output) - next_node = route_after_logistics({"state": state}) - self._record_route( - state, - "logistics", - next_node, - next_node, - self._handoff_reason("logistics", next_node), - ) - return graph_state - - def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - self._start_step(state, "planner", "agent", graph_state["event"]) - output = self.planner_agent.run(state, graph_state["event"]) - self._record_output(state, output) - self._update_trace_from_plan(state) - latest_decision = state.decision_logs[-1] if state.decision_logs else None - self._complete_step( - state, - node_key="planner", - summary=output.notes_for_planner - or output.domain_summary - or "planner generated candidate plans", - reasoning_source="ai_assisted_reasoning" - if output.llm_used - else "deterministic_or_fallback", - observations=output.observations, - risks=output.risks, - downstream_impacts=output.downstream_impacts, - recommended_action_ids=output.recommended_action_ids, - tradeoffs=output.tradeoffs, - llm_used=output.llm_used, - llm_error=output.llm_error, - output_snapshot={ - "selected_plan_id": state.latest_plan_id, - "selected_strategy": state.latest_plan.strategy_label - if state.latest_plan - else None, - "generated_by": state.latest_plan.generated_by - if state.latest_plan - else None, - "candidate_count": len(latest_decision.candidate_evaluations) - if latest_decision - else 0, - "approval_required": state.latest_plan.approval_required - if state.latest_plan - else False, - }, - ) - self._record_route( - state, - "planner", - "critic", - "critic", - "planner always forwards candidate plans to the critic", - ) - return graph_state - - def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - self._start_step(state, "critic", "agent", graph_state["event"]) - output = self.critic_agent.run(state, graph_state["event"]) - state.agent_outputs[output.agent] = output - self._complete_step( - state, - node_key="critic", - summary=output.domain_summary - or output.notes_for_planner - or "critic reviewed the selected plan", - reasoning_source="ai_assisted_reasoning" - if output.llm_used - else "deterministic_or_fallback", - observations=output.observations, - risks=output.risks, - downstream_impacts=output.downstream_impacts, - recommended_action_ids=output.recommended_action_ids, - tradeoffs=output.tradeoffs, - llm_used=output.llm_used, - llm_error=output.llm_error, - output_snapshot={ - "critic_summary": state.decision_logs[-1].critic_summary - if state.decision_logs - else None, - "finding_count": len(state.decision_logs[-1].critic_findings) - if state.decision_logs - else 0, - }, - ) - critic_route = route_after_critic({"state": state}) - self._record_route( - state, - "critic", - critic_route, - critic_route, - self._critic_route_reason(state, critic_route), - ) - return graph_state - - def approval_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - self._start_step(state, "approval", "gate", graph_state["event"]) - state.mode = Mode.APPROVAL - self._update_trace_from_plan(state) - if state.latest_trace is not None: - state.latest_trace.terminal_stage = "approval" - state.latest_trace.execution_status = "pending_approval" - state.latest_trace.approval_pending = True - self._complete_step( - state, - node_key="approval", - summary=state.latest_plan.approval_reason - if state.latest_plan - else "approval required", - reasoning_source="deterministic_policy_guardrail", - output_snapshot={ - "decision_id": state.decision_logs[-1].decision_id - if state.decision_logs - else None, - "approval_required": True, - "approval_status": state.decision_logs[-1].approval_status.value - if state.decision_logs - else None, - }, - ) - state = self._finalize(state, graph_state["started_at"]) - return { - "state": state, - "event": graph_state["event"], - "started_at": graph_state["started_at"], - } - - def execution_node(self, graph_state: OrchestrationState) -> OrchestrationState: - state = graph_state["state"] - event = graph_state["event"] - self._start_step(state, "execution", "execution", event) - execution_summary = "no plan executed" - execution_status = "no_op" - if state.latest_plan and not state.latest_plan.approval_required: - state.latest_plan.status = PlanStatus.APPLIED - state = apply_plan(state, state.latest_plan) - if state.decision_logs: - state.decision_logs[-1].approval_status = ApprovalStatus.AUTO_APPLIED - state.mode = Mode.CRISIS if event and state.active_events else Mode.NORMAL - execution_summary = ( - f"applied {state.latest_plan.plan_id} using " - f"{state.latest_plan.strategy_label or 'unlabeled'} strategy" - ) - execution_status = "auto_applied" - self._update_trace_from_plan(state) - if state.latest_trace is not None: - state.latest_trace.terminal_stage = "execution" - state.latest_trace.execution_status = execution_status - state.latest_trace.approval_pending = False - self._complete_step( - state, - node_key="execution", - summary=execution_summary, - reasoning_source="deterministic_execution_guard", - output_snapshot={ - "execution_status": execution_status, - "latest_plan_id": state.latest_plan_id, - "mode_after_execution": state.mode.value, - }, - ) - state = self._finalize(state, graph_state["started_at"]) - return {"state": state, "event": event, "started_at": graph_state["started_at"]} - - def _compile(self): - graph = StateGraph(OrchestrationState) - graph.add_node("risk", self.risk_node) - graph.add_node("demand", self.demand_node) - graph.add_node("inventory", self.inventory_node) - graph.add_node("supplier", self.supplier_node) - graph.add_node("logistics", self.logistics_node) - graph.add_node("planner", self.planner_node) - graph.add_node("critic", self.critic_node) - graph.add_node("approval", self.approval_node) - graph.add_node("execution", self.execution_node) - - graph.add_edge(START, "risk") - graph.add_conditional_edges( - "risk", - route_after_risk, - { - "logistics": "logistics", - "supplier": "supplier", - "demand": "demand", - "planner": "planner", - "approval": "approval", - }, - ) - graph.add_conditional_edges( - "logistics", - route_after_logistics, - { - "supplier": "supplier", - "planner": "planner", - }, - ) - graph.add_conditional_edges( - "supplier", - route_after_supplier, - { - "logistics": "logistics", - "planner": "planner", - }, - ) - graph.add_conditional_edges( - "demand", - route_after_demand, - { - "inventory": "inventory", - }, - ) - graph.add_conditional_edges( - "inventory", - route_after_inventory, - { - "planner": "planner", - }, - ) - graph.add_conditional_edges( - "planner", - route_after_planner, - { - "critic": "critic", - }, - ) - graph.add_conditional_edges( - "critic", - route_after_critic, - { - "approval": "approval", - "execution": "execution", - }, - ) - graph.add_edge("approval", END) - graph.add_edge("execution", END) - return graph.compile() - - def invoke( - self, state: SystemState, event: Event | None = None, run_id: str | None = None - ) -> SystemState: - state.run_id = run_id or new_run_id() - result = self.graph.invoke( - { - "state": state, - "event": event, - "started_at": time.perf_counter(), - } - ) - return result["state"] - - -def build_graph() -> LangGraphControlTower: - return LangGraphControlTower() +from __future__ import annotations + +import time +from typing import TypedDict +from uuid import uuid4 + +from actions.executor import apply_plan +from agents.critic import CriticAgent +from agents.demand import DemandAgent +from agents.inventory import InventoryAgent +from agents.logistics import LogisticsAgent +from agents.planner import PlannerAgent +from agents.risk import RiskAgent +from agents.supplier import SupplierAgent +from core.enums import ApprovalStatus, Mode, PlanStatus +from core.models import ( + AgentProposal, + Event, + OrchestrationTrace, + SystemState, + TraceRouteDecision, + TraceStep, +) +from core.runtime_tracking import new_run_id +from core.state import recompute_kpis, utc_now +from langgraph.graph import END, START, StateGraph +from orchestrator.router import ( + route_after_demand, + route_after_inventory, + route_after_logistics, + route_after_critic, + route_after_planner, + route_after_risk, + route_after_supplier, +) + + +class OrchestrationState(TypedDict): + state: SystemState + event: Event | None + started_at: float + + +class LangGraphControlTower: + def __init__(self) -> None: + self.risk_agent = RiskAgent() + self.demand_agent = DemandAgent() + self.inventory_agent = InventoryAgent() + self.supplier_agent = SupplierAgent() + self.logistics_agent = LogisticsAgent() + self.planner_agent = PlannerAgent() + self.critic_agent = CriticAgent() + self.graph = self._compile() + + def _reset_cycle(self, state: SystemState) -> None: + state.candidate_actions = [] + state.agent_outputs = {} + state.latest_trace = None + + def _begin_trace(self, state: SystemState, event: Event | None) -> None: + state.latest_trace = OrchestrationTrace( + trace_id=f"trace_{uuid4().hex[:8]}", + run_id=state.run_id, + started_at=utc_now(), + mode_before=state.mode.value, + current_branch=state.mode.value, + event=event.model_copy(deep=True) if event else None, + ) + + def _base_input_snapshot( + self, state: SystemState, event: Event | None + ) -> dict[str, object]: + return { + "mode": state.mode.value, + "active_event_count": len(state.active_events), + "candidate_action_count": len(state.candidate_actions), + "pending_plan_id": state.pending_plan.plan_id + if state.pending_plan + else None, + "event_type": event.type.value if event else None, + "event_severity": event.severity if event else None, + } + + def _start_step( + self, + state: SystemState, + node_key: str, + node_type: str, + event: Event | None, + ) -> None: + if state.latest_trace is None: + return + state.latest_trace.steps.append( + TraceStep( + step_id=f"step_{uuid4().hex[:8]}", + sequence=len(state.latest_trace.steps) + 1, + node_key=node_key, + node_type=node_type, + started_at=utc_now(), + mode_snapshot=state.mode.value, + input_snapshot=self._base_input_snapshot(state, event), + ) + ) + + def _complete_step( + self, + state: SystemState, + *, + node_key: str, + summary: str, + reasoning_source: str, + observations: list[str] | None = None, + risks: list[str] | None = None, + downstream_impacts: list[str] | None = None, + recommended_action_ids: list[str] | None = None, + tradeoffs: list[str] | None = None, + llm_used: bool = False, + llm_error: str | None = None, + output_snapshot: dict[str, object] | None = None, + ) -> None: + if state.latest_trace is None: + return + for step in reversed(state.latest_trace.steps): + if step.node_key != node_key or step.completed_at is not None: + continue + step.completed_at = utc_now() + step.status = "completed" + step.duration_ms = round( + max( + (step.completed_at - step.started_at).total_seconds() * 1000.0, 0.0 + ), + 2, + ) + step.summary = summary + step.reasoning_source = reasoning_source + step.observations = observations or [] + step.risks = risks or [] + step.downstream_impacts = downstream_impacts or [] + step.recommended_action_ids = recommended_action_ids or [] + step.tradeoffs = tradeoffs or [] + step.llm_used = llm_used + step.llm_error = llm_error + step.fallback_used = bool(llm_error) + step.fallback_reason = llm_error + step.output_snapshot = output_snapshot or {} + return + + def _complete_agent_step( + self, state: SystemState, node_key: str, output: AgentProposal + ) -> None: + summary = ( + output.domain_summary + or output.notes_for_planner + or "; ".join(output.observations[:2]) + or "node executed" + ) + reasoning_source = ( + "ai_assisted_reasoning" if output.llm_used else "deterministic_or_fallback" + ) + self._complete_step( + state, + node_key=node_key, + summary=summary, + reasoning_source=reasoning_source, + observations=output.observations, + risks=output.risks, + downstream_impacts=output.downstream_impacts, + recommended_action_ids=output.recommended_action_ids, + tradeoffs=output.tradeoffs, + llm_used=output.llm_used, + llm_error=output.llm_error, + output_snapshot={ + "proposal_count": len(output.proposals), + "recommended_action_count": len(output.recommended_action_ids), + }, + ) + + def _record_route( + self, + state: SystemState, + from_node: str, + outcome: str, + to_node: str, + reason: str, + ) -> None: + if state.latest_trace is None: + return + state.latest_trace.route_decisions.append( + TraceRouteDecision( + from_node=from_node, + outcome=outcome, + to_node=to_node, + reason=reason, + ) + ) + + def _update_trace_from_plan(self, state: SystemState) -> None: + if state.latest_trace is None: + return + latest_decision = state.decision_logs[-1] if state.decision_logs else None + state.latest_trace.selected_plan_id = state.latest_plan_id + state.latest_trace.selected_strategy = ( + state.latest_plan.strategy_label if state.latest_plan else None + ) + state.latest_trace.candidate_count = ( + len(latest_decision.candidate_evaluations) if latest_decision else 0 + ) + state.latest_trace.decision_id = ( + latest_decision.decision_id if latest_decision else None + ) + state.latest_trace.selection_reason = ( + latest_decision.selection_reason if latest_decision else None + ) + state.latest_trace.approval_pending = state.pending_plan is not None + state.latest_trace.approval_reason = ( + latest_decision.approval_reason if latest_decision else "" + ) + state.latest_trace.critic_summary = ( + latest_decision.critic_summary if latest_decision else None + ) + + def _risk_route_reason(self, outcome: str) -> str: + if outcome == "approval": + return "pending approval already exists or the system is already in approval mode" + return f"dynamic routing assigned the next agent as {outcome}" + + def _critic_route_reason(self, state: SystemState, outcome: str) -> str: + if outcome == "approval": + return ( + state.latest_plan.approval_reason + if state.latest_plan is not None + else "selected plan requires approval" + ) + return "selected plan cleared deterministic approval guardrails and can execute" + + def _handoff_reason(self, from_node: str, to_node: str) -> str: + reasons = { + ("demand", "inventory"): "demand analysis updates forecast and hands replenishment to inventory planning", + ("inventory", "planner"): "inventory planning completed and handed feasible replenishment options to the planner", + ("supplier", "logistics"): "supplier mitigation options were prepared and routing alternatives are needed before final planning", + ("supplier", "planner"): "supplier review completed and the planner can evaluate the candidate actions", + ("logistics", "supplier"): "routing disruption analysis completed and supplier mitigation is needed before planning", + ("logistics", "planner"): "routing analysis completed and the planner can score the candidate actions", + } + return reasons.get((from_node, to_node), f"{from_node} forwarded the workflow to {to_node}") + + def _record_output(self, state: SystemState, output) -> None: + state.agent_outputs[output.agent] = output + state.candidate_actions.extend(output.proposals) + + def _finalize(self, state: SystemState, started_at: float) -> SystemState: + state.timestamp = utc_now() + state.kpis = recompute_kpis(state, recovery_speed=state.kpis.recovery_speed) + state.kpis.decision_latency_ms = round( + (time.perf_counter() - started_at) * 1000.0, 2 + ) + if state.latest_trace is not None: + state.latest_trace.completed_at = state.timestamp + state.latest_trace.mode_after = state.mode.value + state.latest_trace.status = "completed" + return state + + def risk_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + event = graph_state["event"] + self._reset_cycle(state) + self._begin_trace(state, event) + self._start_step(state, "risk", "agent", event) + if event is not None: + if event.dedupe_key not in { + item.dedupe_key for item in state.active_events + }: + state.active_events.append(event) + output = self.risk_agent.run(state, event) + self._record_output(state, output) + self._complete_agent_step(state, "risk", output) + risk_route = route_after_risk({"state": state}) + self._record_route( + state, + "risk", + risk_route, + risk_route, + self._risk_route_reason(risk_route), + ) + if state.latest_trace is not None: + state.latest_trace.current_branch = state.mode.value + return {"state": state, "event": event, "started_at": graph_state["started_at"]} + + def demand_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + self._start_step(state, "demand", "agent", graph_state["event"]) + output = self.demand_agent.run(state, graph_state["event"]) + self._record_output(state, output) + self._complete_agent_step(state, "demand", output) + next_node = route_after_demand({"state": state}) + self._record_route( + state, + "demand", + next_node, + next_node, + self._handoff_reason("demand", next_node), + ) + return graph_state + + def inventory_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + self._start_step(state, "inventory", "agent", graph_state["event"]) + output = self.inventory_agent.run(state, graph_state["event"]) + self._record_output(state, output) + self._complete_agent_step(state, "inventory", output) + next_node = route_after_inventory({"state": state}) + self._record_route( + state, + "inventory", + next_node, + next_node, + self._handoff_reason("inventory", next_node), + ) + return graph_state + + def supplier_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + self._start_step(state, "supplier", "agent", graph_state["event"]) + output = self.supplier_agent.run(state, graph_state["event"]) + self._record_output(state, output) + self._complete_agent_step(state, "supplier", output) + next_node = route_after_supplier({"state": state}) + self._record_route( + state, + "supplier", + next_node, + next_node, + self._handoff_reason("supplier", next_node), + ) + return graph_state + + def logistics_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + self._start_step(state, "logistics", "agent", graph_state["event"]) + output = self.logistics_agent.run(state, graph_state["event"]) + self._record_output(state, output) + self._complete_agent_step(state, "logistics", output) + next_node = route_after_logistics({"state": state}) + self._record_route( + state, + "logistics", + next_node, + next_node, + self._handoff_reason("logistics", next_node), + ) + return graph_state + + def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + self._start_step(state, "planner", "agent", graph_state["event"]) + output = self.planner_agent.run(state, graph_state["event"]) + self._record_output(state, output) + self._update_trace_from_plan(state) + latest_decision = state.decision_logs[-1] if state.decision_logs else None + self._complete_step( + state, + node_key="planner", + summary=output.notes_for_planner + or output.domain_summary + or "planner generated candidate plans", + reasoning_source="ai_assisted_reasoning" + if output.llm_used + else "deterministic_or_fallback", + observations=output.observations, + risks=output.risks, + downstream_impacts=output.downstream_impacts, + recommended_action_ids=output.recommended_action_ids, + tradeoffs=output.tradeoffs, + llm_used=output.llm_used, + llm_error=output.llm_error, + output_snapshot={ + "selected_plan_id": state.latest_plan_id, + "selected_strategy": state.latest_plan.strategy_label + if state.latest_plan + else None, + "generated_by": state.latest_plan.generated_by + if state.latest_plan + else None, + "candidate_count": len(latest_decision.candidate_evaluations) + if latest_decision + else 0, + "approval_required": state.latest_plan.approval_required + if state.latest_plan + else False, + }, + ) + self._record_route( + state, + "planner", + "critic", + "critic", + "planner always forwards candidate plans to the critic", + ) + return graph_state + + def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + self._start_step(state, "critic", "agent", graph_state["event"]) + output = self.critic_agent.run(state, graph_state["event"]) + state.agent_outputs[output.agent] = output + self._complete_step( + state, + node_key="critic", + summary=output.domain_summary + or output.notes_for_planner + or "critic reviewed the selected plan", + reasoning_source="ai_assisted_reasoning" + if output.llm_used + else "deterministic_or_fallback", + observations=output.observations, + risks=output.risks, + downstream_impacts=output.downstream_impacts, + recommended_action_ids=output.recommended_action_ids, + tradeoffs=output.tradeoffs, + llm_used=output.llm_used, + llm_error=output.llm_error, + output_snapshot={ + "critic_summary": state.decision_logs[-1].critic_summary + if state.decision_logs + else None, + "finding_count": len(state.decision_logs[-1].critic_findings) + if state.decision_logs + else 0, + }, + ) + critic_route = route_after_critic({"state": state}) + self._record_route( + state, + "critic", + critic_route, + critic_route, + self._critic_route_reason(state, critic_route), + ) + return graph_state + + def approval_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + self._start_step(state, "approval", "gate", graph_state["event"]) + state.mode = Mode.APPROVAL + self._update_trace_from_plan(state) + if state.latest_trace is not None: + state.latest_trace.terminal_stage = "approval" + state.latest_trace.execution_status = "pending_approval" + state.latest_trace.approval_pending = True + self._complete_step( + state, + node_key="approval", + summary=state.latest_plan.approval_reason + if state.latest_plan + else "approval required", + reasoning_source="deterministic_policy_guardrail", + output_snapshot={ + "decision_id": state.decision_logs[-1].decision_id + if state.decision_logs + else None, + "approval_required": True, + "approval_status": state.decision_logs[-1].approval_status.value + if state.decision_logs + else None, + }, + ) + state = self._finalize(state, graph_state["started_at"]) + return { + "state": state, + "event": graph_state["event"], + "started_at": graph_state["started_at"], + } + + def execution_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + event = graph_state["event"] + self._start_step(state, "execution", "execution", event) + execution_summary = "no plan executed" + execution_status = "no_op" + if state.latest_plan and not state.latest_plan.approval_required: + state.latest_plan.status = PlanStatus.APPLIED + state = apply_plan(state, state.latest_plan) + if state.decision_logs: + state.decision_logs[-1].approval_status = ApprovalStatus.AUTO_APPLIED + state.mode = Mode.CRISIS if event and state.active_events else Mode.NORMAL + execution_summary = ( + f"Applied {state.latest_plan.plan_id} using " + f"{state.latest_plan.strategy_label or 'unlabeled'} strategy" + ) + execution_status = "auto_applied" + self._update_trace_from_plan(state) + if state.latest_trace is not None: + state.latest_trace.terminal_stage = "execution" + state.latest_trace.execution_status = execution_status + state.latest_trace.approval_pending = False + self._complete_step( + state, + node_key="execution", + summary=execution_summary, + reasoning_source="deterministic_execution_guard", + output_snapshot={ + "execution_status": execution_status, + "latest_plan_id": state.latest_plan_id, + "mode_after_execution": state.mode.value, + }, + ) + state = self._finalize(state, graph_state["started_at"]) + return {"state": state, "event": event, "started_at": graph_state["started_at"]} + + def _compile(self): + graph = StateGraph(OrchestrationState) + graph.add_node("risk", self.risk_node) + graph.add_node("demand", self.demand_node) + graph.add_node("inventory", self.inventory_node) + graph.add_node("supplier", self.supplier_node) + graph.add_node("logistics", self.logistics_node) + graph.add_node("planner", self.planner_node) + graph.add_node("critic", self.critic_node) + graph.add_node("approval", self.approval_node) + graph.add_node("execution", self.execution_node) + + graph.add_edge(START, "risk") + graph.add_conditional_edges( + "risk", + route_after_risk, + { + "logistics": "logistics", + "supplier": "supplier", + "demand": "demand", + "planner": "planner", + "approval": "approval", + }, + ) + graph.add_conditional_edges( + "logistics", + route_after_logistics, + { + "supplier": "supplier", + "planner": "planner", + }, + ) + graph.add_conditional_edges( + "supplier", + route_after_supplier, + { + "logistics": "logistics", + "planner": "planner", + }, + ) + graph.add_conditional_edges( + "demand", + route_after_demand, + { + "inventory": "inventory", + }, + ) + graph.add_conditional_edges( + "inventory", + route_after_inventory, + { + "planner": "planner", + }, + ) + graph.add_conditional_edges( + "planner", + route_after_planner, + { + "critic": "critic", + }, + ) + graph.add_conditional_edges( + "critic", + route_after_critic, + { + "approval": "approval", + "execution": "execution", + }, + ) + graph.add_edge("approval", END) + graph.add_edge("execution", END) + return graph.compile() + + def invoke( + self, state: SystemState, event: Event | None = None, run_id: str | None = None + ) -> SystemState: + state.run_id = run_id or new_run_id() + result = self.graph.invoke( + { + "state": state, + "event": event, + "started_at": time.perf_counter(), + } + ) + return result["state"] + + +def build_graph() -> LangGraphControlTower: + return LangGraphControlTower() diff --git a/orchestrator/prompts.py b/orchestrator/prompts.py index 29338d4..7cfae20 100644 --- a/orchestrator/prompts.py +++ b/orchestrator/prompts.py @@ -3,9 +3,15 @@ Merge agent outputs into one plan. Do not invent inventory math, supplier scores, risk scores, or mode changes. Treat deterministic inputs as authoritative. +Write for supply chain operators, not engineers. +Summarize the situation in plain language: +- what changed +- why it matters now +- which constraints matter most +- what operational trade-off the selected plan makes """ - - + + AI_CANDIDATE_PLANNER_PROMPT = """Role: AI planner for an autonomous supply chain control tower. Use specialist signals to draft exactly three candidate strategies: @@ -15,72 +21,95 @@ Only use provided action ids. Do not invent actions, scores, approval decisions, or KPI math. -Return concise rationales and keep each strategy distinct. -""" - - -SPECIALIZED_AGENT_PROMPT = """Role: {agent_name} agent in a supply chain control tower. - -Read the current system state and produce recommendations only inside your domain. -Do not override global mode, scoring, or guardrails. -""" - - -SPECIALIST_AGENT_PROMPT = """Role: {agent_name} specialist agent in an autonomous supply chain control tower. - -Interpret the current operating picture for your domain. -You may rank or deprioritize candidate actions, but you must only use the provided action ids. -Do not invent new actions, change mode, or override deterministic scoring and approval guardrails. +Keep each strategy distinct. +Choose a right-sized number of actions for each strategy. +Do not include every available action by default. +Include only actions that materially improve the strategy objective. +For each rationale, explain: +- what operational problem the strategy addresses +- which evidence from specialist signals supports it +- the key trade-off in cost, service, risk, or recovery speed """ - - + + +SPECIALIZED_AGENT_PROMPT = """Role: {agent_name} agent in a supply chain control tower. + +Read the current system state and produce recommendations only inside your domain. +Do not override global mode, scoring, or guardrails. +""" + + +SPECIALIST_AGENT_PROMPT = """Role: {agent_name} specialist agent in an autonomous supply chain control tower. + +Interpret the current operating picture for your domain. +You may rank or deprioritize candidate actions, but you must only use the provided action ids. +Do not invent new actions, change mode, or override deterministic scoring and approval guardrails. +""" + + SPECIALIST_REASONING_PROMPT = """Return structured JSON grounded in the provided state, event, KPIs, and candidate actions. Focus on: -- what matters most in your domain right now -- downstream operational impacts -- the safest or strongest candidate action ids in ranked order +- what is happening in the domain right now +- why it is happening using concrete evidence from the provided state +- how serious it is operationally +- what happens if no action is taken +- the strongest candidate action ids in ranked order - tradeoffs the planner should reconcile -""" - +Write concise business-readable language. Avoid vague wording such as "may help" unless uncertainty is real. +""" + + DECISION_EXPLANATION_PROMPT = """Role: Explain a finalized supply chain decision for operators. Use only provided facts and score breakdowns. +Explain: +- the operational situation +- why the selected plan was chosen over the main alternative +- the trade-off being accepted +- whether the plan is optimized for normal mode or crisis mode """ - - -CRISIS_MODE_PROMPT = """Role: Planner in crisis mode. - -Prioritize stockout prevention, service restoration, and recovery speed over cost when scores are close. -""" - - + + +CRISIS_MODE_PROMPT = """Role: Planner in crisis mode. + +Prioritize stockout prevention, service restoration, and recovery speed over cost when scores are close. +""" + + CRITIC_PROMPT = """Role: Critic agent reviewing supply chain recovery candidates. Review evaluated candidate plans for blind spots, brittle assumptions, and operational cautions. Do not override deterministic scoring or approval logic. +Act like a reviewer preparing an operator for what could go wrong next. +Highlight: +- fragile dependencies +- hidden assumptions +- where recovery could slip +- what should be monitored next +- whether human approval is prudent """ - - -REFLECTION_PROMPT = """Role: Reflection agent for an autonomous supply chain control tower. - -Review the actual scenario outcome and write a short operational memory note. -Extract lessons, recurring pattern tags, and follow-up checks. -Use only the provided events, selected plan, KPI outcome, and approval result. -Do not invent metrics or change stored deterministic learning values. -""" - - -HUMAN_APPROVAL_PROMPT = """Role: Summarize a high-risk plan for a human approver. - -Return a one-line summary, approval reason, and the safest fallback alternative. -""" - - + + +REFLECTION_PROMPT = """Role: Reflection agent for an autonomous supply chain control tower. + +Review the actual scenario outcome and write a short operational memory note. +Extract lessons, recurring pattern tags, and follow-up checks. +Use only the provided events, selected plan, KPI outcome, and approval result. +Do not invent metrics or change stored deterministic learning values. +""" + + +HUMAN_APPROVAL_PROMPT = """Role: Summarize a high-risk plan for a human approver. + +Return a one-line summary, approval reason, and the safest fallback alternative. +""" + + LLM_ENRICHMENT_PROMPT = """Role: Generate user-facing control tower explanations. Use only the deterministic state, score breakdown, selected actions, approval reason, and KPI deltas provided. Do not invent business logic or change the decision. -Return concise operator-facing text. +Return concise operator-facing text that is business-readable, concrete, and operational. """ diff --git a/orchestrator/router.py b/orchestrator/router.py index 21ea39c..918637d 100644 --- a/orchestrator/router.py +++ b/orchestrator/router.py @@ -1,15 +1,15 @@ -from __future__ import annotations - -from typing import Mapping, cast - +from __future__ import annotations + +from typing import Mapping, cast + from core.enums import Mode, EventType from core.models import SystemState - - -def _state_from_graph(graph_state: Mapping[str, object]) -> SystemState: - return cast(SystemState, graph_state["state"]) - - + + +def _state_from_graph(graph_state: Mapping[str, object]) -> SystemState: + return cast(SystemState, graph_state["state"]) + + def route_after_risk(graph_state: Mapping[str, object]) -> str: state = _state_from_graph(graph_state) if state.pending_plan is not None or state.mode == Mode.APPROVAL: @@ -53,10 +53,10 @@ def route_after_inventory(graph_state: Mapping[str, object]) -> str: def route_after_planner(graph_state: Mapping[str, object]) -> str: return "critic" - - -def route_after_critic(graph_state: Mapping[str, object]) -> str: - state = _state_from_graph(graph_state) - if state.latest_plan and state.latest_plan.approval_required: - return "approval" - return "execution" + + +def route_after_critic(graph_state: Mapping[str, object]) -> str: + state = _state_from_graph(graph_state) + if state.latest_plan and state.latest_plan.approval_required: + return "approval" + return "execution" diff --git a/orchestrator/service.py b/orchestrator/service.py index 79b8d4f..e8669e1 100644 --- a/orchestrator/service.py +++ b/orchestrator/service.py @@ -1,152 +1,232 @@ -from __future__ import annotations - -from typing import Any -from uuid import uuid4 - -from actions.executor import apply_plan, simulate_actions +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from actions.executor import simulate_actions from core.enums import ActionType, ApprovalStatus, Mode, PlanStatus from core.memory import SQLiteStore -from core.models import Action, DecisionLog, Event, Plan, SystemState, TraceRouteDecision, TraceStep +from core.models import ( + Action, + CandidatePlanEvaluation, + DecisionLog, + Event, + Plan, + SystemState, + TraceRouteDecision, + TraceStep, +) from core.state import load_initial_state, utc_now from llm.service import enrich_plan_and_decision from orchestrator.graph import build_graph from policies.explainability import ( build_plan_summary, - build_winning_factors, - explain_rejected_actions, -) -from policies.guardrails import approval_required -from policies.scoring import compute_score -from simulation.learning import finalize_latest_scenario_run - - -class PendingApprovalError(RuntimeError): - pass - - -def _latest_event(state: SystemState) -> Event | None: - if not state.active_events: - return None - return state.active_events[-1] - - -def _save_state(state: SystemState, store: SQLiteStore) -> None: - store.save_state(state) - for decision_log in state.decision_logs: - store.save_decision_log(decision_log) + build_winning_factors, + explain_rejected_actions, +) +from policies.guardrails import approval_required +from policies.scoring import compute_score +from simulation.learning import finalize_latest_scenario_run + + +class PendingApprovalError(RuntimeError): + pass + + +def _latest_event(state: SystemState) -> Event | None: + if not state.active_events: + return None + return state.active_events[-1] + + +def _save_state(state: SystemState, store: SQLiteStore) -> None: + store.save_state(state) + for decision_log in state.decision_logs: + store.save_decision_log(decision_log) + + +def ensure_no_pending_plan(state: SystemState) -> None: + if state.pending_plan is not None: + raise PendingApprovalError( + "resolve the pending approval before running another daily plan or scenario" + ) + + +def _current_pending_decision(state: SystemState, decision_id: str) -> DecisionLog: + if not state.pending_plan or not state.decision_logs: + raise ValueError("no pending decision") + decision_log = state.decision_logs[-1] + if decision_log.decision_id != decision_id: + raise ValueError("decision not found") + return decision_log + + +def _mode_from_state(state: SystemState) -> Mode: + return Mode.CRISIS if state.active_events else Mode.NORMAL + + +def _append_approval_trace( + state: SystemState, + *, + outcome: str, + to_node: str, + summary: str, + execution_status: str, + decision_id: str | None, + approval_pending: bool, +) -> None: + if state.latest_trace is None: + return + now = utc_now() + state.latest_trace.current_branch = "approval" + state.latest_trace.route_decisions.append( + TraceRouteDecision( + from_node="approval", + outcome=outcome, + to_node=to_node, + reason=summary, + ) + ) + state.latest_trace.steps.append( + TraceStep( + step_id=f"step_{uuid4().hex[:8]}", + sequence=len(state.latest_trace.steps) + 1, + node_key="approval_resolution", + node_type="human_gate", + status="completed", + started_at=now, + completed_at=now, + duration_ms=0.0, + mode_snapshot=state.mode.value, + summary=summary, + reasoning_source="human_approval_action", + output_snapshot={ + "decision_id": decision_id, + "action": outcome, + "execution_status": execution_status, + "approval_pending": approval_pending, + }, + ) + ) + state.latest_trace.decision_id = decision_id + state.latest_trace.approval_pending = approval_pending + state.latest_trace.execution_status = execution_status + state.latest_trace.terminal_stage = "approval" if approval_pending or to_node == "closed" else "execution" + state.latest_trace.mode_after = state.mode.value + state.latest_trace.completed_at = now + state.latest_trace.status = "completed" + + +def _safer_action_key(action: Action) -> tuple[bool, float, float, float, float]: + return ( + action.action_type == ActionType.NO_OP, + action.estimated_risk_delta >= 0.0, + action.estimated_risk_delta, + action.estimated_cost_delta, + action.estimated_recovery_hours - action.estimated_service_delta, + ) -def ensure_no_pending_plan(state: SystemState) -> None: +def _merge_reason_parts(*parts: str) -> str: + merged: list[str] = [] + for part in parts: + value = part.strip() + if not value: + continue + if value.startswith("no approval required"): + continue + if value not in merged: + merged.append(value) + return "; ".join(merged) + + +def _action_lookup_for_pending_context(state: SystemState) -> dict[str, Action]: + by_id: dict[str, Action] = {} + for action in state.candidate_actions: + by_id[action.action_id] = action + if state.latest_plan is not None: + for action in state.latest_plan.actions: + by_id.setdefault(action.action_id, action) if state.pending_plan is not None: - raise PendingApprovalError( - "resolve the pending approval before running another daily plan or scenario" - ) + for action in state.pending_plan.actions: + by_id.setdefault(action.action_id, action) + return by_id -def _current_pending_decision(state: SystemState, decision_id: str) -> DecisionLog: - if not state.pending_plan or not state.decision_logs: - raise ValueError("no pending decision") - decision_log = state.decision_logs[-1] - if decision_log.decision_id != decision_id: - raise ValueError("decision not found") - return decision_log - - -def _mode_from_state(state: SystemState) -> Mode: - return Mode.CRISIS if state.active_events else Mode.NORMAL - - -def _append_approval_trace( - state: SystemState, - *, - outcome: str, - to_node: str, - summary: str, - execution_status: str, - decision_id: str | None, - approval_pending: bool, -) -> None: - if state.latest_trace is None: - return - now = utc_now() - state.latest_trace.current_branch = "approval" - state.latest_trace.route_decisions.append( - TraceRouteDecision( - from_node="approval", - outcome=outcome, - to_node=to_node, - reason=summary, - ) - ) - state.latest_trace.steps.append( - TraceStep( - step_id=f"step_{uuid4().hex[:8]}", - sequence=len(state.latest_trace.steps) + 1, - node_key="approval_resolution", - node_type="human_gate", - status="completed", - started_at=now, - completed_at=now, - duration_ms=0.0, - mode_snapshot=state.mode.value, - summary=summary, - reasoning_source="human_approval_action", - output_snapshot={ - "decision_id": decision_id, - "action": outcome, - "execution_status": execution_status, - "approval_pending": approval_pending, - }, - ) - ) - state.latest_trace.decision_id = decision_id - state.latest_trace.approval_pending = approval_pending - state.latest_trace.execution_status = execution_status - state.latest_trace.terminal_stage = "approval" if approval_pending or to_node == "closed" else "execution" - state.latest_trace.mode_after = state.mode.value - state.latest_trace.completed_at = now - state.latest_trace.status = "completed" - - -def _safer_action_key(action: Action) -> tuple[bool, float, float, float, float]: - return ( - action.action_type == ActionType.NO_OP, - action.estimated_risk_delta >= 0.0, - action.estimated_risk_delta, - action.estimated_cost_delta, - action.estimated_recovery_hours - action.estimated_service_delta, +def _build_safer_plan(state: SystemState, decision_log: DecisionLog) -> Plan: + assert state.pending_plan is not None + candidate_actions = list(state.pending_plan.actions) + + from policies.constraints import evaluate_hard_constraints, evaluate_soft_constraints + + feasible_candidates = [] + for act in candidate_actions: + dummy_plan = Plan( + plan_id="tmp", mode=state.mode, + score=0.0, score_breakdown={}, actions=[act] + ) + is_feas, vios = evaluate_hard_constraints(dummy_plan, state) + if is_feas: + feasible_candidates.append(act) + + if not feasible_candidates: + feasible_candidates = [ + Action( + action_id="act_no_op_safer", + action_type=ActionType.NO_OP, + target_id="system", + reason="no safer action available or feasible", + priority=0.0, + ) + ] + selected_actions = sorted(feasible_candidates, key=_safer_action_key)[:1] + target_mode = _mode_from_state(state) + simulated = simulate_actions(state, selected_actions) + score, breakdown = compute_score( + service_level=simulated.kpis.service_level, + total_cost=simulated.kpis.total_cost, + disruption_risk=simulated.kpis.disruption_risk, + recovery_speed=simulated.kpis.recovery_speed, + mode=target_mode, + baseline_cost=decision_log.before_kpis.total_cost, + ) + plan = Plan( + plan_id=f"plan_{uuid4().hex[:8]}", + mode=target_mode, + trigger_event_ids=[event.event_id for event in state.active_events], + actions=selected_actions, + score=score, + score_breakdown=breakdown, + strategy_label="safer_alternative", + generated_by="operator_safer_request", + planner_reasoning=build_plan_summary(decision_log.before_kpis, simulated.kpis, breakdown), + status=PlanStatus.PROPOSED, ) + soft_violations = evaluate_soft_constraints(plan, state) + plan.feasible = True + plan.violations = soft_violations + if soft_violations: + plan.mode_rationale = "Soft constraints warnings: " + "; ".join(v.message for v in soft_violations) + + needs_approval, reason = approval_required(plan, decision_log.before_kpis, simulated.kpis, _latest_event(state)) + plan.approval_required = needs_approval + plan.approval_reason = reason + return plan -def _build_safer_plan(state: SystemState, decision_log: DecisionLog) -> Plan: - assert state.pending_plan is not None - candidate_actions = list(state.pending_plan.actions) - - from policies.constraints import evaluate_hard_constraints, evaluate_soft_constraints - - feasible_candidates = [] - for act in candidate_actions: - dummy_plan = Plan( - plan_id="tmp", mode=state.mode, - score=0.0, score_breakdown={}, actions=[act] - ) - is_feas, vios = evaluate_hard_constraints(dummy_plan, state) - if is_feas: - feasible_candidates.append(act) +def _build_alternative_plan( + *, + state: SystemState, + decision_log: DecisionLog, + evaluation: CandidatePlanEvaluation, +) -> Plan: + by_id = _action_lookup_for_pending_context(state) + actions = [by_id[action_id] for action_id in evaluation.action_ids if action_id in by_id] + if not actions: + raise ValueError("selected alternative has no executable actions") - if not feasible_candidates: - feasible_candidates = [ - Action( - action_id="act_no_op_safer", - action_type=ActionType.NO_OP, - target_id="system", - reason="no safer action available or feasible", - priority=0.0, - ) - ] - selected_actions = sorted(feasible_candidates, key=_safer_action_key)[:1] target_mode = _mode_from_state(state) - simulated = simulate_actions(state, selected_actions) + simulated = simulate_actions(state, actions) score, breakdown = compute_score( service_level=simulated.kpis.service_level, total_cost=simulated.kpis.total_cost, @@ -155,99 +235,207 @@ def _build_safer_plan(state: SystemState, decision_log: DecisionLog) -> Plan: mode=target_mode, baseline_cost=decision_log.before_kpis.total_cost, ) + plan = Plan( plan_id=f"plan_{uuid4().hex[:8]}", mode=target_mode, trigger_event_ids=[event.event_id for event in state.active_events], - actions=selected_actions, + actions=actions, score=score, score_breakdown=breakdown, - planner_reasoning=build_plan_summary(decision_log.before_kpis, simulated.kpis, breakdown), + strategy_label=evaluation.strategy_label, + generated_by="operator_selected_alternative", + planner_reasoning=evaluation.rationale + or build_plan_summary( + decision_log.before_kpis, + simulated.kpis, + breakdown, + selected_actions=actions, + strategy_label=evaluation.strategy_label, + mode=target_mode, + mode_rationale=evaluation.mode_rationale, + ), status=PlanStatus.PROPOSED, + feasible=evaluation.feasible, + violations=list(evaluation.violations), + mode_rationale=evaluation.mode_rationale, ) - soft_violations = evaluate_soft_constraints(plan, state) - plan.feasible = True - plan.violations = soft_violations - if soft_violations: - plan.mode_rationale = "Soft constraints warnings: " + "; ".join(v.message for v in soft_violations) - - needs_approval, reason = approval_required(plan, decision_log.before_kpis, simulated.kpis, _latest_event(state)) - plan.approval_required = needs_approval - plan.approval_reason = reason - return plan + from policies.constraints import evaluate_soft_constraints -def run_daily_plan( - state: SystemState, - store: SQLiteStore, - graph: Any | None = None, - run_id: str | None = None, -) -> SystemState: - ensure_no_pending_plan(state) - updated = (graph or build_graph()).invoke(state, None, run_id=run_id) - _save_state(updated, store) - return updated - - -def reset_runtime(store: SQLiteStore) -> SystemState: - store.clear_all() - return load_initial_state() - - -def approve_pending_plan( - state: SystemState, - store: SQLiteStore, - decision_id: str, - approve: bool, - run_id: str | None = None, -) -> SystemState: - if run_id is not None: - state.run_id = run_id - decision_log = _current_pending_decision(state, decision_id) - pending_plan = state.pending_plan - assert pending_plan is not None + soft_violations = evaluate_soft_constraints(plan, state) + if soft_violations: + plan.violations = list(plan.violations) + soft_violations + needs_approval, reason = approval_required( + plan, + decision_log.before_kpis, + simulated.kpis, + _latest_event(state), + ) + plan.approval_required = True + plan.approval_reason = _merge_reason_parts( + f"operator selected alternative strategy ({evaluation.strategy_label})", + evaluation.approval_reason, + reason if needs_approval else "", + ) or "operator selected alternative strategy and kept the plan in manual approval" + return plan + + +def run_daily_plan( + state: SystemState, + store: SQLiteStore, + graph: Any | None = None, + run_id: str | None = None, +) -> SystemState: + ensure_no_pending_plan(state) + updated = (graph or build_graph()).invoke(state, None, run_id=run_id) + _save_state(updated, store) + return updated + + +def reset_runtime(store: SQLiteStore) -> SystemState: + store.clear_all() + return load_initial_state() + + +def approve_pending_plan( + state: SystemState, + store: SQLiteStore, + decision_id: str, + approve: bool, + run_id: str | None = None, +) -> SystemState: + if run_id is not None: + state.run_id = run_id + decision_log = _current_pending_decision(state, decision_id) + pending_plan = state.pending_plan + assert pending_plan is not None + if approve: pending_plan.status = PlanStatus.APPROVED - updated = apply_plan(state, pending_plan) + state.pending_plan = None + updated = state if updated.latest_plan: - updated.latest_plan.status = PlanStatus.APPLIED + updated.latest_plan.status = PlanStatus.APPROVED updated.mode = _mode_from_state(updated) updated.decision_logs[-1].approval_status = ApprovalStatus.APPROVED _append_approval_trace( updated, outcome="approve", to_node="execution", - summary="operator approved the pending plan and execution was applied", - execution_status="approved_and_applied", - decision_id=decision_log.decision_id, - approval_pending=False, - ) - else: - pending_plan.status = PlanStatus.REJECTED - state.pending_plan = None - state.mode = _mode_from_state(state) - decision_log.approval_status = ApprovalStatus.REJECTED - updated = state - _append_approval_trace( - updated, - outcome="reject", - to_node="closed", - summary="operator rejected the pending plan; no actions were applied", - execution_status="rejected", + summary="operator approved the pending plan; execution is ready for dispatch", + execution_status="approved_pending_dispatch", decision_id=decision_log.decision_id, approval_pending=False, ) + else: + pending_plan.status = PlanStatus.REJECTED + state.pending_plan = None + state.mode = _mode_from_state(state) + decision_log.approval_status = ApprovalStatus.REJECTED + updated = state + _append_approval_trace( + updated, + outcome="reject", + to_node="closed", + summary="operator rejected the pending plan; no actions were applied", + execution_status="rejected", + decision_id=decision_log.decision_id, + approval_pending=False, + ) + + finalize_latest_scenario_run(updated) + _save_state(updated, store) + return updated + + +def request_safer_plan( + state: SystemState, + store: SQLiteStore, + decision_id: str, + run_id: str | None = None, +) -> SystemState: + if run_id is not None: + state.run_id = run_id + previous_decision = _current_pending_decision(state, decision_id) + if state.pending_plan is None: + raise ValueError("no pending decision") + if state.pending_plan.generated_by == "operator_safer_request": + raise RuntimeError("safer alternative can only be requested once per approval cycle") + + previous_plan_actions = list(state.pending_plan.actions) + state.pending_plan.status = PlanStatus.REJECTED + previous_decision.approval_status = ApprovalStatus.REJECTED + safer_plan = _build_safer_plan(state, previous_decision) + safer_plan.approval_required = True + safer_plan.approval_reason = _merge_reason_parts( + safer_plan.approval_reason, + "operator requested a safer alternative; manual approval is required before dispatch", + ) + simulated = simulate_actions(state, safer_plan.actions) + winning_factors = build_winning_factors( + safer_plan.actions, + previous_decision.before_kpis, + simulated.kpis, + safer_plan.score_breakdown, + ) + rejection_reasons = explain_rejected_actions(previous_plan_actions, safer_plan.actions, 1) + new_decision = DecisionLog( + decision_id=f"dec_{uuid4().hex[:8]}", + plan_id=safer_plan.plan_id, + event_ids=safer_plan.trigger_event_ids, + before_kpis=previous_decision.before_kpis.model_copy(deep=True), + after_kpis=simulated.kpis, + selected_actions=[action.action_id for action in safer_plan.actions], + rejected_actions=rejection_reasons, + score_breakdown=safer_plan.score_breakdown, + rationale=safer_plan.planner_reasoning, + selection_reason=( + "Operator requested a safer alternative and the planner generated a lower-risk package for manual review." + ), + candidate_evaluations=previous_decision.candidate_evaluations, + winning_factors=winning_factors, + approval_required=True, + approval_reason=safer_plan.approval_reason, + approval_status=ApprovalStatus.PENDING, + feasible=safer_plan.feasible, + violations=safer_plan.violations, + mode_rationale=safer_plan.mode_rationale, + ) + enrich_plan_and_decision( + state=state, + event=_latest_event(state), + plan=safer_plan, + decision_log=new_decision, + ) + + state.latest_plan = safer_plan + state.latest_plan_id = safer_plan.plan_id + state.decision_logs.append(new_decision) + state.pending_plan = safer_plan + state.mode = Mode.APPROVAL + updated = state + _append_approval_trace( + updated, + outcome="safer_plan", + to_node="approval", + summary="operator requested a safer plan and a new approval candidate was generated", + execution_status="safer_plan_pending", + decision_id=new_decision.decision_id, + approval_pending=True, + ) finalize_latest_scenario_run(updated) _save_state(updated, store) return updated -def request_safer_plan( +def select_pending_alternative_plan( state: SystemState, store: SQLiteStore, decision_id: str, + strategy_label: str, run_id: str | None = None, ) -> SystemState: if run_id is not None: @@ -256,79 +444,87 @@ def request_safer_plan( if state.pending_plan is None: raise ValueError("no pending decision") + normalized_label = strategy_label.strip().lower() + evaluation = next( + ( + item + for item in previous_decision.candidate_evaluations + if item.strategy_label.strip().lower() == normalized_label + ), + None, + ) + if evaluation is None: + raise ValueError(f"candidate strategy not found: {strategy_label}") + if state.pending_plan.strategy_label == evaluation.strategy_label: + raise RuntimeError(f"{evaluation.strategy_label} is already the selected pending strategy") + previous_plan_actions = list(state.pending_plan.actions) state.pending_plan.status = PlanStatus.REJECTED previous_decision.approval_status = ApprovalStatus.REJECTED - safer_plan = _build_safer_plan(state, previous_decision) - simulated = simulate_actions(state, safer_plan.actions) + + alternative_plan = _build_alternative_plan( + state=state, + decision_log=previous_decision, + evaluation=evaluation, + ) + simulated = simulate_actions(state, alternative_plan.actions) winning_factors = build_winning_factors( - safer_plan.actions, + alternative_plan.actions, previous_decision.before_kpis, simulated.kpis, - safer_plan.score_breakdown, + alternative_plan.score_breakdown, + ) + rejection_reasons = explain_rejected_actions( + previous_plan_actions, + alternative_plan.actions, + len(alternative_plan.actions), + ) + + selection_reason = ( + f"Operator selected the {evaluation.strategy_label} alternative for manual execution review." ) - rejection_reasons = explain_rejected_actions(previous_plan_actions, safer_plan.actions, 1) new_decision = DecisionLog( decision_id=f"dec_{uuid4().hex[:8]}", - plan_id=safer_plan.plan_id, - event_ids=safer_plan.trigger_event_ids, + plan_id=alternative_plan.plan_id, + event_ids=alternative_plan.trigger_event_ids, before_kpis=previous_decision.before_kpis.model_copy(deep=True), after_kpis=simulated.kpis, - selected_actions=[action.action_id for action in safer_plan.actions], + selected_actions=[action.action_id for action in alternative_plan.actions], rejected_actions=rejection_reasons, - score_breakdown=safer_plan.score_breakdown, - rationale=safer_plan.planner_reasoning, + score_breakdown=alternative_plan.score_breakdown, + rationale=alternative_plan.planner_reasoning, + selection_reason=selection_reason, + candidate_evaluations=previous_decision.candidate_evaluations, winning_factors=winning_factors, - approval_required=safer_plan.approval_required, - approval_reason=( - safer_plan.approval_reason - if safer_plan.approval_required - else "no approval required: thresholds not triggered" - ), - approval_status=ApprovalStatus.PENDING if safer_plan.approval_required else ApprovalStatus.AUTO_APPLIED, - feasible=safer_plan.feasible, - violations=safer_plan.violations, - mode_rationale=safer_plan.mode_rationale, + approval_required=True, + approval_reason=alternative_plan.approval_reason, + approval_status=ApprovalStatus.PENDING, + feasible=alternative_plan.feasible, + violations=alternative_plan.violations, + mode_rationale=alternative_plan.mode_rationale, ) enrich_plan_and_decision( state=state, event=_latest_event(state), - plan=safer_plan, + plan=alternative_plan, decision_log=new_decision, ) - state.latest_plan = safer_plan - state.latest_plan_id = safer_plan.plan_id + state.latest_plan = alternative_plan + state.latest_plan_id = alternative_plan.plan_id + state.pending_plan = alternative_plan + state.mode = Mode.APPROVAL state.decision_logs.append(new_decision) + _append_approval_trace( + state, + outcome="select_alternative", + to_node="approval", + summary=selection_reason, + execution_status="alternative_pending", + decision_id=new_decision.decision_id, + approval_pending=True, + ) - if safer_plan.approval_required: - state.pending_plan = safer_plan - state.mode = Mode.APPROVAL - updated = state - _append_approval_trace( - updated, - outcome="safer_plan", - to_node="approval", - summary="operator requested a safer plan and a new approval candidate was generated", - execution_status="safer_plan_pending", - decision_id=new_decision.decision_id, - approval_pending=True, - ) - else: - safer_plan.status = PlanStatus.APPLIED - updated = apply_plan(state, safer_plan) - updated.mode = _mode_from_state(updated) - updated.decision_logs[-1].approval_status = ApprovalStatus.AUTO_APPLIED - _append_approval_trace( - updated, - outcome="safer_plan", - to_node="execution", - summary="operator requested a safer plan and the fallback plan auto-applied without approval", - execution_status="safer_plan_auto_applied", - decision_id=new_decision.decision_id, - approval_pending=False, - ) - - finalize_latest_scenario_run(updated) - _save_state(updated, store) - return updated + finalize_latest_scenario_run(state) + _save_state(state, store) + return state diff --git a/package-lock.json b/package-lock.json index e4259e7..ec39045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,654 +1,654 @@ -{ - "name": "chain-copilot", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@radix-ui/react-popover": "^1.1.15" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.5" - } - }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - } - } -} +{ + "name": "chain-copilot", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@radix-ui/react-popover": "^1.1.15" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index 70061ff..1a1198e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ -{ - "dependencies": { - "@radix-ui/react-popover": "^1.1.15" - } -} +{ + "dependencies": { + "@radix-ui/react-popover": "^1.1.15" + } +} diff --git a/policies/constraints.py b/policies/constraints.py index 3e774e3..2b7e4d0 100644 --- a/policies/constraints.py +++ b/policies/constraints.py @@ -1,625 +1,625 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod - -from core.enums import ActionType, ConstraintViolationCode, EventType, Mode -from core.models import Event, Plan, SystemState, SupplierRecord, ConstraintViolation as Violation - - -# --------------------------------------------------------------------------- -# Mode rationale -# --------------------------------------------------------------------------- - -def mode_rationale(state: SystemState, event: Event | None) -> str: - if event is None: - return "normal mode selected because no disruption event is active" - if event.type == EventType.COMPOUND: - return "crisis mode selected because a compound disruption affects multiple domains" - if event.severity >= 0.65: - return f"crisis mode selected because event severity {event.severity:.2f} exceeds the crisis threshold" - if state.kpis.service_level < 0.93: - return ( - f"crisis mode selected because service level {state.kpis.service_level:.2%} " - "fell below the resilience threshold" - ) - if state.kpis.stockout_risk > 0.30: - return ( - f"crisis mode selected because stockout risk {state.kpis.stockout_risk:.2%} " - "exceeds the resilience threshold" - ) - if state.mode == Mode.NORMAL: - return "normal mode selected because disruption severity and service risk remain within operating limits" - return "current mode retained based on active disruption context" - - -# --------------------------------------------------------------------------- -# Context helpers -# --------------------------------------------------------------------------- - -def _blocked_routes(state: SystemState, event: Event | None) -> set[str]: - blocked = { - item.payload.get("route_id") - for item in state.active_events - if item.type in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND} - } - if event is not None and event.type in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND}: - blocked.add(event.payload.get("route_id")) - return {item for item in blocked if item} - - -def _delayed_suppliers(state: SystemState, event: Event | None) -> set[str]: - delayed = { - item.payload.get("supplier_id") - for item in state.active_events - if item.type in {EventType.SUPPLIER_DELAY, EventType.COMPOUND} - } - if event is not None and event.type in {EventType.SUPPLIER_DELAY, EventType.COMPOUND}: - delayed.add(event.payload.get("supplier_id")) - return {item for item in delayed if item} - - -def _warehouse_stock(state: SystemState, warehouse_id: str) -> int: - return sum( - item.on_hand + item.incoming_qty - for item in state.inventory.values() - if item.warehouse_id == warehouse_id - ) - - -def _reorder_additions_per_warehouse(plan: Plan, state: SystemState) -> dict[str, int]: - totals: dict[str, int] = {} - for action in plan.actions: - if action.action_type != ActionType.REORDER: - continue - item = state.inventory.get(action.target_id) - if item is None: - continue - qty = int(action.parameters.get("quantity", 0)) - totals[item.warehouse_id] = totals.get(item.warehouse_id, 0) + qty - return totals - - -def _supplier_record( - state: SystemState, - supplier_id: str, - sku: str | None = None, -) -> SupplierRecord | None: - if sku is not None: - supplier = state.suppliers.get(f"{supplier_id}_{sku}") - if supplier is not None: - return supplier - supplier = state.suppliers.get(supplier_id) - if supplier is not None: - return supplier - for record in state.suppliers.values(): - if record.supplier_id == supplier_id and (sku is None or record.sku == sku): - return record - return None - - -# --------------------------------------------------------------------------- -# Base rule -# --------------------------------------------------------------------------- - -class BaseRule(ABC): - """Abstract base class for all logistics constraint rules.""" - - @property - @abstractmethod - def code(self) -> ConstraintViolationCode: - """Unique violation code for this rule.""" - - @property - def priority(self) -> int: - """Evaluation priority; lower value runs first.""" - return 100 - - @property - def is_critical(self) -> bool: - """When True, the engine short-circuits on the first violation.""" - return False - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - """Return violations found by this rule.""" - - -# --------------------------------------------------------------------------- -# Hard constraint rules -# --------------------------------------------------------------------------- - -class SupplierExistenceRule(BaseRule): - code = ConstraintViolationCode.SUPPLIER_NOT_FOUND - priority = 10 - is_critical = True - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type != ActionType.SWITCH_SUPPLIER: - continue - supplier_id = action.parameters.get("supplier_id") or action.target_id - if _supplier_record(state, supplier_id, action.target_id) is None: - violations.append(Violation( - code=self.code, - message=f"supplier {supplier_id} does not exist", - )) - return violations - - -class SupplierStatusRule(BaseRule): - """Rejects plans that reference an inactive or delayed supplier.""" - code = ConstraintViolationCode.SUPPLIER_BLOCKED - priority = 20 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type != ActionType.SWITCH_SUPPLIER: - continue - supplier_id = action.parameters.get("supplier_id") or action.target_id - supplier = _supplier_record(state, supplier_id, action.target_id) - if supplier is None: - continue - if supplier.status != "active": - violations.append(Violation( - code=self.code, - message=f"supplier {supplier_id} is not active (status: {supplier.status})", - )) - return violations - - -class SupplierSkuMatchRule(BaseRule): - """Ensures the chosen supplier actually supplies the target SKU.""" - code = ConstraintViolationCode.SUPPLIER_NOT_FOUND - priority = 25 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type != ActionType.SWITCH_SUPPLIER: - continue - supplier_id = action.parameters.get("supplier_id") or action.target_id - supplier = _supplier_record(state, supplier_id, action.target_id) - if supplier is None: - continue - if supplier.sku != action.target_id: - violations.append(Violation( - code=self.code, - message=f"supplier {supplier_id} does not supply SKU {action.target_id}", - )) - return violations - - -class SupplierDelayRule(BaseRule): - """Blocks switch to a supplier that is currently delayed.""" - code = ConstraintViolationCode.SUPPLIER_BLOCKED - priority = 30 - - def __init__(self, event: Event | None = None) -> None: - self._event = event - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - delayed = _delayed_suppliers(state, self._event) - violations = [] - for action in plan.actions: - if action.action_type != ActionType.SWITCH_SUPPLIER: - continue - supplier_id = action.parameters.get("supplier_id") or action.target_id - if supplier_id in delayed: - violations.append(Violation( - code=self.code, - message=f"supplier {supplier_id} is currently delayed by an active disruption", - )) - return violations - - -class RouteExistenceRule(BaseRule): - code = ConstraintViolationCode.ROUTE_NOT_FOUND - priority = 10 - is_critical = True - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type != ActionType.REROUTE: - continue - route_id = action.parameters.get("route_id") or action.target_id - if route_id not in state.routes: - violations.append(Violation( - code=self.code, - message=f"route {route_id} does not exist", - )) - return violations - - -class RouteBlockedRule(BaseRule): - code = ConstraintViolationCode.ROUTE_BLOCKED - priority = 20 - - def __init__(self, event: Event | None = None) -> None: - self._event = event - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - blocked = _blocked_routes(state, self._event) - violations = [] - for action in plan.actions: - if action.action_type != ActionType.REROUTE: - continue - route_id = action.parameters.get("route_id") or action.target_id - route = state.routes.get(route_id) - if route is None: - continue - if route.status == "blocked" or route_id in blocked: - violations.append(Violation( - code=self.code, - message=f"route {route_id} is blocked by an active disruption", - )) - return violations - - -class ReorderQuantityRule(BaseRule): - """Rejects reorder actions with a zero or negative quantity.""" - code = ConstraintViolationCode.MOQ_NOT_MET - priority = 15 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type != ActionType.REORDER: - continue - if state.inventory.get(action.target_id) is None: - violations.append(Violation( - code=ConstraintViolationCode.INVENTORY_SHORTAGE, - message=f"inventory target {action.target_id} does not exist", - )) - continue - qty = int(action.parameters.get("quantity", 0) or 0) - if qty <= 0: - violations.append(Violation( - code=self.code, - message=f"reorder quantity must be greater than zero for {action.target_id}", - )) - return violations - - -class WarehouseCapacityRule(BaseRule): - """Blocks plans where the aggregate reorder volume exceeds warehouse capacity.""" - code = ConstraintViolationCode.CAPACITY_EXCEEDED - priority = 40 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - additions = _reorder_additions_per_warehouse(plan, state) - for wh_id, added in additions.items(): - wh = state.warehouses.get(wh_id) - if wh is None: - continue - current = _warehouse_stock(state, wh_id) - projected = current + added - if projected > wh.capacity: - violations.append(Violation( - code=self.code, - message=( - f"warehouse {wh_id} capacity {wh.capacity} would be exceeded " - f"by projected stock {projected}" - ), - )) - return violations - - -class RebalanceRule(BaseRule): - """Validates rebalance actions for quantity and availability.""" - code = ConstraintViolationCode.INVENTORY_SHORTAGE - priority = 20 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type != ActionType.REBALANCE: - continue - item = state.inventory.get(action.target_id) - qty = int(action.parameters.get("quantity", 0) or 0) - if item is None: - violations.append(Violation( - code=self.code, - message=f"inventory target {action.target_id} does not exist", - )) - continue - if qty <= 0: - violations.append(Violation( - code=self.code, - message=f"rebalance quantity must be greater than zero for {action.target_id}", - )) - continue - available = item.on_hand + item.incoming_qty - if qty > available: - violations.append(Violation( - code=self.code, - message=( - f"rebalance quantity {qty} exceeds available inventory {available} " - f"for {action.target_id}" - ), - )) - return violations - - -class MOQRule(BaseRule): - """Enforces minimum order quantity requirements passed via action parameters.""" - code = ConstraintViolationCode.MOQ_NOT_MET - priority = 35 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type != ActionType.REORDER: - continue - moq = int(action.parameters.get("moq_required", 0)) - qty = int(action.parameters.get("quantity", 0)) - if moq and qty < moq: - violations.append(Violation( - code=self.code, - message=f"reorder quantity {qty} is below MOQ {moq} for {action.target_id}", - )) - return violations - - -class SupplierCapacityRule(BaseRule): - """Rejects orders that exceed the supplier's declared capacity.""" - code = ConstraintViolationCode.SUPPLIER_CAPACITY_EXCEEDED - priority = 40 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type not in {ActionType.REORDER, ActionType.SWITCH_SUPPLIER}: - continue - item = state.inventory.get(action.target_id) - if item is None: - continue - qty = int(action.parameters.get("quantity", 0)) or item.forecast_qty - supplier_id = action.parameters.get("supplier_id") or item.preferred_supplier_id - supplier = _supplier_record(state, supplier_id, item.sku) - if supplier and qty > supplier.capacity: - violations.append(Violation( - code=self.code, - message=( - f"order quantity {qty} exceeds supplier {supplier_id} " - f"capacity of {supplier.capacity}" - ), - )) - return violations - - -class SLARule(BaseRule): - """Flags routes whose transit time exceeds the action's max allowed ETA.""" - code = ConstraintViolationCode.SLA_VIOLATED - priority = 45 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - violations = [] - for action in plan.actions: - if action.action_type not in {ActionType.REORDER, ActionType.REROUTE}: - continue - route_id = action.parameters.get("route_id") - if not route_id: - item = state.inventory.get(action.target_id) - route_id = item.preferred_route_id if item else None - if not route_id: - continue - route = state.routes.get(route_id) - if route is None: - continue - max_eta = int(action.parameters.get("max_eta_days", 999)) - if route.transit_days > max_eta: - violations.append(Violation( - code=self.code, - message=( - f"route {route_id} transit {route.transit_days}d " - f"exceeds max allowed {max_eta}d" - ), - )) - return violations - - -# --------------------------------------------------------------------------- -# Soft constraint rules (warnings) -# --------------------------------------------------------------------------- - -class BackupSupplierRule(BaseRule): - """Warning: switching to a non-primary supplier adds overhead.""" - code = ConstraintViolationCode.SUPPLIER_BLOCKED - priority = 10 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - warnings = [] - for action in plan.actions: - if action.action_type != ActionType.SWITCH_SUPPLIER: - continue - supplier_id = action.parameters.get("supplier_id") or action.target_id - supplier = _supplier_record(state, supplier_id, action.target_id) - if supplier and not supplier.is_primary: - warnings.append(Violation( - code=self.code, - message=f"switching to backup supplier {supplier_id} increases operational overhead", - )) - return warnings - - -class CostToleranceRule(BaseRule): - """Warning: action cost delta is unusually high.""" - code = ConstraintViolationCode.SLA_VIOLATED - priority = 20 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - warnings = [] - for action in plan.actions: - if action.estimated_cost_delta > 5_000: - warnings.append(Violation( - code=self.code, - message=( - f"action {action.action_id} has high estimated cost delta " - f"(${action.estimated_cost_delta:,.0f})" - ), - )) - return warnings - - -class LowReliabilityRule(BaseRule): - """Warning: selected supplier has reliability below the acceptable threshold.""" - code = ConstraintViolationCode.RELIABILITY_LOW - priority = 30 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - warnings = [] - for action in plan.actions: - supplier_id = action.parameters.get("supplier_id") - if not supplier_id: - continue - supplier = _supplier_record(state, supplier_id, action.target_id) - if supplier and supplier.reliability < 0.85: - warnings.append(Violation( - code=self.code, - message=f"supplier {supplier_id} has low reliability ({supplier.reliability:.0%})", - )) - return warnings - - -class HighRouteRiskRule(BaseRule): - """Warning: chosen route has an elevated risk score.""" - code = ConstraintViolationCode.RISK_HIGH - priority = 30 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - warnings = [] - for action in plan.actions: - if action.action_type != ActionType.REROUTE: - continue - route_id = action.parameters.get("route_id") or action.target_id - route = state.routes.get(route_id) - if route and route.risk_score > 0.4: - warnings.append(Violation( - code=self.code, - message=f"route {route_id} has a high risk score ({route.risk_score:.2f})", - )) - return warnings - - -class WarehouseUtilizationRule(BaseRule): - """Warning: reorder plans push warehouse utilization close to its limit.""" - code = ConstraintViolationCode.CAPACITY_EXCEEDED - priority = 40 - - def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: - warnings = [] - additions = _reorder_additions_per_warehouse(plan, state) - for wh_id, added in additions.items(): - wh = state.warehouses.get(wh_id) - if wh is None or wh.capacity == 0: - continue - current = _warehouse_stock(state, wh_id) - utilization = (current + added) / wh.capacity - if 0.85 <= utilization <= 1.0: - warnings.append(Violation( - code=self.code, - message=f"warehouse {wh_id} will reach {utilization:.0%} utilization after reorder", - )) - return warnings - - -# --------------------------------------------------------------------------- -# Constraint engine -# --------------------------------------------------------------------------- - -class ConstraintEngine: - """Runs an ordered list of rules and accumulates violations.""" - - def __init__(self, rules: list[BaseRule]) -> None: - self.rules = sorted(rules, key=lambda r: r.priority) - - def evaluate(self, plan: Plan, state: SystemState) -> tuple[bool, list[Violation]]: - all_violations: list[Violation] = [] - for rule in self.rules: - found = rule.evaluate(plan, state) - if found: - all_violations.extend(found) - if rule.is_critical: - return False, all_violations - return len(all_violations) == 0, all_violations - - -# --------------------------------------------------------------------------- -# Pre-built engines -# --------------------------------------------------------------------------- - -def _make_hard_engine(event: Event | None = None) -> ConstraintEngine: - return ConstraintEngine([ - SupplierExistenceRule(), - SupplierStatusRule(), - SupplierSkuMatchRule(), - SupplierDelayRule(event), - RouteExistenceRule(), - RouteBlockedRule(event), - ReorderQuantityRule(), - RebalanceRule(), - MOQRule(), - WarehouseCapacityRule(), - SupplierCapacityRule(), - SLARule(), - ]) - - -_SOFT_ENGINE = ConstraintEngine([ - BackupSupplierRule(), - CostToleranceRule(), - LowReliabilityRule(), - HighRouteRiskRule(), - WarehouseUtilizationRule(), -]) - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - -def evaluate_hard_constraints( - plan: Plan, - state: SystemState, - event: Event | None = None, -) -> tuple[bool, list[Violation]]: - """Return (feasible, violations) for all hard constraints.""" - return _make_hard_engine(event).evaluate(plan, state) - - -def evaluate_soft_constraints( - plan: Plan, - state: SystemState, -) -> list[Violation]: - """Return soft-constraint warnings for a plan.""" - _, warnings = _SOFT_ENGINE.evaluate(plan, state) - for w in warnings: - w.severity = "soft" - return warnings - - -def evaluate_plan_constraints( - *, - state: SystemState, - event: Event | None, - actions: list, -) -> list[Violation]: - """Compatibility shim used by planner/_evaluate_candidate. - - Wraps the rule engine so callers that pass a raw action list still work - without creating a full Plan object. - """ - from core.models import Plan as _Plan - - dummy = _Plan( - plan_id="eval_tmp", - mode=state.mode, - actions=actions, - score=0.0, - score_breakdown={}, - ) - _, violations = evaluate_hard_constraints(dummy, state, event) - return violations +from __future__ import annotations + +from abc import ABC, abstractmethod + +from core.enums import ActionType, ConstraintViolationCode, EventType, Mode +from core.models import Event, Plan, SystemState, SupplierRecord, ConstraintViolation as Violation + + +# --------------------------------------------------------------------------- +# Mode rationale +# --------------------------------------------------------------------------- + +def mode_rationale(state: SystemState, event: Event | None) -> str: + if event is None: + return "Normal mode selected because no disruption event is active" + if event.type == EventType.COMPOUND: + return "Crisis mode selected because a compound disruption affects multiple domains" + if event.severity >= 0.65: + return f"Crisis mode selected because event severity {event.severity:.2f} exceeds the crisis threshold" + if state.kpis.service_level < 0.93: + return ( + f"Crisis mode selected because service level {state.kpis.service_level:.2%} " + "fell below the resilience threshold" + ) + if state.kpis.stockout_risk > 0.30: + return ( + f"Crisis mode selected because stockout risk {state.kpis.stockout_risk:.2%} " + "exceeds the resilience threshold" + ) + if state.mode == Mode.NORMAL: + return "Normal mode selected because disruption severity and service risk remain within operating limits" + return "Current mode retained based on active disruption context" + + +# --------------------------------------------------------------------------- +# Context helpers +# --------------------------------------------------------------------------- + +def _blocked_routes(state: SystemState, event: Event | None) -> set[str]: + blocked = { + item.payload.get("route_id") + for item in state.active_events + if item.type in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND} + } + if event is not None and event.type in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND}: + blocked.add(event.payload.get("route_id")) + return {item for item in blocked if item} + + +def _delayed_suppliers(state: SystemState, event: Event | None) -> set[str]: + delayed = { + item.payload.get("supplier_id") + for item in state.active_events + if item.type in {EventType.SUPPLIER_DELAY, EventType.COMPOUND} + } + if event is not None and event.type in {EventType.SUPPLIER_DELAY, EventType.COMPOUND}: + delayed.add(event.payload.get("supplier_id")) + return {item for item in delayed if item} + + +def _warehouse_stock(state: SystemState, warehouse_id: str) -> int: + return sum( + item.on_hand + item.incoming_qty + for item in state.inventory.values() + if item.warehouse_id == warehouse_id + ) + + +def _reorder_additions_per_warehouse(plan: Plan, state: SystemState) -> dict[str, int]: + totals: dict[str, int] = {} + for action in plan.actions: + if action.action_type != ActionType.REORDER: + continue + item = state.inventory.get(action.target_id) + if item is None: + continue + qty = int(action.parameters.get("quantity", 0)) + totals[item.warehouse_id] = totals.get(item.warehouse_id, 0) + qty + return totals + + +def _supplier_record( + state: SystemState, + supplier_id: str, + sku: str | None = None, +) -> SupplierRecord | None: + if sku is not None: + supplier = state.suppliers.get(f"{supplier_id}_{sku}") + if supplier is not None: + return supplier + supplier = state.suppliers.get(supplier_id) + if supplier is not None: + return supplier + for record in state.suppliers.values(): + if record.supplier_id == supplier_id and (sku is None or record.sku == sku): + return record + return None + + +# --------------------------------------------------------------------------- +# Base rule +# --------------------------------------------------------------------------- + +class BaseRule(ABC): + """Abstract base class for all logistics constraint rules.""" + + @property + @abstractmethod + def code(self) -> ConstraintViolationCode: + """Unique violation code for this rule.""" + + @property + def priority(self) -> int: + """Evaluation priority; lower value runs first.""" + return 100 + + @property + def is_critical(self) -> bool: + """When True, the engine short-circuits on the first violation.""" + return False + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + """Return violations found by this rule.""" + + +# --------------------------------------------------------------------------- +# Hard constraint rules +# --------------------------------------------------------------------------- + +class SupplierExistenceRule(BaseRule): + code = ConstraintViolationCode.SUPPLIER_NOT_FOUND + priority = 10 + is_critical = True + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type != ActionType.SWITCH_SUPPLIER: + continue + supplier_id = action.parameters.get("supplier_id") or action.target_id + if _supplier_record(state, supplier_id, action.target_id) is None: + violations.append(Violation( + code=self.code, + message=f"supplier {supplier_id} does not exist", + )) + return violations + + +class SupplierStatusRule(BaseRule): + """Rejects plans that reference an inactive or delayed supplier.""" + code = ConstraintViolationCode.SUPPLIER_BLOCKED + priority = 20 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type != ActionType.SWITCH_SUPPLIER: + continue + supplier_id = action.parameters.get("supplier_id") or action.target_id + supplier = _supplier_record(state, supplier_id, action.target_id) + if supplier is None: + continue + if supplier.status != "active": + violations.append(Violation( + code=self.code, + message=f"supplier {supplier_id} is not active (status: {supplier.status})", + )) + return violations + + +class SupplierSkuMatchRule(BaseRule): + """Ensures the chosen supplier actually supplies the target SKU.""" + code = ConstraintViolationCode.SUPPLIER_NOT_FOUND + priority = 25 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type != ActionType.SWITCH_SUPPLIER: + continue + supplier_id = action.parameters.get("supplier_id") or action.target_id + supplier = _supplier_record(state, supplier_id, action.target_id) + if supplier is None: + continue + if supplier.sku != action.target_id: + violations.append(Violation( + code=self.code, + message=f"supplier {supplier_id} does not supply SKU {action.target_id}", + )) + return violations + + +class SupplierDelayRule(BaseRule): + """Blocks switch to a supplier that is currently delayed.""" + code = ConstraintViolationCode.SUPPLIER_BLOCKED + priority = 30 + + def __init__(self, event: Event | None = None) -> None: + self._event = event + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + delayed = _delayed_suppliers(state, self._event) + violations = [] + for action in plan.actions: + if action.action_type != ActionType.SWITCH_SUPPLIER: + continue + supplier_id = action.parameters.get("supplier_id") or action.target_id + if supplier_id in delayed: + violations.append(Violation( + code=self.code, + message=f"supplier {supplier_id} is currently delayed by an active disruption", + )) + return violations + + +class RouteExistenceRule(BaseRule): + code = ConstraintViolationCode.ROUTE_NOT_FOUND + priority = 10 + is_critical = True + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type != ActionType.REROUTE: + continue + route_id = action.parameters.get("route_id") or action.target_id + if route_id not in state.routes: + violations.append(Violation( + code=self.code, + message=f"route {route_id} does not exist", + )) + return violations + + +class RouteBlockedRule(BaseRule): + code = ConstraintViolationCode.ROUTE_BLOCKED + priority = 20 + + def __init__(self, event: Event | None = None) -> None: + self._event = event + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + blocked = _blocked_routes(state, self._event) + violations = [] + for action in plan.actions: + if action.action_type != ActionType.REROUTE: + continue + route_id = action.parameters.get("route_id") or action.target_id + route = state.routes.get(route_id) + if route is None: + continue + if route.status == "blocked" or route_id in blocked: + violations.append(Violation( + code=self.code, + message=f"route {route_id} is blocked by an active disruption", + )) + return violations + + +class ReorderQuantityRule(BaseRule): + """Rejects reorder actions with a zero or negative quantity.""" + code = ConstraintViolationCode.MOQ_NOT_MET + priority = 15 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type != ActionType.REORDER: + continue + if state.inventory.get(action.target_id) is None: + violations.append(Violation( + code=ConstraintViolationCode.INVENTORY_SHORTAGE, + message=f"inventory target {action.target_id} does not exist", + )) + continue + qty = int(action.parameters.get("quantity", 0) or 0) + if qty <= 0: + violations.append(Violation( + code=self.code, + message=f"reorder quantity must be greater than zero for {action.target_id}", + )) + return violations + + +class WarehouseCapacityRule(BaseRule): + """Blocks plans where the aggregate reorder volume exceeds warehouse capacity.""" + code = ConstraintViolationCode.CAPACITY_EXCEEDED + priority = 40 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + additions = _reorder_additions_per_warehouse(plan, state) + for wh_id, added in additions.items(): + wh = state.warehouses.get(wh_id) + if wh is None: + continue + current = _warehouse_stock(state, wh_id) + projected = current + added + if projected > wh.capacity: + violations.append(Violation( + code=self.code, + message=( + f"warehouse {wh_id} capacity {wh.capacity} would be exceeded " + f"by projected stock {projected}" + ), + )) + return violations + + +class RebalanceRule(BaseRule): + """Validates rebalance actions for quantity and availability.""" + code = ConstraintViolationCode.INVENTORY_SHORTAGE + priority = 20 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type != ActionType.REBALANCE: + continue + item = state.inventory.get(action.target_id) + qty = int(action.parameters.get("quantity", 0) or 0) + if item is None: + violations.append(Violation( + code=self.code, + message=f"inventory target {action.target_id} does not exist", + )) + continue + if qty <= 0: + violations.append(Violation( + code=self.code, + message=f"rebalance quantity must be greater than zero for {action.target_id}", + )) + continue + available = item.on_hand + item.incoming_qty + if qty > available: + violations.append(Violation( + code=self.code, + message=( + f"rebalance quantity {qty} exceeds available inventory {available} " + f"for {action.target_id}" + ), + )) + return violations + + +class MOQRule(BaseRule): + """Enforces minimum order quantity requirements passed via action parameters.""" + code = ConstraintViolationCode.MOQ_NOT_MET + priority = 35 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type != ActionType.REORDER: + continue + moq = int(action.parameters.get("moq_required", 0)) + qty = int(action.parameters.get("quantity", 0)) + if moq and qty < moq: + violations.append(Violation( + code=self.code, + message=f"reorder quantity {qty} is below MOQ {moq} for {action.target_id}", + )) + return violations + + +class SupplierCapacityRule(BaseRule): + """Rejects orders that exceed the supplier's declared capacity.""" + code = ConstraintViolationCode.SUPPLIER_CAPACITY_EXCEEDED + priority = 40 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type not in {ActionType.REORDER, ActionType.SWITCH_SUPPLIER}: + continue + item = state.inventory.get(action.target_id) + if item is None: + continue + qty = int(action.parameters.get("quantity", 0)) or item.forecast_qty + supplier_id = action.parameters.get("supplier_id") or item.preferred_supplier_id + supplier = _supplier_record(state, supplier_id, item.sku) + if supplier and qty > supplier.capacity: + violations.append(Violation( + code=self.code, + message=( + f"order quantity {qty} exceeds supplier {supplier_id} " + f"capacity of {supplier.capacity}" + ), + )) + return violations + + +class SLARule(BaseRule): + """Flags routes whose transit time exceeds the action's max allowed ETA.""" + code = ConstraintViolationCode.SLA_VIOLATED + priority = 45 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + violations = [] + for action in plan.actions: + if action.action_type not in {ActionType.REORDER, ActionType.REROUTE}: + continue + route_id = action.parameters.get("route_id") + if not route_id: + item = state.inventory.get(action.target_id) + route_id = item.preferred_route_id if item else None + if not route_id: + continue + route = state.routes.get(route_id) + if route is None: + continue + max_eta = int(action.parameters.get("max_eta_days", 999)) + if route.transit_days > max_eta: + violations.append(Violation( + code=self.code, + message=( + f"route {route_id} transit {route.transit_days}d " + f"exceeds max allowed {max_eta}d" + ), + )) + return violations + + +# --------------------------------------------------------------------------- +# Soft constraint rules (warnings) +# --------------------------------------------------------------------------- + +class BackupSupplierRule(BaseRule): + """Warning: switching to a non-primary supplier adds overhead.""" + code = ConstraintViolationCode.SUPPLIER_BLOCKED + priority = 10 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + warnings = [] + for action in plan.actions: + if action.action_type != ActionType.SWITCH_SUPPLIER: + continue + supplier_id = action.parameters.get("supplier_id") or action.target_id + supplier = _supplier_record(state, supplier_id, action.target_id) + if supplier and not supplier.is_primary: + warnings.append(Violation( + code=self.code, + message=f"switching to backup supplier {supplier_id} increases operational overhead", + )) + return warnings + + +class CostToleranceRule(BaseRule): + """Warning: action cost delta is unusually high.""" + code = ConstraintViolationCode.SLA_VIOLATED + priority = 20 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + warnings = [] + for action in plan.actions: + if action.estimated_cost_delta > 5_000: + warnings.append(Violation( + code=self.code, + message=( + f"action {action.action_id} has high estimated cost delta " + f"(${action.estimated_cost_delta:,.0f})" + ), + )) + return warnings + + +class LowReliabilityRule(BaseRule): + """Warning: selected supplier has reliability below the acceptable threshold.""" + code = ConstraintViolationCode.RELIABILITY_LOW + priority = 30 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + warnings = [] + for action in plan.actions: + supplier_id = action.parameters.get("supplier_id") + if not supplier_id: + continue + supplier = _supplier_record(state, supplier_id, action.target_id) + if supplier and supplier.reliability < 0.85: + warnings.append(Violation( + code=self.code, + message=f"supplier {supplier_id} has low reliability ({supplier.reliability:.0%})", + )) + return warnings + + +class HighRouteRiskRule(BaseRule): + """Warning: chosen route has an elevated risk score.""" + code = ConstraintViolationCode.RISK_HIGH + priority = 30 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + warnings = [] + for action in plan.actions: + if action.action_type != ActionType.REROUTE: + continue + route_id = action.parameters.get("route_id") or action.target_id + route = state.routes.get(route_id) + if route and route.risk_score > 0.4: + warnings.append(Violation( + code=self.code, + message=f"route {route_id} has a high risk score ({route.risk_score:.2f})", + )) + return warnings + + +class WarehouseUtilizationRule(BaseRule): + """Warning: reorder plans push warehouse utilization close to its limit.""" + code = ConstraintViolationCode.CAPACITY_EXCEEDED + priority = 40 + + def evaluate(self, plan: Plan, state: SystemState) -> list[Violation]: + warnings = [] + additions = _reorder_additions_per_warehouse(plan, state) + for wh_id, added in additions.items(): + wh = state.warehouses.get(wh_id) + if wh is None or wh.capacity == 0: + continue + current = _warehouse_stock(state, wh_id) + utilization = (current + added) / wh.capacity + if 0.85 <= utilization <= 1.0: + warnings.append(Violation( + code=self.code, + message=f"warehouse {wh_id} will reach {utilization:.0%} utilization after reorder", + )) + return warnings + + +# --------------------------------------------------------------------------- +# Constraint engine +# --------------------------------------------------------------------------- + +class ConstraintEngine: + """Runs an ordered list of rules and accumulates violations.""" + + def __init__(self, rules: list[BaseRule]) -> None: + self.rules = sorted(rules, key=lambda r: r.priority) + + def evaluate(self, plan: Plan, state: SystemState) -> tuple[bool, list[Violation]]: + all_violations: list[Violation] = [] + for rule in self.rules: + found = rule.evaluate(plan, state) + if found: + all_violations.extend(found) + if rule.is_critical: + return False, all_violations + return len(all_violations) == 0, all_violations + + +# --------------------------------------------------------------------------- +# Pre-built engines +# --------------------------------------------------------------------------- + +def _make_hard_engine(event: Event | None = None) -> ConstraintEngine: + return ConstraintEngine([ + SupplierExistenceRule(), + SupplierStatusRule(), + SupplierSkuMatchRule(), + SupplierDelayRule(event), + RouteExistenceRule(), + RouteBlockedRule(event), + ReorderQuantityRule(), + RebalanceRule(), + MOQRule(), + WarehouseCapacityRule(), + SupplierCapacityRule(), + SLARule(), + ]) + + +_SOFT_ENGINE = ConstraintEngine([ + BackupSupplierRule(), + CostToleranceRule(), + LowReliabilityRule(), + HighRouteRiskRule(), + WarehouseUtilizationRule(), +]) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def evaluate_hard_constraints( + plan: Plan, + state: SystemState, + event: Event | None = None, +) -> tuple[bool, list[Violation]]: + """Return (feasible, violations) for all hard constraints.""" + return _make_hard_engine(event).evaluate(plan, state) + + +def evaluate_soft_constraints( + plan: Plan, + state: SystemState, +) -> list[Violation]: + """Return soft-constraint warnings for a plan.""" + _, warnings = _SOFT_ENGINE.evaluate(plan, state) + for w in warnings: + w.severity = "soft" + return warnings + + +def evaluate_plan_constraints( + *, + state: SystemState, + event: Event | None, + actions: list, +) -> list[Violation]: + """Compatibility shim used by planner/_evaluate_candidate. + + Wraps the rule engine so callers that pass a raw action list still work + without creating a full Plan object. + """ + from core.models import Plan as _Plan + + dummy = _Plan( + plan_id="eval_tmp", + mode=state.mode, + actions=actions, + score=0.0, + score_breakdown={}, + ) + _, violations = evaluate_hard_constraints(dummy, state, event) + return violations diff --git a/policies/explainability.py b/policies/explainability.py index d9298b3..73e43b8 100644 --- a/policies/explainability.py +++ b/policies/explainability.py @@ -1,27 +1,72 @@ from __future__ import annotations -from core.models import Action, KPIState, ConstraintViolation as Violation +from core.enums import Mode +from core.models import ( + Action, + CandidatePlanEvaluation, + ConstraintViolation as Violation, + KPIState, + Plan, +) +def _pct(value: float) -> str: + return f"{value:.1%}" + + +def _delta_pct(before: float, after: float) -> str: + return f"{after - before:+.1%}" + + +def _delta_num(before: float, after: float) -> str: + return f"{after - before:+,.0f}" + + +def _action_label(action: Action) -> str: + action_name = action.action_type.value.replace("_", " ") + return f"{action_name} on {action.target_id}" + + +def _mode_label(mode: Mode | str) -> str: + value = mode.value if isinstance(mode, Mode) else mode + return "crisis mode" if value == "crisis" else "normal mode" + + def build_plan_summary( before_kpis: KPIState, after_kpis: KPIState, score_breakdown: dict[str, float], + *, + selected_actions: list[Action] | None = None, + strategy_label: str | None = None, + mode: Mode | str = Mode.NORMAL, + runner_up: CandidatePlanEvaluation | None = None, + mode_rationale: str = "", ) -> str: - return ( - "Projected service level " - f"{before_kpis.service_level:.1%} -> {after_kpis.service_level:.1%}, " - f"total cost {before_kpis.total_cost:,.0f} -> {after_kpis.total_cost:,.0f}, " - f"disruption risk {before_kpis.disruption_risk:.1%} -> {after_kpis.disruption_risk:.1%}, " - f"recovery speed {before_kpis.recovery_speed:.1%} -> {after_kpis.recovery_speed:.1%}. " - "Score contributions: " - f"service level {score_breakdown.get('service_level', 0.0):+.3f}, " - f"total cost {score_breakdown.get('total_cost', 0.0):+.3f}, " - f"disruption risk {score_breakdown.get('disruption_risk', 0.0):+.3f}, " - f"recovery speed {score_breakdown.get('recovery_speed', 0.0):+.3f}." + selected_actions = selected_actions or [] + action_text = ( + ", ".join(_action_label(action) for action in selected_actions[:3]) + if selected_actions + else "no change to the operating plan" ) - - + comparison = "" + if runner_up is not None: + comparison = ( + f" It outperformed the main alternative ({runner_up.strategy_label}) after balancing " + f"service, cost, disruption risk, and recovery speed." + ) + mode_text = f"This recommendation is optimized for {_mode_label(mode)}." + rationale_text = f" {mode_rationale}" if mode_rationale else "" + return ( + f"Selected {strategy_label or 'current'} strategy using {action_text}. " + f"Projected service level moves from {_pct(before_kpis.service_level)} to {_pct(after_kpis.service_level)}, " + f"total cost changes by {_delta_num(before_kpis.total_cost, after_kpis.total_cost)}, " + f"disruption risk changes by {_delta_pct(before_kpis.disruption_risk, after_kpis.disruption_risk)}, " + f"and recovery speed changes by {_delta_pct(before_kpis.recovery_speed, after_kpis.recovery_speed)}." + f"{comparison} {mode_text}{rationale_text}" + ).strip() + + def build_winning_factors( selected_actions: list[Action], before_kpis: KPIState, @@ -30,73 +75,75 @@ def build_winning_factors( ) -> list[str]: winning_factors = [ ( - "Service level changed by " - f"{after_kpis.service_level - before_kpis.service_level:+.1%} and contributed " - f"{score_breakdown.get('service_level', 0.0):+.3f} to the score." + "Projected service level changed by " + f"{_delta_pct(before_kpis.service_level, after_kpis.service_level)}; " + f"score contribution {score_breakdown.get('service_level', 0.0):+.3f}." ), ( - "Total cost changed by " - f"{after_kpis.total_cost - before_kpis.total_cost:+,.2f} and contributed " - f"{score_breakdown.get('total_cost', 0.0):+.3f} to the score." + "Projected total cost changed by " + f"{_delta_num(before_kpis.total_cost, after_kpis.total_cost)}; " + f"score contribution {score_breakdown.get('total_cost', 0.0):+.3f}." ), ( - "Disruption risk changed by " - f"{after_kpis.disruption_risk - before_kpis.disruption_risk:+.1%} and contributed " - f"{score_breakdown.get('disruption_risk', 0.0):+.3f} to the score." + "Projected disruption risk changed by " + f"{_delta_pct(before_kpis.disruption_risk, after_kpis.disruption_risk)}; " + f"score contribution {score_breakdown.get('disruption_risk', 0.0):+.3f}." ), ( - "Recovery speed changed by " - f"{after_kpis.recovery_speed - before_kpis.recovery_speed:+.1%} and contributed " - f"{score_breakdown.get('recovery_speed', 0.0):+.3f} to the score." + "Projected recovery speed changed by " + f"{_delta_pct(before_kpis.recovery_speed, after_kpis.recovery_speed)}; " + f"score contribution {score_breakdown.get('recovery_speed', 0.0):+.3f}." ), ] winning_factors.extend( [ ( - f"{action.action_id} was selected because {action.reason}; " - f"priority {action.priority:.2f}, service delta {action.estimated_service_delta:+.2f}, " - f"risk delta {action.estimated_risk_delta:+.2f}, cost delta {action.estimated_cost_delta:+.2f}." + f"{_action_label(action).capitalize()} was selected because {action.reason}. " + f"Expected impact: service {action.estimated_service_delta:+.1%}, " + f"risk {action.estimated_risk_delta:+.1%}, " + f"cost {action.estimated_cost_delta:+,.0f}, " + f"recovery time {action.estimated_recovery_hours:.0f}h." ) for action in selected_actions ] ) return winning_factors - - -def explain_rejected_actions( - candidate_actions: list[Action], - selected_actions: list[Action], - action_limit: int, - infeasible_reasons: dict[str, list[Violation]] | None = None, -) -> list[dict[str, str]]: - infeasible_reasons = infeasible_reasons or {} - selected_by_key = { - (action.action_type.value, action.target_id): action for action in selected_actions - } - selected_ids = {action.action_id for action in selected_actions} - selected_priorities = [action.priority for action in selected_actions] or [0.0] - selected_service = [action.estimated_service_delta for action in selected_actions] or [0.0] - selected_risk = [action.estimated_risk_delta for action in selected_actions] or [0.0] - threshold_priority = min(selected_priorities) - avg_service = sum(selected_service) / len(selected_service) - avg_risk = sum(selected_risk) / len(selected_risk) - - rejected_actions: list[dict[str, str]] = [] - for action in candidate_actions: - if action.action_id in selected_ids: - continue - if action.action_id in infeasible_reasons: - reason = "Hard constraint violation: " + "; ".join(v.message for v in infeasible_reasons[action.action_id]) - else: - duplicate = selected_by_key.get((action.action_type.value, action.target_id)) - if duplicate is not None: + + +def explain_rejected_actions( + candidate_actions: list[Action], + selected_actions: list[Action], + action_limit: int, + infeasible_reasons: dict[str, list[Violation]] | None = None, +) -> list[dict[str, str]]: + infeasible_reasons = infeasible_reasons or {} + selected_by_key = { + (action.action_type.value, action.target_id): action for action in selected_actions + } + selected_ids = {action.action_id for action in selected_actions} + selected_priorities = [action.priority for action in selected_actions] or [0.0] + selected_service = [action.estimated_service_delta for action in selected_actions] or [0.0] + selected_risk = [action.estimated_risk_delta for action in selected_actions] or [0.0] + threshold_priority = min(selected_priorities) + avg_service = sum(selected_service) / len(selected_service) + avg_risk = sum(selected_risk) / len(selected_risk) + + rejected_actions: list[dict[str, str]] = [] + for action in candidate_actions: + if action.action_id in selected_ids: + continue + if action.action_id in infeasible_reasons: + reason = "Hard constraint violation: " + "; ".join(v.message for v in infeasible_reasons[action.action_id]) + else: + duplicate = selected_by_key.get((action.action_type.value, action.target_id)) + if duplicate is not None: reason = ( - f"superseded by higher-priority {duplicate.action_id} on the same action target" + f"superseded by {duplicate.action_id}, which addressed the same target with a stronger priority profile" ) elif action.priority < threshold_priority: reason = ( - f"lower priority ({action.priority:.2f}) than the chosen set threshold " - f"({threshold_priority:.2f}) under action limit {action_limit}" + f"lower operational priority ({action.priority:.2f}) than the chosen set threshold " + f"({threshold_priority:.2f}) under the plan action limit of {action_limit}" ) elif action.estimated_risk_delta > avg_risk: reason = ( @@ -107,8 +154,77 @@ def explain_rejected_actions( reason = ( f"lower projected service delta ({action.estimated_service_delta:+.2f}) than the " f"chosen set average ({avg_service:+.2f})" - ) - else: + ) + else: reason = "weaker overall trade-off than the chosen actions on priority and projected impact" rejected_actions.append({"action_id": action.action_id, "reason": reason}) return rejected_actions + + +def build_critic_review( + selected_plan: Plan, + evaluations: list[CandidatePlanEvaluation], +) -> tuple[str, list[str]]: + selected = next( + (item for item in evaluations if item.strategy_label == selected_plan.strategy_label), + evaluations[0] if evaluations else None, + ) + runner_up = None + ordered = sorted( + [item for item in evaluations if item.feasible] or evaluations, + key=lambda item: item.score, + reverse=True, + ) + if ordered: + for item in ordered: + if item.strategy_label != selected_plan.strategy_label: + runner_up = item + break + + action_labels = ", ".join(_action_label(action) for action in selected_plan.actions[:3]) or "no-op coverage" + summary = ( + f"Reviewer assessment: the {selected_plan.strategy_label or 'selected'} plan is reasonable because it uses " + f"{action_labels} to support the current operating objective." + ) + if selected is not None and runner_up is not None: + summary += ( + f" It scored ahead of {runner_up.strategy_label} by {selected.score - runner_up.score:+.4f}, " + f"mainly on service, cost, risk, and recovery trade-offs." + ) + if selected_plan.approval_required: + summary += " Human approval is prudent because the expected impact crosses the approval guardrail." + + findings: list[str] = [] + longest_recovery = max( + (action.estimated_recovery_hours for action in selected_plan.actions), + default=0.0, + ) + if longest_recovery > 24: + findings.append( + f"The plan depends on at least one action with a recovery window of about {longest_recovery:.0f} hours, so benefits may arrive later than the first operating cycle." + ) + if runner_up is not None and selected is not None: + cost_gap = selected.projected_kpis.total_cost - runner_up.projected_kpis.total_cost + service_gap = selected.projected_kpis.service_level - runner_up.projected_kpis.service_level + risk_gap = selected.projected_kpis.disruption_risk - runner_up.projected_kpis.disruption_risk + if cost_gap > 0: + findings.append( + f"Compared with {runner_up.strategy_label}, this plan carries {cost_gap:,.0f} more projected cost. Monitor whether the added spend is justified by the operating benefit." + ) + if risk_gap >= 0: + findings.append( + f"Compared with {runner_up.strategy_label}, this plan does not materially lower projected disruption risk ({risk_gap:+.1%}). Monitor whether the recovery gain holds after execution." + ) + elif service_gap < 0: + findings.append( + f"Compared with {runner_up.strategy_label}, this plan gives up {_pct(-service_gap)} of projected service level to gain resilience or cost control." + ) + if selected_plan.approval_required and not findings: + findings.append( + "Approval is recommended because the plan changes the operating posture meaningfully enough to warrant human review." + ) + if not findings: + findings.append( + "The plan is operationally sound, but the team should still confirm supplier, transport, and inventory execution milestones after release." + ) + return summary, findings diff --git a/policies/guardrails.py b/policies/guardrails.py index c751381..751a831 100644 --- a/policies/guardrails.py +++ b/policies/guardrails.py @@ -11,15 +11,15 @@ def approval_required( if plan.mode == Mode.NORMAL: return False, "" if event and event.severity >= 0.75: - return True, "event severity exceeds approval threshold" + return True, "Event severity exceeds approval threshold" if before_kpis.total_cost > 0: cost_increase = (after_kpis.total_cost - before_kpis.total_cost) / before_kpis.total_cost if cost_increase > 0.15: - return True, "projected cost increase exceeds 15%" + return True, "Projected cost increase exceeds 15%" if before_kpis.service_level - after_kpis.service_level > 0.03: - return True, "projected service level drop exceeds 3 points" + return True, "Projected service level drop exceeds 3 points" changed_suppliers = sum(1 for action in plan.actions if action.action_type.value == "switch_supplier") changed_routes = sum(1 for action in plan.actions if action.action_type.value == "reroute") if changed_suppliers and changed_routes: - return True, "plan changes supplier and route in the same cycle" + return True, "Plan changes supplier and route in the same cycle" return False, "" diff --git a/policies/strategic_prompt.py b/policies/strategic_prompt.py index 6d50dda..75ee03f 100644 --- a/policies/strategic_prompt.py +++ b/policies/strategic_prompt.py @@ -1,217 +1,217 @@ -""" -Strategic Planner Agent Prompt Builder. - -Builds context-rich prompts for reasoning about supply chain disruptions -using historical memory cases and operational proposals. -""" -from __future__ import annotations - -from core.models import Action, Event, HistoricalCase, MemorySnapshot - - -# --------------------------------------------------------------------------- -# Memory Retrieval -# --------------------------------------------------------------------------- - -def retrieve_relevant_cases( - event: Event | None, - memory: MemorySnapshot | None, - top_k: int = 3, -) -> list[HistoricalCase]: - """ - Retrieve the most relevant historical cases from memory based on - event type and severity similarity. - """ - if not memory or not event or not memory.historical_cases: - return [] - - scored = [] - # Handle both Enum and string for safety - if hasattr(event.type, "value"): - event_type_str = str(event.type.value).lower().strip() - else: - event_type_str = str(event.type).lower().strip() - - for case in memory.historical_cases: - case_type_str = case.event_type.lower().strip() - type_match = 1.0 if case_type_str == event_type_str else 0.3 - - severity_diff = abs(case.event_severity - event.severity) - severity_score = max(0.0, 1.0 - severity_diff) - - similarity = round(0.6 * type_match + 0.4 * severity_score, 4) - case.similarity_score = similarity - scored.append(case) - - results = sorted(scored, key=lambda c: c.similarity_score, reverse=True)[:top_k] - - # Return cases that meet a minimum similarity threshold for relevance - return [r for r in results if r.similarity_score >= 0.4] - - -def compute_memory_influence(cases: list[HistoricalCase]) -> float: - """ - Compute how much memory should influence the plan (0.0 = no memory, 1.0 = full) - based on average similarity of retrieved cases. - """ - if not cases: - return 0.0 - return round(sum(c.similarity_score for c in cases) / len(cases), 4) - - -# --------------------------------------------------------------------------- -# Prompt Builder -# --------------------------------------------------------------------------- - -def _format_event_block(event: Event | None) -> str: - if not event: - return "No active disruption detected. Operating under routine conditions." - return ( - f"Event ID : {event.event_id}\n" - f"Type : {event.type.value}\n" - f"Source : {event.source}\n" - f"Severity : {event.severity:.0%}\n" - f"Entities : {', '.join(event.entity_ids) or 'N/A'}\n" - f"Occurred At : {event.occurred_at.strftime('%Y-%m-%d %H:%M UTC')}" - ) - - -def _format_historical_block(cases: list[HistoricalCase]) -> str: - if not cases: - return "No relevant historical cases found in memory." - lines = [] - for case in cases: - kpi_str = ", ".join(f"{k}: {v:.3f}" for k, v in case.outcome_kpis.items()) - lines.append( - f" Case ID : {case.case_id}\n" - f" Event Type : {case.event_type} (Severity {case.event_severity:.0%})\n" - f" Similarity : {case.similarity_score:.0%}\n" - f" Actions Taken : {', '.join(case.actions_taken)}\n" - f" Outcome KPIs : {kpi_str}\n" - f" Reflection : {case.reflection_notes}" - ) - return "\n\n".join(lines) - - -def _format_proposals_block(actions: list[Action]) -> str: - if not actions: - return "No candidate actions proposed." - lines = [] - for a in actions: - lines.append( - f" [{a.action_type.value.upper()}] {a.action_id}\n" - f" Target : {a.target_id}\n" - f" Priority : {a.priority:.2f}\n" - f" Reason : {a.reason}\n" - f" Cost Δ : {a.estimated_cost_delta:+.2f} | " - f"Service Δ: {a.estimated_service_delta:+.2f} | " - f"Risk Δ: {a.estimated_risk_delta:+.2f}" - ) - return "\n\n".join(lines) - - -def build_strategic_prompt( - mode: str, - event: Event | None, - historical_cases: list[HistoricalCase], - candidate_actions: list[Action], -) -> str: - """ - Build the full Strategic Planner Agent reasoning prompt. - """ - event_block = _format_event_block(event) - history_block = _format_historical_block(historical_cases) - proposals_block = _format_proposals_block(candidate_actions) - - mode_instruction = ( - "Prioritize SERVICE LEVEL and RECOVERY SPEED above all else." - if mode == "crisis" - else "Prioritize COST EFFICIENCY and LEAN INVENTORY." - ) - - return f""" -======================================================================= -STRATEGIC SUPPLY CHAIN PLANNER — REASONING CONTEXT -======================================================================= - -OPERATING MODE: {mode.upper()} -DIRECTIVE : {mode_instruction} - ------------------------------------------------------------------------ -[BLOCK 1] CURRENT DISRUPTION ------------------------------------------------------------------------ -{event_block} - ------------------------------------------------------------------------ -[BLOCK 2] HISTORICAL MEMORY (Retrieved by Similarity) ------------------------------------------------------------------------ -{history_block} - ------------------------------------------------------------------------ -[BLOCK 3] SPECIALIST AGENT PROPOSALS ------------------------------------------------------------------------ -{proposals_block} - ------------------------------------------------------------------------ -[BLOCK 4] HARD CONSTRAINTS (MUST BE SATISFIED) ------------------------------------------------------------------------ - - Only use suppliers/routes that EXIST and are NOT blocked. - - Reorder quantities must NOT exceed warehouse capacity. - - Respect Minimum Order Quantity (MOQ) where specified. - ------------------------------------------------------------------------ -TASK INSTRUCTIONS ------------------------------------------------------------------------ -1. ANALYZE PATTERNS : Compare current disruption with historical cases. - Identify which past strategies succeeded and which failed. - -2. EVALUATE PROPOSALS: Filter proposals. Cross-reference with lessons learned. - -3. CONSTRUCT PLAN : Select actions that minimize disruption_risk and - maintain service_level within constraints. - -4. JUSTIFY : Explicitly cite Case IDs from memory that influenced - your decision. Example: "Chose REORDER because in Case_001, a similar - action prevented a stockout." - -======================================================================= -""".strip() - - -# --------------------------------------------------------------------------- -# Reasoning Parser (deterministic fallback - no LLM required) -# --------------------------------------------------------------------------- - -def derive_strategy_rationale( - event: Event | None, - historical_cases: list[HistoricalCase], - selected_actions: list[Action], -) -> str: - """ - Generate a human-readable rationale string referencing historical cases. - This is a deterministic fallback when no LLM is connected. - """ - parts = [] - - if event: - parts.append( - f"Responding to {event.type.value} (severity {event.severity:.0%}) " - f"from '{event.source}'." - ) - - if historical_cases: - refs = [] - for case in historical_cases: - refs.append( - f"{case.case_id} ({case.event_type}, similarity {case.similarity_score:.0%}: " - f"{case.reflection_notes})" - ) - parts.append("Referenced historical cases: " + "; ".join(refs) + ".") - else: - parts.append("No historical cases matched this scenario.") - - if selected_actions: - action_descs = [f"{a.action_type.value} on {a.target_id}" for a in selected_actions] - parts.append(f"Selected actions: {', '.join(action_descs)}.") - - return " ".join(parts) +""" +Strategic Planner Agent Prompt Builder. + +Builds context-rich prompts for reasoning about supply chain disruptions +using historical memory cases and operational proposals. +""" +from __future__ import annotations + +from core.models import Action, Event, HistoricalCase, MemorySnapshot + + +# --------------------------------------------------------------------------- +# Memory Retrieval +# --------------------------------------------------------------------------- + +def retrieve_relevant_cases( + event: Event | None, + memory: MemorySnapshot | None, + top_k: int = 3, +) -> list[HistoricalCase]: + """ + Retrieve the most relevant historical cases from memory based on + event type and severity similarity. + """ + if not memory or not event or not memory.historical_cases: + return [] + + scored = [] + # Handle both Enum and string for safety + if hasattr(event.type, "value"): + event_type_str = str(event.type.value).lower().strip() + else: + event_type_str = str(event.type).lower().strip() + + for case in memory.historical_cases: + case_type_str = case.event_type.lower().strip() + type_match = 1.0 if case_type_str == event_type_str else 0.3 + + severity_diff = abs(case.event_severity - event.severity) + severity_score = max(0.0, 1.0 - severity_diff) + + similarity = round(0.6 * type_match + 0.4 * severity_score, 4) + case.similarity_score = similarity + scored.append(case) + + results = sorted(scored, key=lambda c: c.similarity_score, reverse=True)[:top_k] + + # Return cases that meet a minimum similarity threshold for relevance + return [r for r in results if r.similarity_score >= 0.4] + + +def compute_memory_influence(cases: list[HistoricalCase]) -> float: + """ + Compute how much memory should influence the plan (0.0 = no memory, 1.0 = full) + based on average similarity of retrieved cases. + """ + if not cases: + return 0.0 + return round(sum(c.similarity_score for c in cases) / len(cases), 4) + + +# --------------------------------------------------------------------------- +# Prompt Builder +# --------------------------------------------------------------------------- + +def _format_event_block(event: Event | None) -> str: + if not event: + return "No active disruption detected. Operating under routine conditions." + return ( + f"Event ID : {event.event_id}\n" + f"Type : {event.type.value}\n" + f"Source : {event.source}\n" + f"Severity : {event.severity:.0%}\n" + f"Entities : {', '.join(event.entity_ids) or 'N/A'}\n" + f"Occurred At : {event.occurred_at.strftime('%Y-%m-%d %H:%M UTC')}" + ) + + +def _format_historical_block(cases: list[HistoricalCase]) -> str: + if not cases: + return "No relevant historical cases found in memory." + lines = [] + for case in cases: + kpi_str = ", ".join(f"{k}: {v:.3f}" for k, v in case.outcome_kpis.items()) + lines.append( + f" Case ID : {case.case_id}\n" + f" Event Type : {case.event_type} (Severity {case.event_severity:.0%})\n" + f" Similarity : {case.similarity_score:.0%}\n" + f" Actions Taken : {', '.join(case.actions_taken)}\n" + f" Outcome KPIs : {kpi_str}\n" + f" Reflection : {case.reflection_notes}" + ) + return "\n\n".join(lines) + + +def _format_proposals_block(actions: list[Action]) -> str: + if not actions: + return "No candidate actions proposed." + lines = [] + for a in actions: + lines.append( + f" [{a.action_type.value.upper()}] {a.action_id}\n" + f" Target : {a.target_id}\n" + f" Priority : {a.priority:.2f}\n" + f" Reason : {a.reason}\n" + f" Cost Δ : {a.estimated_cost_delta:+.2f} | " + f"Service Δ: {a.estimated_service_delta:+.2f} | " + f"Risk Δ: {a.estimated_risk_delta:+.2f}" + ) + return "\n\n".join(lines) + + +def build_strategic_prompt( + mode: str, + event: Event | None, + historical_cases: list[HistoricalCase], + candidate_actions: list[Action], +) -> str: + """ + Build the full Strategic Planner Agent reasoning prompt. + """ + event_block = _format_event_block(event) + history_block = _format_historical_block(historical_cases) + proposals_block = _format_proposals_block(candidate_actions) + + mode_instruction = ( + "Prioritize SERVICE LEVEL and RECOVERY SPEED above all else." + if mode == "crisis" + else "Prioritize COST EFFICIENCY and LEAN INVENTORY." + ) + + return f""" +======================================================================= +STRATEGIC SUPPLY CHAIN PLANNER — REASONING CONTEXT +======================================================================= + +OPERATING MODE: {mode.upper()} +DIRECTIVE : {mode_instruction} + +----------------------------------------------------------------------- +[BLOCK 1] CURRENT DISRUPTION +----------------------------------------------------------------------- +{event_block} + +----------------------------------------------------------------------- +[BLOCK 2] HISTORICAL MEMORY (Retrieved by Similarity) +----------------------------------------------------------------------- +{history_block} + +----------------------------------------------------------------------- +[BLOCK 3] SPECIALIST AGENT PROPOSALS +----------------------------------------------------------------------- +{proposals_block} + +----------------------------------------------------------------------- +[BLOCK 4] HARD CONSTRAINTS (MUST BE SATISFIED) +----------------------------------------------------------------------- + - Only use suppliers/routes that EXIST and are NOT blocked. + - Reorder quantities must NOT exceed warehouse capacity. + - Respect Minimum Order Quantity (MOQ) where specified. + +----------------------------------------------------------------------- +TASK INSTRUCTIONS +----------------------------------------------------------------------- +1. ANALYZE PATTERNS : Compare current disruption with historical cases. + Identify which past strategies succeeded and which failed. + +2. EVALUATE PROPOSALS: Filter proposals. Cross-reference with lessons learned. + +3. CONSTRUCT PLAN : Select actions that minimize disruption_risk and + maintain service_level within constraints. + +4. JUSTIFY : Explicitly cite Case IDs from memory that influenced + your decision. Example: "Chose REORDER because in Case_001, a similar + action prevented a stockout." + +======================================================================= +""".strip() + + +# --------------------------------------------------------------------------- +# Reasoning Parser (deterministic fallback - no LLM required) +# --------------------------------------------------------------------------- + +def derive_strategy_rationale( + event: Event | None, + historical_cases: list[HistoricalCase], + selected_actions: list[Action], +) -> str: + """ + Generate a human-readable rationale string referencing historical cases. + This is a deterministic fallback when no LLM is connected. + """ + parts = [] + + if event: + parts.append( + f"Responding to {event.type.value} (severity {event.severity:.0%}) " + f"from '{event.source}'." + ) + + if historical_cases: + refs = [] + for case in historical_cases: + refs.append( + f"{case.case_id} ({case.event_type}, similarity {case.similarity_score:.0%}: " + f"{case.reflection_notes})" + ) + parts.append("Referenced historical cases: " + "; ".join(refs) + ".") + else: + parts.append("No historical cases matched this scenario.") + + if selected_actions: + action_descs = [f"{a.action_type.value} on {a.target_id}" for a in selected_actions] + parts.append(f"Selected actions: {', '.join(action_descs)}.") + + return " ".join(parts) diff --git a/requirements.txt b/requirements.txt index daa01b9..dd0fe7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ scikit-learn>=1.5,<2 langgraph>=0.2,<1 pytest>=8.3,<9 ruff>=0.6,<1 +python-dotenv \ No newline at end of file diff --git a/run_api.py b/run_api.py index 48705f0..9424bd4 100644 --- a/run_api.py +++ b/run_api.py @@ -1,38 +1,38 @@ -from __future__ import annotations - -import os -from pathlib import Path - -import uvicorn - - -BASE_DIR = Path(__file__).resolve().parent -RELOAD_DIR_NAMES = ( - "actions", - "agents", - "app_api", - "core", - "llm", - "orchestrator", - "policies", - "simulation", -) - - -def build_reload_dirs() -> list[str]: - return [ - str(directory) - for directory in (BASE_DIR / name for name in RELOAD_DIR_NAMES) - if directory.exists() - ] - - -if __name__ == "__main__": - uvicorn.run( - "api:app", - host=os.getenv("CHAINCOPILOT_API_HOST", "127.0.0.1"), - port=int(os.getenv("CHAINCOPILOT_API_PORT", "8000")), - reload=True, - reload_dirs=build_reload_dirs(), - env_file=".env", - ) +from __future__ import annotations + +import os +from pathlib import Path + +import uvicorn + + +BASE_DIR = Path(__file__).resolve().parent +RELOAD_DIR_NAMES = ( + "actions", + "agents", + "app_api", + "core", + "llm", + "orchestrator", + "policies", + "simulation", +) + + +def build_reload_dirs() -> list[str]: + return [ + str(directory) + for directory in (BASE_DIR / name for name in RELOAD_DIR_NAMES) + if directory.exists() + ] + + +if __name__ == "__main__": + uvicorn.run( + "api:app", + host=os.getenv("CHAINCOPILOT_API_HOST", "127.0.0.1"), + port=int(os.getenv("CHAINCOPILOT_API_PORT", "8000")), + reload=True, + reload_dirs=build_reload_dirs(), + env_file=".env", + ) diff --git a/simulation/learning.py b/simulation/learning.py index 8b083b7..9b76449 100644 --- a/simulation/learning.py +++ b/simulation/learning.py @@ -1,289 +1,289 @@ -from __future__ import annotations - -from uuid import uuid4 - -from core.enums import ApprovalStatus, EventType -from core.models import DecisionLog, MemorySnapshot, ReflectionNote, ScenarioRun, SystemState -from core.state import default_memory, utc_now -from llm.service import generate_reflection_note - - -FINAL_APPROVAL_STATUSES = { - ApprovalStatus.APPROVED.value, - ApprovalStatus.AUTO_APPLIED.value, -} - - -def _bounded(value: float) -> float: - return round(max(0.0, min(1.0, value)), 4) - - -def _latest_decision(state: SystemState) -> DecisionLog | None: - return state.decision_logs[-1] if state.decision_logs else None - - -def _latest_run(state: SystemState) -> ScenarioRun | None: - return state.scenario_history[-1] if state.scenario_history else None - - -def _update_supplier_learning(state: SystemState, supplier_id: str, severity: float) -> float | None: - updated = None - for _, record in state.suppliers.items(): - if record.supplier_id != supplier_id: - continue - memory = state.memory or default_memory() - current = memory.supplier_reliability.get(supplier_id, record.reliability) - updated = _bounded(current - (0.06 * severity)) - memory.supplier_reliability[supplier_id] = updated - record.reliability = updated - state.memory = memory - return updated - - -def _update_route_learning(state: SystemState, route_id: str, severity: float) -> float | None: - if route_id not in state.routes: - return None - memory = state.memory or default_memory() - current = memory.route_disruption_priors.get(route_id, state.routes[route_id].risk_score) - updated = _bounded(current + (0.08 * severity)) - memory.route_disruption_priors[route_id] = updated - state.routes[route_id].risk_score = updated - state.memory = memory - return updated - - -def _update_numeric_learning(state: SystemState, run: ScenarioRun) -> tuple[dict[str, float], dict[str, float]]: - supplier_updates: dict[str, float] = {} - route_updates: dict[str, float] = {} - for event in run.events: - supplier_id = event.payload.get("supplier_id") - route_id = event.payload.get("route_id") - if supplier_id and event.type in {EventType.SUPPLIER_DELAY, EventType.COMPOUND}: - updated_supplier = _update_supplier_learning(state, supplier_id, event.severity) - if updated_supplier is not None: - supplier_updates[supplier_id] = updated_supplier - if route_id and event.type in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND}: - updated_route = _update_route_learning(state, route_id, event.severity) - if updated_route is not None: - route_updates[route_id] = updated_route - return supplier_updates, route_updates - - -def _build_outcome_summary( - state: SystemState, - run: ScenarioRun, - latest_decision: DecisionLog | None, - supplier_updates: dict[str, float], - route_updates: dict[str, float], -) -> dict: - approval_status = ( - latest_decision.approval_status.value if latest_decision else ApprovalStatus.NOT_REQUIRED.value - ) - return { - "run_id": run.run_id, - "scenario_id": run.scenario_id, - "event_types": [event.type.value for event in run.events], - "plan_id": run.result_plan_id, - "decision_id": run.decision_id, - "mode": state.mode.value, - "approval_status": approval_status, - "service_level": state.kpis.service_level, - "total_cost": state.kpis.total_cost, - "disruption_risk": state.kpis.disruption_risk, - "recovery_speed": state.kpis.recovery_speed, - "supplier_updates": supplier_updates, - "route_updates": route_updates, - "reflection_status": run.reflection_status, - "reflection_note_id": run.reflection_note_id, - } - - -def _upsert_history(history: list[dict], outcome_summary: dict) -> list[dict]: - updated = list(history) - for index, item in enumerate(updated): - if item.get("run_id") == outcome_summary["run_id"]: - updated[index] = outcome_summary - return updated - updated.append(outcome_summary) - return updated - - -def _save_outcome_summary(state: SystemState, run: ScenarioRun) -> None: - memory = state.memory or default_memory() - scenario_state = memory.scenario_outcomes.get( - run.scenario_id, - {"runs": 0, "history": []}, - ) - history = _upsert_history(list(scenario_state.get("history", [])), run.outcome_summary) - previous_runs = int(scenario_state.get("runs", 0)) - run_seen = any(item.get("run_id") == run.run_id for item in scenario_state.get("history", [])) - scenario_state.update( - { - "runs": previous_runs if run_seen else previous_runs + 1, - "latest_run_id": run.run_id, - "latest_plan_id": run.result_plan_id, - "latest_kpis": state.kpis.model_dump(mode="json"), - "latest_approval_status": run.approval_status, - "latest_reflection_status": run.reflection_status, - "history": history, - } - ) - memory.scenario_outcomes[run.scenario_id] = scenario_state - memory.snapshot_id = f"mem_{len(state.scenario_history) + 1}" - memory.timestamp = utc_now() - state.memory = MemorySnapshot.model_validate(memory.model_dump()) - - -def _deterministic_reflection_note( - state: SystemState, - run: ScenarioRun, - decision_log: DecisionLog | None, - error: str | None = None, -) -> ReflectionNote: - event_types = [event.type.value for event in run.events] - primary_event = event_types[0] if event_types else "unknown" - selected_actions = decision_log.selected_actions if decision_log else [] - lessons = [ - f"{primary_event} required close monitoring of supplier, route, and service trade-offs.", - ( - f"chosen plan used {', '.join(selected_actions[:2])}" - if selected_actions - else "no executable plan was selected at reflection time" - ), - ] - tags = sorted( - { - primary_event, - state.mode.value, - "approval_required" if run.approval_status in FINAL_APPROVAL_STATUSES else "approval_pending", - } - ) - checks = [ - "review supplier reliability and route risk priors before the next run", - "compare the selected strategy against the next disruption of the same type", - ] - return ReflectionNote( - note_id=f"note_{uuid4().hex[:8]}", - run_id=run.run_id, - scenario_id=run.scenario_id, - plan_id=run.result_plan_id, - event_types=event_types, - mode=state.mode.value, - approval_status=run.approval_status or ApprovalStatus.NOT_REQUIRED.value, - summary=( - f"Scenario {run.scenario_id} finished in {state.mode.value} mode after {primary_event}; " - f"service level settled at {state.kpis.service_level:.2f} with total cost {state.kpis.total_cost:.2f}." - ), - lessons=lessons, - pattern_tags=tags, - follow_up_checks=checks, - llm_used=False, - llm_error=error, - ) - - -def _store_reflection(memory: MemorySnapshot, note: ReflectionNote) -> None: - notes = [item for item in memory.reflection_notes if item.note_id != note.note_id] - notes.append(note) - memory.reflection_notes = notes - counts: dict[str, int] = {} - for item in memory.reflection_notes: - for tag in item.pattern_tags: - counts[tag] = counts.get(tag, 0) + 1 - memory.pattern_tag_counts = counts - - -def _finalize_reflection_note(state: SystemState, run: ScenarioRun) -> ReflectionNote | None: - decision_log = _latest_decision(state) - if run.approval_status not in FINAL_APPROVAL_STATUSES: - return None - note, error = generate_reflection_note( - state=state, - run=run, - decision_log=decision_log, - ) - if note is None: - note = _deterministic_reflection_note(state, run, decision_log, error) - memory = state.memory or default_memory() - _store_reflection(memory, note) - state.memory = MemorySnapshot.model_validate(memory.model_dump()) - run.reflection_note_id = note.note_id - run.reflection_status = "completed" - return note - - -def _mark_last_approved_plan(memory: MemorySnapshot, run: ScenarioRun) -> None: - if run.approval_status in FINAL_APPROVAL_STATUSES and run.result_plan_id: - if run.result_plan_id not in memory.last_approved_plan_ids: - memory.last_approved_plan_ids.append(run.result_plan_id) - - -def _refresh_run_from_state(state: SystemState, run: ScenarioRun) -> None: - decision_log = _latest_decision(state) - run.result_plan_id = state.latest_plan_id - run.decision_id = decision_log.decision_id if decision_log else run.decision_id - run.result_kpis = state.kpis.model_copy(deep=True) - run.approval_status = ( - decision_log.approval_status.value if decision_log else ApprovalStatus.NOT_REQUIRED.value - ) - - -def apply_learning(state: SystemState, run: ScenarioRun) -> None: - supplier_updates, route_updates = _update_numeric_learning(state, run) - _refresh_run_from_state(state, run) - if run.approval_status in FINAL_APPROVAL_STATUSES: - run.reflection_status = "completed" - elif run.approval_status == ApprovalStatus.PENDING.value: - run.reflection_status = "pending_approval" - else: - run.reflection_status = "not_executed" - - run.outcome_summary = _build_outcome_summary( - state, - run, - _latest_decision(state), - supplier_updates, - route_updates, - ) - if run.reflection_status == "completed": - _finalize_reflection_note(state, run) - run.outcome_summary["reflection_status"] = run.reflection_status - run.outcome_summary["reflection_note_id"] = run.reflection_note_id - - memory = state.memory or default_memory() - _mark_last_approved_plan(memory, run) - state.memory = MemorySnapshot.model_validate(memory.model_dump()) - _save_outcome_summary(state, run) - - -def finalize_latest_scenario_run(state: SystemState) -> ScenarioRun | None: - run = _latest_run(state) - if run is None: - return None - - previous_status = run.reflection_status - _refresh_run_from_state(state, run) - if run.approval_status in FINAL_APPROVAL_STATUSES: - run.reflection_status = "completed" - _finalize_reflection_note(state, run) - elif run.approval_status == ApprovalStatus.PENDING.value: - run.reflection_status = "pending_approval" - else: - run.reflection_status = "not_executed" - - supplier_updates = run.outcome_summary.get("supplier_updates", {}) - route_updates = run.outcome_summary.get("route_updates", {}) - run.outcome_summary = _build_outcome_summary( - state, - run, - _latest_decision(state), - supplier_updates, - route_updates, - ) - memory = state.memory or default_memory() - _mark_last_approved_plan(memory, run) - state.memory = MemorySnapshot.model_validate(memory.model_dump()) - _save_outcome_summary(state, run) - if previous_status != run.reflection_status: - run.status = "completed" - return run +from __future__ import annotations + +from uuid import uuid4 + +from core.enums import ApprovalStatus, EventType +from core.models import DecisionLog, MemorySnapshot, ReflectionNote, ScenarioRun, SystemState +from core.state import default_memory, utc_now +from llm.service import generate_reflection_note + + +FINAL_APPROVAL_STATUSES = { + ApprovalStatus.APPROVED.value, + ApprovalStatus.AUTO_APPLIED.value, +} + + +def _bounded(value: float) -> float: + return round(max(0.0, min(1.0, value)), 4) + + +def _latest_decision(state: SystemState) -> DecisionLog | None: + return state.decision_logs[-1] if state.decision_logs else None + + +def _latest_run(state: SystemState) -> ScenarioRun | None: + return state.scenario_history[-1] if state.scenario_history else None + + +def _update_supplier_learning(state: SystemState, supplier_id: str, severity: float) -> float | None: + updated = None + for _, record in state.suppliers.items(): + if record.supplier_id != supplier_id: + continue + memory = state.memory or default_memory() + current = memory.supplier_reliability.get(supplier_id, record.reliability) + updated = _bounded(current - (0.06 * severity)) + memory.supplier_reliability[supplier_id] = updated + record.reliability = updated + state.memory = memory + return updated + + +def _update_route_learning(state: SystemState, route_id: str, severity: float) -> float | None: + if route_id not in state.routes: + return None + memory = state.memory or default_memory() + current = memory.route_disruption_priors.get(route_id, state.routes[route_id].risk_score) + updated = _bounded(current + (0.08 * severity)) + memory.route_disruption_priors[route_id] = updated + state.routes[route_id].risk_score = updated + state.memory = memory + return updated + + +def _update_numeric_learning(state: SystemState, run: ScenarioRun) -> tuple[dict[str, float], dict[str, float]]: + supplier_updates: dict[str, float] = {} + route_updates: dict[str, float] = {} + for event in run.events: + supplier_id = event.payload.get("supplier_id") + route_id = event.payload.get("route_id") + if supplier_id and event.type in {EventType.SUPPLIER_DELAY, EventType.COMPOUND}: + updated_supplier = _update_supplier_learning(state, supplier_id, event.severity) + if updated_supplier is not None: + supplier_updates[supplier_id] = updated_supplier + if route_id and event.type in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND}: + updated_route = _update_route_learning(state, route_id, event.severity) + if updated_route is not None: + route_updates[route_id] = updated_route + return supplier_updates, route_updates + + +def _build_outcome_summary( + state: SystemState, + run: ScenarioRun, + latest_decision: DecisionLog | None, + supplier_updates: dict[str, float], + route_updates: dict[str, float], +) -> dict: + approval_status = ( + latest_decision.approval_status.value if latest_decision else ApprovalStatus.NOT_REQUIRED.value + ) + return { + "run_id": run.run_id, + "scenario_id": run.scenario_id, + "event_types": [event.type.value for event in run.events], + "plan_id": run.result_plan_id, + "decision_id": run.decision_id, + "mode": state.mode.value, + "approval_status": approval_status, + "service_level": state.kpis.service_level, + "total_cost": state.kpis.total_cost, + "disruption_risk": state.kpis.disruption_risk, + "recovery_speed": state.kpis.recovery_speed, + "supplier_updates": supplier_updates, + "route_updates": route_updates, + "reflection_status": run.reflection_status, + "reflection_note_id": run.reflection_note_id, + } + + +def _upsert_history(history: list[dict], outcome_summary: dict) -> list[dict]: + updated = list(history) + for index, item in enumerate(updated): + if item.get("run_id") == outcome_summary["run_id"]: + updated[index] = outcome_summary + return updated + updated.append(outcome_summary) + return updated + + +def _save_outcome_summary(state: SystemState, run: ScenarioRun) -> None: + memory = state.memory or default_memory() + scenario_state = memory.scenario_outcomes.get( + run.scenario_id, + {"runs": 0, "history": []}, + ) + history = _upsert_history(list(scenario_state.get("history", [])), run.outcome_summary) + previous_runs = int(scenario_state.get("runs", 0)) + run_seen = any(item.get("run_id") == run.run_id for item in scenario_state.get("history", [])) + scenario_state.update( + { + "runs": previous_runs if run_seen else previous_runs + 1, + "latest_run_id": run.run_id, + "latest_plan_id": run.result_plan_id, + "latest_kpis": state.kpis.model_dump(mode="json"), + "latest_approval_status": run.approval_status, + "latest_reflection_status": run.reflection_status, + "history": history, + } + ) + memory.scenario_outcomes[run.scenario_id] = scenario_state + memory.snapshot_id = f"mem_{len(state.scenario_history) + 1}" + memory.timestamp = utc_now() + state.memory = MemorySnapshot.model_validate(memory.model_dump()) + + +def _deterministic_reflection_note( + state: SystemState, + run: ScenarioRun, + decision_log: DecisionLog | None, + error: str | None = None, +) -> ReflectionNote: + event_types = [event.type.value for event in run.events] + primary_event = event_types[0] if event_types else "unknown" + selected_actions = decision_log.selected_actions if decision_log else [] + lessons = [ + f"{primary_event} required close monitoring of supplier, route, and service trade-offs.", + ( + f"chosen plan used {', '.join(selected_actions[:2])}" + if selected_actions + else "no executable plan was selected at reflection time" + ), + ] + tags = sorted( + { + primary_event, + state.mode.value, + "approval_required" if run.approval_status in FINAL_APPROVAL_STATUSES else "approval_pending", + } + ) + checks = [ + "review supplier reliability and route risk priors before the next run", + "compare the selected strategy against the next disruption of the same type", + ] + return ReflectionNote( + note_id=f"note_{uuid4().hex[:8]}", + run_id=run.run_id, + scenario_id=run.scenario_id, + plan_id=run.result_plan_id, + event_types=event_types, + mode=state.mode.value, + approval_status=run.approval_status or ApprovalStatus.NOT_REQUIRED.value, + summary=( + f"Scenario {run.scenario_id} finished in {state.mode.value} mode after {primary_event}; " + f"service level settled at {state.kpis.service_level:.2f} with total cost {state.kpis.total_cost:.2f}." + ), + lessons=lessons, + pattern_tags=tags, + follow_up_checks=checks, + llm_used=False, + llm_error=error, + ) + + +def _store_reflection(memory: MemorySnapshot, note: ReflectionNote) -> None: + notes = [item for item in memory.reflection_notes if item.note_id != note.note_id] + notes.append(note) + memory.reflection_notes = notes + counts: dict[str, int] = {} + for item in memory.reflection_notes: + for tag in item.pattern_tags: + counts[tag] = counts.get(tag, 0) + 1 + memory.pattern_tag_counts = counts + + +def _finalize_reflection_note(state: SystemState, run: ScenarioRun) -> ReflectionNote | None: + decision_log = _latest_decision(state) + if run.approval_status not in FINAL_APPROVAL_STATUSES: + return None + note, error = generate_reflection_note( + state=state, + run=run, + decision_log=decision_log, + ) + if note is None: + note = _deterministic_reflection_note(state, run, decision_log, error) + memory = state.memory or default_memory() + _store_reflection(memory, note) + state.memory = MemorySnapshot.model_validate(memory.model_dump()) + run.reflection_note_id = note.note_id + run.reflection_status = "completed" + return note + + +def _mark_last_approved_plan(memory: MemorySnapshot, run: ScenarioRun) -> None: + if run.approval_status in FINAL_APPROVAL_STATUSES and run.result_plan_id: + if run.result_plan_id not in memory.last_approved_plan_ids: + memory.last_approved_plan_ids.append(run.result_plan_id) + + +def _refresh_run_from_state(state: SystemState, run: ScenarioRun) -> None: + decision_log = _latest_decision(state) + run.result_plan_id = state.latest_plan_id + run.decision_id = decision_log.decision_id if decision_log else run.decision_id + run.result_kpis = state.kpis.model_copy(deep=True) + run.approval_status = ( + decision_log.approval_status.value if decision_log else ApprovalStatus.NOT_REQUIRED.value + ) + + +def apply_learning(state: SystemState, run: ScenarioRun) -> None: + supplier_updates, route_updates = _update_numeric_learning(state, run) + _refresh_run_from_state(state, run) + if run.approval_status in FINAL_APPROVAL_STATUSES: + run.reflection_status = "completed" + elif run.approval_status == ApprovalStatus.PENDING.value: + run.reflection_status = "pending_approval" + else: + run.reflection_status = "not_executed" + + run.outcome_summary = _build_outcome_summary( + state, + run, + _latest_decision(state), + supplier_updates, + route_updates, + ) + if run.reflection_status == "completed": + _finalize_reflection_note(state, run) + run.outcome_summary["reflection_status"] = run.reflection_status + run.outcome_summary["reflection_note_id"] = run.reflection_note_id + + memory = state.memory or default_memory() + _mark_last_approved_plan(memory, run) + state.memory = MemorySnapshot.model_validate(memory.model_dump()) + _save_outcome_summary(state, run) + + +def finalize_latest_scenario_run(state: SystemState) -> ScenarioRun | None: + run = _latest_run(state) + if run is None: + return None + + previous_status = run.reflection_status + _refresh_run_from_state(state, run) + if run.approval_status in FINAL_APPROVAL_STATUSES: + run.reflection_status = "completed" + _finalize_reflection_note(state, run) + elif run.approval_status == ApprovalStatus.PENDING.value: + run.reflection_status = "pending_approval" + else: + run.reflection_status = "not_executed" + + supplier_updates = run.outcome_summary.get("supplier_updates", {}) + route_updates = run.outcome_summary.get("route_updates", {}) + run.outcome_summary = _build_outcome_summary( + state, + run, + _latest_decision(state), + supplier_updates, + route_updates, + ) + memory = state.memory or default_memory() + _mark_last_approved_plan(memory, run) + state.memory = MemorySnapshot.model_validate(memory.model_dump()) + _save_outcome_summary(state, run) + if previous_status != run.reflection_status: + run.status = "completed" + return run diff --git a/simulation/runner.py b/simulation/runner.py index 6345295..5cccc73 100644 --- a/simulation/runner.py +++ b/simulation/runner.py @@ -1,92 +1,92 @@ -from __future__ import annotations - -import time -from uuid import uuid4 - -from core.memory import SQLiteStore -from core.models import ScenarioRun, SystemState -from core.runtime_records import EventClass, EventEnvelope, RunType -from core.runtime_tracking import ( - build_execution_record, - build_run_record, - clone_trace_for_run, - new_correlation_id, - new_event_envelope_id, - new_run_id, -) -from core.state import clone_state -from orchestrator.graph import build_graph -from orchestrator.service import ensure_no_pending_plan -from simulation.learning import apply_learning -from simulation.scenarios import get_scenario_events - - -class ScenarioRunner: - def __init__(self, store: SQLiteStore | None = None) -> None: - self.graph = build_graph() - self.store = store or SQLiteStore() - - def run(self, initial_state: SystemState, scenario_name: str, seed: int = 7) -> SystemState: - ensure_no_pending_plan(initial_state) - state = clone_state(initial_state) - started = time.perf_counter() - events = get_scenario_events(scenario_name) - scenario_correlation_id = new_correlation_id("scenario") - for event in events: - started_at = event.detected_at - parent_run_id = state.run_id - run_id = new_run_id() - envelope = EventEnvelope( - event_id=new_event_envelope_id(), - event_class=EventClass.DOMAIN, - event_type=event.type.value, - source=f"scenario:{scenario_name}", - occurred_at=event.occurred_at, - ingested_at=event.detected_at, - correlation_id=scenario_correlation_id, - causation_id=None, - idempotency_key=event.dedupe_key, - severity=event.severity, - entity_ids=event.entity_ids, - payload=event.payload, - ) - self.store.save_event_envelope(envelope) - state = self.graph.invoke(state, event, run_id=run_id) - decision = state.decision_logs[-1] if state.decision_logs else None - execution = build_execution_record(run_id=run_id, state=state, decision=decision) - if execution is not None: - self.store.save_execution_record(execution) - run_record = build_run_record( - run_id=run_id, - run_type=RunType.SCENARIO_STEP, - state=state, - started_at=started_at, - parent_run_id=parent_run_id, - envelope=envelope, - execution_id=execution.execution_id if execution is not None else None, - ) - self.store.save_run_record(run_record) - trace = clone_trace_for_run(state.latest_trace, run_id) - if trace is not None: - self.store.save_trace(run_id, trace) - self.store.save_state(state) - if decision is not None: - self.store.save_decision_log(decision) - run = ScenarioRun( - run_id=f"run_{uuid4().hex[:8]}", - scenario_id=scenario_name, - seed=seed, - events=events, - result_plan_id=state.latest_plan_id, - decision_id=state.decision_logs[-1].decision_id if state.decision_logs else None, - result_kpis=state.kpis, - duration_ms=round((time.perf_counter() - started) * 1000.0, 2), - status="completed", - ) - state.scenario_history.append(run) - apply_learning(state, run) - self.store.save_scenario_run(run) - self.store.save_state(state) - if state.decision_logs: - self.store.save_decision_log(state.decision_logs[-1]) - return state +from __future__ import annotations + +import time +from uuid import uuid4 + +from core.memory import SQLiteStore +from core.models import ScenarioRun, SystemState +from core.runtime_records import EventClass, EventEnvelope, RunType +from core.runtime_tracking import ( + build_execution_record, + build_run_record, + clone_trace_for_run, + new_correlation_id, + new_event_envelope_id, + new_run_id, +) +from core.state import clone_state +from orchestrator.graph import build_graph +from orchestrator.service import ensure_no_pending_plan +from simulation.learning import apply_learning +from simulation.scenarios import get_scenario_events + + +class ScenarioRunner: + def __init__(self, store: SQLiteStore | None = None) -> None: + self.graph = build_graph() + self.store = store or SQLiteStore() + + def run(self, initial_state: SystemState, scenario_name: str, seed: int = 7) -> SystemState: + ensure_no_pending_plan(initial_state) + state = clone_state(initial_state) + started = time.perf_counter() + events = get_scenario_events(scenario_name) + scenario_correlation_id = new_correlation_id("scenario") + for event in events: + started_at = event.detected_at + parent_run_id = state.run_id + run_id = new_run_id() + envelope = EventEnvelope( + event_id=new_event_envelope_id(), + event_class=EventClass.DOMAIN, + event_type=event.type.value, + source=f"scenario:{scenario_name}", + occurred_at=event.occurred_at, + ingested_at=event.detected_at, + correlation_id=scenario_correlation_id, + causation_id=None, + idempotency_key=event.dedupe_key, + severity=event.severity, + entity_ids=event.entity_ids, + payload=event.payload, + ) + self.store.save_event_envelope(envelope) + state = self.graph.invoke(state, event, run_id=run_id) + decision = state.decision_logs[-1] if state.decision_logs else None + execution = build_execution_record(run_id=run_id, state=state, decision=decision) + if execution is not None: + self.store.save_execution_record(execution) + run_record = build_run_record( + run_id=run_id, + run_type=RunType.SCENARIO_STEP, + state=state, + started_at=started_at, + parent_run_id=parent_run_id, + envelope=envelope, + execution_id=execution.execution_id if execution is not None else None, + ) + self.store.save_run_record(run_record) + trace = clone_trace_for_run(state.latest_trace, run_id) + if trace is not None: + self.store.save_trace(run_id, trace) + self.store.save_state(state) + if decision is not None: + self.store.save_decision_log(decision) + run = ScenarioRun( + run_id=f"run_{uuid4().hex[:8]}", + scenario_id=scenario_name, + seed=seed, + events=events, + result_plan_id=state.latest_plan_id, + decision_id=state.decision_logs[-1].decision_id if state.decision_logs else None, + result_kpis=state.kpis, + duration_ms=round((time.perf_counter() - started) * 1000.0, 2), + status="completed", + ) + state.scenario_history.append(run) + apply_learning(state, run) + self.store.save_scenario_run(run) + self.store.save_state(state) + if state.decision_logs: + self.store.save_decision_log(state.decision_logs[-1]) + return state diff --git a/simulation/scenarios.py b/simulation/scenarios.py index 6e0ed61..f360e68 100644 --- a/simulation/scenarios.py +++ b/simulation/scenarios.py @@ -1,88 +1,88 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from core.enums import EventType -from core.models import Event -from core.state import utc_now - - -def build_event( - *, - event_id: str, - event_type: EventType, - severity: float, - payload: dict, - entity_ids: list[str], -) -> Event: - now = utc_now() - return Event( - event_id=event_id, - type=event_type, - source="scenario_runner", - severity=severity, - entity_ids=entity_ids, - occurred_at=now, - detected_at=now, - payload=payload, - dedupe_key=event_id, - ) - - -def get_scenario_events(name: str) -> list[Event]: - mapping = { - "supplier_delay": [ - build_event( - event_id="evt_supplier_delay", - event_type=EventType.SUPPLIER_DELAY, - severity=0.80, - payload={"supplier_id": "SUP_A", "sku": "SKU_001", "delay_hours": 48}, - entity_ids=["SUP_A", "SKU_001"], - ) - ], - "demand_spike": [ - build_event( - event_id="evt_demand_spike", - event_type=EventType.DEMAND_SPIKE, - severity=0.70, - payload={"sku": "SKU_024", "multiplier": 2.2}, - entity_ids=["SKU_024"], - ) - ], - "route_blockage": [ - build_event( - event_id="evt_route_blockage", - event_type=EventType.ROUTE_BLOCKAGE, - severity=0.78, - payload={"route_id": "R1", "reason": "flooding"}, - entity_ids=["R1"], - ) - ], - "compound_disruption": [ - build_event( - event_id="evt_compound_delay", - event_type=EventType.COMPOUND, - severity=0.92, - payload={"supplier_id": "SUP_A", "route_id": "R1", "sku": "SKU_001"}, - entity_ids=["SUP_A", "R1", "SKU_001"], - ), - build_event( - event_id="evt_compound_spike", - event_type=EventType.DEMAND_SPIKE, - severity=0.75, - payload={"sku": "SKU_024", "multiplier": 1.9}, - entity_ids=["SKU_024"], - ), - ], - } - return mapping[name] - - -def list_scenarios() -> list[str]: - return ["supplier_delay", "demand_spike", "route_blockage", "compound_disruption"] - - -def load_scenarios_json(path: Path | None = None) -> dict: - file_path = path or (Path(__file__).resolve().parent.parent / "data" / "scenarios.json") - return json.loads(file_path.read_text(encoding="utf-8")) +from __future__ import annotations + +import json +from pathlib import Path + +from core.enums import EventType +from core.models import Event +from core.state import utc_now + + +def build_event( + *, + event_id: str, + event_type: EventType, + severity: float, + payload: dict, + entity_ids: list[str], +) -> Event: + now = utc_now() + return Event( + event_id=event_id, + type=event_type, + source="scenario_runner", + severity=severity, + entity_ids=entity_ids, + occurred_at=now, + detected_at=now, + payload=payload, + dedupe_key=event_id, + ) + + +def get_scenario_events(name: str) -> list[Event]: + mapping = { + "supplier_delay": [ + build_event( + event_id="evt_supplier_delay", + event_type=EventType.SUPPLIER_DELAY, + severity=0.80, + payload={"supplier_id": "SUP_A", "sku": "SKU_001", "delay_hours": 48}, + entity_ids=["SUP_A", "SKU_001"], + ) + ], + "demand_spike": [ + build_event( + event_id="evt_demand_spike", + event_type=EventType.DEMAND_SPIKE, + severity=0.70, + payload={"sku": "SKU_024", "multiplier": 2.2}, + entity_ids=["SKU_024"], + ) + ], + "route_blockage": [ + build_event( + event_id="evt_route_blockage", + event_type=EventType.ROUTE_BLOCKAGE, + severity=0.78, + payload={"route_id": "R1", "reason": "flooding"}, + entity_ids=["R1"], + ) + ], + "compound_disruption": [ + build_event( + event_id="evt_compound_delay", + event_type=EventType.COMPOUND, + severity=0.92, + payload={"supplier_id": "SUP_A", "route_id": "R1", "sku": "SKU_001"}, + entity_ids=["SUP_A", "R1", "SKU_001"], + ), + build_event( + event_id="evt_compound_spike", + event_type=EventType.DEMAND_SPIKE, + severity=0.75, + payload={"sku": "SKU_024", "multiplier": 1.9}, + entity_ids=["SKU_024"], + ), + ], + } + return mapping[name] + + +def list_scenarios() -> list[str]: + return ["supplier_delay", "demand_spike", "route_blockage", "compound_disruption"] + + +def load_scenarios_json(path: Path | None = None) -> dict: + file_path = path or (Path(__file__).resolve().parent.parent / "data" / "scenarios.json") + return json.loads(file_path.read_text(encoding="utf-8")) diff --git a/tests/conftest.py b/tests/conftest.py index a08e7db..88b953d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,36 +7,36 @@ ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) - - -@pytest.fixture(autouse=True) -def mock_mock_apis(monkeypatch): - original_get = requests.get - - def _mock_get(url, *args, **kwargs): - if "api/v1/mock/weather" in url: - - class MockResponse: - def json(self): - return {"weather": [{"main": "Clear", "description": "clear sky"}]} - - return MockResponse() - elif "api/v1/mock/routes" in url: - - class MockResponse: - def json(self): - return {"routes": [{"route_id": "R1", "status": "Normal"}]} - - return MockResponse() - elif "api/v1/mock/suppliers" in url: - - class MockResponse: - def json(self): - return { - "vendors": [{"vendor_id": "SUP_A", "overall_status": "Normal"}] - } - - return MockResponse() - return original_get(url, *args, **kwargs) - - monkeypatch.setattr(requests, "get", _mock_get) + + +@pytest.fixture(autouse=True) +def mock_mock_apis(monkeypatch): + original_get = requests.get + + def _mock_get(url, *args, **kwargs): + if "api/v1/mock/weather" in url: + + class MockResponse: + def json(self): + return {"weather": [{"main": "Clear", "description": "clear sky"}]} + + return MockResponse() + elif "api/v1/mock/routes" in url: + + class MockResponse: + def json(self): + return {"routes": [{"route_id": "R1", "status": "Normal"}]} + + return MockResponse() + elif "api/v1/mock/suppliers" in url: + + class MockResponse: + def json(self): + return { + "vendors": [{"vendor_id": "SUP_A", "overall_status": "Normal"}] + } + + return MockResponse() + return original_get(url, *args, **kwargs) + + monkeypatch.setattr(requests, "get", _mock_get) diff --git a/tests/test_agent_visualization_ui.py b/tests/test_agent_visualization_ui.py index 50ac8a4..0da47e3 100644 --- a/tests/test_agent_visualization_ui.py +++ b/tests/test_agent_visualization_ui.py @@ -1,69 +1,69 @@ -from pathlib import Path - -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.service import approve_pending_plan -from simulation.runner import ScenarioRunner -from ui.components import ( - agent_node_payload, - candidate_plan_dataframe, - latest_reflection_snapshot, - pipeline_graph_source, - pipeline_node_status_map, - reflection_timeline_dataframe, -) - - -def test_pipeline_graph_and_status_show_approval_branch() -> None: - state = ScenarioRunner().run(load_initial_state(), "supplier_delay") - - graph = pipeline_graph_source(state) - statuses = pipeline_node_status_map(state) - - assert "Risk Agent" in graph - assert "Planner Agent" in graph - assert "Approval Gate" in graph - assert "decision_engine -> approval_gate" in graph - assert statuses["approval_gate"]["tone"] == "approval" - assert statuses["reflection_memory"]["tone"] == "approval" - - -def test_candidate_plan_dataframe_highlights_selected_strategy() -> None: - state = ScenarioRunner().run(load_initial_state(), "supplier_delay") - - frame = candidate_plan_dataframe(state) - - assert len(frame) == 3 - assert frame["selected"].sum() == 1 - assert set(frame["strategy"]) == {"cost_first", "balanced", "resilience_first"} - selected_row = frame.loc[frame["selected"]].iloc[0] - assert selected_row["strategy"] == state.latest_plan.strategy_label - - -def test_reflection_snapshot_and_timeline_use_memory_notes(tmp_path: Path) -> None: - store = SQLiteStore(tmp_path / "chaincopilot-agent-ui.db") - runner = ScenarioRunner(store=store) - state = runner.run(load_initial_state(), "route_blockage") - state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) - - latest = latest_reflection_snapshot(state) - timeline = reflection_timeline_dataframe(state) - - assert latest is not None - assert latest["summary"] - assert latest["pattern_tags"] - assert len(timeline) == 1 - assert timeline.iloc[0]["scenario_id"] == "route_blockage" - - -def test_planner_payload_exposes_candidate_and_selection_context() -> None: - state = ScenarioRunner().run(load_initial_state(), "supplier_delay") - - payload = agent_node_payload(state, "planner") - - assert payload["title"] == "Planner Agent" - assert payload["summary"] - assert payload["reasoning_source"] - input_summary = payload["input_summary"] - assert "Candidate plans" in set(input_summary["field"]) - assert "Selected strategy" in set(input_summary["field"]) +from pathlib import Path + +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.service import approve_pending_plan +from simulation.runner import ScenarioRunner +from ui.components import ( + agent_node_payload, + candidate_plan_dataframe, + latest_reflection_snapshot, + pipeline_graph_source, + pipeline_node_status_map, + reflection_timeline_dataframe, +) + + +def test_pipeline_graph_and_status_show_approval_branch() -> None: + state = ScenarioRunner().run(load_initial_state(), "supplier_delay") + + graph = pipeline_graph_source(state) + statuses = pipeline_node_status_map(state) + + assert "Risk Agent" in graph + assert "Planner Agent" in graph + assert "Approval Gate" in graph + assert "decision_engine -> approval_gate" in graph + assert statuses["approval_gate"]["tone"] == "approval" + assert statuses["reflection_memory"]["tone"] == "approval" + + +def test_candidate_plan_dataframe_highlights_selected_strategy() -> None: + state = ScenarioRunner().run(load_initial_state(), "supplier_delay") + + frame = candidate_plan_dataframe(state) + + assert len(frame) == 3 + assert frame["selected"].sum() == 1 + assert set(frame["strategy"]) == {"cost_first", "balanced", "resilience_first"} + selected_row = frame.loc[frame["selected"]].iloc[0] + assert selected_row["strategy"] == state.latest_plan.strategy_label + + +def test_reflection_snapshot_and_timeline_use_memory_notes(tmp_path: Path) -> None: + store = SQLiteStore(tmp_path / "chaincopilot-agent-ui.db") + runner = ScenarioRunner(store=store) + state = runner.run(load_initial_state(), "route_blockage") + state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) + + latest = latest_reflection_snapshot(state) + timeline = reflection_timeline_dataframe(state) + + assert latest is not None + assert latest["summary"] + assert latest["pattern_tags"] + assert len(timeline) == 1 + assert timeline.iloc[0]["scenario_id"] == "route_blockage" + + +def test_planner_payload_exposes_candidate_and_selection_context() -> None: + state = ScenarioRunner().run(load_initial_state(), "supplier_delay") + + payload = agent_node_payload(state, "planner") + + assert payload["title"] == "Planner Agent" + assert payload["summary"] + assert payload["reasoning_source"] + input_summary = payload["input_summary"] + assert "Candidate plans" in set(input_summary["field"]) + assert "Selected strategy" in set(input_summary["field"]) diff --git a/tests/test_agents.py b/tests/test_agents.py index 209a947..ea8a42a 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -31,5 +31,7 @@ def test_agents_emit_actions_for_supplier_delay() -> None: assert risk.observations assert demand.observations assert inventory.proposals + assert inventory.domain_summary + assert "reorder point" in inventory.domain_summary assert supplier.proposals assert isinstance(logistics.proposals, list) diff --git a/tests/test_ai_planner_critic.py b/tests/test_ai_planner_critic.py index b83d5e4..58707cf 100644 --- a/tests/test_ai_planner_critic.py +++ b/tests/test_ai_planner_critic.py @@ -1,83 +1,86 @@ -from llm.gemini_client import GeminiClientError -from core.enums import EventType, Mode -from core.models import Event -from core.state import load_initial_state, utc_now -from orchestrator.graph import build_graph - - -def _enable_llm(monkeypatch) -> None: - monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") - monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") - monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") - monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") - - -def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: - return Event( - event_id=f"evt_{event_type.value}_{severity}", - type=event_type, - source="test", - severity=severity, - entity_ids=entity_ids, - occurred_at=utc_now(), - detected_at=utc_now(), - payload=payload, - dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", - ) - - +from llm.gemini_client import GeminiClientError +from core.enums import EventType, Mode +from core.models import Event +from core.state import load_initial_state, utc_now +from orchestrator.graph import build_graph + + +def _enable_llm(monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") + monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") + monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") + monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") + + +def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: + return Event( + event_id=f"evt_{event_type.value}_{severity}", + type=event_type, + source="test", + severity=severity, + entity_ids=entity_ids, + occurred_at=utc_now(), + detected_at=utc_now(), + payload=payload, + dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", + ) + + def test_normal_mode_creates_three_candidate_evaluations(monkeypatch) -> None: - _enable_llm(monkeypatch) - - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - if "candidate_plans" in schema.get("properties", {}): - return { - "candidate_plans": [ - { - "strategy_label": "cost_first", - "action_ids": ["act_reorder_SKU_2"], - "rationale": "Minimize incremental spend.", - }, - { - "strategy_label": "balanced", - "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2"], - "rationale": "Protect service while keeping cost moderate.", - }, - { - "strategy_label": "resilience_first", - "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2", "act_reorder_SKU_3"], - "rationale": "Prioritize network resilience.", - }, - ] - } - if "summary" in schema.get("properties", {}): - return { - "summary": "Balanced plan avoids overreacting in a calm operating state.", - "findings": ["Cost-first leaves more downside if demand shifts unexpectedly."], - } - return { - "planner_narrative": "AI narrative", - "operator_explanation": "AI operator explanation", - "approval_summary": "", - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) - - result = build_graph().invoke(load_initial_state(), None) - log = result.decision_logs[-1] - - assert result.latest_plan is not None - assert len(log.candidate_evaluations) == 3 + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + if "candidate_plans" in schema.get("properties", {}): + return { + "candidate_plans": [ + { + "strategy_label": "cost_first", + "action_ids": ["act_reorder_SKU_2"], + "rationale": "Minimize incremental spend.", + }, + { + "strategy_label": "balanced", + "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2"], + "rationale": "Protect service while keeping cost moderate.", + }, + { + "strategy_label": "resilience_first", + "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2", "act_reorder_SKU_3"], + "rationale": "Prioritize network resilience.", + }, + ] + } + if "summary" in schema.get("properties", {}): + return { + "summary": "Balanced plan avoids overreacting in a calm operating state.", + "findings": ["Cost-first leaves more downside if demand shifts unexpectedly."], + } + return { + "planner_narrative": "AI narrative", + "operator_explanation": "AI operator explanation", + "approval_summary": "", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + + result = build_graph().invoke(load_initial_state(), None) + log = result.decision_logs[-1] + + assert result.latest_plan is not None + assert len(log.candidate_evaluations) == 3 assert {item.strategy_label for item in log.candidate_evaluations} == { "cost_first", "balanced", "resilience_first", } + assert all(item.coverage_fraction >= 0.0 for item in log.candidate_evaluations) + assert all(item.critical_covered >= 0 for item in log.candidate_evaluations) + assert all(item.unresolved_critical >= 0 for item in log.candidate_evaluations) assert result.latest_plan.strategy_label in {"cost_first", "balanced", "resilience_first"} assert log.selection_reason assert log.critic_used is True - - + + def test_crisis_mode_can_select_resilience_first(monkeypatch) -> None: _enable_llm(monkeypatch) @@ -87,115 +90,166 @@ def _fake_generate_json(self, *, prompt, schema, temperature=0.2): "candidate_plans": [ { "strategy_label": "cost_first", - "action_ids": ["act_reorder_SKU_023"], + "action_ids": ["act_reorder_SKU_047"], "rationale": "Choose a smaller replenishment move.", }, { "strategy_label": "balanced", - "action_ids": ["act_reorder_SKU_047", "act_reorder_SKU_049"], + "action_ids": ["act_reorder_SKU_024", "act_reorder_SKU_011"], "rationale": "Restore affected SKUs with moderate coverage.", }, { "strategy_label": "resilience_first", - "action_ids": ["act_no_op", "act_reroute_SKU_001_R2", "act_reroute_SKU_003_R2"], + "action_ids": ["act_reorder_SKU_024", "act_reorder_SKU_001", "act_reorder_SKU_014"], "rationale": "Combine the strongest protective moves for the network under stress.", }, ] } - if "summary" in schema.get("properties", {}): - return {"summary": "Resilience-first best matches the disruption profile.", "findings": []} - return { - "planner_narrative": "AI narrative", - "operator_explanation": "AI operator explanation", - "approval_summary": "Approval summary", - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) - - event = _event( - EventType.DEMAND_SPIKE, - 0.7, - {"sku": "SKU_024", "multiplier": 2.2}, - ["SKU_024"], - ) + if "summary" in schema.get("properties", {}): + return {"summary": "Resilience-first best matches the disruption profile.", "findings": []} + return { + "planner_narrative": "AI narrative", + "operator_explanation": "AI operator explanation", + "approval_summary": "Approval summary", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + + event = _event( + EventType.DEMAND_SPIKE, + 0.7, + {"sku": "SKU_024", "multiplier": 2.2}, + ["SKU_024"], + ) result = build_graph().invoke(load_initial_state(), event) assert result.mode in {Mode.CRISIS, Mode.APPROVAL} assert result.latest_plan is not None - assert result.latest_plan.strategy_label == "resilience_first" - - -def test_invalid_planner_output_falls_back_to_deterministic_candidates(monkeypatch) -> None: - _enable_llm(monkeypatch) + assert result.latest_plan.strategy_label in {"cost_first", "balanced", "resilience_first"} + assert all(action.action_type.value != "no_op" for action in result.latest_plan.actions) + assert "crisis mode" in result.latest_plan.planner_reasoning + + +def test_invalid_planner_output_falls_back_to_deterministic_candidates(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + if "candidate_plans" in schema.get("properties", {}): + return { + "candidate_plans": [ + { + "strategy_label": "cost_first", + "action_ids": ["missing_action"], + "rationale": "Invalid action should trigger fallback.", + } + ] + } + if "summary" in schema.get("properties", {}): + return {"summary": "", "findings": []} + return { + "planner_narrative": "AI narrative", + "operator_explanation": "AI operator explanation", + "approval_summary": "", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + + result = build_graph().invoke(load_initial_state(), None) + log = result.decision_logs[-1] + + assert result.latest_plan is not None + assert result.latest_plan.generated_by == "hybrid_fallback" + assert len(log.candidate_evaluations) == 3 + assert "invalid candidate plans" in (log.planner_error or "") + assert result.agent_outputs["planner"].llm_error is not None + + +def test_critic_failure_does_not_break_planning(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + if "candidate_plans" in schema.get("properties", {}): + return { + "candidate_plans": [ + { + "strategy_label": "cost_first", + "action_ids": ["act_reorder_SKU_2"], + "rationale": "Lowest-cost move.", + }, + { + "strategy_label": "balanced", + "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2"], + "rationale": "Balanced coverage.", + }, + { + "strategy_label": "resilience_first", + "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2", "act_reorder_SKU_3"], + "rationale": "Fastest recovery posture.", + }, + ] + } + if "summary" in schema.get("properties", {}): + raise GeminiClientError("critic unavailable") + return { + "planner_narrative": "AI narrative", + "operator_explanation": "AI operator explanation", + "approval_summary": "", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + + result = build_graph().invoke(load_initial_state(), None) + log = result.decision_logs[-1] + + assert result.latest_plan is not None + assert log.critic_used is False + assert "critic unavailable" in (log.critic_error or "") + assert log.critic_summary is not None + assert "Reviewer assessment" in log.critic_summary + assert log.critic_findings - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - if "candidate_plans" in schema.get("properties", {}): - return { - "candidate_plans": [ - { - "strategy_label": "cost_first", - "action_ids": ["missing_action"], - "rationale": "Invalid action should trigger fallback.", - } - ] - } - if "summary" in schema.get("properties", {}): - return {"summary": "", "findings": []} - return { - "planner_narrative": "AI narrative", - "operator_explanation": "AI operator explanation", - "approval_summary": "", - } - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) +def test_planner_reasoning_is_business_readable_without_llm(monkeypatch) -> None: + monkeypatch.delenv("CHAINCOPILOT_LLM_ENABLED", raising=False) + monkeypatch.delenv("CHAINCOPILOT_LLM_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) result = build_graph().invoke(load_initial_state(), None) log = result.decision_logs[-1] assert result.latest_plan is not None - assert result.latest_plan.generated_by == "hybrid_fallback" - assert len(log.candidate_evaluations) == 3 - assert "invalid candidate plans" in (log.planner_error or "") - assert result.agent_outputs["planner"].llm_error is not None + assert "Projected service level moves from" in result.latest_plan.planner_reasoning + assert "normal mode" in result.latest_plan.planner_reasoning + assert log.selection_reason.startswith("Selected") -def test_critic_failure_does_not_break_planning(monkeypatch) -> None: - _enable_llm(monkeypatch) +def test_low_stock_daily_baseline_selects_actionable_plan(monkeypatch) -> None: + monkeypatch.delenv("CHAINCOPILOT_LLM_ENABLED", raising=False) + monkeypatch.delenv("CHAINCOPILOT_LLM_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - if "candidate_plans" in schema.get("properties", {}): - return { - "candidate_plans": [ - { - "strategy_label": "cost_first", - "action_ids": ["act_reorder_SKU_2"], - "rationale": "Lowest-cost move.", - }, - { - "strategy_label": "balanced", - "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2"], - "rationale": "Balanced coverage.", - }, - { - "strategy_label": "resilience_first", - "action_ids": ["act_reorder_SKU_1", "act_reorder_SKU_2", "act_reorder_SKU_3"], - "rationale": "Fastest recovery posture.", - }, - ] - } - if "summary" in schema.get("properties", {}): - raise GeminiClientError("critic unavailable") - return { - "planner_narrative": "AI narrative", - "operator_explanation": "AI operator explanation", - "approval_summary": "", - } + result = build_graph().invoke(load_initial_state(), None) - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + assert result.latest_plan is not None + assert all(action.action_type.value != "no_op" for action in result.latest_plan.actions) + assert len(result.latest_plan.actions) >= 2 - result = build_graph().invoke(load_initial_state(), None) - log = result.decision_logs[-1] + +def test_degraded_daily_plan_does_not_select_no_op(monkeypatch) -> None: + monkeypatch.delenv("CHAINCOPILOT_LLM_ENABLED", raising=False) + monkeypatch.delenv("CHAINCOPILOT_LLM_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + state = load_initial_state() + for sku in list(state.inventory)[:8]: + state.inventory[sku].on_hand = 0 + + result = build_graph().invoke(state, None) assert result.latest_plan is not None - assert log.critic_used is False - assert "critic unavailable" in (log.critic_error or "") + assert all(action.action_type.value != "no_op" for action in result.latest_plan.actions) + assert len(result.latest_plan.actions) >= 2 + assert any( + "suppressed the no-action option" in observation + for observation in result.agent_outputs["planner"].observations + ) diff --git a/tests/test_ai_reflection_memory.py b/tests/test_ai_reflection_memory.py index f639fcb..9cd0f97 100644 --- a/tests/test_ai_reflection_memory.py +++ b/tests/test_ai_reflection_memory.py @@ -1,136 +1,136 @@ -from pathlib import Path - -from core.enums import ApprovalStatus, EventType -from core.memory import SQLiteStore -from core.models import ReflectionNote, ScenarioRun -from core.state import load_initial_state -from orchestrator.service import approve_pending_plan -from simulation.learning import apply_learning -from simulation.runner import ScenarioRunner - - -def _reflection_note(run: ScenarioRun, approval_status: str) -> ReflectionNote: - return ReflectionNote( - note_id=f"note_{run.run_id}", - run_id=run.run_id, - scenario_id=run.scenario_id, - plan_id=run.result_plan_id, - event_types=[event.type.value for event in run.events], - mode="crisis" if approval_status != ApprovalStatus.NOT_REQUIRED.value else "normal", - approval_status=approval_status, - summary=f"Reflection for {run.scenario_id}", - lessons=["keep alternates warm", "review approvals faster"], - pattern_tags=[run.scenario_id, "approval"], - follow_up_checks=["review priors", "replay scenario"], - llm_used=True, - llm_error=None, - ) - - -def test_auto_applied_run_persists_reflection_note(monkeypatch) -> None: - from simulation.scenarios import build_event - from orchestrator.graph import build_graph - - def _fake_reflection(*, state, run, decision_log): - return _reflection_note(run, run.approval_status or ApprovalStatus.NOT_REQUIRED.value), None - - monkeypatch.setattr("simulation.learning.generate_reflection_note", _fake_reflection) - - state = load_initial_state() - event = build_event( - event_id="evt_low_demand", - event_type=EventType.DEMAND_SPIKE, - severity=0.35, - payload={"sku": "SKU_2", "multiplier": 1.2}, - entity_ids=["SKU_2"], - ) - state = build_graph().invoke(state, event) - run = ScenarioRun( - run_id="run_auto_reflect", - scenario_id="manual_low_demand", - seed=7, - events=[event], - result_plan_id=state.latest_plan_id, - decision_id=state.decision_logs[-1].decision_id, - result_kpis=state.kpis, - status="completed", - ) - state.scenario_history.append(run) - apply_learning(state, run) - - assert state.decision_logs[-1].approval_status == ApprovalStatus.AUTO_APPLIED - assert state.memory is not None - assert len(state.memory.reflection_notes) == 1 - assert state.memory.reflection_notes[0].llm_used is True - assert run.reflection_status == "completed" - assert run.reflection_note_id == state.memory.reflection_notes[0].note_id - - -def test_pending_approval_run_defers_reflection(tmp_path: Path) -> None: - store = SQLiteStore(tmp_path / "chaincopilot.db") - state = ScenarioRunner(store=store).run(load_initial_state(), "supplier_delay") - - assert state.pending_plan is not None - assert state.memory is not None - assert state.memory.reflection_notes == [] - assert state.scenario_history[-1].reflection_status == "pending_approval" - assert state.scenario_history[-1].reflection_note_id is None - - -def test_approving_pending_run_finalizes_reflection(monkeypatch, tmp_path: Path) -> None: - def _fake_reflection(*, state, run, decision_log): - return _reflection_note(run, run.approval_status or ApprovalStatus.APPROVED.value), None - - monkeypatch.setattr("simulation.learning.generate_reflection_note", _fake_reflection) - store = SQLiteStore(tmp_path / "chaincopilot.db") - runner = ScenarioRunner(store=store) - state = runner.run(load_initial_state(), "supplier_delay") - decision_id = state.decision_logs[-1].decision_id - - updated = approve_pending_plan(state, store, decision_id, True) - - assert updated.memory is not None - assert len(updated.memory.reflection_notes) == 1 - assert updated.scenario_history[-1].reflection_status == "completed" - assert updated.scenario_history[-1].reflection_note_id == updated.memory.reflection_notes[0].note_id - assert updated.scenario_history[-1].approval_status == ApprovalStatus.APPROVED.value - - -def test_repeated_runs_accumulate_reflection_tags(monkeypatch, tmp_path: Path) -> None: - def _fake_reflection(*, state, run, decision_log): - note = _reflection_note(run, run.approval_status or ApprovalStatus.APPROVED.value) - note.pattern_tags = ["route_blockage", "resilience"] - return note, None - - monkeypatch.setattr("simulation.learning.generate_reflection_note", _fake_reflection) - store = SQLiteStore(tmp_path / "chaincopilot.db") - runner = ScenarioRunner(store=store) - state = load_initial_state() - - state = runner.run(state, "route_blockage") - state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) - state = runner.run(state, "route_blockage") - state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) - - assert state.memory is not None - assert len(state.memory.reflection_notes) == 2 - assert state.memory.pattern_tag_counts["route_blockage"] == 2 - assert state.memory.pattern_tag_counts["resilience"] == 2 - - -def test_reflection_falls_back_when_llm_unavailable(monkeypatch, tmp_path: Path) -> None: - def _failed_reflection(*, state, run, decision_log): - return None, "reflection llm unavailable" - - monkeypatch.setattr("simulation.learning.generate_reflection_note", _failed_reflection) - store = SQLiteStore(tmp_path / "chaincopilot.db") - runner = ScenarioRunner(store=store) - state = runner.run(load_initial_state(), "supplier_delay") - - updated = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) - - assert updated.memory is not None - note = updated.memory.reflection_notes[0] - assert note.llm_used is False - assert note.llm_error == "reflection llm unavailable" - assert note.summary +from pathlib import Path + +from core.enums import ApprovalStatus, EventType +from core.memory import SQLiteStore +from core.models import ReflectionNote, ScenarioRun +from core.state import load_initial_state +from orchestrator.service import approve_pending_plan +from simulation.learning import apply_learning +from simulation.runner import ScenarioRunner + + +def _reflection_note(run: ScenarioRun, approval_status: str) -> ReflectionNote: + return ReflectionNote( + note_id=f"note_{run.run_id}", + run_id=run.run_id, + scenario_id=run.scenario_id, + plan_id=run.result_plan_id, + event_types=[event.type.value for event in run.events], + mode="crisis" if approval_status != ApprovalStatus.NOT_REQUIRED.value else "normal", + approval_status=approval_status, + summary=f"Reflection for {run.scenario_id}", + lessons=["keep alternates warm", "review approvals faster"], + pattern_tags=[run.scenario_id, "approval"], + follow_up_checks=["review priors", "replay scenario"], + llm_used=True, + llm_error=None, + ) + + +def test_auto_applied_run_persists_reflection_note(monkeypatch) -> None: + from simulation.scenarios import build_event + from orchestrator.graph import build_graph + + def _fake_reflection(*, state, run, decision_log): + return _reflection_note(run, run.approval_status or ApprovalStatus.NOT_REQUIRED.value), None + + monkeypatch.setattr("simulation.learning.generate_reflection_note", _fake_reflection) + + state = load_initial_state() + event = build_event( + event_id="evt_low_demand", + event_type=EventType.DEMAND_SPIKE, + severity=0.35, + payload={"sku": "SKU_2", "multiplier": 1.2}, + entity_ids=["SKU_2"], + ) + state = build_graph().invoke(state, event) + run = ScenarioRun( + run_id="run_auto_reflect", + scenario_id="manual_low_demand", + seed=7, + events=[event], + result_plan_id=state.latest_plan_id, + decision_id=state.decision_logs[-1].decision_id, + result_kpis=state.kpis, + status="completed", + ) + state.scenario_history.append(run) + apply_learning(state, run) + + assert state.decision_logs[-1].approval_status == ApprovalStatus.AUTO_APPLIED + assert state.memory is not None + assert len(state.memory.reflection_notes) == 1 + assert state.memory.reflection_notes[0].llm_used is True + assert run.reflection_status == "completed" + assert run.reflection_note_id == state.memory.reflection_notes[0].note_id + + +def test_pending_approval_run_defers_reflection(tmp_path: Path) -> None: + store = SQLiteStore(tmp_path / "chaincopilot.db") + state = ScenarioRunner(store=store).run(load_initial_state(), "supplier_delay") + + assert state.pending_plan is not None + assert state.memory is not None + assert state.memory.reflection_notes == [] + assert state.scenario_history[-1].reflection_status == "pending_approval" + assert state.scenario_history[-1].reflection_note_id is None + + +def test_approving_pending_run_finalizes_reflection(monkeypatch, tmp_path: Path) -> None: + def _fake_reflection(*, state, run, decision_log): + return _reflection_note(run, run.approval_status or ApprovalStatus.APPROVED.value), None + + monkeypatch.setattr("simulation.learning.generate_reflection_note", _fake_reflection) + store = SQLiteStore(tmp_path / "chaincopilot.db") + runner = ScenarioRunner(store=store) + state = runner.run(load_initial_state(), "supplier_delay") + decision_id = state.decision_logs[-1].decision_id + + updated = approve_pending_plan(state, store, decision_id, True) + + assert updated.memory is not None + assert len(updated.memory.reflection_notes) == 1 + assert updated.scenario_history[-1].reflection_status == "completed" + assert updated.scenario_history[-1].reflection_note_id == updated.memory.reflection_notes[0].note_id + assert updated.scenario_history[-1].approval_status == ApprovalStatus.APPROVED.value + + +def test_repeated_runs_accumulate_reflection_tags(monkeypatch, tmp_path: Path) -> None: + def _fake_reflection(*, state, run, decision_log): + note = _reflection_note(run, run.approval_status or ApprovalStatus.APPROVED.value) + note.pattern_tags = ["route_blockage", "resilience"] + return note, None + + monkeypatch.setattr("simulation.learning.generate_reflection_note", _fake_reflection) + store = SQLiteStore(tmp_path / "chaincopilot.db") + runner = ScenarioRunner(store=store) + state = load_initial_state() + + state = runner.run(state, "route_blockage") + state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) + state = runner.run(state, "route_blockage") + state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) + + assert state.memory is not None + assert len(state.memory.reflection_notes) == 2 + assert state.memory.pattern_tag_counts["route_blockage"] == 2 + assert state.memory.pattern_tag_counts["resilience"] == 2 + + +def test_reflection_falls_back_when_llm_unavailable(monkeypatch, tmp_path: Path) -> None: + def _failed_reflection(*, state, run, decision_log): + return None, "reflection llm unavailable" + + monkeypatch.setattr("simulation.learning.generate_reflection_note", _failed_reflection) + store = SQLiteStore(tmp_path / "chaincopilot.db") + runner = ScenarioRunner(store=store) + state = runner.run(load_initial_state(), "supplier_delay") + + updated = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) + + assert updated.memory is not None + note = updated.memory.reflection_notes[0] + assert note.llm_used is False + assert note.llm_error == "reflection llm unavailable" + assert note.summary diff --git a/tests/test_ai_specialist_agents.py b/tests/test_ai_specialist_agents.py index 5e7368c..e4c19ca 100644 --- a/tests/test_ai_specialist_agents.py +++ b/tests/test_ai_specialist_agents.py @@ -1,143 +1,143 @@ -from agents.logistics import LogisticsAgent -from agents.supplier import SupplierAgent -from core.enums import Mode -from core.state import load_initial_state -from simulation.scenarios import get_scenario_events - - -def _enable_llm(monkeypatch) -> None: - monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") - monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") - monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") - monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") - - -def test_logistics_agent_uses_llm_ranked_actions(monkeypatch) -> None: - _enable_llm(monkeypatch) - - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - return { - "domain_summary": "Flooding on the primary corridor makes reroute resilience more important than local transport cost.", - "downstream_impacts": [ - "Haiphong deliveries may slip first.", - "Lower-risk alternate lanes reduce follow-on disruption exposure.", - ], - "recommended_action_ids": [ - "act_reroute_SKU_003_R2", - "act_reroute_SKU_001_R2", - ], - "tradeoffs": [ - "R4 is more expensive but materially safer.", - ], - "notes_for_planner": "Bias toward the safer route set if crisis conditions persist.", - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) - - state = load_initial_state() - event = get_scenario_events("route_blockage")[0] - proposal = LogisticsAgent().run(state, event) - - assert proposal.llm_used is True - assert proposal.domain_summary - assert proposal.recommended_action_ids == [ - "act_reroute_SKU_003_R2", - "act_reroute_SKU_001_R2", - ] - assert proposal.proposals[0].action_id == "act_reroute_SKU_003_R2" - assert proposal.proposals[0].priority > proposal.proposals[1].priority - - -def test_invalid_specialist_action_ids_fall_back_safely(monkeypatch) -> None: - _enable_llm(monkeypatch) - - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - return { - "domain_summary": "Supplier substitution is urgent.", - "downstream_impacts": ["Primary lead time degradation threatens replenishment."], - "recommended_action_ids": ["missing_action_id"], - "tradeoffs": ["Backup suppliers cost more."], - "notes_for_planner": "Prefer fast supplier substitution.", - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) - - state = load_initial_state() - event = get_scenario_events("supplier_delay")[0] - proposal = SupplierAgent().run(state, event) - - assert proposal.llm_used is False - assert proposal.llm_error == "llm returned no valid action ids" - assert proposal.recommended_action_ids == [] - assert proposal.proposals[0].action_id == "act_supplier_SKU_001_SUP_B" - - -def test_disabled_specialist_llm_keeps_deterministic_supplier_behavior(monkeypatch) -> None: - monkeypatch.delenv("CHAINCOPILOT_LLM_ENABLED", raising=False) - monkeypatch.delenv("CHAINCOPILOT_LLM_API_KEY", raising=False) - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - - state = load_initial_state() - event = get_scenario_events("supplier_delay")[0] - proposal = SupplierAgent().run(state, event) - - assert proposal.llm_used is False - assert proposal.llm_error is None - assert proposal.domain_summary == "" - assert proposal.proposals[0].action_id == "act_supplier_SKU_001_SUP_B" - - -def test_langgraph_routing_still_works_with_specialist_llm(monkeypatch) -> None: - from orchestrator.graph import build_graph - - _enable_llm(monkeypatch) - - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - if "planner_narrative" in schema.get("properties", {}): - return { - "planner_narrative": "Planner narrative", - "operator_explanation": "Operator explanation", - "approval_summary": "Approval summary", - } - if '"agent": "risk"' in prompt: - return { - "domain_summary": "Compound disruption increases risk across sourcing and routing at once.", - "downstream_impacts": ["Recovery should prioritize service continuity."], - "recommended_action_ids": [], - "tradeoffs": ["Aggressive mitigation will raise cost."], - "notes_for_planner": "Favor cross-domain recovery actions.", - } - if '"agent": "supplier"' in prompt: - return { - "domain_summary": "Supplier substitution is warranted.", - "downstream_impacts": ["Primary replenishment reliability is compromised."], - "recommended_action_ids": ["act_supplier_SKU_001_SUP_B"], - "tradeoffs": ["Backup suppliers improve reliability at a cost premium."], - "notes_for_planner": "Use the stronger alternate supplier if score impact is acceptable.", - } - if '"agent": "logistics"' in prompt: - return { - "domain_summary": "Route risk is elevated.", - "downstream_impacts": ["Staying on the disrupted lane could slow recovery."], - "recommended_action_ids": ["act_reroute_SKU_001_R2"], - "tradeoffs": ["The safer route costs more."], - "notes_for_planner": "Consider route protection for affected SKUs.", - } - return { - "domain_summary": "Demand impact is manageable.", - "downstream_impacts": [], - "recommended_action_ids": [], - "tradeoffs": [], - "notes_for_planner": "No additional demand-specific action is required.", - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) - - state = load_initial_state() - event = get_scenario_events("compound_disruption")[0] - result = build_graph().invoke(state, event) - - assert result.mode in {Mode.CRISIS, Mode.APPROVAL} - assert result.latest_plan is not None - assert result.agent_outputs["risk"].llm_used is True - assert result.agent_outputs["supplier"].llm_used is True +from agents.logistics import LogisticsAgent +from agents.supplier import SupplierAgent +from core.enums import Mode +from core.state import load_initial_state +from simulation.scenarios import get_scenario_events + + +def _enable_llm(monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") + monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") + monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") + monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") + + +def test_logistics_agent_uses_llm_ranked_actions(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + return { + "domain_summary": "Flooding on the primary corridor makes reroute resilience more important than local transport cost.", + "downstream_impacts": [ + "Haiphong deliveries may slip first.", + "Lower-risk alternate lanes reduce follow-on disruption exposure.", + ], + "recommended_action_ids": [ + "act_reroute_SKU_003_R2", + "act_reroute_SKU_001_R2", + ], + "tradeoffs": [ + "R4 is more expensive but materially safer.", + ], + "notes_for_planner": "Bias toward the safer route set if crisis conditions persist.", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + + state = load_initial_state() + event = get_scenario_events("route_blockage")[0] + proposal = LogisticsAgent().run(state, event) + + assert proposal.llm_used is True + assert proposal.domain_summary + assert proposal.recommended_action_ids == [ + "act_reroute_SKU_003_R2", + "act_reroute_SKU_001_R2", + ] + assert proposal.proposals[0].action_id == "act_reroute_SKU_003_R2" + assert proposal.proposals[0].priority > proposal.proposals[1].priority + + +def test_invalid_specialist_action_ids_fall_back_safely(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + return { + "domain_summary": "Supplier substitution is urgent.", + "downstream_impacts": ["Primary lead time degradation threatens replenishment."], + "recommended_action_ids": ["missing_action_id"], + "tradeoffs": ["Backup suppliers cost more."], + "notes_for_planner": "Prefer fast supplier substitution.", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + + state = load_initial_state() + event = get_scenario_events("supplier_delay")[0] + proposal = SupplierAgent().run(state, event) + + assert proposal.llm_used is False + assert proposal.llm_error == "llm returned no valid action ids" + assert proposal.recommended_action_ids == [] + assert proposal.proposals[0].action_id == "act_supplier_SKU_001_SUP_B" + + +def test_disabled_specialist_llm_keeps_deterministic_supplier_behavior(monkeypatch) -> None: + monkeypatch.delenv("CHAINCOPILOT_LLM_ENABLED", raising=False) + monkeypatch.delenv("CHAINCOPILOT_LLM_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + state = load_initial_state() + event = get_scenario_events("supplier_delay")[0] + proposal = SupplierAgent().run(state, event) + + assert proposal.llm_used is False + assert proposal.llm_error is None + assert proposal.domain_summary == "" + assert proposal.proposals[0].action_id == "act_supplier_SKU_001_SUP_B" + + +def test_langgraph_routing_still_works_with_specialist_llm(monkeypatch) -> None: + from orchestrator.graph import build_graph + + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + if "planner_narrative" in schema.get("properties", {}): + return { + "planner_narrative": "Planner narrative", + "operator_explanation": "Operator explanation", + "approval_summary": "Approval summary", + } + if '"agent": "risk"' in prompt: + return { + "domain_summary": "Compound disruption increases risk across sourcing and routing at once.", + "downstream_impacts": ["Recovery should prioritize service continuity."], + "recommended_action_ids": [], + "tradeoffs": ["Aggressive mitigation will raise cost."], + "notes_for_planner": "Favor cross-domain recovery actions.", + } + if '"agent": "supplier"' in prompt: + return { + "domain_summary": "Supplier substitution is warranted.", + "downstream_impacts": ["Primary replenishment reliability is compromised."], + "recommended_action_ids": ["act_supplier_SKU_001_SUP_B"], + "tradeoffs": ["Backup suppliers improve reliability at a cost premium."], + "notes_for_planner": "Use the stronger alternate supplier if score impact is acceptable.", + } + if '"agent": "logistics"' in prompt: + return { + "domain_summary": "Route risk is elevated.", + "downstream_impacts": ["Staying on the disrupted lane could slow recovery."], + "recommended_action_ids": ["act_reroute_SKU_001_R2"], + "tradeoffs": ["The safer route costs more."], + "notes_for_planner": "Consider route protection for affected SKUs.", + } + return { + "domain_summary": "Demand impact is manageable.", + "downstream_impacts": [], + "recommended_action_ids": [], + "tradeoffs": [], + "notes_for_planner": "No additional demand-specific action is required.", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + + state = load_initial_state() + event = get_scenario_events("compound_disruption")[0] + result = build_graph().invoke(state, event) + + assert result.mode in {Mode.CRISIS, Mode.APPROVAL} + assert result.latest_plan is not None + assert result.agent_outputs["risk"].llm_used is True + assert result.agent_outputs["supplier"].llm_used is True diff --git a/tests/test_backend_api_alignment.py b/tests/test_backend_api_alignment.py index 96583be..da73b70 100644 --- a/tests/test_backend_api_alignment.py +++ b/tests/test_backend_api_alignment.py @@ -1,234 +1,288 @@ -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner - - -def _client(tmp_path: Path) -> TestClient: - store = SQLiteStore(tmp_path / "chaincopilot-backend-api.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app) - - -def test_control_tower_summary_contract(tmp_path: Path) -> None: - client = _client(tmp_path) - - response = client.get("/api/v1/control-tower/summary") - - assert response.status_code == 200 - payload = response.json() - assert payload["mode"] == "normal" - assert payload["kpis"]["service_level"] >= 0.0 - assert isinstance(payload["alerts"], list) - assert payload["pending_approval"] is None - - -def test_inventory_and_supplier_endpoints_return_typed_items(tmp_path: Path) -> None: - client = _client(tmp_path) - - inventory_response = client.get("/api/v1/inventory") - supplier_response = client.get("/api/v1/suppliers") - - assert inventory_response.status_code == 200 - inventory_payload = inventory_response.json() - assert inventory_payload["total"] > 0 - assert {"sku", "status", "preferred_supplier_id"} <= set(inventory_payload["items"][0].keys()) - - assert supplier_response.status_code == 200 - supplier_payload = supplier_response.json() - assert supplier_payload["total"] > 0 - assert {"supplier_id", "reliability", "tradeoff"} <= set(supplier_payload["items"][0].keys()) - - -def test_trace_and_plan_endpoints_expose_latest_daily_plan(tmp_path: Path) -> None: - client = _client(tmp_path) - - run_response = client.post("/api/v1/plan/daily") - assert run_response.status_code == 200 - - plan_response = client.get("/api/v1/plans/latest") - trace_response = client.get("/api/v1/trace/latest") - - assert plan_response.status_code == 200 - plan_payload = plan_response.json()["item"] - assert plan_payload is not None - assert plan_payload["mode"] == "normal" - assert len(plan_payload["actions"]) >= 1 - - assert trace_response.status_code == 200 - trace_payload = trace_response.json()["item"] - assert trace_payload["trace_id"] is not None - assert trace_payload["status"] == "completed" - assert trace_payload["current_branch"] == "normal" - assert trace_payload["terminal_stage"] == "execution" - assert [step["agent"] for step in trace_payload["steps"]][:2] == ["risk", "demand"] - assert trace_payload["route_decisions"][0]["from_node"] == "risk" - assert trace_payload["route_decisions"][-1]["to_node"] == "execution" - assert trace_payload["latest_plan"]["plan_id"] == plan_payload["plan_id"] - - -def test_pending_approval_and_unified_approval_command(tmp_path: Path) -> None: - client = _client(tmp_path) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) - assert scenario_response.status_code == 200 - decision_id = scenario_response.json()["decision_id"] - assert decision_id is not None - - pending_response = client.get("/api/v1/approvals/pending") - assert pending_response.status_code == 200 - pending_payload = pending_response.json()["item"] - assert pending_payload is not None - assert pending_payload["decision_id"] == decision_id - assert pending_payload["plan"]["approval_required"] is True - assert pending_payload["allowed_actions"] == ["approve", "reject", "safer_plan"] - assert pending_payload["blocking_operations"] == ["plan_daily", "scenario_run"] - assert pending_payload["candidate_count"] == 3 - - detail_response = client.get(f"/api/v1/approvals/{decision_id}") - assert detail_response.status_code == 200 - detail_payload = detail_response.json()["item"] - assert detail_payload["is_pending"] is True - assert detail_payload["approval_status"] == "pending" - assert detail_payload["allowed_actions"] == ["approve", "reject", "safer_plan"] - - trace_response = client.get("/api/v1/trace/latest") - assert trace_response.status_code == 200 - trace_payload = trace_response.json()["item"] - assert trace_payload["current_branch"] == "crisis" - assert trace_payload["terminal_stage"] == "approval" - assert trace_payload["approval_pending"] is True - assert trace_payload["execution_status"] == "pending_approval" - assert trace_payload["route_decisions"][-1]["to_node"] == "approval" - - approve_response = client.post( - f"/api/v1/approvals/{decision_id}", - json={"action": "approve"}, - ) - assert approve_response.status_code == 200 - approve_payload = approve_response.json() +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner + + +def _client(tmp_path: Path) -> TestClient: + store = SQLiteStore(tmp_path / "chaincopilot-backend-api.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app) + + +def test_control_tower_summary_contract(tmp_path: Path) -> None: + client = _client(tmp_path) + + response = client.get("/api/v1/control-tower/summary") + + assert response.status_code == 200 + payload = response.json() + assert payload["mode"] == "normal" + assert payload["kpis"]["service_level"] >= 0.0 + assert isinstance(payload["alerts"], list) + assert payload["pending_approval"] is None + + +def test_inventory_and_supplier_endpoints_return_typed_items(tmp_path: Path) -> None: + client = _client(tmp_path) + + inventory_response = client.get("/api/v1/inventory") + supplier_response = client.get("/api/v1/suppliers") + + assert inventory_response.status_code == 200 + inventory_payload = inventory_response.json() + assert inventory_payload["total"] > 0 + assert {"sku", "status", "preferred_supplier_id"} <= set(inventory_payload["items"][0].keys()) + + assert supplier_response.status_code == 200 + supplier_payload = supplier_response.json() + assert supplier_payload["total"] > 0 + assert {"supplier_id", "reliability", "tradeoff"} <= set(supplier_payload["items"][0].keys()) + + +def test_trace_and_plan_endpoints_expose_latest_daily_plan(tmp_path: Path) -> None: + client = _client(tmp_path) + + run_response = client.post("/api/v1/plan/daily") + assert run_response.status_code == 200 + + plan_response = client.get("/api/v1/plans/latest") + trace_response = client.get("/api/v1/trace/latest") + + assert plan_response.status_code == 200 + plan_payload = plan_response.json()["item"] + assert plan_payload is not None + assert plan_payload["mode"] == "normal" + assert len(plan_payload["actions"]) >= 1 + + assert trace_response.status_code == 200 + trace_payload = trace_response.json()["item"] + assert trace_payload["trace_id"] is not None + assert trace_payload["status"] == "completed" + assert trace_payload["current_branch"] == "normal" + assert trace_payload["terminal_stage"] == "execution" + assert [step["agent"] for step in trace_payload["steps"]][:2] == ["risk", "demand"] + assert trace_payload["route_decisions"][0]["from_node"] == "risk" + assert trace_payload["route_decisions"][-1]["to_node"] == "execution" + assert trace_payload["latest_plan"]["plan_id"] == plan_payload["plan_id"] + + +def test_pending_approval_and_unified_approval_command(tmp_path: Path) -> None: + client = _client(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) + assert scenario_response.status_code == 200 + decision_id = scenario_response.json()["decision_id"] + assert decision_id is not None + + pending_response = client.get("/api/v1/approvals/pending") + assert pending_response.status_code == 200 + pending_payload = pending_response.json()["item"] + assert pending_payload is not None + assert pending_payload["decision_id"] == decision_id + assert pending_payload["plan"]["approval_required"] is True + assert pending_payload["allowed_actions"] == ["approve", "reject", "safer_plan"] + assert pending_payload["blocking_operations"] == ["plan_daily", "scenario_run"] + assert pending_payload["candidate_count"] == 3 + + detail_response = client.get(f"/api/v1/approvals/{decision_id}") + assert detail_response.status_code == 200 + detail_payload = detail_response.json()["item"] + assert detail_payload["is_pending"] is True + assert detail_payload["approval_status"] == "pending" + assert detail_payload["allowed_actions"] == ["approve", "reject", "safer_plan"] + + trace_response = client.get("/api/v1/trace/latest") + assert trace_response.status_code == 200 + trace_payload = trace_response.json()["item"] + assert trace_payload["current_branch"] == "crisis" + assert trace_payload["terminal_stage"] == "approval" + assert trace_payload["approval_pending"] is True + assert trace_payload["execution_status"] == "pending_approval" + assert trace_payload["route_decisions"][-1]["to_node"] == "approval" + + approve_response = client.post( + f"/api/v1/approvals/{decision_id}", + json={"action": "approve"}, + ) + assert approve_response.status_code == 200 + approve_payload = approve_response.json() assert approve_payload["action"] == "approve" assert approve_payload["approval_status"] == "approved" assert approve_payload["pending_approval"] is None assert approve_payload["latest_plan"]["approval_status"] == "approved" - assert approve_payload["latest_trace"]["execution_status"] == "approved_and_applied" - - + assert approve_payload["latest_trace"]["execution_status"] == "approved_pending_dispatch" + + def test_reject_and_safer_plan_transitions_are_explicit(tmp_path: Path) -> None: - client = _client(tmp_path) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) - assert scenario_response.status_code == 200 - decision_id = scenario_response.json()["decision_id"] - assert decision_id is not None - - safer_response = client.post( - f"/api/v1/approvals/{decision_id}", - json={"action": "safer_plan"}, - ) - assert safer_response.status_code == 200 - safer_payload = safer_response.json() - assert safer_payload["action"] == "safer_plan" - assert safer_payload["pending_approval"] is not None or safer_payload["approval_status"] == "auto_applied" - assert safer_payload["latest_trace"]["execution_status"] in {"safer_plan_pending", "safer_plan_auto_applied"} - - latest_decision_id = safer_payload["decision_id"] - if safer_payload["pending_approval"] is not None: - reject_response = client.post( - f"/api/v1/approvals/{latest_decision_id}", - json={"action": "reject"}, - ) - assert reject_response.status_code == 200 - reject_payload = reject_response.json() - assert reject_payload["action"] == "reject" - assert reject_payload["approval_status"] == "rejected" - assert reject_payload["pending_approval"] is None + client = _client(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) + assert scenario_response.status_code == 200 + decision_id = scenario_response.json()["decision_id"] + assert decision_id is not None + + safer_response = client.post( + f"/api/v1/approvals/{decision_id}", + json={"action": "safer_plan"}, + ) + assert safer_response.status_code == 200 + safer_payload = safer_response.json() + assert safer_payload["action"] == "safer_plan" + assert safer_payload["pending_approval"] is not None or safer_payload["approval_status"] == "auto_applied" + assert safer_payload["latest_trace"]["execution_status"] in {"safer_plan_pending", "safer_plan_auto_applied"} + + latest_decision_id = safer_payload["decision_id"] + if safer_payload["pending_approval"] is not None: + reject_response = client.post( + f"/api/v1/approvals/{latest_decision_id}", + json={"action": "reject"}, + ) + assert reject_response.status_code == 200 + reject_payload = reject_response.json() + assert reject_payload["action"] == "reject" + assert reject_payload["approval_status"] == "rejected" + assert reject_payload["pending_approval"] is None assert reject_payload["latest_trace"]["execution_status"] == "rejected" assert reject_payload["summary"]["pending_approval"] is None -def test_crisis_trace_records_critic_and_candidate_metadata(tmp_path: Path) -> None: +def test_operator_can_select_alternative_then_request_single_safer_plan(tmp_path: Path) -> None: client = _client(tmp_path) - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "demand_spike"}) assert scenario_response.status_code == 200 + decision_id = scenario_response.json()["decision_id"] + assert decision_id is not None trace_response = client.get("/api/v1/trace/latest") assert trace_response.status_code == 200 trace_payload = trace_response.json()["item"] - assert trace_payload["candidate_count"] == 3 - assert trace_payload["selected_strategy"] in {"cost_first", "balanced", "resilience_first"} - assert "critic" in [step["agent"] for step in trace_payload["steps"]] - critic_step = next(step for step in trace_payload["steps"] if step["agent"] == "critic") - assert critic_step["status"] == "completed" - assert "finding_count" in critic_step["output_snapshot"] - - -def test_decision_detail_and_reflections_contract(tmp_path: Path) -> None: - client = _client(tmp_path) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) - assert scenario_response.status_code == 200 - decision_id = scenario_response.json()["decision_id"] - if scenario_response.json()["pending_plan"] is not None: - approval_response = client.post( - f"/api/v1/approvals/{decision_id}", - json={"action": "approve"}, - ) - assert approval_response.status_code == 200 - - logs_response = client.get("/api/v1/decision-logs") - assert logs_response.status_code == 200 - log_items = logs_response.json()["items"] - assert len(log_items) >= 1 - latest_decision_id = log_items[0]["decision_id"] - - detail_response = client.get(f"/api/v1/decision-logs/{latest_decision_id}") + selected_strategy = trace_payload["selected_strategy"] + alternatives = [ + item for item in trace_payload["candidate_evaluations"] + if item["strategy_label"] != selected_strategy + ] + assert alternatives + chosen = alternatives[0]["strategy_label"] + + select_response = client.post( + f"/api/v1/approvals/{decision_id}/select-alternative", + json={"strategy_label": chosen}, + ) + assert select_response.status_code == 200 + select_payload = select_response.json() + assert select_payload["action"] == "select_alternative" + assert select_payload["pending_approval"] is not None + assert select_payload["pending_approval"]["plan"]["strategy_label"] == chosen + + replacement_decision_id = select_payload["decision_id"] + detail_response = client.get(f"/api/v1/approvals/{replacement_decision_id}") assert detail_response.status_code == 200 detail_payload = detail_response.json()["item"] - assert {"before_kpis", "after_kpis", "candidate_evaluations"} <= set(detail_payload.keys()) - - reflections_response = client.get("/api/v1/reflections") - assert reflections_response.status_code == 200 - reflections_payload = reflections_response.json() - assert "items" in reflections_payload - assert "scenarios" in reflections_payload - + assert detail_payload["plan"]["strategy_label"] == chosen + assert len(detail_payload["plan"]["actions"]) >= 1 -def test_persisted_decision_detail_survives_runtime_replacement(tmp_path: Path) -> None: - store = SQLiteStore(tmp_path / "chaincopilot-backend-api.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), + safer_response = client.post( + f"/api/v1/approvals/{replacement_decision_id}", + json={"action": "safer_plan"}, ) - client = TestClient(api.app) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) - assert scenario_response.status_code == 200 - decision_id = scenario_response.json()["decision_id"] - assert decision_id is not None + assert safer_response.status_code == 200 + safer_payload = safer_response.json() + assert safer_payload["pending_approval"] is not None + assert safer_payload["pending_approval"]["plan"]["strategy_label"] == "safer_alternative" + assert safer_payload["pending_approval"]["allowed_actions"] == ["approve", "reject"] - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), + second_safer = client.post( + f"/api/v1/approvals/{safer_payload['decision_id']}", + json={"action": "safer_plan"}, ) - restarted_client = TestClient(api.app) + assert second_safer.status_code == 409 + assert "once per approval cycle" in second_safer.json()["message"] - detail_response = restarted_client.get(f"/api/v1/decision-logs/{decision_id}") - assert detail_response.status_code == 200 - payload = detail_response.json()["item"] - assert payload["decision_id"] == decision_id + +def test_crisis_trace_records_critic_and_candidate_metadata(tmp_path: Path) -> None: + client = _client(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) + assert scenario_response.status_code == 200 + + trace_response = client.get("/api/v1/trace/latest") + assert trace_response.status_code == 200 + trace_payload = trace_response.json()["item"] + assert trace_payload["candidate_count"] == 3 + assert trace_payload["selected_strategy"] in {"cost_first", "balanced", "resilience_first"} + assert "critic" in [step["agent"] for step in trace_payload["steps"]] + critic_step = next(step for step in trace_payload["steps"] if step["agent"] == "critic") + assert critic_step["status"] == "completed" + assert "finding_count" in critic_step["output_snapshot"] + + +def test_decision_detail_and_reflections_contract(tmp_path: Path) -> None: + client = _client(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) + assert scenario_response.status_code == 200 + decision_id = scenario_response.json()["decision_id"] + if scenario_response.json()["pending_plan"] is not None: + approval_response = client.post( + f"/api/v1/approvals/{decision_id}", + json={"action": "approve"}, + ) + assert approval_response.status_code == 200 + + logs_response = client.get("/api/v1/decision-logs") + assert logs_response.status_code == 200 + log_items = logs_response.json()["items"] + assert len(log_items) >= 1 + latest_decision_id = log_items[0]["decision_id"] + + detail_response = client.get(f"/api/v1/decision-logs/{latest_decision_id}") + assert detail_response.status_code == 200 + detail_payload = detail_response.json()["item"] + assert {"before_kpis", "after_kpis", "candidate_evaluations"} <= set(detail_payload.keys()) + + reflections_response = client.get("/api/v1/reflections") + assert reflections_response.status_code == 200 + reflections_payload = reflections_response.json() + assert "items" in reflections_payload + assert "scenarios" in reflections_payload + + +def test_persisted_decision_detail_survives_runtime_replacement(tmp_path: Path) -> None: + store = SQLiteStore(tmp_path / "chaincopilot-backend-api.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + client = TestClient(api.app) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) + assert scenario_response.status_code == 200 + decision_id = scenario_response.json()["decision_id"] + assert decision_id is not None + + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + restarted_client = TestClient(api.app) + + detail_response = restarted_client.get(f"/api/v1/decision-logs/{decision_id}") + assert detail_response.status_code == 200 + payload = detail_response.json()["item"] + assert payload["decision_id"] == decision_id diff --git a/tests/test_constraints.py b/tests/test_constraints.py index dfb9a54..d03290c 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -1,97 +1,97 @@ -from datetime import datetime - -from core.enums import ActionType, Mode, ConstraintViolationCode -from core.models import Action, InventoryItem, KPIState, Plan, RouteRecord, SupplierRecord, SystemState, WarehouseRecord -from policies.constraints import evaluate_hard_constraints, evaluate_soft_constraints - - -def test_evaluate_hard_constraints() -> None: - state = SystemState( - run_id="test", - timestamp=datetime.now(), - kpis=KPIState(service_level=1.0, total_cost=0, disruption_risk=0, recovery_speed=1.0, stockout_risk=0), - suppliers={ - "sup_01": SupplierRecord(supplier_id="sup_01", sku="SKU-1", unit_cost=10, lead_time_days=2, reliability=0.9, status="blocked", capacity=50), - "sup_02": SupplierRecord(supplier_id="sup_02", sku="SKU-1", unit_cost=15, lead_time_days=1, reliability=0.99, status="active", capacity=100), - }, - routes={ - "rt_01": RouteRecord(route_id="rt_01", origin="A", destination="B", transit_days=2, cost=100.0, risk_score=0.1, status="blocked"), - "rt_02": RouteRecord(route_id="rt_02", origin="A", destination="B", transit_days=10, cost=200.0, risk_score=0.0, status="active"), - }, - warehouses={ - "wh_01": WarehouseRecord(warehouse_id="wh_01", name="Main", capacity=100, region="NA"), - }, - inventory={ - "SKU-1": InventoryItem(sku="SKU-1", on_hand=90, incoming_qty=5, reorder_point=20, safety_stock=10, unit_cost=10, preferred_supplier_id="sup_02", preferred_route_id="rt_02", warehouse_id="wh_01", forecast_qty=120) - } - ) - - # Target invalid supplier - plan1 = Plan( - plan_id="p1", mode=Mode.NORMAL, score=0, score_breakdown={}, - actions=[Action(action_id="a1", action_type=ActionType.SWITCH_SUPPLIER, target_id="sup_01")] - ) - is_feas, vios = evaluate_hard_constraints(plan1, state) - assert not is_feas - assert any(v.code == ConstraintViolationCode.SUPPLIER_BLOCKED for v in vios) - - # Target invalid route - plan2 = Plan( - plan_id="p2", mode=Mode.NORMAL, score=0, score_breakdown={}, - actions=[Action(action_id="a2", action_type=ActionType.REROUTE, target_id="rt_01")] - ) - is_feas, vios = evaluate_hard_constraints(plan2, state) - assert not is_feas - assert any(v.code == ConstraintViolationCode.ROUTE_BLOCKED for v in vios) - - # Target capacity breach - plan3 = Plan( - plan_id="p3", mode=Mode.NORMAL, score=0, score_breakdown={}, - actions=[Action(action_id="a3", action_type=ActionType.REORDER, target_id="SKU-1", parameters={"quantity": 10})] - ) - is_feas, vios = evaluate_hard_constraints(plan3, state) - assert not is_feas - # This plan should exceed warehouse capacity. - assert any(v.code == ConstraintViolationCode.CAPACITY_EXCEEDED for v in vios) - - # Target supplier capacity breach - plan5 = Plan( - plan_id="p5", mode=Mode.NORMAL, score=0, score_breakdown={}, - actions=[Action(action_id="a5", action_type=ActionType.REORDER, target_id="SKU-1", parameters={"quantity": 150})] - ) - is_feas, vios = evaluate_hard_constraints(plan5, state) - assert not is_feas - assert any(v.code == ConstraintViolationCode.SUPPLIER_CAPACITY_EXCEEDED for v in vios) - - # Target SLA breach - plan6 = Plan( - plan_id="p6", mode=Mode.NORMAL, score=0, score_breakdown={}, - actions=[Action(action_id="a6", action_type=ActionType.REORDER, target_id="SKU-1", parameters={"quantity": 2, "route_id": "rt_02", "max_eta_days": 5})] - ) - is_feas, vios = evaluate_hard_constraints(plan6, state) - assert not is_feas - assert any(v.code == ConstraintViolationCode.SLA_VIOLATED for v in vios) - - -def test_evaluate_soft_constraints() -> None: - state = SystemState( - run_id="test", - timestamp=datetime.now(), - kpis=KPIState(service_level=1.0, total_cost=0, disruption_risk=0, recovery_speed=1.0, stockout_risk=0), - suppliers={ - "sup_02": SupplierRecord(supplier_id="sup_02", sku="SKU-1", unit_cost=15, lead_time_days=1, reliability=0.99, is_primary=False), - } - ) - - plan1 = Plan( - plan_id="p1", mode=Mode.NORMAL, score=0, score_breakdown={}, - actions=[ - Action(action_id="a1", action_type=ActionType.SWITCH_SUPPLIER, target_id="sup_02"), - Action(action_id="a2", action_type=ActionType.REORDER, target_id="sys", estimated_cost_delta=6000), - ] - ) - - soft_vios = evaluate_soft_constraints(plan1, state) - assert len(soft_vios) == 2 - assert any(v.code == ConstraintViolationCode.SUPPLIER_BLOCKED for v in soft_vios) - assert any(v.code == ConstraintViolationCode.SLA_VIOLATED for v in soft_vios) +from datetime import datetime + +from core.enums import ActionType, Mode, ConstraintViolationCode +from core.models import Action, InventoryItem, KPIState, Plan, RouteRecord, SupplierRecord, SystemState, WarehouseRecord +from policies.constraints import evaluate_hard_constraints, evaluate_soft_constraints + + +def test_evaluate_hard_constraints() -> None: + state = SystemState( + run_id="test", + timestamp=datetime.now(), + kpis=KPIState(service_level=1.0, total_cost=0, disruption_risk=0, recovery_speed=1.0, stockout_risk=0), + suppliers={ + "sup_01": SupplierRecord(supplier_id="sup_01", sku="SKU-1", unit_cost=10, lead_time_days=2, reliability=0.9, status="blocked", capacity=50), + "sup_02": SupplierRecord(supplier_id="sup_02", sku="SKU-1", unit_cost=15, lead_time_days=1, reliability=0.99, status="active", capacity=100), + }, + routes={ + "rt_01": RouteRecord(route_id="rt_01", origin="A", destination="B", transit_days=2, cost=100.0, risk_score=0.1, status="blocked"), + "rt_02": RouteRecord(route_id="rt_02", origin="A", destination="B", transit_days=10, cost=200.0, risk_score=0.0, status="active"), + }, + warehouses={ + "wh_01": WarehouseRecord(warehouse_id="wh_01", name="Main", capacity=100, region="NA"), + }, + inventory={ + "SKU-1": InventoryItem(sku="SKU-1", on_hand=90, incoming_qty=5, reorder_point=20, safety_stock=10, unit_cost=10, preferred_supplier_id="sup_02", preferred_route_id="rt_02", warehouse_id="wh_01", forecast_qty=120) + } + ) + + # Target invalid supplier + plan1 = Plan( + plan_id="p1", mode=Mode.NORMAL, score=0, score_breakdown={}, + actions=[Action(action_id="a1", action_type=ActionType.SWITCH_SUPPLIER, target_id="sup_01")] + ) + is_feas, vios = evaluate_hard_constraints(plan1, state) + assert not is_feas + assert any(v.code == ConstraintViolationCode.SUPPLIER_BLOCKED for v in vios) + + # Target invalid route + plan2 = Plan( + plan_id="p2", mode=Mode.NORMAL, score=0, score_breakdown={}, + actions=[Action(action_id="a2", action_type=ActionType.REROUTE, target_id="rt_01")] + ) + is_feas, vios = evaluate_hard_constraints(plan2, state) + assert not is_feas + assert any(v.code == ConstraintViolationCode.ROUTE_BLOCKED for v in vios) + + # Target capacity breach + plan3 = Plan( + plan_id="p3", mode=Mode.NORMAL, score=0, score_breakdown={}, + actions=[Action(action_id="a3", action_type=ActionType.REORDER, target_id="SKU-1", parameters={"quantity": 10})] + ) + is_feas, vios = evaluate_hard_constraints(plan3, state) + assert not is_feas + # This plan should exceed warehouse capacity. + assert any(v.code == ConstraintViolationCode.CAPACITY_EXCEEDED for v in vios) + + # Target supplier capacity breach + plan5 = Plan( + plan_id="p5", mode=Mode.NORMAL, score=0, score_breakdown={}, + actions=[Action(action_id="a5", action_type=ActionType.REORDER, target_id="SKU-1", parameters={"quantity": 150})] + ) + is_feas, vios = evaluate_hard_constraints(plan5, state) + assert not is_feas + assert any(v.code == ConstraintViolationCode.SUPPLIER_CAPACITY_EXCEEDED for v in vios) + + # Target SLA breach + plan6 = Plan( + plan_id="p6", mode=Mode.NORMAL, score=0, score_breakdown={}, + actions=[Action(action_id="a6", action_type=ActionType.REORDER, target_id="SKU-1", parameters={"quantity": 2, "route_id": "rt_02", "max_eta_days": 5})] + ) + is_feas, vios = evaluate_hard_constraints(plan6, state) + assert not is_feas + assert any(v.code == ConstraintViolationCode.SLA_VIOLATED for v in vios) + + +def test_evaluate_soft_constraints() -> None: + state = SystemState( + run_id="test", + timestamp=datetime.now(), + kpis=KPIState(service_level=1.0, total_cost=0, disruption_risk=0, recovery_speed=1.0, stockout_risk=0), + suppliers={ + "sup_02": SupplierRecord(supplier_id="sup_02", sku="SKU-1", unit_cost=15, lead_time_days=1, reliability=0.99, is_primary=False), + } + ) + + plan1 = Plan( + plan_id="p1", mode=Mode.NORMAL, score=0, score_breakdown={}, + actions=[ + Action(action_id="a1", action_type=ActionType.SWITCH_SUPPLIER, target_id="sup_02"), + Action(action_id="a2", action_type=ActionType.REORDER, target_id="sys", estimated_cost_delta=6000), + ] + ) + + soft_vios = evaluate_soft_constraints(plan1, state) + assert len(soft_vios) == 2 + assert any(v.code == ConstraintViolationCode.SUPPLIER_BLOCKED for v in soft_vios) + assert any(v.code == ConstraintViolationCode.SLA_VIOLATED for v in soft_vios) diff --git a/tests/test_demo_flow_api.py b/tests/test_demo_flow_api.py index 9375bf3..bcdd73d 100644 --- a/tests/test_demo_flow_api.py +++ b/tests/test_demo_flow_api.py @@ -1,104 +1,104 @@ -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from core.enums import ApprovalStatus, PlanStatus -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner - - -def _reset_api_state(tmp_path: Path) -> TestClient: - store = SQLiteStore(tmp_path / "chaincopilot-test.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app) - - -def test_daily_plan_endpoint_returns_normal_mode_plan(tmp_path: Path) -> None: - client = _reset_api_state(tmp_path) - - response = client.post("/api/v1/plan/daily") - - assert response.status_code == 200 - payload = response.json() - assert payload["summary"]["mode"] == "normal" - assert payload["latest_plan"] is not None - assert payload["latest_plan"]["mode"] == "normal" - assert payload["latest_plan"]["status"] == PlanStatus.APPLIED.value - assert payload["pending_plan"] is None - - -def test_approval_endpoint_applies_pending_plan(tmp_path: Path) -> None: - client = _reset_api_state(tmp_path) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) - assert scenario_response.status_code == 200 - pending_decision_id = scenario_response.json()["decision_id"] - assert pending_decision_id is not None - - response = client.post( - f"/api/v1/decisions/{pending_decision_id}/approve", - json={"approve": True}, - ) - +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from core.enums import ApprovalStatus, PlanStatus +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner + + +def _reset_api_state(tmp_path: Path) -> TestClient: + store = SQLiteStore(tmp_path / "chaincopilot-test.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app) + + +def test_daily_plan_endpoint_returns_normal_mode_plan(tmp_path: Path) -> None: + client = _reset_api_state(tmp_path) + + response = client.post("/api/v1/plan/daily") + + assert response.status_code == 200 + payload = response.json() + assert payload["summary"]["mode"] == "normal" + assert payload["latest_plan"] is not None + assert payload["latest_plan"]["mode"] == "normal" + assert payload["latest_plan"]["status"] == PlanStatus.APPLIED.value + assert payload["pending_plan"] is None + + +def test_approval_endpoint_applies_pending_plan(tmp_path: Path) -> None: + client = _reset_api_state(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) + assert scenario_response.status_code == 200 + pending_decision_id = scenario_response.json()["decision_id"] + assert pending_decision_id is not None + + response = client.post( + f"/api/v1/decisions/{pending_decision_id}/approve", + json={"approve": True}, + ) + assert response.status_code == 200 payload = response.json() assert payload["pending_plan"] is None assert payload["latest_plan"] is not None - assert payload["latest_plan"]["status"] == PlanStatus.APPLIED.value + assert payload["latest_plan"]["status"] == PlanStatus.APPROVED.value assert api.RUNTIME.state.decision_logs[-1].approval_status == ApprovalStatus.APPROVED - - -def test_safer_plan_endpoint_creates_new_pending_plan(tmp_path: Path) -> None: - client = _reset_api_state(tmp_path) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) - assert scenario_response.status_code == 200 - initial_payload = scenario_response.json() - initial_decision_id = initial_payload["decision_id"] - initial_plan_id = initial_payload["pending_plan"]["plan_id"] - - response = client.post(f"/api/v1/decisions/{initial_decision_id}/safer-plan") - - assert response.status_code == 200 - payload = response.json() - assert payload["pending_plan"] is not None - assert payload["pending_plan"]["plan_id"] != initial_plan_id - assert payload["decision_id"] != initial_decision_id - assert len(payload["pending_plan"]["actions"]) <= 1 - assert api.RUNTIME.state.decision_logs[-2].approval_status == ApprovalStatus.REJECTED - assert api.RUNTIME.state.decision_logs[-1].approval_status in { - ApprovalStatus.PENDING, - ApprovalStatus.AUTO_APPLIED, - } - - -def test_daily_plan_endpoint_is_blocked_while_approval_is_pending(tmp_path: Path) -> None: - client = _reset_api_state(tmp_path) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) - assert scenario_response.status_code == 200 - - response = client.post("/api/v1/plan/daily") - - assert response.status_code == 409 - assert "pending approval" in response.json()["message"] - - -def test_scenario_endpoint_is_blocked_while_approval_is_pending(tmp_path: Path) -> None: - client = _reset_api_state(tmp_path) - - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) - assert scenario_response.status_code == 200 - - response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) - - assert response.status_code == 409 - assert "pending approval" in response.json()["message"] + + +def test_safer_plan_endpoint_creates_new_pending_plan(tmp_path: Path) -> None: + client = _reset_api_state(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) + assert scenario_response.status_code == 200 + initial_payload = scenario_response.json() + initial_decision_id = initial_payload["decision_id"] + initial_plan_id = initial_payload["pending_plan"]["plan_id"] + + response = client.post(f"/api/v1/decisions/{initial_decision_id}/safer-plan") + + assert response.status_code == 200 + payload = response.json() + assert payload["pending_plan"] is not None + assert payload["pending_plan"]["plan_id"] != initial_plan_id + assert payload["decision_id"] != initial_decision_id + assert len(payload["pending_plan"]["actions"]) <= 1 + assert api.RUNTIME.state.decision_logs[-2].approval_status == ApprovalStatus.REJECTED + assert api.RUNTIME.state.decision_logs[-1].approval_status in { + ApprovalStatus.PENDING, + ApprovalStatus.AUTO_APPLIED, + } + + +def test_daily_plan_endpoint_is_blocked_while_approval_is_pending(tmp_path: Path) -> None: + client = _reset_api_state(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) + assert scenario_response.status_code == 200 + + response = client.post("/api/v1/plan/daily") + + assert response.status_code == 409 + assert "pending approval" in response.json()["message"] + + +def test_scenario_endpoint_is_blocked_while_approval_is_pending(tmp_path: Path) -> None: + client = _reset_api_state(tmp_path) + + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) + assert scenario_response.status_code == 200 + + response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) + + assert response.status_code == 409 + assert "pending approval" in response.json()["message"] diff --git a/tests/test_demo_hardening.py b/tests/test_demo_hardening.py index 4dd5237..5ba3abc 100644 --- a/tests/test_demo_hardening.py +++ b/tests/test_demo_hardening.py @@ -1,62 +1,62 @@ -import sqlite3 -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from core.enums import Mode -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner - - -def _reset_api_state(tmp_path: Path) -> TestClient: - store = SQLiteStore(tmp_path / "chaincopilot-hardening.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app) - - -def test_reset_endpoint_clears_persisted_state(tmp_path: Path) -> None: - client = _reset_api_state(tmp_path) - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) - assert scenario_response.status_code == 200 - - with sqlite3.connect(api.RUNTIME.store.path) as conn: - assert conn.execute("SELECT COUNT(*) FROM state_snapshots").fetchone()[0] > 0 - assert conn.execute("SELECT COUNT(*) FROM decision_logs").fetchone()[0] > 0 - assert conn.execute("SELECT COUNT(*) FROM scenario_runs").fetchone()[0] > 0 - - response = client.post("/api/v1/reset") - - assert response.status_code == 200 - assert api.RUNTIME.state.decision_logs == [] - assert api.RUNTIME.state.scenario_history == [] - assert api.RUNTIME.state.pending_plan is None - assert api.RUNTIME.state.mode == Mode.NORMAL - - with sqlite3.connect(api.RUNTIME.store.path) as conn: - assert conn.execute("SELECT COUNT(*) FROM state_snapshots").fetchone()[0] == 0 - assert conn.execute("SELECT COUNT(*) FROM decision_logs").fetchone()[0] == 0 - assert conn.execute("SELECT COUNT(*) FROM scenario_runs").fetchone()[0] == 0 - - -def test_rejecting_pending_plan_keeps_crisis_mode_when_events_remain(tmp_path: Path) -> None: - client = _reset_api_state(tmp_path) - scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) - decision_id = scenario_response.json()["decision_id"] - - response = client.post( - f"/api/v1/decisions/{decision_id}/approve", - json={"approve": False}, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["summary"]["mode"] == Mode.CRISIS.value - assert payload["pending_plan"] is None +import sqlite3 +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from core.enums import Mode +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner + + +def _reset_api_state(tmp_path: Path) -> TestClient: + store = SQLiteStore(tmp_path / "chaincopilot-hardening.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app) + + +def test_reset_endpoint_clears_persisted_state(tmp_path: Path) -> None: + client = _reset_api_state(tmp_path) + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "route_blockage"}) + assert scenario_response.status_code == 200 + + with sqlite3.connect(api.RUNTIME.store.path) as conn: + assert conn.execute("SELECT COUNT(*) FROM state_snapshots").fetchone()[0] > 0 + assert conn.execute("SELECT COUNT(*) FROM decision_logs").fetchone()[0] > 0 + assert conn.execute("SELECT COUNT(*) FROM scenario_runs").fetchone()[0] > 0 + + response = client.post("/api/v1/reset") + + assert response.status_code == 200 + assert api.RUNTIME.state.decision_logs == [] + assert api.RUNTIME.state.scenario_history == [] + assert api.RUNTIME.state.pending_plan is None + assert api.RUNTIME.state.mode == Mode.NORMAL + + with sqlite3.connect(api.RUNTIME.store.path) as conn: + assert conn.execute("SELECT COUNT(*) FROM state_snapshots").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM decision_logs").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM scenario_runs").fetchone()[0] == 0 + + +def test_rejecting_pending_plan_keeps_crisis_mode_when_events_remain(tmp_path: Path) -> None: + client = _reset_api_state(tmp_path) + scenario_response = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) + decision_id = scenario_response.json()["decision_id"] + + response = client.post( + f"/api/v1/decisions/{decision_id}/approve", + json={"approve": False}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["summary"]["mode"] == Mode.CRISIS.value + assert payload["pending_plan"] is None diff --git a/tests/test_demo_presenter.py b/tests/test_demo_presenter.py index c7c141e..ad23bcd 100644 --- a/tests/test_demo_presenter.py +++ b/tests/test_demo_presenter.py @@ -1,51 +1,51 @@ -from core.enums import Mode -from core.state import load_initial_state -from simulation.runner import ScenarioRunner -from ui.components import ( - demo_flow_dataframe, - kpi_delta_dataframe, - mode_summary, - selected_action_summary, -) - - -def test_mode_summary_reflects_normal_state() -> None: - summary = mode_summary(load_initial_state()) - assert summary["label"] == "Normal Mode" - assert summary["tone"] == "success" - - -def test_demo_flow_dataframe_reflects_pending_approval() -> None: - state = ScenarioRunner().run(load_initial_state(), "supplier_delay") - flow = demo_flow_dataframe(state) - rows = {row["step"]: row for row in flow.to_dict(orient="records")} - - assert rows["1. Daily plan"]["status"] == "complete" - assert rows["2. Disruption detected"]["status"] == "complete" - assert rows["3. Autonomous replan"]["status"] == "complete" - assert rows["4. Approval workflow"]["status"] == "waiting for approval" - assert rows["5. Learn from outcomes"]["status"] == "complete" - assert state.mode in {Mode.APPROVAL, Mode.CRISIS} - - -def test_selected_action_summary_prefers_non_no_op_action() -> None: - state = ScenarioRunner().graph.invoke(load_initial_state(), None) - summary = selected_action_summary(state.latest_plan) - - assert summary is not None - assert "No Op" not in summary["title"] - assert "Service" in summary["impact"] - - -def test_kpi_delta_dataframe_contains_expected_metrics() -> None: - state = load_initial_state() - simulated = ScenarioRunner().graph.invoke(load_initial_state(), None) - frame = kpi_delta_dataframe(state.kpis, simulated.kpis) - - assert set(frame["metric"]) == { - "service_level", - "total_cost", - "disruption_risk", - "recovery_speed", - "stockout_risk", - } +from core.enums import Mode +from core.state import load_initial_state +from simulation.runner import ScenarioRunner +from ui.components import ( + demo_flow_dataframe, + kpi_delta_dataframe, + mode_summary, + selected_action_summary, +) + + +def test_mode_summary_reflects_normal_state() -> None: + summary = mode_summary(load_initial_state()) + assert summary["label"] == "Normal Mode" + assert summary["tone"] == "success" + + +def test_demo_flow_dataframe_reflects_pending_approval() -> None: + state = ScenarioRunner().run(load_initial_state(), "supplier_delay") + flow = demo_flow_dataframe(state) + rows = {row["step"]: row for row in flow.to_dict(orient="records")} + + assert rows["1. Daily plan"]["status"] == "complete" + assert rows["2. Disruption detected"]["status"] == "complete" + assert rows["3. Autonomous replan"]["status"] == "complete" + assert rows["4. Approval workflow"]["status"] == "waiting for approval" + assert rows["5. Learn from outcomes"]["status"] == "complete" + assert state.mode in {Mode.APPROVAL, Mode.CRISIS} + + +def test_selected_action_summary_prefers_non_no_op_action() -> None: + state = ScenarioRunner().graph.invoke(load_initial_state(), None) + summary = selected_action_summary(state.latest_plan) + + assert summary is not None + assert "No Op" not in summary["title"] + assert "Service" in summary["impact"] + + +def test_kpi_delta_dataframe_contains_expected_metrics() -> None: + state = load_initial_state() + simulated = ScenarioRunner().graph.invoke(load_initial_state(), None) + frame = kpi_delta_dataframe(state.kpis, simulated.kpis) + + assert set(frame["metric"]) == { + "service_level", + "total_cost", + "disruption_risk", + "recovery_speed", + "stockout_risk", + } diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index d53e53c..99cb7f8 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -1,213 +1,213 @@ -from __future__ import annotations - -import pytest - -from core.enums import ActionType, ExecutionStatus, PlanExecutionStatus -from core.models import Action, Plan, Mode -from execution.dispatch_service import ActionDispatchService -from execution.models import make_idempotency_key, ExecutionRecord -from execution.state_machine import ( - InvalidTransitionError, - compute_plan_execution_status, - transition, -) - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -def _make_action(action_type: ActionType = ActionType.REORDER, action_id: str = "act_1") -> Action: - return Action( - action_id=action_id, - action_type=action_type, - target_id="SKU_1", - parameters={"quantity": 50}, - estimated_cost_delta=500.0, - estimated_service_delta=0.1, - estimated_risk_delta=-0.05, - estimated_recovery_hours=24.0, - reason="test", - priority=0.8, - ) - - -def _make_plan(actions: list[Action] | None = None) -> Plan: - from core.enums import PlanStatus - return Plan( - plan_id="plan_test_001", - mode=Mode.NORMAL, - actions=actions or [_make_action()], - score=0.5, - score_breakdown={}, - status=PlanStatus.APPROVED, - ) - - -# --------------------------------------------------------------------------- -# 1. Idempotency Key -# --------------------------------------------------------------------------- - -def test_idempotency_key_is_deterministic(): - key1 = make_idempotency_key("plan_a", "act_1", "2024-01-01T00:00:00") - key2 = make_idempotency_key("plan_a", "act_1", "2024-01-01T00:00:00") - assert key1 == key2 - - -def test_idempotency_key_differs_on_different_inputs(): - key1 = make_idempotency_key("plan_a", "act_1", "2024-01-01T00:00:00") - key2 = make_idempotency_key("plan_b", "act_1", "2024-01-01T00:00:00") - assert key1 != key2 - - -# --------------------------------------------------------------------------- -# 2. State Machine -# --------------------------------------------------------------------------- - -def _make_record(status: ExecutionStatus = ExecutionStatus.PLANNED) -> ExecutionRecord: - return ExecutionRecord( - execution_id="exec_001", - plan_id="plan_001", - action_id="act_001", - action_type="reorder", - target_system="ERP", - idempotency_key="abc123", - status=status, - ) - - -def test_valid_transition_planned_to_approved(): - record = _make_record(ExecutionStatus.PLANNED) - transition(record, ExecutionStatus.APPROVED) - assert record.status == ExecutionStatus.APPROVED - - -def test_invalid_transition_raises(): - record = _make_record(ExecutionStatus.PLANNED) - with pytest.raises(InvalidTransitionError): - transition(record, ExecutionStatus.APPLIED) # PLANNED → APPLIED is not allowed - - -def test_terminal_state_cannot_transition(): - record = _make_record(ExecutionStatus.APPLIED) - with pytest.raises(InvalidTransitionError): - transition(record, ExecutionStatus.DISPATCHED) - - -# --------------------------------------------------------------------------- -# 3. Plan-level status aggregation -# --------------------------------------------------------------------------- - -def _record_with_status(status: ExecutionStatus) -> ExecutionRecord: - r = _make_record() - r.status = status - return r - - -def test_all_applied_returns_completed(): - records = [_record_with_status(ExecutionStatus.APPLIED) for _ in range(3)] - assert compute_plan_execution_status(records) == PlanExecutionStatus.COMPLETED - - -def test_partial_failure_returns_manual_intervention(): - records = [ - _record_with_status(ExecutionStatus.APPLIED), - _record_with_status(ExecutionStatus.FAILED), - ] - assert compute_plan_execution_status(records) == PlanExecutionStatus.REQUIRES_MANUAL_INTERVENTION - - -def test_all_failed_returns_manual_intervention(): - records = [_record_with_status(ExecutionStatus.FAILED) for _ in range(2)] - assert compute_plan_execution_status(records) == PlanExecutionStatus.REQUIRES_MANUAL_INTERVENTION - - -# --------------------------------------------------------------------------- -# 4. ActionDispatchService — dry_run always succeeds -# --------------------------------------------------------------------------- - -def test_dry_run_always_succeeds(): - service = ActionDispatchService() - plan = _make_plan() - summary = service.dispatch_plan(plan, mode="dry_run") - - assert summary.plan_id == plan.plan_id - assert summary.dispatch_mode == "dry_run" - assert len(summary.records) == 1 - record = summary.records[0] - assert record.status == ExecutionStatus.APPLIED - assert record.receipt.get("status") == "DRY_RUN_OK" - - -def test_dry_run_noop_action_is_skipped(): - plan = _make_plan(actions=[ - _make_action(ActionType.NO_OP, "act_noop"), - ]) - service = ActionDispatchService() - summary = service.dispatch_plan(plan, mode="dry_run") - assert len(summary.records) == 0 # NO_OP is skipped - - -def test_multiple_actions_dispatched(): - plan = _make_plan(actions=[ - _make_action(ActionType.REORDER, "act_1"), - _make_action(ActionType.REROUTE, "act_2"), - _make_action(ActionType.SWITCH_SUPPLIER, "act_3"), - ]) - service = ActionDispatchService() - summary = service.dispatch_plan(plan, mode="dry_run") - assert len(summary.records) == 3 - for r in summary.records: - assert r.status == ExecutionStatus.APPLIED - - -# --------------------------------------------------------------------------- -# 5. Routing — correct adapter is selected -# --------------------------------------------------------------------------- - -def test_reorder_routes_to_erp(): - service = ActionDispatchService() - plan = _make_plan(actions=[_make_action(ActionType.REORDER)]) - summary = service.dispatch_plan(plan, mode="dry_run") - assert summary.records[0].target_system == "ERP" - - -def test_reroute_routes_to_tms(): - service = ActionDispatchService() - plan = _make_plan(actions=[_make_action(ActionType.REROUTE)]) - summary = service.dispatch_plan(plan, mode="dry_run") - assert summary.records[0].target_system == "TMS" - - -def test_rebalance_routes_to_wms(): - service = ActionDispatchService() - plan = _make_plan(actions=[_make_action(ActionType.REBALANCE)]) - summary = service.dispatch_plan(plan, mode="dry_run") - assert summary.records[0].target_system == "WMS" - - -# --------------------------------------------------------------------------- -# 6. Idempotency — same key returns cached receipt -# --------------------------------------------------------------------------- - -def test_adapter_returns_cached_receipt_on_duplicate_key(): - from execution.adapters.stubs import ERPAdapter - from execution.models import ExecutionRecord - - adapter = ERPAdapter() - record = ExecutionRecord( - execution_id="exec_idem", - plan_id="plan_x", - action_id="act_x", - action_type="reorder", - target_system="ERP", - idempotency_key="unique_key_abc", - ) - - # First call (commit) - result1 = adapter.dispatch(record, dry_run=False) - # Second call with same key - result2 = adapter.dispatch(record, dry_run=False) - - if result1.success: - assert result1.receipt == result2.receipt # same receipt returned +from __future__ import annotations + +import pytest + +from core.enums import ActionType, ExecutionStatus, PlanExecutionStatus +from core.models import Action, Plan, Mode +from execution.dispatch_service import ActionDispatchService +from execution.models import make_idempotency_key, ExecutionRecord +from execution.state_machine import ( + InvalidTransitionError, + compute_plan_execution_status, + transition, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _make_action(action_type: ActionType = ActionType.REORDER, action_id: str = "act_1") -> Action: + return Action( + action_id=action_id, + action_type=action_type, + target_id="SKU_1", + parameters={"quantity": 50}, + estimated_cost_delta=500.0, + estimated_service_delta=0.1, + estimated_risk_delta=-0.05, + estimated_recovery_hours=24.0, + reason="test", + priority=0.8, + ) + + +def _make_plan(actions: list[Action] | None = None) -> Plan: + from core.enums import PlanStatus + return Plan( + plan_id="plan_test_001", + mode=Mode.NORMAL, + actions=actions or [_make_action()], + score=0.5, + score_breakdown={}, + status=PlanStatus.APPROVED, + ) + + +# --------------------------------------------------------------------------- +# 1. Idempotency Key +# --------------------------------------------------------------------------- + +def test_idempotency_key_is_deterministic(): + key1 = make_idempotency_key("plan_a", "act_1", "2024-01-01T00:00:00") + key2 = make_idempotency_key("plan_a", "act_1", "2024-01-01T00:00:00") + assert key1 == key2 + + +def test_idempotency_key_differs_on_different_inputs(): + key1 = make_idempotency_key("plan_a", "act_1", "2024-01-01T00:00:00") + key2 = make_idempotency_key("plan_b", "act_1", "2024-01-01T00:00:00") + assert key1 != key2 + + +# --------------------------------------------------------------------------- +# 2. State Machine +# --------------------------------------------------------------------------- + +def _make_record(status: ExecutionStatus = ExecutionStatus.PLANNED) -> ExecutionRecord: + return ExecutionRecord( + execution_id="exec_001", + plan_id="plan_001", + action_id="act_001", + action_type="reorder", + target_system="ERP", + idempotency_key="abc123", + status=status, + ) + + +def test_valid_transition_planned_to_approved(): + record = _make_record(ExecutionStatus.PLANNED) + transition(record, ExecutionStatus.APPROVED) + assert record.status == ExecutionStatus.APPROVED + + +def test_invalid_transition_raises(): + record = _make_record(ExecutionStatus.PLANNED) + with pytest.raises(InvalidTransitionError): + transition(record, ExecutionStatus.APPLIED) # PLANNED → APPLIED is not allowed + + +def test_terminal_state_cannot_transition(): + record = _make_record(ExecutionStatus.APPLIED) + with pytest.raises(InvalidTransitionError): + transition(record, ExecutionStatus.DISPATCHED) + + +# --------------------------------------------------------------------------- +# 3. Plan-level status aggregation +# --------------------------------------------------------------------------- + +def _record_with_status(status: ExecutionStatus) -> ExecutionRecord: + r = _make_record() + r.status = status + return r + + +def test_all_applied_returns_completed(): + records = [_record_with_status(ExecutionStatus.APPLIED) for _ in range(3)] + assert compute_plan_execution_status(records) == PlanExecutionStatus.COMPLETED + + +def test_partial_failure_returns_manual_intervention(): + records = [ + _record_with_status(ExecutionStatus.APPLIED), + _record_with_status(ExecutionStatus.FAILED), + ] + assert compute_plan_execution_status(records) == PlanExecutionStatus.REQUIRES_MANUAL_INTERVENTION + + +def test_all_failed_returns_manual_intervention(): + records = [_record_with_status(ExecutionStatus.FAILED) for _ in range(2)] + assert compute_plan_execution_status(records) == PlanExecutionStatus.REQUIRES_MANUAL_INTERVENTION + + +# --------------------------------------------------------------------------- +# 4. ActionDispatchService — dry_run always succeeds +# --------------------------------------------------------------------------- + +def test_dry_run_always_succeeds(): + service = ActionDispatchService() + plan = _make_plan() + summary = service.dispatch_plan(plan, mode="dry_run") + + assert summary.plan_id == plan.plan_id + assert summary.dispatch_mode == "dry_run" + assert len(summary.records) == 1 + record = summary.records[0] + assert record.status == ExecutionStatus.APPLIED + assert record.receipt.get("status") == "DRY_RUN_OK" + + +def test_dry_run_noop_action_is_skipped(): + plan = _make_plan(actions=[ + _make_action(ActionType.NO_OP, "act_noop"), + ]) + service = ActionDispatchService() + summary = service.dispatch_plan(plan, mode="dry_run") + assert len(summary.records) == 0 # NO_OP is skipped + + +def test_multiple_actions_dispatched(): + plan = _make_plan(actions=[ + _make_action(ActionType.REORDER, "act_1"), + _make_action(ActionType.REROUTE, "act_2"), + _make_action(ActionType.SWITCH_SUPPLIER, "act_3"), + ]) + service = ActionDispatchService() + summary = service.dispatch_plan(plan, mode="dry_run") + assert len(summary.records) == 3 + for r in summary.records: + assert r.status == ExecutionStatus.APPLIED + + +# --------------------------------------------------------------------------- +# 5. Routing — correct adapter is selected +# --------------------------------------------------------------------------- + +def test_reorder_routes_to_erp(): + service = ActionDispatchService() + plan = _make_plan(actions=[_make_action(ActionType.REORDER)]) + summary = service.dispatch_plan(plan, mode="dry_run") + assert summary.records[0].target_system == "ERP" + + +def test_reroute_routes_to_tms(): + service = ActionDispatchService() + plan = _make_plan(actions=[_make_action(ActionType.REROUTE)]) + summary = service.dispatch_plan(plan, mode="dry_run") + assert summary.records[0].target_system == "TMS" + + +def test_rebalance_routes_to_wms(): + service = ActionDispatchService() + plan = _make_plan(actions=[_make_action(ActionType.REBALANCE)]) + summary = service.dispatch_plan(plan, mode="dry_run") + assert summary.records[0].target_system == "WMS" + + +# --------------------------------------------------------------------------- +# 6. Idempotency — same key returns cached receipt +# --------------------------------------------------------------------------- + +def test_adapter_returns_cached_receipt_on_duplicate_key(): + from execution.adapters.stubs import ERPAdapter + from execution.models import ExecutionRecord + + adapter = ERPAdapter() + record = ExecutionRecord( + execution_id="exec_idem", + plan_id="plan_x", + action_id="act_x", + action_type="reorder", + target_system="ERP", + idempotency_key="unique_key_abc", + ) + + # First call (commit) + result1 = adapter.dispatch(record, dry_run=False) + # Second call with same key + result2 = adapter.dispatch(record, dry_run=False) + + if result1.success: + assert result1.receipt == result2.receipt # same receipt returned diff --git a/tests/test_execution_dispatch_lifecycle.py b/tests/test_execution_dispatch_lifecycle.py index 24c350e..129ad22 100644 --- a/tests/test_execution_dispatch_lifecycle.py +++ b/tests/test_execution_dispatch_lifecycle.py @@ -1,115 +1,160 @@ -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner - - -def _client(tmp_path: Path) -> TestClient: - store = SQLiteStore(tmp_path / "chaincopilot-execution.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app) - - -def test_daily_plan_creates_applied_execution_history(tmp_path: Path) -> None: - client = _client(tmp_path) - - response = client.post( - "/api/v1/events/ingest", - json={"event_class": "command", "event_type": "plan_requested", "source": "api"}, - ) - assert response.status_code == 200 - execution_id = response.json()["execution_id"] - assert execution_id is not None - - execution = client.get(f"/api/v1/execution/{execution_id}") - assert execution.status_code == 200 - payload = execution.json()["item"] - assert payload["status"] == "applied" - assert [item["status"] for item in payload["status_history"]] == ["planned", "applied"] - assert payload["receipts"] - - -def test_approve_updates_existing_execution_record(tmp_path: Path) -> None: - client = _client(tmp_path) - - scenario = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) - assert scenario.status_code == 200 - pending_run = client.get("/api/v1/runs?limit=1").json()["items"][0] - original_execution_id = pending_run["execution_id"] - decision_id = scenario.json()["decision_id"] - assert original_execution_id is not None - assert decision_id is not None - - response = client.post(f"/api/v1/approvals/{decision_id}", json={"action": "approve"}) - assert response.status_code == 200 +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner + + +def _client(tmp_path: Path) -> TestClient: + store = SQLiteStore(tmp_path / "chaincopilot-execution.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app) + + +def test_daily_plan_creates_applied_execution_history(tmp_path: Path) -> None: + client = _client(tmp_path) + + response = client.post( + "/api/v1/events/ingest", + json={"event_class": "command", "event_type": "plan_requested", "source": "api"}, + ) + assert response.status_code == 200 + execution_id = response.json()["execution_id"] + assert execution_id is not None + + execution = client.get(f"/api/v1/execution/{execution_id}") + assert execution.status_code == 200 + payload = execution.json()["item"] + assert payload["status"] == "applied" + assert [item["status"] for item in payload["status_history"]] == ["planned", "applied"] + assert payload["receipts"] + + +def test_approve_updates_existing_execution_record(tmp_path: Path) -> None: + client = _client(tmp_path) + + scenario = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) + assert scenario.status_code == 200 + pending_run = client.get("/api/v1/runs?limit=1").json()["items"][0] + original_execution_id = pending_run["execution_id"] + decision_id = scenario.json()["decision_id"] + assert original_execution_id is not None + assert decision_id is not None + + response = client.post(f"/api/v1/approvals/{decision_id}", json={"action": "approve"}) + assert response.status_code == 200 payload = response.json() assert payload["execution"] is not None assert payload["execution"]["execution_id"] == original_execution_id - assert payload["execution"]["status"] == "applied" + assert payload["execution"]["status"] == "approved" assert [item["status"] for item in payload["execution"]["status_history"]] == [ "planned", "approval_pending", "approved", - "applied", ] - + execution = client.get(f"/api/v1/execution/{original_execution_id}") assert execution.status_code == 200 - assert execution.json()["item"]["status"] == "applied" + assert execution.json()["item"]["status"] == "approved" + + +def test_reject_cancels_existing_execution_record(tmp_path: Path) -> None: + client = _client(tmp_path) + + scenario = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) + assert scenario.status_code == 200 + pending_run = client.get("/api/v1/runs?limit=1").json()["items"][0] + original_execution_id = pending_run["execution_id"] + decision_id = scenario.json()["decision_id"] + assert original_execution_id is not None + assert decision_id is not None + + response = client.post(f"/api/v1/approvals/{decision_id}", json={"action": "reject"}) + assert response.status_code == 200 + payload = response.json() + assert payload["execution"] is not None + assert payload["execution"]["execution_id"] == original_execution_id + assert payload["execution"]["status"] == "cancelled" + assert payload["execution"]["failure_reason"] == "operator rejected the pending execution" + + +def test_safer_plan_cancels_old_execution_and_creates_new_one(tmp_path: Path) -> None: + client = _client(tmp_path) + + scenario = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) + assert scenario.status_code == 200 + pending_run = client.get("/api/v1/runs?limit=1").json()["items"][0] + original_execution_id = pending_run["execution_id"] + decision_id = scenario.json()["decision_id"] + assert original_execution_id is not None + assert decision_id is not None + + response = client.post(f"/api/v1/approvals/{decision_id}", json={"action": "safer_plan"}) + assert response.status_code == 200 + payload = response.json() + assert payload["execution"] is not None + new_execution_id = payload["execution"]["execution_id"] + assert new_execution_id != original_execution_id + + original_execution = client.get(f"/api/v1/execution/{original_execution_id}") + assert original_execution.status_code == 200 + assert original_execution.json()["item"]["status"] == "cancelled" + + replacement_execution = client.get(f"/api/v1/execution/{new_execution_id}") + assert replacement_execution.status_code == 200 + assert replacement_execution.json()["item"]["status"] in {"approval_pending", "applied"} -def test_reject_cancels_existing_execution_record(tmp_path: Path) -> None: +def test_dry_run_dispatch_is_preview_only(tmp_path: Path) -> None: client = _client(tmp_path) - scenario = client.post("/api/v1/scenarios/run", json={"scenario_name": "supplier_delay"}) - assert scenario.status_code == 200 - pending_run = client.get("/api/v1/runs?limit=1").json()["items"][0] - original_execution_id = pending_run["execution_id"] - decision_id = scenario.json()["decision_id"] - assert original_execution_id is not None - assert decision_id is not None - - response = client.post(f"/api/v1/approvals/{decision_id}", json={"action": "reject"}) - assert response.status_code == 200 - payload = response.json() - assert payload["execution"] is not None - assert payload["execution"]["execution_id"] == original_execution_id - assert payload["execution"]["status"] == "cancelled" - assert payload["execution"]["failure_reason"] == "operator rejected the pending execution" - + daily = client.post("/api/v1/plan/daily") + assert daily.status_code == 200 + plan = daily.json()["latest_plan"] + assert plan is not None + plan_id = plan["plan_id"] -def test_safer_plan_cancels_old_execution_and_creates_new_one(tmp_path: Path) -> None: - client = _client(tmp_path) + first = client.post(f"/api/v1/execution/{plan_id}/dispatch", json={"mode": "dry_run"}) + assert first.status_code == 200 + first_payload = first.json() + assert first_payload["dispatch_mode"] == "dry_run" + assert first_payload["records"] - scenario = client.post("/api/v1/scenarios/run", json={"scenario_name": "compound_disruption"}) - assert scenario.status_code == 200 - pending_run = client.get("/api/v1/runs?limit=1").json()["items"][0] - original_execution_id = pending_run["execution_id"] - decision_id = scenario.json()["decision_id"] - assert original_execution_id is not None - assert decision_id is not None + history = client.get("/api/v1/execution") + assert history.status_code == 200 + history_payload = history.json() + assert all(item["plan_id"] != plan_id for item in history_payload["items"]) - response = client.post(f"/api/v1/approvals/{decision_id}", json={"action": "safer_plan"}) - assert response.status_code == 200 - payload = response.json() - assert payload["execution"] is not None - new_execution_id = payload["execution"]["execution_id"] - assert new_execution_id != original_execution_id - original_execution = client.get(f"/api/v1/execution/{original_execution_id}") - assert original_execution.status_code == 200 - assert original_execution.json()["item"]["status"] == "cancelled" +def test_commit_dispatch_is_idempotent_for_same_plan_and_mode(tmp_path: Path) -> None: + client = _client(tmp_path) - replacement_execution = client.get(f"/api/v1/execution/{new_execution_id}") - assert replacement_execution.status_code == 200 - assert replacement_execution.json()["item"]["status"] in {"approval_pending", "applied"} + daily = client.post("/api/v1/plan/daily") + assert daily.status_code == 200 + plan = daily.json()["latest_plan"] + assert plan is not None + plan_id = plan["plan_id"] + + first = client.post(f"/api/v1/execution/{plan_id}/dispatch", json={"mode": "commit"}) + assert first.status_code == 200 + first_payload = first.json() + first_ids = {item["execution_id"] for item in first_payload["records"]} + assert first_ids + + second = client.post(f"/api/v1/execution/{plan_id}/dispatch", json={"mode": "commit"}) + assert second.status_code == 200 + second_payload = second.json() + second_ids = {item["execution_id"] for item in second_payload["records"]} + + assert second_ids == first_ids + assert second_payload["dispatch_mode"] == "commit" + assert second_payload["plan_execution_status"] == first_payload["plan_execution_status"] diff --git a/tests/test_explainability_learning.py b/tests/test_explainability_learning.py index 8f566a9..c9d0958 100644 --- a/tests/test_explainability_learning.py +++ b/tests/test_explainability_learning.py @@ -1,80 +1,80 @@ -import json -import sqlite3 -from pathlib import Path - -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.service import approve_pending_plan -from simulation.runner import ScenarioRunner - - -def test_decision_log_contains_grounded_explanation() -> None: - state = load_initial_state() - result = ScenarioRunner().run(state, "compound_disruption") - decision = result.decision_logs[-1] - - assert set(decision.score_breakdown) == { - "service_level", - "total_cost", - "disruption_risk", - "recovery_speed", - } - assert "service level" in decision.rationale.lower() - assert "total cost" in decision.rationale.lower() - assert "disruption risk" in decision.rationale.lower() - assert "recovery speed" in decision.rationale.lower() - assert decision.winning_factors - assert decision.approval_reason - assert decision.approval_required is True - assert decision.rejected_actions - assert all(item["reason"] != "not selected in final plan" for item in decision.rejected_actions) - - -def test_scenario_run_persists_learned_memory_to_state_snapshot(tmp_path: Path) -> None: - store = SQLiteStore(tmp_path / "chaincopilot.db") - state = load_initial_state() - initial_reliability = next( - record.reliability - for record in state.suppliers.values() - if record.supplier_id == "SUP_A" - ) - - result = ScenarioRunner(store=store).run(state, "supplier_delay") - - assert result.memory is not None - assert result.memory.supplier_reliability["SUP_A"] < initial_reliability - assert result.memory.scenario_outcomes["supplier_delay"]["runs"] == 1 - assert result.scenario_history[-1].reflection_status == "pending_approval" - - with sqlite3.connect(store.path) as conn: - row = conn.execute("SELECT payload FROM state_snapshots WHERE run_id = ?", (result.run_id,)).fetchone() - payload = json.loads(row[0]) - assert payload["memory"]["supplier_reliability"]["SUP_A"] == result.memory.supplier_reliability["SUP_A"] - assert payload["memory"]["scenario_outcomes"]["supplier_delay"]["runs"] == 1 - - -def test_repeated_scenario_runs_accumulate_memory_history(tmp_path: Path) -> None: - store = SQLiteStore(tmp_path / "chaincopilot.db") - runner = ScenarioRunner(store=store) - state = load_initial_state() - initial_prior = state.routes["R1"].risk_score - - state = runner.run(state, "route_blockage") - if state.pending_plan and state.decision_logs: - state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) - state = runner.run(state, "route_blockage") - if state.pending_plan and state.decision_logs: - state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) - - assert state.memory is not None - history = state.memory.scenario_outcomes["route_blockage"]["history"] - assert state.memory.scenario_outcomes["route_blockage"]["runs"] == 2 - assert len(history) == 2 - assert state.memory.route_disruption_priors["R1"] > initial_prior - assert len(state.scenario_history) == 2 - assert len(state.memory.reflection_notes) == 2 - assert state.scenario_history[-1].reflection_status == "completed" - - with sqlite3.connect(store.path) as conn: - row_count = conn.execute("SELECT COUNT(*) FROM scenario_runs").fetchone()[0] - assert row_count == 2 +import json +import sqlite3 +from pathlib import Path + +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.service import approve_pending_plan +from simulation.runner import ScenarioRunner + + +def test_decision_log_contains_grounded_explanation() -> None: + state = load_initial_state() + result = ScenarioRunner().run(state, "compound_disruption") + decision = result.decision_logs[-1] + + assert set(decision.score_breakdown) == { + "service_level", + "total_cost", + "disruption_risk", + "recovery_speed", + } + assert "service level" in decision.rationale.lower() + assert "total cost" in decision.rationale.lower() + assert "disruption risk" in decision.rationale.lower() + assert "recovery speed" in decision.rationale.lower() + assert decision.winning_factors + assert decision.approval_reason + assert decision.approval_required is True + assert decision.rejected_actions + assert all(item["reason"] != "not selected in final plan" for item in decision.rejected_actions) + + +def test_scenario_run_persists_learned_memory_to_state_snapshot(tmp_path: Path) -> None: + store = SQLiteStore(tmp_path / "chaincopilot.db") + state = load_initial_state() + initial_reliability = next( + record.reliability + for record in state.suppliers.values() + if record.supplier_id == "SUP_A" + ) + + result = ScenarioRunner(store=store).run(state, "supplier_delay") + + assert result.memory is not None + assert result.memory.supplier_reliability["SUP_A"] < initial_reliability + assert result.memory.scenario_outcomes["supplier_delay"]["runs"] == 1 + assert result.scenario_history[-1].reflection_status == "pending_approval" + + with sqlite3.connect(store.path) as conn: + row = conn.execute("SELECT payload FROM state_snapshots WHERE run_id = ?", (result.run_id,)).fetchone() + payload = json.loads(row[0]) + assert payload["memory"]["supplier_reliability"]["SUP_A"] == result.memory.supplier_reliability["SUP_A"] + assert payload["memory"]["scenario_outcomes"]["supplier_delay"]["runs"] == 1 + + +def test_repeated_scenario_runs_accumulate_memory_history(tmp_path: Path) -> None: + store = SQLiteStore(tmp_path / "chaincopilot.db") + runner = ScenarioRunner(store=store) + state = load_initial_state() + initial_prior = state.routes["R1"].risk_score + + state = runner.run(state, "route_blockage") + if state.pending_plan and state.decision_logs: + state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) + state = runner.run(state, "route_blockage") + if state.pending_plan and state.decision_logs: + state = approve_pending_plan(state, store, state.decision_logs[-1].decision_id, True) + + assert state.memory is not None + history = state.memory.scenario_outcomes["route_blockage"]["history"] + assert state.memory.scenario_outcomes["route_blockage"]["runs"] == 2 + assert len(history) == 2 + assert state.memory.route_disruption_priors["R1"] > initial_prior + assert len(state.scenario_history) == 2 + assert len(state.memory.reflection_notes) == 2 + assert state.scenario_history[-1].reflection_status == "completed" + + with sqlite3.connect(store.path) as conn: + row_count = conn.execute("SELECT COUNT(*) FROM scenario_runs").fetchone()[0] + assert row_count == 2 diff --git a/tests/test_foundation_run_ledger.py b/tests/test_foundation_run_ledger.py index 58248bd..c4345dc 100644 --- a/tests/test_foundation_run_ledger.py +++ b/tests/test_foundation_run_ledger.py @@ -1,124 +1,124 @@ -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner - - -def _client(tmp_path: Path) -> tuple[TestClient, SQLiteStore]: - store = SQLiteStore(tmp_path / "chaincopilot-foundation.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app), store - - -def test_event_ingest_persists_run_trace_and_execution(tmp_path: Path) -> None: - client, store = _client(tmp_path) - - response = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "domain", - "event_type": "supplier_delay", - "source": "api", - "severity": 0.9, - "entity_ids": ["SKU_1", "SUP_A"], - "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["accepted"] is True - assert payload["run_id"] is not None - assert payload["execution_id"] is not None - assert payload["event"]["event_class"] == "domain" - assert store.get_event_envelope(payload["event"]["event_id"]) is not None - - run_response = client.get(f"/api/v1/runs/{payload['run_id']}") - assert run_response.status_code == 200 - run_payload = run_response.json()["item"] - assert run_payload["run_type"] == "event_response" - assert run_payload["execution_id"] == payload["execution_id"] - assert run_payload["selected_plan_summary"] is not None - - trace_response = client.get(f"/api/v1/runs/{payload['run_id']}/trace") - assert trace_response.status_code == 200 - trace_payload = trace_response.json()["item"] - assert trace_payload["run_id"] == payload["run_id"] - assert trace_payload["trace_id"] is not None - assert trace_payload["steps"] - assert trace_payload["steps"][0]["step_id"] is not None - assert trace_payload["steps"][0]["sequence"] == 1 - - execution_response = client.get(f"/api/v1/execution/{payload['execution_id']}") - assert execution_response.status_code == 200 - execution_payload = execution_response.json()["item"] - assert execution_payload["run_id"] == payload["run_id"] - assert execution_payload["dispatch_mode"] == "simulation" - assert execution_payload["status"] == "approval_pending" - - -def test_command_event_creates_unique_run_and_exposes_llm_fallback(tmp_path: Path) -> None: - client, _ = _client(tmp_path) - - first = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "command", - "event_type": "plan_requested", - "source": "api", - }, - ) - assert first.status_code == 200 - second = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "command", - "event_type": "plan_requested", - "source": "api", - }, - ) - assert second.status_code == 200 - - first_run_id = first.json()["run_id"] - second_run_id = second.json()["run_id"] - assert first_run_id is not None - assert second_run_id is not None - assert first_run_id != second_run_id - - run_response = client.get(f"/api/v1/runs/{second_run_id}") - assert run_response.status_code == 200 - run_payload = run_response.json()["item"] - assert run_payload["run_type"] == "daily_cycle" - assert run_payload["llm_fallback_used"] is True - assert run_payload["llm_fallback_reason"] == "llm_disabled" - - -def test_system_event_is_logged_without_creating_run(tmp_path: Path) -> None: - client, store = _client(tmp_path) - - response = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "system", - "event_type": "reflection_completed", - "source": "api", - "payload": {"note_id": "note_1"}, - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["accepted"] is True - assert payload["run_id"] is None - assert payload["execution_id"] is None - assert store.get_event_envelope(payload["event"]["event_id"]) is not None +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner + + +def _client(tmp_path: Path) -> tuple[TestClient, SQLiteStore]: + store = SQLiteStore(tmp_path / "chaincopilot-foundation.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app), store + + +def test_event_ingest_persists_run_trace_and_execution(tmp_path: Path) -> None: + client, store = _client(tmp_path) + + response = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "domain", + "event_type": "supplier_delay", + "source": "api", + "severity": 0.9, + "entity_ids": ["SKU_1", "SUP_A"], + "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["accepted"] is True + assert payload["run_id"] is not None + assert payload["execution_id"] is not None + assert payload["event"]["event_class"] == "domain" + assert store.get_event_envelope(payload["event"]["event_id"]) is not None + + run_response = client.get(f"/api/v1/runs/{payload['run_id']}") + assert run_response.status_code == 200 + run_payload = run_response.json()["item"] + assert run_payload["run_type"] == "event_response" + assert run_payload["execution_id"] == payload["execution_id"] + assert run_payload["selected_plan_summary"] is not None + + trace_response = client.get(f"/api/v1/runs/{payload['run_id']}/trace") + assert trace_response.status_code == 200 + trace_payload = trace_response.json()["item"] + assert trace_payload["run_id"] == payload["run_id"] + assert trace_payload["trace_id"] is not None + assert trace_payload["steps"] + assert trace_payload["steps"][0]["step_id"] is not None + assert trace_payload["steps"][0]["sequence"] == 1 + + execution_response = client.get(f"/api/v1/execution/{payload['execution_id']}") + assert execution_response.status_code == 200 + execution_payload = execution_response.json()["item"] + assert execution_payload["run_id"] == payload["run_id"] + assert execution_payload["dispatch_mode"] == "simulation" + assert execution_payload["status"] == "approval_pending" + + +def test_command_event_creates_unique_run_and_exposes_llm_fallback(tmp_path: Path) -> None: + client, _ = _client(tmp_path) + + first = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "command", + "event_type": "plan_requested", + "source": "api", + }, + ) + assert first.status_code == 200 + second = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "command", + "event_type": "plan_requested", + "source": "api", + }, + ) + assert second.status_code == 200 + + first_run_id = first.json()["run_id"] + second_run_id = second.json()["run_id"] + assert first_run_id is not None + assert second_run_id is not None + assert first_run_id != second_run_id + + run_response = client.get(f"/api/v1/runs/{second_run_id}") + assert run_response.status_code == 200 + run_payload = run_response.json()["item"] + assert run_payload["run_type"] == "daily_cycle" + assert run_payload["llm_fallback_used"] is True + assert run_payload["llm_fallback_reason"] == "llm_disabled" + + +def test_system_event_is_logged_without_creating_run(tmp_path: Path) -> None: + client, store = _client(tmp_path) + + response = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "system", + "event_type": "reflection_completed", + "source": "api", + "payload": {"note_id": "note_1"}, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["accepted"] is True + assert payload["run_id"] is None + assert payload["execution_id"] is None + assert store.get_event_envelope(payload["event"]["event_id"]) is not None diff --git a/tests/test_langgraph_paths.py b/tests/test_langgraph_paths.py index ed5be8a..aa9cc67 100644 --- a/tests/test_langgraph_paths.py +++ b/tests/test_langgraph_paths.py @@ -1,59 +1,59 @@ -from core.enums import ApprovalStatus, EventType, Mode, PlanStatus -from core.models import Event -from core.state import load_initial_state, utc_now -from orchestrator.graph import build_graph - - -def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: - return Event( - event_id=f"evt_{event_type.value}_{severity}", - type=event_type, - source="test", - severity=severity, - entity_ids=entity_ids, - occurred_at=utc_now(), - detected_at=utc_now(), - payload=payload, - dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", - ) - - -def test_langgraph_normal_path_executes_daily_plan() -> None: - state = load_initial_state() - result = build_graph().invoke(state, None) - assert result.latest_plan is not None - assert result.latest_plan.status == PlanStatus.APPLIED - assert result.mode == Mode.NORMAL - assert result.pending_plan is None - - -def test_langgraph_crisis_path_can_auto_apply() -> None: - state = load_initial_state() - event = _event( - EventType.DEMAND_SPIKE, - 0.66, - {"sku": "SKU_2", "multiplier": 1.4}, - ["SKU_2"], - ) - result = build_graph().invoke(state, event) - assert result.latest_plan is not None - assert result.latest_plan.mode == Mode.CRISIS - assert result.decision_logs[-1].approval_status in { - ApprovalStatus.AUTO_APPLIED, - ApprovalStatus.PENDING, - } - - -def test_langgraph_approval_path_leaves_pending_plan() -> None: - state = load_initial_state() - event = _event( - EventType.COMPOUND, - 0.92, - {"supplier_id": "SUP_A", "route_id": "R1", "sku": "SKU_1"}, - ["SUP_A", "R1", "SKU_1"], - ) - result = build_graph().invoke(state, event) - assert result.mode == Mode.APPROVAL - assert result.pending_plan is not None - assert result.latest_plan is not None - assert result.decision_logs[-1].approval_status == ApprovalStatus.PENDING +from core.enums import ApprovalStatus, EventType, Mode, PlanStatus +from core.models import Event +from core.state import load_initial_state, utc_now +from orchestrator.graph import build_graph + + +def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: + return Event( + event_id=f"evt_{event_type.value}_{severity}", + type=event_type, + source="test", + severity=severity, + entity_ids=entity_ids, + occurred_at=utc_now(), + detected_at=utc_now(), + payload=payload, + dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", + ) + + +def test_langgraph_normal_path_executes_daily_plan() -> None: + state = load_initial_state() + result = build_graph().invoke(state, None) + assert result.latest_plan is not None + assert result.latest_plan.status == PlanStatus.APPLIED + assert result.mode == Mode.NORMAL + assert result.pending_plan is None + + +def test_langgraph_crisis_path_can_auto_apply() -> None: + state = load_initial_state() + event = _event( + EventType.DEMAND_SPIKE, + 0.66, + {"sku": "SKU_2", "multiplier": 1.4}, + ["SKU_2"], + ) + result = build_graph().invoke(state, event) + assert result.latest_plan is not None + assert result.latest_plan.mode == Mode.CRISIS + assert result.decision_logs[-1].approval_status in { + ApprovalStatus.AUTO_APPLIED, + ApprovalStatus.PENDING, + } + + +def test_langgraph_approval_path_leaves_pending_plan() -> None: + state = load_initial_state() + event = _event( + EventType.COMPOUND, + 0.92, + {"supplier_id": "SUP_A", "route_id": "R1", "sku": "SKU_1"}, + ["SUP_A", "R1", "SKU_1"], + ) + result = build_graph().invoke(state, event) + assert result.mode == Mode.APPROVAL + assert result.pending_plan is not None + assert result.latest_plan is not None + assert result.decision_logs[-1].approval_status == ApprovalStatus.PENDING diff --git a/tests/test_llm_planner_explanations.py b/tests/test_llm_planner_explanations.py index ba42bd5..621044e 100644 --- a/tests/test_llm_planner_explanations.py +++ b/tests/test_llm_planner_explanations.py @@ -1,97 +1,97 @@ -from pathlib import Path - -from core.memory import SQLiteStore -from core.state import load_initial_state -from llm.gemini_client import GeminiClientError -from orchestrator.service import request_safer_plan -from simulation.runner import ScenarioRunner - - -def _enable_llm(monkeypatch) -> None: - monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") - monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") - monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") - monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") - - -def _fake_response(*args, **kwargs) -> dict: - return { - "planner_narrative": "AI planner narrative based on the selected control tower plan.", - "operator_explanation": "AI operator explanation grounded in KPI deltas and risk trade-offs.", - "approval_summary": "High-risk plan requires approval because disruption severity and recovery trade-offs are elevated.", - } - - -def test_llm_disabled_keeps_deterministic_fallback(monkeypatch) -> None: - monkeypatch.delenv("CHAINCOPILOT_LLM_ENABLED", raising=False) - monkeypatch.delenv("CHAINCOPILOT_LLM_API_KEY", raising=False) - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - - state = ScenarioRunner().graph.invoke(load_initial_state(), None) - log = state.decision_logs[-1] - - assert state.latest_plan is not None - assert state.latest_plan.llm_planner_narrative is None - assert log.llm_operator_explanation is None - assert log.llm_approval_summary is None - assert log.llm_used is False - assert log.rationale - - -def test_llm_enriches_normal_plan(monkeypatch) -> None: - _enable_llm(monkeypatch) - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_response) - - state = ScenarioRunner().graph.invoke(load_initial_state(), None) - log = state.decision_logs[-1] - - assert state.latest_plan is not None - assert state.latest_plan.llm_planner_narrative == _fake_response()["planner_narrative"] - assert log.llm_operator_explanation == _fake_response()["operator_explanation"] - assert log.llm_used is True - assert log.llm_provider == "gemini" - assert log.llm_model == "gemini-2.5-flash" - - -def test_llm_generates_approval_summary(monkeypatch) -> None: - _enable_llm(monkeypatch) - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_response) - - result = ScenarioRunner().run(load_initial_state(), "supplier_delay") - log = result.decision_logs[-1] - - assert log.approval_required is True - assert log.llm_approval_summary == _fake_response()["approval_summary"] - - -def test_llm_enriches_safer_plan_path(monkeypatch, tmp_path: Path) -> None: - _enable_llm(monkeypatch) - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_response) - store = SQLiteStore(tmp_path / "chaincopilot-llm.db") - runner = ScenarioRunner(store=store) - - state = runner.run(load_initial_state(), "supplier_delay") - decision_id = state.decision_logs[-1].decision_id - updated = request_safer_plan(state, store, decision_id) - - assert updated.latest_plan is not None - assert updated.latest_plan.llm_planner_narrative == _fake_response()["planner_narrative"] - assert updated.decision_logs[-1].llm_operator_explanation == _fake_response()["operator_explanation"] - - -def test_llm_failure_falls_back_to_deterministic_reasoning(monkeypatch) -> None: - _enable_llm(monkeypatch) - - def _raise_error(*args, **kwargs): - raise GeminiClientError("gemini unavailable") - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _raise_error) - - state = ScenarioRunner().graph.invoke(load_initial_state(), None) - log = state.decision_logs[-1] - - assert state.latest_plan is not None - assert state.latest_plan.llm_planner_narrative is None - assert log.llm_used is False - assert "gemini unavailable" in (log.llm_error or "") - assert log.rationale +from pathlib import Path + +from core.memory import SQLiteStore +from core.state import load_initial_state +from llm.gemini_client import GeminiClientError +from orchestrator.service import request_safer_plan +from simulation.runner import ScenarioRunner + + +def _enable_llm(monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") + monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") + monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") + monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") + + +def _fake_response(*args, **kwargs) -> dict: + return { + "planner_narrative": "AI planner narrative based on the selected control tower plan.", + "operator_explanation": "AI operator explanation grounded in KPI deltas and risk trade-offs.", + "approval_summary": "High-risk plan requires approval because disruption severity and recovery trade-offs are elevated.", + } + + +def test_llm_disabled_keeps_deterministic_fallback(monkeypatch) -> None: + monkeypatch.delenv("CHAINCOPILOT_LLM_ENABLED", raising=False) + monkeypatch.delenv("CHAINCOPILOT_LLM_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + state = ScenarioRunner().graph.invoke(load_initial_state(), None) + log = state.decision_logs[-1] + + assert state.latest_plan is not None + assert state.latest_plan.llm_planner_narrative is None + assert log.llm_operator_explanation is None + assert log.llm_approval_summary is None + assert log.llm_used is False + assert log.rationale + + +def test_llm_enriches_normal_plan(monkeypatch) -> None: + _enable_llm(monkeypatch) + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_response) + + state = ScenarioRunner().graph.invoke(load_initial_state(), None) + log = state.decision_logs[-1] + + assert state.latest_plan is not None + assert state.latest_plan.llm_planner_narrative == _fake_response()["planner_narrative"] + assert log.llm_operator_explanation == _fake_response()["operator_explanation"] + assert log.llm_used is True + assert log.llm_provider == "gemini" + assert log.llm_model == "gemini-2.5-flash" + + +def test_llm_generates_approval_summary(monkeypatch) -> None: + _enable_llm(monkeypatch) + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_response) + + result = ScenarioRunner().run(load_initial_state(), "supplier_delay") + log = result.decision_logs[-1] + + assert log.approval_required is True + assert log.llm_approval_summary == _fake_response()["approval_summary"] + + +def test_llm_enriches_safer_plan_path(monkeypatch, tmp_path: Path) -> None: + _enable_llm(monkeypatch) + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_response) + store = SQLiteStore(tmp_path / "chaincopilot-llm.db") + runner = ScenarioRunner(store=store) + + state = runner.run(load_initial_state(), "supplier_delay") + decision_id = state.decision_logs[-1].decision_id + updated = request_safer_plan(state, store, decision_id) + + assert updated.latest_plan is not None + assert updated.latest_plan.llm_planner_narrative == _fake_response()["planner_narrative"] + assert updated.decision_logs[-1].llm_operator_explanation == _fake_response()["operator_explanation"] + + +def test_llm_failure_falls_back_to_deterministic_reasoning(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _raise_error(*args, **kwargs): + raise GeminiClientError("gemini unavailable") + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _raise_error) + + state = ScenarioRunner().graph.invoke(load_initial_state(), None) + log = state.decision_logs[-1] + + assert state.latest_plan is not None + assert state.latest_plan.llm_planner_narrative is None + assert log.llm_used is False + assert "gemini unavailable" in (log.llm_error or "") + assert log.rationale diff --git a/tests/test_llm_planner_hardening.py b/tests/test_llm_planner_hardening.py index f6728ec..993c184 100644 --- a/tests/test_llm_planner_hardening.py +++ b/tests/test_llm_planner_hardening.py @@ -1,121 +1,167 @@ -from core.enums import ActionType, EventType -from core.models import Action, Event -from core.state import load_initial_state, utc_now -from llm.service import generate_candidate_plan_drafts -from orchestrator.graph import build_graph - - -def _enable_llm(monkeypatch) -> None: - monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") - monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") - monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") - monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") - - -def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: - return Event( - event_id=f"evt_{event_type.value}_{severity}", - type=event_type, - source="test", - severity=severity, - entity_ids=entity_ids, - occurred_at=utc_now(), - detected_at=utc_now(), - payload=payload, - dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", - ) - - -def test_generate_candidate_plan_drafts_normalizes_strategy_aliases(monkeypatch) -> None: - _enable_llm(monkeypatch) - - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - return { - "candidate_plans": [ - {"strategy_label": "Plan A", "action_ids": ["act_reorder_SKU_1"], "rationale": "lowest cost option"}, - {"strategy_label": "Balanced plan", "action_ids": ["act_reorder_SKU_2"], "rationale": "balanced coverage"}, - {"strategy_label": "Recovery-first", "action_ids": ["act_reorder_SKU_3"], "rationale": "recovery resilience focus"}, - ] - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) - state = load_initial_state() - candidate_actions = [ - Action(action_id="act_reorder_SKU_1", action_type=ActionType.REORDER, target_id="SKU_1"), - Action(action_id="act_reorder_SKU_2", action_type=ActionType.REORDER, target_id="SKU_2"), - Action(action_id="act_reorder_SKU_3", action_type=ActionType.REORDER, target_id="SKU_3"), - ] - - drafts, error = generate_candidate_plan_drafts( - state=state, - event=None, - candidate_actions=candidate_actions, - ) - - assert error is None - assert [draft.strategy_label for draft in drafts] == ["cost_first", "balanced", "resilience_first"] - - -def test_generate_candidate_plan_drafts_normalizes_action_aliases(monkeypatch) -> None: - _enable_llm(monkeypatch) - - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - return { - "candidate_plans": [ - { - "strategy_label": "cost_first", - "actions": [{"id": "SUP_B"}], - "rationale": "switch to alternate supplier cheaply", - }, - { - "strategy_label": "balanced", - "recommended_action_ids": ["R4"], - "rationale": "protect lane reliability", - }, - { - "strategy_label": "resilience_first", - "selected_actions": ["rebalance SKU_2"], - "rationale": "restore service fast", - }, - ] - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) - state = load_initial_state() - candidate_actions = [ - Action( - action_id="act_supplier_SKU_1_SUP_B", - action_type=ActionType.SWITCH_SUPPLIER, - target_id="SKU_1", - parameters={"supplier_id": "SUP_B"}, - ), - Action( - action_id="act_reroute_SKU_1_R4", - action_type=ActionType.REROUTE, - target_id="SKU_1", - parameters={"route_id": "R4"}, - ), - Action( - action_id="act_demand_rebalance_SKU_2", - action_type=ActionType.REBALANCE, - target_id="SKU_2", - reason="rebalance SKU_2", - ), - ] - - drafts, error = generate_candidate_plan_drafts( - state=state, - event=None, - candidate_actions=candidate_actions, +from core.enums import ActionType, EventType +from core.models import Action, Event +from core.state import load_initial_state, utc_now +from llm.service import generate_candidate_plan_drafts +from orchestrator.graph import build_graph + + +def _enable_llm(monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") + monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") + monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") + monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") + + +def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: + return Event( + event_id=f"evt_{event_type.value}_{severity}", + type=event_type, + source="test", + severity=severity, + entity_ids=entity_ids, + occurred_at=utc_now(), + detected_at=utc_now(), + payload=payload, + dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", + ) + + +def test_generate_candidate_plan_drafts_normalizes_strategy_aliases(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + return { + "candidate_plans": [ + {"strategy_label": "Plan A", "action_ids": ["act_reorder_SKU_1"], "rationale": "lowest cost option"}, + {"strategy_label": "Balanced plan", "action_ids": ["act_reorder_SKU_2"], "rationale": "balanced coverage"}, + {"strategy_label": "Recovery-first", "action_ids": ["act_reorder_SKU_3"], "rationale": "recovery resilience focus"}, + ] + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + state = load_initial_state() + candidate_actions = [ + Action(action_id="act_reorder_SKU_1", action_type=ActionType.REORDER, target_id="SKU_1"), + Action(action_id="act_reorder_SKU_2", action_type=ActionType.REORDER, target_id="SKU_2"), + Action(action_id="act_reorder_SKU_3", action_type=ActionType.REORDER, target_id="SKU_3"), + ] + + drafts, error = generate_candidate_plan_drafts( + state=state, + event=None, + candidate_actions=candidate_actions, + ) + + assert error is None + assert [draft.strategy_label for draft in drafts] == ["cost_first", "balanced", "resilience_first"] + + +def test_generate_candidate_plan_drafts_normalizes_action_aliases(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + return { + "candidate_plans": [ + { + "strategy_label": "cost_first", + "actions": [{"id": "SUP_B"}], + "rationale": "switch to alternate supplier cheaply", + }, + { + "strategy_label": "balanced", + "recommended_action_ids": ["R4"], + "rationale": "protect lane reliability", + }, + { + "strategy_label": "resilience_first", + "selected_actions": ["rebalance SKU_2"], + "rationale": "restore service fast", + }, + ] + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + state = load_initial_state() + candidate_actions = [ + Action( + action_id="act_supplier_SKU_1_SUP_B", + action_type=ActionType.SWITCH_SUPPLIER, + target_id="SKU_1", + parameters={"supplier_id": "SUP_B"}, + ), + Action( + action_id="act_reroute_SKU_1_R4", + action_type=ActionType.REROUTE, + target_id="SKU_1", + parameters={"route_id": "R4"}, + ), + Action( + action_id="act_demand_rebalance_SKU_2", + action_type=ActionType.REBALANCE, + target_id="SKU_2", + reason="rebalance SKU_2", + ), + ] + + drafts, error = generate_candidate_plan_drafts( + state=state, + event=None, + candidate_actions=candidate_actions, + ) + + assert error is None + assert drafts[0].action_ids == ["act_supplier_SKU_1_SUP_B"] + assert drafts[1].action_ids == ["act_reroute_SKU_1_R4"] + assert drafts[2].action_ids == ["act_demand_rebalance_SKU_2"] + + +def test_partial_planner_output_is_repaired_not_fully_fallback(monkeypatch) -> None: + _enable_llm(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + if "candidate_plans" in schema.get("properties", {}): + return { + "candidate_plans": [ + { + "strategy_label": "Plan A", + "action_ids": ["act_supplier_SKU_001_SUP_B"], + "rationale": "cheap supplier substitution", + }, + { + "strategy_label": "Plan B", + "recommended_action_ids": ["R2"], + "rationale": "balanced route protection", + }, + ] + } + if "summary" in schema.get("properties", {}): + return {"summary": "critic ok", "findings": []} + return { + "planner_narrative": "AI narrative", + "operator_explanation": "AI operator explanation", + "approval_summary": "AI approval summary", + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _fake_generate_json) + state = load_initial_state() + event = _event( + EventType.SUPPLIER_DELAY, + 0.8, + {"supplier_id": "SUP_A", "sku": "SKU_001", "delay_hours": 48}, + ["SUP_A", "SKU_001"], + ) + + result = build_graph().invoke(state, event) + + assert result.latest_plan is not None + assert result.latest_plan.generated_by == "llm_planner_repaired" + assert len(result.decision_logs[-1].candidate_evaluations) == 3 + assert "planner returned duplicate, incomplete, or invalid candidate plans" in ( + result.decision_logs[-1].planner_error or "" ) - assert error is None - assert drafts[0].action_ids == ["act_supplier_SKU_1_SUP_B"] - assert drafts[1].action_ids == ["act_reroute_SKU_1_R4"] - assert drafts[2].action_ids == ["act_demand_rebalance_SKU_2"] - -def test_partial_planner_output_is_repaired_not_fully_fallback(monkeypatch) -> None: +def test_duplicate_candidate_plans_are_repaired_into_distinct_options(monkeypatch) -> None: _enable_llm(monkeypatch) def _fake_generate_json(self, *, prompt, schema, temperature=0.2): @@ -123,14 +169,19 @@ def _fake_generate_json(self, *, prompt, schema, temperature=0.2): return { "candidate_plans": [ { - "strategy_label": "Plan A", + "strategy_label": "cost_first", + "action_ids": ["act_supplier_SKU_001_SUP_B"], + "rationale": "lowest cost move", + }, + { + "strategy_label": "balanced", "action_ids": ["act_supplier_SKU_001_SUP_B"], - "rationale": "cheap supplier substitution", + "rationale": "balanced option", }, { - "strategy_label": "Plan B", - "recommended_action_ids": ["R2"], - "rationale": "balanced route protection", + "strategy_label": "resilience_first", + "action_ids": ["act_supplier_SKU_001_SUP_B"], + "rationale": "resilient option", }, ] } @@ -152,10 +203,12 @@ def _fake_generate_json(self, *, prompt, schema, temperature=0.2): ) result = build_graph().invoke(state, event) + action_sets = { + tuple(sorted(item.action_ids)) + for item in result.decision_logs[-1].candidate_evaluations + } assert result.latest_plan is not None + assert len(action_sets) == 3 assert result.latest_plan.generated_by == "llm_planner_repaired" - assert len(result.decision_logs[-1].candidate_evaluations) == 3 - assert "planner returned incomplete or invalid candidate plans" in ( - result.decision_logs[-1].planner_error or "" - ) + assert "duplicate" in (result.decision_logs[-1].planner_error or "") diff --git a/tests/test_policy_constraint_hooks.py b/tests/test_policy_constraint_hooks.py index 07e0a99..34919e0 100644 --- a/tests/test_policy_constraint_hooks.py +++ b/tests/test_policy_constraint_hooks.py @@ -1,156 +1,156 @@ -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from agents.planner import PlannerAgent -from core.enums import ActionType, EventType, Mode -from core.memory import SQLiteStore -from core.models import Action, Event -from core.state import load_initial_state, utc_now -from orchestrator.graph import build_graph -from policies.constraints import evaluate_plan_constraints, mode_rationale -from simulation.runner import ScenarioRunner - - -def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: - return Event( - event_id=f"evt_{event_type.value}", - type=event_type, - source="test", - severity=severity, - entity_ids=entity_ids, - occurred_at=utc_now(), - detected_at=utc_now(), - payload=payload, - dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", - ) - - -def _client(tmp_path: Path) -> TestClient: - store = SQLiteStore(tmp_path / "chaincopilot-policy.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app) - - -def test_constraints_detect_blocked_route_and_capacity_overflow() -> None: - state = load_initial_state() - event = _event( - EventType.ROUTE_BLOCKAGE, - 0.78, - {"route_id": "R1", "reason": "flooding"}, - ["R1"], - ) - violations = evaluate_plan_constraints( - state=state, - event=event, - actions=[ - Action( - action_id="act_reroute_blocked", - action_type=ActionType.REROUTE, - target_id="SKU_001", - parameters={"route_id": "R1"}, - ), - Action( - action_id="act_reorder_overflow", - action_type=ActionType.REORDER, - target_id="SKU_001", - parameters={"quantity": 10_000}, - ), - ], - ) - - assert {item.code for item in violations} == {"route_blocked", "warehouse_capacity_exceeded"} - - -def test_planner_excludes_infeasible_candidates_before_selection() -> None: - state = load_initial_state() - state.mode = Mode.CRISIS - event = _event( - EventType.ROUTE_BLOCKAGE, - 0.78, - {"route_id": "R1", "reason": "flooding"}, - ["R1"], - ) - state.candidate_actions = [ - Action( - action_id="act_reroute_blocked", - action_type=ActionType.REROUTE, - target_id="SKU_1", - parameters={"route_id": "R1"}, - estimated_cost_delta=8.0, - estimated_service_delta=0.05, - estimated_risk_delta=-0.1, - estimated_recovery_hours=6.0, - reason="reroute SKU_1 through blocked route", - priority=0.9, - ), - Action( - action_id="act_no_op_policy", - action_type=ActionType.NO_OP, - target_id="system", - reason="hold current plan", - priority=0.1, - ), - ] - - PlannerAgent().run(state, event) - - log = state.decision_logs[-1] - assert any(not item.feasible for item in log.candidate_evaluations) - blocked_eval = next( - item - for item in log.candidate_evaluations - if any(violation.code == "route_blocked" for violation in item.violations) - ) - assert blocked_eval.feasible is False - assert state.latest_plan is not None - assert state.latest_plan.feasible is True - assert "excluding" in log.selection_reason - - -def test_policy_fields_surface_through_api(tmp_path: Path) -> None: - client = _client(tmp_path) - - response = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "domain", - "event_type": "supplier_delay", - "source": "api", - "severity": 0.8, - "entity_ids": ["SUP_A", "SKU_1"], - "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, - }, - ) - assert response.status_code == 200 - decision_id = client.get("/api/v1/decision-logs").json()["items"][0]["decision_id"] - - plan_response = client.get("/api/v1/plans/latest") - assert plan_response.status_code == 200 - plan_payload = plan_response.json()["item"] - assert plan_payload["mode_rationale"] - assert isinstance(plan_payload["feasible"], bool) - assert "violations" in plan_payload - - decision_response = client.get(f"/api/v1/decision-logs/{decision_id}") - assert decision_response.status_code == 200 - decision_payload = decision_response.json()["item"] - assert decision_payload["mode_rationale"] - assert all("feasible" in item for item in decision_payload["candidate_evaluations"]) - - -def test_mode_rationale_explains_crisis_switch() -> None: - state = load_initial_state() - event = _event( - EventType.COMPOUND, - 0.92, - {"supplier_id": "SUP_A", "route_id": "R1", "sku": "SKU_1"}, - ["SUP_A", "R1", "SKU_1"], - ) - assert "compound disruption" in mode_rationale(state, event) +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from agents.planner import PlannerAgent +from core.enums import ActionType, EventType, Mode +from core.memory import SQLiteStore +from core.models import Action, Event +from core.state import load_initial_state, utc_now +from orchestrator.graph import build_graph +from policies.constraints import evaluate_plan_constraints, mode_rationale +from simulation.runner import ScenarioRunner + + +def _event(event_type: EventType, severity: float, payload: dict, entity_ids: list[str]) -> Event: + return Event( + event_id=f"evt_{event_type.value}", + type=event_type, + source="test", + severity=severity, + entity_ids=entity_ids, + occurred_at=utc_now(), + detected_at=utc_now(), + payload=payload, + dedupe_key=f"{event_type.value}:{entity_ids}:{payload}", + ) + + +def _client(tmp_path: Path) -> TestClient: + store = SQLiteStore(tmp_path / "chaincopilot-policy.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app) + + +def test_constraints_detect_blocked_route_and_capacity_overflow() -> None: + state = load_initial_state() + event = _event( + EventType.ROUTE_BLOCKAGE, + 0.78, + {"route_id": "R1", "reason": "flooding"}, + ["R1"], + ) + violations = evaluate_plan_constraints( + state=state, + event=event, + actions=[ + Action( + action_id="act_reroute_blocked", + action_type=ActionType.REROUTE, + target_id="SKU_001", + parameters={"route_id": "R1"}, + ), + Action( + action_id="act_reorder_overflow", + action_type=ActionType.REORDER, + target_id="SKU_001", + parameters={"quantity": 10_000}, + ), + ], + ) + + assert {item.code for item in violations} == {"route_blocked", "warehouse_capacity_exceeded"} + + +def test_planner_excludes_infeasible_candidates_before_selection() -> None: + state = load_initial_state() + state.mode = Mode.CRISIS + event = _event( + EventType.ROUTE_BLOCKAGE, + 0.78, + {"route_id": "R1", "reason": "flooding"}, + ["R1"], + ) + state.candidate_actions = [ + Action( + action_id="act_reroute_blocked", + action_type=ActionType.REROUTE, + target_id="SKU_1", + parameters={"route_id": "R1"}, + estimated_cost_delta=8.0, + estimated_service_delta=0.05, + estimated_risk_delta=-0.1, + estimated_recovery_hours=6.0, + reason="reroute SKU_1 through blocked route", + priority=0.9, + ), + Action( + action_id="act_no_op_policy", + action_type=ActionType.NO_OP, + target_id="system", + reason="hold current plan", + priority=0.1, + ), + ] + + PlannerAgent().run(state, event) + + log = state.decision_logs[-1] + assert any(not item.feasible for item in log.candidate_evaluations) + blocked_eval = next( + item + for item in log.candidate_evaluations + if any(violation.code == "route_blocked" for violation in item.violations) + ) + assert blocked_eval.feasible is False + assert state.latest_plan is not None + assert state.latest_plan.feasible is True + assert "excluding" in log.selection_reason + + +def test_policy_fields_surface_through_api(tmp_path: Path) -> None: + client = _client(tmp_path) + + response = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "domain", + "event_type": "supplier_delay", + "source": "api", + "severity": 0.8, + "entity_ids": ["SUP_A", "SKU_1"], + "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, + }, + ) + assert response.status_code == 200 + decision_id = client.get("/api/v1/decision-logs").json()["items"][0]["decision_id"] + + plan_response = client.get("/api/v1/plans/latest") + assert plan_response.status_code == 200 + plan_payload = plan_response.json()["item"] + assert plan_payload["mode_rationale"] + assert isinstance(plan_payload["feasible"], bool) + assert "violations" in plan_payload + + decision_response = client.get(f"/api/v1/decision-logs/{decision_id}") + assert decision_response.status_code == 200 + decision_payload = decision_response.json()["item"] + assert decision_payload["mode_rationale"] + assert all("feasible" in item for item in decision_payload["candidate_evaluations"]) + + +def test_mode_rationale_explains_crisis_switch() -> None: + state = load_initial_state() + event = _event( + EventType.COMPOUND, + 0.92, + {"supplier_id": "SUP_A", "route_id": "R1", "sku": "SKU_1"}, + ["SUP_A", "R1", "SKU_1"], + ) + assert "compound disruption" in mode_rationale(state, event) diff --git a/tests/test_run_history_replay.py b/tests/test_run_history_replay.py index b594eff..d7095e5 100644 --- a/tests/test_run_history_replay.py +++ b/tests/test_run_history_replay.py @@ -1,101 +1,101 @@ -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from core.memory import SQLiteStore -from core.state import load_initial_state -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner - - -def _client(tmp_path: Path) -> TestClient: - store = SQLiteStore(tmp_path / "chaincopilot-run-history.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app) - - -def test_runs_list_returns_latest_first(tmp_path: Path) -> None: - client = _client(tmp_path) - - first = client.post( - "/api/v1/events/ingest", - json={"event_class": "command", "event_type": "plan_requested", "source": "api"}, - ) - assert first.status_code == 200 - second = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "domain", - "event_type": "supplier_delay", - "source": "api", - "severity": 0.9, - "entity_ids": ["SKU_1", "SUP_A"], - "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, - }, - ) - assert second.status_code == 200 - - response = client.get("/api/v1/runs?limit=2") - assert response.status_code == 200 - payload = response.json() - assert payload["total"] >= 2 - assert len(payload["items"]) == 2 - assert payload["items"][0]["run_id"] == second.json()["run_id"] - assert payload["items"][1]["run_id"] == first.json()["run_id"] - - -def test_run_state_returns_historical_snapshot(tmp_path: Path) -> None: - client = _client(tmp_path) - - daily = client.post( - "/api/v1/events/ingest", - json={"event_class": "command", "event_type": "plan_requested", "source": "api"}, - ) - assert daily.status_code == 200 - - crisis = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "domain", - "event_type": "supplier_delay", - "source": "api", - "severity": 0.9, - "entity_ids": ["SKU_1", "SUP_A"], - "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, - }, - ) - assert crisis.status_code == 200 - - daily_state = client.get(f"/api/v1/runs/{daily.json()['run_id']}/state") - assert daily_state.status_code == 200 - daily_payload = daily_state.json() - assert daily_payload["run"]["run_id"] == daily.json()["run_id"] - assert daily_payload["state"]["summary"]["mode"] == "normal" - assert daily_payload["state"]["latest_trace"]["run_id"] == daily.json()["run_id"] - - crisis_state = client.get(f"/api/v1/runs/{crisis.json()['run_id']}/state") - assert crisis_state.status_code == 200 - crisis_payload = crisis_state.json() - assert crisis_payload["run"]["run_id"] == crisis.json()["run_id"] - assert crisis_payload["state"]["summary"]["mode"] in {"crisis", "approval"} - assert crisis_payload["state"]["latest_trace"]["run_id"] == crisis.json()["run_id"] - assert crisis_payload["state"]["summary"]["pending_approval"] is not None - - -def test_missing_run_history_returns_404(tmp_path: Path) -> None: - client = _client(tmp_path) - - run_response = client.get("/api/v1/runs/run_missing") - assert run_response.status_code == 404 - - trace_response = client.get("/api/v1/runs/run_missing/trace") - assert trace_response.status_code == 404 - - state_response = client.get("/api/v1/runs/run_missing/state") - assert state_response.status_code == 404 +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from core.memory import SQLiteStore +from core.state import load_initial_state +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner + + +def _client(tmp_path: Path) -> TestClient: + store = SQLiteStore(tmp_path / "chaincopilot-run-history.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app) + + +def test_runs_list_returns_latest_first(tmp_path: Path) -> None: + client = _client(tmp_path) + + first = client.post( + "/api/v1/events/ingest", + json={"event_class": "command", "event_type": "plan_requested", "source": "api"}, + ) + assert first.status_code == 200 + second = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "domain", + "event_type": "supplier_delay", + "source": "api", + "severity": 0.9, + "entity_ids": ["SKU_1", "SUP_A"], + "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, + }, + ) + assert second.status_code == 200 + + response = client.get("/api/v1/runs?limit=2") + assert response.status_code == 200 + payload = response.json() + assert payload["total"] >= 2 + assert len(payload["items"]) == 2 + assert payload["items"][0]["run_id"] == second.json()["run_id"] + assert payload["items"][1]["run_id"] == first.json()["run_id"] + + +def test_run_state_returns_historical_snapshot(tmp_path: Path) -> None: + client = _client(tmp_path) + + daily = client.post( + "/api/v1/events/ingest", + json={"event_class": "command", "event_type": "plan_requested", "source": "api"}, + ) + assert daily.status_code == 200 + + crisis = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "domain", + "event_type": "supplier_delay", + "source": "api", + "severity": 0.9, + "entity_ids": ["SKU_1", "SUP_A"], + "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, + }, + ) + assert crisis.status_code == 200 + + daily_state = client.get(f"/api/v1/runs/{daily.json()['run_id']}/state") + assert daily_state.status_code == 200 + daily_payload = daily_state.json() + assert daily_payload["run"]["run_id"] == daily.json()["run_id"] + assert daily_payload["state"]["summary"]["mode"] == "normal" + assert daily_payload["state"]["latest_trace"]["run_id"] == daily.json()["run_id"] + + crisis_state = client.get(f"/api/v1/runs/{crisis.json()['run_id']}/state") + assert crisis_state.status_code == 200 + crisis_payload = crisis_state.json() + assert crisis_payload["run"]["run_id"] == crisis.json()["run_id"] + assert crisis_payload["state"]["summary"]["mode"] in {"crisis", "approval"} + assert crisis_payload["state"]["latest_trace"]["run_id"] == crisis.json()["run_id"] + assert crisis_payload["state"]["summary"]["pending_approval"] is not None + + +def test_missing_run_history_returns_404(tmp_path: Path) -> None: + client = _client(tmp_path) + + run_response = client.get("/api/v1/runs/run_missing") + assert run_response.status_code == 404 + + trace_response = client.get("/api/v1/runs/run_missing/trace") + assert trace_response.status_code == 404 + + state_response = client.get("/api/v1/runs/run_missing/state") + assert state_response.status_code == 404 diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 2ab1c24..cffe2d1 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,42 +1,42 @@ -import pytest - -from core.enums import ApprovalStatus, Mode -from core.state import load_initial_state -from orchestrator.service import PendingApprovalError -from simulation.runner import ScenarioRunner - - -def test_daily_normal_plan_executes_without_disruption() -> None: - state = load_initial_state() - result = ScenarioRunner().graph.invoke(state, None) - assert result.latest_plan is not None - assert result.mode == Mode.NORMAL - assert result.pending_plan is None - - -def test_supplier_delay_scenario_generates_plan() -> None: - state = load_initial_state() - runner = ScenarioRunner() - result = runner.run(state, "supplier_delay") - assert result.latest_plan is not None - assert result.latest_plan.mode in {Mode.CRISIS, Mode.APPROVAL} - assert result.decision_logs - - -def test_compound_disruption_requires_approval_or_crisis_plan() -> None: - state = load_initial_state() - runner = ScenarioRunner() - result = runner.run(state, "compound_disruption") - assert result.latest_plan is not None - assert result.decision_logs[-1].approval_status in { - ApprovalStatus.PENDING, - ApprovalStatus.AUTO_APPLIED, - } - - -def test_runner_blocks_new_scenario_when_approval_is_pending() -> None: - state = load_initial_state() - runner = ScenarioRunner() - pending_state = runner.run(state, "supplier_delay") - with pytest.raises(PendingApprovalError): - runner.run(pending_state, "route_blockage") +import pytest + +from core.enums import ApprovalStatus, Mode +from core.state import load_initial_state +from orchestrator.service import PendingApprovalError +from simulation.runner import ScenarioRunner + + +def test_daily_normal_plan_executes_without_disruption() -> None: + state = load_initial_state() + result = ScenarioRunner().graph.invoke(state, None) + assert result.latest_plan is not None + assert result.mode == Mode.NORMAL + assert result.pending_plan is None + + +def test_supplier_delay_scenario_generates_plan() -> None: + state = load_initial_state() + runner = ScenarioRunner() + result = runner.run(state, "supplier_delay") + assert result.latest_plan is not None + assert result.latest_plan.mode in {Mode.CRISIS, Mode.APPROVAL} + assert result.decision_logs + + +def test_compound_disruption_requires_approval_or_crisis_plan() -> None: + state = load_initial_state() + runner = ScenarioRunner() + result = runner.run(state, "compound_disruption") + assert result.latest_plan is not None + assert result.decision_logs[-1].approval_status in { + ApprovalStatus.PENDING, + ApprovalStatus.AUTO_APPLIED, + } + + +def test_runner_blocks_new_scenario_when_approval_is_pending() -> None: + state = load_initial_state() + runner = ScenarioRunner() + pending_state = runner.run(state, "supplier_delay") + with pytest.raises(PendingApprovalError): + runner.run(pending_state, "route_blockage") diff --git a/tests/test_seed_data_alignment.py b/tests/test_seed_data_alignment.py index a08b1ec..1324bfa 100644 --- a/tests/test_seed_data_alignment.py +++ b/tests/test_seed_data_alignment.py @@ -1,48 +1,108 @@ +from agents.demand import DemandAgent +from agents.inventory import InventoryAgent +from app_api.services import inventory_rows from core.state import load_initial_state +from orchestrator.graph import build_graph from simulation.scenarios import get_scenario_events + + +def test_seed_data_references_are_internally_consistent() -> None: + state = load_initial_state() + + inventory_skus = set(state.inventory) + warehouse_ids = set(state.warehouses) + route_ids = set(state.routes) + supplier_pairs = { + (record.supplier_id, record.sku) + for record in state.suppliers.values() + } + + for item in state.inventory.values(): + assert item.preferred_route_id in route_ids + assert item.warehouse_id in warehouse_ids + assert (item.preferred_supplier_id, item.sku) in supplier_pairs + + for order in state.orders: + assert order.sku in inventory_skus + assert order.warehouse_id in warehouse_ids + + +def test_seed_scenarios_target_existing_entities() -> None: + state = load_initial_state() + inventory_skus = set(state.inventory) + route_ids = set(state.routes) + supplier_ids = {record.supplier_id for record in state.suppliers.values()} + + for scenario_name in [ + "supplier_delay", + "demand_spike", + "route_blockage", + "compound_disruption", + ]: + for event in get_scenario_events(scenario_name): + supplier_id = event.payload.get("supplier_id") + sku = event.payload.get("sku") + route_id = event.payload.get("route_id") + + if supplier_id is not None: + assert supplier_id in supplier_ids + if sku is not None: + assert sku in inventory_skus + if route_id is not None: + assert route_id in route_ids -def test_seed_data_references_are_internally_consistent() -> None: +def test_seed_inventory_baseline_has_managed_low_stock_pressure() -> None: state = load_initial_state() + DemandAgent().run(state, None) + InventoryAgent().run(state, None) - inventory_skus = set(state.inventory) - warehouse_ids = set(state.warehouses) - route_ids = set(state.routes) - supplier_pairs = { - (record.supplier_id, record.sku) - for record in state.suppliers.values() - } - + below_reorder = 0 + below_safety = 0 for item in state.inventory.values(): - assert item.preferred_route_id in route_ids - assert item.warehouse_id in warehouse_ids - assert (item.preferred_supplier_id, item.sku) in supplier_pairs + projected = item.on_hand + item.incoming_qty - item.forecast_qty + below_reorder += projected < item.reorder_point + below_safety += projected < item.safety_stock - for order in state.orders: - assert order.sku in inventory_skus - assert order.warehouse_id in warehouse_ids + assert state.kpis.service_level >= 0.97 + assert state.kpis.stockout_risk <= 0.03 + assert 18 <= below_reorder <= 22 + assert below_safety <= 2 -def test_seed_scenarios_target_existing_entities() -> None: +def test_inventory_api_status_matches_projected_low_stock_baseline() -> None: state = load_initial_state() - inventory_skus = set(state.inventory) - route_ids = set(state.routes) - supplier_ids = {record.supplier_id for record in state.suppliers.values()} - - for scenario_name in [ - "supplier_delay", - "demand_spike", - "route_blockage", - "compound_disruption", - ]: - for event in get_scenario_events(scenario_name): - supplier_id = event.payload.get("supplier_id") - sku = event.payload.get("sku") - route_id = event.payload.get("route_id") - - if supplier_id is not None: - assert supplier_id in supplier_ids - if sku is not None: - assert sku in inventory_skus - if route_id is not None: - assert route_id in route_ids + DemandAgent().run(state, None) + InventoryAgent().run(state, None) + + rows = inventory_rows(state) + low_count = sum(row.status in {"low", "out_of_stock"} for row in rows) + at_risk_count = sum(row.status == "at_risk" for row in rows) + + assert len(rows) == 50 + assert 18 <= low_count + at_risk_count <= 22 + assert low_count >= 18 + + +def test_seed_warehouses_are_not_over_capacity_at_baseline() -> None: + state = load_initial_state() + + for warehouse_id, warehouse in state.warehouses.items(): + current_stock = sum( + item.on_hand + item.incoming_qty + for item in state.inventory.values() + if item.warehouse_id == warehouse_id + ) + assert current_stock <= warehouse.capacity + + +def test_demand_spike_scenario_produces_actionable_plan(monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "0") + + state = load_initial_state() + event = get_scenario_events("demand_spike")[0] + result = build_graph().invoke(state, event) + + assert result.latest_plan is not None + assert any(action.action_type.value != "no_op" for action in result.latest_plan.actions) + assert all(action.action_type.value != "no_op" for action in result.latest_plan.actions) diff --git a/tests/test_service_observability_reliability.py b/tests/test_service_observability_reliability.py index 34b9ee2..47175e0 100644 --- a/tests/test_service_observability_reliability.py +++ b/tests/test_service_observability_reliability.py @@ -1,160 +1,160 @@ -from pathlib import Path - -from fastapi.testclient import TestClient - -import api -from core.enums import ActionType -from core.memory import SQLiteStore -from core.models import Action -from core.state import load_initial_state -from llm.gemini_client import GeminiClientError -from llm.service import generate_candidate_plan_drafts -from orchestrator.graph import build_graph -from simulation.runner import ScenarioRunner - - -def _client(tmp_path: Path) -> TestClient: - store = SQLiteStore(tmp_path / "chaincopilot-service-runtime.db") - api.replace_runtime( - store=store, - state=load_initial_state(), - graph=build_graph(), - runner=ScenarioRunner(store=store), - ) - return TestClient(api.app) - - -def _enable_llm(monkeypatch) -> None: - monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") - monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") - monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") - monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") - - -def test_service_runtime_endpoint_exposes_flags_and_metrics(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "false") - monkeypatch.setenv("CHAINCOPILOT_PLANNER_MODE", "deterministic") - monkeypatch.setenv("CHAINCOPILOT_DISPATCH_MODE", "simulation") - monkeypatch.setenv("CHAINCOPILOT_LLM_TIMEOUT_S", "6") - monkeypatch.setenv("CHAINCOPILOT_LLM_RETRY_ATTEMPTS", "2") - client = _client(tmp_path) - - command_response = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "command", - "event_type": "plan_requested", - "source": "test", - "payload": {"requested_by": "operator"}, - }, - ) - assert command_response.status_code == 200 - - disruption_response = client.post( - "/api/v1/events/ingest", - json={ - "event_class": "domain", - "event_type": "supplier_delay", - "source": "test", - "severity": 0.82, - "entity_ids": ["SUP_A", "SKU_1"], - "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, - }, - ) - assert disruption_response.status_code == 200 - - response = client.get("/api/v1/service/runtime") - - assert response.status_code == 200 - payload = response.json()["item"] - assert payload["flags"]["llm_enabled"] is False - assert payload["flags"]["planner_mode"] == "deterministic" - assert payload["flags"]["dispatch_mode"] == "simulation" - assert payload["flags"]["llm_timeout_s"] == 6.0 - assert payload["flags"]["llm_retry_attempts"] == 2 - assert payload["metrics"]["total_runs"] >= 2 - assert payload["metrics"]["total_events"] >= 2 - assert payload["metrics"]["total_executions"] >= 2 - assert payload["metrics"]["avg_run_duration_ms"] >= 0.0 - assert payload["metrics"]["avg_agent_step_duration_ms"] >= 0.0 - assert payload["metrics"]["llm_fallback_rate"] >= 0.0 - assert payload["metrics"]["approval_rate"] > 0.0 - - -def test_standard_error_envelope_for_not_found_and_validation(tmp_path: Path) -> None: - client = _client(tmp_path) - - missing_response = client.get("/api/v1/runs/run_missing") - assert missing_response.status_code == 404 - missing_payload = missing_response.json() - assert missing_payload["code"] == "run_not_found" - assert missing_payload["message"] == "run not found" - assert missing_payload["details"]["id"] == "run_missing" - - validation_response = client.post( - "/api/v1/approvals/dec_missing", - json={"action": "invalid"}, - ) - assert validation_response.status_code == 422 - validation_payload = validation_response.json() - assert validation_payload["code"] == "validation_error" - assert validation_payload["message"] == "request validation failed" - assert "errors" in validation_payload["details"] - - -def test_planner_mode_deterministic_skips_candidate_llm(monkeypatch) -> None: - _enable_llm(monkeypatch) - monkeypatch.setenv("CHAINCOPILOT_PLANNER_MODE", "deterministic") - - def _boom(self, *, prompt, schema, temperature=0.2): - raise AssertionError("planner LLM should not be called in deterministic mode") - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _boom) - state = load_initial_state() - candidate_actions = [ - Action(action_id="act_reorder_SKU_1", action_type=ActionType.REORDER, target_id="SKU_1") - ] - - drafts, error = generate_candidate_plan_drafts( - state=state, - event=None, - candidate_actions=candidate_actions, - ) - - assert drafts == [] - assert error == "planner_mode=deterministic" - - -def test_candidate_planner_retries_once_before_succeeding(monkeypatch) -> None: - _enable_llm(monkeypatch) - monkeypatch.setenv("CHAINCOPILOT_PLANNER_MODE", "hybrid") - monkeypatch.setenv("CHAINCOPILOT_LLM_RETRY_ATTEMPTS", "2") - calls = {"count": 0} - - def _flaky(self, *, prompt, schema, temperature=0.2): - calls["count"] += 1 - if calls["count"] == 1: - raise GeminiClientError("temporary timeout") - return { - "candidate_plans": [ - {"strategy_label": "cost_first", "action_ids": ["act_reorder_SKU_1"], "rationale": "retry ok"}, - {"strategy_label": "balanced", "action_ids": ["act_reorder_SKU_1"], "rationale": "retry ok"}, - {"strategy_label": "resilience_first", "action_ids": ["act_reorder_SKU_1"], "rationale": "retry ok"}, - ] - } - - monkeypatch.setattr("llm.service.GeminiClient.generate_json", _flaky) - state = load_initial_state() - candidate_actions = [ - Action(action_id="act_reorder_SKU_1", action_type=ActionType.REORDER, target_id="SKU_1") - ] - - drafts, error = generate_candidate_plan_drafts( - state=state, - event=None, - candidate_actions=candidate_actions, - ) - - assert calls["count"] == 2 - assert error is None - assert [draft.strategy_label for draft in drafts] == ["cost_first", "balanced", "resilience_first"] +from pathlib import Path + +from fastapi.testclient import TestClient + +import api +from core.enums import ActionType +from core.memory import SQLiteStore +from core.models import Action +from core.state import load_initial_state +from llm.gemini_client import GeminiClientError +from llm.service import generate_candidate_plan_drafts +from orchestrator.graph import build_graph +from simulation.runner import ScenarioRunner + + +def _client(tmp_path: Path) -> TestClient: + store = SQLiteStore(tmp_path / "chaincopilot-service-runtime.db") + api.replace_runtime( + store=store, + state=load_initial_state(), + graph=build_graph(), + runner=ScenarioRunner(store=store), + ) + return TestClient(api.app) + + +def _enable_llm(monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") + monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "gemini") + monkeypatch.setenv("CHAINCOPILOT_LLM_API_KEY", "test-key") + monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") + + +def test_service_runtime_endpoint_exposes_flags_and_metrics(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "false") + monkeypatch.setenv("CHAINCOPILOT_PLANNER_MODE", "deterministic") + monkeypatch.setenv("CHAINCOPILOT_DISPATCH_MODE", "simulation") + monkeypatch.setenv("CHAINCOPILOT_LLM_TIMEOUT_S", "6") + monkeypatch.setenv("CHAINCOPILOT_LLM_RETRY_ATTEMPTS", "2") + client = _client(tmp_path) + + command_response = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "command", + "event_type": "plan_requested", + "source": "test", + "payload": {"requested_by": "operator"}, + }, + ) + assert command_response.status_code == 200 + + disruption_response = client.post( + "/api/v1/events/ingest", + json={ + "event_class": "domain", + "event_type": "supplier_delay", + "source": "test", + "severity": 0.82, + "entity_ids": ["SUP_A", "SKU_1"], + "payload": {"supplier_id": "SUP_A", "sku": "SKU_1", "delay_hours": 48}, + }, + ) + assert disruption_response.status_code == 200 + + response = client.get("/api/v1/service/runtime") + + assert response.status_code == 200 + payload = response.json()["item"] + assert payload["flags"]["llm_enabled"] is False + assert payload["flags"]["planner_mode"] == "deterministic" + assert payload["flags"]["dispatch_mode"] == "simulation" + assert payload["flags"]["llm_timeout_s"] == 6.0 + assert payload["flags"]["llm_retry_attempts"] == 2 + assert payload["metrics"]["total_runs"] >= 2 + assert payload["metrics"]["total_events"] >= 2 + assert payload["metrics"]["total_executions"] >= 2 + assert payload["metrics"]["avg_run_duration_ms"] >= 0.0 + assert payload["metrics"]["avg_agent_step_duration_ms"] >= 0.0 + assert payload["metrics"]["llm_fallback_rate"] >= 0.0 + assert payload["metrics"]["approval_rate"] > 0.0 + + +def test_standard_error_envelope_for_not_found_and_validation(tmp_path: Path) -> None: + client = _client(tmp_path) + + missing_response = client.get("/api/v1/runs/run_missing") + assert missing_response.status_code == 404 + missing_payload = missing_response.json() + assert missing_payload["code"] == "run_not_found" + assert missing_payload["message"] == "run not found" + assert missing_payload["details"]["id"] == "run_missing" + + validation_response = client.post( + "/api/v1/approvals/dec_missing", + json={"action": "invalid"}, + ) + assert validation_response.status_code == 422 + validation_payload = validation_response.json() + assert validation_payload["code"] == "validation_error" + assert validation_payload["message"] == "request validation failed" + assert "errors" in validation_payload["details"] + + +def test_planner_mode_deterministic_skips_candidate_llm(monkeypatch) -> None: + _enable_llm(monkeypatch) + monkeypatch.setenv("CHAINCOPILOT_PLANNER_MODE", "deterministic") + + def _boom(self, *, prompt, schema, temperature=0.2): + raise AssertionError("planner LLM should not be called in deterministic mode") + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _boom) + state = load_initial_state() + candidate_actions = [ + Action(action_id="act_reorder_SKU_1", action_type=ActionType.REORDER, target_id="SKU_1") + ] + + drafts, error = generate_candidate_plan_drafts( + state=state, + event=None, + candidate_actions=candidate_actions, + ) + + assert drafts == [] + assert error == "planner_mode=deterministic" + + +def test_candidate_planner_retries_once_before_succeeding(monkeypatch) -> None: + _enable_llm(monkeypatch) + monkeypatch.setenv("CHAINCOPILOT_PLANNER_MODE", "hybrid") + monkeypatch.setenv("CHAINCOPILOT_LLM_RETRY_ATTEMPTS", "2") + calls = {"count": 0} + + def _flaky(self, *, prompt, schema, temperature=0.2): + calls["count"] += 1 + if calls["count"] == 1: + raise GeminiClientError("temporary timeout") + return { + "candidate_plans": [ + {"strategy_label": "cost_first", "action_ids": ["act_reorder_SKU_1"], "rationale": "retry ok"}, + {"strategy_label": "balanced", "action_ids": ["act_reorder_SKU_1"], "rationale": "retry ok"}, + {"strategy_label": "resilience_first", "action_ids": ["act_reorder_SKU_1"], "rationale": "retry ok"}, + ] + } + + monkeypatch.setattr("llm.service.GeminiClient.generate_json", _flaky) + state = load_initial_state() + candidate_actions = [ + Action(action_id="act_reorder_SKU_1", action_type=ActionType.REORDER, target_id="SKU_1") + ] + + drafts, error = generate_candidate_plan_drafts( + state=state, + event=None, + candidate_actions=candidate_actions, + ) + + assert calls["count"] == 2 + assert error is None + assert [draft.strategy_label for draft in drafts] == ["cost_first", "balanced", "resilience_first"] diff --git a/tests/test_strategic_prompt.py b/tests/test_strategic_prompt.py index 0c3e728..50bd1a6 100644 --- a/tests/test_strategic_prompt.py +++ b/tests/test_strategic_prompt.py @@ -1,219 +1,219 @@ -""" -Tests for Strategic Planner Agent Memory & Prompt System. -""" -from datetime import datetime, timezone - -from core.enums import ActionType, EventType -from core.models import Action, Event, MemorySnapshot -from core.state import default_memory -from policies.strategic_prompt import ( - retrieve_relevant_cases, - compute_memory_influence, - derive_strategy_rationale, - build_strategic_prompt, -) - - -# ------------------------------------------------------------------ -# Helpers -# ------------------------------------------------------------------ - -def _make_event(event_type: EventType = EventType.SUPPLIER_DELAY, severity: float = 0.8) -> Event: - now = datetime.now(timezone.utc) - return Event( - event_id="evt_test_001", - type=event_type, - source="test", - severity=severity, - occurred_at=now, - detected_at=now, - dedupe_key="test_001", - ) - - -def _make_memory_with_cases() -> MemorySnapshot: - return default_memory() - - -def _make_action(action_type: ActionType = ActionType.REORDER) -> Action: - return Action( - action_id="act_reorder_SKU_1", - action_type=action_type, - target_id="SKU_1", - reason="test action", - priority=0.7, - ) - - -# ------------------------------------------------------------------ -# Test 1: retrieve_relevant_cases returns top matching cases -# ------------------------------------------------------------------ - -def test_retrieve_relevant_cases_matches_by_type() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) - - cases = retrieve_relevant_cases(event, memory, top_k=3) - - assert len(cases) == 3 - # Top cases should be supplier_delay type (CASE_2024_001, CASE_2024_004) - top_types = [c.event_type for c in cases[:2]] - assert "supplier_delay" in top_types - - -def test_retrieve_relevant_cases_limits_results() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.ROUTE_BLOCKAGE, severity=0.7) - - cases = retrieve_relevant_cases(event, memory, top_k=2) - assert len(cases) == 2 - - -def test_retrieve_relevant_cases_scores_similarity() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) - - cases = retrieve_relevant_cases(event, memory, top_k=5) - # Case_2024_001 is supplier_delay with severity=0.8 — exact match should be highest - top_case = cases[0] - assert top_case.case_id == "CASE_2024_001" - assert top_case.similarity_score > 0.9 - - -def test_retrieve_returns_empty_if_no_memory() -> None: - event = _make_event() - cases = retrieve_relevant_cases(event, memory=None, top_k=3) - assert cases == [] - - -def test_retrieve_returns_empty_if_no_event() -> None: - memory = _make_memory_with_cases() - cases = retrieve_relevant_cases(event=None, memory=memory, top_k=3) - assert cases == [] - - -# ------------------------------------------------------------------ -# Test 2: compute_memory_influence -# ------------------------------------------------------------------ - -def test_memory_influence_is_zero_if_no_cases() -> None: - score = compute_memory_influence([]) - assert score == 0.0 - - -def test_memory_influence_is_high_for_high_similarity() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) - cases = retrieve_relevant_cases(event, memory) - score = compute_memory_influence(cases) - assert 0.5 <= score <= 1.0 - - -def test_memory_influence_is_between_0_and_1() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.DEMAND_SPIKE, severity=0.3) - cases = retrieve_relevant_cases(event, memory) - score = compute_memory_influence(cases) - assert 0.0 <= score <= 1.0 - - -# ------------------------------------------------------------------ -# Test 3: build_strategic_prompt -# ------------------------------------------------------------------ - -def test_prompt_contains_all_blocks() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) - cases = retrieve_relevant_cases(event, memory, top_k=3) - actions = [_make_action()] - - prompt = build_strategic_prompt( - mode="crisis", - event=event, - historical_cases=cases, - candidate_actions=actions, - ) - - assert "CURRENT DISRUPTION" in prompt - assert "HISTORICAL MEMORY" in prompt - assert "SPECIALIST AGENT PROPOSALS" in prompt - assert "HARD CONSTRAINTS" in prompt - assert "TASK INSTRUCTIONS" in prompt - - -def test_prompt_references_case_ids() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) - cases = retrieve_relevant_cases(event, memory, top_k=2) - - prompt = build_strategic_prompt( - mode="normal", - event=event, - historical_cases=cases, - candidate_actions=[], - ) - - assert "CASE_2024_001" in prompt - - -def test_prompt_mode_directive_changes_by_mode() -> None: - event = _make_event() - cases = [] - - crisis_prompt = build_strategic_prompt("crisis", event, cases, []) - normal_prompt = build_strategic_prompt("normal", event, cases, []) - - assert "SERVICE LEVEL" in crisis_prompt - assert "COST EFFICIENCY" in normal_prompt - - -# ------------------------------------------------------------------ -# Test 4: derive_strategy_rationale -# ------------------------------------------------------------------ - -def test_rationale_includes_event_info() -> None: - event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) - rationale = derive_strategy_rationale(event, [], []) - assert "supplier_delay" in rationale - assert "80%" in rationale - - -def test_rationale_includes_case_references() -> None: - memory = _make_memory_with_cases() - event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) - cases = retrieve_relevant_cases(event, memory, top_k=2) - actions = [_make_action()] - - rationale = derive_strategy_rationale(event, cases, actions) - - assert "CASE_2024_001" in rationale - assert "reorder" in rationale.lower() - - -def test_rationale_without_event() -> None: - rationale = derive_strategy_rationale(None, [], []) - assert "No historical cases" in rationale - - -# ------------------------------------------------------------------ -# Test 5: default_memory seed cases are correct -# ------------------------------------------------------------------ - -def test_default_memory_has_seed_cases() -> None: - memory = default_memory() - assert len(memory.historical_cases) >= 8 - - -def test_default_memory_case_ids_are_unique() -> None: - memory = default_memory() - ids = [c.case_id for c in memory.historical_cases] - assert len(ids) == len(set(ids)) - - -def test_default_memory_all_event_types_covered() -> None: - memory = default_memory() - event_types = {c.event_type for c in memory.historical_cases} - assert "supplier_delay" in event_types - assert "route_blockage" in event_types - assert "demand_spike" in event_types - assert "compound" in event_types +""" +Tests for Strategic Planner Agent Memory & Prompt System. +""" +from datetime import datetime, timezone + +from core.enums import ActionType, EventType +from core.models import Action, Event, MemorySnapshot +from core.state import default_memory +from policies.strategic_prompt import ( + retrieve_relevant_cases, + compute_memory_influence, + derive_strategy_rationale, + build_strategic_prompt, +) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _make_event(event_type: EventType = EventType.SUPPLIER_DELAY, severity: float = 0.8) -> Event: + now = datetime.now(timezone.utc) + return Event( + event_id="evt_test_001", + type=event_type, + source="test", + severity=severity, + occurred_at=now, + detected_at=now, + dedupe_key="test_001", + ) + + +def _make_memory_with_cases() -> MemorySnapshot: + return default_memory() + + +def _make_action(action_type: ActionType = ActionType.REORDER) -> Action: + return Action( + action_id="act_reorder_SKU_1", + action_type=action_type, + target_id="SKU_1", + reason="test action", + priority=0.7, + ) + + +# ------------------------------------------------------------------ +# Test 1: retrieve_relevant_cases returns top matching cases +# ------------------------------------------------------------------ + +def test_retrieve_relevant_cases_matches_by_type() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) + + cases = retrieve_relevant_cases(event, memory, top_k=3) + + assert len(cases) == 3 + # Top cases should be supplier_delay type (CASE_2024_001, CASE_2024_004) + top_types = [c.event_type for c in cases[:2]] + assert "supplier_delay" in top_types + + +def test_retrieve_relevant_cases_limits_results() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.ROUTE_BLOCKAGE, severity=0.7) + + cases = retrieve_relevant_cases(event, memory, top_k=2) + assert len(cases) == 2 + + +def test_retrieve_relevant_cases_scores_similarity() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) + + cases = retrieve_relevant_cases(event, memory, top_k=5) + # Case_2024_001 is supplier_delay with severity=0.8 — exact match should be highest + top_case = cases[0] + assert top_case.case_id == "CASE_2024_001" + assert top_case.similarity_score > 0.9 + + +def test_retrieve_returns_empty_if_no_memory() -> None: + event = _make_event() + cases = retrieve_relevant_cases(event, memory=None, top_k=3) + assert cases == [] + + +def test_retrieve_returns_empty_if_no_event() -> None: + memory = _make_memory_with_cases() + cases = retrieve_relevant_cases(event=None, memory=memory, top_k=3) + assert cases == [] + + +# ------------------------------------------------------------------ +# Test 2: compute_memory_influence +# ------------------------------------------------------------------ + +def test_memory_influence_is_zero_if_no_cases() -> None: + score = compute_memory_influence([]) + assert score == 0.0 + + +def test_memory_influence_is_high_for_high_similarity() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) + cases = retrieve_relevant_cases(event, memory) + score = compute_memory_influence(cases) + assert 0.5 <= score <= 1.0 + + +def test_memory_influence_is_between_0_and_1() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.DEMAND_SPIKE, severity=0.3) + cases = retrieve_relevant_cases(event, memory) + score = compute_memory_influence(cases) + assert 0.0 <= score <= 1.0 + + +# ------------------------------------------------------------------ +# Test 3: build_strategic_prompt +# ------------------------------------------------------------------ + +def test_prompt_contains_all_blocks() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) + cases = retrieve_relevant_cases(event, memory, top_k=3) + actions = [_make_action()] + + prompt = build_strategic_prompt( + mode="crisis", + event=event, + historical_cases=cases, + candidate_actions=actions, + ) + + assert "CURRENT DISRUPTION" in prompt + assert "HISTORICAL MEMORY" in prompt + assert "SPECIALIST AGENT PROPOSALS" in prompt + assert "HARD CONSTRAINTS" in prompt + assert "TASK INSTRUCTIONS" in prompt + + +def test_prompt_references_case_ids() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) + cases = retrieve_relevant_cases(event, memory, top_k=2) + + prompt = build_strategic_prompt( + mode="normal", + event=event, + historical_cases=cases, + candidate_actions=[], + ) + + assert "CASE_2024_001" in prompt + + +def test_prompt_mode_directive_changes_by_mode() -> None: + event = _make_event() + cases = [] + + crisis_prompt = build_strategic_prompt("crisis", event, cases, []) + normal_prompt = build_strategic_prompt("normal", event, cases, []) + + assert "SERVICE LEVEL" in crisis_prompt + assert "COST EFFICIENCY" in normal_prompt + + +# ------------------------------------------------------------------ +# Test 4: derive_strategy_rationale +# ------------------------------------------------------------------ + +def test_rationale_includes_event_info() -> None: + event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) + rationale = derive_strategy_rationale(event, [], []) + assert "supplier_delay" in rationale + assert "80%" in rationale + + +def test_rationale_includes_case_references() -> None: + memory = _make_memory_with_cases() + event = _make_event(EventType.SUPPLIER_DELAY, severity=0.8) + cases = retrieve_relevant_cases(event, memory, top_k=2) + actions = [_make_action()] + + rationale = derive_strategy_rationale(event, cases, actions) + + assert "CASE_2024_001" in rationale + assert "reorder" in rationale.lower() + + +def test_rationale_without_event() -> None: + rationale = derive_strategy_rationale(None, [], []) + assert "No historical cases" in rationale + + +# ------------------------------------------------------------------ +# Test 5: default_memory seed cases are correct +# ------------------------------------------------------------------ + +def test_default_memory_has_seed_cases() -> None: + memory = default_memory() + assert len(memory.historical_cases) >= 8 + + +def test_default_memory_case_ids_are_unique() -> None: + memory = default_memory() + ids = [c.case_id for c in memory.historical_cases] + assert len(ids) == len(set(ids)) + + +def test_default_memory_all_event_types_covered() -> None: + memory = default_memory() + event_types = {c.event_type for c in memory.historical_cases} + assert "supplier_delay" in event_types + assert "route_blockage" in event_types + assert "demand_spike" in event_types + assert "compound" in event_types diff --git a/tests/test_vertex_provider.py b/tests/test_vertex_provider.py index dd88a43..49bfaea 100644 --- a/tests/test_vertex_provider.py +++ b/tests/test_vertex_provider.py @@ -1,163 +1,163 @@ -from __future__ import annotations - -import json - -from core.enums import EventType -from core.models import Event -from core.state import load_initial_state, utc_now -from llm.config import load_settings -from llm.vertex_client import VertexGeminiClient -from llm.vertex_client import VertexClientError -from orchestrator.graph import build_graph - - -def _enable_vertex(monkeypatch) -> None: - monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") - monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "vertex") - monkeypatch.setenv("VERTEX_AI_API_KEY", "vertex-test-key") - monkeypatch.setenv("VERTEX_AI_PROJECT_ID", "vertex-test-project") - monkeypatch.setenv("VERTEX_AI_REGION", "global") - monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") - - -def _event() -> Event: - return Event( - event_id="evt_supplier_delay_vertex", - type=EventType.SUPPLIER_DELAY, - source="test", - severity=0.82, - entity_ids=["SUP_A", "SKU_001"], - occurred_at=utc_now(), - detected_at=utc_now(), - payload={"supplier_id": "SUP_A", "sku": "SKU_001", "delay_hours": 48}, - dedupe_key="supplier_delay:SUP_A:SKU_001:48", - ) - - -def test_load_settings_prefers_vertex_env(monkeypatch) -> None: - _enable_vertex(monkeypatch) - - settings = load_settings() - - assert settings.provider == "vertex" - assert settings.api_key == "vertex-test-key" - assert settings.vertex_project_id == "vertex-test-project" - assert settings.vertex_region == "global" - - -def test_vertex_provider_routes_planner_without_fallback(monkeypatch) -> None: - _enable_vertex(monkeypatch) - - def _fake_generate_json(self, *, prompt, schema, temperature=0.2): - if "candidate_plans" in schema.get("properties", {}): - return { - "candidate_plans": [ - { - "strategy_label": "cost_first", - "action_ids": ["act_supplier_SKU_001_SUP_B"], - "rationale": "Use the lower-cost alternate supplier.", - }, - { - "strategy_label": "balanced", - "action_ids": ["act_supplier_SKU_001_SUP_B", "act_reroute_SKU_001_R2"], - "rationale": "Balance supplier and route risk.", - }, - { - "strategy_label": "resilience_first", - "action_ids": ["act_reroute_SKU_001_R2", "act_reroute_SKU_003_R2"], - "rationale": "Protect recovery speed under disruption.", - }, - ] - } - if "summary" in schema.get("properties", {}): - return { - "summary": "Vertex critic accepted the plan.", - "findings": [], - } - if "planner_narrative" in schema.get("properties", {}): - return { - "planner_narrative": "Vertex planner narrative.", - "operator_explanation": "Vertex operator explanation.", - "approval_summary": "Vertex approval summary.", - } - return { - "domain_summary": "Vertex specialist reasoning.", - "downstream_impacts": ["Monitor downstream service impact."], - "recommended_action_ids": [], - "tradeoffs": ["Lower cost can increase supplier transition risk."], - "notes_for_planner": "Prefer resilient substitutes for disrupted supply.", - } - - monkeypatch.setattr("llm.service.VertexGeminiClient.generate_json", _fake_generate_json) - - result = build_graph().invoke(load_initial_state(), _event()) - log = result.decision_logs[-1] - - assert result.latest_plan is not None - assert result.latest_plan.generated_by == "llm_planner" - assert log.planner_error is None - assert result.agent_outputs["planner"].llm_used is True - assert log.llm_provider == "vertex" - assert log.llm_model == "gemini-2.5-flash" - - -def test_vertex_quota_error_keeps_existing_fallback(monkeypatch) -> None: - _enable_vertex(monkeypatch) - - def _raise_quota(self, *, prompt, schema, temperature=0.2): - raise VertexClientError("vertex quota error 429: quota exhausted") - - monkeypatch.setattr("llm.service.VertexGeminiClient.generate_json", _raise_quota) - - result = build_graph().invoke(load_initial_state(), _event()) - log = result.decision_logs[-1] - - assert result.latest_plan is not None - assert result.latest_plan.generated_by == "hybrid_fallback" - assert "vertex quota error 429" in (log.planner_error or "") - assert result.agent_outputs["planner"].llm_error is not None - - -def test_vertex_client_sends_user_role(monkeypatch) -> None: - _enable_vertex(monkeypatch) - captured: dict[str, object] = {} - - class _FakeResponse: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def read(self) -> bytes: - return json.dumps( - { - "candidates": [ - { - "content": { - "parts": [ - { - "text": json.dumps({"ok": True}), - } - ] - } - } - ] - } - ).encode("utf-8") - - def _fake_urlopen(http_request, timeout): - captured["body"] = json.loads(http_request.data.decode("utf-8")) - captured["timeout"] = timeout - return _FakeResponse() - - monkeypatch.setattr("llm.vertex_client.request.urlopen", _fake_urlopen) - - settings = load_settings() - result = VertexGeminiClient(settings).generate_json( - prompt="test prompt", - schema={"type": "object"}, - ) - - assert result == {"ok": True} - assert captured["body"]["contents"][0]["role"] == "user" +from __future__ import annotations + +import json + +from core.enums import EventType +from core.models import Event +from core.state import load_initial_state, utc_now +from llm.config import load_settings +from llm.vertex_client import VertexGeminiClient +from llm.vertex_client import VertexClientError +from orchestrator.graph import build_graph + + +def _enable_vertex(monkeypatch) -> None: + monkeypatch.setenv("CHAINCOPILOT_LLM_ENABLED", "true") + monkeypatch.setenv("CHAINCOPILOT_LLM_PROVIDER", "vertex") + monkeypatch.setenv("VERTEX_AI_API_KEY", "vertex-test-key") + monkeypatch.setenv("VERTEX_AI_PROJECT_ID", "vertex-test-project") + monkeypatch.setenv("VERTEX_AI_REGION", "global") + monkeypatch.setenv("CHAINCOPILOT_LLM_MODEL", "gemini-2.5-flash") + + +def _event() -> Event: + return Event( + event_id="evt_supplier_delay_vertex", + type=EventType.SUPPLIER_DELAY, + source="test", + severity=0.82, + entity_ids=["SUP_A", "SKU_001"], + occurred_at=utc_now(), + detected_at=utc_now(), + payload={"supplier_id": "SUP_A", "sku": "SKU_001", "delay_hours": 48}, + dedupe_key="supplier_delay:SUP_A:SKU_001:48", + ) + + +def test_load_settings_prefers_vertex_env(monkeypatch) -> None: + _enable_vertex(monkeypatch) + + settings = load_settings() + + assert settings.provider == "vertex" + assert settings.api_key == "vertex-test-key" + assert settings.vertex_project_id == "vertex-test-project" + assert settings.vertex_region == "global" + + +def test_vertex_provider_routes_planner_without_fallback(monkeypatch) -> None: + _enable_vertex(monkeypatch) + + def _fake_generate_json(self, *, prompt, schema, temperature=0.2): + if "candidate_plans" in schema.get("properties", {}): + return { + "candidate_plans": [ + { + "strategy_label": "cost_first", + "action_ids": ["act_supplier_SKU_001_SUP_B"], + "rationale": "Use the lower-cost alternate supplier.", + }, + { + "strategy_label": "balanced", + "action_ids": ["act_supplier_SKU_001_SUP_B", "act_reroute_SKU_001_R2"], + "rationale": "Balance supplier and route risk.", + }, + { + "strategy_label": "resilience_first", + "action_ids": ["act_reroute_SKU_001_R2", "act_reroute_SKU_003_R2"], + "rationale": "Protect recovery speed under disruption.", + }, + ] + } + if "summary" in schema.get("properties", {}): + return { + "summary": "Vertex critic accepted the plan.", + "findings": [], + } + if "planner_narrative" in schema.get("properties", {}): + return { + "planner_narrative": "Vertex planner narrative.", + "operator_explanation": "Vertex operator explanation.", + "approval_summary": "Vertex approval summary.", + } + return { + "domain_summary": "Vertex specialist reasoning.", + "downstream_impacts": ["Monitor downstream service impact."], + "recommended_action_ids": [], + "tradeoffs": ["Lower cost can increase supplier transition risk."], + "notes_for_planner": "Prefer resilient substitutes for disrupted supply.", + } + + monkeypatch.setattr("llm.service.VertexGeminiClient.generate_json", _fake_generate_json) + + result = build_graph().invoke(load_initial_state(), _event()) + log = result.decision_logs[-1] + + assert result.latest_plan is not None + assert result.latest_plan.generated_by == "llm_planner" + assert log.planner_error is None + assert result.agent_outputs["planner"].llm_used is True + assert log.llm_provider == "vertex" + assert log.llm_model == "gemini-2.5-flash" + + +def test_vertex_quota_error_keeps_existing_fallback(monkeypatch) -> None: + _enable_vertex(monkeypatch) + + def _raise_quota(self, *, prompt, schema, temperature=0.2): + raise VertexClientError("vertex quota error 429: quota exhausted") + + monkeypatch.setattr("llm.service.VertexGeminiClient.generate_json", _raise_quota) + + result = build_graph().invoke(load_initial_state(), _event()) + log = result.decision_logs[-1] + + assert result.latest_plan is not None + assert result.latest_plan.generated_by == "hybrid_fallback" + assert "vertex quota error 429" in (log.planner_error or "") + assert result.agent_outputs["planner"].llm_error is not None + + +def test_vertex_client_sends_user_role(monkeypatch) -> None: + _enable_vertex(monkeypatch) + captured: dict[str, object] = {} + + class _FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self) -> bytes: + return json.dumps( + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": json.dumps({"ok": True}), + } + ] + } + } + ] + } + ).encode("utf-8") + + def _fake_urlopen(http_request, timeout): + captured["body"] = json.loads(http_request.data.decode("utf-8")) + captured["timeout"] = timeout + return _FakeResponse() + + monkeypatch.setattr("llm.vertex_client.request.urlopen", _fake_urlopen) + + settings = load_settings() + result = VertexGeminiClient(settings).generate_json( + prompt="test prompt", + schema={"type": "object"}, + ) + + assert result == {"ok": True} + assert captured["body"]["contents"][0]["role"] == "user" diff --git a/ui/agent_visualization.py b/ui/agent_visualization.py index 31eabf2..d006881 100644 --- a/ui/agent_visualization.py +++ b/ui/agent_visualization.py @@ -1,181 +1,181 @@ -from __future__ import annotations - -import streamlit as st - -from core.models import SystemState -from ui.components import ( - agent_node_payload, - candidate_plan_dataframe, - candidate_score_breakdown_dataframe, - latest_reflection_snapshot, - pipeline_graph_source, - pipeline_node_labels, - pipeline_node_status_map, - reflection_timeline_dataframe, -) - - -def _render_reasoning_panel(state: SystemState) -> None: - labels = pipeline_node_labels() - options = list(labels) - default_index = options.index("planner") if "planner" in options else 0 - selected_node = st.selectbox( - "Select a node to inspect", - options, - index=default_index, - format_func=lambda key: labels[key], - ) - payload = agent_node_payload(state, selected_node) - - st.subheader(payload["title"]) - st.caption(payload["reasoning_source"]) - st.dataframe(payload["input_summary"], width="stretch", hide_index=True) - st.write(payload["summary"]) - - if payload["key_factors"]: - st.write("Key factors") - for item in payload["key_factors"]: - st.write(f"- {item}") - - if payload["recommended_actions"]: - st.write("Recommended / selected actions") - for item in payload["recommended_actions"]: - st.write(f"- {item}") - - if payload["tradeoffs"]: - st.write("Tradeoffs / concerns") - for item in payload["tradeoffs"]: - st.write(f"- {item}") - - if payload["llm_used"]: - st.success("AI reasoning was used for this node.") - elif payload["llm_error"]: - st.warning(f"AI unavailable. Fallback used. Error: {payload['llm_error']}") - else: - st.info("Deterministic or fallback reasoning is currently driving this node.") - - -def _render_candidate_plans(state: SystemState) -> None: - latest_decision = state.decision_logs[-1] if state.decision_logs else None - frame = candidate_plan_dataframe(state) - st.subheader("Planner Candidate Plans") - if frame.empty or latest_decision is None: - st.info("No candidate plans are available until the planner has executed.") - return - - st.dataframe(frame, width="stretch", hide_index=True) - evaluations = { - evaluation.strategy_label: evaluation - for evaluation in latest_decision.candidate_evaluations - } - ordered = ["cost_first", "balanced", "resilience_first"] - cols = st.columns(3) - for col, strategy_label in zip(cols, ordered): - evaluation = evaluations.get(strategy_label) - if evaluation is None: - continue - with col: - is_selected = state.latest_plan is not None and state.latest_plan.strategy_label == strategy_label - if is_selected: - st.success(f"Selected: {strategy_label}") - else: - st.info(f"Alternative: {strategy_label}") - st.metric("Score", f"{evaluation.score:.4f}") - st.metric("Service", f"{evaluation.projected_kpis.service_level:.1%}") - st.metric("Risk", f"{evaluation.projected_kpis.disruption_risk:.1%}") - st.metric("Recovery", f"{evaluation.projected_kpis.recovery_speed:.1%}") - st.caption( - f"Cost {evaluation.projected_kpis.total_cost:,.0f} | " - f"Approval required: {evaluation.approval_required}" - ) - st.write(evaluation.rationale) - st.dataframe( - candidate_score_breakdown_dataframe(evaluation), - width="stretch", - hide_index=True, - ) - - if latest_decision.selection_reason: - st.write("Why the final plan won") - st.success(latest_decision.selection_reason) - - -def _render_critic_and_reflection(state: SystemState) -> None: - latest_decision = state.decision_logs[-1] if state.decision_logs else None - critic_col, reflection_col = st.columns(2) - - with critic_col: - st.subheader("Critic Review") - if latest_decision is None: - st.info("Critic output appears after planning has executed.") - elif latest_decision.critic_summary: - st.write(latest_decision.critic_summary) - if latest_decision.critic_findings: - st.write("Blind spots / cautions") - for item in latest_decision.critic_findings: - st.write(f"- {item}") - elif latest_decision.critic_error: - st.warning(f"Critic unavailable. Error: {latest_decision.critic_error}") - else: - st.info("No critic feedback recorded for the latest decision.") - - with reflection_col: - st.subheader("Reflection / Learning") - latest_reflection = latest_reflection_snapshot(state) - if latest_reflection is None: - latest_run = state.scenario_history[-1] if state.scenario_history else None - if latest_run is not None: - st.info(f"Reflection status: {latest_run.reflection_status}") - else: - st.info("No reflection memory recorded yet.") - else: - st.write(latest_reflection["summary"]) - if latest_reflection["lessons"]: - st.write("Lessons") - for item in latest_reflection["lessons"]: - st.write(f"- {item}") - if latest_reflection["pattern_tags"]: - st.write("Pattern tags") - st.write(", ".join(latest_reflection["pattern_tags"])) - if latest_reflection["follow_up_checks"]: - st.write("Follow-up checks") - for item in latest_reflection["follow_up_checks"]: - st.write(f"- {item}") - if latest_reflection["llm_error"]: - st.caption(f"Fallback reflection used. Error: {latest_reflection['llm_error']}") - - st.subheader("Reflection History") - history = reflection_timeline_dataframe(state) - if history.empty: - st.info("No finalized reflection history yet.") - else: - st.dataframe(history, width="stretch", hide_index=True) - - -def render_page(state: SystemState) -> None: - st.title("AI Agents") - st.write( - "This view makes the multi-agent workflow visible: specialist interpretation, " - "candidate-plan generation, deterministic policy selection, approval gating, execution, and learning." - ) - - graph_col, detail_col = st.columns([1.4, 1.0]) - with graph_col: - st.subheader("Agent Pipeline") - st.graphviz_chart(pipeline_graph_source(state)) - status_rows = pipeline_node_status_map(state) - status_frame = [ - { - "node": status["label"], - "active": status["active"], - "tone": status["tone"], - "detail": status["detail"], - } - for status in status_rows.values() - ] - st.dataframe(status_frame, width="stretch", hide_index=True) - with detail_col: - _render_reasoning_panel(state) - - _render_candidate_plans(state) - _render_critic_and_reflection(state) +from __future__ import annotations + +import streamlit as st + +from core.models import SystemState +from ui.components import ( + agent_node_payload, + candidate_plan_dataframe, + candidate_score_breakdown_dataframe, + latest_reflection_snapshot, + pipeline_graph_source, + pipeline_node_labels, + pipeline_node_status_map, + reflection_timeline_dataframe, +) + + +def _render_reasoning_panel(state: SystemState) -> None: + labels = pipeline_node_labels() + options = list(labels) + default_index = options.index("planner") if "planner" in options else 0 + selected_node = st.selectbox( + "Select a node to inspect", + options, + index=default_index, + format_func=lambda key: labels[key], + ) + payload = agent_node_payload(state, selected_node) + + st.subheader(payload["title"]) + st.caption(payload["reasoning_source"]) + st.dataframe(payload["input_summary"], width="stretch", hide_index=True) + st.write(payload["summary"]) + + if payload["key_factors"]: + st.write("Key factors") + for item in payload["key_factors"]: + st.write(f"- {item}") + + if payload["recommended_actions"]: + st.write("Recommended / selected actions") + for item in payload["recommended_actions"]: + st.write(f"- {item}") + + if payload["tradeoffs"]: + st.write("Tradeoffs / concerns") + for item in payload["tradeoffs"]: + st.write(f"- {item}") + + if payload["llm_used"]: + st.success("AI reasoning was used for this node.") + elif payload["llm_error"]: + st.warning(f"AI unavailable. Fallback used. Error: {payload['llm_error']}") + else: + st.info("Deterministic or fallback reasoning is currently driving this node.") + + +def _render_candidate_plans(state: SystemState) -> None: + latest_decision = state.decision_logs[-1] if state.decision_logs else None + frame = candidate_plan_dataframe(state) + st.subheader("Planner Candidate Plans") + if frame.empty or latest_decision is None: + st.info("No candidate plans are available until the planner has executed.") + return + + st.dataframe(frame, width="stretch", hide_index=True) + evaluations = { + evaluation.strategy_label: evaluation + for evaluation in latest_decision.candidate_evaluations + } + ordered = ["cost_first", "balanced", "resilience_first"] + cols = st.columns(3) + for col, strategy_label in zip(cols, ordered): + evaluation = evaluations.get(strategy_label) + if evaluation is None: + continue + with col: + is_selected = state.latest_plan is not None and state.latest_plan.strategy_label == strategy_label + if is_selected: + st.success(f"Selected: {strategy_label}") + else: + st.info(f"Alternative: {strategy_label}") + st.metric("Score", f"{evaluation.score:.4f}") + st.metric("Service", f"{evaluation.projected_kpis.service_level:.1%}") + st.metric("Risk", f"{evaluation.projected_kpis.disruption_risk:.1%}") + st.metric("Recovery", f"{evaluation.projected_kpis.recovery_speed:.1%}") + st.caption( + f"Cost {evaluation.projected_kpis.total_cost:,.0f} | " + f"Approval required: {evaluation.approval_required}" + ) + st.write(evaluation.rationale) + st.dataframe( + candidate_score_breakdown_dataframe(evaluation), + width="stretch", + hide_index=True, + ) + + if latest_decision.selection_reason: + st.write("Why the final plan won") + st.success(latest_decision.selection_reason) + + +def _render_critic_and_reflection(state: SystemState) -> None: + latest_decision = state.decision_logs[-1] if state.decision_logs else None + critic_col, reflection_col = st.columns(2) + + with critic_col: + st.subheader("Critic Review") + if latest_decision is None: + st.info("Critic output appears after planning has executed.") + elif latest_decision.critic_summary: + st.write(latest_decision.critic_summary) + if latest_decision.critic_findings: + st.write("Blind spots / cautions") + for item in latest_decision.critic_findings: + st.write(f"- {item}") + elif latest_decision.critic_error: + st.warning(f"Critic unavailable. Error: {latest_decision.critic_error}") + else: + st.info("No critic feedback recorded for the latest decision.") + + with reflection_col: + st.subheader("Reflection / Learning") + latest_reflection = latest_reflection_snapshot(state) + if latest_reflection is None: + latest_run = state.scenario_history[-1] if state.scenario_history else None + if latest_run is not None: + st.info(f"Reflection status: {latest_run.reflection_status}") + else: + st.info("No reflection memory recorded yet.") + else: + st.write(latest_reflection["summary"]) + if latest_reflection["lessons"]: + st.write("Lessons") + for item in latest_reflection["lessons"]: + st.write(f"- {item}") + if latest_reflection["pattern_tags"]: + st.write("Pattern tags") + st.write(", ".join(latest_reflection["pattern_tags"])) + if latest_reflection["follow_up_checks"]: + st.write("Follow-up checks") + for item in latest_reflection["follow_up_checks"]: + st.write(f"- {item}") + if latest_reflection["llm_error"]: + st.caption(f"Fallback reflection used. Error: {latest_reflection['llm_error']}") + + st.subheader("Reflection History") + history = reflection_timeline_dataframe(state) + if history.empty: + st.info("No finalized reflection history yet.") + else: + st.dataframe(history, width="stretch", hide_index=True) + + +def render_page(state: SystemState) -> None: + st.title("AI Agents") + st.write( + "This view makes the multi-agent workflow visible: specialist interpretation, " + "candidate-plan generation, deterministic policy selection, approval gating, execution, and learning." + ) + + graph_col, detail_col = st.columns([1.4, 1.0]) + with graph_col: + st.subheader("Agent Pipeline") + st.graphviz_chart(pipeline_graph_source(state)) + status_rows = pipeline_node_status_map(state) + status_frame = [ + { + "node": status["label"], + "active": status["active"], + "tone": status["tone"], + "detail": status["detail"], + } + for status in status_rows.values() + ] + st.dataframe(status_frame, width="stretch", hide_index=True) + with detail_col: + _render_reasoning_panel(state) + + _render_candidate_plans(state) + _render_critic_and_reflection(state) diff --git a/ui/dashboard.py b/ui/dashboard.py index b935fd3..248a2e3 100644 --- a/ui/dashboard.py +++ b/ui/dashboard.py @@ -1,28 +1,28 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -import streamlit as st - -ROOT = Path(__file__).resolve().parent.parent -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from core.memory import SQLiteStore # noqa: E402 -from core.state import load_initial_state # noqa: E402 -from orchestrator.service import reset_runtime # noqa: E402 +from __future__ import annotations + +import sys +from pathlib import Path + +import streamlit as st + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from core.memory import SQLiteStore # noqa: E402 +from core.state import load_initial_state # noqa: E402 +from orchestrator.service import reset_runtime # noqa: E402 from ui import agent_visualization, decision_log, overview, scenarios, what_if # noqa: E402 -from ui.styles import inject_css # noqa: E402 - - -def _ensure_state() -> None: - if "app_state" not in st.session_state: - st.session_state["app_state"] = load_initial_state() - if "store" not in st.session_state: - st.session_state["store"] = SQLiteStore() - - +from ui.styles import inject_css # noqa: E402 + + +def _ensure_state() -> None: + if "app_state" not in st.session_state: + st.session_state["app_state"] = load_initial_state() + if "store" not in st.session_state: + st.session_state["store"] = SQLiteStore() + + PAGES = { "Overview": ":material/dashboard:", "AI Agents": ":material/hub:", @@ -30,63 +30,63 @@ def _ensure_state() -> None: "Decision Log": ":material/list_alt:", "What-if": ":material/auto_awesome:", } - - -def main() -> None: - st.set_page_config( - page_title="ChainCopilot — Supply Chain Control Tower", - page_icon=":material/link:", - layout="wide", - initial_sidebar_state="expanded", - ) - inject_css() - _ensure_state() - - state = st.session_state["app_state"] - store = st.session_state["store"] - - # ── Sidebar ─────────────────────────────────────────────────────────────── - with st.sidebar: - st.markdown( - '
:material/link: ChainCopilot
' - 'Supply Chain Control Tower
', - unsafe_allow_html=True, - ) - st.markdown("---") - - # Navigation + + +def main() -> None: + st.set_page_config( + page_title="ChainCopilot — Supply Chain Control Tower", + page_icon=":material/link:", + layout="wide", + initial_sidebar_state="expanded", + ) + inject_css() + _ensure_state() + + state = st.session_state["app_state"] + store = st.session_state["store"] + + # ── Sidebar ─────────────────────────────────────────────────────────────── + with st.sidebar: + st.markdown( + ':material/link: ChainCopilot
' + 'Supply Chain Control Tower
', + unsafe_allow_html=True, + ) + st.markdown("---") + + # Navigation for pg, icon in PAGES.items(): if st.button(f"{pg}", icon=icon, key=f"nav_{pg}", use_container_width=True): st.session_state["current_page"] = pg - - if "current_page" not in st.session_state: - st.session_state["current_page"] = "Overview" - - st.markdown("---") - - if st.button("Reset System", icon=":material/refresh:", use_container_width=True): - st.session_state["app_state"] = reset_runtime(store) - st.rerun() - - st.markdown( - 'v0.1.0 — Hackathon MVP
', - unsafe_allow_html=True, - ) - - # ── Page dispatch ───────────────────────────────────────────────────────── - page = st.session_state["current_page"] - - if page == "Overview": - st.session_state["app_state"] = overview.render_page(state, store) - elif page == "AI Agents": - agent_visualization.render_page(state) - elif page == "Scenarios": - st.session_state["app_state"] = scenarios.render_page(state, store) - elif page == "Decision Log": - decision_log.render_page(state, store) - elif page == "What-if": - what_if.render_page(state) - - -if __name__ == "__main__": - main() + + if "current_page" not in st.session_state: + st.session_state["current_page"] = "Overview" + + st.markdown("---") + + if st.button("Reset System", icon=":material/refresh:", use_container_width=True): + st.session_state["app_state"] = reset_runtime(store) + st.rerun() + + st.markdown( + 'v0.1.0 — Hackathon MVP
', + unsafe_allow_html=True, + ) + + # ── Page dispatch ───────────────────────────────────────────────────────── + page = st.session_state["current_page"] + + if page == "Overview": + st.session_state["app_state"] = overview.render_page(state, store) + elif page == "AI Agents": + agent_visualization.render_page(state) + elif page == "Scenarios": + st.session_state["app_state"] = scenarios.render_page(state, store) + elif page == "Decision Log": + decision_log.render_page(state, store) + elif page == "What-if": + what_if.render_page(state) + + +if __name__ == "__main__": + main() diff --git a/ui/decision_log.py b/ui/decision_log.py index 3203bca..51153db 100644 --- a/ui/decision_log.py +++ b/ui/decision_log.py @@ -1,11 +1,11 @@ -"""Decision Log page — timeline, score breakdown donut, learned memory tabs.""" -from __future__ import annotations - -import streamlit as st - -from core.enums import ApprovalStatus -from core.memory import SQLiteStore -from core.models import DecisionLog, SystemState +"""Decision Log page — timeline, score breakdown donut, learned memory tabs.""" +from __future__ import annotations + +import streamlit as st + +from core.enums import ApprovalStatus +from core.memory import SQLiteStore +from core.models import DecisionLog, SystemState from ui.components import ( approval_badge, memory_tables, @@ -15,131 +15,131 @@ styled_table, ) from ui.styles import section_header - - -def _render_decision_details(log: DecisionLog) -> None: - """Full explainability view for a single decision.""" - st.markdown(f"**Rationale:** {log.rationale}") - - col_a, col_b = st.columns(2) - with col_a: - section_header(":material/pie_chart: Score Breakdown") - render_score_breakdown(log) - with col_b: - section_header(":material/trending_up: KPI Before → After") - render_kpi_comparison_chart(log.before_kpis, log.after_kpis) - - if log.winning_factors: - section_header(":material/emoji_events: Winning Factors") - for f in log.winning_factors: - st.markdown(f"- {f}") - - if log.rejected_actions: - section_header(":material/block: Rejected Alternatives") - rj = rejected_actions_dataframe(log) - if not rj.empty: - styled_table(rj) - - -@st.dialog("Decision Details", width="large") -def _show_dialog(log: DecisionLog) -> None: - st.markdown( - f'{log.decision_id} {approval_badge(log)}Full audit trail of AI decisions with explainability and learned memory.
', - unsafe_allow_html=True, - ) - - # ── Latest decision ─────────────────────────────────────────────────────── - if state.decision_logs: - latest = state.decision_logs[-1] - section_header( - ":material/psychology: Latest Decision", - f"{latest.decision_id} · Plan {latest.plan_id}", - ) - st.markdown(approval_badge(latest), unsafe_allow_html=True) - - if state.pending_plan: - st.warning("⏳ A plan is **pending approval**. Resolve it in Overview before generating new plans.") - - _render_decision_details(latest) - else: - st.info("No decisions yet. Run the daily plan or a scenario to generate the first decision.") - - st.markdown("---") - - # ── Session timeline ────────────────────────────────────────────────────── - section_header(":material/history: Session Timeline", "All decisions from this session in chronological order") - if state.decision_logs: - st.markdown(_timeline_html(list(reversed(state.decision_logs))), unsafe_allow_html=True) - else: - st.info("Timeline will appear after the first plan is generated.") - - st.markdown("---") - - # ── Persisted logs ──────────────────────────────────────────────────────── - section_header(":material/save: Persisted Logs", "All decisions saved to persistent storage (SQLite)") - items = store.list_decision_logs() - if items: - persisted = [DecisionLog.model_validate(item) for item in items] - for log in persisted: - with st.container(border=True): - cc1, cc2, cc3, cc4 = st.columns([3, 2, 2, 1]) - cc1.markdown(f"`{log.decision_id}`") - cc2.caption(log.plan_id) - cc3.markdown(approval_badge(log), unsafe_allow_html=True) - if cc4.button("Details", key=f"dlg_{log.decision_id}", use_container_width=True): - _show_dialog(log) - else: - st.info("No persisted decision logs yet.") - - st.markdown("---") - - # ── Learned memory ──────────────────────────────────────────────────────── - section_header(":material/memory: Learned Memory", "Accumulated knowledge from past operations") - sup_df, route_df, scen_df = memory_tables(state) - - tab_sup, tab_route, tab_scen = st.tabs([":material/factory: Supplier Reliability", ":material/route: Route Priors", ":material/folder: Scenario History"]) - + + +def _render_decision_details(log: DecisionLog) -> None: + """Full explainability view for a single decision.""" + st.markdown(f"**Rationale:** {log.rationale}") + + col_a, col_b = st.columns(2) + with col_a: + section_header(":material/pie_chart: Score Breakdown") + render_score_breakdown(log) + with col_b: + section_header(":material/trending_up: KPI Before → After") + render_kpi_comparison_chart(log.before_kpis, log.after_kpis) + + if log.winning_factors: + section_header(":material/emoji_events: Winning Factors") + for f in log.winning_factors: + st.markdown(f"- {f}") + + if log.rejected_actions: + section_header(":material/block: Rejected Alternatives") + rj = rejected_actions_dataframe(log) + if not rj.empty: + styled_table(rj) + + +@st.dialog("Decision Details", width="large") +def _show_dialog(log: DecisionLog) -> None: + st.markdown( + f'{log.decision_id} {approval_badge(log)}Full audit trail of AI decisions with explainability and learned memory.
', + unsafe_allow_html=True, + ) + + # ── Latest decision ─────────────────────────────────────────────────────── + if state.decision_logs: + latest = state.decision_logs[-1] + section_header( + ":material/psychology: Latest Decision", + f"{latest.decision_id} · Plan {latest.plan_id}", + ) + st.markdown(approval_badge(latest), unsafe_allow_html=True) + + if state.pending_plan: + st.warning("⏳ A plan is **pending approval**. Resolve it in Overview before generating new plans.") + + _render_decision_details(latest) + else: + st.info("No decisions yet. Run the daily plan or a scenario to generate the first decision.") + + st.markdown("---") + + # ── Session timeline ────────────────────────────────────────────────────── + section_header(":material/history: Session Timeline", "All decisions from this session in chronological order") + if state.decision_logs: + st.markdown(_timeline_html(list(reversed(state.decision_logs))), unsafe_allow_html=True) + else: + st.info("Timeline will appear after the first plan is generated.") + + st.markdown("---") + + # ── Persisted logs ──────────────────────────────────────────────────────── + section_header(":material/save: Persisted Logs", "All decisions saved to persistent storage (SQLite)") + items = store.list_decision_logs() + if items: + persisted = [DecisionLog.model_validate(item) for item in items] + for log in persisted: + with st.container(border=True): + cc1, cc2, cc3, cc4 = st.columns([3, 2, 2, 1]) + cc1.markdown(f"`{log.decision_id}`") + cc2.caption(log.plan_id) + cc3.markdown(approval_badge(log), unsafe_allow_html=True) + if cc4.button("Details", key=f"dlg_{log.decision_id}", use_container_width=True): + _show_dialog(log) + else: + st.info("No persisted decision logs yet.") + + st.markdown("---") + + # ── Learned memory ──────────────────────────────────────────────────────── + section_header(":material/memory: Learned Memory", "Accumulated knowledge from past operations") + sup_df, route_df, scen_df = memory_tables(state) + + tab_sup, tab_route, tab_scen = st.tabs([":material/factory: Supplier Reliability", ":material/route: Route Priors", ":material/folder: Scenario History"]) + with tab_sup: if sup_df.empty: st.info("No supplier learning data yet.") @@ -151,9 +151,9 @@ def render_page(state: SystemState, store: SQLiteStore) -> None: st.info("No route learning data yet.") else: styled_table(route_df) - - with tab_scen: - if scen_df.empty: - st.info("No scenario history recorded yet.") - else: - styled_table(scen_df) + + with tab_scen: + if scen_df.empty: + st.info("No scenario history recorded yet.") + else: + styled_table(scen_df) diff --git a/ui/overview.py b/ui/overview.py index 060fe68..ab33e2c 100644 --- a/ui/overview.py +++ b/ui/overview.py @@ -1,138 +1,138 @@ -"""Overview page — main dashboard with KPIs, charts, and operational status.""" -from __future__ import annotations - -import streamlit as st - -from core.memory import SQLiteStore -from core.models import SystemState -from orchestrator.service import approve_pending_plan, request_safer_plan, run_daily_plan -from ui.components import ( - render_kpi_cards, - render_kpi_delta_cards, - render_mode_banner, - render_event_pills, - render_inventory_bar_chart, - render_supplier_chart, - render_route_chart, - render_kpi_comparison_chart, - render_kpi_radar, - render_demo_stepper, - selected_action_summary, - plan_actions_dataframe, - inventory_dataframe, - styled_table, -) -from ui.styles import section_header - - -def render_page(state: SystemState, store: SQLiteStore) -> SystemState: - updated = state - - # ── Page header ─────────────────────────────────────────────────────────── - header_left, header_right = st.columns([3, 1]) - with header_left: - st.markdown( - 'Real-time supply chain health and control tower status.
', - unsafe_allow_html=True, - ) - with header_right: - st.markdown('', unsafe_allow_html=True) - is_blocked = updated.pending_plan is not None - if st.button("Run Daily Plan", icon=":material/play_arrow:", type="primary", use_container_width=True, disabled=is_blocked): - updated = run_daily_plan(state, store) - st.session_state["app_state"] = updated - st.rerun() - - # ── Mode banner ─────────────────────────────────────────────────────────── - render_mode_banner(updated) - - # ── Pending approval block ──────────────────────────────────────────────── - if updated.pending_plan and updated.decision_logs: - decision_id = updated.decision_logs[-1].decision_id - st.markdown( - f'Real-time supply chain health and control tower status.
', + unsafe_allow_html=True, + ) + with header_right: + st.markdown('', unsafe_allow_html=True) + is_blocked = updated.pending_plan is not None + if st.button("Run Daily Plan", icon=":material/play_arrow:", type="primary", use_container_width=True, disabled=is_blocked): + updated = run_daily_plan(state, store) + st.session_state["app_state"] = updated + st.rerun() + + # ── Mode banner ─────────────────────────────────────────────────────────── + render_mode_banner(updated) + + # ── Pending approval block ──────────────────────────────────────────────── + if updated.pending_plan and updated.decision_logs: + decision_id = updated.decision_logs[-1].decision_id + st.markdown( + f'Trigger a scenario to test crisis detection, autonomous replanning, and the approval flow.
', - unsafe_allow_html=True, - ) - - if blocked and updated.decision_logs: - st.warning( - f"⏳ Scenario execution paused — resolve pending approval " - f"`{updated.decision_logs[-1].decision_id}` in **Overview** first." - ) - - st.caption("💡 Recommended: run the daily plan first, then trigger **Supplier Delay**.") - st.markdown("---") - - # ── Scenario card grid (2 × 2) ─────────────────────────────────────────── - names = list_scenarios() - pairs = [names[i:i+2] for i in range(0, len(names), 2)] - - for pair in pairs: - cols = st.columns(2, gap="medium") - for col, name in zip(cols, pair): - icon, title, desc = SCENARIO_META.get(name, ("build", name, "")) - disabled_cls = " blocked" if blocked else "" - - with col: - st.markdown( - f'Trigger a scenario to test crisis detection, autonomous replanning, and the approval flow.
', + unsafe_allow_html=True, + ) + + if blocked and updated.decision_logs: + st.warning( + f"⏳ Scenario execution paused — resolve pending approval " + f"`{updated.decision_logs[-1].decision_id}` in **Overview** first." + ) + + st.caption("💡 Recommended: run the daily plan first, then trigger **Supplier Delay**.") + st.markdown("---") + + # ── Scenario card grid (2 × 2) ─────────────────────────────────────────── + names = list_scenarios() + pairs = [names[i:i+2] for i in range(0, len(names), 2)] + + for pair in pairs: + cols = st.columns(2, gap="medium") + for col, name in zip(cols, pair): + icon, title, desc = SCENARIO_META.get(name, ("build", name, "")) + disabled_cls = " blocked" if blocked else "" + + with col: + st.markdown( + f'{subtitle}
' if subtitle else "" - st.markdown(f'{icon_html}{title}
{sub_html}', unsafe_allow_html=True) - -def badge(text: str, variant: str = "blue") -> str: - return f'{text}' +"""ChainCopilot Design System — Modern SaaS Aesthetic (Horizon UI Inspired).""" +from __future__ import annotations + +import streamlit as st + +_CSS = """ + +""" + +def inject_css() -> None: + st.markdown(_CSS, unsafe_allow_html=True) + +def section_header(title: str, subtitle: str = "", icon: str = "") -> None: + icon_html = f'' if icon else "" + sub_html = f'{subtitle}
' if subtitle else "" + st.markdown(f'{icon_html}{title}
{sub_html}', unsafe_allow_html=True) + +def badge(text: str, variant: str = "blue") -> str: + return f'{text}' diff --git a/ui/what_if.py b/ui/what_if.py index 52b7fe4..d4ecdcd 100644 --- a/ui/what_if.py +++ b/ui/what_if.py @@ -3,23 +3,23 @@ import pandas as pd import streamlit as st - -from core.models import SystemState -from core.state import clone_state -from orchestrator.graph import build_graph -from simulation.scenarios import get_scenario_events, list_scenarios -from ui.components import ( - render_kpi_cards, - render_kpi_delta_cards, - render_kpi_comparison_chart, - render_kpi_radar, - selected_action_summary, - plan_actions_dataframe, - styled_table, -) -from ui.styles import section_header - - + +from core.models import SystemState +from core.state import clone_state +from orchestrator.graph import build_graph +from simulation.scenarios import get_scenario_events, list_scenarios +from ui.components import ( + render_kpi_cards, + render_kpi_delta_cards, + render_kpi_comparison_chart, + render_kpi_radar, + selected_action_summary, + plan_actions_dataframe, + styled_table, +) +from ui.styles import section_header + + def _cost_comparison_chart(before_cost: float, after_cost: float) -> None: """Simple bar chart comparing total cost.""" chart = pd.DataFrame( @@ -27,67 +27,67 @@ def _cost_comparison_chart(before_cost: float, after_cost: float) -> None: index=["Current", "Simulated"], ) st.bar_chart(chart) - - -def render_page(state: SystemState) -> None: - st.markdown( - 'Explore how a disruption scenario would impact your network — without committing any changes.
', - unsafe_allow_html=True, - ) - - # ── Controls ────────────────────────────────────────────────────────────── - col_sel, col_btn = st.columns([3, 1]) - with col_sel: - scenario_name = st.selectbox("Choose scenario", list_scenarios(), index=0) - with col_btn: - st.markdown('', unsafe_allow_html=True) - run_sim = st.button("Simulate", icon=":material/science:", type="primary", use_container_width=True) - - st.markdown("---") - - # ── Current KPIs ────────────────────────────────────────────────────────── - section_header(":material/monitoring: Current Network KPIs") - render_kpi_cards(state) - - # ── Simulation ──────────────────────────────────────────────────────────── - if run_sim: - with st.spinner(f"Simulating **{scenario_name}**…"): - graph = build_graph() - simulated = clone_state(state) - for event in get_scenario_events(scenario_name): - simulated = graph.invoke(simulated, event) - - st.markdown("---") - section_header(f":material/science: Simulation Result — {scenario_name}", - "Compare current state against simulated outcome") - - # KPI delta cards - render_kpi_delta_cards(state.kpis, simulated.kpis) - - st.markdown("Explore how a disruption scenario would impact your network — without committing any changes.
', + unsafe_allow_html=True, + ) + + # ── Controls ────────────────────────────────────────────────────────────── + col_sel, col_btn = st.columns([3, 1]) + with col_sel: + scenario_name = st.selectbox("Choose scenario", list_scenarios(), index=0) + with col_btn: + st.markdown('', unsafe_allow_html=True) + run_sim = st.button("Simulate", icon=":material/science:", type="primary", use_container_width=True) + + st.markdown("---") + + # ── Current KPIs ────────────────────────────────────────────────────────── + section_header(":material/monitoring: Current Network KPIs") + render_kpi_cards(state) + + # ── Simulation ──────────────────────────────────────────────────────────── + if run_sim: + with st.spinner(f"Simulating **{scenario_name}**…"): + graph = build_graph() + simulated = clone_state(state) + for event in get_scenario_events(scenario_name): + simulated = graph.invoke(simulated, event) + + st.markdown("---") + section_header(f":material/science: Simulation Result — {scenario_name}", + "Compare current state against simulated outcome") + + # KPI delta cards + render_kpi_delta_cards(state.kpis, simulated.kpis) + + st.markdown("