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.workflows — and 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.
OBSERVED — ceiling.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:
docs/adr/0003-money-is-not-the-models-job.md— the ruleapp/domain/sourcing/ceiling.py— the money, 442 linesapp/domain/calling/stance.py— the negotiation, 459 linespoc/pc-web/server/figure_gate.py— the guard, 287 linespoc/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.