refactor estimation service to use structured agent
This commit is contained in:
parent
47cc1541ed
commit
96421b264c
7 changed files with 316 additions and 361 deletions
|
|
@ -18,6 +18,7 @@ class ChatService:
|
|||
headers["X-API-Key"] = self.api_key
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ class ChatService:
|
|||
"""Send a message to talestorm-ai and get response"""
|
||||
async with await self.get_client() as client:
|
||||
try:
|
||||
response = await client.post(self.base_url + "/chat/", json={
|
||||
response = await client.post("/chat/", json={
|
||||
"chat_session_id": session_id,
|
||||
"user_message": message
|
||||
})
|
||||
|
|
@ -47,7 +48,7 @@ class ChatService:
|
|||
"""Get chat history for a session and format it properly"""
|
||||
async with await self.get_client() as client:
|
||||
try:
|
||||
response = await client.get(self.base_url + "/chat/", params={"chat_session_id": session_id})
|
||||
response = await client.get("/chat", params={"chat_session_id": session_id})
|
||||
if response.status_code == 200:
|
||||
messages = response.json()
|
||||
history = []
|
||||
|
|
@ -101,10 +102,10 @@ class ChatService:
|
|||
"""Process an insurance chat request"""
|
||||
try:
|
||||
if not session_id:
|
||||
session_id = await session_service.create_session()
|
||||
session_id = await session_service.create_session(agent_id=settings.TALESTORM_AGENT_ID)
|
||||
|
||||
elif not await session_service.validate_session(session_id):
|
||||
session_id = await session_service.create_session()
|
||||
session_id = await session_service.create_session(agent_id=settings.TALESTORM_AGENT_ID)
|
||||
|
||||
chat_response = await self.send_message(session_id, message)
|
||||
history = await self.get_chat_history(session_id)
|
||||
|
|
@ -119,6 +120,7 @@ class ChatService:
|
|||
}
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
return {
|
||||
"session_id": session_id or "fallback-session",
|
||||
"answer": f"I'm sorry, I'm experiencing technical difficulties. Please try again later. Error: {str(e)}",
|
||||
|
|
|
|||
|
|
@ -1,225 +1,228 @@
|
|||
from datetime import datetime
|
||||
import httpx
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import Dict, Any, List, Optional
|
||||
from ..config import settings
|
||||
from .session_service import session_service
|
||||
|
||||
# ---------------- Constants ---------------- #
|
||||
|
||||
DECLINABLE_CONDITIONS = {
|
||||
"aids", "organ_transplant", "multiple_sclerosis", "alzheimer_disease",
|
||||
"heart_attack", "congestive_heart_failure", "bipolar", "seizures"
|
||||
}
|
||||
|
||||
DECLINABLE_ISSUE_KEYS = {"heart_attack", "congestive_heart_failure", "bipolar", "seizures"}
|
||||
HIGH_RISK_FLAGS = {"50_units", "treat_diabetes"}
|
||||
|
||||
PRICING_TABLE = {
|
||||
1: [122, 243, 219, 365],
|
||||
1.5: [127, 253, 228, 379],
|
||||
2: [133, 265, 239, 397],
|
||||
2.5: [139, 277, 249, 415],
|
||||
3: [145, 290, 261, 435],
|
||||
3.5: [151, 302, 272, 452],
|
||||
4: [158, 315, 283, 472],
|
||||
4.5: [164, 327, 295, 491],
|
||||
5: [172, 343, 309, 514],
|
||||
5.5: [182, 364, 327, 545],
|
||||
6: [196, 390, 351, 585],
|
||||
6.5: [209, 417, 376, 626],
|
||||
7: [222, 444, 400, 666]
|
||||
}
|
||||
|
||||
# Simplified drug price map (in a real implementation, you'd load this from a file)
|
||||
DRUG_PRICE_MAP = {
|
||||
"metformin": 15.0,
|
||||
"insulin": 45.0,
|
||||
"aspirin": 8.0,
|
||||
"atorvastatin": 25.0,
|
||||
"lisinopril": 12.0,
|
||||
"amlodipine": 18.0,
|
||||
"omeprazole": 22.0,
|
||||
"simvastatin": 20.0,
|
||||
"hydrochlorothiazide": 10.0,
|
||||
"losartan": 30.0
|
||||
}
|
||||
|
||||
# ---------------- Helper Functions ---------------- #
|
||||
|
||||
def calculate_age(dob_str: str, effective_date_str: str) -> Optional[int]:
|
||||
"""Calculate age from date of birth and effective date"""
|
||||
try:
|
||||
dob = datetime.strptime(dob_str, "%d/%m/%Y")
|
||||
ref = datetime.strptime(effective_date_str, "%d/%m/%Y")
|
||||
return (ref - dob).days // 365
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def calculate_bmi(weight: float, ft: int, inch: int) -> Optional[float]:
|
||||
"""Calculate BMI from weight and height"""
|
||||
try:
|
||||
inches = ft * 12 + inch
|
||||
return (weight / (inches ** 2)) * 703
|
||||
except (ValueError, TypeError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
def check_declinable(phq: Dict[str, Any]) -> bool:
|
||||
"""Check if application should be declined based on PHQ data"""
|
||||
if phq.get("treatment") or phq.get("invalid"):
|
||||
return True
|
||||
class EstimationService:
|
||||
"""Service for handling insurance estimation via TALESTORM API"""
|
||||
|
||||
for cond in phq.get("conditions", []):
|
||||
if cond.get("key") in DECLINABLE_CONDITIONS:
|
||||
return True
|
||||
def __init__(self):
|
||||
self.base_url = settings.TALESTORM_API_BASE_URL
|
||||
self.api_key = settings.TALESTORM_API_KEY
|
||||
self.agent_id = settings.TALESTORM_ESTIMATION_AGENT_ID
|
||||
|
||||
for issue in phq.get("issues", []):
|
||||
for detail in issue.get("details", []):
|
||||
if detail.get("key") in DECLINABLE_ISSUE_KEYS:
|
||||
return True
|
||||
async def get_client(self) -> httpx.AsyncClient:
|
||||
"""Get HTTP client for TALESTORM API"""
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def base_tier(age: Optional[int]) -> float:
|
||||
"""Calculate base tier based on age"""
|
||||
if age is None:
|
||||
return 4.0
|
||||
if age <= 30:
|
||||
return 1.0
|
||||
elif age <= 40:
|
||||
return 2.0
|
||||
elif age <= 50:
|
||||
return 3.0
|
||||
elif age <= 60:
|
||||
return 3.5
|
||||
else:
|
||||
return 4.0
|
||||
|
||||
def adjust_tier(tier: float, bmi: Optional[float], meds: List[Dict[str, Any]],
|
||||
issues: List[Dict[str, Any]], applicant_index: int) -> float:
|
||||
"""Adjust tier based on health factors"""
|
||||
if bmi is not None and (bmi < 16 or bmi > 40):
|
||||
tier += 1.0
|
||||
def _format_estimation_request(self, request_data: Dict[str, Any]) -> str:
|
||||
"""Format the estimation request as a natural language message for the AI"""
|
||||
applicants = request_data.get("applicants", [])
|
||||
plans = request_data.get("plans", [])
|
||||
phq = request_data.get("phq", {})
|
||||
income = request_data.get("income", 0)
|
||||
address = request_data.get("address", {})
|
||||
|
||||
# Build the request message
|
||||
message_parts = ["Please provide an insurance estimation for the following application. Analyze all health factors, medications, conditions, and risk factors to determine appropriate tier assignments and pricing:"]
|
||||
|
||||
# Add applicants information
|
||||
message_parts.append("\nApplicants:")
|
||||
for i, applicant in enumerate(applicants):
|
||||
applicant_type = "primary" if i == 0 else "dependent" if i > 0 else "unknown"
|
||||
message_parts.append(f"- {applicant.get('firstName', '')} {applicant.get('lastName', '')} ({applicant_type})")
|
||||
message_parts.append(f" Age: {applicant.get('dob', '')}")
|
||||
message_parts.append(f" Gender: {applicant.get('gender', '')}")
|
||||
message_parts.append(f" Weight: {applicant.get('weight', '')} lbs")
|
||||
message_parts.append(f" Height: {applicant.get('heightFt', '')}'{applicant.get('heightIn', '')}\"")
|
||||
message_parts.append(f" Nicotine use: {'Yes' if applicant.get('nicotine') else 'No'}")
|
||||
|
||||
# Add plan information
|
||||
if plans:
|
||||
plan = plans[0]
|
||||
message_parts.append(f"\nPlan: Coverage type {plan.get('coverage', '')}")
|
||||
|
||||
# Add PHQ information
|
||||
if phq:
|
||||
message_parts.append("\nHealth Information:")
|
||||
if phq.get("medications"):
|
||||
message_parts.append("Medications:")
|
||||
for med in phq["medications"]:
|
||||
message_parts.append(f"- {med.get('name', '')} ({med.get('dosage', '')} {med.get('frequency', '')})")
|
||||
|
||||
if phq.get("issues"):
|
||||
message_parts.append("Health Issues:")
|
||||
for issue in phq["issues"]:
|
||||
message_parts.append(f"- {issue.get('key', '')}")
|
||||
for detail in issue.get("details", []):
|
||||
message_parts.append(f" * {detail.get('description', '')}")
|
||||
|
||||
if phq.get("conditions"):
|
||||
message_parts.append("Conditions:")
|
||||
for condition in phq["conditions"]:
|
||||
message_parts.append(f"- {condition.get('description', '')}")
|
||||
|
||||
# Add income and address
|
||||
message_parts.append(f"\nIncome: ${income:,.2f}")
|
||||
if address:
|
||||
addr_parts = []
|
||||
if address.get("address1"):
|
||||
addr_parts.append(address["address1"])
|
||||
if address.get("address2"):
|
||||
addr_parts.append(address["address2"])
|
||||
if address.get("city") and address.get("state"):
|
||||
addr_parts.append(f"{address['city']}, {address['state']}")
|
||||
if address.get("zipcode"):
|
||||
addr_parts.append(address["zipcode"])
|
||||
|
||||
if addr_parts:
|
||||
message_parts.append(f"Address: {', '.join(addr_parts)}")
|
||||
|
||||
message_parts.append("\nPlease provide a detailed estimation including tier assignment, pricing, and individual applicant analysis.")
|
||||
message_parts.append("\nConsider the following factors in your analysis:")
|
||||
message_parts.append("- Age and BMI of each applicant")
|
||||
message_parts.append("- Medical conditions and their severity")
|
||||
message_parts.append("- Prescription medications and their costs")
|
||||
message_parts.append("- Risk factors (nicotine use, etc.)")
|
||||
message_parts.append("- Family coverage type and plan details")
|
||||
message_parts.append("\nRespond with a JSON object that follows this exact schema:")
|
||||
message_parts.append("{")
|
||||
message_parts.append(' "status": "accepted" or "rejected",')
|
||||
message_parts.append(' "details": {')
|
||||
message_parts.append(' "dtq": boolean,')
|
||||
message_parts.append(' "reason": "string",')
|
||||
message_parts.append(' "tier": integer,')
|
||||
message_parts.append(' "total_price": float')
|
||||
message_parts.append(' },')
|
||||
message_parts.append(' "results": [')
|
||||
message_parts.append(' {')
|
||||
message_parts.append(' "name": "string",')
|
||||
message_parts.append(' "applicant_type": "string",')
|
||||
message_parts.append(' "age": integer,')
|
||||
message_parts.append(' "bmi": float,')
|
||||
message_parts.append(' "tier": integer,')
|
||||
message_parts.append(' "rx_spend": float,')
|
||||
message_parts.append(' "message": "string"')
|
||||
message_parts.append(' }')
|
||||
message_parts.append(' ]')
|
||||
message_parts.append('}')
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
personal_meds = [m for m in meds if m.get("applicant") == applicant_index]
|
||||
if len(personal_meds) >= 3:
|
||||
tier += 0.5
|
||||
|
||||
for issue in issues:
|
||||
for detail in issue.get("details", []):
|
||||
if detail.get("key") in HIGH_RISK_FLAGS:
|
||||
tier += 0.5
|
||||
|
||||
return round(min(tier, 7.0) * 2) / 2
|
||||
|
||||
def calculate_rx_spend(medications: List[Dict[str, Any]], applicant_index: int) -> float:
|
||||
"""Calculate total prescription spend for an applicant"""
|
||||
total = 0.0
|
||||
for med in medications:
|
||||
if med.get("applicant") != applicant_index:
|
||||
continue
|
||||
drug = med.get("name", "").lower().strip()
|
||||
if drug in DRUG_PRICE_MAP:
|
||||
total += DRUG_PRICE_MAP[drug]
|
||||
return round(total, 2)
|
||||
|
||||
def get_rx_price(tier: float, coverage_type: int) -> float:
|
||||
"""Get prescription price based on tier and coverage type"""
|
||||
tier = round(tier * 2) / 2
|
||||
coverage_index = {
|
||||
1: 0, 2: 1, 3: 2, 4: 3
|
||||
}.get(coverage_type, 0)
|
||||
return PRICING_TABLE.get(tier, PRICING_TABLE[5])[coverage_index]
|
||||
|
||||
# ---------------- Main Underwriting Logic ---------------- #
|
||||
|
||||
def run_underwriting(applicants: List[Dict[str, Any]], phq: Dict[str, Any],
|
||||
plans: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Run comprehensive underwriting analysis"""
|
||||
results = []
|
||||
max_tier = 0
|
||||
dtq_found = False
|
||||
ages = []
|
||||
|
||||
# Calculate ages for all applicants
|
||||
for applicant in applicants:
|
||||
age = calculate_age(applicant.get("dob"), phq.get("effectiveDate"))
|
||||
if age is not None:
|
||||
ages.append(age)
|
||||
|
||||
oldest_age = max(ages) if ages else None
|
||||
base = base_tier(oldest_age) if oldest_age else None
|
||||
|
||||
coverage = plans[0]["coverage"] if plans else 1
|
||||
coverage_index = {1: 0, 2: 1, 3: 2, 4: 3}.get(coverage, 0)
|
||||
|
||||
for idx, applicant in enumerate(applicants):
|
||||
async def estimate_insurance(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Send estimation request to TALESTORM API and parse response"""
|
||||
try:
|
||||
age = calculate_age(applicant.get("dob"), phq.get("effectiveDate"))
|
||||
bmi = calculate_bmi(
|
||||
applicant.get("weight"),
|
||||
applicant.get("heightFt"),
|
||||
applicant.get("heightIn")
|
||||
)
|
||||
name = applicant.get("firstName", "Unknown")
|
||||
applicant_type = {
|
||||
1: "Primary",
|
||||
2: "Spouse",
|
||||
3: "Dependent"
|
||||
}.get(applicant.get("applicant"), "Unknown")
|
||||
|
||||
is_dtq = check_declinable(phq)
|
||||
rx_spend = calculate_rx_spend(phq.get("medications", []), idx)
|
||||
|
||||
if is_dtq:
|
||||
tier = 5.0
|
||||
dtq_found = True
|
||||
message = "Declined due to high-risk conditions (DTQ triggered)."
|
||||
elif age is None or bmi is None or base is None:
|
||||
tier = 4.5
|
||||
message = "Assigned fallback Tier 4.5 due to missing age or BMI."
|
||||
else:
|
||||
tier = base
|
||||
tier = adjust_tier(tier, bmi, phq.get("medications", []),
|
||||
phq.get("issues", []), idx)
|
||||
|
||||
tier_price = PRICING_TABLE.get(tier, PRICING_TABLE[5])[coverage_index]
|
||||
max_price = PRICING_TABLE[7][coverage_index]
|
||||
|
||||
if rx_spend > max_price:
|
||||
tier = 5.0
|
||||
message = f"Declined due to high Rx spend (${rx_spend} > ${max_price})"
|
||||
elif rx_spend > tier_price:
|
||||
for t in sorted(PRICING_TABLE.keys()):
|
||||
if PRICING_TABLE[t][coverage_index] >= rx_spend:
|
||||
tier = t
|
||||
break
|
||||
message = f"Rx spend ${rx_spend} increased tier to {tier}."
|
||||
else:
|
||||
message = f"Tier {tier} assigned with Rx spend ${rx_spend} within allowed limits."
|
||||
# Create or get existing session with estimation agent
|
||||
session_id = await session_service.create_session(agent_id=self.agent_id)
|
||||
|
||||
# Format the request as a natural language message
|
||||
estimation_message = self._format_estimation_request(request_data)
|
||||
|
||||
# Send request to TALESTORM API
|
||||
async with await self.get_client() as client:
|
||||
response = await client.post(
|
||||
"/chat/",
|
||||
json={
|
||||
"chat_session_id": session_id,
|
||||
"user_message": estimation_message
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"TALESTORM API error: {response.status_code} {response.text}")
|
||||
|
||||
# Parse the response
|
||||
response_data = response.json()
|
||||
message_content = response_data.get("message", "")
|
||||
|
||||
# Try to parse the JSON response from the message
|
||||
try:
|
||||
# The response should contain a JSON string within the message
|
||||
# First, try to extract JSON from the message if it's wrapped in code blocks
|
||||
if "```json" in message_content:
|
||||
start = message_content.find("```json") + 7
|
||||
end = message_content.find("```", start)
|
||||
if end != -1:
|
||||
message_content = message_content[start:end].strip()
|
||||
elif "```" in message_content:
|
||||
# Handle generic code blocks
|
||||
start = message_content.find("```") + 3
|
||||
end = message_content.find("```", start)
|
||||
if end != -1:
|
||||
message_content = message_content[start:end].strip()
|
||||
|
||||
# Clean up any leading/trailing whitespace and newlines
|
||||
message_content = message_content.strip()
|
||||
|
||||
parsed_response = json.loads(message_content)
|
||||
|
||||
# Validate that the response has the expected structure
|
||||
if not isinstance(parsed_response, dict):
|
||||
raise ValueError("Response is not a JSON object")
|
||||
|
||||
required_fields = ["status", "details", "results"]
|
||||
for field in required_fields:
|
||||
if field not in parsed_response:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
# Validate details structure
|
||||
details = parsed_response.get("details", {})
|
||||
required_details = ["dtq", "reason", "tier", "total_price"]
|
||||
for field in required_details:
|
||||
if field not in details:
|
||||
raise ValueError(f"Missing required detail field: {field}")
|
||||
|
||||
# Validate results structure
|
||||
results = parsed_response.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
raise ValueError("Results must be an array")
|
||||
|
||||
for result in results:
|
||||
required_result_fields = ["name", "applicant_type", "age", "bmi", "tier", "rx_spend", "message"]
|
||||
for field in required_result_fields:
|
||||
if field not in result:
|
||||
raise ValueError(f"Missing required result field: {field}")
|
||||
|
||||
return parsed_response
|
||||
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
# If the response is not valid JSON or doesn't match schema, create a fallback response
|
||||
print(f"Failed to parse JSON response: {e}")
|
||||
print(f"Message content: {message_content}")
|
||||
return {
|
||||
"status": "error",
|
||||
"details": {
|
||||
"dtq": False,
|
||||
"reason": f"Unable to parse AI response: {str(e)}",
|
||||
"tier": 4,
|
||||
"total_price": 0.0
|
||||
},
|
||||
"results": []
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
tier = 4.5
|
||||
rx_spend = 0.0
|
||||
message = f"Fallback Tier 4.5 due to system error: {str(e)}"
|
||||
# Return error response
|
||||
return {
|
||||
"status": "error",
|
||||
"details": {
|
||||
"dtq": False,
|
||||
"reason": f"Estimation service error: {str(e)}",
|
||||
"tier": 4,
|
||||
"total_price": 0.0
|
||||
},
|
||||
"results": []
|
||||
}
|
||||
|
||||
max_tier = max(max_tier, tier)
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"applicant_type": applicant_type,
|
||||
"age": age,
|
||||
"bmi": round(bmi, 2) if bmi else None,
|
||||
"tier": tier,
|
||||
"rx_spend": rx_spend,
|
||||
"message": message
|
||||
})
|
||||
|
||||
total_price = get_rx_price(max_tier, coverage)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"combined": {
|
||||
"tier": max_tier,
|
||||
"total_price": total_price,
|
||||
"dtq": dtq_found,
|
||||
"message": f"Final assigned tier is {max_tier} based on age, health, Rx cost, or fallback logic."
|
||||
}
|
||||
}
|
||||
# Global estimation service instance
|
||||
estimation_service = EstimationService()
|
||||
|
|
@ -26,7 +26,7 @@ class SessionService:
|
|||
"""List available agents from talestorm-ai"""
|
||||
async with await self.get_client() as client:
|
||||
try:
|
||||
response = await client.get("/agents/")
|
||||
response = await client.get("/agents")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
|
|
@ -57,12 +57,11 @@ class SessionService:
|
|||
"""Create a new chat session in talestorm-ai"""
|
||||
async with await self.get_client() as client:
|
||||
if not agent_id:
|
||||
if self.agent_id:
|
||||
agent_id = self.agent_id
|
||||
else:
|
||||
agent_id = settings.TALESTORM_AGENT_ID
|
||||
# Use the default agent ID for backward compatibility
|
||||
agent_id = self.agent_id or settings.TALESTORM_AGENT_ID
|
||||
|
||||
response = await client.post("/sessions/", params={"agent_id": agent_id})
|
||||
print(response.request.url)
|
||||
session_data = response.json()
|
||||
return str(session_data["id"])
|
||||
|
||||
|
|
@ -82,7 +81,7 @@ class SessionService:
|
|||
"""List all sessions from talestorm-ai"""
|
||||
async with await self.get_client() as client:
|
||||
try:
|
||||
response = await client.get("/sessions/")
|
||||
response = await client.get("/sessions")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue