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=( + '
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)}'
+ '