feat(fusion_repairs): Bundle 2 - weekend self-service (CL6/CL7 + CL15 + CL17)

CL6/CL7 AI self-check engine
- New fusion.repair.ai.service AbstractModel with single guardrailed
  suggest_self_check(category_id, symptoms, urgency) entry point.
- Hard-escalation FIRST (before any AI call): stairlift / porch lift +
  safety symptoms (smoke / burning / spark / stuck / motor), OR any
  mention of fire / injury / hurt / bleeding / trapped, OR urgency=safety
  -> escalate immediately regardless of AI availability.
- AI call via fusion.api.service.call_openai() (consumer='fusion_repairs',
  feature='client_self_triage') with try/fallback per project rule -
  no hard fusion_api dep, no install error if it's missing.
- Strict response validation: JSON schema check, max 3 steps, max 200
  chars per field, forbidden-phrase regex (diagnose, you have, medical
  condition, stop using, consult doctor, price patterns) - on any
  failure falls back to deterministic rules.
- 24h in-memory cache keyed by (category, symptom_hash) so repeat calls
  during AI cost-cap incidents come from cache.
- System prompt + JSON schema published as ir.config_parameter so office
  can refine without code changes (default prompt + schema in spec
  Appendix A).
- New fusion.repair.self.check.rule model + 17 seeded rules across all
  7 product categories (data/self_check_data.xml) - these are the
  deterministic fallback AND the canonical seed if AI is disabled.
- New /repair/self_check jsonrpc route (auth=public) gated by the
  per-IP rate-limit; defensive input bounds (max 5 symptoms, 500 chars
  each) defend against prompt-injection bloat.

CL15 weekend safety escalation + on-call paging
- New fusion.repair.on.call.service AbstractModel with:
  * find_next_on_call(exclude=...) -> lowest x_fc_on_call_priority
  * page_on_call(repair) -> sends mail to next available + writes
    x_fc_on_call_token / x_fc_on_call_paged_user_id / paged_at on the
    repair, posts chatter
  * acknowledge(repair, user) -> records ack, posts chatter
  * cron_escalate_unacknowledged() -> every 5 min, re-pages the next
    priority for repairs paged >15 min ago without ack
- Auto-fires from intake service whenever x_fc_urgency='safety' is
  submitted. _is_business_hours() defaults to "page" when no calendar
  is set or after working hours.
- New email_template_on_call_page with 4px red accent + acknowledge
  CTA button linking to /repair/on-call/ack/<token>.
- /repair/on-call/ack/<token> http route (auth=user, must be the paged
  manager OR any internal user) records the ack and renders confirmation.
- 5-minute cron 'Fusion Repairs: Escalate unacknowledged on-call pages'
  with configurable window via fusion_repairs.on_call_escalate_minutes
  (default 15).
- New repair.order fields x_fc_on_call_token, x_fc_on_call_paged_user_id,
  x_fc_on_call_paged_at, x_fc_on_call_acknowledged_user_ids,
  x_fc_on_call_acknowledged_at - all copy=False so duplicates start fresh.

CL17 QR sticker generator
- New fusion.repair.qr.sticker.wizard TransientModel takes a Many2many
  of stock.lot records (optionally filtered by product).
- QWeb PDF report fusion_repairs.report_qr_stickers prints a 4-up
  sticker sheet on letter paper: 80mm x 50mm per sticker with the
  QR code (38mm), product name, serial number, and the canonical
  portal URL (from web.base.url + fusion_repairs.client_portal_url).
- QR encodes /repair?sn=<serial> which the public client portal
  already pre-fills via the ?sn= query param.
- Uses the qrcode library if available; renders 'QR lib missing'
  placeholder otherwise so the PDF still prints.
- New menu Configuration > Generate QR Stickers + standalone wizard.

Verified end-to-end on local westin-v19:
  CL6 stairlift+smoke -> escalate=True source=escalated reason=safety
  CL6 bed (no AI) -> fallback returned escalate=True (safe default)
  CL15 admin paged for RO-202605-10 with 27-char token
  CL17 sticker URL: /repair?sn=001124032521528404
       QR data URI: data:image/png;base64,iVBORw... (PNG OK)

