umr2015 commited on
Commit
018ba57
·
verified ·
1 Parent(s): ee45458

Initial Tiny Dispatch Coach demo

Browse files
Files changed (4) hide show
  1. README.md +28 -5
  2. app.py +573 -0
  3. requirements.txt +3 -0
  4. sample_orders.csv +9 -0
README.md CHANGED
@@ -1,13 +1,36 @@
1
  ---
2
  title: Tiny Dispatch Coach
3
- emoji: 🚀
4
  colorFrom: green
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.15.2
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Tiny Dispatch Coach
3
+ emoji: 🚚
4
  colorFrom: green
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.14.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: Small-model route coach
12
+ tags:
13
+ - gradio
14
+ - hackathon
15
+ - small-models
16
+ - operations-research
17
+ - logistics
18
  ---
19
 
20
+ # Tiny Dispatch Coach
21
+
22
+ Tiny Dispatch Coach is a Backyard AI project for small delivery teams.
23
+
24
+ It converts a daily order sheet and messy dispatcher notes into:
25
+
26
+ - structured delivery constraints,
27
+ - route plans with time-window and capacity checks,
28
+ - before/after metrics against a manual baseline,
29
+ - driver-ready route cards,
30
+ - a simple visual route map.
31
+
32
+ The app is designed for the Build Small Hackathon rule set: Gradio, Hugging Face
33
+ Spaces, and models under 32B parameters. The first public version ships with a
34
+ deterministic offline planner so the demo is usable without cloud APIs. During
35
+ the hack window, the natural-language constraint parser can be swapped to a
36
+ local small model backend such as MiniCPM or Llama via llama.cpp.
app.py ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import io
3
+ import math
4
+ import re
5
+ from dataclasses import dataclass, replace
6
+ from pathlib import Path
7
+ from typing import Dict, Iterable, List, Optional, Tuple
8
+
9
+ import gradio as gr
10
+ import pandas as pd
11
+
12
+
13
+ DEPOT = {
14
+ "customer": "Depot",
15
+ "lat": 40.7280,
16
+ "lng": -73.9980,
17
+ }
18
+
19
+ SAMPLE_PATH = Path(__file__).with_name("sample_orders.csv")
20
+ AVG_SPEED_KMPH = 22.0
21
+ CAPACITY = 18
22
+ START_MINUTE = 8 * 60
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Stop:
27
+ order_id: str
28
+ customer: str
29
+ lat: float
30
+ lng: float
31
+ demand: int
32
+ service_min: int
33
+ ready_time: int
34
+ due_time: int
35
+ priority: str
36
+ notes: str
37
+ manual_sequence: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PlanStop:
42
+ stop: Stop
43
+ arrival: int
44
+ start: int
45
+ depart: int
46
+ distance_km: float
47
+ late_min: int
48
+ wait_min: int
49
+
50
+
51
+ def time_to_min(value: str) -> int:
52
+ value = str(value or "").strip()
53
+ if not value:
54
+ return 17 * 60
55
+ match = re.match(r"^(\d{1,2}):(\d{2})$", value)
56
+ if not match:
57
+ return 17 * 60
58
+ hour, minute = int(match.group(1)), int(match.group(2))
59
+ return max(0, min(23 * 60 + 59, hour * 60 + minute))
60
+
61
+
62
+ def min_to_time(value: int) -> str:
63
+ value = max(0, int(round(value)))
64
+ return f"{value // 60:02d}:{value % 60:02d}"
65
+
66
+
67
+ def haversine_km(a_lat: float, a_lng: float, b_lat: float, b_lng: float) -> float:
68
+ radius = 6371.0
69
+ lat1, lat2 = math.radians(a_lat), math.radians(b_lat)
70
+ d_lat = math.radians(b_lat - a_lat)
71
+ d_lng = math.radians(b_lng - a_lng)
72
+ h = (
73
+ math.sin(d_lat / 2) ** 2
74
+ + math.cos(lat1) * math.cos(lat2) * math.sin(d_lng / 2) ** 2
75
+ )
76
+ return 2 * radius * math.asin(math.sqrt(h))
77
+
78
+
79
+ def travel_minutes(distance_km: float) -> int:
80
+ return int(math.ceil((distance_km / AVG_SPEED_KMPH) * 60))
81
+
82
+
83
+ def parse_orders(file_obj) -> List[Stop]:
84
+ if file_obj is None:
85
+ df = pd.read_csv(SAMPLE_PATH)
86
+ else:
87
+ file_path = file_obj if isinstance(file_obj, str) else file_obj.name
88
+ df = pd.read_csv(file_path)
89
+
90
+ required = {
91
+ "order_id",
92
+ "customer",
93
+ "lat",
94
+ "lng",
95
+ "demand",
96
+ "service_min",
97
+ "ready_time",
98
+ "due_time",
99
+ "priority",
100
+ "notes",
101
+ }
102
+ missing = sorted(required - set(df.columns))
103
+ if missing:
104
+ raise gr.Error(f"CSV is missing required columns: {', '.join(missing)}")
105
+
106
+ if "manual_sequence" not in df.columns:
107
+ df["manual_sequence"] = range(1, len(df) + 1)
108
+
109
+ stops: List[Stop] = []
110
+ for row in df.to_dict("records"):
111
+ stops.append(
112
+ Stop(
113
+ order_id=str(row["order_id"]),
114
+ customer=str(row["customer"]),
115
+ lat=float(row["lat"]),
116
+ lng=float(row["lng"]),
117
+ demand=int(row["demand"]),
118
+ service_min=int(row["service_min"]),
119
+ ready_time=time_to_min(str(row["ready_time"])),
120
+ due_time=time_to_min(str(row["due_time"])),
121
+ priority=str(row["priority"]).lower(),
122
+ notes=str(row.get("notes", "")),
123
+ manual_sequence=int(row.get("manual_sequence", len(stops) + 1)),
124
+ )
125
+ )
126
+ return stops
127
+
128
+
129
+ def parse_dispatch_notes(notes: str) -> Dict[str, object]:
130
+ text = (notes or "").lower()
131
+ constraints: Dict[str, object] = {
132
+ "prefer_early_priority": True,
133
+ "avoid_late_penalty": 2.0,
134
+ "max_route_load": CAPACITY,
135
+ "depot_start": START_MINUTE,
136
+ "boost_terms": [],
137
+ }
138
+
139
+ if "cold" in text or "fresh" in text or "produce" in text:
140
+ constraints["boost_terms"].append("fresh")
141
+ if "medical" in text or "clinic" in text or "medicine" in text:
142
+ constraints["boost_terms"].append("medical")
143
+ if "school" in text:
144
+ constraints["boost_terms"].append("school")
145
+ if "lunch" in text or "noon" in text:
146
+ constraints["soft_due_before"] = 12 * 60
147
+
148
+ hour_match = re.search(r"(?:start|leave|depart)\D{0,12}(\d{1,2})(?::(\d{2}))?", text)
149
+ if hour_match:
150
+ hour = int(hour_match.group(1))
151
+ minute = int(hour_match.group(2) or 0)
152
+ if 1 <= hour <= 23:
153
+ constraints["depot_start"] = hour * 60 + minute
154
+
155
+ capacity_match = re.search(r"(?:capacity|load|max load|van)\D{0,12}(\d{1,3})", text)
156
+ if capacity_match:
157
+ constraints["max_route_load"] = max(1, int(capacity_match.group(1)))
158
+
159
+ return constraints
160
+
161
+
162
+ def priority_weight(stop: Stop, constraints: Dict[str, object]) -> float:
163
+ score = 0.0
164
+ if stop.priority == "high":
165
+ score -= 1.4
166
+ if constraints.get("soft_due_before") and stop.due_time <= int(constraints["soft_due_before"]):
167
+ score -= 0.8
168
+ searchable = f"{stop.customer} {stop.notes}".lower()
169
+ for term in constraints.get("boost_terms", []):
170
+ if term in searchable:
171
+ score -= 1.0
172
+ return score
173
+
174
+
175
+ def nearest_neighbor(stops: List[Stop], constraints: Dict[str, object]) -> List[Stop]:
176
+ remaining = list(stops)
177
+ planned: List[Stop] = []
178
+ cur_lat, cur_lng = DEPOT["lat"], DEPOT["lng"]
179
+ current_time = int(constraints["depot_start"])
180
+ current_load = 0
181
+ route_capacity = int(constraints["max_route_load"])
182
+
183
+ while remaining:
184
+ best: Optional[Tuple[float, Stop]] = None
185
+ for stop in remaining:
186
+ distance = haversine_km(cur_lat, cur_lng, stop.lat, stop.lng)
187
+ eta = current_time + travel_minutes(distance)
188
+ late = max(0, eta - stop.due_time)
189
+ capacity_pressure = 999 if current_load + stop.demand > route_capacity else 0
190
+ wait = max(0, stop.ready_time - eta)
191
+ score = (
192
+ distance
193
+ + late * 0.12 * float(constraints["avoid_late_penalty"])
194
+ + wait * 0.01
195
+ + priority_weight(stop, constraints)
196
+ + capacity_pressure
197
+ )
198
+ if best is None or score < best[0]:
199
+ best = (score, stop)
200
+
201
+ chosen = best[1]
202
+ distance = haversine_km(cur_lat, cur_lng, chosen.lat, chosen.lng)
203
+ arrival = current_time + travel_minutes(distance)
204
+ current_time = max(arrival, chosen.ready_time) + chosen.service_min
205
+ current_load += chosen.demand
206
+ planned.append(chosen)
207
+ remaining.remove(chosen)
208
+ cur_lat, cur_lng = chosen.lat, chosen.lng
209
+
210
+ return planned
211
+
212
+
213
+ def two_opt(route: List[Stop]) -> List[Stop]:
214
+ if len(route) < 4:
215
+ return route
216
+ improved = True
217
+ best = route[:]
218
+ while improved:
219
+ improved = False
220
+ for i in range(1, len(best) - 2):
221
+ for j in range(i + 1, len(best)):
222
+ if j - i == 1:
223
+ continue
224
+ candidate = best[:]
225
+ candidate[i:j] = reversed(best[i:j])
226
+ if route_distance(candidate) + 1e-9 < route_distance(best):
227
+ best = candidate
228
+ improved = True
229
+ route = best
230
+ return best
231
+
232
+
233
+ def route_distance(route: Iterable[Stop]) -> float:
234
+ cur_lat, cur_lng = DEPOT["lat"], DEPOT["lng"]
235
+ total = 0.0
236
+ last_lat, last_lng = cur_lat, cur_lng
237
+ for stop in route:
238
+ total += haversine_km(last_lat, last_lng, stop.lat, stop.lng)
239
+ last_lat, last_lng = stop.lat, stop.lng
240
+ total += haversine_km(last_lat, last_lng, cur_lat, cur_lng)
241
+ return total
242
+
243
+
244
+ def simulate(route: List[Stop], start_minute: int) -> Tuple[List[PlanStop], Dict[str, float]]:
245
+ cur_lat, cur_lng = DEPOT["lat"], DEPOT["lng"]
246
+ current = start_minute
247
+ plan: List[PlanStop] = []
248
+ load = 0
249
+ total_distance = 0.0
250
+ total_late = 0
251
+ total_wait = 0
252
+
253
+ for stop in route:
254
+ distance = haversine_km(cur_lat, cur_lng, stop.lat, stop.lng)
255
+ arrival = current + travel_minutes(distance)
256
+ start = max(arrival, stop.ready_time)
257
+ wait = max(0, start - arrival)
258
+ late = max(0, start - stop.due_time)
259
+ depart = start + stop.service_min
260
+ plan.append(
261
+ PlanStop(
262
+ stop=stop,
263
+ arrival=arrival,
264
+ start=start,
265
+ depart=depart,
266
+ distance_km=distance,
267
+ late_min=late,
268
+ wait_min=wait,
269
+ )
270
+ )
271
+ total_distance += distance
272
+ total_late += late
273
+ total_wait += wait
274
+ load += stop.demand
275
+ current = depart
276
+ cur_lat, cur_lng = stop.lat, stop.lng
277
+
278
+ back = haversine_km(cur_lat, cur_lng, DEPOT["lat"], DEPOT["lng"])
279
+ total_distance += back
280
+ finish = current + travel_minutes(back)
281
+ metrics = {
282
+ "distance_km": total_distance,
283
+ "late_min": total_late,
284
+ "wait_min": total_wait,
285
+ "finish_min": finish,
286
+ "load": load,
287
+ "on_time_rate": 100.0 * (1 - sum(1 for p in plan if p.late_min > 0) / max(1, len(plan))),
288
+ }
289
+ return plan, metrics
290
+
291
+
292
+ def manual_route(stops: List[Stop]) -> List[Stop]:
293
+ return sorted(stops, key=lambda stop: stop.manual_sequence)
294
+
295
+
296
+ def route_table(plan: List[PlanStop]) -> pd.DataFrame:
297
+ return pd.DataFrame(
298
+ [
299
+ {
300
+ "#": idx + 1,
301
+ "Order": item.stop.order_id,
302
+ "Customer": item.stop.customer,
303
+ "Arrive": min_to_time(item.arrival),
304
+ "Start": min_to_time(item.start),
305
+ "Depart": min_to_time(item.depart),
306
+ "Window": f"{min_to_time(item.stop.ready_time)}-{min_to_time(item.stop.due_time)}",
307
+ "Demand": item.stop.demand,
308
+ "Late min": item.late_min,
309
+ "Notes": item.stop.notes,
310
+ }
311
+ for idx, item in enumerate(plan)
312
+ ]
313
+ )
314
+
315
+
316
+ def metrics_markdown(auto_metrics: Dict[str, float], manual_metrics: Dict[str, float]) -> str:
317
+ distance_delta = manual_metrics["distance_km"] - auto_metrics["distance_km"]
318
+ late_delta = manual_metrics["late_min"] - auto_metrics["late_min"]
319
+ return f"""
320
+ ### Dispatch Score
321
+
322
+ | Metric | Manual baseline | Tiny Dispatch Coach | Change |
323
+ |---|---:|---:|---:|
324
+ | Distance | {manual_metrics['distance_km']:.1f} km | {auto_metrics['distance_km']:.1f} km | {distance_delta:+.1f} km |
325
+ | Late minutes | {manual_metrics['late_min']:.0f} | {auto_metrics['late_min']:.0f} | {late_delta:+.0f} |
326
+ | Waiting minutes | {manual_metrics['wait_min']:.0f} | {auto_metrics['wait_min']:.0f} | {manual_metrics['wait_min'] - auto_metrics['wait_min']:+.0f} |
327
+ | Finish time | {min_to_time(manual_metrics['finish_min'])} | {min_to_time(auto_metrics['finish_min'])} | |
328
+ | On-time rate | {manual_metrics['on_time_rate']:.0f}% | {auto_metrics['on_time_rate']:.0f}% | {auto_metrics['on_time_rate'] - manual_metrics['on_time_rate']:+.0f} pts |
329
+
330
+ **Coach note:** This route prioritizes high-risk time windows first, then uses a nearest-neighbor pass with a 2-opt cleanup. It is intentionally transparent so a dispatcher can override it.
331
+ """
332
+
333
+
334
+ def constraints_markdown(constraints: Dict[str, object]) -> str:
335
+ rows = "\n".join(f"- **{key}**: `{value}`" for key, value in constraints.items())
336
+ return f"### Parsed Dispatcher Notes\n{rows}"
337
+
338
+
339
+ def route_cards(plan: List[PlanStop]) -> str:
340
+ cards = []
341
+ for idx, item in enumerate(plan, start=1):
342
+ status = "late" if item.late_min else "on time"
343
+ cards.append(
344
+ f"""
345
+ <div class="route-card">
346
+ <div class="route-card-top">
347
+ <span class="route-index">{idx}</span>
348
+ <span class="route-title">{item.stop.customer}</span>
349
+ <span class="route-status {status.replace(' ', '-')}">{status}</span>
350
+ </div>
351
+ <div class="route-meta">{item.stop.order_id} · arrive {min_to_time(item.arrival)} · depart {min_to_time(item.depart)} · load {item.stop.demand}</div>
352
+ <div class="route-note">{item.stop.notes}</div>
353
+ </div>
354
+ """
355
+ )
356
+ return "<div class='route-cards'>" + "\n".join(cards) + "</div>"
357
+
358
+
359
+ def route_map(plan: List[PlanStop]) -> str:
360
+ points = [(DEPOT["lat"], DEPOT["lng"], "Depot")]
361
+ points.extend((item.stop.lat, item.stop.lng, item.stop.customer) for item in plan)
362
+ lat_values = [p[0] for p in points]
363
+ lng_values = [p[1] for p in points]
364
+ min_lat, max_lat = min(lat_values), max(lat_values)
365
+ min_lng, max_lng = min(lng_values), max(lng_values)
366
+ pad_lat = max(0.002, (max_lat - min_lat) * 0.12)
367
+ pad_lng = max(0.002, (max_lng - min_lng) * 0.12)
368
+ min_lat -= pad_lat
369
+ max_lat += pad_lat
370
+ min_lng -= pad_lng
371
+ max_lng += pad_lng
372
+
373
+ def xy(lat: float, lng: float) -> Tuple[float, float]:
374
+ x = 40 + (lng - min_lng) / (max_lng - min_lng) * 820
375
+ y = 520 - (lat - min_lat) / (max_lat - min_lat) * 460
376
+ return x, y
377
+
378
+ coords = [xy(lat, lng) for lat, lng, _ in points]
379
+ path = " ".join(f"{x:.1f},{y:.1f}" for x, y in coords + [coords[0]])
380
+ marker_html = []
381
+ for idx, ((lat, lng, label), (x, y)) in enumerate(zip(points, coords)):
382
+ is_depot = idx == 0
383
+ fill = "#0f766e" if is_depot else "#f59e0b"
384
+ text = "D" if is_depot else str(idx)
385
+ marker_html.append(
386
+ f"""
387
+ <g>
388
+ <circle cx="{x:.1f}" cy="{y:.1f}" r="15" fill="{fill}" stroke="#fff" stroke-width="3" />
389
+ <text x="{x:.1f}" y="{y + 5:.1f}" text-anchor="middle" font-size="13" font-weight="700" fill="#fff">{text}</text>
390
+ <text x="{x + 20:.1f}" y="{y - 10:.1f}" font-size="12" fill="#1f2937">{label}</text>
391
+ </g>
392
+ """
393
+ )
394
+ return f"""
395
+ <div class="map-wrap">
396
+ <svg viewBox="0 0 900 560" role="img" aria-label="Route map">
397
+ <rect x="0" y="0" width="900" height="560" rx="8" fill="#f8fafc" />
398
+ <path d="M {path}" fill="none" stroke="#2563eb" stroke-width="4" stroke-linejoin="round" stroke-linecap="round" opacity="0.78" />
399
+ {''.join(marker_html)}
400
+ </svg>
401
+ </div>
402
+ """
403
+
404
+
405
+ def analyze(file_obj, notes: str):
406
+ stops = parse_orders(file_obj)
407
+ constraints = parse_dispatch_notes(notes)
408
+ auto_route = two_opt(nearest_neighbor(stops, constraints))
409
+ manual = manual_route(stops)
410
+ auto_plan, auto_metrics = simulate(auto_route, int(constraints["depot_start"]))
411
+ manual_plan, manual_metrics = simulate(manual, int(constraints["depot_start"]))
412
+
413
+ return (
414
+ metrics_markdown(auto_metrics, manual_metrics),
415
+ constraints_markdown(constraints),
416
+ route_table(auto_plan),
417
+ route_cards(auto_plan),
418
+ route_map(auto_plan),
419
+ )
420
+
421
+
422
+ CUSTOM_CSS = """
423
+ .gradio-container {
424
+ --radius-lg: 8px;
425
+ }
426
+ .hero {
427
+ min-height: 260px;
428
+ border-radius: 8px;
429
+ padding: 36px;
430
+ background:
431
+ linear-gradient(rgba(9, 47, 44, .72), rgba(9, 47, 44, .62)),
432
+ url('https://images.unsplash.com/photo-1601584115197-04ecc0da31d7?auto=format&fit=crop&w=1600&q=80');
433
+ background-size: cover;
434
+ background-position: center;
435
+ color: white;
436
+ display: flex;
437
+ flex-direction: column;
438
+ justify-content: end;
439
+ }
440
+ .hero h1 {
441
+ font-size: 42px;
442
+ line-height: 1.05;
443
+ margin: 0 0 10px 0;
444
+ letter-spacing: 0;
445
+ }
446
+ .hero p {
447
+ max-width: 760px;
448
+ font-size: 16px;
449
+ margin: 0;
450
+ }
451
+ .route-cards {
452
+ display: grid;
453
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
454
+ gap: 10px;
455
+ }
456
+ .route-card {
457
+ border: 1px solid #d6d3d1;
458
+ border-radius: 8px;
459
+ padding: 12px;
460
+ background: #fff;
461
+ }
462
+ .route-card-top {
463
+ display: flex;
464
+ align-items: center;
465
+ gap: 8px;
466
+ }
467
+ .route-index {
468
+ display: inline-grid;
469
+ place-items: center;
470
+ width: 26px;
471
+ height: 26px;
472
+ border-radius: 50%;
473
+ background: #0f766e;
474
+ color: white;
475
+ font-weight: 700;
476
+ }
477
+ .route-title {
478
+ font-weight: 700;
479
+ flex: 1;
480
+ }
481
+ .route-status {
482
+ border-radius: 999px;
483
+ padding: 3px 8px;
484
+ font-size: 12px;
485
+ background: #dcfce7;
486
+ color: #166534;
487
+ }
488
+ .route-status.late {
489
+ background: #fee2e2;
490
+ color: #991b1b;
491
+ }
492
+ .route-meta {
493
+ color: #57534e;
494
+ font-size: 13px;
495
+ margin-top: 8px;
496
+ }
497
+ .route-note {
498
+ color: #292524;
499
+ font-size: 14px;
500
+ margin-top: 6px;
501
+ }
502
+ .map-wrap {
503
+ border: 1px solid #d6d3d1;
504
+ border-radius: 8px;
505
+ overflow: hidden;
506
+ background: white;
507
+ }
508
+ """
509
+
510
+
511
+ DEFAULT_NOTES = (
512
+ "Start at 8:00. School and clinic stops are urgent. Fresh produce should be "
513
+ "delivered before lunch. Van capacity 18."
514
+ )
515
+
516
+
517
+ with gr.Blocks(
518
+ title="Tiny Dispatch Coach",
519
+ css=CUSTOM_CSS,
520
+ theme=gr.themes.Soft(primary_hue="emerald", secondary_hue="amber", neutral_hue="stone"),
521
+ ) as demo:
522
+ gr.HTML(
523
+ """
524
+ <section class="hero">
525
+ <h1>Tiny Dispatch Coach</h1>
526
+ <p>Turn a small delivery sheet and messy dispatcher notes into a route plan, tradeoff explanation, and driver-ready cards. Built for small models, Gradio, and real neighborhood logistics.</p>
527
+ </section>
528
+ """
529
+ )
530
+
531
+ with gr.Row():
532
+ with gr.Column(scale=2):
533
+ order_file = gr.File(
534
+ label="Orders CSV",
535
+ file_types=[".csv"],
536
+ type="filepath",
537
+ )
538
+ notes = gr.Textbox(
539
+ label="Dispatcher notes",
540
+ value=DEFAULT_NOTES,
541
+ lines=5,
542
+ )
543
+ run = gr.Button("Plan route", variant="primary")
544
+ with gr.Column(scale=1):
545
+ gr.Markdown(
546
+ """
547
+ ### CSV columns
548
+ `order_id`, `customer`, `lat`, `lng`, `demand`, `service_min`, `ready_time`, `due_time`, `priority`, `notes`, optional `manual_sequence`.
549
+
550
+ Leave the file empty to run the included sample route.
551
+ """
552
+ )
553
+
554
+ metrics = gr.Markdown()
555
+ constraints = gr.Markdown()
556
+ table = gr.Dataframe(label="Optimized route", interactive=False)
557
+ cards = gr.HTML(label="Driver cards")
558
+ map_html = gr.HTML(label="Route map")
559
+
560
+ run.click(
561
+ analyze,
562
+ inputs=[order_file, notes],
563
+ outputs=[metrics, constraints, table, cards, map_html],
564
+ )
565
+ demo.load(
566
+ analyze,
567
+ inputs=[order_file, notes],
568
+ outputs=[metrics, constraints, table, cards, map_html],
569
+ )
570
+
571
+
572
+ if __name__ == "__main__":
573
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=6.14.0
2
+ pandas>=2.2.0
3
+
sample_orders.csv ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ order_id,customer,lat,lng,demand,service_min,ready_time,due_time,priority,notes,manual_sequence
2
+ SYN-1001,Synthetic Stop A,40.7241,-73.9962,7,8,09:00,11:00,high,Perishable demo order before lunch,1
3
+ SYN-1002,Synthetic Stop B,40.7316,-73.9893,5,6,09:30,12:00,normal,Demo back-door delivery note,2
4
+ SYN-1003,Synthetic Stop C,40.7362,-74.0027,4,10,08:30,10:30,high,Time-critical demo parcel,4
5
+ SYN-1004,Synthetic Stop D,40.7194,-74.0060,3,5,10:00,14:00,normal,Call-on-arrival demo note,3
6
+ SYN-1005,Synthetic Stop E,40.7438,-73.9901,8,8,11:00,15:30,normal,Two demo cartons,5
7
+ SYN-1006,Synthetic Stop F,40.7282,-74.0111,2,5,13:00,16:00,low,Flexible demo stop,6
8
+ SYN-1007,Synthetic Stop G,40.7218,-73.9857,6,7,08:00,09:45,high,Early-window demo stop,7
9
+ SYN-1008,Synthetic Stop H,40.7335,-74.0082,4,5,12:00,17:00,normal,Demo front-desk note,8