Files
Odoo-Modules/fusion_helpdesk_central/models/engagement_wizard.py
gsinghpal c520803c84 feat(fusion_helpdesk_central): findings-first wizard, explicit Generate button
The old flow fired OpenAI on wizard open with just ticket + chatter,
so the AI summary was just a paraphrase of what the user originally
reported — your engineering analysis (scope, limitations, recommended
approach) never made it to the owner. Restructure to a two-step flow:

  1. Open wizard → empty findings + empty summary, NO OpenAI call
  2. You write findings: scope / effort / approach / risk
  3. Click 'Generate Summary from Findings' → OpenAI runs with
     ticket + chatter + findings, where the prompt explicitly tells
     the model to weight findings MORE THAN the original report
  4. Review/edit, then Send

Bulk wizard mirrors the flow per line: each row gets its own
findings + summary, one 'Generate All Summaries' button fans out
parallel OpenAI calls using each line's own findings.

Updated SUMMARY_PROMPT to:
- Tell the model the support engineer's findings are authoritative
- Emit a bullet structure that leads with the recommendation, not
  the user's restated ask
- Side with findings over the original report when they conflict

New tests cover:
- default_get does NOT fire OpenAI (regression guard for auto-AI)
- Findings text actually reaches the OpenAI prompt
- Send works with a manually-typed summary (no AI in the loop)
- Existing bulk + validation paths still pass with the new shape

Also folds in the deferred code-review #7: ThreadPoolExecutor now
explicitly cancels pending futures on timeout via
shutdown(wait=False, cancel_futures=True) so a slow OpenAI day can't
hold the wizard open for ceil(N/workers)*15s.

Bumps fusion_helpdesk_central to 19.0.2.3.0.

Smoke-tested live on nexa: opening the wizard makes zero OpenAI calls;
clicking Generate with findings='My findings: scope is XL, ~8h' makes
exactly one call and the findings text is verifiably in the prompt
body received by call_openai_chat.
2026-05-27 13:49:02 -04:00

