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

Add MiniCPM5 local parser and multi-trip planner

Browse files
Files changed (5) hide show
  1. FIELD_NOTES.md +52 -0
  2. README.md +39 -6
  3. agent_trace.json +32 -0
  4. app.py +273 -82
  5. 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. 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 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
 
@@ -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 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:
@@ -241,7 +341,7 @@ def route_distance(route: Iterable[Stop]) -> float:
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] = []
@@ -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
- "#": idx + 1,
 
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:** 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:
@@ -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
- 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
@@ -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.78" />
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 = 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),
@@ -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 a route plan, tradeoff explanation, and driver-ready cards. Built for small models, Gradio, and real neighborhood logistics.</p>
 
 
 
 
 
 
 
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