Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- .gitignore +75 -0
- README.md +630 -15
- brain/.env.example +26 -0
- brain/.github/workflows/test.yml +42 -0
- brain/.gitignore +95 -0
- brain/Dockerfile +23 -0
- brain/Makefile +34 -0
- brain/README.md +484 -0
- brain/alembic.ini +114 -0
- brain/alembic/env.py +92 -0
- brain/alembic/script.py.mako +26 -0
- brain/alembic/versions/001_initial_schema.py +145 -0
- brain/alembic/versions/002_phase2_phase3_models.py +179 -0
- brain/alembic/versions/003_add_pending_status.py +36 -0
- brain/alembic/versions/004_add_explanation_fields.py +42 -0
- brain/alembic/versions/005_phase7_ev_recovery.py +59 -0
- brain/alembic/versions/006_phase8_learning_agent.py +119 -0
- brain/app/__init__.py +1 -0
- brain/app/api/__init__.py +24 -0
- brain/app/api/admin.py +298 -0
- brain/app/api/admin_learning.py +368 -0
- brain/app/api/agent_events.py +93 -0
- brain/app/api/allocation.py +808 -0
- brain/app/api/allocation_langgraph.py +513 -0
- brain/app/api/consolidation.py +109 -0
- brain/app/api/driver_api.py +175 -0
- brain/app/api/drivers.py +136 -0
- brain/app/api/feedback.py +108 -0
- brain/app/api/routes.py +93 -0
- brain/app/api/runs.py +251 -0
- brain/app/config.py +77 -0
- brain/app/core/__init__.py +1 -0
- brain/app/core/events.py +154 -0
- brain/app/database.py +108 -0
- brain/app/main.py +187 -0
- brain/app/models/__init__.py +49 -0
- brain/app/models/allocation_run.py +64 -0
- brain/app/models/appeal.py +77 -0
- brain/app/models/assignment.py +71 -0
- brain/app/models/decision_log.py +55 -0
- brain/app/models/delivery_log.py +102 -0
- brain/app/models/driver.py +226 -0
- brain/app/models/driver_effort_model.py +90 -0
- brain/app/models/fairness_config.py +69 -0
- brain/app/models/learning_episode.py +92 -0
- brain/app/models/manual_override.py +73 -0
- brain/app/models/package.py +68 -0
- brain/app/models/route.py +97 -0
- 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 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
---
|
| 5 |
|
| 6 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
## Generated by ML Intern
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
##
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
```
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · Fairness-Aware Route Allocation · Multi-Agent Intelligence</strong>
|
| 11 |
+
</p>
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<a href="#-the-problem">Problem</a> •
|
| 15 |
+
<a href="#-our-solution">Solution</a> •
|
| 16 |
+
<a href="#-5-agent-consolidation-pipeline">Consolidation Pipeline</a> •
|
| 17 |
+
<a href="#-fair-dispatch-pipeline">Fair Dispatch</a> •
|
| 18 |
+
<a href="#-architecture">Architecture</a> •
|
| 19 |
+
<a href="#-dashboards--visualization">Dashboards</a> •
|
| 20 |
+
<a href="#-quick-start">Quick Start</a> •
|
| 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> · 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})>"
|