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.
This commit is contained in:
gsinghpal
2026-05-27 13:49:02 -04:00
parent 7349f3180d
commit c520803c84
6 changed files with 303 additions and 85 deletions

View File

@@ -64,11 +64,20 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
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='AI Summary',
help='OpenAI-generated brief. Edit before sending if you want to '
'tweak the framing. Empty? The wizard fell back to manual — '
'type your own brief, send normally.',
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(
@@ -129,34 +138,35 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
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)
summary = self._generate_summary(ticket)
vals.update({
'ticket_id': ticket.id,
'ai_summary': summary,
'ai_unavailable': not bool(summary),
'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)
# One summary per ticket, fanned out in parallel so the modal doesn't
# block for N * 15s. If the fan-out itself times out we still open
# the wizard — the user just has to fill in summaries manually.
summaries = self._generate_summaries_parallel(tickets)
any_ok = any(s for s in summaries.values())
vals.update({
'ticket_ids': [(6, 0, tickets.ids)],
'line_ids': [
(0, 0, {'ticket_id': t.id,
'ai_summary': summaries.get(t.id, '')})
(0, 0, {'ticket_id': t.id, 'findings': '', 'ai_summary': ''})
for t in tickets
],
'ai_unavailable': not any_ok,
'ai_unavailable': False,
})
return vals
@@ -227,9 +237,13 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
msg_data,
)
def _generate_summary(self, ticket):
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."""
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()
@@ -238,35 +252,39 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
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))
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, tickets):
def _generate_summaries_parallel(self, ticket_findings):
"""{ticket_id: summary_or_empty} for the bulk wizard.
Submits N calls in parallel via a thread pool. Each call has its own
15s timeout; the whole batch is capped at _BULK_AI_TIMEOUT so a slow
single call doesn't hold up the rest. Anything still pending at the
cap returns ''."""
`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 {t.id: '' for t in tickets}
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 t in tickets:
name, desc, msgs = self._summary_inputs(t)
inputs[t.id] = truncate_for_openai(
build_summary_prompt(name, desc, msgs))
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 = {t.id: '' for t in tickets}
with concurrent.futures.ThreadPoolExecutor(
max_workers=_BULK_AI_WORKERS) as pool:
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()
@@ -288,8 +306,67 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
'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
# ------------------------------------------------------------------
@@ -374,7 +451,14 @@ class FusionHelpdeskEngagementWizardLine(models.TransientModel):
ticket_name = fields.Char(
related='ticket_id.name', readonly=True,
)
ai_summary = fields.Text(
string='AI Summary',
help='Per-ticket summary — edit before send.',
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.',
)