-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathresponder.py
More file actions
118 lines (97 loc) · 5.11 KB
/
Copy pathresponder.py
File metadata and controls
118 lines (97 loc) · 5.11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
"""LLM-generated customer replies — but inside hard guardrails.
The brief asks for personalized responses "using a language model." The original
build used fixed templates (safer, but literally not LLM-generated). This module
splits the difference the way a real regulated product does:
- the LLM drafts the WARMTH (a 1-2 sentence empathetic message), so replies feel
personal and varied;
- CODE owns every FACT. The ticket number is generated by the DB and passed in;
the LLM is told to include it verbatim, and we VERIFY it did. The model never
invents a ticket number, a status, a timeline, or a dollar amount.
Every draft passes a validator before it reaches a customer:
- within a length cap (no rambling),
- for an apology, the exact ticket id MUST appear,
- no fabricated specifics (digits other than the ticket id, URLs, '$').
If the draft fails validation OR the model is unavailable OR LLM responses are
turned off, we fall back to the approved template. So the worst case is the
original safe behaviour — never a crash, never an unreviewed claim.
Status updates and escalation messages are deliberately NOT generated here — those
are pure facts/process and stay templated in config.py.
"""
import re
import config
import llm
from config import (
DEFAULT_CUSTOMER_NAME,
TEMPLATE_NEGATIVE,
TEMPLATE_POSITIVE,
)
_RESPONDER_SYSTEM = """You write short, warm replies for a bank's customer support.
Rules you must follow exactly:
- 1 to 2 sentences, under 60 words. Plain text only.
- Be genuinely empathetic and human, not corporate or robotic.
- Include ONLY the facts you are given. Do NOT invent ticket numbers, statuses,
timelines, amounts, dates, or links.
- If given a ticket number, include it exactly as written, with a # in front.
- Never promise a specific resolution time beyond "shortly"/"soon"."""
_responder_llm_singleton = None
def _responder_llm():
global _responder_llm_singleton
if _responder_llm_singleton is None:
# Slight temperature for warmth; timeout/retries from the central client.
_responder_llm_singleton = llm.chat_model(120, temperature=0.4)
return _responder_llm_singleton
def _draft(prompt: str, _llm=None) -> str | None:
"""Ask the model for a reply; return the text or None on any failure / when off."""
if not config.USE_LLM_RESPONSES and _llm is None:
return None
try:
client = _llm if _llm is not None else _responder_llm()
msg = client.invoke([("system", _RESPONDER_SYSTEM), ("human", prompt)])
return llm.extract_text(getattr(msg, "content", "")).strip()
except Exception:
return None
# Commitments the model can't actually keep and code can't verify. In a banking
# reply, "a senior manager will personally call you tomorrow" or "we'll refund you"
# are liabilities — if a draft makes one, we reject it and ship the approved
# template instead. ("follow up shortly", which the template itself uses, is fine.)
_PROMISE_RE = re.compile(
r"\b(guarantee|promise|personally|manager|refund|compensat|reimburs|waive|"
r"credit (your|you)|call you|phone you|next (week|day|business day)|tomorrow|"
r"close of business|\bcob\b|by (the )?end of|by (monday|tuesday|wednesday|thursday|friday)|"
r"within (a|an|the|\d+)\s*(hour|day|week|month|business))",
re.IGNORECASE,
)
def _is_safe(text: str, allowed_digits: str | None = None) -> bool:
"""Reject drafts that ramble or smuggle in fabricated specifics.
allowed_digits: the one numeric token permitted (the ticket id). Any OTHER run
of digits, a URL, a '$', or a promise/timeline the system can't honor -> reject.
"""
if not text or len(text) > config.RESPONSE_MAX_CHARS:
return False
if "$" in text or "http" in text.lower():
return False
if _PROMISE_RE.search(text): # invented commitment -> fall back to the template
return False
numbers = re.findall(r"\d+", text)
allowed = {allowed_digits} if allowed_digits else set()
return all(n in allowed for n in numbers)
def generate_thankyou(customer_name: str | None, _llm=None) -> str:
"""Warm thank-you for positive feedback. LLM-drafted, template fallback."""
name = customer_name or DEFAULT_CUSTOMER_NAME
draft = _draft(f"Thank a happy customer named {name} for their kind feedback.", _llm)
if draft and _is_safe(draft):
return draft
return TEMPLATE_POSITIVE.format(customer_name=name)
def generate_apology(customer_name: str | None, ticket_id: str, _llm=None) -> str:
"""Empathetic apology for negative feedback that cites the (code-supplied)
ticket id. LLM-drafted, but the id MUST appear or we fall back to the template."""
name = customer_name or DEFAULT_CUSTOMER_NAME
draft = _draft(
f"Apologize empathetically to {name} for a banking problem and tell them a "
f"support ticket #{ticket_id} has been created and the team will follow up. "
f"Include the exact ticket number #{ticket_id}.",
_llm,
)
if draft and _is_safe(draft, allowed_digits=ticket_id) and ticket_id in draft:
return draft
return TEMPLATE_NEGATIVE.format(customer_name=name, ticket_id=ticket_id)