351 lines
14 KiB
Python
351 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2024-2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
import json
|
|
import logging
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FusionClientChatSession(models.Model):
|
|
_name = 'fusion.client.chat.session'
|
|
_description = 'Client Intelligence Chat Session'
|
|
_order = 'create_date desc'
|
|
|
|
name = fields.Char(string='Session Title', required=True,
|
|
default=lambda self: f'Chat - {fields.Date.today()}')
|
|
profile_id = fields.Many2one(
|
|
'fusion.client.profile', string='Client Profile',
|
|
ondelete='set null',
|
|
help='If set, chat is scoped to this specific client',
|
|
)
|
|
user_id = fields.Many2one(
|
|
'res.users', string='User', default=lambda self: self.env.user,
|
|
required=True,
|
|
)
|
|
message_ids = fields.One2many(
|
|
'fusion.client.chat.message', 'session_id', string='Messages',
|
|
)
|
|
state = fields.Selection([
|
|
('active', 'Active'),
|
|
('archived', 'Archived'),
|
|
], default='active', string='State')
|
|
|
|
# Input field for the form view
|
|
user_input = fields.Text(string='Your Question')
|
|
|
|
def action_send_message(self):
|
|
"""Process user message and generate AI response."""
|
|
self.ensure_one()
|
|
if not self.user_input or not self.user_input.strip():
|
|
return
|
|
|
|
question = self.user_input.strip()
|
|
|
|
# Create user message
|
|
self.env['fusion.client.chat.message'].create({
|
|
'session_id': self.id,
|
|
'role': 'user',
|
|
'content': question,
|
|
})
|
|
|
|
# Generate AI response
|
|
try:
|
|
response = self._generate_ai_response(question)
|
|
except Exception as e:
|
|
_logger.exception('AI chat error: %s', e)
|
|
response = f'Sorry, I encountered an error processing your question. Error: {str(e)}'
|
|
|
|
# Create assistant message
|
|
msg = self.env['fusion.client.chat.message'].create({
|
|
'session_id': self.id,
|
|
'role': 'assistant',
|
|
'content': response,
|
|
})
|
|
|
|
# Clear input
|
|
self.user_input = False
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fusion.client.chat.session',
|
|
'view_mode': 'form',
|
|
'res_id': self.id,
|
|
'target': 'current',
|
|
}
|
|
|
|
def _generate_ai_response(self, question):
|
|
"""Generate an AI-powered response to the user question.
|
|
|
|
Uses OpenAI API to analyze the question, query relevant data,
|
|
and formulate a response.
|
|
"""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
api_key = ICP.get_param('fusion_claims.ai_api_key', '')
|
|
if not api_key:
|
|
return self._generate_local_response(question)
|
|
|
|
ai_model = ICP.get_param('fusion_claims.ai_model', 'gpt-4o-mini')
|
|
|
|
# Build context about available data
|
|
context_data = self._build_data_context(question)
|
|
|
|
# Build system prompt
|
|
system_prompt = self._build_system_prompt()
|
|
|
|
# Build messages
|
|
messages = [{'role': 'system', 'content': system_prompt}]
|
|
|
|
# Add conversation history (last 10 messages)
|
|
history = self.message_ids.sorted('create_date')[-10:]
|
|
for msg in history:
|
|
messages.append({'role': msg.role, 'content': msg.content})
|
|
|
|
# Add current question with data context
|
|
user_msg = question
|
|
if context_data:
|
|
user_msg += f'\n\n--- Retrieved Data ---\n{context_data}'
|
|
messages.append({'role': 'user', 'content': user_msg})
|
|
|
|
# Call OpenAI API
|
|
try:
|
|
import requests
|
|
response = requests.post(
|
|
'https://api.openai.com/v1/chat/completions',
|
|
headers={
|
|
'Authorization': f'Bearer {api_key}',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
json={
|
|
'model': ai_model,
|
|
'messages': messages,
|
|
'max_tokens': 2000,
|
|
'temperature': 0.3,
|
|
},
|
|
timeout=30,
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
return result['choices'][0]['message']['content']
|
|
except ImportError:
|
|
return self._generate_local_response(question)
|
|
except Exception as e:
|
|
_logger.warning('OpenAI API error: %s', e)
|
|
return self._generate_local_response(question)
|
|
|
|
def _generate_local_response(self, question):
|
|
"""Generate a response without AI, using direct database queries.
|
|
|
|
This is the fallback when no API key is configured.
|
|
"""
|
|
question_lower = question.lower()
|
|
Profile = self.env['fusion.client.profile']
|
|
SaleOrder = self.env['sale.order']
|
|
|
|
# If scoped to a specific profile
|
|
if self.profile_id:
|
|
profile = self.profile_id
|
|
orders = SaleOrder.search([
|
|
('partner_id', '=', profile.partner_id.id),
|
|
('x_fc_sale_type', '!=', False),
|
|
]) if profile.partner_id else SaleOrder
|
|
|
|
lines = []
|
|
lines.append(f'**Client: {profile.display_name}**')
|
|
lines.append(f'- Health Card: {profile.health_card_number or "N/A"}')
|
|
lines.append(f'- Date of Birth: {profile.date_of_birth or "N/A"}')
|
|
lines.append(f'- City: {profile.city or "N/A"}')
|
|
lines.append(f'- Medical Condition: {profile.medical_condition or "N/A"}')
|
|
lines.append(f'- Mobility Status: {profile.mobility_status or "N/A"}')
|
|
lines.append(f'- Total Claims: {len(orders)}')
|
|
lines.append(f'- Total ADP Funded: ${profile.total_adp_funded:,.2f}')
|
|
lines.append(f'- Total Client Portion: ${profile.total_client_portion:,.2f}')
|
|
|
|
if orders:
|
|
lines.append('\n**Claims History:**')
|
|
for order in orders[:10]:
|
|
status = dict(order._fields['x_fc_adp_application_status'].selection).get(
|
|
order.x_fc_adp_application_status, order.x_fc_adp_application_status or 'N/A'
|
|
)
|
|
lines.append(
|
|
f'- {order.name}: {order.x_fc_sale_type or "N/A"} | '
|
|
f'Status: {status} | '
|
|
f'ADP: ${order.x_fc_adp_portion_total:,.2f} | '
|
|
f'Client: ${order.x_fc_client_portion_total:,.2f}'
|
|
)
|
|
|
|
apps = profile.application_data_ids[:5]
|
|
if apps:
|
|
lines.append('\n**Application History:**')
|
|
for app in apps:
|
|
lines.append(
|
|
f'- {app.application_date or "No date"}: '
|
|
f'{app.device_category or "N/A"} | '
|
|
f'Device: {app.base_device or "N/A"} | '
|
|
f'Reason: {app.reason_for_application or "N/A"}'
|
|
)
|
|
|
|
return '\n'.join(lines)
|
|
|
|
# Global queries
|
|
total_profiles = Profile.search_count([])
|
|
total_orders = SaleOrder.search_count([('x_fc_sale_type', '!=', False)])
|
|
|
|
if 'how many' in question_lower or 'count' in question_lower:
|
|
if 'client' in question_lower or 'profile' in question_lower:
|
|
return f'There are currently **{total_profiles}** client profiles in the system.'
|
|
if 'claim' in question_lower or 'order' in question_lower or 'case' in question_lower:
|
|
return f'There are currently **{total_orders}** claims/orders in the system.'
|
|
|
|
# Search for specific client
|
|
if 'find' in question_lower or 'search' in question_lower or 'show' in question_lower:
|
|
# Try to extract name from question
|
|
words = question.split()
|
|
profiles = Profile.search([], limit=20)
|
|
for word in words:
|
|
if len(word) > 2 and word[0].isupper():
|
|
found = Profile.search([
|
|
'|',
|
|
('first_name', 'ilike', word),
|
|
('last_name', 'ilike', word),
|
|
], limit=5)
|
|
if found:
|
|
profiles = found
|
|
break
|
|
|
|
if profiles:
|
|
lines = [f'Found **{len(profiles)}** matching profile(s):']
|
|
for p in profiles[:10]:
|
|
lines.append(
|
|
f'- **{p.display_name}** | HC: {p.health_card_number or "N/A"} | '
|
|
f'City: {p.city or "N/A"} | Claims: {p.claim_count}'
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
return (
|
|
f'I have access to **{total_profiles}** client profiles and **{total_orders}** claims. '
|
|
f'You can ask me questions like:\n'
|
|
f'- "How many clients are from Brampton?"\n'
|
|
f'- "Find client Raymond Wellesley"\n'
|
|
f'- "Show all clients with CVA diagnosis"\n\n'
|
|
f'For more intelligent responses, configure an OpenAI API key in '
|
|
f'Fusion Claims > Configuration > Settings.'
|
|
)
|
|
|
|
def _build_system_prompt(self):
|
|
"""Build the system prompt for the AI."""
|
|
profile_context = ''
|
|
if self.profile_id:
|
|
p = self.profile_id
|
|
profile_context = f"""
|
|
You are currently looking at a specific client profile:
|
|
- Name: {p.display_name}
|
|
- Health Card: {p.health_card_number or 'N/A'}
|
|
- DOB: {p.date_of_birth or 'N/A'}
|
|
- City: {p.city or 'N/A'}
|
|
- Medical Condition: {p.medical_condition or 'N/A'}
|
|
- Mobility Status: {p.mobility_status or 'N/A'}
|
|
- Total Claims: {p.claim_count}
|
|
- Total ADP Funded: ${p.total_adp_funded:,.2f}
|
|
- Total Client Portion: ${p.total_client_portion:,.2f}
|
|
"""
|
|
|
|
return f"""You are a helpful AI assistant for Fusion Claims, a healthcare equipment claims management system.
|
|
You help users find information about clients, their ADP (Assistive Devices Program) claims, funding history,
|
|
medical conditions, and devices.
|
|
|
|
Available data includes:
|
|
- Client profiles with personal info, health card numbers, addresses, medical conditions
|
|
- ADP application data parsed from XML submissions
|
|
- Sale orders with funding type, status, ADP/client portions
|
|
- Device information (wheelchairs, walkers, power bases, seating)
|
|
|
|
Funding types: ADP, ODSP, WSIB, March of Dimes, Muscular Dystrophy, Insurance, Hardship Funding, Rentals, Direct/Private
|
|
Client types: REG (75%/25%), ODS, OWP, ACS, LTC, SEN, CCA (100%/0%)
|
|
{profile_context}
|
|
Answer concisely and include specific data when available. Format monetary values with $ and commas."""
|
|
|
|
def _build_data_context(self, question):
|
|
"""Query relevant data based on the question to provide context to AI."""
|
|
question_lower = question.lower()
|
|
context_parts = []
|
|
|
|
Profile = self.env['fusion.client.profile']
|
|
SaleOrder = self.env['sale.order']
|
|
|
|
if self.profile_id:
|
|
# Scoped to specific client - load their data
|
|
p = self.profile_id
|
|
orders = SaleOrder.search([
|
|
('partner_id', '=', p.partner_id.id),
|
|
('x_fc_sale_type', '!=', False),
|
|
], limit=20) if p.partner_id else SaleOrder
|
|
|
|
if orders:
|
|
order_data = []
|
|
for o in orders:
|
|
order_data.append({
|
|
'name': o.name,
|
|
'sale_type': o.x_fc_sale_type,
|
|
'status': o.x_fc_adp_application_status,
|
|
'adp_total': o.x_fc_adp_portion_total,
|
|
'client_total': o.x_fc_client_portion_total,
|
|
'amount_total': o.amount_total,
|
|
'date': str(o.date_order) if o.date_order else '',
|
|
})
|
|
context_parts.append(f'Orders: {json.dumps(order_data)}')
|
|
|
|
apps = p.application_data_ids[:10]
|
|
if apps:
|
|
app_data = []
|
|
for a in apps:
|
|
app_data.append({
|
|
'date': str(a.application_date) if a.application_date else '',
|
|
'device_category': a.device_category,
|
|
'base_device': a.base_device or '',
|
|
'condition': a.medical_condition or '',
|
|
'reason': a.reason_for_application or '',
|
|
'authorizer': f'{a.authorizer_first_name} {a.authorizer_last_name}'.strip(),
|
|
})
|
|
context_parts.append(f'Applications: {json.dumps(app_data)}')
|
|
else:
|
|
# Global query - provide summary stats
|
|
total_profiles = Profile.search_count([])
|
|
total_orders = SaleOrder.search_count([('x_fc_sale_type', '!=', False)])
|
|
|
|
# City distribution
|
|
if 'city' in question_lower or 'cities' in question_lower or 'where' in question_lower:
|
|
city_data = SaleOrder.read_group(
|
|
[('x_fc_sale_type', '!=', False), ('partner_id.city', '!=', False)],
|
|
['partner_id'],
|
|
['partner_id'],
|
|
limit=20,
|
|
)
|
|
context_parts.append(f'Total profiles: {total_profiles}, Total orders: {total_orders}')
|
|
|
|
context_parts.append(f'Summary: {total_profiles} profiles, {total_orders} orders')
|
|
|
|
return '\n'.join(context_parts) if context_parts else ''
|
|
|
|
|
|
class FusionClientChatMessage(models.Model):
|
|
_name = 'fusion.client.chat.message'
|
|
_description = 'Chat Message'
|
|
_order = 'create_date asc'
|
|
|
|
session_id = fields.Many2one(
|
|
'fusion.client.chat.session', string='Session',
|
|
required=True, ondelete='cascade',
|
|
)
|
|
role = fields.Selection([
|
|
('user', 'User'),
|
|
('assistant', 'Assistant'),
|
|
], string='Role', required=True)
|
|
content = fields.Text(string='Content', required=True)
|
|
timestamp = fields.Datetime(
|
|
string='Timestamp', default=fields.Datetime.now,
|
|
)
|