Align case stages to 5-stage spec and add Paralegal AI agent

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>
This commit is contained in:
2026-05-28 19:12:53 +00:00
parent 7bc0cc8554
commit 23c54b1b9f
6 changed files with 397 additions and 68 deletions

View File

@@ -1,71 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<data>
<record id="fl_stage_intake" model="fl.case.stage">
<field name="name">Intake &amp; Qualification</field>
<field name="name">Intake</field>
<field name="sequence">10</field>
<field name="fold">False</field>
<field name="description">Case created. Conflict check, questionnaire completion, fee waiver assessment.</field>
</record>
<record id="fl_stage_preparation" model="fl.case.stage">
<field name="name">Document Preparation</field>
<record id="fl_stage_active" model="fl.case.stage">
<field name="name">Active</field>
<field name="sequence">20</field>
<field name="fold">False</field>
</record>
<record id="fl_stage_filed" model="fl.case.stage">
<field name="name">Filed — Awaiting Service</field>
<field name="sequence">30</field>
<field name="fold">False</field>
</record>
<record id="fl_stage_service_complete" model="fl.case.stage">
<field name="name">Service Complete</field>
<field name="sequence">40</field>
<field name="fold">False</field>
<field name="description">Intake complete and conflict check passed. Service of process, mandatory disclosure (FL-12.932), initial hearings.</field>
</record>
<record id="fl_stage_discovery" model="fl.case.stage">
<field name="name">Discovery</field>
<field name="sequence">50</field>
<field name="sequence">30</field>
<field name="fold">False</field>
<field name="description">Interrogatories, production requests, depositions per discovery suggestion wizard.</field>
</record>
<record id="fl_stage_deposition" model="fl.case.stage">
<field name="name">Deposition Stage</field>
<field name="sequence">60</field>
<field name="fold">False</field>
</record>
<record id="fl_stage_mediation" model="fl.case.stage">
<field name="name">Mediation</field>
<field name="sequence">70</field>
<field name="fold">False</field>
</record>
<record id="fl_stage_hearing_scheduled" model="fl.case.stage">
<field name="name">Hearing Scheduled</field>
<field name="sequence">80</field>
<field name="fold">False</field>
</record>
<record id="fl_stage_order_entered" model="fl.case.stage">
<field name="name">Order Entered</field>
<field name="sequence">90</field>
<record id="fl_stage_pretrial" model="fl.case.stage">
<field name="name">Pre-Trial</field>
<field name="sequence">40</field>
<field name="fold">False</field>
<field name="description">Discovery closed. Pretrial statement, exhibit list, witness list, mediation scheduling.</field>
</record>
<record id="fl_stage_closed" model="fl.case.stage">
<field name="name">Closed</field>
<field name="sequence">100</field>
<field name="fold">True</field>
</record>
<record id="fl_stage_referred_out" model="fl.case.stage">
<field name="name">Referred to Attorney</field>
<field name="sequence">110</field>
<field name="sequence">50</field>
<field name="fold">True</field>
<field name="description">Final order filed. Archive checklist, billing reconciliation, file retention notice.</field>
</record>
</data>

View File

@@ -16,3 +16,4 @@ from . import fl_ai_engine
from . import fl_argument
from . import fl_case
from . import fl_conflict_check
from . import fl_paralegal_agent

View File

