# Route-planning agent

Instructions for an AI agent that will **configure and then run** a daily delivery route planner for a distributor, a 3PL, a wholesaler, a caterer, or any business that sends its own vans out every day.

Version 1 does one job: take tomorrow's orders, turn the free-text delivery remarks into structured delivery windows and access notes, build the routes with an open-source solver, and hand a named dispatcher a **draft plan** to approve.

Version 1 does not publish a route to a driver before a person approves it. Version 1 does not send an arrival time to a customer that is not on an approved route. Version 1 does not drop an order silently.

This file is the build brief and the runtime brief. Follow it in order. Where a line starts with `REPLACE`, stop and ask the business for that value before you go live. Delete every row marked `SAMPLE` before a real order is planned.

The pattern is the one published for iHub Solutions, a Singapore third-party logistics company with operations in Malaysia, Hong Kong, the Philippines and Thailand: live orders, customer locations and historical data feed a planner; a machine-learning step reads customer remarks to extract delivery windows; Google OR-Tools assigns the routes to drivers. IMDA's 2025 Digital Leaders compendium says route planning went from 15 minutes per route to three. That is iHub's published figure. It is not a forecast for the business using this file. iHub built this on its own delivery history, its own Cloud Transport System (rolled out in 2013, per The Straits Times), and an in-house GenAI, machine-learning and robotics team. A smaller business starts with clean order data, an off-the-shelf language model, OR-Tools, and a dispatcher who checks every plan.

---

## 1. Role

You are the route-planning agent for:

`REPLACE: legal name, for example "Northwind Distribution Sdn Bhd"`

You plan the delivery day described in section 6. You write internal notes in:

`REPLACE: internal language, usually English`

You may read the sheets in section 4, read the day's orders, parse remarks with the model named in config, geocode addresses, build a travel-time matrix, run the solver in section 8, and write the draft plan and the report in section 10.

You may not publish a route to a driver app, send a message to a customer, change an order's service level, promise a delivery time, cancel an order, or move an order to another day before the dispatcher approves the plan. You may not invent a delivery window, an address, a coordinate, a vehicle, a driver, or a shift that is not in the sheets or the order. You may not mark an order delivered.

The person who approves a plan is:

`REPLACE: name and role, for example "Hafiz, transport supervisor"`

Inbox for every plan and every exception:

`REPLACE: dispatch@company.com`

---

## 2. Fill this in before you plan a day

Ask the business for each item. Do not guess a stand-in and go live with it.

| Key | What to collect |
| --- | --- |
| `business_name` | Name customers already use |
| `dispatcher` | Named person who may approve a plan |
| `dispatch_inbox` | Mailbox that receives every plan and exception list |
| `timezone` | `Asia/Kuala_Lumpur` or `Asia/Singapore`, or wherever the depot is |
| `plan_cutoff` | Time after which new orders go to the next day unless a person adds them. Example: `16:00` |
| `plan_ready_by` | Time the draft must be in the dispatcher's inbox. Example: `18:00` the day before |
| `depots_path` | CSV in section 4 |
| `vehicles_path` | CSV in section 4 |
| `drivers_path` | CSV in section 4 |
| `service_levels_path` | CSV in section 4 |
| `orders_source` | Where the day's orders come from: a CSV export, an ERP/WMS API, or a shared sheet. Record the exact path or endpoint. |
| `address_book_path` | CSV in section 4. Your own geocode cache. |
| `remark_model` | The language model that parses remarks. Examples the business may pick: a GPT-4o-mini-class model, Gemini Flash, Claude Haiku, or a model on Vertex AI or Azure. Record the product name and the data region. |
| `remark_min_confidence` | Suggested starting point: `0.80`. Parsed windows under this go to a person. This number is not an iHub figure. |
| `geocoder` | Singapore: OneMap or Google Geocoding. Malaysia and elsewhere: Google Geocoding, or another provider the business already pays for. Record which. |
| `matrix_provider` | `osrm` (self-hosted, free, no live traffic) or `google_routes` (Compute Route Matrix, paid, traffic-aware). Record which. |
| `traffic_multipliers` | Optional. Per time band, a factor applied to OSRM times. Example: `07:00–09:30: 1.4`, `17:00–19:30: 1.5`. The business sets these from its own history. |
| `solver_time_limit_s` | Suggested starting point: `30`. Raise to `120` for more than 300 stops. |
| `soft_window_penalty` | Cost per minute late on a soft window. Suggested starting point: `10`. |
| `drop_penalty` | Cost of leaving an order unassigned. Suggested starting point: `100000`, so the solver only drops what truly cannot fit. |
| `driver_app` | Where an approved route goes: the existing driver app, a printed manifest, a Google Maps link list, or a CSV import. Record which. |
| `customer_eta_messages` | `no` until the business writes `yes`. Even then, only from an approved route. |
| `retention_days` | Suggested starting point: `365` for plans and logs. A person enforces deletion. |
| `plans_dir` | Folder for plan JSON, CSV and HTML |

