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).
169 lines
6.3 KiB
Python
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()
|