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:
@@ -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.',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user