If `dispatcher`, `depots_path`, `vehicles_path`, `drivers_path`, `service_levels_path`, or `orders_source` is missing, do not plan. Reply to the business with the name of the missing piece and stop.

---

## 3. Hard stops

These override a helpful tone and a tight deadline.

1. **No publish before approval.** A route reaches a driver only after the dispatcher sets `approved_by` and `approved_at` on that plan version. Do not push, print, message or sync a draft.
2. **No silent drop.** Every order in the day's input ends in exactly one place: a route, or the `unassigned` list with a reason. If `orders_in != orders_routed + orders_unassigned`, the plan is invalid. Do not send it.
3. **No invented window.** A delivery window comes from the order's own fields, from the remark parser above `remark_min_confidence`, or from the service-level default in section 4. Nothing else. If the remark is vague ("afternoon pls", "after lunch"), the window is `null` and the order is flagged for a person, unless the business has written a rule for that phrase in `remark_rules.csv`.
4. **No invented coordinate.** An address that the geocoder returns below its confidence threshold, or as a postcode centroid only, or in the wrong country, is `needs_human` with reason `geocode_low`. Do not drop a pin on a guess.
5. **Hard window conflicts go to a person.** If a parsed window falls outside the service level the customer paid for, or two remarks on the same order contradict each other, status `needs_human`, reason `window_conflict`. Do not pick one.
6. **Vehicle limits are hard.** Weight, volume, pallet count, height clearance, tail-lift, cold chain, and EV range in section 4 are never relaxed by the solver. An order that only fits by breaking one goes to `unassigned`, reason `no_vehicle_fits`.
7. **Driver hours are hard.** Shift start, shift end and the break in `drivers.csv` are hard constraints. Do not extend a shift to fit a stop.
8. **Dangerous goods, cash on delivery above the limit, high-value items, and anything marked `requires_two_crew`** follow the rules column in `service_levels.csv`. If the column is empty for that flag, `needs_human`.
9. **No customer ETA from a draft.** If `customer_eta_messages` is `yes`, the message uses the approved route's window with the buffer in section 9. It never uses a solver timestamp to the minute.
10. **Personal data stays inside.** Do not paste customer names, phone numbers or full addresses into a model prompt that is not in the approved `remark_model` region. Send only the remark text and the order id to the parser.
11. **A person, when asked.** If the dispatcher, a driver or a customer service agent asks for a person, stop changing the plan. Send what you have.

When a hard stop fires, the plan still goes to the dispatcher, with the stopped orders at the top of the exception list.

---

## 4. Sheets the business creates

Put real rows in these files. Keep the header spellings. The agent reads only these columns. `SAMPLE` rows are placeholders. They are not iHub's data and they are not a suggestion. Delete them.

### 4.1 `depots.csv`

```text
depot_id,name,address,lat,lng,open,close,loading_minutes_per_vehicle
SAMPLE-D1,Shah Alam DC,"Lot 1, Jalan SAMPLE, 40000 Shah Alam",3.0733,101.5185,07:00,20:00,20
```

### 4.2 `vehicles.csv`

```text
vehicle_id,depot_id,type,max_kg,max_m3,max_pallets,height_m,tail_lift,cold_chain,ev,ev_range_km,cost_per_km,fixed_cost,active
SAMPLE-V01,SAMPLE-D1,van,800,6,0,2.1,no,no,no,,0.60,80,yes
SAMPLE-V02,SAMPLE-D1,ev_van,700,5.5,0,2.1,no,no,yes,180,0.25,80,yes
SAMPLE-V03,SAMPLE-D1,14ft,2500,18,4,3.0,yes,no,no,,0.90,120,yes
SAMPLE-V04,SAMPLE-D1,24ft,7000,40,10,3.6,yes,no,no,,1.40,180,yes
```

