Add Attorney AI agent (substantive strategy memo)

- fl.attorney.agent (AbstractModel): manual-only substantive analysis fired from
  the case AI tab. Builds a full case context (parties, children, financials,
  issue tags, prior analyses) plus candidate statute/caselaw lists, and asks
  Claude to author a strategy memo, draft arguments/counterarguments, write a
  risk narrative, and assess substantial change (FL 61.30(1)(b))
- Grounds output in the real library: the model may only pick statutes/case law
  from the supplied candidates, which are then resolved back to records and
  linked (fl.analysis.cited_statute_ids / matched_caselaw_ids, case.caselaw_ids)
- Rule-based fallback produces a usable memo (complexity, statutes by category,
  caselaw by tag, risk flags) when the API is unavailable — never a raw error
- fl.analysis: add analysis_type, strategy_memo (Html), risk_narrative,
  cited_statute_ids; surface them in the analysis views
- fl.case: add attorney_memo_id + related memo/risk display; action_run_attorney_agent
  opens the memo; "Generate Attorney Strategy Memo" button on the AI tab (admin)
- Refactor fl_ai_engine: extract shared call_claude_json(system, user) +
  _extract_json so both agents and the engine share one Claude/JSON path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 00:12:20 +00:00
parent 23c54b1b9f
commit 465c049251
7 changed files with 441 additions and 13 deletions

View File

@@ -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

View File

@@ -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
# ──────────────────────────────────────────────────────────────────────────

View File

@@ -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)'

View File

@@ -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 = (
'<p><i>Rule-based strategy memo (Claude API unavailable).</i></p>'
f'<p><b>Case type:</b> {case.case_type} &nbsp; '
f'<b>Complexity:</b> {complexity}</p>'
'<p><b>Applicable statutes:</b> '
+ (', '.join(picked_statutes.mapped('name')) or '') + '</p>'
'<p><b>Relevant case law:</b> '
+ (', '.join(picked_caselaw.mapped('citation')) or '') + '</p>'
'<p><b>Substantial change of circumstances (FL 61.30(1)(b)):</b> '
+ ('Likely — threshold met.' if substantial_change
else 'Not established on current figures.') + '</p>'
'<p><b>Risks:</b></p><ul>'
+ (''.join(f'<li>{r}</li>' for r in risks) or '<li>None flagged.</li>')
+ '</ul>'
)
analysis.write({
'state': 'complete',
'strategy_memo': memo_html,
'risk_narrative': '\n'.join(risks),
'plain_english_summary': (
'Rule-based strategy summary generated without AI. '
'Review statutes, case law, and risks above.'
),
'attorney_referral_flag': attorney_flag,
'attorney_referral_reason': (
'; '.join(risks) if attorney_flag else ''
),
'confidence_level': 'low',
'case_complexity': complexity,
'cited_statute_ids': [(6, 0, picked_statutes.ids)],
'matched_caselaw_ids': [(6, 0, picked_caselaw.ids)],
'error_message': error,
})
self._link_and_announce(case, analysis, picked_caselaw, attorney_flag,
analysis.attorney_referral_reason)
# ──────────────────────────────────────────────────────────────────────
# Shared finalization
# ──────────────────────────────────────────────────────────────────────
def _link_and_announce(self, case, analysis, caselaw, attorney_flag, reason):
vals = {'attorney_memo_id': analysis.id}
if caselaw:
vals['caselaw_ids'] = [(4, c.id) for c in caselaw]
case.write(vals)
body = ('<b>⚖️ Attorney Strategy Memo</b><br/>'
+ (analysis.plain_english_summary or 'Strategy memo generated.'))
if attorney_flag:
body += (
'<br/><br/><b style="color:#dc3545;">⚠️ Attorney involvement '
'recommended:</b> ' + (reason or 'High complexity / risk.')
)
case.message_post(body=body, subtype_xmlid='mail.mt_comment')
def _memo_to_html(self, result, statutes, caselaw):
def _list(items):
return ''.join(f'<li>{i}</li>' for i in (items or [])) or '<li>—</li>'
memo = (result.get('strategy_memo') or '').replace('\n', '<br/>')
return (
f'<div>{memo}</div>'
'<hr/><p><b>Top statutes:</b> '
+ (', '.join(statutes.mapped('name')) or '') + '</p>'
'<p><b>Top case law:</b> '
+ (', '.join(caselaw.mapped('citation')) or '') + '</p>'
'<p><b>Petitioner arguments:</b></p><ul>'
+ _list(result.get('petitioner_arguments')) + '</ul>'
'<p><b>Respondent counterarguments:</b></p><ul>'
+ _list(result.get('respondent_counterarguments')) + '</ul>'
'<p><b>Substantial change (FL 61.30(1)(b)):</b> '
+ (result.get('substantial_change_narrative') or '') + '</p>'
)

