Files
Odoo-Modules/fusion_helpdesk_central/utils.py
gsinghpal 396170b438 feat(fusion_helpdesk): owner-approval engagement flow + AI summary + reporting
Ships the design spec at docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md.

What's new on central (fusion_helpdesk_central 19.0.1.2.0 -> 19.0.2.0.0):

- Engagement model: 8 new fields on helpdesk.ticket (state, snapshotted
  owner email/name, single-use UUID4 token, sent/reminded/decided
  timestamps, AI summary, stored-computed turnaround hours).
- Wizard: single + bulk modes on one fusion.helpdesk.engagement.wizard
  TransientModel with a child wizard.line for per-ticket bulk summaries.
  default_get pulls the OpenAI summary on open; AI fan-out for bulk is
  parallel via ThreadPoolExecutor (max 5 workers, 30s overall cap).
- OpenAI client in utils.py — stdlib urllib, 15s per-call timeout, every
  failure collapses to '' so the wizard's manual-summary fallback fires.
- Public portal: /fusion_helpdesk/engagement/<token>/<decision> GET +
  POST, four branded standalone QWeb pages (confirm/done/invalid/error).
  Token is single-use, cleared on confirm. Decision posts a public
  comment attributed to the resolved owner partner; chatter propagates
  to the employee's My Tickets thread per the "fully visible" UX choice.
- Mail templates (single + bulk) with magic-link buttons. Bulk template
  renders one card per ticket, each with its own approve/reject URL.
- Reminder cron: daily, single-shot per engagement, configurable via
  fusion_helpdesk_central.engagement_reminder_days ICP (default 3, 0
  disables).
- Reporting dashboard: pivot/graph/list/kanban over helpdesk.ticket
  filtered to engaged ones, with avg-turnaround measure. Menu lives
  under Helpdesk > Reporting > Owner Engagements.
- Client_key extended with owner_email/owner_name fields; ticket.create
  upserts them from the client-side piggyback (no new sync endpoint).
- 100% coverage on utils + integration tests on wizard, controllers,
  re-engagement, cron, computed turnaround. OpenAI mocked in CI.

What's new on client (fusion_helpdesk 19.0.1.7.1 -> 19.0.2.0.0):

- Two new ICP settings: fusion_helpdesk.owner_email / .owner_name with
  a new "Owner Approval" block in Settings > Fusion Helpdesk.
- controllers/main.py::submit piggybacks both keys on every ticket
  payload so central keeps client_key.owner_email/name fresh
  automatically.

Verified live end-to-end on entech -> nexa: payload upsert, wizard with
mocked AI, action_send, portal GET/POST/GET-again cycle, second click
hits the friendly invalid-token page. Token entropy = 122 bits (UUID4).
2026-05-27 13:03:23 -04:00

169 lines
6.3 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.
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.
- Final bullet: open questions the approver should think about.
- Do not invent facts. If the thread doesn't say, write "not stated".
- No greetings, no sign-offs, no preamble.
Ticket title: {name}
Original report:
{description_plain}
Replies so far:
{messages_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):
"""Render SUMMARY_PROMPT with ticket + chatter content.
`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.
"""
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)'
return SUMMARY_PROMPT.format(
name=name, description_plain=desc, messages_plain=msgs,
)
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()