Spaces:
Sleeping
Sleeping
File size: 8,095 Bytes
c1be7c3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | # Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""
Scripted persona opponent for procurement negotiation.
The opponent's behavior is deterministic given a seed AND sensitive to
the agent's language quality via the rapport system.
"""
import random
from dataclasses import dataclass, field
from typing import Dict, Tuple
COLLABORATIVE_SIGNALS = [
"understand",
"partnership",
"mutual",
"together",
"value",
"appreciate",
"flexible",
"work with",
"long-term",
"relationship",
"reasonable",
"fair",
"both",
"solution",
]
AGGRESSIVE_SIGNALS = [
"demand",
"require",
"final offer",
"unacceptable",
"must",
"non-negotiable",
"take it or leave",
"bottom line",
"ultimatum",
"insist",
"refuse",
"absolutely not",
]
PERSONA_TEMPLATES = {
"cooperative": {
"opening": [
"Thanks for reaching out. Our standard pricing for this package is ${target}. Happy to discuss.",
"We value your interest. We're pricing this at ${target} based on current market rates.",
],
"counter": [
"I appreciate you working with us. Based on our costs, ${counter} is where we can be.",
"Thank you for your offer. We can move to ${counter} given our margin requirements.",
],
"near_close": [
"I think we're close. If you can do ${close}, I can get this approved today.",
"We're almost there. ${close} works for our team. Shall we finalize?",
],
"accept": "That works for us. Let's move forward at those terms.",
"reject": "That's below what we can accept, but we want to make this work.",
},
"cash_flow_stressed": {
"opening": [
"Our pricing is ${target}. I should mention — payment timing is particularly important to us this quarter.",
"We're at ${target}. Between us, our finance team has specific requirements around cash flow timing.",
],
"counter": [
"We can move on price if payment terms work for you. ${counter} with your payment preference?",
"Price flexibility depends on receivables timing for us. ${counter} if we can discuss payment terms.",
],
"near_close": [
"If you can do Net-30 on payment, we can get to ${close} on price.",
"Payment timing is our real constraint. ${close} with faster payment terms?",
],
"accept": "Agreed. The payment structure works for our cash flow needs.",
"reject": "The price is tight but we could explore it if payment terms align.",
},
"aggressive_anchor": {
"opening": [
"Our price is ${target}. This reflects our full service quality and market position.",
"We're firm at ${target}. This is based on our cost structure and service level.",
],
"counter": [
"We can go to ${counter}. That's already a significant concession from our position.",
"${counter} is our revised position. We're not in a position to move much further.",
],
"hardening": [
"We've already moved considerably. ${floor} is our absolute position.",
"I need to be direct — we're at ${floor} and that's where we'll stay.",
],
"near_close": [
"Final position: ${close}. We need a decision today.",
"${close} is where we are. This is our best and final offer.",
],
"accept": "Accepted.",
"reject": "That doesn't work. Come back with a serious offer.",
},
}
class ScriptedPersonaOpponent:
def __init__(self, task_id: str, seed: int, persona: str):
self.rng = random.Random(seed)
self.task_id = task_id
self.persona = persona
self.templates = PERSONA_TEMPLATES[persona]
if task_id == "single_issue":
self.price_floor = self.rng.uniform(42000, 46000)
self.price_target = self.price_floor * self.rng.uniform(1.28, 1.38)
elif task_id == "multi_issue":
self.price_floor = self.rng.uniform(40000, 46000)
self.price_target = self.price_floor * self.rng.uniform(1.25, 1.35)
self.payment_preference = self.rng.choice([30, 45, 60])
elif task_id == "adversarial":
self.price_floor = self.rng.uniform(85000, 95000)
self.price_target = self.price_floor * self.rng.uniform(1.30, 1.40)
self.rapport = 0.5
self.concession_count = 0
self.current_position = self.price_target
def update_rapport(self, agent_message: str) -> None:
msg_lower = agent_message.lower()
delta = 0.0
delta += sum(0.08 for w in COLLABORATIVE_SIGNALS if w in msg_lower)
delta -= sum(0.08 for w in AGGRESSIVE_SIGNALS if w in msg_lower)
delta = max(-0.20, min(0.20, delta))
self.rapport = max(0.0, min(1.0, self.rapport + delta))
def get_concession_rate(self) -> float:
base_rates = {
"cooperative": 0.05,
"cash_flow_stressed": 0.07,
"aggressive_anchor": 0.04,
}
base = base_rates[self.persona]
modifier = (self.rapport - 0.5) * base
return max(0.01, base + modifier)
def respond(
self,
agent_message: str,
agent_terms: Dict,
round_number: int,
consecutive_concessions: int,
) -> Tuple[str, Dict]:
self.update_rapport(agent_message)
self.concession_count += 1
agent_price = agent_terms.get("price", 0)
if (
round_number >= 2
and agent_price >= self.price_floor
and self._acceptance_condition(agent_terms)
):
return self.templates["accept"], {**agent_terms, "_accepted": True}
concession = self.get_concession_rate()
if self.persona == "aggressive_anchor" and consecutive_concessions >= 2:
concession = concession * 0.4
template_key = "hardening"
elif round_number >= self._max_rounds() * 0.7:
template_key = "near_close"
else:
template_key = "counter"
new_position = self.current_position * (1 - concession)
new_position = max(self.price_floor, new_position)
self.current_position = new_position
templates_for_key = self.templates.get(template_key, self.templates["counter"])
template = self.rng.choice(templates_for_key)
message = template.replace("${counter}", f"${new_position:,.0f}")
message = message.replace("${floor}", f"${self.price_floor:,.0f}")
message = message.replace("${close}", f"${new_position:,.0f}")
counter_terms = dict(agent_terms)
counter_terms["price"] = round(new_position, 2)
if self.persona == "cash_flow_stressed" and "payment_days" in agent_terms:
if agent_terms["payment_days"] > 60:
message += (
" Though I'll need to flag the payment timing to our finance team."
)
return message, counter_terms
def _acceptance_condition(self, terms: Dict) -> bool:
if self.persona == "cash_flow_stressed":
payment_ok = terms.get("payment_days", 60) <= 45
return payment_ok
return True
def _max_rounds(self) -> int:
return {"single_issue": 6, "multi_issue": 8, "adversarial": 10}[self.task_id]
def get_opening_message(self) -> Tuple[str, Dict]:
template = self.rng.choice(self.templates["opening"])
message = template.replace("${target}", f"${self.price_target:,.0f}")
terms = {"price": round(self.price_target, 2)}
if self.task_id in ["multi_issue", "adversarial"]:
terms["payment_days"] = 90
if self.task_id == "adversarial":
terms["support_hours"] = 80
return message, terms
|