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

@@ -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.',

View File

@@ -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.',
)

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):

View File

@@ -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,
)

View File

@@ -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 &amp; 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>