From c520803c847187269e48a647e2133691042f50c6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 13:49:02 -0400 Subject: [PATCH] feat(fusion_helpdesk_central): findings-first wizard, explicit Generate button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- fusion_helpdesk_central/__manifest__.py | 2 +- .../models/engagement_wizard.py | 152 ++++++++++++++---- .../tests/test_engagement.py | 124 ++++++++++---- fusion_helpdesk_central/tests/test_utils.py | 13 ++ fusion_helpdesk_central/utils.py | 46 ++++-- .../views/engagement_wizard_views.xml | 51 +++++- 6 files changed, 303 insertions(+), 85 deletions(-) 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. - + - + + + + + +
+
+
- + +
+
- + +