import json import logging from odoo import models from .fl_ai_engine import CLAUDE_MODEL _logger = logging.getLogger(__name__) # Candidate library sizes passed to the model (it picks the top 3-5 from these). MAX_STATUTE_CANDIDATES = 12 MAX_CASELAW_CANDIDATES = 12 ATTORNEY_SYSTEM_PROMPT = ( "You are a senior Florida family-law attorney assistant for the 11th Judicial " "Circuit (Miami-Dade). You provide SUBSTANTIVE strategy support to the legal " "team — you are NOT giving legal advice to a litigant. Analyze the full case " "record and build on any prior analyses. " "Identify the strongest applicable statutes and case law, but you MUST choose " "ONLY from the candidate lists provided — never invent a citation. Draft " "arguments for the case's primary issues and counterarguments the opposing " "party is likely to raise. Surface substantive risks (domestic violence, " "hidden assets, income imputation, unrepresented respondent). For child-support " "modification, assess whether there is a substantial change of circumstances " "under FL 61.30(1)(b). Recommend attorney involvement when domestic violence, " "opposing counsel, or high complexity is present. " "Respond with a single JSON object only — no prose outside the JSON." ) class FlAttorneyAgent(models.AbstractModel): """ Attorney AI agent — substantive legal reasoning. Fires only on a deliberate user action (button on the case AI tab); never runs automatically. Produces a strategy memo stored as an fl.analysis record of type 'attorney', links the top statutes/case law from the existing library, drafts arguments, and writes a risk narrative. Falls back to a rule-based memo when the Claude API is unavailable — never surfaces a raw API error to the user. """ _name = 'fl.attorney.agent' _description = 'Attorney AI Agent (substantive)' # ────────────────────────────────────────────────────────────────────── # Public entry point # ────────────────────────────────────────────────────────────────────── def generate_strategy_memo(self, case): """Run a full substantive analysis. Returns the fl.analysis memo record.""" analysis = self.env['fl.analysis'].create({ 'case_id': case.id, 'analysis_type': 'attorney', 'state': 'pending', 'model_used': CLAUDE_MODEL, }) statutes = self._candidate_statutes(case) caselaw = self._candidate_caselaw(case) ai_used = False try: context = self._build_context(case, statutes, caselaw) result = self.env['fl.ai.engine'].call_claude_json( user_content=( 'Case record:\n' + json.dumps(context, indent=2) + '\n\nProduce the strategy memo now.' ), system=ATTORNEY_SYSTEM_PROMPT, max_tokens=3000, ) self._store_memo(case, analysis, result, statutes, caselaw) ai_used = True except Exception as exc: _logger.error("Attorney agent failed for case %s: %s", case.id, exc, exc_info=True) self._store_fallback(case, analysis, statutes, caselaw, str(exc)) self._log_ai_time(case, 'Attorney strategy memo', ai_used) return analysis def _log_ai_time(self, case, note, ai_used): """Log a non-billable AI audit entry. No-op until fl.timesheet exists.""" if 'fl.timesheet' not in self.env: return try: self.env['fl.timesheet'].sudo().create({ 'case_id': case.id, 'name': note, 'is_billable': False, 'ai_agent': 'attorney', 'duration_hours': 0.1 if ai_used else 0.02, }) except Exception as exc: # never block on audit logging _logger.warning("Attorney AI-time logging skipped for case %s: %s", case.id, exc) # ────────────────────────────────────────────────────────────────────── # Candidate library (grounds the model in real records) # ────────────────────────────────────────────────────────────────────── def _candidate_statutes(self, case): """Statutes relevant to the case's issue tags + case type.""" statutes = self.env['fl.paralegal.agent']._cross_reference_statutes(case) return statutes[:MAX_STATUTE_CANDIDATES] def _candidate_caselaw(self, case): """Case law tagged with any of the case's active issue tags.""" if not case.issue_tag_ids: return self.env['fl.caselaw'].search( [('active', '=', True)], order='year desc', limit=MAX_CASELAW_CANDIDATES) return self.env['fl.caselaw'].search([ ('active', '=', True), ('issue_tag_ids', 'in', case.issue_tag_ids.ids), ], limit=MAX_CASELAW_CANDIDATES) # ────────────────────────────────────────────────────────────────────── # Context # ────────────────────────────────────────────────────────────────────── def _build_context(self, case, statutes, caselaw): pet = case.party_ids.filtered(lambda p: p.role == 'petitioner')[:1] resp = case.party_ids.filtered(lambda p: p.role == 'respondent')[:1] def _party(party): if not party: return None return { 'employment_type': party.employment_type, 'gross_monthly_income': party.gross_monthly_income or 0, 'net_monthly_income': party.net_monthly_income or 0, 'effective_monthly_income': party.effective_monthly_income or 0, 'income_imputed': party.income_imputed, 'lifestyle_inconsistency': party.lifestyle_inconsistency_flag, } # Prior completed analyses (the just-created memo is still 'pending', # so it is excluded) — lets the agent build on earlier work. prior = case.analysis_ids.filtered( lambda a: a.state == 'complete' ).sorted('create_date', reverse=True)[:3] return { 'case_type': case.case_type, 'stage': case.stage_id.name if case.stage_id else 'unknown', 'complexity': ( case.latest_analysis_id.case_complexity or self.env['fl.ai.engine']._fallback_complexity(case) ), 'issue_tags': case.issue_tag_ids.mapped('name'), 'petitioner': _party(pet), 'respondent': _party(resp), 'respondent_has_counsel': case.respondent_has_counsel, 'respondent_answered': case.respondent_answered, 'children': [ {'age': c.age, 'approaching_emancipation': c.approaching_emancipation} for c in case.child_ids if not c.emancipated ], 'support': { 'current_order_total': case.current_order_total 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, 'substantial_change_detail': case.substantial_change_detail or None, }, 'timesharing': { 'petitioner_overnights': case.petitioner_overnights or 0, 'respondent_overnights': case.respondent_overnights or 0, 'substantial_timesharing': case.substantial_timesharing_applies, }, 'flags': { 'domestic_violence': case.domestic_violence_flag, 'dv_injunction_active': case.dv_injunction_active, 'residency_met': case.residency_requirement_met, 'fee_waiver_eligible': case.fee_waiver_eligible, }, 'prior_analyses': [ {'date': str(a.analysis_date), 'type': a.analysis_type, 'summary': a.plain_english_summary or ''} for a in prior ], 'candidate_statutes': [ {'name': s.name, 'title': s.title, 'category': s.category} for s in statutes ], 'candidate_caselaw': [ {'citation': c.citation, 'holding': (c.holding or '')[:300], 'favorable_to': c.favorable_to} for c in caselaw ], 'output_schema': { 'strategy_memo': 'detailed strategy memo (markdown allowed)', 'plain_english_summary': '3-5 sentence summary, no jargon', 'plain_english_summary_es': 'same summary in Spanish', 'top_statutes': 'list of 3-5 exact statute names from candidate_statutes', 'top_caselaw': 'list of 3-5 exact citations from candidate_caselaw', 'petitioner_arguments': 'list of strings', 'respondent_counterarguments': 'list of strings', 'procedural_risks': 'list of strings', 'risk_narrative': 'substantive risk narrative string', 'substantial_change_detected': 'true/false (modification cases)', 'substantial_change_narrative': 'string', 'attorney_referral_flag': 'true/false', 'attorney_referral_reason': 'string or null', 'confidence_level': 'high|medium|low', 'case_complexity': 'simple|moderate|complex', }, } # ────────────────────────────────────────────────────────────────────── # Store (AI result) # ────────────────────────────────────────────────────────────────────── def _store_memo(self, case, analysis, result, statutes, caselaw): # Resolve the model's picks back to real records (only from candidates) picked_statutes = statutes.filtered( lambda s: s.name in (result.get('top_statutes') or []) ) or statutes[:5] picked_caselaw = caselaw.filtered( lambda c: c.citation in (result.get('top_caselaw') or []) ) or caselaw[:5] attorney_flag = result.get('attorney_referral_flag', False) if isinstance(attorney_flag, str): attorney_flag = attorney_flag.lower() in ('true', '1', 'yes') memo_html = self._memo_to_html(result, picked_statutes, picked_caselaw) analysis.write({ 'state': 'complete', 'strategy_memo': memo_html, 'plain_english_summary': result.get('plain_english_summary', ''), 'plain_english_summary_es': result.get('plain_english_summary_es', ''), 'petitioner_arguments': json.dumps( result.get('petitioner_arguments', []), ensure_ascii=False, indent=2), 'respondent_counterarguments': json.dumps( result.get('respondent_counterarguments', []), ensure_ascii=False, indent=2), 'procedural_risks': json.dumps( result.get('procedural_risks', []), ensure_ascii=False, indent=2), 'risk_narrative': result.get('risk_narrative', ''), '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', 'moderate'), 'cited_statute_ids': [(6, 0, picked_statutes.ids)], 'matched_caselaw_ids': [(6, 0, picked_caselaw.ids)], 'raw_response': json.dumps(result, ensure_ascii=False, indent=2), }) self._link_and_announce(case, analysis, picked_caselaw, attorney_flag, result.get('attorney_referral_reason')) # ────────────────────────────────────────────────────────────────────── # Store (rule-based fallback — no AI) # ────────────────────────────────────────────────────────────────────── def _store_fallback(self, case, analysis, statutes, caselaw, error): complexity = self.env['fl.ai.engine']._fallback_complexity(case) picked_statutes = statutes[:5] picked_caselaw = caselaw[:5] risks = [] if case.domestic_violence_flag: risks.append('Domestic violence flagged — affects mediation, ' 'timesharing, and safety; attorney strongly advised.') if case.respondent_has_counsel: risks.append('Respondent has counsel — pro se petitioner is at a ' 'significant disadvantage.') if any(p.income_imputed or p.lifestyle_inconsistency_flag for p in case.party_ids): risks.append('Possible income imputation / lifestyle inconsistency ' '(FL 61.30(2)(b)).') if case.case_type == 'modification' and not case.threshold_met: risks.append('Modification threshold (FL 61.30(1)(b)) not currently met.') if not case.residency_requirement_met: risks.append('Residency requirement (FL 61.021) not yet verified.') attorney_flag = bool( case.domestic_violence_flag or case.respondent_has_counsel or complexity == 'complex' ) substantial_change = ( case.case_type == 'modification' and case.threshold_met ) memo_html = ( '
Rule-based strategy memo (Claude API unavailable).
' f'Case type: {case.case_type} ' f'Complexity: {complexity}
' 'Applicable statutes: ' + (', '.join(picked_statutes.mapped('name')) or '—') + '
' 'Relevant case law: ' + (', '.join(picked_caselaw.mapped('citation')) or '—') + '
' 'Substantial change of circumstances (FL 61.30(1)(b)): ' + ('Likely — threshold met.' if substantial_change else 'Not established on current figures.') + '
' 'Risks:
Top statutes: ' + (', '.join(statutes.mapped('name')) or '—') + '
' 'Top case law: ' + (', '.join(caselaw.mapped('citation')) or '—') + '
' 'Petitioner arguments:
Respondent counterarguments:
Substantial change (FL 61.30(1)(b)): ' + (result.get('substantial_change_narrative') or '—') + '
' )