Skip to content

Commit ff9c262

Browse files
Convert call routes and session runtime to async PostgreSQL
1 parent 1bc8f08 commit ff9c262

5 files changed

Lines changed: 130 additions & 68 deletions

File tree

backend/app/api/routes/call.py

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
from typing import Optional
33

44
import httpx
5-
from fastapi import APIRouter, HTTPException, Request
5+
from fastapi import APIRouter, Depends, HTTPException, Request
66
from fastapi.responses import Response
77
from pydantic import BaseModel, Field
8+
from sqlalchemy.ext.asyncio import AsyncSession
89

910
from app.config import settings
11+
from app.database import get_db
1012
from app.integrations.phone import normalize_phone_number
1113
from app.integrations.twilio_client import (
1214
TwilioConfigError,
@@ -15,8 +17,9 @@
1517
start_outbound_call,
1618
)
1719
from app.models import CompleteResponse, Session, Worker, new_id, utc_now_iso
20+
from app.services import store
1821
from app.services.session_runtime import complete_session, create_session, require_session, update_session
19-
from app.services.store import call_session_index, sessions, workers
22+
from app.services.store import call_session_index
2023

2124
router = APIRouter(tags=["call"])
2225

@@ -35,23 +38,23 @@ class TwilioStatusResponse(BaseModel):
3538
status: Optional[str] = None
3639

3740

38-
def _find_worker_by_phone(phone_number: str) -> Optional[Worker]:
41+
async def _find_worker_by_phone(db: AsyncSession, phone_number: str) -> Optional[Worker]:
3942
normalized = normalize_phone_number(phone_number)
40-
for worker in workers.values():
41-
if normalize_phone_number(worker.phone_number or "") == normalized:
42-
return worker
43-
return None
43+
if not normalized:
44+
return None
45+
return await store.get_worker_by_phone(db, normalized)
4446

4547