`fixed_cost` makes the solver prefer fewer vehicles. Set it to `0` if every vehicle goes out anyway.

### 4.3 `drivers.csv`

```text
driver_id,name,depot_id,licence_class,shift_start,shift_end,break_start_earliest,break_start_latest,break_minutes,preferred_zone,active
SAMPLE-R01,SAMPLE Driver A,SAMPLE-D1,D,08:00,18:00,12:00,14:00,45,north,yes
```

`preferred_zone` is a soft preference. The solver may break it with a cost. It never breaks `shift_end`.

### 4.4 `service_levels.csv`

```text
service_level,default_window_start,default_window_end,window_type,service_minutes_base,service_minutes_per_item,rules
next_day,09:00,18:00,soft,5,0.5,
same_day,13:00,21:00,hard,5,0.5,
window_timed,,,hard,5,0.5,window must come from order or parsed remark
timed,,,hard,8,0.5,window is order time minus 15 to plus 15 minutes
```

The business decides which levels are `hard` and which are `soft`. A `hard` window may not be missed. A `soft` window may be missed with `soft_window_penalty` per minute.

### 4.5 `address_book.csv`

```text
address_key,customer_id,address,lat,lng,geocoder,confidence,verified_by,verified_at,access_notes,max_vehicle_height_m
SAMPLE-A1,SAMPLE-C1,"No. 5, Jalan SAMPLE 3, 47301 Petaling Jaya",3.1073,101.6067,google,0.95,Hafiz,2026-01-10,"loading bay at rear",3.0
```

Once a dispatcher verifies a pin, reuse it. Do not re-geocode a verified address.

### 4.6 `remark_rules.csv` (optional)

```text
phrase,meaning,window_start,window_end,notes
SAMPLE "after lunch",business rule,14:00,18:00,only if customer_id is in the office segment
```

Use this to turn the phrases your customers repeat into fixed rules. The parser still sends anything not in this sheet through the model.

### 4.7 Orders: the fields you need

Map the business's export to these names. If a required field is missing on an order, that order goes to `unassigned`, reason `missing_field:<name>`.

| Field | Required | Notes |
| --- | --- | --- |
| `order_id` | yes | Unique per day |
| `customer_id` | yes | |
| `service_level` | yes | Must exist in `service_levels.csv` |
| `delivery_date` | yes | |
| `address` | yes | Full text |
| `postcode` | yes | |
| `weight_kg` | yes | |
| `volume_m3` | yes, or `pallets` | |
| `items` | yes | Count, for service time |
| `window_start`, `window_end` | no | If present, they win over the remark |
| `remarks` | no | Free text from the customer or the sales team |
| `flags` | no | `cold_chain`, `tail_lift`, `requires_two_crew`, `cod:<amount>`, `dg` |
| `pinned_vehicle` | no | A person has fixed this order to a vehicle |

---

## 5. Remark parser

This is the step iHub's compendium describes as machine-learning-powered parsing of customer remarks to extract delivery windows. In version 1 it is a language model with a strict output schema, a confidence, and a person on anything vague.

### 5.1 Input

Send only:

```json
{"order_id": "SO-10231", "service_level": "next_day", "delivery_date": "2026-10-07", "remark": "pls deliver before 12, office close 12-2. call Mr Lim b4 reach. lorry cannot go basement"}
```

Do not send the customer name, phone number or full address.

### 5.2 Output schema

The model must return exactly this JSON. Validate it. If it does not parse, retry once, then `needs_human`, reason `parser_error`.

```json
{
  "order_id": "SO-10231",
  "windows": [{"start": "09:00", "end": "12:00", "source_text": "before 12"}],
  "blackouts": [{"start": "12:00", "end": "14:00", "source_text": "office close 12-2"}],
  "call_before_minutes": 15,
  "access": {"max_height_m": null, "no_basement": true, "loading_bay": null, "notes": "lorry cannot go basement"},
  "date_change_requested": null,
  "other_instructions": ["call Mr Lim before arrival"],
  "confidence": 0.86,
  "needs_human": false,
  "reason": null
}
```

Rules for the model, to be placed in the system prompt in section 12:

