Download brain/app/api/allocation.py from MouleeswaranM/FairRelay: direct link, hf CLI and curl.
- Browser
- Download file 34.1 kB
-
https://huggingface.co/MouleeswaranM/FairRelay/resolve/refs%2Fpr%2F2/brain/app/api/allocation.py
- Command line
-
hf download hf://MouleeswaranM/FairRelay@refs/pr/2/brain/app/api/allocation.py
-
curl -L -o allocation.py https://huggingface.co/MouleeswaranM/FairRelay/resolve/refs%2Fpr%2F2/brain/app/api/allocation.py
34.1 kB
| """ | |
| Allocation API endpoint. | |
| Handles POST /api/v1/allocate for fair route allocation using multi-agent pipeline. | |
| Phase 4.1: Multi-agent architecture with MLEffortAgent, RoutePlannerAgent, FairnessManagerAgent. | |
| Phase 4.2: Added Driver Liaison Agents and Final Resolution for negotiation. | |
| Phase 4.3: Added ExplainabilityAgent v2 for template-based explanations. | |
| Phase 8: Learning Agent integration for bandit-based config tuning. | |
| """ | |
| import statistics | |
| import uuid | |
| from datetime import datetime, timedelta | |
| from typing import Dict, List, Optional | |
| from fastapi import APIRouter, Depends, HTTPException, status | |
| from sqlalchemy import select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.database import get_db | |
| from app.models import Driver, Package, Route, RoutePackage, Assignment | |
| from app.models.driver import PreferredLanguage, VehicleType | |
| from app.models.package import PackagePriority | |
| from app.models.allocation_run import AllocationRun, AllocationRunStatus | |
| from app.models.decision_log import DecisionLog | |
| from app.schemas.allocation import ( | |
| AllocationRequest, | |
| AllocationResponse, | |
| AssignmentResponse, | |
| GlobalFairness, | |
| RouteSummary, | |
| ) | |
| from app.schemas.agent_schemas import ( | |
| FairnessThresholds, | |
| DriverAssignmentProposal, | |
| DriverContext, | |
| DriverLiaisonDecision, | |
| ) | |
| from app.services.clustering import cluster_packages, order_stops_by_nearest_neighbor | |
| from app.services.workload import calculate_workload, calculate_route_difficulty, estimate_route_time | |
| from app.services.fairness import calculate_fairness_score | |
| from app.services.explainability import ExplainabilityAgent, generate_explanation | |
| from app.services.ml_effort_agent import MLEffortAgent | |
| from app.services.route_planner_agent import RoutePlannerAgent | |
| from app.services.fairness_manager_agent import FairnessManagerAgent | |
| from app.services.driver_liaison_agent import DriverLiaisonAgent | |
| from app.services.final_resolution import FinalResolutionAgent | |
| from app.models.driver import DriverStatsDaily, DriverFeedback | |
| from app.models.manual_override import ManualOverride | |
| from app.models.fairness_config import FairnessConfig | |
| from app.schemas.explainability import DriverExplanationInput | |
| from app.services.learning_agent import LearningAgent, hash_config | |
| router = APIRouter(prefix="/allocate", tags=["Allocation"]) | |
| async def allocate( | |
| request: AllocationRequest, | |
| db: AsyncSession = Depends(get_db), | |
| ) -> AllocationResponse: | |
| """Perform fair route allocation using multi-agent pipeline.""" | |
| # Validate input | |
| if not request.packages: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="At least 1 package is required", | |
| ) | |
| if not request.drivers: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="At least 1 driver is required", | |
| ) | |
| # ========== START ALLOCATION RUN ========== | |
| allocation_run = AllocationRun( | |
| date=request.allocation_date, | |
| num_drivers=len(request.drivers), | |
| num_packages=len(request.packages), | |
| num_routes=0, # Updated after clustering | |
| status=AllocationRunStatus.PENDING, | |
| started_at=datetime.utcnow(), | |
| ) | |
| db.add(allocation_run) | |
| await db.flush() # Get allocation_run.id | |
| try: | |
| # ========== PHASE 0: UPSERT DATA & CLUSTERING ========== | |
| # Step 1: Upsert drivers | |
| driver_map = {} # external_id -> Driver model | |
| driver_models: List[Driver] = [] | |
| for driver_input in request.drivers: | |
| result = await db.execute( | |
| select(Driver).where(Driver.external_id == driver_input.id) | |
| ) | |
| driver = result.scalar_one_or_none() | |
| if driver: | |
| driver.name = driver_input.name | |
| driver.vehicle_capacity_kg = driver_input.vehicle_capacity_kg | |
| driver.preferred_language = PreferredLanguage(driver_input.preferred_language) | |
| else: | |
| driver = Driver( | |
| external_id=driver_input.id, | |
| name=driver_input.name, | |
| vehicle_capacity_kg=driver_input.vehicle_capacity_kg, | |
| preferred_language=PreferredLanguage(driver_input.preferred_language), | |
| vehicle_type=VehicleType.ICE, | |
| ) | |
| db.add(driver) | |
| driver_map[driver_input.id] = driver | |
| await db.flush() | |
| driver_models = list(driver_map.values()) | |
| # Step 2: Upsert packages | |
| package_map = {} | |
| package_dicts = [] | |
| for pkg_input in request.packages: | |
| result = await db.execute( | |
| select(Package).where(Package.external_id == pkg_input.id) | |
| ) | |
| package = result.scalar_one_or_none() | |
| if package: | |
| package.weight_kg = pkg_input.weight_kg | |
| package.fragility_level = pkg_input.fragility_level | |
| package.address = pkg_input.address | |
| package.latitude = pkg_input.latitude | |
| package.longitude = pkg_input.longitude | |
| package.priority = PackagePriority(pkg_input.priority) | |
| else: | |
| package = Package( | |
| external_id=pkg_input.id, | |
| weight_kg=pkg_input.weight_kg, | |
| fragility_level=pkg_input.fragility_level, | |
| address=pkg_input.address, | |
| latitude=pkg_input.latitude, | |
| longitude=pkg_input.longitude, | |
| priority=PackagePriority(pkg_input.priority), | |
| ) | |
| db.add(package) | |
| package_map[pkg_input.id] = package | |
| package_dicts.append({ | |
| "external_id": pkg_input.id, | |
| "weight_kg": pkg_input.weight_kg, | |
| "fragility_level": pkg_input.fragility_level, | |
| "address": pkg_input.address, | |
| "latitude": pkg_input.latitude, | |
| "longitude": pkg_input.longitude, | |
| "priority": pkg_input.priority, | |
| }) | |
| await db.flush() | |
| # Step 3: Cluster packages into routes | |
| clusters = cluster_packages( | |
| packages=package_dicts, | |
| num_drivers=len(request.drivers), | |
| ) | |
| # Step 4: Create routes | |
| route_models: List[Route] = [] | |
| route_dicts = [] | |
| for cluster in clusters: | |
| ordered_packages = order_stops_by_nearest_neighbor( | |
| cluster.packages, | |
| request.warehouse.lat, | |
| request.warehouse.lng, | |
| ) | |
| # Calculate total distance (Warehouse -> Stop 1 -> ... -> Stop N -> Warehouse) | |
| from app.services.clustering import haversine_distance | |
| total_dist = 0.0 | |
| curr_lat, curr_lng = request.warehouse.lat, request.warehouse.lng | |
| for p in ordered_packages: | |
| dist = haversine_distance(curr_lat, curr_lng, p["latitude"], p["longitude"]) | |
| total_dist += dist | |
| curr_lat, curr_lng = p["latitude"], p["longitude"] | |
| # Add return trip to warehouse | |
| total_dist += haversine_distance(curr_lat, curr_lng, request.warehouse.lat, request.warehouse.lng) | |
| avg_fragility = sum(p["fragility_level"] for p in cluster.packages) / max(len(cluster.packages), 1) | |
| difficulty = calculate_route_difficulty( | |
| total_weight_kg=cluster.total_weight_kg, | |
| num_stops=cluster.num_stops, | |
| avg_fragility=avg_fragility, | |
| ) | |
| est_time = estimate_route_time( | |
| num_packages=cluster.num_packages, | |
| num_stops=cluster.num_stops, | |
| ) | |
| route = Route( | |
| date=request.allocation_date, | |
| cluster_id=cluster.cluster_id, | |
| total_weight_kg=cluster.total_weight_kg, | |
| num_packages=cluster.num_packages, | |
| num_stops=cluster.num_stops, | |
| route_difficulty_score=difficulty, | |
| estimated_time_minutes=est_time, | |
| total_distance_km=total_dist, | |
| ) | |
| db.add(route) | |
| route_models.append(route) | |
| workload = calculate_workload({ | |
| "num_packages": cluster.num_packages, | |
| "total_weight_kg": cluster.total_weight_kg, | |
| "route_difficulty_score": difficulty, | |
| "estimated_time_minutes": est_time, | |
| }) | |
| route_dicts.append({ | |
| "cluster_id": cluster.cluster_id, | |
| "num_packages": cluster.num_packages, | |
| "total_weight_kg": cluster.total_weight_kg, | |
| "num_stops": cluster.num_stops, | |
| "route_difficulty_score": difficulty, | |
| "estimated_time_minutes": est_time, | |
| "workload_score": workload, | |
| "packages": ordered_packages, | |
| }) | |
| await db.flush() | |
| # Update allocation run with route count | |
| allocation_run.num_routes = len(route_models) | |
| # Create RoutePackage associations | |
| for i, route in enumerate(route_models): | |
| for stop_order, pkg_data in enumerate(route_dicts[i]["packages"]): | |
| package = package_map[pkg_data["external_id"]] | |
| route_package = RoutePackage( | |
| route_id=route.id, | |
| package_id=package.id, | |
| stop_order=stop_order + 1, | |
| ) | |
| db.add(route_package) | |
| # ========== PHASE 1: ML EFFORT AGENT ========== | |
| ml_agent = MLEffortAgent() | |
| # Get active fairness config for EV settings | |
| config_result = await db.execute( | |
| select(FairnessConfig).where(FairnessConfig.is_active == True).limit(1) | |
| ) | |
| active_config = config_result.scalar_one_or_none() | |
| ev_config = { | |
| "safety_margin_pct": active_config.ev_safety_margin_pct if active_config else 10.0, | |
| "charging_penalty_weight": active_config.ev_charging_penalty_weight if active_config else 0.3, | |
| } | |
| effort_result = ml_agent.compute_effort_matrix( | |
| drivers=driver_models, | |
| routes=route_models, | |
| ev_config=ev_config, | |
| ) | |
| # Log decision | |
| ml_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="ML_EFFORT", | |
| step_type="MATRIX_GENERATION", | |
| input_snapshot=ml_agent.get_input_snapshot(driver_models, route_models), | |
| output_snapshot={ | |
| **ml_agent.get_output_snapshot(effort_result), | |
| "num_infeasible_ev_pairs": len(effort_result.infeasible_pairs), | |
| }, | |
| ) | |
| db.add(ml_log) | |
| # ========== PHASE 1.5: RECOVERY TARGETS (Phase 7) ========== | |
| from app.services.recovery_service import get_driver_recovery_targets | |
| driver_ids = [d.id for d in driver_models] | |
| recovery_targets = await get_driver_recovery_targets( | |
| db, driver_ids, request.allocation_date, active_config | |
| ) | |
| recovery_penalty_weight = active_config.recovery_penalty_weight if active_config else 3.0 | |
| # ========== PHASE 2: ROUTE PLANNER AGENT - PROPOSAL 1 ========== | |
| planner_agent = RoutePlannerAgent() | |
| proposal1 = planner_agent.plan( | |
| effort_result=effort_result, | |
| drivers=driver_models, | |
| routes=route_models, | |
| recovery_targets=recovery_targets, | |
| recovery_penalty_weight=recovery_penalty_weight, | |
| proposal_number=1, | |
| ) | |
| # Log proposal 1 | |
| proposal1_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="ROUTE_PLANNER", | |
| step_type="PROPOSAL_1", | |
| input_snapshot=planner_agent.get_input_snapshot(effort_result), | |
| output_snapshot=planner_agent.get_output_snapshot(proposal1), | |
| ) | |
| db.add(proposal1_log) | |
| # ========== PHASE 3: FAIRNESS MANAGER AGENT ========== | |
| fairness_agent = FairnessManagerAgent( | |
| thresholds=FairnessThresholds( | |
| gini_threshold=0.33, | |
| stddev_threshold=25.0, | |
| max_gap_threshold=25.0, | |
| ) | |
| ) | |
| fairness_check1 = fairness_agent.check(proposal1, proposal_number=1) | |
| # Log fairness check 1 | |
| fairness1_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="FAIRNESS_MANAGER", | |
| step_type="FAIRNESS_CHECK_PROPOSAL_1", | |
| input_snapshot=fairness_agent.get_input_snapshot(proposal1), | |
| output_snapshot=fairness_agent.get_output_snapshot(fairness_check1), | |
| ) | |
| db.add(fairness1_log) | |
| # Determine final allocation | |
| final_plan = proposal1 | |
| final_fairness = fairness_check1 | |
| if fairness_check1.status == "REOPTIMIZE" and fairness_check1.recommendations: | |
| # Build penalties and run Proposal 2 | |
| penalties = planner_agent.build_penalties_from_recommendations( | |
| fairness_check1.recommendations, | |
| proposal1.per_driver_effort, | |
| ) | |
| proposal2 = planner_agent.plan( | |
| effort_result=effort_result, | |
| drivers=driver_models, | |
| routes=route_models, | |
| fairness_penalties=penalties, | |
| recovery_targets=recovery_targets, | |
| recovery_penalty_weight=recovery_penalty_weight, | |
| proposal_number=2, | |
| ) | |
| # Log proposal 2 | |
| proposal2_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="ROUTE_PLANNER", | |
| step_type="PROPOSAL_2", | |
| input_snapshot=planner_agent.get_input_snapshot(effort_result, penalties), | |
| output_snapshot=planner_agent.get_output_snapshot(proposal2), | |
| ) | |
| db.add(proposal2_log) | |
| # Check fairness of proposal 2 | |
| fairness_check2 = fairness_agent.check(proposal2, proposal_number=2) | |
| # Log fairness check 2 | |
| fairness2_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="FAIRNESS_MANAGER", | |
| step_type="FAIRNESS_CHECK_PROPOSAL_2", | |
| input_snapshot=fairness_agent.get_input_snapshot(proposal2), | |
| output_snapshot=fairness_agent.get_output_snapshot(fairness_check2), | |
| ) | |
| db.add(fairness2_log) | |
| # Use proposal 2 if it improves fairness | |
| if (fairness_check2.metrics.gini_index <= fairness_check1.metrics.gini_index or | |
| fairness_check2.metrics.max_gap < fairness_check1.metrics.max_gap): | |
| final_plan = proposal2 | |
| final_fairness = fairness_check2 | |
| # ========== PHASE 4: DRIVER LIAISON AGENTS (Phase 4.2) ========== | |
| # Build DriverAssignmentProposals with ranking | |
| sorted_allocations = sorted( | |
| final_plan.allocation, | |
| key=lambda x: x.effort, | |
| reverse=True # Highest effort = rank 1 | |
| ) | |
| driver_proposals: List[DriverAssignmentProposal] = [] | |
| for rank, alloc_item in enumerate(sorted_allocations, start=1): | |
| driver_proposals.append(DriverAssignmentProposal( | |
| driver_id=str(alloc_item.driver_id), | |
| route_id=str(alloc_item.route_id), | |
| effort=alloc_item.effort, | |
| rank_in_team=rank, | |
| )) | |
| # Build DriverContexts from recent stats (last 7 days) | |
| driver_contexts: Dict[str, DriverContext] = {} | |
| cutoff_date = request.allocation_date - timedelta(days=7) | |
| for driver in driver_models: | |
| driver_id_str = str(driver.id) | |
| # Query recent daily stats | |
| stats_result = await db.execute( | |
| select(DriverStatsDaily) | |
| .where(DriverStatsDaily.driver_id == driver.id) | |
| .where(DriverStatsDaily.date >= cutoff_date) | |
| .order_by(DriverStatsDaily.date.desc()) | |
| ) | |
| recent_stats = stats_result.scalars().all() | |
| if recent_stats: | |
| recent_efforts = [s.avg_workload_score for s in recent_stats if s.avg_workload_score] | |
| if recent_efforts: | |
| recent_avg = statistics.mean(recent_efforts) | |
| recent_std = statistics.stdev(recent_efforts) if len(recent_efforts) > 1 else 0.0 | |
| else: | |
| recent_avg = final_fairness.metrics.avg_effort | |
| recent_std = final_fairness.metrics.std_dev | |
| # Count hard days (above avg + std) | |
| hard_threshold = recent_avg + recent_std | |
| hard_days = sum(1 for e in recent_efforts if e > hard_threshold) | |
| else: | |
| recent_avg = final_fairness.metrics.avg_effort | |
| recent_std = final_fairness.metrics.std_dev | |
| hard_days = 0 | |
| # Get recent fatigue score from feedback | |
| feedback_result = await db.execute( | |
| select(DriverFeedback) | |
| .where(DriverFeedback.driver_id == driver.id) | |
| .order_by(DriverFeedback.created_at.desc()) | |
| .limit(1) | |
| ) | |
| recent_feedback = feedback_result.scalar_one_or_none() | |
| fatigue_score = float(recent_feedback.tiredness_level) if recent_feedback else 3.0 | |
| fatigue_score = max(1.0, min(5.0, fatigue_score)) # Clamp to 1-5 | |
| driver_contexts[driver_id_str] = DriverContext( | |
| driver_id=driver_id_str, | |
| recent_avg_effort=recent_avg, | |
| recent_std_effort=recent_std, | |
| recent_hard_days=hard_days, | |
| fatigue_score=fatigue_score, | |
| preferences={}, # TODO: Pull from driver preferences if available | |
| ) | |
| # Run Driver Liaison Agent | |
| liaison_agent = DriverLiaisonAgent() | |
| negotiation_result = liaison_agent.run_for_all_drivers( | |
| proposals=driver_proposals, | |
| driver_contexts=driver_contexts, | |
| effort_matrix=effort_result.matrix, | |
| driver_ids=effort_result.driver_ids, | |
| route_ids=effort_result.route_ids, | |
| global_avg_effort=final_fairness.metrics.avg_effort, | |
| global_std_effort=final_fairness.metrics.std_dev, | |
| ) | |
| # Log Driver Liaison decisions | |
| liaison_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="DRIVER_LIAISON", | |
| step_type="NEGOTIATION_DECISIONS", | |
| input_snapshot=liaison_agent.get_input_snapshot( | |
| driver_proposals, | |
| final_fairness.metrics.avg_effort, | |
| final_fairness.metrics.std_dev, | |
| ), | |
| output_snapshot=liaison_agent.get_output_snapshot(negotiation_result), | |
| ) | |
| db.add(liaison_log) | |
| # ========== PHASE 5: FINAL RESOLUTION (Phase 4.2) ========== | |
| # Check if any COUNTER decisions need resolution | |
| counter_decisions = [ | |
| d for d in negotiation_result.decisions if d.decision == "COUNTER" | |
| ] | |
| # Variables for final allocation | |
| final_allocation = final_plan.allocation | |
| final_per_driver_effort = final_plan.per_driver_effort | |
| if counter_decisions: | |
| # Run Final Resolution | |
| resolution_agent = FinalResolutionAgent() | |
| resolution_result = resolution_agent.resolve_counters( | |
| approved_proposal=final_plan, | |
| decisions=negotiation_result.decisions, | |
| effort_matrix=effort_result.matrix, | |
| driver_ids=effort_result.driver_ids, | |
| route_ids=effort_result.route_ids, | |
| current_metrics=final_fairness.metrics, | |
| ) | |
| # Log Final Resolution | |
| resolution_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="ROUTE_PLANNER", | |
| step_type="FINAL_RESOLUTION", | |
| input_snapshot=resolution_agent.get_input_snapshot( | |
| len(counter_decisions), | |
| final_fairness.metrics, | |
| final_fairness.metrics.avg_effort, | |
| ), | |
| output_snapshot=resolution_agent.get_output_snapshot(resolution_result), | |
| ) | |
| db.add(resolution_log) | |
| # Update final metrics with resolution result | |
| if resolution_result.swaps_applied: | |
| # Use resolved allocation | |
| final_per_driver_effort = resolution_result.per_driver_effort | |
| # Update allocation_run metrics | |
| allocation_run.global_gini_index = resolution_result.metrics.get("gini_index", final_fairness.metrics.gini_index) | |
| allocation_run.global_std_dev = resolution_result.metrics.get("std_dev", final_fairness.metrics.std_dev) | |
| allocation_run.global_max_gap = resolution_result.metrics.get("max_gap", final_fairness.metrics.max_gap) | |
| # ========== PHASE 6: EXPLAINABILITY AGENT (Phase 4.3) ========== | |
| # Build lookup for route by ID | |
| route_by_id = {str(r.id): r for r in route_models} | |
| route_dict_by_id = {} | |
| for i, r in enumerate(route_models): | |
| route_dict_by_id[str(r.id)] = route_dicts[i] | |
| driver_by_id = {str(d.id): d for d in driver_models} | |
| # Compute per-driver ranks (1=hardest) | |
| sorted_efforts = sorted( | |
| final_per_driver_effort.items(), | |
| key=lambda x: x[1], | |
| reverse=True | |
| ) | |
| rank_by_driver = {did: idx + 1 for idx, (did, _) in enumerate(sorted_efforts)} | |
| num_drivers = len(final_per_driver_effort) | |
| # Build liaison decisions lookup | |
| liaison_by_driver = {} | |
| if 'negotiation_result' in dir(): | |
| for decision in negotiation_result.decisions: | |
| liaison_by_driver[decision.driver_id] = decision | |
| # Build swaps lookup | |
| swapped_drivers = set() | |
| if 'resolution_result' in dir() and resolution_result.swaps_applied: | |
| for swap in resolution_result.swaps_applied: | |
| swapped_drivers.add(swap.driver_a) | |
| swapped_drivers.add(swap.driver_b) | |
| # Initialize ExplainabilityAgent | |
| explain_agent = ExplainabilityAgent() | |
| category_counts: Dict[str, int] = {} | |
| avg_effort = final_fairness.metrics.avg_effort | |
| assignments_response = [] | |
| for alloc_item in final_plan.allocation: | |
| driver_id_str = str(alloc_item.driver_id) | |
| driver = driver_by_id[driver_id_str] | |
| route = route_by_id[str(alloc_item.route_id)] | |
| route_dict = route_dict_by_id[str(alloc_item.route_id)] | |
| # Use resolved effort if available (after swaps), else original | |
| effort = final_per_driver_effort.get(driver_id_str, alloc_item.effort) | |
| fairness_score = calculate_fairness_score(effort, avg_effort) | |
| # Get driver context for explanation | |
| driver_context = driver_contexts.get(driver_id_str) | |
| history_efforts = [] | |
| history_hard_days = 0 | |
| if driver_context: | |
| history_efforts = [driver_context.recent_avg_effort] if driver_context.recent_avg_effort else [] | |
| history_hard_days = driver_context.recent_hard_days | |
| # Get effort breakdown from ML agent | |
| breakdown_key = f"{driver_id_str}:{alloc_item.route_id}" | |
| effort_breakdown_obj = effort_result.breakdown.get(breakdown_key) | |
| effort_breakdown = {} | |
| if effort_breakdown_obj: | |
| effort_breakdown = { | |
| "physical_effort": effort_breakdown_obj.physical_effort, | |
| "route_complexity": effort_breakdown_obj.route_complexity, | |
| "time_pressure": effort_breakdown_obj.time_pressure, | |
| } | |
| # Get liaison decision | |
| liaison_decision = liaison_by_driver.get(driver_id_str) | |
| # Check for manual override | |
| had_override = False | |
| try: | |
| override_result = await db.execute( | |
| select(ManualOverride) | |
| .where(ManualOverride.allocation_run_id == allocation_run.id) | |
| .where(ManualOverride.new_driver_id == driver.id) | |
| .limit(1) | |
| ) | |
| had_override = override_result.scalar_one_or_none() is not None | |
| except Exception: | |
| pass # ManualOverride may not exist yet | |
| # Determine if recovery day | |
| is_recovery = ( | |
| history_hard_days >= 3 and | |
| effort < avg_effort * 0.85 | |
| ) | |
| # Build explanation input | |
| explain_input = DriverExplanationInput( | |
| driver_id=driver_id_str, | |
| driver_name=driver.name, | |
| num_drivers=num_drivers, | |
| today_effort=effort, | |
| today_rank=rank_by_driver.get(driver_id_str, num_drivers), | |
| route_id=str(alloc_item.route_id), | |
| route_summary={ | |
| "num_packages": route.num_packages, | |
| "total_weight_kg": route.total_weight_kg, | |
| "num_stops": route.num_stops, | |
| "difficulty_score": route.route_difficulty_score, | |
| "estimated_time_minutes": route.estimated_time_minutes, | |
| }, | |
| effort_breakdown=effort_breakdown, | |
| global_avg_effort=avg_effort, | |
| global_std_effort=final_fairness.metrics.std_dev, | |
| global_gini_index=final_fairness.metrics.gini_index, | |
| global_max_gap=final_fairness.metrics.max_gap, | |
| history_efforts_last_7_days=history_efforts, | |
| history_hard_days_last_7=history_hard_days, | |
| is_recovery_day=is_recovery, | |
| had_manual_override=had_override, | |
| liaison_decision=liaison_decision.decision if liaison_decision else None, | |
| swap_applied=driver_id_str in swapped_drivers, | |
| ) | |
| # Generate explanations | |
| explain_output = explain_agent.build_explanation_for_driver(explain_input) | |
| # Track category counts | |
| category_counts[explain_output.category] = category_counts.get(explain_output.category, 0) + 1 | |
| # Create assignment with both explanations | |
| assignment = Assignment( | |
| date=request.allocation_date, | |
| driver_id=driver.id, | |
| route_id=route.id, | |
| workload_score=effort, | |
| fairness_score=fairness_score, | |
| explanation=explain_output.driver_explanation, # Legacy field | |
| driver_explanation=explain_output.driver_explanation, | |
| admin_explanation=explain_output.admin_explanation, | |
| allocation_run_id=allocation_run.id, | |
| ) | |
| db.add(assignment) | |
| # Build response | |
| assignments_response.append(AssignmentResponse( | |
| driver_id=driver.id, | |
| driver_external_id=driver.external_id, | |
| driver_name=driver.name, | |
| route_id=route.id, | |
| workload_score=effort, | |
| fairness_score=fairness_score, | |
| route_summary=RouteSummary( | |
| num_packages=route.num_packages, | |
| total_weight_kg=route.total_weight_kg, | |
| num_stops=route.num_stops, | |
| route_difficulty_score=route.route_difficulty_score, | |
| estimated_time_minutes=route.estimated_time_minutes, | |
| ), | |
| explanation=explain_output.driver_explanation, | |
| )) | |
| # Log ExplainabilityAgent step | |
| explain_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="EXPLAINABILITY", | |
| step_type="EXPLANATIONS_GENERATED", | |
| input_snapshot=explain_agent.get_input_snapshot( | |
| num_drivers=num_drivers, | |
| avg_effort=avg_effort, | |
| std_effort=final_fairness.metrics.std_dev, | |
| gini_index=final_fairness.metrics.gini_index, | |
| category_counts=category_counts, | |
| ), | |
| output_snapshot=explain_agent.get_output_snapshot( | |
| total_explanations=len(assignments_response), | |
| category_counts=category_counts, | |
| ), | |
| ) | |
| db.add(explain_log) | |
| # ========== PHASE 7: UPDATE DAILY STATS (Phase 7) ========== | |
| from app.services.recovery_service import update_daily_stats_for_run | |
| await update_daily_stats_for_run( | |
| db=db, | |
| allocation_run_id=allocation_run.id, | |
| target_date=request.allocation_date, | |
| config=active_config, | |
| ) | |
| # ========== PHASE 8: CREATE LEARNING EPISODE ========== | |
| # Create a learning episode for bandit feedback (reward computed later by cron) | |
| try: | |
| learning_agent = LearningAgent(db) | |
| # Build config snapshot for the episode | |
| config_snapshot = {} | |
| if active_config: | |
| config_snapshot = { | |
| "gini_threshold": active_config.gini_threshold, | |
| "stddev_threshold": active_config.stddev_threshold, | |
| "recovery_lightening_factor": active_config.recovery_lightening_factor, | |
| "ev_charging_penalty_weight": active_config.ev_charging_penalty_weight, | |
| "max_gap_threshold": active_config.max_gap_threshold, | |
| } | |
| # Determine if this is experimental (10% of runs) | |
| import random | |
| is_experimental = random.random() < 0.10 | |
| await learning_agent.create_episode( | |
| allocation_run_id=allocation_run.id, | |
| fairness_config=config_snapshot, | |
| num_drivers=len(driver_models), | |
| num_routes=len(route_models), | |
| is_experimental=is_experimental, | |
| ) | |
| # Log learning episode creation | |
| learning_log = DecisionLog( | |
| allocation_run_id=allocation_run.id, | |
| agent_name="LEARNING", | |
| step_type="EPISODE_CREATED", | |
| input_snapshot={ | |
| "config_hash": hash_config(config_snapshot), | |
| "is_experimental": is_experimental, | |
| }, | |
| output_snapshot={ | |
| "status": "pending_reward", | |
| }, | |
| ) | |
| db.add(learning_log) | |
| except Exception as learning_error: | |
| # Learning is non-critical, log but don't fail allocation | |
| import logging | |
| logging.warning(f"Failed to create learning episode: {learning_error}") | |
| # ========== FINALIZE ALLOCATION RUN ========== | |
| allocation_run.global_gini_index = final_fairness.metrics.gini_index | |
| allocation_run.global_std_dev = final_fairness.metrics.std_dev | |
| allocation_run.global_max_gap = final_fairness.metrics.max_gap | |
| allocation_run.status = AllocationRunStatus.SUCCESS | |
| allocation_run.finished_at = datetime.utcnow() | |
| await db.commit() | |
| return AllocationResponse( | |
| allocation_run_id=allocation_run.id, | |
| allocation_date=request.allocation_date, | |
| global_fairness=GlobalFairness( | |
| avg_workload=final_fairness.metrics.avg_effort, | |
| std_dev=final_fairness.metrics.std_dev, | |
| gini_index=final_fairness.metrics.gini_index, | |
| ), | |
| assignments=assignments_response, | |
| ) | |
| except Exception as e: | |
| # Mark allocation run as failed | |
| allocation_run.status = AllocationRunStatus.FAILED | |
| allocation_run.error_message = str(e)[:500] | |
| allocation_run.finished_at = datetime.utcnow() | |
| await db.commit() | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail={ | |
| "message": "Allocation failed", | |
| "run_id": str(allocation_run.id), | |
| "error": str(e)[:200], | |
| }, | |
| ) | |