465 lines
20 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Wizard that drives the owner-approval engagement flow.
Two modes, one wizard:
- Single-ticket: opened from the ticket form button. `ticket_id` is set,
`ticket_ids` is empty. One AI summary, one email, one engagement.
- Bulk: opened from the list-view server action. `ticket_ids` is set,
`ticket_id` is empty. One AI summary per ticket (via a child transient
model), one combined email with one card per ticket, each card with
its own approve/reject tokens.
The wizard generates the AI summary on `default_get` so the user sees a
ready-to-edit brief the moment the modal opens, then sends mail + writes
the engagement state on `action_send`.
"""
import concurrent.futures
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import html2plaintext
from odoo.addons.fusion_helpdesk_central.utils import (
build_summary_prompt,
call_openai_chat,
truncate_for_openai,
)
_logger = logging.getLogger(__name__)
# Parallel OpenAI calls for bulk mode. Five is enough to keep the wizard
# snappy without slamming OpenAI's rate limits on a single deployment.
_BULK_AI_WORKERS = 5
_BULK_AI_TIMEOUT = 30 # seconds; overall cap for the parallel summary fan-out
class FusionHelpdeskEngagementWizard(models.TransientModel):
_name = 'fusion.helpdesk.engagement.wizard'
_description = 'Fusion Helpdesk — Owner Engagement Wizard'
# Mode: single vs bulk. The view branches on `mode`; it's a computed
# store=False field so we don't need to set it on default_get manually
# in every action.
mode = fields.Selection(
[('single', 'Single ticket'), ('bulk', 'Bulk')],
compute='_compute_mode', store=False,
)
ticket_id = fields.Many2one(
'helpdesk.ticket', string='Ticket', ondelete='cascade',
)
ticket_ids = fields.Many2many(
'helpdesk.ticket', string='Tickets (bulk)',
)
line_ids = fields.One2many(
'fusion.helpdesk.engagement.wizard.line', 'wizard_id',
string='Per-Ticket Summaries',
)
personal_note = fields.Char(
string='Personal Note',
help='One-line note from you, prepended above the AI summary in the '
'email body. Optional. Skip if the summary speaks for itself.',
)
findings = fields.Text(
string='Your Findings',
help='Your engineering analysis: scope, limitations, recommended '
'approach, effort, risks — anything the original reporter '
'would not have known. The AI weighs these MORE HEAVILY than '
'the ticket description when generating the owner summary. '
'Optional but strongly recommended: without it, the summary '
'is just the AI restating the user\'s report.',
)
ai_summary = fields.Text(
string='Summary to Send',
help='Brief shown to the owner in the approval email. Either '
'generated from your findings + ticket (click "Generate '
'Summary") or written by hand. Edit freely before sending.',
)
owner_email_display = fields.Char(
string='Owner Email', compute='_compute_owner_display', store=False,
)
owner_name_display = fields.Char(
string='Owner Name', compute='_compute_owner_display', store=False,
)
ai_unavailable = fields.Boolean(
string='AI unavailable', store=False,
help='True when OpenAI returned no summary on wizard open. The view '
'shows a soft banner so the user knows to write a manual brief.',
)
# ------------------------------------------------------------------
@api.depends('ticket_id', 'ticket_ids')
def _compute_mode(self):
for w in self:
w.mode = 'bulk' if w.ticket_ids else 'single'
@api.depends('ticket_id', 'ticket_ids')
def _compute_owner_display(self):
for w in self:
ticket = w.ticket_id or (w.ticket_ids[:1] if w.ticket_ids else None)
if ticket:
email, name = ticket._fc_owner_contact()
else:
email, name = (False, False)
w.owner_email_display = email or ''
w.owner_name_display = name or ''
# ------------------------------------------------------------------
# Wizard open: pull tickets from context, generate AI summary(ies).
# ------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
vals = super().default_get(fields_list)
ctx = self.env.context or {}
ticket_id = ctx.get('default_ticket_id') or ctx.get('active_id')
ticket_ids = ctx.get('default_ticket_ids') or ctx.get('active_ids') or []
active_model = ctx.get('active_model')
# Disambiguate single vs bulk by what the caller actually selected.
# The list-view server action passes active_ids; the form button
# passes a single active_id via a deliberate context key.
if ctx.get('fhc_bulk') and ticket_ids:
return self._default_get_bulk(vals, ticket_ids)
if active_model == 'helpdesk.ticket' and ticket_ids and not ticket_id:
# Edge: opened from list selection without our explicit context
# key. If exactly one, treat as single; otherwise bulk.
if len(ticket_ids) == 1:
ticket_id = ticket_ids[0]
else:
return self._default_get_bulk(vals, ticket_ids)
if ticket_id:
return self._default_get_single(vals, ticket_id)
return vals
def _default_get_single(self, vals, ticket_id):
# No AI on open — user writes findings first, then clicks
# "Generate Summary" to fire OpenAI. Summary starts empty so the
# view's Send button can be disabled until either Generate runs
# or the user types one manually.
ticket = self.env['helpdesk.ticket'].browse(ticket_id)
if not ticket.exists():
raise UserError(_('Ticket %s no longer exists.') % ticket_id)
self._validate_engagement_target(ticket)
vals.update({
'ticket_id': ticket.id,
'ai_summary': '',
'findings': '',
'ai_unavailable': False,
})
return vals
def _default_get_bulk(self, vals, ticket_ids):
# Same as single: no AI on open. User fills findings per ticket
# then hits "Generate Summary" on each line (or "Generate All" —
# see action_generate_all_summaries).
tickets = self.env['helpdesk.ticket'].browse(ticket_ids).exists()
self._validate_bulk_targets(tickets)
vals.update({
'ticket_ids': [(6, 0, tickets.ids)],
'line_ids': [
(0, 0, {'ticket_id': t.id, 'findings': '', 'ai_summary': ''})
for t in tickets
],
'ai_unavailable': False,
})
return vals
# ------------------------------------------------------------------
# Validation gates — run BEFORE we waste an OpenAI call.
# ------------------------------------------------------------------
def _validate_engagement_target(self, ticket):
if not ticket.x_fc_client_label:
raise UserError(_(
'This ticket is not tagged with a client deployment, so the '
'central has no owner contact to send to. Owner-approval is '
'only available on in-app tickets.'
))
email, _name = ticket._fc_owner_contact()
if not email:
raise UserError(_(
'No owner contact configured for client "%s". Ask the client '
'to fill it in under Settings → Fusion Helpdesk → Owner '
'Approval, then file any ticket from that deployment so the '
'central learns the contact.'
) % (ticket.x_fc_client_label,))
def _validate_bulk_targets(self, tickets):
if not tickets:
raise UserError(_('No tickets selected.'))
labels = {t.x_fc_client_label for t in tickets}
labels.discard(False)
labels.discard('')
if len(labels) != 1:
raise UserError(_(
'Cannot bulk-engage tickets across different deployments — '
'one owner per engagement. Selected labels: %s.'
) % (', '.join(sorted(labels)) or '(none)'))
blockers = tickets.filtered(
lambda t: t.x_fc_engagement_state in ('pending', 'approved')
)
if blockers:
raise UserError(_(
'%(n)s of the selected tickets already have a pending or '
'approved engagement. Re-engage them individually from the '
'ticket form. Tickets: %(ids)s'
) % {'n': len(blockers),
'ids': ', '.join('#%s' % t.id for t in blockers)})
# Reuse single validation for the owner-contact check (any one
# ticket suffices since they share the same client_label).
self._validate_engagement_target(tickets[0])
# ------------------------------------------------------------------
# AI summary generation
# ------------------------------------------------------------------
def _summary_inputs(self, ticket):
"""Pull the data we feed to OpenAI: title + plain-text description +
plain-text public messages (oldest first). Internal notes excluded."""
msgs = self.env['mail.message'].search([
('model', '=', 'helpdesk.ticket'),
('res_id', '=', ticket.id),
('message_type', 'in', ('comment', 'email')),
('subtype_id.internal', '=', False),
], order='id asc')
msg_data = [{
'author': m.author_id.name or m.email_from or 'unknown',
'date': fields.Datetime.to_string(m.date) if m.date else '',
'body_plain': html2plaintext(m.body or '') or '',
} for m in msgs]
return (
ticket.name or '',
html2plaintext(ticket.description or '') or '',
msg_data,
)
def _generate_summary(self, ticket, findings=''):
"""Single-ticket summary. Returns '' on any failure — the wizard
treats empty as "AI unavailable" and shows the manual fallback.
`findings` is the user's free-text analysis from the wizard. The
prompt explicitly tells the model to weight it more than the
original user report — see SUMMARY_PROMPT in utils.py."""
ICP = self.env['ir.config_parameter'].sudo()
api_key = (ICP.get_param(
'fusion_helpdesk_central.openai_api_key') or '').strip()
if not api_key:
return ''
model = (ICP.get_param(
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
name, desc, msgs = self._summary_inputs(ticket)
prompt = truncate_for_openai(
build_summary_prompt(name, desc, msgs, findings=findings)
)
return call_openai_chat(api_key, model, prompt)
def _generate_summaries_parallel(self, ticket_findings):
"""{ticket_id: summary_or_empty} for the bulk wizard.
`ticket_findings` is a dict {ticket_id: (ticket_recordset, findings_str)}
so each parallel call uses its own per-ticket findings.
"""
ICP = self.env['ir.config_parameter'].sudo()
api_key = (ICP.get_param(
'fusion_helpdesk_central.openai_api_key') or '').strip()
if not api_key:
return {tid: '' for tid in ticket_findings}
model = (ICP.get_param(
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
# Build inputs serially (DB-bound, fast) before fanning out the
# HTTP calls in parallel.
inputs = {}
for tid, (ticket, findings) in ticket_findings.items():
name, desc, msgs = self._summary_inputs(ticket)
inputs[tid] = truncate_for_openai(
build_summary_prompt(name, desc, msgs, findings=findings))
results = {tid: '' for tid in ticket_findings}
# Cancel pending futures on overall timeout so a slow OpenAI day
# doesn't block the wizard for ceil(N/workers) * 15s.
pool = concurrent.futures.ThreadPoolExecutor(
max_workers=_BULK_AI_WORKERS)
try:
futures = {
pool.submit(call_openai_chat, api_key, model, p): tid
for tid, p in inputs.items()
}
try:
for fut in concurrent.futures.as_completed(
futures, timeout=_BULK_AI_TIMEOUT):
tid = futures[fut]
try:
results[tid] = fut.result() or ''
except Exception as e: # noqa: BLE001 — log + continue
_logger.warning(
'fusion_helpdesk_central: bulk AI summary for '
'ticket %s failed: %s', tid, e,
)
except concurrent.futures.TimeoutError:
_logger.warning(
'fusion_helpdesk_central: bulk AI summary fan-out timed '
'out after %ss; remaining tickets will get empty '
'summaries.', _BULK_AI_TIMEOUT,
)
finally:
# py3.9+: cancel_futures stops queued tasks from starting;
# in-flight urlopen calls still finish their 15s per-call cap
# and drop their result on the floor.
pool.shutdown(wait=False, cancel_futures=True)
return results
# ------------------------------------------------------------------
# User-driven AI trigger — explicit "Generate" buttons in the view.
# ------------------------------------------------------------------
def action_generate_summary(self):
"""Single mode: fire OpenAI with the current findings, drop the
result into ai_summary. Returns an action to keep the wizard open
(replaces the current view instead of closing it).
"""
self.ensure_one()
if self.mode != 'single' or not self.ticket_id:
raise UserError(_(
'Generate Summary only works in single-ticket mode. Use '
'the per-line Generate buttons on the bulk wizard.'
))
summary = self._generate_summary(
self.ticket_id, findings=self.findings or '',
)
self.ai_summary = summary
self.ai_unavailable = not bool(summary)
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.helpdesk.engagement.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_generate_all_summaries(self):
"""Bulk mode: fire OpenAI per-ticket in parallel using each line's
own findings. Lines that already have a non-empty ai_summary get
regenerated too (the user clicked the button — they meant it).
"""
self.ensure_one()
if self.mode != 'bulk' or not self.line_ids:
raise UserError(_(
'Generate All only works in bulk mode.'
))
ticket_findings = {
line.ticket_id.id: (line.ticket_id, line.findings or '')
for line in self.line_ids
}
results = self._generate_summaries_parallel(ticket_findings)
for line in self.line_ids:
line.ai_summary = results.get(line.ticket_id.id, '')
any_ok = any(results.values())
self.ai_unavailable = not any_ok
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.helpdesk.engagement.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
# ------------------------------------------------------------------
# Send: write engagement state + queue mail
# ------------------------------------------------------------------
def action_send(self):
self.ensure_one()
if self.mode == 'bulk':
return self._action_send_bulk()
return self._action_send_single()
def _action_send_single(self):
ticket = self.ticket_id
if not ticket:
raise UserError(_('Wizard has no ticket attached.'))
# Re-resolve owner from client_key in case it changed between
# default_get and Send (small window, but the source of truth wins).
email, name = ticket._fc_owner_contact()
if not email:
raise UserError(_('Owner contact disappeared since you opened '
'the wizard. Refresh and try again.'))
ticket._fc_reset_engagement(email, name, self.ai_summary or '')
template = self.env.ref(
'fusion_helpdesk_central.mail_template_engagement',
raise_if_not_found=False,
)
if not template:
raise UserError(_('Engagement mail template not found — was '
'fusion_helpdesk_central installed cleanly?'))
# Pass the personal note + is_reminder=False into the template's
# rendering context. Use `with_context(**data)` per CLAUDE.md —
# `ctx=...` won't reach the template body.
template.with_context(
fhc_personal_note=self.personal_note or '',
fhc_is_reminder=False,
).send_mail(ticket.id, force_send=False)
return {'type': 'ir.actions.act_window_close'}
def _action_send_bulk(self):
if not self.ticket_ids:
raise UserError(_('Wizard has no tickets attached.'))
# Snapshot owner from the first ticket — bulk is locked to a single
# client_label by validation, so they all share an owner.
email, name = self.ticket_ids[0]._fc_owner_contact()
if not email:
raise UserError(_('Owner contact disappeared since you opened '
'the wizard. Refresh and try again.'))
summary_by_id = {line.ticket_id.id: line.ai_summary or ''
for line in self.line_ids}
for ticket in self.ticket_ids:
ticket._fc_reset_engagement(
email, name, summary_by_id.get(ticket.id, ''),
)
template = self.env.ref(
'fusion_helpdesk_central.mail_template_engagement_bulk',
raise_if_not_found=False,
)
if not template:
raise UserError(_('Bulk engagement mail template not found — '
'was fusion_helpdesk_central installed cleanly?'))
# The bulk template renders once against the FIRST ticket but reads
# the full set from context. Each ticket already has its own token
# (snapped above), so the template iterates self.env['helpdesk.ticket']
# by the ids we pass in.
template.with_context(
fhc_personal_note=self.personal_note or '',
fhc_is_reminder=False,
fhc_bulk_ticket_ids=self.ticket_ids.ids,
).send_mail(self.ticket_ids[0].id, force_send=False)
return {'type': 'ir.actions.act_window_close'}
class FusionHelpdeskEngagementWizardLine(models.TransientModel):
_name = 'fusion.helpdesk.engagement.wizard.line'
_description = 'Fusion Helpdesk — Per-Ticket Bulk Engagement Line'
wizard_id = fields.Many2one(
'fusion.helpdesk.engagement.wizard', required=True,
ondelete='cascade',
)
ticket_id = fields.Many2one(
'helpdesk.ticket', required=True, ondelete='cascade',
)
ticket_name = fields.Char(
related='ticket_id.name', readonly=True,
)
findings = fields.Text(
string='Your Findings',
help='Per-ticket engineering findings. The Generate button on this '
'line uses these as the most authoritative input for the AI '
'summary.',
)
ai_summary = fields.Text(
string='Summary to Send',
help='Per-ticket summary — generated from findings + ticket, or '
'written by hand. Edit before send.',
)