import json import logging from odoo import models _logger = logging.getLogger(__name__) OLLAMA_URL = 'http://192.168.2.10:11434/api/generate' OLLAMA_MODEL = 'llama3.1' # ───────────────────────────────────────────────────────────────────────────── # Rule-based issue tag weights # Maps (field_name, value_or_True) → list of issue tag XML ids # ───────────────────────────────────────────────────────────────────────────── ISSUE_RULES = [ # Modification threshold ('threshold_met', True, ['modification_threshold']), # Income imputation triggers ('respondent_employment_status', 'unemployed', ['income_imputation']), ('respondent_employment_status', 'self_employed', ['self_employment_income', 'income_imputation']), ('petitioner_employment_status', 'self_employed', ['self_employment_income']), # Timesharing deviation ('substantial_timesharing', True, ['timesharing_deviation']), # Domestic violence ('domestic_violence_flag', True, ['domestic_violence']), # Fee waiver ('fee_waiver_eligible', True, ['fee_waiver']), # Default judgment track ('respondent_answered', False, ['default_judgment']), # Residency ('residency_requirement_met', False, ['residency']), # Parenting class ('parenting_class_required', True, ['parenting_class']), # Post-order ('case_type', 'modification', ['post_order']), ] # ───────────────────────────────────────────────────────────────────────────── # Caselaw topic → issue tag matching # Used by _match_caselaw to find relevant cases # ───────────────────────────────────────────────────────────────────────────── TAG_TO_CASELAW_DOMAINS = { 'modification_threshold': [('issue_tag_ids.name', '=', 'modification_threshold')], 'income_imputation': [('issue_tag_ids.name', '=', 'income_imputation')], 'self_employment_income': [('issue_tag_ids.name', '=', 'self_employment_income')], 'timesharing_deviation': [('issue_tag_ids.name', '=', 'timesharing_deviation')], 'domestic_violence': [('issue_tag_ids.name', '=', 'domestic_violence')], 'fee_waiver': [('issue_tag_ids.name', '=', 'fee_waiver')], 'default_judgment': [('issue_tag_ids.name', '=', 'default_judgment')], 'residency': [('issue_tag_ids.name', '=', 'residency')], 'parenting_class': [('issue_tag_ids.name', '=', 'parenting_class')], 'post_order': [('issue_tag_ids.name', '=', 'post_order')], } # Maximum caselaw records to pass to Ollama (keep prompt size manageable) MAX_CASELAW_IN_PROMPT = 8 class FlAiEngine(models.AbstractModel): """ Phase 5 — Full Ollama integration with rule-based pre-processing. 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 Ollama prompt 5. _call_ollama — HTTP call to Ollama with error handling 6. _store_analysis — persist fl.analysis record with results AbstractModel — not stored in the database. """ _name = 'fl.ai.engine' _description = 'Family Law AI Analysis Engine (Ollama)' # ────────────────────────────────────────────────────────────────────────── # Public entry point # ────────────────────────────────────────────────────────────────────────── def analyze_case(self, case_id): """ Full Phase 5 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': OLLAMA_MODEL, } analysis = self.env['fl.analysis'].create(analysis_vals) try: # Step 1: Rule-based tagging triggered_tags = self._rule_based_tagging(case) if triggered_tags: existing_tags = case.issue_tag_ids.mapped('name') new_tag_recs = self.env['fl.issue.tag'].search([ ('name', 'in', triggered_tags), ('name', 'not in', existing_tags), ]) if new_tag_recs: case.write({'issue_tag_ids': [(4, t.id) for t in new_tag_recs]}) # Step 2: Match caselaw matched_cases = self._match_caselaw(triggered_tags) # Step 3: Build case context context = self._build_case_context(case, matched_cases) # Step 4: Determine complexity (used in prompt and result) complexity = self._assess_complexity(case, triggered_tags) # Step 5: Build prompt prompt = self._build_prompt(context, complexity) # Step 6: Call Ollama result = self._call_ollama(prompt) # Step 7: Store results 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) analysis.write({ 'state': 'failed', 'error_message': str(exc), '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 present in the case. Returns a set of issue tag name strings that were triggered. """ triggered = set() for rule in ISSUE_RULES: field_name, expected, tags = rule if not hasattr(case, field_name): continue val = getattr(case, field_name) # Handle relational fields (Many2one returns recordset) if hasattr(val, '_name'): continue if val == expected: triggered.update(tags) # Additional logic: income imputation if large discrepancy if (case.petitioner_net_income and case.respondent_net_income and case.respondent_employment_status not in ('unemployed', 'self_employed')): pet = case.petitioner_net_income resp = case.respondent_net_income if resp > 0 and pet > 0: ratio = max(pet, resp) / min(pet, resp) if ratio > 3.0: triggered.add('income_imputation') # Emancipation approaching if case.child_ids: 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 the triggered issue tags. Prioritizes 3rd DCA cases (Miami-Dade's primary appellate court), then FL Supreme Court, then other DCAs. Returns up to MAX_CASELAW_IN_PROMPT records. """ if not triggered_tags: # Return a small set of foundational cases return self.env['fl.caselaw'].search( [('active', '=', True)], order='year desc', limit=4, ) # Collect matching case IDs per tag, with deduplication matched_ids = set() for tag in triggered_tags: domain = TAG_TO_CASELAW_DOMAINS.get(tag, []) if domain: recs = self.env['fl.caselaw'].search( [('active', '=', True)] + domain, limit=6, ) matched_ids.update(recs.ids) if not matched_ids: return self.env['fl.caselaw'].browse() # Fetch and sort: 3rd DCA first, then FL Supreme, then by year desc cases = self.env['fl.caselaw'].browse(list(matched_ids)) 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, } 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 actual SSNs etc. """ 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': case.petitioner_id.employment_status if case.petitioner_id else 'unknown', 'monthly_net_income': case.petitioner_net_income or 0, }, 'respondent': { 'employment': case.respondent_id.employment_status if case.respondent_id else 'unknown', 'monthly_net_income': case.respondent_net_income or 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_year or 0, 'respondent_overnights': case.respondent_overnights_year or 0, 'substantial_timesharing': case.substantial_timesharing, }, '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.parenting_class_completed, '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' # ────────────────────────────────────────────────────────────────────────── # Step 5: Build Ollama prompt # ────────────────────────────────────────────────────────────────────────── def _build_prompt(self, context, complexity): """ Construct the system + user prompt for Ollama. Returns a string. The LLM 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": {"attorney_trigger" 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 Ollama # ────────────────────────────────────────────────────────────────────────── def _call_ollama(self, prompt): """ Call Ollama API (llama3.1) and return parsed JSON dict. Raises on network error or JSON parse failure. """ try: import requests except ImportError: raise RuntimeError( 'requests library not available. ' 'Install with: pip install requests' ) _logger.info("FL AI Engine: calling Ollama at %s (model=%s)", OLLAMA_URL, OLLAMA_MODEL) response = requests.post( OLLAMA_URL, json={ 'model': OLLAMA_MODEL, 'prompt': prompt, 'stream': False, 'options': { 'temperature': 0.1, 'top_p': 0.9, 'num_predict': 2000, }, }, timeout=180, ) response.raise_for_status() raw = response.json().get('response', '{}').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('```') if len(parts) >= 3: raw = parts[1] elif len(parts) == 2: raw = parts[1] if raw.lower().startswith('json'): raw = raw[4:] raw = raw.strip() # Extract first JSON object if extra text 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"Ollama returned invalid JSON: {exc}") from exc # ────────────────────────────────────────────────────────────────────────── # Step 7: Store analysis # ────────────────────────────────────────────────────────────────────────── def _store_analysis(self, analysis, result, matched_cases, complexity): """ Write parsed Ollama result into the fl.analysis record. """ # Normalize boolean field from Ollama (may come as string "true") attorney_flag = result.get('attorney_referral_flag', False) if isinstance(attorney_flag, str): attorney_flag = attorney_flag.lower() in ('true', '1', 'yes') # Serialize list fields to JSON strings for Text fields 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 referral flagged, post urgent chatter message on the case 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 | " "Three-Day Rule: 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 )