feat: AI patient onboarding backend and documentation
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.schemas.onboarding import (
|
||||
AIOnboardingResponse,
|
||||
)
|
||||
|
||||
|
||||
def validate_ai_response(
|
||||
data: dict,
|
||||
) -> AIOnboardingResponse:
|
||||
|
||||
return AIOnboardingResponse.model_validate(
|
||||
data
|
||||
)
|
||||
Reference in New Issue
Block a user