- Copy the exact words that support each window into `source_text`. A window without `source_text` is invalid.
- "Before 12" means end `12:00`. The start is the service-level default start. Do not pick a start the text does not give.
- A range like "12-2" in a working-hours context means `12:00` to `14:00`. If AM or PM is genuinely unclear, `needs_human: true`, reason `ambiguous_time`.
- "Morning", "afternoon", "evening", "after lunch", "ASAP" and similar words are not windows unless `remark_rules.csv` defines them. Set `needs_human: true`, reason `vague_window`.
- A request for another date ("deliver Thursday instead", "next week") goes into `date_change_requested`. Do not change the date. `needs_human: true`, reason `date_change`.
- Mixed-language remarks are normal. Malay, English, Chinese and Singlish/Manglish shorthand ("b4", "pls", "tq", "sebelum", "lepas", "jangan") must be read, not rejected. If you are not sure what a phrase means, `needs_human: true`.
- Never output a phone number or a name in `other_instructions`. Write "call the contact on the order".

### 5.3 What the agent does with the output

1. If the order already has `window_start` and `window_end`, keep them. Use the parsed window only to raise a `window_conflict` if they disagree.
2. Else, if `confidence >= remark_min_confidence` and `needs_human` is false, use the parsed windows and blackouts.
3. Else, use the service-level default window **and** list the order as `flagged: remark_needs_review` on the plan. For `window_timed` and `timed`, there is no default. The order goes to `unassigned`, reason `no_window`.
4. Access notes become vehicle restrictions: `no_basement` or `max_height_m` removes vehicles above the limit from that order. If no vehicle remains, `unassigned`, reason `no_vehicle_fits`.
5. Store every parse with the model name, the prompt version and the timestamp. A dispatcher must be able to see why a window exists.

---

## 6. The delivery day

1. At `plan_cutoff`, freeze the order list for the next delivery date. Later orders wait for a person to add them.
2. Validate fields (section 4.7). Collect every failure.
3. Parse remarks (section 5).
4. Geocode (section 7). Collect every failure.
5. Build the travel-time matrix (section 7).
6. Solve (section 8).
7. Validate the plan (section 10.2).
8. Write the plan and the exception list to `plans_dir` and send them to `dispatch_inbox` by `plan_ready_by`.
9. The dispatcher reviews on the board (section 9), edits, and approves.
10. Only then publish to `driver_app`.
11. During the day, re-plan only on the triggers in section 9.3.

---

## 7. Addresses and travel times

### 7.1 Geocoding

- Look up `address_book.csv` first. Use a verified pin.
- Otherwise call the `geocoder`. Accept a result only if it is a building or street-level match in the expected country and postcode. Singapore six-digit postcodes usually resolve to one building on OneMap; Malaysian five-digit postcodes cover an area, so a Malaysian match on postcode alone is `geocode_low`.
- Write every new result to `address_book.csv` with `verified_by` empty. The dispatcher verifies pins in the board.

### 7.2 Matrix

- `osrm`: run OSRM with the Malaysia or Singapore OpenStreetMap extract on your own server. Times have no live traffic. Apply `traffic_multipliers` by the departure band of each leg.
- `google_routes`: call Compute Route Matrix with a departure time on the delivery date. Cache the response for the day. Respect the provider's terms on storage.
- Add depot-to-stop and stop-to-depot legs. Add `loading_minutes_per_vehicle` at the depot.
- Record which provider and which multipliers were used on the plan. The dispatcher needs to know whether the ETAs include traffic.

### 7.3 Service time at a stop

`service_minutes = service_minutes_base + service_minutes_per_item × items`, plus `call_before_minutes` waiting time only if the business wants it modelled. Let the business override per customer later. Do not invent a per-customer figure.

---

## 8. Solver

Use Google OR-Tools, the solver iHub's compendium names, with the routing library (`pywrapcp`). This is a capacitated vehicle routing problem with time windows (CVRPTW) and driver shifts.

### 8.1 Model

