- analyze returns 202 immediately; frontend polls group status (3s interval, 30min cap) - quota system: atomic check-and-consume, trial/team/enterprise plans, 402 on exceed - persona generation split into tier batches (Cloudflare 120s limit safe) - LLM client: 105s timeout, thinking param for reasoning models - tests: quota suite (4), persona cardinality/initiation updates
153 lines
6.2 KiB
Python
153 lines
6.2 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
|
|
|
|
|
|
def make_tier_personas(tier: str) -> list[dict[str, Any]]:
|
|
"""5 personas for ONE tier (mock of the per-tier generation calls)."""
|
|
start = {"A": 1, "B": 6, "C": 11}[tier]
|
|
return [_sample_persona(start + i, tier) for i in range(5)]
|
|
|
|
|
|
def make_one_persona(tier: str, idx: int, *, wrong_text: bool = False) -> dict[str, Any]:
|
|
"""A single persona for a one-persona-per-call generation."""
|
|
p = _sample_persona(idx, tier)
|
|
if wrong_text:
|
|
p["special"] = "wrong_text"
|
|
elif tier == "C":
|
|
p["special"] = ""
|
|
return p
|
|
|
|
|
|
class MockLLM:
|
|
"""Drop-in for app.llm.LLMClient — reads config the same way."""
|
|
|
|
def __init__(self, **kwargs):
|
|
self._tier_calls: dict[str, int] = {"A": 0, "B": 0, "C": 0}
|
|
|
|
def _next_persona(self, user_prompt: str) -> dict[str, Any] | None:
|
|
"""Return the next single persona for a one-persona-per-call prompt, or None."""
|
|
tier = None
|
|
for t in ("A", "B", "C"):
|
|
if f"Generate exactly 1 persona, tier {t}" in user_prompt:
|
|
tier = t
|
|
break
|
|
if tier is None:
|
|
return None
|
|
slot = self._tier_calls[tier]
|
|
self._tier_calls[tier] += 1
|
|
start = {"A": 1, "B": 6, "C": 11}[tier]
|
|
return make_one_persona(tier, start + slot, wrong_text=(tier == "C" and slot % 5 == 0))
|
|
|
|
def complete(self, system_prompt: str, user_prompt: str, **kw) -> str:
|
|
if "market-research persona designer" in system_prompt.lower():
|
|
one = self._next_persona(user_prompt)
|
|
if one is not None:
|
|
return json.dumps({"personas": [one]}, ensure_ascii=False)
|
|
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:
|
|
one = self._next_persona(user_prompt)
|
|
if one is not None:
|
|
return {"personas": [one]}
|
|
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)
|