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.
465 lines
20 KiB
Python
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.',
|
|
)
|