We need to implement the **Transfer Outcome API** (Stage A and Stage B) on the backend side, using the agent configuration we already have stored. Here's what Weneed to build: ## **1\. GetTransferMetadata Endpoint (Stage A)** When the PBX calls `GET /api/Transfers/GetTransferMetadata/{conversationId}` after the AI agent decides to transfer, our backend needs to: * Look up the conversation by `conversation_id` to find the associated agent * Read the agent's config (the JSON you showed) to find the `forward_number` event type * Check if the current time falls within `fromHours`/`toHours` in the specified `timezone`(As per today meeting this is not need to check from my side). * Return the **first phone number** from `phone_numbers` array, along with the rules (ring\_timeout, max\_retries, etc.) * Store a transfer session (which number index we're on, attempt count) — likely in Redis since this is real-time The response would map our config like this: `transferNumber` → first phone\_number, `transferTrunk` → its sip\_trunk, `timeoutSec` → `rules.ring_timeout`, `maxAttempts` → `rules.max_retries`, `fallbackAction` → `rules.fallback` (map `ai_agent` → `resume_ai`, `hang_up` → `hangup`). ## **2\. ReportTransferOutcome Endpoint (Stage B)** When the PBX reports back with a `dialstatus` (BUSY, NOANSWER, CHANUNAVAIL, etc.), our backend needs to: * Look up the transfer session state (current phone number index, attempt count) * Map the `dialstatus` to the per-number rules from your config. For example, if calling phone\_number\[0\] and dialstatus is `BUSY`, check `phone_numbers[0].rules.busy` — which says `"retry"`, so you return `retry_same`. If it says `"ai_agent"`, return `resume_ai`. If `"hang_up"`, return `hangup`. * If the rule says `retry` and you've exceeded `max_retries`, move to the **next phone number** in the list (`dial_next`) * If all numbers are exhausted, use the top-level `rules.fallback` * **Store the decision** in a `transfer_outcomes` table for idempotency — same `(conversation_id, attempt)` always returns the same response ## **3\. What We Need to Create** **Database table:** ```sql class TransferOutcome(db.Model): """ Stores each transfer attempt decision for idempotency. Primary Key: (conversation_id, attempt) ensures same input → same output. """ __tablename__ = "transfer_outcomes" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) conversation_id = Column(String(64), nullable=False, index=True) tenant_id = Column(String(64), nullable=False, index=True) attempt = Column(Integer, nullable=False) # What the PBX reported dialed_number = Column(String(32), nullable=True) dialed_trunk = Column(String(64), nullable=True) dialstatus = Column(String(20), nullable=True) # BUSY, NOANSWER, CHANUNAVAIL, ANSWER, CANCEL, CONGESTION hangupcause_q850 = Column(Integer, nullable=True) tech_cause = Column(String(128), nullable=True) hangup_source = Column(String(128), nullable=True) # What the backend decided decision_action = Column(String(20), nullable=True) # hangup, retry_same, dial_next, switch_trunk, resume_ai decision_number = Column(String(32), nullable=True) decision_trunk = Column(String(64), nullable=True) decision_timeout_sec = Column(Integer, nullable=True) decision_wait_ms = Column(Integer, nullable=True) decision_message = Column(String(256), nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) __table_args__ = ( Index("idx_transfer_idempotency", "conversation_id", "attempt", unique=True), ) ``` ```sql class TransferSession(db.Model): """ Tracks the active transfer session state for a conversation. This replaces/supplements Redis for persistent state tracking. """ __tablename__ = "transfer_sessions" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) conversation_id = Column(String(64), nullable=False, unique=True, index=True) tenant_id = Column(String(64), nullable=False, index=True) agent_id = Column(String(64), nullable=True) # Transfer policy from agent config event_type = Column(String(32), nullable=True) # forward_number ring_timeout = Column(Integer, default=30) max_retries = Column(Integer, default=3) retry_delay = Column(Integer, default=3) # seconds fallback_action = Column(String(20), default="hangup") # hangup or resume_ai # Phone numbers list (stored as JSON) phone_numbers = Column(JSON, nullable=True) # Current state current_number_index = Column(Integer, default=0) current_retry_count = Column(Integer, default=0) total_attempts = Column(Integer, default=0) is_active = Column(Boolean, default=True) final_status = Column(String(20), nullable=True) # success, exhausted, cancelled created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) ``` **Redis state** for active transfers (keyed by conversation\_id): ```json { "current_number_index": 0, "retry_count": 0, "agent_config": { ... }, "fallback_action": "resume_ai" } ``` **Service layer** — a new `TransferService` (or add to `KalimeraCallLogService`) with methods like `get_transfer_metadata(conversation_id)` and `report_transfer_outcome(conversation_id, attempt, dialstatus, ...)` that contain the decision logic. **Two new routes** in our API — either as new actions in our existing `KalimeraCallConversation.post()` or as a separate resource class. Given the spec uses different HTTP methods and paths, a separate resource makes more sense. ## **4\. Decision Logic (Core of Stage B)** ``` 1. Check idempotency table — if (conversation_id, attempt) exists, return stored decision 2. Get transfer state from Redis 3. Map dialstatus → per-number rule (busy/no_answer/unavailable) 4. If rule == "retry" and retry_count < max_retries → retry_same (with retry_delay as waitMs) 5. If rule == "retry" but retries exhausted → move to next number (dial_next) 6. If rule == "ai_agent" → resume_ai 7. If rule == "hang_up" → hangup 8. If no more numbers → use top-level fallback 9. Store decision in DB, update Redis state, return response ``` ## **Why 2 Tables?** **`transfer_sessions`** \= "Where are we RIGHT NOW in the transfer process?" It tracks the **live state**: which phone number are we currently trying, how many retries have we done on this number, what's the fallback if everything fails. This gets **updated** on every attempt. **`transfer_outcomes`** \= "What happened on attempt \#1, \#2, \#3...?" It stores **each individual attempt and the decision we made**. This is the idempotency table — if the PBX sends the same request twice (network glitch), we look here and return the exact same answer. ## **The Full Flow (Step by Step)** Let me walk through a real scenario using your agent config: ``` Phone numbers: ["3456" on Sip Test1111, "7890" on Sip Test1111] Rules: ring_timeout=25, max_retries=2, retry_delay=3, fallback=ai_agent Phone "3456" rules: no_answer→ai_agent, busy→retry, unavailable→retry Phone "7890" rules: no_answer→retry, busy→ai_agent, unavailable→hang_up ``` ### **Step 1: AI Agent decides to transfer** The AI agent on the call decides "I need to transfer this call to a human." It closes the AudioSocket connection to the PBX. ### **Step 2: PBX calls Stage A** ``` GET /api/Transfers/GetTransferMetadata/conv-123 ``` **What the code does:** 1. Finds conversation `conv-123` in the database 2. Gets the agent linked to this conversation 3. Reads the agent's config JSON (As above Given) 4. Finds the `forward_number` event type 5. Checks: is current time between `01:02` and `04:04` in `America/Vancouver`? If no → returns `shouldHangup: true` 6. If yes → creates a `TransferSession` row: ``` conversation_id: conv-123current_number_index: 0 (pointing to "3456")current_retry_count: 0max_retries: 2phone_numbers: [the full array from config] ``` 7. Returns to PBX: ```json { "shouldHangup": false, "transferNumber": "3456", "transferTrunk": "Sip Test1111", "timeoutSec": 25, "maxAttempts": 2, "fallbackAction": "resume_ai"} ``` ### **Step 3: PBX dials "3456", it's BUSY** PBX calls Stage B: ``` POST /api/Transfers/report-outcome { "conversation_id": "conv-123", "attempt": 1, "dialstatus": "BUSY" } ``` **What the code does:** 1. Checks idempotency: any existing row for `(conv-123, attempt=1)`? No → continue 2. Loads the `TransferSession`: current\_number\_index=0 (phone "3456") 3. Maps `BUSY` → rule key `"busy"` 4. Looks at phone "3456"'s rules: `busy → "retry"` 5. Checks: `current_retry_count (0) < max_retries (2)`? Yes → **retry\_same** 6. Updates session: `current_retry_count = 1` 7. Stores in `transfer_outcomes`: `(conv-123, attempt=1) → retry_same` 8. Returns: ```json { "action": "retry_same", "waitMs": 3000, "timeoutSec": 25, "message": "BUSY — retrying same number (attempt 1/2)"} ``` ### **Step 4: PBX dials "3456" again, still BUSY** ``` POST: { "conversation_id": "conv-123", "attempt": 2, "dialstatus": "BUSY" } ``` **What happens:** 1. Idempotency check: no row for `(conv-123, attempt=2)` → continue 2. Session: current\_number\_index=0, current\_retry\_count=1 3. `BUSY` → `busy` → phone "3456" says `"retry"` 4. Check: `current_retry_count (1) < max_retries (2)`? Yes → **retry\_same** 5. Updates: `current_retry_count = 2` 6. Returns: `retry_same` with waitMs=3000 ### **Step 5: PBX dials "3456" a third time, BUSY again** ``` POST: { "conversation_id": "conv-123", "attempt": 3, "dialstatus": "BUSY" } ``` **What happens:** 1. Session: current\_retry\_count=2 2. `BUSY` → `busy` → "retry" 3. Check: `current_retry_count (2) < max_retries (2)`? **No, retries exhausted\!** 4. Code calls `_try_next_number()` → next\_idx \= 1, which is phone "7890" 5. Updates session: `current_number_index = 1`, `current_retry_count = 0` 6. Returns: ```json { "action": "dial_next", "nextNumber": "7890", "nextTrunk": "Sip Test1111", "timeoutSec": 25, "message": "BUSY — trying next number (7890)"} ``` ### **Step 6: PBX dials "7890", it's BUSY** ``` POST: { "conversation_id": "conv-123", "attempt": 4, "dialstatus": "BUSY" } ``` **What happens:** 1. Session: current\_number\_index=1 (phone "7890") 2. `BUSY` → `busy` → phone "7890" says `"ai_agent"` 3. This maps directly to **resume\_ai** (no retries, the rule says go to AI) 4. Closes session: `is_active = False, final_status = "exhausted"` 5. Returns: ```json { "action": "resume_ai", "message": "BUSY — returning to AI agent"} ``` The PBX reconnects AudioSocket, and the AI says something like "They're unavailable right now, would you like to leave a message?" ### **Idempotency Example** Now imagine on Step 3, the PBX had a network timeout and retries the same request: ``` POST: { "conversation_id": "conv-123", "attempt": 1, "dialstatus": "BUSY" } ``` The code hits the idempotency check first — finds the existing `transfer_outcomes` row for `(conv-123, attempt=1)` — and returns the **exact same response** without touching the session state. This prevents the retry count from being incorrectly incremented.