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