Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.gcloudignore
.git/
.gitignore
# Python virtual environments — the WinError 1920 crash source
.venv/
venv/
pdf_env/
__pycache__/
*.pyc
*.pyd
*.egg-info/
# Large data files
data/whetstone.db
data/raw/
data/cache/
*.parquet
*.sqlite
*.db
# Dev-only
.claude/
.vscode/
.idea/
# Team files (not needed in container)
合同模版文件库/
thesis/
outputs/
scripts/
tests/
0608/
W2/
.kaggle_ref/
# Docs
*.md
*.pdf
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
*.pdf
# exceptions — code files that happen to use .txt extension
!backend/requirements.txt
# README is the only tracked .md (shown on GitHub project page)
!README.md

# secrets
.env
Expand Down
116 changes: 116 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# ⚔️ Whetstone — Legal Training Dojo

> **Cambridge Hack the Law 2026** · Legora Challenge: *The Sparring Room*

![Whetstone Demo](frontend/demo.png)

A flight simulator for junior lawyers. Three AI-driven arenas where the opponent does NOT fold just because you sound confident.

---

## What It Does

Junior lawyers learn by doing — but real clients are expensive mistakes. Whetstone gives them infinite reps against an AI adversary that:

- Guards a **hidden reservation value** it will not reveal
- Blocks unearned concessions via a **concession gate** (independent referee LLM)
- Adapts difficulty to your historical performance (Beginner → Standard → Advanced)
- Scores every response with an **evidence-anchored rubric** — every verdict cites the exact turn and quote

---

## Three Arenas

| Arena | AI Role | Legal Focus |
|---|---|---|
| ⚖️ **Negotiation Table** | Opposing Counsel | UK M&A indemnity cap / SaaS liability cap (English law) |
| 🔥 **Hot Seat** | Senior Partner | GDPR Article 33 — breach notification to ICO |
| 🗣️ **Difficult Client** | Hostile Client | CDPA 1988 s.11(1) — commissioned works & IP ownership |

---

## Features

- **Intro Animation** — branded video splash screen on load, Skip › button, graceful fallback if video unavailable
- **3 Personas** — Hardball / Slippery / Stone Wall, each with distinct pressure tactics
- **Adaptive Difficulty** — Level 1–3 auto-set from score history
- **Voice Mode** — Web Speech API, `lang='en-GB'`, mic button in chat
- **Turning-Point Replay** — see the exact exchange where the round turned, with a stronger-play suggestion
- **PII Redaction** — Microsoft Presidio + regex fallback, human-in-the-loop approve/reject UI
- **Legal Corpus** — SEC EDGAR full-text search + CUAD dataset + EU Cellar API (GDPR, IP Directive)
- **Red/Blue Team Debate** — two AI lawyers debate; you identify the planted flaw
- **Progress Dashboard** — radial charts, sparklines, streak counter, coaching notes

---

## Tech Stack

```
Frontend React 18 (CDN) · Tailwind CSS · Web Speech API
Backend FastAPI · Python 3.13 · SQLite (WAL mode)
AI Models Claude Sonnet 4.6 (adversary) — OpenRouter
Gemini 2.0 Flash (judge/scoring) — Vertex AI (GCP)
Data SEC EDGAR EFTS · CUAD (HuggingFace) · EU Cellar SPARQL API
Safety Microsoft Presidio PII redaction
Deployment Google Cloud Run (europe-west2) · BigQuery corpus table
```

---

## Run Locally

```bash
git clone https://github.com/Vector897/whetstone
cd whetstone

# Create venv and install dependencies
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r backend/requirements.txt

# Configure API keys
copy .env.example .env
# Edit .env: set OPENROUTER_API_KEY

# Start server (frontend + backend in one)
uvicorn app.main:app --app-dir backend --host 127.0.0.1 --port 8000
# Open: http://localhost:8000
```

---

## Deploy to Google Cloud Run

```bash
# From D:\Whetstone — run from project root to avoid WinError 1920
cd D:\Whetstone
gcloud config set project whetstone-500713
gcloud run deploy whetstone \
--source . --port 8080 --allow-unauthenticated \
--region europe-west2 \
--set-env-vars="OPENROUTER_API_KEY=YOUR_KEY,GCP_PROJECT=whetstone-500713,VERTEX_LOCATION=europe-west2"
```

---

## Hackathon Compliance

| Requirement | Status |
|---|---|
| MUST 1 — Real legal scenario (UK IP + Data Privacy) | ✅ GDPR Art 33 · CDPA 1988 · English law M&A |
| MUST 2 — AI as adversary, not assistant | ✅ Hidden reservation value · concession gate referee |
| MUST 3 — Objective scoring with specific feedback | ✅ 5-dimension analytic rubric, evidence-anchored per turn |
| BONUS — Custom UI | ✅ Dark dojo theme, fixed sidebar |
| BONUS — Persona selector | ✅ Hardball / Slippery / Stone Wall |
| BONUS — Adaptive difficulty | ✅ 3 levels computed from score history |
| BONUS — Progress tracking | ✅ SQLite persistent, radial charts + sparklines |
| BONUS — Replay turning point | ✅ Modal with transcript + stronger-play coaching |
| BONUS — Voice mode | ✅ Web Speech API, en-GB |
| BONUS — Intro animation | ✅ Video splash screen with Skip › fallback |
| GCP — Cloud Run deployment | ✅ `europe-west2`, public URL |
| GCP — Vertex AI Gemini 2.0 Flash | ✅ Judge / scoring / referee calls |
| GCP — BigQuery corpus | ✅ `whetstone.corpus` table (SEC EDGAR + CUAD clauses) |

---