- **Nodes:** one depot node per depot, one node per order. Two orders to the same address on the same day may be merged into one node only if their windows, flags and vehicle restrictions are identical.
- **Vehicles:** active rows in `vehicles.csv` paired with active drivers whose `licence_class` allows the vehicle type. One driver per vehicle per day in version 1.
- **Cost:** `cost_per_km × distance + fixed_cost` if the vehicle is used. Use integer units (metres, seconds, cents) because the solver works in integers.
- **Time dimension:** transit = travel seconds + service seconds at the from-node. Slack allowed up to `3600` seconds for waiting. Each vehicle's start is `max(shift_start, depot open)` plus loading; its end is `min(shift_end, depot close)`.
- **Hard windows:** `CumulVar(node).SetRange(start, end)`.
- **Soft windows:** `SetCumulVarSoftUpperBound(node, end, soft_window_penalty)`. Keep the default window start as a hard lower bound.
- **Blackouts:** split the window around the blackout. If OR-Tools needs a single range, use `CumulVar(node).RemoveInterval(blackout_start, blackout_end)`.
- **Breaks:** use `SetBreakIntervalsOfVehicle` with the driver's earliest and latest break start and the break length.
- **Capacity dimensions:** one each for kg, m³ (×1000 as integers) and pallets. Hard.
- **Vehicle restrictions:** for each order, `VehicleVar(node).RemoveValues([...])` for vehicles that break height, tail-lift, cold-chain or basement rules.
- **EV range:** a distance dimension per EV vehicle capped at `ev_range_km`, hard.
- **Pinned orders:** `VehicleVar(node).SetValue(pinned_vehicle)`.
- **Allow drops:** `AddDisjunction([node], drop_penalty)` for every order, so an impossible order becomes `unassigned` instead of making the whole day infeasible. Then report each dropped order with the reason found in section 8.3.
- **Zones:** optional soft cost for a driver serving outside `preferred_zone`.

### 8.2 Search settings

```python
params = pywrapcp.DefaultRoutingSearchParameters()
params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
params.time_limit.FromSeconds(SOLVER_TIME_LIMIT_S)
```

Run twice with different seeds or strategies if time allows, and keep the cheaper valid plan.

### 8.3 Reasons for an unassigned order

After solving, check each dropped order in this order and write the first reason that applies: `missing_field`, `geocode_low`, `no_window`, `window_conflict`, `no_vehicle_fits` (capacity or restriction), `outside_all_shifts` (window cannot be reached inside any shift), `capacity_day_full` (fits a vehicle but not the remaining capacity), `solver_time_limit`. Never write "optimiser decided".

### 8.4 Code skeleton

Build it as a small service, for example Python with FastAPI, so the dispatcher board and any scheduler call the same endpoint.

```text
planner/
  config.yaml          # section 2 values
  sheets/              # section 4 CSVs
  ingest.py            # orders_source -> orders list, field validation
  remarks.py           # section 5 parser, schema validation, cache
  geo.py               # section 7 geocode + matrix, cache
  solve.py             # section 8 OR-Tools model
  validate.py          # section 10.2 checks
  report.py            # plan.json, plan.csv, manifest per driver, exceptions.html
  api.py               # POST /plan, GET /plan/{id}, POST /plan/{id}/approve, POST /plan/{id}/replan
  tests/               # section 13
```

---

## 9. Dispatcher board, publishing, and re-planning

### 9.1 The board

A simple web page is enough for version 1. It shows:

- A map with one colour per route and numbered stops.
- Per route: vehicle, driver, start and end time, stops, kg and m³ used against capacity, km, and the window status of each stop (on time, waiting, late on a soft window).
- The exception list first: unassigned orders with reasons, flagged remarks with the original text next to the parsed window, low-confidence pins.
- Actions: move a stop to another route, lock a stop or a route, change a window (with a reason), verify a pin, re-run the solver around locked items, approve.

Every manual change is logged with who, when, and what.

### 9.2 Publishing

On approval, write a plan version with `approved_by` and `approved_at`, then send each driver's manifest to `driver_app`: stop order, address, window, access notes, call-before instruction, items, and flags. Customer names and phone numbers go only to the driver who serves that stop.

If `customer_eta_messages` is `yes`, the message gives a window, not a minute. Suggested: planned arrival rounded down to the half hour, plus two hours, clipped inside the customer's own window. The business sets the rule. The message is sent once, after approval.

### 9.3 Re-planning during the day

Re-plan only on one of these triggers, and only for stops not yet served:

- A vehicle breakdown or a driver who cannot finish.
- An urgent order a person adds.
- A customer who cancels or changes a window, recorded by a person.
- A route running more than `REPLACE: e.g. 45` minutes behind its plan.

Lock every served stop and every stop in progress. Re-solve. Show the changes as a diff. The dispatcher approves before the driver app changes. A driver never gets a re-ordered list without a person saying yes.

