From 145fe0461558b3eaf1b4fd76556201f98c591949 Mon Sep 17 00:00:00 2001 From: Jiya Gupta Date: Thu, 7 May 2026 15:17:08 +0530 Subject: [PATCH] feat: implement MVP for multilingual AI voice-based student re-engagement platform --- .gitignore | 36 +++ README.md | 60 ++++- app.py | 227 ++++++++++++++++++ database/calls.db | Bin 0 -> 12288 bytes database/students.json | 62 +++++ requirements.txt | 5 + .../frappe_service.cpython-312.pyc | Bin 0 -> 3740 bytes .../logger_service.cpython-312.pyc | Bin 0 -> 3699 bytes .../nudging_engine.cpython-312.pyc | Bin 0 -> 2045 bytes .../rabbitmq_service.cpython-312.pyc | Bin 0 -> 3536 bytes .../__pycache__/vapi_service.cpython-312.pyc | Bin 0 -> 3145 bytes services/frappe_service.py | 82 +++++++ services/logger_service.py | 79 ++++++ services/nudging_engine.py | 52 ++++ services/rabbitmq_service.py | 64 +++++ services/trigger_calls.py | 99 ++++++++ services/vapi_service.py | 92 +++++++ 17 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 database/calls.db create mode 100644 database/students.json create mode 100644 requirements.txt create mode 100644 services/__pycache__/frappe_service.cpython-312.pyc create mode 100644 services/__pycache__/logger_service.cpython-312.pyc create mode 100644 services/__pycache__/nudging_engine.cpython-312.pyc create mode 100644 services/__pycache__/rabbitmq_service.cpython-312.pyc create mode 100644 services/__pycache__/vapi_service.cpython-312.pyc create mode 100644 services/frappe_service.py create mode 100644 services/logger_service.py create mode 100644 services/nudging_engine.py create mode 100644 services/rabbitmq_service.py create mode 100644 services/trigger_calls.py create mode 100644 services/vapi_service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e2339c --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Virtual environments +.venv/ +mvp/.venv/ + +# Environment files +.env + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ + +# PyInstaller +*.manifest +*.spec + +# VS Code settings +.vscode/ + +# macOS +.DS_Store + +# Logs +mvp/logs/ + +# SQLite DB +mvp/database/*.db + +# pytest +.pytest_cache/ + diff --git a/README.md b/README.md index 04e1239..f1b5277 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ -# C4GT_2026 \ No newline at end of file +# TAP Voice Re-Engagement Platform (MVP) + +AI-powered multilingual voice engagement platform for re-engaging inactive students using conversational voice agents. + +Built using: +- FastAPI +- VAPI +- RabbitMQ +- Deepgram STT +- Azure Speech TTS +- GPT-4o-mini + +--- + +# Overview + +Student drop-off after onboarding is one of the biggest challenges in large-scale government learning deployments. + +This project implements an MVP for an AI-powered multilingual voice engagement platform that proactively interacts with inactive students and parents through conversational voice sessions. + +The system: +- detects inactive students, +- personalizes engagement conversations, +- supports multilingual voice interactions, +- enables scalable async campaign orchestration. + +--- + +# Features + +## Voice Engagement +- AI-powered conversational voice sessions +- Personalized student interactions +- Browser-based voice testing +- VAPI voice orchestration + +## Multilingual Support +- Hindi +- Marathi +- Punjabi +- English + +## Backend Infrastructure +- FastAPI orchestration backend +- RabbitMQ async queue support +- Worker-ready architecture +- Modular service design + +## Data Layer +- Dummy student dataset support +- Optional Frappe LMS integration +- Fallback data loading strategy + +## Analytics +- Call/session logging +- Engagement tracking foundations +- Retry/escalation workflow foundations + +--- diff --git a/app.py b/app.py new file mode 100644 index 0000000..d921bf9 --- /dev/null +++ b/app.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +import os +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +from fastapi import FastAPI + +from .services.frappe_service import fetch_students_from_frappe +from .services.logger_service import ( + configure_logging, + get_logger, + init_database, + log_call_activity, +) +from .services.nudging_engine import filter_inactive_students, validate_student_record +from .services.vapi_service import trigger_outbound_call + +BASE_DIR = Path(__file__).resolve().parent +STUDENTS_FILE = BASE_DIR / "database" / "students.json" +DATABASE_FILE = BASE_DIR / "database" / "calls.db" +LOGS_DIR = BASE_DIR / "logs" + +logger = get_logger() + + +def load_env_file(env_path: Path | None = None) -> None: + env_file = env_path or (BASE_DIR / ".env") + if not env_file.exists(): + return + + for raw_line in env_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + + if key and key not in os.environ: + os.environ[key] = value + + +def read_students_from_json() -> list[dict[str, Any]]: + if not STUDENTS_FILE.exists(): + logger.error("students.json not found at %s", STUDENTS_FILE) + return [] + + try: + with STUDENTS_FILE.open("r", encoding="utf-8") as file: + payload = json.load(file) + except json.JSONDecodeError as exc: + logger.error("Invalid JSON in students.json: %s", exc) + return [] + except Exception as exc: + logger.error("Failed to read students.json: %s", exc) + return [] + + if not isinstance(payload, list): + logger.error("students.json must contain a JSON array") + return [] + + valid_students: list[dict[str, Any]] = [] + for item in payload: + student = validate_student_record(item) + if student: + valid_students.append(student) + + return valid_students + + +def load_students() -> tuple[list[dict[str, Any]], str]: + frappe_students = fetch_students_from_frappe() + if frappe_students: + valid_students: list[dict[str, Any]] = [] + for item in frappe_students: + student = validate_student_record(item) + if student: + valid_students.append(student) + if valid_students: + logger.info("Loaded %d students from Frappe", len(valid_students)) + return valid_students, "frappe" + + json_students = read_students_from_json() + logger.info("Loaded %d students from JSON fallback", len(json_students)) + return json_students, "json" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + load_env_file() + configure_logging(LOGS_DIR) + init_database(DATABASE_FILE) + + students, source = load_students() + app.state.students = students + app.state.students_source = source + + logger.info("Application initialized with %d students from %s", len(students), source) + yield + + +app = FastAPI( + title="AI-Powered Multilingual Student Re-engagement MVP", + version="1.0.0", + lifespan=lifespan, +) + + +@app.get("/") +def root() -> dict[str, Any]: + students = getattr(app.state, "students", []) + source = getattr(app.state, "students_source", "unknown") + inactive_students = filter_inactive_students(students) + + return { + "status": "ok", + "project": "AI-powered multilingual student re-engagement voice agent", + "students_loaded": len(students), + "inactive_students": len(inactive_students), + "inactive_threshold_days": 5, + "data_source": source, + } + + +@app.get("/students") +def get_students() -> dict[str, Any]: + students = getattr(app.state, "students", []) + return { + "count": len(students), + "students": students, + } + + +@app.get("/trigger-calls") +def trigger_calls() -> dict[str, Any]: + students = getattr(app.state, "students", []) + inactive_students = filter_inactive_students(students) + + if not inactive_students: + return { + "status": "success", + "message": "No inactive students found for calling", + "triggered": 0, + "results": [], + } + + results: list[dict[str, Any]] = [] + + for student in inactive_students: + student_name = student["name"] + logger.info("Triggering call for %s", student_name) + + try: + response = trigger_outbound_call(student) + call_status = "initiated" if response.get("success") else "failed" + + log_call_activity( + database_path=DATABASE_FILE, + student_id=str(student["student_id"]), + phone=str(student["phone"]), + call_status=call_status, + ) + + if response.get("success"): + logger.info("Call initiated for %s", student_name) + else: + logger.error("Failed to call student %s", student_name) + + results.append( + { + "student_id": student["student_id"], + "name": student_name, + "phone": student["phone"], + "status": call_status, + "vapi_response": response, + } + ) + except Exception as exc: + log_call_activity( + database_path=DATABASE_FILE, + student_id=str(student["student_id"]), + phone=str(student["phone"]), + call_status="failed", + ) + logger.error("Failed to call student %s: %s", student_name, exc) + + results.append( + { + "student_id": student["student_id"], + "name": student_name, + "phone": student["phone"], + "status": "failed", + "error": str(exc), + } + ) + + return { + "status": "completed", + "triggered": len(inactive_students), + "results": results, + } + + +@app.get("/health") +def health_check() -> dict[str, Any]: + students = getattr(app.state, "students", []) + env_status = { + "VAPI_API_KEY": bool(os.getenv("VAPI_API_KEY")), + "VAPI_ASSISTANT_ID": bool(os.getenv("VAPI_ASSISTANT_ID")), + "VAPI_PHONE_NUMBER_ID": bool(os.getenv("VAPI_PHONE_NUMBER_ID")), + "RABBITMQ_URL": bool(os.getenv("RABBITMQ_URL")), + "RABBITMQ_QUEUE": bool(os.getenv("RABBITMQ_QUEUE")), + "FRAPPE_URL": bool(os.getenv("FRAPPE_URL")), + "FRAPPE_API_KEY": bool(os.getenv("FRAPPE_API_KEY")), + "FRAPPE_API_SECRET": bool(os.getenv("FRAPPE_API_SECRET")), + } + + return { + "status": "healthy", + "students_loaded": len(students), + "database_ready": DATABASE_FILE.exists(), + "env_status": env_status, + } \ No newline at end of file diff --git a/database/calls.db b/database/calls.db new file mode 100644 index 0000000000000000000000000000000000000000..d46ea1d1f6baadde80f549ca62289865f28ab865 GIT binary patch literal 12288 zcmeI#%}T>S5C`zxR1^h^x1OYPY-#c03m9dQV4Bvr1#=3qT{V!8)@*%%;>C;4=%ctP zwvhCYd-)G+k_j`*=9kkvJ-V@0^qPO9k)~^Q!8m6(M2smC9!I zv~}kTTMCl)tt+Tj+smR<4NYf%eaTNc-7fz+-RwDwk|a#>w_^8TRrcAvrrsIVBA%1u zNpUZHn)uGh_5*ql0ohYIc09AoNO*F%*H!4%TxB{m-BLVD^2TOOU3b57@t$X@sc3V+ zLPvU4G?nT&Ri-jsR0}I7?3c5-8Tt_rfB*y_009U<00Izz00bZa0SFwh0OtP#{<-)x Y2tWV=5P$##AOHafKmY;|fWW`NADNJCS^xk5 literal 0 HcmV?d00001 diff --git a/database/students.json b/database/students.json new file mode 100644 index 0000000..92b4784 --- /dev/null +++ b/database/students.json @@ -0,0 +1,62 @@ +[ + { + "student_id": "S1001", + "name": "Ravi Kumar", + "phone": "+919876543210", + "language": "Hindi", + "course": "Python Basics", + "days_inactive": 7, + "progress": 42, + "pending_assignments": 3 + }, + { + "student_id": "S1001", + "name": "Ravi Kumar", + "phone": "+919876543210", + "language": "Hindi", + "course": "Python Basics", + "days_inactive": 7, + "progress": 42, + "pending_assignments": 3 + }, + { + "student_id": "S1002", + "name": "Ayesha Khan", + "phone": "+919812345671", + "language": "English", + "course": "Data Structures", + "days_inactive": 2, + "progress": 68, + "pending_assignments": 1 + }, + { + "student_id": "S1003", + "name": "Meera Iyer", + "phone": "+919900112233", + "language": "Tamil", + "course": "Web Development", + "days_inactive": 10, + "progress": 31, + "pending_assignments": 4 + }, + { + "student_id": "S1004", + "name": "Arjun Das", + "phone": "+919700223344", + "language": "Bengali", + "course": "Machine Learning", + "days_inactive": 5, + "progress": 55, + "pending_assignments": 2 + }, + { + "student_id": "S1005", + "name": "Sofia Morales", + "phone": "+14155550123", + "language": "Spanish", + "course": "AI Fundamentals", + "days_inactive": 1, + "progress": 84, + "pending_assignments": 0 + } +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b4eda2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115.0 +uvicorn>=0.30.0 +requests>=2.32.0 +pydantic>=2.8.0 +pika>=1.3.0 diff --git a/services/__pycache__/frappe_service.cpython-312.pyc b/services/__pycache__/frappe_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94fc93fe93e2ac86556f916cb4cbe3b6fca8e22b GIT binary patch literal 3740 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 literal 0 HcmV?d00001 diff --git a/services/__pycache__/nudging_engine.cpython-312.pyc b/services/__pycache__/nudging_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7883ce80585cce59910ca74bc7472e276269a88 GIT binary patch literal 2045 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 US{`|Kxpydv+q*_nRJUpIe-Pj7)&Kwi literal 0 HcmV?d00001 diff --git a/services/frappe_service.py b/services/frappe_service.py new file mode 100644 index 0000000..023d14b --- /dev/null +++ b/services/frappe_service.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import os +from typing import Any + +import requests + +from .logger_service import get_logger + +logger = get_logger() + + +def _extract_value(record: dict[str, Any], keys: list[str], default: Any = "") -> Any: + for key in keys: + value = record.get(key) + if value not in (None, ""): + return value + return default + + +def _map_frappe_student(record: dict[str, Any]) -> dict[str, Any] | None: + student = { + "student_id": _extract_value(record, ["student_id", "name", "id"]), + "name": _extract_value(record, ["student_name", "full_name", "name"]), + "phone": _extract_value(record, ["phone", "mobile_number", "contact_number", "mobile"]), + "language": _extract_value(record, ["language", "preferred_language"], "English"), + "course": _extract_value(record, ["course", "course_name", "program"]), + "days_inactive": _extract_value(record, ["days_inactive", "inactive_days"], 0), + "progress": _extract_value(record, ["progress", "completion", "course_progress"], 0), + "pending_assignments": _extract_value( + record, + ["pending_assignments", "assignments_pending", "pending_tasks"], + 0, + ), + } + + if not student["student_id"] or not student["name"] or not student["phone"] or not student["course"]: + return None + + return student + + +def fetch_students_from_frappe() -> list[dict[str, Any]]: + frappe_url = os.getenv("FRAPPE_URL", "").strip() + frappe_api_key = os.getenv("FRAPPE_API_KEY", "").strip() + frappe_api_secret = os.getenv("FRAPPE_API_SECRET", "").strip() + + if not frappe_url or not frappe_api_key or not frappe_api_secret: + logger.info("Frappe configuration not complete, skipping Frappe fetch") + return [] + + endpoint = frappe_url.rstrip("/") + "/api/resource/Student?limit_page_length=100" + headers = { + "Authorization": f"token {frappe_api_key}:{frappe_api_secret}", + "Accept": "application/json", + } + + try: + response = requests.get(endpoint, headers=headers, timeout=20) + response.raise_for_status() + payload = response.json() + except requests.RequestException as exc: + logger.error("Frappe fetch failed: %s", exc) + return [] + except ValueError as exc: + logger.error("Frappe returned invalid JSON: %s", exc) + return [] + + records = payload.get("data", []) + if not isinstance(records, list): + logger.error("Unexpected Frappe response format") + return [] + + students: list[dict[str, Any]] = [] + for record in records: + if not isinstance(record, dict): + continue + mapped_student = _map_frappe_student(record) + if mapped_student: + students.append(mapped_student) + + return students \ No newline at end of file diff --git a/services/logger_service.py b/services/logger_service.py new file mode 100644 index 0000000..772c2be --- /dev/null +++ b/services/logger_service.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +LOGGER_NAME = "student_reengagement_mvp" + + +def get_logger() -> logging.Logger: + return logging.getLogger(LOGGER_NAME) + + +def configure_logging(logs_dir: Path) -> None: + logs_dir.mkdir(parents=True, exist_ok=True) + logger = get_logger() + logger.setLevel(logging.INFO) + + if logger.handlers: + return + + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) + + file_handler = logging.FileHandler(logs_dir / "app.log", encoding="utf-8") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter( + logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") + ) + + logger.addHandler(console_handler) + logger.addHandler(file_handler) + logger.propagate = False + + +def init_database(database_path: Path) -> None: + database_path.parent.mkdir(parents=True, exist_ok=True) + + connection = sqlite3.connect(database_path) + try: + cursor = connection.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS call_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id TEXT NOT NULL, + phone TEXT NOT NULL, + call_status TEXT NOT NULL, + timestamp TEXT NOT NULL + ) + """ + ) + connection.commit() + finally: + connection.close() + + +def log_call_activity( + database_path: Path, + student_id: str, + phone: str, + call_status: str, +) -> None: + timestamp = datetime.now(timezone.utc).isoformat() + connection = sqlite3.connect(database_path) + try: + cursor = connection.cursor() + cursor.execute( + """ + INSERT INTO call_logs (student_id, phone, call_status, timestamp) + VALUES (?, ?, ?, ?) + """, + (student_id, phone, call_status, timestamp), + ) + connection.commit() + finally: + connection.close() \ No newline at end of file diff --git a/services/nudging_engine.py b/services/nudging_engine.py new file mode 100644 index 0000000..41a3ee1 --- /dev/null +++ b/services/nudging_engine.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +INACTIVE_THRESHOLD_DAYS = 5 + + +def validate_student_record(student: Any) -> dict[str, Any] | None: + if not isinstance(student, dict): + return None + + required_fields = [ + "student_id", + "name", + "phone", + "language", + "course", + "days_inactive", + "progress", + "pending_assignments", + ] + + missing_fields = [field for field in required_fields if field not in student] + if missing_fields: + return None + + try: + normalized_student = { + "student_id": str(student["student_id"]).strip(), + "name": str(student["name"]).strip(), + "phone": str(student["phone"]).strip(), + "language": str(student["language"]).strip(), + "course": str(student["course"]).strip(), + "days_inactive": int(student["days_inactive"]), + "progress": student["progress"], + "pending_assignments": int(student["pending_assignments"]), + } + except (TypeError, ValueError): + return None + + if not normalized_student["student_id"] or not normalized_student["name"]: + return None + + return normalized_student + + +def is_inactive_student(student: dict[str, Any]) -> bool: + return int(student.get("days_inactive", 0)) >= INACTIVE_THRESHOLD_DAYS + + +def filter_inactive_students(students: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [student for student in students if is_inactive_student(student)] \ No newline at end of file diff --git a/services/rabbitmq_service.py b/services/rabbitmq_service.py new file mode 100644 index 0000000..92fa7e0 --- /dev/null +++ b/services/rabbitmq_service.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import contextlib +import json +import os +from datetime import datetime, timezone +from typing import Any + +try: + import pika +except ImportError: # pragma: no cover - optional dependency during local editing + pika = None + +from .logger_service import get_logger + +logger = get_logger() + +DEFAULT_RABBITMQ_URL = "amqp://guest:guest@localhost:5672/%2F" +DEFAULT_RABBITMQ_QUEUE = "student_call_requests" + + +def build_call_request_message(student: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + return { + "student": student, + "vapi_payload": payload, + "queued_at": datetime.now(timezone.utc).isoformat(), + } + + +def publish_call_request(student: dict[str, Any], payload: dict[str, Any]) -> bool: + rabbitmq_url = os.getenv("RABBITMQ_URL", "").strip() + queue_name = os.getenv("RABBITMQ_QUEUE", DEFAULT_RABBITMQ_QUEUE).strip() or DEFAULT_RABBITMQ_QUEUE + + if not rabbitmq_url: + logger.info("RABBITMQ_URL not configured, skipping queue publish for %s", student["name"]) + return False + + if pika is None: + logger.warning("pika is not installed, skipping RabbitMQ publish for %s", student["name"]) + return False + + connection = None + try: + connection = pika.BlockingConnection(pika.URLParameters(rabbitmq_url)) + channel = connection.channel() + channel.queue_declare(queue=queue_name, durable=True) + channel.basic_publish( + exchange="", + routing_key=queue_name, + body=json.dumps(build_call_request_message(student, payload)).encode("utf-8"), + properties=pika.BasicProperties( + content_type="application/json", + delivery_mode=2, + ), + ) + logger.info("Queued call request for %s in RabbitMQ queue %s", student["name"], queue_name) + return True + except Exception as exc: + logger.warning("RabbitMQ publish failed for %s: %s", student["name"], exc) + return False + finally: + with contextlib.suppress(Exception): + if connection is not None: + connection.close() diff --git a/services/trigger_calls.py b/services/trigger_calls.py new file mode 100644 index 0000000..c25a3b2 --- /dev/null +++ b/services/trigger_calls.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +from .services.logger_service import get_logger, init_database, log_call_activity +from .services.nudging_engine import validate_student_record, filter_inactive_students +from .services.vapi_service import create_vapi_call + +logger = get_logger() +BASE = Path(__file__).resolve().parent +ENV_FILE = BASE / ".env" +STUDENTS_FILE = BASE / "students.json" +DB_FILE = BASE / "database" / "calls.db" + + +def load_env(env_path: Path | None = None) -> None: + path = env_path or ENV_FILE + if not path.exists(): + logger.info("No .env file found at %s", path) + return + + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k and k not in os.environ: + os.environ[k] = v + + +def load_students() -> list[dict[str, Any]]: + if not STUDENTS_FILE.exists(): + logger.error("students.json not found: %s", STUDENTS_FILE) + return [] + + try: + with STUDENTS_FILE.open("r", encoding="utf-8") as fh: + payload = json.load(fh) + except Exception as exc: + logger.error("Failed to read students.json: %s", exc) + return [] + + students: list[dict[str, Any]] = [] + if not isinstance(payload, list): + logger.error("students.json must be an array") + return [] + + for item in payload: + s = validate_student_record(item) + if s: + students.append(s) + + return students + + +def main() -> int: + load_env() + init_database(DB_FILE) + + students = load_students() + inactive = filter_inactive_students(students) + + if not inactive: + logger.info("No inactive students to call") + print("No inactive students found") + return 0 + + for student in inactive: + name = student.get("name") + logger.info("Triggering call for %s", name) + try: + resp = create_vapi_call(student) + except Exception as exc: + logger.error("Exception while calling %s: %s", name, exc) + log_call_activity(DB_FILE, str(student.get("student_id")), str(student.get("phone")), "failed") + print({"student_id": student.get("student_id"), "name": name, "status": "failed", "error": str(exc)}) + continue + + status = "initiated" if resp.get("success") else "failed" + log_call_activity(DB_FILE, str(student.get("student_id")), str(student.get("phone")), status) + + if resp.get("success"): + logger.info("Call initiated for %s", name) + else: + logger.error("Failed to call %s: %s", name, resp.get("error")) + + print({"student_id": student.get("student_id"), "name": name, "status": status, "response": resp}) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/vapi_service.py b/services/vapi_service.py new file mode 100644 index 0000000..0381d30 --- /dev/null +++ b/services/vapi_service.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from typing import Any + +import requests + +from .logger_service import get_logger +from .rabbitmq_service import publish_call_request + +logger = get_logger() + +DEFAULT_VAPI_URL = "https://api.vapi.ai/call" + + +def create_vapi_call(student: dict[str, Any]) -> dict[str, Any]: + api_key = os.getenv("VAPI_API_KEY", "").strip() + assistant_id = os.getenv("VAPI_ASSISTANT_ID", "").strip() + phone_number_id = os.getenv("VAPI_PHONE_NUMBER_ID", "").strip() + + if not api_key: + raise ValueError("Missing VAPI_API_KEY") + if not assistant_id: + raise ValueError("Missing VAPI_ASSISTANT_ID") + if not phone_number_id: + raise ValueError("Missing VAPI_PHONE_NUMBER_ID") + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # Correct VAPI payload format per API reference + payload = { + "assistantId": assistant_id, + "phoneNumberId": phone_number_id, + "customer": { + "number": student["phone"], + }, + "assistantOverrides": { + "variableValues": { + "student_name": student["name"], + "course_name": student["course"], + "days_inactive": str(student["days_inactive"]), + } + } + } + + publish_call_request(student, payload) + + try: + response = requests.post(DEFAULT_VAPI_URL, headers=headers, json=payload, timeout=30) + except requests.RequestException as exc: + logger.error("VAPI request failed for %s: %s", student["name"], exc) + return { + "success": False, + "error": str(exc), + } + + if response.status_code not in (200, 201, 202): + logger.error( + "VAPI returned %s for %s: %s", + response.status_code, + student["name"], + response.text, + ) + return { + "success": False, + "status_code": response.status_code, + "error": response.text, + } + + try: + response_data = response.json() + except ValueError: + logger.error("VAPI returned invalid JSON for %s", student["name"]) + return { + "success": False, + "status_code": response.status_code, + "error": "VAPI returned invalid JSON", + } + + logger.info("VAPI call succeeded for %s", student["name"]) + return { + "success": True, + "status_code": response.status_code, + "data": response_data, + } + + +def trigger_outbound_call(student: dict[str, Any]) -> dict[str, Any]: + return create_vapi_call(student) \ No newline at end of file