From 60a7ec56c1f2477dad8ca187b126a94e6991c735 Mon Sep 17 00:00:00 2001 From: dinhdat07 Date: Tue, 14 Apr 2026 16:57:46 +0000 Subject: [PATCH 01/12] feat: add forward simulation plan evaluation --- agents/planner.py | 135 ++++++----- app_api/schemas.py | 50 +++- app_api/services.py | 62 +++-- core/models.py | 50 +++- orchestrator/graph.py | 62 +++-- orchestrator/service.py | 126 ++++++---- policies/explainability.py | 24 +- policies/guardrails.py | 2 + simulation/domain_rules.py | 383 +++++++++++++++++++++++++++++++ simulation/evaluator.py | 201 ++++++++++++++++ tests/test_forward_simulation.py | 83 +++++++ 11 files changed, 1026 insertions(+), 152 deletions(-) create mode 100644 simulation/domain_rules.py create mode 100644 simulation/evaluator.py create mode 100644 tests/test_forward_simulation.py diff --git a/agents/planner.py b/agents/planner.py index ccd290b..d0b40b6 100644 --- a/agents/planner.py +++ b/agents/planner.py @@ -2,7 +2,6 @@ from uuid import uuid4 -from actions.executor import simulate_actions from agents.base import BaseAgent from core.enums import ActionType, ApprovalStatus, PlanStatus from core.models import ( @@ -24,14 +23,15 @@ 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 -from policies.strategic_prompt import ( +from policies.guardrails import approval_required +from policies.scoring import compute_score +from policies.strategic_prompt import ( retrieve_relevant_cases, compute_memory_influence, - derive_strategy_rationale, - build_strategic_prompt, + derive_strategy_rationale, + build_strategic_prompt, ) +from simulation.evaluator import evaluate_candidate_plan logger = get_logger(__name__) @@ -338,35 +338,44 @@ def _evaluate_candidate( actions=actions, ) feasible = not violations - if not feasible: - return CandidatePlanEvaluation( - strategy_label=strategy_label, - action_ids=[action.action_id for action in actions], - score=0.0, + if not feasible: + return CandidatePlanEvaluation( + strategy_label=strategy_label, + action_ids=[action.action_id for action in actions], + score=0.0, score_breakdown={ "service_level": 0.0, "total_cost": 0.0, "disruption_risk": 0.0, - "recovery_speed": 0.0, - }, - projected_kpis=before_kpis.model_copy(deep=True), - feasible=False, - violations=violations, - mode_rationale=selected_mode_rationale, - approval_required=False, - approval_reason="not evaluated because the candidate plan is infeasible", - rationale=rationale, - llm_used=llm_used, - ) - simulated = simulate_actions(state, 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=state.mode, - baseline_cost=before_kpis.total_cost, - ) + "recovery_speed": 0.0, + }, + projected_kpis=before_kpis.model_copy(deep=True), + worst_case_kpis=before_kpis.model_copy(deep=True), + simulation_horizon_days=0, + projection_steps=[], + projected_state_summary=None, + projection_summary="candidate was not simulated because hard constraints failed", + feasible=False, + violations=violations, + mode_rationale=selected_mode_rationale, + approval_required=False, + approval_reason="not evaluated because the candidate plan is infeasible", + rationale=rationale, + llm_used=llm_used, + ) + projection = evaluate_candidate_plan( + state=state, + event=event, + actions=actions, + ) + score, breakdown = compute_score( + service_level=projection.worst_case_kpis.service_level, + total_cost=projection.projected_kpis.total_cost, + disruption_risk=projection.worst_case_kpis.disruption_risk, + recovery_speed=projection.projected_kpis.recovery_speed, + mode=state.mode, + baseline_cost=before_kpis.total_cost, + ) transient_plan = Plan( plan_id=f"plan_eval_{uuid4().hex[:8]}", mode=state.mode, @@ -375,22 +384,32 @@ def _evaluate_candidate( score=score, score_breakdown=breakdown, strategy_label=strategy_label, - generated_by="llm" if llm_used else "deterministic_fallback", - planner_reasoning=rationale, - status=PlanStatus.PROPOSED, - ) - needs_approval, reason = approval_required(transient_plan, before_kpis, simulated.kpis, event) + generated_by="llm" if llm_used else "deterministic_fallback", + planner_reasoning=rationale, + status=PlanStatus.PROPOSED, + ) + needs_approval, reason = approval_required( + transient_plan, + before_kpis, + projection.projected_kpis, + event, + ) 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, - violations=[], - mode_rationale=selected_mode_rationale, - approval_required=needs_approval, - approval_reason=reason if needs_approval else "no approval required: thresholds not triggered", + score_breakdown=breakdown, + projected_kpis=projection.projected_kpis, + worst_case_kpis=projection.worst_case_kpis, + simulation_horizon_days=projection.simulation_horizon_days, + projection_steps=projection.projection_steps, + projected_state_summary=projection.projected_state_summary, + projection_summary=projection.projection_summary, + feasible=True, + violations=[], + mode_rationale=selected_mode_rationale, + approval_required=needs_approval, + approval_reason=reason if needs_approval else "no approval required: thresholds not triggered", rationale=rationale, llm_used=llm_used, ) @@ -808,17 +827,22 @@ def _safe_hold_evaluation( mode=state.mode, baseline_cost=before_kpis.total_cost, ) - return CandidatePlanEvaluation( - strategy_label="safe_hold", - action_ids=[safe_action.action_id], - score=score, - score_breakdown=breakdown, - projected_kpis=before_kpis.model_copy(deep=True), - feasible=True, - violations=[], - mode_rationale=mode_rationale(state, event), - approval_required=False, - approval_reason="no approval required: retaining current state because all candidate plans were infeasible", + return CandidatePlanEvaluation( + strategy_label="safe_hold", + action_ids=[safe_action.action_id], + score=score, + score_breakdown=breakdown, + projected_kpis=before_kpis.model_copy(deep=True), + worst_case_kpis=before_kpis.model_copy(deep=True), + simulation_horizon_days=0, + projection_steps=[], + projected_state_summary=None, + projection_summary="the system retained the current network position because no feasible candidate plan was available", + feasible=True, + violations=[], + mode_rationale=mode_rationale(state, event), + approval_required=False, + approval_reason="no approval required: retaining current state because all candidate plans were infeasible", rationale=( f"no candidate plan satisfied hard constraints; retaining the current network state after excluding " f"{infeasible_count} infeasible candidate plan(s)" @@ -998,6 +1022,9 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: mode=state.mode, runner_up=runner_up, mode_rationale=selected_evaluation.mode_rationale, + projection_summary=selected_evaluation.projection_summary, + simulation_horizon_days=selected_evaluation.simulation_horizon_days, + worst_case_kpis=selected_evaluation.worst_case_kpis, ) final_plan = Plan( diff --git a/app_api/schemas.py b/app_api/schemas.py index 29dab33..f68cb11 100644 --- a/app_api/schemas.py +++ b/app_api/schemas.py @@ -117,22 +117,50 @@ class ActionView(BaseModel): parameters: dict[str, Any] = Field(default_factory=dict) -class ConstraintViolationView(BaseModel): - code: str - message: str - action_id: str | None = None - severity: str = "hard" - - +class ConstraintViolationView(BaseModel): + code: str + message: str + action_id: str | None = None + severity: str = "hard" + + +class ProjectionStepView(BaseModel): + step_index: int + label: str + kpis: KPIView + event_severity: float + inventory_at_risk: int + inventory_out_of_stock: int + backlog_units: int + inbound_units_due: int + summary: str = "" + key_changes: list[str] = Field(default_factory=list) + + +class ProjectedStateSummaryView(BaseModel): + inventory_at_risk: int + inventory_out_of_stock: int + backlog_units: int + inbound_units_scheduled: int + dominant_constraint: str = "" + event_severity_end: float = 0.0 + summary: str = "" + + 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 + worst_case_kpis: KPIView | None = None + simulation_horizon_days: int = 0 + projection_steps: list[ProjectionStepView] = Field(default_factory=list) + projected_state_summary: ProjectedStateSummaryView | None = None + projection_summary: str = "" + feasible: bool = True + violations: list[ConstraintViolationView] = Field(default_factory=list) + mode_rationale: str = "" approval_required: bool approval_reason: str rationale: str diff --git a/app_api/services.py b/app_api/services.py index f9e6e0a..79620f8 100644 --- a/app_api/services.py +++ b/app_api/services.py @@ -74,18 +74,20 @@ ExecutionRecordView, InventoryRowView, KPIView, - PendingApprovalView, - PlanView, - ReflectionView, - RunView, + PendingApprovalView, + PlanView, + ProjectedStateSummaryView, + ProjectionStepView, + ReflectionView, + RunView, RouteDecisionView, ScenarioOutcomeView, ServiceFlagsView, ServiceMetricsView, ServiceRuntimeView, SupplierRowView, - TraceView, -) + TraceView, +) def _error_detail( @@ -721,11 +723,30 @@ def action_view(action: Action) -> ActionView: ) -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: +def constraint_violation_view(item: ConstraintViolation) -> ConstraintViolationView: + return ConstraintViolationView(**item.model_dump(mode="json")) + + +def projection_step_view(item) -> ProjectionStepView: + return ProjectionStepView( + step_index=item.step_index, + label=item.label, + kpis=kpi_view(item.kpis), + event_severity=item.event_severity, + inventory_at_risk=item.inventory_at_risk, + inventory_out_of_stock=item.inventory_out_of_stock, + backlog_units=item.backlog_units, + inbound_units_due=item.inbound_units_due, + summary=item.summary, + key_changes=item.key_changes, + ) + + +def projected_state_summary_view(item) -> ProjectedStateSummaryView: + return ProjectedStateSummaryView(**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( @@ -775,11 +796,20 @@ def candidate_evaluation_view(item) -> 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, + score_breakdown=item.score_breakdown, + projected_kpis=kpi_view(item.projected_kpis), + worst_case_kpis=kpi_view(item.worst_case_kpis) if item.worst_case_kpis is not None else None, + simulation_horizon_days=item.simulation_horizon_days, + projection_steps=[projection_step_view(step) for step in item.projection_steps], + projected_state_summary=( + projected_state_summary_view(item.projected_state_summary) + if item.projected_state_summary is not None + else None + ), + projection_summary=item.projection_summary, + 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, diff --git a/core/models.py b/core/models.py index 0833eaa..00f9220 100644 --- a/core/models.py +++ b/core/models.py @@ -134,22 +134,50 @@ class CandidatePlanDraft(BaseModel): llm_used: bool = False -class ConstraintViolation(BaseModel): - code: ConstraintViolationCode - message: str - action_id: str | None = None - severity: str = "hard" - - +class ConstraintViolation(BaseModel): + code: ConstraintViolationCode + message: str + action_id: str | None = None + severity: str = "hard" + + +class ProjectionStep(BaseModel): + step_index: int = Field(ge=1) + label: str + kpis: KPIState + event_severity: float = Field(ge=0.0, le=1.0) + inventory_at_risk: int = Field(ge=0) + inventory_out_of_stock: int = Field(ge=0) + backlog_units: int = Field(ge=0) + inbound_units_due: int = Field(ge=0) + summary: str = "" + key_changes: list[str] = Field(default_factory=list) + + +class ProjectedStateSummary(BaseModel): + inventory_at_risk: int = Field(ge=0) + inventory_out_of_stock: int = Field(ge=0) + backlog_units: int = Field(ge=0) + inbound_units_scheduled: int = Field(ge=0) + dominant_constraint: str = "" + event_severity_end: float = Field(default=0.0, ge=0.0, le=1.0) + summary: str = "" + + 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 + worst_case_kpis: KPIState | None = None + simulation_horizon_days: int = Field(default=0, ge=0) + projection_steps: list[ProjectionStep] = Field(default_factory=list) + projected_state_summary: ProjectedStateSummary | None = None + projection_summary: str = "" + feasible: bool = True + violations: list[ConstraintViolation] = Field(default_factory=list) + mode_rationale: str = "" approval_required: bool = False approval_reason: str = "" rationale: str = "" diff --git a/orchestrator/graph.py b/orchestrator/graph.py index 0231e7f..6e3cd17 100644 --- a/orchestrator/graph.py +++ b/orchestrator/graph.py @@ -357,11 +357,11 @@ def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: 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 + 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 @@ -381,14 +381,50 @@ def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: "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, - }, - ) + "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, + "simulation_horizon_days": ( + latest_decision.candidate_evaluations[0].simulation_horizon_days + if latest_decision and latest_decision.candidate_evaluations + else 0 + ), + "projection_summary": ( + next( + ( + item.projection_summary + for item in latest_decision.candidate_evaluations + if item.strategy_label == state.latest_plan.strategy_label + ), + "", + ) + if latest_decision and state.latest_plan + else "" + ), + "projection_steps": ( + [ + { + "label": step.label, + "service_level": step.kpis.service_level, + "disruption_risk": step.kpis.disruption_risk, + "recovery_speed": step.kpis.recovery_speed, + "inventory_at_risk": step.inventory_at_risk, + "inventory_out_of_stock": step.inventory_out_of_stock, + "backlog_units": step.backlog_units, + "summary": step.summary, + } + for item in latest_decision.candidate_evaluations + if state.latest_plan and item.strategy_label == state.latest_plan.strategy_label + for step in item.projection_steps + ] + if latest_decision and state.latest_plan + else [] + ), + }, + ) self._record_route( state, "planner", diff --git a/orchestrator/service.py b/orchestrator/service.py index e8669e1..7fc8846 100644 --- a/orchestrator/service.py +++ b/orchestrator/service.py @@ -3,7 +3,6 @@ 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 ( @@ -24,9 +23,10 @@ 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 +from policies.guardrails import approval_required +from policies.scoring import compute_score +from simulation.evaluator import evaluate_candidate_plan +from simulation.learning import finalize_latest_scenario_run class PendingApprovalError(RuntimeError): @@ -169,27 +169,31 @@ def _build_safer_plan(state: SystemState, decision_log: DecisionLog) -> Plan: 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, + 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, - ) + ] + selected_actions = sorted(feasible_candidates, key=_safer_action_key)[:1] + target_mode = _mode_from_state(state) + projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=selected_actions, + ) + score, breakdown = compute_score( + service_level=projection.worst_case_kpis.service_level, + total_cost=projection.projected_kpis.total_cost, + disruption_risk=projection.worst_case_kpis.disruption_risk, + recovery_speed=projection.projected_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, @@ -199,7 +203,17 @@ def _build_safer_plan(state: SystemState, decision_log: DecisionLog) -> Plan: score_breakdown=breakdown, strategy_label="safer_alternative", generated_by="operator_safer_request", - planner_reasoning=build_plan_summary(decision_log.before_kpis, simulated.kpis, breakdown), + planner_reasoning=build_plan_summary( + decision_log.before_kpis, + projection.projected_kpis, + breakdown, + selected_actions=selected_actions, + strategy_label="safer_alternative", + mode=target_mode, + projection_summary=projection.projection_summary, + simulation_horizon_days=projection.simulation_horizon_days, + worst_case_kpis=projection.worst_case_kpis, + ), status=PlanStatus.PROPOSED, ) soft_violations = evaluate_soft_constraints(plan, state) @@ -208,7 +222,12 @@ def _build_safer_plan(state: SystemState, decision_log: DecisionLog) -> Plan: 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)) + needs_approval, reason = approval_required( + plan, + decision_log.before_kpis, + projection.projected_kpis, + _latest_event(state), + ) plan.approval_required = needs_approval plan.approval_reason = reason return plan @@ -226,12 +245,16 @@ def _build_alternative_plan( raise ValueError("selected alternative has no executable actions") target_mode = _mode_from_state(state) - simulated = simulate_actions(state, actions) + projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=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, + service_level=projection.worst_case_kpis.service_level, + total_cost=projection.projected_kpis.total_cost, + disruption_risk=projection.worst_case_kpis.disruption_risk, + recovery_speed=projection.projected_kpis.recovery_speed, mode=target_mode, baseline_cost=decision_log.before_kpis.total_cost, ) @@ -248,12 +271,15 @@ def _build_alternative_plan( planner_reasoning=evaluation.rationale or build_plan_summary( decision_log.before_kpis, - simulated.kpis, + projection.projected_kpis, breakdown, selected_actions=actions, strategy_label=evaluation.strategy_label, mode=target_mode, mode_rationale=evaluation.mode_rationale, + projection_summary=evaluation.projection_summary, + simulation_horizon_days=evaluation.simulation_horizon_days, + worst_case_kpis=evaluation.worst_case_kpis, ), status=PlanStatus.PROPOSED, feasible=evaluation.feasible, @@ -270,7 +296,7 @@ def _build_alternative_plan( needs_approval, reason = approval_required( plan, decision_log.before_kpis, - simulated.kpis, + projection.projected_kpis, _latest_event(state), ) plan.approval_required = True @@ -373,21 +399,25 @@ def request_safer_plan( 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, - ) + safer_projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=safer_plan.actions, + ) + winning_factors = build_winning_factors( + safer_plan.actions, + previous_decision.before_kpis, + safer_projection.projected_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], + 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=safer_projection.projected_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, @@ -467,11 +497,15 @@ def select_pending_alternative_plan( decision_log=previous_decision, evaluation=evaluation, ) - simulated = simulate_actions(state, alternative_plan.actions) + alternative_projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=alternative_plan.actions, + ) winning_factors = build_winning_factors( alternative_plan.actions, previous_decision.before_kpis, - simulated.kpis, + alternative_projection.projected_kpis, alternative_plan.score_breakdown, ) rejection_reasons = explain_rejected_actions( @@ -488,7 +522,7 @@ def select_pending_alternative_plan( 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, + after_kpis=alternative_projection.projected_kpis, selected_actions=[action.action_id for action in alternative_plan.actions], rejected_actions=rejection_reasons, score_breakdown=alternative_plan.score_breakdown, diff --git a/policies/explainability.py b/policies/explainability.py index 73e43b8..f10b51f 100644 --- a/policies/explainability.py +++ b/policies/explainability.py @@ -42,6 +42,9 @@ def build_plan_summary( mode: Mode | str = Mode.NORMAL, runner_up: CandidatePlanEvaluation | None = None, mode_rationale: str = "", + projection_summary: str = "", + simulation_horizon_days: int | None = None, + worst_case_kpis: KPIState | None = None, ) -> str: selected_actions = selected_actions or [] action_text = ( @@ -57,13 +60,25 @@ def build_plan_summary( ) mode_text = f"This recommendation is optimized for {_mode_label(mode)}." rationale_text = f" {mode_rationale}" if mode_rationale else "" + horizon_text = ( + f" Over the next {simulation_horizon_days} day(s), " + if simulation_horizon_days and simulation_horizon_days > 0 + else " " + ) + worst_case_text = "" + if worst_case_kpis is not None: + worst_case_text = ( + f"Worst projected service reaches {_pct(worst_case_kpis.service_level)} and " + f"worst disruption risk reaches {_pct(worst_case_kpis.disruption_risk)}." + ) + projection_text = f" {projection_summary}" if projection_summary 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}" + f"{comparison}{horizon_text}{worst_case_text}{projection_text} {mode_text}{rationale_text}" ).strip() @@ -191,6 +206,8 @@ def build_critic_review( 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 is not None and selected.projection_summary: + summary += f" {selected.projection_summary}" if selected_plan.approval_required: summary += " Human approval is prudent because the expected impact crosses the approval guardrail." @@ -219,6 +236,11 @@ def build_critic_review( 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 is not None and selected.projected_state_summary is not None: + findings.append( + "Projected end-state: " + f"{selected.projected_state_summary.summary}" + ) 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." diff --git a/policies/guardrails.py b/policies/guardrails.py index 751a831..7f8b527 100644 --- a/policies/guardrails.py +++ b/policies/guardrails.py @@ -12,6 +12,8 @@ def approval_required( return False, "" if event and event.severity >= 0.75: return True, "Event severity exceeds approval threshold" + if event and event.severity >= 0.65 and len(plan.actions) >= 3: + return True, "Disruption response package exceeds automatic execution 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: diff --git a/simulation/domain_rules.py b/simulation/domain_rules.py new file mode 100644 index 0000000..fe45433 --- /dev/null +++ b/simulation/domain_rules.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from core.enums import ActionType, EventType +from core.models import Action, Event, InventoryItem, SystemState +from core.state import recompute_kpis + + +SIMULATION_HORIZON_DAYS = 3 + + +@dataclass +class SimulationContext: + working_state: SystemState + baseline_forecast_by_sku: dict[str, int] + backlog_by_sku: dict[str, int] = field(default_factory=dict) + inbound_schedule: dict[int, dict[str, int]] = field(default_factory=dict) + original_incoming_by_sku: dict[str, int] = field(default_factory=dict) + event_severity: float = 0.0 + mitigation_count: int = 0 + + +def _supplier_for_item(state: SystemState, item: InventoryItem): + return state.suppliers.get(f"{item.preferred_supplier_id}_{item.sku}") or state.suppliers.get( + item.preferred_supplier_id + ) + + +def _route_for_item(state: SystemState, item: InventoryItem): + return state.routes.get(item.preferred_route_id) + + +def _event_type(event: Event | None) -> EventType | None: + return event.type if event is not None else None + + +def _event_penalty_days( + *, + event: Event | None, + supplier_id: str | None = None, + route_id: str | None = None, +) -> int: + if event is None: + return 0 + severity_penalty = 2 if event.severity >= 0.85 else 1 if event.severity >= 0.55 else 0 + if event.type == EventType.SUPPLIER_DELAY: + affected_supplier = str(event.payload.get("supplier_id") or "") + if supplier_id and supplier_id == affected_supplier: + return max(1, severity_penalty) + if event.type == EventType.ROUTE_BLOCKAGE: + affected_route = str(event.payload.get("route_id") or "") + if route_id and route_id == affected_route: + return max(1, severity_penalty) + if event.type == EventType.COMPOUND: + supplier_match = supplier_id and supplier_id in { + str(value) for value in event.payload.get("supplier_ids", []) + } + route_match = route_id and route_id in { + str(value) for value in event.payload.get("route_ids", []) + } + if supplier_match or route_match: + return max(1, severity_penalty) + return 0 + + +def _route_delay_days(state: SystemState, item: InventoryItem, event: Event | None) -> int: + route = _route_for_item(state, item) + if route is None: + return 0 + blocked_penalty = 2 if route.status == "blocked" else 0 + risk_penalty = 1 if route.risk_score >= 0.65 else 0 + return blocked_penalty + risk_penalty + _event_penalty_days( + event=event, + route_id=route.route_id, + ) + + +def _arrival_step_for_item( + state: SystemState, + item: InventoryItem, + *, + event: Event | None, + expedited: bool = False, +) -> int: + supplier = _supplier_for_item(state, item) + route = _route_for_item(state, item) + lead_time = max(int(supplier.lead_time_days), 1) if supplier is not None else 1 + transit = max(int(route.transit_days), 0) if route is not None else 0 + route_penalty = _route_delay_days(state, item, event) + supplier_penalty = _event_penalty_days( + event=event, + supplier_id=supplier.supplier_id if supplier is not None else None, + ) + raw_days = lead_time + transit + route_penalty + supplier_penalty + if expedited: + raw_days = max(raw_days - 1, 1) + return max(1, min(SIMULATION_HORIZON_DAYS, raw_days)) + + +def initialize_context(state: SystemState, event: Event | None) -> SimulationContext: + baseline_forecast = { + sku: max(int(item.forecast_qty), 0) for sku, item in state.inventory.items() + } + context = SimulationContext( + working_state=state, + baseline_forecast_by_sku=baseline_forecast, + original_incoming_by_sku={ + sku: max(int(item.incoming_qty), 0) for sku, item in state.inventory.items() + }, + event_severity=event.severity if event is not None else 0.0, + ) + for sku, item in state.inventory.items(): + inbound_qty = max(int(item.incoming_qty), 0) + if inbound_qty <= 0: + item.incoming_qty = 0 + continue + arrival_step = _arrival_step_for_item(state, item, event=event) + context.inbound_schedule.setdefault(arrival_step, {}) + context.inbound_schedule[arrival_step][sku] = ( + context.inbound_schedule[arrival_step].get(sku, 0) + inbound_qty + ) + item.incoming_qty = 0 + _refresh_future_incoming(context) + state.kpis = recompute_kpis(state) + return context + + +def apply_candidate_actions( + context: SimulationContext, + actions: list[Action], + *, + event: Event | None, +) -> None: + state = context.working_state + for action in actions: + if action.action_type == ActionType.NO_OP: + continue + if action.action_type == ActionType.SWITCH_SUPPLIER: + item = state.inventory.get(action.target_id) + if item is None: + continue + supplier_id = str(action.parameters.get("supplier_id") or "") + supplier = state.suppliers.get(f"{supplier_id}_{item.sku}") or state.suppliers.get( + supplier_id + ) + if supplier is None: + continue + item.preferred_supplier_id = supplier_id + item.unit_cost = supplier.unit_cost + elif action.action_type == ActionType.REROUTE: + item = state.inventory.get(action.target_id) + route_id = str(action.parameters.get("route_id") or "") + if item is None or not route_id: + continue + item.preferred_route_id = route_id + elif action.action_type == ActionType.REORDER: + item = state.inventory.get(action.target_id) + if item is None: + continue + quantity = max(int(action.parameters.get("quantity", 0)), 0) + if quantity <= 0: + continue + supplier = _supplier_for_item(state, item) + capacity = max(int(supplier.capacity), 0) if supplier is not None else quantity + scheduled_qty = min(quantity, capacity) if capacity > 0 else quantity + arrival_step = _arrival_step_for_item(state, item, event=event) + context.inbound_schedule.setdefault(arrival_step, {}) + context.inbound_schedule[arrival_step][item.sku] = ( + context.inbound_schedule[arrival_step].get(item.sku, 0) + scheduled_qty + ) + elif action.action_type == ActionType.REBALANCE: + item = state.inventory.get(action.target_id) + if item is None: + continue + quantity = max(int(action.parameters.get("quantity", 0)) // 2, 0) + if quantity <= 0: + continue + arrival_step = 1 + context.inbound_schedule.setdefault(arrival_step, {}) + context.inbound_schedule[arrival_step][item.sku] = ( + context.inbound_schedule[arrival_step].get(item.sku, 0) + quantity + ) + state.extra_cost += max(action.estimated_cost_delta, 0.0) + context.mitigation_count += 1 + _refresh_future_incoming(context) + state.kpis = recompute_kpis(state) + + +def advance_demand(context: SimulationContext, *, step_index: int, event: Event | None) -> None: + state = context.working_state + event_type = _event_type(event) + spike_multiplier = float(event.payload.get("multiplier", 1.0)) if event is not None else 1.0 + spike_sku = str(event.payload.get("sku") or "") if event is not None else "" + if event_type != EventType.DEMAND_SPIKE or not spike_sku: + for sku, item in state.inventory.items(): + item.forecast_qty = context.baseline_forecast_by_sku.get(sku, item.forecast_qty) + return + + decay_ratio = 0.55 ** max(step_index - 1, 0) + active_multiplier = 1.0 + max(spike_multiplier - 1.0, 0.0) * decay_ratio + for sku, item in state.inventory.items(): + base = context.baseline_forecast_by_sku.get(sku, item.forecast_qty) + if sku == spike_sku: + item.forecast_qty = max(int(round(base * active_multiplier)), 0) + else: + item.forecast_qty = base + + +def advance_supplier(context: SimulationContext, *, step_index: int, event: Event | None) -> list[str]: + state = context.working_state + observations: list[str] = [] + if event is None: + return observations + if event.type not in {EventType.SUPPLIER_DELAY, EventType.COMPOUND}: + return observations + for item in state.inventory.values(): + supplier = _supplier_for_item(state, item) + if supplier is None: + continue + if supplier.reliability < 0.85 and step_index == 1: + observations.append( + f"{item.sku} still depends on supplier reliability of {supplier.reliability:.0%}" + ) + return observations + + +def advance_logistics(context: SimulationContext, *, step_index: int, event: Event | None) -> list[str]: + state = context.working_state + observations: list[str] = [] + if event is None: + return observations + if event.type not in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND}: + return observations + for item in state.inventory.values(): + route = _route_for_item(state, item) + if route is None: + continue + if route.status == "blocked": + observations.append(f"{item.sku} remains exposed to blocked route {route.route_id}") + elif route.risk_score >= 0.6 and step_index == 1: + observations.append( + f"{item.sku} still uses a high-risk lane ({route.route_id}, risk {route.risk_score:.0%})" + ) + return observations + + +def advance_inventory(context: SimulationContext, *, step_index: int) -> dict[str, int]: + state = context.working_state + inbound_due = context.inbound_schedule.get(step_index, {}) + total_inbound = 0 + total_backlog = 0 + at_risk = 0 + out_of_stock = 0 + + for sku, item in state.inventory.items(): + arriving = max(int(inbound_due.get(sku, 0)), 0) + if arriving: + item.on_hand += arriving + total_inbound += arriving + + backlog = max(int(context.backlog_by_sku.get(sku, 0)), 0) + demand = max(int(item.forecast_qty), 0) + required = backlog + demand + served = min(max(int(item.on_hand), 0), required) + item.on_hand = max(int(item.on_hand) - served, 0) + remaining_backlog = max(required - served, 0) + context.backlog_by_sku[sku] = remaining_backlog + total_backlog += remaining_backlog + + if item.on_hand <= 0 and remaining_backlog > 0: + out_of_stock += 1 + elif item.on_hand + item.incoming_qty <= max(item.safety_stock, 0): + at_risk += 1 + elif item.on_hand + item.incoming_qty <= max(item.reorder_point, 0): + at_risk += 1 + + _refresh_future_incoming(context) + state.kpis = recompute_kpis(state) + return { + "inbound_units_due": total_inbound, + "backlog_units": total_backlog, + "inventory_at_risk": at_risk, + "inventory_out_of_stock": out_of_stock, + } + + +def advance_risk( + context: SimulationContext, + *, + event: Event | None, + step_metrics: dict[str, int], +) -> float: + state = context.working_state + if event is None: + context.event_severity = 0.0 + return context.event_severity + + unresolved_pressure = 0.0 + if state.inventory: + unresolved_pressure = step_metrics["inventory_out_of_stock"] / max(len(state.inventory), 1) + improvement = min(context.mitigation_count * 0.03, 0.15) + decay = 0.08 + improvement + escalation = 0.05 if unresolved_pressure >= 0.08 else 0.0 + context.event_severity = max( + 0.0, + min(1.0, context.event_severity - decay + escalation), + ) + if state.active_events: + state.active_events[-1].severity = round(context.event_severity, 4) + state.kpis = recompute_kpis( + state, + recovery_speed=_projected_recovery_speed(context, step_metrics), + ) + return context.event_severity + + +def _projected_recovery_speed( + context: SimulationContext, + step_metrics: dict[str, int], +) -> float: + state = context.working_state + at_risk_ratio = step_metrics["inventory_at_risk"] / max(len(state.inventory), 1) + backlog_ratio = step_metrics["backlog_units"] / max( + sum(max(item.forecast_qty, 0) for item in state.inventory.values()), + 1, + ) + base = 0.9 if state.mode.value == "normal" else 0.65 + improvement = min(context.mitigation_count * 0.015, 0.12) + penalty = min((at_risk_ratio * 0.25) + (backlog_ratio * 0.35), 0.35) + return max(0.05, min(0.99, base + improvement - penalty)) + + +def dominant_constraint(context: SimulationContext, step_metrics: dict[str, int]) -> str: + state = context.working_state + if step_metrics["inventory_out_of_stock"] > 0: + return "inventory shortfall" + if step_metrics["backlog_units"] > 0: + return "order backlog" + if any( + (_route_for_item(state, item) is not None and _route_for_item(state, item).status == "blocked") + for item in state.inventory.values() + ): + return "route disruption" + if any( + (_supplier_for_item(state, item) is not None and _supplier_for_item(state, item).reliability < 0.85) + for item in state.inventory.values() + ): + return "supplier reliability" + return "demand pressure" + + +def step_summary( + context: SimulationContext, + *, + step_index: int, + step_metrics: dict[str, int], + change_notes: list[str], +) -> str: + parts = [ + f"T+{step_index} projects service at {context.working_state.kpis.service_level:.0%}", + f"risk at {context.working_state.kpis.disruption_risk:.0%}", + ] + if step_metrics["inventory_out_of_stock"] > 0: + parts.append(f"{step_metrics['inventory_out_of_stock']} SKU(s) remain out of stock") + elif step_metrics["inventory_at_risk"] > 0: + parts.append(f"{step_metrics['inventory_at_risk']} SKU(s) remain at risk") + if step_metrics["inbound_units_due"] > 0: + parts.append(f"{step_metrics['inbound_units_due']} inbound units arrive") + if step_metrics["backlog_units"] > 0: + parts.append(f"backlog holds at {step_metrics['backlog_units']} units") + if change_notes: + parts.append(change_notes[0]) + return ", ".join(parts) + + +def _refresh_future_incoming(context: SimulationContext) -> None: + state = context.working_state + for sku, item in state.inventory.items(): + remaining = 0 + for scheduled in context.inbound_schedule.values(): + remaining += max(int(scheduled.get(sku, 0)), 0) + item.incoming_qty = remaining diff --git a/simulation/evaluator.py b/simulation/evaluator.py new file mode 100644 index 0000000..3feb321 --- /dev/null +++ b/simulation/evaluator.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from core.enums import EventType +from core.models import ( + Action, + Event, + KPIState, + ProjectedStateSummary, + ProjectionStep, + SystemState, +) +from core.state import clone_state +from simulation.domain_rules import ( + SIMULATION_HORIZON_DAYS, + SimulationContext, + advance_demand, + advance_inventory, + advance_logistics, + advance_risk, + advance_supplier, + apply_candidate_actions, + dominant_constraint, + initialize_context, + step_summary, +) + + +@dataclass +class CandidateProjection: + projected_state: SystemState + projected_kpis: KPIState + worst_case_kpis: KPIState + projection_steps: list[ProjectionStep] + projected_state_summary: ProjectedStateSummary + projection_summary: str + simulation_horizon_days: int = SIMULATION_HORIZON_DAYS + + +def evaluate_candidate_plan( + *, + state: SystemState, + event: Event | None, + actions: list[Action], + horizon_days: int = SIMULATION_HORIZON_DAYS, +) -> CandidateProjection: + projected_state = clone_state(state) + projected_event = event.model_copy(deep=True) if event is not None else None + if projected_event is not None and projected_state.active_events: + projected_state.active_events[-1] = projected_event + context = initialize_context(projected_state, projected_event) + apply_candidate_actions(context, actions, event=projected_event) + + projection_steps: list[ProjectionStep] = [] + service_values: list[float] = [projected_state.kpis.service_level] + cost_values: list[float] = [projected_state.kpis.total_cost] + risk_values: list[float] = [projected_state.kpis.disruption_risk] + stockout_values: list[float] = [projected_state.kpis.stockout_risk] + final_metrics: dict[str, int] = { + "inventory_at_risk": 0, + "inventory_out_of_stock": 0, + "backlog_units": 0, + "inbound_units_due": 0, + } + + for step_index in range(1, max(horizon_days, 1) + 1): + advance_demand(context, step_index=step_index, event=projected_event) + supplier_notes = advance_supplier(context, step_index=step_index, event=projected_event) + route_notes = advance_logistics(context, step_index=step_index, event=projected_event) + step_metrics = advance_inventory(context, step_index=step_index) + final_metrics = step_metrics + severity = advance_risk(context, event=projected_event, step_metrics=step_metrics) + step_kpis = projected_state.kpis.model_copy(deep=True) + service_values.append(step_kpis.service_level) + cost_values.append(step_kpis.total_cost) + risk_values.append(step_kpis.disruption_risk) + stockout_values.append(step_kpis.stockout_risk) + + key_changes = supplier_notes[:2] + route_notes[:2] + if step_metrics["inbound_units_due"] > 0: + key_changes.append(f"{step_metrics['inbound_units_due']} units landed into the network") + if step_metrics["backlog_units"] > 0: + key_changes.append(f"backlog stands at {step_metrics['backlog_units']} units") + + projection_steps.append( + ProjectionStep( + step_index=step_index, + label=f"T+{step_index}", + kpis=step_kpis, + event_severity=round(severity, 4), + inventory_at_risk=step_metrics["inventory_at_risk"], + inventory_out_of_stock=step_metrics["inventory_out_of_stock"], + backlog_units=step_metrics["backlog_units"], + inbound_units_due=step_metrics["inbound_units_due"], + summary=step_summary( + context, + step_index=step_index, + step_metrics=step_metrics, + change_notes=key_changes, + ), + key_changes=key_changes[:4], + ) + ) + + projected_kpis = projected_state.kpis.model_copy(deep=True) + worst_case_kpis = KPIState( + service_level=round(min(service_values), 4), + total_cost=round(max(cost_values), 2), + disruption_risk=round(max(risk_values), 4), + recovery_speed=projected_kpis.recovery_speed, + stockout_risk=round(max(stockout_values), 4), + decision_latency_ms=projected_kpis.decision_latency_ms, + ) + summary = _projected_state_summary( + context, + final_metrics=final_metrics, + projected_event=projected_event, + ) + projection_summary = _projection_summary( + projected_steps=projection_steps, + projected_kpis=projected_kpis, + worst_case_kpis=worst_case_kpis, + summary=summary, + event=projected_event, + ) + return CandidateProjection( + projected_state=projected_state, + projected_kpis=projected_kpis, + worst_case_kpis=worst_case_kpis, + projection_steps=projection_steps, + projected_state_summary=summary, + projection_summary=projection_summary, + simulation_horizon_days=max(horizon_days, 1), + ) + + +def _projected_state_summary( + context: SimulationContext, + *, + final_metrics: dict[str, int], + projected_event: Event | None, +) -> ProjectedStateSummary: + total_scheduled = sum( + max(int(qty), 0) + for schedule in context.inbound_schedule.values() + for qty in schedule.values() + ) + constraint = dominant_constraint(context, final_metrics) + event_severity_end = context.event_severity if projected_event is not None else 0.0 + summary = ( + f"By T+{SIMULATION_HORIZON_DAYS}, the network projects " + f"{final_metrics['inventory_at_risk']} at-risk SKU(s), " + f"{final_metrics['inventory_out_of_stock']} out-of-stock SKU(s), " + f"and {final_metrics['backlog_units']} backlog units. " + f"The dominant constraint is {constraint}." + ) + return ProjectedStateSummary( + inventory_at_risk=final_metrics["inventory_at_risk"], + inventory_out_of_stock=final_metrics["inventory_out_of_stock"], + backlog_units=final_metrics["backlog_units"], + inbound_units_scheduled=total_scheduled, + dominant_constraint=constraint, + event_severity_end=round(event_severity_end, 4), + summary=summary, + ) + + +def _projection_summary( + *, + projected_steps: list[ProjectionStep], + projected_kpis: KPIState, + worst_case_kpis: KPIState, + summary: ProjectedStateSummary, + event: Event | None, +) -> str: + if not projected_steps: + return "No forward projection was generated." + worst_service_step = min( + projected_steps, + key=lambda item: item.kpis.service_level, + ) + highest_risk_step = max( + projected_steps, + key=lambda item: item.kpis.disruption_risk, + ) + event_text = "" + if event is not None: + if event.type == EventType.DEMAND_SPIKE: + event_text = " Demand pressure is expected to normalize gradually." + elif event.type in {EventType.SUPPLIER_DELAY, EventType.ROUTE_BLOCKAGE, EventType.COMPOUND}: + event_text = " Mitigation quality determines whether disruption severity decays fast enough." + return ( + f"Over the next {len(projected_steps)} day(s), service bottoms at " + f"{worst_case_kpis.service_level:.0%} on {worst_service_step.label} while disruption risk peaks at " + f"{worst_case_kpis.disruption_risk:.0%} on {highest_risk_step.label}. " + f"By {projected_steps[-1].label}, the plan projects service at {projected_kpis.service_level:.0%}, " + f"risk at {projected_kpis.disruption_risk:.0%}, and recovery speed at {projected_kpis.recovery_speed:.0%}. " + f"{summary.summary}{event_text}" + ) + diff --git a/tests/test_forward_simulation.py b/tests/test_forward_simulation.py new file mode 100644 index 0000000..941e3a7 --- /dev/null +++ b/tests/test_forward_simulation.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from core.enums import ActionType, EventType +from core.models import Action +from core.state import load_initial_state +from simulation.evaluator import evaluate_candidate_plan +from simulation.scenarios import build_event + + +def _stressed_sku_state(): + state = load_initial_state() + sku = next(iter(state.inventory)) + item = state.inventory[sku] + item.on_hand = 5 + item.incoming_qty = 0 + item.forecast_qty = max(item.forecast_qty, 80) + item.reorder_point = max(item.reorder_point, 90) + item.safety_stock = max(item.safety_stock, 30) + supplier = state.suppliers.get(f"{item.preferred_supplier_id}_{sku}") + if supplier is not None: + supplier.lead_time_days = 1 + route = state.routes.get(item.preferred_route_id) + if route is not None: + route.transit_days = 0 + route.risk_score = min(route.risk_score, 0.2) + return state, sku + + +def test_forward_projection_returns_three_timeline_steps() -> None: + state, sku = _stressed_sku_state() + action = Action( + action_id=f"act_reorder_{sku}", + action_type=ActionType.REORDER, + target_id=sku, + parameters={"quantity": 160}, + estimated_cost_delta=1200.0, + estimated_service_delta=0.12, + estimated_risk_delta=-0.08, + estimated_recovery_hours=24.0, + reason="expedite replenishment", + priority=0.9, + ) + + projection = evaluate_candidate_plan(state=state, event=None, actions=[action]) + + assert projection.simulation_horizon_days == 3 + assert [step.label for step in projection.projection_steps] == ["T+1", "T+2", "T+3"] + assert projection.projected_state_summary is not None + assert projection.projection_summary + + +def test_reorder_improves_projected_outcome_and_mitigation_lowers_severity() -> None: + state, sku = _stressed_sku_state() + event = build_event( + event_id="evt_test_demand_spike", + event_type=EventType.DEMAND_SPIKE, + severity=0.72, + payload={"sku": sku, "multiplier": 1.8}, + entity_ids=[sku], + ) + no_op_projection = evaluate_candidate_plan(state=state, event=event, actions=[]) + reorder_projection = evaluate_candidate_plan( + state=state, + event=event, + actions=[ + Action( + action_id=f"act_reorder_{sku}", + action_type=ActionType.REORDER, + target_id=sku, + parameters={"quantity": 180}, + estimated_cost_delta=1400.0, + estimated_service_delta=0.15, + estimated_risk_delta=-0.1, + estimated_recovery_hours=24.0, + reason="cover demand spike", + priority=0.95, + ) + ], + ) + + assert reorder_projection.projected_kpis.service_level >= no_op_projection.projected_kpis.service_level + assert reorder_projection.projected_state_summary.backlog_units <= no_op_projection.projected_state_summary.backlog_units + assert reorder_projection.projected_state_summary.event_severity_end <= no_op_projection.projected_state_summary.event_severity_end From 6fdcd27702dffc2dc62e06fbd1c05c2995c267f9 Mon Sep 17 00:00:00 2001 From: Mai Duc Duy Date: Wed, 15 Apr 2026 00:15:20 +0700 Subject: [PATCH 02/12] change .env.example --- .env.example | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index e53b39f..30a2a8f 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,10 @@ # Do not commit real credentials. # Core LLM toggle -CHAINCOPILOT_LLM_ENABLED=true -CHAINCOPILOT_LLM_TIMEOUT_S=60 +CHAINCOPILOT_LLM_ENABLED=false +CHAINCOPILOT_LLM_TIMEOUT_S=4 CHAINCOPILOT_LLM_RETRY_ATTEMPTS=1 -CHAINCOPILOT_LLM_MODEL=gemma-4-31B-it +CHAINCOPILOT_LLM_MODEL=gemini-2.5-flash # Planner mode # - hybrid: use LLM for candidate drafts, then deterministic scoring/selection @@ -15,22 +15,18 @@ CHAINCOPILOT_PLANNER_MODE=hybrid # Provider selection # - gemini: Gemini Developer API # - vertex: Vertex AI Gemini via aiplatform.googleapis.com -CHAINCOPILOT_LLM_PROVIDER=fpt +CHAINCOPILOT_LLM_PROVIDER=gemini # Gemini Developer API -CHAINCOPILOT_LLM_API_KEY= +CHAINCOPILOT_LLM_API_KEY=REPLACE_WITH_GEMINI_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 +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 - - - - From ad998f41b1c5452266bc4d1c028f22f08d45de6f Mon Sep 17 00:00:00 2001 From: Mai Duc Duy Date: Wed, 15 Apr 2026 11:34:19 +0700 Subject: [PATCH 03/12] fix websocket --- api.py | 49 +- app_api/routers.py | 172 ++++- orchestrator/graph.py | 1232 +++++++++++++++++++---------------- requirements.txt | 3 +- scratch/test_realtime.py | 47 ++ scratch/test_realtime_v2.py | 51 ++ scratch/test_ws_env.py | 36 + streaming/__init__.py | 4 + streaming/event_bus.py | 119 ++++ streaming/schemas.py | 58 ++ 10 files changed, 1173 insertions(+), 598 deletions(-) create mode 100644 scratch/test_realtime.py create mode 100644 scratch/test_realtime_v2.py create mode 100644 scratch/test_ws_env.py create mode 100644 streaming/__init__.py create mode 100644 streaming/event_bus.py create mode 100644 streaming/schemas.py diff --git a/api.py b/api.py index 3c110eb..6f6680e 100644 --- a/api.py +++ b/api.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @@ -10,7 +10,7 @@ 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 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 @@ -51,12 +51,12 @@ def replace_runtime( """Reconfigures the service with fresh components.""" global RUNTIME selected_store = store or SQLiteStore() - RUNTIME = ControlTowerRuntime( - store=selected_store, - 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(), + RUNTIME = ControlTowerRuntime( + store=selected_store, + 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 @@ -64,7 +64,9 @@ def replace_runtime( def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI: """Application factory for the ChainCopilot API.""" + print(">>> CALLING create_app") if runtime is not None: + replace_runtime( store=runtime.store, state=runtime.state, @@ -74,7 +76,6 @@ def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI: ) instance = FastAPI(title="ChainCopilot API", version="0.2.0") - # Configure CORS middleware instance.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -83,8 +84,32 @@ def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI: allow_headers=["*"], ) + @instance.get("/health") + def health(): + return {"status": "ok", "version": "0.2.0-ws-debug"} + # Modular routers - Includes all /api/v1 endpoints - instance.include_router(create_router(lambda: RUNTIME)) + + instance.include_router(create_router(lambda: RUNTIME), prefix="/api/v1") + + # Direct WebSocket route (UNIQUE PATH - NO PREFIX) + print(">>> REGISTERING WEBSOCKET: /thinking-stream/{run_id}") + @instance.websocket("/thinking-stream/{run_id}") + async def thinking_stream(websocket: WebSocket, run_id: str) -> None: + from streaming.event_bus import event_bus + await websocket.accept() + try: + async for thinking_event in event_bus.subscribe(run_id): + await websocket.send_text(thinking_event.model_dump_json()) + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass register_error_handlers(instance) return instance @@ -150,3 +175,7 @@ async def _unhandled_exception_handler(_: Request, exc: Exception) -> JSONRespon sync_legacy_globals() app = create_app(RUNTIME) + +@app.get("/health-module") +def health_module(): + return {"status": "ok", "source": "module-level"} diff --git a/app_api/routers.py b/app_api/routers.py index 51d6b1d..cfdcb8f 100644 --- a/app_api/routers.py +++ b/app_api/routers.py @@ -1,15 +1,16 @@ from __future__ import annotations +import asyncio from collections.abc import Callable -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, BackgroundTasks, HTTPException, WebSocket, WebSocketDisconnect -from app_api.schemas import ( - ActionExecutionRecordView, - ApprovalAlternativeRequest, - ApprovalCommandResultResponse, - ApprovalDetailResponse, - ApprovalCommandRequest, +from app_api.schemas import ( + ActionExecutionRecordView, + ApprovalAlternativeRequest, + ApprovalCommandResultResponse, + ApprovalDetailResponse, + ApprovalCommandRequest, ControlTowerStateResponse, ControlTowerSummaryResponse, DecisionLogDetailResponse, @@ -73,7 +74,6 @@ def create_router(runtime_getter: Callable[[], ControlTowerRuntime]) -> APIRouter: router = APIRouter( - prefix="/api/v1", responses={ 404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}, @@ -382,35 +382,35 @@ def get_approval(decision_id: str) -> ApprovalDetailResponse: @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) + 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, - action=request.action, - execution=runtime.latest_execution(), - ) - - @router.post( - "/approvals/{decision_id}/select-alternative", - response_model=ApprovalCommandResultResponse, - ) - def select_approval_alternative( - decision_id: str, request: ApprovalAlternativeRequest - ) -> ApprovalCommandResultResponse: - runtime = runtime_getter() - 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(), - ) + action=request.action, + execution=runtime.latest_execution(), + ) + + @router.post( + "/approvals/{decision_id}/select-alternative", + response_model=ApprovalCommandResultResponse, + ) + def select_approval_alternative( + decision_id: str, request: ApprovalAlternativeRequest + ) -> ApprovalCommandResultResponse: + runtime = runtime_getter() + 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(), + ) @router.get("/decision-logs", response_model=DecisionLogListResponse) def get_decision_logs() -> DecisionLogListResponse: @@ -630,4 +630,110 @@ def what_if(request: WhatIfRequest) -> dict: runtime = runtime_getter() return runtime.what_if(request.scenario_name, seed=request.seed) + # ------------------------------------------------------------------ + # Streaming: WebSocket + trigger endpoint + # ------------------------------------------------------------------ + + @router.post("/plan/daily/stream") + async def daily_plan_stream(background_tasks: BackgroundTasks) -> dict: + """ + Trigger a daily-plan run with streaming. + + Flow: + 1. Client calls POST /plan/daily/stream → receives {run_id, ws_url} + 2. Client connects to WS /ws/thinking/{run_id} → receives real-time ThinkingEvents + 3. After graph completion, server closes WS channel (sentinel None) + + Backward compat: Legacy endpoint POST /plan/daily remains independent. + """ + from core.runtime_tracking import new_run_id + run_id = new_run_id() + background_tasks.add_task(_stream_daily_plan, runtime_getter, run_id) + return { + "run_id": run_id, + "ws_url": f"/ws/thinking/{run_id}", + } + + @router.websocket("/ws/thinking/{run_id}") + async def websocket_thinking_stream(websocket: WebSocket, run_id: str) -> None: + """ + WebSocket endpoint for real-time thinking/reasoning stream. + + Subscribes to the event bus for the given run_id and sends + ThinkingEvents to the connected client as they occur. + + Connection closes when: + - Graph completes (server sends sentinel None) + - Client disconnects + - Timeout after 120 seconds + """ + from streaming.event_bus import event_bus + + await websocket.accept() + try: + async for event in event_bus.subscribe(run_id): + if event is None: + break + await websocket.send_json(event.model_dump()) + except WebSocketDisconnect: + pass + finally: + await websocket.close() + return router + + +# ------------------------------------------------------------------ +# Background task helpers (module-level — outside create_router) +# ------------------------------------------------------------------ + +async def _stream_daily_plan(runtime_getter: Callable, run_id: str) -> None: + """ + Runs the graph in a threadpool executor to avoid blocking the event loop. + Ensures event_bus.close() is always called, even on exception. + """ + from streaming.event_bus import event_bus + from streaming.schemas import ThinkingEvent + + loop = asyncio.get_event_loop() + runtime = runtime_getter() + try: + await loop.run_in_executor( + None, + lambda: _sync_run_with_run_id(runtime, run_id), + ) + except Exception as exc: + # Emit error event before closing the channel + event_bus.publish( + run_id, + ThinkingEvent( + type="error", + agent="system", + step="fatal", + message=f"{exc.__class__.__name__}: {exc}", + data={"exception": exc.__class__.__name__}, + ), + ) + finally: + event_bus.close(run_id) + + +def _sync_run_with_run_id(runtime, run_id: str) -> None: + """ + Sync wrapper — runs in thread pool. + Calls graph.invoke() with run_id so _emit() calls can publish to the bus. + Saves state and artifacts after completion (mirrors run_daily logic). + """ + from orchestrator.service import ( + PendingApprovalError, + _save_state, + ensure_no_pending_plan, + ) + from fastapi import HTTPException as _HTTPException + + ensure_no_pending_plan(runtime.state) + # graph.invoke() forwards run_id into OrchestrationState + # so every _emit() in nodes will publish to event_bus + updated = runtime.graph.invoke(runtime.state, None, run_id=run_id) + _save_state(updated, runtime.store) + runtime.state = updated diff --git a/orchestrator/graph.py b/orchestrator/graph.py index 6e3cd17..2c253bd 100644 --- a/orchestrator/graph.py +++ b/orchestrator/graph.py @@ -29,358 +29,449 @@ 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 + + route_after_planner, + route_after_risk, + route_after_supplier, +) + + +class OrchestrationState(TypedDict): + state: SystemState + event: Event | None + started_at: float + run_id: str | None + + +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 _emit( + self, + graph_state: "OrchestrationState", + *, + type: str, + agent: str, + step: str, + message: str, + data: dict | None = None, + ) -> None: + """ + Emits a ThinkingEvent to the EventBus for the current run_id. + No-op if graph_state has no run_id (backward compat). + Does not raise exceptions — emit errors should not block the graph. + """ + run_id = graph_state.get("run_id") + if not run_id: + return + try: + from streaming.event_bus import event_bus + from streaming.schemas import ThinkingEvent + event_bus.publish( + run_id, + ThinkingEvent( + type=type, # type: ignore[arg-type] + agent=agent, + step=step, + message=message, + data=data or {}, + ), + ) + except Exception: + pass # emit failure must NEVER break the graph + + 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._emit(graph_state, type="start", agent="risk", step="init", + message="Starting orchestration cycle", + data={"mode": state.mode.value, "pending_plan": state.pending_plan is not None}) + 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) + max_sev = max((e.severity for e in state.active_events), default=0.0) + self._emit(graph_state, type="analysis", agent="risk", step="event_scan", + message=f"Risk Agent scanning {len(state.active_events)} active events", + data={"active_events": len(state.active_events), "max_severity": round(max_sev, 2)}) + 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._emit(graph_state, type="observation", agent="risk", step="proposals", + message=f"Risk Agent proposed {len(output.proposals)} actions", + data={"proposals": len(output.proposals), "observations": output.observations[:2]}) + self._emit(graph_state, type="decision", agent="risk", step="routing", + message=f"Next routing → {risk_route}", + data={"next_node": risk_route, "reason": self._risk_route_reason(risk_route)}) + 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"], "run_id": graph_state.get("run_id")} + + def demand_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + sku_count = len(state.inventory) + self._emit(graph_state, type="analysis", agent="demand", step="demand_forecast", + message=f"Demand Agent analyzing forecast for {sku_count} SKUs", + data={"sku_count": sku_count}) + 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._emit(graph_state, type="observation", agent="demand", step="demand_done", + message=f"Demand analysis completed — moving to {next_node}", + data={"proposals": len(output.proposals), "observations": output.observations[:2]}) + self._record_route( + state, + "demand", + next_node, + next_node, + self._handoff_reason("demand", next_node), + ) + return {**graph_state, "run_id": graph_state.get("run_id")} + + def inventory_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + critical_skus = sum( + 1 for item in state.inventory.values() + if item.on_hand + item.incoming_qty - item.forecast_qty < item.reorder_point + ) + self._emit(graph_state, type="analysis", agent="inventory", step="stock_check", + message=f"Inventory Agent checking {len(state.inventory)} SKUs", + data={"sku_count": len(state.inventory), "critical_skus": critical_skus}) + 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}) + below_safety = sum( + 1 for item in state.inventory.values() + if item.on_hand + item.incoming_qty - item.forecast_qty < item.safety_stock + ) + self._emit(graph_state, type="observation", agent="inventory", step="inventory_done", + message=f"Identified {below_safety} SKUs below safety stock — moving to {next_node}", + data={"proposals": len(output.proposals), "below_safety": below_safety}) + self._record_route( + state, + "inventory", + next_node, + next_node, + self._handoff_reason("inventory", next_node), + ) + return {**graph_state, "run_id": graph_state.get("run_id")} + + def supplier_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + supplier_count = len(state.suppliers) + self._emit(graph_state, type="analysis", agent="supplier", step="supplier_scan", + message=f"Supplier Agent evaluating {supplier_count} suppliers", + data={"supplier_count": supplier_count}) + 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._emit(graph_state, type="observation", agent="supplier", step="supplier_done", + message=f"Supplier Agent proposed {len(output.proposals)} actions — moving to {next_node}", + data={"proposals": len(output.proposals), "observations": output.observations[:2]}) + self._record_route( + state, + "supplier", + next_node, + next_node, + self._handoff_reason("supplier", next_node), + ) + return {**graph_state, "run_id": graph_state.get("run_id")} + + def logistics_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + route_count = len(state.routes) + self._emit(graph_state, type="analysis", agent="logistics", step="route_scan", + message=f"Logistics Agent checking {route_count} transport routes", + data={"route_count": route_count}) + 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._emit(graph_state, type="observation", agent="logistics", step="logistics_done", + message=f"Logistics Agent proposed {len(output.proposals)} actions — moving to {next_node}", + data={"proposals": len(output.proposals), "observations": output.observations[:2]}) + self._record_route( + state, + "logistics", + next_node, + next_node, + self._handoff_reason("logistics", next_node), + ) + return {**graph_state, "run_id": graph_state.get("run_id")} + + def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + candidate_count = len(state.candidate_actions) + self._emit(graph_state, type="thinking", agent="planner", step="strategy_init", + message="Planner generating 3 strategies: cost_first, balanced, resilience_first", + data={"strategies": 3, "candidate_actions": candidate_count}) + self._emit(graph_state, type="thinking", agent="planner", step="simulation", + message="Running simulations for each strategy", + data={"candidate_actions": candidate_count}) + 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, + 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, @@ -425,210 +516,243 @@ def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: ), }, ) - 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() + if state.latest_plan is not None: + self._emit(graph_state, type="decision", agent="planner", step="plan_selected", + message=f"Selected strategy '{state.latest_plan.strategy_label or 'unknown'}' " + f"(score={state.latest_plan.score:.3f})", + data={ + "plan_id": state.latest_plan.plan_id, + "strategy": state.latest_plan.strategy_label, + "score": round(state.latest_plan.score, 4), + "approval_required": state.latest_plan.approval_required, + "action_count": len(state.latest_plan.actions), + }) + self._record_route( + state, + "planner", + "critic", + "critic", + "planner always forwards candidate plans to the critic", + ) + return {**graph_state, "run_id": graph_state.get("run_id")} + + 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, "run_id": graph_state.get("run_id")} + + def approval_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + approval_reason = state.latest_plan.approval_reason if state.latest_plan else "approval required" + decision_id = state.decision_logs[-1].decision_id if state.decision_logs else None + self._emit(graph_state, type="decision", agent="system", step="approval_gate", + message=f"Plan awaiting manual approval: {approval_reason}", + data={"reason": approval_reason, "decision_id": decision_id}) + 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=approval_reason, + reasoning_source="deterministic_policy_guardrail", + output_snapshot={ + "decision_id": decision_id, + "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"]) + self._emit(graph_state, type="final", agent="system", step="complete", + message="Orchestration complete — plan awaiting approval", + data={ + "execution_status": "pending_approval", + "plan_id": state.latest_plan.plan_id if state.latest_plan else None, + "mode": state.mode.value, + }) + return { + "state": state, + "event": graph_state["event"], + "started_at": graph_state["started_at"], + "run_id": graph_state.get("run_id"), + } + + def execution_node(self, graph_state: OrchestrationState) -> OrchestrationState: + state = graph_state["state"] + event = graph_state["event"] + plan_id = state.latest_plan.plan_id if state.latest_plan else None + self._emit(graph_state, type="action", agent="system", step="execute", + message=f"Executing plan '{plan_id or 'N/A'}'", + data={"plan_id": plan_id, + "action_count": len(state.latest_plan.actions) if state.latest_plan else 0}) + 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"]) + self._emit(graph_state, type="final", agent="system", step="complete", + message=f"Orchestration complete — {execution_status}", + data={ + "execution_status": execution_status, + "plan_id": state.latest_plan_id, + "mode": state.mode.value, + }) + return {"state": state, "event": event, "started_at": graph_state["started_at"], "run_id": graph_state.get("run_id")} + + 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(), + "run_id": state.run_id, + } + ) + return result["state"] + + +def build_graph() -> LangGraphControlTower: + return LangGraphControlTower() diff --git a/requirements.txt b/requirements.txt index dd0fe7c..aadb84d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ 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 +python-dotenv +websockets>=16.0,<17 \ No newline at end of file diff --git a/scratch/test_realtime.py b/scratch/test_realtime.py new file mode 100644 index 0000000..d1ff3f1 --- /dev/null +++ b/scratch/test_realtime.py @@ -0,0 +1,47 @@ +import asyncio +import json +import sys +import os + +# Add current directory to path so 'api' can be imported +sys.path.append(os.getcwd()) + +from fastapi.testclient import TestClient +from api import app + +def test_websocket_stream(): + client = TestClient(app) + + print("--- 1. Triggering stream ---") + response = client.post("/api/v1/plan/daily/stream") + data = response.json() + run_id = data["run_id"] + ws_url = data["ws_url"] + print(f"Run ID: {run_id}") + print(f"WS URL: {ws_url}\n") + + print("--- 2. Connecting to WebSocket & Listening ---") + # Sử dụng TestClient để connect websocket + with client.websocket_connect(ws_url) as websocket: + try: + while True: + # Đợi nhận message + message = websocket.receive_text() + event = json.loads(message) + + # In ra format đẹp + seq = event.get("sequence", 0) + agent = event.get("agent", "unknown") + step = event.get("step", "unknown") + msg = event.get("message", "") + + print(f"[{seq}] {agent.upper()} > {step}: {msg}") + + if event.get("type") == "final": + print("\n--- Stream Finished Automatically ---") + break + except Exception as e: + print(f"\nConnection closed or error: {e}") + +if __name__ == "__main__": + test_websocket_stream() diff --git a/scratch/test_realtime_v2.py b/scratch/test_realtime_v2.py new file mode 100644 index 0000000..6668615 --- /dev/null +++ b/scratch/test_realtime_v2.py @@ -0,0 +1,51 @@ +import asyncio +import json +import httpx +import websockets + +async def test_realtime_v2(): + url = "http://localhost:8000/api/v1/plan/daily/stream" + + print("--- 1. Triggering stream via HTTP POST ---") + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, timeout=10.0) + data = response.json() + except Exception as e: + print(f"Error: Could not connect to server at localhost:8000. Did you start uvicorn?") + return + + run_id = data["run_id"] + # WebSocket endpoint includes the /api/v1 prefix + ws_url = f"ws://localhost:8000/api/v1/ws/thinking/{run_id}" + + print(f"Run ID: {run_id}") + print(f"Connecting to: {ws_url}\n") + + print("--- 2. Listening to Live Thinking Steps ---") + try: + async with websockets.connect(ws_url) as websocket: + while True: + message = await websocket.recv() + event = json.loads(message) + + seq = event.get("sequence", 0) + agent = event.get("agent", "unknown") + step = event.get("step", "unknown") + msg = event.get("message", "") + + print(f"[{seq}] {agent.upper()} > {step}: {msg}") + + if event.get("type") == "final": + print("\n--- Stream Finished ---") + break + except websockets.exceptions.ConnectionClosed: + print("\nConnection closed.") + except Exception as e: + print(f"Error during streaming: {e}") + +if __name__ == "__main__": + asyncio.run(test_realtime_v2()) + + + diff --git a/scratch/test_ws_env.py b/scratch/test_ws_env.py new file mode 100644 index 0000000..a567341 --- /dev/null +++ b/scratch/test_ws_env.py @@ -0,0 +1,36 @@ +import asyncio +from fastapi import FastAPI, WebSocket +import uvicorn +import httpx +import websockets +from multiprocessing import Process +import time + +app = FastAPI() + +@app.websocket("/ws/{run_id}") +async def ws_endpoint(websocket: WebSocket, run_id: str): + await websocket.accept() + await websocket.send_text(f"Hello, {run_id}") + await websocket.close() + +def run_server(): + uvicorn.run(app, host="127.0.0.1", port=8005) + +async def test_client(): + uri = "ws://127.0.0.1:8005/ws/abc" + print(f"Connecting to {uri}") + try: + async with websockets.connect(uri) as websocket: + msg = await websocket.recv() + print("Received:", msg) + except Exception as e: + print("Error:", e) + +if __name__ == "__main__": + p = Process(target=run_server) + p.start() + time.sleep(2) # Give it time to start + asyncio.run(test_client()) + p.terminate() + p.join() diff --git a/streaming/__init__.py b/streaming/__init__.py new file mode 100644 index 0000000..db6c89f --- /dev/null +++ b/streaming/__init__.py @@ -0,0 +1,4 @@ +from streaming.schemas import ThinkingEvent, ThinkingEventType +from streaming.event_bus import RunEventBus, event_bus + +__all__ = ["ThinkingEvent", "ThinkingEventType", "RunEventBus", "event_bus"] diff --git a/streaming/event_bus.py b/streaming/event_bus.py new file mode 100644 index 0000000..7ec9ecd --- /dev/null +++ b/streaming/event_bus.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +from collections import defaultdict +from typing import AsyncGenerator + +from streaming.schemas import ThinkingEvent + + +class RunEventBus: + """ + In-memory pub/sub bus, keyed by run_id. + + Workflow: + 1. Background task (sync, in threadpool) calls publish() whenever a graph + node emits a ThinkingEvent. + 2. WebSocket handler (async) calls subscribe() to yield each event to + the Next.js client via WS frames. + 3. When the graph completes, the background task calls close() to signal the end. + + Thread safety: + asyncio.Queue.put_nowait() is safe to call from non-async threads + (see docs: https://docs.python.org/3/library/asyncio-queue.html). + This is why we use asyncio.Queue instead of queue.Queue. + """ + + def __init__(self) -> None: + # dict[run_id → list of subscriber queues] + self._queues: dict[str, list[asyncio.Queue]] = defaultdict(list) + # sequence counter per run_id + self._seq: dict[str, int] = defaultdict(int) + + # ------------------------------------------------------------------ # + # Producer side — called from sync thread (graph node or background task) + # ------------------------------------------------------------------ # + + def publish(self, run_id: str, event: ThinkingEvent) -> None: + """ + Assigns run_id and sequence number, then pushes the event to all + subscriber queues for that run_id. + + Safe to call from any thread. + """ + event.run_id = run_id + self._seq[run_id] += 1 + event.sequence = self._seq[run_id] + for q in list(self._queues.get(run_id, [])): + try: + q.put_nowait(event) + except asyncio.QueueFull: + # If subscriber is too slow — discard instead of blocking + pass + + def close(self, run_id: str) -> None: + """ + Sends sentinel None into each queue to signal end-of-stream. + Subscribers will exit the loop upon receiving None. + """ + for q in list(self._queues.get(run_id, [])): + try: + q.put_nowait(None) + except asyncio.QueueFull: + pass + # Clean up sequence counter + self._seq.pop(run_id, None) + + # ------------------------------------------------------------------ # + # Consumer side — called from async WebSocket handler + # ------------------------------------------------------------------ # + + async def subscribe(self, run_id: str) -> AsyncGenerator[ThinkingEvent, None]: + """ + Async generator — yields each ThinkingEvent for run_id. + + Exits when: + - Sentinel None received (graph completed) + - Timeout 120s (client disconnected or graph stalled) + + Automatically cleans up the queue upon exit. + """ + q: asyncio.Queue[ThinkingEvent | None] = asyncio.Queue(maxsize=512) + self._queues[run_id].append(q) + try: + while True: + try: + event = await asyncio.wait_for(q.get(), timeout=120.0) + except asyncio.TimeoutError: + # Graph stalled or client stopped reading — end gracefully + break + if event is None: + # Sentinel — stream ended normally + break + yield event + finally: + # Cleanup: remove queue from subscriber list + queues = self._queues.get(run_id) + if queues is not None: + try: + queues.remove(q) + except ValueError: + pass + if not queues: + self._queues.pop(run_id, None) + + # ------------------------------------------------------------------ # + # Diagnostics + # ------------------------------------------------------------------ # + + def active_runs(self) -> list[str]: + """Returns a list of run_ids currently having subscribers (debug only).""" + return list(self._queues.keys()) + + def subscriber_count(self, run_id: str) -> int: + """Number of subscribers currently listening on a run_id (debug only).""" + return len(self._queues.get(run_id, [])) + + +# Singleton — import and use directly from any module +event_bus = RunEventBus() diff --git a/streaming/schemas.py b/streaming/schemas.py new file mode 100644 index 0000000..a6b8d3c --- /dev/null +++ b/streaming/schemas.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +ThinkingEventType = Literal[ + "start", # Starting process + "analysis", # Analyzing data + "thinking", # Reasoning / Considering + "decision", # Making interim decision + "action", # Performing action (dispatch, apply) + "observation", # Receiving results from agent / tool + "reflection", # Critic evaluating the plan + "final", # Final conclusion — trace complete + "error", # Unexpected system error +] + + +class ThinkingEvent(BaseModel): + """ + A thinking/action step of the multi-agent system, + streamed in real-time via WebSocket to the UI. + + Example: + ThinkingEvent( + type="thinking", + agent="planner", + step="strategy_generation", + message="Generating 3 strategies: cost_first, balanced, resilience_first", + data={"strategies": 3, "candidate_count": 12}, + ) + """ + + type: ThinkingEventType + agent: str = Field( + description="Agent name emitting the event: 'risk' | 'demand' | 'inventory' | " + "'supplier' | 'logistics' | 'planner' | 'critic' | 'system'" + ) + step: str = Field( + description="Short step name in snake_case, e.g., 'event_scan', 'plan_selected'" + ) + message: str = Field( + description="Human-readable description of current task — used directly on UI" + ) + data: dict[str, Any] = Field( + default_factory=dict, + description="Detailed metadata, agent-specific", + ) + run_id: str | None = Field( + default=None, + description="run_id of the current orchestration cycle, auto-assigned by EventBus", + ) + sequence: int = Field( + default=0, + description="Order of the event in the run — auto-incremented by EventBus", + ) From 48b194d1f2d08f7d731cf29a81c68f2c2ec3755d Mon Sep 17 00:00:00 2001 From: trandangduat Date: Wed, 15 Apr 2026 14:43:22 +0700 Subject: [PATCH 04/12] feat: implement real-time trace updates in LangGraph orchestrator --- app_api/services.py | 2297 +++++++++++++++++++++------------------ orchestrator/graph.py | 227 ++-- orchestrator/service.py | 774 ++++++------- simulation/runner.py | 20 +- 4 files changed, 1793 insertions(+), 1525 deletions(-) diff --git a/app_api/services.py b/app_api/services.py index 79620f8..fe7f32b 100644 --- a/app_api/services.py +++ b/app_api/services.py @@ -4,30 +4,38 @@ 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, @@ -36,9 +44,9 @@ 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, @@ -47,94 +55,97 @@ 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 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, +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, ProjectedStateSummaryView, ProjectionStepView, ReflectionView, RunView, - RouteDecisionView, - ScenarioOutcomeView, - ServiceFlagsView, - ServiceMetricsView, - ServiceRuntimeView, - SupplierRowView, + 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": + + +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, @@ -143,251 +154,290 @@ def create(cls, store: SQLiteStore | None = None) -> "ControlTowerRuntime": runner=ScenarioRunner(store=local_store), dispatch_service=ActionDispatchService(), ) - + def reinitialize(self, store: SQLiteStore | None = None) -> None: self.store = store or self.store 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 = 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": - 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 - + 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() + + def on_trace_update(trace): + self.state.latest_trace = trace + + try: + self.state = run_daily_plan( + self.state, + self.store, + graph=self.graph, + run_id=run_id, + trace_updater=on_trace_update, + ) + 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) + + def on_trace_update(trace): + self.state.latest_trace = trace + + try: + self.state = self.runner.run( + self.state, scenario_name, seed=seed, trace_updater=on_trace_update + ) + 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: + 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) + 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( @@ -398,36 +448,40 @@ def approval_command(self, decision_id: str, action: str) -> SystemState: 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, - ) + 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, @@ -449,7 +503,9 @@ def approval_command(self, decision_id: str, action: str) -> SystemState: ) return self.state - def select_approval_alternative(self, decision_id: str, strategy_label: 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() @@ -459,7 +515,9 @@ def select_approval_alternative(self, decision_id: str, strategy_label: str) -> 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)) + parent_execution = self._execution_for_id( + self._parent_execution_id(parent_run_id) + ) execution_record: ExecutionRecord | None = None try: @@ -502,20 +560,22 @@ def select_approval_alternative(self, decision_id: str, strategy_label: str) -> 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: @@ -530,199 +590,242 @@ def dispatch_plan(self, plan_id: str, mode: str) -> dict[str, Any]: 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 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, + "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], + "records": [ + action_execution_record_view(r) for r in existing_records + ], "compensation_hints": [], } summary = self.dispatch_service.dispatch_plan(plan, mode=mode) - + # 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()) - - + 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) + payload = ( + item if isinstance(item, EventEnvelope) else EventEnvelope.model_validate(item) + ) 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) 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 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")) @@ -746,51 +849,55 @@ def projected_state_summary_view(item) -> ProjectedStateSummaryView: return ProjectedStateSummaryView(**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 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, @@ -798,7 +905,9 @@ def candidate_evaluation_view(item) -> CandidateEvaluationView: score=item.score, score_breakdown=item.score_breakdown, projected_kpis=kpi_view(item.projected_kpis), - worst_case_kpis=kpi_view(item.worst_case_kpis) if item.worst_case_kpis is not None else None, + worst_case_kpis=kpi_view(item.worst_case_kpis) + if item.worst_case_kpis is not None + else None, simulation_horizon_days=item.simulation_horizon_days, projection_steps=[projection_step_view(step) for step in item.projection_steps], projected_state_summary=( @@ -808,7 +917,9 @@ def candidate_evaluation_view(item) -> CandidateEvaluationView: ), projection_summary=item.projection_summary, feasible=item.feasible, - violations=[constraint_violation_view(violation) for violation in item.violations], + 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, @@ -818,44 +929,47 @@ def candidate_evaluation_view(item) -> CandidateEvaluationView: 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_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), + 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), ) @@ -906,7 +1020,11 @@ def _committed_qty_within_lt(state: SystemState, item) -> int: def _inventory_position(state: SystemState, item) -> float: - return item.on_hand + _expected_inbound_within_lt(state, item) - _committed_qty_within_lt(state, item) + return ( + item.on_hand + + _expected_inbound_within_lt(state, item) + - _committed_qty_within_lt(state, item) + ) def _dynamic_safety_stock(state: SystemState, item) -> float: @@ -945,124 +1063,130 @@ def _inventory_status(state: SystemState, item) -> str: return "in_stock" -def inventory_rows(state: SystemState, *, search: str | None = None, status: str | None = None) -> list[InventoryRowView]: - needle = (search or "").strip().lower() +def inventory_rows( + state: SystemState, *, search: str | None = None, status: str | None = None +) -> list[InventoryRowView]: + needle = (search or "").strip().lower() status_filter = (status or "").strip().lower() rows: list[InventoryRowView] = [] for item in state.inventory.values(): 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] - - + 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 []) + allowed_actions = ["approve", "reject"] + ( + ["safer_plan"] if can_request_safer else [] + ) return PendingApprovalView( decision_id=decision.decision_id, approval_status=decision.approval_status.value, @@ -1075,16 +1199,27 @@ def pending_approval_view(state: SystemState) -> PendingApprovalView | None: 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 []) + 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, @@ -1093,319 +1228,349 @@ def approval_detail_view(state: SystemState, decision_id: str) -> ApprovalDetail approval_reason=decision.approval_reason, is_pending=pending, 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: + 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 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/orchestrator/graph.py b/orchestrator/graph.py index 6e3cd17..cb618f7 100644 --- a/orchestrator/graph.py +++ b/orchestrator/graph.py @@ -1,7 +1,7 @@ from __future__ import annotations import time -from typing import TypedDict +from typing import Callable, TypedDict from uuid import uuid4 from actions.executor import apply_plan @@ -35,10 +35,11 @@ ) -class OrchestrationState(TypedDict): +class OrchestrationState(TypedDict, total=False): state: SystemState event: Event | None started_at: float + trace_updater: "Callable[[OrchestrationTrace], None]" class LangGraphControlTower: @@ -235,14 +236,34 @@ def _critic_route_reason(self, state: SystemState, outcome: str) -> str: 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", + ( + "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}") + 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 @@ -260,12 +281,22 @@ def _finalize(self, state: SystemState, started_at: float) -> SystemState: state.latest_trace.status = "completed" return state + def _notify_trace_update( + self, + state: SystemState, + trace_updater: Callable[[OrchestrationTrace], None] | None, + ) -> None: + if trace_updater and state.latest_trace: + trace_updater(state.latest_trace) + def risk_node(self, graph_state: OrchestrationState) -> OrchestrationState: state = graph_state["state"] - event = graph_state["event"] + event = graph_state.get("event") + trace_updater = graph_state.get("trace_updater") self._reset_cycle(state) self._begin_trace(state, event) self._start_step(state, "risk", "agent", event) + self._notify_trace_update(state, trace_updater) if event is not None: if event.dedupe_key not in { item.dedupe_key for item in state.active_events @@ -284,12 +315,20 @@ def risk_node(self, graph_state: OrchestrationState) -> OrchestrationState: ) 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"]} + self._notify_trace_update(state, trace_updater) + return { + "state": state, + "event": event, + "started_at": graph_state["started_at"], + "trace_updater": trace_updater, + } 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"]) + trace_updater = graph_state.get("trace_updater") + self._start_step(state, "demand", "agent", graph_state.get("event")) + self._notify_trace_update(state, trace_updater) + output = self.demand_agent.run(state, graph_state.get("event")) self._record_output(state, output) self._complete_agent_step(state, "demand", output) next_node = route_after_demand({"state": state}) @@ -300,12 +339,15 @@ def demand_node(self, graph_state: OrchestrationState) -> OrchestrationState: next_node, self._handoff_reason("demand", next_node), ) + self._notify_trace_update(state, trace_updater) 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"]) + trace_updater = graph_state.get("trace_updater") + self._start_step(state, "inventory", "agent", graph_state.get("event")) + self._notify_trace_update(state, trace_updater) + output = self.inventory_agent.run(state, graph_state.get("event")) self._record_output(state, output) self._complete_agent_step(state, "inventory", output) next_node = route_after_inventory({"state": state}) @@ -316,12 +358,15 @@ def inventory_node(self, graph_state: OrchestrationState) -> OrchestrationState: next_node, self._handoff_reason("inventory", next_node), ) + self._notify_trace_update(state, trace_updater) 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"]) + trace_updater = graph_state.get("trace_updater") + self._start_step(state, "supplier", "agent", graph_state.get("event")) + self._notify_trace_update(state, trace_updater) + output = self.supplier_agent.run(state, graph_state.get("event")) self._record_output(state, output) self._complete_agent_step(state, "supplier", output) next_node = route_after_supplier({"state": state}) @@ -332,12 +377,15 @@ def supplier_node(self, graph_state: OrchestrationState) -> OrchestrationState: next_node, self._handoff_reason("supplier", next_node), ) + self._notify_trace_update(state, trace_updater) 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"]) + trace_updater = graph_state.get("trace_updater") + self._start_step(state, "logistics", "agent", graph_state.get("event")) + self._notify_trace_update(state, trace_updater) + output = self.logistics_agent.run(state, graph_state.get("event")) self._record_output(state, output) self._complete_agent_step(state, "logistics", output) next_node = route_after_logistics({"state": state}) @@ -348,20 +396,23 @@ def logistics_node(self, graph_state: OrchestrationState) -> OrchestrationState: next_node, self._handoff_reason("logistics", next_node), ) + self._notify_trace_update(state, trace_updater) 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"]) + trace_updater = graph_state.get("trace_updater") + self._start_step(state, "planner", "agent", graph_state.get("event")) + self._notify_trace_update(state, trace_updater) + output = self.planner_agent.run(state, graph_state.get("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 + 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 @@ -381,50 +432,51 @@ def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: "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, - "simulation_horizon_days": ( - latest_decision.candidate_evaluations[0].simulation_horizon_days - if latest_decision and latest_decision.candidate_evaluations - else 0 - ), - "projection_summary": ( - next( - ( - item.projection_summary - for item in latest_decision.candidate_evaluations - if item.strategy_label == state.latest_plan.strategy_label - ), - "", - ) - if latest_decision and state.latest_plan - else "" - ), - "projection_steps": ( - [ - { - "label": step.label, - "service_level": step.kpis.service_level, - "disruption_risk": step.kpis.disruption_risk, - "recovery_speed": step.kpis.recovery_speed, - "inventory_at_risk": step.inventory_at_risk, - "inventory_out_of_stock": step.inventory_out_of_stock, - "backlog_units": step.backlog_units, - "summary": step.summary, - } - for item in latest_decision.candidate_evaluations - if state.latest_plan and item.strategy_label == state.latest_plan.strategy_label - for step in item.projection_steps - ] - if latest_decision and state.latest_plan - else [] - ), - }, - ) + "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, + "simulation_horizon_days": ( + latest_decision.candidate_evaluations[0].simulation_horizon_days + if latest_decision and latest_decision.candidate_evaluations + else 0 + ), + "projection_summary": ( + next( + ( + item.projection_summary + for item in latest_decision.candidate_evaluations + if item.strategy_label == state.latest_plan.strategy_label + ), + "", + ) + if latest_decision and state.latest_plan + else "" + ), + "projection_steps": ( + [ + { + "label": step.label, + "service_level": step.kpis.service_level, + "disruption_risk": step.kpis.disruption_risk, + "recovery_speed": step.kpis.recovery_speed, + "inventory_at_risk": step.inventory_at_risk, + "inventory_out_of_stock": step.inventory_out_of_stock, + "backlog_units": step.backlog_units, + "summary": step.summary, + } + for item in latest_decision.candidate_evaluations + if state.latest_plan + and item.strategy_label == state.latest_plan.strategy_label + for step in item.projection_steps + ] + if latest_decision and state.latest_plan + else [] + ), + }, + ) self._record_route( state, "planner", @@ -432,12 +484,15 @@ def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: "critic", "planner always forwards candidate plans to the critic", ) + self._notify_trace_update(state, trace_updater) 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"]) + trace_updater = graph_state.get("trace_updater") + self._start_step(state, "critic", "agent", graph_state.get("event")) + self._notify_trace_update(state, trace_updater) + output = self.critic_agent.run(state, graph_state.get("event")) state.agent_outputs[output.agent] = output self._complete_step( state, @@ -472,11 +527,14 @@ def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: critic_route, self._critic_route_reason(state, critic_route), ) + self._notify_trace_update(state, trace_updater) return graph_state def approval_node(self, graph_state: OrchestrationState) -> OrchestrationState: state = graph_state["state"] - self._start_step(state, "approval", "gate", graph_state["event"]) + trace_updater = graph_state.get("trace_updater") + self._start_step(state, "approval", "gate", graph_state.get("event")) + self._notify_trace_update(state, trace_updater) state.mode = Mode.APPROVAL self._update_trace_from_plan(state) if state.latest_trace is not None: @@ -501,16 +559,20 @@ def approval_node(self, graph_state: OrchestrationState) -> OrchestrationState: }, ) state = self._finalize(state, graph_state["started_at"]) + self._notify_trace_update(state, trace_updater) return { "state": state, - "event": graph_state["event"], + "event": graph_state.get("event"), "started_at": graph_state["started_at"], + "trace_updater": trace_updater, } def execution_node(self, graph_state: OrchestrationState) -> OrchestrationState: state = graph_state["state"] - event = graph_state["event"] + event = graph_state.get("event") + trace_updater = graph_state.get("trace_updater") self._start_step(state, "execution", "execution", event) + self._notify_trace_update(state, trace_updater) execution_summary = "no plan executed" execution_status = "no_op" if state.latest_plan and not state.latest_plan.approval_required: @@ -541,7 +603,13 @@ def execution_node(self, graph_state: OrchestrationState) -> OrchestrationState: }, ) state = self._finalize(state, graph_state["started_at"]) - return {"state": state, "event": event, "started_at": graph_state["started_at"]} + self._notify_trace_update(state, trace_updater) + return { + "state": state, + "event": event, + "started_at": graph_state["started_at"], + "trace_updater": trace_updater, + } def _compile(self): graph = StateGraph(OrchestrationState) @@ -617,7 +685,11 @@ def _compile(self): return graph.compile() def invoke( - self, state: SystemState, event: Event | None = None, run_id: str | None = None + self, + state: SystemState, + event: Event | None = None, + run_id: str | None = None, + trace_updater: Callable[[OrchestrationTrace], None] | None = None, ) -> SystemState: state.run_id = run_id or new_run_id() result = self.graph.invoke( @@ -625,6 +697,7 @@ def invoke( "state": state, "event": event, "started_at": time.perf_counter(), + "trace_updater": trace_updater, } ) return result["state"] diff --git a/orchestrator/service.py b/orchestrator/service.py index 7fc8846..78147df 100644 --- a/orchestrator/service.py +++ b/orchestrator/service.py @@ -3,30 +3,30 @@ from typing import Any from uuid import uuid4 -from core.enums import ActionType, ApprovalStatus, Mode, PlanStatus -from core.memory import SQLiteStore -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, +from core.enums import ActionType, ApprovalStatus, Mode, PlanStatus +from core.memory import SQLiteStore +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.evaluator import evaluate_candidate_plan -from simulation.learning import finalize_latest_scenario_run +from policies.guardrails import approval_required +from policies.scoring import compute_score +from simulation.evaluator import evaluate_candidate_plan +from simulation.learning import finalize_latest_scenario_run class PendingApprovalError(RuntimeError): @@ -111,201 +111,212 @@ def _append_approval_trace( 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.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]: +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 _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: - for action in state.pending_plan.actions: - by_id.setdefault(action.action_id, action) - return by_id - - -def _build_safer_plan(state: SystemState, decision_log: DecisionLog) -> Plan: + ) + + +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: + for action in state.pending_plan.actions: + by_id.setdefault(action.action_id, action) + return by_id + + +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 - + + 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] + 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, + 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) - projection = evaluate_candidate_plan( - state=state, - event=_latest_event(state), - actions=selected_actions, - ) - score, breakdown = compute_score( - service_level=projection.worst_case_kpis.service_level, - total_cost=projection.projected_kpis.total_cost, - disruption_risk=projection.worst_case_kpis.disruption_risk, - recovery_speed=projection.projected_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, - projection.projected_kpis, - breakdown, - selected_actions=selected_actions, - strategy_label="safer_alternative", - mode=target_mode, - projection_summary=projection.projection_summary, - simulation_horizon_days=projection.simulation_horizon_days, - worst_case_kpis=projection.worst_case_kpis, - ), - status=PlanStatus.PROPOSED, - ) + ] + selected_actions = sorted(feasible_candidates, key=_safer_action_key)[:1] + target_mode = _mode_from_state(state) + projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=selected_actions, + ) + score, breakdown = compute_score( + service_level=projection.worst_case_kpis.service_level, + total_cost=projection.projected_kpis.total_cost, + disruption_risk=projection.worst_case_kpis.disruption_risk, + recovery_speed=projection.projected_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, + projection.projected_kpis, + breakdown, + selected_actions=selected_actions, + strategy_label="safer_alternative", + mode=target_mode, + projection_summary=projection.projection_summary, + simulation_horizon_days=projection.simulation_horizon_days, + worst_case_kpis=projection.worst_case_kpis, + ), + 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, - projection.projected_kpis, - _latest_event(state), - ) - plan.approval_required = needs_approval - plan.approval_reason = reason - return plan - - -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") - - target_mode = _mode_from_state(state) - projection = evaluate_candidate_plan( - state=state, - event=_latest_event(state), - actions=actions, - ) - score, breakdown = compute_score( - service_level=projection.worst_case_kpis.service_level, - total_cost=projection.projected_kpis.total_cost, - disruption_risk=projection.worst_case_kpis.disruption_risk, - recovery_speed=projection.projected_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=actions, - score=score, - score_breakdown=breakdown, - strategy_label=evaluation.strategy_label, - generated_by="operator_selected_alternative", - planner_reasoning=evaluation.rationale - or build_plan_summary( - decision_log.before_kpis, - projection.projected_kpis, - breakdown, - selected_actions=actions, - strategy_label=evaluation.strategy_label, - mode=target_mode, - mode_rationale=evaluation.mode_rationale, - projection_summary=evaluation.projection_summary, - simulation_horizon_days=evaluation.simulation_horizon_days, - worst_case_kpis=evaluation.worst_case_kpis, - ), - status=PlanStatus.PROPOSED, - feasible=evaluation.feasible, - violations=list(evaluation.violations), - mode_rationale=evaluation.mode_rationale, - ) - - from policies.constraints import evaluate_soft_constraints - - 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, - projection.projected_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 + plan.mode_rationale = "Soft constraints warnings: " + "; ".join( + v.message for v in soft_violations + ) + + needs_approval, reason = approval_required( + plan, + decision_log.before_kpis, + projection.projected_kpis, + _latest_event(state), + ) + plan.approval_required = needs_approval + plan.approval_reason = reason + return plan + + +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") + + target_mode = _mode_from_state(state) + projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=actions, + ) + score, breakdown = compute_score( + service_level=projection.worst_case_kpis.service_level, + total_cost=projection.projected_kpis.total_cost, + disruption_risk=projection.worst_case_kpis.disruption_risk, + recovery_speed=projection.projected_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=actions, + score=score, + score_breakdown=breakdown, + strategy_label=evaluation.strategy_label, + generated_by="operator_selected_alternative", + planner_reasoning=evaluation.rationale + or build_plan_summary( + decision_log.before_kpis, + projection.projected_kpis, + breakdown, + selected_actions=actions, + strategy_label=evaluation.strategy_label, + mode=target_mode, + mode_rationale=evaluation.mode_rationale, + projection_summary=evaluation.projection_summary, + simulation_horizon_days=evaluation.simulation_horizon_days, + worst_case_kpis=evaluation.worst_case_kpis, + ), + status=PlanStatus.PROPOSED, + feasible=evaluation.feasible, + violations=list(evaluation.violations), + mode_rationale=evaluation.mode_rationale, + ) + + from policies.constraints import evaluate_soft_constraints + + 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, + projection.projected_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( @@ -313,9 +324,12 @@ def run_daily_plan( store: SQLiteStore, graph: Any | None = None, run_id: str | None = None, + trace_updater: Any = None, ) -> SystemState: ensure_no_pending_plan(state) - updated = (graph or build_graph()).invoke(state, None, run_id=run_id) + updated = (graph or build_graph()).invoke( + state, None, run_id=run_id, trace_updater=trace_updater + ) _save_state(updated, store) return updated @@ -338,23 +352,23 @@ def approve_pending_plan( pending_plan = state.pending_plan assert pending_plan is not None - if approve: - pending_plan.status = PlanStatus.APPROVED - state.pending_plan = None - updated = state - if updated.latest_plan: - 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; execution is ready for dispatch", - execution_status="approved_pending_dispatch", - decision_id=decision_log.decision_id, - approval_pending=False, - ) + if approve: + pending_plan.status = PlanStatus.APPROVED + state.pending_plan = None + updated = state + if updated.latest_plan: + 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; 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 @@ -376,63 +390,67 @@ def approve_pending_plan( return updated -def request_safer_plan( +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", - ) - safer_projection = evaluate_candidate_plan( - state=state, - event=_latest_event(state), - actions=safer_plan.actions, - ) - winning_factors = build_winning_factors( - safer_plan.actions, - previous_decision.before_kpis, - safer_projection.projected_kpis, - safer_plan.score_breakdown, - ) - rejection_reasons = explain_rejected_actions(previous_plan_actions, safer_plan.actions, 1) +) -> 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", + ) + safer_projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=safer_plan.actions, + ) + winning_factors = build_winning_factors( + safer_plan.actions, + previous_decision.before_kpis, + safer_projection.projected_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=safer_projection.projected_kpis, - selected_actions=[action.action_id for action in safer_plan.actions], + 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=safer_projection.projected_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, - ) + 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), @@ -440,125 +458,125 @@ def request_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 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: - state.run_id = run_id - previous_decision = _current_pending_decision(state, decision_id) - 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 - - alternative_plan = _build_alternative_plan( - state=state, - decision_log=previous_decision, - evaluation=evaluation, - ) - alternative_projection = evaluate_candidate_plan( - state=state, - event=_latest_event(state), - actions=alternative_plan.actions, - ) - winning_factors = build_winning_factors( - alternative_plan.actions, - previous_decision.before_kpis, - alternative_projection.projected_kpis, - 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." - ) - new_decision = DecisionLog( - decision_id=f"dec_{uuid4().hex[:8]}", - 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=alternative_projection.projected_kpis, - selected_actions=[action.action_id for action in alternative_plan.actions], - rejected_actions=rejection_reasons, - 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=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=alternative_plan, - decision_log=new_decision, - ) - - 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, - ) - - finalize_latest_scenario_run(state) - _save_state(state, store) - return state + 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 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: + state.run_id = run_id + previous_decision = _current_pending_decision(state, decision_id) + 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 + + alternative_plan = _build_alternative_plan( + state=state, + decision_log=previous_decision, + evaluation=evaluation, + ) + alternative_projection = evaluate_candidate_plan( + state=state, + event=_latest_event(state), + actions=alternative_plan.actions, + ) + winning_factors = build_winning_factors( + alternative_plan.actions, + previous_decision.before_kpis, + alternative_projection.projected_kpis, + 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." + new_decision = DecisionLog( + decision_id=f"dec_{uuid4().hex[:8]}", + 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=alternative_projection.projected_kpis, + selected_actions=[action.action_id for action in alternative_plan.actions], + rejected_actions=rejection_reasons, + 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=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=alternative_plan, + decision_log=new_decision, + ) + + 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, + ) + + finalize_latest_scenario_run(state) + _save_state(state, store) + return state diff --git a/simulation/runner.py b/simulation/runner.py index 5cccc73..adeb0c0 100644 --- a/simulation/runner.py +++ b/simulation/runner.py @@ -26,7 +26,13 @@ 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: + def run( + self, + initial_state: SystemState, + scenario_name: str, + seed: int = 7, + trace_updater=None, + ) -> SystemState: ensure_no_pending_plan(initial_state) state = clone_state(initial_state) started = time.perf_counter() @@ -51,9 +57,13 @@ def run(self, initial_state: SystemState, scenario_name: str, seed: int = 7) -> payload=event.payload, ) self.store.save_event_envelope(envelope) - state = self.graph.invoke(state, event, run_id=run_id) + state = self.graph.invoke( + state, event, run_id=run_id, trace_updater=trace_updater + ) decision = state.decision_logs[-1] if state.decision_logs else None - execution = build_execution_record(run_id=run_id, state=state, decision=decision) + 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( @@ -78,7 +88,9 @@ def run(self, initial_state: SystemState, scenario_name: str, seed: int = 7) -> 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, + 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", From d1201027daf2abf89ff8a466907954fc9a053319 Mon Sep 17 00:00:00 2001 From: trandangduat Date: Wed, 15 Apr 2026 23:42:06 +0700 Subject: [PATCH 05/12] fix: only allow Risk Agent to summarize the situations, not giving recommended actions --- agents/risk.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/agents/risk.py b/agents/risk.py index b387df7..78d474c 100644 --- a/agents/risk.py +++ b/agents/risk.py @@ -12,8 +12,10 @@ class RiskAgent(BaseAgent): custom_system_prompt = ( "Role: {agent_name} specialist agent in an autonomous supply chain control tower. " - "CRITICAL: Write the 'domain_summary' in English summarizing the disruption based on external_api_data" - "Make it sound natural but precise. Do not invent new actions or make up false information." + "CRITICAL: Your ONLY goal is to write a comprehensive 'domain_summary' in English " + "summarizing the disruption based on external_api_data. " + "Make it sound natural but precise. Do not invent new actions or make up false information. " + "Return empty arrays for impacts, tradeoffs, and recommended actions." ) def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: @@ -21,19 +23,10 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: state.mode = select_mode(state, event) api_payloads = {} if event is None: - proposal.observations.append( - f"Network operating in {state.mode.value} mode with no active trigger event" - ) proposal.domain_summary = ( f"Risk review completed with no incoming disruption. Current operating mode is {state.mode.value}." ) else: - proposal.observations.append( - f"{event.type.value.replace('_', ' ')} detected at severity {event.severity:.2f}" - ) - proposal.risks.append( - f"Mode switched to {state.mode.value} because disruption severity requires closer monitoring" - ) proposal.domain_summary = ( f"{event.type.value.replace('_', ' ').title()} requires risk review for " f"{', '.join(event.entity_ids) if event.entity_ids else 'the network'}." @@ -80,8 +73,12 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: "external_api_data": api_payloads, }, ) - if not proposal.downstream_impacts and event is not None: - proposal.downstream_impacts.append( - "Elevated disruption risk may affect downstream service level and recovery speed." - ) + + # Clear other fields so only domain_summary is returned + proposal.observations.clear() + proposal.risks.clear() + proposal.downstream_impacts.clear() + proposal.recommended_action_ids.clear() + proposal.tradeoffs.clear() + return proposal From 07c770cf5f481edd11cc3f9d1a16fe292db40267 Mon Sep 17 00:00:00 2001 From: Mai Duc Duy Date: Wed, 15 Apr 2026 23:47:31 +0700 Subject: [PATCH 06/12] fix streaming/event_bus.py --- streaming/event_bus.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/streaming/event_bus.py b/streaming/event_bus.py index 7ec9ecd..f60fe7a 100644 --- a/streaming/event_bus.py +++ b/streaming/event_bus.py @@ -25,8 +25,8 @@ class RunEventBus: """ def __init__(self) -> None: - # dict[run_id → list of subscriber queues] - self._queues: dict[str, list[asyncio.Queue]] = defaultdict(list) + # dict[run_id → list of (loop, subscriber queue)] + self._queues: dict[str, list[tuple[asyncio.AbstractEventLoop, asyncio.Queue]]] = defaultdict(list) # sequence counter per run_id self._seq: dict[str, int] = defaultdict(int) @@ -44,11 +44,12 @@ def publish(self, run_id: str, event: ThinkingEvent) -> None: event.run_id = run_id self._seq[run_id] += 1 event.sequence = self._seq[run_id] - for q in list(self._queues.get(run_id, [])): + for loop, q in list(self._queues.get(run_id, [])): + if q.full(): + continue try: - q.put_nowait(event) - except asyncio.QueueFull: - # If subscriber is too slow — discard instead of blocking + loop.call_soon_threadsafe(q.put_nowait, event) + except Exception: pass def close(self, run_id: str) -> None: @@ -56,10 +57,12 @@ def close(self, run_id: str) -> None: Sends sentinel None into each queue to signal end-of-stream. Subscribers will exit the loop upon receiving None. """ - for q in list(self._queues.get(run_id, [])): + for loop, q in list(self._queues.get(run_id, [])): + if q.full(): + continue try: - q.put_nowait(None) - except asyncio.QueueFull: + loop.call_soon_threadsafe(q.put_nowait, None) + except Exception: pass # Clean up sequence counter self._seq.pop(run_id, None) @@ -78,8 +81,12 @@ async def subscribe(self, run_id: str) -> AsyncGenerator[ThinkingEvent, None]: Automatically cleans up the queue upon exit. """ + import time + loop = asyncio.get_running_loop() q: asyncio.Queue[ThinkingEvent | None] = asyncio.Queue(maxsize=512) - self._queues[run_id].append(q) + subscriber_tuple = (loop, q) + self._queues[run_id].append(subscriber_tuple) + last_yield = 0.0 try: while True: try: @@ -90,13 +97,21 @@ async def subscribe(self, run_id: str) -> AsyncGenerator[ThinkingEvent, None]: if event is None: # Sentinel — stream ended normally break + + # Pace the events so UI doesn't get flooded in exactly the same millisecond + now = time.time() + elapsed = now - last_yield + if elapsed < 0.5: + await asyncio.sleep(0.5 - elapsed) + last_yield = time.time() + yield event finally: # Cleanup: remove queue from subscriber list queues = self._queues.get(run_id) if queues is not None: try: - queues.remove(q) + queues.remove(subscriber_tuple) except ValueError: pass if not queues: @@ -117,3 +132,5 @@ def subscriber_count(self, run_id: str) -> int: # Singleton — import and use directly from any module event_bus = RunEventBus() + + From 843d03a6a5f93e08ad47b6a7de2d27e3c773ec21 Mon Sep 17 00:00:00 2001 From: Mai Duc Duy Date: Wed, 15 Apr 2026 23:55:09 +0700 Subject: [PATCH 07/12] feat: implement LangGraphControlTower for orchestrating agent workflows and telemetry tracing --- orchestrator/graph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/graph.py b/orchestrator/graph.py index a8857f6..4da832e 100644 --- a/orchestrator/graph.py +++ b/orchestrator/graph.py @@ -28,7 +28,8 @@ route_after_demand, route_after_inventory, route_after_logistics, - route_after_critic, route_after_planner, + route_after_critic, + route_after_planner, route_after_risk, route_after_supplier, ) @@ -870,4 +871,3 @@ def invoke( def build_graph() -> LangGraphControlTower: return LangGraphControlTower() -hControlTower() From b49e5da8f4ebef2946a98bc88e481abfb1562d01 Mon Sep 17 00:00:00 2001 From: Mai Duc Duy Date: Thu, 16 Apr 2026 00:51:57 +0700 Subject: [PATCH 08/12] feat/socket --- .env.example | 2 +- orchestrator/graph.py | 28 ++++++++++++++++++++++++++++ streaming/event_bus.py | 18 +++++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 30a2a8f..698134e 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ CHAINCOPILOT_LLM_ENABLED=false CHAINCOPILOT_LLM_TIMEOUT_S=4 CHAINCOPILOT_LLM_RETRY_ATTEMPTS=1 -CHAINCOPILOT_LLM_MODEL=gemini-2.5-flash +CHAINCOPILOT_LLM_MODEL=gemini-2.0-flash-lite # Planner mode # - hybrid: use LLM for candidate drafts, then deterministic scoring/selection diff --git a/orchestrator/graph.py b/orchestrator/graph.py index 4da832e..63ec8c3 100644 --- a/orchestrator/graph.py +++ b/orchestrator/graph.py @@ -624,6 +624,15 @@ def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: event = graph_state.get("event") trace_updater = graph_state.get("trace_updater") + plan_label = state.latest_plan.strategy_label if state.latest_plan else "unknown" + self._emit(graph_state, type="analysis", agent="critic", step="review_init", + message=f"Critic reviewing plan '{plan_label}' for feasibility and risk compliance", + data={ + "plan_id": state.latest_plan.plan_id if state.latest_plan else None, + "strategy": plan_label, + "action_count": len(state.latest_plan.actions) if state.latest_plan else 0, + }) + self._start_step(state, "critic", "agent", event) self._notify_trace_update(state, trace_updater) @@ -655,6 +664,25 @@ def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: }, ) critic_route = route_after_critic({"state": state}) + finding_count = ( + len(state.decision_logs[-1].critic_findings) + if state.decision_logs else 0 + ) + critic_summary = ( + state.decision_logs[-1].critic_summary + if state.decision_logs else "no findings" + ) + self._emit(graph_state, type="reflection", agent="critic", step="review_done", + message=f"Critic completed review — {finding_count} finding(s)", + data={ + "finding_count": finding_count, + "critic_summary": critic_summary, + "approval_required": state.latest_plan.approval_required if state.latest_plan else False, + }) + self._emit(graph_state, type="decision", agent="critic", step="routing", + message=f"Critic routing → {critic_route}", + data={"next_node": critic_route, + "reason": self._critic_route_reason(state, critic_route)}) self._record_route( state, "critic", diff --git a/streaming/event_bus.py b/streaming/event_bus.py index f60fe7a..3927685 100644 --- a/streaming/event_bus.py +++ b/streaming/event_bus.py @@ -56,16 +56,24 @@ def close(self, run_id: str) -> None: """ Sends sentinel None into each queue to signal end-of-stream. Subscribers will exit the loop upon receiving None. + + If a queue is full, drains one item to make room for the sentinel + so the subscriber is guaranteed to receive the termination signal. """ for loop, q in list(self._queues.get(run_id, [])): - if q.full(): - continue try: + if q.full(): + # Drain one slot so sentinel can be enqueued + try: + q.get_nowait() + except Exception: + pass loop.call_soon_threadsafe(q.put_nowait, None) except Exception: pass - # Clean up sequence counter + # Clean up sequence counter and queue registry self._seq.pop(run_id, None) + self._queues.pop(run_id, None) # ------------------------------------------------------------------ # # Consumer side — called from async WebSocket handler @@ -101,8 +109,8 @@ async def subscribe(self, run_id: str) -> AsyncGenerator[ThinkingEvent, None]: # Pace the events so UI doesn't get flooded in exactly the same millisecond now = time.time() elapsed = now - last_yield - if elapsed < 0.5: - await asyncio.sleep(0.5 - elapsed) + if elapsed < 0.1: + await asyncio.sleep(0.1 - elapsed) last_yield = time.time() yield event From 428832e4bbd5d2725f9551d951a94d76306b9602 Mon Sep 17 00:00:00 2001 From: Mai Duc Duy Date: Thu, 16 Apr 2026 03:17:07 +0700 Subject: [PATCH 09/12] feat: implement control tower runtime service and API scaffolding for orchestration and event management --- agents/risk.py | 2 ++ app_api/routers.py | 78 +++++++++++++++++++++++++++++++++++++++----- app_api/services.py | 4 +-- simulation/runner.py | 15 +++++---- 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/agents/risk.py b/agents/risk.py index 78d474c..08ddb93 100644 --- a/agents/risk.py +++ b/agents/risk.py @@ -82,3 +82,5 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: proposal.tradeoffs.clear() return proposal + + diff --git a/app_api/routers.py b/app_api/routers.py index cfdcb8f..02802b0 100644 --- a/app_api/routers.py +++ b/app_api/routers.py @@ -609,6 +609,27 @@ def run_scenario(request: ScenarioRequest) -> dict: payload["scenario_history_count"] = len(runtime.state.scenario_history) return payload + @router.post("/scenarios/run/stream") + def stream_run_scenario(request: ScenarioRequest, background_tasks: BackgroundTasks) -> dict: + """ + Trigger a scenario run with streaming via WebSocket. + """ + global MOCK_ENVIRONMENT + MOCK_ENVIRONMENT = request.scenario_name + from core.runtime_tracking import new_run_id + run_id = new_run_id() + background_tasks.add_task( + _stream_scenario_plan, + runtime_getter, + run_id, + request.scenario_name, + request.seed, + ) + return { + "run_id": run_id, + "ws_url": f"/ws/thinking/{run_id}", + } + @router.post("/decisions/{decision_id}/approve") def approve_decision(decision_id: str, request: LegacyApprovalRequest) -> dict: runtime = runtime_getter() @@ -660,25 +681,30 @@ async def websocket_thinking_stream(websocket: WebSocket, run_id: str) -> None: WebSocket endpoint for real-time thinking/reasoning stream. Subscribes to the event bus for the given run_id and sends - ThinkingEvents to the connected client as they occur. - - Connection closes when: - - Graph completes (server sends sentinel None) - - Client disconnects - - Timeout after 120 seconds + a combined payload of the current event and the full trace update. """ from streaming.event_bus import event_bus await websocket.accept() + runtime = runtime_getter() try: async for event in event_bus.subscribe(run_id): if event is None: break - await websocket.send_json(event.model_dump()) + + # Send a combined payload: the specific event + the overall trace snapshot + payload = { + "event": event.model_dump(), + "trace": latest_trace_view(runtime.state).model_dump(mode="json"), + } + await websocket.send_json(payload) except WebSocketDisconnect: pass finally: - await websocket.close() + try: + await websocket.close() + except Exception: + pass return router @@ -737,3 +763,39 @@ def _sync_run_with_run_id(runtime, run_id: str) -> None: updated = runtime.graph.invoke(runtime.state, None, run_id=run_id) _save_state(updated, runtime.store) runtime.state = updated + + +async def _stream_scenario_plan( + runtime_getter: Callable, run_id: str, scenario_name: str, seed: int +) -> None: + from streaming.event_bus import event_bus + from streaming.schemas import ThinkingEvent + + loop = asyncio.get_event_loop() + runtime = runtime_getter() + try: + await loop.run_in_executor( + None, + lambda: _sync_scenario_with_run_id(runtime, run_id, scenario_name, seed), + ) + except Exception as exc: + event_bus.publish( + run_id, + ThinkingEvent( + type="error", + agent="system", + step="fatal", + message=f"{exc.__class__.__name__}: {exc}", + data={"exception": exc.__class__.__name__}, + ), + ) + finally: + event_bus.close(run_id) + + +def _sync_scenario_with_run_id( + runtime, run_id: str, scenario_name: str, seed: int +) -> None: + # run_scenario internally calls graph.invoke via ScenarioRunner + # and handles Saving artifacts + runtime.run_scenario(scenario_name, seed=seed, run_id=run_id) diff --git a/app_api/services.py b/app_api/services.py index fe7f32b..b7aa431 100644 --- a/app_api/services.py +++ b/app_api/services.py @@ -405,7 +405,7 @@ def ingest_envelope( ), ) - def run_scenario(self, scenario_name: str, seed: int) -> SystemState: + def run_scenario(self, scenario_name: str, seed: int, run_id: str | None = None) -> SystemState: if scenario_name not in list_scenarios(): raise_not_found("scenario", scenario_name) @@ -414,7 +414,7 @@ def on_trace_update(trace): try: self.state = self.runner.run( - self.state, scenario_name, seed=seed, trace_updater=on_trace_update + self.state, scenario_name, seed=seed, trace_updater=on_trace_update, run_id=run_id ) except PendingApprovalError as exc: raise_conflict(str(exc), code="pending_approval") diff --git a/simulation/runner.py b/simulation/runner.py index adeb0c0..bd429c6 100644 --- a/simulation/runner.py +++ b/simulation/runner.py @@ -32,16 +32,17 @@ def run( scenario_name: str, seed: int = 7, trace_updater=None, + run_id: str | None = None, ) -> 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: + for i, event in enumerate(events): started_at = event.detected_at parent_run_id = state.run_id - run_id = new_run_id() + current_run_id = run_id if (i == 0 and run_id) else new_run_id() envelope = EventEnvelope( event_id=new_event_envelope_id(), event_class=EventClass.DOMAIN, @@ -58,16 +59,16 @@ def run( ) self.store.save_event_envelope(envelope) state = self.graph.invoke( - state, event, run_id=run_id, trace_updater=trace_updater + state, event, run_id=current_run_id, trace_updater=trace_updater ) decision = state.decision_logs[-1] if state.decision_logs else None execution = build_execution_record( - run_id=run_id, state=state, decision=decision + run_id=current_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_id=current_run_id, run_type=RunType.SCENARIO_STEP, state=state, started_at=started_at, @@ -76,9 +77,9 @@ def run( 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) + trace = clone_trace_for_run(state.latest_trace, current_run_id) if trace is not None: - self.store.save_trace(run_id, trace) + self.store.save_trace(current_run_id, trace) self.store.save_state(state) if decision is not None: self.store.save_decision_log(decision) From f6c4a38e9496a20bf2366ce94bff8361660d456b Mon Sep 17 00:00:00 2001 From: Mai Duc Duy Date: Thu, 16 Apr 2026 12:12:27 +0700 Subject: [PATCH 10/12] feat: implement API routers, orchestration graph, and streaming schemas for chain-copilot system --- app_api/routers.py | 3 +- orchestrator/graph.py | 295 ++++++++++++++++++++++++++++++++++-------- streaming/schemas.py | 5 + 3 files changed, 245 insertions(+), 58 deletions(-) diff --git a/app_api/routers.py b/app_api/routers.py index 02802b0..6b8c39b 100644 --- a/app_api/routers.py +++ b/app_api/routers.py @@ -694,7 +694,7 @@ async def websocket_thinking_stream(websocket: WebSocket, run_id: str) -> None: # Send a combined payload: the specific event + the overall trace snapshot payload = { - "event": event.model_dump(), + "event": event.model_dump(mode="json"), "trace": latest_trace_view(runtime.state).model_dump(mode="json"), } await websocket.send_json(payload) @@ -720,6 +720,7 @@ async def _stream_daily_plan(runtime_getter: Callable, run_id: str) -> None: """ from streaming.event_bus import event_bus from streaming.schemas import ThinkingEvent + from datetime import datetime loop = asyncio.get_event_loop() runtime = runtime_getter() diff --git a/orchestrator/graph.py b/orchestrator/graph.py index 63ec8c3..a6aea34 100644 --- a/orchestrator/graph.py +++ b/orchestrator/graph.py @@ -83,6 +83,7 @@ def _emit( step=step, message=message, data=data or {}, + timestamp=utc_now(), ), ) except Exception: @@ -344,19 +345,47 @@ def risk_node(self, graph_state: OrchestrationState) -> OrchestrationState: }: state.active_events.append(event) + # Step 1: Mode selection + self._emit(graph_state, type="thinking", agent="risk", step="mode_selection", + message=f"Selecting operating mode based on {len(state.active_events)} active events", + data={"active_events": len(state.active_events)}) + + # Step 2: External API calls + self._emit(graph_state, type="action", agent="risk", step="api_weather", + message="Fetching external Weather API for disruption intel", + data={"api": "weather", "endpoint": "/mock/weather"}) + + self._emit(graph_state, type="action", agent="risk", step="api_routes", + message="Fetching external Routes API for transport status", + data={"api": "routes", "endpoint": "/mock/routes"}) + + self._emit(graph_state, type="action", agent="risk", step="api_suppliers", + message="Fetching external Suppliers API for supplier health", + data={"api": "suppliers", "endpoint": "/mock/suppliers"}) + + # Step 3: LLM enrichment + self._emit(graph_state, type="thinking", agent="risk", step="llm_enrichment", + message="Calling LLM to synthesize risk assessment from API data", + data={"capability": "specialist", "purpose": "domain_summary_generation"}) + max_sev = max((e.severity for e in state.active_events), default=0.0) - self._emit(graph_state, type="analysis", agent="risk", step="event_scan", - message=f"Risk Agent scanning {len(state.active_events)} active events", - data={"active_events": len(state.active_events), "max_severity": round(max_sev, 2)}) - output = self.risk_agent.run(state, event) self._record_output(state, output) self._complete_agent_step(state, "risk", output) - + + # Step 4: Results + self._emit(graph_state, type="observation", agent="risk", step="results", + message=f"Risk assessment complete — {len(output.proposals)} mitigations proposed", + data={ + "proposals": len(output.proposals), + "llm_used": output.llm_used, + "llm_error": output.llm_error, + "mode": state.mode.value, + "max_severity": round(max_sev, 2), + "domain_summary": (output.domain_summary or "")[:200], + }) + risk_route = route_after_risk({"state": state}) - self._emit(graph_state, type="observation", agent="risk", step="proposals", - message=f"Risk Agent proposed {len(output.proposals)} actions", - data={"proposals": len(output.proposals), "observations": output.observations[:2]}) self._emit(graph_state, type="decision", agent="risk", step="routing", message=f"Next routing → {risk_route}", data={"next_node": risk_route, "reason": self._risk_route_reason(risk_route)}) @@ -381,21 +410,51 @@ def demand_node(self, graph_state: OrchestrationState) -> OrchestrationState: trace_updater = graph_state.get("trace_updater") sku_count = len(state.inventory) - self._emit(graph_state, type="analysis", agent="demand", step="demand_forecast", - message=f"Demand Agent analyzing forecast for {sku_count} SKUs", + self._emit(graph_state, type="start", agent="demand", step="init", + message=f"Demand Agent beginning forecast analysis for {sku_count} SKUs", data={"sku_count": sku_count}) self._start_step(state, "demand", "agent", event) self._notify_trace_update(state, trace_updater) - + + # Step 1: Check for demand spike event + has_spike = event and event.type.value == "demand_spike" + if has_spike: + multiplier = event.payload.get("multiplier", 1.5) + spike_sku = event.payload.get("sku") + self._emit(graph_state, type="analysis", agent="demand", step="spike_detection", + message=f"Demand spike detected: SKU {spike_sku} at {multiplier}x multiplier", + data={"spike_sku": spike_sku, "multiplier": multiplier}) + else: + self._emit(graph_state, type="analysis", agent="demand", step="routine_forecast", + message="No demand spike — running routine 30-day rolling forecast", + data={"method": "rolling_average_30d"}) + + # Step 2: Statistical computation + self._emit(graph_state, type="thinking", agent="demand", step="statistics", + message="Computing per-SKU average demand, standard deviation, and forecast adjustments", + data={"method": "pandas_rolling_stats", "window": 30}) + output = self.demand_agent.run(state, event) self._record_output(state, output) self._complete_agent_step(state, "demand", output) - + + # Step 3: LLM enrichment (if triggered) + if output.llm_used: + self._emit(graph_state, type="observation", agent="demand", step="llm_result", + message="LLM enriched demand summary with contextual analysis", + data={"llm_used": True, "domain_summary": (output.domain_summary or "")[:200]}) + else: + self._emit(graph_state, type="observation", agent="demand", step="deterministic_result", + message="Demand analysis completed using deterministic forecasting", + data={"llm_used": False, "llm_error": output.llm_error, + "proposals": len(output.proposals), + "observations": output.observations[:3]}) + next_node = route_after_demand({"state": state}) - self._emit(graph_state, type="observation", agent="demand", step="demand_done", - message=f"Demand analysis completed — moving to {next_node}", - data={"proposals": len(output.proposals), "observations": output.observations[:2]}) + self._emit(graph_state, type="decision", agent="demand", step="routing", + message=f"Demand analysis completed — routing to {next_node}", + data={"next_node": next_node, "proposals": len(output.proposals)}) self._record_route( state, @@ -412,29 +471,53 @@ def inventory_node(self, graph_state: OrchestrationState) -> OrchestrationState: event = graph_state.get("event") trace_updater = graph_state.get("trace_updater") + # Step 1: Initial scan critical_skus = sum( 1 for item in state.inventory.values() if item.on_hand + item.incoming_qty - item.forecast_qty < item.reorder_point ) - self._emit(graph_state, type="analysis", agent="inventory", step="stock_check", + below_safety = sum( + 1 for item in state.inventory.values() + if item.on_hand + item.incoming_qty - item.forecast_qty < item.safety_stock + ) + self._emit(graph_state, type="start", agent="inventory", step="init", message=f"Inventory Agent checking {len(state.inventory)} SKUs", - data={"sku_count": len(state.inventory), "critical_skus": critical_skus}) + data={"sku_count": len(state.inventory), "critical_skus": critical_skus, "below_safety": below_safety}) self._start_step(state, "inventory", "agent", event) self._notify_trace_update(state, trace_updater) - + + # Step 2: Computational analysis + self._emit(graph_state, type="thinking", agent="inventory", step="rop_calculation", + message="Computing Reorder Point (ROP), Safety Stock (SS), and Days of Cover for each SKU", + data={"method": "z_score_based_safety_stock", "formula": "ROP = LTD + z * σ * √LT"}) + + self._emit(graph_state, type="thinking", agent="inventory", step="replenishment_check", + message="Identifying SKUs that need replenishment by comparing projected stock vs ROP", + data={"comparison": "projected_stock < reorder_point"}) + output = self.inventory_agent.run(state, event) self._record_output(state, output) self._complete_agent_step(state, "inventory", output) - + + # Step 3: Results + reorder_count = len(output.proposals) + if output.llm_used: + self._emit(graph_state, type="observation", agent="inventory", step="llm_result", + message=f"LLM enriched inventory analysis — {reorder_count} reorder proposals generated", + data={"llm_used": True, "reorder_proposals": reorder_count, + "domain_summary": (output.domain_summary or "")[:200]}) + else: + self._emit(graph_state, type="observation", agent="inventory", step="results", + message=f"Inventory analysis complete — {reorder_count} reorder proposals, {below_safety} SKUs below safety stock", + data={"llm_used": False, "llm_error": output.llm_error, + "reorder_proposals": reorder_count, "below_safety": below_safety, + "observations": output.observations[:3]}) + next_node = route_after_inventory({"state": state}) - below_safety = sum( - 1 for item in state.inventory.values() - if item.on_hand + item.incoming_qty - item.forecast_qty < item.safety_stock - ) - self._emit(graph_state, type="observation", agent="inventory", step="inventory_done", - message=f"Identified {below_safety} SKUs below safety stock — moving to {next_node}", - data={"proposals": len(output.proposals), "below_safety": below_safety}) + self._emit(graph_state, type="decision", agent="inventory", step="routing", + message=f"Inventory planning done — routing to {next_node}", + data={"next_node": next_node}) self._record_route( state, @@ -452,21 +535,50 @@ def supplier_node(self, graph_state: OrchestrationState) -> OrchestrationState: trace_updater = graph_state.get("trace_updater") supplier_count = len(state.suppliers) - self._emit(graph_state, type="analysis", agent="supplier", step="supplier_scan", + self._emit(graph_state, type="start", agent="supplier", step="init", message=f"Supplier Agent evaluating {supplier_count} suppliers", data={"supplier_count": supplier_count}) self._start_step(state, "supplier", "agent", event) self._notify_trace_update(state, trace_updater) - + + # Step 1: Detecting delayed suppliers + delayed_supplier = event.payload.get("supplier_id") if event and event.type.value == "supplier_delay" else None + if delayed_supplier: + self._emit(graph_state, type="analysis", agent="supplier", step="delay_detection", + message=f"Supplier delay detected: {delayed_supplier}", + data={"delayed_supplier": delayed_supplier, "event_type": event.type.value}) + else: + self._emit(graph_state, type="analysis", agent="supplier", step="routine_eval", + message="No supplier disruption — running routine supplier ranking", + data={"method": "reliability_cost_leadtime_sort"}) + + # Step 2: Ranking and switching + self._emit(graph_state, type="thinking", agent="supplier", step="ranking", + message="Ranking suppliers by reliability, unit cost, and lead time for each SKU", + data={"criteria": ["reliability", "unit_cost", "lead_time_days"]}) + output = self.supplier_agent.run(state, event) self._record_output(state, output) self._complete_agent_step(state, "supplier", output) - + + # Step 3: Results + if output.llm_used: + self._emit(graph_state, type="observation", agent="supplier", step="llm_result", + message=f"LLM enriched supplier analysis — {len(output.proposals)} switch proposals", + data={"llm_used": True, "proposals": len(output.proposals), + "domain_summary": (output.domain_summary or "")[:200]}) + else: + self._emit(graph_state, type="observation", agent="supplier", step="results", + message=f"Supplier Agent proposed {len(output.proposals)} supplier switches", + data={"llm_used": False, "llm_error": output.llm_error, + "proposals": len(output.proposals), + "observations": output.observations[:3]}) + next_node = route_after_supplier({"state": state}) - self._emit(graph_state, type="observation", agent="supplier", step="supplier_done", - message=f"Supplier Agent proposed {len(output.proposals)} actions — moving to {next_node}", - data={"proposals": len(output.proposals), "observations": output.observations[:2]}) + self._emit(graph_state, type="decision", agent="supplier", step="routing", + message=f"Supplier evaluation done — routing to {next_node}", + data={"next_node": next_node}) self._record_route( state, @@ -484,21 +596,51 @@ def logistics_node(self, graph_state: OrchestrationState) -> OrchestrationState: trace_updater = graph_state.get("trace_updater") route_count = len(state.routes) - self._emit(graph_state, type="analysis", agent="logistics", step="route_scan", + self._emit(graph_state, type="start", agent="logistics", step="init", message=f"Logistics Agent checking {route_count} transport routes", data={"route_count": route_count}) self._start_step(state, "logistics", "agent", event) self._notify_trace_update(state, trace_updater) - + + # Step 1: Route blockage detection + blocked_route = event.payload.get("route_id") if event and event.type.value == "route_blockage" else None + if blocked_route: + self._emit(graph_state, type="analysis", agent="logistics", step="blockage_detection", + message=f"Route blockage detected on {blocked_route}", + data={"blocked_route": blocked_route, "reason": event.payload.get("reason", "unknown")}) + else: + self._emit(graph_state, type="analysis", agent="logistics", step="routine_check", + message="No route blockage — optimizing transport routes", + data={"method": "risk_transit_cost_sort"}) + + # Step 2: Route ranking + available = sum(1 for r in state.routes.values() if r.status != "blocked") + self._emit(graph_state, type="thinking", agent="logistics", step="route_ranking", + message=f"Ranking {available} available routes by risk score, transit time, and cost", + data={"available_routes": available, "criteria": ["risk_score", "transit_days", "cost"]}) + output = self.logistics_agent.run(state, event) self._record_output(state, output) self._complete_agent_step(state, "logistics", output) - + + # Step 3: Results + if output.llm_used: + self._emit(graph_state, type="observation", agent="logistics", step="llm_result", + message=f"LLM enriched logistics analysis — {len(output.proposals)} reroute proposals", + data={"llm_used": True, "proposals": len(output.proposals), + "domain_summary": (output.domain_summary or "")[:200]}) + else: + self._emit(graph_state, type="observation", agent="logistics", step="results", + message=f"Logistics Agent proposed {len(output.proposals)} reroute actions", + data={"llm_used": False, "llm_error": output.llm_error, + "proposals": len(output.proposals), + "observations": output.observations[:3]}) + next_node = route_after_logistics({"state": state}) - self._emit(graph_state, type="observation", agent="logistics", step="logistics_done", - message=f"Logistics Agent proposed {len(output.proposals)} actions — moving to {next_node}", - data={"proposals": len(output.proposals), "observations": output.observations[:2]}) + self._emit(graph_state, type="decision", agent="logistics", step="routing", + message=f"Logistics planning done — routing to {next_node}", + data={"next_node": next_node}) self._record_route( state, @@ -516,16 +658,33 @@ def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: trace_updater = graph_state.get("trace_updater") candidate_count = len(state.candidate_actions) - self._emit(graph_state, type="thinking", agent="planner", step="strategy_init", - message="Planner generating 3 strategies: cost_first, balanced, resilience_first", - data={"strategies": 3, "candidate_actions": candidate_count}) - self._emit(graph_state, type="thinking", agent="planner", step="simulation", - message="Running simulations for each strategy", - data={"candidate_actions": candidate_count}) - + self._emit(graph_state, type="start", agent="planner", step="init", + message=f"Planner starting with {candidate_count} candidate actions from specialist agents", + data={"candidate_actions": candidate_count, "mode": state.mode.value}) + self._start_step(state, "planner", "agent", event) self._notify_trace_update(state, trace_updater) - + + # Step 1: Feasibility filtering + self._emit(graph_state, type="thinking", agent="planner", step="feasibility_filter", + message="Filtering candidate actions through hard constraints", + data={"method": "evaluate_hard_constraints", "input_count": candidate_count}) + + # Step 2: Strategy generation + self._emit(graph_state, type="thinking", agent="planner", step="strategy_generation", + message="Generating 3 strategies: cost_first, balanced, resilience_first", + data={"strategies": ["cost_first", "balanced", "resilience_first"]}) + + # Step 3: LLM or deterministic + self._emit(graph_state, type="action", agent="planner", step="candidate_drafting", + message="Calling LLM to draft candidate plans (falling back to deterministic if unavailable)", + data={"capability": "planner", "fallback": "deterministic_sort"}) + + # Step 4: Simulation + self._emit(graph_state, type="thinking", agent="planner", step="simulation", + message="Running multi-horizon simulations for each strategy to project KPIs", + data={"method": "evaluate_candidate_plan", "metrics": ["service_level", "cost", "disruption_risk", "recovery_speed"]}) + output = self.planner_agent.run(state, event) self._record_output(state, output) self._update_trace_from_plan(state) @@ -598,7 +757,15 @@ def planner_node(self, graph_state: OrchestrationState) -> OrchestrationState: ), }, ) + + # Step 5: Report results if state.latest_plan is not None: + generated_by = state.latest_plan.generated_by or "unknown" + self._emit(graph_state, type="observation", agent="planner", step="generation_method", + message=f"Plan generated by: {generated_by}", + data={"generated_by": generated_by, "llm_used": output.llm_used, + "llm_error": output.llm_error}) + self._emit(graph_state, type="decision", agent="planner", step="plan_selected", message=f"Selected strategy '{state.latest_plan.strategy_label or 'unknown'}' " f"(score={state.latest_plan.score:.3f})", @@ -625,8 +792,8 @@ def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: trace_updater = graph_state.get("trace_updater") plan_label = state.latest_plan.strategy_label if state.latest_plan else "unknown" - self._emit(graph_state, type="analysis", agent="critic", step="review_init", - message=f"Critic reviewing plan '{plan_label}' for feasibility and risk compliance", + self._emit(graph_state, type="start", agent="critic", step="init", + message=f"Critic starting review of plan '{plan_label}'", data={ "plan_id": state.latest_plan.plan_id if state.latest_plan else None, "strategy": plan_label, @@ -635,7 +802,12 @@ def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: self._start_step(state, "critic", "agent", event) self._notify_trace_update(state, trace_updater) - + + # Step 1: LLM critique + self._emit(graph_state, type="action", agent="critic", step="llm_critique", + message="Calling LLM to critique the selected plan against alternatives", + data={"capability": "critic", "purpose": "plan_review_and_findings"}) + output = self.critic_agent.run(state, event) state.agent_outputs[output.agent] = output self._complete_step( @@ -663,6 +835,8 @@ def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: else 0, }, ) + + # Step 2: Report results critic_route = route_after_critic({"state": state}) finding_count = ( len(state.decision_logs[-1].critic_findings) @@ -672,17 +846,24 @@ def critic_node(self, graph_state: OrchestrationState) -> OrchestrationState: state.decision_logs[-1].critic_summary if state.decision_logs else "no findings" ) - self._emit(graph_state, type="reflection", agent="critic", step="review_done", - message=f"Critic completed review — {finding_count} finding(s)", - data={ - "finding_count": finding_count, - "critic_summary": critic_summary, - "approval_required": state.latest_plan.approval_required if state.latest_plan else False, - }) + + if output.llm_used: + self._emit(graph_state, type="observation", agent="critic", step="llm_result", + message=f"LLM critique completed — {finding_count} finding(s)", + data={"llm_used": True, "finding_count": finding_count, + "critic_summary": (critic_summary or "")[:200]}) + else: + self._emit(graph_state, type="observation", agent="critic", step="deterministic_result", + message=f"Deterministic review completed — {finding_count} finding(s)", + data={"llm_used": False, "llm_error": output.llm_error, + "finding_count": finding_count, + "critic_summary": (critic_summary or "")[:200]}) + self._emit(graph_state, type="decision", agent="critic", step="routing", message=f"Critic routing → {critic_route}", data={"next_node": critic_route, - "reason": self._critic_route_reason(state, critic_route)}) + "reason": self._critic_route_reason(state, critic_route), + "approval_required": state.latest_plan.approval_required if state.latest_plan else False}) self._record_route( state, "critic", diff --git a/streaming/schemas.py b/streaming/schemas.py index a6b8d3c..cb87201 100644 --- a/streaming/schemas.py +++ b/streaming/schemas.py @@ -2,6 +2,7 @@ from typing import Any, Literal +from datetime import datetime from pydantic import BaseModel, Field @@ -56,3 +57,7 @@ class ThinkingEvent(BaseModel): default=0, description="Order of the event in the run — auto-incremented by EventBus", ) + timestamp: datetime = Field( + default_factory=lambda: datetime.now(), + description="ISO timestamp of when the event was emitted", + ) From d9c1fd977c977c40c402417dfc33ad0eeda0f060 Mon Sep 17 00:00:00 2001 From: dinhdat07 Date: Thu, 16 Apr 2026 16:20:39 +0700 Subject: [PATCH 11/12] chore: remove unused import to pass ruff check --- app_api/routers.py | 3 --- scratch/test_realtime.py | 1 - scratch/test_realtime_v2.py | 4 ++-- scratch/test_ws_env.py | 1 - 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app_api/routers.py b/app_api/routers.py index 6b8c39b..e1abf5e 100644 --- a/app_api/routers.py +++ b/app_api/routers.py @@ -720,7 +720,6 @@ async def _stream_daily_plan(runtime_getter: Callable, run_id: str) -> None: """ from streaming.event_bus import event_bus from streaming.schemas import ThinkingEvent - from datetime import datetime loop = asyncio.get_event_loop() runtime = runtime_getter() @@ -752,11 +751,9 @@ def _sync_run_with_run_id(runtime, run_id: str) -> None: Saves state and artifacts after completion (mirrors run_daily logic). """ from orchestrator.service import ( - PendingApprovalError, _save_state, ensure_no_pending_plan, ) - from fastapi import HTTPException as _HTTPException ensure_no_pending_plan(runtime.state) # graph.invoke() forwards run_id into OrchestrationState diff --git a/scratch/test_realtime.py b/scratch/test_realtime.py index d1ff3f1..e7af96b 100644 --- a/scratch/test_realtime.py +++ b/scratch/test_realtime.py @@ -1,4 +1,3 @@ -import asyncio import json import sys import os diff --git a/scratch/test_realtime_v2.py b/scratch/test_realtime_v2.py index 6668615..2196d2f 100644 --- a/scratch/test_realtime_v2.py +++ b/scratch/test_realtime_v2.py @@ -11,8 +11,8 @@ async def test_realtime_v2(): try: response = await client.post(url, timeout=10.0) data = response.json() - except Exception as e: - print(f"Error: Could not connect to server at localhost:8000. Did you start uvicorn?") + except Exception: + print("Error: Could not connect to server at localhost:8000. Did you start uvicorn?") return run_id = data["run_id"] diff --git a/scratch/test_ws_env.py b/scratch/test_ws_env.py index a567341..602a7d1 100644 --- a/scratch/test_ws_env.py +++ b/scratch/test_ws_env.py @@ -1,7 +1,6 @@ import asyncio from fastapi import FastAPI, WebSocket import uvicorn -import httpx import websockets from multiprocessing import Process import time From 1c469c7851eceae2f02e1e5e34f6b0b4697ef18e Mon Sep 17 00:00:00 2001 From: dinhdat07 Date: Thu, 16 Apr 2026 16:31:31 +0700 Subject: [PATCH 12/12] fix(risk-agent): restore summary observations for supplier delay flows --- agents/risk.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/agents/risk.py b/agents/risk.py index 08ddb93..b8c6680 100644 --- a/agents/risk.py +++ b/agents/risk.py @@ -23,10 +23,19 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: state.mode = select_mode(state, event) api_payloads = {} if event is None: + proposal.observations.append( + f"Network operating in {state.mode.value} mode with no active trigger event" + ) proposal.domain_summary = ( f"Risk review completed with no incoming disruption. Current operating mode is {state.mode.value}." ) else: + proposal.observations.append( + f"{event.type.value.replace('_', ' ')} detected at severity {event.severity:.2f}" + ) + proposal.risks.append( + f"Mode switched to {state.mode.value} because disruption severity requires closer monitoring" + ) proposal.domain_summary = ( f"{event.type.value.replace('_', ' ').title()} requires risk review for " f"{', '.join(event.entity_ids) if event.entity_ids else 'the network'}." @@ -73,11 +82,13 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal: "external_api_data": api_payloads, }, ) - - # Clear other fields so only domain_summary is returned - proposal.observations.clear() - proposal.risks.clear() - proposal.downstream_impacts.clear() + + if not proposal.downstream_impacts and event is not None: + proposal.downstream_impacts.append( + "Elevated disruption risk may affect downstream service level and recovery speed." + ) + + # Keep the risk agent non-prescriptive even when the LLM returns extras. proposal.recommended_action_ids.clear() proposal.tradeoffs.clear()