"""Persisted AI suggestions for bank line reconciliations. One row per (statement_line, candidate_match). The OWL widget reads these to render confidence badges; users accept/reject which feeds back into the pattern learning system. The AI never writes account.partial.reconcile directly — it writes suggestions here, and the user (or batch-accept action) approves them through the engine's accept_suggestion() method. """ import logging from odoo import api, fields, models _logger = logging.getLogger(__name__) class FusionReconcileSuggestion(models.Model): _name = "fusion.reconcile.suggestion" _description = "AI-generated bank reconciliation suggestion" _order = "statement_line_id, confidence desc" company_id = fields.Many2one('res.company', required=True, index=True, default=lambda self: self.env.company) statement_line_id = fields.Many2one('account.bank.statement.line', required=True, index=True, ondelete='cascade') # The proposal proposed_move_line_ids = fields.Many2many('account.move.line', string="Proposed matches") proposed_write_off_amount = fields.Monetary(currency_field='currency_id') proposed_write_off_account_id = fields.Many2one('account.account') currency_id = fields.Many2one('res.currency', related='statement_line_id.currency_id', store=True) # Scoring confidence = fields.Float(required=True) confidence_band = fields.Selection([ ('high', 'High (>=95%)'), ('medium', 'Medium (70-94%)'), ('low', 'Low (50-69%)'), ('none', 'No confidence (<50%)'), ], compute='_compute_band', store=True) rank = fields.Integer(help="1 = top suggestion, 2-N = alternatives") reasoning = fields.Text(help="Human-readable explanation") # Feature breakdown (for transparency + future learning) score_amount_match = fields.Float() score_partner_pattern = fields.Float() score_precedent_similarity = fields.Float() score_ai_rerank = fields.Float() # Provenance generated_at = fields.Datetime(default=fields.Datetime.now) generated_by = fields.Selection([ ('cron_batch', 'Batch cron'), ('on_demand', 'User refreshed alternatives'), ('on_open', 'Widget opened (lazy)'), ]) provider_used = fields.Char( help="e.g. 'claude_sonnet_4_5', 'lmstudio_qwen_7b', 'statistical_only'") tokens_used = fields.Integer(help="if AI re-rank invoked") generation_ms = fields.Integer(help="latency for monitoring") # Lifecycle state = fields.Selection([ ('pending', 'Pending review'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('superseded', 'Superseded by newer suggestion'), ('stale', 'Stale (line changed since)'), ], default='pending', required=True, index=True) accepted_at = fields.Datetime() accepted_by = fields.Many2one('res.users') rejected_at = fields.Datetime() rejected_reason = fields.Selection([ ('wrong_invoice', 'Wrong invoice'), ('wrong_partner', 'Wrong partner'), ('wrong_amount', 'Amount off'), ('not_a_match', 'No good match exists'), ('other', 'Other'), ]) _confidence_in_range = models.Constraint( 'CHECK (confidence >= 0.0 AND confidence <= 1.0)', 'Confidence must be between 0.0 and 1.0', ) @api.depends('confidence') def _compute_band(self): for sug in self: c = sug.confidence if c >= 0.95: sug.confidence_band = 'high' elif c >= 0.70: sug.confidence_band = 'medium' elif c >= 0.50: sug.confidence_band = 'low' else: sug.confidence_band = 'none' # ------------------------------------------------------------------ # CRUD overrides — trigger MV refresh so the OWL widget sees fresh # confidence bands / top suggestion ids without waiting for cron. # ------------------------------------------------------------------ @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) self._trigger_mv_refresh() return records def write(self, vals): res = super().write(vals) # Only refresh on changes that affect the MV's projected columns. if 'state' in vals or 'confidence' in vals or 'rank' in vals: self._trigger_mv_refresh() return res def _trigger_mv_refresh(self): """Best-effort MV refresh; never poison the originating transaction. Uses concurrently=False because Postgres forbids REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block, and Odoo's per-request cursor is always in a transaction. The cron job (Task 25) opens a dedicated autocommit cursor for CONCURRENTLY refreshes when the MV grows large enough that a brief blocking refresh becomes objectionable. """ try: self.env['fusion.unreconciled.bank.line.mv']._refresh( concurrently=False) except Exception as e: # noqa: BLE001 _logger.warning( "MV refresh after suggestion write failed: %s", e)