From 60a7ec56c1f2477dad8ca187b126a94e6991c735 Mon Sep 17 00:00:00 2001 From: dinhdat07 Date: Tue, 14 Apr 2026 16:57:46 +0000 Subject: [PATCH 1/3] 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 48b194d1f2d08f7d731cf29a81c68f2c2ec3755d Mon Sep 17 00:00:00 2001 From: trandangduat Date: Wed, 15 Apr 2026 14:43:22 +0700 Subject: [PATCH 2/3] 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 3/3] 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