[verified] harden Sales Trainer and add PostgreSQL foundation
This commit is contained in:
@@ -11,6 +11,8 @@ from typing import Any
|
||||
|
||||
from ..llm import LLMClient, LLMError
|
||||
|
||||
MAX_PERSONA_REPLY_CHARS = 4000
|
||||
|
||||
CHAT_SYSTEM = """You are playing a REALISTIC customer named {name} in a sales-training chat.
|
||||
Stay perfectly in character at ALL times. Use {tone}.
|
||||
|
||||
@@ -24,8 +26,12 @@ naturally and it makes sense for a real customer to reveal them):
|
||||
- Your pains (some may be product-solvable, some NOT): {pains}
|
||||
- Your negotiation levers: {levers}
|
||||
- Your goal/mood: {goal}
|
||||
- Tier: {tier} (A = ready, B = researching/unsure, C = resistant but has a real pain)
|
||||
- Your tolerance (TOLERANCE): you walk away after about {tolerance} irritating/off-point/pushy
|
||||
answers. If the seller is repeatedly wrong, ignores your need, or is pushy, you feel fed up.
|
||||
- Recontact lead: {recontact}. If true, you have prior context with this seller and may be warmer
|
||||
after the time-lapse note, but still require the real pain to be handled.
|
||||
Special flag: {special}.
|
||||
Initiation mode: {init_mode}. {special_instr}
|
||||
|
||||
BEHAVIOR RULES:
|
||||
@@ -61,12 +67,10 @@ REALISM RULES:
|
||||
it's a WIN. Do NOT auto-fail on special cases — always reward genuinely skillful recovery.
|
||||
- LOST reflects the persona TYPICALLY losing (real-world >90% of such leads do not convert), but the
|
||||
trainee's skill evaluation must remain fair: a strong close beats a weak one, always.
|
||||
- If the chat drags on many turns (or turns > ~12) without the seller reaching the pain or closing,
|
||||
treat it as LOST due to failing to convert / the opportunity cooling (mirrors real leads going cold).
|
||||
- If the seller was pushy, rude, ignored the need, or mis-diagnosed the pain, mark LOST even if the
|
||||
price was acceptable.
|
||||
|
||||
Scoring (0-100): painResolution + trust + objectionHandling + efficiency (fewer turns, higher).
|
||||
Scoring (0-100): painResolution + trust + objectionHandling + quality of the close.
|
||||
Return JSON:
|
||||
{
|
||||
"outcome": "won" | "lost",
|
||||
@@ -119,9 +123,12 @@ class Simulator:
|
||||
budget=persona.get("budget", ""),
|
||||
decision_timeline=persona.get("decision_timeline", ""),
|
||||
pains=pains_txt,
|
||||
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
|
||||
levers=self._describe_levers(persona.get("negotiation_levers", [])),
|
||||
goal=persona.get("goal", ""),
|
||||
tier=persona.get("tier", "B"),
|
||||
tolerance=tolerance,
|
||||
recontact="yes" if persona.get("recontact") else "no",
|
||||
special=persona.get("special", ""),
|
||||
init_mode="you contacted the seller first (customer-initiated)"
|
||||
if persona.get("initiation_mode") == "customer"
|
||||
else "the seller opened the sale to you (you are a lead)",
|
||||
@@ -139,14 +146,17 @@ class Simulator:
|
||||
msgs.append({"role": "system", "content": f"[scene note from transcript]: {m.get('text')}"})
|
||||
else:
|
||||
msgs.append({"role": role, "content": m.get("text", "")})
|
||||
try:
|
||||
for attempt in range(2):
|
||||
if attempt:
|
||||
msgs.append({
|
||||
"role": "system",
|
||||
"content": "Contract correction: output one JSON object with a non-empty string field named reply. Do not include analysis or extra prose.",
|
||||
})
|
||||
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
||||
except LLMError as exc:
|
||||
raise
|
||||
# Return the customer's reply as plain text (no forced JSON) — a natural sentence is
|
||||
# the persona's message. Mood/decision are evaluated separately by evaluate_turn().
|
||||
reply = resp.strip() if resp and resp.strip() else "(ลูกค้ายังไม่ตอบ)"
|
||||
return reply, {"decision": "none", "mood": 0}
|
||||
parsed = self._parse_persona_reply(resp)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
raise LLMError("persona reply was empty or missing reply field")
|
||||
|
||||
def evaluate_turn(self, *, persona, messages, internal=None) -> dict[str, Any]:
|
||||
"""Per-turn state evaluation: how the persona feels + whether it has decided.
|
||||
@@ -193,6 +203,8 @@ class Simulator:
|
||||
except LLMError:
|
||||
# judge unavailable — fall back to pending (don't crash, don't misuse keywords)
|
||||
return {"mood": 0, "decision": "pending", "score_delta": 0, "reason": ""}
|
||||
if not isinstance(result, dict):
|
||||
return {"mood": 0, "decision": "pending", "score_delta": 0, "reason": ""}
|
||||
result.setdefault("mood", 0)
|
||||
result.setdefault("decision", "pending")
|
||||
result.setdefault("score_delta", 0)
|
||||
@@ -224,7 +236,7 @@ class Simulator:
|
||||
try:
|
||||
state_note = (
|
||||
"\n\nINTERNAL (hidden, for judging only): "
|
||||
f"turns={internal.get('turns', 0)}, score_trend={internal.get('score', 50)}"
|
||||
f"score_trend={internal.get('score', 50)}"
|
||||
)
|
||||
except Exception:
|
||||
state_note = ""
|
||||
@@ -235,6 +247,8 @@ class Simulator:
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
result.setdefault("outcome", "lost")
|
||||
result.setdefault("score", 0)
|
||||
result.setdefault("pain", "")
|
||||
@@ -259,6 +273,66 @@ class Simulator:
|
||||
out.append(str(p))
|
||||
return "; ".join(out)
|
||||
|
||||
def _describe_levers(self, levers: object) -> str:
|
||||
if not isinstance(levers, list):
|
||||
return "price, delivery time"
|
||||
values = []
|
||||
for lever in levers[:20]:
|
||||
if isinstance(lever, str) and lever.strip():
|
||||
values.append(lever.strip())
|
||||
elif isinstance(lever, dict):
|
||||
label = lever.get("name") or lever.get("label") or lever.get("description")
|
||||
if isinstance(label, str) and label.strip():
|
||||
values.append(label.strip())
|
||||
return ", ".join(values) or "price, delivery time"
|
||||
|
||||
def _parse_persona_reply(self, text: object) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Parse provider output without leaking protocol fields to the bubble.
|
||||
|
||||
The preferred contract is a JSON object. A plain natural-language
|
||||
response remains a safe compatibility fallback for providers that ignore
|
||||
JSON mode. Objects without a usable ``reply`` are retryable; they are
|
||||
never rendered verbatim.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
raw = text.strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
payload: Any = None
|
||||
candidates = [raw]
|
||||
extracted = self._extract_json(raw)
|
||||
if extracted != raw:
|
||||
candidates.append(extracted)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
payload = json.loads(candidate)
|
||||
break
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
if isinstance(payload, dict):
|
||||
reply = payload.get("reply")
|
||||
if not isinstance(reply, str) or not reply.strip():
|
||||
return None
|
||||
decision = payload.get("decision")
|
||||
if decision not in ("none", "buy", "walk"):
|
||||
decision = "none"
|
||||
try:
|
||||
mood = max(-2, min(2, int(payload.get("mood", 0))))
|
||||
except (TypeError, ValueError):
|
||||
mood = 0
|
||||
return reply.strip()[:MAX_PERSONA_REPLY_CHARS], {
|
||||
"decision": decision,
|
||||
"mood": mood,
|
||||
}
|
||||
|
||||
# A plain sentence is safe; protocol-looking malformed JSON is not.
|
||||
if raw.startswith(("{", "[", "```")):
|
||||
return None
|
||||
return raw[:MAX_PERSONA_REPLY_CHARS], {"decision": "none", "mood": 0}
|
||||
|
||||
def _special_instr(self, persona: dict[str, Any]) -> str:
|
||||
if persona.get("special") == "wrong_text":
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user