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

@@ -172,40 +172,98 @@ class TestEngagementReset(TestEngagementBase):
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
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()
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(
default_ticket_id=t.id,
active_id=t.id,
active_model='helpdesk.ticket',
).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')
# 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)
wizard.personal_note = 'please review'
result = wizard.action_send()
# action returns the standard close-modal action
self.assertEqual(result.get('type'), 'ir.actions.act_window_close')
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertEqual(t.x_fc_engagement_email, 'owner@testclient.com')
self.assertTrue(t.x_fc_engagement_token)
def test_single_send_uses_current_client_key_owner(self):
# The wizard must read the FRESH owner contact from client_key,
# not a stale snapshot — if the client_key is updated between
# default_get and Send, Send wins.
def test_send_with_manual_summary_no_ai(self):
# Skipping Generate altogether: user types the summary by hand.
# Send should work without ever invoking OpenAI.
t = self._make_ticket()
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).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'
wizard.action_send()
self.assertEqual(t.x_fc_engagement_email, 'changed@testclient.com')
def test_wizard_rejects_ticket_without_client_label(self):
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(
default_ticket_id=t.id,
).create({})
@@ -213,17 +271,18 @@ class TestEngagementWizard(TestEngagementBase):
def test_wizard_rejects_when_owner_contact_missing(self):
self.client_key.write({'owner_email': False, 'owner_name': False})
t = self._make_ticket()
with _patch_openai(), self.assertRaises(UserError):
with self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
def test_wizard_marks_ai_unavailable_when_summary_empty(self):
def test_generate_marks_ai_unavailable_when_empty(self):
t = self._make_ticket()
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
with _patch_openai(return_value=''):
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
wizard.action_generate_summary()
self.assertTrue(wizard.ai_unavailable)
self.assertEqual(wizard.ai_summary, '')
@@ -231,34 +290,37 @@ class TestEngagementWizard(TestEngagementBase):
ts = self.env['helpdesk.ticket']
for i in range(3):
ts |= self._make_ticket(name='[TESTCLIENT] Bug %s' % i)
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=ts.ids,
active_ids=ts.ids,
active_model='helpdesk.ticket',
fhc_bulk=True,
).create({})
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=ts.ids,
active_ids=ts.ids,
active_model='helpdesk.ticket',
fhc_bulk=True,
).create({})
self.assertEqual(wizard.mode, 'bulk')
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()
for t in ts:
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertTrue(t.x_fc_engagement_token)
# Each ticket must have its OWN token
tokens = {t.x_fc_engagement_token for t in ts}
self.assertEqual(len(tokens), 3)
def test_bulk_rejects_mixed_clients(self):
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({
'client_label': 'OTHERCLIENT',
'owner_email': 'other@x.com', 'owner_name': 'Other',
})
t2 = self._make_ticket(
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(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,
@@ -268,7 +330,7 @@ class TestEngagementWizard(TestEngagementBase):
t1 = self._make_ticket()
t1._fc_reset_engagement('o@x.com', 'Owner', '') # already pending
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(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,

View File

@@ -54,6 +54,19 @@ class TestBuildSummaryPrompt(TransactionCase):
# survives; '(empty)' marker keeps the model honest.
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')
class TestTruncateForOpenAI(TransactionCase):