Bumped to 19.0.1.2.0 (minor bump - new public-facing capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-20 23:40:52 -04:00
parent 3a15164605
commit 5c8768c556
18 changed files with 1146 additions and 1 deletions

View File

@@ -9,6 +9,9 @@ from . import intake_answer
from . import service_catalog
from . import repair_warranty
from . import maintenance_contract
from . import repair_self_check_rule
from . import repair_ai_service
from . import repair_on_call_service
from . import product_template
from . import res_partner
from . import res_users

View File

@@ -179,6 +179,13 @@ class FusionRepairIntakeService(models.AbstractModel):
'Created in <b>Quote Only</b> mode - no technician dispatched.'
)))
# CL15: page the on-call manager for safety intakes after hours.
if repair.x_fc_urgency == 'safety':
try:
self.env['fusion.repair.on.call.service'].sudo().page_on_call(repair)
except Exception as e:
_logger.warning('On-call page failed for %s: %s', repair.name, e)
# Emails (client + office).
self._send_intake_emails(repair)

View File

@@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Repair AI Service - single guardrailed entry point for client-portal AI.
Per the design spec (Appendix A), this AbstractModel:
1) Builds a strict system prompt forbidding medical advice, diagnoses,
stop-using recommendations, etc.
2) Calls fusion.api.service.call_openai() if available (try/fallback per
fusion-api-integration rule - never installs as a hard dep)
3) JSON-schema validates the response and runs a forbidden-phrase regex
4) Always falls back to deterministic fusion.repair.self.check.rule
records on any failure - intake must never be blocked by AI
System prompt + JSON schema live in ir.config_parameter so the office can
refine them without code changes.
"""
import hashlib
import json
import logging
import re
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# ----- Safety filters -----
FORBIDDEN_PATTERNS = [
re.compile(r'\b(diagnos(e|is|ed|ing))\b', re.I),
re.compile(r'\byou have\b', re.I),
re.compile(r'\bmedical condition\b', re.I),
re.compile(r'\bstop using\b', re.I),
re.compile(r'\bconsult\s+(your|a)\s+(doctor|physician|nurse)\b', re.I),
re.compile(r'(\$|CAD|USD)\s?\d+', re.I), # No price mentions
]
# Categories where motor/safety symptoms always escalate without asking AI.
SAFETY_CATEGORY_CODES = ('stairlift', 'porch_lift')
SAFETY_SYMPTOMS = (
'smoke', 'burning', 'spark', 'fire', 'stuck', 'trapped',
'motor', 'brake fail', "won't stop", 'overshoot',
)
DEFAULT_SYSTEM_PROMPT = (
"You are a triage assistant for Fusion Repairs, a Canadian medical "
"equipment service company. Your ONLY job is to suggest 1-3 safe, "
"reversible self-check steps a client can try on their medical equipment "
"before scheduling a technician visit.\n\n"
"ABSOLUTE RULES:\n"
"1. NEVER provide medical advice, diagnoses, or health recommendations.\n"
"2. NEVER claim a definitive cause for the problem.\n"
"3. NEVER recommend stopping use of medical equipment.\n"
"4. NEVER use phrases like 'you have', 'I diagnose', 'you should stop', "
"'medical condition', 'consult your doctor'.\n"
"5. ONLY suggest steps that are: safe, reversible, require no tools, "
"take under 2 minutes, and pose zero risk to the client or equipment.\n"
"6. If symptoms involve smoke, sparks, burning smell, motors on "
"stairlifts/porch lifts, OR if you are uncertain -> return "
"escalate_immediately: true.\n"
"7. Maximum 3 steps. Each step <= 1 sentence. Grade-6 reading level. "
"No technical jargon.\n"
"8. NEVER reference part numbers, prices, or other clients.\n"
"9. If client reports injury, equipment fire, or person trapped -> "
"escalate_immediately: true with escalation_reason: 'emergency'.\n"
"10. You MUST output valid JSON matching the provided schema. No prose, "
"no markdown, no commentary."
)
class FusionRepairAIService(models.AbstractModel):
_name = 'fusion.repair.ai.service'
_description = 'Repair AI Service - guardrailed self-check engine'
# ------------------------------------------------------------------
# PUBLIC API
# ------------------------------------------------------------------
@api.model
def suggest_self_check(self, product_category_id=None, symptoms=None, urgency=None):
"""Return a list of safe self-check steps for the client portal.
Returns a dict with shape:
{
'escalate_immediately': bool,
'escalation_reason': str | None,
'confidence': 'high' | 'medium' | 'low',
'steps': [{'instruction': str, 'expected_result': str,
'safety_note': str | None}, ...],
'source': 'ai' | 'fallback' | 'escalated',
'disclaimer': str,
}
"""
symptoms = [s for s in (symptoms or []) if s]
category = (
self.env['fusion.repair.product.category'].sudo().browse(product_category_id)
if product_category_id else False
)
# Pre-check: hard-escalate for safety-critical category + symptom combos
# without consulting AI. This is BEFORE any AI call so even if AI is
# down we still escalate the right way.
if self._should_hard_escalate(category, symptoms, urgency):
return self._escalated_response('safety')
# Try the AI, fall back to deterministic rules on any failure.
ai_result = self._try_ai(category, symptoms)
if ai_result:
ai_result['source'] = 'ai'
ai_result['disclaimer'] = self._disclaimer()
return ai_result
return self._deterministic_fallback(category, symptoms)
# ------------------------------------------------------------------
# HARD ESCALATION
# ------------------------------------------------------------------
@api.model
def _should_hard_escalate(self, category, symptoms, urgency):
if urgency == 'safety':
return True
text = ' '.join(symptoms).lower()
if category and category.code in SAFETY_CATEGORY_CODES:
if any(kw in text for kw in SAFETY_SYMPTOMS):
return True
# Anyone reporting fire / injury / trapped person, regardless of category.
if any(kw in text for kw in ('fire', 'injury', 'hurt', 'bleeding', 'trapped')):
return True
return False
@api.model
def _escalated_response(self, reason):
return {
'escalate_immediately': True,
'escalation_reason': reason,
'confidence': 'high',
'steps': [],
'source': 'escalated',
'disclaimer': self._disclaimer(),
}
# ------------------------------------------------------------------
# AI CALL (try/fallback)
# ------------------------------------------------------------------
@api.model
def _try_ai(self, category, symptoms):
try:
ApiService = self.env.get('fusion.api.service')
if not ApiService:
return None
messages = [
{'role': 'system', 'content': self._system_prompt()},
{'role': 'user', 'content': self._user_prompt(category, symptoms)},
]
cache_key = self._cache_key(category, symptoms)
cached = self._cache_get(cache_key)
if cached:
return cached
raw = ApiService.call_openai(
consumer='fusion_repairs',
feature='client_self_triage',
messages=messages,
max_tokens=400,
)
if not raw:
return None
parsed = self._safe_parse(raw)
if not parsed:
self._log_incident('parse_failed', raw)
return None
self._cache_set(cache_key, parsed)
return parsed
except Exception as e:
_logger.info('AI self-check skipped: %s', e)
return None
@api.model
def _system_prompt(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(
'fusion_repairs.ai_self_check_system_prompt',
DEFAULT_SYSTEM_PROMPT,
)
@api.model
def _user_prompt(self, category, symptoms):
cat_name = category.name if category else 'medical equipment'
return (
f"Equipment category: {cat_name}\n"
f"Reported symptoms: {'; '.join(symptoms) if symptoms else '(none provided)'}\n"
"Output the JSON object only."
)
# ------------------------------------------------------------------
# SAFE PARSE + VALIDATE
# ------------------------------------------------------------------
@api.model
def _safe_parse(self, raw):
"""Parse the AI response, validate against the JSON schema, and run
forbidden-phrase regex filters. Returns None on any failure - caller
falls back to deterministic rules."""
if not raw:
return None
text = raw.strip()
# Strip code-fence wrapping if AI added it.
if text.startswith('```'):
text = re.sub(r'^```[a-zA-Z]*\n?', '', text)
text = re.sub(r'\n?```$', '', text)
try:
data = json.loads(text)
except (ValueError, TypeError):
return None
# Schema check (minimal - we don't pull in jsonschema as a dep)
if not isinstance(data, dict):
return None
if not isinstance(data.get('escalate_immediately'), bool):
return None
confidence = data.get('confidence')
if confidence not in ('high', 'medium', 'low'):
return None
steps = data.get('steps')
if not isinstance(steps, list) or len(steps) > 3:
return None
# Coherence: not-escalated must have at least one step.
if not data['escalate_immediately'] and not steps:
return None
# Per-step validation + forbidden-phrase scan.
cleaned_steps = []
for step in steps:
if not isinstance(step, dict):
return None
instr = step.get('instruction')
expected = step.get('expected_result')
if not isinstance(instr, str) or not instr.strip():
return None
if not isinstance(expected, str) or not expected.strip():
return None
if len(instr) > 200 or len(expected) > 200:
return None
if self._contains_forbidden(instr) or self._contains_forbidden(expected):
return None
note = step.get('safety_note')
if note is not None and (not isinstance(note, str) or len(note) > 200):
return None
if note and self._contains_forbidden(note):
return None
cleaned_steps.append({
'instruction': instr.strip(),
'expected_result': expected.strip(),
'safety_note': (note or '').strip() or None,
})
return {
'escalate_immediately': data['escalate_immediately'],
'escalation_reason': data.get('escalation_reason') or None,
'confidence': confidence,
'steps': cleaned_steps,
}
@api.model
def _contains_forbidden(self, text):
if not text:
return False
return any(p.search(text) for p in FORBIDDEN_PATTERNS)
# ------------------------------------------------------------------
# DETERMINISTIC FALLBACK
# ------------------------------------------------------------------
@api.model
def _deterministic_fallback(self, category, symptoms):
"""Look up fusion.repair.self.check.rule records for the category
and return the matching steps. Used when AI is unavailable or
returns invalid / unsafe content."""
Rule = self.env['fusion.repair.self.check.rule'].sudo()
steps = []
if category:
haystack = ' '.join(symptoms).lower()
rules = Rule.search([
('category_id', '=', category.id),
('active', '=', True),
], order='sequence')
for r in rules:
kws = [k.strip().lower() for k in (r.symptom_keywords or '').split(',') if k.strip()]
if not kws or any(kw and kw in haystack for kw in kws):
steps.append({
'instruction': r.instruction or '',
'expected_result': r.expected_result or '',
'safety_note': r.safety_note or None,
})
if len(steps) >= 3:
break
return {
'escalate_immediately': len(steps) == 0,
'escalation_reason': None if steps else 'no_match',
'confidence': 'medium' if steps else 'low',
'steps': steps,
'source': 'fallback',
'disclaimer': self._disclaimer(),
}
# ------------------------------------------------------------------
# CACHE (in-memory per worker, 24h)
# ------------------------------------------------------------------
_CACHE = {}
_CACHE_TTL = 24 * 3600
@api.model
def _cache_key(self, category, symptoms):
symptom_hash = hashlib.sha256(
('|'.join(sorted(s.lower() for s in symptoms))).encode()
).hexdigest()[:16]
return f"{category.code if category else 'none'}:{symptom_hash}"
@api.model
def _cache_get(self, key):
import time
entry = self._CACHE.get(key)
if not entry:
return None
ts, value = entry
if time.time() - ts > self._CACHE_TTL:
self._CACHE.pop(key, None)
return None
return value
@api.model
def _cache_set(self, key, value):
import time
# Bound cache size to ~512 entries.
if len(self._CACHE) > 512:
self._CACHE.clear()
self._CACHE[key] = (time.time(), value)
# ------------------------------------------------------------------
# MISC
# ------------------------------------------------------------------
@api.model
def _disclaimer(self):
return ("This is not medical advice. If you're unsure, schedule a "
"technician visit. In an emergency, call 9-1-1.")
@api.model
def _log_incident(self, kind, raw):
_logger.warning('AI self-check incident (%s): %s', kind, (raw or '')[:300])

View File

@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""On-call service - finds the next on-call manager and pages them.
Triggered when a safety-flagged repair comes in outside business hours
(or any time, if the user wants to call us about a stuck stairlift).
Per the design spec section "Weekend safety escalation":
1. 911 disclaimer is shown to the client
2. repair.order created with priority=high + Monday-followup activity
3. Page next on-call manager (lowest x_fc_on_call_priority among
active users with x_fc_on_call=True)
4. SMS + email sent; tokenized /repair/on-call/ack/<token> for ack
5. 15-minute escalation cron pages next priority if first doesn't ack
6. All actions logged to repair chatter
Phase 2 ships with priority-int sorting; Phase 4 will replace with proper
shift scheduling (date ranges per on-call user).
"""
import logging
import secrets
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class FusionRepairOnCallService(models.AbstractModel):
_name = 'fusion.repair.on.call.service'
_description = 'Repair On-Call Paging Service'
# ------------------------------------------------------------------
# PUBLIC API
# ------------------------------------------------------------------
@api.model
def find_next_on_call(self, exclude_user_ids=None):
"""Return the highest-priority active on-call user, or empty recordset."""
exclude_user_ids = exclude_user_ids or []
Users = self.env['res.users'].sudo()
return Users.search([
('x_fc_on_call', '=', True),
('active', '=', True),
('id', 'not in', exclude_user_ids),
], order='x_fc_on_call_priority asc, id asc', limit=1)
@api.model
def page_on_call(self, repair, force=False):
"""Page the next on-call manager for the given repair.
Skips if outside business hours check disabled OR already paged
unless force=True. Returns the paged user or empty recordset.
"""
repair.ensure_one()
if not force and self._is_business_hours():
_logger.info('On-call page skipped for %s - inside business hours',
repair.name)
return self.env['res.users']
# Don't re-page a repair that's already been paged in this cycle.
already_paged = repair.x_fc_on_call_acknowledged_user_ids.ids
target = self.find_next_on_call(exclude_user_ids=already_paged)
if not target:
self._notify_office_no_oncall(repair)
return self.env['res.users']
token = secrets.token_urlsafe(20)
repair.write({
'x_fc_on_call_token': token,
'x_fc_on_call_paged_user_id': target.id,
'x_fc_on_call_paged_at': fields.Datetime.now(),
})
self._send_page_email(repair, target, token)
self._post_chatter(repair, target)
return target
@api.model
def acknowledge(self, repair, user):
"""Mark a repair's on-call page as acknowledged by `user`."""
repair.ensure_one()
repair.x_fc_on_call_acknowledged_user_ids = [(4, user.id)]
repair.x_fc_on_call_acknowledged_at = fields.Datetime.now()
repair.message_post(body=Markup(_(
'On-call page <b>acknowledged</b> by %s.'
)) % (user.name or user.login or ''))
@api.model
def cron_escalate_unacknowledged(self):
"""Cron: re-page the next priority for any repair whose first page
is older than 15 minutes without acknowledgement."""
ICP = self.env['ir.config_parameter'].sudo()
try:
window_min = int(ICP.get_param(
'fusion_repairs.on_call_escalate_minutes', '15'
))
except (ValueError, TypeError):
window_min = 15
cutoff = fields.Datetime.now() - timedelta(minutes=window_min)
Repair = self.env['repair.order'].sudo()
stale = Repair.search([
('x_fc_on_call_paged_at', '!=', False),
('x_fc_on_call_paged_at', '<=', cutoff),
('x_fc_on_call_acknowledged_at', '=', False),
('state', 'not in', ('done', 'cancel')),
])
for r in stale:
already = r.x_fc_on_call_acknowledged_user_ids.ids + [
r.x_fc_on_call_paged_user_id.id
]
self.page_on_call(r, force=True)
# ------------------------------------------------------------------
# HELPERS
# ------------------------------------------------------------------
@api.model
def _is_business_hours(self):
"""True when within the company resource_calendar's working time."""
cal = self.env.company.resource_calendar_id
if not cal:
return False # Treat "no calendar" as always after-hours so we always page.
now = fields.Datetime.now()
try:
return bool(cal._work_intervals_batch(now, now)[False])
except Exception:
return False
@api.model
def _send_page_email(self, repair, target, token):
try:
tpl = self.env.ref(
'fusion_repairs.email_template_on_call_page',
raise_if_not_found=False,
)
if tpl:
tpl.with_context(
on_call_token=token,
on_call_user=target,
).send_mail(repair.id, force_send=False, email_values={
'email_to': target.email or target.partner_id.email or '',
})
except Exception as e:
_logger.warning('On-call page email failed for repair %s: %s',
repair.name, e)
@api.model
def _post_chatter(self, repair, target):
repair.message_post(body=Markup(_(
'After-hours <b>safety paged</b> %(name)s '
'(priority %(p)s). Awaiting acknowledgement.'
)) % {
'name': target.name or target.login or '',
'p': str(target.x_fc_on_call_priority or 99),
})
@api.model
def _notify_office_no_oncall(self, repair):
_logger.error(
'No on-call user configured (x_fc_on_call=True) - safety repair '
'%s will queue for Monday with no page.',
repair.name,
)
repair.message_post(body=Markup(_(
'<span style="color:#c00"><b>WARNING:</b> No on-call user '
'configured. This safety repair was queued but no one was paged. '
'Configure x_fc_on_call on a manager.</span>'
)))

