From 7bc0cc8554c8ec23acae52c6adb892efe124259f Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Thu, 28 May 2026 18:19:32 +0000 Subject: [PATCH] Add conflict-of-interest check engine; gate stage advancement at Intake - New fl.conflict.check model: screens petitioner/respondent/party_ids names against parties on other open cases (exact partner match + difflib fuzzy match at 0.85 threshold); skips folded/closed stages - Runs automatically as the first action in fl.case.create; logs conflicts to chatter with matched-case detail and never silently passes - fl.case gains conflict_check_passed/conflict_check_id/conflict_check_ids; write() blocks advancing stage_id past Intake until the check passes - Admin-only action_override requires a written justification, stamps user/date, and flips conflict_check_passed True with a chatter audit entry - Add conflict check form/tree/search views, action, Cases sub-menu item, case form banner + Run Conflict Check button, and Kanban conflict badge - ACL entries for fl.conflict.check (admin full, paralegal no-delete) - Finish Claude migration cleanup in fl_analysis.py (model_used default, docstring/help text) - Add .gitignore for Python artifacts Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 6 + activeblue_familylaw/__manifest__.py | 1 + activeblue_familylaw/models/__init__.py | 1 + activeblue_familylaw/models/fl_analysis.py | 9 +- activeblue_familylaw/models/fl_case.py | 46 ++++ .../models/fl_conflict_check.py | 215 ++++++++++++++++++ .../security/ir.model.access.csv | 3 + activeblue_familylaw/views/fl_case_views.xml | 34 ++- .../views/fl_conflict_check_views.xml | 119 ++++++++++ activeblue_familylaw/views/menu_views.xml | 7 + 10 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 .gitignore create mode 100644 activeblue_familylaw/models/fl_conflict_check.py create mode 100644 activeblue_familylaw/views/fl_conflict_check_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ccfdd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.idea/ +.vscode/ +*.swp diff --git a/activeblue_familylaw/__manifest__.py b/activeblue_familylaw/__manifest__.py index b9d0fea..9a24f90 100644 --- a/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw/__manifest__.py @@ -55,6 +55,7 @@ 'views/fl_statute_views.xml', 'views/fl_wizard_views.xml', 'views/fl_discovery_suggest_views.xml', + 'views/fl_conflict_check_views.xml', 'views/menu_views.xml', # Phase 4 — QWeb PDF Reports 'report/report_financial_affidavit_short.xml', diff --git a/activeblue_familylaw/models/__init__.py b/activeblue_familylaw/models/__init__.py index 725a64a..ee7a77a 100644 --- a/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw/models/__init__.py @@ -15,3 +15,4 @@ from . import fl_analysis from . import fl_ai_engine from . import fl_argument from . import fl_case +from . import fl_conflict_check diff --git a/activeblue_familylaw/models/fl_analysis.py b/activeblue_familylaw/models/fl_analysis.py index 9e774f9..f241047 100644 --- a/activeblue_familylaw/models/fl_analysis.py +++ b/activeblue_familylaw/models/fl_analysis.py @@ -2,10 +2,7 @@ from odoo import fields, models class FlAnalysis(models.Model): - """ - Phase 5 — Full Ollama AI analysis implementation. - Phase 1: Stub with fields required by fl_case computed fields. - """ + """AI case analysis record. Written by the Claude API engine (fl.ai.engine).""" _name = 'fl.analysis' _description = 'AI Case Analysis Result' _order = 'create_date desc' @@ -19,7 +16,7 @@ class FlAnalysis(models.Model): ) model_used = fields.Char( string='AI Model', - default='llama3.1' + default='claude-sonnet-4-20250514' ) # ── Results (referenced by fl_case related fields) ───────────────────── @@ -67,7 +64,7 @@ class FlAnalysis(models.Model): ], string='Case Complexity') raw_response = fields.Text( string='Raw AI Response', - help='Full JSON response from Ollama — for debugging' + help='Full JSON response from the Claude API — for debugging' ) error_message = fields.Text( string='Error (if analysis failed)' diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py index dbc782e..27a16d0 100644 --- a/activeblue_familylaw/models/fl_case.py +++ b/activeblue_familylaw/models/fl_case.py @@ -1,4 +1,5 @@ from odoo import _, api, fields, models +from odoo.exceptions import UserError from dateutil.relativedelta import relativedelta @@ -86,6 +87,23 @@ class FlCase(models.Model): ) active = fields.Boolean(default=True) + # ══════════════════════════════════════════════════════════════════════ + # CONFLICT OF INTEREST + # ══════════════════════════════════════════════════════════════════════ + + conflict_check_passed = fields.Boolean( + string='Conflict Check Passed', + default=False, tracking=True, + help='Case cannot advance past Intake until the conflict-of-interest ' + 'check passes (or is overridden by an administrator).' + ) + conflict_check_id = fields.Many2one( + 'fl.conflict.check', string='Latest Conflict Check', readonly=True + ) + conflict_check_ids = fields.One2many( + 'fl.conflict.check', 'case_id', string='Conflict Checks' + ) + # ══════════════════════════════════════════════════════════════════════ # PARTIES # ══════════════════════════════════════════════════════════════════════ @@ -794,6 +812,8 @@ class FlCase(models.Model): ) or _('New') records = super().create(vals_list) for record in records: + # 0. Conflict-of-interest screen — runs before any other action + self.env['fl.conflict.check'].run_check(record) # 1. Create Odoo project for task management record._create_case_project() # 2. Generate deadlines if filing date already set @@ -807,6 +827,20 @@ class FlCase(models.Model): return records def write(self, vals): + # 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']) + intake = self.env.ref( + 'activeblue_familylaw.fl_stage_intake', raise_if_not_found=False + ) + if intake and new_stage.sequence > intake.sequence: + for rec in self: + if not rec.conflict_check_passed: + raise UserError(_( + "Cannot advance case %s past Intake: the " + "conflict-of-interest check has not passed. Resolve or " + "override the conflict before changing the stage." + ) % rec.name) result = super().write(vals) # Recalculate service-anchored deadlines when service_date is set/changed if 'service_date' in vals: @@ -1090,3 +1124,15 @@ class FlCase(models.Model): 'target': 'new', 'context': {'default_case_id': self.id}, } + + def action_run_conflict_check(self): + self.ensure_one() + check = self.env['fl.conflict.check'].run_check(self) + return { + 'type': 'ir.actions.act_window', + 'name': 'Conflict Check Result', + 'res_model': 'fl.conflict.check', + 'res_id': check.id, + 'view_mode': 'form', + 'target': 'new', + } diff --git a/activeblue_familylaw/models/fl_conflict_check.py b/activeblue_familylaw/models/fl_conflict_check.py new file mode 100644 index 0000000..d0a9889 --- /dev/null +++ b/activeblue_familylaw/models/fl_conflict_check.py @@ -0,0 +1,215 @@ +import difflib +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +# Fuzzy name-match threshold (difflib.SequenceMatcher ratio). Per spec. +FUZZY_THRESHOLD = 0.85 + + +class FlConflictCheck(models.Model): + _name = 'fl.conflict.check' + _description = 'Conflict of Interest Check' + _inherit = ['mail.thread'] + _order = 'create_date desc' + _rec_name = 'name' + + name = fields.Char( + string='Reference', compute='_compute_name', store=True + ) + case_id = fields.Many2one( + 'fl.case', string='Case', required=True, + ondelete='cascade', index=True + ) + check_date = fields.Datetime( + string='Check Date', default=fields.Datetime.now, readonly=True + ) + status = fields.Selection([ + ('clear', 'Clear — No Conflict'), + ('conflict', 'Conflict Detected'), + ('override', 'Conflict Overridden'), + ], string='Status', default='clear', required=True, tracking=True) + matched_case_ids = fields.Many2many( + 'fl.case', 'fl_conflict_check_matched_case_rel', + 'conflict_id', 'matched_case_id', + string='Conflicting Cases' + ) + match_detail = fields.Text(string='Match Detail', readonly=True) + + # ── Override (group_admin only) ──────────────────────────────────────── + override_justification = fields.Text( + string='Override Justification', + help='Written justification required to override a detected conflict.' + ) + override_user_id = fields.Many2one( + 'res.users', string='Overridden By', readonly=True + ) + override_date = fields.Datetime(string='Override Date', readonly=True) + + @api.depends('case_id', 'case_id.name') + def _compute_name(self): + for rec in self: + rec.name = _('Conflict Check — %s') % (rec.case_id.name or _('New')) + + # ────────────────────────────────────────────────────────────────────── + # Engine + # ────────────────────────────────────────────────────────────────────── + + @api.model + def run_check(self, case): + """ + Run a conflict-of-interest screen for the given case. + Creates and returns an fl.conflict.check record, sets + case.conflict_check_passed and links the latest check. + Never silently passes a conflict — always surfaces it to chatter. + """ + names = self._collect_party_names(case) + matches = self._find_conflicts(case, names) + + check = self.create({ + 'case_id': case.id, + 'status': 'conflict' if matches else 'clear', + 'matched_case_ids': ( + [(6, 0, list({m['case_id'] for m in matches}))] if matches else False + ), + 'match_detail': ( + self._format_matches(matches) if matches else 'No conflicts found.' + ), + }) + + case.conflict_check_id = check.id + + if matches: + case.conflict_check_passed = False + case.message_post( + body=( + '
' + '