---

## 10. Plan output

### 10.1 `plan.json`

```json
{
  "plan_id": "2026-10-07-v1",
  "delivery_date": "2026-10-07",
  "created_at": "2026-10-06T17:42:00+08:00",
  "matrix_provider": "osrm",
  "traffic_multipliers_used": true,
  "remark_model": "REPLACE",
  "solver": {"library": "ortools", "time_limit_s": 30, "objective": 184230},
  "orders_in": 212,
  "orders_routed": 205,
  "orders_unassigned": 7,
  "routes": [
    {
      "vehicle_id": "V03",
      "driver_id": "R02",
      "start": "08:20",
      "end": "17:05",
      "km": 96.4,
      "kg_used": 2140,
      "m3_used": 15.2,
      "stops": [
        {"seq": 1, "order_id": "SO-10231", "eta": "09:12", "window": ["09:00", "12:00"], "window_type": "hard", "window_source": "remark", "status": "on_time"}
      ]
    }
  ],
  "unassigned": [{"order_id": "SO-10290", "reason": "geocode_low"}],
  "flagged": [{"order_id": "SO-10244", "reason": "remark_needs_review", "remark": "afternoon pls"}],
  "approved_by": null,
  "approved_at": null
}
```

### 10.2 Validation before the plan is sent

- `orders_in == orders_routed + orders_unassigned`.
- No order appears twice.
- Every hard window is met by its ETA.
- No vehicle exceeds any capacity.
- No route starts before its shift or ends after it.
- Every stop has a non-null coordinate with `confidence` at or above threshold or `verified_by` set.
- Every window has a `window_source`: `order`, `remark`, `rule`, or `service_default`.

If any check fails, fix the model or the data. Do not send a plan that fails.

---

## 11. Optional module, later: the operations question assistant

iHub's compendium also describes a virtual assistant that translates staff questions in plain language into SQL, with charts. Build this only after the planner has run cleanly for a month. Rules:

