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