Files
famlaw/activeblue_familylaw/models/fl_attorney_agent.py
tocmo0nlord 70c951a7ef Add timesheet / AI-audit time tracking (wraps account.analytic.line)
- fl.timesheet via delegation inheritance on account.analytic.line so billable
  hours flow through standard Odoo Accounting; duration_hours maps to unit_amount
- Fields: case_id, employee_id, is_billable, ai_agent, duration_hours, computed
  hourly_rate/billable_amount (rate from hr.employee.fl_hourly_rate, else firm
  default ir.config_parameter fl_timesheet.default_hourly_rate)
- _resolve_analytic_account: prefers the case project's analytic account
  (version-agnostic field lookup), falls back to a cached firm account under any
  available analytic plan — handles the required account_id on the wrapped line
- Add 'analytic' to manifest depends; ACL for fl.timesheet and account.analytic.line
  (admin + paralegal) so non-admins can post entries
- fl.case: timesheet_ids + total_billable_hours/amount + total_ai_audit_hours +
  currency_id; new Time & Billing tab; Timesheets menu + standalone views
- Both AI agents now log non-billable audit entries via sudo() (paralegal +
  attorney, ai_agent set); logging stays a guarded no-op if creation fails

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:26:00 +00:00

365 lines
18 KiB
Python

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 = (
'<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>'
)