View File

@@ -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',
}

View File

@@ -8,6 +8,7 @@
<field name="arch" type="xml">
<tree string="AI Analyses">
<field name="case_id"/>
<field name="analysis_type"/>
<field name="analysis_date"/>
<field name="model_used"/>
<field name="attorney_referral_flag"/>
@@ -27,6 +28,7 @@
<group>
<group>
<field name="case_id"/>
<field name="analysis_type"/>
<field name="analysis_date"/>
<field name="model_used"/>
<field name="state"/>
@@ -42,10 +44,22 @@
<strong>⚠️ ATTORNEY REFERRAL RECOMMENDED</strong><br/>
<field name="attorney_referral_reason" readonly="1" nolabel="1"/>
</div>
<separator string="Strategy Memo"
attrs="{'invisible': [('analysis_type', '!=', 'attorney')]}"/>
<field name="strategy_memo" widget="html" readonly="1" nolabel="1"
attrs="{'invisible': [('analysis_type', '!=', 'attorney')]}"/>
<separator string="Risk Narrative"
attrs="{'invisible': [('risk_narrative', '=', False)]}"/>
<field name="risk_narrative" readonly="1" nolabel="1"
attrs="{'invisible': [('risk_narrative', '=', False)]}"/>
<separator string="Plain English Summary (English)"/>
<field name="plain_english_summary" readonly="1" nolabel="1"/>
<separator string="Plain English Summary (Español)"/>
<field name="plain_english_summary_es" readonly="1" nolabel="1"/>
<separator string="Cited Statutes"
attrs="{'invisible': [('cited_statute_ids', '=', [])]}"/>
<field name="cited_statute_ids" widget="many2many_tags" readonly="1" nolabel="1"
attrs="{'invisible': [('cited_statute_ids', '=', [])]}"/>
<separator string="Matched Case Law"/>
<field name="matched_caselaw_ids" widget="many2many_tags" readonly="1" nolabel="1"/>
</sheet>

View File

@@ -266,10 +266,22 @@
placeholder="Run AI Analysis to generate summary"/>
<separator string="AI Case Summary (Español)"/>
<field name="ai_plain_english_es" readonly="1" nolabel="1"/>
<separator string="Attorney Strategy Memo"/>
<button name="action_run_attorney_agent"
string="Generate Attorney Strategy Memo"
type="object" class="btn-primary"
groups="activeblue_familylaw.group_admin"/>
<field name="attorney_memo_id" readonly="1"
attrs="{'invisible': [('attorney_memo_id', '=', False)]}"/>
<field name="attorney_memo_html" widget="html" readonly="1" nolabel="1"
attrs="{'invisible': [('attorney_memo_id', '=', False)]}"/>
<field name="attorney_risk_narrative" readonly="1"
attrs="{'invisible': [('attorney_memo_id', '=', False)]}"/>
<separator string="All Analyses"/>
<field name="analysis_ids">
<tree string="Analyses">
<field name="analysis_date"/>
<field name="analysis_type"/>
<field name="model_used"/>
<field name="attorney_referral_flag"/>
<field name="confidence_level"/>