feat: AI patient onboarding backend and documentation

This commit is contained in:
2026-08-27 12:15:09 +05:30
parent b9440772d1
commit 59c1372fe3
49 changed files with 4012 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""AI package initialization."""
@@ -0,0 +1,69 @@
import json
from app.config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL
class ClaudeClient:
def __init__(self):
self.api_key = ANTHROPIC_API_KEY
self.model = ANTHROPIC_MODEL
async def validate_extraction(
self,
patient_context: dict,
specialty_config: dict,
conversation_history: list,
extracted_data: dict,
) -> dict:
if not self.api_key or self.api_key == "your_claude_key":
return {
"valid": True,
"missing": [],
"recommendation": "continue",
"hallucinations_detected": False,
}
try:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=self.api_key)
prompt = f"""
You are a senior clinical auditor and AI quality control specialist.
Your task is to validate whether the extracted patient data accurately reflects the actual patient conversation without hallucination or omitted critical criteria.
PATIENT CONTEXT:
{json.dumps(patient_context, indent=2)}
SPECIALTY REQUIREMENTS:
{json.dumps(specialty_config, indent=2)}
CONVERSATION HISTORY:
{json.dumps(conversation_history, indent=2)}
PROPOSED EXTRACTED STATE:
{json.dumps(extracted_data, indent=2)}
Return ONLY a JSON response:
{{
"valid": true/false,
"missing": ["list of critical missing focus items for this specialty, if any"],
"recommendation": "continue or ask_severity or ask_duration etc.",
"hallucinations_detected": false
}}
"""
response = await client.messages.create(
model=self.model,
max_tokens=1024,
system="You are a clinical AI quality controller. Output only valid JSON.",
messages=[{"role": "user", "content": prompt}],
)
content = response.content[0].text
return json.loads(content)
except Exception as e:
print(f"[Claude Validation Error]: {e}")
return {
"valid": True,
"missing": [],
"recommendation": "continue",
"hallucinations_detected": False,
}
+33
View File
@@ -0,0 +1,33 @@
import json
def build_onboarding_context(
session,
state,
conversation_history,
):
return {
"patient": {
"id": session.patient_id,
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
},
"category": {
"type": session.category_type,
"code": session.category_code,
"name": session.category_name,
},
"language": session.language,
"current_stage": session.stage,
"current_state": json.loads(
state.state_json
),
"conversation": conversation_history,
}
@@ -0,0 +1,73 @@
import json
import base64
from app.config import GEMINI_API_KEY, GEMINI_MODEL
class GeminiClient:
def __init__(self):
self.api_key = GEMINI_API_KEY
self.model = GEMINI_MODEL
async def extract_from_document(
self,
file_bytes: bytes,
mime_type: str,
patient_context: dict,
) -> dict:
if not self.api_key or self.api_key == "your_gemini_key":
return {
"document_type": "medical_report",
"extracted_text": "Sample HbA1c 7.2%, Blood Pressure 130/85 mmHg",
"findings": [
{"test": "HbA1c", "value": "7.2", "unit": "%", "interpretation": "Elevated"},
{"test": "Blood Pressure", "value": "130/85", "unit": "mmHg", "interpretation": "Stage 1"},
],
"medications_found": ["Metformin 500mg"],
"allergies_found": [],
"summary": "Report indicates elevated HbA1c and prehypertension.",
}
try:
import google.generativeai as genai
genai.configure(api_key=self.api_key)
model = genai.GenerativeModel(self.model)
prompt = f"""
You are a medical document extraction AI.
Analyze this medical report / prescription / lab result for patient {patient_context.get('name', 'Patient')}.
Extract all lab tests, numeric values, units, diagnosed conditions, medications, and allergies into structured JSON.
Return ONLY JSON:
{{
"document_type": "lab_report | prescription | discharge_summary | other",
"extracted_text": "brief summary text",
"findings": [
{{
"test": "test name",
"value": "numeric or text value",
"unit": "unit or null",
"interpretation": "Normal | High | Low | etc."
}}
],
"medications_found": ["medication 1", "medication 2"],
"allergies_found": ["allergy 1"],
"summary": "short clinical summary of the document"
}}
"""
part = {
"mime_type": mime_type,
"data": file_bytes,
}
response = await model.generate_content_async([part, prompt])
cleaned_text = response.text.replace("```json", "").replace("```", "").strip()
return json.loads(cleaned_text)
except Exception as e:
print(f"[Gemini Extraction Error]: {e}")
return {
"document_type": "unknown",
"findings": [],
"medications_found": [],
"allergies_found": [],
"summary": f"Could not extract document details: {e}",
}
@@ -0,0 +1,39 @@
from openai import AsyncOpenAI
from app.config import OPENAI_API_KEY, OPENAI_MODEL
class OpenAIClient:
_client = None
def __init__(self):
self.api_key = OPENAI_API_KEY
self.model = OPENAI_MODEL
if OpenAIClient._client is None and self.api_key:
OpenAIClient._client = AsyncOpenAI(
api_key=self.api_key,
timeout=12.0,
max_retries=1,
)
async def generate_response(
self,
system_prompt: str,
user_message: str,
) -> str:
client = OpenAIClient._client or AsyncOpenAI(
api_key=self.api_key or "sk-dummy-key-placeholder",
timeout=12.0,
)
response = await client.chat.completions.create(
model=self.model or "gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
response_format={"type": "json_object"},
temperature=0.35,
max_completion_tokens=140,
)
return response.choices[0].message.content
@@ -0,0 +1,149 @@
import json
from app.ai.openai_client import OpenAIClient
from app.ai.prompts import build_system_prompt
from app.ai.validator import validate_ai_response
from app.config import OPENAI_API_KEY
from app.specialties.registry import get_specialty_config
class AIOrchestrator:
def __init__(self):
self.openai = OpenAIClient()
async def process(
self,
session,
state,
conversation_history,
user_message,
):
specialty = get_specialty_config(
session.category_code
)
current_state = json.loads(
state.state_json
)
context = {
"patient": {
"id": session.patient_id,
"name": session.patient_name,
"age": session.age,
"gender": session.gender,
},
"category": {
"type": session.category_type,
"code": session.category_code,
"name": session.category_name,
},
"specialty_config": specialty,
"language": session.language,
"current_stage": session.stage,
"current_state": current_state,
"conversation_history": conversation_history,
"new_patient_message": user_message,
}
prompt = build_system_prompt(context)
# Real OpenAI Call if valid API key is set
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=user_message,
)
parsed = json.loads(raw_response)
validated = validate_ai_response(parsed)
return validated.model_dump()
except Exception as e:
print(f"[OpenAI Orchestrator Error]: {e}")
# Fallback simulator for offline development with concise clinical flow
parsed = self._simulate_specialty_response(session, specialty, user_message, len(conversation_history))
validated = validate_ai_response(parsed)
return validated.model_dump()
def _simulate_specialty_response(self, session, specialty, user_message, turn_count=0):
stage = session.stage
code = specialty.get("code", "general")
msg_lower = user_message.lower().strip()
is_negative = msg_lower in ["no", "no more", "nothing", "nothing else", "know nothing", "none", "illness none"]
# Fast conclusion if patient says "No" or already had 4 turns
if is_negative or turn_count >= 6:
return {
"reply": "Thank you. I have recorded your intake details for the doctor to review.",
"next_stage": "confirmation",
"state_updates": {
"reason_for_visit": None,
"symptoms": [],
"medical_history": ["None reported"] if is_negative else [],
"medications": [],
"allergies": ["None"] if is_negative else []
},
"completed": True
}
if stage == "greeting":
if code == "cardiology":
reply = "Could you please share what cardiac or chest symptoms you are experiencing?"
else:
reply = "Could you please describe what primary symptoms you are experiencing today?"
return {
"reply": reply,
"next_stage": "reason_for_visit",
"state_updates": {
"reason_for_visit": user_message,
"symptoms": [{"name": user_message, "duration": "recent"}],
"medical_history": [],
"medications": [],
"allergies": []
},
"completed": False
}
elif stage == "reason_for_visit":
reply = "How long have you had these symptoms, and have you taken any medicines?"
return {
"reply": reply,
"next_stage": "medications",
"state_updates": {
"reason_for_visit": user_message,
"symptoms": [{"name": user_message, "duration": "reported"}],
"medical_history": [],
"medications": [],
"allergies": []
},
"completed": False
}
elif stage == "medications":
reply = "Thank you. Do you have any known drug allergies or existing health conditions?"
return {
"reply": reply,
"next_stage": "allergies",
"state_updates": {
"reason_for_visit": None,
"symptoms": [],
"medical_history": [],
"medications": [user_message] if not is_negative else [],
"allergies": []
},
"completed": False
}
else:
return {
"reply": "Thank you. I have recorded your intake details for the doctor to review.",
"next_stage": "confirmation",
"state_updates": {
"reason_for_visit": None,
"symptoms": [],
"medical_history": [],
"medications": [],
"allergies": ["None"] if is_negative else [user_message]
},
"completed": True
}
+89
View File
@@ -0,0 +1,89 @@
import json
def build_system_prompt(context: dict) -> str:
specialty = context.get("specialty_config", {})
patient = context.get("patient", {})
language = context.get("language", "ta-IN")
stage = context.get("current_stage", "greeting")
current_state = context.get("current_state", {})
conversation_history = context.get("conversation_history", [])
turn_count = len(conversation_history)
lang_instructions = {
"ta-IN": (
"LANGUAGE & ACCENT: Professional, Respectful, and Articulate Tamil (தமிழ்).\n"
"- Speak with the professional, calm, and reassuring demeanor of a senior hospital clinical coordinator.\n"
"- Use polite, respectful Tamil words (e.g. 'வணக்கம்', 'கூற முடியுமா?', 'புரிகிறது', 'நன்றி').\n"
"- Keep sentence structure clear, elegant, and under 14 words."
),
"hi-IN": (
"LANGUAGE & ACCENT: Professional, Respectful, and Clear Hindi (हिन्दी).\n"
"- Speak with the dignified and polite tone of an experienced hospital triage officer.\n"
"- Use respectful language with 'आप' and clear clinical clarity.\n"
"- Keep sentence structure polite, concise, and under 14 words."
),
"en-IN": (
"LANGUAGE & ACCENT: Polished, Professional Indian Healthcare English.\n"
"- Speak with a calm, articulate, and reassuring clinical tone.\n"
"- Use clear, professional phrasing (e.g. 'Thank you. I have recorded your symptoms for the doctor.').\n"
"- Keep each response concise, respectful, and under 14 words."
),
"te-IN": (
"LANGUAGE & ACCENT: Professional and Respectful Telugu (తెలుగు).\n"
"- Speak with dignified, clear hospital intake phrasing (under 14 words)."
),
"kn-IN": (
"LANGUAGE & ACCENT: Professional and Respectful Kannada (ಕನ್ನಡ).\n"
"- Speak with dignified, clear hospital intake phrasing (under 14 words)."
),
}
selected_lang_rule = lang_instructions.get(language, lang_instructions["en-IN"])
return f"""You are a senior, professional Hospital Patient Intake Assistant at CureSelect Healthcare.
{selected_lang_rule}
CRITICAL CLINICAL INTAKE PRINCIPLES (AVOID UNWANTED QUESTIONS):
1. **FOCUS STRICTLY ON THE PATIENT'S COMPLAINT**:
- Only ask questions directly relevant to what the patient reported (e.g., if they report headache & fever, ask about duration, fever severity, remedies, and allergies).
- **DO NOT randomly cross-examine unrelated organ systems** (e.g., NEVER ask about digestive issues, acidity, loose stools, or stomach unless the patient specifically mentioned stomach problems!).
2. **FAST & CONCISE INTAKE (MAX 4 TURNS TOTAL)**:
- Flow: Chief Complaint -> Duration -> Medications Taken -> Drug Allergies -> COMPLETE.
- Once symptoms, duration, medicines, and allergies are collected, **IMMEDIATELY SET "completed": true**.
3. **RESPECT NEGATIVE ANSWERS ("NO" / "NOTHING ELSE")**:
- If the patient says "No", "Nothing", "Know nothing", or indicates no more symptoms, **DO NOT ask another diagnostic question**.
- Conclude immediately: *"Thank you. I have recorded your details for the doctor to review."* with `"completed": true` and `"next_stage": "confirmation"`.
4. **CONCISENESS**: Exactly ONE spoken sentence (maximum 12 to 14 words).
5. **NO ARTIFACTS**: No bullet points, no brackets, no markdown formatting. Deliver clean spoken dialogue.
6. **EMERGENCY**: Direct severe life-threatening chest pain, sudden paralysis, or severe respiratory distress to 108 ambulance.
PATIENT: {patient.get("name", "Patient")}, {patient.get("age", "")}y, {patient.get("gender", "")}
DEPARTMENT: {specialty.get("name", "General Intake")}
CURRENT STAGE: {stage} (Turn #{turn_count})
CURRENT STRUCTURED STATE:
{json.dumps(current_state, ensure_ascii=False)}
CONVERSATION HISTORY:
{json.dumps(conversation_history, ensure_ascii=False)}
OUTPUT JSON ONLY:
{{
"reply": "1 concise, respectful sentence (max 14 words)",
"next_stage": "greeting" | "reason_for_visit" | "symptoms" | "medical_history" | "medications" | "allergies" | "confirmation",
"completed": true | false,
"state_updates": {{
"reason_for_visit": {{"status": "reported" | "not_collected", "value": "..."}} or null,
"symptoms": {{"status": "reported" | "not_collected", "items": [{{"name": "...", "duration": "...", "severity": "..."}}]}} or null,
"medical_history": {{"status": "reported" | "confirmed_none", "items": ["..."]}} or null,
"medications": {{"status": "reported" | "confirmed_none", "items": ["..."]}} or null,
"allergies": {{"status": "reported" | "confirmed_none", "items": ["..."]}} or null
}}
}}
"""
@@ -0,0 +1,84 @@
import base64
import httpx2
from app.config import SARVAM_API_KEY
class SarvamClient:
_client = None
def __init__(self):
self.api_key = SARVAM_API_KEY
self.base_url = "https://api.sarvam.ai"
def _get_client(self) -> httpx2.AsyncClient:
if SarvamClient._client is None:
SarvamClient._client = httpx2.AsyncClient(
timeout=12.0,
limits=httpx2.Limits(max_keepalive_connections=5, max_connections=10),
)
return SarvamClient._client
async def speech_to_text(
self,
audio_bytes: bytes,
filename: str = "audio.webm",
language_code: str = "ta-IN",
) -> str:
if not self.api_key or self.api_key == "your_sarvam_key":
return ""
client = self._get_client()
files = {"file": (filename, audio_bytes, "audio/webm")}
data = {"model": "saaras:v2", "language_code": language_code}
headers = {"api-subscription-key": self.api_key}
try:
res = await client.post(
f"{self.base_url}/speech-to-text",
files=files,
data=data,
headers=headers,
)
if res.status_code == 200:
return res.json().get("transcript", "").strip()
except Exception as e:
print(f"[Sarvam STT Error]: {e}")
return ""
async def text_to_speech(
self,
text: str,
language_code: str = "ta-IN",
) -> bytes | None:
if not self.api_key or self.api_key == "your_sarvam_key":
return None
lang_prefix = language_code.lower()[:2]
speaker = "meera" if lang_prefix == "ta" else "arvind"
client = self._get_client()
headers = {
"api-subscription-key": self.api_key,
"Content-Type": "application/json",
}
payload = {
"inputs": [text],
"target_language_code": language_code,
"speaker": speaker,
"model": "bulbul:v1",
}
try:
res = await client.post(
f"{self.base_url}/text-to-speech",
json=payload,
headers=headers,
)
if res.status_code == 200:
audios = res.json().get("audios", [])
if audios:
return base64.b64decode(audios[0])
except Exception as e:
print(f"[Sarvam TTS Error]: {e}")
return None
+12
View File
@@ -0,0 +1,12 @@
from app.schemas.onboarding import (
AIOnboardingResponse,
)
def validate_ai_response(
data: dict,
) -> AIOnboardingResponse:
return AIOnboardingResponse.model_validate(
data
)