Sales Trainer v0.1: corporate sales-training simulator (Flask+Vue, 15 personas, chat simulator, judge, analytics)

- Auth/roles (no self-reg), admin user provision, JWT
- Analyze: sales kit + initial pain-fit from form/upload
- Persona generator: 15 personas (5/tier) w/ pain variety, negotiation, init mode, channel, latent/revealable, wrong_text special
- Chat simulator: per-mode initiation, one-shot, hidden signals, judge-LLM debrief+coaching
- Trainee loop: win/lose board, weak-areas, user-generated personas
- Admin analytics; EN+TH Vue SPA served by Flask
- Deploy: Dockerfile, docker-compose, README, eng-log + HANDOFF
- Tests (mock LLM): m0/m1/routes/e2e all pass
This commit is contained in:
Macky
2026-08-07 15:31:06 +07:00
commit c3d31c06e2
70 changed files with 6135 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Service layer."""

View File

@@ -0,0 +1,96 @@
"""Analyzer: extracts a Sales Kit (product facts) + initial pain-fit from inputs.
Product data is used primarily to derive pains that persona generation can build
against. The result also carries a `scenario` prompt that frames persona creation.
"""
from __future__ import annotations
from typing import Any
from ..llm import LLMClient
SALES_KIT_SYSTEM = """You are an expert ecommerce/B2B analyst. Given product information
(typed in a form and/or extracted from uploaded files), produce a structured Sales Kit.
Rules:
- Output ONLY valid JSON with the exact keys requested.
- Pain-fit: judge which pains / customer pain categories the product can PLAUSIBLY solve,
and clearly distinguish "strong fit" from "partial / weak fit".
- The product info is only initial grounding; personas may be reused across similar products.
- If some fields are unknown, leave them as empty lists / empty strings (never invent specifics).
Output schema:
{
"productName": string,
"category": string,
"valueProps": [string],
"features": [string],
"pricingAnchors": [string],
"targetAudience": { "segment": string, "demographics": string, "useCases": [string] },
"objectionHandlers": [string],
"initialPainFit": [
{ "pain": string, "fit": "strong"|"partial"|"weak", "evidence": string }
],
"scenarioFrame": string
}
The scenarioFrame is a one-paragraph description of the selling situation (who the seller,
what channel, target segment) that will frame persona creation.
"""
class Analyzer:
def __init__(self, llm: LLMClient) -> None:
self.llm = llm
def analyze(
self,
*,
product: str = "",
segment: str = "",
description: str = "",
file_text: str = "",
channel: str = "facebook",
) -> dict[str, Any]:
# Build the merged product context (form wins over file text)
product_src = product.strip() or file_text.strip() or ""
context = (
f"PRODUCT (form/typed):\n{product}\n\n" if product.strip() else ""
)
if segment.strip():
context += f"INITIAL CUSTOMER SEGMENT:\n{segment}\n\n"
if description.strip():
context += f"ADDITIONAL DESCRIPTION / SCENARIO:\n{description}\n\n"
if file_text.strip():
context += f"UPLOADED FILE CONTENT:\n{file_text[:12000]}\n"
if not context.strip():
raise ValueError("no product information provided (form or file)")
user_prompt = (
f"Channel: {channel}\n\n"
f"Analyze the following and return the Sales Kit JSON:\n\n{context}"
)
result = self.llm.complete_json(
SALES_KIT_SYSTEM, user_prompt, temperature=0.2, max_tokens=5000
)
# Normalize shape defensively
result.setdefault("productName", product_src[:200] or "Untitled product")
result.setdefault("category", "")
result.setdefault("valueProps", [])
result.setdefault("features", [])
result.setdefault("pricingAnchors", [])
result.setdefault("targetAudience", {
"segment": segment or "",
"demographics": "",
"useCases": [],
})
result.setdefault("objectionHandlers", [])
result.setdefault("initialPainFit", [])
result.setdefault("scenarioFrame", description or "")
for k in ("valueProps", "features", "pricingAnchors", "objectionHandlers"):
if not isinstance(result[k], list):
result[k] = []
if not isinstance(result.get("initialPainFit"), list):
result["initialPainFit"] = []
return result

View File

@@ -0,0 +1,46 @@
"""File parsing for uploaded documents (pdf / markdown / txt)."""
from __future__ import annotations
from pathlib import Path
class ParseError(Exception):
pass
def parse_pdf(path: Path) -> str:
import fitz # PyMuPDF
try:
doc = fitz.open(path)
except Exception as exc:
raise ParseError(f"cannot open PDF: {exc}") from exc
parts = []
for page in doc:
parts.append(page.get_text())
doc.close()
return "\n".join(parts)
def parse_text(path: Path) -> str:
import chardet
raw = path.read_bytes()
# Try utf-8 first, else detect encoding
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
guess = chardet.detect(raw)
enc = guess.get("encoding") or "utf-8"
try:
return raw.decode(enc, errors="replace")
except Exception:
return raw.decode("utf-8", errors="replace")
def parse_document(path: Path) -> str:
ext = path.suffix.lower().lstrip(".")
if ext == "pdf":
return parse_pdf(path)
return parse_text(path)

View File

@@ -0,0 +1,97 @@
"""Persona group store: groups hold a sale kit + personas + report.
A group is created by an admin from a setup form + optional files. After analyze,
it contains `personas` (15 by default = 5 per tier) and a `report`. Groups are
editable/re-analyzeable by admins. Trainees only read revealable views of personas
and run one-shot sessions (sessions are stored separately).
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
from .store import ensure_persona_shape
DEFAULT_TIERS = ["A", "B", "C"]
PERSONAS_PER_TIER = 5
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
class GroupStore:
def __init__(self, data_dir: Path) -> None:
self.groups = JsonStore(data_dir / "groups")
def create(self, *, org_id: str, creator_id: str, title: str) -> dict[str, Any]:
gid = new_id("group")
group = {
"id": gid,
"org_id": org_id,
"creator_id": creator_id,
"title": title or "Untitled group",
"status": "draft", # draft -> analyzing -> ready | failed
"created_at": _now(),
"updated_at": _now(),
"input": {}, # form fields
"sales_kit": None,
"personas": [], # full persona dicts
"report": None,
"error": None,
}
return self.groups.create(group, key=gid)
def get(self, gid: str) -> dict[str, Any]:
return self.groups.get(gid)
def get_or_none(self, gid: str) -> dict[str, Any] | None:
return self.groups.get_or_none(gid)
def update(self, gid: str, **fields: Any) -> dict[str, Any]:
fields.setdefault("updated_at", _now())
return self.groups.update(gid, **fields)
def list_for_org(self, org_id: str | None = None) -> list[dict[str, Any]]:
groups = self.groups.all()
if org_id:
groups = [g for g in groups if g.get("org_id") == org_id]
return groups
def list_visible_to(
self, *, role: str, org_id: str | None = None
) -> list[dict[str, Any]]:
"""List groups a given role/user can see. Trainees see only ready groups."""
groups = self.groups.all()
if org_id:
groups = [g for g in groups if g.get("org_id") == org_id]
if role == "user":
groups = [g for g in groups if g.get("status") == "ready"]
return groups
# ── personas ────────────────────────────────────────────────────────
def set_personas(self, gid: str, personas: list[dict[str, Any]]) -> dict[str, Any]:
personas = [ensure_persona_shape(p) for p in personas]
return self.groups.update(gid, personas=personas)
def get_persona(self, gid: str, pid: str) -> dict[str, Any] | None:
group = self.get(gid)
for p in group.get("personas", []):
if p.get("id") == pid:
return p
return None
def update_persona(self, gid: str, pid: str, patch: dict[str, Any]) -> dict[str, Any]:
group = self.get(gid)
found = False
for i, p in enumerate(group.get("personas", [])):
if p.get("id") == pid:
merged = {**p, **patch, "id": pid}
group["personas"][i] = ensure_persona_shape(merged)
found = True
break
if not found:
raise ValueError("persona not found")
return self.groups.replace(gid, group)

