danvancea commited on
Commit
15fe476
·
verified ·
1 Parent(s): fcfdc75

Upload 23 files

Browse files
Files changed (4) hide show
  1. .dockerignore +26 -26
  2. Dockerfile +28 -28
  3. app.py +480 -480
  4. requirements.txt +1 -1
.dockerignore CHANGED
@@ -1,26 +1,26 @@
1
- __pycache__/
2
- *.pyc
3
- *.pyo
4
- *.pyd
5
- .pytest_cache/
6
- .cache/
7
- cache/
8
- .venv/
9
- venv/
10
-
11
- # Plots and binders generated during local exploration
12
- *.png
13
- *.pdf
14
- challenge_description.txt
15
- obsolete/
16
-
17
- # Local secrets — must never enter the image. Use env vars instead.
18
- seed/service-account.json
19
- .env
20
- .env.*
21
-
22
- # Editor / OS
23
- .vscode/
24
- .idea/
25
- .DS_Store
26
- Thumbs.db
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .pytest_cache/
6
+ .cache/
7
+ cache/
8
+ .venv/
9
+ venv/
10
+
11
+ # Plots and binders generated during local exploration
12
+ *.png
13
+ *.pdf
14
+ challenge_description.txt
15
+ obsolete/
16
+
17
+ # Local secrets — must never enter the image. Use env vars instead.
18
+ seed/service-account.json
19
+ .env
20
+ .env.*
21
+
22
+ # Editor / OS
23
+ .vscode/
24
+ .idea/
25
+ .DS_Store
26
+ Thumbs.db
Dockerfile CHANGED
@@ -1,28 +1,28 @@
1
- FROM python:3.12-slim
2
-
3
- ENV PYTHONDONTWRITEBYTECODE=1 \
4
- PYTHONUNBUFFERED=1 \
5
- MPLBACKEND=Agg \
6
- PORT=7860
7
-
8
- # Native deps for shapely / pyproj / osmnx.
9
- RUN apt-get update && apt-get install -y --no-install-recommends \
10
- libgeos-dev \
11
- libspatialindex-dev \
12
- libproj-dev \
13
- proj-bin \
14
- && rm -rf /var/lib/apt/lists/*
15
-
16
- WORKDIR /app
17
-
18
- # Install Python deps first so layer caches across code changes.
19
- COPY requirements.txt .
20
- RUN pip install --no-cache-dir -r requirements.txt
21
-
22
- # Copy backend sources. .dockerignore filters out pycache, plots, secrets.
23
- COPY . .
24
-
25
- EXPOSE 7860
26
-
27
- # Listen on $PORT (set by HF Spaces, Cloud Run, Fly, Render). Fallback to 7860.
28
- CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"]
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ MPLBACKEND=Agg \
6
+ PORT=7860
7
+
8
+ # Native deps for shapely / pyproj / osmnx.
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ libgeos-dev \
11
+ libspatialindex-dev \
12
+ libproj-dev \
13
+ proj-bin \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /app
17
+
18
+ # Install Python deps first so layer caches across code changes.
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy backend sources. .dockerignore filters out pycache, plots, secrets.
23
+ COPY . .
24
+
25
+ EXPOSE 7860
26
+
27
+ # Listen on $PORT (set by HF Spaces, Cloud Run, Fly, Render). Fallback to 7860.
28
+ CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"]
app.py CHANGED
@@ -1,480 +1,480 @@
1
- """FastAPI server: OR-tools VRPSPD-TW + analytics layer.
2
-
3
- GET /health liveness probe
4
- GET /sample-request bundled example body
5
- POST /optimize main endpoint, returns plan + polylines + KPIs + explanations
6
- POST /whatif perturb the request (driver out, traffic, stop cancelled) and re-solve
7
- POST /baseline naive comparator alone
8
- GET /docs auto Swagger UI
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import json
14
- from contextlib import asynccontextmanager
15
- from pathlib import Path
16
- from typing import Literal
17
-
18
- import os
19
- import httpx
20
- from anthropic import Anthropic
21
- import numpy as np
22
- from fastapi import FastAPI, HTTPException, Response
23
- from fastapi.middleware.cors import CORSMiddleware
24
- from pydantic import BaseModel
25
-
26
- from analytics import (
27
- CO2_KG_PER_KM, KPIs, compute_kpis, explain_van, load_profile, warehouse_prep,
28
- )
29
- from baseline import solve_baseline
30
- from firestore_writer import build_route_doc, is_enabled as fs_enabled, write_routes
31
- from geometry import route_polyline
32
- from graph_manager import get_or_build_graph
33
- from loader import build_travel_matrix, load
34
- from solver import solve_vrp
35
-
36
- SAMPLE = Path(__file__).with_name("sample_request.json")
37
- PRODUCTS = Path(__file__).with_name("products.json")
38
-
39
-
40
- # --- Request schema (mirrors sample_request.json) ----------------------
41
-
42
- class Coords(BaseModel):
43
- lat: float
44
- lng: float
45
-
46
-
47
- class TimeWindow(BaseModel):
48
- open: str
49
- close: str
50
-
51
-
52
- class Line(BaseModel):
53
- product_id: str
54
- qty: int
55
-
56
-
57
- class StopReq(BaseModel):
58
- id: str
59
- coords: Coords
60
- time_window: TimeWindow
61
- deliveries: list[Line]
62
- pickups: list[Line] = []
63
-
64
-
65
- class DepotReq(BaseModel):
66
- id: str
67
- coords: Coords
68
- open: str
69
- close: str
70
-
71
-
72
- class FleetReq(BaseModel):
73
- num_vans: int
74
- van_type: str
75
- vans_ref: str | None = None
76
- products_ref: str | None = None
77
-
78
-
79
- class DriverReq(BaseModel):
80
- id: str
81
- shift_start: str
82
- shift_end: str
83
-
84
-
85
- class OptimizeRequest(BaseModel):
86
- request_id: str | None = None
87
- date: str | None = None
88
- depot: DepotReq
89
- fleet: FleetReq
90
- drivers: list[DriverReq]
91
- stops: list[StopReq]
92
-
93
-
94
- class Disruption(BaseModel):
95
- type: Literal["driver_unavailable", "stop_cancelled", "traffic"]
96
- driver_id: str | None = None
97
- stop_id: str | None = None
98
- multiplier: float | None = None # for "traffic"
99
-
100
-
101
- class WhatIfRequest(BaseModel):
102
- request: OptimizeRequest
103
- disruption: Disruption
104
-
105
- class ChatRequest(BaseModel):
106
- transcript: str
107
- context: str
108
-
109
- class TTSRequest(BaseModel):
110
- text: str
111
-
112
-
113
- # --- Response schema ---------------------------------------------------
114
-
115
- class StopOut(BaseModel):
116
- sequence: int
117
- id: str
118
- arrival_time: str
119
- coords: Coords
120
-
121
-
122
- class LoadPointOut(BaseModel):
123
- after_stop: str
124
- cells: int
125
- kg: int
126
-
127
-
128
- class VanOut(BaseModel):
129
- van_idx: int
130
- driver_id: str
131
- feasible: bool
132
- travel_time_min: float
133
- total_time_h: float
134
- peak_cells: int
135
- peak_kg: int
136
- stops: list[StopOut]
137
- polyline: list[Coords]
138
- load_profile: list[LoadPointOut]
139
- explanations: list[str]
140
-
141
-
142
- class KPIsOut(BaseModel):
143
- fleet_drive_min: float
144
- baseline_drive_min: float
145
- savings_pct: float
146
- fleet_km: float
147
- baseline_km: float
148
- co2_kg_saved: float
149
- driver_utilization_pct: float
150
- capacity_utilization_pct: float
151
- stops_per_van: list[int]
152
- feasible_vans: int
153
- total_vans: int
154
-
155
-
156
- class OptimizeResponse(BaseModel):
157
- request_id: str | None
158
- fleet_drive_min: float
159
- fleet_total_h: float
160
- all_feasible: bool
161
- depot: DepotReq
162
- vans: list[VanOut]
163
- kpis: KPIsOut
164
- warehouse_prep: list[str]
165
- firestore_written: int = 0
166
-
167
-
168
- # --- App ---------------------------------------------------------------
169
-
170
- @asynccontextmanager
171
- async def lifespan(_: FastAPI):
172
- get_or_build_graph()
173
- yield
174
-
175
-
176
- app = FastAPI(title="Damm Smart Truck API", version="2.0.0", lifespan=lifespan)
177
- app.add_middleware(
178
- CORSMiddleware, allow_origins=["*"],
179
- allow_methods=["GET", "POST"], allow_headers=["*"],
180
- )
181
-
182
-
183
- def _hm(s: int) -> str:
184
- h, m = divmod(int(s) // 60, 60)
185
- return f"{h:02d}:{m:02d}"
186
-
187
-
188
- def _run_pipeline(
189
- body: dict,
190
- *,
191
- write_firestore: bool,
192
- traffic_multiplier: float = 1.0,
193
- time_limit_s: int = 5,
194
- ) -> OptimizeResponse:
195
- """Shared core for /optimize and /whatif."""
196
- try:
197
- depot, fleet, drivers, stops = load(body)
198
- except KeyError as e:
199
- raise HTTPException(422, f"unknown reference: {e}")
200
- if not drivers:
201
- raise HTTPException(422, "no drivers in fleet")
202
- if len(drivers) != fleet.num_vans:
203
- raise HTTPException(
204
- 422, f"num_vans={fleet.num_vans} but {len(drivers)} drivers"
205
- )
206
- if not stops:
207
- raise HTTPException(422, "no stops to deliver")
208
-
209
- matrix = build_travel_matrix(depot, stops)
210
- if traffic_multiplier != 1.0:
211
- matrix.time_s = (matrix.time_s * float(traffic_multiplier)).astype(np.float32)
212
-
213
- plan = solve_vrp(depot, fleet, drivers, stops, matrix, time_limit_s=time_limit_s)
214
- if not plan.vans:
215
- # The disruption made the day infeasible — return a structured
216
- # "no plan" response instead of 500 so the UI can show it.
217
- return OptimizeResponse(
218
- request_id=body.get("request_id"),
219
- fleet_drive_min=0.0,
220
- fleet_total_h=0.0,
221
- all_feasible=False,
222
- depot=DepotReq(**body["depot"]),
223
- vans=[],
224
- kpis=KPIsOut(
225
- fleet_drive_min=0.0, baseline_drive_min=0.0, savings_pct=0.0,
226
- fleet_km=0.0, baseline_km=0.0, co2_kg_saved=0.0,
227
- driver_utilization_pct=0.0, capacity_utilization_pct=0.0,
228
- stops_per_van=[], feasible_vans=0, total_vans=fleet.num_vans,
229
- ),
230
- warehouse_prep=[
231
- "INFEASIBLE: no valid plan exists for this scenario. "
232
- "Try relaxing time windows, adding a backup driver, or rescheduling stops."
233
- ],
234
- firestore_written=0,
235
- )
236
-
237
- baseline_plan = solve_baseline(depot, fleet, drivers, stops, matrix)
238
-
239
- stops_by_id = {s.id: s for s in stops}
240
- graph = get_or_build_graph()
241
-
242
- vans_out: list[VanOut] = []
243
- for v in plan.vans:
244
- sequence = ["DEPOT"] + [p.id for p in v.stops] + ["DEPOT"]
245
- polyline = (
246
- [Coords(lat=lat, lng=lng) for lat, lng in route_polyline(graph, matrix, sequence)]
247
- if v.stops else []
248
- )
249
- prof = load_profile(v, stops_by_id)
250
- vans_out.append(VanOut(
251
- van_idx=v.van_idx,
252
- driver_id=v.driver_id,
253
- feasible=v.feasible,
254
- travel_time_min=round(v.travel_s / 60, 2),
255
- total_time_h=round(v.total_s / 3600, 3),
256
- peak_cells=v.peak_cells,
257
- peak_kg=v.peak_kg,
258
- stops=[
259
- StopOut(
260
- sequence=p.sequence, id=p.id,
261
- arrival_time=_hm(p.arrival_s),
262
- coords=Coords(lat=stops_by_id[p.id].lat, lng=stops_by_id[p.id].lng),
263
- )
264
- for p in v.stops
265
- ],
266
- polyline=polyline,
267
- load_profile=[
268
- LoadPointOut(after_stop=lp.after_stop, cells=lp.cells, kg=lp.kg)
269
- for lp in prof
270
- ],
271
- explanations=explain_van(v, stops_by_id, drivers[v.van_idx]),
272
- ))
273
-
274
- kpis = compute_kpis(plan, baseline_plan, fleet, drivers, matrix)
275
- prep = warehouse_prep(plan, body["stops"], stops_by_id)
276
-
277
- written = 0
278
- if write_firestore and fs_enabled():
279
- service_times_s = {s.id: s.service_time_s for s in stops}
280
- docs = [
281
- build_route_doc(
282
- driver_id=v.driver_id,
283
- truck_id=f"T-{v.van_idx + 1:02d}",
284
- van_type=body["fleet"]["van_type"],
285
- depot=body["depot"],
286
- request_stops=body["stops"],
287
- van_plan=v,
288
- service_times_s=service_times_s,
289
- )
290
- for v in plan.vans
291
- ]
292
- written = write_routes(docs)
293
-
294
- return OptimizeResponse(
295
- request_id=body.get("request_id"),
296
- fleet_drive_min=round(plan.drive_s / 60, 2),
297
- fleet_total_h=round(plan.total_s / 3600, 3),
298
- all_feasible=plan.all_feasible,
299
- depot=DepotReq(**body["depot"]),
300
- vans=vans_out,
301
- kpis=KPIsOut(**kpis.__dict__),
302
- warehouse_prep=prep,
303
- firestore_written=written,
304
- )
305
-
306
-
307
- # --- Endpoints ---------------------------------------------------------
308
-
309
- @app.get("/health")
310
- def health():
311
- return {"status": "ok"}
312
-
313
-
314
- @app.get("/sample-request", response_model=OptimizeRequest)
315
- def sample_request():
316
- if not SAMPLE.exists():
317
- raise HTTPException(404, "sample_request.json missing")
318
- return json.loads(SAMPLE.read_text(encoding="utf-8"))
319
-
320
-
321
- @app.get("/products")
322
- def products():
323
- if not PRODUCTS.exists():
324
- raise HTTPException(404, "products.json missing")
325
- return json.loads(PRODUCTS.read_text(encoding="utf-8"))
326
-
327
-
328
- @app.post("/optimize", response_model=OptimizeResponse)
329
- def optimize(req: OptimizeRequest) -> OptimizeResponse:
330
- return _run_pipeline(req.model_dump(), write_firestore=True)
331
-
332
-
333
- @app.post("/whatif", response_model=OptimizeResponse)
334
- def whatif(req: WhatIfRequest) -> OptimizeResponse:
335
- body = req.request.model_dump()
336
- multiplier = 1.0
337
- d = req.disruption
338
-
339
- if d.type == "driver_unavailable":
340
- if not d.driver_id:
341
- raise HTTPException(422, "driver_id required for driver_unavailable")
342
- body["drivers"] = [x for x in body["drivers"] if x["id"] != d.driver_id]
343
- body["fleet"]["num_vans"] = len(body["drivers"])
344
- elif d.type == "stop_cancelled":
345
- if not d.stop_id:
346
- raise HTTPException(422, "stop_id required for stop_cancelled")
347
- body["stops"] = [s for s in body["stops"] if s["id"] != d.stop_id]
348
- elif d.type == "traffic":
349
- multiplier = float(d.multiplier or 1.3)
350
-
351
- # What-ifs are exploratory; don't overwrite the real plan in Firestore.
352
- # Larger time budget — disrupted scenarios are tighter to solve.
353
- return _run_pipeline(
354
- body, write_firestore=False, traffic_multiplier=multiplier, time_limit_s=15,
355
- )
356
-
357
-
358
- @app.post("/baseline", response_model=OptimizeResponse)
359
- def baseline_endpoint(req: OptimizeRequest) -> OptimizeResponse:
360
- """Run only the naive baseline (for explicit comparison demos)."""
361
- body = req.model_dump()
362
- depot, fleet, drivers, stops = load(body)
363
- matrix = build_travel_matrix(depot, stops)
364
- plan = solve_baseline(depot, fleet, drivers, stops, matrix)
365
- stops_by_id = {s.id: s for s in stops}
366
- graph = get_or_build_graph()
367
-
368
- vans_out: list[VanOut] = []
369
- for v in plan.vans:
370
- sequence = ["DEPOT"] + [p.id for p in v.stops] + ["DEPOT"]
371
- polyline = (
372
- [Coords(lat=lat, lng=lng) for lat, lng in route_polyline(graph, matrix, sequence)]
373
- if v.stops else []
374
- )
375
- vans_out.append(VanOut(
376
- van_idx=v.van_idx, driver_id=v.driver_id, feasible=v.feasible,
377
- travel_time_min=round(v.travel_s / 60, 2),
378
- total_time_h=round(v.total_s / 3600, 3),
379
- peak_cells=v.peak_cells, peak_kg=v.peak_kg,
380
- stops=[
381
- StopOut(
382
- sequence=p.sequence, id=p.id,
383
- arrival_time=_hm(p.arrival_s),
384
- coords=Coords(lat=stops_by_id[p.id].lat, lng=stops_by_id[p.id].lng),
385
- )
386
- for p in v.stops
387
- ],
388
- polyline=polyline,
389
- load_profile=[
390
- LoadPointOut(after_stop=lp.after_stop, cells=lp.cells, kg=lp.kg)
391
- for lp in load_profile(v, stops_by_id)
392
- ],
393
- explanations=explain_van(v, stops_by_id, drivers[v.van_idx]),
394
- ))
395
-
396
- return OptimizeResponse(
397
- request_id=req.request_id,
398
- fleet_drive_min=round(plan.drive_s / 60, 2),
399
- fleet_total_h=round(plan.total_s / 3600, 3),
400
- all_feasible=plan.all_feasible,
401
- depot=req.depot,
402
- vans=vans_out,
403
- kpis=KPIsOut(
404
- fleet_drive_min=round(plan.drive_s / 60, 2),
405
- baseline_drive_min=round(plan.drive_s / 60, 2),
406
- savings_pct=0.0,
407
- fleet_km=0.0, baseline_km=0.0, co2_kg_saved=0.0,
408
- driver_utilization_pct=0.0, capacity_utilization_pct=0.0,
409
- stops_per_van=[len(v.stops) for v in plan.vans],
410
- feasible_vans=sum(1 for v in plan.vans if v.feasible),
411
- total_vans=len(plan.vans),
412
- ),
413
- warehouse_prep=warehouse_prep(plan, body["stops"], stops_by_id),
414
- firestore_written=0,
415
- )
416
-
417
- @app.post("/api/chat")
418
- def chat_endpoint(req: ChatRequest):
419
- api_key = os.environ.get("ANTHROPIC_API_KEY", "")
420
- if not api_key:
421
- raise HTTPException(500, "ANTHROPIC_API_KEY not configured")
422
- client = Anthropic(api_key=api_key)
423
- system = (
424
- "You are a hands-free voice assistant for Damm Motion delivery drivers.\n\n"
425
- "Current route status:\n" + req.context + "\n\n"
426
- "Based on what the driver said, decide the best action and give a short spoken reply (max 2 sentences).\n"
427
- "Respond in the same language the driver used. Be concise — this will be read aloud.\n"
428
- "You MUST return ONLY valid JSON matching this schema:\n"
429
- "{\n"
430
- ' "action": "mark_delivered" | "next_stop" | "navigate" | "status" | "unknown",\n'
431
- ' "response": "..."\n'
432
- "}"
433
- )
434
- try:
435
- response = client.messages.create(
436
- model="claude-3-haiku-20240307",
437
- max_tokens=200,
438
- system=system,
439
- messages=[{"role": "user", "content": req.transcript}]
440
- )
441
- raw_text = response.content[0].text.strip()
442
- if raw_text.startswith("```"):
443
- lines = raw_text.split("\n")
444
- if lines[0].startswith("```"): lines = lines[1:]
445
- if lines[-1].startswith("```"): lines = lines[:-1]
446
- raw_text = "\n".join(lines).strip()
447
-
448
- return json.loads(raw_text)
449
- except Exception as e:
450
- raise HTTPException(500, f"Anthropic API error: {e}")
451
-
452
- @app.post("/api/tts")
453
- def tts_endpoint(req: TTSRequest):
454
- api_key = os.environ.get("ELEVENLABS_API_KEY", "")
455
- voice_id = os.environ.get("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
456
- if not api_key:
457
- raise HTTPException(500, "ELEVENLABS_API_KEY not configured")
458
- url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
459
- headers = {
460
- "xi-api-key": api_key,
461
- "Content-Type": "application/json"
462
- }
463
- payload = {
464
- "text": req.text,
465
- "model_id": "eleven_multilingual_v2",
466
- "voice_settings": {"stability": 0.5, "similarity_boost": 0.75}
467
- }
468
- try:
469
- with httpx.Client() as client:
470
- resp = client.post(url, headers=headers, json=payload, timeout=10.0)
471
- resp.raise_for_status()
472
- return Response(content=resp.content, media_type="audio/mpeg")
473
- except Exception as e:
474
- raise HTTPException(500, f"ElevenLabs API error: {e}")
475
-
476
-
477
-
478
- if __name__ == "__main__":
479
- import uvicorn
480
- uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False)
 
1
+ """FastAPI server: OR-tools VRPSPD-TW + analytics layer.
2
+
3
+ GET /health liveness probe
4
+ GET /sample-request bundled example body
5
+ POST /optimize main endpoint, returns plan + polylines + KPIs + explanations
6
+ POST /whatif perturb the request (driver out, traffic, stop cancelled) and re-solve
7
+ POST /baseline naive comparator alone
8
+ GET /docs auto Swagger UI
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from contextlib import asynccontextmanager
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ import os
19
+ import httpx
20
+ from anthropic import Anthropic
21
+ import numpy as np
22
+ from fastapi import FastAPI, HTTPException, Response
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from pydantic import BaseModel
25
+
26
+ from analytics import (
27
+ CO2_KG_PER_KM, KPIs, compute_kpis, explain_van, load_profile, warehouse_prep,
28
+ )
29
+ from baseline import solve_baseline
30
+ from firestore_writer import build_route_doc, is_enabled as fs_enabled, write_routes
31
+ from geometry import route_polyline
32
+ from graph_manager import get_or_build_graph
33
+ from loader import build_travel_matrix, load
34
+ from solver import solve_vrp
35
+
36
+ SAMPLE = Path(__file__).with_name("sample_request.json")
37
+ PRODUCTS = Path(__file__).with_name("products.json")
38
+
39
+
40
+ # --- Request schema (mirrors sample_request.json) ----------------------
41
+
42
+ class Coords(BaseModel):
43
+ lat: float
44
+ lng: float
45
+
46
+
47
+ class TimeWindow(BaseModel):
48
+ open: str
49
+ close: str
50
+
51
+
52
+ class Line(BaseModel):
53
+ product_id: str
54
+ qty: int
55
+
56
+
57
+ class StopReq(BaseModel):
58
+ id: str
59
+ coords: Coords
60
+ time_window: TimeWindow
61
+ deliveries: list[Line]
62
+ pickups: list[Line] = []
63
+
64
+
65
+ class DepotReq(BaseModel):
66
+ id: str
67
+ coords: Coords
68
+ open: str
69
+ close: str
70
+
71
+
72
+ class FleetReq(BaseModel):
73
+ num_vans: int
74
+ van_type: str
75
+ vans_ref: str | None = None
76
+ products_ref: str | None = None
77
+
78
+
79
+ class DriverReq(BaseModel):
80
+ id: str
81
+ shift_start: str
82
+ shift_end: str
83
+
84
+
85
+ class OptimizeRequest(BaseModel):
86
+ request_id: str | None = None
87
+ date: str | None = None
88
+ depot: DepotReq
89
+ fleet: FleetReq
90
+ drivers: list[DriverReq]
91
+ stops: list[StopReq]
92
+
93
+
94
+ class Disruption(BaseModel):
95
+ type: Literal["driver_unavailable", "stop_cancelled", "traffic"]
96
+ driver_id: str | None = None
97
+ stop_id: str | None = None
98
+ multiplier: float | None = None # for "traffic"
99
+
100
+
101
+ class WhatIfRequest(BaseModel):
102
+ request: OptimizeRequest
103
+ disruption: Disruption
104
+
105
+ class ChatRequest(BaseModel):
106
+ transcript: str
107
+ context: str
108
+
109
+ class TTSRequest(BaseModel):
110
+ text: str
111
+
112
+
113
+ # --- Response schema ---------------------------------------------------
114
+
115
+ class StopOut(BaseModel):
116
+ sequence: int
117
+ id: str
118
+ arrival_time: str
119
+ coords: Coords
120
+
121
+
122
+ class LoadPointOut(BaseModel):
123
+ after_stop: str
124
+ cells: int
125
+ kg: int
126
+
127
+
128
+ class VanOut(BaseModel):
129
+ van_idx: int
130
+ driver_id: str
131
+ feasible: bool
132
+ travel_time_min: float
133
+ total_time_h: float
134
+ peak_cells: int
135
+ peak_kg: int
136
+ stops: list[StopOut]
137
+ polyline: list[Coords]
138
+ load_profile: list[LoadPointOut]
139
+ explanations: list[str]
140
+
141
+
142
+ class KPIsOut(BaseModel):
143
+ fleet_drive_min: float
144
+ baseline_drive_min: float
145
+ savings_pct: float
146
+ fleet_km: float
147
+ baseline_km: float
148
+ co2_kg_saved: float
149
+ driver_utilization_pct: float
150
+ capacity_utilization_pct: float
151
+ stops_per_van: list[int]
152
+ feasible_vans: int
153
+ total_vans: int
154
+
155
+
156
+ class OptimizeResponse(BaseModel):
157
+ request_id: str | None
158
+ fleet_drive_min: float
159
+ fleet_total_h: float
160
+ all_feasible: bool
161
+ depot: DepotReq
162
+ vans: list[VanOut]
163
+ kpis: KPIsOut
164
+ warehouse_prep: list[str]
165
+ firestore_written: int = 0
166
+
167
+
168
+ # --- App ---------------------------------------------------------------
169
+
170
+ @asynccontextmanager
171
+ async def lifespan(_: FastAPI):
172
+ get_or_build_graph()
173
+ yield
174
+
175
+
176
+ app = FastAPI(title="Damm Smart Truck API", version="2.0.0", lifespan=lifespan)
177
+ app.add_middleware(
178
+ CORSMiddleware, allow_origins=["*"],
179
+ allow_methods=["GET", "POST"], allow_headers=["*"],
180
+ )
181
+
182
+
183
+ def _hm(s: int) -> str:
184
+ h, m = divmod(int(s) // 60, 60)
185
+ return f"{h:02d}:{m:02d}"
186
+
187
+
188
+ def _run_pipeline(
189
+ body: dict,
190
+ *,
191
+ write_firestore: bool,
192
+ traffic_multiplier: float = 1.0,
193
+ time_limit_s: int = 5,
194
+ ) -> OptimizeResponse:
195
+ """Shared core for /optimize and /whatif."""
196
+ try:
197
+ depot, fleet, drivers, stops = load(body)
198
+ except KeyError as e:
199
+ raise HTTPException(422, f"unknown reference: {e}")
200
+ if not drivers:
201
+ raise HTTPException(422, "no drivers in fleet")
202
+ if len(drivers) != fleet.num_vans:
203
+ raise HTTPException(
204
+ 422, f"num_vans={fleet.num_vans} but {len(drivers)} drivers"
205
+ )
206
+ if not stops:
207
+ raise HTTPException(422, "no stops to deliver")
208
+
209
+ matrix = build_travel_matrix(depot, stops)
210
+ if traffic_multiplier != 1.0:
211
+ matrix.time_s = (matrix.time_s * float(traffic_multiplier)).astype(np.float32)
212
+
213
+ plan = solve_vrp(depot, fleet, drivers, stops, matrix, time_limit_s=time_limit_s)
214
+ if not plan.vans:
215
+ # The disruption made the day infeasible — return a structured
216
+ # "no plan" response instead of 500 so the UI can show it.
217
+ return OptimizeResponse(
218
+ request_id=body.get("request_id"),
219
+ fleet_drive_min=0.0,
220
+ fleet_total_h=0.0,
221
+ all_feasible=False,
222
+ depot=DepotReq(**body["depot"]),
223
+ vans=[],
224
+ kpis=KPIsOut(
225
+ fleet_drive_min=0.0, baseline_drive_min=0.0, savings_pct=0.0,
226
+ fleet_km=0.0, baseline_km=0.0, co2_kg_saved=0.0,
227
+ driver_utilization_pct=0.0, capacity_utilization_pct=0.0,
228
+ stops_per_van=[], feasible_vans=0, total_vans=fleet.num_vans,
229
+ ),
230
+ warehouse_prep=[
231
+ "INFEASIBLE: no valid plan exists for this scenario. "
232
+ "Try relaxing time windows, adding a backup driver, or rescheduling stops."
233
+ ],
234
+ firestore_written=0,
235
+ )
236
+
237
+ baseline_plan = solve_baseline(depot, fleet, drivers, stops, matrix)
238
+
239
+ stops_by_id = {s.id: s for s in stops}
240
+ graph = get_or_build_graph()
241
+
242
+ vans_out: list[VanOut] = []
243
+ for v in plan.vans:
244
+ sequence = ["DEPOT"] + [p.id for p in v.stops] + ["DEPOT"]
245
+ polyline = (
246
+ [Coords(lat=lat, lng=lng) for lat, lng in route_polyline(graph, matrix, sequence)]
247
+ if v.stops else []
248
+ )
249
+ prof = load_profile(v, stops_by_id)
250
+ vans_out.append(VanOut(
251
+ van_idx=v.van_idx,
252
+ driver_id=v.driver_id,
253
+ feasible=v.feasible,
254
+ travel_time_min=round(v.travel_s / 60, 2),
255
+ total_time_h=round(v.total_s / 3600, 3),
256
+ peak_cells=v.peak_cells,
257
+ peak_kg=v.peak_kg,
258
+ stops=[
259
+ StopOut(
260
+ sequence=p.sequence, id=p.id,
261
+ arrival_time=_hm(p.arrival_s),
262
+ coords=Coords(lat=stops_by_id[p.id].lat, lng=stops_by_id[p.id].lng),
263
+ )
264
+ for p in v.stops
265
+ ],
266
+ polyline=polyline,
267
+ load_profile=[
268
+ LoadPointOut(after_stop=lp.after_stop, cells=lp.cells, kg=lp.kg)
269
+ for lp in prof
270
+ ],
271
+ explanations=explain_van(v, stops_by_id, drivers[v.van_idx]),
272
+ ))
273
+
274
+ kpis = compute_kpis(plan, baseline_plan, fleet, drivers, matrix)
275
+ prep = warehouse_prep(plan, body["stops"], stops_by_id)
276
+
277
+ written = 0
278
+ if write_firestore and fs_enabled():
279
+ service_times_s = {s.id: s.service_time_s for s in stops}
280
+ docs = [
281
+ build_route_doc(
282
+ driver_id=v.driver_id,
283
+ truck_id=f"T-{v.van_idx + 1:02d}",
284
+ van_type=body["fleet"]["van_type"],
285
+ depot=body["depot"],
286
+ request_stops=body["stops"],
287
+ van_plan=v,
288
+ service_times_s=service_times_s,
289
+ )
290
+ for v in plan.vans
291
+ ]
292
+ written = write_routes(docs)
293
+
294
+ return OptimizeResponse(
295
+ request_id=body.get("request_id"),
296
+ fleet_drive_min=round(plan.drive_s / 60, 2),
297
+ fleet_total_h=round(plan.total_s / 3600, 3),
298
+ all_feasible=plan.all_feasible,
299
+ depot=DepotReq(**body["depot"]),
300
+ vans=vans_out,
301
+ kpis=KPIsOut(**kpis.__dict__),
302
+ warehouse_prep=prep,
303
+ firestore_written=written,
304
+ )
305
+
306
+
307
+ # --- Endpoints ---------------------------------------------------------
308
+
309
+ @app.get("/health")
310
+ def health():
311
+ return {"status": "ok"}
312
+
313
+
314
+ @app.get("/sample-request", response_model=OptimizeRequest)
315
+ def sample_request():
316
+ if not SAMPLE.exists():
317
+ raise HTTPException(404, "sample_request.json missing")
318
+ return json.loads(SAMPLE.read_text(encoding="utf-8"))
319
+
320
+
321
+ @app.get("/products")
322
+ def products():
323
+ if not PRODUCTS.exists():
324
+ raise HTTPException(404, "products.json missing")
325
+ return json.loads(PRODUCTS.read_text(encoding="utf-8"))
326
+
327
+
328
+ @app.post("/optimize", response_model=OptimizeResponse)
329
+ def optimize(req: OptimizeRequest) -> OptimizeResponse:
330
+ return _run_pipeline(req.model_dump(), write_firestore=True)
331
+
332
+
333
+ @app.post("/whatif", response_model=OptimizeResponse)
334
+ def whatif(req: WhatIfRequest) -> OptimizeResponse:
335
+ body = req.request.model_dump()
336
+ multiplier = 1.0
337
+ d = req.disruption
338
+
339
+ if d.type == "driver_unavailable":
340
+ if not d.driver_id:
341
+ raise HTTPException(422, "driver_id required for driver_unavailable")
342
+ body["drivers"] = [x for x in body["drivers"] if x["id"] != d.driver_id]
343
+ body["fleet"]["num_vans"] = len(body["drivers"])
344
+ elif d.type == "stop_cancelled":
345
+ if not d.stop_id:
346
+ raise HTTPException(422, "stop_id required for stop_cancelled")
347
+ body["stops"] = [s for s in body["stops"] if s["id"] != d.stop_id]
348
+ elif d.type == "traffic":
349
+ multiplier = float(d.multiplier or 1.3)
350
+
351
+ # What-ifs are exploratory; don't overwrite the real plan in Firestore.
352
+ # Larger time budget — disrupted scenarios are tighter to solve.
353
+ return _run_pipeline(
354
+ body, write_firestore=False, traffic_multiplier=multiplier, time_limit_s=15,
355
+ )
356
+
357
+
358
+ @app.post("/baseline", response_model=OptimizeResponse)
359
+ def baseline_endpoint(req: OptimizeRequest) -> OptimizeResponse:
360
+ """Run only the naive baseline (for explicit comparison demos)."""
361
+ body = req.model_dump()
362
+ depot, fleet, drivers, stops = load(body)
363
+ matrix = build_travel_matrix(depot, stops)
364
+ plan = solve_baseline(depot, fleet, drivers, stops, matrix)
365
+ stops_by_id = {s.id: s for s in stops}
366
+ graph = get_or_build_graph()
367
+
368
+ vans_out: list[VanOut] = []
369
+ for v in plan.vans:
370
+ sequence = ["DEPOT"] + [p.id for p in v.stops] + ["DEPOT"]
371
+ polyline = (
372
+ [Coords(lat=lat, lng=lng) for lat, lng in route_polyline(graph, matrix, sequence)]
373
+ if v.stops else []
374
+ )
375
+ vans_out.append(VanOut(
376
+ van_idx=v.van_idx, driver_id=v.driver_id, feasible=v.feasible,
377
+ travel_time_min=round(v.travel_s / 60, 2),
378
+ total_time_h=round(v.total_s / 3600, 3),
379
+ peak_cells=v.peak_cells, peak_kg=v.peak_kg,
380
+ stops=[
381
+ StopOut(
382
+ sequence=p.sequence, id=p.id,
383
+ arrival_time=_hm(p.arrival_s),
384
+ coords=Coords(lat=stops_by_id[p.id].lat, lng=stops_by_id[p.id].lng),
385
+ )
386
+ for p in v.stops
387
+ ],
388
+ polyline=polyline,
389
+ load_profile=[
390
+ LoadPointOut(after_stop=lp.after_stop, cells=lp.cells, kg=lp.kg)
391
+ for lp in load_profile(v, stops_by_id)
392
+ ],
393
+ explanations=explain_van(v, stops_by_id, drivers[v.van_idx]),
394
+ ))
395
+
396
+ return OptimizeResponse(
397
+ request_id=req.request_id,
398
+ fleet_drive_min=round(plan.drive_s / 60, 2),
399
+ fleet_total_h=round(plan.total_s / 3600, 3),
400
+ all_feasible=plan.all_feasible,
401
+ depot=req.depot,
402
+ vans=vans_out,
403
+ kpis=KPIsOut(
404
+ fleet_drive_min=round(plan.drive_s / 60, 2),
405
+ baseline_drive_min=round(plan.drive_s / 60, 2),
406
+ savings_pct=0.0,
407
+ fleet_km=0.0, baseline_km=0.0, co2_kg_saved=0.0,
408
+ driver_utilization_pct=0.0, capacity_utilization_pct=0.0,
409
+ stops_per_van=[len(v.stops) for v in plan.vans],
410
+ feasible_vans=sum(1 for v in plan.vans if v.feasible),
411
+ total_vans=len(plan.vans),
412
+ ),
413
+ warehouse_prep=warehouse_prep(plan, body["stops"], stops_by_id),
414
+ firestore_written=0,
415
+ )
416
+
417
+ @app.post("/api/chat")
418
+ def chat_endpoint(req: ChatRequest):
419
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
420
+ if not api_key:
421
+ raise HTTPException(500, "ANTHROPIC_API_KEY not configured")
422
+ client = Anthropic(api_key=api_key)
423
+ system = (
424
+ "You are a hands-free voice assistant for Damm Motion delivery drivers.\n\n"
425
+ "Current route status:\n" + req.context + "\n\n"
426
+ "Based on what the driver said, decide the best action and give a short spoken reply (max 2 sentences).\n"
427
+ "Respond in the same language the driver used. Be concise — this will be read aloud.\n"
428
+ "You MUST return ONLY valid JSON matching this schema:\n"
429
+ "{\n"
430
+ ' "action": "mark_delivered" | "next_stop" | "navigate" | "status" | "unknown",\n'
431
+ ' "response": "..."\n'
432
+ "}"
433
+ )
434
+ try:
435
+ response = client.messages.create(
436
+ model="claude-3-haiku-20240307",
437
+ max_tokens=200,
438
+ system=system,
439
+ messages=[{"role": "user", "content": req.transcript}]
440
+ )
441
+ raw_text = response.content[0].text.strip()
442
+ if raw_text.startswith("```"):
443
+ lines = raw_text.split("\n")
444
+ if lines[0].startswith("```"): lines = lines[1:]
445
+ if lines[-1].startswith("```"): lines = lines[:-1]
446
+ raw_text = "\n".join(lines).strip()
447
+
448
+ return json.loads(raw_text)
449
+ except Exception as e:
450
+ raise HTTPException(500, f"Anthropic API error: {e}")
451
+
452
+ @app.post("/api/tts")
453
+ def tts_endpoint(req: TTSRequest):
454
+ api_key = os.environ.get("ELEVENLABS_API_KEY", "")
455
+ voice_id = os.environ.get("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
456
+ if not api_key:
457
+ raise HTTPException(500, "ELEVENLABS_API_KEY not configured")
458
+ url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
459
+ headers = {
460
+ "xi-api-key": api_key,
461
+ "Content-Type": "application/json"
462
+ }
463
+ payload = {
464
+ "text": req.text,
465
+ "model_id": "eleven_multilingual_v2",
466
+ "voice_settings": {"stability": 0.5, "similarity_boost": 0.75}
467
+ }
468
+ try:
469
+ with httpx.Client() as client:
470
+ resp = client.post(url, headers=headers, json=payload, timeout=10.0)
471
+ resp.raise_for_status()
472
+ return Response(content=resp.content, media_type="audio/mpeg")
473
+ except Exception as e:
474
+ raise HTTPException(500, f"ElevenLabs API error: {e}")
475
+
476
+
477
+
478
+ if __name__ == "__main__":
479
+ import uvicorn
480
+ uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False)
requirements.txt CHANGED
@@ -12,4 +12,4 @@ pyproj>=3.6
12
  ortools>=9.10
13
  firebase-admin>=6.5
14
  anthropic>=0.40.0
15
- httpx>=0.27.2
 
12
  ortools>=9.10
13
  firebase-admin>=6.5
14
  anthropic>=0.40.0
15
+ httpx>=0.27.2