MouleeswaranM commited on
Commit
fcf8749
·
verified ·
1 Parent(s): 0076060

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +75 -0
  3. README.md +630 -15
  4. brain/.env.example +26 -0
  5. brain/.github/workflows/test.yml +42 -0
  6. brain/.gitignore +95 -0
  7. brain/Dockerfile +23 -0
  8. brain/Makefile +34 -0
  9. brain/README.md +484 -0
  10. brain/alembic.ini +114 -0
  11. brain/alembic/env.py +92 -0
  12. brain/alembic/script.py.mako +26 -0
  13. brain/alembic/versions/001_initial_schema.py +145 -0
  14. brain/alembic/versions/002_phase2_phase3_models.py +179 -0
  15. brain/alembic/versions/003_add_pending_status.py +36 -0
  16. brain/alembic/versions/004_add_explanation_fields.py +42 -0
  17. brain/alembic/versions/005_phase7_ev_recovery.py +59 -0
  18. brain/alembic/versions/006_phase8_learning_agent.py +119 -0
  19. brain/app/__init__.py +1 -0
  20. brain/app/api/__init__.py +24 -0
  21. brain/app/api/admin.py +298 -0
  22. brain/app/api/admin_learning.py +368 -0
  23. brain/app/api/agent_events.py +93 -0
  24. brain/app/api/allocation.py +808 -0
  25. brain/app/api/allocation_langgraph.py +513 -0
  26. brain/app/api/consolidation.py +109 -0
  27. brain/app/api/driver_api.py +175 -0
  28. brain/app/api/drivers.py +136 -0
  29. brain/app/api/feedback.py +108 -0
  30. brain/app/api/routes.py +93 -0
  31. brain/app/api/runs.py +251 -0
  32. brain/app/config.py +77 -0
  33. brain/app/core/__init__.py +1 -0
  34. brain/app/core/events.py +154 -0
  35. brain/app/database.py +108 -0
  36. brain/app/main.py +187 -0
  37. brain/app/models/__init__.py +49 -0
  38. brain/app/models/allocation_run.py +64 -0
  39. brain/app/models/appeal.py +77 -0
  40. brain/app/models/assignment.py +71 -0
  41. brain/app/models/decision_log.py +55 -0
  42. brain/app/models/delivery_log.py +102 -0
  43. brain/app/models/driver.py +226 -0
  44. brain/app/models/driver_effort_model.py +90 -0
  45. brain/app/models/fairness_config.py +69 -0
  46. brain/app/models/learning_episode.py +92 -0
  47. brain/app/models/manual_override.py +73 -0
  48. brain/app/models/package.py +68 -0
  49. brain/app/models/route.py +97 -0
  50. brain/app/models/route_swap.py +89 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ ops/logistic_flutter/orchastra_ps4/ecology/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ========================
2
+ # FairRelay Monorepo
3
+ # ========================
4
+
5
+ # Environment & Secrets
6
+ .env
7
+ .env.local
8
+ .env.*.local
9
+ *.pem
10
+ *.key
11
+
12
+ # Python
13
+ __pycache__/
14
+ *.py[cod]
15
+ *$py.class
16
+ *.so
17
+ .Python
18
+ venv/
19
+ .venv/
20
+ ENV/
21
+ *.egg-info/
22
+ *.egg
23
+
24
+ # Node.js
25
+ node_modules/
26
+ npm-debug.log*
27
+ yarn-error.log*
28
+
29
+ # Flutter / Android
30
+ **/android/local.properties
31
+ **/android/.gradle/
32
+ **/android/app/build/
33
+ **/android/build/
34
+ **/.dart_tool/
35
+ **/.flutter-plugins
36
+ **/.flutter-plugins-dependencies
37
+ **/.packages
38
+ *.apk
39
+ *.aab
40
+
41
+ # iOS
42
+ **/ios/Pods/
43
+ **/ios/.symlinks/
44
+
45
+ # Build artifacts
46
+ dist/
47
+ build/
48
+
49
+ # IDE
50
+ .idea/
51
+ .vscode/
52
+ *.swp
53
+ *.swo
54
+ *~
55
+
56
+ # OS
57
+ .DS_Store
58
+ Thumbs.db
59
+ Desktop.ini
60
+
61
+ # Logs
62
+ *.log
63
+
64
+ # Database
65
+ *.sqlite
66
+ *.sqlite3
67
+ *.db
68
+
69
+ # RL Experience (regenerated at runtime)
70
+ brain/data/rl_experience.json
71
+
72
+ # Coverage / Testing
73
+ .coverage
74
+ htmlcov/
75
+ .pytest_cache/
README.md CHANGED
@@ -1,26 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- tags:
3
- - ml-intern
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  ---
5
 
6
- # MouleeswaranM/FairRelay
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
- <!-- ml-intern-provenance -->
9
- ## Generated by ML Intern
10
 