View File

@@ -0,0 +1,42 @@
"""Generate a user's own persona (private) from weak-area spec or a manual form."""
from __future__ import annotations
from typing import Any
from ..llm import LLMClient
OWN_PERSONA_SYSTEM = """You generate ONE customer persona for a sales-training simulator,
PRIVATE to a specific trainee. You produce valid JSON only: {"persona": { ... }}.
The persona dict must contain: name, tier, channel, initiation_mode, profession, age_group,
location, product_context (revealable), plus background, income, lifestyle, personality,
communication_style, budget, decision_timeline, goal, objections[], pains[] (with fit + rootCause
+ resolutionConditions), negotiation_levers[], opener, difficulty, special, notes.
The trainee wants to specifically practice against the described weakness/profile, so make this
persona HARD in exactly that dimension (e.g. heavy price negotiation, seller-initiated cold lead,
skeptical). Keep pains partially product-solvable for realism.
"""
def build_own_persona_user_prompt(*, mode: str, spec: dict[str, Any]) -> str:
if mode == "weak-area":
return (
"Mode: WEAK-AREA 'lock' persona. Generate a persona specifically targeting the "
"trainee's reported weaknesses:\n" + str(spec)
)
return "Mode: MANUAL. Generate a persona matching the trainee's description:\n" + str(spec)
def generate_own_persona(llm: LLMClient, *, mode: str, spec: dict[str, Any]) -> dict[str, Any]:
user_prompt = build_own_persona_user_prompt(mode=mode, spec=spec)
result = llm.complete_json(OWN_PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=7000)
persona = result.get("persona") or result
if not isinstance(persona, dict):
raise ValueError("own-persona generator returned invalid data")
persona.setdefault("tier", "B")
persona.setdefault("channel", "facebook")
persona.setdefault("initiation_mode", "customer")
persona.setdefault("pains", [])
persona.setdefault("negotiation_levers", [])
return persona