' + '🚩 CONFLICT OF INTEREST DETECTED

' + '

One or more parties on this case match parties on other ' + 'open cases. This case cannot advance past Intake until ' + 'the conflict is resolved or overridden by an administrator.

' + '
'
+                    f'{self._format_matches(matches)}
' + '
' + ), + subtype_xmlid='mail.mt_comment', + ) + else: + case.conflict_check_passed = True + case.message_post( + body='✅ Conflict-of-interest check clear — no matching parties on other open cases.', + subtype_xmlid='mail.mt_note', + ) + + _logger.info( + "FL Conflict Check: case %s → %s (%d match(es))", + case.id, check.status, len(matches) + ) + return check + + def _collect_party_names(self, case): + """ + Collect (partner_id, name, role_label) tuples for every party on a case. + Pulls from petitioner_id, respondent_id, and party_ids — deduplicated by + partner. Drops entries with no name. + """ + entries = [] + seen = set() + + def _add(partner, role_label): + if partner and partner.id not in seen and partner.name: + entries.append((partner.id, partner.name, role_label)) + seen.add(partner.id) + + _add(case.petitioner_id, 'Petitioner') + _add(case.respondent_id, 'Respondent') + for party in case.party_ids: + role_label = dict(party._fields['role'].selection).get(party.role, '') + _add(party.partner_id, role_label) + + return entries + + def _find_conflicts(self, case, names): + """ + Compare this case's party names against parties on other open cases. + Exact partner-id match OR fuzzy name match (>= FUZZY_THRESHOLD) flags a + conflict. Folded stages (Closed, Referred to Attorney) are treated as + not-open and skipped. Returns a list of match dicts. + """ + if not names: + return [] + + other_cases = self.env['fl.case'].search([ + ('id', '!=', case.id), + ('active', '=', True), + ]) + + matches = [] + for other in other_cases: + if other.stage_id and other.stage_id.fold: + continue + other_names = self._collect_party_names(other) + for pid, pname, prole in names: + for opid, opname, oprole in other_names: + if pid == opid: + ratio = 1.0 + else: + ratio = difflib.SequenceMatcher( + None, + (pname or '').lower().strip(), + (opname or '').lower().strip(), + ).ratio() + if ratio >= FUZZY_THRESHOLD: + matches.append({ + 'case_id': other.id, + 'case_ref': other.name, + 'new_party': f'{pname} ({prole})', + 'existing_party': f'{opname} ({oprole})', + 'score': round(ratio, 2), + }) + return matches + + def _format_matches(self, matches): + lines = [] + for m in matches: + lines.append( + f"• {m['new_party']} ↔ {m['existing_party']} " + f"on case {m['case_ref']} (similarity {m['score']:.0%})" + ) + return '\n'.join(lines) + + # ────────────────────────────────────────────────────────────────────── + # Actions + # ────────────────────────────────────────────────────────────────────── + + def action_override(self): + """Manager-only override of a detected conflict. Requires justification.""" + self.ensure_one() + if not self.env.user.has_group('activeblue_familylaw.group_admin'): + raise UserError(_( + 'Only a Family Law Administrator can override a conflict of interest.' + )) + if not (self.override_justification or '').strip(): + raise UserError(_( + 'A written justification is required to override a conflict.' + )) + self.write({ + 'status': 'override', + 'override_user_id': self.env.user.id, + 'override_date': fields.Datetime.now(), + }) + self.case_id.conflict_check_passed = True + self.case_id.message_post( + body=( + f'⚠ CONFLICT OVERRIDE authorized by ' + f'{self.env.user.name}.
' + f'Justification: {self.override_justification}' + ), + subtype_xmlid='mail.mt_comment', + ) + return True diff --git a/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw/security/ir.model.access.csv index a4dd4f5..358f772 100644 --- a/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw/security/ir.model.access.csv @@ -78,6 +78,9 @@ access_fl_fee_waiver_petitioner,fl.fee.waiver petitioner,model_fl_fee_waiver,gro access_fl_income_withholding_admin,fl.income.withholding admin,model_fl_income_withholding,group_admin,1,1,1,1 access_fl_income_withholding_paralegal,fl.income.withholding paralegal,model_fl_income_withholding,group_paralegal,1,1,1,0 access_fl_income_withholding_petitioner,fl.income.withholding petitioner,model_fl_income_withholding,group_portal_petitioner,1,0,0,0 +# ── fl.conflict.check ──────────────────────────────────────────────────────── +access_fl_conflict_check_admin,fl.conflict.check admin,model_fl_conflict_check,group_admin,1,1,1,1 +access_fl_conflict_check_paralegal,fl.conflict.check paralegal,model_fl_conflict_check,group_paralegal,1,1,1,0 # ── fl.intake.wizard ───────────────────────────────────────────────────────── access_fl_intake_wizard_admin,fl.intake.wizard admin,model_fl_intake_wizard,group_admin,1,1,1,1 access_fl_intake_wizard_paralegal,fl.intake.wizard paralegal,model_fl_intake_wizard,group_paralegal,1,1,1,1 diff --git a/activeblue_familylaw/views/fl_case_views.xml b/activeblue_familylaw/views/fl_case_views.xml index 0bfb04a..9203716 100644 --- a/activeblue_familylaw/views/fl_case_views.xml +++ b/activeblue_familylaw/views/fl_case_views.xml @@ -18,6 +18,9 @@