Files
Odoo-Modules/fusion_helpdesk_central/utils.py
gsinghpal c520803c84 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.
2026-05-27 13:49:02 -04:00

191 lines
7.4 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure helpers for the owner-approval engagement flow.
No Odoo environment, no `request`, no network — just data in, data out.
Everything here is unit-testable in isolation, which is the same pattern
fusion_helpdesk/utils.py already follows. The OpenAI client lives in
`openai_client.py` so this file stays IO-free and trivially testable.
"""
import json
import logging
import urllib.error
import urllib.request
from markupsafe import Markup, escape
_logger = logging.getLogger(__name__)
# Frozen as a Python constant rather than an ICP key — the prompt is
# load-bearing on output quality and we want every change reviewed in git.
# Use {placeholders} to template; see build_summary_prompt for the fill.
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, 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 findings or thread don't say, write
"not stated".
- No greetings, no sign-offs, no preamble.
Ticket title: {name}
Original report from the user:
{description_plain}
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
# context. 8000 chars * ~0.25 tokens/char ~= 2000 tokens, well under any model
# cap and cheap on gpt-4o-mini.
OPENAI_PROMPT_MAX_CHARS = 8000
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}.
`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)'
if messages:
lines = []
for m in messages:
author = (m.get('author') or 'unknown').strip()
date = (m.get('date') or '').strip()
body = (m.get('body_plain') or '').strip() or '(empty)'
lines.append('%s (%s): %s' % (author, date, body))
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,
)
def truncate_for_openai(prompt, max_chars=OPENAI_PROMPT_MAX_CHARS):
"""Cap prompt length so a 50-message thread can't run up the bill.
Truncates from the END (keeps the title + description + earliest messages),
appending a marker so the model knows context was cut. Earliest replies
are usually the most relevant for an approval decision; later replies tend
to be operational chatter.
"""
if len(prompt) <= max_chars:
return prompt
marker = '\n\n[...truncated for length...]'
keep = max_chars - len(marker)
if keep <= 0:
return marker[:max_chars]
return prompt[:keep] + marker
def format_engagement_chatter(decision, owner_name, comment=None):
"""Build the chatter message posted on the ticket when the owner decides.
Returns Markup so message_post renders it as HTML (not escaped). Decision
must be 'approved' or 'rejected' — anything else raises ValueError so a
typo upstream fails loud instead of posting a half-rendered message.
"""
if decision == 'approved':
prefix, label = '', 'Approved'
elif decision == 'rejected':
prefix, label = '', 'Rejected'
else:
raise ValueError(
'format_engagement_chatter: decision must be "approved" or '
'"rejected", got %r' % (decision,)
)
name = escape((owner_name or '').strip() or 'the owner')
text = (comment or '').strip()
body = '<p><b>%s %s by %s</b></p>' % (prefix, label, name)
if text:
body += '<blockquote style="margin:6px 0 0 0; padding:6px 12px; ' \
'border-left:3px solid #d8dadd; color:#444;"><i>%s</i></blockquote>' \
% escape(text).replace('\n', '<br/>')
return Markup(body)
def call_openai_chat(api_key, model, prompt, timeout=15):
"""Single non-streaming call to OpenAI's chat-completions endpoint.
Returns the response text on success, or '' on any failure (network,
timeout, non-2xx, malformed JSON, empty choices). Never raises — the
caller treats '' as "AI unavailable" and shows the manual-summary
fallback in the wizard.
Uses urllib.request from the stdlib so we don't add a pip dependency
just for one HTTP call. JSON shape per OpenAI's chat-completions spec
as of 2026: messages, model, response choices[0].message.content.
"""
if not api_key or not prompt:
return ''
payload = json.dumps({
'model': model or 'gpt-4o-mini',
'messages': [{'role': 'user', 'content': prompt}],
'temperature': 0.2, # we want deterministic-ish summaries
}).encode('utf-8')
req = urllib.request.Request(
'https://api.openai.com/v1/chat/completions',
data=payload,
headers={
'Authorization': 'Bearer %s' % api_key,
'Content-Type': 'application/json',
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI returned HTTP %s', e.code,
)
return ''
except (urllib.error.URLError, TimeoutError, OSError) as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI network error: %s', e,
)
return ''
except (ValueError, json.JSONDecodeError) as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI returned malformed JSON: %s', e,
)
return ''
choices = (data or {}).get('choices') or []
if not choices:
return ''
content = (choices[0].get('message') or {}).get('content') or ''
return content.strip()