View File

@@ -0,0 +1,74 @@
"""Persona generator: builds 15 personas (5 per tier) from a Sales Kit + scenario."""
from __future__ import annotations
import json
from typing import Any
from ..llm import LLMClient
from .persona_prompts import PERSONA_SYSTEM
TIERS = ["A", "B", "C"]
PER_TIER = 5
class PersonaGenerator:
def __init__(self, llm: LLMClient) -> None:
self.llm = llm
def generate(
self,
*,
sales_kit: dict[str, Any],
language: str = "en",
channel: str = "facebook",
) -> list[dict[str, Any]]:
kit_json = json.dumps(sales_kit, ensure_ascii=False)[:12000]
lang_name = "Thai" if language == "th" else "English"
scenario = (sales_kit.get("scenarioFrame") or "").strip() or "a general product sale"
user_prompt = (
f"Platform/channel preference: {channel}\n"
f"Language: {lang_name} (all persona text in {lang_name})\n"
f"Sales Kit:\n{kit_json}\n\n"
f"Generate exactly 15 personas (5 per tier A/B/C) as JSON."
)
result = self.llm.complete_json(
PERSONA_SYSTEM, user_prompt, temperature=0.8, max_tokens=14000
)
personas = result.get("personas") or []
if not isinstance(personas, list) or not personas:
raise ValueError("persona generator returned no personas")
normalized, counts = [], {"A": 0, "B": 0, "C": 0}
for idx, p in enumerate(personas, start=1):
if not isinstance(p, dict):
continue
tier = p.get("tier", p.get("intent_tier"))
if tier not in TIERS:
tier = "B"
if counts[tier] >= PER_TIER:
continue # skip overflow per tier
counts[tier] += 1
p["id"] = f"persona-{idx:02d}"
p["tier"] = tier
p["channel"] = p.get("channel", channel)
p.setdefault("initiation_mode", "customer")
p.setdefault("special", "")
p.setdefault("difficulty", 1)
p.setdefault("pains", [])
p.setdefault("negotiation_levers", [])
p.setdefault("objections", [])
normalized.append(p)
# Wrap tier-C: ensure at least one wrong_text persona
if "C" in counts and not any(
p.get("special") == "wrong_text" for p in normalized
):
# find first tier-C and mark it
for p in normalized:
if p["tier"] == "C":
p["special"] = "wrong_text"
break
if len(normalized) < 15:
raise ValueError(f"expected 15 personas, generated {len(normalized)}")
return normalized

