One call, end to end
What happens between the vendor picking up and the line dropping, drawn as the
code does it. File names are the ones under app/domain/calling/ and
poc/pc-web/server/.
The phases
The brain (brain.py) keeps one enum, Phase, and chooses briefs and exits
by it. Before v0.0.2 the same information lived in nine booleans and the
combinations that fell between them were the live defects.
stateDiagram-v2
[*] --> OPENING: greeting spoken (canned)
OPENING --> PITCHED: right person confirmed → load pitched (canned)
OPENING --> CLOSING: wrong number · voicemail · callback · abuse ×2
PITCHED --> PROBING: a figure → ask why (model)
PITCHED --> SQUEEZED: a figure at or under the anchor → ask for a little more, once
PITCHED --> PROBING: a figure over the ceiling → ask why, quote held
PROBING --> COUNTERED: he justified it → counter_once, the one authorised figure
PROBING --> PITCHED: a new figure → the ladder re-evaluates
PROBING --> DEFERRED: he held an over-ceiling quote → close, a person decides
SQUEEZED --> DEFERRED: anything but a lower figure → accept his quote
COUNTERED --> DEFERRED: yes → deal at the concession (take_counter)
COUNTERED --> LAST_ASK: no → one more ask, naming no figure
LAST_ASK --> DEFERRED: anything but a new figure → settle where he stands
DEFERRED --> CLOSING: an acknowledgement → goodbye (model), hang up
CLOSING --> [*]: audio finished → EndWorkerFrame
Two rules do most of the work:
- PROBING expects an answer. After "why so high?", the next turn is his justification unless it is a question, a hearing problem, a farewell, or a new figure. A bare "हां जी" gets one more ask, then the model carries on.
- DEFERRED is terminal. Once the wind-down line has been said, the next acknowledgement ends the call. Before, "I will confirm and call back" was the end of the call to the vendor and nothing to the code.
The ladder itself (stance.py, ceiling.py) is unchanged in shape: anchor,
exactly one concession drawn from a seeded band, a ceiling that is never
spoken. What changed is when it is consulted — an out-of-range quote and an
under-anchor quote are both held and questioned before the ladder sees them.
Ending the call
sequenceDiagram
participant B as OriBrain
participant CE as CallEnd
participant T as transport.output
participant W as PipelineWorker
B->>CE: arm("vendor_ended_call") — any Decision.hang_up
Note over CE: watchdog starts (20 s)
T-->>B: BotStartedSpeakingFrame (the goodbye)
B->>CE: on_bot_started() — speaking_since_arm = true
T-->>B: BotStoppedSpeakingFrame
B->>CE: on_bot_stopped()
Note over CE: playout grace 400 ms
CE->>W: EndWorkerFrame (downstream, once)
W-->>B: on_pipeline_finished
Note over B: session row: disposition, ended_by=ori
The rules that came from four failed fixes:
- End only on audio that started after arming. A barge-in on the previous line used to deliver that line's audio-stopped after the decision and end the call before the goodbye.
- A barge-in on the goodbye holds the line for what he has to say; the watchdog still bounds the call.
- Every path arms the same object: a model goodbye, a canned goodbye, the
give-up line, and abuse on the second strike. A second arm, a client
disconnect after the end, or an idle timeout is a no-op that still records
ended_by.
OBSERVED on 2026-09-10 (poc/e2e.py, vendor-goodbye scenario): the session
record shows ended_by=ori 406 ms after the last bot_stopped_speaking.
The turn boundary
sequenceDiagram
participant V as vendor
participant F as Flux
participant B as OriBrain
V->>F: "तो मेरा rate लगभग …" (pause)
F->>B: TranscriptionFrame "तो मेरा rate लगभग"
Note over B: utterance_may_continue() → "ends on a dangling लगभग"
Note over B: hold 1.5 s
V->>F: "… तेईस हज़ार के आस पास"
F->>B: TranscriptionFrame
B->>B: merge → "तो मेरा rate लगभग तेईस हज़ार के आस पास"
Note over B: hear_rate → 23,000 · route → rate · phase → PROBING
Flux's end-of-turn confidence is a probability; the guard is a rule. When the
guard fires, the fragment is held for hold_s (1.5 s) and the continuation is
merged before anything is decided. If nothing arrives, the fragment is decided
on as it stands. The eot_threshold knob (0.8) is documented, not measured —
the bench corpus is clean single utterances and cannot reproduce a pause before
a number.
What the model receives
For a non-money turn, in this order: the load brief (route, vehicle, weight,
material, pickup, who she is calling — spoken forms only), the last three
exchanges with her own figures stripped, what he just said, and the brief for
the move. For a money turn, no load brief. Never a rupee figure, never a
threshold, never the ceiling. The one figure the model may voice arrives as
the literal token {rate}, substituted after generation.