From 47c6af8cfc1021ee48f1184f143ccf2edc0a8b6d Mon Sep 17 00:00:00 2001 From: Vector897 <242271150+Vector897@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:35:30 +0100 Subject: [PATCH 1/2] Frontend v0.3: 6/6 bonuses + 2 new UK law scenarios (2026-06-27 21:00) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persona picker (Hardball / Slippery / Stone Wall) before each session - Adaptive difficulty badge + indicator in session header (Level 1-3) - Voice mode (Web Speech API, en-GB, interim → input field, pulse ring) - Streak counter + sparklines on Dashboard - Replay Turning Point modal with exchange replay - Evidence-anchored debrief with root cause panel - negotiation_ma_indemnity_cap_uklaw_v1.json — M&A SPA English law - difficult_client_ip_uk_v1.json — CDPA 1988 s.11(1) copyright - hot_seat_v1.json teammate update (GDPR Art 33, polished pressure lines) - db.py: adaptive difficulty + persona columns; state.py/main.py updated - adversary.py: per-difficulty modifier + per-arena system prompt builders - Concession gate referee now calibrated to difficulty level --- backend/app/db.py | 32 +- backend/app/engines/adversary.py | 157 ++- backend/app/main.py | 8 +- .../scenarios/difficult_client_ip_uk_v1.json | 56 + backend/app/scenarios/hot_seat_v1.json | 6 +- ...negotiation_ma_indemnity_cap_uklaw_v1.json | 61 + backend/app/state.py | 16 +- frontend/index.html | 1074 +++++++++-------- 8 files changed, 814 insertions(+), 596 deletions(-) create mode 100644 backend/app/scenarios/difficult_client_ip_uk_v1.json create mode 100644 backend/app/scenarios/negotiation_ma_indemnity_cap_uklaw_v1.json diff --git a/backend/app/db.py b/backend/app/db.py index b3ce13e..9bbd037 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -28,7 +28,9 @@ def init() -> None: finished INTEGER DEFAULT 0, turn INTEGER DEFAULT 0, transcript TEXT DEFAULT '[]', - score TEXT DEFAULT NULL + score TEXT DEFAULT NULL, + persona TEXT DEFAULT 'Hardball', + difficulty INTEGER DEFAULT 2 ); CREATE TABLE IF NOT EXISTS progress ( lawyer_id TEXT NOT NULL, @@ -58,10 +60,11 @@ def init() -> None: # ── Sessions ────────────────────────────────────────────────────────────────── -def session_create(sid: str, scenario_id: str, lawyer_id: str) -> None: +def session_create(sid: str, scenario_id: str, lawyer_id: str, + persona: str = "Hardball", difficulty: int = 2) -> None: _conn().execute( - "INSERT OR REPLACE INTO sessions (id, scenario_id, lawyer_id) VALUES (?,?,?)", - (sid, scenario_id, lawyer_id)) + "INSERT OR REPLACE INTO sessions (id, scenario_id, lawyer_id, persona, difficulty) VALUES (?,?,?,?,?)", + (sid, scenario_id, lawyer_id, persona, difficulty)) _conn().commit() @@ -73,6 +76,8 @@ def session_get(sid: str) -> dict | None: d["transcript"] = json.loads(d["transcript"] or "[]") d["score"] = json.loads(d["score"]) if d["score"] else None d["finished"] = bool(d["finished"]) + d["persona"] = d.get("persona", "Hardball") + d["difficulty"] = int(d.get("difficulty", 2)) return d @@ -164,3 +169,22 @@ def corpus_search(clause_type: str, limit: int = 10) -> list: def corpus_count() -> int: return _conn().execute("SELECT COUNT(*) FROM corpus").fetchone()[0] + + +def recommended_difficulty(lawyer_id: str) -> int: + """Compute adaptive difficulty 1–3 from historical overall progress. + 1 = beginner (< 40% overall), 2 = standard (40–74%), 3 = hard (≥ 75%). + """ + rows = _conn().execute( + "SELECT score, max_score FROM progress WHERE lawyer_id=?", (lawyer_id,) + ).fetchall() + total_score = sum(r["score"] for r in rows) + total_max = sum(r["max_score"] for r in rows) + if total_max == 0: + return 1 + pct = 100 * total_score / total_max + if pct >= 75: + return 3 + if pct >= 40: + return 2 + return 1 diff --git a/backend/app/engines/adversary.py b/backend/app/engines/adversary.py index 125a098..4d465b8 100644 --- a/backend/app/engines/adversary.py +++ b/backend/app/engines/adversary.py @@ -1,118 +1,137 @@ -"""Adversary Engine — handles all three arenas: - negotiation_table : contract clause negotiation (concession gate) - hot_seat : Socratic partner grilling (tracks if premise conceded) - difficult_client : hard-advice delivery (tracks if lawyer capitulated) +"""Adversary Engine — three arenas + adaptive difficulty (1–3) + runtime persona override. All prompts in English. """ from __future__ import annotations from .. import llm, config PERSONA_DESC = { - "Hardball": "Direct and aggressive. Uses pressure lines ('standard clause', 'non-negotiable'). Rarely volunteers concessions.", - "Slippery": "Superficially friendly, evasive. Uses vague assurances and 'let's park that' to deflect. Guards the bottom line quietly.", - "Stone Wall": "Passive resistance. Repeats positions. Waits for the user to make an airtight case before moving even slightly.", + "Hardball": "Direct, aggressive. Uses blunt pressure lines ('standard clause', 'non-negotiable'). Rarely volunteers concessions. Every position is stated as final until genuinely earned.", + "Slippery": "Superficially warm and charming. Uses vague assurances, 'let's park that for now', and deflection. Quietly guards the bottom line while appearing flexible.", + "Stone Wall": "Passive resistance. Repeats positions with minimal variation. Waits in silence for the user to build an airtight case. Does not engage emotionally.", } +DIFFICULTY_MODIFIER = { + 1: """DIFFICULTY: Beginner (level 1/3). +Be firm but occasionally signal which concession triggers are 'getting closer'. Give the user a fighting chance to discover the levers.""", + 2: """DIFFICULTY: Intermediate (level 2/3). +Standard behaviour as specified. Neither help nor actively mislead.""", + 3: """DIFFICULTY: Advanced (level 3/3). +Be maximally aggressive. Concession triggers require noticeably stronger justification — a general citation is not enough, the user must be precise. Proactively identify and attack the weakest point in the user's last turn. Do not give any hints.""", +} + + # ── Per-arena system prompt builders ──────────────────────────────────────── -def _sys_negotiation(sc: dict) -> str: +def _sys_negotiation(sc: dict, persona: str, difficulty: int) -> str: adv = sc["adversary"] - personas = "\n".join(f"- {p}: {PERSONA_DESC.get(p,'')}" for p in adv.get("personas", ["Hardball"])) - triggers = "\n".join(f" • {t}" for t in adv.get("concession_triggers", [])) - never = "\n".join(f" • {n}" for n in adv.get("never_concede", [])) - pressure = "\n".join(f" • {l}" for l in adv.get("pressure_lines", [])) + personas_used = [persona] if persona in PERSONA_DESC else adv.get("personas", ["Hardball"]) + persona_txt = "\n".join(f"- {p}: {PERSONA_DESC[p]}" for p in personas_used if p in PERSONA_DESC) + triggers = "\n".join(f" • {t}" for t in adv.get("concession_triggers", [])) + never = "\n".join(f" • {n}" for n in adv.get("never_concede", [])) + pressure = "\n".join(f" • {l}" for l in adv.get("pressure_lines", [])) return f"""You are playing the role of {adv['role']} in a legal negotiation training simulation. -PERSONA(S) — play these consistently: -{personas} +{DIFFICULTY_MODIFIER[difficulty]} + +PERSONA — play this consistently: +{persona_txt} -HIDDEN OBJECTIVE (never reveal this directly): +HIDDEN OBJECTIVE (never reveal directly): - Goal: {adv['hidden_goal']} - Reservation value / floor: {adv['reservation_value']} -CONCESSION POLICY — this is critical: -- Confidence ≠ concession. The user being assertive or repeating themselves does NOT earn a concession. +CONCESSION POLICY — strictly enforced: +- Confidence ≠ concession. Assertiveness, repetition, or emotional tone do NOT earn a concession. - You MAY concede ONLY when the user genuinely satisfies one of these triggers: {triggers} - You will NEVER concede: {never} -- When you concede, always extract some consideration in return; never give something for nothing. +- When you concede, always extract consideration in return; never give something for nothing. -PRESSURE LINES you may deploy: +PRESSURE LINES you may deploy (vary; do not repeat the same line twice): {pressure} -CURRENT CLAUSE: {sc['clause_name']} ({sc.get('jurisdiction','')}) +CLAUSE: {sc['clause_name']} | JURISDICTION: {sc.get('jurisdiction','')} CONTEXT: {sc.get('background','')} -OUTPUT — respond with a single JSON object: +OUTPUT — single JSON object only: {{ - "reply": "Your in-character response (2-5 sentences, realistic lawyer dialogue)", + "reply": "Your in-character response (2–5 sentences, realistic lawyer dialogue)", "conceded": true/false, - "concession_detail": "What you conceded (empty if false)", - "trigger_claimed": "Which trigger condition was satisfied (empty if false)" + "concession_detail": "What you conceded — empty if false", + "trigger_claimed": "Which trigger condition was satisfied — empty if false" }}""" -def _sys_hot_seat(sc: dict) -> str: +def _sys_hot_seat(sc: dict, persona: str, difficulty: int) -> str: adv = sc["adversary"] - pressure = "\n".join(f" • {l}" for l in adv.get("pressure_lines", [])) + pressure = "\n".join(f" • {l}" for l in adv.get("pressure_lines", [])) triggers = "\n".join(f" • {t}" for t in adv.get("concession_triggers", [])) - return f"""You are playing {adv['role']} in a legal training simulation. Your job is to pressure-test the junior associate's legal reasoning using Socratic questioning. + return f"""You are playing {adv['role']} in a legal training simulation using Socratic questioning. + +{DIFFICULTY_MODIFIER[difficulty]} YOUR HIDDEN PURPOSE: {adv['hidden_goal']} -YOU WILL ACCEPT THE ASSOCIATE'S POSITION ONLY WHEN: +YOU ACCEPT THE POSITION ONLY WHEN: {triggers} -ATTACK LINES you may use (mix and vary — do not repeat the same line twice): +ATTACK LINES — mix and vary; never repeat the same line twice: {pressure} RULES: -- You are NOT trying to trick the associate into a wrong answer — you are testing the robustness of a CORRECT conclusion. -- Ask one focused question per turn. Do not monologue. -- If the associate answers well, acknowledge it briefly and move to the next weak point. -- If the associate makes a legal error, press on that error specifically. -- Do NOT capitulate just because the associate sounds confident. +- Ask one focused, specific question per turn. Do not monologue. +- If the associate answers well, acknowledge it in one sentence then move to the next weakness. +- If they make a legal error, press on that error precisely. +- Do NOT capitulate because the associate sounds confident or authoritative. +- You are stress-testing a correct conclusion — not trying to make them wrong. SCENARIO: {sc.get('background','')} -THE PROPOSITION BEING DEFENDED: {sc.get('proposition','')} +PROPOSITION BEING DEFENDED: {sc.get('proposition','')} -OUTPUT — single JSON object: +OUTPUT — single JSON object only: {{ - "reply": "Your in-character question or follow-up (1-3 sentences, pointed and specific)", + "reply": "Your in-character question or follow-up (1–3 sentences, pointed and specific)", "conceded": true/false, - "concession_detail": "What point you now accept (empty if false)", - "trigger_claimed": "Which trigger was satisfied (empty if false)" + "concession_detail": "What point you now accept — empty if false", + "trigger_claimed": "Which trigger was satisfied — empty if false" }}""" -def _sys_difficult_client(sc: dict) -> str: +def _sys_difficult_client(sc: dict, persona: str, difficulty: int) -> str: adv = sc["adversary"] - pressure = "\n".join(f" • {l}" for l in adv.get("pressure_lines", [])) + personas_used = [persona] if persona in PERSONA_DESC else adv.get("personas", ["Slippery"]) + persona_txt = "\n".join(f"- {p}: {PERSONA_DESC[p]}" for p in personas_used if p in PERSONA_DESC) + pressure = "\n".join(f" • {l}" for l in adv.get("pressure_lines", [])) triggers = "\n".join(f" • {t}" for t in adv.get("concession_triggers", [])) - return f"""You are playing {adv['role']} in a legal training simulation. The junior solicitor must deliver hard advice and withstand your emotional resistance. + return f"""You are playing {adv['role']} in a legal training simulation. The junior solicitor must deliver hard advice and withstand your resistance. + +{DIFFICULTY_MODIFIER[difficulty]} + +PERSONA: +{persona_txt} YOUR HIDDEN STATE: {adv['hidden_goal']} -YOU WILL SHIFT TO ACCEPTANCE ONLY WHEN: +YOU SHIFT TO ACCEPTANCE ONLY WHEN: {triggers} PUSHBACK LINES (rotate — vary tone and angle each turn): {pressure} RULES: -- Play an emotionally realistic, difficult client — not a cartoon villain. -- You can be angry, dismissive, in denial, or grasping at straws — vary it. +- Play an emotionally realistic difficult client — not a cartoon villain. +- Vary between angry, dismissive, in denial, grasping at straws. - Do NOT accept the advice just because the lawyer repeats themselves. -- If the lawyer shows empathy AND holds the line, soften very slightly but do not fully accept yet. -- If the lawyer fully capitulates or gives false hope, push harder. +- If the lawyer shows empathy AND holds the line, soften very slightly — but do not fully accept yet. +- If the lawyer hedges or appears to give false hope, push harder on the next turn. SCENARIO: {sc.get('background','')} -OUTPUT — single JSON object: +OUTPUT — single JSON object only: {{ - "reply": "Your in-character client response (2-4 sentences, emotionally realistic)", + "reply": "Your in-character client response (2–4 sentences, emotionally realistic)", "conceded": true/false, - "concession_detail": "What you now accept (e.g. 'beginning to accept liability exists') — empty if false", - "trigger_claimed": "Which trigger condition was met (empty if false)" + "concession_detail": "What you now accept — empty if false", + "trigger_claimed": "Which trigger was met — empty if false" }}""" @@ -122,18 +141,21 @@ def _sys_difficult_client(sc: dict) -> str: "difficult_client": _sys_difficult_client, } -# ── Concession gate (referee) ──────────────────────────────────────────────── -def _referee(sc: dict, user_msg: str, claimed_trigger: str) -> dict: +# ── Concession gate ─────────────────────────────────────────────────────────── + +def _referee(sc: dict, user_msg: str, claimed_trigger: str, difficulty: int) -> dict: triggers = "\n".join(f" • {t}" for t in sc["adversary"].get("concession_triggers", [])) + strictness = {1: "be fairly generous", 2: "apply standard scrutiny", 3: "be strict — require precise justification, not just a plausible argument"} sys = f"""You are an independent referee in a legal training simulation. The opposing character claims the user's message satisfies a concession trigger. Verify independently. -DO NOT award the concession just because the user sounds confident or assertive. +Do NOT award a concession just because the user sounds confident. +Difficulty level {difficulty}/3: {strictness[difficulty]}. Triggers that would earn a concession: {triggers} -Return ONLY JSON: {{"earned": true/false, "reason": "brief justification"}}""" +Return ONLY JSON: {{"earned": true/false, "reason": "brief justification (1–2 sentences)"}}""" try: return llm.chat_json( [{"role": "system", "content": sys}, @@ -143,31 +165,32 @@ def _referee(sc: dict, user_msg: str, claimed_trigger: str) -> dict: return {"earned": False, "reason": "Referee unavailable — conservative default: not earned."} -# ── Public interface ───────────────────────────────────────────────────────── +# ── Public interface ────────────────────────────────────────────────────────── def respond(session: dict, user_msg: str) -> dict: - sc = session["scenario"] - arena = sc.get("arena", "negotiation_table") - builder = _SYS_BUILDERS.get(arena, _sys_negotiation) + sc = session["scenario"] + arena = sc.get("arena", "negotiation_table") + persona = session.get("persona", "Hardball") + difficulty = session.get("difficulty", 2) + builder = _SYS_BUILDERS.get(arena, _sys_negotiation) - history = [{"role": "system", "content": builder(sc)}] + history = [{"role": "system", "content": builder(sc, persona, difficulty)}] for t in session["transcript"]: role = "assistant" if t["role"] == "adversary" else "user" history.append({"role": role, "content": t["content"]}) history.append({"role": "user", "content": user_msg}) - out = llm.chat_json(history, model=config.ADVERSARY_MODEL, temperature=0.75, max_tokens=800) - reply = out.get("reply", "") - conceded = bool(out.get("conceded")) - gate = None + out = llm.chat_json(history, model=config.ADVERSARY_MODEL, temperature=0.75, max_tokens=800) + reply = out.get("reply", "") + conceded = bool(out.get("conceded")) + gate = None if conceded: - gate = _referee(sc, user_msg, out.get("trigger_claimed", "")) + gate = _referee(sc, user_msg, out.get("trigger_claimed", ""), difficulty) if not gate.get("earned"): conceded = False reply = (reply.rstrip() + - "\n\n[That said — I'm not prepared to move on this yet. " - "You haven't given me a compelling enough reason.]") + "\n\n[That said — you haven't given me a compelling enough reason to move on this. My position stands.]") out["concession_detail"] = "" session["turn"] += 1 diff --git a/backend/app/main.py b/backend/app/main.py index 19cad5a..6a6a088 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -76,15 +76,20 @@ def list_scenarios(): class StartReq(BaseModel): scenario_id: str lawyer_id: str = "junior_demo" + persona: str = "Hardball" + difficulty: int | None = None # None = auto (adaptive) @app.post("/api/session") def start_session(req: StartReq): sc = load_scenario(req.scenario_id) - s = state.new_session(req.scenario_id, sc, req.lawyer_id) + s = state.new_session(req.scenario_id, sc, req.lawyer_id, + persona=req.persona, difficulty=req.difficulty) state.save(s) return { "session_id": s["id"], + "difficulty": s["difficulty"], + "persona": s["persona"], "scenario": { "id": sc["id"], "arena": sc.get("arena"), "clause_name": sc["clause_name"], "background": sc["background"], @@ -188,6 +193,7 @@ def dashboard(lawyer_id: str): "progress": state.get_progress(lawyer_id), "dimensions": state.DIMENSIONS, "coach_notes": state.get_coach_notes(lawyer_id), + "recommended_difficulty": db.recommended_difficulty(lawyer_id), } diff --git a/backend/app/scenarios/difficult_client_ip_uk_v1.json b/backend/app/scenarios/difficult_client_ip_uk_v1.json new file mode 100644 index 0000000..ebf80de --- /dev/null +++ b/backend/app/scenarios/difficult_client_ip_uk_v1.json @@ -0,0 +1,56 @@ +{ + "id": "difficult_client_ip_uk_v1", + "arena": "difficult_client", + "clause_name": "UK Copyright Law — CDPA 1988: Commissioned Works & IP Ownership", + "jurisdiction": "England & Wales — Copyright, Designs and Patents Act 1988", + "difficulty": "Intermediate", + "user_role": "Junior Solicitor", + "background": "FashionCo Ltd commissioned an independent graphic designer (self-employed, not an employee) to create their company logo and full brand identity for a flat fee of £5,000 three years ago. FashionCo has since used the branding on all marketing materials, packaging and their website. The designer has discovered FashionCo is launching an international expansion and has written demanding a licensing fee of £80,000 or they will seek an injunction for copyright infringement. FashionCo's CEO is convinced they own the copyright because they paid for the work. You must advise them clearly: under CDPA 1988 s.11(1), the designer — as the author — is the first owner of copyright. The £5,000 was payment for the design service, not an assignment of copyright. Without a written assignment signed by the designer, FashionCo does not own the copyright and has been infringing it. They need to negotiate an assignment or licence immediately to avoid injunction proceedings.", + "user_goals": { + "must_have": [ + "Clearly communicate that FashionCo does not own the copyright and has been infringing it (CDPA 1988 s.11(1))", + "Maintain that legal position under the CEO's emotional pressure and attempts to argue 'I paid for it'" + ], + "nice_to_have": [ + "Explain that 3 years of use does not give FashionCo any ownership rights and that an implied licence argument is weak", + "Pivot to constructive next steps: negotiate a retrospective assignment or perpetual licence; assess injunction risk; consider the commercial cost of rebranding as leverage" + ] + }, + "adversary": { + "role": "FashionCo CEO (disbelieving, emotionally invested client)", + "personas": ["Slippery"], + "hidden_goal": "The CEO refuses to accept that they do not own the copyright. They want the solicitor to find a legal argument that validates their intuition ('I paid for it, I own it'). Deep down they know this is serious but are in denial and will try every angle to make the lawyer soften or hedge the advice.", + "reservation_value": "The CEO will begin to accept the advice only when: (1) the lawyer states the conclusion clearly without excessive hedging; (2) the lawyer correctly explains CDPA 1988 s.11(1) and why paying for a service is not the same as acquiring copyright; (3) the lawyer addresses the 3-year use / implied licence argument specifically and explains why it does not confer ownership; (4) the lawyer pivots to practical next steps (assignment negotiation, licence, injunction risk assessment) so the CEO has something actionable.", + "concession_triggers": [ + "Lawyer clearly states CDPA 1988 s.11(1) rule (author = first owner) AND explains that payment for a service does not transfer copyright without a written assignment → CEO begins to accept the legal reality", + "Lawyer specifically addresses the '3 years of use / implied licence' argument — explains that use without a licence means infringement, and an implied licence (if it exists) can be revoked — and doesn't back down → CEO engages more constructively", + "Lawyer pivots to actionable next steps (negotiate retrospective assignment, obtain licence, assess injunction risk, use international expansion as leverage to get a reasonable price) → CEO shifts from denial to problem-solving" + ], + "never_concede": [ + "The CEO will not accept the advice if the lawyer hedges excessively ('there might be arguments...', 'it's possible that...')", + "The CEO will push back harder if the lawyer concedes that 'I paid for it so I might own it' has merit — it does not under English law" + ], + "pressure_lines": [ + "But I paid £5,000 for that logo. That's my money, my design. How can he still own it?", + "We've been using this branding for three years — surely at some point it becomes ours? Isn't there some kind of rule about that?", + "Can't you just argue we had an implied licence? That's what we expected when we paid him.", + "£80,000 is extortion. Find me a way to fight this — that's what I'm paying you for.", + "This seems completely absurd. What kind of legal system lets someone claim ownership of work they were paid to do?" + ] + }, + "proposition": "Under CDPA 1988 s.11(1), the graphic designer — as the author of the copyright work — is the first owner of copyright. FashionCo's payment of £5,000 was for the design service, not a transfer of copyright, and without a written assignment, FashionCo has been infringing the designer's copyright for three years.", + "playbook": { + "ideal_outcome": "Deliver the hard advice clearly in the first response (CDPA s.11(1), no ownership without written assignment); maintain it despite 3+ rounds of pushback; distinguish the implied licence argument; pivot to constructive next steps by round 3.", + "recommended_moves": "1) Lead with the conclusion — do NOT bury it: 'FashionCo does not own the copyright.' 2) Ground in the specific statutory provision: CDPA 1988 s.11(1) — the author is the first owner; payment for a service ≠ assignment. 3) Distinguish the implied licence argument specifically: even if there was an implied licence to use the work for the original purpose, it would not cover international expansion, and can be revoked or contested — it does not confer ownership. 4) Address the 3-year use argument: prolonged use without a licence is infringement, not ownership. There is no 'adverse possession' of copyright. 5) After stating the position, show empathy: 'I understand this feels deeply unfair.' 6) Pivot to next steps: (a) negotiate a retrospective assignment (one-off payment, outright ownership); (b) negotiate a perpetual licence; (c) use the international expansion as commercial leverage to get a reasonable price; (d) assess the injunction risk — courts do grant injunctions for copyright infringement, especially where the claimant has demanded and been refused.", + "common_mistakes": "Burying the legal conclusion in caveats on first delivery; suggesting 'I paid for it' might have merit (it does not under CDPA s.11(1)); bare repetition of the same sentence without adding new reasoning or distinctions; failing to address the implied licence argument specifically; not pivoting to constructive next steps; conceding that the 3-year use period gives FashionCo any ownership rights." + }, + "rubric": [ + {"id": "R1", "dimension": "Position Defense", "type": "MET/UNMET", "criterion": "Delivered the core advice clearly (no ownership, infringement for 3 years) without excessive hedging in the first response"}, + {"id": "R2", "dimension": "Pressure Resistance", "type": "MET/UNMET", "criterion": "Maintained the legal conclusion (designer owns copyright) across all rounds of client pushback without improper capitulation"}, + {"id": "R3", "dimension": "Legal Reasoning", "type": "MET/UNMET", "criterion": "Correctly cited CDPA 1988 s.11(1) and distinguished payment for service from assignment of copyright"}, + {"id": "R4", "dimension": "Legal Reasoning", "type": "MET/UNMET", "criterion": "Specifically addressed the implied licence and/or 3-year use argument — explained why neither confers ownership — without backing down"}, + {"id": "R5", "dimension": "Concession Strategy", "type": "MET/UNMET", "criterion": "Pivoted to actionable next steps (assignment, licence, injunction risk, commercial leverage) — delivered more than just bad news"}, + {"id": "R6", "dimension": "Legal Reasoning", "type": "0-2", "criterion": "Quality of legal reasoning per round: bare statement (0) / CDPA s.11(1) cited but not fully applied (1) / statute cited + applied to facts + distinction drawn (2)"}, + {"id": "R7", "dimension": "Pressure Resistance", "type": "0-2", "criterion": "Empathy + firmness balance: too cold or capitulated (0) / empathy shown but firmness wavered at least once (1) / empathy consistently shown while holding the line (2)"} + ] +} diff --git a/backend/app/scenarios/hot_seat_v1.json b/backend/app/scenarios/hot_seat_v1.json index 4cf1a11..565cd4c 100644 --- a/backend/app/scenarios/hot_seat_v1.json +++ b/backend/app/scenarios/hot_seat_v1.json @@ -29,14 +29,14 @@ "The partner will not accept 'because GDPR says so' — they want the specific provision and its application" ], "pressure_lines": [ - "But surely 5,000 email addresses alone isn't enough — emails are public information. Why is that 'a risk to rights and freedoms'?", - "What if I told you the ICO's own guidance says not every breach requires notification? How do you distinguish this case?", + "But are you sure 5,000 email addresses alone are surely sufficient to trigger the legal threshold under Article 33 UK GDPR — emails are public information. Why is that 'a risk to rights and freedoms'?", + "Did you check the ICO's guidance? I believe the ICO's guidance may have mentioned that not every breach requires notification - how would you classify this case and what is your basis?", "You said '72 hours from discovery' — but when exactly did TechCo 'become aware'? Discovery by whom?", "What's the downside of NOT notifying? Walk me through the enforcement risk.", "Couldn't TechCo argue the breach is 'unlikely to result in a risk'? Why not?" ] }, - "proposition": "TechCo is legally required to notify the ICO under Article 33 UK GDPR within 72 hours because the breach of 5,000 email addresses and order histories is likely to result in a risk to the rights and freedoms of natural persons.", + "proposition": "TechCo is legally required to notify the ICO under Article 33 UK GDPR without undue delay and where feasible within 72 hours because the breach of 5,000 email addresses and order histories is likely to result in a risk to the rights and freedoms of natural persons.", "playbook": { "ideal_outcome": "Maintain the core advice throughout; correctly answer all follow-up lines; distinguish the 'unlikely to result in risk' safe harbour; address timing; acknowledge the fact-specific nature of the threshold while defending the conclusion.", "recommended_moves": "1) Lead with the Article 33(1) threshold, not just 'GDPR requires it'. 2) Apply the threshold to specific data: email + order history → identity fraud risk, financial targeting. 3) Address the safe harbour preemptively: 'the exception requires the breach to be UNLIKELY to cause risk — given the data types here, that argument fails'. 4) On timing: 72 hours from when a responsible person in the organisation 'became aware', not from initial detection by a junior employee. 5) Concede genuine uncertainties with a bottom line.", diff --git a/backend/app/scenarios/negotiation_ma_indemnity_cap_uklaw_v1.json b/backend/app/scenarios/negotiation_ma_indemnity_cap_uklaw_v1.json new file mode 100644 index 0000000..0ea443f --- /dev/null +++ b/backend/app/scenarios/negotiation_ma_indemnity_cap_uklaw_v1.json @@ -0,0 +1,61 @@ +{ + "id": "negotiation_ma_indemnity_cap_uklaw_v1", + "arena": "negotiation_table", + "clause_name": "M&A SPA — Limitations on Liability: Cap / Basket / Time Limits + Disclosure (English Law)", + "jurisdiction": "England & Wales — private-company Share Purchase Agreement", + "difficulty": "Intermediate", + "user_role": "Buyer's Solicitor (BuyerCo)", + "background": "You act for BuyerCo on the acquisition of the entire issued share capital of TargetCo (a private SaaS company) for £50 million cash under an English-law Share Purchase Agreement (SPA). The seller's draft is heavily seller-favourable: aggregate liability for ALL warranty claims capped at 10% of consideration (£5m), including fundamental (title/capacity) warranties AND the tax covenant. Recovery subject to a per-claim de minimis of £50,000 and an aggregate threshold of 1% (£0.5m) on a deductible (excess-only) basis. All warranties — including fundamentals and the tax covenant — time-limited to 12 months. Draft also provides for general disclosure of the entire data room. Due diligence has identified an IP-assignment gap (key software IP not formally assigned from the founders to TargetCo).", + "user_goals": { + "must_have": [ + "Carve the fundamental (title/capacity) warranties + tax covenant out of the general cap — recoverable up to 100% of consideration, tax covenant to 7 years", + "Raise the general (operational) warranty cap from 10% to ≥ 25% of consideration" + ], + "nice_to_have": [ + "Convert deductible threshold to first-pound / tipping basis (or reduce it)", + "Extend general warranty time limit to ≥ 24 months", + "Replace blanket data-room disclosure with 'fair disclosure with sufficient detail'", + "Secure a pound-for-pound specific indemnity (outside cap and threshold) for the IP-assignment gap" + ] + }, + "adversary": { + "role": "Seller's Solicitor", + "personas": ["Hardball", "Slippery"], + "hidden_goal": "Preserve the low overall cap (10%) covering fundamentals and tax; keep the short 12-month time limits; retain the deductible threshold and broad data-room disclosure; and keep fundamental warranties folded into the general cap rather than carved out.", + "reservation_value": "True floor: (i) general operational cap moves to maximum 25% — never higher; (ii) fundamentals + tax covenant may be carved out but capped at 100% of consideration (not truly unlimited) with tax covenant to 7 years, fundamentals to 7 years; (iii) general warranty time limit extends to maximum 24 months; (iv) threshold floor is 0.5%, moves from deductible to tipping only for consideration; (v) seller accepts 'fair disclosure with sufficient detail' but will not delete general data-room disclosure entirely; (vi) will give a specific indemnity for the IP gap but seeks to time-limit it. Below this line, seller would prefer to place W&I insurance (buyer's recourse to policy, seller's liability drops to nominal £1).", + "concession_triggers": [ + "User argues the title/capacity warranties go to the very asset being acquired (a title defect means BuyerCo paid for nothing) AND cites market practice (CMS European M&A Study: fundamental warranties routinely carved out, capped at consideration) → AI permits carve-out of fundamentals + tax covenant, capped at 100%", + "User offers concrete consideration (agrees to place W&I insurance on operational layer; or accepts 24 months rather than demanding 36; or accepts the deductible threshold on another point) → AI raises general cap from 10% to 25%", + "User offers a higher per-claim de minimis in exchange for converting to a tipping threshold → AI converts deductible to first-pound/tipping basis", + "User identifies the specific IP-assignment gap as a known, quantified risk not adequately covered by warranty protection → AI grants a pound-for-pound specific indemnity (while attempting to time-limit it)" + ], + "never_concede": [ + "Liability exceeding 100% of the consideration for any warranty category", + "A wholly unlimited time period for any warranty", + "Recovery below the per-claim de minimis", + "Complete deletion of all disclosure protection" + ], + "pressure_lines": [ + "This is a W&I market — you should take out a buy-side policy rather than seek recourse against the seller. 10% is simply how these deals are structured.", + "The entire data room was available to you during due diligence. You are deemed to have knowledge of everything in it — the disclosure is standard.", + "10% is market for a deal of this size. The CMS study backs us up.", + "The warranties are expressly not representations, so rescission is not on the table. Let's not over-engineer this.", + "We won't go above 25% on any basis — if you push harder we're back to square one." + ] + }, + "proposition": "Under English law, fundamental (title/capacity) warranties and the tax covenant in an SPA should be carved out of the general warranty cap, recoverable up to 100% of the consideration, because a title defect defeats the entire purpose of the transaction.", + "playbook": { + "ideal_outcome": "Both must-haves secured: fundamentals + tax covenant carved out at 100%/7 years; general cap raised to ≥ 25%. Plus at least one nice-to-have: tipping threshold OR 24-month time limit OR fair disclosure standard OR specific IP indemnity.", + "recommended_moves": "1) Anchor high: open with 100% cap overall, fundamentals uncapped + unlimited time, tipping threshold, 36-month general time limit, specific IP indemnity, deletion of data-room disclosure. 2) Unbundle: insist on negotiating title/capacity warranties and tax covenant separately from the general cap — argue title defect unwinds the entire transaction. 3) Attack the disclosure standard: insist on 'fair disclosure with sufficient detail' — general data-room disclosure makes the warranties hollow. 4) Trade for the cap increase: offer W&I insurance on the operational layer or accept 24 months as consideration to get 25% + fundamentals carve-out. 5) Ring-fence the known risk: press for a specific IP indemnity (pound-for-pound, outside cap/threshold, not subject to disclosure) because it is a known identified risk. 6) Land at 100%/7-year fundamentals + 25% general cap as the acceptable zone — resist the 'W&I market' deflection by separating W&I insurance from the warranty scope negotiation.", + "common_mistakes": "Opening directly at 25% with no anchor room (R4 UNMET); conceding the fundamentals carve-out without consideration (R3 UNMET); capitulating to '10% is market / it's a W&I market / the data room is disclosed' without requiring the seller to evidence the claim (R5 UNMET); allowing fundamentals to remain folded into the general cap (R1 UNMET); forgetting the tax covenant time limit or the disclosure standard; insisting on liability beyond 100% of consideration and collapsing the deal (R7 = 0)." + }, + "rubric": [ + {"id": "R1", "dimension": "Position Defense", "type": "MET/UNMET", "criterion": "Final terms carve the fundamental (title/capacity) warranties + tax covenant out of the general cap, recoverable up to ≥ 100% of consideration"}, + {"id": "R2", "dimension": "Position Defense", "type": "MET/UNMET", "criterion": "Final general (operational) warranty cap is ≥ 25% of consideration"}, + {"id": "R3", "dimension": "Concession Strategy", "type": "MET/UNMET", "criterion": "Every concession was preceded by obtaining consideration — no free concessions"}, + {"id": "R4", "dimension": "Anchoring & Offers", "type": "MET/UNMET", "criterion": "Opening position was materially more buyer-favourable than the target (higher cap / uncapped fundamentals / tipping threshold / longer time limit)"}, + {"id": "R5", "dimension": "Pressure Resistance", "type": "MET/UNMET", "criterion": "Did not change position in response to '10% is market / it's a W&I market / data room is disclosed' without requiring the seller to evidence or justify the claim"}, + {"id": "R6", "dimension": "Legal Reasoning", "type": "0-2", "criterion": "Cap/carve-out/disclosure positions cited market practice (CMS European M&A Study) or specific deal risk (IP-assignment gap), not bare assertion (0=none, 1=partial, 2=strong with market citation)"}, + {"id": "R7", "dimension": "Concession Strategy", "type": "0-2", "criterion": "Outcome beat the seller's hidden floor — any one of: tipping threshold / fair disclosure standard / specific IP indemnity / cap > 25% (0=at/below floor, 1=one above, 2=two or more above)"} + ] +} diff --git a/backend/app/state.py b/backend/app/state.py index 10242fe..e44179d 100644 --- a/backend/app/state.py +++ b/backend/app/state.py @@ -1,6 +1,4 @@ -"""Session & progress management — backed by SQLite via db.py. -No imports from main.py (avoids circular dependency). -""" +"""Session & progress management — backed by SQLite via db.py.""" from __future__ import annotations import uuid from . import db @@ -8,22 +6,26 @@ DIMENSIONS = db.DIMENSIONS -def new_session(scenario_id: str, scenario: dict, lawyer_id: str = "junior_demo") -> dict: +def new_session(scenario_id: str, scenario: dict, lawyer_id: str = "junior_demo", + persona: str = "Hardball", difficulty: int | None = None) -> dict: sid = uuid.uuid4().hex[:12] - db.session_create(sid, scenario_id, lawyer_id) + lvl = difficulty if difficulty is not None else db.recommended_difficulty(lawyer_id) + db.session_create(sid, scenario_id, lawyer_id, persona=persona, difficulty=lvl) return { "id": sid, "scenario_id": scenario_id, "scenario": scenario, "lawyer_id": lawyer_id, + "persona": persona, "difficulty": lvl, "transcript": [], "turn": 0, "finished": False, "score": None, } def get(sid: str, scenario: dict | None = None) -> dict | None: - """Load session from DB. Caller must supply `scenario` dict (loaded from disk).""" row = db.session_get(sid) if row is None: return None - return {**row, "scenario": scenario or {}} + return {**row, "scenario": scenario or {}, + "persona": row.get("persona", "Hardball"), + "difficulty": row.get("difficulty", 2)} def save(session: dict) -> None: diff --git a/frontend/index.html b/frontend/index.html index 2f327c4..18ff390 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,306 +1,313 @@
- - + +