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
|
||||
{
|
||||
'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.',
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,28 +29,65 @@
|
||||
invisible="not ai_unavailable">
|
||||
<strong>AI summary unavailable.</strong>
|
||||
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.
|
||||
</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'">
|
||||
<field name="personal_note"
|
||||
placeholder="One-line note that appears above the summary in the email…"/>
|
||||
<field name="ai_summary" string="Summary to send"
|
||||
placeholder="Bullet-point summary that the owner will read first." />
|
||||
</group>
|
||||
<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>
|
||||
|
||||
<!-- 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'">
|
||||
<field name="personal_note"
|
||||
placeholder="One-line note that appears once at the top of the combined email…"/>
|
||||
</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">
|
||||
<list editable="bottom" create="0" delete="0">
|
||||
<field name="ticket_id" 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>
|
||||
</field>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user