feat: AI patient onboarding backend and documentation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Services package initialization."""
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.ai.orchestrator import AIOrchestrator
|
||||
|
||||
|
||||
class AIOnboardingService:
|
||||
|
||||
def __init__(self):
|
||||
self.orchestrator = AIOrchestrator()
|
||||
|
||||
async def process_message(
|
||||
self,
|
||||
session,
|
||||
state,
|
||||
conversation_history,
|
||||
user_message,
|
||||
):
|
||||
return await self.orchestrator.process(
|
||||
session=session,
|
||||
state=state,
|
||||
conversation_history=conversation_history,
|
||||
user_message=user_message,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
|
||||
|
||||
class CompletionService:
|
||||
|
||||
def is_complete(
|
||||
self,
|
||||
session,
|
||||
state,
|
||||
) -> bool:
|
||||
data = json.loads(
|
||||
state.state_json
|
||||
)
|
||||
|
||||
reason = data.get("reason_for_visit")
|
||||
symptoms = data.get("symptoms")
|
||||
history = data.get("medical_history")
|
||||
medications = data.get("medications")
|
||||
allergies = data.get("allergies")
|
||||
|
||||
# Check structured status dictionary format
|
||||
if isinstance(reason, dict):
|
||||
if reason.get("status") == "not_collected" or not reason.get("value"):
|
||||
return False
|
||||
elif not reason:
|
||||
return False
|
||||
|
||||
if isinstance(symptoms, dict):
|
||||
if symptoms.get("status") == "not_collected" or len(symptoms.get("items", [])) == 0:
|
||||
return False
|
||||
elif not symptoms:
|
||||
return False
|
||||
|
||||
if isinstance(history, dict):
|
||||
if history.get("status") == "not_collected":
|
||||
return False
|
||||
elif history is None:
|
||||
return False
|
||||
|
||||
if isinstance(medications, dict):
|
||||
if medications.get("status") == "not_collected":
|
||||
return False
|
||||
elif medications is None:
|
||||
return False
|
||||
|
||||
if isinstance(allergies, dict):
|
||||
if allergies.get("status") == "not_collected":
|
||||
return False
|
||||
elif allergies is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,75 @@
|
||||
class ConversationEngine:
|
||||
|
||||
def generate_response(
|
||||
self,
|
||||
session,
|
||||
state,
|
||||
user_message,
|
||||
):
|
||||
|
||||
stage = session.stage
|
||||
|
||||
if stage == "greeting":
|
||||
|
||||
return {
|
||||
"message": (
|
||||
"What brings you to the hospital today?"
|
||||
),
|
||||
"next_stage": "reason_for_visit",
|
||||
}
|
||||
|
||||
if stage == "reason_for_visit":
|
||||
|
||||
return {
|
||||
"message": (
|
||||
"Can you tell me more about what "
|
||||
"you are experiencing?"
|
||||
),
|
||||
"next_stage": "symptom_collection",
|
||||
}
|
||||
|
||||
if stage == "symptom_collection":
|
||||
|
||||
return {
|
||||
"message": (
|
||||
"How long have you been experiencing "
|
||||
"this problem?"
|
||||
),
|
||||
"next_stage": "symptom_details",
|
||||
}
|
||||
|
||||
if stage == "symptom_details":
|
||||
|
||||
return {
|
||||
"message": (
|
||||
"Are you currently taking any "
|
||||
"medications?"
|
||||
),
|
||||
"next_stage": "medications",
|
||||
}
|
||||
|
||||
if stage == "medications":
|
||||
|
||||
return {
|
||||
"message": (
|
||||
"Do you have any known allergies?"
|
||||
),
|
||||
"next_stage": "allergies",
|
||||
}
|
||||
|
||||
if stage == "allergies":
|
||||
|
||||
return {
|
||||
"message": (
|
||||
"Thank you. I have collected the "
|
||||
"initial information."
|
||||
),
|
||||
"next_stage": "summary",
|
||||
}
|
||||
|
||||
return {
|
||||
"message": (
|
||||
"Thank you. Let's continue."
|
||||
),
|
||||
"next_stage": stage,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import re
|
||||
|
||||
# Common Indian phonetic variations & clinical abbreviations
|
||||
PHONETIC_REPLACEMENTS = {
|
||||
# Medical terms & symptoms
|
||||
r"\b(suger|shugar|sugur)\b": "sugar (diabetes)",
|
||||
r"\b(presure|presur|blood preser)\b": "blood pressure",
|
||||
r"\b(bp)\b": "BP",
|
||||
r"\b(chest painu|chestpain|chst pain)\b": "chest pain",
|
||||
r"\b(hart attack|heart attacku)\b": "heart attack",
|
||||
r"\b(stomech pain|stomach painu)\b": "stomach pain",
|
||||
r"\b(head ache|hedache|headacheu)\b": "headache",
|
||||
r"\b(breath kashtam|breathing problem|moochu thinaral)\b": "breathing difficulty",
|
||||
r"\b(injekshan|injaction|injecshun)\b": "injection",
|
||||
r"\b(opration|oparation)\b": "operation",
|
||||
r"\b(tablat|tablit|mathirai|goli)\b": "tablets",
|
||||
r"\b(alargy|alergi)\b": "allergy",
|
||||
r"\b(fevar|favar)\b": "fever",
|
||||
r"\b(coughu|coff)\b": "cough",
|
||||
r"\b(vomit|vomitingu|vomitin)\b": "vomiting",
|
||||
|
||||
# Common Indian Medicines (Phonetic normalization)
|
||||
r"\b(parasetamal|paracitamol|paracetamol 650|dolo 650|dolo)\b": "Paracetamol (Dolo 650)",
|
||||
r"\b(metformin|methformin|glycomet)\b": "Metformin",
|
||||
r"\b(telmisartan|telma|telmikind)\b": "Telmisartan",
|
||||
r"\b(amlodipine|amlong|amlo)\b": "Amlodipine",
|
||||
r"\b(pantoprazole|pantocid|pan d|pan 40)\b": "Pantoprazole",
|
||||
r"\b(omeprazole|omez)\b": "Omeprazole",
|
||||
r"\b(atorvastatin|atorva|atorlip)\b": "Atorvastatin",
|
||||
r"\b(cetirizine|cetzine|citrizin)\b": "Cetirizine",
|
||||
r"\b(azithromycin|azithral|azithro)\b": "Azithromycin",
|
||||
r"\b(amoxicillin|mox|augmentin)\b": "Amoxicillin",
|
||||
r"\b(insulin injection|insuline)\b": "Insulin",
|
||||
}
|
||||
|
||||
|
||||
def normalize_medical_speech(transcript: str, language_code: str = "en-IN") -> str:
|
||||
"""
|
||||
Cleans, sanitizes, and normalizes spoken transcripts from STT models
|
||||
to ensure high clinical fidelity while retaining the patient's original meaning.
|
||||
"""
|
||||
if not transcript or not transcript.strip():
|
||||
return ""
|
||||
|
||||
cleaned = transcript.strip()
|
||||
|
||||
# Remove repeated filler words or audio stutter artifacts
|
||||
cleaned = re.sub(r"\b(um+|uh+|ah+|erm+|er+)\b", "", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
|
||||
# If it's English or Romanized Indic code-mixed speech, apply phonetic dictionary
|
||||
lang_lower = language_code.lower()
|
||||
if lang_lower.startswith("en") or any(c.isascii() for c in cleaned):
|
||||
for pattern, replacement in PHONETIC_REPLACEMENTS.items():
|
||||
cleaned = re.sub(pattern, replacement, cleaned, flags=re.IGNORECASE)
|
||||
|
||||
# Standardize spaces and punctuation
|
||||
cleaned = re.sub(r"\s+([.,!?])", r"\1", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
|
||||
# Capitalize first letter if needed
|
||||
if cleaned and len(cleaned) > 1:
|
||||
cleaned = cleaned[0].upper() + cleaned[1:]
|
||||
|
||||
return cleaned
|
||||
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models import (
|
||||
OnboardingMessage,
|
||||
OnboardingSession,
|
||||
OnboardingState,
|
||||
)
|
||||
|
||||
|
||||
class OnboardingService:
|
||||
|
||||
def get_session(
|
||||
self,
|
||||
db: Session,
|
||||
session_id: str,
|
||||
):
|
||||
return (
|
||||
db.query(OnboardingSession)
|
||||
.filter(
|
||||
OnboardingSession.session_id == session_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_state(
|
||||
self,
|
||||
db: Session,
|
||||
session_id: str,
|
||||
):
|
||||
state = (
|
||||
db.query(OnboardingState)
|
||||
.filter(
|
||||
OnboardingState.session_id == session_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not state:
|
||||
default_state = {
|
||||
"reason_for_visit": {
|
||||
"status": "not_collected",
|
||||
"value": None
|
||||
},
|
||||
"symptoms": {
|
||||
"status": "not_collected",
|
||||
"items": []
|
||||
},
|
||||
"medical_history": {
|
||||
"status": "not_collected",
|
||||
"items": []
|
||||
},
|
||||
"medications": {
|
||||
"status": "not_collected",
|
||||
"items": []
|
||||
},
|
||||
"allergies": {
|
||||
"status": "not_collected",
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
|
||||
state = OnboardingState(
|
||||
session_id=session_id,
|
||||
state_json=json.dumps(default_state),
|
||||
)
|
||||
|
||||
db.add(state)
|
||||
db.commit()
|
||||
db.refresh(state)
|
||||
|
||||
return state
|
||||
|
||||
def save_message(
|
||||
self,
|
||||
db: Session,
|
||||
session_id: str,
|
||||
role: str,
|
||||
message: str,
|
||||
stage: str,
|
||||
):
|
||||
new_message = OnboardingMessage(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
message=message,
|
||||
stage=stage,
|
||||
)
|
||||
|
||||
db.add(new_message)
|
||||
db.commit()
|
||||
|
||||
return new_message
|
||||
|
||||
def update_state(
|
||||
self,
|
||||
db: Session,
|
||||
state: OnboardingState,
|
||||
data: dict,
|
||||
):
|
||||
state.state_json = json.dumps(data)
|
||||
db.commit()
|
||||
db.refresh(state)
|
||||
return state
|
||||
@@ -0,0 +1,13 @@
|
||||
class PatientSubmissionService:
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
result: dict,
|
||||
) -> dict:
|
||||
# Destination patient / hospital EHR / HIMS API integration point
|
||||
# In production, this can perform an async HTTP POST to your Hospital Backend
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Patient data successfully submitted to hospital system",
|
||||
"patient_id": result.get("patient_id"),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
from app.ai.openai_client import OpenAIClient
|
||||
from app.config import OPENAI_API_KEY
|
||||
from app.schemas.onboarding import PatientOnboardingResult
|
||||
|
||||
|
||||
class ResultExtractionService:
|
||||
|
||||
def __init__(self):
|
||||
self.openai = OpenAIClient()
|
||||
|
||||
async def extract_result(
|
||||
self,
|
||||
session,
|
||||
state,
|
||||
conversation_history,
|
||||
) -> dict:
|
||||
current_state = json.loads(
|
||||
state.state_json
|
||||
)
|
||||
|
||||
prompt = f"""
|
||||
You are a hospital patient onboarding data extraction assistant.
|
||||
|
||||
Your task is to convert the completed onboarding information
|
||||
into structured patient intake data.
|
||||
|
||||
You are NOT a doctor.
|
||||
Do not diagnose the patient.
|
||||
Do not add information that was not provided.
|
||||
|
||||
PATIENT:
|
||||
{json.dumps({
|
||||
"id": session.patient_id,
|
||||
"name": session.patient_name,
|
||||
"age": session.age,
|
||||
"gender": session.gender,
|
||||
}, indent=2)}
|
||||
|
||||
CATEGORY:
|
||||
{json.dumps({
|
||||
"type": session.category_type,
|
||||
"code": session.category_code,
|
||||
"name": session.category_name,
|
||||
}, indent=2)}
|
||||
|
||||
CURRENT STATE:
|
||||
{json.dumps(current_state, indent=2)}
|
||||
|
||||
CONVERSATION:
|
||||
{json.dumps(conversation_history, indent=2)}
|
||||
|
||||
Return ONLY JSON conforming to this exact structure:
|
||||
{{
|
||||
"patient_id": "{session.patient_id}",
|
||||
"category": {{
|
||||
"type": "{session.category_type}",
|
||||
"code": "{session.category_code}",
|
||||
"name": "{session.category_name}"
|
||||
}},
|
||||
"chief_complaint": "string or null",
|
||||
"symptoms": [
|
||||
{{
|
||||
"name": "string",
|
||||
"duration": "string or null",
|
||||
"severity": "string or null",
|
||||
"description": "string or null"
|
||||
}}
|
||||
],
|
||||
"medical_history": [],
|
||||
"medications": [],
|
||||
"allergies": [],
|
||||
"additional_information": "string or null",
|
||||
"language": "{session.language}",
|
||||
"onboarding_completed": true
|
||||
}}
|
||||
"""
|
||||
|
||||
if OPENAI_API_KEY and OPENAI_API_KEY.strip() not in ["your_openai_api_key", "your_openai_key", ""]:
|
||||
try:
|
||||
raw_response = await self.openai.generate_response(
|
||||
system_prompt=prompt,
|
||||
user_message="Generate the final structured patient onboarding result.",
|
||||
)
|
||||
cleaned = raw_response.strip()
|
||||
if cleaned.startswith("```json"):
|
||||
cleaned = cleaned[7:]
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned[3:]
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned[:-3]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
parsed = json.loads(cleaned)
|
||||
validated = PatientOnboardingResult.model_validate(parsed)
|
||||
return validated.model_dump()
|
||||
except Exception as e:
|
||||
print(f"[Result Extraction Error]: {e}, falling back to structured state")
|
||||
|
||||
|
||||
# Fallback simulator from state
|
||||
chief_complaint = current_state.get("reason_for_visit")
|
||||
if isinstance(chief_complaint, dict):
|
||||
chief_complaint = chief_complaint.get("value")
|
||||
|
||||
symptoms_raw = current_state.get("symptoms")
|
||||
symptoms_list = []
|
||||
if isinstance(symptoms_raw, dict):
|
||||
symptoms_list = symptoms_raw.get("items", [])
|
||||
elif isinstance(symptoms_raw, list):
|
||||
symptoms_list = symptoms_raw
|
||||
|
||||
meds_raw = current_state.get("medications")
|
||||
meds_list = meds_raw.get("items", []) if isinstance(meds_raw, dict) else (meds_raw or [])
|
||||
|
||||
allergies_raw = current_state.get("allergies")
|
||||
allergies_list = allergies_raw.get("items", []) if isinstance(allergies_raw, dict) else (allergies_raw or [])
|
||||
|
||||
history_raw = current_state.get("medical_history")
|
||||
history_list = history_raw.get("items", []) if isinstance(history_raw, dict) else (history_raw or [])
|
||||
|
||||
fallback_result = {
|
||||
"patient_id": session.patient_id,
|
||||
"category": {
|
||||
"type": session.category_type,
|
||||
"code": session.category_code,
|
||||
"name": session.category_name,
|
||||
},
|
||||
"chief_complaint": chief_complaint or "Consultation Intake",
|
||||
"symptoms": symptoms_list or [{"name": chief_complaint or "Reported discomfort", "duration": "recent", "severity": "mild", "description": None}],
|
||||
"medical_history": history_list,
|
||||
"medications": meds_list,
|
||||
"allergies": allergies_list,
|
||||
"additional_information": None,
|
||||
"language": session.language,
|
||||
"onboarding_completed": True,
|
||||
}
|
||||
validated = PatientOnboardingResult.model_validate(fallback_result)
|
||||
return validated.model_dump()
|
||||
@@ -0,0 +1,32 @@
|
||||
from app.ai.claude_client import ClaudeClient
|
||||
from app.specialties.registry import get_specialty_config
|
||||
|
||||
|
||||
class ValidationService:
|
||||
|
||||
def __init__(self):
|
||||
self.claude = ClaudeClient()
|
||||
|
||||
async def validate_state_extraction(
|
||||
self,
|
||||
session,
|
||||
conversation_history: list,
|
||||
extracted_state: dict,
|
||||
) -> dict:
|
||||
specialty_config = get_specialty_config(session.category_code)
|
||||
patient_context = {
|
||||
"id": session.patient_id,
|
||||
"name": session.patient_name,
|
||||
"age": session.age,
|
||||
"gender": session.gender,
|
||||
"category": session.category_name,
|
||||
}
|
||||
|
||||
validation = await self.claude.validate_extraction(
|
||||
patient_context=patient_context,
|
||||
specialty_config=specialty_config,
|
||||
conversation_history=conversation_history,
|
||||
extracted_data=extracted_state,
|
||||
)
|
||||
|
||||
return validation
|
||||
@@ -0,0 +1,270 @@
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import wave
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
import httpx2
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import OPENAI_API_KEY, SARVAM_API_KEY, GEMINI_API_KEY, GEMINI_MODEL
|
||||
from app.services.medical_speech_normalizer import normalize_medical_speech
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
AUDIO_DIR = BASE_DIR / "data" / "audio"
|
||||
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class VoiceService:
|
||||
|
||||
def __init__(self):
|
||||
self.openai_key = OPENAI_API_KEY
|
||||
self.sarvam_key = SARVAM_API_KEY
|
||||
self.gemini_key = GEMINI_API_KEY
|
||||
self.gemini_model = GEMINI_MODEL
|
||||
|
||||
def get_initial_greeting(self, patient_name: str | None, language: str) -> str:
|
||||
name = patient_name or "அவர்களே"
|
||||
lang_lower = language.lower()
|
||||
|
||||
if lang_lower.startswith("ta"):
|
||||
return f"வணக்கம் {name}, CureSelect மருத்துவமனைக்கு நல்வரவு. இன்று தங்களுக்கு ஏற்பட்டுள்ள உடல்நல பாதிப்பு பற்றி கூற முடியுமா?"
|
||||
elif lang_lower.startswith("hi"):
|
||||
return f"नमस्ते {name} जी, CureSelect अस्पताल में आपका स्वागत है। आज आप किस स्वास्थ्य समस्या के लिए परामर्श लेना चाहते हैं?"
|
||||
elif lang_lower.startswith("te"):
|
||||
return f"నమస్కారం {name} గారు, CureSelect హాస్పిటల్కు స్వాగతం. ఈరోజు మీకున్న ఆరోగ్య సమస్య గురించి చెప్పగలరా?"
|
||||
elif lang_lower.startswith("kn"):
|
||||
return f"ನಮಸ್ಕಾರ {name} ಅವರೇ, CureSelect ಆಸ್ಪತ್ರೆಗೆ ಸುಸ್ವಾಗತ. ಇಂದು ನಿಮ್ಮ ಆರೋಗ್ಯದ ತೊಂದರೆ ಬಗ್ಗೆ ತಿಳಿಸಬಹುದೇ?"
|
||||
else:
|
||||
return f"Hello {name}, welcome to CureSelect Healthcare. Could you please share what health symptoms you are experiencing today?"
|
||||
|
||||
async def speech_to_text(
|
||||
self,
|
||||
audio_bytes: bytes,
|
||||
filename: str = "audio.webm",
|
||||
language_code: str = "en-IN",
|
||||
specialty_code: str = "general",
|
||||
patient_context: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
High-precision multi-model Indian speech transcription pipeline:
|
||||
1. Sarvam AI Saaras v2 (Specialized Indic Speech Engine)
|
||||
2. Google Gemini Multimodal Audio (Multilingual Code-Switching & Acoustic Context)
|
||||
3. OpenAI Whisper-1 (Medical Specialty Context Boosted)
|
||||
4. Clinical & Phonetic Post-Processing Normalizer
|
||||
"""
|
||||
lang_prefix = language_code.lower()[:2]
|
||||
raw_transcript = ""
|
||||
|
||||
# 1. Sarvam AI Saaras v2 for Native Indian Languages
|
||||
if (
|
||||
self.sarvam_key
|
||||
and self.sarvam_key.strip()
|
||||
and self.sarvam_key not in ["your_sarvam_api_key", "your_sarvam_key", ""]
|
||||
and lang_prefix in ["ta", "hi", "te", "kn", "bn", "mr", "gu", "ml", "pa", "od", "en"]
|
||||
):
|
||||
try:
|
||||
async with httpx2.AsyncClient(timeout=8.0) as client:
|
||||
files = {
|
||||
"file": (filename, audio_bytes, "audio/webm")
|
||||
}
|
||||
data = {
|
||||
"model": "saaras:v2",
|
||||
"language_code": language_code,
|
||||
"with_diacritics": "true",
|
||||
}
|
||||
headers = {
|
||||
"api-subscription-key": self.sarvam_key
|
||||
}
|
||||
res = await client.post(
|
||||
"https://api.sarvam.ai/speech-to-text",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
if res.status_code == 200:
|
||||
res_json = res.json()
|
||||
transcript = res_json.get("transcript", "").strip()
|
||||
if transcript:
|
||||
raw_transcript = transcript
|
||||
except Exception as e:
|
||||
print(f"[Sarvam STT Error]: {e}")
|
||||
|
||||
# 2. Google Gemini Multimodal Audio (Excellent with Tanglish/Hinglish & Medical terms)
|
||||
if (
|
||||
not raw_transcript
|
||||
and self.gemini_key
|
||||
and self.gemini_key.strip()
|
||||
and self.gemini_key not in ["your_gemini_key", ""]
|
||||
):
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=self.gemini_key)
|
||||
model = genai.GenerativeModel(self.gemini_model or "gemini-1.5-flash")
|
||||
|
||||
mime_type = "audio/webm"
|
||||
if filename.endswith(".wav"):
|
||||
mime_type = "audio/wav"
|
||||
elif filename.endswith(".mp3"):
|
||||
mime_type = "audio/mp3"
|
||||
|
||||
audio_part = {
|
||||
"mime_type": mime_type,
|
||||
"data": audio_bytes,
|
||||
}
|
||||
audio_prompt = f"""
|
||||
You are an expert medical transcription engine for Indian hospitals.
|
||||
Accurately transcribe the spoken audio into text.
|
||||
Language context: {language_code}. Specialty: {specialty_code}.
|
||||
Handle code-switching (e.g. Tamil/Hindi mixed with English medical words like BP, sugar, chest pain, tablets, fever, vomiting).
|
||||
Return ONLY the exact verbatim transcription without explanation, punctuation commentary, or markdown quotes.
|
||||
"""
|
||||
response = await model.generate_content_async([audio_part, audio_prompt])
|
||||
text = response.text.strip().replace('"', '').replace("```", "")
|
||||
if text and len(text) > 1 and "unable to transcribe" not in text.lower():
|
||||
raw_transcript = text
|
||||
except Exception as e:
|
||||
print(f"[Gemini Audio STT Error]: {e}")
|
||||
|
||||
# 3. OpenAI Whisper-1 with Medical Specialty Context Prompting
|
||||
if (
|
||||
not raw_transcript
|
||||
and self.openai_key
|
||||
and self.openai_key.strip() not in ["your_openai_api_key", "your_openai_key", ""]
|
||||
):
|
||||
client = AsyncOpenAI(api_key=self.openai_key, timeout=8.0)
|
||||
try:
|
||||
audio_file = io.BytesIO(audio_bytes)
|
||||
audio_file.name = filename if filename.endswith((".wav", ".mp3", ".webm", ".m4a")) else "audio.webm"
|
||||
|
||||
specialty_vocab = {
|
||||
"cardiology": "Cardiology, chest pain, palpitations, ECG, hypertension, BP, heart attack, shortness of breath, Metformin, Telmisartan, Amlodipine, Atorvastatin, Sorbitrate",
|
||||
"orthopedics": "Orthopedics, joint pain, knee pain, fracture, swelling, arthritis, Paracetamol, Tramadol, Calcium, Vitamin D, physiotherapy",
|
||||
"general": "General medicine, fever, cough, cold, headache, diabetes, high blood pressure, vomiting, loose motion, Paracetamol 650, Dolo 650, Azithromycin, Pantoprazole",
|
||||
"pediatrics": "Pediatrics, baby fever, vaccination, cough, cold, vomiting, appetite loss, Paracetamol drops",
|
||||
"dermatology": "Dermatology, skin rash, itching, redness, fungal infection, Cetirizine, ointment, allergy",
|
||||
}
|
||||
|
||||
spec_terms = specialty_vocab.get(specialty_code, specialty_vocab["general"])
|
||||
lang_prompts = {
|
||||
"ta": f"மருத்துவ ஆலோசனை, நெஞ்சு வலி, சர்க்கரை வியாதி, பிபி, மாத்திரை, காய்ச்சல், இருமல், வலி, {spec_terms}",
|
||||
"hi": f"चिकित्सा, सीने में दर्द, बीपी, शुगर, बुखार, दवाई, सिरदर्द, खांसी, {spec_terms}",
|
||||
"te": f"వైద్యం, ఛాతీ నొప్పి, షుగర్, బీపీ, మందులు, జ్వరం, {spec_terms}",
|
||||
"kn": f"ವೈದ್ಯಕೀಯ, ಎದೆ ನೋವು, ಬಿಪಿ, ಸಕ್ಕರೆ ಕಾಯಿಲೆ, ಮಾತ್ರೆಗಳು, ಜ್ವರ, {spec_terms}",
|
||||
"en": f"Medical symptoms, chest pain, diabetes, blood pressure, fever, cough, {spec_terms}",
|
||||
}
|
||||
med_prompt = lang_prompts.get(lang_prefix, lang_prompts["en"])
|
||||
|
||||
transcript = await client.audio.transcriptions.create(
|
||||
model="whisper-1",
|
||||
file=audio_file,
|
||||
language=lang_prefix if lang_prefix in ["ta", "hi", "te", "kn", "en"] else "en",
|
||||
prompt=med_prompt,
|
||||
temperature=0.0,
|
||||
)
|
||||
if transcript.text and len(transcript.text.strip()) > 1:
|
||||
raw_transcript = transcript.text.strip()
|
||||
except Exception as e:
|
||||
print(f"[OpenAI Whisper Error]: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
# 4. Apply Medical Speech Normalizer to eliminate phonetic mishearings
|
||||
normalized = normalize_medical_speech(raw_transcript, language_code=language_code)
|
||||
return normalized
|
||||
|
||||
async def text_to_speech(
|
||||
self,
|
||||
text: str,
|
||||
language_code: str = "en-IN",
|
||||
) -> tuple[str, bytes]:
|
||||
lang_prefix = language_code.lower()[:2]
|
||||
audio_id = f"aud_{uuid4().hex[:12]}"
|
||||
|
||||
# Clean punctuation and brackets before synthesis
|
||||
cleaned_text = (
|
||||
text.replace("(", "")
|
||||
.replace(")", "")
|
||||
.replace("*", "")
|
||||
.replace("#", "")
|
||||
.replace("- ", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
# 1. Use Sarvam AI TTS for Indian Languages (Native Indian Accent)
|
||||
if (
|
||||
self.sarvam_key
|
||||
and self.sarvam_key.strip()
|
||||
and self.sarvam_key not in ["your_sarvam_api_key", "your_sarvam_key", ""]
|
||||
and lang_prefix in ["ta", "hi", "te", "kn", "bn", "mr", "gu", "ml", "pa", "od", "en"]
|
||||
):
|
||||
try:
|
||||
async with httpx2.AsyncClient(timeout=6.0) as client:
|
||||
headers = {
|
||||
"api-subscription-key": self.sarvam_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"inputs": [cleaned_text],
|
||||
"target_language_code": language_code,
|
||||
"speaker": "meera",
|
||||
"model": "bulbul:v1",
|
||||
}
|
||||
res = await client.post(
|
||||
"https://api.sarvam.ai/text-to-speech",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
if res.status_code == 200:
|
||||
res_json = res.json()
|
||||
audios = res_json.get("audios", [])
|
||||
if audios:
|
||||
audio_data = base64.b64decode(audios[0])
|
||||
file_path = AUDIO_DIR / f"{audio_id}.wav"
|
||||
file_path.write_bytes(audio_data)
|
||||
return audio_id, audio_data
|
||||
except Exception as e:
|
||||
print(f"[Sarvam TTS Error]: {e}")
|
||||
|
||||
# 2. Use OpenAI TTS
|
||||
if self.openai_key and self.openai_key.strip() not in ["your_openai_api_key", "your_openai_key", ""]:
|
||||
client = AsyncOpenAI(api_key=self.openai_key, timeout=6.0)
|
||||
try:
|
||||
response = await client.audio.speech.create(
|
||||
model="tts-1",
|
||||
voice="shimmer",
|
||||
input=cleaned_text,
|
||||
speed=1.0,
|
||||
)
|
||||
audio_data = response.content
|
||||
file_path = AUDIO_DIR / f"{audio_id}.mp3"
|
||||
file_path.write_bytes(audio_data)
|
||||
return audio_id, audio_data
|
||||
except Exception as e:
|
||||
print(f"[OpenAI TTS Error]: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
# 3. Fallback
|
||||
audio_data = self._generate_fallback_wav()
|
||||
file_path = AUDIO_DIR / f"{audio_id}.wav"
|
||||
file_path.write_bytes(audio_data)
|
||||
return audio_id, audio_data
|
||||
|
||||
def _generate_fallback_wav(self) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(16000)
|
||||
data = [int(1000 * (i % 32 < 16)) for i in range(4000)]
|
||||
wf.writeframes(struct.pack(f"<{len(data)}h", *data))
|
||||
return buf.getvalue()
|
||||
|
||||
def get_audio_path(self, audio_id: str) -> Path | None:
|
||||
for ext in [".wav", ".mp3", ".webm", ".m4a"]:
|
||||
p = AUDIO_DIR / f"{audio_id}{ext}"
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
Reference in New Issue
Block a user