- 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>
365 lines
18 KiB
Python
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} '
|
|
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>'
|
|
)
|