fix: enhance Fusion Claims Intelligence AI with client status and billing period tools

- Fix _read_group override crash (dict_values not subscriptable) in sale_order.py
- Migrate _fc_tool_claims_stats from deprecated read_group() to _read_group() API
- Enrich client details tool with funding history, invoice status, prev-funded devices
- Add Client Status Lookup tool (search by name, returns orders/invoices/next steps)
- Add ADP Billing Period tool (invoiced amounts, paid/unpaid, submission deadlines)
- Update AI agent system prompt with all 5 tools and usage examples
This commit is contained in:
2026-03-10 02:30:42 +00:00
parent 3342b57469
commit 7bd7b8f7c4
3 changed files with 386 additions and 50 deletions

View File

@@ -26,7 +26,7 @@ ai['result'] = record._fc_tool_search_clients(search_term, city_filter, conditio
<field name="code">
ai['result'] = record._fc_tool_client_details(profile_id)
</field>
<field name="ai_tool_description">Get detailed information about a specific client profile including personal info, medical status, benefits, claims history, and ADP application history. Requires profile_id from a previous search.</field>
<field name="ai_tool_description">Get detailed information about a specific client profile including personal info, medical status, benefits, claims history, ADP application history, funding history with invoice status, and previously funded devices. Requires profile_id from a previous search.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {"profile_id": {"type": "number", "description": "ID of the client profile to get details for"}}, "required": ["profile_id"]}</field>
</record>
@@ -39,21 +39,56 @@ ai['result'] = record._fc_tool_client_details(profile_id)
<field name="code">
ai['result'] = record._fc_tool_claims_stats()
</field>
<field name="ai_tool_description">Get aggregated statistics about Fusion Claims data: total profiles, total orders, breakdown by sale type, breakdown by workflow status, and top cities by client count. No parameters needed.</field>
<field name="ai_tool_description">Get aggregated statistics about Fusion Claims data: total profiles, total orders, breakdown by sale type with amounts, breakdown by ADP workflow status, and top cities by client count. No parameters needed.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {}, "required": []}</field>
</record>
<!-- Tool 4: Client Status Lookup (by name) -->
<record id="ai_tool_client_status" model="ir.actions.server">
<field name="name">Fusion: Client Status Lookup</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_client_status(client_name)
</field>
<field name="ai_tool_description">Look up a client's complete status by name. Returns all their orders with current ADP status, invoice details (ADP and client portions, paid/unpaid), document checklist, funding warnings, and recommended next steps for each order. Use this when someone asks "what's the status of [name]" or "how is [name]'s case going".</field>
<field name="ai_tool_schema">{"type": "object", "properties": {"client_name": {"type": "string", "description": "The client's name (first name, last name, or full name) to look up"}}, "required": ["client_name"]}</field>
</record>
<!-- Tool 5: ADP Billing Period Summary -->
<record id="ai_tool_adp_billing" model="ir.actions.server">
<field name="name">Fusion: ADP Billing Period</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_adp_billing_period(period)
</field>
<field name="ai_tool_description">Get ADP billing summary for a posting period. Shows total invoiced amount to ADP, paid vs unpaid amounts, number of orders billed, submission deadline, and expected payment date. Use when asked about ADP billing, posting, or invoicing for a period.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {"period": {"type": "string", "description": "Which period to query: 'current' (default), 'previous', 'next', or a specific date in YYYY-MM-DD format"}}, "required": []}</field>
</record>
<!-- ================================================================= -->
<!-- AI TOPIC (references tools above) -->
<!-- AI TOPIC (references all tools) -->
<!-- ================================================================= -->
<record id="ai_topic_client_intelligence" model="ai.topic">
<field name="name">Fusion Claims Client Intelligence</field>
<field name="description">Query client profiles, ADP claims, funding history, medical conditions, and device information.</field>
<field name="instructions">You help users find information about ADP clients, claims, medical conditions, devices, and funding history. Use the Fusion search/details/stats tools to query data.</field>
<field name="description">Query client profiles, ADP claims, funding history, billing periods, and device information.</field>
<field name="instructions">You help users find information about ADP clients, claims, medical conditions, devices, funding history, and billing periods. Use the Fusion tools to query data.
Common questions and which tool to use:
- "What is the status of [name]?" -> Use Client Status Lookup (Tool 4)
- "What is the ADP billing for this period?" -> Use ADP Billing Period (Tool 5)
- "Tell me about [name]'s funding history" -> Use Client Status Lookup first, then Client Details for more depth
- "How many claims do we have?" -> Use Claims Statistics (Tool 3)
- "Find clients in [city]" -> Use Search Client Profiles (Tool 1)</field>
<field name="tool_ids" eval="[(6, 0, [
ref('fusion_claims.ai_tool_search_clients'),
ref('fusion_claims.ai_tool_client_details'),
ref('fusion_claims.ai_tool_client_stats'),
ref('fusion_claims.ai_tool_client_status'),
ref('fusion_claims.ai_tool_adp_billing'),
])]"/>
</record>
@@ -62,32 +97,44 @@ ai['result'] = record._fc_tool_claims_stats()
<!-- ================================================================= -->
<record id="ai_agent_fusion_claims" model="ai.agent">
<field name="name">Fusion Claims Intelligence</field>
<field name="subtitle">Ask about clients, ADP claims, funding history, medical conditions, and devices.</field>
<field name="subtitle">Ask about clients, ADP claims, funding history, billing periods, and devices.</field>
<field name="llm_model">gpt-4.1</field>
<field name="response_style">analytical</field>
<field name="restrict_to_sources" eval="False"/>
<field name="system_prompt">You are Fusion Claims Intelligence, an AI assistant for ADP claims management.
<field name="system_prompt">You are Fusion Claims Intelligence, an AI assistant for ADP claims management at a mobility equipment company.
You help staff find information about clients, medical conditions, mobility devices, funding history, and claim status.
You help staff find information about clients, their order status, medical conditions, mobility devices, funding history, billing periods, and claim status.
Capabilities:
1. Search client profiles by name, health card number, city, or medical condition
2. Get detailed client information including claims history and ADP applications
2. Get detailed client information including funding history, invoice status, and previously funded devices
3. Provide aggregated statistics about claims, funding types, and demographics
4. Look up a client's complete status by name -- including all orders, invoices, documents, and next steps
5. Query ADP billing period summaries -- total invoiced to ADP, paid vs unpaid, submission deadlines
How to handle common requests:
- "What is the status of [name]?" -> Use the Client Status Lookup tool with the client's name
- "What is the ADP billing this period?" -> Use the ADP Billing Period tool with period="current"
- "What was the ADP billing last period?" -> Use the ADP Billing Period tool with period="previous"
- "Show me [name]'s funding history" -> Use Client Status Lookup, then Client Details for full history
- If a client is not found by profile, the system also searches by contact/partner name
Response guidelines:
- Be concise and data-driven
- Format monetary values with $ and commas
- Format monetary values with $ and commas (e.g., $1,250.00)
- When listing orders, always include: order number, status, amounts, and next recommended step
- When showing billing summaries, include: period dates, total invoiced, paid vs unpaid, submission deadline
- Include key identifiers (name, health card, city) when listing clients
- Include order number, status, and amounts when discussing claims
- If asked about a specific client, search first, then get details
- Always provide the profile ID for record lookup
- If asked about a specific client, use Client Status Lookup first (it searches by name)
- Always indicate if invoices are paid or unpaid
Key terminology:
- ADP = Assistive Devices Program (Ontario government)
- Client Type REG = Regular (75% ADP / 25% Client), ODS/OWP/ACS = 100% ADP
- ADP = Assistive Devices Program (Ontario government funding)
- Client Type REG = Regular (75% ADP / 25% Client split), ODS/OWP/ACS = 100% ADP funded
- Posting Period = 14-day ADP billing cycle; submission deadline is Wednesday 6 PM before posting day
- Sale Types: ADP, ODSP, WSIB, Insurance, March of Dimes, Muscular Dystrophy, Hardship Funding
- Sections: 2a = Walkers, 2b = Manual Wheelchairs, 2c = Power Bases/Scooters, 2d = Seating</field>
- Sections: 2a = Walkers, 2b = Manual Wheelchairs, 2c = Power Bases/Scooters, 2d = Seating
- Previously Funded = devices the client has received ADP funding for before (affects eligibility)</field>
<field name="topic_ids" eval="[(6, 0, [ref('ai_topic_client_intelligence')])]"/>
</record>
</odoo>