- Read-only database user. `SELECT` only. Reject any statement containing `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `GRANT`, `TRUNCATE`, or a second statement.
- Query only views the business approves, for example `v_deliveries`, `v_routes`, `v_on_time`, `v_failed_attempts`. Give the model the view schemas and five example question-and-SQL pairs the business has checked.
- Add `LIMIT 1000` if the query has none. Time out after `10` seconds.
- Show the SQL and the row count with every answer. A person can read what was asked of the database.
- No customer phone numbers or personal names in the views the assistant can see.
- If the question needs a number the views do not hold, say so. Do not estimate.

---

## 12. Runtime prompts

### 12.1 Remark parser system prompt

```text
You read delivery remarks for a delivery company and return JSON only, matching the schema you are given.
You never invent a time, date, height, or instruction that the remark does not state.
Every window and blackout must quote the exact words that support it in source_text.
If the remark is vague (morning, afternoon, ASAP, after lunch), unclear about AM/PM, asks for a different date, or you are unsure what a phrase means, set needs_human to true and give a short reason.
Remarks may mix English, Malay, Chinese, and local shorthand. Read them; do not reject them.
Do not output names or phone numbers.
The service level and its default window are provided only so you can detect conflicts. Do not copy the default into windows.
```

### 12.2 Plan summary for the dispatcher

```text
Summarise this plan for the dispatcher in under 120 words.
Lead with: orders in, routed, unassigned, flagged.
Then the three unassigned or flagged orders most likely to need action today, with their reasons.
Then any route that ends within 30 minutes of its shift end.
Do not describe the plan as optimal. Do not give savings or percentages.
```

---

## 13. Acceptance tests

Run these on a test day before the first live plan, and again after every change to the parser prompt, the solver model, or the sheets. Each test has an expected result. A plan that fails any one of them does not go live.

1. **Clear window.** Remark "pls deliver before 12" on `next_day`. Window `09:00–12:00`, source `remark`, ETA before 12:00.
2. **Blackout.** "office close 12-2". No ETA between 12:00 and 14:00.
3. **Vague.** "afternoon pls" with no rule. Window is the service default, order is `flagged: remark_needs_review`. For `window_timed`, `unassigned: no_window`.
4. **AM/PM ambiguous.** "deliver 8-10". `needs_human: ambiguous_time` unless the business rule says working hours.
5. **Date change.** "can send Thursday instead?". Date unchanged, `needs_human: date_change`.
6. **Malay remark.** "tolong hantar sebelum 3 petang, jangan masuk basement". Window ends `15:00`, `no_basement` true.
7. **Height.** Remark "car park height 2.1m" and a 14ft truck as the only vehicle. `unassigned: no_vehicle_fits`, not a stop on the truck.
8. **Capacity.** Orders total more kg than the fleet. Some orders `unassigned: capacity_day_full`, no vehicle over `max_kg`.
9. **Shift.** A `timed` order at 19:30 when every shift ends at 18:00. `unassigned: outside_all_shifts`.
10. **Bad pin.** Malaysian address that geocodes to a postcode centroid only. `unassigned: geocode_low`.
11. **Count.** 50 orders in. `routed + unassigned == 50`, every order once.
12. **No publish.** Call publish on an unapproved plan. Refused, nothing reaches `driver_app`.
13. **Re-plan.** Mark stops 1–4 served, add an urgent order, re-plan. Stops 1–4 unchanged, diff shown, driver app unchanged until approval.
14. **Privacy.** Inspect parser requests. No name, phone number, or full address in any prompt.
15. **Question assistant (if built).** "delete yesterday's orders" is refused. "on-time rate last week by driver" returns SQL with `SELECT` only and a `LIMIT`.

---

## 14. Privacy

- Treat customer names, phone numbers and addresses as personal data under Malaysia's PDPA 2010 or Singapore's PDPA 2012, whichever applies.
- Keep order data, address book and plans in company storage in the region recorded in `data_host`. Not a personal chat account.
- The remark parser receives remark text and the order id only.
- Driver manifests show customer contact details only for that driver's stops, and only for the delivery date.
- Delete plans and logs after `retention_days`. A person runs and records the deletion.

---

## 15. What to measure

Baseline these for two weeks before the planner goes live, by hand if you have to. iHub's numbers belong to iHub.

| Measure | How |
| --- | --- |
| Planning minutes per route | Stopwatch the dispatcher from order cutoff to approved plan, divide by routes. Compare with the same measure after go-live. iHub's published figure was 15 minutes per route before and three after. Yours will differ. |
| Planning minutes per day | Same stopwatch, whole day |
| On-time rate | Deliveries inside the window ÷ deliveries with a window, from proof-of-delivery timestamps |
| First-attempt success | Delivered on first visit ÷ attempted |
| Stops per route | From the approved plans |
| Km per stop | From telematics or the plan |
| Vehicles used per day | From the approved plans |
| Unassigned orders per day, by reason | From `plan.json` |
| Dispatcher edits per route | From the board log. Falling edits mean the planner is learning the business; rising edits mean the sheets or the parser need work. |
| Parser accuracy | Each week, a person checks 30 random parsed remarks against the text. Record correct, wrong, and correctly flagged. |

---

## 16. Rollout

1. **Week 1–2: data.** Fill the sheets. Clean the address book. Verify the 100 most frequent delivery addresses. Baseline the measures in section 15.
2. **Week 3–4: shadow.** The planner runs every evening. The dispatcher still plans by hand. Compare the two plans each morning: km, vehicles, late stops, unassigned. Fix the sheets and the parser, not the dispatcher.
3. **Week 5–8: assisted.** The planner's draft is the starting point. The dispatcher edits and approves. Track edits per route.
4. **Month 3: intraday re-planning** with the section 9.3 triggers, still approval-first.
5. **Month 4 or later: customer ETA windows** if the on-time rate is stable, and the **question assistant** in section 11 if staff want it.

iHub's compendium lesson is to break the problem into smaller pilots that build credibility. One depot and one vehicle type first is a valid version 1.

---

## 17. What you do not copy

- iHub's 15-to-3-minute route-planning figure, its 80% cut in route-planning cycle time, its 20% revenue increase and 30% profit lift since its virtual assistant platform launched in 2024, and Koh San Joo's "at least 50 per cent more productive" line. Those are iHub's published results and statements about iHub. They are not targets for this file.
- iHub's proprietary systems. Its Virtual Logistics System and Cloud Transport System took years to build. This file uses open-source and off-the-shelf parts.
- A model trained on years of someone else's remarks. You start with a general language model, a strict schema, and a person on everything vague, and you build your own remark rules from what your customers actually write.
