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 0000000..d46ea1d Binary files /dev/null and b/database/calls.db differ 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 0000000..94fc93f Binary files /dev/null and b/services/__pycache__/frappe_service.cpython-312.pyc differ diff --git a/services/__pycache__/logger_service.cpython-312.pyc b/services/__pycache__/logger_service.cpython-312.pyc new file mode 100644 index 0000000..c7bc056 Binary files /dev/null and b/services/__pycache__/logger_service.cpython-312.pyc differ diff --git a/services/__pycache__/nudging_engine.cpython-312.pyc b/services/__pycache__/nudging_engine.cpython-312.pyc new file mode 100644 index 0000000..d7883ce Binary files /dev/null and b/services/__pycache__/nudging_engine.cpython-312.pyc differ diff --git a/services/__pycache__/rabbitmq_service.cpython-312.pyc b/services/__pycache__/rabbitmq_service.cpython-312.pyc new file mode 100644 index 0000000..03dac6e Binary files /dev/null and b/services/__pycache__/rabbitmq_service.cpython-312.pyc differ diff --git a/services/__pycache__/vapi_service.cpython-312.pyc b/services/__pycache__/vapi_service.cpython-312.pyc new file mode 100644 index 0000000..9b8d499 Binary files /dev/null and b/services/__pycache__/vapi_service.cpython-312.pyc differ 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