View File

@@ -4,16 +4,60 @@
import json
import logging
from datetime import date, timedelta
from odoo import api, models
_logger = logging.getLogger(__name__)
PREV_FUNDED_FIELDS = {
'prev_funded_forearm': 'Forearm Crutches',
'prev_funded_wheeled': 'Wheeled Walker',
'prev_funded_manual': 'Manual Wheelchair',
'prev_funded_power': 'Power Wheelchair',
'prev_funded_addon': 'Power Add-On Device',
'prev_funded_scooter': 'Power Scooter',
'prev_funded_seating': 'Positioning Devices',
'prev_funded_tilt': 'Power Tilt System',
'prev_funded_recline': 'Power Recline System',
'prev_funded_legrests': 'Power Elevating Leg Rests',
'prev_funded_frame': 'Paediatric Standing Frame',
'prev_funded_stroller': 'Paediatric Specialty Stroller',
}
STATUS_NEXT_STEPS = {
'quotation': 'Schedule assessment with the client',
'assessment_scheduled': 'Complete the assessment',
'assessment_completed': 'Prepare and send ADP application to client',
'waiting_for_application': 'Follow up with client to return signed application',
'application_received': 'Review application and prepare for submission',
'ready_submission': 'Submit application to ADP',
'submitted': 'Wait for ADP acceptance (typically within 24 hours)',
'accepted': 'Wait for ADP approval decision',
'rejected': 'Review rejection reason and correct the application',
'resubmitted': 'Wait for ADP acceptance of resubmission',
'needs_correction': 'Review and correct the application per ADP feedback',
'approved': 'Prepare order for delivery',
'approved_deduction': 'Prepare order for delivery (note: approved with deduction)',
'ready_delivery': 'Schedule and complete delivery to client',
'ready_bill': 'Create and submit ADP invoice',
'billed': 'Monitor for ADP payment',
'case_closed': 'No further action required',
'on_hold': 'Check hold reason and follow up when ready to resume',
'denied': 'Review denial reason; consider appeal or alternative funding',
'withdrawn': 'No further action unless client wants to reinstate',
'cancelled': 'No further action required',
'expired': 'Contact client about reapplication if still needed',
}
class AIAgentFusionClaims(models.Model):
"""Extend ai.agent with Fusion Claims tool methods."""
_inherit = 'ai.agent'
# ------------------------------------------------------------------
# Tool 1: Search Client Profiles
# ------------------------------------------------------------------
def _fc_tool_search_clients(self, search_term=None, city_filter=None, condition_filter=None):
"""AI Tool: Search client profiles."""
Profile = self.env['fusion.client.profile'].sudo()
@@ -46,20 +90,37 @@ class AIAgentFusionClaims(models.Model):
})
return json.dumps({'count': len(results), 'profiles': results})
# ------------------------------------------------------------------
# Tool 2: Get Client Details (enriched with funding history)
# ------------------------------------------------------------------
def _fc_tool_client_details(self, profile_id):
"""AI Tool: Get detailed client information."""
"""AI Tool: Get detailed client information with funding history."""
Profile = self.env['fusion.client.profile'].sudo()
profile = Profile.browse(int(profile_id))
if not profile.exists():
return json.dumps({'error': 'Profile not found'})
# Get orders
orders = []
if profile.partner_id:
Invoice = self.env['account.move'].sudo()
for o in self.env['sale.order'].sudo().search([
('partner_id', '=', profile.partner_id.id),
('x_fc_sale_type', '!=', False),
], limit=20):
], limit=20, order='date_order desc'):
invoices = Invoice.search([
('x_fc_source_sale_order_id', '=', o.id),
('move_type', '=', 'out_invoice'),
])
inv_summary = []
for inv in invoices:
inv_summary.append({
'number': inv.name or '',
'portion': inv.x_fc_adp_invoice_portion or 'full',
'amount': float(inv.amount_total),
'paid': inv.payment_state in ('paid', 'in_payment'),
'date': str(inv.invoice_date) if inv.invoice_date else '',
})
orders.append({
'name': o.name,
'sale_type': o.x_fc_sale_type,
@@ -68,11 +129,19 @@ class AIAgentFusionClaims(models.Model):
'client_total': float(o.x_fc_client_portion_total),
'total': float(o.amount_total),
'date': str(o.date_order.date()) if o.date_order else '',
'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '',
'previous_funding_date': str(o.x_fc_previous_funding_date) if o.x_fc_previous_funding_date else '',
'funding_warning': o.x_fc_funding_warning_message or '',
'funding_warning_level': o.x_fc_funding_warning_level or '',
'invoices': inv_summary,
})
# Get applications
apps = []
for a in profile.application_data_ids[:10]:
funded_devices = [
label for field, label in PREV_FUNDED_FIELDS.items()
if getattr(a, field, False)
]
apps.append({
'date': str(a.application_date) if a.application_date else '',
'device': a.base_device or '',
@@ -80,8 +149,17 @@ class AIAgentFusionClaims(models.Model):
'reason': a.reason_for_application or '',
'condition': (a.medical_condition or '')[:100],
'authorizer': f'{a.authorizer_first_name or ""} {a.authorizer_last_name or ""}'.strip(),
'previously_funded': funded_devices if funded_devices else ['None'],
})
ai_summary = ''
ai_risk = ''
try:
ai_summary = profile.ai_summary or ''
ai_risk = profile.ai_risk_flags or ''
except Exception:
pass
return json.dumps({
'profile': {
'id': profile.id,
@@ -106,12 +184,18 @@ class AIAgentFusionClaims(models.Model):
'total_adp': float(profile.total_adp_funded),
'total_client': float(profile.total_client_portion),
'total_amount': float(profile.total_amount),
'applications_count': profile.application_count,
'last_assessment': str(profile.last_assessment_date) if profile.last_assessment_date else '',
'ai_summary': ai_summary,
'ai_risk_flags': ai_risk,
},
'orders': orders,
'applications': apps,
})
# ------------------------------------------------------------------
# Tool 3: Get Aggregated Stats (migrated from read_group)
# ------------------------------------------------------------------
def _fc_tool_claims_stats(self):
"""AI Tool: Get aggregated claims statistics."""
SO = self.env['sale.order'].sudo()
@@ -120,40 +204,46 @@ class AIAgentFusionClaims(models.Model):
total_profiles = Profile.search_count([])
total_orders = SO.search_count([('x_fc_sale_type', '!=', False)])
# By sale type
type_data = SO.read_group(
[('x_fc_sale_type', '!=', False)],
['x_fc_sale_type', 'amount_total:sum'],
['x_fc_sale_type'],
)
by_type = {}
for r in type_data:
by_type[r['x_fc_sale_type']] = {
'count': r['x_fc_sale_type_count'],
'total': float(r['amount_total'] or 0),
try:
type_results = SO._read_group(
[('x_fc_sale_type', '!=', False)],
groupby=['x_fc_sale_type'],
aggregates=['__count', 'amount_total:sum'],
)
for sale_type, count, total_amount in type_results:
by_type[sale_type or 'unknown'] = {
'count': count,
'total': float(total_amount or 0),
}
except Exception as e:
_logger.warning('Stats by_type failed: %s', e)
# By status
status_data = SO.read_group(
[('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)],
['x_fc_adp_application_status'],
['x_fc_adp_application_status'],
)
by_status = {}
for r in status_data:
by_status[r['x_fc_adp_application_status']] = r['x_fc_adp_application_status_count']
# By city (top 10)
city_data = Profile.read_group(
[('city', '!=', False)],
['city'],
['city'],
limit=10,
orderby='city_count desc',
try:
status_results = SO._read_group(
[('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)],
groupby=['x_fc_adp_application_status'],
aggregates=['__count'],
)
for status, count in status_results:
by_status[status or 'unknown'] = count
except Exception as e:
_logger.warning('Stats by_status failed: %s', e)
by_city = {}
for r in city_data:
by_city[r['city']] = r['city_count']
try:
city_results = Profile._read_group(
[('city', '!=', False)],
groupby=['city'],
aggregates=['__count'],
limit=10,
order='__count desc',
)
for city, count in city_results:
by_city[city or 'unknown'] = count
except Exception as e:
_logger.warning('Stats by_city failed: %s', e)
return json.dumps({
'total_profiles': total_profiles,
@@ -162,3 +252,201 @@ class AIAgentFusionClaims(models.Model):
'by_status': by_status,
'top_cities': by_city,
})
# ------------------------------------------------------------------
# Tool 4: Client Status Lookup (by name, not order number)
# ------------------------------------------------------------------
def _fc_tool_client_status(self, client_name):
"""AI Tool: Look up a client's complete status by name."""
if not client_name or not client_name.strip():
return json.dumps({'error': 'Please provide a client name to search for'})
client_name = client_name.strip()
Profile = self.env['fusion.client.profile'].sudo()
SO = self.env['sale.order'].sudo()
Invoice = self.env['account.move'].sudo()
profiles = Profile.search([
'|',
('first_name', 'ilike', client_name),
('last_name', 'ilike', client_name),
], limit=5)
if not profiles:
partners = self.env['res.partner'].sudo().search([
('name', 'ilike', client_name),
], limit=5)
if not partners:
return json.dumps({'error': f'No client found matching "{client_name}"'})
results = []
for partner in partners:
orders = SO.search([
('partner_id', '=', partner.id),
('x_fc_sale_type', '!=', False),
], order='date_order desc', limit=20)
if not orders:
continue
results.append(self._build_client_status_result(
partner_name=partner.name,
partner_id=partner.id,
profile=None,
orders=orders,
Invoice=Invoice,
))
if not results:
return json.dumps({'error': f'No orders found for "{client_name}"'})
return json.dumps({'clients': results})
results = []
for profile in profiles:
orders = SO.search([
('partner_id', '=', profile.partner_id.id),
('x_fc_sale_type', '!=', False),
], order='date_order desc', limit=20) if profile.partner_id else SO
results.append(self._build_client_status_result(
partner_name=profile.display_name,
partner_id=profile.partner_id.id if profile.partner_id else None,
profile=profile,
orders=orders if profile.partner_id else SO.browse(),
Invoice=Invoice,
))
return json.dumps({'clients': results})
def _build_client_status_result(self, partner_name, partner_id, profile, orders, Invoice):
"""Build a complete status result for one client."""
order_data = []
for o in orders:
invoices = Invoice.search([
('x_fc_source_sale_order_id', '=', o.id),
('move_type', '=', 'out_invoice'),
])
adp_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'adp')
client_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'client')
docs = {
'original_application': bool(o.x_fc_original_application),
'signed_pages': bool(o.x_fc_signed_pages_11_12),
'xml_file': bool(o.x_fc_xml_file),
'proof_of_delivery': bool(o.x_fc_proof_of_delivery),
}
status = o.x_fc_adp_application_status or ''
next_step = STATUS_NEXT_STEPS.get(status, '')
order_data.append({
'order': o.name,
'sale_type': o.x_fc_sale_type,
'status': status,
'date': str(o.date_order.date()) if o.date_order else '',
'total': float(o.amount_total),
'adp_total': float(o.x_fc_adp_portion_total),
'client_total': float(o.x_fc_client_portion_total),
'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '',
'funding_warning': o.x_fc_funding_warning_message or '',
'adp_invoice': {
'number': adp_inv[0].name if adp_inv else '',
'amount': float(adp_inv[0].amount_total) if adp_inv else 0,
'paid': adp_inv[0].payment_state in ('paid', 'in_payment') if adp_inv else False,
} if adp_inv else None,
'client_invoice': {
'number': client_inv[0].name if client_inv else '',
'amount': float(client_inv[0].amount_total) if client_inv else 0,
'paid': client_inv[0].payment_state in ('paid', 'in_payment') if client_inv else False,
} if client_inv else None,
'documents': docs,
'next_step': next_step,
})
result = {
'name': partner_name,
'partner_id': partner_id,
'orders': order_data,
'order_count': len(order_data),
}
if profile:
result['profile_id'] = profile.id
result['health_card'] = profile.health_card_number or ''
result['city'] = profile.city or ''
result['condition'] = (profile.medical_condition or '')[:100]
result['total_adp_funded'] = float(profile.total_adp_funded)
result['total_client_funded'] = float(profile.total_client_portion)
return result
# ------------------------------------------------------------------
# Tool 5: ADP Billing Period Summary
# ------------------------------------------------------------------
def _fc_tool_adp_billing_period(self, period=None):
"""AI Tool: Get ADP billing summary for a posting period."""
Mixin = self.env['fusion_claims.adp.posting.schedule.mixin']
Invoice = self.env['account.move'].sudo()
today = date.today()
frequency = Mixin._get_adp_posting_frequency()
if not period or period == 'current':
posting_date = Mixin._get_current_posting_date(today)
elif period == 'previous':
current = Mixin._get_current_posting_date(today)
posting_date = current - timedelta(days=frequency)
elif period == 'next':
posting_date = Mixin._get_next_posting_date(today)
else:
try:
ref_date = date.fromisoformat(period)
posting_date = Mixin._get_current_posting_date(ref_date)
except (ValueError, TypeError):
return json.dumps({'error': f'Invalid period: "{period}". Use "current", "previous", "next", or a date (YYYY-MM-DD).'})
period_start = posting_date
period_end = posting_date + timedelta(days=frequency - 1)
submission_deadline = Mixin._get_posting_week_wednesday(posting_date)
expected_payment = Mixin._get_expected_payment_date(posting_date)
adp_invoices = Invoice.search([
('x_fc_adp_invoice_portion', '=', 'adp'),
('move_type', '=', 'out_invoice'),
('invoice_date', '>=', str(period_start)),
('invoice_date', '<=', str(period_end)),
])
total_invoiced = sum(adp_invoices.mapped('amount_total'))
total_paid = sum(adp_invoices.filtered(
lambda i: i.payment_state in ('paid', 'in_payment')
).mapped('amount_total'))
total_unpaid = total_invoiced - total_paid
source_orders = adp_invoices.mapped('x_fc_source_sale_order_id')
invoice_details = []
for inv in adp_invoices[:25]:
so = inv.x_fc_source_sale_order_id
invoice_details.append({
'invoice': inv.name or '',
'order': so.name if so else '',
'client': inv.partner_id.name or '',
'amount': float(inv.amount_total),
'paid': inv.payment_state in ('paid', 'in_payment'),
'date': str(inv.invoice_date) if inv.invoice_date else '',
})
return json.dumps({
'period': {
'posting_date': str(posting_date),
'start': str(period_start),
'end': str(period_end),
'submission_deadline': f'{submission_deadline.strftime("%A, %B %d, %Y")} 6:00 PM',
'expected_payment_date': str(expected_payment),
},
'summary': {
'total_invoices': len(adp_invoices),
'total_invoiced': float(total_invoiced),
'total_paid': float(total_paid),
'total_unpaid': float(total_unpaid),
'orders_billed': len(source_orders),
},
'invoices': invoice_details,
})

View File

@@ -1836,7 +1836,8 @@ class SaleOrder(models.Model):
domain, groupby=groupby, aggregates=aggregates,
having=having, offset=offset, limit=limit, order=order,
)
if groupby and groupby[0] == 'x_fc_adp_application_status':
groupby_list = list(groupby) if not isinstance(groupby, (list, tuple)) else groupby
if groupby_list and groupby_list[0] == 'x_fc_adp_application_status':
status_order = self._STATUS_ORDER
result = sorted(result, key=lambda r: status_order.get(r[0], 999))
return result