Every module, and what it owns

OBSERVED — line counts and one-line summaries taken from each module's own docstring via ast.get_docstring, not from memory.

The layering is enforced, not conventional: import-linter holds a contract named "domain is pure" that forbids app.domain from importing app.adapters, app.api, app.db, app.workflowsand from importing boto3, psycopg, dbos, fastapi, httpx, pydantic_ai. Domain logic can be tested with no network, no database, and no credentials.


The work before anyone calls

A load arrives as an email and passes through six stages before Ori dials.

email  →  parsing  →  extraction  →  verification  →  resolve  →  planning  →  sourcing  →  CALL
module lines owns
parsing/email_source.py 150 Get the HTML body out of whatever the mail connector hands us
parsing/html_body.py 164 Turn an HTML email body into text a table parser can read
parsing/do_table.py 473 Deterministic parser for the ERP dispatch table embedded in order emails
verification/evidence.py 213 Check that what the model claims to have read is actually in the email
verification/reconcile.py 131 Overwrite the model's arithmetic with the parser's
resolve/customer.py 194 Match an extracted customer to one we actually have — step 3
planning/vehicle.py 148 Choose a vehicle for a load — step 4
sourcing/ceiling.py 442 What we are authorised to pay — step 7, "the hardest boundary in the system"

⭐ Note verification/reconcile.py. Its one-line summary is "Overwrite the model's arithmetic with the parser's." The one rule is not confined to the voice path — anywhere a model produces a number, code replaces it.


The money path

sourcing/ceiling.py — 442 lines

The only code authorised to produce a rupee figure. Everything else consumes what it returns.

OBSERVED — public surface:

@dataclass(frozen=True, slots=True)
class RateBand:  ...          # market observations for a lane
class Ladder:    ...          # anchor, concession, ceiling
class Response:  ...          # what to do about a quote

def build_ladder(...) -> Ladder
def respond_to(quote: int, ladder: Ladder, *, already_countered: bool) -> Response

Three rungs, computed before the call starts:

rung what it is
anchor what we open at
concession the single step we may make — a sha256-seeded point in the band
ceiling contract rate + cap %. Never spoken.

Why the concession is seeded, not the midpoint. A midpoint is recoverable: a vendor who called twice could subtract and derive the ceiling exactly. It is instead sha256(seed|anchor) mapped into [CONCESSION_MIN, CONCESSION_MAX] — stable per load, reproducible for an auditor, unguessable from outside.

OBSERVEDceiling.py:121-133:

"hashlib, NOT the built-in hash() — that is salted per process, so the same load would get a different ladder on every restart and no two workers would agree. Reproducibility is not decoration here: an auditor has to be able to rebuild the exact ladder a call was run against."

Sanity guards:

constant value prevents
IMPLAUSIBLE_MULTIPLE 3 A contract rate more than 3× the market band is treated as suspect, not as licence
MIN_BAND_N 20 Refuses to price from a band with too few observations
MIN_BAND_N_SANITY 5 A contract 3× above its lane's p90 is suspect — and a suspect contract closes the call (stance.py:352), because the concession derives from the same bad rate. Raised from 1 on 2026-09-08: at n=1–3 a single mis-keyed booking row is p90, and one dropped digit disabled every contract on that lane, 39 of 39

calling/stance.py — 459 lines

The negotiation state machine. OBSERVED — six stances:

ask_why · confirm_number · counter_once · last_ask · close_defer · close_out_of_range

And the authorisation table — which stances may speak a figure at all:

stance may speak
counter_once counter_words, their_rate_words
confirm_number their_rate_words
the other four nothing

Guards: MAX_PUSHBACKS = 3, MAX_UNREADABLE = 3.

probe_answered() is the newest and least proven code here. It handles "why is your rate so high?" → he answers → we counter. Two earlier versions were reverted for booking trucks at prices nobody said. The third passes two facts separately: whether he answered the probe, and what he is asking now.

poc/pc-web/server/figure_gate.py — 287 lines

The last thing before the voice.

"Sits immediately before the TTS service, so it sees the final text — after slot substitution, after transliteration, after every other processor has [run]."

It refuses any rupee figure the ladder did not authorise and substitutes a written fallback rather than speak it.

It has caught real leaks — and it has caused one. A fix for a false positive introduced a leak that would have spoken ₹70,000 against a ₹12,075 ceiling. Bug fixes carry bugs; this file is the standing evidence.


Hearing the number

asr/numbers.py — 1,710 lines, 41% comments

OBSERVED"What rate did the vendor actually say — the number layer above ASR."

The largest and most heavily annotated file in the repo (48 ⛔ markers). It is that long because Hindi offers a dozen ways to say the same amount and speech recognition mangles all of them.

OBSERVED — run against the live parser:

