Kashish
Fixed app.py
8994735
Raw
History Blame Contribute Delete
12.4 kB
import asyncio
import os
import sys
import time
import subprocess
from pathlib import Path
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
# Ensure root is in sys.path
_root = str(Path(__file__).resolve().parent.parent)
if _root not in sys.path:
sys.path.insert(0, _root)
from env import LegalResearchEnv
from models import Observation, State, Action, StepResponse, ResetRequest, ResetResponse
# Global State
_env: Optional[LegalResearchEnv] = None
_is_ready: bool = False
_init_error: Optional[str] = None
# UI Content
LANDING_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legal Research OpenEnv ⚖️</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d1117;
--card-bg: #161b22;
--accent: #58a6ff;
--text-main: #c9d1d9;
--text-dim: #8b949e;
--success: #3fb950;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.6;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 2rem;
}
.header { text-align: center; margin-bottom: 3rem; max-width: 800px; }
.badge {
display: inline-block;
background: rgba(88, 166, 255, 0.1);
color: var(--accent);
padding: 0.4rem 1rem;
border-radius: 2rem;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 1rem;
border: 1px solid rgba(88, 166, 255, 0.2);
}
h1 { font-size: 3rem; font-weight: 800; margin-bottom: 1rem; color: #fff; }
.description { font-size: 1.1rem; color: var(--text-dim); margin-bottom: 2rem; }
.features {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-bottom: 3rem;
}
.feature-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500;
color: var(--accent);
}
.feature-item::before { content: '✓'; color: var(--success); font-weight: 900; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
width: 100%;
max-width: 1000px;
}
.card {
background: var(--card-bg);
border: 1px solid #30363d;
border-radius: 12px;
padding: 2rem;
transition: transform 0.2s, border-color 0.2s;
}
.card:hover { transform: translateY(-4px); border-color: var(--accent); }
.card-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.5rem;
margin-bottom: 1rem;
color: #fff;
}
.endpoint {
font-family: 'Courier New', monospace;
background: #0d1117;
padding: 0.5rem;
border-radius: 6px;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: #79c0ff;
}
.stat-line {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.stat-val { color: var(--success); font-weight: 700; }
.btn {
display: inline-block;
margin-top: 1.5rem;
background: var(--accent);
color: #fff;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
}
.footer { margin-top: 4rem; color: var(--text-dim); font-size: 0.9rem; }
.status-ready { color: var(--success); }
.status-loading { color: #f2cc60; animation: pulse 2s infinite; }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
</style>
</head>
<body>
<div class="header">
<div class="badge">⚖️ OpenEnv &middot; Legal Research RL Environment</div>
<h1>Teach AI Agents<br>to Research the Law</h1>
<p class="description">
An OpenEnv-compatible reinforcement learning environment where AI agents learn to search, read, extract and reason about real Supreme Court judgments over 26,000+ cases.
</p>
<div class="features">
<div class="feature-item">26,000+ Judgments indexed</div>
<div class="feature-item">3 Graders</div>
<div class="feature-item">BM25 + TF-IDF</div>
<div class="feature-item">Continuous Reward signal</div>
</div>
</div>
<div class="cards">
<div class="card">
<div class="card-title">📘 API Docs</div>
<p style="margin-bottom: 1rem; color: var(--text-dim);">
Interactive Swagger UI.
</p>
<div class="endpoint">POST /reset</div>
<div class="endpoint">POST /step</div>
<div class="endpoint">GET /state</div>
<a href="/docs" class="btn">Open Swagger UI &rarr;</a>
</div>
<div class="card">
<div class="card-title">💚 Health Status</div>
<p style="margin-bottom: 1rem; color: var(--text-dim);">
Live environment health check.
</p>
<div class="stat-line"><span>Status:</span> <span class="stat-val {{STATUS_CLASS}}">{{STATUS_TEXT}}</span></div>
<div class="stat-line"><span>Version:</span> <span class="stat-val">2.0.0</span></div>
<a href="/health" class="btn">Check health &rarr;</a>
</div>
</div>
<div class="footer">
GitHub &middot; Swagger UI &middot; OpenEnv Compliant
</div>
</body>
</html>
"""
# Background Initialization
async def bg_init_corpus():
global _env, _is_ready, _init_error
try:
start_t = time.time()
print("[INIT] Running corpus preparation check...")
subprocess.run(["python", "prepare_corpus.py"], check=False)
corpus_dir = os.getenv("CORPUS_DIR", "data/judgments")
print(f"[INIT] Loading LegalResearchEnv from: {corpus_dir}")
loop = asyncio.get_event_loop()
_env = await loop.run_in_executor(None, lambda: LegalResearchEnv(corpus_dir=corpus_dir))
_is_ready = True
duration = time.time() - start_t
docs = len(_env.search_engine.case_ids) if _env.search_engine else 0
print(f"[INIT] Environment ready in {duration:.2f}s. {docs} documents loaded.")
except Exception as e:
_init_error = str(e)
print(f"[ERROR] Initialization failed: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
print("[SERVER] Startup complete. Spawning background loader...")
asyncio.create_task(bg_init_corpus())
yield
print("[SERVER] Shutting down.")
app = FastAPI(
title="Legal Research OpenEnv ⚖️",
version="2.0.0",
description="OpenEnv environment for legal research on Indian Supreme Court judgments. Actions: search, read, extract, assess, submit.",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# --- Endpoints ---
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def root():
status_text = "ok" if _is_ready else "loading..."
status_class = "status-ready" if _is_ready else "status-loading"
# docs_loaded = len(_env.search_engine.case_ids) if _env and _env.search_engine else 0
html = LANDING_HTML.replace("{{STATUS_TEXT}}", status_text) \
.replace("{{STATUS_CLASS}}", status_class) \
return HTMLResponse(content=html)
@app.post("/reset", response_model=ResetResponse)
async def reset(
request: Optional[ResetRequest] = Body(
None,
openapi_examples={
"easy": {
"summary": "Easy task (constitutional law)",
"value": {"difficulty": "easy"}
},
"medium": {
"summary": "Medium task (criminal law)",
"value": {"difficulty": "medium"}
},
"hard": {
"summary": "Hard task (corporate law, multi-step)",
"value": {"difficulty": "hard"}
},
"empty": {
"summary": "No body (defaults to easy)",
"value": {}
}
}
)
):
""" Reset environment and start a new episode.
**Body (optional JSON):**
- `difficulty`: `"easy"` | `"medium"` | `"hard"` (default: `"easy"`)
- Sending an empty body `{}` or no body is also valid.
**Returns:** Observation with task description, available actions, and empty search results."""
if not _is_ready:
if _init_error: raise HTTPException(status_code=500, detail=_init_error)
raise HTTPException(status_code=503, detail="Corpus still loading...")
difficulty = "easy"
if request and request.difficulty:
difficulty = request.difficulty
obs = _env.reset(difficulty=difficulty)
return ResetResponse(observation=obs)
@app.post("/step", response_model=StepResponse)
async def step(action: Action):
"""
**Always call `/reset` first to start an episode.**
**Action examples** — paste one of these into the request body:
1. **Search** (use first — gets you case IDs):
```json
{"action_type": "search", "query": "constitutional fundamental rights article"}
```
2. **Read** (copy a `case_id` from search results above):
```json
{"action_type": "read", "case_id": "<paste case_id from search results>"}
```
3. **Extract** (after reading a document):
```json
{"action_type": "extract", "extraction_type": "legal_principle"}
```
4. **Assess** (hard task only — use string values, not objects):
```json
{"action_type": "assess", "assessment": {"precedent_application": "The cases establish director liability under fiduciary duty.", "conclusion": "Director is personally liable for breach of duty of care."}}
```
5. **Submit** (ends the episode and returns final score):
```json
{"action_type": "submit", "submission": {"summary": "Research complete", "cases_found": 3}}
```
"""
if not _is_ready:
raise HTTPException(status_code=503, detail="Corpus still loading...")
return _env.step(action)
@app.get("/state")
async def get_state():
"""Get current environment state."""
if not _is_ready: return {"status": "loading"}
return _env.state()
@app.get("/health")
async def health():
"""Health check endpoint."""
if not _is_ready: return {"status": "loading", "init_error": _init_error}
docs = len(_env.search_engine.case_ids) if _env and _env.search_engine else 0
return {
"status": "ok",
"environment": "legal_research_env",
"tasks": ["easy", "medium", "hard"]
}
@app.get("/validate")
async def validate():
"""Endpoint for OpenEnv automated task discovery."""
return {
"tasks": [
{"id": "easy", "name": "Constitutional Retrieval", "difficulty": "easy", "reward_range": [0.01, 0.99]},
{"id": "medium", "name": "Criminal Law Analysis", "difficulty": "medium", "reward_range": [0.01, 0.99]},
{"id": "hard", "name": "Corporate Liability Research", "difficulty": "hard", "reward_range": [0.01, 0.99]}
],
"grader_type": "heuristic_legal_grader",
"spec_version": "2.0.0"
}
def main():
import uvicorn
port = int(os.getenv("PORT", "7860"))
uvicorn.run("server.app:app", host="0.0.0.0", port=port, workers=1, reload=False)
if __name__ == "__main__":
main()