Stage alignment: - Replace the 11 procedural stages with the spec's 5-stage machine (Intake/Active/Discovery/Pre-Trial/Closed); only Closed is folded - Make fl_stage_data.xml updatable (drop noupdate) so the rename applies on upgrade; keep fl_stage_intake/discovery/closed XML ids - Update case search filters to the new stage names (Intake/Active/Discovery/Pre-Trial) Issue-tag bug fix (fl_ai_engine): - Rule-based tagging and caselaw matching compared snake_case keys against the human-readable seeded tag names, so they never matched and issue_tag_ids was never populated. Add RULE_KEY_TO_TAG_NAME and translate keys to real names before all fl.issue.tag / fl.caselaw lookups Paralegal agent (fl.paralegal.agent, AbstractModel): - on_stage_change(): fast rule-based pass fired automatically on stage entry and case creation — generates the stage task batch (idempotent), recalculates filing/service deadlines, cross-references statutes by issue tag + case type, posts a chatter summary. No Claude call, so it never blocks the workflow - run_manual(): full pass adding a best-effort Claude procedural briefing with rule-based fallback; wired to a "Paralegal Review" button on the case form - AI audit-time logging is guarded behind a fl.timesheet existence check (model not built yet) - fl.case.write fires on_stage_change only when stage_id actually changes; create() generates the Intake batch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
324 lines
15 KiB
Python
324 lines
15 KiB
Python
import json
|
|
import logging
|
|
|
|
from odoo import _, models
|
|
|
|
from .fl_ai_engine import CLAUDE_MODEL
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Stage → procedural task batch (keyed by stage XML id, per the 5-stage machine
|
|
# in CLAUDE.md). Each entry: (task name, description, sequence).
|
|
# Task names are prefixed with the stage name at creation time, which also
|
|
# provides idempotency (a batch already generated for a stage is not duplicated).
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
STAGE_TASK_BATCHES = {
|
|
'activeblue_familylaw.fl_stage_intake': [
|
|
('Run conflict-of-interest check',
|
|
'Screen all parties against other open cases before proceeding.', 10),
|
|
('Complete client intake questionnaire',
|
|
'Collect parties, children, income, and case facts.', 20),
|
|
('Assess fee-waiver eligibility (FL 57.082)',
|
|
'Check petitioner income vs 200% FPL; prepare FL-12.902(a) if eligible.', 30),
|
|
],
|
|
'activeblue_familylaw.fl_stage_active': [
|
|
('Effect service of process',
|
|
'Serve Summons + Petition; record the service date to start procedural clocks.', 10),
|
|
('Serve mandatory disclosure (FL-12.932)',
|
|
'Exchange FL 12.285 mandatory disclosure within 45 days of service.', 20),
|
|
('Schedule initial hearings',
|
|
'Calendar the case management conference and any temporary-relief hearings.', 30),
|
|
],
|
|
'activeblue_familylaw.fl_stage_discovery': [
|
|
('Draft interrogatories',
|
|
'Prepare written interrogatories directed to the opposing party.', 10),
|
|
('Draft requests for production',
|
|
'Request financial and supporting documents.', 20),
|
|
('Schedule depositions',
|
|
'Notice depositions where income or material facts are disputed.', 30),
|
|
],
|
|
'activeblue_familylaw.fl_stage_pretrial': [
|
|
('Prepare pretrial statement',
|
|
'Summarize stipulations, contested issues, and relief sought.', 10),
|
|
('Compile exhibit list',
|
|
'List and pre-mark all trial exhibits.', 20),
|
|
('Compile witness list',
|
|
'Identify and disclose all witnesses.', 30),
|
|
('Schedule mediation',
|
|
'Confirm court-ordered mediation; separate rooms if DV flagged (FL 44.102).', 40),
|
|
],
|
|
'activeblue_familylaw.fl_stage_closed': [
|
|
('Complete archive checklist',
|
|
'Confirm the final order is filed and all documents are stored.', 10),
|
|
('Reconcile billing',
|
|
'Finalize timesheet entries and issue the final invoice.', 20),
|
|
('Send file retention notice',
|
|
'Notify the client of the file retention / destruction policy.', 30),
|
|
],
|
|
}
|
|
|
|
# Issue tag display name (data/fl_issue_tags.xml) → fl.statute category.
|
|
TAG_NAME_TO_STATUTE_CATEGORY = {
|
|
'Modification Threshold': 'modification',
|
|
'Income Imputation': 'child_support',
|
|
'Self-Employment Income': 'child_support',
|
|
'Timesharing Deviation': 'timesharing',
|
|
'Domestic Violence': 'domestic_violence',
|
|
'Fee Waiver / Indigent Status': 'fee_waiver',
|
|
'Default Judgment': 'procedure',
|
|
'Parenting Class Required': 'timesharing',
|
|
'Residency Requirement': 'dissolution',
|
|
'Post-Order / Income Withholding': 'enforcement',
|
|
'Lifestyle Inconsistency': 'child_support',
|
|
'Child Emancipation': 'modification',
|
|
'Substantial Change in Circumstances': 'modification',
|
|
'Alimony (2023 Reform)': 'alimony',
|
|
}
|
|
|
|
# Base statute category implied by case type (always cross-referenced).
|
|
CASE_TYPE_STATUTE_CATEGORY = {
|
|
'modification': 'modification',
|
|
'alimony_modification': 'alimony',
|
|
'custody_modification': 'timesharing',
|
|
'dissolution_children': 'dissolution',
|
|
'dissolution_no_children': 'dissolution',
|
|
'paternity': 'paternity',
|
|
}
|
|
|
|
|
|
class FlParalegalAgent(models.AbstractModel):
|
|
"""
|
|
Paralegal AI agent — procedural intelligence.
|
|
|
|
Two entry points:
|
|
• on_stage_change(case) — fast rule-based pass fired automatically when a
|
|
case enters a new stage (and on case creation). No Claude call, so it
|
|
never adds latency or blocks the workflow.
|
|
• run_manual(case) — full pass including a Claude-generated procedural
|
|
briefing, fired on demand from the case form. Falls back to the
|
|
rule-based summary when the API is unavailable.
|
|
|
|
Both passes generate the stage task batch, recalculate procedural deadlines,
|
|
cross-reference statutes for the case's active issue tags, post a chatter
|
|
summary, and log non-billable AI audit time (when fl.timesheet exists).
|
|
"""
|
|
_name = 'fl.paralegal.agent'
|
|
_description = 'Paralegal AI Agent (procedural)'
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Public entry points
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
def on_stage_change(self, case):
|
|
"""Automatic rule-based pass for a stage entry. No Claude call."""
|
|
tasks = self._generate_stage_tasks(case)
|
|
self._recalculate_deadlines(case)
|
|
statutes = self._cross_reference_statutes(case)
|
|
self._post_summary(case, tasks, statutes, ai_narrative=None)
|
|
self._log_ai_time(case, _('Paralegal stage pass: %s') % (
|
|
case.stage_id.name or 'unknown'), ai_used=False)
|
|
return tasks
|
|
|
|
def run_manual(self, case):
|
|
"""Manual full pass including a best-effort Claude procedural briefing."""
|
|
tasks = self._generate_stage_tasks(case)
|
|
self._recalculate_deadlines(case)
|
|
statutes = self._cross_reference_statutes(case)
|
|
narrative = self._ai_procedural_summary(case, tasks, statutes)
|
|
self._post_summary(case, tasks, statutes, ai_narrative=narrative)
|
|
self._log_ai_time(case, _('Paralegal manual review (AI)'),
|
|
ai_used=bool(narrative))
|
|
return tasks
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Procedural building blocks (rule-based)
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
def _generate_stage_tasks(self, case):
|
|
"""Create the current stage's task batch in the case project. Idempotent."""
|
|
Task = self.env['project.task']
|
|
if not case.stage_id or not case.project_id:
|
|
return Task
|
|
|
|
batch = None
|
|
for xmlid, tasks in STAGE_TASK_BATCHES.items():
|
|
stage = self.env.ref(xmlid, raise_if_not_found=False)
|
|
if stage and stage.id == case.stage_id.id:
|
|
batch = tasks
|
|
break
|
|
if not batch:
|
|
return Task
|
|
|
|
existing_names = set(Task.search([
|
|
('project_id', '=', case.project_id.id)
|
|
]).mapped('name'))
|
|
|
|
created = Task
|
|
for name, description, sequence in batch:
|
|
prefixed = f'[{case.stage_id.name}] {name}'
|
|
if prefixed in existing_names:
|
|
continue
|
|
created |= Task.create({
|
|
'name': prefixed,
|
|
'description': description,
|
|
'project_id': case.project_id.id,
|
|
'sequence': sequence,
|
|
})
|
|
return created
|
|
|
|
def _recalculate_deadlines(self, case):
|
|
"""Regenerate filing- and service-anchored deadlines (both idempotent)."""
|
|
Deadline = self.env['fl.deadline']
|
|
Deadline.generate_deadlines_for_case(case)
|
|
Deadline.recalculate_service_deadlines(case)
|
|
|
|
def _cross_reference_statutes(self, case):
|
|
"""Find FL statutes relevant to the case's issue tags and case type."""
|
|
categories = set()
|
|
for tag in case.issue_tag_ids:
|
|
cat = TAG_NAME_TO_STATUTE_CATEGORY.get(tag.name)
|
|
if cat:
|
|
categories.add(cat)
|
|
base = CASE_TYPE_STATUTE_CATEGORY.get(case.case_type)
|
|
if base:
|
|
categories.add(base)
|
|
if not categories:
|
|
return self.env['fl.statute']
|
|
return self.env['fl.statute'].search([
|
|
('active', '=', True),
|
|
('category', 'in', list(categories)),
|
|
])
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Summary / chatter
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
def _post_summary(self, case, tasks, statutes, ai_narrative=None):
|
|
parts = ['<b>🧑⚖️ Paralegal Agent</b>',
|
|
self._rule_based_summary(case, tasks, statutes)]
|
|
if ai_narrative:
|
|
parts.append(
|
|
'<hr/><b>Procedural briefing:</b><br/>'
|
|
+ ai_narrative.replace('\n', '<br/>')
|
|
)
|
|
case.message_post(
|
|
body=''.join(f'<div>{p}</div>' for p in parts),
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|
|
|
|
def _rule_based_summary(self, case, tasks, statutes):
|
|
lines = [_('Processed stage: <b>%s</b>.') % (case.stage_id.name or 'unknown')]
|
|
if tasks:
|
|
items = ''.join(f'<li>{t.name}</li>' for t in tasks)
|
|
lines.append(_('Generated %d task(s):') % len(tasks) + f'<ul>{items}</ul>')
|
|
else:
|
|
lines.append(_('No new tasks (batch already present).'))
|
|
if statutes:
|
|
lines.append(
|
|
_('Relevant statutes: %s.') % ', '.join(statutes.mapped('name'))
|
|
)
|
|
open_dl = case.deadline_ids.filtered(
|
|
lambda d: not d.completed and not d.waived
|
|
)
|
|
if open_dl:
|
|
lines.append(_('%(n)d open deadline(s); next: %(next)s.') % {
|
|
'n': len(open_dl),
|
|
'next': case.next_deadline_label or '—',
|
|
})
|
|
return '<br/>'.join(lines)
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Claude procedural briefing (best-effort)
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
def _ai_procedural_summary(self, case, tasks, statutes):
|
|
"""
|
|
Generate a short procedural briefing via Claude. Returns plain text, or
|
|
None on any failure (caller falls back to the rule-based summary).
|
|
Never raises — AI must not block the workflow.
|
|
"""
|
|
try:
|
|
import anthropic
|
|
except ImportError:
|
|
return None
|
|
|
|
api_key = self.env['ir.config_parameter'].sudo().get_param('fl_ai.claude_api_key')
|
|
if not api_key:
|
|
return None
|
|
|
|
complexity = (
|
|
case.latest_analysis_id.case_complexity
|
|
or self.env['fl.ai.engine']._fallback_complexity(case)
|
|
)
|
|
open_dl = case.deadline_ids.filtered(
|
|
lambda d: not d.completed and not d.waived
|
|
)
|
|
context = {
|
|
'case_type': case.case_type,
|
|
'stage': case.stage_id.name if case.stage_id else 'unknown',
|
|
'complexity': complexity,
|
|
'issue_tags': case.issue_tag_ids.mapped('name'),
|
|
'open_deadlines': [
|
|
{'name': d.name, 'due_date': str(d.due_date),
|
|
'statute': d.statute_reference or ''}
|
|
for d in open_dl
|
|
],
|
|
'tasks_just_generated': tasks.mapped('name'),
|
|
'relevant_statutes': statutes.mapped('name'),
|
|
'domestic_violence': case.domestic_violence_flag,
|
|
'respondent_has_counsel': case.respondent_has_counsel,
|
|
}
|
|
|
|
system = (
|
|
"You are a Florida family-law paralegal assistant for the 11th Judicial "
|
|
"Circuit (Miami-Dade). You handle PROCEDURE, not legal advice. Given a "
|
|
"case's current stage, open deadlines, generated tasks, and relevant "
|
|
"statutes, write a concise procedural briefing for the legal team: what "
|
|
"to do next, what deadlines are most urgent, and any procedural risks. "
|
|
"Recommend attorney involvement when domestic violence, opposing counsel, "
|
|
"or high complexity is present. Plain text only, under 200 words. Do not "
|
|
"restate the input verbatim."
|
|
)
|
|
|
|
try:
|
|
client = anthropic.Anthropic(api_key=api_key)
|
|
message = client.messages.create(
|
|
model=CLAUDE_MODEL,
|
|
max_tokens=700,
|
|
system=system,
|
|
messages=[{
|
|
'role': 'user',
|
|
'content': (
|
|
'Case context:\n' + json.dumps(context, indent=2)
|
|
+ '\n\nWrite the procedural briefing now.'
|
|
),
|
|
}],
|
|
)
|
|
return message.content[0].text.strip()
|
|
except (anthropic.APIError, anthropic.APIConnectionError,
|
|
anthropic.RateLimitError) as exc:
|
|
_logger.warning("Paralegal agent AI briefing failed for case %s: %s",
|
|
case.id, exc)
|
|
return None
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Audit time logging (guarded — fl.timesheet not yet built)
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
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'].create({
|
|
'case_id': case.id,
|
|
'name': note,
|
|
'is_billable': False,
|
|
'ai_agent': 'paralegal',
|
|
'duration_hours': 0.05 if ai_used else 0.01,
|
|
})
|
|
except Exception as exc: # never block on audit logging
|
|
_logger.warning("Paralegal AI-time logging skipped for case %s: %s",
|
|
case.id, exc)
|