Spaces:
Sleeping
Sleeping
| # salespath_env/server/rules.py | |
| from dataclasses import dataclass | |
| from typing import Callable | |
| from ..models import SalesPathAction, SalesPathState | |
| class BusinessRule: | |
| """ | |
| Returns True when the rule is VIOLATED. | |
| """ | |
| rule_id: str | |
| name: str | |
| description: str | |
| check: Callable[[SalesPathState, SalesPathAction], bool] | |
| def _qualify_before_present( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R01: | |
| PRESENT before QUALIFY is invalid. | |
| """ | |
| if action.action_type == "PRESENT": | |
| return "QUALIFY" not in state.steps_completed | |
| return False | |
| def _demo_before_negotiate( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R02: | |
| NEGOTIATE before OFFER_DEMO is invalid. | |
| """ | |
| if action.action_type == "NEGOTIATE": | |
| return "OFFER_DEMO" not in state.steps_completed | |
| return False | |
| def _budget_known_to_negotiate( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R03: | |
| Cannot NEGOTIATE while budget is unknown. | |
| """ | |
| if action.action_type == "NEGOTIATE": | |
| return state.prospect_profile.get("budget_signal") == "unknown" | |
| return False | |
| def _discount_after_objections( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R04: | |
| Discount only after 2 objections handled. | |
| """ | |
| if action.action_type == "NEGOTIATE": | |
| if "discount" in action.content.lower(): | |
| return state.objections_handled < 2 | |
| return False | |
| def _no_repeat_action( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R05: | |
| Same action twice in a row is invalid. | |
| FIX: conversation_history alternates agent/prospect entries. | |
| Must filter to agent-only turns before comparing. | |
| """ | |
| agent_turns = [ | |
| e for e in state.conversation_history | |
| if e.get("speaker") == "agent" | |
| ] | |
| if agent_turns: | |
| return agent_turns[-1].get("action_type", "") == action.action_type | |
| return False | |
| def _prospect_first( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R06: | |
| First action must be PROSPECT. | |
| """ | |
| if state.turn_number == 1: | |
| return action.action_type != "PROSPECT" | |
| return False | |
| def _followup_timing( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R07: | |
| FOLLOW_UP only valid after prospect silence (no response for 1+ agent turns). | |
| Violation if the prospect HAS replied since the last agent action. | |
| FIX: Previous logic was inverted — it was blocking valid FOLLOW_UP. | |
| """ | |
| if action.action_type == "FOLLOW_UP": | |
| if not state.conversation_history: | |
| return True # Nothing happened yet — FOLLOW_UP makes no sense | |
| agent_turns = [ | |
| e for e in state.conversation_history | |
| if e.get("speaker") == "agent" | |
| ] | |
| prospect_turns = [ | |
| e for e in state.conversation_history | |
| if e.get("speaker") == "prospect" | |
| ] | |
| if not agent_turns: | |
| return True | |
| last_agent_turn_num = agent_turns[-1]["turn"] | |
| last_prospect_turn_num = max( | |
| (e["turn"] for e in prospect_turns), | |
| default=0, | |
| ) | |
| # Violation if prospect already responded AFTER the last agent turn | |
| return last_prospect_turn_num >= last_agent_turn_num | |
| return False | |
| def _disqualify_logic( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R08: | |
| DISQUALIFY is correct ONLY when: | |
| - true_budget < close_threshold AND | |
| - decision_maker is False | |
| Violation if prospect is actually closeable OR has a decision maker. | |
| FIX: Both conditions must hold for a valid disqualification. | |
| """ | |
| if action.action_type == "DISQUALIFY": | |
| true_budget = state.hidden_state.get("true_budget", 0.5) | |
| close_threshold = state.hidden_state.get("close_threshold", 0.5) | |
| decision_maker = state.prospect_profile.get("decision_maker", True) | |
| # Valid disqualify requires: low budget AND no decision maker | |
| valid_disqualify = (true_budget < close_threshold) and (not decision_maker) | |
| return not valid_disqualify # Violation if NOT a valid disqualify case | |
| return False | |
| def _close_requires_demo( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R09: | |
| Difficulty 2+ requires OFFER_DEMO before CLOSE. | |
| """ | |
| if action.action_type == "CLOSE": | |
| if state.difficulty >= 2: | |
| return "OFFER_DEMO" not in state.steps_completed | |
| return False | |
| BUSINESS_RULES = [ | |
| BusinessRule( | |
| "R01", | |
| "qualify_before_present", | |
| "Must QUALIFY before PRESENT", | |
| _qualify_before_present, | |
| ), | |
| BusinessRule( | |
| "R02", | |
| "demo_before_negotiate", | |
| "Must OFFER_DEMO before NEGOTIATE", | |
| _demo_before_negotiate, | |
| ), | |
| BusinessRule( | |
| "R03", | |
| "budget_known_to_negotiate", | |
| "Budget must be known before NEGOTIATE", | |
| _budget_known_to_negotiate, | |
| ), | |
| BusinessRule( | |
| "R04", | |
| "discount_after_objections", | |
| "Discount only after 2 objections handled", | |
| _discount_after_objections, | |
| ), | |
| BusinessRule( | |
| "R05", | |
| "no_repeat_action", | |
| "Cannot repeat same action consecutively", | |
| _no_repeat_action, | |
| ), | |
| BusinessRule( | |
| "R06", | |
| "prospect_first", | |
| "First action must be PROSPECT", | |
| _prospect_first, | |
| ), | |
| BusinessRule( | |
| "R07", | |
| "followup_timing", | |
| "FOLLOW_UP only after prospect silence", | |
| _followup_timing, | |
| ), | |
| BusinessRule( | |
| "R08", | |
| "disqualify_logic", | |
| "DISQUALIFY only when prospect is genuinely unqualified", | |
| _disqualify_logic, | |
| ), | |
| BusinessRule( | |
| "R09", | |
| "close_requires_demo", | |
| "Must OFFER_DEMO before CLOSE (difficulty 2+)", | |
| _close_requires_demo, | |
| ), | |
| ] | |
| def check_rules( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> list[str]: | |
| """ | |
| Returns list of violated rule IDs. | |
| """ | |
| violated = [] | |
| for rule in BUSINESS_RULES: | |
| if rule.check(state, action): | |
| violated.append(rule.rule_id) | |
| return violated |