View File

@@ -0,0 +1,43 @@
"""Persona generation prompts (system + output schema instructions)."""
from __future__ import annotations
PERSONA_SYSTEM = """You are a world-class market-research persona designer for a sales-training
simulator. Given a Sales Kit (product facts + initial pain-fit) and a scenario frame, you generate
REALISTIC customer personas that a trainee will chat with to practice closing a sale.
Generate exactly 15 personas = 5 in tier A + 5 in tier B + 5 in tier C.
TIER MEANING:
- A = Ready to buy (has budget+authority+urgency, but still expects fit confirmation & handles 1-2
objections; can still WALK AWAY if the seller is rude or clearly wrong).
- B = Unsure / educating (researching; needs discovery, trust, proof, reason-to-act-now; stalls easily).
- C = Not interested but has pain (resistant, unaware/skeptical/budget-constrained, BUT has a real
unresolved pain; the ONLY path to close is surfacing and resolving it).
EACH persona MUST include ALL of these fields:
- name, tier, channel, initiation_mode
- profession, age_group, location, product_context (REVEALABLE - what a real seller could know)
- background, income, lifestyle, personality, communication_style (LATENT)
- budget, decision_timeline, goal, objections[] (LATENT)
- pains[] (LATENT)
- negotiation_levers[] (LATENT)
- opener, special, difficulty, notes
RULES:
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
personalities. Consistent with the product's target audience + scenario frame.
2. PAIN VARIETY: most pains do NOT map 1:1 to the product. Include pains the product solves
DIRECTLY (fit=strong), some only PARTIALLY solve (fit=partial), and some UNRELATED (fit=weak /
red herring). For each pain give: id, name, fit, description, rootCause, and resolutionConditions[]
(what the seller must satisfy to resolve it).
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
4. INITIATION MODE: pick per persona "customer" (they message first) or "seller" (seller must open
the sale - e.g. insurance/proactive). You may mix, but every persona picks one.
5. CHANNEL: "facebook" or "line".
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.
7. difficulty 1-5. special="" unless wrong_text.
8. Language: output all human text in the requested language.
Only output valid JSON: {"personas": [ ... ]}
"""

View File

