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:
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1
|
# License OPL-1
|
||||||
{
|
{
|
||||||
'name': 'Fusion Helpdesk Central — Client API Keys',
|
'name': 'Fusion Helpdesk Central — Client API Keys',
|
||||||
'version': '19.0.2.2.0',
|
'version': '19.0.2.3.0',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
||||||
'keys used by fusion_helpdesk client deployments.',
|
'keys used by fusion_helpdesk client deployments.',
|
||||||
|
|||||||
@@ -64,11 +64,20 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
|
|||||||
help='One-line note from you, prepended above the AI summary in the '
|
help='One-line note from you, prepended above the AI summary in the '
|
||||||
'email body. Optional. Skip if the summary speaks for itself.',
|
'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(
|
ai_summary = fields.Text(
|
||||||
string='AI Summary',
|
string='Summary to Send',
|
||||||
help='OpenAI-generated brief. Edit before sending if you want to '
|
help='Brief shown to the owner in the approval email. Either '
|
||||||
'tweak the framing. Empty? The wizard fell back to manual — '
|
'generated from your findings + ticket (click "Generate '
|
||||||
'type your own brief, send normally.',
|
'Summary") or written by hand. Edit freely before sending.',
|
||||||
)
|
)
|
||||||
|
|
||||||
owner_email_display = fields.Char(
|
owner_email_display = fields.Char(
|
||||||
@@ -129,34 +138,35 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
|
|||||||
return vals
|
return vals
|
||||||
|
|
||||||
def _default_get_single(self, vals, ticket_id):
|
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)
|
ticket = self.env['helpdesk.ticket'].browse(ticket_id)
|
||||||
if not ticket.exists():
|
if not ticket.exists():
|
||||||
raise UserError(_('Ticket %s no longer exists.') % ticket_id)
|
raise UserError(_('Ticket %s no longer exists.') % ticket_id)
|
||||||
self._validate_engagement_target(ticket)
|
self._validate_engagement_target(ticket)
|
||||||
summary = self._generate_summary(ticket)
|
|
||||||
vals.update({
|
vals.update({
|
||||||
'ticket_id': ticket.id,
|
'ticket_id': ticket.id,
|
||||||
'ai_summary': summary,
|
'ai_summary': '',
|
||||||
'ai_unavailable': not bool(summary),
|
'findings': '',
|
||||||
|
'ai_unavailable': False,
|
||||||
})
|
})
|
||||||
return vals
|
return vals
|
||||||
|
|
||||||
def _default_get_bulk(self, vals, ticket_ids):
|
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()
|
tickets = self.env['helpdesk.ticket'].browse(ticket_ids).exists()
|
||||||
self._validate_bulk_targets(tickets)
|
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({
|
vals.update({
|
||||||
'ticket_ids': [(6, 0, tickets.ids)],
|
'ticket_ids': [(6, 0, tickets.ids)],
|
||||||
'line_ids': [
|
'line_ids': [
|
||||||
(0, 0, {'ticket_id': t.id,
|
(0, 0, {'ticket_id': t.id, 'findings': '', 'ai_summary': ''})
|
||||||
'ai_summary': summaries.get(t.id, '')})
|
|
||||||
for t in tickets
|
for t in tickets
|
||||||
],
|
],
|
||||||
'ai_unavailable': not any_ok,
|
'ai_unavailable': False,
|
||||||
})
|
})
|
||||||
return vals
|
return vals
|
||||||
|
|
||||||
@@ -227,9 +237,13 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
|
|||||||
msg_data,
|
msg_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _generate_summary(self, ticket):
|
def _generate_summary(self, ticket, findings=''):
|
||||||
"""Single-ticket summary. Returns '' on any failure — the wizard
|
"""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()
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
api_key = (ICP.get_param(
|
api_key = (ICP.get_param(
|
||||||
'fusion_helpdesk_central.openai_api_key') or '').strip()
|
'fusion_helpdesk_central.openai_api_key') or '').strip()
|
||||||
@@ -238,35 +252,39 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
|
|||||||
model = (ICP.get_param(
|
model = (ICP.get_param(
|
||||||
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
|
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
|
||||||
name, desc, msgs = self._summary_inputs(ticket)
|
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)
|
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.
|
"""{ticket_id: summary_or_empty} for the bulk wizard.
|
||||||
|
|
||||||
Submits N calls in parallel via a thread pool. Each call has its own
|
`ticket_findings` is a dict {ticket_id: (ticket_recordset, findings_str)}
|
||||||
15s timeout; the whole batch is capped at _BULK_AI_TIMEOUT so a slow
|
so each parallel call uses its own per-ticket findings.
|
||||||
single call doesn't hold up the rest. Anything still pending at the
|
"""
|
||||||
cap returns ''."""
|
|
||||||
ICP = self.env['ir.config_parameter'].sudo()
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
api_key = (ICP.get_param(
|
api_key = (ICP.get_param(
|
||||||
'fusion_helpdesk_central.openai_api_key') or '').strip()
|
'fusion_helpdesk_central.openai_api_key') or '').strip()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return {t.id: '' for t in tickets}
|
return {tid: '' for tid in ticket_findings}
|
||||||
model = (ICP.get_param(
|
model = (ICP.get_param(
|
||||||
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
|
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
|
||||||
|
|
||||||
# Build inputs serially (DB-bound, fast) before fanning out the
|
# Build inputs serially (DB-bound, fast) before fanning out the
|
||||||
# HTTP calls in parallel.
|
# HTTP calls in parallel.
|
||||||
inputs = {}
|
inputs = {}
|
||||||
for t in tickets:
|
for tid, (ticket, findings) in ticket_findings.items():
|
||||||
name, desc, msgs = self._summary_inputs(t)
|
name, desc, msgs = self._summary_inputs(ticket)
|
||||||
inputs[t.id] = truncate_for_openai(
|
inputs[tid] = truncate_for_openai(
|
||||||
build_summary_prompt(name, desc, msgs))
|
build_summary_prompt(name, desc, msgs, findings=findings))
|
||||||
|
|
||||||
results = {t.id: '' for t in tickets}
|
results = {tid: '' for tid in ticket_findings}
|
||||||
with concurrent.futures.ThreadPoolExecutor(
|
# Cancel pending futures on overall timeout so a slow OpenAI day
|
||||||
max_workers=_BULK_AI_WORKERS) as pool:
|
# doesn't block the wizard for ceil(N/workers) * 15s.
|
||||||
|
pool = concurrent.futures.ThreadPoolExecutor(
|
||||||
|
max_workers=_BULK_AI_WORKERS)
|
||||||
|
try:
|
||||||
futures = {
|
futures = {
|
||||||
pool.submit(call_openai_chat, api_key, model, p): tid
|
pool.submit(call_openai_chat, api_key, model, p): tid
|
||||||
for tid, p in inputs.items()
|
for tid, p in inputs.items()
|
||||||
@@ -288,8 +306,67 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
|
|||||||
'out after %ss; remaining tickets will get empty '
|
'out after %ss; remaining tickets will get empty '
|
||||||
'summaries.', _BULK_AI_TIMEOUT,
|
'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
|
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
|
# Send: write engagement state + queue mail
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -374,7 +451,14 @@ class FusionHelpdeskEngagementWizardLine(models.TransientModel):
|
|||||||
ticket_name = fields.Char(
|
ticket_name = fields.Char(
|
||||||
related='ticket_id.name', readonly=True,
|
related='ticket_id.name', readonly=True,
|
||||||
)
|
)
|
||||||
ai_summary = fields.Text(
|
findings = fields.Text(
|
||||||
string='AI Summary',
|
string='Your Findings',
|
||||||
help='Per-ticket summary — edit before send.',
|
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.',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -172,40 +172,98 @@ class TestEngagementReset(TestEngagementBase):
|
|||||||
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
||||||
class TestEngagementWizard(TestEngagementBase):
|
class TestEngagementWizard(TestEngagementBase):
|
||||||
|
|
||||||
def test_single_send_via_wizard(self):
|
def test_default_get_does_not_fire_openai(self):
|
||||||
|
# Two-step flow: opening the wizard must NOT call OpenAI — the user
|
||||||
|
# has to write findings first, then click Generate. Wrap call_openai_chat
|
||||||
|
# in a mock that fails the test if invoked.
|
||||||
|
from unittest.mock import patch
|
||||||
t = self._make_ticket()
|
t = self._make_ticket()
|
||||||
with _patch_openai():
|
called = {'n': 0}
|
||||||
|
def _spy(*a, **kw):
|
||||||
|
called['n'] += 1
|
||||||
|
return 'should-not-appear'
|
||||||
|
with patch(
|
||||||
|
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
|
||||||
|
'call_openai_chat',
|
||||||
|
side_effect=_spy,
|
||||||
|
):
|
||||||
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
default_ticket_id=t.id,
|
default_ticket_id=t.id,
|
||||||
active_id=t.id,
|
|
||||||
active_model='helpdesk.ticket',
|
|
||||||
).create({})
|
).create({})
|
||||||
|
self.assertEqual(called['n'], 0,
|
||||||
|
'default_get must not auto-fire OpenAI.')
|
||||||
|
self.assertEqual(wizard.ai_summary, '')
|
||||||
|
self.assertEqual(wizard.findings, '')
|
||||||
|
|
||||||
|
def test_single_send_via_wizard(self):
|
||||||
|
t = self._make_ticket()
|
||||||
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
|
default_ticket_id=t.id,
|
||||||
|
active_id=t.id,
|
||||||
|
active_model='helpdesk.ticket',
|
||||||
|
).create({})
|
||||||
self.assertEqual(wizard.mode, 'single')
|
self.assertEqual(wizard.mode, 'single')
|
||||||
|
# User writes findings, clicks Generate Summary
|
||||||
|
wizard.findings = 'Scope is small; ~2 hours of work.'
|
||||||
|
with _patch_openai():
|
||||||
|
wizard.action_generate_summary()
|
||||||
self.assertIn('summary bullet', wizard.ai_summary)
|
self.assertIn('summary bullet', wizard.ai_summary)
|
||||||
wizard.personal_note = 'please review'
|
wizard.personal_note = 'please review'
|
||||||
result = wizard.action_send()
|
result = wizard.action_send()
|
||||||
# action returns the standard close-modal action
|
|
||||||
self.assertEqual(result.get('type'), 'ir.actions.act_window_close')
|
self.assertEqual(result.get('type'), 'ir.actions.act_window_close')
|
||||||
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
||||||
self.assertEqual(t.x_fc_engagement_email, 'owner@testclient.com')
|
self.assertEqual(t.x_fc_engagement_email, 'owner@testclient.com')
|
||||||
self.assertTrue(t.x_fc_engagement_token)
|
self.assertTrue(t.x_fc_engagement_token)
|
||||||
|
|
||||||
def test_single_send_uses_current_client_key_owner(self):
|
def test_send_with_manual_summary_no_ai(self):
|
||||||
# The wizard must read the FRESH owner contact from client_key,
|
# Skipping Generate altogether: user types the summary by hand.
|
||||||
# not a stale snapshot — if the client_key is updated between
|
# Send should work without ever invoking OpenAI.
|
||||||
# default_get and Send, Send wins.
|
|
||||||
t = self._make_ticket()
|
t = self._make_ticket()
|
||||||
with _patch_openai():
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
default_ticket_id=t.id,
|
||||||
default_ticket_id=t.id,
|
).create({})
|
||||||
).create({})
|
wizard.ai_summary = 'Manually written brief; no AI involved.'
|
||||||
|
wizard.action_send()
|
||||||
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
||||||
|
self.assertEqual(t.x_fc_ai_summary,
|
||||||
|
'Manually written brief; no AI involved.')
|
||||||
|
|
||||||
|
def test_generate_summary_passes_findings_to_ai(self):
|
||||||
|
# Pin the prompt-construction wiring: findings field must reach
|
||||||
|
# build_summary_prompt, which must reach call_openai_chat.
|
||||||
|
from unittest.mock import patch
|
||||||
|
t = self._make_ticket()
|
||||||
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
|
default_ticket_id=t.id,
|
||||||
|
).create({})
|
||||||
|
wizard.findings = 'UNIQUE-FINDINGS-MARKER-XYZ'
|
||||||
|
captured = {}
|
||||||
|
def _spy(api_key, model, prompt, timeout=15):
|
||||||
|
captured['prompt'] = prompt
|
||||||
|
return '• generated'
|
||||||
|
with patch(
|
||||||
|
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
|
||||||
|
'call_openai_chat',
|
||||||
|
side_effect=_spy,
|
||||||
|
):
|
||||||
|
wizard.action_generate_summary()
|
||||||
|
self.assertIn('UNIQUE-FINDINGS-MARKER-XYZ', captured['prompt'])
|
||||||
|
|
||||||
|
def test_single_send_uses_current_client_key_owner(self):
|
||||||
|
# Send must read the FRESH owner contact from client_key, not a
|
||||||
|
# stale snapshot from default_get.
|
||||||
|
t = self._make_ticket()
|
||||||
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
|
default_ticket_id=t.id,
|
||||||
|
).create({})
|
||||||
|
wizard.ai_summary = 'manual summary'
|
||||||
self.client_key.owner_email = 'changed@testclient.com'
|
self.client_key.owner_email = 'changed@testclient.com'
|
||||||
wizard.action_send()
|
wizard.action_send()
|
||||||
self.assertEqual(t.x_fc_engagement_email, 'changed@testclient.com')
|
self.assertEqual(t.x_fc_engagement_email, 'changed@testclient.com')
|
||||||
|
|
||||||
def test_wizard_rejects_ticket_without_client_label(self):
|
def test_wizard_rejects_ticket_without_client_label(self):
|
||||||
t = self._make_ticket(x_fc_client_label=False)
|
t = self._make_ticket(x_fc_client_label=False)
|
||||||
with _patch_openai(), self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
default_ticket_id=t.id,
|
default_ticket_id=t.id,
|
||||||
).create({})
|
).create({})
|
||||||
@@ -213,17 +271,18 @@ class TestEngagementWizard(TestEngagementBase):
|
|||||||
def test_wizard_rejects_when_owner_contact_missing(self):
|
def test_wizard_rejects_when_owner_contact_missing(self):
|
||||||
self.client_key.write({'owner_email': False, 'owner_name': False})
|
self.client_key.write({'owner_email': False, 'owner_name': False})
|
||||||
t = self._make_ticket()
|
t = self._make_ticket()
|
||||||
with _patch_openai(), self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
default_ticket_id=t.id,
|
default_ticket_id=t.id,
|
||||||
).create({})
|
).create({})
|
||||||
|
|
||||||
def test_wizard_marks_ai_unavailable_when_summary_empty(self):
|
def test_generate_marks_ai_unavailable_when_empty(self):
|
||||||
t = self._make_ticket()
|
t = self._make_ticket()
|
||||||
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
|
default_ticket_id=t.id,
|
||||||
|
).create({})
|
||||||
with _patch_openai(return_value=''):
|
with _patch_openai(return_value=''):
|
||||||
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
wizard.action_generate_summary()
|
||||||
default_ticket_id=t.id,
|
|
||||||
).create({})
|
|
||||||
self.assertTrue(wizard.ai_unavailable)
|
self.assertTrue(wizard.ai_unavailable)
|
||||||
self.assertEqual(wizard.ai_summary, '')
|
self.assertEqual(wizard.ai_summary, '')
|
||||||
|
|
||||||
@@ -231,34 +290,37 @@ class TestEngagementWizard(TestEngagementBase):
|
|||||||
ts = self.env['helpdesk.ticket']
|
ts = self.env['helpdesk.ticket']
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
ts |= self._make_ticket(name='[TESTCLIENT] Bug %s' % i)
|
ts |= self._make_ticket(name='[TESTCLIENT] Bug %s' % i)
|
||||||
with _patch_openai():
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
default_ticket_ids=ts.ids,
|
||||||
default_ticket_ids=ts.ids,
|
active_ids=ts.ids,
|
||||||
active_ids=ts.ids,
|
active_model='helpdesk.ticket',
|
||||||
active_model='helpdesk.ticket',
|
fhc_bulk=True,
|
||||||
fhc_bulk=True,
|
).create({})
|
||||||
).create({})
|
|
||||||
self.assertEqual(wizard.mode, 'bulk')
|
self.assertEqual(wizard.mode, 'bulk')
|
||||||
self.assertEqual(len(wizard.line_ids), 3)
|
self.assertEqual(len(wizard.line_ids), 3)
|
||||||
|
# User fills findings + generates summaries (or writes by hand)
|
||||||
|
for line in wizard.line_ids:
|
||||||
|
line.findings = 'Scope: small. Effort: low.'
|
||||||
|
with _patch_openai():
|
||||||
|
wizard.action_generate_all_summaries()
|
||||||
|
for line in wizard.line_ids:
|
||||||
|
self.assertIn('summary bullet', line.ai_summary)
|
||||||
wizard.action_send()
|
wizard.action_send()
|
||||||
for t in ts:
|
for t in ts:
|
||||||
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
||||||
self.assertTrue(t.x_fc_engagement_token)
|
self.assertTrue(t.x_fc_engagement_token)
|
||||||
# Each ticket must have its OWN token
|
|
||||||
tokens = {t.x_fc_engagement_token for t in ts}
|
tokens = {t.x_fc_engagement_token for t in ts}
|
||||||
self.assertEqual(len(tokens), 3)
|
self.assertEqual(len(tokens), 3)
|
||||||
|
|
||||||
def test_bulk_rejects_mixed_clients(self):
|
def test_bulk_rejects_mixed_clients(self):
|
||||||
t1 = self._make_ticket()
|
t1 = self._make_ticket()
|
||||||
# Need another client_key for the mix to be valid otherwise the
|
|
||||||
# owner-contact check fires first.
|
|
||||||
self.env['fusion.helpdesk.client.key'].create({
|
self.env['fusion.helpdesk.client.key'].create({
|
||||||
'client_label': 'OTHERCLIENT',
|
'client_label': 'OTHERCLIENT',
|
||||||
'owner_email': 'other@x.com', 'owner_name': 'Other',
|
'owner_email': 'other@x.com', 'owner_name': 'Other',
|
||||||
})
|
})
|
||||||
t2 = self._make_ticket(
|
t2 = self._make_ticket(
|
||||||
name='[OTHERCLIENT] x', x_fc_client_label='OTHERCLIENT')
|
name='[OTHERCLIENT] x', x_fc_client_label='OTHERCLIENT')
|
||||||
with _patch_openai(), self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
default_ticket_ids=[t1.id, t2.id],
|
default_ticket_ids=[t1.id, t2.id],
|
||||||
fhc_bulk=True,
|
fhc_bulk=True,
|
||||||
@@ -268,7 +330,7 @@ class TestEngagementWizard(TestEngagementBase):
|
|||||||
t1 = self._make_ticket()
|
t1 = self._make_ticket()
|
||||||
t1._fc_reset_engagement('o@x.com', 'Owner', '') # already pending
|
t1._fc_reset_engagement('o@x.com', 'Owner', '') # already pending
|
||||||
t2 = self._make_ticket(name='[TESTCLIENT] B')
|
t2 = self._make_ticket(name='[TESTCLIENT] B')
|
||||||
with _patch_openai(), self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
||||||
default_ticket_ids=[t1.id, t2.id],
|
default_ticket_ids=[t1.id, t2.id],
|
||||||
fhc_bulk=True,
|
fhc_bulk=True,
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ class TestBuildSummaryPrompt(TransactionCase):
|
|||||||
# survives; '(empty)' marker keeps the model honest.
|
# survives; '(empty)' marker keeps the model honest.
|
||||||
self.assertIn('(empty)', prompt)
|
self.assertIn('(empty)', prompt)
|
||||||
|
|
||||||
|
def test_findings_included_in_prompt(self):
|
||||||
|
# Findings is the support engineer's analysis — must reach OpenAI.
|
||||||
|
prompt = build_summary_prompt(
|
||||||
|
't', 'd', [], findings='Scope is small. ~2h of work.',
|
||||||
|
)
|
||||||
|
self.assertIn('Scope is small. ~2h of work.', prompt)
|
||||||
|
|
||||||
|
def test_findings_absent_uses_explicit_marker(self):
|
||||||
|
# Empty findings collapses to an explicit marker so the model
|
||||||
|
# doesn't read a blank section as "missing".
|
||||||
|
prompt = build_summary_prompt('t', 'd', [], findings='')
|
||||||
|
self.assertIn('none provided', prompt)
|
||||||
|
|
||||||
|
|
||||||
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
||||||
class TestTruncateForOpenAI(TransactionCase):
|
class TestTruncateForOpenAI(TransactionCase):
|
||||||
|
|||||||
@@ -23,22 +23,35 @@ _logger = logging.getLogger(__name__)
|
|||||||
SUMMARY_PROMPT = """You are summarising a customer support ticket for a busy executive
|
SUMMARY_PROMPT = """You are summarising a customer support ticket for a busy executive
|
||||||
who needs to decide whether to approve the work.
|
who needs to decide whether to approve the work.
|
||||||
|
|
||||||
|
The support engineer has reviewed this ticket and written their findings
|
||||||
|
below (scope, limitations, recommended approach, anything the original
|
||||||
|
reporter wouldn't have known). Treat those findings as authoritative —
|
||||||
|
they reflect the actual engineering reality, not just the user's
|
||||||
|
description of the problem. If the findings contradict the original
|
||||||
|
report, side with the findings and call out the gap.
|
||||||
|
|
||||||
Output rules:
|
Output rules:
|
||||||
- 4-6 short bullet points, plain text (no markdown).
|
- 4-6 short bullet points, plain text (no markdown).
|
||||||
- First bullet: the ask, in one sentence.
|
- First bullet: the ask, in one sentence, framed in the support
|
||||||
- Second bullet: the business impact if approved.
|
engineer's terms (not just a paraphrase of the original report).
|
||||||
- Third bullet: the business impact if NOT approved (or "none material").
|
- Second bullet: the support engineer's recommendation, if they made one.
|
||||||
- Optional bullets: cost / effort signals if any are mentioned.
|
- Third bullet: business impact if approved.
|
||||||
|
- Fourth bullet: business impact if NOT approved (or "none material").
|
||||||
|
- Optional bullets: scope / effort / risk signals from the findings.
|
||||||
- Final bullet: open questions the approver should think about.
|
- Final bullet: open questions the approver should think about.
|
||||||
- Do not invent facts. If the thread doesn't say, write "not stated".
|
- Do not invent facts. If the findings or thread don't say, write
|
||||||
|
"not stated".
|
||||||
- No greetings, no sign-offs, no preamble.
|
- No greetings, no sign-offs, no preamble.
|
||||||
|
|
||||||
Ticket title: {name}
|
Ticket title: {name}
|
||||||
Original report:
|
Original report from the user:
|
||||||
{description_plain}
|
{description_plain}
|
||||||
|
|
||||||
Replies so far:
|
Replies in the ticket thread:
|
||||||
{messages_plain}
|
{messages_plain}
|
||||||
|
|
||||||
|
Support engineer's findings (your most important input):
|
||||||
|
{findings_plain}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Bound the prompt sent to OpenAI so a runaway thread doesn't blow cost or
|
# Bound the prompt sent to OpenAI so a runaway thread doesn't blow cost or
|
||||||
@@ -47,13 +60,17 @@ Replies so far:
|
|||||||
OPENAI_PROMPT_MAX_CHARS = 8000
|
OPENAI_PROMPT_MAX_CHARS = 8000
|
||||||
|
|
||||||
|
|
||||||
def build_summary_prompt(ticket_name, description_plain, messages):
|
def build_summary_prompt(ticket_name, description_plain, messages,
|
||||||
"""Render SUMMARY_PROMPT with ticket + chatter content.
|
findings=''):
|
||||||
|
"""Render SUMMARY_PROMPT with ticket + chatter + support findings.
|
||||||
|
|
||||||
`messages` is a list of dicts with at minimum {author, date, body_plain}.
|
`messages` is a list of dicts with at minimum {author, date, body_plain}.
|
||||||
We render one line per message so the model can attribute who said what.
|
`findings` is the support engineer's free-text notes from the wizard —
|
||||||
Empty inputs collapse to "(none)" so the prompt never has bare blanks
|
their scope/effort/recommendation that the AI should weight more
|
||||||
that the model could interpret as a missing section.
|
heavily than the original user description.
|
||||||
|
|
||||||
|
Empty inputs collapse to explicit markers so the prompt never has
|
||||||
|
bare blanks the model could misread as missing sections.
|
||||||
"""
|
"""
|
||||||
name = (ticket_name or '').strip() or '(untitled)'
|
name = (ticket_name or '').strip() or '(untitled)'
|
||||||
desc = (description_plain or '').strip() or '(no description)'
|
desc = (description_plain or '').strip() or '(no description)'
|
||||||
@@ -67,8 +84,13 @@ def build_summary_prompt(ticket_name, description_plain, messages):
|
|||||||
msgs = '\n---\n'.join(lines)
|
msgs = '\n---\n'.join(lines)
|
||||||
else:
|
else:
|
||||||
msgs = '(no replies yet)'
|
msgs = '(no replies yet)'
|
||||||
|
findings_text = (findings or '').strip() or (
|
||||||
|
'(none provided — base the summary on the user\'s report and '
|
||||||
|
'thread alone)'
|
||||||
|
)
|
||||||
return SUMMARY_PROMPT.format(
|
return SUMMARY_PROMPT.format(
|
||||||
name=name, description_plain=desc, messages_plain=msgs,
|
name=name, description_plain=desc, messages_plain=msgs,
|
||||||
|
findings_plain=findings_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,28 +29,65 @@
|
|||||||
invisible="not ai_unavailable">
|
invisible="not ai_unavailable">
|
||||||
<strong>AI summary unavailable.</strong>
|
<strong>AI summary unavailable.</strong>
|
||||||
OpenAI didn't return a summary (no API key set, rate limit,
|
OpenAI didn't return a summary (no API key set, rate limit,
|
||||||
or network error). Write a quick brief below before sending —
|
or network error). Write a quick brief in the Summary
|
||||||
everything else still works.
|
field below before sending — everything else still works.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Single mode: one summary field for the one ticket. -->
|
<!--
|
||||||
|
Single mode. The user fills findings first, clicks
|
||||||
|
Generate Summary, reviews the AI output, edits if needed,
|
||||||
|
then Sends. We deliberately don't auto-fire OpenAI on
|
||||||
|
open — the AI needs the engineer's analysis to be useful.
|
||||||
|
-->
|
||||||
<group invisible="mode != 'single'">
|
<group invisible="mode != 'single'">
|
||||||
<field name="personal_note"
|
<field name="personal_note"
|
||||||
placeholder="One-line note that appears above the summary in the email…"/>
|
placeholder="One-line note that appears above the summary in the email…"/>
|
||||||
<field name="ai_summary" string="Summary to send"
|
</group>
|
||||||
placeholder="Bullet-point summary that the owner will read first." />
|
<group invisible="mode != 'single'" string="1. Your Findings">
|
||||||
|
<field name="findings" nolabel="1"
|
||||||
|
placeholder="What did your investigation surface? Scope, limitations, recommended approach, effort, risks — anything the owner needs to know that the original reporter wouldn't have."/>
|
||||||
|
</group>
|
||||||
|
<group invisible="mode != 'single'" string="2. Summary to Send">
|
||||||
|
<div class="o_row" colspan="2">
|
||||||
|
<button name="action_generate_summary" type="object"
|
||||||
|
string="Generate Summary from Findings"
|
||||||
|
icon="fa-magic"
|
||||||
|
class="btn-secondary"/>
|
||||||
|
<span class="o_form_label text-muted ms-2">
|
||||||
|
← click after writing your findings, then review & edit below
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<field name="ai_summary" nolabel="1"
|
||||||
|
placeholder="Click Generate Summary above, or write the brief yourself. The owner reads this first."/>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<!-- Bulk mode: per-ticket lines, each with its own summary. -->
|
<!--
|
||||||
|
Bulk mode: same two-step flow per ticket. Findings +
|
||||||
|
Summary editable inline; a single Generate All button
|
||||||
|
fans out OpenAI in parallel using each line's findings.
|
||||||
|
-->
|
||||||
<group invisible="mode != 'bulk'">
|
<group invisible="mode != 'bulk'">
|
||||||
<field name="personal_note"
|
<field name="personal_note"
|
||||||
placeholder="One-line note that appears once at the top of the combined email…"/>
|
placeholder="One-line note that appears once at the top of the combined email…"/>
|
||||||
</group>
|
</group>
|
||||||
|
<div class="o_row" colspan="2"
|
||||||
|
invisible="mode != 'bulk'">
|
||||||
|
<button name="action_generate_all_summaries" type="object"
|
||||||
|
string="Generate All Summaries"
|
||||||
|
icon="fa-magic"
|
||||||
|
class="btn-secondary"/>
|
||||||
|
<span class="o_form_label text-muted ms-2">
|
||||||
|
← fill in findings per row first, then click to regenerate all summaries in parallel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<field name="line_ids" invisible="mode != 'bulk'" nolabel="1">
|
<field name="line_ids" invisible="mode != 'bulk'" nolabel="1">
|
||||||
<list editable="bottom" create="0" delete="0">
|
<list editable="bottom" create="0" delete="0">
|
||||||
<field name="ticket_id" readonly="1"/>
|
<field name="ticket_id" readonly="1"/>
|
||||||
<field name="ticket_name" readonly="1"/>
|
<field name="ticket_name" readonly="1"/>
|
||||||
<field name="ai_summary"/>
|
<field name="findings"
|
||||||
|
placeholder="Your engineering findings for this ticket…"/>
|
||||||
|
<field name="ai_summary"
|
||||||
|
placeholder="(empty — click Generate All above, or write by hand)"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user