From 23c54b1b9fc0af5ab8e4606e2b5d420b4e9e05e4 Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Thu, 28 May 2026 19:12:53 +0000 Subject: [PATCH] Align case stages to 5-stage spec and add Paralegal AI agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- activeblue_familylaw/data/fl_stage_data.xml | 59 +--- activeblue_familylaw/models/__init__.py | 1 + activeblue_familylaw/models/fl_ai_engine.py | 48 +-- activeblue_familylaw/models/fl_case.py | 23 ++ .../models/fl_paralegal_agent.py | 323 ++++++++++++++++++ activeblue_familylaw/views/fl_case_views.xml | 11 +- 6 files changed, 397 insertions(+), 68 deletions(-) create mode 100644 activeblue_familylaw/models/fl_paralegal_agent.py diff --git a/activeblue_familylaw/data/fl_stage_data.xml b/activeblue_familylaw/data/fl_stage_data.xml index 7664d06..ea4db5f 100644 --- a/activeblue_familylaw/data/fl_stage_data.xml +++ b/activeblue_familylaw/data/fl_stage_data.xml @@ -1,71 +1,40 @@ - + - Intake & Qualification + Intake 10 False + Case created. Conflict check, questionnaire completion, fee waiver assessment. - - Document Preparation + + Active 20 False - - - - Filed — Awaiting Service - 30 - False - - - - Service Complete - 40 - False + Intake complete and conflict check passed. Service of process, mandatory disclosure (FL-12.932), initial hearings. Discovery - 50 + 30 False + Interrogatories, production requests, depositions per discovery suggestion wizard. - - Deposition Stage - 60 - False - - - - Mediation - 70 - False - - - - Hearing Scheduled - 80 - False - - - - Order Entered - 90 + + Pre-Trial + 40 False + Discovery closed. Pretrial statement, exhibit list, witness list, mediation scheduling. Closed - 100 - True - - - - Referred to Attorney - 110 + 50 True + Final order filed. Archive checklist, billing reconciliation, file retention notice. diff --git a/activeblue_familylaw/models/__init__.py b/activeblue_familylaw/models/__init__.py index ee7a77a..d5c3a93 100644 --- a/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw/models/__init__.py @@ -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 diff --git a/activeblue_familylaw/models/fl_ai_engine.py b/activeblue_familylaw/models/fl_ai_engine.py index 1af1efc..13393f8 100644 --- a/activeblue_familylaw/models/fl_ai_engine.py +++ b/activeblue_familylaw/models/fl_ai_engine.py @@ -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() diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py index 27a16d0..fe92cbc 100644 --- a/activeblue_familylaw/models/fl_case.py +++ b/activeblue_familylaw/models/fl_case.py @@ -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, + }, + } diff --git a/activeblue_familylaw/models/fl_paralegal_agent.py b/activeblue_familylaw/models/fl_paralegal_agent.py new file mode 100644 index 0000000..f6a8963 --- /dev/null +++ b/activeblue_familylaw/models/fl_paralegal_agent.py @@ -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 = ['🧑‍⚖️ Paralegal Agent', + self._rule_based_summary(case, tasks, statutes)] + if ai_narrative: + parts.append( + '
Procedural briefing:
' + + ai_narrative.replace('\n', '
') + ) + case.message_post( + body=''.join(f'
{p}
' for p in parts), + subtype_xmlid='mail.mt_note', + ) + + def _rule_based_summary(self, case, tasks, statutes): + lines = [_('Processed stage: %s.') % (case.stage_id.name or 'unknown')] + if tasks: + items = ''.join(f'
  • {t.name}
  • ' for t in tasks) + lines.append(_('Generated %d task(s):') % len(tasks) + f'
      {items}
    ') + 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 '
    '.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) diff --git a/activeblue_familylaw/views/fl_case_views.xml b/activeblue_familylaw/views/fl_case_views.xml index 9203716..5070738 100644 --- a/activeblue_familylaw/views/fl_case_views.xml +++ b/activeblue_familylaw/views/fl_case_views.xml @@ -21,6 +21,9 @@