Add MiniCPM5 local parser and multi-trip planner
Browse files- FIELD_NOTES.md +52 -0
- README.md +39 -6
- agent_trace.json +32 -0
- app.py +273 -82
- requirements.txt +2 -1
FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Notes: Tiny Dispatch Coach
|
| 2 |
+
|
| 3 |
+
## What changed after the first prototype
|
| 4 |
+
|
| 5 |
+
The first version was a normal route optimizer wrapped in Gradio. That was not
|
| 6 |
+
enough for Build Small. The useful part of the product is not only route math;
|
| 7 |
+
it is turning messy human dispatch notes into constraints that a planner can
|
| 8 |
+
actually verify.
|
| 9 |
+
|
| 10 |
+
The current design uses OpenBMB MiniCPM5-1B-GGUF as the small local model for
|
| 11 |
+
constraint parsing. The deterministic optimizer then plans routes with capacity,
|
| 12 |
+
time windows, waiting time, lateness, and a manual baseline comparison.
|
| 13 |
+
|
| 14 |
+
## Why MiniCPM5-1B
|
| 15 |
+
|
| 16 |
+
- It is an OpenBMB model, matching the hackathon sponsor category.
|
| 17 |
+
- It is 1.08B parameters, far below the 32B rule.
|
| 18 |
+
- The GGUF release can run locally through llama.cpp.
|
| 19 |
+
- Its model card highlights local deployment, tool use, long context, and
|
| 20 |
+
compact agent workflows, which fit this route-coaching task.
|
| 21 |
+
|
| 22 |
+
## What the model does
|
| 23 |
+
|
| 24 |
+
MiniCPM5 receives dispatcher notes such as:
|
| 25 |
+
|
| 26 |
+
```text
|
| 27 |
+
Start at 8:00. School and clinic stops are urgent. Fresh produce should be
|
| 28 |
+
delivered before lunch. Van capacity 18.
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
It returns a compact JSON constraint object:
|
| 32 |
+
|
| 33 |
+
```json
|
| 34 |
+
{
|
| 35 |
+
"prefer_early_priority": true,
|
| 36 |
+
"avoid_late_penalty": 2.0,
|
| 37 |
+
"max_route_load": 18,
|
| 38 |
+
"depot_start": 480,
|
| 39 |
+
"soft_due_before": 720,
|
| 40 |
+
"boost_terms": ["school", "fresh"]
|
| 41 |
+
}
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
The planner treats those constraints as inputs. It does not let the language
|
| 45 |
+
model invent routes or metrics.
|
| 46 |
+
|
| 47 |
+
## Privacy stance
|
| 48 |
+
|
| 49 |
+
The demo data is synthetic. The app stores nothing, uses no cloud LLM API, and
|
| 50 |
+
does not require user secrets. Uploaded CSVs are processed only during the
|
| 51 |
+
Gradio session.
|
| 52 |
+
|
README.md
CHANGED
|
@@ -13,24 +13,57 @@ 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.
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
- gradio
|
| 14 |
- hackathon
|
| 15 |
- small-models
|
| 16 |
+
- minicpm
|
| 17 |
+
- openbmb
|
| 18 |
- operations-research
|
| 19 |
- logistics
|
| 20 |
---
|
| 21 |
|
| 22 |
# Tiny Dispatch Coach
|
| 23 |
|
| 24 |
+
Tiny Dispatch Coach is a Backyard AI project for small delivery teams. It uses
|
| 25 |
+
OpenBMB `MiniCPM5-1B-GGUF` as the local small model for dispatch-note parsing,
|
| 26 |
+
then hands the structured constraints to a deterministic route planner.
|
| 27 |
|
| 28 |
It converts a daily order sheet and messy dispatcher notes into:
|
| 29 |
|
| 30 |
+
- structured delivery constraints parsed from messy human notes,
|
| 31 |
- route plans with time-window and capacity checks,
|
| 32 |
- before/after metrics against a manual baseline,
|
| 33 |
- driver-ready route cards,
|
| 34 |
- a simple visual route map.
|
| 35 |
|
| 36 |
The app is designed for the Build Small Hackathon rule set: Gradio, Hugging Face
|
| 37 |
+
Spaces, and models under 32B parameters.
|
| 38 |
+
|
| 39 |
+
## Model
|
| 40 |
+
|
| 41 |
+
- Model repo: `openbmb/MiniCPM5-1B-GGUF`
|
| 42 |
+
- File: `MiniCPM5-1B-Q4_K_M.gguf`
|
| 43 |
+
- Parameter count: `1.08B`
|
| 44 |
+
- Runtime target: local GGUF through `llama-cpp-python`
|
| 45 |
+
- Cloud LLM APIs: none
|
| 46 |
+
|
| 47 |
+
If the local model runtime is unavailable during a cold start, the app falls
|
| 48 |
+
back to a deterministic parser and makes that visible in the parser trace. The
|
| 49 |
+
route optimizer never depends on hidden model output: every route, time window,
|
| 50 |
+
lateness minute, and baseline delta is computed deterministically.
|
| 51 |
+
|
| 52 |
+
## Current Scope
|
| 53 |
+
|
| 54 |
+
Included now:
|
| 55 |
+
|
| 56 |
+
- MiniCPM5 text constraint parsing.
|
| 57 |
+
- Capacity-safe multi-trip route planning.
|
| 58 |
+
- Manual baseline comparison.
|
| 59 |
+
- Synthetic sample data only.
|
| 60 |
+
- Field notes and a shareable model/planner trace.
|
| 61 |
+
|
| 62 |
+
Planned after the core demo is stable:
|
| 63 |
+
|
| 64 |
+
- Optional image intake with MiniCPM-V 4.6 for order-sheet OCR.
|
| 65 |
+
- Optional deeper reporting with MiniCPM4.1-8B if Space resources allow it.
|
| 66 |
+
|
| 67 |
+
Not included:
|
| 68 |
+
|
| 69 |
+
- VoxCPM2 or voice/TTS features.
|
agent_trace.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "Tiny Dispatch Coach",
|
| 3 |
+
"model": {
|
| 4 |
+
"repo": "openbmb/MiniCPM5-1B-GGUF",
|
| 5 |
+
"file": "MiniCPM5-1B-Q4_K_M.gguf",
|
| 6 |
+
"parameters": "1.08B",
|
| 7 |
+
"runtime": "llama.cpp via llama-cpp-python"
|
| 8 |
+
},
|
| 9 |
+
"trace": [
|
| 10 |
+
{
|
| 11 |
+
"step": "parse_dispatch_notes",
|
| 12 |
+
"input": "Start at 8:00. School and clinic stops are urgent. Fresh produce should be delivered before lunch. Van capacity 18.",
|
| 13 |
+
"expected_json": {
|
| 14 |
+
"prefer_early_priority": true,
|
| 15 |
+
"avoid_late_penalty": 2.0,
|
| 16 |
+
"max_route_load": 18,
|
| 17 |
+
"depot_start": 480,
|
| 18 |
+
"soft_due_before": 720,
|
| 19 |
+
"boost_terms": ["school", "fresh"]
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"step": "deterministic_planner",
|
| 24 |
+
"policy": "Minimize late minutes first, then late stops, then distance. Split into capacity-safe trips when needed."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"step": "explain_results",
|
| 28 |
+
"policy": "Show route cards, time windows, waiting time, lateness, and manual-baseline deltas."
|
| 29 |
+
}
|
| 30 |
+
],
|
| 31 |
+
"privacy": "Synthetic sample data only. No API keys, personal email, customer records, or company data are stored in this artifact."
|
| 32 |
+
}
|
app.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
-
import
|
| 2 |
-
import io
|
| 3 |
import math
|
|
|
|
| 4 |
import re
|
| 5 |
-
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Dict, Iterable, List, Optional, Tuple
|
| 8 |
|
|
@@ -20,6 +23,9 @@ 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)
|
|
@@ -40,6 +46,7 @@ class Stop:
|
|
| 40 |
@dataclass(frozen=True)
|
| 41 |
class PlanStop:
|
| 42 |
stop: Stop
|
|
|
|
| 43 |
arrival: int
|
| 44 |
start: int
|
| 45 |
depart: int
|
|
@@ -159,6 +166,96 @@ def parse_dispatch_notes(notes: str) -> Dict[str, object]:
|
|
| 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":
|
|
@@ -172,62 +269,65 @@ def priority_weight(stop: Stop, constraints: Dict[str, object]) -> float:
|
|
| 172 |
return score
|
| 173 |
|
| 174 |
|
| 175 |
-
def
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
|
| 233 |
def route_distance(route: Iterable[Stop]) -> float:
|
|
@@ -241,7 +341,7 @@ def route_distance(route: Iterable[Stop]) -> float:
|
|
| 241 |
return total
|
| 242 |
|
| 243 |
|
| 244 |
-
def
|
| 245 |
cur_lat, cur_lng = DEPOT["lat"], DEPOT["lng"]
|
| 246 |
current = start_minute
|
| 247 |
plan: List[PlanStop] = []
|
|
@@ -260,6 +360,7 @@ def simulate(route: List[Stop], start_minute: int) -> Tuple[List[PlanStop], Dict
|
|
| 260 |
plan.append(
|
| 261 |
PlanStop(
|
| 262 |
stop=stop,
|
|
|
|
| 263 |
arrival=arrival,
|
| 264 |
start=start,
|
| 265 |
depart=depart,
|
|
@@ -289,6 +390,33 @@ def simulate(route: List[Stop], start_minute: int) -> Tuple[List[PlanStop], Dict
|
|
| 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 |
|
|
@@ -297,7 +425,8 @@ def route_table(plan: List[PlanStop]) -> pd.DataFrame:
|
|
| 297 |
return pd.DataFrame(
|
| 298 |
[
|
| 299 |
{
|
| 300 |
-
"
|
|
|
|
| 301 |
"Order": item.stop.order_id,
|
| 302 |
"Customer": item.stop.customer,
|
| 303 |
"Arrive": min_to_time(item.arrival),
|
|
@@ -321,19 +450,36 @@ def metrics_markdown(auto_metrics: Dict[str, float], manual_metrics: Dict[str, f
|
|
| 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:**
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
|
| 339 |
def route_cards(plan: List[PlanStop]) -> str:
|
|
@@ -344,12 +490,12 @@ def route_cards(plan: List[PlanStop]) -> str:
|
|
| 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 |
)
|
|
@@ -376,7 +522,15 @@ def route_map(plan: List[PlanStop]) -> str:
|
|
| 376 |
return x, y
|
| 377 |
|
| 378 |
coords = [xy(lat, lng) for lat, lng, _ in points]
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
marker_html = []
|
| 381 |
for idx, ((lat, lng, label), (x, y)) in enumerate(zip(points, coords)):
|
| 382 |
is_depot = idx == 0
|
|
@@ -395,7 +549,7 @@ def route_map(plan: List[PlanStop]) -> str:
|
|
| 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.
|
| 399 |
{''.join(marker_html)}
|
| 400 |
</svg>
|
| 401 |
</div>
|
|
@@ -404,15 +558,15 @@ def route_map(plan: List[PlanStop]) -> str:
|
|
| 404 |
|
| 405 |
def analyze(file_obj, notes: str):
|
| 406 |
stops = parse_orders(file_obj)
|
| 407 |
-
constraints =
|
| 408 |
-
|
| 409 |
manual = manual_route(stops)
|
| 410 |
-
auto_plan, auto_metrics =
|
| 411 |
-
manual_plan, manual_metrics =
|
| 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),
|
|
@@ -448,6 +602,29 @@ CUSTOM_CSS = """
|
|
| 448 |
font-size: 16px;
|
| 449 |
margin: 0;
|
| 450 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
.route-cards {
|
| 452 |
display: grid;
|
| 453 |
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
@@ -523,7 +700,14 @@ with gr.Blocks(
|
|
| 523 |
"""
|
| 524 |
<section class="hero">
|
| 525 |
<h1>Tiny Dispatch Coach</h1>
|
| 526 |
-
<p>Turn a small delivery sheet and messy dispatcher notes into
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
</section>
|
| 528 |
"""
|
| 529 |
)
|
|
@@ -542,6 +726,16 @@ with gr.Blocks(
|
|
| 542 |
)
|
| 543 |
run = gr.Button("Plan route", variant="primary")
|
| 544 |
with gr.Column(scale=1):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
gr.Markdown(
|
| 546 |
"""
|
| 547 |
### CSV columns
|
|
@@ -552,7 +746,9 @@ Leave the file empty to run the included sample route.
|
|
| 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")
|
|
@@ -562,11 +758,6 @@ Leave the file empty to run the included sample route.
|
|
| 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__":
|
|
|
|
| 1 |
+
import json
|
|
|
|
| 2 |
import math
|
| 3 |
+
import os
|
| 4 |
import re
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from html import escape
|
| 8 |
+
from itertools import permutations
|
| 9 |
from pathlib import Path
|
| 10 |
from typing import Dict, Iterable, List, Optional, Tuple
|
| 11 |
|
|
|
|
| 23 |
AVG_SPEED_KMPH = 22.0
|
| 24 |
CAPACITY = 18
|
| 25 |
START_MINUTE = 8 * 60
|
| 26 |
+
MINICPM_REPO = "openbmb/MiniCPM5-1B-GGUF"
|
| 27 |
+
MINICPM_FILE = "MiniCPM5-1B-Q4_K_M.gguf"
|
| 28 |
+
MINICPM_PARAMS = "1.08B"
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass(frozen=True)
|
|
|
|
| 46 |
@dataclass(frozen=True)
|
| 47 |
class PlanStop:
|
| 48 |
stop: Stop
|
| 49 |
+
route_id: int
|
| 50 |
arrival: int
|
| 51 |
start: int
|
| 52 |
depart: int
|
|
|
|
| 166 |
return constraints
|
| 167 |
|
| 168 |
|
| 169 |
+
def normalize_constraints(raw: Dict[str, object]) -> Dict[str, object]:
|
| 170 |
+
constraints = {
|
| 171 |
+
"prefer_early_priority": bool(raw.get("prefer_early_priority", True)),
|
| 172 |
+
"avoid_late_penalty": float(raw.get("avoid_late_penalty", 2.0) or 2.0),
|
| 173 |
+
"max_route_load": int(raw.get("max_route_load", CAPACITY) or CAPACITY),
|
| 174 |
+
"depot_start": int(raw.get("depot_start", START_MINUTE) or START_MINUTE),
|
| 175 |
+
"boost_terms": list(raw.get("boost_terms", []) or []),
|
| 176 |
+
"source": raw.get("source", "rule-fallback"),
|
| 177 |
+
}
|
| 178 |
+
if raw.get("soft_due_before") is not None:
|
| 179 |
+
constraints["soft_due_before"] = int(raw["soft_due_before"])
|
| 180 |
+
constraints["max_route_load"] = max(1, min(200, constraints["max_route_load"]))
|
| 181 |
+
constraints["avoid_late_penalty"] = max(0.5, min(10.0, constraints["avoid_late_penalty"]))
|
| 182 |
+
return constraints
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def extract_json_object(text: str) -> Optional[Dict[str, object]]:
|
| 186 |
+
match = re.search(r"\{.*\}", text or "", re.DOTALL)
|
| 187 |
+
if not match:
|
| 188 |
+
return None
|
| 189 |
+
try:
|
| 190 |
+
parsed = json.loads(match.group(0))
|
| 191 |
+
except json.JSONDecodeError:
|
| 192 |
+
return None
|
| 193 |
+
return parsed if isinstance(parsed, dict) else None
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@lru_cache(maxsize=1)
|
| 197 |
+
def get_minicpm_llm():
|
| 198 |
+
if os.environ.get("DISABLE_MINICPM", "").lower() in {"1", "true", "yes"}:
|
| 199 |
+
return None
|
| 200 |
+
try:
|
| 201 |
+
from huggingface_hub import hf_hub_download
|
| 202 |
+
from llama_cpp import Llama
|
| 203 |
+
except Exception:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
try:
|
| 207 |
+
model_path = hf_hub_download(repo_id=MINICPM_REPO, filename=MINICPM_FILE)
|
| 208 |
+
return Llama(
|
| 209 |
+
model_path=model_path,
|
| 210 |
+
n_ctx=2048,
|
| 211 |
+
n_threads=max(1, min(4, os.cpu_count() or 2)),
|
| 212 |
+
n_gpu_layers=0,
|
| 213 |
+
verbose=False,
|
| 214 |
+
)
|
| 215 |
+
except Exception:
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def minicpm_parse_dispatch_notes(notes: str) -> Tuple[Dict[str, object], str]:
|
| 220 |
+
fallback = normalize_constraints(parse_dispatch_notes(notes))
|
| 221 |
+
llm = get_minicpm_llm()
|
| 222 |
+
if llm is None:
|
| 223 |
+
fallback["source"] = "rule-fallback"
|
| 224 |
+
return fallback, "MiniCPM5 local runtime is unavailable, so the deterministic parser handled the notes."
|
| 225 |
+
|
| 226 |
+
prompt = f"""You are a dispatch constraint parser for a small delivery route planner.
|
| 227 |
+
Return only valid JSON with these keys:
|
| 228 |
+
prefer_early_priority: boolean
|
| 229 |
+
avoid_late_penalty: number between 0.5 and 10
|
| 230 |
+
max_route_load: integer
|
| 231 |
+
depot_start: minutes after midnight as integer
|
| 232 |
+
soft_due_before: optional minutes after midnight
|
| 233 |
+
boost_terms: short lowercase strings
|
| 234 |
+
|
| 235 |
+
Dispatcher notes:
|
| 236 |
+
{notes}
|
| 237 |
+
"""
|
| 238 |
+
try:
|
| 239 |
+
result = llm(
|
| 240 |
+
prompt,
|
| 241 |
+
max_tokens=180,
|
| 242 |
+
temperature=0.1,
|
| 243 |
+
top_p=0.9,
|
| 244 |
+
stop=["\n\n"],
|
| 245 |
+
)
|
| 246 |
+
text = result["choices"][0]["text"]
|
| 247 |
+
parsed = extract_json_object(text)
|
| 248 |
+
if parsed:
|
| 249 |
+
parsed["source"] = f"{MINICPM_REPO}/{MINICPM_FILE}"
|
| 250 |
+
return normalize_constraints(parsed), text.strip()
|
| 251 |
+
except Exception as exc:
|
| 252 |
+
fallback["source"] = "rule-fallback"
|
| 253 |
+
return fallback, f"MiniCPM5 parsing failed and fallback parser was used: {exc}"
|
| 254 |
+
|
| 255 |
+
fallback["source"] = "rule-fallback"
|
| 256 |
+
return fallback, "MiniCPM5 returned no valid JSON, so the deterministic parser handled the notes."
|
| 257 |
+
|
| 258 |
+
|
| 259 |
def priority_weight(stop: Stop, constraints: Dict[str, object]) -> float:
|
| 260 |
score = 0.0
|
| 261 |
if stop.priority == "high":
|
|
|
|
| 269 |
return score
|
| 270 |
|
| 271 |
|
| 272 |
+
def score_route(route: List[Stop], start_minute: int) -> Tuple[int, float, int]:
|
| 273 |
+
plan, metrics = simulate_single_route(route, start_minute, route_id=1)
|
| 274 |
+
late_stops = sum(1 for item in plan if item.late_min > 0)
|
| 275 |
+
return int(metrics["late_min"]), float(metrics["distance_km"]), late_stops
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def best_order_for_group(stops: List[Stop], start_minute: int) -> List[Stop]:
|
| 279 |
+
if len(stops) <= 1:
|
| 280 |
+
return stops[:]
|
| 281 |
+
if len(stops) <= 7:
|
| 282 |
+
candidates = permutations(stops)
|
| 283 |
+
else:
|
| 284 |
+
ordered = sorted(stops, key=lambda s: (s.due_time, s.ready_time, -priority_weight(s, {})))
|
| 285 |
+
candidates = [ordered]
|
| 286 |
+
|
| 287 |
+
best_route: Optional[List[Stop]] = None
|
| 288 |
+
best_score: Optional[Tuple[int, float, int]] = None
|
| 289 |
+
for candidate in candidates:
|
| 290 |
+
route = list(candidate)
|
| 291 |
+
score = score_route(route, start_minute)
|
| 292 |
+
if best_score is None or score < best_score:
|
| 293 |
+
best_score = score
|
| 294 |
+
best_route = route
|
| 295 |
+
return best_route or stops[:]
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def build_capacity_routes(stops: List[Stop], constraints: Dict[str, object]) -> List[List[Stop]]:
|
| 299 |
+
capacity = int(constraints["max_route_load"])
|
| 300 |
+
ordered = sorted(
|
| 301 |
+
stops,
|
| 302 |
+
key=lambda stop: (
|
| 303 |
+
stop.due_time,
|
| 304 |
+
stop.ready_time,
|
| 305 |
+
0 if stop.priority == "high" else 1,
|
| 306 |
+
priority_weight(stop, constraints),
|
| 307 |
+
),
|
| 308 |
+
)
|
| 309 |
+
routes: List[List[Stop]] = []
|
| 310 |
+
loads: List[int] = []
|
| 311 |
+
for stop in ordered:
|
| 312 |
+
best_idx = None
|
| 313 |
+
best_score = None
|
| 314 |
+
for idx, route in enumerate(routes):
|
| 315 |
+
if loads[idx] + stop.demand > capacity:
|
| 316 |
+
continue
|
| 317 |
+
trial = best_order_for_group(route + [stop], int(constraints["depot_start"]))
|
| 318 |
+
late, dist, late_stops = score_route(trial, int(constraints["depot_start"]))
|
| 319 |
+
score = (late, late_stops, dist)
|
| 320 |
+
if best_score is None or score < best_score:
|
| 321 |
+
best_score = score
|
| 322 |
+
best_idx = idx
|
| 323 |
+
if best_idx is None:
|
| 324 |
+
routes.append([stop])
|
| 325 |
+
loads.append(stop.demand)
|
| 326 |
+
else:
|
| 327 |
+
routes[best_idx] = best_order_for_group(routes[best_idx] + [stop], int(constraints["depot_start"]))
|
| 328 |
+
loads[best_idx] += stop.demand
|
| 329 |
+
|
| 330 |
+
return [best_order_for_group(route, int(constraints["depot_start"])) for route in routes]
|
| 331 |
|
| 332 |
|
| 333 |
def route_distance(route: Iterable[Stop]) -> float:
|
|
|
|
| 341 |
return total
|
| 342 |
|
| 343 |
|
| 344 |
+
def simulate_single_route(route: List[Stop], start_minute: int, route_id: int) -> Tuple[List[PlanStop], Dict[str, float]]:
|
| 345 |
cur_lat, cur_lng = DEPOT["lat"], DEPOT["lng"]
|
| 346 |
current = start_minute
|
| 347 |
plan: List[PlanStop] = []
|
|
|
|
| 360 |
plan.append(
|
| 361 |
PlanStop(
|
| 362 |
stop=stop,
|
| 363 |
+
route_id=route_id,
|
| 364 |
arrival=arrival,
|
| 365 |
start=start,
|
| 366 |
depart=depart,
|
|
|
|
| 390 |
return plan, metrics
|
| 391 |
|
| 392 |
|
| 393 |
+
def simulate_routes(routes: List[List[Stop]], start_minute: int) -> Tuple[List[PlanStop], Dict[str, float]]:
|
| 394 |
+
all_plan: List[PlanStop] = []
|
| 395 |
+
total_distance = 0.0
|
| 396 |
+
total_late = 0
|
| 397 |
+
total_wait = 0
|
| 398 |
+
total_load = 0
|
| 399 |
+
finish_min = start_minute
|
| 400 |
+
for route_id, route in enumerate(routes, start=1):
|
| 401 |
+
plan, metrics = simulate_single_route(route, start_minute, route_id)
|
| 402 |
+
all_plan.extend(plan)
|
| 403 |
+
total_distance += metrics["distance_km"]
|
| 404 |
+
total_late += metrics["late_min"]
|
| 405 |
+
total_wait += metrics["wait_min"]
|
| 406 |
+
total_load += metrics["load"]
|
| 407 |
+
finish_min = max(finish_min, metrics["finish_min"])
|
| 408 |
+
metrics = {
|
| 409 |
+
"distance_km": total_distance,
|
| 410 |
+
"late_min": total_late,
|
| 411 |
+
"wait_min": total_wait,
|
| 412 |
+
"finish_min": finish_min,
|
| 413 |
+
"load": total_load,
|
| 414 |
+
"routes": len(routes),
|
| 415 |
+
"on_time_rate": 100.0 * (1 - sum(1 for p in all_plan if p.late_min > 0) / max(1, len(all_plan))),
|
| 416 |
+
}
|
| 417 |
+
return all_plan, metrics
|
| 418 |
+
|
| 419 |
+
|
| 420 |
def manual_route(stops: List[Stop]) -> List[Stop]:
|
| 421 |
return sorted(stops, key=lambda stop: stop.manual_sequence)
|
| 422 |
|
|
|
|
| 425 |
return pd.DataFrame(
|
| 426 |
[
|
| 427 |
{
|
| 428 |
+
"Route": item.route_id,
|
| 429 |
+
"Stop #": sum(1 for prev in plan[:idx] if prev.route_id == item.route_id) + 1,
|
| 430 |
"Order": item.stop.order_id,
|
| 431 |
"Customer": item.stop.customer,
|
| 432 |
"Arrive": min_to_time(item.arrival),
|
|
|
|
| 450 |
|
| 451 |
| Metric | Manual baseline | Tiny Dispatch Coach | Change |
|
| 452 |
|---|---:|---:|---:|
|
| 453 |
+
| Routes / trips | {manual_metrics.get('routes', 1):.0f} | {auto_metrics.get('routes', 1):.0f} | |
|
| 454 |
| Distance | {manual_metrics['distance_km']:.1f} km | {auto_metrics['distance_km']:.1f} km | {distance_delta:+.1f} km |
|
| 455 |
| Late minutes | {manual_metrics['late_min']:.0f} | {auto_metrics['late_min']:.0f} | {late_delta:+.0f} |
|
| 456 |
| Waiting minutes | {manual_metrics['wait_min']:.0f} | {auto_metrics['wait_min']:.0f} | {manual_metrics['wait_min'] - auto_metrics['wait_min']:+.0f} |
|
| 457 |
| Finish time | {min_to_time(manual_metrics['finish_min'])} | {min_to_time(auto_metrics['finish_min'])} | |
|
| 458 |
| 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 |
|
| 459 |
|
| 460 |
+
**Coach note:** The planner treats time-window risk as the first objective, then uses distance as a tie-breaker. It may split the day into multiple feasible trips when the notes imply a small van capacity.
|
| 461 |
"""
|
| 462 |
|
| 463 |
|
| 464 |
+
def constraints_markdown(constraints: Dict[str, object], model_trace: str) -> str:
|
| 465 |
rows = "\n".join(f"- **{key}**: `{value}`" for key, value in constraints.items())
|
| 466 |
+
trace = escape(model_trace or "")
|
| 467 |
+
return f"""### OpenBMB MiniCPM5 Constraint Parse
|
| 468 |
+
|
| 469 |
+
**Model path:** `{MINICPM_REPO}` / `{MINICPM_FILE}`
|
| 470 |
+
**Parameter count:** `{MINICPM_PARAMS}`
|
| 471 |
+
**Runtime target:** local GGUF via llama.cpp; deterministic parser fallback if the runtime is unavailable.
|
| 472 |
+
|
| 473 |
+
{rows}
|
| 474 |
+
|
| 475 |
+
<details>
|
| 476 |
+
<summary>Parser trace</summary>
|
| 477 |
+
|
| 478 |
+
```text
|
| 479 |
+
{trace}
|
| 480 |
+
```
|
| 481 |
+
</details>
|
| 482 |
+
"""
|
| 483 |
|
| 484 |
|
| 485 |
def route_cards(plan: List[PlanStop]) -> str:
|
|
|
|
| 490 |
f"""
|
| 491 |
<div class="route-card">
|
| 492 |
<div class="route-card-top">
|
| 493 |
+
<span class="route-index">{item.route_id}.{sum(1 for prev in plan[:idx - 1] if prev.route_id == item.route_id) + 1}</span>
|
| 494 |
+
<span class="route-title">{escape(item.stop.customer)}</span>
|
| 495 |
<span class="route-status {status.replace(' ', '-')}">{status}</span>
|
| 496 |
</div>
|
| 497 |
+
<div class="route-meta">{escape(item.stop.order_id)} 路 route {item.route_id} 路 arrive {min_to_time(item.arrival)} 路 depart {min_to_time(item.depart)} 路 load {item.stop.demand}</div>
|
| 498 |
+
<div class="route-note">{escape(item.stop.notes)}</div>
|
| 499 |
</div>
|
| 500 |
"""
|
| 501 |
)
|
|
|
|
| 522 |
return x, y
|
| 523 |
|
| 524 |
coords = [xy(lat, lng) for lat, lng, _ in points]
|
| 525 |
+
route_paths = []
|
| 526 |
+
for route_id in sorted({item.route_id for item in plan}):
|
| 527 |
+
route_items = [item for item in plan if item.route_id == route_id]
|
| 528 |
+
route_points = [coords[0]]
|
| 529 |
+
for item in route_items:
|
| 530 |
+
idx = plan.index(item) + 1
|
| 531 |
+
route_points.append(coords[idx])
|
| 532 |
+
route_points.append(coords[0])
|
| 533 |
+
route_paths.append(" ".join(f"{x:.1f},{y:.1f}" for x, y in route_points))
|
| 534 |
marker_html = []
|
| 535 |
for idx, ((lat, lng, label), (x, y)) in enumerate(zip(points, coords)):
|
| 536 |
is_depot = idx == 0
|
|
|
|
| 549 |
<div class="map-wrap">
|
| 550 |
<svg viewBox="0 0 900 560" role="img" aria-label="Route map">
|
| 551 |
<rect x="0" y="0" width="900" height="560" rx="8" fill="#f8fafc" />
|
| 552 |
+
{''.join(f'<path d="M {path}" fill="none" stroke="#2563eb" stroke-width="4" stroke-linejoin="round" stroke-linecap="round" opacity="0.62" />' for path in route_paths)}
|
| 553 |
{''.join(marker_html)}
|
| 554 |
</svg>
|
| 555 |
</div>
|
|
|
|
| 558 |
|
| 559 |
def analyze(file_obj, notes: str):
|
| 560 |
stops = parse_orders(file_obj)
|
| 561 |
+
constraints, model_trace = minicpm_parse_dispatch_notes(notes)
|
| 562 |
+
auto_routes = build_capacity_routes(stops, constraints)
|
| 563 |
manual = manual_route(stops)
|
| 564 |
+
auto_plan, auto_metrics = simulate_routes(auto_routes, int(constraints["depot_start"]))
|
| 565 |
+
manual_plan, manual_metrics = simulate_routes([manual], int(constraints["depot_start"]))
|
| 566 |
|
| 567 |
return (
|
| 568 |
metrics_markdown(auto_metrics, manual_metrics),
|
| 569 |
+
constraints_markdown(constraints, model_trace),
|
| 570 |
route_table(auto_plan),
|
| 571 |
route_cards(auto_plan),
|
| 572 |
route_map(auto_plan),
|
|
|
|
| 602 |
font-size: 16px;
|
| 603 |
margin: 0;
|
| 604 |
}
|
| 605 |
+
.badges {
|
| 606 |
+
display: flex;
|
| 607 |
+
flex-wrap: wrap;
|
| 608 |
+
gap: 8px;
|
| 609 |
+
margin-top: 18px;
|
| 610 |
+
}
|
| 611 |
+
.badge {
|
| 612 |
+
border: 1px solid rgba(255, 255, 255, .42);
|
| 613 |
+
border-radius: 999px;
|
| 614 |
+
padding: 5px 10px;
|
| 615 |
+
color: #f8fafc;
|
| 616 |
+
background: rgba(15, 118, 110, .58);
|
| 617 |
+
font-size: 13px;
|
| 618 |
+
font-weight: 700;
|
| 619 |
+
}
|
| 620 |
+
.model-note {
|
| 621 |
+
border: 1px solid #d6d3d1;
|
| 622 |
+
border-radius: 8px;
|
| 623 |
+
padding: 12px;
|
| 624 |
+
background: #f8fafc;
|
| 625 |
+
color: #292524;
|
| 626 |
+
font-size: 14px;
|
| 627 |
+
}
|
| 628 |
.route-cards {
|
| 629 |
display: grid;
|
| 630 |
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
|
|
| 700 |
"""
|
| 701 |
<section class="hero">
|
| 702 |
<h1>Tiny Dispatch Coach</h1>
|
| 703 |
+
<p>Turn a small delivery sheet and messy dispatcher notes into route plans, tradeoff explanations, and driver-ready cards. Built around OpenBMB MiniCPM5-1B-GGUF plus a deterministic planner.</p>
|
| 704 |
+
<div class="badges">
|
| 705 |
+
<span class="badge">OpenBMB MiniCPM5</span>
|
| 706 |
+
<span class="badge">1.08B params</span>
|
| 707 |
+
<span class="badge">GGUF / llama.cpp</span>
|
| 708 |
+
<span class="badge">No cloud LLM API</span>
|
| 709 |
+
<span class="badge">Synthetic demo data</span>
|
| 710 |
+
</div>
|
| 711 |
</section>
|
| 712 |
"""
|
| 713 |
)
|
|
|
|
| 726 |
)
|
| 727 |
run = gr.Button("Plan route", variant="primary")
|
| 728 |
with gr.Column(scale=1):
|
| 729 |
+
gr.HTML(
|
| 730 |
+
f"""
|
| 731 |
+
<div class="model-note">
|
| 732 |
+
<strong>Small-model core:</strong><br>
|
| 733 |
+
<code>{MINICPM_REPO}</code><br>
|
| 734 |
+
<code>{MINICPM_FILE}</code><br>
|
| 735 |
+
The model parses human dispatch notes into JSON constraints. The route math is deterministic and auditable.
|
| 736 |
+
</div>
|
| 737 |
+
"""
|
| 738 |
+
)
|
| 739 |
gr.Markdown(
|
| 740 |
"""
|
| 741 |
### CSV columns
|
|
|
|
| 746 |
)
|
| 747 |
|
| 748 |
metrics = gr.Markdown()
|
| 749 |
+
constraints = gr.Markdown(
|
| 750 |
+
"### OpenBMB MiniCPM5 Constraint Parse\nClick **Plan route** to parse notes with MiniCPM5-1B-GGUF and build the route plan."
|
| 751 |
+
)
|
| 752 |
table = gr.Dataframe(label="Optimized route", interactive=False)
|
| 753 |
cards = gr.HTML(label="Driver cards")
|
| 754 |
map_html = gr.HTML(label="Route map")
|
|
|
|
| 758 |
inputs=[order_file, notes],
|
| 759 |
outputs=[metrics, constraints, table, cards, map_html],
|
| 760 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
|
| 762 |
|
| 763 |
if __name__ == "__main__":
|
requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
gradio>=6.14.0
|
| 2 |
pandas>=2.2.0
|
| 3 |
-
|
|
|
|
|
|
| 1 |
gradio>=6.14.0
|
| 2 |
pandas>=2.2.0
|
| 3 |
+
huggingface_hub>=0.34.0
|
| 4 |
+
llama-cpp-python>=0.3.9
|