46-
def _upsert_worker(
48+
async def _upsert_worker(
49+
db: AsyncSession,
4750
*,
4851
phone_number: Optional[str],
4952
worker_name: Optional[str],
5053
specialization: Optional[str],
5154
experience_years: int,
5255
) -> Worker:
5356
normalized_phone = normalize_phone_number(phone_number)
54-
existing = _find_worker_by_phone(normalized_phone or "") if normalized_phone else None
57+
existing = await _find_worker_by_phone(db, normalized_phone or "") if normalized_phone else None
5558
if existing:
5659
updates = {}
5760
if worker_name:
@@ -61,7 +64,7 @@ def _upsert_worker(
6164
updates["experience_years"] = max(existing.experience_years, experience_years)
6265
if updates:
6366
updated = existing.model_copy(update=updates)
64-
workers[existing.id] = updated
67+
await store.update_worker(db, updated)
6568
return updated
6669
return existing
6770

@@ -73,7 +76,7 @@ def _upsert_worker(
7376
phone_number=normalized_phone,
7477
created_at=utc_now_iso(),
7578
)
76-
workers[worker.id] = worker
79+
await store.create_worker(db, worker)
7780
return worker
7881

7982

@@ -94,15 +97,20 @@ def _build_greeting_text(session: Session) -> str:
9497

9598

9699
@router.post("/calls/twilio/start")
97-
async def start_twilio_screening_call(payload: TwilioCallStartRequest):
98-
worker = _upsert_worker(
100+
async def start_twilio_screening_call(
101+
payload: TwilioCallStartRequest,
102+
db: AsyncSession = Depends(get_db),
103+
):
104+
worker = await _upsert_worker(
105+
db,
99106
phone_number=payload.phone_number,
100107
worker_name=payload.worker_name,
101108
specialization=payload.specialization,
102109
experience_years=payload.experience_years,
103110
)
104111
assignment = (payload.assignment or settings.twilio_default_assignment).strip()
105-
session, first_question = create_session(
112+
session, first_question = await create_session(
113+
db,
106114
worker,
107115
assignment,
108116
interview_mode="call",
@@ -122,7 +130,8 @@ async def start_twilio_screening_call(payload: TwilioCallStartRequest):
122130
except httpx.HTTPError as exc:
123131
raise HTTPException(status_code=502, detail=f"Twilio call start failed: {exc}") from exc
124132

125-
session = update_session(
133+
session = await update_session(
134+
db,
126135
session.id,
127136
external_call_id=outbound.get("sid"),
128137
external_call_status=outbound.get("status") or "queued",
@@ -143,45 +152,46 @@ async def start_twilio_screening_call(payload: TwilioCallStartRequest):
143152

144153

145154
@router.api_route("/calls/twilio/twiml", methods=["GET", "POST"])
146-
async def twilio_twiml(request: Request) -> Response:
155+
async def twilio_twiml(
156+
request: Request,
157+
db: AsyncSession = Depends(get_db),
158+
) -> Response:
147159
query = dict(request.query_params)
148160
body = dict(await request.form()) if request.method == "POST" else {}
149161
payload = {**query, **body}
150162
session_id = payload.get("session_id")
151163
if not session_id:
152164
raise HTTPException(status_code=400, detail="session_id is required")
153165

154-
session = require_session(str(session_id))
166+
session = await require_session(db, str(session_id))
155167
return _twiml_response(_build_greeting_text(session))
156168

157169

158170
@router.api_route("/calls/twilio/incoming", methods=["GET", "POST"])
159-
async def twilio_incoming_webhook(request: Request) -> Response:
160-
"""
161-
Incoming voice webhook used when a caller dials your Twilio number directly.
162-
163-
Creates a call-mode session on-the-fly and responds with TwiML that
164-
speaks the phone-assessment greeting and the first interview question.
165-
"""
171+
async def twilio_incoming_webhook(
172+
request: Request,
173+
db: AsyncSession = Depends(get_db),
174+
) -> Response:
166175
query = dict(request.query_params)
167176
body = dict(await request.form()) if request.method == "POST" else {}
168177
payload: dict[str, object] = {**query, **body}
169178

170-
# Twilio sends From (caller) + CallSid on voice webhooks.
171179
from_number = payload.get("From") or payload.get("from") or payload.get("Caller")
172180
call_sid = extract_twilio_sid(payload)
173181
if not from_number:
174182
raise HTTPException(status_code=400, detail="From number is required")
175183

176-
worker = _upsert_worker(
184+
worker = await _upsert_worker(
185+
db,
177186
phone_number=str(from_number),
178187
worker_name="श्रमिक",
179188
specialization=None,
180189
experience_years=0,
181190
)
182191

183192
assignment = settings.twilio_default_assignment.strip()
184-
session, _ = create_session(
193+
session, _ = await create_session(
194+
db,
185195
worker,
186196
assignment,
187197
interview_mode="call",
@@ -193,14 +203,16 @@ async def twilio_incoming_webhook(request: Request) -> Response:
193203
)
194204

195205
if call_sid:
196-
# Enables /calls/twilio/status to resolve the session even without session_id query params.
197206
call_session_index[call_sid] = session.id
198207

199208
return _twiml_response(_build_greeting_text(session))
200209

201210

202211
@router.api_route("/calls/twilio/status", methods=["GET", "POST"], response_model=TwilioStatusResponse)
203-
async def twilio_status_callback(request: Request) -> TwilioStatusResponse:
212+
async def twilio_status_callback(
213+
request: Request,
214+
db: AsyncSession = Depends(get_db),
215+
) -> TwilioStatusResponse:
204216
body = await request.form() if request.method == "POST" else {}
205217
payload = {**dict(request.query_params), **dict(body)}
206218
call_sid = extract_twilio_sid(payload)
@@ -212,7 +224,7 @@ async def twilio_status_callback(request: Request) -> TwilioStatusResponse:
212224
resolved_session_id = None
213225
if session_id:
214226
try:
215-
require_session(str(session_id))
227+
await require_session(db, str(session_id))
216228
resolved_session_id = str(session_id)
217229
except HTTPException:
218230
resolved_session_id = None
@@ -226,18 +238,20 @@ async def twilio_status_callback(request: Request) -> TwilioStatusResponse:
226238
except ValueError:
227239
parsed_duration = None
228240

229-
session = update_session(
241+
current = await require_session(db, resolved_session_id)
242+
session = await update_session(
243+
db,
230244
resolved_session_id,
231-
external_call_id=call_sid or require_session(resolved_session_id).external_call_id,
232-
external_call_status=str(status) if status else require_session(resolved_session_id).external_call_status,
233-
call_duration_seconds=parsed_duration or require_session(resolved_session_id).call_duration_seconds,
234-
latest_call_recording_url=str(recording_url) if recording_url else require_session(resolved_session_id).latest_call_recording_url,
245+
external_call_id=call_sid or current.external_call_id,
246+
external_call_status=str(status) if status else current.external_call_status,
247+
call_duration_seconds=parsed_duration or current.call_duration_seconds,
248+
latest_call_recording_url=str(recording_url) if recording_url else current.latest_call_recording_url,
235249
)
236250
if call_sid:
237251
call_session_index[call_sid] = session.id
238252

239253
terminal_statuses = {"completed", "busy", "failed", "no-answer", "canceled"}
240254
if (session.external_call_status or "").lower() in terminal_statuses and session.status == "live":
241-
session = complete_session(session.id, locale="hi")
255+
session = await complete_session(db, session.id, locale="hi")
242256

243257
return TwilioStatusResponse(ok=True, session_id=session.id, status=session.external_call_status)

backend/app/db_models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class WorkerDB(Base):
1212
name: Mapped[str] = mapped_column(String(80), nullable=False)
1313
specialization: Mapped[str] = mapped_column(String(120), nullable=False)
1414
experience_years: Mapped[int] = mapped_column(Integer, nullable=False)
15+
phone_number: Mapped[str] = mapped_column(String(30), nullable=True)
1516
created_at: Mapped[datetime] = mapped_column(
1617
DateTime(timezone=True),
1718
default=lambda: datetime.now(timezone.utc),
@@ -43,3 +44,10 @@ class SessionDB(Base):
4344
integrity_events: Mapped[dict] = mapped_column(JSON, default=list)
4445
current_phase: Mapped[str] = mapped_column(String(50), default="intro")
4546
self_ratings: Mapped[dict] = mapped_column(JSON, default=dict)
47+
interview_mode: Mapped[str] = mapped_column(String(20), default="web", nullable=True)
48+
call_provider: Mapped[str] = mapped_column(String(30), nullable=True)
49+
call_phone_number: Mapped[str] = mapped_column(String(30), nullable=True)
50+
external_call_id: Mapped[str] = mapped_column(String(100), nullable=True)
51+
external_call_status: Mapped[str] = mapped_column(String(30), nullable=True)
52+
call_duration_seconds: Mapped[int] = mapped_column(Integer, nullable=True)
53+
latest_call_recording_url: Mapped[str] = mapped_column(Text, nullable=True)

backend/app/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class Worker(BaseModel):
1818
name: str
1919
specialization: str
2020
experience_years: int
21+
phone_number: Optional[str] = None
2122
created_at: str
2223

2324

@@ -124,6 +125,13 @@ class Session(BaseModel):
124125
phase0_profile: Dict[str, Any] = Field(default_factory=dict)
125126
phase0_completed: bool = False
126127
portfolio_enrichment: List[PortfolioItem] = Field(default_factory=list)
128+
interview_mode: Optional[str] = "web"
129+
call_provider: Optional[str] = None
130+
call_phone_number: Optional[str] = None
131+
external_call_id: Optional[str] = None
132+
external_call_status: Optional[str] = None
133+
call_duration_seconds: Optional[int] = None
134+
latest_call_recording_url: Optional[str] = None
127135

128136

129137
class PriorWorkMediaRequest(BaseModel):

backend/app/services/session_runtime.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from fastapi import HTTPException
2+
from sqlalchemy.ext.asyncio import AsyncSession
23

34
from app.agents.screening_logic import choose_opening_question, finalize_session, run_agent_turn
45
from app.models import (
@@ -9,10 +10,11 @@
910
new_id,
1011
utc_now_iso,
1112
)
12-
from app.services.store import sessions
13+
from app.services import store
1314

1415

15-
def create_session(
16+
async def create_session(
17+
db: AsyncSession,
1618
worker: Worker,
1719
assignment: str,
1820
*,
@@ -26,7 +28,6 @@ def create_session(
2628
first_question = choose_opening_question(
2729
worker.name,
2830
assignment,
29-
interview_mode=interview_mode,
3031
locale=locale,
3132
)
3233
session = Session(
@@ -48,26 +49,27 @@ def create_session(
4849
external_call_id=external_call_id,
4950
external_call_status=external_call_status,
5051
)
51-
sessions[session.id] = session
52+
await store.create_session(db, session)
5253
return session, first_question
5354

5455

55-
def require_session(session_id: str) -> Session:
56-
session = sessions.get(session_id)
56+
async def require_session(db: AsyncSession, session_id: str) -> Session:
57+
session = await store.get_session(db, session_id)
5758
if not session:
5859
raise HTTPException(status_code=404, detail="Session not found")
5960
return session
6061

6162

62-
def append_turn(
63+
async def append_turn(
64+
db: AsyncSession,
6365
session_id: str,
6466
worker_text: str,
6567
*,
6668
locale: str = "en",
6769
rubric_tag: str | None = None,
6870
acoustic_confidence: float | None = None,
6971
) -> tuple[Session, TurnResponse]:
70-
session = require_session(session_id)
72+
session = await require_session(db, session_id)
7173
if session.status != "live":
7274
raise HTTPException(status_code=400, detail="Session already completed")
7375

@@ -95,7 +97,7 @@ def append_turn(
9597
"live_score": new_score,
9698
}
9799
)
98-
sessions[session_id] = updated
100+
await store.update_session(db, updated)
99101
return updated, TurnResponse(
100102
ai_question=result["ai_reply"],
101103
coach_note="",
@@ -105,17 +107,17 @@ def append_turn(
105107
)
106108

107109

108-
def complete_session(session_id: str, *, locale: str = "en") -> Session:
109-
session = require_session(session_id)
110+
async def complete_session(db: AsyncSession, session_id: str, *, locale: str = "en") -> Session:
111+
session = await require_session(db, session_id)
110112
if session.status == "completed":
111113
return session
112114
completed = finalize_session(session, locale)
113-
sessions[session_id] = completed
115+
await store.update_session(db, completed)
114116
return completed
115117

116118

117-
def update_session(session_id: str, **updates: object) -> Session:
118-
session = require_session(session_id)
119+
async def update_session(db: AsyncSession, session_id: str, **updates: object) -> Session:
120+
session = await require_session(db, session_id)
119121
updated = session.model_copy(update=updates)
120-
sessions[session_id] = updated
122+
await store.update_session(db, updated)
121123
return updated

0 commit comments

Comments
 (0)