Architecture · current state · diagnostic

BenzAuto — WhatsApp booking pipeline

How an inbound message becomes a reply, and how the scheduling engine decides what slots to offer. Diagrams reflect the code as it stands (apps/pipeline apps/server packages/db). Amber notes mark the gaps behind the two live failures (KL Chan, Gabriel).

1 · Components — who talks to whom

Four apps share one Neon Postgres. apps/server owns the database; the chatbot (apps/pipeline) is DB-free and reaches data only through server endpoints. The response brain calls tools; the worker fetches "turn scope" (who the customer is) before the models run.

flowchart LR
  cust(["Customer · WhatsApp"]) --- meta["Meta Cloud API"]
  subgraph PIPE["apps/pipeline · 3002 · DB-free"]
    wh["webhook.ts
verify + dedupe"] q[("BullMQ · Redis")] wk["worker.ts
orchestrate turn"] tr["triage.ts
qwen3-30b"] rb["response.ts
brain · gpt-4o-mini
+ tool loop + validator"] end subgraph SRV["apps/server · 3001 · DB owner"] tool["/ai-tool endpoints/"] svc["services:
turn-scope · KB
findAvailableSlots"] end db[("Neon Postgres
conversations · messages
appointments · bays
service_items · knowledge")] dash["apps/dashboard · 3000
staff inbox"] meta --> wh --> q --> wk wk --> tr --> rb wk -- "turn scope" --> tool rb -- "tool calls" --> tool tool --> svc --> db rb -- "reply" --> meta dash --> db meta --- cust
pipeline the chatbot server owns DB + tools dashboard staff inbox

2 · Message lifecycle — one customer turn

The path a single message takes. Each model runs once per turn; the brain may loop through tools before it replies. The amber points are where context is missing or unchecked — the root of the failures.

sequenceDiagram
  autonumber
  actor C as Customer
  participant M as Meta API
  participant W as pipeline webhook
  participant K as worker
  participant T as triage (qwen)
  participant B as brain (gpt-4o-mini)
  participant S as server /ai-tool
  participant E as findAvailableSlots
  participant D as Postgres
  C->>M: "book oil + brake, tmr afternoon"
  M->>W: webhook (verify, dedupe)
  W->>K: enqueue + dispatch
  K->>S: get turn scope
  S->>D: customer, vehicles, history
  K->>T: classify + detect language + entities
  T-->>K: booking · lang=en · serviceHints · "tomorrow"
  Note over K,B: context = system + language + customer + KB + history + msg
NO current date/time · GAP G3 K->>B: build context + run tool loop B->>S: check_availability {date:"tomorrow", serviceHints} Note right of S: tool args have NO time-of-day field · GAP G2 S->>E: findAvailableSlots E->>D: hours, bays, appts, service durations E-->>S: first 3 slots from earliest open · GAP G1 S-->>B: 09:00 · 09:30 · 10:00 Note over B: validator checks IDs/prices/"booked"
NOT the offered slots · GAP G5 B-->>M: "only morning slots tomorrow" M-->>C: reply (afternoon was free)

3 · Availability engine — findAvailableSlots()

The whole "do we have a slot" decision lives in one function (packages/db/src/services/slot-proposal.ts). It walks forward in time and returns the first 3 slots it finds. A time is available if any of the 4 bays is free for the full job duration. Defaults: lead 120 min, horizon 60 days, granularity 30 min, slot limit 3.

flowchart TD
  A([check_availability
date? · serviceHints? · plate?]) --> B["Load org
timezone · business hours
lead 120m · horizon 60d · gran 30m"] B --> C["Sum service durations
oil 60 + brake 90 = 150m
read bookingWindows · maxPerDay"] C --> D["Load active bays = 4"] D --> E["Load appointments in window"] E --> F["Start day = earliest bookable
(or resolved dateHint if later)"] F --> G{"slots < 3
AND within horizon?"} G -- no --> Z([return slots]) G -- yes --> H{"day open
not holiday
not at daily cap?"} H -- no --> N["next day"] --> G H -- yes --> I{"step +30m from open
job fits before close?"} I -- no --> N I -- yes --> J{"within service
bookingWindows?"} J -- no --> I J -- yes --> K{"any of 4 bays
free for 150m?"} K -- no --> I K -- yes --> L["add slot"] --> Mq{"have 3 slots?"} Mq -- yes --> Z Mq -- no --> I

4 · Data model — what the engine reads

Scheduling is a resource problem: bays × time × job duration, constrained by hours and rules. These are the tables/fields the engine touches. Workshop rules (hours, lead, horizon, granularity) live in Organization.settings JSON; per-service rules live on the service item.

erDiagram
  ORGANIZATION ||--o{ BAY : "has"
  ORGANIZATION ||--o{ SERVICE_ITEM : "offers"
  ORGANIZATION ||--o{ APPOINTMENT : "owns"
  BAY ||--o{ APPOINTMENT : "assigned to"
  APPOINTMENT ||--o{ APPOINTMENT_SERVICE : "includes"
  SERVICE_ITEM ||--o{ APPOINTMENT_SERVICE : "listed in"
  ORGANIZATION {
    json settings "businessHours per day · booking{lead,horizon,gran} · timezone"
  }
  BAY {
    string name
    bool isActive "4 active"
  }
  SERVICE_ITEM {
    int estimatedDurationMin "how long it blocks a bay"
    json bookingWindows "time-of-day limit (unused here)"
    int maxPerDay "daily cap (unused here)"
  }
  APPOINTMENT {
    datetime startTime
    datetime endTime
    uuid bayId "which bay"
  }
  APPOINTMENT_SERVICE {
    uuid serviceItemId
  }
  

5 · The gaps, mapped

Two live chats, one shared root: the tool can't express what the customer wants, and the brain has no time sense or reality-check to compensate. Layer tells you where the fix belongs.

Gabriel · "afternoon?"
  • G1Offered 09:00/09:30/10:00, stopped — afternoon (3 free bays) never shown.
  • G2"What about afternoon?" — no way to ask; bot insists no afternoon.
  • G7"What services?" — KB empty, deflected.
  • G8"issue checking availability for tomorrow" — raw tool error leaked.
KL Chan · dates & language
  • G3"next week" returned today — brain has no calendar.
  • G4"1/7", "3/7", "30th" not parsed → collapsed to today.
  • G5Invented "Tuesday 4 July" slots — no slot reality-check.
  • G6English answered in Malay — language re-guessed each msg.