diff --git a/activeblue_familylaw/models/__init__.py b/activeblue_familylaw/models/__init__.py index d5c3a93..6c20caa 100644 --- a/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw/models/__init__.py @@ -17,3 +17,4 @@ from . import fl_argument from . import fl_case from . import fl_conflict_check from . import fl_paralegal_agent +from . import fl_attorney_agent diff --git a/activeblue_familylaw/models/fl_ai_engine.py b/activeblue_familylaw/models/fl_ai_engine.py index 13393f8..e27d1e1 100644 --- a/activeblue_familylaw/models/fl_ai_engine.py +++ b/activeblue_familylaw/models/fl_ai_engine.py @@ -391,9 +391,15 @@ Respond with the JSON object only. No other text.""" # ────────────────────────────────────────────────────────────────────────── def _call_claude(self, prompt): + """Call Claude with a single user prompt; return parsed JSON dict.""" + return self.call_claude_json(prompt) + + def call_claude_json(self, user_content, system=None, max_tokens=2048): """ - Call Claude API and return parsed JSON dict. - Raises RuntimeError on connection error or JSON parse failure. + Shared Claude API entry point used by every agent in this module. + Sends `user_content` (optionally with a `system` prompt), parses the + response as JSON, and returns a dict. Raises RuntimeError on missing + library/key, API error, or JSON parse failure. API key read from ir.config_parameter key 'fl_ai.claude_api_key'. """ try: @@ -410,16 +416,19 @@ Respond with the JSON object only. No other text.""" 'Set fl_ai.claude_api_key in Settings → Technical → System Parameters.' ) - _logger.info("FL AI Engine: calling Claude API (model=%s)", CLAUDE_MODEL) + _logger.info("FL AI: calling Claude API (model=%s)", CLAUDE_MODEL) client = anthropic.Anthropic(api_key=api_key) + create_kwargs = { + 'model': CLAUDE_MODEL, + 'max_tokens': max_tokens, + 'messages': [{'role': 'user', 'content': user_content}], + } + if system: + create_kwargs['system'] = system try: - message = client.messages.create( - model=CLAUDE_MODEL, - max_tokens=2048, - messages=[{'role': 'user', 'content': prompt}], - ) + message = client.messages.create(**create_kwargs) except anthropic.RateLimitError as exc: raise RuntimeError(f'Claude API rate limit exceeded: {exc}') from exc except anthropic.APIConnectionError as exc: @@ -427,10 +436,12 @@ Respond with the JSON object only. No other text.""" 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]) + return self._extract_json(message.content[0].text.strip()) + + def _extract_json(self, raw): + """Strip markdown fences / leading prose and parse the first JSON object.""" + _logger.debug("FL AI 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 @@ -438,7 +449,6 @@ Respond with the JSON object only. No other text.""" 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 @@ -455,7 +465,7 @@ Respond with the JSON object only. No other text.""" try: return json.loads(raw) except json.JSONDecodeError as exc: - _logger.error("FL AI Engine: JSON parse error: %s\nRaw: %s", exc, raw[:500]) + _logger.error("FL AI: JSON parse error: %s\nRaw: %s", exc, raw[:500]) raise RuntimeError(f"Claude returned invalid JSON: {exc}") from exc # ────────────────────────────────────────────────────────────────────────── diff --git a/activeblue_familylaw/models/fl_analysis.py b/activeblue_familylaw/models/fl_analysis.py index f241047..27dc761 100644 --- a/activeblue_familylaw/models/fl_analysis.py +++ b/activeblue_familylaw/models/fl_analysis.py @@ -10,6 +10,10 @@ class FlAnalysis(models.Model): case_id = fields.Many2one( 'fl.case', ondelete='cascade', index=True ) + analysis_type = fields.Selection([ + ('engine', 'AI Engine Analysis'), + ('attorney', 'Attorney Strategy Memo'), + ], string='Analysis Type', default='engine') analysis_date = fields.Datetime( string='Analysis Date', default=fields.Datetime.now @@ -36,6 +40,23 @@ class FlAnalysis(models.Model): help='Resumen en español — sin jerga legal' ) + # ── Attorney Strategy Memo ───────────────────────────────────────────── + strategy_memo = fields.Html( + string='Strategy Memo', + help='Substantive strategy memo authored by the Attorney agent.' + ) + risk_narrative = fields.Text( + string='Risk Narrative', + help='Narrative of substantive risks: DV, hidden assets, income ' + 'imputation, unrepresented respondent, etc.' + ) + cited_statute_ids = fields.Many2many( + 'fl.statute', + 'fl_analysis_statute_rel', + 'analysis_id', 'statute_id', + string='Cited Statutes' + ) + # ── Analysis Detail (Phase 5) ────────────────────────────────────────── petitioner_arguments = fields.Text( string='Petitioner Arguments (JSON)' diff --git a/activeblue_familylaw/models/fl_attorney_agent.py b/activeblue_familylaw/models/fl_attorney_agent.py new file mode 100644 index 0000000..a07e911 --- /dev/null +++ b/activeblue_familylaw/models/fl_attorney_agent.py @@ -0,0 +1,345 @@ +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) + + 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) + 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)) + + return analysis + + # ────────────────────────────────────────────────────────────────────── + # 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 '—') + '
' + ) diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py index fe92cbc..2f2ab4c 100644 --- a/activeblue_familylaw/models/fl_case.py +++ b/activeblue_familylaw/models/fl_case.py @@ -434,6 +434,19 @@ class FlCase(models.Model): string='Latest Analysis', compute='_compute_latest_analysis', store=True ) + attorney_memo_id = fields.Many2one( + 'fl.analysis', + string='Attorney Strategy Memo', + help='Current strategy memo produced by the Attorney agent.' + ) + attorney_memo_html = fields.Html( + string='Strategy Memo', + related='attorney_memo_id.strategy_memo' + ) + attorney_risk_narrative = fields.Text( + string='Risk Narrative', + related='attorney_memo_id.risk_narrative' + ) attorney_referral_flag = fields.Boolean( string='Attorney Referral Recommended', compute='_compute_attorney_referral_flag', store=True @@ -1159,3 +1172,15 @@ class FlCase(models.Model): 'sticky': False, }, } + + def action_run_attorney_agent(self): + self.ensure_one() + memo = self.env['fl.attorney.agent'].generate_strategy_memo(self) + return { + 'type': 'ir.actions.act_window', + 'name': 'Attorney Strategy Memo', + 'res_model': 'fl.analysis', + 'res_id': memo.id, + 'view_mode': 'form', + 'target': 'current', + } diff --git a/activeblue_familylaw/views/fl_analysis_views.xml b/activeblue_familylaw/views/fl_analysis_views.xml index aa6cd38..5f62f63 100644 --- a/activeblue_familylaw/views/fl_analysis_views.xml +++ b/activeblue_familylaw/views/fl_analysis_views.xml @@ -8,6 +8,7 @@