@@ -25,19 +25,22 @@ CASE_FIELD_RULES = [
]
# ─────────────────────────────────────────────────────────────────────────────
# Caselaw topic → issue tag matching
# Internal rule key → seeded fl.issue.tag display name (data/fl_issue_tags.xml).
# Rule keys are the snake_case identifiers used in CASE_FIELD_RULES and the
# tagging logic; the actual tag records use human-readable names, so all DB
# lookups must translate through this map.
# ─────────────────────────────────────────────────────────────────────────────
TAG_TO_CASELAW_DOMAINS = {
'modification_threshold': [('issue_tag_ids.name', '=', 'modification_threshold')],
'income_imputation': [('issue_tag_ids.name', '=', 'income_imputation')],
'self_employment_income': [('issue_tag_ids.name', '=', 'self_employment_income')],
'timesharing_deviation': [('issue_tag_ids.name', '=', 'timesharing_deviation')],
'domestic_violence': [('issue_tag_ids.name', '=', 'domestic_violence')],
'fee_waiver': [('issue_tag_ids.name', '=', 'fee_waiver')],
'default_judgment': [('issue_tag_ids.name', '=', 'default_judgment')],
'residency': [('issue_tag_ids.name', '=', 'residency')],
'parenting_class': [('issue_tag_ids.name', '=', 'parenting_class')],
'post_order': [('issue_tag_ids.name', '=', 'post_order')],
RULE_KEY_TO_TAG_NAME = {
'modification_threshold': 'Modification Threshold',
'income_imputation': 'Income Imputation',
'self_employment_income': 'Self-Employment Income',
'timesharing_deviation': 'Timesharing Deviation',
'domestic_violence': 'Domestic Violence',
'fee_waiver': 'Fee Waiver / Indigent Status',
'default_judgment': 'Default Judgment',
'residency': 'Residency Requirement',
'parenting_class': 'Parenting Class Required',
'post_order': 'Post-Order / Income Withholding',
}
MAX_CASELAW_IN_PROMPT = 8
@@ -81,9 +84,13 @@ class FlAiEngine(models.AbstractModel):
try:
triggered_tags = self._rule_based_tagging(case)
if triggered_tags:
tag_names = [
RULE_KEY_TO_TAG_NAME[k]
for k in triggered_tags if k in RULE_KEY_TO_TAG_NAME
]
existing_tags = case.issue_tag_ids.mapped('name')
new_tag_recs = self.env['fl.issue.tag'].search([
('name', 'in', list(triggered_tags)),
('name', 'in', tag_names),
('name', 'not in', existing_tags),
])
if new_tag_recs:
@@ -183,13 +190,14 @@ class FlAiEngine(models.AbstractModel):
matched_ids = set()
for tag in triggered_tags:
domain = TAG_TO_CASELAW_DOMAINS.get(tag, [])
if domain:
recs = self.env['fl.caselaw'].search(
[('active', '=', True)] + domain,
limit=6,
)
matched_ids.update(recs.ids)
tag_name = RULE_KEY_TO_TAG_NAME.get(tag)
if not tag_name:
continue
recs = self.env['fl.caselaw'].search([
('active', '=', True),
('issue_tag_ids.name', '=', tag_name),
], limit=6)
matched_ids.update(recs.ids)
if not matched_ids:
return self.env['fl.caselaw'].browse()

View File

@@ -824,9 +824,12 @@ class FlCase(models.Model):
# 4. Handle DV flag
if record.domestic_violence_flag:
record._handle_dv_flag()
# 5. Paralegal agent — generate the Intake stage task batch
self.env['fl.paralegal.agent'].on_stage_change(record)
return records
def write(self, vals):
stage_changed_recs = self.browse()
# Block advancing past Intake until conflict check has passed
if vals.get('stage_id'):
new_stage = self.env['fl.case.stage'].browse(vals['stage_id'])
@@ -841,7 +844,13 @@ class FlCase(models.Model):
"conflict-of-interest check has not passed. Resolve or "
"override the conflict before changing the stage."
) % rec.name)
stage_changed_recs = self.filtered(
lambda r: r.stage_id.id != new_stage.id
)
result = super().write(vals)
# Fire the paralegal agent (rule-based) when a case enters a new stage
for rec in stage_changed_recs:
self.env['fl.paralegal.agent'].on_stage_change(rec)
# Recalculate service-anchored deadlines when service_date is set/changed
if 'service_date' in vals:
for rec in self:
@@ -1136,3 +1145,17 @@ class FlCase(models.Model):
'view_mode': 'form',
'target': 'new',
}
def action_run_paralegal(self):
self.ensure_one()
self.env['fl.paralegal.agent'].run_manual(self)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Paralegal Agent'),
'message': _('Procedural review complete — see the case chatter.'),
'type': 'success',
'sticky': False,
},
}

View File

@@ -0,0 +1,323 @@
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)

View File

@@ -21,6 +21,9 @@
<button name="action_run_conflict_check" string="Run Conflict Check"
type="object"
groups="activeblue_familylaw.group_admin,activeblue_familylaw.group_paralegal"/>
<button name="action_run_paralegal" string="Paralegal Review"
type="object"
groups="activeblue_familylaw.group_admin,activeblue_familylaw.group_paralegal"/>
</header>
<!-- Attorney Referral Banner -->
@@ -350,11 +353,13 @@
<filter string="Active Cases" name="active"
domain="[('active', '=', True)]"/>
<filter string="Intake" name="stage_intake"
domain="[('stage_id.name', '=', 'Intake &amp; Qualification')]"/>
<filter string="Filed" name="stage_filed"
domain="[('stage_id.name', '=', 'Filed — Awaiting Service')]"/>
domain="[('stage_id.name', '=', 'Intake')]"/>
<filter string="Active" name="stage_active"
domain="[('stage_id.name', '=', 'Active')]"/>
<filter string="Discovery" name="stage_discovery"
domain="[('stage_id.name', '=', 'Discovery')]"/>
<filter string="Pre-Trial" name="stage_pretrial"
domain="[('stage_id.name', '=', 'Pre-Trial')]"/>
<separator/>
<filter string="Modification Cases" name="type_modification"
domain="[('case_type', '=', 'modification')]"/>