input status rupees
अठारह हज़ार पाँच सौ heard 18,500
अठारह पाँच (elided) heard 18,500
साढ़े बाईस हज़ार (half) heard 22,500
28 हज़ार 500 (digits + scale) heard 28,500
पैंतीस चालीस हज़ार (a range) ambiguous
दो हज़ार रुपये प्रति टन per_unit
डीज़ल नब्बे रुपये लीटर absent
twenty eight हज़ार five hundred ambiguous

The last four rows are the design. The parser returns a record, not a number, because how it read the figure decides what may be done with it:

RateHeard(status, rupees, because, alternatives, numbers, per_unit)

rupees is only ever set when status == "heard". Anything else routes to "ask again". She never spends a number she could not read.

because carries the reasoning in words — "more than one plausible rate: 35,000 vs 40,000" — so a disputed call can be reconstructed.

The rest of the ASR layer

module lines owns
asr/figures.py 197 "Does this sentence speak a rupee figure? — the check the whole design rests on"
asr/plates.py 615 The vehicle registration — the value that closes a booking
asr/say.py 101 Turning a value back into speech — the outbound half of the number rule
asr/speak.py 264 Making text sayable before it reaches the voice
asr/translit.py 245 Romanised Hinglish → Devanagari, outbound voice leg only
asr/spelling.py 63 How the same Devanagari word arrives spelled two different ways

translit.py is outbound-only, deliberately. Inbound text is never transliterated — the parser reads what arrived.


The live agent

poc/pc-web/server/dialogue.py — 1,092 lines, 48% comments

OBSERVED — 20 intents:

rate · price_q · load_q · closing · chitchat · affirm · deny · cant_hear
presence · unclear · other · probe · not_a_person · wrong_number · callback
bot_challenge · abuse · rate_unclear · rate_rider · history_q

An ordered chain of keyword tests, first match wins. 0.102 ms per utterance.

This is the brittle part. Four defects in a single day, all the same shape — an inflection in one list and absent from another (बोलो handled, बोल not; दोगे handled, देंगे not). Measured on the 133 pinned utterances: prepending a filler हां changes 11 routings; joining the first two words, which STT does constantly, changes 27.

PROPOSED — the fix is not "replace it with a model". It is a semantic fallback under the rules, handling only what currently falls through to OTHER. See 03-papers.md on Rasa's coexistence guidance.

poc/pc-web/server/brain.py — 1,146 lines

Maps intent + ladder state to a brief — a short instruction the model must follow. Guards: MAX_NOTHING = 3 (consecutive contentless turns), MAX_TURNS = 25 ("a freight call is never this long" — a 43-turn livelock once was).

_authorised is cleared at the top of decide(), not on one branch. It was reset only on the fall-through path, so every early return — rider, clarify, probe, closing, wrap_up, hang_up — carried the previous turn's authorised figures. Two of those invoke the model.

poc/pc-web/server/ori_bot.py — 1,086 lines

Assembles the pipeline. OBSERVED — eleven processors, three of them ours:

Pipeline([
    transport.input(), stt, ori, user_agg, llm,
    SlotFill(ori, session),
    FigureGate(ori, session),      # ⛔ LAST THING BEFORE THE VOICE
    tts, transport.output(), recorder, assistant_agg,
])

Services: DeepgramFluxSTTService (flux-general-multi, hi+en hints, numerals on, eot_threshold 0.8), AWSBedrockLLMService (google.gemma-3-27b-it, max_tokens=90, temperature=0.0, ap-south-1), MurfTTSService (ours — 222 lines, Murf ships no Pipecat service), SileroVADAnalyzer.

The rest

module lines owns
telemetry.py 416 Structured usage metrics; dedupes broadcast sibling frames
feedback.py 124 Extracted so the path that once lost real feedback is testable without pipecat
murf_tts.py 228 Our own Pipecat service. rate: -10 (10% slower)
ui/studio.js 882 The test console — scenario picker, live transcript, voice feedback
version.py 3 One definition. The server stamps it into the page and every session record

The operator's view

domain/board.py

⭐ Worth reading its docstring in full. It encodes a real product decision:

"An earlier prototype grouped loads by status: needs-you, negotiating, settled. That is Gmail's information architecture, and it is wrong here for the same reason it is wrong for shipment exceptions: situations do not arrive in time order, they expire in cost order."

So there are no groups. One stack, ordered by:

urgency = rupees exposed / hours until the truck is needed

with BLOCKED_MULTIPLIER = 3.0 and MIN_HOURS = 0.5.


Where to start reading

PROPOSED — if you want to understand this codebase in an hour, read in this order:

  1. docs/adr/0003-money-is-not-the-models-job.md — the rule
  2. app/domain/sourcing/ceiling.py — the money, 442 lines
  3. app/domain/calling/stance.py — the negotiation, 459 lines
  4. poc/pc-web/server/figure_gate.py — the guard, 287 lines
  5. poc/pc-web/server/ori_bot.py:602-615 — the pipeline, 20 lines

That is ~1,200 lines and covers every decision that matters. numbers.py and dialogue.py are large but they are breadth, not depth — read them when you need to change them.