Files
sales-trainer/backend/scripts/mock_llm.py
Macky c92400b195 refactor(chat): decide buy/walk by per-turn LLM judge (not fixed keywords)
Removed the fixed-value text detector. persona_reply no longer forces JSON meta; instead a
per-turn evaluate_turn() calls the judge LLM after every customer reply to read the persona's
current mood + whether it has decided (buy/walk/pending) + score_delta + reason. send_message
consumes that context-based decision to (a) end the chat as won/lost and (b) move the score.

This is what the user asked: the system evaluates EVERY turn and decides at the moment it's
truly committed — not keyword matching (so 'ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?' stays pending).
Mock updated: judge returns buy on first send (keeps E2E deterministic). 11/11 suites pass.
2026-08-09 13:19:15 +07:00

121 lines
4.9 KiB
Python

"""Mock LLM for deterministic end-to-end tests (no external API needed).
Substitutes for app.llm.LLMClient. Returns canned JSON for structured calls and
simple replies for chat calls, so the full analyze→persona→chat→debrief flow runs.
"""
from __future__ import annotations
import json
from typing import Any
SAMPLE_SALES_KIT = {
"productName": "CloudPOS",
"category": "POS software",
"valueProps": ["faster checkout", "inventory sync"],
"features": ["tablets", "reports"],
"pricingAnchors": ["1,000 THB/month"],
"targetAudience": {"segment": "SME restaurants", "demographics": "", "useCases": ["front counter"]},
"objectionHandlers": ["free trial", "setup included"],
"initialPainFit": [
{"pain": "slow checkout queues", "fit": "strong", "evidence": "faster checkout"},
{"pain": "lost sales from stockouts", "fit": "partial", "evidence": "inventory sync"},
],
"scenarioFrame": "Cloud POS sold over LINE to Bangkok SME restaurants.",
}
def _sample_persona(idx: int, tier: str) -> dict[str, Any]:
return {
"id": f"persona-{idx:02d}",
"name": f"Persona {idx}",
"tier": tier,
"channel": "line",
"initiation_mode": "customer" if idx % 3 else "seller",
"profession": "restaurant owner",
"age_group": "30s",
"location": "Bangkok",
"product_context": "running a small noodle shop",
"background": "Runs a family noodle shop for 8 years.",
"income": "60k THB/month",
"lifestyle": "works long hours",
"personality": "practical and cautious",
"communication_style": "short, direct, casual",
"budget": "1,500 THB/month max",
"decision_timeline": "within 2 weeks",
"goal": "reduce lunch-rush queues",
"objections": ["too expensive", "hard to learn"],
"pains": [
{"id": "p1", "name": "slow checkout", "fit": "strong",
"description": "Long queues at lunch", "rootCause": "manual order taking",
"resolutionConditions": ["show faster checkout", "offer a trial"]},
{"id": "p2", "name": "stockouts", "fit": "partial",
"description": "Runs out of ingredients", "rootCause": "no inventory tracking",
"resolutionConditions": ["show inventory feature"]},
],
"negotiation_levers": ["price reduction", "free setup"],
"opener": "Hi, I saw your POS ad. Does it work with small shops?",
"special": "wrong_text" if (tier == "C" and idx % 5 == 4) else "",
"difficulty": 2 if tier == "A" else (3 if tier == "B" else 4),
"notes": "sample",
}
def make_personas() -> list[dict[str, Any]]:
out = []
idx = 1
for tier in ["A", "B", "C"]:
for _ in range(5):
out.append(_sample_persona(idx, tier))
idx += 1
return out
class MockLLM:
"""Drop-in for app.llm.LLMClient — reads config the same way."""
persona_count = 0
def __init__(self, **kwargs):
pass
def complete(self, system_prompt: str, user_prompt: str, **kw) -> str:
if "Persona generation prompts" in system_prompt or "persona designer" in system_prompt.lower():
return json.dumps({"personas": make_personas()}, ensure_ascii=False)
if "market-research persona designer" in system_prompt.lower():
return json.dumps({"personas": make_personas()}, ensure_ascii=False)
return "ok"
def complete_json(self, system_prompt: str, user_prompt: str, **kw) -> dict[str, Any]:
sp = system_prompt.lower()
if "ecommerce/b2b analyst" in sp:
return dict(SAMPLE_SALES_KIT)
if "market-research persona designer" in sp:
return {"personas": make_personas()}
if "sales-training simulator" in sp and "PRIVATE" in system_prompt:
return {"persona": _sample_persona(99, "C")}
if "judge" in sp and "sales-training chat" in sp:
return {
"outcome": "won",
"score": 82,
"pain": "slow checkout queues",
"why": "resolved the pain and secured acceptance",
"failurePoints": [],
"coaching": [],
"painProgress": {"slow checkout": 100},
}
if "neutral sales-coaching judge" in sp:
# Per-turn state evaluation: mock decides to buy on the first seller message
# (keeps E2E deterministic: first send auto-finishes as won), else pending.
return {"mood": 1, "decision": "buy", "score_delta": 5, "reason": "mock buy"}
return {}
def complete_conversation(self, messages, **kw) -> str:
# persona chat: echo a short in-character reply with a decision.
# On the first send, the persona decides to buy (so E2E auto-finishes as won).
return json.dumps({
"reply": "I see. Tell me more about the price then.",
"decision": "buy",
"mood": 1,
}, ensure_ascii=False)