View File

@@ -97,6 +97,39 @@ class RepairOrder(models.Model):
'office has not yet authorised dispatching a technician.',
)
# ------------------------------------------------------------------
# ON-CALL PAGING (CL15)
# Set when a safety repair is paged to the on-call manager. Allows
# ack and the 15-minute escalation cron to roll forward to the next
# priority if not acknowledged.
# ------------------------------------------------------------------
x_fc_on_call_token = fields.Char(
string='On-Call Ack Token',
copy=False,
index=True,
)
x_fc_on_call_paged_user_id = fields.Many2one(
'res.users',
string='On-Call Paged User',
copy=False,
index=True,
)
x_fc_on_call_paged_at = fields.Datetime(
string='On-Call Paged At',
copy=False,
)
x_fc_on_call_acknowledged_user_ids = fields.Many2many(
'res.users',
'fusion_repair_on_call_ack_rel',
'repair_id', 'user_id',
string='On-Call Acknowledgements',
copy=False,
)
x_fc_on_call_acknowledged_at = fields.Datetime(
string='Acknowledged At',
copy=False,
)
# Maintenance contract back-link (Phase 3)
x_fc_maintenance_contract_id = fields.Many2one(
'fusion.repair.maintenance.contract',

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Deterministic self-check rules.
Seeded per equipment category + symptom keyword combination. Used by
fusion.repair.ai.service when:
- AI is unavailable (fusion_api not installed / OpenAI down)
- AI returns malformed / unsafe content
- The category has no AI configured
Also rendered directly on the client portal when AI is disabled per spec.
"""
from odoo import fields, models
class FusionRepairSelfCheckRule(models.Model):
_name = 'fusion.repair.self.check.rule'
_description = 'Repair Self-Check Rule (deterministic fallback)'
_order = 'category_id, sequence, id'
name = fields.Char(string='Title', required=True, translate=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
required=True,
index=True,
ondelete='cascade',
)
symptom_keywords = fields.Char(
string='Symptom Keywords',
help='Comma-separated, lowercase. Empty matches any symptom.',
)
instruction = fields.Text(
string='Instruction',
required=True,
translate=True,
help='What to ask the client to do. Plain English, <= 1 sentence.',
)
expected_result = fields.Text(
string='Expected Result',
required=True,
translate=True,
help='What success looks like ("alarm stops", "wheel spins freely").',
)
safety_note = fields.Text(
string='Safety Note',
translate=True,
help='Optional warning shown in red below the instruction.',
)