import json import logging from odoo import models _logger = logging.getLogger(__name__) CLAUDE_MODEL = 'claude-sonnet-4-20250514' # ───────────────────────────────────────────────────────────────────────────── # Rule-based issue tag weights # Maps (field_name, value_or_True) → list of issue tag names # Applied to fl.case fields directly (employment-based rules handled separately # via party_ids lookup in _rule_based_tagging). # ───────────────────────────────────────────────────────────────────────────── CASE_FIELD_RULES = [ ('threshold_met', True, ['modification_threshold']), ('substantial_timesharing_applies', True, ['timesharing_deviation']), ('domestic_violence_flag', True, ['domestic_violence']), ('fee_waiver_eligible', True, ['fee_waiver']), ('respondent_answered', False, ['default_judgment']), ('residency_requirement_met', False, ['residency']), ('parenting_class_required', True, ['parenting_class']), ('case_type', 'modification', ['post_order']), ] # ───────────────────────────────────────────────────────────────────────────── # Internal rule key → seeded fl.issue.tag display name (data/fl_issue_tags.xml). # Rule keys are the snake_case identifiers used in CASE_FIELD_RULES and the # tagging logic; the actual tag records use human-readable names, so all DB # lookups must translate through this map. # ───────────────────────────────────────────────────────────────────────────── RULE_KEY_TO_TAG_NAME = { 'modification_threshold': 'Modification Threshold', 'income_imputation': 'Income Imputation', 'self_employment_income': 'Self-Employment Income', 'timesharing_deviation': 'Timesharing Deviation', 'domestic_violence': 'Domestic Violence', 'fee_waiver': 'Fee Waiver / Indigent Status', 'default_judgment': 'Default Judgment', 'residency': 'Residency Requirement', 'parenting_class': 'Parenting Class Required', 'post_order': 'Post-Order / Income Withholding', } MAX_CASELAW_IN_PROMPT = 8 class FlAiEngine(models.AbstractModel): """ AI Analysis Engine — Claude API (claude-sonnet-4-20250514). Workflow: 1. _rule_based_tagging — tag issue tags from case field values (fast, deterministic) 2. _match_caselaw — find relevant FL cases from the caselaw library 3. _build_case_context — serialize case data to a JSON dict 4. _build_prompt — compose the Claude prompt 5. _call_claude — Claude API call with error handling 6. _store_analysis — persist fl.analysis record with results Falls back to _fallback_complexity() when Claude API is unavailable. AbstractModel — not stored in the database. """ _name = 'fl.ai.engine' _description = 'Family Law AI Analysis Engine (Claude API)' # ────────────────────────────────────────────────────────────────────────── # Public entry point # ────────────────────────────────────────────────────────────────────────── def analyze_case(self, case_id): """Full analysis entry point. Returns fl.analysis record.""" case = self.env['fl.case'].browse(case_id) if not case.exists(): raise ValueError(f"Case {case_id} not found") analysis_vals = { 'case_id': case.id, 'state': 'pending', 'model_used': CLAUDE_MODEL, } analysis = self.env['fl.analysis'].create(analysis_vals) try: triggered_tags = self._rule_based_tagging(case) if triggered_tags: tag_names = [ RULE_KEY_TO_TAG_NAME[k] for k in triggered_tags if k in RULE_KEY_TO_TAG_NAME ] existing_tags = case.issue_tag_ids.mapped('name') new_tag_recs = self.env['fl.issue.tag'].search([ ('name', 'in', tag_names), ('name', 'not in', existing_tags), ]) if new_tag_recs: case.write({'issue_tag_ids': [(4, t.id) for t in new_tag_recs]}) matched_cases = self._match_caselaw(triggered_tags) context = self._build_case_context(case, matched_cases) complexity = self._assess_complexity(case, triggered_tags) prompt = self._build_prompt(context, complexity) result = self._call_claude(prompt) self._store_analysis(analysis, result, matched_cases, complexity) except Exception as exc: _logger.error("AI analysis failed for case %s: %s", case_id, exc, exc_info=True) fallback_complexity = self._fallback_complexity(case) analysis.write({ 'state': 'failed', 'error_message': str(exc), 'case_complexity': fallback_complexity, 'plain_english_summary': ( "AI analysis could not be completed at this time. " "Please try again later or contact support." ), 'plain_english_summary_es': ( "El análisis de IA no pudo completarse en este momento. " "Por favor intente más tarde o contacte soporte." ), }) return analysis # ────────────────────────────────────────────────────────────────────────── # Step 1: Rule-based tagging # ────────────────────────────────────────────────────────────────────────── def _rule_based_tagging(self, case): """ Apply deterministic rules to identify legal issues in the case. Returns a set of issue tag name strings that were triggered. """ triggered = set() # Direct case field checks for field_name, expected, tags in CASE_FIELD_RULES: val = getattr(case, field_name, None) if val == expected: triggered.update(tags) # Employment-based checks from party_ids petitioner_party = case.party_ids.filtered(lambda p: p.role == 'petitioner') respondent_party = case.party_ids.filtered(lambda p: p.role == 'respondent') pet = petitioner_party[0] if petitioner_party else None resp = respondent_party[0] if respondent_party else None if resp: if resp.employment_type == 'unemployed': triggered.add('income_imputation') elif resp.employment_type in ('self_employed', 'underemployed'): triggered.update(['self_employment_income', 'income_imputation']) if pet and pet.employment_type == 'self_employed': triggered.add('self_employment_income') # Large income disparity → possible income imputation issue if pet and resp: pet_income = pet.effective_monthly_income or 0 resp_income = resp.effective_monthly_income or 0 if (pet_income > 0 and resp_income > 0 and resp.employment_type not in ('unemployed', 'self_employed', 'underemployed')): ratio = max(pet_income, resp_income) / min(pet_income, resp_income) if ratio > 3.0: triggered.add('income_imputation') # Approaching emancipation → post_order concern for child in case.child_ids: if child.approaching_emancipation: triggered.add('post_order') break return triggered # ────────────────────────────────────────────────────────────────────────── # Step 2: Match caselaw # ────────────────────────────────────────────────────────────────────────── def _match_caselaw(self, triggered_tags): """ Search the fl.caselaw library for cases matching triggered issue tags. Prioritizes 3rd DCA (Miami-Dade's appellate court), then FL Supreme Court. Returns up to MAX_CASELAW_IN_PROMPT records. """ if not triggered_tags: return self.env['fl.caselaw'].search( [('active', '=', True)], order='year desc', limit=4, ) matched_ids = set() for tag in triggered_tags: tag_name = RULE_KEY_TO_TAG_NAME.get(tag) if not tag_name: continue recs = self.env['fl.caselaw'].search([ ('active', '=', True), ('issue_tag_ids.name', '=', tag_name), ], limit=6) matched_ids.update(recs.ids) if not matched_ids: return self.env['fl.caselaw'].browse() court_priority = { '3rd_dca': 0, 'fl_supreme': 1, '4th_dca': 2, '2nd_dca': 3, '1st_dca': 4, '5th_dca': 5, '11th_circuit': 6, 'other': 7, } cases = self.env['fl.caselaw'].browse(list(matched_ids)) sorted_cases = sorted( cases, key=lambda c: (court_priority.get(c.court, 9), -c.year) ) return self.env['fl.caselaw'].browse( [c.id for c in sorted_cases[:MAX_CASELAW_IN_PROMPT]] ) # ────────────────────────────────────────────────────────────────────────── # Step 3: Build case context # ────────────────────────────────────────────────────────────────────────── def _build_case_context(self, case, matched_cases): """ Serialize the case into a JSON-serializable dict for the prompt. Keeps PII minimal — uses role names, not SSNs etc. """ petitioner_party = case.party_ids.filtered(lambda p: p.role == 'petitioner') respondent_party = case.party_ids.filtered(lambda p: p.role == 'respondent') pet = petitioner_party[0] if petitioner_party else None resp = respondent_party[0] if respondent_party else None context = { 'case_type': case.case_type, 'stage': case.stage_id.name if case.stage_id else 'unknown', 'filing_date': str(case.filing_date) if case.filing_date else None, 'service_date': str(case.service_date) if case.service_date else None, 'petitioner': { 'employment': pet.employment_type if pet else 'unknown', 'monthly_net_income': pet.effective_monthly_income if pet else 0, }, 'respondent': { 'employment': resp.employment_type if resp else 'unknown', 'monthly_net_income': resp.effective_monthly_income if resp else 0, 'answered': case.respondent_answered, 'has_counsel': case.respondent_has_counsel, }, 'support': { 'current_order_amount': case.current_order_amount or 0, 'calculated_support': case.calculated_support or 0, 'support_difference': case.support_difference or 0, 'support_difference_pct': round(case.support_difference_pct or 0, 1), 'threshold_met': case.threshold_met, 'substantial_change_basis': case.substantial_change_basis or None, }, 'children': [ { 'age': c.age, 'approaching_emancipation': c.approaching_emancipation, 'days_until_emancipation': ( c.days_until_emancipation if c.approaching_emancipation else None ), } for c in (case.child_ids or []) if not c.emancipated ], 'timesharing': { 'petitioner_overnights': case.petitioner_overnights or 0, 'respondent_overnights': case.respondent_overnights or 0, 'substantial_timesharing': case.substantial_timesharing_applies, }, 'residency_met': case.residency_requirement_met, 'domestic_violence': case.domestic_violence_flag, 'fee_waiver_eligible': case.fee_waiver_eligible, 'parenting_class_required': case.parenting_class_required, 'parenting_class_completed': ( case.petitioner_parenting_class_done and case.respondent_parenting_class_done ), 'issue_tags': list(case.issue_tag_ids.mapped('name')), 'caselaw': [ { 'citation': cl.citation, 'holding': (cl.holding or '')[:300], 'favorable_to': cl.favorable_to, } for cl in matched_cases ], } return context # ────────────────────────────────────────────────────────────────────────── # Step 4: Complexity assessment # ────────────────────────────────────────────────────────────────────────── def _assess_complexity(self, case, triggered_tags): """Simple heuristic complexity scorer. Returns 'simple', 'moderate', or 'complex'.""" score = 0 if case.domestic_violence_flag: score += 3 if case.respondent_has_counsel: score += 2 if 'income_imputation' in triggered_tags: score += 2 if 'self_employment_income' in triggered_tags: score += 2 if case.child_ids and len(case.child_ids) > 2: score += 1 if triggered_tags: score += len(triggered_tags) if score <= 3: return 'simple' elif score <= 7: return 'moderate' else: return 'complex' def _fallback_complexity(self, case): """ Rule-based complexity fallback used when Claude API is unavailable. Returns 'simple', 'moderate', or 'complex'. """ score = 0 if case.domestic_violence_flag: score += 3 if case.respondent_has_counsel: score += 2 if case.child_ids and len(case.child_ids) > 2: score += 1 if score <= 1: return 'simple' elif score <= 4: return 'moderate' else: return 'complex' # ────────────────────────────────────────────────────────────────────────── # Step 5: Build prompt # ────────────────────────────────────────────────────────────────────────── def _build_prompt(self, context, complexity): """ Construct the prompt for Claude. Returns a string (user message content). The model is instructed to respond with a JSON object only. """ context_json = json.dumps(context, indent=2) attorney_trigger = ( context.get('domestic_violence') or context.get('respondent', {}).get('has_counsel') or complexity == 'complex' ) prompt = f"""You are an AI legal assistant for a Florida family law case management system. \ Your role is to help pro se (self-represented) litigants in Miami-Dade County understand their case. \ You are NOT providing legal advice — you are explaining the legal framework and procedure. IMPORTANT RULES: - Always recommend an attorney when the case involves domestic violence, opposing counsel, or high complexity. - Florida uses the FL 61.30 income-shares model for child support. - Modification threshold: 15% AND $50 difference required (FL 61.30(1)(b)). - Timesharing credit applies when either parent has >73 overnights/year (FL 61.30(11)(b)). - All output must be JSON only — no markdown, no prose outside the JSON object. CASE DATA: {context_json} TASK: Analyze this case and return a JSON object with exactly these fields: {{ "plain_english_summary": "3-5 sentence plain English explanation of the case situation and key legal issues — no jargon", "plain_english_summary_es": "Same 3-5 sentences in Spanish", "petitioner_arguments": ["argument 1", "argument 2", "argument 3"], "respondent_counterarguments": ["counterargument 1", "counterargument 2"], "procedural_risks": ["risk 1", "risk 2"], "attorney_referral_flag": {"true" if attorney_trigger else "false"}, "attorney_referral_reason": "reason if flag is true, else null", "confidence_level": "high|medium|low", "case_complexity": "{complexity}", "next_steps": ["step 1", "step 2", "step 3"] }} Respond with the JSON object only. No other text.""" return prompt # ────────────────────────────────────────────────────────────────────────── # Step 6: Call Claude API # ────────────────────────────────────────────────────────────────────────── def _call_claude(self, prompt): """ Call Claude API and return parsed JSON dict. Raises RuntimeError on connection error or JSON parse failure. API key read from ir.config_parameter key 'fl_ai.claude_api_key'. """ try: import anthropic except ImportError: raise RuntimeError( 'anthropic library not installed. Run: pip install anthropic' ) api_key = self.env['ir.config_parameter'].sudo().get_param('fl_ai.claude_api_key') if not api_key: raise RuntimeError( 'Claude API key not configured. ' 'Set fl_ai.claude_api_key in Settings → Technical → System Parameters.' ) _logger.info("FL AI Engine: calling Claude API (model=%s)", CLAUDE_MODEL) client = anthropic.Anthropic(api_key=api_key) try: message = client.messages.create( model=CLAUDE_MODEL, max_tokens=2048, messages=[{'role': 'user', 'content': prompt}], ) except anthropic.RateLimitError as exc: raise RuntimeError(f'Claude API rate limit exceeded: {exc}') from exc except anthropic.APIConnectionError as exc: raise RuntimeError(f'Claude API connection error: {exc}') from exc except anthropic.APIError as exc: raise RuntimeError(f'Claude API error: {exc}') from exc raw = message.content[0].text.strip() _logger.debug("FL AI Engine raw response (first 200 chars): %s", raw[:200]) # Strip markdown code fences if present if raw.startswith('```'): parts = raw.split('```') raw = parts[1] if len(parts) >= 2 else raw if raw.lower().startswith('json'): raw = raw[4:] raw = raw.strip() # Extract first JSON object if extra prose leaked through if raw and raw[0] == '{': brace_depth = 0 end_idx = 0 for i, ch in enumerate(raw): if ch == '{': brace_depth += 1 elif ch == '}': brace_depth -= 1 if brace_depth == 0: end_idx = i + 1 break raw = raw[:end_idx] try: return json.loads(raw) except json.JSONDecodeError as exc: _logger.error("FL AI Engine: JSON parse error: %s\nRaw: %s", exc, raw[:500]) raise RuntimeError(f"Claude returned invalid JSON: {exc}") from exc # ────────────────────────────────────────────────────────────────────────── # Step 7: Store analysis # ────────────────────────────────────────────────────────────────────────── def _store_analysis(self, analysis, result, matched_cases, complexity): """Write parsed Claude result into the fl.analysis record.""" attorney_flag = result.get('attorney_referral_flag', False) if isinstance(attorney_flag, str): attorney_flag = attorney_flag.lower() in ('true', '1', 'yes') petitioner_args = result.get('petitioner_arguments', []) respondent_counter = result.get('respondent_counterarguments', []) procedural_risks = result.get('procedural_risks', []) vals = { 'state': 'complete', 'plain_english_summary': result.get('plain_english_summary', ''), 'plain_english_summary_es': result.get('plain_english_summary_es', ''), 'attorney_referral_flag': bool(attorney_flag), 'attorney_referral_reason': result.get('attorney_referral_reason') or '', 'confidence_level': result.get('confidence_level', 'medium'), 'case_complexity': result.get('case_complexity', complexity), 'petitioner_arguments': json.dumps(petitioner_args, ensure_ascii=False, indent=2), 'respondent_counterarguments': json.dumps(respondent_counter, ensure_ascii=False, indent=2), 'procedural_risks': json.dumps(procedural_risks, ensure_ascii=False, indent=2), 'raw_response': json.dumps(result, ensure_ascii=False, indent=2), } if matched_cases: vals['matched_caselaw_ids'] = [(6, 0, matched_cases.ids)] analysis.write(vals) if attorney_flag and analysis.case_id: analysis.case_id.message_post( body=( "⚠ AI ANALYSIS — ATTORNEY REFERRAL RECOMMENDED
" f"{result.get('attorney_referral_reason', 'Case complexity warrants legal counsel.')}
" "FL Volunteer Lawyers Project: flvlp.org | " "3-1-1 Legal Info: flcourts.gov" ), message_type='comment', subtype_xmlid='mail.mt_comment', ) _logger.info( "FL AI Engine: analysis %s complete (complexity=%s, attorney_referral=%s)", analysis.id, complexity, attorney_flag )