Spaces:
Runtime error
Runtime error
| # 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. | |
| """ | |
| if state.conversation_history: | |
| last_action = state.conversation_history[-1].get("action_type", "") | |
| return last_action == 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. | |
| Violation if the prospect's last response had actual content | |
| (i.e., the prospect is still engaged and waiting for a reply). | |
| """ | |
| if action.action_type == "FOLLOW_UP": | |
| if state.conversation_history: | |
| # Walk backwards to find the last prospect message | |
| for entry in reversed(state.conversation_history): | |
| if entry.get("speaker") == "prospect": | |
| response_token = entry.get("response_token", "") | |
| # FOLLOW_UP is only valid if the prospect went silent | |
| return response_token != "silence" | |
| # No prospect message found — first turn, so violation | |
| return True | |
| # No history at all — first turn, can't FOLLOW_UP yet | |
| return True | |
| return False | |
| def _disqualify_logic( | |
| state: SalesPathState, | |
| action: SalesPathAction, | |
| ) -> bool: | |
| """ | |
| R08: | |
| DISQUALIFY only when prospect is genuinely not closeable. | |
| Violation if prospect is actually closeable. | |
| """ | |
| 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) | |
| return (true_budget >= close_threshold) and decision_maker | |
| 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 |