@@ -0,0 +1,92 @@
"""Report builder: assemble a human-readable analysis report from sales kit + personas."""
from __future__ import annotations
from typing import Any
TIER_NAMES = {
"A": ("Ready to buy", "ตั้งใจซื้อ"),
"B": ("Unsure / educating", "ไม่แน่ใจ"),
"C": ("Not interested but has pain", "ไม่สนใจแต่มี pain"),
}
def build_report(*, sales_kit: dict[str, Any], personas: list[dict[str, Any]], language: str = "th") -> dict[str, Any]:
thai = language == "th"
tiers: dict[str, list[dict[str, Any]]] = {"A": [], "B": [], "C": []}
for p in personas:
tiers.get(p.get("tier", "B"), []).append(p)
sections = []
sections.append({
"title": "Sales Kit / ข้อมูลสินค้า" if thai else "Sales Kit",
"content": _render_sales_kit(sales_kit, thai),
})
for tier, personas_list in tiers.items():
label = TIER_NAMES[tier][1 if thai else 0]
sections.append({
"title": f"Tier {tier}{label}",
"content": _render_tier(personas_list, thai),
})
return {
"title": f"{sales_kit.get('productName', 'Product')} — Sales Training Analysis",
"summary": "Customer personas + pain analysis for sales training.",
"language": language,
"sections": sections,
"raw_personas": personas,
}
def _render_sales_kit(kit: dict[str, Any], thai: bool) -> str:
lines = []
lines.append(f"**{'สินค้า' if thai else 'Product'}:** {kit.get('productName', '-')}")
if kit.get("category"):
lines.append(f"**{'หมวดหมู่' if thai else 'Category'}:** {kit['category']}")
if kit.get("valueProps"):
lines.append(f"**{'คุณค่า' if thai else 'Value props'}:** " + "; ".join(kit["valueProps"]))
if kit.get("features"):
lines.append(f"**{'ฟีเจอร์' if thai else 'Features'}:** " + "; ".join(kit["features"]))
if kit.get("pricingAnchors"):
lines.append(f"**{'ราคา' if thai else 'Pricing'}:** " + "; ".join(kit["pricingAnchors"]))
ta = kit.get("targetAudience") or {}
if ta.get("segment"):
lines.append(f"**{'กลุ่มเป้าหมาย' if thai else 'Target segment'}:** {ta['segment']}")
if kit.get("initialPainFit"):
lines.append(f"**{'Pain ที่สินค้าแก้ได้เบื้องต้น' if thai else 'Initial pain-fit'}:**")
for p in kit["initialPainFit"]:
lines.append(f"- ({p.get('fit', '?')}) {p.get('pain', '')}")
return "\n".join(lines)
def _render_tier(personas: list[dict[str, Any]], thai: bool) -> str:
if not personas:
return "_" + ("ไม่มี" if thai else "none") + "_"
blocks = []
for p in personas:
blocks.append(_render_persona(p, thai))
return "\n\n---\n\n".join(blocks)
def _render_persona(p: dict[str, Any], thai: bool) -> str:
lines = [f"### {p.get('name', '-')} (difficulty {p.get('difficulty', 1)})"]
lines.append(f"- {'อาชีพ' if thai else 'Profession'}: {p.get('profession', '-')} | "
f"{'อายุ' if thai else 'Age'}: {p.get('age_group', '-')} | "
f"{'ช่องทาง' if thai else 'Channel'}: {p.get('channel', 'facebook')} | "
f"{'เปิดบท' if thai else 'Initiation'}: {p.get('initiation_mode', 'customer')}")
if p.get("special"):
lines.append(f"- SPECIAL: {p['special']}")
lines.append(f"- {'พื้นหลัง' if thai else 'Background'}: {p.get('background', '-')}")
lines.append(f"- {'รายได้' if thai else 'Income'}: {p.get('income', '-')} | "
f"{'ไลฟ์สไตล์' if thai else 'Lifestyle'}: {p.get('lifestyle', '-')}")
lines.append(f"- {'นิสัย' if thai else 'Personality'}: {p.get('personality', '-')}")
if p.get("pains"):
lines.append(f"- {'Pain points (latent)' if thai else 'Pains (latent)'}:")
for pain in p.get("pains", []):
conds = "; ".join(pain.get("resolutionConditions", [])) if isinstance(pain, dict) else ""
lines.append(f" - [{pain.get('fit', '?') if isinstance(pain, dict) else '?'}] "
f"{pain.get('name', pain) if isinstance(pain, dict) else pain}"
f"{' — resolve: ' + conds if conds else ''}")
if p.get("negotiation_levers"):
levers = p.get("negotiation_levers") or []
lines.append(f"- {'ต่อรอง' if thai else 'Negotiation levers'}: " + ", ".join(str(x) for x in levers))
return "\n".join(lines)

View File

