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.