diff --git a/fusion_helpdesk_central/__manifest__.py b/fusion_helpdesk_central/__manifest__.py
index 440534f3..8b3fdcfb 100644
--- a/fusion_helpdesk_central/__manifest__.py
+++ b/fusion_helpdesk_central/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
- 'version': '19.0.2.2.0',
+ 'version': '19.0.2.3.0',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',
diff --git a/fusion_helpdesk_central/models/engagement_wizard.py b/fusion_helpdesk_central/models/engagement_wizard.py
index a5e1138e..d6b03d4a 100644
--- a/fusion_helpdesk_central/models/engagement_wizard.py
+++ b/fusion_helpdesk_central/models/engagement_wizard.py
@@ -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.',
)
diff --git a/fusion_helpdesk_central/tests/test_engagement.py b/fusion_helpdesk_central/tests/test_engagement.py
index 6a2c44df..490e33ed 100644
--- a/fusion_helpdesk_central/tests/test_engagement.py
+++ b/fusion_helpdesk_central/tests/test_engagement.py
@@ -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,
diff --git a/fusion_helpdesk_central/tests/test_utils.py b/fusion_helpdesk_central/tests/test_utils.py
index dd301adc..baadd91f 100644
--- a/fusion_helpdesk_central/tests/test_utils.py
+++ b/fusion_helpdesk_central/tests/test_utils.py
@@ -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):
diff --git a/fusion_helpdesk_central/utils.py b/fusion_helpdesk_central/utils.py
index 3c267d2a..05a68d7e 100644
--- a/fusion_helpdesk_central/utils.py
+++ b/fusion_helpdesk_central/utils.py
@@ -23,22 +23,35 @@ _logger = logging.getLogger(__name__)
SUMMARY_PROMPT = """You are summarising a customer support ticket for a busy executive
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:
- 4-6 short bullet points, plain text (no markdown).
-- First bullet: the ask, in one sentence.
-- Second bullet: the business impact if approved.
-- Third bullet: the business impact if NOT approved (or "none material").
-- Optional bullets: cost / effort signals if any are mentioned.
+- First bullet: the ask, in one sentence, framed in the support
+ engineer's terms (not just a paraphrase of the original report).
+- Second bullet: the support engineer's recommendation, if they made one.
+- 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.
-- 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.
Ticket title: {name}
-Original report:
+Original report from the user:
{description_plain}
-Replies so far:
+Replies in the ticket thread:
{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
@@ -47,13 +60,17 @@ Replies so far:
OPENAI_PROMPT_MAX_CHARS = 8000
-def build_summary_prompt(ticket_name, description_plain, messages):
- """Render SUMMARY_PROMPT with ticket + chatter content.
+def build_summary_prompt(ticket_name, description_plain, messages,
+ findings=''):
+ """Render SUMMARY_PROMPT with ticket + chatter + support findings.
`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.
- Empty inputs collapse to "(none)" so the prompt never has bare blanks
- that the model could interpret as a missing section.
+ `findings` is the support engineer's free-text notes from the wizard —
+ their scope/effort/recommendation that the AI should weight more
+ 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)'
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)
else:
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(
name=name, description_plain=desc, messages_plain=msgs,
+ findings_plain=findings_text,
)
diff --git a/fusion_helpdesk_central/views/engagement_wizard_views.xml b/fusion_helpdesk_central/views/engagement_wizard_views.xml
index 1bbbde20..a082d799 100644
--- a/fusion_helpdesk_central/views/engagement_wizard_views.xml
+++ b/fusion_helpdesk_central/views/engagement_wizard_views.xml
@@ -29,28 +29,65 @@
invisible="not ai_unavailable">
AI summary unavailable.
OpenAI didn't return a summary (no API key set, rate limit,
- or network error). Write a quick brief below before sending —
- everything else still works.
+ or network error). Write a quick brief in the Summary
+ field below before sending — everything else still works.
-
+