*Cambridge Hack the Law 2026 · Team Whetstone*
10 changes: 10 additions & 0 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ def init() -> None:
persona TEXT DEFAULT 'Hardball',
difficulty INTEGER DEFAULT 2
);
""")
# Migrate existing DBs that predate the persona/difficulty columns
for col, coldef in [("persona", "TEXT DEFAULT 'Hardball'"),
("difficulty", "INTEGER DEFAULT 2")]:
try:
_conn().execute(f"ALTER TABLE sessions ADD COLUMN {col} {coldef}")
_conn().commit()
except Exception:
pass # column already exists
Comment on lines +39 to +43
_conn().executescript("""
CREATE TABLE IF NOT EXISTS progress (
lawyer_id TEXT NOT NULL,
dimension TEXT NOT NULL,
Expand Down
18 changes: 17 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel

from . import config, state, db, llm
Expand Down Expand Up @@ -47,6 +47,22 @@ def index():
return FileResponse(FRONTEND)


@app.get("/intro.mp4")
def intro_video():
path = FRONTEND.parent / "intro.mp4"
if path.exists():
return FileResponse(str(path), media_type="video/mp4")
return Response(status_code=404)


@app.get("/demo.png")
def demo_image():
path = FRONTEND.parent / "demo.png"
if path.exists():
return FileResponse(str(path), media_type="image/png")
return Response(status_code=404)


# ── Health & meta ─────────────────────────────────────────────────────────────

@app.get("/api/health")
Expand Down
Binary file added frontend/demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 59 additions & 22 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,21 @@
#ls{position:fixed;inset:0;background:#070c18;display:flex;align-items:center;justify-content:center;z-index:999;transition:opacity .4s}
#ls.out{opacity:0;pointer-events:none}
.pulse-dot::after{content:'';position:absolute;inset:0;border-radius:50%;border:2px solid #ef4444;animation:pulse-ring 1.4s ease infinite}
#vi{position:fixed;inset:0;z-index:1001;background:#070c18;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:opacity .5s}
#vi.out{opacity:0;pointer-events:none}
#vi video{max-width:100%;max-height:90vh;object-fit:contain;border-radius:12px}
#vi-skip{position:absolute;bottom:32px;right:32px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#e2e8f4;font-family:'Inter',sans-serif;font-size:.8rem;font-weight:600;padding:8px 18px;border-radius:40px;cursor:pointer;transition:background .2s}
#vi-skip:hover{background:rgba(255,255,255,.16)}
</style>
</head>
<body>

<div id="ls">
<div id="vi">
<video id="vv" autoplay muted playsinline src="/intro.mp4"></video>
<button id="vi-skip">Skip ›</button>
</div>

<div id="ls" style="display:none">
<div style="text-align:center">
<div style="font-size:2.8rem;margin-bottom:10px">⚔️</div>
<div style="font-size:1.05rem;font-weight:800;color:#38bdf8;letter-spacing:.07em">WHETSTONE</div>
Expand Down Expand Up @@ -872,29 +882,56 @@ <h1 className="text-xl font-bold">{navItem?.label}</h1>
</script>
<script>
(function(){
var lb=document.getElementById("lb");
lb&&(lb.style.width="60%");
window.addEventListener("DOMContentLoaded",function(){
lb&&(lb.style.width="90%");
setTimeout(function(){
try{
var code=document.getElementById("src").textContent;
var out=Babel.transform(code,{presets:[["react",{runtime:"classic"}]]}).code;
(0,eval)(out);
lb&&(lb.style.width="100%");
var _started=false;
function startApp(){
if(_started)return;
_started=true;
// fade out video overlay
var vi=document.getElementById("vi");
if(vi){vi.classList.add("out");setTimeout(function(){vi.style.display="none";},520);}
// show loading screen
var ls=document.getElementById("ls");
if(ls){ls.style.display="flex";}
var lb=document.getElementById("lb");
lb&&(lb.style.width="60%");
setTimeout(function(){
lb&&(lb.style.width="90%");
setTimeout(function(){
var ls=document.getElementById("ls");
ls&&ls.classList.add("out");
setTimeout(function(){ls&&(ls.style.display="none");},450);
},200);
}catch(e){
document.getElementById("root").innerHTML=
'<pre style="color:#ef4444;padding:24px;white-space:pre-wrap;font-family:monospace">Boot error: '+(e&&e.message||e)+'\n\n'+(e&&e.stack||'')+'</pre>';
document.getElementById("ls").style.display="none";
console.error(e);
try{
var code=document.getElementById("src").textContent;
var out=Babel.transform(code,{presets:[["react",{runtime:"classic"}]]}).code;
(0,eval)(out);
lb&&(lb.style.width="100%");
setTimeout(function(){
ls&&ls.classList.add("out");
setTimeout(function(){ls&&(ls.style.display="none");},450);
},200);
}catch(e){
document.getElementById("root").innerHTML=
'<pre style="color:#ef4444;padding:24px;white-space:pre-wrap;font-family:monospace">Boot error: '+(e&&e.message||e)+'\n\n'+(e&&e.stack||'')+'</pre>';
if(ls)ls.style.display="none";
console.error(e);
}
},100);
},80);
}

window.addEventListener("DOMContentLoaded",function(){
var vv=document.getElementById("vv");
var skip=document.getElementById("vi-skip");
if(vv){
vv.addEventListener("ended",startApp);
vv.addEventListener("error",startApp);
// if video can't autoplay (blocked), fall back after 200ms
var playPromise=vv.play();
if(playPromise&&typeof playPromise.catch==="function"){
playPromise.catch(function(){startApp();});
}
Comment on lines +925 to +929
} else {
startApp();
}
},100);
});
if(skip){skip.addEventListener("click",startApp);}
});
})();
</script>
</body>
Expand Down
Binary file added frontend/intro.mp4
Binary file not shown.