-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsimulator.py
More file actions
395 lines (347 loc) · 16.3 KB
/
Copy pathsimulator.py
File metadata and controls
395 lines (347 loc) · 16.3 KB
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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
#!/usr/bin/env python3
"""
Contract Negotiation Simulator
Author: Marco De Roni | Legal AI
GitHub: https://github.com/marcoderoni/contract-negotiation-simulator
"""
import os
import sys
import json
import time
import textwrap
from datetime import datetime
from anthropic import Anthropic
# ── Colours ────────────────────────────────────────────────────────────────
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
# ── Scenarios ───────────────────────────────────────────────────────────────
SCENARIOS = {
"1": {
"name": "SaaS MSA — Limitation of Liability",
"type": "MSA",
"your_role": "Vendor Counsel",
"counterparty_role": "Customer Counsel",
"description": "Customer wants uncapped liability; you must defend a 12-month ACV cap.",
"opening_clause": (
"Except for breaches of confidentiality, IP infringement, or wilful misconduct, "
"each party's total liability shall not exceed the fees paid in the 12 months "
"preceding the claim."
),
"playbook_guidance": (
"Acceptable: up to 24-month cap for regulated industries. "
"Never accept: unlimited liability or revenue-based caps. "
"Fallback: carve out data breaches at 2× ACV."
),
"difficulty": "Medium",
"turns": 6,
},
"2": {
"name": "DPA — Sub-processor Approval",
"type": "DPA",
"your_role": "Vendor Counsel",
"counterparty_role": "Customer DPO/Counsel",
"description": (
"Customer demands individual prior written consent for every sub-processor change. "
"You must protect operational flexibility while maintaining GDPR compliance."
),
"opening_clause": (
"Vendor may engage sub-processors listed in Annex A. Vendor shall notify Customer "
"30 days in advance of any new sub-processor. Customer may object within 14 days; "
"if no objection is raised, the sub-processor is deemed approved."
),
"playbook_guidance": (
"Acceptable: 14-day objection window, published sub-processor list. "
"Never accept: prior individual written consent per sub-processor. "
"Fallback: termination right if objection is reasonable and vendor cannot remedy."
),
"difficulty": "Hard",
"turns": 6,
},
"3": {
"name": "CSA — IP Ownership & Work for Hire",
"type": "CSA",
"your_role": "Vendor Counsel",
"counterparty_role": "Customer Counsel",
"description": (
"Customer demands ownership of all deliverables including pre-existing IP. "
"You must protect the vendor's core technology and IP."
),
"opening_clause": (
"All deliverables created under this Agreement shall be considered works made for hire. "
"To the extent any deliverable does not qualify, Vendor hereby assigns all rights, "
"title and interest to Customer, excluding Vendor's Pre-Existing IP as defined herein."
),
"playbook_guidance": (
"Acceptable: assignment of bespoke deliverables excluding pre-existing IP and general know-how. "
"Never accept: assignment of platform IP or any broadly defined 'improvements'. "
"Fallback: licence-back of deliverables incorporating Vendor IP."
),
"difficulty": "Medium",
"turns": 5,
},
"4": {
"name": "MSA — Termination for Convenience",
"type": "MSA",
"your_role": "Vendor Counsel",
"counterparty_role": "Customer Counsel",
"description": (
"Customer insists on termination for convenience at any time with 30-day notice. "
"You need to protect multi-year revenue commitments."
),
"opening_clause": (
"This Agreement may be terminated by either party for any reason upon 90 days' "
"written notice, provided that any outstanding fees for the remainder of the "
"current subscription term shall become immediately due and payable upon termination."
),
"playbook_guidance": (
"Acceptable: T4C after first anniversary with 90-day notice and pro-rata refund. "
"Never accept: T4C with full refund within first 12 months. "
"Fallback: T4C with 6-month notice after year 1 and no refund of past fees."
),
"difficulty": "Easy",
"turns": 5,
},
}
# ── Counterparty system prompt ───────────────────────────────────────────────
def build_counterparty_prompt(scenario: dict) -> str:
return f"""You are an experienced {scenario['counterparty_role']} negotiating a {scenario['type']} contract clause.
SCENARIO: {scenario['description']}
YOUR MANDATE:
- Protect your client's interests aggressively but professionally
- Push back on the vendor's opening position
- Propose concrete redlines and alternative language — always use proper legal drafting
- Vary your tactics: sometimes concede minor points to gain major ones
- You may escalate pressure or show flexibility depending on how well the user argues
- Stay in character at all times — respond as a real counterparty counsel would in a live negotiation
CURRENT CLAUSE UNDER NEGOTIATION:
{scenario['opening_clause']}
TONE: Professional, direct, occasionally firm. Use concise legal language.
LENGTH: Keep responses to 3–6 sentences unless proposing redline language, in which case draft the full clause.
FORMAT: Speak as yourself (the counterparty counsel). Do not narrate or explain what you are doing."""
# ── Scoring prompt ───────────────────────────────────────────────────────────
def build_scoring_prompt(scenario: dict, history: list) -> str:
convo = "\n".join([
f"{'YOU' if m['role'] == 'user' else 'COUNTERPARTY'}: {m['content']}"
for m in history
])
return f"""You are a senior legal negotiation coach evaluating a contract negotiation exercise.
SCENARIO: {scenario['name']}
USER ROLE: {scenario['your_role']}
PLAYBOOK GUIDANCE: {scenario['playbook_guidance']}
NEGOTIATION TRANSCRIPT:
{convo}
Evaluate the user's negotiation performance and respond ONLY with a JSON object in exactly this format:
{{
"overall_score": <integer 0-100>,
"grade": "<A / B / C / D / F>",
"dimensions": {{
"playbook_adherence": {{
"score": <integer 0-100>,
"comment": "<one sentence>"
}},
"legal_precision": {{
"score": <integer 0-100>,
"comment": "<one sentence>"
}},
"negotiation_strategy": {{
"score": <integer 0-100>,
"comment": "<one sentence>"
}},
"commercial_awareness": {{
"score": <integer 0-100>,
"comment": "<one sentence>"
}}
}},
"strengths": ["<strength 1>", "<strength 2>"],
"improvements": ["<improvement 1>", "<improvement 2>"],
"best_move": "<The single best thing the user did or said>",
"missed_opportunity": "<The key point the user failed to press or exploit>",
"suggested_closing_position": "<How a senior counsel would have closed this clause>"
}}"""
# ── Helpers ──────────────────────────────────────────────────────────────────
def wrap(text: str, width: int = 82) -> str:
return "\n".join(
textwrap.fill(line, width) if line.strip() else line
for line in text.split("\n")
)
def print_header():
print(f"\n{BOLD}{CYAN}")
print("╔══════════════════════════════════════════════════════════════════════╗")
print("║ CONTRACT NEGOTIATION SIMULATOR · Legal AI by Marco De Roni ║")
print("╚══════════════════════════════════════════════════════════════════════╝")
print(RESET)
def print_scenario_menu():
print(f"{BOLD}Select a scenario:{RESET}\n")
for key, s in SCENARIOS.items():
diff_colour = GREEN if s["difficulty"] == "Easy" else (YELLOW if s["difficulty"] == "Medium" else RED)
print(f" {CYAN}[{key}]{RESET} {s['name']}")
print(f" {DIM}{s['description'][:72]}...{RESET}")
print(f" Type: {s['type']} · Turns: {s['turns']} · Difficulty: {diff_colour}{s['difficulty']}{RESET}\n")
def print_clause_box(label: str, text: str):
print(f"\n{BOLD}{YELLOW}{'─'*72}")
print(f" {label}")
print(f"{'─'*72}{RESET}")
print(wrap(f" {text}"))
print(f"{YELLOW}{'─'*72}{RESET}\n")
def print_counterparty(text: str):
print(f"\n{RED}{BOLD}⚖ COUNTERPARTY:{RESET}")
print(wrap(f" {text}"))
def print_score(result: dict):
grade_colours = {"A": GREEN, "B": GREEN, "C": YELLOW, "D": YELLOW, "F": RED}
g = result.get("grade", "C")
c = grade_colours.get(g, YELLOW)
score = result.get("overall_score", 0)
print(f"\n{BOLD}{CYAN}{'═'*72}")
print(f" FINAL SCORE: {c}{score}/100 · Grade: {g}{RESET}{BOLD}{CYAN}")
print(f"{'═'*72}{RESET}\n")
dims = result.get("dimensions", {})
labels = {
"playbook_adherence": "Playbook Adherence ",
"legal_precision": "Legal Precision ",
"negotiation_strategy": "Negotiation Strategy",
"commercial_awareness": "Commercial Awareness",
}
for key, label in labels.items():
d = dims.get(key, {})
s = d.get("score", 0)
bar = "█" * (s // 10) + "░" * (10 - s // 10)
colour = GREEN if s >= 75 else (YELLOW if s >= 50 else RED)
print(f" {label} {colour}{bar} {s:>3}{RESET} {DIM}{d.get('comment','')}{RESET}")
print(f"\n{BOLD}✅ Strengths:{RESET}")
for s in result.get("strengths", []):
print(f" • {s}")
print(f"\n{BOLD}🔧 Areas to improve:{RESET}")
for i in result.get("improvements", []):
print(f" • {i}")
print(f"\n{BOLD}🏆 Best move:{RESET}")
print(wrap(f" {result.get('best_move','—')}"))
print(f"\n{BOLD}⚠️ Missed opportunity:{RESET}")
print(wrap(f" {result.get('missed_opportunity','—')}"))
print(f"\n{BOLD}{GREEN}💡 How a senior counsel would have closed it:{RESET}")
print(wrap(f" {result.get('suggested_closing_position','—')}"))
print()
def save_session(scenario: dict, history: list, score: dict):
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
fname = f"session_{scenario['type']}_{ts}.json"
data = {
"scenario": scenario["name"],
"timestamp": ts,
"score": score,
"transcript": history,
}
with open(fname, "w") as f:
json.dump(data, f, indent=2)
print(f"{DIM}Session saved → {fname}{RESET}\n")
# ── Main loop ────────────────────────────────────────────────────────────────
def run():
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
print(f"{RED}Error: ANTHROPIC_API_KEY not set.{RESET}")
sys.exit(1)
client = Anthropic(api_key=api_key)
print_header()
print_scenario_menu()
# Scenario selection
while True:
choice = input(f"{BOLD}Enter scenario number (1-{len(SCENARIOS)}) or Q to quit: {RESET}").strip()
if choice.upper() == "Q":
print("Goodbye.")
sys.exit(0)
if choice in SCENARIOS:
scenario = SCENARIOS[choice]
break
print(f"{RED}Invalid choice. Try again.{RESET}")
print(f"\n{BOLD}{GREEN}Starting: {scenario['name']}{RESET}")
print(f"{DIM}You are: {scenario['your_role']} · {scenario['turns']} turns · Difficulty: {scenario['difficulty']}{RESET}")
print(f"\n{DIM}Tip: Propose redline language, cite playbook limits, use commercial arguments.{RESET}")
print(f"{DIM}Type 'score' at any point to end the session and receive your evaluation.{RESET}\n")
input(f"Press {BOLD}ENTER{RESET} to begin...\n")
# Show opening clause
print_clause_box("📄 OPENING CLAUSE (your position)", scenario["opening_clause"])
# Build message history for counterparty AI
system_prompt = build_counterparty_prompt(scenario)
history = [] # stores {"role": "user"/"assistant", "content": "..."}
ui_history = [] # same structure, used for scoring
# Counterparty opens
opening_msg = (
f"I've reviewed your proposed {scenario['type']} clause. "
f"Let me explain why this doesn't work for us and what we need instead."
)
init_response = client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
system=system_prompt,
messages=[{"role": "user", "content": opening_msg}],
)
cp_opening = init_response.content[0].text
history.append({"role": "user", "content": opening_msg})
history.append({"role": "assistant", "content": cp_opening})
ui_history.append({"role": "assistant", "content": cp_opening})
print_counterparty(cp_opening)
# ── Negotiation turns ────────────────────────────────────────────────────
turn = 0
while turn < scenario["turns"]:
turn += 1
prompt = f"{BOLD}[Turn {turn}/{scenario['turns']}] Your response:{RESET}\n> "
try:
user_input = input(prompt).strip()
except (KeyboardInterrupt, EOFError):
print("\nSession interrupted.")
break
if not user_input:
continue
if user_input.lower() in ("score", "exit", "quit", "q"):
break
ui_history.append({"role": "user", "content": user_input})
# Append to history for counterparty context
history.append({"role": "user", "content": user_input})
# Get counterparty response
print(f"{DIM}Counterparty is responding...{RESET}")
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=512,
system=system_prompt,
messages=history,
)
cp_reply = response.content[0].text
history.append({"role": "assistant", "content": cp_reply})
ui_history.append({"role": "assistant", "content": cp_reply})
print_counterparty(cp_reply)
if turn == scenario["turns"]:
print(f"\n{YELLOW}{BOLD}⏱ Final turn reached. Generating your score...{RESET}\n")
time.sleep(1)
# ── Scoring ──────────────────────────────────────────────────────────────
if not ui_history:
print(f"{YELLOW}No turns completed — no score generated.{RESET}")
return
print(f"\n{DIM}Analysing your negotiation...{RESET}")
scoring_prompt = build_scoring_prompt(scenario, ui_history)
score_response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": scoring_prompt}],
)
raw = score_response.content[0].text.strip()
# Strip markdown fences if present
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
try:
score_data = json.loads(raw)
except json.JSONDecodeError:
print(f"{RED}Could not parse score. Raw output:{RESET}\n{raw}")
return
print_score(score_data)
save_session(scenario, ui_history, score_data)
# Replay option
again = input(f"{BOLD}Run another scenario? (Y/N): {RESET}").strip().upper()
if again == "Y":
run()
if __name__ == "__main__":
run()