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
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.venv/
.env
__pycache__/
*.pyc
*.pyo
*.pyd
data/*.db
data/whetstone.db
.git/
*.md
*.txt
*.pdf
Comment on lines +10 to +12
.kaggle_ref/
outputs/
thesis/
scripts/
tests/
0608/
W2/
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*.md
*.txt
*.pdf
# exceptions — code files that happen to use .txt extension
!backend/requirements.txt

# secrets
.env
Expand Down
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM python:3.13-slim

WORKDIR /app

# Install Python dependencies
COPY backend/requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

# Optional: Presidio NLP model (non-fatal if it fails)
RUN python -m spacy download en_core_web_sm 2>/dev/null || true

# Copy application source
COPY . .

# Cloud Run injects PORT; default 8080
ENV PORT=8080
EXPOSE 8080

CMD exec uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port ${PORT}
25 changes: 20 additions & 5 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
"""集中读取 .env 配置(项目根 D:\\Whetstone\\.env)。"""
"""Centralised config — reads .env at project root (D:\\Whetstone\\.env)."""
from pathlib import Path
import os
from dotenv import load_dotenv

# backend/app/config.py -> 项目根 = parents[2]
ROOT = Path(__file__).resolve().parents[2]
load_dotenv(ROOT / ".env")

OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
# ── OpenRouter ────────────────────────────────────────────────────────────────
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")

# ── Model routing ─────────────────────────────────────────────────────────────
# ADVERSARY_MODEL — main opponent (Claude for best adversarial quality)
# JUDGE_MODEL — scoring + concession-gate referee
# set to "gemini-2.0-flash-001" to use Vertex AI (GCP prize track)
# REDTEAM_MODEL — red/blue team debate
ADVERSARY_MODEL = os.getenv("ADVERSARY_MODEL", "anthropic/claude-3.7-sonnet")
JUDGE_MODEL = os.getenv("JUDGE_MODEL", "nvidia/llama-3.1-nemotron-70b-instruct")
REDTEAM_MODEL = os.getenv("REDTEAM_MODEL", "nvidia/llama-3.1-nemotron-70b-instruct")
JUDGE_MODEL = os.getenv(
"JUDGE_MODEL",
# Default to Gemini via Vertex AI if GCP_PROJECT is configured, else Nemotron
"gemini-2.0-flash-001" if os.getenv("GCP_PROJECT") else "nvidia/llama-3.1-nemotron-70b-instruct",
)
REDTEAM_MODEL = os.getenv("REDTEAM_MODEL", "nvidia/llama-3.1-nemotron-70b-instruct")

# ── GCP / Vertex AI ───────────────────────────────────────────────────────────
GCP_PROJECT = os.getenv("GCP_PROJECT", "") # frictionless account project id
VERTEX_LOCATION = os.getenv("VERTEX_LOCATION", "europe-west2")

# ── EU law ────────────────────────────────────────────────────────────────────
CELLAR_SPARQL = os.getenv("CELLAR_SPARQL", "https://publications.europa.eu/webapi/rdf/sparql")

# ── Paths ─────────────────────────────────────────────────────────────────────
SCENARIO_DIR = Path(__file__).resolve().parent / "scenarios"
118 changes: 118 additions & 0 deletions backend/app/data/bigquery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""BigQuery corpus layer — GCP prize track: 'A home for your data'.

Stores SEC EDGAR / CUAD legal clause corpus in BigQuery alongside SQLite.
Falls back gracefully to SQLite-only if BigQuery is unavailable or unconfigured.

Usage:
from .data import bigquery as bq
bq.ensure_table() # called once on startup
bq.insert(rows) # list of dicts matching SCHEMA
bq.search("liability_cap") # returns list of clause dicts
"""
from __future__ import annotations
import logging
from typing import Any

from .. import config

log = logging.getLogger(__name__)

DATASET = "whetstone"
TABLE = "corpus"

# ── Optional BigQuery import ──────────────────────────────────────────────────

try:
from google.cloud import bigquery as _bq
_BQ_OK = True
except ImportError:
_BQ_OK = False

# ── Internal helpers ──────────────────────────────────────────────────────────

_client_cache: Any = None


def _client():
global _client_cache
if not _BQ_OK or not config.GCP_PROJECT:
return None
if _client_cache is None:
_client_cache = _bq.Client(project=config.GCP_PROJECT)
return _client_cache


def _full_table() -> str:
return f"{config.GCP_PROJECT}.{DATASET}.{TABLE}"


# ── Public API ────────────────────────────────────────────────────────────────

def ensure_table() -> bool:
"""Auto-create BigQuery dataset + corpus table. Returns True if BQ is active."""
c = _client()
if not c:
return False
try:
ds_ref = _bq.Dataset(f"{config.GCP_PROJECT}.{DATASET}")
ds_ref.location = config.VERTEX_LOCATION or "EU"
c.create_dataset(ds_ref, exists_ok=True)
schema = [
_bq.SchemaField("id", "STRING", mode="REQUIRED"),
_bq.SchemaField("source", "STRING"),
_bq.SchemaField("clause_type", "STRING"),
_bq.SchemaField("text", "STRING"),
_bq.SchemaField("metadata", "JSON"),
_bq.SchemaField("inserted_at", "TIMESTAMP",
default_value_expression="CURRENT_TIMESTAMP()"),
]
tbl = _bq.Table(_full_table(), schema=schema)
c.create_table(tbl, exists_ok=True)
log.info("BigQuery table ready: %s", _full_table())
return True
except Exception as exc:
log.warning("BigQuery setup failed (will use SQLite fallback): %s", exc)
return False


def insert(rows: list[dict]) -> bool:
"""Stream-insert rows into BigQuery corpus table."""
c = _client()
if not c or not rows:
return False
try:
errors = c.insert_rows_json(_full_table(), rows)
if errors:
log.warning("BigQuery insert errors: %s", errors)
return len(errors) == 0
except Exception as exc:
log.warning("BigQuery insert failed: %s", exc)
return False


def search(clause_type: str, limit: int = 10) -> list[dict]:
"""Full-text search by clause_type in BigQuery. Returns [] on failure."""
c = _client()
if not c:
return []
try:
query = f"""
SELECT id, source, clause_type, text, metadata
FROM `{_full_table()}`
WHERE clause_type LIKE @clause_type
LIMIT @limit
"""
job_config = _bq.QueryJobConfig(query_parameters=[
_bq.ScalarQueryParameter("clause_type", "STRING", f"%{clause_type}%"),
_bq.ScalarQueryParameter("limit", "INT64", limit),
])
rows = c.query(query, job_config=job_config).result()
return [dict(r) for r in rows]
except Exception as exc:
log.warning("BigQuery search failed: %s", exc)
return []


def active() -> bool:
"""True if BigQuery is installed and GCP_PROJECT is configured."""
return _BQ_OK and bool(config.GCP_PROJECT)
32 changes: 28 additions & 4 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Comment on lines +63 to 68


Expand All @@ -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


Expand Down Expand Up @@ -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
Loading