11
- This model repository was generated by [ML Intern](https://github.com/huggingface/ml-intern), an agent for machine learning research and development on the Hugging Face Hub.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- - Try ML Intern: https://smolagents-ml-intern.hf.space
14
- - Source code: https://github.com/huggingface/ml-intern
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- ## Usage
17
 
18
- ```python
19
- from transformers import AutoModelForCausalLM, AutoTokenizer
 
 
 
 
 
 
 
 
 
20
 
21
- model_id = "MouleeswaranM/FairRelay"
22
- tokenizer = AutoTokenizer.from_pretrained(model_id)
23
- model = AutoModelForCausalLM.from_pretrained(model_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  ```
25
 
26
- For non-causal architectures, replace `AutoModelForCausalLM` with the appropriate `AutoModel` class.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/Challenge-%235_AI_Load_Consolidation-f97316?style=for-the-badge" alt="Challenge #5" />
3
+ <img src="https://img.shields.io/badge/LogisticsNow-Hackathon_2026-3b82f6?style=for-the-badge" alt="LogisticsNow" />
4
+ <img src="https://img.shields.io/badge/Team-FairRelay-10b981?style=for-the-badge" alt="Team FairRelay" />
5
+ </p>
6
+
7
+ <h1 align="center">FairRelay</h1>
8
+
9
+ <p align="center">
10
+ <strong>AI-Powered Load Consolidation Engine &middot; Fairness-Aware Route Allocation &middot; Multi-Agent Intelligence</strong>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="#-the-problem">Problem</a> &bull;
15
+ <a href="#-our-solution">Solution</a> &bull;
16
+ <a href="#-5-agent-consolidation-pipeline">Consolidation Pipeline</a> &bull;
17
+ <a href="#-fair-dispatch-pipeline">Fair Dispatch</a> &bull;
18
+ <a href="#-architecture">Architecture</a> &bull;
19
+ <a href="#-dashboards--visualization">Dashboards</a> &bull;
20
+ <a href="#-quick-start">Quick Start</a> &bull;
21
+ <a href="#-api-reference">API Reference</a>
22
+ </p>
23
+
24
+ ---
25
+
26
+ ## The Problem
27
+
28
+ > Logistics networks transport shipments with **partially filled vehicles** due to poor load planning. There is **no AI-driven system** for automatic load consolidation that intelligently groups shipments, maximizes vehicle capacity, simulates strategies, and learns continuously.
29
+ >
30
+ > At the same time, **15M+ gig delivery workers** in India face systemic dispatch bias — traditional systems assign **3x more deliveries** to some drivers (Gini = 0.85) while others earn near nothing.
31
+
32
+ **FairRelay solves both.**
33
+
34
+ ---
35
+
36
+ ## Our Solution
37
+
38
+ FairRelay is a **full-stack AI logistics platform** with two core engines:
39
+
40
+ | Engine | What It Does | Agents |
41
+ |--------|-------------|--------|
42
+ | **Load Consolidation Engine** | Groups shipments by geography + time windows, bin-packs into trucks using OR-Tools CP-SAT solver, scores confidence, and learns via Q-Learning | 5 agents |
43
+ | **Fair Dispatch Engine** | Allocates routes to drivers using fairness-aware AI with Gini coefficient optimization, wellness tracking, EV-aware routing, and LLM explanations | 8+ agents |
44
+
45
+ Both engines are orchestrated via **LangGraph** multi-agent workflows, exposed as single API endpoints, and come with live visualization dashboards.
46
+
47
+ ### Hackathon Deliverables Mapping
48
+
49
+ | Expected Deliverable | Our Implementation |
50
+ |---------------------|-------------------|
51
+ | **Consolidation Engine Prototype** | 5-agent LangGraph pipeline — KMeans geo-clustering + OR-Tools CP-SAT bin-packing |
52
+ | **Visualization Dashboard** | Interactive dark-themed dashboard with Leaflet maps, Chart.js analytics, agent pipeline viz, heatmaps |
53
+ | **Performance Simulation** | Multi-scenario simulator comparing Tight/Balanced/Aggressive strategies with full KPI comparison |
54
+ | **Continuous Optimization** | Tabular Q-Learning agent with file-based experience store, reward function, and policy recommendation |
55
+
56
+ ---
57
+
58
+ ## 5-Agent Consolidation Pipeline
59
+
60
+ ```
61
+ POST /api/v1/consolidate → One API call. Five agents. Optimized loads.
62
+ ```
63
+
64
+ ```
65
+ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
66
+ │ AGENT 1 │ │ AGENT 2 │ │ AGENT 3 │
67
+ │ Geo-Clustering │───>│ Time-Window │───>│ Capacity │
68
+ │ (KMeans + │ │ Filtering │ │ Optimization │
69
+ │ Silhouette) │ │ (Overlap check) │ │ (OR-Tools SAT) │
70
+ └──────────────────┘ └──────────────────┘ └──────────────────┘
71
+
72
+
73
+ ┌──────────────────┐ ┌──────────────────┐
74
+ │ AGENT 5 │ │ AGENT 4 │
75
+ │ Continuous │<───│ Scoring & │
76
+ │ Learning │ │ Confidence │
77
+ │ (Q-Learning) │ │ (Composite AI) │
78
+ └──────────────────┘ └──────────────────┘
79
+ ```
80
+
81
+ ### Agent Breakdown
82
+
83
+ | # | Agent | Algorithm | What It Does |
84
+ |---|-------|-----------|-------------|
85
+ | 1 | **Geo-Clustering** | scikit-learn KMeans + Silhouette scoring | Groups shipments by pickup/drop proximity. Auto-selects optimal K (2–10). Splits oversized clusters via greedy radius fallback. |
86
+ | 2 | **Time-Window** | Interval overlap analysis | Filters clusters by delivery time compatibility. Configurable tolerance (default 120 min). Splits time-incompatible shipments into separate groups. |
87
+ | 3 | **Capacity Optimization** | Google OR-Tools CP-SAT Integer Programming | Bin-packs shipments into trucks respecting weight + volume. Minimizes trucks used. Falls back to First-Fit-Decreasing heuristic if solver unavailable. 3-second solver timeout. |
88
+ | 4 | **Scoring & Confidence** | Weighted composite scoring | Per-group confidence = `capFit×0.4 + geoScore×0.35 + timeScore×0.25`. Global optimization score factors in utilization, trip reduction, and improvement gain. Computes all KPIs vs naive baseline. |
89
+ | 5 | **Continuous Learning** | Tabular Q-Learning (RL) | Stores experience in `data/rl_experience.json` (max 500 episodes). Reward = f(utilization, trips, carbon, score). Updates Q-table to recommend optimal (radius, tolerance) parameters. Detects policy convergence/degradation trends. |
90
+
91
+ ### Consolidation KPIs Produced
92
+
93
+ | KPI | Description |
94
+ |-----|-------------|
95
+ | Vehicle Utilization (Before/After) | Percentage improvement from naive to consolidated |
96
+ | Trips Reduced | Absolute count + percentage of eliminated trips |
97
+ | Distance Saved (km) | Haversine-calculated route distance reduction |
98
+ | CO2 Saved (kg) | `distanceSaved × 0.21 kg/km` |
99
+ | Carbon Credit Value (USD) | `carbonSaved / 1000 × $25/ton` |
100
+ | Fuel Saved (INR) | `distanceSaved × Rs.22.5/km` |
101
+ | Cost Reduction (%) | Direct cost savings from trip elimination |
102
+ | Optimization Score (0–100) | Weighted composite with letter grade (A+/A/B/C/D) |
103
+ | Avg AI Confidence (0–100) | Mean per-group confidence across all bins |
104
+
105
+ ### Scenario Simulation
106
+
107
+ ```
108
+ POST /api/v1/consolidate/simulate
109
+ ```
110
+
111
+ Run multiple consolidation strategies in parallel and get the best recommendation:
112
+
113
+ | Scenario | Radius | Time Tolerance | Use Case |
114
+ |----------|--------|---------------|----------|
115
+ | **Tight Clustering** | 15 km | 60 min | Dense urban, strict deadlines |
116
+ | **Balanced** | 30 km | 120 min | General purpose |
117
+ | **Aggressive Merge** | 60 km | 240 min | Inter-city, flexible windows |
118
+
119
+ The system runs all scenarios, compares optimization scores, and recommends the best strategy.
120
+
121
+ ---
122
+
123
+ ## Fair Dispatch Pipeline
124
+
125
+ ```
126
+ POST /api/v1/allocate/langgraph → Fairness-aware route allocation
127
+ ```
128
+
129
+ ```
130
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
131
+ │ Initialize │ → │ Clustering │ → │ ML Effort │
132
+ │ Node │ │ Agent (KMeans) │ │ Agent (XGBoost)│
133
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
134
+
135
+
136
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
137
+ │ EV Recovery │ ← │ Fairness │ ← │ Route Planner │
138
+ │ Node │ │ Manager │ │ (Hungarian) │
139
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
140
+ │ │
141
+ ▼ ▼ (if Gini > 0.25)
142
+ ┌─────────────────┐ ┌─────────────────┐
143
+ │ Driver Liaison │ │ Reoptimize │
144
+ │ Agent │ │ Loop │
145
+ └─────────────────┘ └─────────────────┘
146
+
147
+
148
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
149
+ │ Learning │ → │ LLM Explain │ → │ Finalize │
150
+ │ Agent │ │ (Gemini) │ │ Node │
151
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
152
+ ```
153
+
154
+ | Agent | Purpose | Key Algorithm |
155
+ |-------|---------|---------------|
156
+ | **Initialize Node** | Validates inputs, sets up allocation state | Schema validation |
157
+ | **Clustering Agent** | Groups packages by geography | K-Means |
158
+ | **ML Effort Agent** | Scores driver-route effort pairs | XGBoost |
159
+ | **Route Planner** | Solves optimal driver-route assignment | Hungarian Algorithm |
160
+ | **Fairness Manager** | Evaluates workload inequality | Gini Index (threshold: 0.25) |
161
+ | **EV Recovery Node** | Handles electric vehicle battery constraints | Charging station insertion |
162
+ | **Driver Liaison** | Processes driver negotiations/appeals | Rule-based + AI |
163
+ | **Learning Agent** | Improves future allocations from feedback | Feedback loop |
164
+ | **LLM Explain Node** | Generates natural language explanations | Google Gemini |
165
+
166
+ ### Fairness Algorithms
167
+
168
+ **Workload Score:**
169
+ ```
170
+ workload = a × num_packages + b × total_weight_kg + c × route_difficulty + d × estimated_time
171
+ ```
172
+
173
+ **Gini Index** (0 = perfect equality, 1 = maximum inequality):
174
+ ```
175
+ G = (2 × Σ(i × x_i)) / (n × Σx_i) − (n + 1) / n
176
+ ```
177
+
178
+ **Individual Fairness Score:**
179
+ ```
180
+ fairness_score = 1 − |workload − avg_workload| / max(avg_workload, 1)
181
+ ```
182
+
183
+ Key Result: **Gini reduced from 0.85 → 0.12** (Grade A fairness)
184
+
185
+ ---
186
+
187
+ ## Architecture
188
+
189
+ ```
190
+ ┌──────────────────────────────────────────────────────────────────────┐
191
+ │ FAIRRELAY PLATFORM │
192
+ ├──────────────┬──────────────┬──────────────┬────────────────────────┤
193
+ │ Landing │ AI Supply │ Flutter │ Streamlit │
194
+ │ Page │ Chain │ Mobile │ Women │
195
+ │ (React) │ Dashboard │ App │ Empowerment Hub │
196
+ │ Vercel │ (React) │ (Android) │ (Python) │
197
+ │ │ Vercel │ │ │
198
+ ├──────────────┴──────┬───────┴──────────────┴────────────────────────┤
199
+ │ │ │
200
+ │ Backend-DM (Node.js/Express) │
201
+ │ JWT Auth · Prisma ORM · Socket.IO │
202
+ │ Driver Relay · Absorption Handshake · e-Way Bills │
203
+ │ Render │
204
+ │ │ │
205
+ │ │ BRAIN_URL proxy │
206
+ │ ▼ │
207
+ │ Brain (Python/FastAPI) │
208
+ │ LangGraph Multi-Agent Orchestration │
209
+ │ 5-Agent Consolidation + 8-Agent Fair Dispatch │
210
+ │ OR-Tools · XGBoost · KMeans · Q-Learning · Gemini │
211
+ │ Render │
212
+ │ │ │
213
+ │ PostgreSQL (Neon) │
214
+ └─────────────────────────────────────────────────────────────────────┘
215
+ ```
216
+
217
+ ### Tech Stack
218
+
219
+ | Layer | Technology |
220
+ |-------|-----------|
221
+ | **AI Engine (Brain)** | Python 3.11, FastAPI, LangGraph, scikit-learn, XGBoost, Google OR-Tools CP-SAT, Gemini API |
222
+ | **Operations Backend** | Node.js, Express 5, Prisma ORM, PostgreSQL, Socket.IO, Puppeteer, JWT/RBAC |
223
+ | **Dashboard** | React 19, TypeScript, Vite, Redux Toolkit, TailwindCSS, Leaflet, Recharts |
224
+ | **Mobile** | Flutter, Dart, Google Maps, Provider, Dio |
225
+ | **Landing Page** | React, TypeScript, Vite |
226
+ | **Visualization** | Leaflet maps, Chart.js, custom agent pipeline UI, heatmaps |
227
+ | **Database** | PostgreSQL 14+ (Neon serverless), SQLAlchemy async |
228
+ | **Deployment** | Render (backends), Vercel (frontends), Gunicorn |
229
+
230
+ ---
231
+
232
+ ## Dashboards & Visualization
233
+
234
+ ### Load Consolidation Dashboard (`/demo/consolidation`)
235
+
236
+ - **5-Agent Pipeline Visualization** — Each agent lights up in sequence with execution time and output metrics
237
+ - **AI Optimization Score Ring** — Doughnut chart with letter grade (A+/A/B/C/D)
238
+ - **8 KPI Cards** — Utilization, trips reduced, distance saved, CO2, fuel savings, confidence, groups, cost reduction
239
+ - **Interactive Route Map** — Three views: Optimized (color-coded), Before (naive gray), Compare (overlay)
240
+ - **Consolidated Groups Table** — Truck assignment, weight/volume utilization bars, AI confidence badges
241
+ - **Analytics Charts** — Utilization before vs after, Group confidence radar, Weight distribution doughnut
242
+ - **Shipment Compatibility Heatmap** — N x N pairwise compatibility matrix (geo + time)
243
+ - **Scenario Comparison Panel** — Side-by-side results for Tight/Balanced/Aggressive with recommendation badge
244
+ - **AI Learning Insights** — Pattern detection, corridor identification, Q-Learning convergence status
245
+ - **Agent Decision Logs** — Terminal-style log viewer for full pipeline transparency
246
+
247
+ ### Fair Dispatch Visualization (`/demo/visualization`)
248
+
249
+ - **8-Agent Pipeline Visualization** — Real-time agent status with animated transitions
250
+ - **Live Map** — Route visualization on Leaflet with driver assignments
251
+ - **Fairness Metrics** — Gini index, individual scores, equity analysis
252
+ - **Agent Activity Feed** — Decision logs from every agent in the pipeline
253
+
254
+ ### Operations Dashboard (React)
255
+
256
+ - **Real-time Driver Tracking** — Live map with Socket.IO updates
257
+ - **Dispatch Management** — Assign missions, view driver profiles, experience-based routing
258
+ - **Absorption Handshake** — Peer-to-peer goods exchange with QR codes
259
+ - **e-Way Bill Generation** — Professional government-format PDFs via Puppeteer
260
+ - **Analytics** — Fleet KPIs, delivery stats, driver performance
261
+
262
+ ---
263
+
264
+ ## Key Features
265
+
266
+ ### AI Load Consolidation
267
+ - **Intelligent Shipment Grouping** — KMeans geo-clustering with silhouette optimization + time window filtering
268
+ - **Capacity Optimization** — OR-Tools CP-SAT integer programming to minimize trucks, maximize utilization
269
+ - **Scenario Simulation** — Multi-strategy comparison with automated recommendation
270
+ - **Continuous Optimization** — Q-Learning RL agent that improves radius/tolerance parameters over time
271
+ - **Shipment Compatibility Analysis** — Pairwise heatmap scoring (60% geo + 40% time)
272
+
273
+ ### Fairness-Aware Dispatch
274
+ - **Gini Coefficient Optimization** — Measurably fair workload distribution (Gini <= 0.15 guaranteed)
275
+ - **Driver Wellness Engine** — Hours worked, rest tracking, illness flags, burnout prevention
276
+ - **Night Safety Routing** — Automatic safety filtering for women drivers on night routes
277
+ - **EV-Aware Routing** — Battery constraints and charging station integration
278
+ - **Explainable Decisions** — 100% of allocations come with Gemini-generated natural language explanations
279
+
280
+ ### Operations Platform
281
+ - **Driver Relay System** — Multi-zone handoffs at virtual hubs for long-haul optimization
282
+ - **Absorption Handshake** — Offline-capable cryptographic QR verification for goods exchange
283
+ - **Dynamic e-Way Bills** — Government-format PDF generation via Puppeteer, no external APIs
284
+ - **Real-time Tracking** — Socket.IO powered live driver and delivery status updates
285
+
286
  ---
287
+
288
+ ## SDG Impact
289
+
290
+ | SDG | Target | Our Contribution |
291
+ |-----|--------|-----------------|
292
+ | **SDG 8** — Decent Work | Fair income distribution | Gini 0.85 → 0.12 across all drivers |
293
+ | **SDG 10** — Reduced Inequalities | Equal opportunity | Wellness-aware, gender-safe dispatch |
294
+ | **SDG 13** — Climate Action | Reduce emissions | 14.2 kg CO2 saved per allocation run, EV-first routing |
295
+
296
+ ---
297
+
298
+ ## Quick Start
299
+
300
+ ### Prerequisites
301
+
302
+ - Python 3.11+
303
+ - Node.js 18+
304
+ - PostgreSQL 14+ (or SQLite for development)
305
+ - Git
306
+
307
+ ### 1. Brain (AI Engine)
308
+
309
+ ```bash
310
+ cd brain
311
+
312
+ # Create virtual environment
313
+ python -m venv venv
314
+ venv\Scripts\activate # Windows
315
+ # source venv/bin/activate # Linux/macOS
316
+
317
+ # Install dependencies
318
+ pip install -r requirements.txt
319
+
320
+ # Configure environment
321
+ cp .env.example .env
322
+ # Edit .env with your DATABASE_URL, GOOGLE_API_KEY etc.
323
+
324
+ # Run database migrations
325
+ alembic upgrade head
326
+
327
+ # Start the server
328
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
329
+ ```
330
+
331
+ **Access Points:**
332
+
333
+ | Page | URL |
334
+ |------|-----|
335
+ | API Docs (Swagger) | http://localhost:8000/docs |
336
+ | ReDoc | http://localhost:8000/redoc |
337
+ | Consolidation Dashboard | http://localhost:8000/demo/consolidation |
338
+ | Fair Dispatch Demo | http://localhost:8000/demo/allocate |
339
+ | Agent Visualization | http://localhost:8000/demo/visualization |
340
+
341
+ ### 2. Backend-DM (Operations Server)
342
+
343
+ ```bash
344
+ cd ops/backend-dm
345
+
346
+ npm install
347
+
348
+ cp .env.example .env
349
+ # Edit .env: DATABASE_URL, JWT_SECRET, BRAIN_URL=http://localhost:8000
350
+
351
+ npx prisma generate
352
+ npx prisma db push
353
+
354
+ node index.js
355
+ # Runs on http://localhost:3000
356
+ ```
357
+
358
+ ### 3. AI Supply Chain Dashboard
359
+
360
+ ```bash
361
+ cd ops/AIsupplychain/aisupply
362
+
363
+ npm install
364
+
365
+ # Create .env
366
+ echo "VITE_API_URL=http://localhost:3000" > .env
367
+
368
+ npm run dev
369
+ # Runs on http://localhost:5173
370
+ ```
371
+
372
+ ### 4. Landing Page
373
+
374
+ ```bash
375
+ cd landing
376
+
377
+ npm install
378
+ npm run dev
379
+ # Runs on http://localhost:5174
380
+ ```
381
+
382
  ---
383
 
384
+ ## API Reference
385
+
386
+ ### Load Consolidation
387
+
388
+ | Method | Endpoint | Description |
389
+ |--------|----------|-------------|
390
+ | `POST` | `/api/v1/consolidate` | Run 5-agent consolidation pipeline (LangGraph) |
391
+ | `POST` | `/api/v1/consolidate/sync` | Run consolidation (sync fallback, no LangGraph) |
392
+ | `POST` | `/api/v1/consolidate/simulate` | Multi-scenario simulation with recommendation |
393
+
394
+ #### Consolidation Request
395
+
396
+ ```json
397
+ {
398
+ "shipments": [
399
+ {
400
+ "id": "SH-001",
401
+ "pickupLat": 19.076, "pickupLng": 72.877,
402
+ "dropLat": 18.520, "dropLng": 73.856,
403
+ "pickupLocation": "Mumbai", "dropLocation": "Pune",
404
+ "weight": 450, "volume": 2.1,
405
+ "timeWindowStart": "2026-03-10T08:00:00",
406
+ "timeWindowEnd": "2026-03-10T18:00:00",
407
+ "priority": "HIGH"
408
+ }
409
+ ],
410
+ "trucks": [
411
+ {
412
+ "id": "TRK-001",
413
+ "name": "Tata Ace Gold",
414
+ "maxWeight": 2000, "maxVolume": 8.0,
415
+ "co2PerKm": 0.21
416
+ }
417
+ ],
418
+ "options": {
419
+ "maxGroupRadiusKm": 30,
420
+ "timeWindowToleranceMinutes": 120
421
+ }
422
+ }
423
+ ```
424
+
425
+ #### Consolidation Response
426
+
427
+ ```json
428
+ {
429
+ "groups": [
430
+ {
431
+ "groupId": 0,
432
+ "truckId": "TRK-001",
433
+ "truckName": "Tata Ace Gold",
434
+ "shipmentCount": 4,
435
+ "shipments": [{ "id": "SH-001", "pickupLocation": "Mumbai", "dropLocation": "Pune", "weight": 450, "volume": 2.1 }],
436
+ "totalWeight": 1680, "totalVolume": 6.8,
437
+ "utilizationWeight": 84.0, "utilizationVolume": 85.0,
438
+ "confidence": 87
439
+ }
440
+ ],
441
+ "metrics": {
442
+ "utilizationBefore": 38.2,
443
+ "utilizationAfter": 78.5,
444
+ "utilizationImprovement": 40.3,
445
+ "tripsReduced": 6,
446
+ "tripReductionPercent": 60.0,
447
+ "distanceSavedKm": 487.3,
448
+ "carbonSavedKg": 102.3,
449
+ "carbonCreditUSD": 2.56,
450
+ "fuelSavedINR": 10964.25,
451
+ "optimizationScore": 82,
452
+ "avgConfidence": 85
453
+ },
454
+ "insights": [
455
+ { "type": "pattern", "text": "High-density corridor: Mumbai-Pune (4 shipments)", "impact": "high" },
456
+ { "type": "learning", "text": "Q-table updated. Reward: 76.4. Best action: radius=30km, tolerance=120min", "impact": "medium" }
457
+ ],
458
+ "agentSteps": [
459
+ { "agent": "GeoClusteringAgent", "action": "completed", "method": "kmeans", "clusters": 3, "duration_ms": 45 }
460
+ ]
461
+ }
462
+ ```
463
+
464
+ ### Fair Dispatch
465
+
466
+ | Method | Endpoint | Description |
467
+ |--------|----------|-------------|
468
+ | `POST` | `/api/v1/allocate/langgraph` | Run 8-agent fair dispatch pipeline |
469
+ | `GET` | `/api/v1/drivers/{id}` | Get driver details and stats |
470
+ | `GET` | `/api/v1/routes/{id}` | Get route details and packages |
471
+ | `POST` | `/api/v1/feedback` | Submit driver feedback for learning |
472
 
473
+ #### Fair Dispatch Request
 
474
 
475
+ ```json
476
+ {
477
+ "date": "2026-03-10",
478
+ "warehouse": { "lat": 12.9716, "lng": 77.5946 },
479
+ "packages": [
480
+ {
481
+ "id": "pkg_001",
482
+ "weight_kg": 2.5,
483
+ "address": "123 Main St, Bangalore",
484
+ "latitude": 12.97, "longitude": 77.60,
485
+ "priority": "NORMAL"
486
+ }
487
+ ],
488
+ "drivers": [
489
+ {
490
+ "id": "driver_001",
491
+ "name": "Raju",
492
+ "vehicle_capacity_kg": 150,
493
+ "vehicle_type": "PETROL"
494
+ }
495
+ ]
496
+ }
497
+ ```
498
+
499
+ #### Fair Dispatch Response
500
 
501
+ ```json
502
+ {
503
+ "status": "SUCCESS",
504
+ "global_fairness": {
505
+ "gini_index": 0.12,
506
+ "avg_workload": 63.2,
507
+ "std_dev": 5.4
508
+ },
509
+ "assignments": [
510
+ {
511
+ "driver_id": "driver_001",
512
+ "fairness_score": 0.92,
513
+ "route_summary": { "num_packages": 22, "total_weight_kg": 48.5, "estimated_time_minutes": 145 },
514
+ "explanation": "Your route covers the Koramangala area with 22 packages. Expected completion: 2.5 hours."
515
+ }
516
+ ]
517
+ }
518
+ ```
519
 
520
+ ### Operations (Backend-DM)
521
 
522
+ | Method | Endpoint | Description |
523
+ |--------|----------|-------------|
524
+ | `GET` | `/api/dashboard/stats` | Dashboard KPIs |
525
+ | `GET` | `/api/drivers` | List all drivers |
526
+ | `POST` | `/api/dispatch/assign` | Assign mission to driver |
527
+ | `POST` | `/api/absorption/initiate` | Initiate goods handover |
528
+ | `POST` | `/api/absorption/verify` | Verify QR handshake |
529
+ | `GET` | `/api/ewaybill/generate/:id` | Generate e-Way Bill PDF |
530
+ | `GET` | `/api/hubs` | List virtual relay hubs |
531
+
532
+ ---
533
 
534
+ ## Project Structure
535
+
536
+ ```
537
+ fairrelay/
538
+ ├── brain/ # AI Engine (Python/FastAPI)
539
+ │ ├── app/
540
+ │ │ ├── api/
541
+ │ │ │ ├── consolidation.py # Load consolidation endpoints
542
+ │ │ │ ├── allocation_langgraph.py # Fair dispatch endpoints
543
+ │ │ │ ├── admin.py
544
+ │ │ │ ├── drivers.py
545
+ │ │ │ └── feedback.py
546
+ │ │ ├── services/
547
+ │ │ │ ├── consolidation_engine.py # 5 consolidation agents
548
+ │ │ │ ├── consolidation_workflow.py # LangGraph consolidation flow
549
+ │ │ │ ├── langgraph_workflow.py # LangGraph dispatch flow
550
+ │ │ │ ├── langgraph_nodes.py # Dispatch agent implementations
551
+ │ │ │ ├── ml_effort_agent.py # XGBoost scoring
552
+ │ │ │ ├── fairness_manager_agent.py # Gini evaluation
553
+ │ │ │ ├── route_planner_agent.py # Hungarian algorithm
554
+ │ │ │ └── gemini_explain_node.py # LLM explanations
555
+ │ │ ├── schemas/
556
+ │ │ │ ├── consolidation.py # Consolidation Pydantic models
557
+ │ │ │ └── allocation.py # Dispatch Pydantic models
558
+ │ │ ├── models/ # SQLAlchemy ORM models
559
+ │ │ ├── config.py
560
+ │ │ ├── database.py
561
+ │ │ └── main.py
562
+ │ ├── frontend/
563
+ │ │ ├── consolidation.html # Consolidation dashboard
564
+ │ │ ├── visualization.html # Agent visualization
565
+ │ │ └── demo.html # API demo page
566
+ │ ├── data/
567
+ │ │ └── rl_experience.json # Q-Learning experience store
568
+ │ ├── alembic/ # Database migrations
569
+ │ ├── requirements.txt
570
+ │ ├── Dockerfile
571
+ │ ├── gunicorn.conf.py
572
+ │ └── render.yaml
573
+
574
+ ├── ops/ # Operations Platform
575
+ │ ├── backend-dm/ # Node.js backend
576
+ │ │ ├── controllers/
577
+ │ │ │ ├── routeController.js # Relay logic & assignment
578
+ │ │ │ ├── ewayBillController.js # PDF generation
579
+ │ │ │ └── dispatchController.js # Brain proxy
580
+ │ │ ├── services/
581
+ │ │ │ ├── dispatch.js # Brain API integration
582
+ │ │ │ ├── puppeteer.service.js # PDF rendering
583
+ │ │ │ └── qr.service.js # QR code generation
584
+ │ │ ├── prisma/schema.prisma
585
+ │ │ ├── render.yaml
586
+ │ │ └── index.js
587
+ │ │
588
+ │ ├── AIsupplychain/aisupply/ # React Dashboard (Vite)
589
+ │ │ ├── src/
590
+ │ │ │ ├── pages/ # Dashboard, Drivers, Routes, Bills, Tracking
591
+ │ │ │ ├── store/ # Redux slices
592
+ │ │ │ └── components/
593
+ │ │ └── vercel.json
594
+ │ │
595
+ │ └── logistic_flutter/
596
+ │ ├── orchastra_ps4/ecology/ # Flutter Mobile App
597
+ │ └── streamlit/ # Women Empowerment Hub
598
+
599
+ └── landing/ # Marketing Website (React/Vite)
600
+ ├── src/components/
601
+ │ ├── Hero.tsx # Problem statement + stats
602
+ │ ├── Features.tsx # 6 feature cards
603
+ │ ├── LiveDemo.tsx # Interactive allocation demo
604
+ │ └── HowItWorks.tsx # 3-step integration guide
605
+ └── vercel.json
606
  ```
607
 
608
+ ---
609
+
610
+ ## Deployment
611
+
612
+ | Component | Platform | URL Pattern |
613
+ |-----------|----------|-------------|
614
+ | Brain (AI Engine) | Render | `brain-api.onrender.com` |
615
+ | Backend-DM | Render | `backend-dm.onrender.com` |
616
+ | Dashboard | Vercel | `dashboard.fairrelay.io` |
617
+ | Landing Page | Vercel | `fairrelay.io` |
618
+
619
+ Both backend services include `render.yaml` for one-click Render deployment. Frontend apps include `vercel.json` with API rewrites configured.
620
+
621
+ ---
622
+
623
+ ## Performance Results
624
+
625
+ | Metric | Before | After | Improvement |
626
+ |--------|--------|-------|-------------|
627
+ | Vehicle Utilization | ~38% | ~78% | **+40 percentage points** |
628
+ | Trips Required | 10 | 4 | **60% reduction** |
629
+ | Distance Traveled | 2,847 km | 1,523 km | **46% less** |
630
+ | CO2 Emissions | — | -102 kg saved | **Carbon negative** |
631
+ | Fuel Cost | — | -Rs. 10,964 saved | **Per consolidation run** |
632
+ | Workload Gini Index | 0.85 | 0.12 | **Grade A fairness** |
633
+ | Decision Explainability | 0% | 100% | **Full transparency** |
634
+
635
+ ---
636
+
637
+ <p align="center">
638
+ <strong>Fair routes. Optimized loads. Explainable by default.</strong>
639
+ <br/>
640
+ Built for <a href="#">LogisticsNow Hackathon 2026</a> &middot; Challenge #5: AI Load Consolidation
641
+ </p>
brain/.env.example ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Database Configuration
2
+ DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/fairrelay
3
+
4
+ # Application Settings
5
+ APP_ENV=development
6
+ DEBUG=true
7
+
8
+ # CORS origins (comma-separated, used in production)
9
+ CORS_ORIGINS=https://fairrelay.vercel.app,https://fairrelay-dashboard.vercel.app
10
+
11
+ # Workload Score Weights
12
+ WORKLOAD_WEIGHT_A=1.0
13
+ WORKLOAD_WEIGHT_B=0.5
14
+ WORKLOAD_WEIGHT_C=10.0
15
+ WORKLOAD_WEIGHT_D=0.2
16
+
17
+ # Clustering Settings
18
+ TARGET_PACKAGES_PER_ROUTE=20
19
+
20
+ # LangGraph / LangSmith (optional - for tracing)
21
+ LANGCHAIN_TRACING_V2=false
22
+ LANGCHAIN_API_KEY=
23
+ LANGCHAIN_PROJECT=fair-dispatch-dev
24
+
25
+ # Gemini API (optional - for LLM explanations)
26
+ GOOGLE_API_KEY=
brain/.github/workflows/test.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Tests
2
+ on: [push, pull_request]
3
+
4
+ jobs:
5
+ test:
6
+ runs-on: ubuntu-latest
7
+ services:
8
+ postgres:
9
+ image: postgres:15
10
+ env:
11
+ POSTGRES_DB: fair_dispatch_test
12
+ POSTGRES_USER: test
13
+ POSTGRES_PASSWORD: test
14
+ options: >-
15
+ --health-cmd pg_isready
16
+ --health-interval 10s
17
+ --health-timeout 5s
18
+ --health-retries 5
19
+ ports:
20
+ - 5432:5432
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: actions/setup-python@v5
25
+ with:
26
+ python-version: '3.11'
27
+
28
+ - name: Install dependencies
29
+ run: |
30
+ pip install -r requirements.txt
31
+ pip install pytest pytest-asyncio pytest-xdist pytest-cov httpx faker factory-boy
32
+
33
+ - name: Run tests
34
+ env:
35
+ TEST_DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/fair_dispatch_test
36
+ run: |
37
+ pytest tests/ -n auto --cov=app --cov-report=xml --cov-fail-under=90
38
+
39
+ - name: Upload coverage
40
+ uses: codecov/codecov-action@v3
41
+ with:
42
+ file: coverage.xml
brain/.gitignore ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment files
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+
28
+ # Virtual environments
29
+ venv/
30
+ ENV/
31
+ env/
32
+ .venv/
33
+
34
+ # IDE
35
+ .idea/
36
+ .vscode/
37
+ *.swp
38
+ *.swo
39
+ *~
40
+
41
+ # Testing
42
+ .pytest_cache/
43
+ .coverage
44
+ htmlcov/
45
+ .tox/
46
+ .nox/
47
+
48
+ # Database
49
+ *.db
50
+ *.sqlite3
51
+
52
+ # Logs
53
+ *.log
54
+
55
+ # OS
56
+ .DS_Store
57
+ Thumbs.db
58
+
59
+ # Debug and test files
60
+ debug_*.py
61
+ test_*_output.txt
62
+ error*.txt
63
+ *_error.txt
64
+
65
+ # Cache directories
66
+ cache/
67
+ **/cache/
68
+ *.json.cache
69
+ .mypy_cache/
70
+
71
+ # Node.js (if applicable)
72
+ node_modules/
73
+ npm-debug.log*
74
+ yarn-debug.log*
75
+ yarn-error.log*
76
+ package-lock.json
77
+ yarn.lock
78
+
79
+ # Jupyter Notebooks
80
+ .ipynb_checkpoints/
81
+
82
+ # Local test data
83
+ test_*.json
84
+
85
+ # Alembic versions (keep tracked but ignore temp files)
86
+ alembic/versions/__pycache__/
87
+
88
+ # Streamlit
89
+ .streamlit/
90
+
91
+ # Local development files
92
+ *.local
93
+ *.bak
94
+ *.tmp
95
+ *.temp
brain/Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ gcc \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy requirements first for layer caching
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy application code
15
+ COPY . .
16
+
17
+ # Create non-root user
18
+ RUN useradd --create-home appuser
19
+ USER appuser
20
+
21
+ EXPOSE 8000
22
+
23
+ CMD ["gunicorn", "app.main:app", "-c", "gunicorn.conf.py"]
brain/Makefile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: test test-e2e test-parallel test-cov lint
2
+
3
+ # Run all tests
4
+ test:
5
+ pytest tests/ -v --tb=short
6
+
7
+ # Run E2E tests only
8
+ test-e2e:
9
+ pytest tests/test_full_workflow.py tests/test_ev_recovery_e2e.py tests/test_admin_api.py -v
10
+
11
+ # Run tests in parallel (requires pytest-xdist)
12
+ test-parallel:
13
+ pytest tests/ -n auto -v
14
+
15
+ # Run with coverage
16
+ test-cov:
17
+ pytest tests/ --cov=app --cov-report=html --cov-report=term --cov-fail-under=90
18
+
19
+ # Lint
20
+ lint:
21
+ # ruff check app/ tests/ # ruff might not be installed
22
+ # mypy app/
23
+ echo "Linting skipped (install ruff/mypy to enable)"
24
+
25
+ # Start test database
26
+ test-db-up:
27
+ docker-compose -f tests/docker-compose.test.yml up -d
28
+
29
+ # Stop test database
30
+ test-db-down:
31
+ docker-compose -f tests/docker-compose.test.yml down -v
32
+
33
+ # Full CI pipeline
34
+ ci: test-cov
brain/README.md ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <h1 align="center">🚚 Fair Dispatch System</h1>
3
+ <p align="center">
4
+ <strong>Single‑API Fair Routing · Angelic Fairness Engine · Live Agent Visualization</strong>
5
+ </p>
6
+ <p align="center">
7
+ <a href="#-quick-start">Quick Start</a> •
8
+ <a href="#-features">Features</a> •
9
+ <a href="#-architecture">Architecture</a> •
10
+ <a href="#-api-reference">API Reference</a> •
11
+ <a href="#-visualization-dashboard">Dashboard</a>
12
+ </p>
13
+ </p>
14
+
15
+ ---
16
+
17
+ Fair Dispatch is an AI‑assisted, **fairness‑aware route allocation engine** designed as a single seamless API that any logistics stack can plug into.
18
+
19
+ **You send today's drivers and packages as JSON. The system does everything else:**
20
+ - 📦 Clustering packages into optimal routes
21
+ - ⚖️ Calculating effort scores and fairness metrics
22
+ - 🛣️ Planning routes with EV-aware optimization
23
+ - 🤝 Balancing workload across drivers
24
+ - 🤖 AI-powered driver negotiation and explanation
25
+ - 📊 Learning from feedback to improve over time
26
+
27
+ ...and streams the whole multi‑agent process into a **live visualization**.
28
+
29
+ ## ✨ Features
30
+
31
+ | Feature | Description |
32
+ |---------|-------------|
33
+ | **🎯 Single API Endpoint** | One POST to `/api/v1/langgraph/allocate` handles everything |
34
+ | **🤖 5+ Specialized AI Agents** | LangGraph-orchestrated multi-agent workflow |
35
+ | **⚖️ Fairness-First Design** | Gini index, individual fairness scores, and equity metrics |
36
+ | **🗣️ Natural Language Explanations** | Gemini-powered driver-friendly route explanations |
37
+ | **📊 Live Agent Visualization** | Real-time Streamlit dashboard showing agent workflow |
38
+ | **🔄 Continuous Learning** | Feedback loop improves allocations over time |
39
+ | **⚡ EV-Aware Routing** | Battery constraints and charging station integration |
40
+ | **🔐 Full Audit Trail** | Complete decision logging for transparency |
41
+
42
+ ## 🏗️ Architecture
43
+
44
+ ### Multi-Agent Workflow (LangGraph)
45
+
46
+ ```
47
+ ┌────────────────────────────────────────────────────────────────────────────────┐
48
+ │ FAIR DISPATCH WORKFLOW │
49
+ └────────────────────────────────────────────────────────────────────────────────┘
50
+
51
+
52
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
53
+ │ 🔧 Initialize │ → │ 📦 Clustering │ → │ 💪 ML Effort │
54
+ │ Node │ │ Agent │ │ Agent │
55
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
56
+
57
+
58
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
59
+ │ ⚡ EV Recovery │ ← │ ⚖️ Fairness │ ← │ 🛣️ Route │
60
+ │ Node │ │ Manager │ │ Planner │
61
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
62
+ │ │
63
+ ▼ ▼ (if unfair)
64
+ ┌─────────────────┐ ┌─────────────────┐
65
+ │ 🤝 Driver │ │ 🔄 Reoptimize │
66
+ │ Liaison │ │ Loop │
67
+ └─────────────────┘ └─────────────────┘
68
+
69
+
70
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
71
+ │ 🎓 Learning │ → │ 🗣️ LLM │ → │ ✅ Finalize │
72
+ │ Agent │ │ Explain │ │ Node │
73
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
74
+ ```
75
+
76
+ ### Agent Descriptions
77
+
78
+ | Agent | Purpose | Key Outputs |
79
+ |-------|---------|-------------|
80
+ | **Initialize Node** | Sets up allocation state, validates inputs | Validated driver/package data |
81
+ | **Clustering Agent** | Groups packages using K-Means by geography | Route clusters with centroids |
82
+ | **ML Effort Agent** | Builds effort matrix for all driver-route pairs | Effort scores, XGBoost predictions |
83
+ | **Route Planner Agent** | Solves optimal assignment (Hungarian algorithm) | Driver-route assignments |
84
+ | **Fairness Manager** | Evaluates Gini index, std dev, thresholds | ACCEPT or REOPTIMIZE decision |
85
+ | **EV Recovery Node** | Handles EV battery constraints | Charging station insertions |
86
+ | **Driver Liaison Agent** | Handles driver negotiations/appeals | Appeal resolutions |
87
+ | **Learning Agent** | Updates models from feedback | Improved future allocations |
88
+ | **LLM Explain Node** | Generates natural language explanations | Human-readable route descriptions |
89
+
90
+ ## 🚀 Quick Start
91
+
92
+ ### Prerequisites
93
+
94
+ - **Python 3.11+**
95
+ - **PostgreSQL 14+** (or SQLite for development)
96
+ - **Git**
97
+
98
+ ### 1. Clone & Setup
99
+
100
+ ```bash
101
+ # Clone the repository
102
+ git clone https://github.com/your-org/fair-dispatch-system.git
103
+ cd fair-dispatch-system
104
+
105
+ # Create virtual environment
106
+ python -m venv venv
107
+
108
+ # Activate virtual environment
109
+ # Windows:
110
+ venv\Scripts\activate
111
+ # Linux/macOS:
112
+ source venv/bin/activate
113
+
114
+ # Install dependencies
115
+ pip install -r requirements.txt
116
+ ```
117
+
118
+ ### 2. Configure Environment
119
+
120
+ ```bash
121
+ # Copy example environment file
122
+ cp .env.example .env
123
+
124
+ # Edit .env with your configuration
125
+ ```
126
+
127
+ **Essential environment variables:**
128
+
129
+ ```env
130
+ # Database (PostgreSQL recommended for production)
131
+ DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/fair_dispatch
132
+
133
+ # Application
134
+ APP_ENV=development
135
+ DEBUG=true
136
+
137
+ # Optional: Gemini API for AI explanations
138
+ GOOGLE_API_KEY=your-gemini-api-key
139
+
140
+ # Optional: LangSmith tracing
141
+ LANGCHAIN_TRACING_V2=true
142
+ LANGCHAIN_API_KEY=your-langsmith-key
143
+ ```
144
+
145
+ ### 3. Setup Database
146
+
147
+ ```bash
148
+ # Create PostgreSQL database
149
+ createdb fair_dispatch
150
+
151
+ # Run migrations
152
+ alembic upgrade head
153
+ ```
154
+
155
+ ### 4. Start the Server
156
+
157
+ ```bash
158
+ # Development server with hot reload
159
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
160
+ ```
161
+
162
+ ### 5. Access the System
163
+
164
+ | Endpoint | URL |
165
+ |----------|-----|
166
+ | **API Documentation** | http://localhost:8000/docs |
167
+ | **ReDoc** | http://localhost:8000/redoc |
168
+ | **Demo Page** | http://localhost:8000/demo/allocate |
169
+ | **Admin Dashboard** | http://localhost:8000/admin |
170
+
171
+ ## 📊 Visualization Dashboard
172
+
173
+ The system includes a **real-time Streamlit dashboard** for monitoring allocations:
174
+
175
+ ```bash
176
+ # Navigate to dashboard directory
177
+ cd supply_chain_dashboard
178
+
179
+ # Install dashboard dependencies
180
+ pip install -r requirements.txt
181
+
182
+ # Run the dashboard
183
+ streamlit run dashboard.py
184
+ ```
185
+
186
+ **Dashboard Features:**
187
+ - 🗺️ **Live Map Visualization** - See routes on an interactive map
188
+ - 📈 **Fairness Metrics** - Real-time Gini index and equity scores
189
+ - 🤖 **Agent Activity Feed** - Watch agents work in real-time
190
+ - 📊 **Analytics Charts** - Workload distribution and trends
191
+
192
+ ## 📡 API Reference
193
+
194
+ ### Primary Endpoint: Allocate Routes
195
+
196
+ **`POST /api/v1/langgraph/allocate`**
197
+
198
+ This single endpoint handles the complete allocation workflow.
199
+
200
+ #### Request
201
+
202
+ ```json
203
+ {
204
+ "date": "2026-02-10",
205
+ "warehouse": {
206
+ "lat": 12.9716,
207
+ "lng": 77.5946
208
+ },
209
+ "packages": [
210
+ {
211
+ "id": "pkg_001",
212
+ "weight_kg": 2.5,
213
+ "fragility_level": 3,
214
+ "address": "123 Main St, Bangalore",
215
+ "latitude": 12.97,
216
+ "longitude": 77.60,
217
+ "priority": "NORMAL"
218
+ },
219
+ {
220
+ "id": "pkg_002",
221
+ "weight_kg": 1.0,
222
+ "fragility_level": 1,
223
+ "address": "456 Oak Ave, Bangalore",
224
+ "latitude": 12.98,
225
+ "longitude": 77.61,
226
+ "priority": "HIGH"
227
+ }
228
+ ],
229
+ "drivers": [
230
+ {
231
+ "id": "driver_001",
232
+ "name": "Raju",
233
+ "vehicle_capacity_kg": 150,
234
+ "preferred_language": "en",
235
+ "vehicle_type": "PETROL"
236
+ },
237
+ {
238
+ "id": "driver_002",
239
+ "name": "Kumar",
240
+ "vehicle_capacity_kg": 200,
241
+ "preferred_language": "ta",
242
+ "vehicle_type": "EV",
243
+ "ev_range_km": 120
244
+ }
245
+ ]
246
+ }
247
+ ```
248
+
249
+ #### Response
250
+
251
+ ```json
252
+ {
253
+ "allocation_run_id": "550e8400-e29b-41d4-a716-446655440000",
254
+ "date": "2026-02-10",
255
+ "status": "SUCCESS",
256
+ "global_fairness": {
257
+ "avg_workload": 63.2,
258
+ "std_dev": 5.4,
259
+ "gini_index": 0.12,
260
+ "max_gap": 8.3
261
+ },
262
+ "assignments": [
263
+ {
264
+ "driver_id": "driver_001",
265
+ "driver_name": "Raju",
266
+ "route_id": "route_uuid",
267
+ "workload_score": 65.3,
268
+ "fairness_score": 0.92,
269
+ "route_summary": {
270
+ "num_packages": 22,
271
+ "total_weight_kg": 48.5,
272
+ "num_stops": 14,
273
+ "estimated_time_minutes": 145
274
+ },
275
+ "explanation": "Your route covers the Koramangala area with 22 packages, mostly residential. Expected completion time is around 2.5 hours with moderate traffic."
276
+ }
277
+ ],
278
+ "agent_events": [
279
+ {
280
+ "agent": "clustering_agent",
281
+ "status": "completed",
282
+ "message": "Created 5 route clusters"
283
+ },
284
+ {
285
+ "agent": "fairness_manager",
286
+ "status": "completed",
287
+ "message": "Allocation ACCEPTED (Gini: 0.12)"
288
+ }
289
+ ]
290
+ }
291
+ ```
292
+
293
+ ### Additional Endpoints
294
+
295
+ | Method | Endpoint | Description |
296
+ |--------|----------|-------------|
297
+ | `GET` | `/api/v1/drivers/{id}` | Get driver details and stats |
298
+ | `GET` | `/api/v1/routes/{id}` | Get route details and packages |
299
+ | `POST` | `/api/v1/feedback` | Submit driver feedback |
300
+ | `GET` | `/api/v1/admin/dashboard` | Admin dashboard data |
301
+ | `GET` | `/api/v1/runs` | List allocation runs |
302
+ | `GET` | `/api/v1/runs/{id}/events` | Get agent events for a run |
303
+
304
+ ## 🧪 Testing
305
+
306
+ ```bash
307
+ # Run all tests
308
+ make test
309
+
310
+ # Run with coverage
311
+ make test-cov
312
+
313
+ # Run specific test file
314
+ pytest tests/test_allocation.py -v
315
+
316
+ # Run E2E tests only
317
+ make test-e2e
318
+
319
+ # Run tests in parallel (faster)
320
+ pytest tests/ -n auto
321
+ ```
322
+
323
+ ## ⚙️ Configuration
324
+
325
+ ### Environment Variables
326
+
327
+ | Variable | Default | Description |
328
+ |----------|---------|-------------|
329
+ | `DATABASE_URL` | - | PostgreSQL connection string |
330
+ | `DEBUG` | `true` | Enable debug mode |
331
+ | `GOOGLE_API_KEY` | - | Gemini API key for explanations |
332
+ | `LANGCHAIN_TRACING_V2` | `false` | Enable LangSmith tracing |
333
+ | `LANGCHAIN_API_KEY` | - | LangSmith API key |
334
+
335
+ ### Workload Score Weights
336
+
337
+ | Variable | Default | Description |
338
+ |----------|---------|-------------|
339
+ | `WORKLOAD_WEIGHT_A` | `1.0` | Weight for num_packages |
340
+ | `WORKLOAD_WEIGHT_B` | `0.5` | Weight for total_weight_kg |
341
+ | `WORKLOAD_WEIGHT_C` | `10.0` | Weight for route_difficulty_score |
342
+ | `WORKLOAD_WEIGHT_D` | `0.2` | Weight for estimated_time_minutes |
343
+
344
+ ### Fairness Thresholds
345
+
346
+ | Variable | Default | Description |
347
+ |----------|---------|-------------|
348
+ | `TARGET_PACKAGES_PER_ROUTE` | `20` | Target packages per cluster |
349
+ | `GINI_THRESHOLD` | `0.25` | Max acceptable Gini index |
350
+ | `STD_DEV_THRESHOLD` | `15.0` | Max acceptable standard deviation |
351
+
352
+ ## 📐 Algorithms
353
+
354
+ ### Workload Score Formula
355
+
356
+ ```
357
+ workload_score = a × num_packages
358
+ + b × total_weight_kg
359
+ + c × route_difficulty_score
360
+ + d × estimated_time_minutes
361
+ ```
362
+
363
+ ### Gini Index
364
+
365
+ Measures inequality in workload distribution (0 = perfect equality, 1 = maximum inequality):
366
+
367
+ ```
368
+ G = (2 × Σ(i × x_i)) / (n × Σx_i) - (n + 1) / n
369
+ ```
370
+
371
+ ### Individual Fairness Score
372
+
373
+ Per-driver fairness relative to average:
374
+
375
+ ```
376
+ fairness_score = 1 - |workload - avg_workload| / max(avg_workload, 1)
377
+ ```
378
+
379
+ ## 📁 Project Structure
380
+
381
+ ```
382
+ fair-dispatch-system/
383
+ ├── 📂 alembic/ # Database migrations
384
+ │ └── versions/ # Migration files
385
+ ├── 📂 app/
386
+ │ ├── 📂 api/ # FastAPI routers
387
+ │ │ ├── allocation.py # POST /allocate (basic)
388
+ │ │ ├── allocation_langgraph.py # POST /langgraph/allocate
389
+ │ │ ├── admin.py # Admin endpoints
390
+ │ │ ├── drivers.py # Driver endpoints
391
+ │ │ ├── feedback.py # Feedback endpoints
392
+ │ │ └── routes.py # Route endpoints
393
+ │ ├── 📂 models/ # SQLAlchemy models
394
+ │ │ ├── driver.py
395
+ │ │ ├── package.py
396
+ │ │ ├── route.py
397
+ │ │ └── assignment.py
398
+ │ ├── 📂 schemas/ # Pydantic DTOs
399
+ │ ├── 📂 services/ # Business logic
400
+ │ │ ├── langgraph_workflow.py # Agent orchestration
401
+ │ │ ├── langgraph_nodes.py # Individual agents
402
+ │ │ ├── ml_effort_agent.py # ML scoring
403
+ │ │ ├── fairness_manager_agent.py
404
+ │ │ ├── route_planner_agent.py
405
+ │ │ ├── driver_liaison_agent.py
406
+ │ │ ├── learning_agent.py
407
+ │ │ ├── gemini_explain_node.py
408
+ │ │ └── ...
409
+ │ ├── config.py # Settings
410
+ │ ├── database.py # DB connection
411
+ │ └── main.py # FastAPI app
412
+ ├── 📂 frontend/ # Static frontend files
413
+ │ ├── index.html # Demo UI
414
+ │ └── visualization.html # Live visualization
415
+ ├── 📂 supply_chain_dashboard/ # Streamlit dashboard
416
+ │ ├── dashboard.py
417
+ │ └── api_client.py
418
+ ├── 📂 tests/ # Test suite
419
+ ├── .env.example
420
+ ├── requirements.txt
421
+ ├── Makefile
422
+ └── README.md
423
+ ```
424
+
425
+ ## 🔧 Development
426
+
427
+ ### Running in Development Mode
428
+
429
+ ```bash
430
+ # Start with auto-reload
431
+ uvicorn app.main:app --reload
432
+
433
+ # Start with custom port
434
+ uvicorn app.main:app --reload --port 3000
435
+
436
+ # Start with debug logging
437
+ DEBUG=true uvicorn app.main:app --reload
438
+ ```
439
+
440
+ ### Database Migrations
441
+
442
+ ```bash
443
+ # Create new migration
444
+ alembic revision --autogenerate -m "Add new table"
445
+
446
+ # Apply migrations
447
+ alembic upgrade head
448
+
449
+ # Rollback one version
450
+ alembic downgrade -1
451
+
452
+ # View migration history
453
+ alembic history
454
+ ```
455
+
456
+ ### Makefile Commands
457
+
458
+ ```bash
459
+ make test # Run all tests
460
+ make test-cov # Run with coverage
461
+ make test-e2e # Run E2E tests
462
+ make test-parallel # Run tests in parallel
463
+ make lint # Run linting
464
+ make format # Format code
465
+ make ci # Full CI pipeline
466
+ ```
467
+
468
+ ## 🤝 Contributing
469
+
470
+ 1. Fork the repository
471
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
472
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
473
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
474
+ 5. Open a Pull Request
475
+
476
+ ## 📄 License
477
+
478
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
479
+
480
+ ---
481
+
482
+ <p align="center">
483
+ Built with ❤️ for fairer logistics
484
+ </p>
brain/alembic.ini ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ script_location = alembic
6
+
7
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8
+ # Uncomment the line below if you want the files to be named in numerical order
9
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
10
+
11
+ # sys.path path, will be prepended to sys.path if present.
12
+ # defaults to the current working directory.
13
+ prepend_sys_path = .
14
+
15
+ # timezone to use when rendering the date within the migration file
16
+ # as well as the filename.
17
+ # If specified, requires the python-dateutil library that can be
18
+ # installed by adding `alembic[tz]` to the pip requirements
19
+ # string value is passed to dateutil.tz.gettz()
20
+ # leave blank for localtime
21
+ # timezone =
22
+
23
+ # max length of characters to apply to the
24
+ # "slug" field
25
+ # truncate_slug_length = 40
26
+
27
+ # set to 'true' to run the environment during
28
+ # the 'revision' command, regardless of autogenerate
29
+ # revision_environment = false
30
+
31
+ # set to 'true' to allow .pyc and .pyo files without
32
+ # a source .py file to be detected as revisions in the
33
+ # versions/ directory
34
+ # sourceless = false
35
+
36
+ # version location specification; This defaults
37
+ # to alembic/versions. When using multiple version
38
+ # directories, initial revisions must be specified with --version-path.
39
+ # The path separator used here should be the separator specified by "version_path_separator" below.
40
+ # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
41
+
42
+ # version path separator; As mentioned above, this is the character used to split
43
+ # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
44
+ # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
45
+ # Valid values for version_path_separator are:
46
+ #
47
+ # version_path_separator = :
48
+ # version_path_separator = ;
49
+ # version_path_separator = space
50
+ version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
51
+
52
+ # set to 'true' to search source files recursively
53
+ # in each "version_locations" directory
54
+ # new in Alembic version 1.10
55
+ # recursive_version_locations = false
56
+
57
+ # the output encoding used when revision files
58
+ # are written from script.py.mako
59
+ # output_encoding = utf-8
60
+
61
+ sqlalchemy.url = driver://user:pass@localhost/dbname
62
+
63
+
64
+ [post_write_hooks]
65
+ # post_write_hooks defines scripts or Python functions that are run
66
+ # on newly generated revision scripts. See the documentation for further
67
+ # detail and examples
68
+
69
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
70
+ # hooks = black
71
+ # black.type = console_scripts
72
+ # black.entrypoint = black
73
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
74
+
75
+ # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
76
+ # hooks = ruff
77
+ # ruff.type = exec
78
+ # ruff.executable = %(here)s/.venv/bin/ruff
79
+ # ruff.options = --fix REVISION_SCRIPT_FILENAME
80
+
81
+ # Logging configuration
82
+ [loggers]
83
+ keys = root,sqlalchemy,alembic
84
+
85
+ [handlers]
86
+ keys = console
87
+
88
+ [formatters]
89
+ keys = generic
90
+
91
+ [logger_root]
92
+ level = WARN
93
+ handlers = console
94
+ qualname =
95
+
96
+ [logger_sqlalchemy]
97
+ level = WARN
98
+ handlers =
99
+ qualname = sqlalchemy.engine
100
+
101
+ [logger_alembic]
102
+ level = INFO
103
+ handlers =
104
+ qualname = alembic
105
+
106
+ [handler_console]
107
+ class = StreamHandler
108
+ args = (sys.stderr,)
109
+ level = NOTSET
110
+ formatter = generic
111
+
112
+ [formatter_generic]
113
+ format = %(levelname)-5.5s [%(name)s] %(message)s
114
+ datefmt = %H:%M:%S
brain/alembic/env.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Alembic environment configuration.
3
+ Handles database connection and migration context.
4
+ """
5
+
6
+ import asyncio
7
+ from logging.config import fileConfig
8
+
9
+ from sqlalchemy import pool
10
+ from sqlalchemy.engine import Connection
11
+ from sqlalchemy.ext.asyncio import async_engine_from_config
12
+
13
+ from alembic import context
14
+
15
+ # Import models to ensure they're registered with Base.metadata
16
+ from app.database import Base
17
+ from app.models import * # noqa: F401, F403
18
+ from app.config import get_settings
19
+
20
+ # this is the Alembic Config object
21
+ config = context.config
22
+
23
+ # Get database URL from settings
24
+ settings = get_settings()
25
+ config.set_main_option("sqlalchemy.url", settings.database_url)
26
+
27
+ # Interpret the config file for Python logging.
28
+ if config.config_file_name is not None:
29
+ fileConfig(config.config_file_name)
30
+
31
+ # add your model's MetaData object here for 'autogenerate' support
32
+ target_metadata = Base.metadata
33
+
34
+
35
+ def run_migrations_offline() -> None:
36
+ """Run migrations in 'offline' mode.
37
+
38
+ This configures the context with just a URL
39
+ and not an Engine, though an Engine is acceptable
40
+ here as well. By skipping the Engine creation
41
+ we don't even need a DBAPI to be available.
42
+
43
+ Calls to context.execute() here emit the given string to the
44
+ script output.
45
+ """
46
+ url = config.get_main_option("sqlalchemy.url")
47
+ context.configure(
48
+ url=url,
49
+ target_metadata=target_metadata,
50
+ literal_binds=True,
51
+ dialect_opts={"paramstyle": "named"},
52
+ )
53
+
54
+ with context.begin_transaction():
55
+ context.run_migrations()
56
+
57
+
58
+ def do_run_migrations(connection: Connection) -> None:
59
+ """Run migrations with the provided connection."""
60
+ context.configure(connection=connection, target_metadata=target_metadata)
61
+
62
+ with context.begin_transaction():
63
+ context.run_migrations()
64
+
65
+
66
+ async def run_async_migrations() -> None:
67
+ """Run migrations in async mode."""
68
+ connectable = async_engine_from_config(
69
+ config.get_section(config.config_ini_section, {}),
70
+ prefix="sqlalchemy.",
71
+ poolclass=pool.NullPool,
72
+ )
73
+
74
+ async with connectable.connect() as connection:
75
+ await connection.run_sync(do_run_migrations)
76
+
77
+ await connectable.dispose()
78
+
79
+
80
+ def run_migrations_online() -> None:
81
+ """Run migrations in 'online' mode.
82
+
83
+ In this scenario we need to create an Engine
84
+ and associate a connection with the context.
85
+ """
86
+ asyncio.run(run_async_migrations())
87
+
88
+
89
+ if context.is_offline_mode():
90
+ run_migrations_offline()
91
+ else:
92
+ run_migrations_online()
brain/alembic/script.py.mako ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
brain/alembic/versions/001_initial_schema.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initial schema - Create all tables
2
+
3
+ Revision ID: 001_initial_schema
4
+ Revises:
5
+ Create Date: 2026-02-03
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ from sqlalchemy.dialects import postgresql
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '001_initial_schema'
16
+ down_revision: Union[str, None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # Create enum types
23
+ op.execute("CREATE TYPE preferredlanguage AS ENUM ('en', 'ta', 'hi', 'te', 'kn')")
24
+ op.execute("CREATE TYPE vehicletype AS ENUM ('ICE', 'EV', 'BICYCLE')")
25
+ op.execute("CREATE TYPE packagepriority AS ENUM ('NORMAL', 'HIGH', 'EXPRESS')")
26
+ op.execute("CREATE TYPE hardestaspect AS ENUM ('traffic', 'parking', 'stairs', 'weather', 'heavy_load', 'customer', 'navigation', 'other')")
27
+
28
+ # Create drivers table
29
+ op.create_table(
30
+ 'drivers',
31
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
32
+ sa.Column('external_id', sa.String(100), unique=True, nullable=True, index=True),
33
+ sa.Column('name', sa.String(255), nullable=False),
34
+ sa.Column('phone', sa.String(20), nullable=True),
35
+ sa.Column('whatsapp_number', sa.String(20), nullable=True),
36
+ sa.Column('preferred_language', postgresql.ENUM('en', 'ta', 'hi', 'te', 'kn', name='preferredlanguage', create_type=False), nullable=False, server_default='en'),
37
+ sa.Column('vehicle_type', postgresql.ENUM('ICE', 'EV', 'BICYCLE', name='vehicletype', create_type=False), nullable=False, server_default='ICE'),
38
+ sa.Column('vehicle_capacity_kg', sa.Float(), nullable=False, server_default='100.0'),
39
+ sa.Column('license_number', sa.String(50), nullable=True),
40
+ sa.Column('ev_charging_pref', postgresql.JSON(), nullable=True),
41
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
42
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
43
+ )
44
+
45
+ # Create packages table
46
+ op.create_table(
47
+ 'packages',
48
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
49
+ sa.Column('external_id', sa.String(100), unique=True, nullable=False, index=True),
50
+ sa.Column('weight_kg', sa.Float(), nullable=False, server_default='1.0'),
51
+ sa.Column('fragility_level', sa.Integer(), nullable=False, server_default='1'),
52
+ sa.Column('address', sa.Text(), nullable=False),
53
+ sa.Column('latitude', sa.Float(), nullable=False),
54
+ sa.Column('longitude', sa.Float(), nullable=False),
55
+ sa.Column('priority', postgresql.ENUM('NORMAL', 'HIGH', 'EXPRESS', name='packagepriority', create_type=False), nullable=False, server_default='NORMAL'),
56
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
57
+ )
58
+
59
+ # Create routes table
60
+ op.create_table(
61
+ 'routes',
62
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
63
+ sa.Column('date', sa.Date(), nullable=False, index=True),
64
+ sa.Column('cluster_id', sa.Integer(), nullable=False),
65
+ sa.Column('total_weight_kg', sa.Float(), nullable=False, server_default='0.0'),
66
+ sa.Column('num_packages', sa.Integer(), nullable=False, server_default='0'),
67
+ sa.Column('num_stops', sa.Integer(), nullable=False, server_default='0'),
68
+ sa.Column('route_difficulty_score', sa.Float(), nullable=False, server_default='1.0'),
69
+ sa.Column('estimated_time_minutes', sa.Integer(), nullable=False, server_default='60'),
70
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
71
+ )
72
+
73
+ # Create route_packages association table
74
+ op.create_table(
75
+ 'route_packages',
76
+ sa.Column('route_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('routes.id', ondelete='CASCADE'), primary_key=True),
77
+ sa.Column('package_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('packages.id', ondelete='CASCADE'), primary_key=True),
78
+ sa.Column('stop_order', sa.Integer(), nullable=False, server_default='0'),
79
+ )
80
+
81
+ # Create assignments table
82
+ op.create_table(
83
+ 'assignments',
84
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
85
+ sa.Column('date', sa.Date(), nullable=False, index=True),
86
+ sa.Column('driver_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='CASCADE'), nullable=False, index=True),
87
+ sa.Column('route_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('routes.id', ondelete='CASCADE'), nullable=False, index=True),
88
+ sa.Column('workload_score', sa.Float(), nullable=False, server_default='0.0'),
89
+ sa.Column('fairness_score', sa.Float(), nullable=False, server_default='1.0'),
90
+ sa.Column('explanation', sa.Text(), nullable=True),
91
+ sa.Column('allocation_run_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
92
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
93
+ )
94
+
95
+ # Create driver_stats_daily table
96
+ op.create_table(
97
+ 'driver_stats_daily',
98
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
99
+ sa.Column('driver_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='CASCADE'), nullable=False, index=True),
100
+ sa.Column('date', sa.Date(), nullable=False, index=True),
101
+ sa.Column('avg_workload_score', sa.Float(), nullable=False, server_default='0.0'),
102
+ sa.Column('total_routes', sa.Integer(), nullable=False, server_default='0'),
103
+ sa.Column('gini_contribution', sa.Float(), nullable=True),
104
+ sa.Column('reported_stress_level', sa.Float(), nullable=True),
105
+ sa.Column('reported_fairness_score', sa.Float(), nullable=True),
106
+ )
107
+
108
+ # Create driver_feedback table
109
+ op.create_table(
110
+ 'driver_feedback',
111
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
112
+ sa.Column('driver_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='CASCADE'), nullable=False, index=True),
113
+ sa.Column('assignment_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=False, index=True),
114
+ sa.Column('fairness_rating', sa.Integer(), nullable=False),
115
+ sa.Column('stress_level', sa.Integer(), nullable=False),
116
+ sa.Column('tiredness_level', sa.Integer(), nullable=False),
117
+ sa.Column('hardest_aspect', postgresql.ENUM('traffic', 'parking', 'stairs', 'weather', 'heavy_load', 'customer', 'navigation', 'other', name='hardestaspect', create_type=False), nullable=True),
118
+ sa.Column('comments', sa.Text(), nullable=True),
119
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
120
+ )
121
+
122
+ # Create indexes for common queries
123
+ op.create_index('ix_assignments_allocation_run', 'assignments', ['allocation_run_id'])
124
+ op.create_index('ix_driver_stats_driver_date', 'driver_stats_daily', ['driver_id', 'date'])
125
+
126
+
127
+ def downgrade() -> None:
128
+ # Drop indexes
129
+ op.drop_index('ix_driver_stats_driver_date', table_name='driver_stats_daily')
130
+ op.drop_index('ix_assignments_allocation_run', table_name='assignments')
131
+
132
+ # Drop tables in reverse order (respecting foreign keys)
133
+ op.drop_table('driver_feedback')
134
+ op.drop_table('driver_stats_daily')
135
+ op.drop_table('assignments')
136
+ op.drop_table('route_packages')
137
+ op.drop_table('routes')
138
+ op.drop_table('packages')
139
+ op.drop_table('drivers')
140
+
141
+ # Drop enum types
142
+ op.execute("DROP TYPE IF EXISTS hardestaspect")
143
+ op.execute("DROP TYPE IF EXISTS packagepriority")
144
+ op.execute("DROP TYPE IF EXISTS vehicletype")
145
+ op.execute("DROP TYPE IF EXISTS preferredlanguage")
brain/alembic/versions/002_phase2_phase3_models.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add Phase 2 and Phase 3 models
2
+
3
+ Revision ID: 002_phase2_phase3_models
4
+ Revises: 001_initial_schema
5
+ Create Date: 2026-02-04
6
+
7
+ Creates tables:
8
+ - delivery_logs
9
+ - route_swap_requests
10
+ - stop_issues
11
+ - appeals
12
+ - manual_overrides
13
+ - fairness_configs
14
+ - allocation_runs
15
+ - decision_logs
16
+
17
+ Modifies tables:
18
+ - driver_feedback (adds new columns)
19
+ """
20
+
21
+ from alembic import op
22
+ import sqlalchemy as sa
23
+ from sqlalchemy.dialects.postgresql import UUID
24
+
25
+
26
+ # revision identifiers, used by Alembic.
27
+ revision = '002_phase2_phase3_models'
28
+ down_revision = '001_initial_schema'
29
+ branch_labels = None
30
+ depends_on = None
31
+
32
+
33
+ def upgrade() -> None:
34
+ # Create allocation_runs table first (referenced by decision_logs)
35
+ op.create_table(
36
+ 'allocation_runs',
37
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
38
+ sa.Column('date', sa.Date(), nullable=False, index=True),
39
+ sa.Column('num_drivers', sa.Integer(), nullable=False, default=0),
40
+ sa.Column('num_routes', sa.Integer(), nullable=False, default=0),
41
+ sa.Column('num_packages', sa.Integer(), nullable=False, default=0),
42
+ sa.Column('global_gini_index', sa.Float(), default=0.0),
43
+ sa.Column('global_std_dev', sa.Float(), default=0.0),
44
+ sa.Column('global_max_gap', sa.Float(), default=0.0),
45
+ sa.Column('status', sa.Enum('SUCCESS', 'FAILED', name='allocationrunstatus'), nullable=False),
46
+ sa.Column('error_message', sa.Text(), nullable=True),
47
+ sa.Column('started_at', sa.DateTime(), nullable=False),
48
+ sa.Column('finished_at', sa.DateTime(), nullable=True),
49
+ )
50
+
51
+ # Create delivery_logs table
52
+ op.create_table(
53
+ 'delivery_logs',
54
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
55
+ sa.Column('assignment_id', UUID(as_uuid=True), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=False, index=True),
56
+ sa.Column('route_id', UUID(as_uuid=True), sa.ForeignKey('routes.id', ondelete='CASCADE'), nullable=False, index=True),
57
+ sa.Column('driver_id', UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='CASCADE'), nullable=False, index=True),
58
+ sa.Column('stop_order', sa.Integer(), nullable=False),
59
+ sa.Column('package_id', UUID(as_uuid=True), sa.ForeignKey('packages.id', ondelete='SET NULL'), nullable=True),
60
+ sa.Column('status', sa.Enum('DELIVERED', 'FAILED', 'PARTIAL', name='deliverystatus'), nullable=False),
61
+ sa.Column('issue_type', sa.Enum('NONE', 'NOT_AT_HOME', 'WRONG_ADDRESS', 'SAFETY', 'ACCESS_DENIED', 'OTHER', name='deliveryissuetype'), nullable=False),
62
+ sa.Column('photo_url', sa.String(500), nullable=True),
63
+ sa.Column('signature_data', sa.Text(), nullable=True),
64
+ sa.Column('notes', sa.Text(), nullable=True),
65
+ sa.Column('timestamp', sa.DateTime(), nullable=False),
66
+ )
67
+
68
+ # Create route_swap_requests table
69
+ op.create_table(
70
+ 'route_swap_requests',
71
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
72
+ sa.Column('from_driver_id', UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='CASCADE'), nullable=False, index=True),
73
+ sa.Column('to_driver_id', UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='SET NULL'), nullable=True),
74
+ sa.Column('assignment_id', UUID(as_uuid=True), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=False, index=True),
75
+ sa.Column('reason', sa.Text(), nullable=False),
76
+ sa.Column('preferred_date', sa.Date(), nullable=True),
77
+ sa.Column('status', sa.Enum('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED', name='swaprequeststatus'), nullable=False),
78
+ sa.Column('created_at', sa.DateTime(), nullable=False),
79
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
80
+ )
81
+
82
+ # Create stop_issues table
83
+ op.create_table(
84
+ 'stop_issues',
85
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
86
+ sa.Column('assignment_id', UUID(as_uuid=True), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=False, index=True),
87
+ sa.Column('route_id', UUID(as_uuid=True), sa.ForeignKey('routes.id', ondelete='CASCADE'), nullable=False, index=True),
88
+ sa.Column('driver_id', UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='CASCADE'), nullable=False, index=True),
89
+ sa.Column('stop_order', sa.Integer(), nullable=False),
90
+ sa.Column('issue_type', sa.Enum('NAVIGATION', 'SAFETY', 'TIME_WINDOW', 'CUSTOMER_UNAVAILABLE', 'OTHER', name='stopissuetype'), nullable=False),
91
+ sa.Column('notes', sa.Text(), nullable=False),
92
+ sa.Column('created_at', sa.DateTime(), nullable=False),
93
+ )
94
+
95
+ # Create appeals table
96
+ op.create_table(
97
+ 'appeals',
98
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
99
+ sa.Column('driver_id', UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='CASCADE'), nullable=False, index=True),
100
+ sa.Column('assignment_id', UUID(as_uuid=True), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=False, index=True),
101
+ sa.Column('reason', sa.Text(), nullable=False),
102
+ sa.Column('status', sa.Enum('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', name='appealstatus'), nullable=False),
103
+ sa.Column('admin_note', sa.Text(), nullable=True),
104
+ sa.Column('created_at', sa.DateTime(), nullable=False),
105
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
106
+ )
107
+
108
+ # Create manual_overrides table
109
+ op.create_table(
110
+ 'manual_overrides',
111
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
112
+ sa.Column('allocation_run_id', UUID(as_uuid=True), nullable=False, index=True),
113
+ sa.Column('old_driver_id', UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='SET NULL'), nullable=True),
114
+ sa.Column('new_driver_id', UUID(as_uuid=True), sa.ForeignKey('drivers.id', ondelete='SET NULL'), nullable=True),
115
+ sa.Column('route_id', UUID(as_uuid=True), sa.ForeignKey('routes.id', ondelete='SET NULL'), nullable=True),
116
+ sa.Column('reason', sa.Text(), nullable=True),
117
+ sa.Column('before_metrics', sa.JSON(), nullable=True),
118
+ sa.Column('after_metrics', sa.JSON(), nullable=True),
119
+ sa.Column('created_at', sa.DateTime(), nullable=False),
120
+ )
121
+
122
+ # Create fairness_configs table
123
+ op.create_table(
124
+ 'fairness_configs',
125
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
126
+ sa.Column('is_active', sa.Boolean(), nullable=False, index=True),
127
+ sa.Column('workload_weight_packages', sa.Float(), default=1.0),
128
+ sa.Column('workload_weight_weight_kg', sa.Float(), default=0.5),
129
+ sa.Column('workload_weight_difficulty', sa.Float(), default=10.0),
130
+ sa.Column('workload_weight_time', sa.Float(), default=0.2),
131
+ sa.Column('gini_threshold', sa.Float(), default=0.33),
132
+ sa.Column('stddev_threshold', sa.Float(), default=25.0),
133
+ sa.Column('max_gap_threshold', sa.Float(), default=25.0),
134
+ sa.Column('recovery_mode_enabled', sa.Boolean(), default=False),
135
+ sa.Column('created_at', sa.DateTime(), nullable=False),
136
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
137
+ )
138
+
139
+ # Create decision_logs table
140
+ op.create_table(
141
+ 'decision_logs',
142
+ sa.Column('id', UUID(as_uuid=True), primary_key=True),
143
+ sa.Column('allocation_run_id', UUID(as_uuid=True), sa.ForeignKey('allocation_runs.id', ondelete='CASCADE'), nullable=False, index=True),
144
+ sa.Column('agent_name', sa.String(100), nullable=False, index=True),
145
+ sa.Column('step_type', sa.String(100), nullable=False),
146
+ sa.Column('input_snapshot', sa.JSON(), nullable=True),
147
+ sa.Column('output_snapshot', sa.JSON(), nullable=True),
148
+ sa.Column('created_at', sa.DateTime(), nullable=False),
149
+ )
150
+
151
+ # Add new columns to driver_feedback table for Phase 2 extended feedback
152
+ op.add_column('driver_feedback', sa.Column('route_difficulty_self_report', sa.Integer(), nullable=True))
153
+ op.add_column('driver_feedback', sa.Column('would_take_similar_route_again', sa.Boolean(), nullable=True))
154
+ op.add_column('driver_feedback', sa.Column('most_unfair_aspect', sa.String(100), nullable=True))
155
+
156
+
157
+ def downgrade() -> None:
158
+ # Remove new columns from driver_feedback
159
+ op.drop_column('driver_feedback', 'most_unfair_aspect')
160
+ op.drop_column('driver_feedback', 'would_take_similar_route_again')
161
+ op.drop_column('driver_feedback', 'route_difficulty_self_report')
162
+
163
+ # Drop tables in reverse order
164
+ op.drop_table('decision_logs')
165
+ op.drop_table('fairness_configs')
166
+ op.drop_table('manual_overrides')
167
+ op.drop_table('appeals')
168
+ op.drop_table('stop_issues')
169
+ op.drop_table('route_swap_requests')
170
+ op.drop_table('delivery_logs')
171
+ op.drop_table('allocation_runs')
172
+
173
+ # Drop enums
174
+ op.execute("DROP TYPE IF EXISTS allocationrunstatus")
175
+ op.execute("DROP TYPE IF EXISTS deliverystatus")
176
+ op.execute("DROP TYPE IF EXISTS deliveryissuetype")
177
+ op.execute("DROP TYPE IF EXISTS swaprequeststatus")
178
+ op.execute("DROP TYPE IF EXISTS stopissuetype")
179
+ op.execute("DROP TYPE IF EXISTS appealstatus")
brain/alembic/versions/003_add_pending_status.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add PENDING status to allocation_run_status enum.
2
+
3
+ Revision ID: 003_add_pending_status
4
+ Revises: 002_phase2_phase3_models
5
+ Create Date: 2026-02-04
6
+ """
7
+
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = '003_add_pending_status'
14
+ down_revision = '002_phase2_phase3_models'
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ """Add PENDING to allocationrunstatus enum."""
21
+ # For PostgreSQL, we need to add the new value to the enum type
22
+ op.execute("ALTER TYPE allocationrunstatus ADD VALUE IF NOT EXISTS 'PENDING' BEFORE 'SUCCESS'")
23
+
24
+
25
+ def downgrade() -> None:
26
+ """Note: PostgreSQL does not support removing enum values directly.
27
+
28
+ To fully downgrade, you would need to:
29
+ 1. Create a new enum without PENDING
30
+ 2. Update all PENDING rows to another status
31
+ 3. Alter the column to use the new enum
32
+ 4. Drop the old enum
33
+
34
+ For simplicity, this downgrade is a no-op.
35
+ """
36
+ pass
brain/alembic/versions/004_add_explanation_fields.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add driver_explanation and admin_explanation columns to assignments.
2
+
3
+ Revision ID: 004
4
+ Revises: 003_add_pending_status
5
+ Create Date: 2026-02-04
6
+ """
7
+
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = '004'
14
+ down_revision = '003_add_pending_status'
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade():
20
+ # Add driver_explanation column
21
+ op.add_column(
22
+ 'assignments',
23
+ sa.Column('driver_explanation', sa.Text(), nullable=True)
24
+ )
25
+
26
+ # Add admin_explanation column
27
+ op.add_column(
28
+ 'assignments',
29
+ sa.Column('admin_explanation', sa.Text(), nullable=True)
30
+ )
31
+
32
+ # Backfill: copy existing explanation to driver_explanation
33
+ op.execute("""
34
+ UPDATE assignments
35
+ SET driver_explanation = explanation
36
+ WHERE explanation IS NOT NULL
37
+ """)
38
+
39
+
40
+ def downgrade():
41
+ op.drop_column('assignments', 'admin_explanation')
42
+ op.drop_column('assignments', 'driver_explanation')
brain/alembic/versions/005_phase7_ev_recovery.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 7: EV-aware routing and Recovery Mode
3
+
4
+ Revision ID: 005_phase7_ev_recovery
5
+ Revises: 004_add_explanation_fields
6
+ Create Date: 2026-02-04
7
+ """
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+
12
+
13
+ # revision identifiers
14
+ revision = '005_phase7_ev_recovery'
15
+ down_revision = '004_add_explanation_fields'
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+
20
+ def upgrade():
21
+ # Driver EV fields
22
+ op.add_column('drivers', sa.Column('battery_range_km', sa.Float(), nullable=True))
23
+ op.add_column('drivers', sa.Column('charging_time_minutes', sa.Integer(), nullable=True))
24
+
25
+ # Route distance field
26
+ op.add_column('routes', sa.Column('total_distance_km', sa.Float(), nullable=True))
27
+
28
+ # DriverStatsDaily recovery fields
29
+ op.add_column('driver_stats_daily', sa.Column('is_hard_day', sa.Boolean(), nullable=False, server_default='false'))
30
+ op.add_column('driver_stats_daily', sa.Column('complexity_debt', sa.Float(), nullable=False, server_default='0.0'))
31
+ op.add_column('driver_stats_daily', sa.Column('is_recovery_day', sa.Boolean(), nullable=False, server_default='false'))
32
+
33
+ # FairnessConfig recovery/EV fields
34
+ op.add_column('fairness_configs', sa.Column('complexity_debt_hard_threshold', sa.Float(), nullable=False, server_default='2.0'))
35
+ op.add_column('fairness_configs', sa.Column('recovery_lightening_factor', sa.Float(), nullable=False, server_default='0.7'))
36
+ op.add_column('fairness_configs', sa.Column('recovery_penalty_weight', sa.Float(), nullable=False, server_default='3.0'))
37
+ op.add_column('fairness_configs', sa.Column('ev_charging_penalty_weight', sa.Float(), nullable=False, server_default='0.3'))
38
+ op.add_column('fairness_configs', sa.Column('ev_safety_margin_pct', sa.Float(), nullable=False, server_default='10.0'))
39
+
40
+
41
+ def downgrade():
42
+ # FairnessConfig
43
+ op.drop_column('fairness_configs', 'ev_safety_margin_pct')
44
+ op.drop_column('fairness_configs', 'ev_charging_penalty_weight')
45
+ op.drop_column('fairness_configs', 'recovery_penalty_weight')
46
+ op.drop_column('fairness_configs', 'recovery_lightening_factor')
47
+ op.drop_column('fairness_configs', 'complexity_debt_hard_threshold')
48
+
49
+ # DriverStatsDaily
50
+ op.drop_column('driver_stats_daily', 'is_recovery_day')
51
+ op.drop_column('driver_stats_daily', 'complexity_debt')
52
+ op.drop_column('driver_stats_daily', 'is_hard_day')
53
+
54
+ # Route
55
+ op.drop_column('routes', 'total_distance_km')
56
+
57
+ # Driver
58
+ op.drop_column('drivers', 'charging_time_minutes')
59
+ op.drop_column('drivers', 'battery_range_km')
brain/alembic/versions/006_phase8_learning_agent.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 8: Learning Agent tables and DriverStatsDaily extensions.
2
+
3
+ Revision ID: 006_phase8_learning
4
+ Revises: 005_phase7_ev_recovery
5
+ Create Date: 2026-02-04
6
+
7
+ Creates:
8
+ - learning_episodes table for bandit learning
9
+ - driver_effort_models table for per-driver XGBoost models
10
+ - Extends driver_stats_daily with learning fields
11
+ """
12
+
13
+ from alembic import op
14
+ import sqlalchemy as sa
15
+ from sqlalchemy.dialects import postgresql
16
+
17
+
18
+ # revision identifiers, used by Alembic.
19
+ revision = '006_phase8_learning'
20
+ down_revision = '005_phase7_ev_recovery'
21
+ branch_labels = None
22
+ depends_on = None
23
+
24
+
25
+ def upgrade() -> None:
26
+ # Create learning_episodes table
27
+ op.create_table(
28
+ 'learning_episodes',
29
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
30
+ sa.Column('allocation_run_id', postgresql.UUID(as_uuid=True),
31
+ sa.ForeignKey('allocation_runs.id', ondelete='CASCADE'),
32
+ nullable=False, unique=True),
33
+ sa.Column('config_hash', sa.String(64), nullable=False),
34
+ sa.Column('fairness_config', postgresql.JSONB, nullable=False),
35
+ sa.Column('arm_idx', sa.Integer, nullable=False, default=0),
36
+ sa.Column('num_drivers', sa.Integer, nullable=False, default=0),
37
+ sa.Column('num_routes', sa.Integer, nullable=False, default=0),
38
+ sa.Column('episode_reward', sa.Float, nullable=True),
39
+ sa.Column('reward_computed_at', sa.DateTime, nullable=True),
40
+ sa.Column('alpha_prior', sa.Float, default=1.0),
41
+ sa.Column('beta_prior', sa.Float, default=1.0),
42
+ sa.Column('samples_count', sa.Integer, default=0),
43
+ sa.Column('is_experimental', sa.Boolean, default=False),
44
+ sa.Column('avg_fairness_rating', sa.Float, nullable=True),
45
+ sa.Column('avg_stress_level', sa.Float, nullable=True),
46
+ sa.Column('completion_rate', sa.Float, nullable=True),
47
+ sa.Column('feedback_count', sa.Integer, default=0),
48
+ sa.Column('created_at', sa.DateTime, nullable=False,
49
+ server_default=sa.func.now()),
50
+ )
51
+
52
+ # Create indexes for learning_episodes
53
+ op.create_index('ix_learning_episodes_config_hash', 'learning_episodes', ['config_hash'])
54
+ op.create_index('ix_learning_episodes_created_at', 'learning_episodes', ['created_at'])
55
+ op.create_index('ix_learning_episodes_is_experimental', 'learning_episodes', ['is_experimental'])
56
+ op.create_index('ix_learning_episodes_allocation_run_id', 'learning_episodes', ['allocation_run_id'])
57
+
58
+ # Create driver_effort_models table
59
+ op.create_table(
60
+ 'driver_effort_models',
61
+ sa.Column('driver_id', postgresql.UUID(as_uuid=True),
62
+ sa.ForeignKey('drivers.id', ondelete='CASCADE'),
63
+ primary_key=True),
64
+ sa.Column('model_version', sa.Integer, nullable=False, default=1),
65
+ sa.Column('model_pickle', sa.LargeBinary, nullable=True),
66
+ sa.Column('training_samples', sa.Integer, default=0),
67
+ sa.Column('feature_names', postgresql.JSONB, nullable=True),
68
+ sa.Column('mse_history', postgresql.JSONB, nullable=True),
69
+ sa.Column('current_mse', sa.Float, nullable=True),
70
+ sa.Column('r2_score', sa.Float, nullable=True),
71
+ sa.Column('active', sa.Boolean, default=True),
72
+ sa.Column('last_trained_at', sa.DateTime, nullable=True),
73
+ sa.Column('created_at', sa.DateTime, nullable=False,
74
+ server_default=sa.func.now()),
75
+ sa.Column('updated_at', sa.DateTime, nullable=False,
76
+ server_default=sa.func.now(), onupdate=sa.func.now()),
77
+ )
78
+
79
+ # Create index for driver_effort_models
80
+ op.create_index('ix_driver_effort_models_active', 'driver_effort_models', ['active'])
81
+
82
+ # Extend driver_stats_daily with learning fields
83
+ op.add_column('driver_stats_daily',
84
+ sa.Column('predicted_effort', sa.Float, nullable=True))
85
+ op.add_column('driver_stats_daily',
86
+ sa.Column('actual_effort', sa.Float, nullable=True))
87
+ op.add_column('driver_stats_daily',
88
+ sa.Column('prediction_error', sa.Float, nullable=True))
89
+ op.add_column('driver_stats_daily',
90
+ sa.Column('model_version_used', sa.Integer, nullable=True))
91
+ op.add_column('driver_stats_daily',
92
+ sa.Column('allocation_run_id', postgresql.UUID(as_uuid=True),
93
+ sa.ForeignKey('allocation_runs.id', ondelete='SET NULL'),
94
+ nullable=True))
95
+
96
+ # Create index for allocation_run_id
97
+ op.create_index('ix_driver_stats_daily_allocation_run_id',
98
+ 'driver_stats_daily', ['allocation_run_id'])
99
+
100
+
101
+ def downgrade() -> None:
102
+ # Remove indexes
103
+ op.drop_index('ix_driver_stats_daily_allocation_run_id', 'driver_stats_daily')
104
+ op.drop_index('ix_driver_effort_models_active', 'driver_effort_models')
105
+ op.drop_index('ix_learning_episodes_allocation_run_id', 'learning_episodes')
106
+ op.drop_index('ix_learning_episodes_is_experimental', 'learning_episodes')
107
+ op.drop_index('ix_learning_episodes_created_at', 'learning_episodes')
108
+ op.drop_index('ix_learning_episodes_config_hash', 'learning_episodes')
109
+
110
+ # Remove columns from driver_stats_daily
111
+ op.drop_column('driver_stats_daily', 'allocation_run_id')
112
+ op.drop_column('driver_stats_daily', 'model_version_used')
113
+ op.drop_column('driver_stats_daily', 'prediction_error')
114
+ op.drop_column('driver_stats_daily', 'actual_effort')
115
+ op.drop_column('driver_stats_daily', 'predicted_effort')
116
+
117
+ # Drop tables
118
+ op.drop_table('driver_effort_models')
119
+ op.drop_table('learning_episodes')
brain/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """App package initialization."""
brain/app/api/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API routers package initialization."""
2
+
3
+ from app.api.allocation import router as allocation_router
4
+ from app.api.drivers import router as drivers_router
5
+ from app.api.routes import router as routes_router
6
+ from app.api.feedback import router as feedback_router
7
+ from app.api.driver_api import router as driver_api_router
8
+ from app.api.admin import router as admin_router
9
+ from app.api.admin_learning import router as admin_learning_router
10
+ from app.api.allocation_langgraph import router as allocation_langgraph_router
11
+ from app.api.consolidation import router as consolidation_router
12
+
13
+ __all__ = [
14
+ "allocation_router",
15
+ "drivers_router",
16
+ "routes_router",
17
+ "feedback_router",
18
+ "driver_api_router",
19
+ "admin_router",
20
+ "admin_learning_router",
21
+ "allocation_langgraph_router",
22
+ "consolidation_router",
23
+ ]
24
+
brain/app/api/admin.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 3 Admin-facing API endpoints.
3
+ Handles admin operations: health, allocation runs, assignments, metrics, appeals, overrides, config.
4
+ """
5
+
6
+ from datetime import date
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+ from uuid import UUID
11
+
12
+ from app.database import get_db
13
+ from app.schemas.admin import (
14
+ HealthResponse,
15
+ AllocationRunsListResponse,
16
+ AdminAssignmentsListResponse,
17
+ FairnessMetricsResponse,
18
+ WorkloadHeatmapResponse,
19
+ DriverHistoryResponse,
20
+ AppealsListResponse,
21
+ AppealDecisionRequest,
22
+ AppealDecisionResponse,
23
+ ManualOverrideRequest,
24
+ ManualOverrideResponse,
25
+ FairnessConfigRequest,
26
+ FairnessConfigResponse,
27
+ AgentTimelineResponse,
28
+ DriverAllocationStoryResponse,
29
+ )
30
+ from app.services.admin_service import (
31
+ get_system_health,
32
+ get_allocation_runs,
33
+ get_assignments_paginated,
34
+ get_fairness_metrics_series,
35
+ get_workload_heatmap,
36
+ get_driver_history,
37
+ list_appeals,
38
+ decide_appeal,
39
+ perform_manual_override,
40
+ get_active_fairness_config,
41
+ create_fairness_config,
42
+ get_agent_timeline,
43
+ get_driver_allocation_story,
44
+ )
45
+
46
+ router = APIRouter(prefix="/admin", tags=["Admin"])
47
+
48
+
49
+ @router.get(
50
+ "/health",
51
+ response_model=HealthResponse,
52
+ summary="System health check",
53
+ description="Get system health status including database and latest allocation run.",
54
+ )
55
+ async def health_check(
56
+ db: AsyncSession = Depends(get_db),
57
+ ) -> HealthResponse:
58
+ """Check system health."""
59
+ return await get_system_health(db)
60
+
61
+
62
+ @router.get(
63
+ "/allocation_runs",
64
+ response_model=AllocationRunsListResponse,
65
+ summary="List allocation runs",
66
+ description="Get allocation runs for a specific date.",
67
+ )
68
+ async def get_allocation_runs_endpoint(
69
+ date: date = Query(..., description="Date to query"),
70
+ db: AsyncSession = Depends(get_db),
71
+ ) -> AllocationRunsListResponse:
72
+ """List allocation runs for date."""
73
+ return await get_allocation_runs(db, date)
74
+
75
+
76
+ @router.get(
77
+ "/assignments",
78
+ response_model=AdminAssignmentsListResponse,
79
+ summary="List assignments",
80
+ description="Get paginated list of assignments with filters.",
81
+ )
82
+ async def get_assignments_endpoint(
83
+ date: date = Query(..., description="Date to query"),
84
+ driver_id: UUID = Query(default=None, description="Filter by driver"),
85
+ min_fairness: float = Query(default=None, ge=0, le=1, description="Minimum fairness score"),
86
+ max_fairness: float = Query(default=None, ge=0, le=1, description="Maximum fairness score"),
87
+ page: int = Query(default=1, ge=1, description="Page number"),
88
+ page_size: int = Query(default=50, ge=1, le=100, description="Items per page"),
89
+ db: AsyncSession = Depends(get_db),
90
+ ) -> AdminAssignmentsListResponse:
91
+ """Get paginated assignments."""
92
+ return await get_assignments_paginated(
93
+ db, date, driver_id, min_fairness, max_fairness, page, page_size
94
+ )
95
+
96
+
97
+ @router.get(
98
+ "/metrics/fairness",
99
+ response_model=FairnessMetricsResponse,
100
+ summary="Fairness metrics time series",
101
+ description="Get fairness metrics over a date range.",
102
+ )
103
+ async def get_fairness_metrics_endpoint(
104
+ start_date: date = Query(..., description="Start date"),
105
+ end_date: date = Query(..., description="End date"),
106
+ db: AsyncSession = Depends(get_db),
107
+ ) -> FairnessMetricsResponse:
108
+ """Get fairness metrics time series."""
109
+ return await get_fairness_metrics_series(db, start_date, end_date)
110
+
111
+
112
+ @router.get(
113
+ "/workload_heatmap",
114
+ response_model=WorkloadHeatmapResponse,
115
+ summary="Workload heatmap",
116
+ description="Get workload heatmap data for visualization.",
117
+ )
118
+ async def get_heatmap_endpoint(
119
+ start_date: date = Query(..., description="Start date"),
120
+ end_date: date = Query(..., description="End date"),
121
+ db: AsyncSession = Depends(get_db),
122
+ ) -> WorkloadHeatmapResponse:
123
+ """Get workload heatmap data."""
124
+ return await get_workload_heatmap(db, start_date, end_date)
125
+
126
+
127
+ @router.get(
128
+ "/driver/{driver_id}/history",
129
+ response_model=DriverHistoryResponse,
130
+ summary="Driver history",
131
+ description="Get detailed driver history including appeals and overrides.",
132
+ )
133
+ async def get_driver_history_endpoint(
134
+ driver_id: UUID,
135
+ window_days: int = Query(default=30, ge=1, le=365, description="Days to look back"),
136
+ db: AsyncSession = Depends(get_db),
137
+ ) -> DriverHistoryResponse:
138
+ """Get driver history."""
139
+ result = await get_driver_history(db, driver_id, window_days)
140
+ if not result:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_404_NOT_FOUND,
143
+ detail="Driver not found",
144
+ )
145
+ return result
146
+
147
+
148
+ @router.get(
149
+ "/appeals",
150
+ response_model=AppealsListResponse,
151
+ summary="List appeals",
152
+ description="Get list of appeals with optional status filter.",
153
+ )
154
+ async def get_appeals_endpoint(
155
+ status: str = Query(default=None, description="Filter by status (PENDING, APPROVED, REJECTED, RESOLVED)"),
156
+ db: AsyncSession = Depends(get_db),
157
+ ) -> AppealsListResponse:
158
+ """List appeals."""
159
+ return await list_appeals(db, status)
160
+
161
+
162
+ @router.post(
163
+ "/appeals/{appeal_id}/decision",
164
+ response_model=AppealDecisionResponse,
165
+ summary="Decide appeal",
166
+ description="Update appeal status with admin decision.",
167
+ )
168
+ async def decide_appeal_endpoint(
169
+ appeal_id: UUID,
170
+ request: AppealDecisionRequest,
171
+ db: AsyncSession = Depends(get_db),
172
+ ) -> AppealDecisionResponse:
173
+ """Make decision on an appeal."""
174
+ try:
175
+ result = await decide_appeal(db, appeal_id, request.status, request.admin_note)
176
+ if not result:
177
+ raise HTTPException(
178
+ status_code=status.HTTP_404_NOT_FOUND,
179
+ detail="Appeal not found",
180
+ )
181
+ await db.commit()
182
+ return result
183
+ except ValueError as e:
184
+ raise HTTPException(
185
+ status_code=status.HTTP_400_BAD_REQUEST,
186
+ detail=str(e),
187
+ )
188
+
189
+
190
+ @router.post(
191
+ "/manual_override",
192
+ response_model=ManualOverrideResponse,
193
+ summary="Manual override",
194
+ description="Manually reassign a route from one driver to another.",
195
+ )
196
+ async def manual_override_endpoint(
197
+ request: ManualOverrideRequest,
198
+ db: AsyncSession = Depends(get_db),
199
+ ) -> ManualOverrideResponse:
200
+ """Perform manual route override."""
201
+ try:
202
+ result = await perform_manual_override(
203
+ db=db,
204
+ allocation_run_id=request.allocation_run_id,
205
+ old_driver_id=request.old_driver_id,
206
+ new_driver_id=request.new_driver_id,
207
+ route_id=request.route_id,
208
+ reason=request.reason,
209
+ )
210
+ await db.commit()
211
+ return result
212
+ except ValueError as e:
213
+ raise HTTPException(
214
+ status_code=status.HTTP_400_BAD_REQUEST,
215
+ detail=str(e),
216
+ )
217
+
218
+
219
+ @router.get(
220
+ "/fairness_config",
221
+ response_model=FairnessConfigResponse,
222
+ summary="Get fairness config",
223
+ description="Get the currently active fairness configuration.",
224
+ )
225
+ async def get_fairness_config_endpoint(
226
+ db: AsyncSession = Depends(get_db),
227
+ ) -> FairnessConfigResponse:
228
+ """Get active fairness config."""
229
+ result = await get_active_fairness_config(db)
230
+ if not result:
231
+ raise HTTPException(
232
+ status_code=status.HTTP_404_NOT_FOUND,
233
+ detail="No active fairness config found",
234
+ )
235
+ return result
236
+
237
+
238
+ @router.post(
239
+ "/fairness_config",
240
+ response_model=FairnessConfigResponse,
241
+ status_code=status.HTTP_201_CREATED,
242
+ summary="Create fairness config",
243
+ description="Create new fairness config and deactivate existing ones.",
244
+ )
245
+ async def create_fairness_config_endpoint(
246
+ request: FairnessConfigRequest,
247
+ db: AsyncSession = Depends(get_db),
248
+ ) -> FairnessConfigResponse:
249
+ """Create new fairness config."""
250
+ result = await create_fairness_config(
251
+ db=db,
252
+ workload_weight_packages=request.workload_weight_packages,
253
+ workload_weight_weight_kg=request.workload_weight_weight_kg,
254
+ workload_weight_difficulty=request.workload_weight_difficulty,
255
+ workload_weight_time=request.workload_weight_time,
256
+ gini_threshold=request.gini_threshold,
257
+ stddev_threshold=request.stddev_threshold,
258
+ max_gap_threshold=request.max_gap_threshold,
259
+ recovery_mode_enabled=request.recovery_mode_enabled,
260
+ )
261
+ await db.commit()
262
+ return result
263
+
264
+
265
+ @router.get(
266
+ "/agent_timeline",
267
+ response_model=AgentTimelineResponse,
268
+ summary="Agent timeline",
269
+ description="Get agent decision logs for an allocation run.",
270
+ )
271
+ async def get_agent_timeline_endpoint(
272
+ allocation_run_id: UUID = Query(..., description="Allocation run ID"),
273
+ db: AsyncSession = Depends(get_db),
274
+ ) -> AgentTimelineResponse:
275
+ """Get agent timeline for allocation run."""
276
+ return await get_agent_timeline(db, allocation_run_id)
277
+
278
+
279
+ @router.get(
280
+ "/driver_allocation_story",
281
+ response_model=DriverAllocationStoryResponse,
282
+ summary="Driver allocation story",
283
+ description="Get complete allocation story for a driver on a specific date.",
284
+ )
285
+ async def get_driver_allocation_story_endpoint(
286
+ driver_id: UUID = Query(..., description="Driver ID"),
287
+ date: date = Query(..., description="Date to query (ISO format)"),
288
+ db: AsyncSession = Depends(get_db),
289
+ ) -> DriverAllocationStoryResponse:
290
+ """Get driver allocation story for a specific date."""
291
+ result = await get_driver_allocation_story(db, driver_id, date)
292
+ if not result:
293
+ raise HTTPException(
294
+ status_code=status.HTTP_404_NOT_FOUND,
295
+ detail="No assignment found for driver on given date",
296
+ )
297
+ return result
298
+
brain/app/api/admin_learning.py ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Learning API endpoints for Phase 8.
3
+ Provides monitoring, control, and debugging for the Learning Agent.
4
+ """
5
+
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional
8
+ from uuid import UUID
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
11
+ from sqlalchemy import select, func
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from app.database import get_db
15
+ from app.models import (
16
+ LearningEpisode,
17
+ DriverEffortModel,
18
+ FairnessConfig,
19
+ Driver,
20
+ )
21
+ from app.services.learning_agent import LearningAgent, hash_config
22
+ from app.schemas.learning_schemas import (
23
+ LearningStatusResponse,
24
+ LearningEpisodeResponse,
25
+ LearningEpisodesListResponse,
26
+ DriverModelStatusResponse,
27
+ ForceConfigRequest,
28
+ ForceConfigResponse,
29
+ TriggerLearningRequest,
30
+ TriggerLearningResponse,
31
+ DriverModelUpdateRequest,
32
+ DriverModelUpdateResponse,
33
+ AllDriverModelsListResponse,
34
+ BanditStatistics,
35
+ )
36
+
37
+
38
+ router = APIRouter(prefix="/admin/learning", tags=["Admin - Learning Agent"])
39
+
40
+
41
+ @router.get(
42
+ "/status",
43
+ response_model=LearningStatusResponse,
44
+ summary="Get learning agent status",
45
+ description="Returns overall learning status including bandit statistics, "
46
+ "active driver models, and top performing configurations.",
47
+ )
48
+ async def get_learning_status(
49
+ db: AsyncSession = Depends(get_db),
50
+ ) -> LearningStatusResponse:
51
+ """Get current learning agent status."""
52
+ agent = LearningAgent(db)
53
+ status_dict = await agent.get_learning_status()
54
+
55
+ return LearningStatusResponse(
56
+ current_config=status_dict.get("current_config"),
57
+ top_performing_configs=status_dict.get("top_performing_configs", []),
58
+ driver_models_active=status_dict.get("driver_models_active", 0),
59
+ avg_prediction_mse=status_dict.get("avg_prediction_mse", 0.0),
60
+ recent_episodes_7d=status_dict.get("recent_episodes_7d", 0),
61
+ total_arms=status_dict.get("total_arms", 81),
62
+ bandit_statistics=BanditStatistics(
63
+ total_samples=status_dict.get("bandit_statistics", {}).get("total_samples", 0),
64
+ explored_arms=status_dict.get("bandit_statistics", {}).get("explored_arms", 0),
65
+ total_arms=status_dict.get("total_arms", 81),
66
+ ),
67
+ )
68
+
69
+
70
+ @router.get(
71
+ "/episodes",
72
+ response_model=LearningEpisodesListResponse,
73
+ summary="List learning episodes",
74
+ description="Returns paginated list of learning episodes with rewards.",
75
+ )
76
+ async def list_episodes(
77
+ page: int = Query(1, ge=1),
78
+ page_size: int = Query(20, ge=1, le=100),
79
+ has_reward: Optional[bool] = Query(None),
80
+ is_experimental: Optional[bool] = Query(None),
81
+ db: AsyncSession = Depends(get_db),
82
+ ) -> LearningEpisodesListResponse:
83
+ """List learning episodes with optional filters."""
84
+ # Build query
85
+ query = select(LearningEpisode).order_by(LearningEpisode.created_at.desc())
86
+ count_query = select(func.count(LearningEpisode.id))
87
+
88
+ # Apply filters
89
+ if has_reward is True:
90
+ query = query.where(LearningEpisode.episode_reward.isnot(None))
91
+ count_query = count_query.where(LearningEpisode.episode_reward.isnot(None))
92
+ elif has_reward is False:
93
+ query = query.where(LearningEpisode.episode_reward.is_(None))
94
+ count_query = count_query.where(LearningEpisode.episode_reward.is_(None))
95
+
96
+ if is_experimental is not None:
97
+ query = query.where(LearningEpisode.is_experimental == is_experimental)
98
+ count_query = count_query.where(LearningEpisode.is_experimental == is_experimental)
99
+
100
+ # Get total count
101
+ total_result = await db.execute(count_query)
102
+ total = total_result.scalar() or 0
103
+
104
+ # Apply pagination
105
+ offset = (page - 1) * page_size
106
+ query = query.offset(offset).limit(page_size)
107
+
108
+ result = await db.execute(query)
109
+ episodes = result.scalars().all()
110
+
111
+ return LearningEpisodesListResponse(
112
+ episodes=[LearningEpisodeResponse.model_validate(ep) for ep in episodes],
113
+ total=total,
114
+ page=page,
115
+ page_size=page_size,
116
+ has_next=(offset + len(episodes)) < total,
117
+ )
118
+
119
+
120
+ @router.get(
121
+ "/episodes/{episode_id}",
122
+ response_model=LearningEpisodeResponse,
123
+ summary="Get learning episode details",
124
+ )
125
+ async def get_episode(
126
+ episode_id: UUID,
127
+ db: AsyncSession = Depends(get_db),
128
+ ) -> LearningEpisodeResponse:
129
+ """Get details of a specific learning episode."""
130
+ result = await db.execute(
131
+ select(LearningEpisode).where(LearningEpisode.id == episode_id)
132
+ )
133
+ episode = result.scalar_one_or_none()
134
+
135
+ if not episode:
136
+ raise HTTPException(
137
+ status_code=status.HTTP_404_NOT_FOUND,
138
+ detail=f"Episode {episode_id} not found",
139
+ )
140
+
141
+ return LearningEpisodeResponse.model_validate(episode)
142
+
143
+
144
+ @router.post(
145
+ "/force_config",
146
+ response_model=ForceConfigResponse,
147
+ summary="Force a specific fairness config",
148
+ description="Override bandit selection with a specific config. "
149
+ "Use for emergency rollbacks or testing.",
150
+ )
151
+ async def force_config(
152
+ request: ForceConfigRequest,
153
+ db: AsyncSession = Depends(get_db),
154
+ ) -> ForceConfigResponse:
155
+ """Force a specific fairness configuration."""
156
+ # Get current active config
157
+ result = await db.execute(
158
+ select(FairnessConfig).where(FairnessConfig.is_active == True).limit(1)
159
+ )
160
+ current = result.scalar_one_or_none()
161
+
162
+ previous_config = None
163
+ if current:
164
+ previous_config = {
165
+ "gini_threshold": current.gini_threshold,
166
+ "stddev_threshold": current.stddev_threshold,
167
+ "recovery_lightening_factor": current.recovery_lightening_factor,
168
+ "ev_charging_penalty_weight": current.ev_charging_penalty_weight,
169
+ }
170
+ current.is_active = False
171
+
172
+ # Create new config
173
+ new_config = FairnessConfig(
174
+ is_active=True,
175
+ gini_threshold=request.gini_threshold,
176
+ stddev_threshold=request.stddev_threshold,
177
+ recovery_lightening_factor=request.recovery_lightening_factor,
178
+ ev_charging_penalty_weight=request.ev_charging_penalty_weight,
179
+ max_gap_threshold=request.max_gap_threshold,
180
+ )
181
+ db.add(new_config)
182
+ await db.commit()
183
+
184
+ return ForceConfigResponse(
185
+ status="success",
186
+ message=f"Config forced. Reason: {request.reason}",
187
+ config_applied={
188
+ "gini_threshold": request.gini_threshold,
189
+ "stddev_threshold": request.stddev_threshold,
190
+ "recovery_lightening_factor": request.recovery_lightening_factor,
191
+ "ev_charging_penalty_weight": request.ev_charging_penalty_weight,
192
+ "max_gap_threshold": request.max_gap_threshold,
193
+ },
194
+ previous_config=previous_config,
195
+ )
196
+
197
+
198
+ @router.post(
199
+ "/trigger",
200
+ response_model=TriggerLearningResponse,
201
+ summary="Manually trigger learning pipeline",
202
+ description="Trigger the daily learning pipeline manually. "
203
+ "Useful for testing or forcing immediate updates.",
204
+ )
205
+ async def trigger_learning(
206
+ request: TriggerLearningRequest,
207
+ db: AsyncSession = Depends(get_db),
208
+ ) -> TriggerLearningResponse:
209
+ """Manually trigger learning pipeline."""
210
+ from cron.daily_learning import DailyLearningPipeline
211
+
212
+ start_time = datetime.utcnow()
213
+ metrics = {
214
+ "episodes_processed": 0,
215
+ "rewards_computed": 0,
216
+ "models_updated": 0,
217
+ "config_selection": None,
218
+ "errors": [],
219
+ }
220
+
221
+ try:
222
+ pipeline = DailyLearningPipeline(db)
223
+
224
+ if request.process_episodes:
225
+ await pipeline._process_pending_episodes()
226
+ metrics["episodes_processed"] = pipeline.metrics.get("episodes_processed", 0)
227
+ metrics["rewards_computed"] = pipeline.metrics.get("rewards_computed", 0)
228
+
229
+ if request.select_config:
230
+ await pipeline._select_todays_config()
231
+ metrics["config_selection"] = pipeline.metrics.get("config_selection")
232
+
233
+ if request.update_models:
234
+ await pipeline._update_driver_models()
235
+ metrics["models_updated"] = pipeline.metrics.get("models_updated", 0)
236
+
237
+ await db.commit()
238
+
239
+ return TriggerLearningResponse(
240
+ status="success",
241
+ episodes_processed=metrics["episodes_processed"],
242
+ rewards_computed=metrics["rewards_computed"],
243
+ models_updated=metrics["models_updated"],
244
+ config_selection=metrics["config_selection"],
245
+ duration_seconds=(datetime.utcnow() - start_time).total_seconds(),
246
+ errors=metrics.get("errors", []),
247
+ )
248
+
249
+ except Exception as e:
250
+ return TriggerLearningResponse(
251
+ status="error",
252
+ duration_seconds=(datetime.utcnow() - start_time).total_seconds(),
253
+ errors=[str(e)],
254
+ )
255
+
256
+
257
+ @router.get(
258
+ "/models",
259
+ response_model=AllDriverModelsListResponse,
260
+ summary="List all driver effort models",
261
+ )
262
+ async def list_driver_models(
263
+ active_only: bool = Query(True),
264
+ db: AsyncSession = Depends(get_db),
265
+ ) -> AllDriverModelsListResponse:
266
+ """List all driver effort models."""
267
+ query = select(DriverEffortModel)
268
+ if active_only:
269
+ query = query.where(DriverEffortModel.active == True)
270
+ query = query.order_by(DriverEffortModel.last_trained_at.desc().nullsfirst())
271
+
272
+ result = await db.execute(query)
273
+ models = result.scalars().all()
274
+
275
+ # Calculate stats
276
+ active_count = sum(1 for m in models if m.active)
277
+ mse_values = [m.current_mse for m in models if m.current_mse is not None]
278
+ avg_mse = sum(mse_values) / len(mse_values) if mse_values else None
279
+
280
+ return AllDriverModelsListResponse(
281
+ models=[
282
+ DriverModelStatusResponse(
283
+ driver_id=m.driver_id,
284
+ model_version=m.model_version,
285
+ training_samples=m.training_samples,
286
+ current_mse=m.current_mse,
287
+ r2_score=m.r2_score,
288
+ mse_history=m.mse_history,
289
+ active=m.active,
290
+ last_trained_at=m.last_trained_at,
291
+ )
292
+ for m in models
293
+ ],
294
+ total=len(models),
295
+ active_count=active_count,
296
+ avg_mse=avg_mse,
297
+ )
298
+
299
+
300
+ @router.get(
301
+ "/models/{driver_id}",
302
+ response_model=DriverModelStatusResponse,
303
+ summary="Get driver model status",
304
+ )
305
+ async def get_driver_model(
306
+ driver_id: UUID,
307
+ db: AsyncSession = Depends(get_db),
308
+ ) -> DriverModelStatusResponse:
309
+ """Get status of a specific driver's effort model."""
310
+ result = await db.execute(
311
+ select(DriverEffortModel).where(DriverEffortModel.driver_id == driver_id)
312
+ )
313
+ model = result.scalar_one_or_none()
314
+
315
+ if not model:
316
+ raise HTTPException(
317
+ status_code=status.HTTP_404_NOT_FOUND,
318
+ detail=f"No model found for driver {driver_id}",
319
+ )
320
+
321
+ return DriverModelStatusResponse(
322
+ driver_id=model.driver_id,
323
+ model_version=model.model_version,
324
+ training_samples=model.training_samples,
325
+ current_mse=model.current_mse,
326
+ r2_score=model.r2_score,
327
+ mse_history=model.mse_history,
328
+ active=model.active,
329
+ last_trained_at=model.last_trained_at,
330
+ )
331
+
332
+
333
+ @router.post(
334
+ "/models/{driver_id}/retrain",
335
+ response_model=DriverModelUpdateResponse,
336
+ summary="Retrain a driver's model",
337
+ )
338
+ async def retrain_driver_model(
339
+ driver_id: UUID,
340
+ db: AsyncSession = Depends(get_db),
341
+ ) -> DriverModelUpdateResponse:
342
+ """Manually trigger retraining of a driver's effort model."""
343
+ # Check driver exists
344
+ result = await db.execute(
345
+ select(Driver).where(Driver.id == driver_id)
346
+ )
347
+ driver = result.scalar_one_or_none()
348
+
349
+ if not driver:
350
+ raise HTTPException(
351
+ status_code=status.HTTP_404_NOT_FOUND,
352
+ detail=f"Driver {driver_id} not found",
353
+ )
354
+
355
+ agent = LearningAgent(db)
356
+ result = await agent.effort_learner.update_model(driver_id)
357
+
358
+ await db.commit()
359
+
360
+ return DriverModelUpdateResponse(
361
+ status=result.get("status", "unknown"),
362
+ driver_id=driver_id,
363
+ model_version=result.get("model_version"),
364
+ training_samples=result.get("training_samples"),
365
+ mse=result.get("mse"),
366
+ r2_score=result.get("r2_score"),
367
+ reason=result.get("reason"),
368
+ )
brain/app/api/agent_events.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent Events SSE Endpoint.
3
+
4
+ Provides Server-Sent Events stream for real-time agent status updates
5
+ to visualization frontends.
6
+ """
7
+
8
+ from fastapi import APIRouter, Query
9
+ from fastapi.responses import StreamingResponse
10
+ from typing import Optional
11
+ import json
12
+ import asyncio
13
+
14
+ from app.core.events import agent_event_bus
15
+
16
+
17
+ router = APIRouter(prefix="/api/v1", tags=["agent-events"])
18
+
19
+
20
+ @router.get("/agent-events/stream")
21
+ async def agent_events_stream(
22
+ run_id: Optional[str] = Query(None, description="Filter by allocation run ID")
23
+ ):
24
+ """
25
+ Server-Sent Events endpoint for agent events.
26
+
27
+ Returns a continuous stream of agent events for real-time visualization.
28
+ Optionally filter by allocation_run_id via query parameter.
29
+
30
+ Args:
31
+ run_id: Optional allocation run ID to filter events
32
+
33
+ Returns:
34
+ SSE stream of agent events
35
+ """
36
+
37
+ async def event_generator():
38
+ # Send initial connection event
39
+ init_event = {
40
+ "type": "connected",
41
+ "message": "SSE connection established",
42
+ "filter_run_id": run_id,
43
+ }
44
+ yield f"data: {json.dumps(init_event)}\n\n"
45
+
46
+ # Send any recent events for this run
47
+ if run_id:
48
+ recent = agent_event_bus.get_recent_events(allocation_run_id=run_id)
49
+ for event in recent:
50
+ yield f"data: {json.dumps(event)}\n\n"
51
+
52
+ # Stream live events
53
+ async for event in agent_event_bus.subscribe():
54
+ # Filter by run_id if specified
55
+ if run_id and event.get("allocation_run_id") != run_id:
56
+ continue
57
+
58
+ # SSE format: "data: {...}\n\n"
59
+ yield f"data: {json.dumps(event)}\n\n"
60
+
61
+ return StreamingResponse(
62
+ event_generator(),
63
+ media_type="text/event-stream",
64
+ headers={
65
+ "Cache-Control": "no-cache",
66
+ "Connection": "keep-alive",
67
+ "X-Accel-Buffering": "no", # Disable nginx buffering
68
+ },
69
+ )
70
+
71
+
72
+ @router.get("/agent-events/recent")
73
+ async def get_recent_events(
74
+ run_id: Optional[str] = Query(None, description="Filter by allocation run ID"),
75
+ limit: int = Query(50, ge=1, le=200, description="Maximum events to return"),
76
+ ):
77
+ """
78
+ Get recent agent events (non-streaming).
79
+
80
+ Useful for initial page load or debugging.
81
+
82
+ Args:
83
+ run_id: Optional allocation run ID to filter events
84
+ limit: Maximum number of events to return
85
+
86
+ Returns:
87
+ List of recent agent events
88
+ """
89
+ events = agent_event_bus.get_recent_events(
90
+ allocation_run_id=run_id,
91
+ limit=limit,
92
+ )
93
+ return {"events": events, "count": len(events)}
brain/app/api/allocation.py ADDED
@@ -0,0 +1,808 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Allocation API endpoint.
3
+ Handles POST /api/v1/allocate for fair route allocation using multi-agent pipeline.
4
+
5
+ Phase 4.1: Multi-agent architecture with MLEffortAgent, RoutePlannerAgent, FairnessManagerAgent.
6
+ Phase 4.2: Added Driver Liaison Agents and Final Resolution for negotiation.
7
+ Phase 4.3: Added ExplainabilityAgent v2 for template-based explanations.
8
+ Phase 8: Learning Agent integration for bandit-based config tuning.
9
+ """
10
+
11
+ import statistics
12
+ import uuid
13
+ from datetime import datetime, timedelta
14
+ from typing import Dict, List, Optional
15
+
16
+ from fastapi import APIRouter, Depends, HTTPException, status
17
+ from sqlalchemy import select
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from app.database import get_db
21
+ from app.models import Driver, Package, Route, RoutePackage, Assignment
22
+ from app.models.driver import PreferredLanguage, VehicleType
23
+ from app.models.package import PackagePriority
24
+ from app.models.allocation_run import AllocationRun, AllocationRunStatus
25
+ from app.models.decision_log import DecisionLog
26
+ from app.schemas.allocation import (
27
+ AllocationRequest,
28
+ AllocationResponse,
29
+ AssignmentResponse,
30
+ GlobalFairness,
31
+ RouteSummary,
32
+ )
33
+ from app.schemas.agent_schemas import (
34
+ FairnessThresholds,
35
+ DriverAssignmentProposal,
36
+ DriverContext,
37
+ DriverLiaisonDecision,
38
+ )
39
+ from app.services.clustering import cluster_packages, order_stops_by_nearest_neighbor
40
+ from app.services.workload import calculate_workload, calculate_route_difficulty, estimate_route_time
41
+ from app.services.fairness import calculate_fairness_score
42
+ from app.services.explainability import ExplainabilityAgent, generate_explanation
43
+ from app.services.ml_effort_agent import MLEffortAgent
44
+ from app.services.route_planner_agent import RoutePlannerAgent
45
+ from app.services.fairness_manager_agent import FairnessManagerAgent
46
+ from app.services.driver_liaison_agent import DriverLiaisonAgent
47
+ from app.services.final_resolution import FinalResolutionAgent
48
+ from app.models.driver import DriverStatsDaily, DriverFeedback
49
+ from app.models.manual_override import ManualOverride
50
+ from app.models.fairness_config import FairnessConfig
51
+ from app.schemas.explainability import DriverExplanationInput
52
+ from app.services.learning_agent import LearningAgent, hash_config
53
+
54
+ router = APIRouter(prefix="/allocate", tags=["Allocation"])
55
+
56
+
57
+ @router.post(
58
+ "",
59
+ response_model=AllocationResponse,
60
+ status_code=status.HTTP_200_OK,
61
+ summary="Allocate packages to drivers",
62
+ description="""
63
+ Main allocation endpoint using multi-agent pipeline:
64
+ 1. Phase 0: Cluster packages into routes
65
+ 2. Phase 1: ML Effort Agent builds effort matrix
66
+ 3. Phase 2: Route Planner Agent generates optimal assignment (Proposal 1)
67
+ 4. Phase 3: Fairness Manager evaluates; may request re-optimization (Proposal 2)
68
+ 5. Persist AllocationRun, Assignments, and DecisionLog entries
69
+ """,
70
+ )
71
+ async def allocate(
72
+ request: AllocationRequest,
73
+ db: AsyncSession = Depends(get_db),
74
+ ) -> AllocationResponse:
75
+ """Perform fair route allocation using multi-agent pipeline."""
76
+
77
+ # Validate input
78
+ if not request.packages:
79
+ raise HTTPException(
80
+ status_code=status.HTTP_400_BAD_REQUEST,
81
+ detail="At least 1 package is required",
82
+ )
83
+ if not request.drivers:
84
+ raise HTTPException(
85
+ status_code=status.HTTP_400_BAD_REQUEST,
86
+ detail="At least 1 driver is required",
87
+ )
88
+
89
+ # ========== START ALLOCATION RUN ==========
90
+ allocation_run = AllocationRun(
91
+ date=request.allocation_date,
92
+ num_drivers=len(request.drivers),
93
+ num_packages=len(request.packages),
94
+ num_routes=0, # Updated after clustering
95
+ status=AllocationRunStatus.PENDING,
96
+ started_at=datetime.utcnow(),
97
+ )
98
+ db.add(allocation_run)
99
+ await db.flush() # Get allocation_run.id
100
+
101
+ try:
102
+ # ========== PHASE 0: UPSERT DATA & CLUSTERING ==========
103
+
104
+ # Step 1: Upsert drivers
105
+ driver_map = {} # external_id -> Driver model
106
+ driver_models: List[Driver] = []
107
+
108
+ for driver_input in request.drivers:
109
+ result = await db.execute(
110
+ select(Driver).where(Driver.external_id == driver_input.id)
111
+ )
112
+ driver = result.scalar_one_or_none()
113
+
114
+ if driver:
115
+ driver.name = driver_input.name
116
+ driver.vehicle_capacity_kg = driver_input.vehicle_capacity_kg
117
+ driver.preferred_language = PreferredLanguage(driver_input.preferred_language)
118
+ else:
119
+ driver = Driver(
120
+ external_id=driver_input.id,
121
+ name=driver_input.name,
122
+ vehicle_capacity_kg=driver_input.vehicle_capacity_kg,
123
+ preferred_language=PreferredLanguage(driver_input.preferred_language),
124
+ vehicle_type=VehicleType.ICE,
125
+ )
126
+ db.add(driver)
127
+
128
+ driver_map[driver_input.id] = driver
129
+
130
+ await db.flush()
131
+ driver_models = list(driver_map.values())
132
+
133
+ # Step 2: Upsert packages
134
+ package_map = {}
135
+ package_dicts = []
136
+
137
+ for pkg_input in request.packages:
138
+ result = await db.execute(
139
+ select(Package).where(Package.external_id == pkg_input.id)
140
+ )
141
+ package = result.scalar_one_or_none()
142
+
143
+ if package:
144
+ package.weight_kg = pkg_input.weight_kg
145
+ package.fragility_level = pkg_input.fragility_level
146
+ package.address = pkg_input.address
147
+ package.latitude = pkg_input.latitude
148
+ package.longitude = pkg_input.longitude
149
+ package.priority = PackagePriority(pkg_input.priority)
150
+ else:
151
+ package = Package(
152
+ external_id=pkg_input.id,
153
+ weight_kg=pkg_input.weight_kg,
154
+ fragility_level=pkg_input.fragility_level,
155
+ address=pkg_input.address,
156
+ latitude=pkg_input.latitude,
157
+ longitude=pkg_input.longitude,
158
+ priority=PackagePriority(pkg_input.priority),
159
+ )
160
+ db.add(package)
161
+
162
+ package_map[pkg_input.id] = package
163
+ package_dicts.append({
164
+ "external_id": pkg_input.id,
165
+ "weight_kg": pkg_input.weight_kg,
166
+ "fragility_level": pkg_input.fragility_level,
167
+ "address": pkg_input.address,
168
+ "latitude": pkg_input.latitude,
169
+ "longitude": pkg_input.longitude,
170
+ "priority": pkg_input.priority,
171
+ })
172
+
173
+ await db.flush()
174
+
175
+ # Step 3: Cluster packages into routes
176
+ clusters = cluster_packages(
177
+ packages=package_dicts,
178
+ num_drivers=len(request.drivers),
179
+ )
180
+
181
+ # Step 4: Create routes
182
+ route_models: List[Route] = []
183
+ route_dicts = []
184
+
185
+ for cluster in clusters:
186
+ ordered_packages = order_stops_by_nearest_neighbor(
187
+ cluster.packages,
188
+ request.warehouse.lat,
189
+ request.warehouse.lng,
190
+ )
191
+
192
+ # Calculate total distance (Warehouse -> Stop 1 -> ... -> Stop N -> Warehouse)
193
+ from app.services.clustering import haversine_distance
194
+ total_dist = 0.0
195
+ curr_lat, curr_lng = request.warehouse.lat, request.warehouse.lng
196
+
197
+ for p in ordered_packages:
198
+ dist = haversine_distance(curr_lat, curr_lng, p["latitude"], p["longitude"])
199
+ total_dist += dist
200
+ curr_lat, curr_lng = p["latitude"], p["longitude"]
201
+
202
+ # Add return trip to warehouse
203
+ total_dist += haversine_distance(curr_lat, curr_lng, request.warehouse.lat, request.warehouse.lng)
204
+
205
+ avg_fragility = sum(p["fragility_level"] for p in cluster.packages) / max(len(cluster.packages), 1)
206
+
207
+ difficulty = calculate_route_difficulty(
208
+ total_weight_kg=cluster.total_weight_kg,
209
+ num_stops=cluster.num_stops,
210
+ avg_fragility=avg_fragility,
211
+ )
212
+
213
+ est_time = estimate_route_time(
214
+ num_packages=cluster.num_packages,
215
+ num_stops=cluster.num_stops,
216
+ )
217
+
218
+ route = Route(
219
+ date=request.allocation_date,
220
+ cluster_id=cluster.cluster_id,
221
+ total_weight_kg=cluster.total_weight_kg,
222
+ num_packages=cluster.num_packages,
223
+ num_stops=cluster.num_stops,
224
+ route_difficulty_score=difficulty,
225
+ estimated_time_minutes=est_time,
226
+ total_distance_km=total_dist,
227
+ )
228
+ db.add(route)
229
+ route_models.append(route)
230
+
231
+ workload = calculate_workload({
232
+ "num_packages": cluster.num_packages,
233
+ "total_weight_kg": cluster.total_weight_kg,
234
+ "route_difficulty_score": difficulty,
235
+ "estimated_time_minutes": est_time,
236
+ })
237
+
238
+ route_dicts.append({
239
+ "cluster_id": cluster.cluster_id,
240
+ "num_packages": cluster.num_packages,
241
+ "total_weight_kg": cluster.total_weight_kg,
242
+ "num_stops": cluster.num_stops,
243
+ "route_difficulty_score": difficulty,
244
+ "estimated_time_minutes": est_time,
245
+ "workload_score": workload,
246
+ "packages": ordered_packages,
247
+ })
248
+
249
+ await db.flush()
250
+
251
+ # Update allocation run with route count
252
+ allocation_run.num_routes = len(route_models)
253
+
254
+ # Create RoutePackage associations
255
+ for i, route in enumerate(route_models):
256
+ for stop_order, pkg_data in enumerate(route_dicts[i]["packages"]):
257
+ package = package_map[pkg_data["external_id"]]
258
+ route_package = RoutePackage(
259
+ route_id=route.id,
260
+ package_id=package.id,
261
+ stop_order=stop_order + 1,
262
+ )
263
+ db.add(route_package)
264
+
265
+ # ========== PHASE 1: ML EFFORT AGENT ==========
266
+ ml_agent = MLEffortAgent()
267
+
268
+ # Get active fairness config for EV settings
269
+ config_result = await db.execute(
270
+ select(FairnessConfig).where(FairnessConfig.is_active == True).limit(1)
271
+ )
272
+ active_config = config_result.scalar_one_or_none()
273
+
274
+ ev_config = {
275
+ "safety_margin_pct": active_config.ev_safety_margin_pct if active_config else 10.0,
276
+ "charging_penalty_weight": active_config.ev_charging_penalty_weight if active_config else 0.3,
277
+ }
278
+
279
+ effort_result = ml_agent.compute_effort_matrix(
280
+ drivers=driver_models,
281
+ routes=route_models,
282
+ ev_config=ev_config,
283
+ )
284
+
285
+ # Log decision
286
+ ml_log = DecisionLog(
287
+ allocation_run_id=allocation_run.id,
288
+ agent_name="ML_EFFORT",
289
+ step_type="MATRIX_GENERATION",
290
+ input_snapshot=ml_agent.get_input_snapshot(driver_models, route_models),
291
+ output_snapshot={
292
+ **ml_agent.get_output_snapshot(effort_result),
293
+ "num_infeasible_ev_pairs": len(effort_result.infeasible_pairs),
294
+ },
295
+ )
296
+ db.add(ml_log)
297
+
298
+ # ========== PHASE 1.5: RECOVERY TARGETS (Phase 7) ==========
299
+ from app.services.recovery_service import get_driver_recovery_targets
300
+
301
+ driver_ids = [d.id for d in driver_models]
302
+ recovery_targets = await get_driver_recovery_targets(
303
+ db, driver_ids, request.allocation_date, active_config
304
+ )
305
+ recovery_penalty_weight = active_config.recovery_penalty_weight if active_config else 3.0
306
+
307
+ # ========== PHASE 2: ROUTE PLANNER AGENT - PROPOSAL 1 ==========
308
+ planner_agent = RoutePlannerAgent()
309
+
310
+ proposal1 = planner_agent.plan(
311
+ effort_result=effort_result,
312
+ drivers=driver_models,
313
+ routes=route_models,
314
+ recovery_targets=recovery_targets,
315
+ recovery_penalty_weight=recovery_penalty_weight,
316
+ proposal_number=1,
317
+ )
318
+
319
+ # Log proposal 1
320
+ proposal1_log = DecisionLog(
321
+ allocation_run_id=allocation_run.id,
322
+ agent_name="ROUTE_PLANNER",
323
+ step_type="PROPOSAL_1",
324
+ input_snapshot=planner_agent.get_input_snapshot(effort_result),
325
+ output_snapshot=planner_agent.get_output_snapshot(proposal1),
326
+ )
327
+ db.add(proposal1_log)
328
+
329
+ # ========== PHASE 3: FAIRNESS MANAGER AGENT ==========
330
+ fairness_agent = FairnessManagerAgent(
331
+ thresholds=FairnessThresholds(
332
+ gini_threshold=0.33,
333
+ stddev_threshold=25.0,
334
+ max_gap_threshold=25.0,
335
+ )
336
+ )
337
+
338
+ fairness_check1 = fairness_agent.check(proposal1, proposal_number=1)
339
+
340
+ # Log fairness check 1
341
+ fairness1_log = DecisionLog(
342
+ allocation_run_id=allocation_run.id,
343
+ agent_name="FAIRNESS_MANAGER",
344
+ step_type="FAIRNESS_CHECK_PROPOSAL_1",
345
+ input_snapshot=fairness_agent.get_input_snapshot(proposal1),
346
+ output_snapshot=fairness_agent.get_output_snapshot(fairness_check1),
347
+ )
348
+ db.add(fairness1_log)
349
+
350
+ # Determine final allocation
351
+ final_plan = proposal1
352
+ final_fairness = fairness_check1
353
+
354
+ if fairness_check1.status == "REOPTIMIZE" and fairness_check1.recommendations:
355
+ # Build penalties and run Proposal 2
356
+ penalties = planner_agent.build_penalties_from_recommendations(
357
+ fairness_check1.recommendations,
358
+ proposal1.per_driver_effort,
359
+ )
360
+
361
+ proposal2 = planner_agent.plan(
362
+ effort_result=effort_result,
363
+ drivers=driver_models,
364
+ routes=route_models,
365
+ fairness_penalties=penalties,
366
+ recovery_targets=recovery_targets,
367
+ recovery_penalty_weight=recovery_penalty_weight,
368
+ proposal_number=2,
369
+ )
370
+
371
+ # Log proposal 2
372
+ proposal2_log = DecisionLog(
373
+ allocation_run_id=allocation_run.id,
374
+ agent_name="ROUTE_PLANNER",
375
+ step_type="PROPOSAL_2",
376
+ input_snapshot=planner_agent.get_input_snapshot(effort_result, penalties),
377
+ output_snapshot=planner_agent.get_output_snapshot(proposal2),
378
+ )
379
+ db.add(proposal2_log)
380
+
381
+ # Check fairness of proposal 2
382
+ fairness_check2 = fairness_agent.check(proposal2, proposal_number=2)
383
+
384
+ # Log fairness check 2
385
+ fairness2_log = DecisionLog(
386
+ allocation_run_id=allocation_run.id,
387
+ agent_name="FAIRNESS_MANAGER",
388
+ step_type="FAIRNESS_CHECK_PROPOSAL_2",
389
+ input_snapshot=fairness_agent.get_input_snapshot(proposal2),
390
+ output_snapshot=fairness_agent.get_output_snapshot(fairness_check2),
391
+ )
392
+ db.add(fairness2_log)
393
+
394
+ # Use proposal 2 if it improves fairness
395
+ if (fairness_check2.metrics.gini_index <= fairness_check1.metrics.gini_index or
396
+ fairness_check2.metrics.max_gap < fairness_check1.metrics.max_gap):
397
+ final_plan = proposal2
398
+ final_fairness = fairness_check2
399
+
400
+ # ========== PHASE 4: DRIVER LIAISON AGENTS (Phase 4.2) ==========
401
+
402
+ # Build DriverAssignmentProposals with ranking
403
+ sorted_allocations = sorted(
404
+ final_plan.allocation,
405
+ key=lambda x: x.effort,
406
+ reverse=True # Highest effort = rank 1
407
+ )
408
+ driver_proposals: List[DriverAssignmentProposal] = []
409
+ for rank, alloc_item in enumerate(sorted_allocations, start=1):
410
+ driver_proposals.append(DriverAssignmentProposal(
411
+ driver_id=str(alloc_item.driver_id),
412
+ route_id=str(alloc_item.route_id),
413
+ effort=alloc_item.effort,
414
+ rank_in_team=rank,
415
+ ))
416
+
417
+ # Build DriverContexts from recent stats (last 7 days)
418
+ driver_contexts: Dict[str, DriverContext] = {}
419
+ cutoff_date = request.allocation_date - timedelta(days=7)
420
+
421
+ for driver in driver_models:
422
+ driver_id_str = str(driver.id)
423
+
424
+ # Query recent daily stats
425
+ stats_result = await db.execute(
426
+ select(DriverStatsDaily)
427
+ .where(DriverStatsDaily.driver_id == driver.id)
428
+ .where(DriverStatsDaily.date >= cutoff_date)
429
+ .order_by(DriverStatsDaily.date.desc())
430
+ )
431
+ recent_stats = stats_result.scalars().all()
432
+
433
+ if recent_stats:
434
+ recent_efforts = [s.avg_workload_score for s in recent_stats if s.avg_workload_score]
435
+ if recent_efforts:
436
+ recent_avg = statistics.mean(recent_efforts)
437
+ recent_std = statistics.stdev(recent_efforts) if len(recent_efforts) > 1 else 0.0
438
+ else:
439
+ recent_avg = final_fairness.metrics.avg_effort
440
+ recent_std = final_fairness.metrics.std_dev
441
+
442
+ # Count hard days (above avg + std)
443
+ hard_threshold = recent_avg + recent_std
444
+ hard_days = sum(1 for e in recent_efforts if e > hard_threshold)
445
+ else:
446
+ recent_avg = final_fairness.metrics.avg_effort
447
+ recent_std = final_fairness.metrics.std_dev
448
+ hard_days = 0
449
+
450
+ # Get recent fatigue score from feedback
451
+ feedback_result = await db.execute(
452
+ select(DriverFeedback)
453
+ .where(DriverFeedback.driver_id == driver.id)
454
+ .order_by(DriverFeedback.created_at.desc())
455
+ .limit(1)
456
+ )
457
+ recent_feedback = feedback_result.scalar_one_or_none()
458
+ fatigue_score = float(recent_feedback.tiredness_level) if recent_feedback else 3.0
459
+ fatigue_score = max(1.0, min(5.0, fatigue_score)) # Clamp to 1-5
460
+
461
+ driver_contexts[driver_id_str] = DriverContext(
462
+ driver_id=driver_id_str,
463
+ recent_avg_effort=recent_avg,
464
+ recent_std_effort=recent_std,
465
+ recent_hard_days=hard_days,
466
+ fatigue_score=fatigue_score,
467
+ preferences={}, # TODO: Pull from driver preferences if available
468
+ )
469
+
470
+ # Run Driver Liaison Agent
471
+ liaison_agent = DriverLiaisonAgent()
472
+ negotiation_result = liaison_agent.run_for_all_drivers(
473
+ proposals=driver_proposals,
474
+ driver_contexts=driver_contexts,
475
+ effort_matrix=effort_result.matrix,
476
+ driver_ids=effort_result.driver_ids,
477
+ route_ids=effort_result.route_ids,
478
+ global_avg_effort=final_fairness.metrics.avg_effort,
479
+ global_std_effort=final_fairness.metrics.std_dev,
480
+ )
481
+
482
+ # Log Driver Liaison decisions
483
+ liaison_log = DecisionLog(
484
+ allocation_run_id=allocation_run.id,
485
+ agent_name="DRIVER_LIAISON",
486
+ step_type="NEGOTIATION_DECISIONS",
487
+ input_snapshot=liaison_agent.get_input_snapshot(
488
+ driver_proposals,
489
+ final_fairness.metrics.avg_effort,
490
+ final_fairness.metrics.std_dev,
491
+ ),
492
+ output_snapshot=liaison_agent.get_output_snapshot(negotiation_result),
493
+ )
494
+ db.add(liaison_log)
495
+
496
+ # ========== PHASE 5: FINAL RESOLUTION (Phase 4.2) ==========
497
+
498
+ # Check if any COUNTER decisions need resolution
499
+ counter_decisions = [
500
+ d for d in negotiation_result.decisions if d.decision == "COUNTER"
501
+ ]
502
+
503
+ # Variables for final allocation
504
+ final_allocation = final_plan.allocation
505
+ final_per_driver_effort = final_plan.per_driver_effort
506
+
507
+ if counter_decisions:
508
+ # Run Final Resolution
509
+ resolution_agent = FinalResolutionAgent()
510
+ resolution_result = resolution_agent.resolve_counters(
511
+ approved_proposal=final_plan,
512
+ decisions=negotiation_result.decisions,
513
+ effort_matrix=effort_result.matrix,
514
+ driver_ids=effort_result.driver_ids,
515
+ route_ids=effort_result.route_ids,
516
+ current_metrics=final_fairness.metrics,
517
+ )
518
+
519
+ # Log Final Resolution
520
+ resolution_log = DecisionLog(
521
+ allocation_run_id=allocation_run.id,
522
+ agent_name="ROUTE_PLANNER",
523
+ step_type="FINAL_RESOLUTION",
524
+ input_snapshot=resolution_agent.get_input_snapshot(
525
+ len(counter_decisions),
526
+ final_fairness.metrics,
527
+ final_fairness.metrics.avg_effort,
528
+ ),
529
+ output_snapshot=resolution_agent.get_output_snapshot(resolution_result),
530
+ )
531
+ db.add(resolution_log)
532
+
533
+ # Update final metrics with resolution result
534
+ if resolution_result.swaps_applied:
535
+ # Use resolved allocation
536
+ final_per_driver_effort = resolution_result.per_driver_effort
537
+ # Update allocation_run metrics
538
+ allocation_run.global_gini_index = resolution_result.metrics.get("gini_index", final_fairness.metrics.gini_index)
539
+ allocation_run.global_std_dev = resolution_result.metrics.get("std_dev", final_fairness.metrics.std_dev)
540
+ allocation_run.global_max_gap = resolution_result.metrics.get("max_gap", final_fairness.metrics.max_gap)
541
+
542
+ # ========== PHASE 6: EXPLAINABILITY AGENT (Phase 4.3) ==========
543
+
544
+ # Build lookup for route by ID
545
+ route_by_id = {str(r.id): r for r in route_models}
546
+ route_dict_by_id = {}
547
+ for i, r in enumerate(route_models):
548
+ route_dict_by_id[str(r.id)] = route_dicts[i]
549
+
550
+ driver_by_id = {str(d.id): d for d in driver_models}
551
+
552
+ # Compute per-driver ranks (1=hardest)
553
+ sorted_efforts = sorted(
554
+ final_per_driver_effort.items(),
555
+ key=lambda x: x[1],
556
+ reverse=True
557
+ )
558
+ rank_by_driver = {did: idx + 1 for idx, (did, _) in enumerate(sorted_efforts)}
559
+ num_drivers = len(final_per_driver_effort)
560
+
561
+ # Build liaison decisions lookup
562
+ liaison_by_driver = {}
563
+ if 'negotiation_result' in dir():
564
+ for decision in negotiation_result.decisions:
565
+ liaison_by_driver[decision.driver_id] = decision
566
+
567
+ # Build swaps lookup
568
+ swapped_drivers = set()
569
+ if 'resolution_result' in dir() and resolution_result.swaps_applied:
570
+ for swap in resolution_result.swaps_applied:
571
+ swapped_drivers.add(swap.driver_a)
572
+ swapped_drivers.add(swap.driver_b)
573
+
574
+ # Initialize ExplainabilityAgent
575
+ explain_agent = ExplainabilityAgent()
576
+ category_counts: Dict[str, int] = {}
577
+ avg_effort = final_fairness.metrics.avg_effort
578
+
579
+ assignments_response = []
580
+
581
+ for alloc_item in final_plan.allocation:
582
+ driver_id_str = str(alloc_item.driver_id)
583
+ driver = driver_by_id[driver_id_str]
584
+ route = route_by_id[str(alloc_item.route_id)]
585
+ route_dict = route_dict_by_id[str(alloc_item.route_id)]
586
+
587
+ # Use resolved effort if available (after swaps), else original
588
+ effort = final_per_driver_effort.get(driver_id_str, alloc_item.effort)
589
+ fairness_score = calculate_fairness_score(effort, avg_effort)
590
+
591
+ # Get driver context for explanation
592
+ driver_context = driver_contexts.get(driver_id_str)
593
+ history_efforts = []
594
+ history_hard_days = 0
595
+ if driver_context:
596
+ history_efforts = [driver_context.recent_avg_effort] if driver_context.recent_avg_effort else []
597
+ history_hard_days = driver_context.recent_hard_days
598
+
599
+ # Get effort breakdown from ML agent
600
+ breakdown_key = f"{driver_id_str}:{alloc_item.route_id}"
601
+ effort_breakdown_obj = effort_result.breakdown.get(breakdown_key)
602
+ effort_breakdown = {}
603
+ if effort_breakdown_obj:
604
+ effort_breakdown = {
605
+ "physical_effort": effort_breakdown_obj.physical_effort,
606
+ "route_complexity": effort_breakdown_obj.route_complexity,
607
+ "time_pressure": effort_breakdown_obj.time_pressure,
608
+ }
609
+
610
+ # Get liaison decision
611
+ liaison_decision = liaison_by_driver.get(driver_id_str)
612
+
613
+ # Check for manual override
614
+ had_override = False
615
+ try:
616
+ override_result = await db.execute(
617
+ select(ManualOverride)
618
+ .where(ManualOverride.allocation_run_id == allocation_run.id)
619
+ .where(ManualOverride.new_driver_id == driver.id)
620
+ .limit(1)
621
+ )
622
+ had_override = override_result.scalar_one_or_none() is not None
623
+ except Exception:
624
+ pass # ManualOverride may not exist yet
625
+
626
+ # Determine if recovery day
627
+ is_recovery = (
628
+ history_hard_days >= 3 and
629
+ effort < avg_effort * 0.85
630
+ )
631
+
632
+ # Build explanation input
633
+ explain_input = DriverExplanationInput(
634
+ driver_id=driver_id_str,
635
+ driver_name=driver.name,
636
+ num_drivers=num_drivers,
637
+ today_effort=effort,
638
+ today_rank=rank_by_driver.get(driver_id_str, num_drivers),
639
+ route_id=str(alloc_item.route_id),
640
+ route_summary={
641
+ "num_packages": route.num_packages,
642
+ "total_weight_kg": route.total_weight_kg,
643
+ "num_stops": route.num_stops,
644
+ "difficulty_score": route.route_difficulty_score,
645
+ "estimated_time_minutes": route.estimated_time_minutes,
646
+ },
647
+ effort_breakdown=effort_breakdown,
648
+ global_avg_effort=avg_effort,
649
+ global_std_effort=final_fairness.metrics.std_dev,
650
+ global_gini_index=final_fairness.metrics.gini_index,
651
+ global_max_gap=final_fairness.metrics.max_gap,
652
+ history_efforts_last_7_days=history_efforts,
653
+ history_hard_days_last_7=history_hard_days,
654
+ is_recovery_day=is_recovery,
655
+ had_manual_override=had_override,
656
+ liaison_decision=liaison_decision.decision if liaison_decision else None,
657
+ swap_applied=driver_id_str in swapped_drivers,
658
+ )
659
+
660
+ # Generate explanations
661
+ explain_output = explain_agent.build_explanation_for_driver(explain_input)
662
+
663
+ # Track category counts
664
+ category_counts[explain_output.category] = category_counts.get(explain_output.category, 0) + 1
665
+
666
+ # Create assignment with both explanations
667
+ assignment = Assignment(
668
+ date=request.allocation_date,
669
+ driver_id=driver.id,
670
+ route_id=route.id,
671
+ workload_score=effort,
672
+ fairness_score=fairness_score,
673
+ explanation=explain_output.driver_explanation, # Legacy field
674
+ driver_explanation=explain_output.driver_explanation,
675
+ admin_explanation=explain_output.admin_explanation,
676
+ allocation_run_id=allocation_run.id,
677
+ )
678
+ db.add(assignment)
679
+
680
+ # Build response
681
+ assignments_response.append(AssignmentResponse(
682
+ driver_id=driver.id,
683
+ driver_external_id=driver.external_id,
684
+ driver_name=driver.name,
685
+ route_id=route.id,
686
+ workload_score=effort,
687
+ fairness_score=fairness_score,
688
+ route_summary=RouteSummary(
689
+ num_packages=route.num_packages,
690
+ total_weight_kg=route.total_weight_kg,
691
+ num_stops=route.num_stops,
692
+ route_difficulty_score=route.route_difficulty_score,
693
+ estimated_time_minutes=route.estimated_time_minutes,
694
+ ),
695
+ explanation=explain_output.driver_explanation,
696
+ ))
697
+
698
+ # Log ExplainabilityAgent step
699
+ explain_log = DecisionLog(
700
+ allocation_run_id=allocation_run.id,
701
+ agent_name="EXPLAINABILITY",
702
+ step_type="EXPLANATIONS_GENERATED",
703
+ input_snapshot=explain_agent.get_input_snapshot(
704
+ num_drivers=num_drivers,
705
+ avg_effort=avg_effort,
706
+ std_effort=final_fairness.metrics.std_dev,
707
+ gini_index=final_fairness.metrics.gini_index,
708
+ category_counts=category_counts,
709
+ ),
710
+ output_snapshot=explain_agent.get_output_snapshot(
711
+ total_explanations=len(assignments_response),
712
+ category_counts=category_counts,
713
+ ),
714
+ )
715
+ db.add(explain_log)
716
+
717
+ # ========== PHASE 7: UPDATE DAILY STATS (Phase 7) ==========
718
+ from app.services.recovery_service import update_daily_stats_for_run
719
+
720
+ await update_daily_stats_for_run(
721
+ db=db,
722
+ allocation_run_id=allocation_run.id,
723
+ target_date=request.allocation_date,
724
+ config=active_config,
725
+ )
726
+
727
+ # ========== PHASE 8: CREATE LEARNING EPISODE ==========
728
+ # Create a learning episode for bandit feedback (reward computed later by cron)
729
+ try:
730
+ learning_agent = LearningAgent(db)
731
+
732
+ # Build config snapshot for the episode
733
+ config_snapshot = {}
734
+ if active_config:
735
+ config_snapshot = {
736
+ "gini_threshold": active_config.gini_threshold,
737
+ "stddev_threshold": active_config.stddev_threshold,
738
+ "recovery_lightening_factor": active_config.recovery_lightening_factor,
739
+ "ev_charging_penalty_weight": active_config.ev_charging_penalty_weight,
740
+ "max_gap_threshold": active_config.max_gap_threshold,
741
+ }
742
+
743
+ # Determine if this is experimental (10% of runs)
744
+ import random
745
+ is_experimental = random.random() < 0.10
746
+
747
+ await learning_agent.create_episode(
748
+ allocation_run_id=allocation_run.id,
749
+ fairness_config=config_snapshot,
750
+ num_drivers=len(driver_models),
751
+ num_routes=len(route_models),
752
+ is_experimental=is_experimental,
753
+ )
754
+
755
+ # Log learning episode creation
756
+ learning_log = DecisionLog(
757
+ allocation_run_id=allocation_run.id,
758
+ agent_name="LEARNING",
759
+ step_type="EPISODE_CREATED",
760
+ input_snapshot={
761
+ "config_hash": hash_config(config_snapshot),
762
+ "is_experimental": is_experimental,
763
+ },
764
+ output_snapshot={
765
+ "status": "pending_reward",
766
+ },
767
+ )
768
+ db.add(learning_log)
769
+ except Exception as learning_error:
770
+ # Learning is non-critical, log but don't fail allocation
771
+ import logging
772
+ logging.warning(f"Failed to create learning episode: {learning_error}")
773
+
774
+ # ========== FINALIZE ALLOCATION RUN ==========
775
+ allocation_run.global_gini_index = final_fairness.metrics.gini_index
776
+ allocation_run.global_std_dev = final_fairness.metrics.std_dev
777
+ allocation_run.global_max_gap = final_fairness.metrics.max_gap
778
+ allocation_run.status = AllocationRunStatus.SUCCESS
779
+ allocation_run.finished_at = datetime.utcnow()
780
+
781
+ await db.commit()
782
+
783
+ return AllocationResponse(
784
+ allocation_run_id=allocation_run.id,
785
+ allocation_date=request.allocation_date,
786
+ global_fairness=GlobalFairness(
787
+ avg_workload=final_fairness.metrics.avg_effort,
788
+ std_dev=final_fairness.metrics.std_dev,
789
+ gini_index=final_fairness.metrics.gini_index,
790
+ ),
791
+ assignments=assignments_response,
792
+ )
793
+
794
+ except Exception as e:
795
+ # Mark allocation run as failed
796
+ allocation_run.status = AllocationRunStatus.FAILED
797
+ allocation_run.error_message = str(e)[:500]
798
+ allocation_run.finished_at = datetime.utcnow()
799
+ await db.commit()
800
+
801
+ raise HTTPException(
802
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
803
+ detail={
804
+ "message": "Allocation failed",
805
+ "run_id": str(allocation_run.id),
806
+ "error": str(e)[:200],
807
+ },
808
+ )
brain/app/api/allocation_langgraph.py ADDED
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph-enabled Allocation API endpoint.
3
+ Wraps the existing allocation logic with LangGraph orchestration.
4
+ """
5
+
6
+ import os
7
+ import statistics
8
+ import uuid
9
+ from datetime import datetime, timedelta
10
+ from typing import Dict, List, Optional
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
13
+ from sqlalchemy import select
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from app.database import get_db
17
+ from app.models import Driver, Package, Route, RoutePackage, Assignment
18
+ from app.models.driver import PreferredLanguage, VehicleType
19
+ from app.models.package import PackagePriority
20
+ from app.models.allocation_run import AllocationRun, AllocationRunStatus
21
+ from app.models.decision_log import DecisionLog
22
+ from app.models.driver import DriverStatsDaily, DriverFeedback
23
+ from app.models.fairness_config import FairnessConfig
24
+ from app.schemas.allocation import (
25
+ AllocationRequest,
26
+ AllocationResponse,
27
+ AssignmentResponse,
28
+ GlobalFairness,
29
+ RouteSummary,
30
+ )
31
+ from app.services.clustering import cluster_packages, order_stops_by_nearest_neighbor, haversine_distance
32
+ from app.services.workload import calculate_workload, calculate_route_difficulty, estimate_route_time
33
+ from app.services.fairness import calculate_fairness_score
34
+ from app.services.learning_agent import LearningAgent, hash_config
35
+ from app.schemas.allocation_state import AllocationState
36
+ from app.services.langgraph_workflow import invoke_allocation_workflow
37
+
38
+ router = APIRouter(prefix="/allocate", tags=["Allocation"])
39
+
40
+
41
+ @router.post(
42
+ "/langgraph",
43
+ response_model=AllocationResponse,
44
+ status_code=status.HTTP_200_OK,
45
+ summary="Allocate packages to drivers (LangGraph)",
46
+ description="""
47
+ LangGraph-enabled allocation endpoint using multi-agent workflow:
48
+ 1. ML Effort Agent builds effort matrix
49
+ 2. Route Planner Agent generates optimal assignment
50
+ 3. Fairness Manager evaluates; may trigger re-optimization
51
+ 4. Driver Liaison Agent negotiates per-driver
52
+ 5. Final Resolution resolves counter-proposals
53
+ 6. Explainability Agent generates explanations
54
+ 7. (Optional) Gemini 1.5 Flash for personalized explanations
55
+
56
+ Uses LangGraph StateGraph for orchestration with LangSmith tracing.
57
+ """,
58
+ )
59
+ async def allocate_langgraph(
60
+ request: AllocationRequest,
61
+ db: AsyncSession = Depends(get_db),
62
+ enable_gemini: bool = Query(False, description="Enable Gemini 1.5 Flash explanations"),
63
+ ) -> AllocationResponse:
64
+ """Perform fair route allocation using LangGraph workflow."""
65
+
66
+ # Validate input
67
+ if not request.packages:
68
+ raise HTTPException(
69
+ status_code=status.HTTP_400_BAD_REQUEST,
70
+ detail="At least 1 package is required",
71
+ )
72
+ if not request.drivers:
73
+ raise HTTPException(
74
+ status_code=status.HTTP_400_BAD_REQUEST,
75
+ detail="At least 1 driver is required",
76
+ )
77
+
78
+ allocation_date = request.allocation_date
79
+
80
+ # ========== START ALLOCATION RUN ==========
81
+ allocation_run = AllocationRun(
82
+ date=allocation_date,
83
+ num_drivers=len(request.drivers),
84
+ num_packages=len(request.packages),
85
+ num_routes=0,
86
+ status=AllocationRunStatus.PENDING,
87
+ started_at=datetime.utcnow(),
88
+ )
89
+ db.add(allocation_run)
90
+ await db.flush()
91
+
92
+ try:
93
+ # ========== PHASE 0: UPSERT DATA & CLUSTERING ==========
94
+ # (Same as original - this is DB-dependent and must stay in endpoint)
95
+
96
+ # Step 1: Upsert drivers
97
+ driver_map = {}
98
+ driver_models: List[Driver] = []
99
+
100
+ for driver_input in request.drivers:
101
+ result = await db.execute(
102
+ select(Driver).where(Driver.external_id == driver_input.id)
103
+ )
104
+ driver = result.scalar_one_or_none()
105
+
106
+ if driver:
107
+ driver.name = driver_input.name
108
+ driver.vehicle_capacity_kg = driver_input.vehicle_capacity_kg
109
+ driver.preferred_language = PreferredLanguage(driver_input.preferred_language)
110
+ else:
111
+ driver = Driver(
112
+ external_id=driver_input.id,
113
+ name=driver_input.name,
114
+ vehicle_capacity_kg=driver_input.vehicle_capacity_kg,
115
+ preferred_language=PreferredLanguage(driver_input.preferred_language),
116
+ vehicle_type=VehicleType.ICE,
117
+ )
118
+ db.add(driver)
119
+
120
+ driver_map[driver_input.id] = driver
121
+
122
+ await db.flush()
123
+ driver_models = list(driver_map.values())
124
+
125
+ # Step 2: Upsert packages
126
+ package_map = {}
127
+ package_dicts = []
128
+
129
+ for pkg_input in request.packages:
130
+ result = await db.execute(
131
+ select(Package).where(Package.external_id == pkg_input.id)
132
+ )
133
+ package = result.scalar_one_or_none()
134
+
135
+ if package:
136
+ package.weight_kg = pkg_input.weight_kg
137
+ package.fragility_level = pkg_input.fragility_level
138
+ package.address = pkg_input.address
139
+ package.latitude = pkg_input.latitude
140
+ package.longitude = pkg_input.longitude
141
+ package.priority = PackagePriority(pkg_input.priority)
142
+ else:
143
+ package = Package(
144
+ external_id=pkg_input.id,
145
+ weight_kg=pkg_input.weight_kg,
146
+ fragility_level=pkg_input.fragility_level,
147
+ address=pkg_input.address,
148
+ latitude=pkg_input.latitude,
149
+ longitude=pkg_input.longitude,
150
+ priority=PackagePriority(pkg_input.priority),
151
+ )
152
+ db.add(package)
153
+
154
+ package_map[pkg_input.id] = package
155
+ package_dicts.append({
156
+ "external_id": pkg_input.id,
157
+ "weight_kg": pkg_input.weight_kg,
158
+ "fragility_level": pkg_input.fragility_level,
159
+ "address": pkg_input.address,
160
+ "latitude": pkg_input.latitude,
161
+ "longitude": pkg_input.longitude,
162
+ "priority": pkg_input.priority,
163
+ })
164
+
165
+ await db.flush()
166
+
167
+ # Step 3: Cluster packages into routes
168
+ clusters = cluster_packages(
169
+ packages=package_dicts,
170
+ num_drivers=len(request.drivers),
171
+ )
172
+
173
+ # Step 4: Create routes
174
+ route_models: List[Route] = []
175
+ route_dicts = []
176
+
177
+ for cluster in clusters:
178
+ ordered_packages = order_stops_by_nearest_neighbor(
179
+ cluster.packages,
180
+ request.warehouse.lat,
181
+ request.warehouse.lng,
182
+ )
183
+
184
+ # Calculate total distance
185
+ total_dist = 0.0
186
+ curr_lat, curr_lng = request.warehouse.lat, request.warehouse.lng
187
+
188
+ for p in ordered_packages:
189
+ dist = haversine_distance(curr_lat, curr_lng, p["latitude"], p["longitude"])
190
+ total_dist += dist
191
+ curr_lat, curr_lng = p["latitude"], p["longitude"]
192
+
193
+ total_dist += haversine_distance(curr_lat, curr_lng, request.warehouse.lat, request.warehouse.lng)
194
+
195
+ avg_fragility = sum(p["fragility_level"] for p in cluster.packages) / max(len(cluster.packages), 1)
196
+
197
+ difficulty = calculate_route_difficulty(
198
+ total_weight_kg=cluster.total_weight_kg,
199
+ num_stops=cluster.num_stops,
200
+ avg_fragility=avg_fragility,
201
+ )
202
+
203
+ est_time = estimate_route_time(
204
+ num_packages=cluster.num_packages,
205
+ num_stops=cluster.num_stops,
206
+ )
207
+
208
+ route = Route(
209
+ date=allocation_date,
210
+ cluster_id=cluster.cluster_id,
211
+ total_weight_kg=cluster.total_weight_kg,
212
+ num_packages=cluster.num_packages,
213
+ num_stops=cluster.num_stops,
214
+ route_difficulty_score=difficulty,
215
+ estimated_time_minutes=est_time,
216
+ total_distance_km=total_dist,
217
+ allocation_run_id=allocation_run.id,
218
+ )
219
+ db.add(route)
220
+ route_models.append(route)
221
+
222
+ workload = calculate_workload({
223
+ "num_packages": cluster.num_packages,
224
+ "total_weight_kg": cluster.total_weight_kg,
225
+ "route_difficulty_score": difficulty,
226
+ "estimated_time_minutes": est_time,
227
+ })
228
+
229
+ route_dicts.append({
230
+ "cluster_id": cluster.cluster_id,
231
+ "num_packages": cluster.num_packages,
232
+ "total_weight_kg": cluster.total_weight_kg,
233
+ "num_stops": cluster.num_stops,
234
+ "route_difficulty_score": difficulty,
235
+ "estimated_time_minutes": est_time,
236
+ "workload_score": workload,
237
+ "packages": ordered_packages,
238
+ })
239
+
240
+ await db.flush()
241
+
242
+ allocation_run.num_routes = len(route_models)
243
+
244
+ # Create RoutePackage associations
245
+ for i, route in enumerate(route_models):
246
+ for stop_order, pkg_data in enumerate(route_dicts[i]["packages"]):
247
+ package = package_map[pkg_data["external_id"]]
248
+ route_package = RoutePackage(
249
+ route_id=route.id,
250
+ package_id=package.id,
251
+ stop_order=stop_order + 1,
252
+ )
253
+ db.add(route_package)
254
+
255
+ # ========== GET CONFIG ==========
256
+ config_result = await db.execute(
257
+ select(FairnessConfig).where(FairnessConfig.is_active == True).limit(1)
258
+ )
259
+ active_config = config_result.scalar_one_or_none()
260
+
261
+ config_used = {}
262
+ if active_config:
263
+ config_used = {
264
+ "gini_threshold": active_config.gini_threshold,
265
+ "stddev_threshold": active_config.stddev_threshold,
266
+ "max_gap_threshold": active_config.max_gap_threshold,
267
+ "ev_safety_margin_pct": active_config.ev_safety_margin_pct,
268
+ "ev_charging_penalty_weight": active_config.ev_charging_penalty_weight,
269
+ "recovery_penalty_weight": active_config.recovery_penalty_weight,
270
+ "recovery_lightening_factor": active_config.recovery_lightening_factor,
271
+ }
272
+
273
+ # ========== GET RECOVERY TARGETS ==========
274
+ from app.services.recovery_service import get_driver_recovery_targets
275
+
276
+ driver_ids = [d.id for d in driver_models]
277
+ recovery_targets = await get_driver_recovery_targets(
278
+ db, driver_ids, allocation_date, active_config
279
+ )
280
+ recovery_targets_str = {str(k): v for k, v in recovery_targets.items()}
281
+
282
+ # ========== BUILD DRIVER CONTEXTS ==========
283
+ driver_contexts: Dict[str, dict] = {}
284
+ cutoff_date = allocation_date - timedelta(days=7)
285
+
286
+ for driver in driver_models:
287
+ driver_id_str = str(driver.id)
288
+
289
+ stats_result = await db.execute(
290
+ select(DriverStatsDaily)
291
+ .where(DriverStatsDaily.driver_id == driver.id)
292
+ .where(DriverStatsDaily.date >= cutoff_date)
293
+ .order_by(DriverStatsDaily.date.desc())
294
+ )
295
+ recent_stats = stats_result.scalars().all()
296
+
297
+ if recent_stats:
298
+ recent_efforts = [s.avg_workload_score for s in recent_stats if s.avg_workload_score]
299
+ if recent_efforts:
300
+ recent_avg = statistics.mean(recent_efforts)
301
+ recent_std = statistics.stdev(recent_efforts) if len(recent_efforts) > 1 else 0.0
302
+ else:
303
+ recent_avg = 60.0
304
+ recent_std = 15.0
305
+
306
+ hard_threshold = recent_avg + recent_std
307
+ hard_days = sum(1 for e in recent_efforts if e > hard_threshold)
308
+ else:
309
+ recent_avg = 60.0
310
+ recent_std = 15.0
311
+ hard_days = 0
312
+
313
+ feedback_result = await db.execute(
314
+ select(DriverFeedback)
315
+ .where(DriverFeedback.driver_id == driver.id)
316
+ .order_by(DriverFeedback.created_at.desc())
317
+ .limit(1)
318
+ )
319
+ recent_feedback = feedback_result.scalar_one_or_none()
320
+ fatigue_score = float(recent_feedback.tiredness_level) if recent_feedback else 3.0
321
+ fatigue_score = max(1.0, min(5.0, fatigue_score))
322
+
323
+ driver_contexts[driver_id_str] = {
324
+ "driver_id": driver_id_str,
325
+ "recent_avg_effort": recent_avg,
326
+ "recent_std_effort": recent_std,
327
+ "recent_hard_days": hard_days,
328
+ "fatigue_score": fatigue_score,
329
+ "preferences": {},
330
+ }
331
+
332
+ # ========== SERIALIZE MODELS FOR LANGGRAPH ==========
333
+ driver_model_dicts = []
334
+ for d in driver_models:
335
+ driver_model_dicts.append({
336
+ "id": str(d.id),
337
+ "external_id": d.external_id,
338
+ "name": d.name,
339
+ "vehicle_capacity_kg": d.vehicle_capacity_kg,
340
+ "preferred_language": d.preferred_language.value if hasattr(d.preferred_language, 'value') else d.preferred_language,
341
+ "vehicle_type": d.vehicle_type.value if hasattr(d.vehicle_type, 'value') else str(d.vehicle_type),
342
+ "battery_range_km": getattr(d, 'battery_range_km', None),
343
+ "charging_time_minutes": getattr(d, 'charging_time_minutes', None),
344
+ "is_ev": d.vehicle_type.value == "EV" if hasattr(d.vehicle_type, 'value') else str(d.vehicle_type) == "EV",
345
+ "experience_years": getattr(d, 'experience_years', 2),
346
+ })
347
+
348
+ route_model_dicts = []
349
+ for r in route_models:
350
+ route_model_dicts.append({
351
+ "id": str(r.id),
352
+ "date": str(r.date),
353
+ "cluster_id": r.cluster_id,
354
+ "total_weight_kg": r.total_weight_kg,
355
+ "num_packages": r.num_packages,
356
+ "num_stops": r.num_stops,
357
+ "route_difficulty_score": r.route_difficulty_score,
358
+ "estimated_time_minutes": r.estimated_time_minutes,
359
+ "total_distance_km": r.total_distance_km,
360
+ })
361
+
362
+ # Add route IDs to route_dicts
363
+ for i, rd in enumerate(route_dicts):
364
+ rd["id"] = str(route_models[i].id)
365
+
366
+ # ========== INVOKE LANGGRAPH WORKFLOW ==========
367
+ if enable_gemini:
368
+ os.environ["ENABLE_GEMINI_EXPLAIN"] = "true"
369
+
370
+ workflow_result = await invoke_allocation_workflow(
371
+ request_dict=request.model_dump(mode="json"),
372
+ config_used=config_used,
373
+ driver_models=driver_model_dicts,
374
+ route_models=route_model_dicts,
375
+ route_dicts=route_dicts,
376
+ driver_contexts=driver_contexts,
377
+ recovery_targets=recovery_targets_str,
378
+ allocation_run_id=str(allocation_run.id),
379
+ thread_id=str(allocation_run.id),
380
+ )
381
+
382
+ # ========== PERSIST DECISION LOGS ==========
383
+ for log_entry in workflow_result.decision_logs:
384
+ decision_log = DecisionLog(
385
+ allocation_run_id=allocation_run.id,
386
+ agent_name=log_entry["agent_name"],
387
+ step_type=log_entry["step_type"],
388
+ input_snapshot=log_entry.get("input_snapshot", {}),
389
+ output_snapshot=log_entry.get("output_snapshot", {}),
390
+ )
391
+ db.add(decision_log)
392
+
393
+ # ========== CREATE ASSIGNMENTS ==========
394
+ final_proposal = workflow_result.final_proposal or workflow_result.route_proposal_1
395
+ final_fairness = workflow_result.final_fairness or workflow_result.fairness_check_1
396
+ final_per_driver_effort = workflow_result.final_per_driver_effort or final_proposal["per_driver_effort"]
397
+
398
+ driver_by_id = {str(d.id): d for d in driver_models}
399
+ route_by_id = {str(r.id): r for r in route_models}
400
+
401
+ assignments_response = []
402
+
403
+ for alloc_item in final_proposal["allocation"]:
404
+ driver_id_str = str(alloc_item["driver_id"])
405
+ route_id_str = str(alloc_item["route_id"])
406
+
407
+ driver = driver_by_id.get(driver_id_str)
408
+ route = route_by_id.get(route_id_str)
409
+
410
+ if not driver or not route:
411
+ continue
412
+
413
+ effort = final_per_driver_effort.get(driver_id_str, alloc_item["effort"])
414
+ avg_effort = final_fairness["metrics"]["avg_effort"]
415
+ fairness_score = calculate_fairness_score(effort, avg_effort)
416
+
417
+ explanation_data = workflow_result.explanations.get(driver_id_str, {})
418
+ driver_explanation = explanation_data.get("driver_explanation", "Route assigned.")
419
+ admin_explanation = explanation_data.get("admin_explanation", "")
420
+
421
+ assignment = Assignment(
422
+ date=allocation_date,
423
+ driver_id=driver.id,
424
+ route_id=route.id,
425
+ workload_score=effort,
426
+ fairness_score=fairness_score,
427
+ explanation=driver_explanation,
428
+ driver_explanation=driver_explanation,
429
+ admin_explanation=admin_explanation,
430
+ allocation_run_id=allocation_run.id,
431
+ )
432
+ db.add(assignment)
433
+
434
+ assignments_response.append(AssignmentResponse(
435
+ driver_id=driver.id,
436
+ driver_external_id=driver.external_id,
437
+ driver_name=driver.name,
438
+ route_id=route.id,
439
+ workload_score=effort,
440
+ fairness_score=fairness_score,
441
+ route_summary=RouteSummary(
442
+ num_packages=route.num_packages,
443
+ total_weight_kg=route.total_weight_kg,
444
+ num_stops=route.num_stops,
445
+ route_difficulty_score=route.route_difficulty_score,
446
+ estimated_time_minutes=route.estimated_time_minutes,
447
+ ),
448
+ explanation=driver_explanation,
449
+ ))
450
+
451
+ # ========== UPDATE DAILY STATS ==========
452
+ from app.services.recovery_service import update_daily_stats_for_run
453
+
454
+ await update_daily_stats_for_run(
455
+ db=db,
456
+ allocation_run_id=allocation_run.id,
457
+ target_date=allocation_date,
458
+ config=active_config,
459
+ )
460
+
461
+ # ========== CREATE LEARNING EPISODE ==========
462
+ try:
463
+ learning_agent = LearningAgent(db)
464
+
465
+ import random
466
+ is_experimental = random.random() < 0.10
467
+
468
+ await learning_agent.create_episode(
469
+ allocation_run_id=allocation_run.id,
470
+ fairness_config=config_used,
471
+ num_drivers=len(driver_models),
472
+ num_routes=len(route_models),
473
+ is_experimental=is_experimental,
474
+ )
475
+ except Exception as learning_error:
476
+ import logging
477
+ logging.warning(f"Failed to create learning episode: {learning_error}")
478
+
479
+ # ========== FINALIZE ==========
480
+ metrics = final_fairness["metrics"]
481
+ allocation_run.global_gini_index = metrics["gini_index"]
482
+ allocation_run.global_std_dev = metrics["std_dev"]
483
+ allocation_run.global_max_gap = metrics["max_gap"]
484
+ allocation_run.status = AllocationRunStatus.SUCCESS
485
+ allocation_run.finished_at = datetime.utcnow()
486
+
487
+ await db.commit()
488
+
489
+ return AllocationResponse(
490
+ allocation_run_id=allocation_run.id,
491
+ allocation_date=allocation_date,
492
+ global_fairness=GlobalFairness(
493
+ avg_workload=metrics["avg_effort"],
494
+ std_dev=metrics["std_dev"],
495
+ gini_index=metrics["gini_index"],
496
+ ),
497
+ assignments=assignments_response,
498
+ )
499
+
500
+ except Exception as e:
501
+ allocation_run.status = AllocationRunStatus.FAILED
502
+ allocation_run.error_message = str(e)[:500]
503
+ allocation_run.finished_at = datetime.utcnow()
504
+ await db.commit()
505
+
506
+ raise HTTPException(
507
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
508
+ detail={
509
+ "message": "LangGraph allocation failed",
510
+ "run_id": str(allocation_run.id),
511
+ "error": str(e)[:200],
512
+ },
513
+ )
brain/app/api/consolidation.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API endpoints for AI Load Consolidation.
3
+
4
+ POST /consolidate — Run consolidation (LangGraph multi-agent pipeline)
5
+ POST /consolidate/sync — Run consolidation (synchronous fallback)
6
+ POST /consolidate/simulate — Compare multiple scenarios
7
+ """
8
+
9
+ from typing import List
10
+ from uuid import uuid4
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+
14
+ from app.schemas.consolidation import (
15
+ ConsolidationRequest,
16
+ SimulationRequest,
17
+ ConsolidationResult,
18
+ )
19
+ from app.services.consolidation_engine import run_consolidation_pipeline
20
+
21
+ router = APIRouter(prefix="/consolidate", tags=["Consolidation"])
22
+
23
+
24
+ @router.post("", response_model=ConsolidationResult)
25
+ async def consolidate(req: ConsolidationRequest):
26
+ """
27
+ Run AI Load Consolidation through the 5-agent LangGraph pipeline.
28
+
29
+ Agents executed in order:
30
+ 1. GeoClusteringAgent — geographic proximity clustering
31
+ 2. TimeWindowAgent — time-window compatibility filtering
32
+ 3. CapacityOptimizationAgent — FFD bin-packing
33
+ 4. ScoringConfidenceAgent — AI confidence + optimization score
34
+ 5. ContinuousLearningAgent — actionable insights
35
+ """
36
+ if not req.shipments:
37
+ raise HTTPException(400, "shipments array must not be empty")
38
+ if not req.trucks:
39
+ raise HTTPException(400, "trucks array must not be empty")
40
+
41
+ shipments = [s.model_dump() for s in req.shipments]
42
+ trucks = [t.model_dump() for t in req.trucks]
43
+ options = req.options.model_dump()
44
+
45
+ try:
46
+ from app.services.consolidation_workflow import invoke_consolidation_workflow
47
+ result = await invoke_consolidation_workflow(
48
+ shipments=shipments,
49
+ trucks=trucks,
50
+ options=options,
51
+ thread_id=str(uuid4()),
52
+ )
53
+ except Exception as e:
54
+ # Fallback to synchronous pipeline if LangGraph fails
55
+ print(f"LangGraph consolidation failed ({e}), using sync pipeline")
56
+ result = run_consolidation_pipeline(shipments, trucks, options)
57
+
58
+ return ConsolidationResult(**result)
59
+
60
+
61
+ @router.post("/sync", response_model=ConsolidationResult)
62
+ async def consolidate_sync(req: ConsolidationRequest):
63
+ """
64
+ Run consolidation using the synchronous multi-agent pipeline (no LangGraph).
65
+ Useful for environments without LangGraph installed.
66
+ """
67
+ if not req.shipments:
68
+ raise HTTPException(400, "shipments array must not be empty")
69
+ if not req.trucks:
70
+ raise HTTPException(400, "trucks array must not be empty")
71
+
72
+ shipments = [s.model_dump() for s in req.shipments]
73
+ trucks = [t.model_dump() for t in req.trucks]
74
+ options = req.options.model_dump()
75
+
76
+ result = run_consolidation_pipeline(shipments, trucks, options)
77
+ return ConsolidationResult(**result)
78
+
79
+
80
+ @router.post("/simulate")
81
+ async def simulate_scenarios(req: SimulationRequest):
82
+ """
83
+ Compare multiple consolidation strategies side-by-side.
84
+ Returns results for each scenario and a recommendation.
85
+ """
86
+ if not req.shipments or not req.trucks:
87
+ raise HTTPException(400, "shipments and trucks arrays required")
88
+ if not req.scenarios:
89
+ raise HTTPException(400, "at least one scenario required")
90
+
91
+ shipments = [s.model_dump() for s in req.shipments]
92
+ trucks = [t.model_dump() for t in req.trucks]
93
+
94
+ results = []
95
+ for sc in req.scenarios:
96
+ opts = {
97
+ "maxGroupRadiusKm": sc.maxGroupRadiusKm,
98
+ "timeWindowToleranceMinutes": sc.timeWindowToleranceMinutes,
99
+ "scenarioName": sc.name,
100
+ }
101
+ r = run_consolidation_pipeline(shipments, trucks, opts)
102
+ results.append({"name": sc.name, **r})
103
+
104
+ best = max(results, key=lambda r: r["metrics"]["optimizationScore"])
105
+
106
+ return {
107
+ "scenarios": results,
108
+ "recommendation": best["name"],
109
+ }
brain/app/api/driver_api.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 2 Driver-facing API endpoints.
3
+ Handles driver operations: assignments, stats, deliveries, feedback, swaps, issues.
4
+ """
5
+
6
+ from datetime import date as date_type
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+ from uuid import UUID
11
+
12
+ from app.database import get_db
13
+ from app.schemas.driver_api import (
14
+ TodayAssignmentResponse,
15
+ DriverStatsWindowResponse,
16
+ DeliveryLogRequest,
17
+ DeliveryLogResponse,
18
+ RouteSwapRequestCreate,
19
+ RouteSwapRequestResponse,
20
+ StopIssueRequest,
21
+ StopIssueResponse,
22
+ )
23
+ from app.services.driver_service import (
24
+ get_today_assignment,
25
+ get_driver_stats,
26
+ log_delivery,
27
+ create_route_swap_request,
28
+ create_stop_issue,
29
+ )
30
+
31
+ router = APIRouter(tags=["Driver"])
32
+
33
+
34
+ @router.get(
35
+ "/assignments/today",
36
+ response_model=TodayAssignmentResponse,
37
+ summary="Get today's assignment",
38
+ description="Fetch the driver's assignment for the given date with full stop details.",
39
+ )
40
+ async def get_assignment_today(
41
+ driver_id: UUID = Query(..., description="Driver UUID"),
42
+ target_date: date_type = Query(default=None, description="Date (defaults to today)"),
43
+ db: AsyncSession = Depends(get_db),
44
+ ) -> TodayAssignmentResponse:
45
+ """Get driver's assignment for today or specified date."""
46
+ actual_date = target_date or date_type.today()
47
+
48
+ result = await get_today_assignment(db, driver_id, actual_date)
49
+
50
+ if not result:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_404_NOT_FOUND,
53
+ detail="No assignment found for driver on given date",
54
+ )
55
+
56
+ return result
57
+
58
+
59
+ @router.get(
60
+ "/drivers/{driver_id}/stats",
61
+ response_model=DriverStatsWindowResponse,
62
+ summary="Get driver stats",
63
+ description="Get driver statistics over a time window.",
64
+ )
65
+ async def get_driver_stats_endpoint(
66
+ driver_id: UUID,
67
+ window_days: int = Query(default=7, ge=1, le=90, description="Days to look back"),
68
+ db: AsyncSession = Depends(get_db),
69
+ ) -> DriverStatsWindowResponse:
70
+ """Get driver stats over time window."""
71
+ result = await get_driver_stats(db, driver_id, window_days)
72
+
73
+ if not result:
74
+ raise HTTPException(
75
+ status_code=status.HTTP_404_NOT_FOUND,
76
+ detail="Driver not found",
77
+ )
78
+
79
+ return result
80
+
81
+
82
+ @router.post(
83
+ "/deliveries/log",
84
+ response_model=DeliveryLogResponse,
85
+ status_code=status.HTTP_201_CREATED,
86
+ summary="Log delivery",
87
+ description="Log a delivery attempt at a stop.",
88
+ )
89
+ async def log_delivery_endpoint(
90
+ request: DeliveryLogRequest,
91
+ db: AsyncSession = Depends(get_db),
92
+ ) -> DeliveryLogResponse:
93
+ """Log a delivery at a stop."""
94
+ try:
95
+ result = await log_delivery(
96
+ db=db,
97
+ assignment_id=request.assignment_id,
98
+ route_id=request.route_id,
99
+ driver_id=request.driver_id,
100
+ stop_order=request.stop_order,
101
+ status=request.status,
102
+ issue_type=request.issue_type,
103
+ package_id=request.package_id,
104
+ photo_url=request.photo_url,
105
+ signature_data=request.signature_data,
106
+ notes=request.notes,
107
+ )
108
+ await db.commit()
109
+ return result
110
+ except ValueError as e:
111
+ raise HTTPException(
112
+ status_code=status.HTTP_400_BAD_REQUEST,
113
+ detail=str(e),
114
+ )
115
+
116
+
117
+ @router.post(
118
+ "/route_swap_requests",
119
+ response_model=RouteSwapRequestResponse,
120
+ status_code=status.HTTP_201_CREATED,
121
+ summary="Request route swap",
122
+ description="Submit a route swap request.",
123
+ )
124
+ async def create_route_swap_endpoint(
125
+ request: RouteSwapRequestCreate,
126
+ db: AsyncSession = Depends(get_db),
127
+ ) -> RouteSwapRequestResponse:
128
+ """Create a route swap request."""
129
+ try:
130
+ result = await create_route_swap_request(
131
+ db=db,
132
+ from_driver_id=request.from_driver_id,
133
+ assignment_id=request.assignment_id,
134
+ reason=request.reason,
135
+ to_driver_id=request.to_driver_id,
136
+ preferred_date=request.preferred_date,
137
+ )
138
+ await db.commit()
139
+ return result
140
+ except ValueError as e:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_400_BAD_REQUEST,
143
+ detail=str(e),
144
+ )
145
+
146
+
147
+ @router.post(
148
+ "/stop_issues",
149
+ response_model=StopIssueResponse,
150
+ status_code=status.HTTP_201_CREATED,
151
+ summary="Report stop issue",
152
+ description="Report an issue at a specific stop.",
153
+ )
154
+ async def create_stop_issue_endpoint(
155
+ request: StopIssueRequest,
156
+ db: AsyncSession = Depends(get_db),
157
+ ) -> StopIssueResponse:
158
+ """Create a stop issue report."""
159
+ try:
160
+ result = await create_stop_issue(
161
+ db=db,
162
+ assignment_id=request.assignment_id,
163
+ route_id=request.route_id,
164
+ driver_id=request.driver_id,
165
+ stop_order=request.stop_order,
166
+ issue_type=request.issue_type,
167
+ notes=request.notes,
168
+ )
169
+ await db.commit()
170
+ return result
171
+ except ValueError as e:
172
+ raise HTTPException(
173
+ status_code=status.HTTP_400_BAD_REQUEST,
174
+ detail=str(e),
175
+ )
brain/app/api/drivers.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Drivers API endpoint.
3
+ Handles GET /api/v1/drivers/{id} for driver details.
4
+ """
5
+
6
+ from datetime import date, timedelta
7
+ from uuid import UUID
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, status
10
+ from sqlalchemy import select
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from sqlalchemy.orm import selectinload
13
+
14
+ from app.database import get_db
15
+ from app.models import Driver, DriverStatsDaily
16
+ from app.schemas.driver import DriverResponse, DriverStatsResponse
17
+
18
+ router = APIRouter(prefix="/drivers", tags=["Drivers"])
19
+
20
+
21
+ @router.get(
22
+ "/{driver_id}",
23
+ response_model=DriverResponse,
24
+ summary="Get driver details",
25
+ description="Returns driver details including recent fairness statistics (last 7 days).",
26
+ )
27
+ async def get_driver(
28
+ driver_id: UUID,
29
+ db: AsyncSession = Depends(get_db),
30
+ ) -> DriverResponse:
31
+ """Get driver details by ID."""
32
+
33
+ # Fetch driver with recent stats
34
+ result = await db.execute(
35
+ select(Driver)
36
+ .where(Driver.id == driver_id)
37
+ .options(selectinload(Driver.daily_stats))
38
+ )
39
+ driver = result.scalar_one_or_none()
40
+
41
+ if not driver:
42
+ raise HTTPException(
43
+ status_code=status.HTTP_404_NOT_FOUND,
44
+ detail=f"Driver with ID {driver_id} not found",
45
+ )
46
+
47
+ # Get stats for last 7 days
48
+ seven_days_ago = date.today() - timedelta(days=7)
49
+ recent_stats = [
50
+ DriverStatsResponse(
51
+ date=stat.date,
52
+ avg_workload_score=stat.avg_workload_score,
53
+ total_routes=stat.total_routes,
54
+ gini_contribution=stat.gini_contribution,
55
+ reported_stress_level=stat.reported_stress_level,
56
+ reported_fairness_score=stat.reported_fairness_score,
57
+ )
58
+ for stat in driver.daily_stats
59
+ if stat.date >= seven_days_ago
60
+ ]
61
+
62
+ # Sort by date descending
63
+ recent_stats.sort(key=lambda x: x.date, reverse=True)
64
+
65
+ return DriverResponse(
66
+ id=driver.id,
67
+ external_id=driver.external_id,
68
+ name=driver.name,
69
+ phone=driver.phone,
70
+ whatsapp_number=driver.whatsapp_number,
71
+ preferred_language=driver.preferred_language.value,
72
+ vehicle_type=driver.vehicle_type.value,
73
+ vehicle_capacity_kg=driver.vehicle_capacity_kg,
74
+ license_number=driver.license_number,
75
+ created_at=driver.created_at,
76
+ updated_at=driver.updated_at,
77
+ recent_stats=recent_stats,
78
+ )
79
+
80
+
81
+ @router.get(
82
+ "/external/{external_id}",
83
+ response_model=DriverResponse,
84
+ summary="Get driver by external ID",
85
+ description="Returns driver details by external ID (from integration system).",
86
+ )
87
+ async def get_driver_by_external_id(
88
+ external_id: str,
89
+ db: AsyncSession = Depends(get_db),
90
+ ) -> DriverResponse:
91
+ """Get driver details by external ID."""
92
+
93
+ result = await db.execute(
94
+ select(Driver)
95
+ .where(Driver.external_id == external_id)
96
+ .options(selectinload(Driver.daily_stats))
97
+ )
98
+ driver = result.scalar_one_or_none()
99
+
100
+ if not driver:
101
+ raise HTTPException(
102
+ status_code=status.HTTP_404_NOT_FOUND,
103
+ detail=f"Driver with external ID {external_id} not found",
104
+ )
105
+
106
+ # Get stats for last 7 days
107
+ seven_days_ago = date.today() - timedelta(days=7)
108
+ recent_stats = [
109
+ DriverStatsResponse(
110
+ date=stat.date,
111
+ avg_workload_score=stat.avg_workload_score,
112
+ total_routes=stat.total_routes,
113
+ gini_contribution=stat.gini_contribution,
114
+ reported_stress_level=stat.reported_stress_level,
115
+ reported_fairness_score=stat.reported_fairness_score,
116
+ )
117
+ for stat in driver.daily_stats
118
+ if stat.date >= seven_days_ago
119
+ ]
120
+
121
+ recent_stats.sort(key=lambda x: x.date, reverse=True)
122
+
123
+ return DriverResponse(
124
+ id=driver.id,
125
+ external_id=driver.external_id,
126
+ name=driver.name,
127
+ phone=driver.phone,
128
+ whatsapp_number=driver.whatsapp_number,
129
+ preferred_language=driver.preferred_language.value,
130
+ vehicle_type=driver.vehicle_type.value,
131
+ vehicle_capacity_kg=driver.vehicle_capacity_kg,
132
+ license_number=driver.license_number,
133
+ created_at=driver.created_at,
134
+ updated_at=driver.updated_at,
135
+ recent_stats=recent_stats,
136
+ )
brain/app/api/feedback.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feedback API endpoint.
3
+ Handles POST /api/v1/feedback for driver feedback submission.
4
+ """
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, status
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from app.database import get_db
11
+ from app.models import Driver, Assignment, DriverFeedback
12
+ from app.models.driver import HardestAspect
13
+ from app.schemas.feedback import FeedbackRequest, FeedbackResponse
14
+
15
+ router = APIRouter(prefix="/feedback", tags=["Feedback"])
16
+
17
+
18
+ @router.post(
19
+ "",
20
+ response_model=FeedbackResponse,
21
+ status_code=status.HTTP_201_CREATED,
22
+ summary="Submit driver feedback",
23
+ description="Allows drivers to submit feedback about their assignment.",
24
+ )
25
+ async def submit_feedback(
26
+ request: FeedbackRequest,
27
+ db: AsyncSession = Depends(get_db),
28
+ ) -> FeedbackResponse:
29
+ """Submit driver feedback for an assignment."""
30
+
31
+ # Verify driver exists
32
+ result = await db.execute(
33
+ select(Driver).where(Driver.id == request.driver_id)
34
+ )
35
+ driver = result.scalar_one_or_none()
36
+
37
+ if not driver:
38
+ raise HTTPException(
39
+ status_code=status.HTTP_404_NOT_FOUND,
40
+ detail=f"Driver with ID {request.driver_id} not found",
41
+ )
42
+
43
+ # Verify assignment exists and belongs to driver
44
+ result = await db.execute(
45
+ select(Assignment).where(Assignment.id == request.assignment_id)
46
+ )
47
+ assignment = result.scalar_one_or_none()
48
+
49
+ if not assignment:
50
+ raise HTTPException(
51
+ status_code=status.HTTP_404_NOT_FOUND,
52
+ detail=f"Assignment with ID {request.assignment_id} not found",
53
+ )
54
+
55
+ if assignment.driver_id != request.driver_id:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_400_BAD_REQUEST,
58
+ detail="Assignment does not belong to the specified driver",
59
+ )
60
+
61
+ # Check for duplicate feedback
62
+ result = await db.execute(
63
+ select(DriverFeedback)
64
+ .where(DriverFeedback.driver_id == request.driver_id)
65
+ .where(DriverFeedback.assignment_id == request.assignment_id)
66
+ )
67
+ existing_feedback = result.scalar_one_or_none()
68
+
69
+ if existing_feedback:
70
+ raise HTTPException(
71
+ status_code=status.HTTP_409_CONFLICT,
72
+ detail="Feedback already submitted for this assignment",
73
+ )
74
+
75
+ # Create feedback
76
+ hardest_aspect = None
77
+ if request.hardest_aspect:
78
+ try:
79
+ hardest_aspect = HardestAspect(request.hardest_aspect)
80
+ except ValueError:
81
+ hardest_aspect = HardestAspect.OTHER
82
+
83
+ feedback = DriverFeedback(
84
+ driver_id=request.driver_id,
85
+ assignment_id=request.assignment_id,
86
+ fairness_rating=request.fairness_rating,
87
+ stress_level=request.stress_level,
88
+ tiredness_level=request.tiredness_level,
89
+ hardest_aspect=hardest_aspect,
90
+ comments=request.comments,
91
+ )
92
+
93
+ db.add(feedback)
94
+ await db.commit()
95
+ await db.refresh(feedback)
96
+
97
+ return FeedbackResponse(
98
+ id=feedback.id,
99
+ driver_id=feedback.driver_id,
100
+ assignment_id=feedback.assignment_id,
101
+ fairness_rating=feedback.fairness_rating,
102
+ stress_level=feedback.stress_level,
103
+ tiredness_level=feedback.tiredness_level,
104
+ hardest_aspect=feedback.hardest_aspect.value if feedback.hardest_aspect else None,
105
+ comments=feedback.comments,
106
+ created_at=feedback.created_at,
107
+ message="Feedback submitted successfully",
108
+ )
brain/app/api/routes.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routes API endpoint.
3
+ Handles GET /api/v1/routes/{id} for route details.
4
+ """
5
+
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status
9
+ from sqlalchemy import select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ from sqlalchemy.orm import selectinload
12
+
13
+ from app.database import get_db
14
+ from app.models import Route, Assignment, Driver, RoutePackage
15
+ from app.schemas.route import RouteResponse, RouteAssignmentInfo, RouteStopInfo
16
+
17
+ router = APIRouter(prefix="/routes", tags=["Routes"])
18
+
19
+
20
+ @router.get(
21
+ "/{route_id}",
22
+ response_model=RouteResponse,
23
+ summary="Get route details",
24
+ description="Returns route details including assignment information and stops.",
25
+ )
26
+ async def get_route(
27
+ route_id: UUID,
28
+ db: AsyncSession = Depends(get_db),
29
+ ) -> RouteResponse:
30
+ """Get route details by ID."""
31
+
32
+ # Fetch route with assignments and packages
33
+ result = await db.execute(
34
+ select(Route)
35
+ .where(Route.id == route_id)
36
+ .options(
37
+ selectinload(Route.assignments).selectinload(Assignment.driver),
38
+ selectinload(Route.route_packages).selectinload(RoutePackage.package)
39
+ )
40
+ )
41
+ route = result.scalar_one_or_none()
42
+
43
+ if not route:
44
+ raise HTTPException(
45
+ status_code=status.HTTP_404_NOT_FOUND,
46
+ detail=f"Route with ID {route_id} not found",
47
+ )
48
+
49
+ # Get assignment info if exists
50
+ assignment_info = None
51
+ if route.assignments:
52
+ # Get the most recent assignment
53
+ assignment = max(route.assignments, key=lambda a: a.created_at)
54
+ assignment_info = RouteAssignmentInfo(
55
+ assignment_id=assignment.id,
56
+ driver_id=assignment.driver_id,
57
+ driver_name=assignment.driver.name if assignment.driver else "Unknown",
58
+ workload_score=assignment.workload_score,
59
+ fairness_score=assignment.fairness_score,
60
+ explanation=assignment.explanation,
61
+ )
62
+
63
+ # Get stops info
64
+ stops = []
65
+ if route.route_packages:
66
+ # Sort by stop order
67
+ sorted_packages = sorted(route.route_packages, key=lambda rp: rp.stop_order)
68
+ for rp in sorted_packages:
69
+ if rp.package:
70
+ stops.append(RouteStopInfo(
71
+ package_id=rp.package.id,
72
+ stop_order=rp.stop_order,
73
+ address=rp.package.address,
74
+ latitude=rp.package.latitude,
75
+ longitude=rp.package.longitude,
76
+ weight_kg=rp.package.weight_kg,
77
+ priority=rp.package.priority.value if hasattr(rp.package.priority, 'value') else str(rp.package.priority),
78
+ fragility_level=rp.package.fragility_level
79
+ ))
80
+
81
+ return RouteResponse(
82
+ id=route.id,
83
+ date=route.date,
84
+ cluster_id=route.cluster_id,
85
+ total_weight_kg=route.total_weight_kg,
86
+ num_packages=route.num_packages,
87
+ num_stops=route.num_stops,
88
+ route_difficulty_score=route.route_difficulty_score,
89
+ estimated_time_minutes=route.estimated_time_minutes,
90
+ created_at=route.created_at,
91
+ assignment=assignment_info,
92
+ stops=stops,
93
+ )
brain/app/api/runs.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Run-scoped API endpoints.
3
+ Provides endpoints for fetching data specific to an allocation run.
4
+ """
5
+
6
+ import json
7
+ import uuid
8
+ from datetime import datetime
9
+ from typing import List, Optional
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException, status
12
+ from fastapi.responses import StreamingResponse
13
+ from pydantic import BaseModel
14
+ from sqlalchemy import select
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from app.database import get_db
18
+ from app.models import Route, RoutePackage, Package, Assignment, Driver
19
+ from app.models.allocation_run import AllocationRun, AllocationRunStatus
20
+ from app.core.events import agent_event_bus
21
+
22
+
23
+ router = APIRouter(prefix="/runs", tags=["Runs"])
24
+
25
+
26
+ # =============================================================================
27
+ # Pydantic Schemas
28
+ # =============================================================================
29
+
30
+ class StopInfo(BaseModel):
31
+ """Stop information for map display."""
32
+ lat: float
33
+ lng: float
34
+ package_id: str
35
+ stop_order: int
36
+ address: Optional[str] = None
37
+
38
+
39
+ class RouteOnMap(BaseModel):
40
+ """Route information for map display."""
41
+ route_id: str
42
+ driver_id: Optional[str] = None
43
+ driver_name: Optional[str] = None
44
+ stops: List[StopInfo]
45
+ total_weight_kg: float
46
+ num_packages: int
47
+ estimated_time_minutes: int
48
+
49
+
50
+ class RoutesOnMapResponse(BaseModel):
51
+ """Response for routes-on-map endpoint."""
52
+ allocation_run_id: str
53
+ routes: List[RouteOnMap]
54
+
55
+
56
+ class RunSummaryResponse(BaseModel):
57
+ """Summary information for an allocation run."""
58
+ allocation_run_id: str
59
+ date: str
60
+ status: str
61
+ num_drivers: int
62
+ num_routes: int
63
+ num_packages: int
64
+ global_gini_index: float
65
+ global_std_dev: float
66
+ global_max_gap: float
67
+ started_at: Optional[str] = None
68
+ finished_at: Optional[str] = None
69
+
70
+
71
+ # =============================================================================
72
+ # Endpoints
73
+ # =============================================================================
74
+
75
+ @router.get(
76
+ "/{run_id}/routes-on-map",
77
+ response_model=RoutesOnMapResponse,
78
+ summary="Get routes for map visualization",
79
+ description="""
80
+ Returns all routes and their stop coordinates for a given allocation run.
81
+ Used exclusively by the 8090 visualization map.
82
+ Each route includes driver info and ordered stops with coordinates.
83
+ """,
84
+ )
85
+ async def get_routes_for_map(
86
+ run_id: uuid.UUID,
87
+ db: AsyncSession = Depends(get_db),
88
+ ) -> RoutesOnMapResponse:
89
+ """Return routes and stops for map visualization, scoped to run_id."""
90
+
91
+ # Verify run exists
92
+ run_result = await db.execute(
93
+ select(AllocationRun).where(AllocationRun.id == run_id)
94
+ )
95
+ allocation_run = run_result.scalar_one_or_none()
96
+
97
+ if not allocation_run:
98
+ raise HTTPException(
99
+ status_code=status.HTTP_404_NOT_FOUND,
100
+ detail=f"Allocation run {run_id} not found",
101
+ )
102
+
103
+ # Get all routes for this run
104
+ routes_result = await db.execute(
105
+ select(Route).where(Route.allocation_run_id == run_id)
106
+ )
107
+ routes = routes_result.scalars().all()
108
+
109
+ # Get assignments to map routes to drivers
110
+ assignments_result = await db.execute(
111
+ select(Assignment, Driver)
112
+ .join(Driver, Assignment.driver_id == Driver.id)
113
+ .where(Assignment.allocation_run_id == run_id)
114
+ )
115
+ assignments = assignments_result.all()
116
+
117
+ # Build driver lookup by route_id
118
+ driver_by_route: dict[uuid.UUID, tuple] = {}
119
+ for assignment, driver in assignments:
120
+ driver_by_route[assignment.route_id] = (str(driver.id), driver.name)
121
+
122
+ route_objs: List[RouteOnMap] = []
123
+
124
+ for route in routes:
125
+ # Get stops for this route ordered by stop_order
126
+ stops_result = await db.execute(
127
+ select(RoutePackage, Package)
128
+ .join(Package, RoutePackage.package_id == Package.id)
129
+ .where(RoutePackage.route_id == route.id)
130
+ .order_by(RoutePackage.stop_order)
131
+ )
132
+ stops_data = stops_result.all()
133
+
134
+ stops = [
135
+ StopInfo(
136
+ lat=pkg.latitude,
137
+ lng=pkg.longitude,
138
+ package_id=str(pkg.id),
139
+ stop_order=rp.stop_order,
140
+ address=pkg.address,
141
+ )
142
+ for rp, pkg in stops_data
143
+ ]
144
+
145
+ driver_info = driver_by_route.get(route.id)
146
+
147
+ route_objs.append(RouteOnMap(
148
+ route_id=str(route.id),
149
+ driver_id=driver_info[0] if driver_info else None,
150
+ driver_name=driver_info[1] if driver_info else None,
151
+ stops=stops,
152
+ total_weight_kg=route.total_weight_kg,
153
+ num_packages=route.num_packages,
154
+ estimated_time_minutes=route.estimated_time_minutes,
155
+ ))
156
+
157
+ return RoutesOnMapResponse(
158
+ allocation_run_id=str(run_id),
159
+ routes=route_objs,
160
+ )
161
+
162
+
163
+ @router.get(
164
+ "/{run_id}/summary",
165
+ response_model=RunSummaryResponse,
166
+ summary="Get allocation run summary",
167
+ description="Returns metadata and metrics for a specific allocation run.",
168
+ )
169
+ async def get_run_summary(
170
+ run_id: uuid.UUID,
171
+ db: AsyncSession = Depends(get_db),
172
+ ) -> RunSummaryResponse:
173
+ """Return summary information for an allocation run."""
174
+
175
+ result = await db.execute(
176
+ select(AllocationRun).where(AllocationRun.id == run_id)
177
+ )
178
+ allocation_run = result.scalar_one_or_none()
179
+
180
+ if not allocation_run:
181
+ raise HTTPException(
182
+ status_code=status.HTTP_404_NOT_FOUND,
183
+ detail=f"Allocation run {run_id} not found",
184
+ )
185
+
186
+ return RunSummaryResponse(
187
+ allocation_run_id=str(allocation_run.id),
188
+ date=str(allocation_run.date),
189
+ status=allocation_run.status.value,
190
+ num_drivers=allocation_run.num_drivers,
191
+ num_routes=allocation_run.num_routes,
192
+ num_packages=allocation_run.num_packages,
193
+ global_gini_index=allocation_run.global_gini_index,
194
+ global_std_dev=allocation_run.global_std_dev,
195
+ global_max_gap=allocation_run.global_max_gap,
196
+ started_at=allocation_run.started_at.isoformat() if allocation_run.started_at else None,
197
+ finished_at=allocation_run.finished_at.isoformat() if allocation_run.finished_at else None,
198
+ )
199
+
200
+
201
+ @router.get(
202
+ "/{run_id}/recent-events",
203
+ summary="Get recent agent events for a run",
204
+ description="Returns recent agent events from the in-memory event bus for a specific run.",
205
+ )
206
+ async def get_recent_events_for_run(run_id: uuid.UUID):
207
+ """Return cached recent events for a run (for late joiners)."""
208
+ run_id_str = str(run_id)
209
+ events = agent_event_bus.get_recent_events(allocation_run_id=run_id_str, limit=50)
210
+ return {"allocation_run_id": run_id_str, "events": events}
211
+
212
+
213
+ @router.get(
214
+ "/{run_id}/agent-events",
215
+ summary="SSE stream for agent events (run-scoped)",
216
+ description="""
217
+ Server-Sent Events stream of agent events filtered by allocation_run_id.
218
+ Only events matching the specified run_id will be streamed.
219
+ Connect using EventSource in browser.
220
+ """,
221
+ )
222
+ async def agent_events_for_run(run_id: uuid.UUID):
223
+ """SSE endpoint filtered by allocation_run_id."""
224
+
225
+ run_id_str = str(run_id)
226
+
227
+ async def event_generator():
228
+ # Send initial connection event
229
+ connected_event = {
230
+ "type": "connected",
231
+ "allocation_run_id": run_id_str,
232
+ "message": f"Subscribed to events for run {run_id_str[:8]}...",
233
+ "timestamp": datetime.utcnow().isoformat(),
234
+ }
235
+ yield f"data: {json.dumps(connected_event)}\n\n"
236
+
237
+ # Subscribe to event bus and filter by run_id
238
+ async for event in agent_event_bus.subscribe():
239
+ # Only emit events for this specific run
240
+ if event.get("allocation_run_id") == run_id_str:
241
+ yield f"data: {json.dumps(event)}\n\n"
242
+
243
+ return StreamingResponse(
244
+ event_generator(),
245
+ media_type="text/event-stream",
246
+ headers={
247
+ "Cache-Control": "no-cache",
248
+ "Connection": "keep-alive",
249
+ "X-Accel-Buffering": "no",
250
+ },
251
+ )
brain/app/config.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Application configuration using Pydantic Settings.
3
+ Loads from environment variables and .env file.
4
+ """
5
+
6
+ from functools import lru_cache
7
+ from typing import Optional
8
+
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ """Application settings loaded from environment variables."""
14
+
15
+ model_config = SettingsConfigDict(
16
+ env_file=".env",
17
+ env_file_encoding="utf-8",
18
+ case_sensitive=False,
19
+ )
20
+
21
+ # Database (must be set via DATABASE_URL env var in production)
22
+ database_url: str = ""
23
+
24
+ # Application
25
+ app_env: str = "production"
26
+ debug: bool = False
27
+ app_title: str = "Fair Dispatch System"
28
+ app_version: str = "1.0.0"
29
+ api_prefix: str = "/api/v1"
30
+
31
+ # CORS - comma-separated allowed origins
32
+ cors_origins: str = "https://fairrelay.vercel.app,https://fairrelay-dashboard.vercel.app"
33
+
34
+ # Workload Score Weights
35
+ workload_weight_a: float = 1.0 # num_packages weight
36
+ workload_weight_b: float = 0.5 # total_weight_kg weight
37
+ workload_weight_c: float = 10.0 # route_difficulty_score weight
38
+ workload_weight_d: float = 0.2 # estimated_time_minutes weight
39
+
40
+ # Clustering Settings
41
+ target_packages_per_route: int = 20
42
+
43
+ # Route Difficulty Weights
44
+ difficulty_weight_per_kg: float = 0.01
45
+ difficulty_weight_per_stop: float = 0.1
46
+ difficulty_base: float = 1.0
47
+
48
+ # Time Estimation (minutes)
49
+ time_per_package: float = 5.0
50
+ time_per_stop: float = 3.0
51
+ base_route_time: float = 30.0
52
+
53
+ # LangGraph / LangSmith (optional)
54
+ langchain_tracing_v2: bool = False
55
+ langchain_api_key: Optional[str] = None
56
+ langchain_project: str = "fair-dispatch-dev"
57
+
58
+ # Gemini API (optional)
59
+ google_api_key: Optional[str] = None
60
+ enable_gemini_explain: bool = False
61
+
62
+ @property
63
+ def is_production(self) -> bool:
64
+ return self.app_env == "production"
65
+
66
+ @property
67
+ def cors_origin_list(self) -> list[str]:
68
+ origins = [o.strip() for o in self.cors_origins.split(",") if o.strip()]
69
+ if not self.is_production:
70
+ origins.extend(["http://localhost:5173", "http://localhost:3000", "http://localhost:8000"])
71
+ return origins
72
+
73
+
74
+ @lru_cache()
75
+ def get_settings() -> Settings:
76
+ """Get cached settings instance."""
77
+ return Settings()
brain/app/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Core module for Fair Dispatch System
brain/app/core/events.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent Event Bus for real-time SSE synchronization.
3
+
4
+ Provides pub/sub mechanism for agent events that can be consumed
5
+ by multiple SSE clients (visualization UI, demo page, etc.).
6
+ """
7
+
8
+ from typing import AsyncIterator, Dict, Any, List, Optional
9
+ from uuid import UUID
10
+ import asyncio
11
+ import time
12
+
13
+
14
+ class AgentEventBus:
15
+ """
16
+ Simple in-process pub/sub for agent events.
17
+
18
+ Multiple listeners (SSE connections) can subscribe and receive
19
+ events published by LangGraph nodes during allocation runs.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._subscribers: List[asyncio.Queue] = []
24
+ self._lock = asyncio.Lock()
25
+ self._recent_events: List[Dict[str, Any]] = []
26
+ self._max_recent = 100 # Keep last 100 events for late joiners
27
+
28
+ async def subscribe(self) -> AsyncIterator[Dict[str, Any]]:
29
+ """
30
+ Async generator yielding events. Callers iterate and send as SSE.
31
+
32
+ Yields:
33
+ Agent event dictionaries
34
+ """
35
+ queue: asyncio.Queue = asyncio.Queue()
36
+
37
+ async with self._lock:
38
+ self._subscribers.append(queue)
39
+ # Send recent events to catch up
40
+ for event in self._recent_events[-20:]:
41
+ await queue.put(event)
42
+
43
+ try:
44
+ while True:
45
+ event = await queue.get()
46
+ yield event
47
+ finally:
48
+ async with self._lock:
49
+ if queue in self._subscribers:
50
+ self._subscribers.remove(queue)
51
+
52
+ async def publish(self, event: Dict[str, Any]) -> None:
53
+ """
54
+ Publish an event to all subscribers.
55
+
56
+ Args:
57
+ event: Agent event dictionary
58
+ """
59
+ async with self._lock:
60
+ # Store in recent events
61
+ self._recent_events.append(event)
62
+ if len(self._recent_events) > self._max_recent:
63
+ self._recent_events = self._recent_events[-self._max_recent:]
64
+
65
+ # Broadcast to all subscribers
66
+ for queue in self._subscribers:
67
+ try:
68
+ await queue.put(event)
69
+ except Exception:
70
+ pass # Ignore failed subscribers
71
+
72
+ def get_recent_events(
73
+ self,
74
+ allocation_run_id: Optional[str] = None,
75
+ limit: int = 50
76
+ ) -> List[Dict[str, Any]]:
77
+ """
78
+ Get recent events, optionally filtered by allocation run.
79
+
80
+ Args:
81
+ allocation_run_id: Filter by specific run (optional)
82
+ limit: Maximum events to return
83
+
84
+ Returns:
85
+ List of recent events
86
+ """
87
+ events = self._recent_events
88
+ if allocation_run_id:
89
+ events = [
90
+ e for e in events
91
+ if e.get("allocation_run_id") == allocation_run_id
92
+ ]
93
+ return events[-limit:]
94
+
95
+
96
+ # Global singleton
97
+ agent_event_bus = AgentEventBus()
98
+
99
+
100
+ def make_agent_event(
101
+ allocation_run_id: str,
102
+ agent_name: str,
103
+ step_type: str,
104
+ state: str,
105
+ payload: Optional[Dict[str, Any]] = None,
106
+ ) -> Dict[str, Any]:
107
+ """
108
+ Create a standardized agent event dictionary.
109
+
110
+ Args:
111
+ allocation_run_id: UUID string of the allocation run
112
+ agent_name: Agent identifier (e.g., "ML_EFFORT", "ROUTE_PLANNER")
113
+ step_type: Step identifier (e.g., "MATRIX_GENERATION", "PROPOSAL_1")
114
+ state: Event state - "STARTED", "COMPLETED", or "ERROR"
115
+ payload: Optional additional data for the event
116
+
117
+ Returns:
118
+ Formatted event dictionary
119
+ """
120
+ return {
121
+ "allocation_run_id": str(allocation_run_id),
122
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
123
+ "agent_name": agent_name,
124
+ "step_type": step_type,
125
+ "state": state,
126
+ "payload": payload or {},
127
+ }
128
+
129
+
130
+ async def publish_agent_event(
131
+ allocation_run_id: str,
132
+ agent_name: str,
133
+ step_type: str,
134
+ state: str,
135
+ payload: Optional[Dict[str, Any]] = None,
136
+ ) -> None:
137
+ """
138
+ Convenience function to publish an agent event.
139
+
140
+ Args:
141
+ allocation_run_id: UUID string of the allocation run
142
+ agent_name: Agent identifier
143
+ step_type: Step identifier
144
+ state: Event state
145
+ payload: Optional additional data
146
+ """
147
+ event = make_agent_event(
148
+ allocation_run_id=allocation_run_id,
149
+ agent_name=agent_name,
150
+ step_type=step_type,
151
+ state=state,
152
+ payload=payload,
153
+ )
154
+ await agent_event_bus.publish(event)
brain/app/database.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database connection and session management.
3
+ Uses async SQLAlchemy with asyncpg driver.
4
+ """
5
+
6
+ import uuid
7
+
8
+ from sqlalchemy import CHAR, text
9
+ from sqlalchemy.dialects.postgresql import UUID as PG_UUID
10
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
11
+ from sqlalchemy.orm import DeclarativeBase
12
+ from sqlalchemy.types import TypeDecorator
13
+
14
+ from app.config import get_settings
15
+
16
+
17
+ class GUID(TypeDecorator):
18
+ """
19
+ Platform-independent GUID type.
20
+ Uses PostgreSQL's UUID type when available, otherwise uses CHAR(32) for SQLite.
21
+ Stores as stringified hex values in SQLite.
22
+ """
23
+ impl = CHAR
24
+ cache_ok = True
25
+
26
+ def load_dialect_impl(self, dialect):
27
+ if dialect.name == 'postgresql':
28
+ return dialect.type_descriptor(PG_UUID(as_uuid=True))
29
+ else:
30
+ return dialect.type_descriptor(CHAR(32))
31
+
32
+ def process_bind_param(self, value, dialect):
33
+ if value is None:
34
+ return value
35
+ elif dialect.name == 'postgresql':
36
+ return value
37
+ else:
38
+ if isinstance(value, uuid.UUID):
39
+ return value.hex
40
+ else:
41
+ return uuid.UUID(value).hex
42
+
43
+ def process_result_value(self, value, dialect):
44
+ if value is None:
45
+ return value
46
+ elif dialect.name == 'postgresql':
47
+ return value
48
+ else:
49
+ if isinstance(value, uuid.UUID):
50
+ return value
51
+ return uuid.UUID(value)
52
+
53
+ settings = get_settings()
54
+
55
+ # Create async engine with production-ready pool settings
56
+ engine = create_async_engine(
57
+ settings.database_url,
58
+ echo=settings.debug,
59
+ future=True,
60
+ pool_size=20,
61
+ max_overflow=10,
62
+ pool_recycle=3600,
63
+ pool_pre_ping=True,
64
+ )
65
+
66
+ # Session factory
67
+ async_session_maker = async_sessionmaker(
68
+ engine,
69
+ class_=AsyncSession,
70
+ expire_on_commit=False,
71
+ )
72
+
73
+
74
+ class Base(DeclarativeBase):
75
+ """Base class for all SQLAlchemy models."""
76
+ pass
77
+
78
+
79
+ async def get_db() -> AsyncSession:
80
+ """
81
+ Dependency that provides a database session.
82
+ Yields a session and ensures it's closed after use.
83
+ """
84
+ async with async_session_maker() as session:
85
+ try:
86
+ yield session
87
+ await session.commit()
88
+ except Exception:
89
+ await session.rollback()
90
+ raise
91
+ finally:
92
+ await session.close()
93
+
94
+
95
+ async def check_db_health() -> bool:
96
+ """Check database connectivity by running a simple query."""
97
+ try:
98
+ async with engine.connect() as conn:
99
+ await conn.execute(text("SELECT 1"))
100
+ return True
101
+ except Exception:
102
+ return False
103
+
104
+
105
+ async def init_db() -> None:
106
+ """Initialize database tables (for development only)."""
107
+ async with engine.begin() as conn:
108
+ await conn.run_sync(Base.metadata.create_all)
brain/app/main.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fair Dispatch System - FastAPI Application
3
+ Main entry point for the API server.
4
+ """
5
+
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse, JSONResponse
13
+ from starlette.responses import Response
14
+ from fastapi.staticfiles import StaticFiles
15
+
16
+ from app.config import get_settings
17
+ from app.api import (
18
+ allocation_router,
19
+ drivers_router,
20
+ routes_router,
21
+ feedback_router,
22
+ driver_api_router,
23
+ admin_router,
24
+ admin_learning_router,
25
+ allocation_langgraph_router,
26
+ consolidation_router,
27
+ )
28
+ from app.api.agent_events import router as agent_events_router
29
+ from app.api.runs import router as runs_router
30
+
31
+ # Configure structured logging
32
+ logging.basicConfig(
33
+ level=logging.INFO,
34
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
35
+ )
36
+ logger = logging.getLogger("fairrelay.brain")
37
+
38
+ settings = get_settings()
39
+
40
+ # Path to frontend directory
41
+ FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
42
+
43
+
44
+ @asynccontextmanager
45
+ async def lifespan(app: FastAPI):
46
+ """Application lifespan handler for startup and shutdown events."""
47
+ # Startup
48
+ logger.info(f"Starting {settings.app_title} v{settings.app_version} (env={settings.app_env})")
49
+
50
+ if not settings.is_production:
51
+ try:
52
+ from app.database import init_db
53
+ await init_db()
54
+ logger.info("Database tables initialized (dev mode)")
55
+ except Exception as e:
56
+ logger.warning(f"Database unavailable - running without persistence: {e}")
57
+ else:
58
+ # In production, just verify connectivity
59
+ try:
60
+ from app.database import check_db_health
61
+ healthy = await check_db_health()
62
+ if healthy:
63
+ logger.info("Database connected successfully")
64
+ else:
65
+ logger.warning("Database health check failed - some endpoints may not work")
66
+ except Exception as e:
67
+ logger.warning(f"Database check failed: {e}")
68
+
69
+ yield
70
+ # Shutdown
71
+ logger.info("Shutting down...")
72
+
73
+
74
+ # Create FastAPI application
75
+ app = FastAPI(
76
+ title=settings.app_title,
77
+ version=settings.app_version,
78
+ description="""
79
+ ## Fair Dispatch System API
80
+
81
+ A fairness-focused route allocation system for delivery operations.
82
+
83
+ ### Features
84
+ - **Route Clustering**: Groups packages using K-Means for efficient routes
85
+ - **Workload Scoring**: Calculates balanced workload metrics
86
+ - **Fairness Metrics**: Computes Gini index and fairness scores
87
+ - **Explainability**: Provides human-readable explanations for allocations
88
+ - **LangGraph Workflow**: Multi-agent orchestration with LangSmith tracing
89
+
90
+ ### Main Endpoints
91
+ - `POST /api/v1/allocate` - Allocate packages to drivers (original)
92
+ - `POST /api/v1/allocate/langgraph` - Allocate with LangGraph workflow
93
+ - `POST /api/v1/consolidate` - AI Load Consolidation (5-agent LangGraph pipeline)
94
+ - `POST /api/v1/consolidate/simulate` - Compare consolidation scenarios
95
+ - `GET /api/v1/drivers/{id}` - Get driver details and stats
96
+ - `GET /api/v1/routes/{id}` - Get route details
97
+ - `POST /api/v1/feedback` - Submit driver feedback
98
+ - `GET /api/v1/agent-events/stream` - SSE stream for agent events
99
+ """,
100
+ lifespan=lifespan,
101
+ docs_url="/docs" if not settings.is_production else None,
102
+ redoc_url="/redoc" if not settings.is_production else None,
103
+ )
104
+
105
+
106
+ # Global exception handler - prevent stack trace leaks in production
107
+ @app.exception_handler(Exception)
108
+ async def global_exception_handler(request: Request, exc: Exception):
109
+ logger.error(f"Unhandled error on {request.method} {request.url.path}: {exc}", exc_info=True)
110
+ return JSONResponse(
111
+ status_code=500,
112
+ content={"detail": "Internal server error"},
113
+ )
114
+
115
+
116
+ # Add CORS middleware with configurable origins
117
+ app.add_middleware(
118
+ CORSMiddleware,
119
+ allow_origins=settings.cors_origin_list,
120
+ allow_credentials=True,
121
+ allow_methods=["*"],
122
+ allow_headers=["*"],
123
+ )
124
+
125
+ # Include API routers
126
+ app.include_router(allocation_router, prefix=settings.api_prefix)
127
+ app.include_router(allocation_langgraph_router, prefix=settings.api_prefix)
128
+ app.include_router(drivers_router, prefix=settings.api_prefix)
129
+ app.include_router(routes_router, prefix=settings.api_prefix)
130
+ app.include_router(feedback_router, prefix=settings.api_prefix)
131
+ app.include_router(driver_api_router, prefix=settings.api_prefix)
132
+ app.include_router(admin_router, prefix=settings.api_prefix)
133
+ app.include_router(admin_learning_router, prefix=settings.api_prefix)
134
+ app.include_router(consolidation_router, prefix=settings.api_prefix)
135
+
136
+ # Include SSE agent events router (no prefix - it defines its own)
137
+ app.include_router(agent_events_router)
138
+
139
+ # Include run-scoped endpoints
140
+ app.include_router(runs_router, prefix=settings.api_prefix)
141
+
142
+
143
+ @app.get("/", tags=["Health"])
144
+ async def root():
145
+ """Root endpoint - health check."""
146
+ return {
147
+ "status": "healthy",
148
+ "service": settings.app_title,
149
+ "version": settings.app_version,
150
+ }
151
+
152
+
153
+ @app.get("/health", tags=["Health"])
154
+ async def health_check():
155
+ """Health check endpoint with actual DB verification."""
156
+ from app.database import check_db_health
157
+ db_ok = await check_db_health()
158
+ status = "healthy" if db_ok else "degraded"
159
+ return {
160
+ "status": status,
161
+ "database": "connected" if db_ok else "disconnected",
162
+ }
163
+
164
+
165
+ # Mount static files and demo endpoints
166
+ if FRONTEND_DIR.exists():
167
+ app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
168
+
169
+ NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
170
+
171
+ @app.get("/demo/allocate", tags=["Demo"])
172
+ async def demo_allocate():
173
+ """Serve the API demo page for testing allocation endpoint."""
174
+ demo_path = FRONTEND_DIR / "demo.html"
175
+ return FileResponse(demo_path, media_type="text/html", headers=NO_CACHE)
176
+
177
+ @app.get("/demo/visualization", tags=["Demo"])
178
+ async def demo_visualization():
179
+ """Serve the agent visualization page."""
180
+ viz_path = FRONTEND_DIR / "visualization.html"
181
+ return FileResponse(viz_path, media_type="text/html", headers=NO_CACHE)
182
+
183
+ @app.get("/demo/consolidation", tags=["Demo"])
184
+ async def demo_consolidation():
185
+ """Serve the 5-agent load consolidation pipeline visualization."""
186
+ path = FRONTEND_DIR / "consolidation.html"
187
+ return FileResponse(path, media_type="text/html", headers=NO_CACHE)
brain/app/models/__init__.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Models package initialization - imports all models for easy access."""
2
+
3
+ from app.models.driver import Driver, DriverStatsDaily, DriverFeedback, VehicleType, PreferredLanguage
4
+ from app.models.package import Package
5
+ from app.models.route import Route, RoutePackage
6
+ from app.models.assignment import Assignment
7
+ from app.models.delivery_log import DeliveryLog, DeliveryStatus, DeliveryIssueType
8
+ from app.models.route_swap import RouteSwapRequest, SwapRequestStatus
9
+ from app.models.stop_issue import StopIssue, StopIssueType
10
+ from app.models.appeal import Appeal, AppealStatus
11
+ from app.models.manual_override import ManualOverride
12
+ from app.models.fairness_config import FairnessConfig
13
+ from app.models.allocation_run import AllocationRun, AllocationRunStatus
14
+ from app.models.decision_log import DecisionLog
15
+ from app.models.learning_episode import LearningEpisode
16
+ from app.models.driver_effort_model import DriverEffortModel
17
+
18
+ __all__ = [
19
+ # Phase 1 models
20
+ "Driver",
21
+ "DriverStatsDaily",
22
+ "DriverFeedback",
23
+ "VehicleType",
24
+ "PreferredLanguage",
25
+ "Package",
26
+ "Route",
27
+ "RoutePackage",
28
+ "Assignment",
29
+ # Phase 2 models
30
+ "DeliveryLog",
31
+ "DeliveryStatus",
32
+ "DeliveryIssueType",
33
+ "RouteSwapRequest",
34
+ "SwapRequestStatus",
35
+ "StopIssue",
36
+ "StopIssueType",
37
+ # Phase 3 models
38
+ "Appeal",
39
+ "AppealStatus",
40
+ "ManualOverride",
41
+ "FairnessConfig",
42
+ "AllocationRun",
43
+ "AllocationRunStatus",
44
+ "DecisionLog",
45
+ # Phase 8 models
46
+ "LearningEpisode",
47
+ "DriverEffortModel",
48
+ ]
49
+
brain/app/models/allocation_run.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AllocationRun database model.
3
+ High-level metadata per /allocate execution.
4
+ """
5
+
6
+ import enum
7
+ import uuid
8
+ from datetime import datetime, date
9
+ from typing import Optional
10
+
11
+ from sqlalchemy import Integer, Float, Text, Date, DateTime, Enum
12
+ from sqlalchemy.orm import Mapped, mapped_column
13
+
14
+ from app.database import Base, GUID
15
+
16
+
17
+ class AllocationRunStatus(str, enum.Enum):
18
+ """Status of an allocation run."""
19
+ PENDING = "PENDING"
20
+ SUCCESS = "SUCCESS"
21
+ FAILED = "FAILED"
22
+
23
+
24
+ class AllocationRun(Base):
25
+ """
26
+ AllocationRun model for high-level allocation metadata.
27
+ Tracks each allocation execution with global metrics.
28
+ """
29
+ __tablename__ = "allocation_runs"
30
+
31
+ id: Mapped[uuid.UUID] = mapped_column(
32
+ GUID(),
33
+ primary_key=True,
34
+ default=uuid.uuid4,
35
+ )
36
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
37
+ num_drivers: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
38
+ num_routes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
39
+ num_packages: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
40
+
41
+ # Global fairness metrics
42
+ global_gini_index: Mapped[float] = mapped_column(Float, default=0.0)
43
+ global_std_dev: Mapped[float] = mapped_column(Float, default=0.0)
44
+ global_max_gap: Mapped[float] = mapped_column(Float, default=0.0)
45
+
46
+ status: Mapped[AllocationRunStatus] = mapped_column(
47
+ Enum(AllocationRunStatus),
48
+ nullable=False,
49
+ default=AllocationRunStatus.SUCCESS,
50
+ )
51
+ error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
52
+
53
+ started_at: Mapped[datetime] = mapped_column(
54
+ DateTime,
55
+ default=datetime.utcnow,
56
+ nullable=False,
57
+ )
58
+ finished_at: Mapped[Optional[datetime]] = mapped_column(
59
+ DateTime,
60
+ nullable=True,
61
+ )
62
+
63
+ def __repr__(self) -> str:
64
+ return f"<AllocationRun(id={self.id}, date={self.date}, status={self.status})>"
brain/app/models/appeal.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Appeal database model.
3
+ Represents a driver raising an appeal about a route's fairness.
4
+ """
5
+
6
+ import enum
7
+ import uuid
8
+ from datetime import datetime
9
+ from typing import Optional, TYPE_CHECKING
10
+
11
+ from sqlalchemy import Text, DateTime, ForeignKey, Enum
12
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
13
+
14
+ from app.database import Base, GUID
15
+
16
+ if TYPE_CHECKING:
17
+ from app.models.driver import Driver
18
+ from app.models.assignment import Assignment
19
+
20
+
21
+ class AppealStatus(str, enum.Enum):
22
+ """Status of an appeal."""
23
+ PENDING = "PENDING"
24
+ APPROVED = "APPROVED"
25
+ REJECTED = "REJECTED"
26
+ RESOLVED = "RESOLVED"
27
+
28
+
29
+ class Appeal(Base):
30
+ """
31
+ Appeal model for driver fairness appeals.
32
+ Allows drivers to contest route assignments they feel are unfair.
33
+ """
34
+ __tablename__ = "appeals"
35
+
36
+ id: Mapped[uuid.UUID] = mapped_column(
37
+ GUID(),
38
+ primary_key=True,
39
+ default=uuid.uuid4,
40
+ )
41
+ driver_id: Mapped[uuid.UUID] = mapped_column(
42
+ GUID(),
43
+ ForeignKey("drivers.id", ondelete="CASCADE"),
44
+ nullable=False,
45
+ index=True,
46
+ )
47
+ assignment_id: Mapped[uuid.UUID] = mapped_column(
48
+ GUID(),
49
+ ForeignKey("assignments.id", ondelete="CASCADE"),
50
+ nullable=False,
51
+ index=True,
52
+ )
53
+ reason: Mapped[str] = mapped_column(Text, nullable=False)
54
+ status: Mapped[AppealStatus] = mapped_column(
55
+ Enum(AppealStatus),
56
+ nullable=False,
57
+ default=AppealStatus.PENDING,
58
+ )
59
+ admin_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
60
+ created_at: Mapped[datetime] = mapped_column(
61
+ DateTime,
62
+ default=datetime.utcnow,
63
+ nullable=False,
64
+ )
65
+ updated_at: Mapped[datetime] = mapped_column(
66
+ DateTime,
67
+ default=datetime.utcnow,
68
+ onupdate=datetime.utcnow,
69
+ nullable=False,
70
+ )
71
+
72
+ # Relationships
73
+ driver: Mapped["Driver"] = relationship("Driver")
74
+ assignment: Mapped["Assignment"] = relationship("Assignment")
75
+
76
+ def __repr__(self) -> str:
77
+ return f"<Appeal(id={self.id}, driver_id={self.driver_id}, status={self.status})>"
brain/app/models/assignment.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Assignment database model.
3
+ Represents the allocation of a route to a driver.
4
+ """
5
+
6
+ import uuid
7
+ from datetime import datetime, date
8
+ from typing import Optional, List, TYPE_CHECKING
9
+
10
+ from sqlalchemy import Float, Text, Date, DateTime, ForeignKey
11
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
12
+
13
+ from app.database import Base, GUID
14
+
15
+ if TYPE_CHECKING:
16
+ from app.models.driver import Driver, DriverFeedback
17
+ from app.models.route import Route
18
+
19
+
20
+ class Assignment(Base):
21
+ """
22
+ Assignment model representing the allocation of a route to a driver.
23
+ Contains workload and fairness scores plus explanation.
24
+ """
25
+ __tablename__ = "assignments"
26
+
27
+ id: Mapped[uuid.UUID] = mapped_column(
28
+ GUID(),
29
+ primary_key=True,
30
+ default=uuid.uuid4,
31
+ )
32
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
33
+ driver_id: Mapped[uuid.UUID] = mapped_column(
34
+ GUID(),
35
+ ForeignKey("drivers.id", ondelete="CASCADE"),
36
+ nullable=False,
37
+ index=True,
38
+ )
39
+ route_id: Mapped[uuid.UUID] = mapped_column(
40
+ GUID(),
41
+ ForeignKey("routes.id", ondelete="CASCADE"),
42
+ nullable=False,
43
+ index=True,
44
+ )
45
+ workload_score: Mapped[float] = mapped_column(Float, default=0.0)
46
+ fairness_score: Mapped[float] = mapped_column(Float, default=1.0)
47
+ explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
48
+ driver_explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
49
+ admin_explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
50
+ allocation_run_id: Mapped[uuid.UUID] = mapped_column(
51
+ GUID(),
52
+ nullable=False,
53
+ index=True,
54
+ )
55
+
56
+ created_at: Mapped[datetime] = mapped_column(
57
+ DateTime,
58
+ default=datetime.utcnow,
59
+ )
60
+
61
+ # Relationships
62
+ driver: Mapped["Driver"] = relationship("Driver", back_populates="assignments")
63
+ route: Mapped["Route"] = relationship("Route", back_populates="assignments")
64
+ feedback: Mapped[List["DriverFeedback"]] = relationship(
65
+ "DriverFeedback",
66
+ back_populates="assignment",
67
+ cascade="all, delete-orphan",
68
+ )
69
+
70
+ def __repr__(self) -> str:
71
+ return f"<Assignment(id={self.id}, driver_id={self.driver_id}, route_id={self.route_id})>"
brain/app/models/decision_log.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DecisionLog database model.
3
+ Stores per-agent step logs for workflow visualization.
4
+ """
5
+
6
+ import uuid
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from sqlalchemy import String, DateTime, ForeignKey, JSON
11
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
12
+
13
+ from app.database import Base, GUID
14
+
15
+
16
+ class DecisionLog(Base):
17
+ """
18
+ DecisionLog model for agent workflow visualization.
19
+ Records per-agent step logs during allocation with input/output snapshots.
20
+ """
21
+ __tablename__ = "decision_logs"
22
+
23
+ id: Mapped[uuid.UUID] = mapped_column(
24
+ GUID(),
25
+ primary_key=True,
26
+ default=uuid.uuid4,
27
+ )
28
+ allocation_run_id: Mapped[uuid.UUID] = mapped_column(
29
+ GUID(),
30
+ ForeignKey("allocation_runs.id", ondelete="CASCADE"),
31
+ nullable=False,
32
+ index=True,
33
+ )
34
+ agent_name: Mapped[str] = mapped_column(
35
+ String(100),
36
+ nullable=False,
37
+ index=True,
38
+ )
39
+ step_type: Mapped[str] = mapped_column(
40
+ String(100),
41
+ nullable=False,
42
+ )
43
+ input_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
44
+ output_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
45
+ created_at: Mapped[datetime] = mapped_column(
46
+ DateTime,
47
+ default=datetime.utcnow,
48
+ nullable=False,
49
+ )
50
+
51
+ # Relationships
52
+ allocation_run = relationship("AllocationRun")
53
+
54
+ def __repr__(self) -> str:
55
+ return f"<DecisionLog(id={self.id}, agent={self.agent_name}, step={self.step_type})>"
brain/app/models/delivery_log.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeliveryLog database model.
3
+ Represents a delivery attempt at a given stop.
4
+ """
5
+
6
+ import enum
7
+ import uuid
8
+ from datetime import datetime
9
+ from typing import Optional, TYPE_CHECKING
10
+
11
+ from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Enum
12
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
13
+
14
+ from app.database import Base, GUID
15
+
16
+ if TYPE_CHECKING:
17
+ from app.models.assignment import Assignment
18
+ from app.models.route import Route
19
+ from app.models.driver import Driver
20
+ from app.models.package import Package
21
+
22
+
23
+ class DeliveryStatus(str, enum.Enum):
24
+ """Status of a delivery attempt."""
25
+ DELIVERED = "DELIVERED"
26
+ FAILED = "FAILED"
27
+ PARTIAL = "PARTIAL"
28
+
29
+
30
+ class DeliveryIssueType(str, enum.Enum):
31
+ """Type of issue encountered during delivery."""
32
+ NONE = "NONE"
33
+ NOT_AT_HOME = "NOT_AT_HOME"
34
+ WRONG_ADDRESS = "WRONG_ADDRESS"
35
+ SAFETY = "SAFETY"
36
+ ACCESS_DENIED = "ACCESS_DENIED"
37
+ OTHER = "OTHER"
38
+
39
+
40
+ class DeliveryLog(Base):
41
+ """
42
+ DeliveryLog model representing a delivery attempt at a given stop.
43
+ Tracks delivery status, issues, and proof of delivery.
44
+ """
45
+ __tablename__ = "delivery_logs"
46
+
47
+ id: Mapped[uuid.UUID] = mapped_column(
48
+ GUID(),
49
+ primary_key=True,
50
+ default=uuid.uuid4,
51
+ )
52
+ assignment_id: Mapped[uuid.UUID] = mapped_column(
53
+ GUID(),
54
+ ForeignKey("assignments.id", ondelete="CASCADE"),
55
+ nullable=False,
56
+ index=True,
57
+ )
58
+ route_id: Mapped[uuid.UUID] = mapped_column(
59
+ GUID(),
60
+ ForeignKey("routes.id", ondelete="CASCADE"),
61
+ nullable=False,
62
+ index=True,
63
+ )
64
+ driver_id: Mapped[uuid.UUID] = mapped_column(
65
+ GUID(),
66
+ ForeignKey("drivers.id", ondelete="CASCADE"),
67
+ nullable=False,
68
+ index=True,
69
+ )
70
+ stop_order: Mapped[int] = mapped_column(Integer, nullable=False)
71
+ package_id: Mapped[Optional[uuid.UUID]] = mapped_column(
72
+ GUID(),
73
+ ForeignKey("packages.id", ondelete="SET NULL"),
74
+ nullable=True,
75
+ )
76
+ status: Mapped[DeliveryStatus] = mapped_column(
77
+ Enum(DeliveryStatus),
78
+ nullable=False,
79
+ default=DeliveryStatus.DELIVERED,
80
+ )
81
+ issue_type: Mapped[DeliveryIssueType] = mapped_column(
82
+ Enum(DeliveryIssueType),
83
+ nullable=False,
84
+ default=DeliveryIssueType.NONE,
85
+ )
86
+ photo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
87
+ signature_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
88
+ notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
89
+ timestamp: Mapped[datetime] = mapped_column(
90
+ DateTime,
91
+ default=datetime.utcnow,
92
+ nullable=False,
93
+ )
94
+
95
+ # Relationships
96
+ assignment: Mapped["Assignment"] = relationship("Assignment")
97
+ route: Mapped["Route"] = relationship("Route")
98
+ driver: Mapped["Driver"] = relationship("Driver")
99
+ package: Mapped[Optional["Package"]] = relationship("Package")
100
+
101
+ def __repr__(self) -> str:
102
+ return f"<DeliveryLog(id={self.id}, stop_order={self.stop_order}, status={self.status})>"
brain/app/models/driver.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Driver-related database models.
3
+ Includes Driver, DriverStatsDaily, and DriverFeedback models.
4
+ """
5
+
6
+ import enum
7
+ import uuid
8
+ from datetime import datetime, date
9
+ from typing import Optional, List, TYPE_CHECKING
10
+
11
+ from sqlalchemy import String, Float, Integer, Text, Date, DateTime, ForeignKey, Enum, JSON, Boolean
12
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
13
+
14
+ from app.database import Base, GUID
15
+
16
+ if TYPE_CHECKING:
17
+ from app.models.assignment import Assignment
18
+
19
+
20
+ class PreferredLanguage(str, enum.Enum):
21
+ """Supported languages for driver communication."""
22
+ EN = "en"
23
+ TA = "ta"
24
+ HI = "hi"
25
+ TE = "te"
26
+ KN = "kn"
27
+
28
+
29
+ class VehicleType(str, enum.Enum):
30
+ """Types of vehicles used for delivery."""
31
+ ICE = "ICE"
32
+ EV = "EV"
33
+ BICYCLE = "BICYCLE"
34
+
35
+
36
+ class Driver(Base):
37
+ """
38
+ Driver model representing delivery personnel.
39
+ Stores personal info, vehicle details, and preferences.
40
+ """
41
+ __tablename__ = "drivers"
42
+
43
+ id: Mapped[uuid.UUID] = mapped_column(
44
+ GUID(),
45
+ primary_key=True,
46
+ default=uuid.uuid4,
47
+ )
48
+ external_id: Mapped[Optional[str]] = mapped_column(
49
+ String(100),
50
+ unique=True,
51
+ nullable=True,
52
+ index=True,
53
+ )
54
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
55
+ phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
56
+ whatsapp_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
57
+ preferred_language: Mapped[PreferredLanguage] = mapped_column(
58
+ Enum(PreferredLanguage),
59
+ default=PreferredLanguage.EN,
60
+ )
61
+ vehicle_type: Mapped[VehicleType] = mapped_column(
62
+ Enum(VehicleType),
63
+ default=VehicleType.ICE,
64
+ )
65
+ vehicle_capacity_kg: Mapped[float] = mapped_column(Float, default=100.0)
66
+ license_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
67
+ ev_charging_pref: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
68
+
69
+ # EV-specific fields (Phase 7)
70
+ battery_range_km: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
71
+ charging_time_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
72
+
73
+ created_at: Mapped[datetime] = mapped_column(
74
+ DateTime,
75
+ default=datetime.utcnow,
76
+ )
77
+ updated_at: Mapped[datetime] = mapped_column(
78
+ DateTime,
79
+ default=datetime.utcnow,
80
+ onupdate=datetime.utcnow,
81
+ )
82
+
83
+ # Relationships
84
+ daily_stats: Mapped[List["DriverStatsDaily"]] = relationship(
85
+ "DriverStatsDaily",
86
+ back_populates="driver",
87
+ cascade="all, delete-orphan",
88
+ )
89
+ assignments: Mapped[List["Assignment"]] = relationship(
90
+ "Assignment",
91
+ back_populates="driver",
92
+ cascade="all, delete-orphan",
93
+ )
94
+ feedback: Mapped[List["DriverFeedback"]] = relationship(
95
+ "DriverFeedback",
96
+ back_populates="driver",
97
+ cascade="all, delete-orphan",
98
+ )
99
+
100
+ @property
101
+ def is_ev(self) -> bool:
102
+ """Check if driver uses an electric vehicle."""
103
+ return self.vehicle_type == VehicleType.EV
104
+
105
+ def __repr__(self) -> str:
106
+ return f"<Driver(id={self.id}, name={self.name})>"
107
+
108
+
109
+ class DriverStatsDaily(Base):
110
+ """
111
+ Daily statistics for each driver.
112
+ Tracks workload, routes, fairness metrics, and recovery state per day.
113
+ """
114
+ __tablename__ = "driver_stats_daily"
115
+
116
+ id: Mapped[uuid.UUID] = mapped_column(
117
+ GUID(),
118
+ primary_key=True,
119
+ default=uuid.uuid4,
120
+ )
121
+ driver_id: Mapped[uuid.UUID] = mapped_column(
122
+ GUID(),
123
+ ForeignKey("drivers.id", ondelete="CASCADE"),
124
+ nullable=False,
125
+ index=True,
126
+ )
127
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
128
+ avg_workload_score: Mapped[float] = mapped_column(Float, default=0.0)
129
+ total_routes: Mapped[int] = mapped_column(Integer, default=0)
130
+ gini_contribution: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
131
+ reported_stress_level: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
132
+ reported_fairness_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
133
+
134
+ # Recovery tracking fields (Phase 7)
135
+ is_hard_day: Mapped[bool] = mapped_column(Boolean, default=False)
136
+ complexity_debt: Mapped[float] = mapped_column(Float, default=0.0)
137
+ is_recovery_day: Mapped[bool] = mapped_column(Boolean, default=False)
138
+
139
+ # Learning fields (Phase 8)
140
+ predicted_effort: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
141
+ actual_effort: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
142
+ prediction_error: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
143
+ model_version_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
144
+ allocation_run_id: Mapped[Optional[uuid.UUID]] = mapped_column(
145
+ GUID(),
146
+ ForeignKey("allocation_runs.id", ondelete="SET NULL"),
147
+ nullable=True,
148
+ index=True,
149
+ )
150
+
151
+ # Relationships
152
+ driver: Mapped["Driver"] = relationship("Driver", back_populates="daily_stats")
153
+
154
+ def __repr__(self) -> str:
155
+ return f"<DriverStatsDaily(driver_id={self.driver_id}, date={self.date})>"
156
+
157
+
158
+ class HardestAspect(str, enum.Enum):
159
+ """Common difficult aspects of delivery work."""
160
+ TRAFFIC = "traffic"
161
+ PARKING = "parking"
162
+ STAIRS = "stairs"
163
+ WEATHER = "weather"
164
+ HEAVY_LOAD = "heavy_load"
165
+ CUSTOMER = "customer"
166
+ NAVIGATION = "navigation"
167
+ OTHER = "other"
168
+
169
+
170
+ class DriverFeedback(Base):
171
+ """
172
+ Feedback submitted by drivers after completing assignments.
173
+ Used for learning and improving future allocations.
174
+ """
175
+ __tablename__ = "driver_feedback"
176
+
177
+ id: Mapped[uuid.UUID] = mapped_column(
178
+ GUID(),
179
+ primary_key=True,
180
+ default=uuid.uuid4,
181
+ )
182
+ driver_id: Mapped[uuid.UUID] = mapped_column(
183
+ GUID(),
184
+ ForeignKey("drivers.id", ondelete="CASCADE"),
185
+ nullable=False,
186
+ index=True,
187
+ )
188
+ assignment_id: Mapped[uuid.UUID] = mapped_column(
189
+ GUID(),
190
+ ForeignKey("assignments.id", ondelete="CASCADE"),
191
+ nullable=False,
192
+ index=True,
193
+ )
194
+ fairness_rating: Mapped[int] = mapped_column(Integer, nullable=False) # 1-5
195
+ stress_level: Mapped[int] = mapped_column(Integer, nullable=False) # 1-10
196
+ tiredness_level: Mapped[int] = mapped_column(Integer, nullable=False) # 1-5
197
+ hardest_aspect: Mapped[Optional[HardestAspect]] = mapped_column(
198
+ Enum(HardestAspect),
199
+ nullable=True,
200
+ )
201
+ comments: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
202
+
203
+ # Extended feedback fields (Phase 2)
204
+ route_difficulty_self_report: Mapped[Optional[int]] = mapped_column(
205
+ Integer,
206
+ nullable=True,
207
+ ) # 1-5 scale
208
+ would_take_similar_route_again: Mapped[Optional[bool]] = mapped_column(
209
+ nullable=True,
210
+ )
211
+ most_unfair_aspect: Mapped[Optional[str]] = mapped_column(
212
+ String(100),
213
+ nullable=True,
214
+ )
215
+
216
+ created_at: Mapped[datetime] = mapped_column(
217
+ DateTime,
218
+ default=datetime.utcnow,
219
+ )
220
+
221
+ # Relationships
222
+ driver: Mapped["Driver"] = relationship("Driver", back_populates="feedback")
223
+ assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="feedback")
224
+
225
+ def __repr__(self) -> str:
226
+ return f"<DriverFeedback(id={self.id}, driver_id={self.driver_id})>"
brain/app/models/driver_effort_model.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DriverEffortModel database model.
3
+ Stores per-driver XGBoost models for personalized effort prediction.
4
+ """
5
+
6
+ import uuid
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from sqlalchemy import Boolean, Float, Integer, DateTime, ForeignKey, LargeBinary, JSON
11
+ from sqlalchemy.orm import Mapped, mapped_column
12
+
13
+ from app.database import Base, GUID
14
+
15
+
16
+ class DriverEffortModel(Base):
17
+ """
18
+ DriverEffortModel stores serialized XGBoost models per driver.
19
+ Each driver gets their own personalized effort prediction model.
20
+ """
21
+ __tablename__ = "driver_effort_models"
22
+
23
+ driver_id: Mapped[uuid.UUID] = mapped_column(
24
+ GUID(),
25
+ ForeignKey("drivers.id", ondelete="CASCADE"),
26
+ primary_key=True,
27
+ )
28
+
29
+ model_version: Mapped[int] = mapped_column(
30
+ Integer,
31
+ default=1,
32
+ nullable=False,
33
+ )
34
+
35
+ # Serialized XGBoost model (pickle format)
36
+ model_pickle: Mapped[Optional[bytes]] = mapped_column(
37
+ LargeBinary,
38
+ nullable=True,
39
+ )
40
+
41
+ # Training metadata
42
+ training_samples: Mapped[int] = mapped_column(
43
+ Integer,
44
+ default=0,
45
+ )
46
+ feature_names: Mapped[Optional[dict]] = mapped_column(
47
+ JSON,
48
+ nullable=True,
49
+ )
50
+
51
+ # Performance tracking
52
+ mse_history: Mapped[Optional[dict]] = mapped_column(
53
+ JSON,
54
+ nullable=True,
55
+ default=list,
56
+ ) # List of last 10 MSE values
57
+ current_mse: Mapped[Optional[float]] = mapped_column(
58
+ Float,
59
+ nullable=True,
60
+ )
61
+ r2_score: Mapped[Optional[float]] = mapped_column(
62
+ Float,
63
+ nullable=True,
64
+ )
65
+
66
+ # Model state
67
+ active: Mapped[bool] = mapped_column(
68
+ Boolean,
69
+ default=True,
70
+ index=True,
71
+ )
72
+
73
+ last_trained_at: Mapped[Optional[datetime]] = mapped_column(
74
+ DateTime,
75
+ nullable=True,
76
+ )
77
+ created_at: Mapped[datetime] = mapped_column(
78
+ DateTime,
79
+ default=datetime.utcnow,
80
+ nullable=False,
81
+ )
82
+ updated_at: Mapped[datetime] = mapped_column(
83
+ DateTime,
84
+ default=datetime.utcnow,
85
+ onupdate=datetime.utcnow,
86
+ nullable=False,
87
+ )
88
+
89
+ def __repr__(self) -> str:
90
+ return f"<DriverEffortModel(driver_id={self.driver_id}, v{self.model_version})>"
brain/app/models/fairness_config.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FairnessConfig database model.
3
+ Stores fairness engine weights and thresholds.
4
+ """
5
+
6
+ import uuid
7
+ from datetime import datetime
8
+
9
+ from sqlalchemy import Boolean, Float, DateTime
10
+ from sqlalchemy.orm import Mapped, mapped_column
11
+
12
+ from app.database import Base, GUID
13
+
14
+
15
+ class FairnessConfig(Base):
16
+ """
17
+ FairnessConfig model for storing fairness engine weights and thresholds.
18
+ Only one config should be active at a time.
19
+ """
20
+ __tablename__ = "fairness_configs"
21
+
22
+ id: Mapped[uuid.UUID] = mapped_column(
23
+ GUID(),
24
+ primary_key=True,
25
+ default=uuid.uuid4,
26
+ )
27
+ is_active: Mapped[bool] = mapped_column(
28
+ Boolean,
29
+ default=True,
30
+ nullable=False,
31
+ index=True,
32
+ )
33
+
34
+ # Workload weights
35
+ workload_weight_packages: Mapped[float] = mapped_column(Float, default=1.0)
36
+ workload_weight_weight_kg: Mapped[float] = mapped_column(Float, default=0.5)
37
+ workload_weight_difficulty: Mapped[float] = mapped_column(Float, default=10.0)
38
+ workload_weight_time: Mapped[float] = mapped_column(Float, default=0.2)
39
+
40
+ # Fairness thresholds
41
+ gini_threshold: Mapped[float] = mapped_column(Float, default=0.33)
42
+ stddev_threshold: Mapped[float] = mapped_column(Float, default=25.0)
43
+ max_gap_threshold: Mapped[float] = mapped_column(Float, default=25.0)
44
+
45
+ # Recovery mode (Phase 7)
46
+ recovery_mode_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
47
+ complexity_debt_hard_threshold: Mapped[float] = mapped_column(Float, default=2.0)
48
+ recovery_lightening_factor: Mapped[float] = mapped_column(Float, default=0.7)
49
+ recovery_penalty_weight: Mapped[float] = mapped_column(Float, default=3.0)
50
+
51
+ # EV config (Phase 7)
52
+ ev_charging_penalty_weight: Mapped[float] = mapped_column(Float, default=0.3)
53
+ ev_safety_margin_pct: Mapped[float] = mapped_column(Float, default=10.0)
54
+
55
+ created_at: Mapped[datetime] = mapped_column(
56
+ DateTime,
57
+ default=datetime.utcnow,
58
+ nullable=False,
59
+ )
60
+ updated_at: Mapped[datetime] = mapped_column(
61
+ DateTime,
62
+ default=datetime.utcnow,
63
+ onupdate=datetime.utcnow,
64
+ nullable=False,
65
+ )
66
+
67
+ def __repr__(self) -> str:
68
+ return f"<FairnessConfig(id={self.id}, is_active={self.is_active})>"
69
+
brain/app/models/learning_episode.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LearningEpisode database model.
3
+ Tracks bandit learning episodes per allocation run for Thompson Sampling.
4
+ """
5
+
6
+ import uuid
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from sqlalchemy import Boolean, Float, Integer, String, DateTime, ForeignKey, Text, JSON
11
+ from sqlalchemy.orm import Mapped, mapped_column
12
+
13
+ from app.database import Base, GUID
14
+
15
+
16
+ class LearningEpisode(Base):
17
+ """
18
+ LearningEpisode model for tracking bandit learning episodes.
19
+ Each allocation run creates one episode; reward is computed 24h+ later.
20
+ """
21
+ __tablename__ = "learning_episodes"
22
+
23
+ id: Mapped[uuid.UUID] = mapped_column(
24
+ GUID(),
25
+ primary_key=True,
26
+ default=uuid.uuid4,
27
+ )
28
+ allocation_run_id: Mapped[uuid.UUID] = mapped_column(
29
+ GUID(),
30
+ ForeignKey("allocation_runs.id", ondelete="CASCADE"),
31
+ nullable=False,
32
+ index=True,
33
+ unique=True, # One episode per allocation run
34
+ )
35
+
36
+ # Bandit arm identification
37
+ config_hash: Mapped[str] = mapped_column(
38
+ String(64),
39
+ nullable=False,
40
+ index=True,
41
+ )
42
+ fairness_config: Mapped[dict] = mapped_column(
43
+ JSON,
44
+ nullable=False,
45
+ )
46
+ arm_idx: Mapped[int] = mapped_column(
47
+ Integer,
48
+ nullable=False,
49
+ default=0,
50
+ )
51
+
52
+ # Episode context
53
+ num_drivers: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
54
+ num_routes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
55
+
56
+ # Reward (computed later by cron job)
57
+ episode_reward: Mapped[Optional[float]] = mapped_column(
58
+ Float,
59
+ nullable=True,
60
+ )
61
+ reward_computed_at: Mapped[Optional[datetime]] = mapped_column(
62
+ DateTime,
63
+ nullable=True,
64
+ )
65
+
66
+ # Thompson Sampling priors at time of selection
67
+ alpha_prior: Mapped[float] = mapped_column(Float, default=1.0)
68
+ beta_prior: Mapped[float] = mapped_column(Float, default=1.0)
69
+ samples_count: Mapped[int] = mapped_column(Integer, default=0)
70
+
71
+ # A/B testing flag
72
+ is_experimental: Mapped[bool] = mapped_column(
73
+ Boolean,
74
+ default=False,
75
+ index=True,
76
+ )
77
+
78
+ # Feedback aggregation (stored for debugging)
79
+ avg_fairness_rating: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
80
+ avg_stress_level: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
81
+ completion_rate: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
82
+ feedback_count: Mapped[int] = mapped_column(Integer, default=0)
83
+
84
+ created_at: Mapped[datetime] = mapped_column(
85
+ DateTime,
86
+ default=datetime.utcnow,
87
+ nullable=False,
88
+ index=True,
89
+ )
90
+
91
+ def __repr__(self) -> str:
92
+ return f"<LearningEpisode(id={self.id}, reward={self.episode_reward})>"
brain/app/models/manual_override.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ManualOverride database model.
3
+ Captures manual admin interventions in allocations.
4
+ """
5
+
6
+ import uuid
7
+ from datetime import datetime
8
+ from typing import Optional, TYPE_CHECKING
9
+
10
+ from sqlalchemy import Text, DateTime, ForeignKey, JSON
11
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
12
+
13
+ from app.database import Base, GUID
14
+
15
+ if TYPE_CHECKING:
16
+ from app.models.driver import Driver
17
+ from app.models.route import Route
18
+
19
+
20
+ class ManualOverride(Base):
21
+ """
22
+ ManualOverride model for admin manual interventions.
23
+ Records route reassignments with before/after fairness metrics.
24
+ """
25
+ __tablename__ = "manual_overrides"
26
+
27
+ id: Mapped[uuid.UUID] = mapped_column(
28
+ GUID(),
29
+ primary_key=True,
30
+ default=uuid.uuid4,
31
+ )
32
+ allocation_run_id: Mapped[uuid.UUID] = mapped_column(
33
+ GUID(),
34
+ nullable=False,
35
+ index=True,
36
+ )
37
+ old_driver_id: Mapped[Optional[uuid.UUID]] = mapped_column(
38
+ GUID(),
39
+ ForeignKey("drivers.id", ondelete="SET NULL"),
40
+ nullable=True,
41
+ )
42
+ new_driver_id: Mapped[Optional[uuid.UUID]] = mapped_column(
43
+ GUID(),
44
+ ForeignKey("drivers.id", ondelete="SET NULL"),
45
+ nullable=True,
46
+ )
47
+ route_id: Mapped[Optional[uuid.UUID]] = mapped_column(
48
+ GUID(),
49
+ ForeignKey("routes.id", ondelete="SET NULL"),
50
+ nullable=True,
51
+ )
52
+ reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
53
+ before_metrics: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
54
+ after_metrics: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
55
+ created_at: Mapped[datetime] = mapped_column(
56
+ DateTime,
57
+ default=datetime.utcnow,
58
+ nullable=False,
59
+ )
60
+
61
+ # Relationships
62
+ old_driver: Mapped[Optional["Driver"]] = relationship(
63
+ "Driver",
64
+ foreign_keys=[old_driver_id],
65
+ )
66
+ new_driver: Mapped[Optional["Driver"]] = relationship(
67
+ "Driver",
68
+ foreign_keys=[new_driver_id],
69
+ )
70
+ route: Mapped[Optional["Route"]] = relationship("Route")
71
+
72
+ def __repr__(self) -> str:
73
+ return f"<ManualOverride(id={self.id}, old_driver={self.old_driver_id}, new_driver={self.new_driver_id})>"
brain/app/models/package.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Package database model.
3
+ Represents delivery packages with location and priority info.
4
+ """
5
+
6
+ import enum
7
+ import uuid
8
+ from datetime import datetime
9
+ from typing import Optional, List, TYPE_CHECKING
10
+
11
+ from sqlalchemy import String, Float, Integer, Text, DateTime, Enum
12
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
13
+
14
+ from app.database import Base, GUID
15
+
16
+ if TYPE_CHECKING:
17
+ from app.models.route import RoutePackage
18
+
19
+
20
+ class PackagePriority(str, enum.Enum):
21
+ """Priority levels for package delivery."""
22
+ NORMAL = "NORMAL"
23
+ HIGH = "HIGH"
24
+ EXPRESS = "EXPRESS"
25
+
26
+
27
+ class Package(Base):
28
+ """
29
+ Package model representing items to be delivered.
30
+ Contains weight, fragility, location, and priority information.
31
+ """
32
+ __tablename__ = "packages"
33
+
34
+ id: Mapped[uuid.UUID] = mapped_column(
35
+ GUID(),
36
+ primary_key=True,
37
+ default=uuid.uuid4,
38
+ )
39
+ external_id: Mapped[str] = mapped_column(
40
+ String(100),
41
+ unique=True,
42
+ nullable=False,
43
+ index=True,
44
+ )
45
+ weight_kg: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
46
+ fragility_level: Mapped[int] = mapped_column(Integer, nullable=False, default=1) # 1-5
47
+ address: Mapped[str] = mapped_column(Text, nullable=False)
48
+ latitude: Mapped[float] = mapped_column(Float, nullable=False)
49
+ longitude: Mapped[float] = mapped_column(Float, nullable=False)
50
+ priority: Mapped[PackagePriority] = mapped_column(
51
+ Enum(PackagePriority),
52
+ default=PackagePriority.NORMAL,
53
+ )
54
+
55
+ created_at: Mapped[datetime] = mapped_column(
56
+ DateTime,
57
+ default=datetime.utcnow,
58
+ )
59
+
60
+ # Relationships
61
+ route_packages: Mapped[List["RoutePackage"]] = relationship(
62
+ "RoutePackage",
63
+ back_populates="package",
64
+ cascade="all, delete-orphan",
65
+ )
66
+
67
+ def __repr__(self) -> str:
68
+ return f"<Package(id={self.id}, external_id={self.external_id})>"
brain/app/models/route.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Route database models.
3
+ Includes Route and RoutePackage (association table) models.
4
+ """
5
+
6
+ import uuid
7
+ from datetime import datetime, date
8
+ from typing import Optional, List, TYPE_CHECKING
9
+
10
+ from sqlalchemy import Integer, Float, Date, DateTime, ForeignKey
11
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
12
+
13
+ from app.database import Base, GUID
14
+
15
+ if TYPE_CHECKING:
16
+ from app.models.package import Package
17
+ from app.models.assignment import Assignment
18
+
19
+
20
+ class Route(Base):
21
+ """
22
+ Route model representing a delivery route (cluster of packages).
23
+ Contains aggregated metrics about the route.
24
+ """
25
+ __tablename__ = "routes"
26
+
27
+ id: Mapped[uuid.UUID] = mapped_column(
28
+ GUID(),
29
+ primary_key=True,
30
+ default=uuid.uuid4,
31
+ )
32
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
33
+ cluster_id: Mapped[int] = mapped_column(Integer, nullable=False)
34
+ total_weight_kg: Mapped[float] = mapped_column(Float, default=0.0)
35
+ num_packages: Mapped[int] = mapped_column(Integer, default=0)
36
+ num_stops: Mapped[int] = mapped_column(Integer, default=0)
37
+ route_difficulty_score: Mapped[float] = mapped_column(Float, default=1.0)
38
+ estimated_time_minutes: Mapped[int] = mapped_column(Integer, default=60)
39
+
40
+ # Distance for EV range calculations (Phase 7)
41
+ total_distance_km: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
42
+
43
+ # Run scoping - links route to specific allocation run
44
+ # Nullable for backward compatibility with existing routes
45
+ allocation_run_id: Mapped[Optional[uuid.UUID]] = mapped_column(
46
+ GUID(),
47
+ ForeignKey("allocation_runs.id", ondelete="CASCADE"),
48
+ nullable=True,
49
+ index=True,
50
+ )
51
+
52
+ created_at: Mapped[datetime] = mapped_column(
53
+ DateTime,
54
+ default=datetime.utcnow,
55
+ )
56
+
57
+ # Relationships
58
+ route_packages: Mapped[List["RoutePackage"]] = relationship(
59
+ "RoutePackage",
60
+ back_populates="route",
61
+ cascade="all, delete-orphan",
62
+ )
63
+ assignments: Mapped[List["Assignment"]] = relationship(
64
+ "Assignment",
65
+ back_populates="route",
66
+ cascade="all, delete-orphan",
67
+ )
68
+
69
+ def __repr__(self) -> str:
70
+ return f"<Route(id={self.id}, cluster_id={self.cluster_id}, packages={self.num_packages})>"
71
+
72
+
73
+ class RoutePackage(Base):
74
+ """
75
+ Association table linking routes to packages with stop order.
76
+ Represents which packages belong to which route and in what order.
77
+ """
78
+ __tablename__ = "route_packages"
79
+
80
+ route_id: Mapped[uuid.UUID] = mapped_column(
81
+ GUID(),
82
+ ForeignKey("routes.id", ondelete="CASCADE"),
83
+ primary_key=True,
84
+ )
85
+ package_id: Mapped[uuid.UUID] = mapped_column(
86
+ GUID(),
87
+ ForeignKey("packages.id", ondelete="CASCADE"),
88
+ primary_key=True,
89
+ )
90
+ stop_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
91
+
92
+ # Relationships
93
+ route: Mapped["Route"] = relationship("Route", back_populates="route_packages")
94
+ package: Mapped["Package"] = relationship("Package", back_populates="route_packages")
95
+
96
+ def __repr__(self) -> str:
97
+ return f"<RoutePackage(route_id={self.route_id}, package_id={self.package_id}, order={self.stop_order})>"
brain/app/models/route_swap.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RouteSwapRequest database model.
3
+ Represents a driver requesting to swap or change a route.
4
+ """
5
+
6
+ import enum
7
+ import uuid
8
+ from datetime import datetime, date
9
+ from typing import Optional, TYPE_CHECKING
10
+
11
+ from sqlalchemy import Text, Date, DateTime, ForeignKey, Enum
12
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
13
+
14
+ from app.database import Base, GUID
15
+
16
+ if TYPE_CHECKING:
17
+ from app.models.driver import Driver
18
+ from app.models.assignment import Assignment
19
+
20
+
21
+ class SwapRequestStatus(str, enum.Enum):
22
+ """Status of a route swap request."""
23
+ PENDING = "PENDING"
24
+ APPROVED = "APPROVED"
25
+ REJECTED = "REJECTED"
26
+ CANCELLED = "CANCELLED"
27
+
28
+
29
+ class RouteSwapRequest(Base):
30
+ """
31
+ RouteSwapRequest model for driver route swap/change requests.
32
+ Tracks request status and allows drivers to request lighter routes.
33
+ """
34
+ __tablename__ = "route_swap_requests"
35
+
36
+ id: Mapped[uuid.UUID] = mapped_column(
37
+ GUID(),
38
+ primary_key=True,
39
+ default=uuid.uuid4,
40
+ )
41
+ from_driver_id: Mapped[uuid.UUID] = mapped_column(
42
+ GUID(),
43
+ ForeignKey("drivers.id", ondelete="CASCADE"),
44
+ nullable=False,
45
+ index=True,
46
+ )
47
+ to_driver_id: Mapped[Optional[uuid.UUID]] = mapped_column(
48
+ GUID(),
49
+ ForeignKey("drivers.id", ondelete="SET NULL"),
50
+ nullable=True,
51
+ )
52
+ assignment_id: Mapped[uuid.UUID] = mapped_column(
53
+ GUID(),
54
+ ForeignKey("assignments.id", ondelete="CASCADE"),
55
+ nullable=False,
56
+ index=True,
57
+ )
58
+ reason: Mapped[str] = mapped_column(Text, nullable=False)
59
+ preferred_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
60
+ status: Mapped[SwapRequestStatus] = mapped_column(
61
+ Enum(SwapRequestStatus),
62
+ nullable=False,
63
+ default=SwapRequestStatus.PENDING,
64
+ )
65
+ created_at: Mapped[datetime] = mapped_column(
66
+ DateTime,
67
+ default=datetime.utcnow,
68
+ nullable=False,
69
+ )
70
+ updated_at: Mapped[datetime] = mapped_column(
71
+ DateTime,
72
+ default=datetime.utcnow,
73
+ onupdate=datetime.utcnow,
74
+ nullable=False,
75
+ )
76
+
77
+ # Relationships
78
+ from_driver: Mapped["Driver"] = relationship(
79
+ "Driver",
80
+ foreign_keys=[from_driver_id],
81
+ )
82
+ to_driver: Mapped[Optional["Driver"]] = relationship(
83
+ "Driver",
84
+ foreign_keys=[to_driver_id],
85
+ )
86
+ assignment: Mapped["Assignment"] = relationship("Assignment")
87
+
88
+ def __repr__(self) -> str:
89
+ return f"<RouteSwapRequest(id={self.id}, from_driver={self.from_driver_id}, status={self.status})>"