[verified] harden Sales Trainer and add PostgreSQL foundation

This commit is contained in:
Macky
2026-08-16 02:41:10 +07:00
parent 6811dc1db9
commit dbfce9aa54
179 changed files with 19955 additions and 1505 deletions

View File

@@ -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 (