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