@@ -0,0 +1,81 @@
"""Training session store.
A session = one trainee's one-shot chat attempt against one persona. It records the
full transcript + internal state + outcome + debrief. One user may have at most one
session per persona (one-shot rule), enforced here.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
class SessionStore:
def __init__(self, data_dir: Path) -> None:
self.sessions = JsonStore(data_dir / "sessions")
def create(
self,
*,
user_id: str,
group_id: str,
persona_id: str,
persona_name: str,
persona_meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
# One-shot: reject if the user already has a finished session on this persona
existing = self.sessions.where(
lambda r: r.get("user_id") == user_id
and r.get("persona_id") == persona_id
and r.get("outcome") in ("won", "lost")
)
if existing:
raise ValueError("you have already trained on this persona (one-shot)")
sid = new_id("session")
session = {
"id": sid,
"user_id": user_id,
"group_id": group_id,
"persona_id": persona_id,
"persona_name": persona_name,
"persona_meta": persona_meta or {},
"status": "active", # active | finished
"outcome": None, # won | lost | abandoned
"messages": [], # [{role, text, ts}]
"internal": {"trust": 50, "pain_progress": {}, "buying_signals": [], "tier": None},
"debrief": None,
"created_at": _now(),
"updated_at": _now(),
}
return self.sessions.create(session, key=sid)
def get(self, sid: str) -> dict[str, Any]:
return self.sessions.get(sid)
def get_or_none(self, sid: str) -> dict[str, Any] | None:
return self.sessions.get_or_none(sid)
def update(self, sid: str, **fields: Any) -> dict[str, Any]:
fields.setdefault("updated_at", _now())
return self.sessions.update(sid, **fields)
def active_for_persona(self, user_id: str, persona_id: str) -> dict[str, Any] | None:
hits = self.sessions.where(
lambda r: r.get("user_id") == user_id
and r.get("persona_id") == persona_id
and r.get("status") == "active"
)
return hits[0] if hits else None
def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
return sorted(
self.sessions.where(lambda r: r.get("user_id") == user_id),
key=lambda r: r.get("created_at", ""),
)

View File

@@ -0,0 +1,186 @@
"""Sales chat simulator: the trainee's chat engine against one persona.
Reuses the persona card + sales kit + chat history + internal state. A separate
judge-LLM decides outcome (won/lost) + scoring + coaching. Hidden/latent data is
never exposed mid-chat. Initiation is per-persona (customer or seller).
"""
from __future__ import annotations
import json
from typing import Any
from ..llm import LLMClient, LLMError
CHAT_SYSTEM = """You are playing a REALISTIC customer named {name} in a sales-training chat.
Stay perfectly in character at ALL times. Use {tone}.
CONTEXT ABOUT YOU (USE THIS — it is your truth, but DO NOT reveal latent details unless asked
naturally and it makes sense for a real customer to reveal them):
- Profession: {profession} | Age: {age_group} | Channel: {channel}
- Background: {background}
- Personality: {personality}
- Lifestyle: {lifestyle} | Income: {income}
- Budget: {budget} | Decision timeline: {decision_timeline}
- Your pains (some may be product-solvable, some NOT): {pains}
- Your negotiation levers: {levers}
- Your goal/mood: {goal}
Initiation mode: {init_mode}. {special_instr}
BEHAVIOR RULES:
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies,
delivery time, scope, payment).
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops
and you may refuse to continue / walk away — even if you wanted the product.
3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your
pains unprompted.
4. Respond in natural, in-character chat style ({channel} style, casual for LINE).
5. Stay in character; never mention that you are a simulation or an AI persona.
Reply with a JSON object: {{"reply": "<your message>"}}
Only output that JSON.
"""
JUDGE_SYSTEM = """You are the JUDGE of a sales-training chat. Decide the outcome and score it.
A sale is CLOSED only if BOTH:
1. The seller resolved the customer's real pain(s) (the conditions that matter to this persona),
AND
2. The customer verbally accepts the offer/price (in the final exchange).
Otherwise it is LOST (or abandoned if the user ended early).
Scoring (0-100): painResolution + trust + objectionHandling are the only factors.
Return JSON:
{
"outcome": "won" | "lost",
"score": 0-100,
"pain": "the persona's key pain",
"why": "brief reason for won/lost",
"failurePoints": ["what went wrong, or []"],
"coaching": ["for each weak point, a concrete 'you should have said/asked this instead']",
"painProgress": {"painName": 0-100}
}
"""
class Simulator:
def __init__(self, llm: LLMClient, judge_llm: LLMClient | None = None) -> None:
self.llm = llm
self.judge_llm = judge_llm or llm
# ── persona reply ──────────────────────────────────────────────────
def persona_reply(
self,
*,
persona: dict[str, Any],
sales_kit: dict[str, Any],
messages: list[dict[str, str]],
internal: dict[str, Any],
) -> str:
pains_txt = self._describe_pains(persona.get("pains", []))
system = CHAT_SYSTEM.format(
name=persona.get("name", "Customer"),
tone=persona.get("communication_style", "natural, casual"),
profession=persona.get("profession", "customer"),
age_group=persona.get("age_group", "adult"),
channel=persona.get("channel", "facebook"),
background=persona.get("background", ""),
personality=persona.get("personality", ""),
lifestyle=persona.get("lifestyle", ""),
income=persona.get("income", ""),
budget=persona.get("budget", ""),
decision_timeline=persona.get("decision_timeline", ""),
pains=pains_txt,
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
goal=persona.get("goal", ""),
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)",
special_instr=self._special_instr(persona),
)
msgs = [{"role": "system", "content": system}]
# send a compact recap of internal state to the persona ad
# (doesn't leak to trainee)
msgs.append({
"role": "system",
"content": "Internal state (for your role-play only): "
+ json.dumps(internal, ensure_ascii=False),
})
msgs.extend(messages[-30:]) # context window
try:
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
except LLMError as exc:
raise
# extract {reply: ...}
try:
data = json.loads(self._extract_json(resp))
reply = data.get("reply") or data.get("response") or str(resp)
except Exception:
reply = resp
return reply.strip()
# ── judge ──────────────────────────────────────────────────────────
def judge(
self,
*,
persona: dict[str, Any],
messages: list[dict[str, str]],
) -> dict[str, Any]:
persona_summary = json.dumps({
"name": persona.get("name"),
"pains": persona.get("pains", []),
"budget": persona.get("budget"),
"negotiation_levers": persona.get("negotiation_levers"),
"special": persona.get("special"),
}, ensure_ascii=False)
transcript = "\n".join(
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
)
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}"
try:
result = self.judge_llm.complete_json(
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
)
except LLMError as exc:
raise
result.setdefault("outcome", "lost")
result.setdefault("score", 0)
result.setdefault("pain", "")
result.setdefault("why", "")
result.setdefault("failurePoints", [])
result.setdefault("coaching", [])
result.setdefault("painProgress", {})
return result
# ── helpers ────────────────────────────────────────────────────────
def _describe_pains(self, pains: list[Any]) -> str:
if not pains:
return "(you have some personal frustrations, but the seller must find out)"
out = []
for p in pains:
if isinstance(p, dict):
out.append(
f"{p.get('name','pain')} (fit={p.get('fit','?')}): {p.get('description','')} "
f"root={p.get('rootCause','')}"
)
else:
out.append(str(p))
return "; ".join(out)
def _special_instr(self, persona: dict[str, Any]) -> str:
if persona.get("special") == "wrong_text":
return (
"SPECIAL: You opened as if ready to buy, but the moment the seller replies you act "
"disinterested and try to end the chat (e.g. 'never mind, forget it'). Deep down your "
"pain is still real. A seller who gently re-engages without pushing may earn a second "
"chance; a pushy seller drives you away for good."
)
return ""
def _extract_json(self, text: str) -> str:
text = text.strip()
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return text

View File

@@ -0,0 +1,75 @@
"""Persona data model + shape normalization.
A persona has a canonical schema. Fields are split into:
- revealable: shown to trainees up front (what a real seller could plausibly know)
- latent: hidden until the conversation ends (pain, income, personality, budget,
negotiation levers, hidden opener, etc.)
Every persona also carries an `intent_tier` (A/B/C), an `initiation_mode`
(customer/seller), a `channel` (facebook/line), a set of `pains` with resolution
conditions, `negotiation_levers`, and optional `special` flags (e.g. wrong_text).
"""
from __future__ import annotations
from typing import Any
DEFAULT_TIERS = ["A", "B", "C"]
def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
"""Fill defaults so a persona dict is always structurally complete."""
pid = p.get("id") or p.get("name", "persona")
base = {
"id": pid,
"name": p.get("name", ""),
"tier": p.get("tier", p.get("intent_tier", "B")),
"initiation_mode": p.get("initiation_mode", "customer"), # customer | seller
"channel": p.get("channel", "facebook"), # facebook | line
# revealable
"profession": p.get("profession", ""),
"age_group": p.get("age_group", ""),
"location": p.get("location", ""),
"product_context": p.get("product_context", ""),
# latent (hidden until end)
"background": p.get("background", ""),
"income": p.get("income", ""),
"lifestyle": p.get("lifestyle", ""),
"personality": p.get("personality", ""),
"communication_style": p.get("communication_style", ""),
"budget": p.get("budget", ""),
"decision_timeline": p.get("decision_timeline", ""),
"goal": p.get("goal", ""),
"objections": p.get("objections", []),
"pains": p.get("pains", []),
"negotiation_levers": p.get("negotiation_levers", []),
"opener": p.get("opener", ""),
"special": p.get("special", ""), # e.g. "wrong_text" | ""
"difficulty": p.get("difficulty", 1), # 1..5
"notes": p.get("notes", ""),
}
# validate
if base["tier"] not in DEFAULT_TIERS:
base["tier"] = "B"
if base["initiation_mode"] not in ("customer", "seller"):
base["initiation_mode"] = "customer"
if base["channel"] not in ("facebook", "line"):
base["channel"] = "facebook"
return base
def revealable_view(p: dict[str, Any]) -> dict[str, Any]:
"""Return ONLY the fields a trainee may see before/while chatting."""
return {
"id": p.get("id"),
"name": p.get("name"),
"tier": p.get("tier"),
"channel": p.get("channel"),
"initiation_mode": p.get("initiation_mode"),
"profession": p.get("profession"),
"age_group": p.get("age_group"),
"location": p.get("location"),
"product_context": p.get("product_context"),
}
def full_view(p: dict[str, Any]) -> dict[str, Any]:
return ensure_persona_shape(p)

View File

@@ -0,0 +1,81 @@
"""Trainee loop: win/lose board, weak-area analysis, user-generated personas.
A user never re-chats a persona. To keep training, they generate new personas —
either auto from their weak areas ("lock") or from a manual form. Generated
personas are private to the user.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any
from ..storage.store import JsonStore, new_id
from .store import ensure_persona_shape
class MyPersonaStore:
def __init__(self, data_dir: Path) -> None:
self.personas = JsonStore(data_dir / "my_personas")
def _path_key(self, user_id: str, pid: str) -> str:
return f"{user_id}__{pid}"
def create(self, *, user_id: str, persona: dict[str, Any]) -> dict[str, Any]:
p = ensure_persona_shape(persona)
if "id" not in p or not p["id"]:
p["id"] = new_id("myp")
record = {
"key": self._path_key(user_id, p["id"]),
"user_id": user_id,
"persona": p,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
return self.personas.create(record, key=record["key"])
def list_for(self, user_id: str) -> list[dict[str, Any]]:
return [
r.get("persona")
for r in self.personas.where(lambda x: x.get("user_id") == user_id)
]
def analyze_weak_areas(sessions: list[dict[str, Any]]) -> dict[str, Any]:
"""Summarize which persona attributes a user tends to lose against."""
losses, wins = [], []
for ses in sessions:
if ses.get("outcome") == "won":
wins.append(ses)
elif ses.get("outcome") == "lost":
losses.append(ses)
def tally(key: str, label: str) -> list[dict[str, Any]]:
from collections import Counter
c = Counter()
for l in losses:
meta = l.get("persona_meta") or {}
v = meta.get(key)
if v is not None:
c[v] += 1
return [{"value": k, "losses": v} for k, v in c.most_common(3)]
return {
"total_sessions": len(sessions),
"wins": len(wins),
"losses": len(losses),
"by_tier": tally("tier", "tier"),
"by_initiation": tally("initiation_mode", "initiation"),
"by_channel": tally("channel", "channel"),
"top_loss_personas": [
{
"persona_id": l.get("persona_id"),
"persona_name": l.get("persona_name"),
"score": (l.get("debrief") or {}).get("score", 0),
"why": (l.get("debrief") or {}).get("why", ""),
}
for l in sorted(
losses, key=lambda x: (x.get("debrief") or {}).get("score", 0)
)[:5]
],
}