salespath-env / salespath_env /server /prospect_simulator.py
Imsachin010's picture
first commit
b77d3c5
# salespath_env/server/prospect_simulator.py
from ..models import SalesPathAction, SalesPathState
RESPONSE_TEXT = {
"open:positive_signal": "That sounds interesting. Tell me more about how this works.",
"open:neutral_signal": "I see. We're evaluating a few options at the moment.",
"objection:price": "The pricing seems higher than what we budgeted for.",
"objection:timing": "The timing isn't ideal — we're in the middle of a quarter close.",
"objection:premature_pitch": (
"I'm not sure we're ready to discuss solutions yet. "
"What do you know about our current situation?"
),
"deflect:budget_not_discussed": (
"We haven't really talked about what we're looking for yet."
),
"deflect:stall": (
"Let me get back to you on this. A lot is happening on our end."
),
"accept:demo_scheduled": (
"Yes, let's set up a demo. What time works next week?"
),
"accept:close_success": (
"Alright, I think we can move forward with this. "
"Send over the paperwork."
),
"reject:close_failed": (
"I don't think we're ready to commit at this point."
),
"silence": "",
"exit:disqualified": (
"I think we're done here. This isn't the right fit."
),
}
class ProspectSimulator:
"""
Pure rule-based simulator.
No LLM. No transformers. Deterministic behavior.
"""
def respond(
self,
action: SalesPathAction,
state: SalesPathState,
) -> tuple[str, str]:
"""
Returns:
(response_token, response_text)
"""
token = self._get_token(action, state)
text = RESPONSE_TEXT[token]
return token, text
def _get_token(
self,
action: SalesPathAction,
state: SalesPathState,
) -> str:
atype = action.action_type
difficulty = state.difficulty
turn = state.turn_number
profile = state.prospect_profile
hidden = state.hidden_state
objections = state.objections_handled
# -----------------------------
# Rule-triggered responses first
# -----------------------------
if state.constraints_violated:
latest = state.constraints_violated[-1]
if latest == "R01":
return "objection:premature_pitch"
if latest == "R03":
return "deflect:budget_not_discussed"
# -----------------------------
# Action-based responses
# -----------------------------
if atype == "PROSPECT":
return "open:positive_signal"
if atype == "QUALIFY":
# Reveal budget if hidden
if profile.get("budget_signal") == "unknown":
state.prospect_profile["budget_signal"] = hidden.get(
"revealed_budget",
"medium",
)
return "open:neutral_signal"
if atype == "PRESENT":
if difficulty >= 2:
if objections == 0:
return "objection:price"
return "open:positive_signal"
if atype == "HANDLE_OBJECTION":
state.objections_handled += 1
required_objections = hidden.get("num_objections", 1)
if state.objections_handled >= required_objections:
return "open:positive_signal"
if objections == 0:
return "objection:timing"
return "open:positive_signal"
if atype == "OFFER_DEMO":
return "accept:demo_scheduled"
if atype == "NEGOTIATE":
return "open:neutral_signal"
if atype == "CLOSE":
true_budget = hidden.get("true_budget", 0.7)
close_threshold = hidden.get("close_threshold", 0.5)
decision_maker = profile.get("decision_maker", True)
if (
true_budget >= close_threshold
and decision_maker
):
return "accept:close_success"
return "reject:close_failed"
if atype == "FOLLOW_UP":
return "open:neutral_signal"
if atype == "DISQUALIFY":
return "exit:disqualified"
# -----------------------------
# Difficulty 3+ mode shift
# -----------------------------
if difficulty >= 3 and turn >= 10:
import random
if random.random() < hidden.get("stall_probability", 0.0):
return "deflect:stall"
return "open:neutral_signal"