bruuu01 commited on
Commit
fd62210
·
verified ·
1 Parent(s): 5334dc6

Deploy JurisGPT backend

Browse files
backend/app/main.py CHANGED
@@ -33,6 +33,7 @@ from app.routes import (
33
  eval as eval_routes,
34
  cases,
35
  lawyers,
 
36
  )
37
 
38
  # Import middleware
@@ -154,6 +155,11 @@ app.include_router(csrf_router, prefix="/api", tags=["csrf"])
154
  # ============== Authentication Routes ==============
155
  app.include_router(auth.router, prefix="/api/auth", tags=["authentication"])
156
 
 
 
 
 
 
157
  # ============== Core Business Routes ==============
158
  app.include_router(companies.router, prefix="/api/companies", tags=["companies"])
159
  app.include_router(matters.router, prefix="/api/matters", tags=["matters"])
 
33
  eval as eval_routes,
34
  cases,
35
  lawyers,
36
+ demo,
37
  )
38
 
39
  # Import middleware
 
155
  # ============== Authentication Routes ==============
156
  app.include_router(auth.router, prefix="/api/auth", tags=["authentication"])
157
 
158
+ # ============== Public Demo Route ==============
159
+ # Unauthenticated, rate-limited, read-only. Kept separate from /api/chat so the
160
+ # authenticated surface keeps its require_auth dependency untouched.
161
+ app.include_router(demo.router, prefix="/api/demo", tags=["demo"])
162
+
163
  # ============== Core Business Routes ==============
164
  app.include_router(companies.router, prefix="/api/companies", tags=["companies"])
165
  app.include_router(matters.router, prefix="/api/matters", tags=["matters"])
backend/app/middleware/csrf.py CHANGED
@@ -22,6 +22,10 @@ UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
22
  # headers, so cookie-based CSRF is moot for chat. Removing the exemption
23
  # closes the cross-site-POST hole the security audit flagged.
24
  CSRF_EXEMPT_PATHS = {
 
 
 
 
25
  "/api/webhooks/",
26
  "/api/auth/login",
27
  "/api/auth/register",
 
22
  # headers, so cookie-based CSRF is moot for chat. Removing the exemption
23
  # closes the cross-site-POST hole the security audit flagged.
24
  CSRF_EXEMPT_PATHS = {
25
+ # Public demo endpoint: unauthenticated and cookie-less, so there is no
26
+ # ambient credential for a cross-site POST to abuse. It has its own
27
+ # per-caller rate limit instead.
28
+ "/api/demo/",
29
  "/api/webhooks/",
30
  "/api/auth/login",
31
  "/api/auth/register",
backend/app/routes/._demo.py ADDED
Binary file (4.1 kB). View file
 
backend/app/routes/demo.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public, unauthenticated demo endpoint.
2
+
3
+ Exists so a conference audience can try JurisGPT without creating an account.
4
+
5
+ Deliberately a separate router rather than relaxing auth on `/api/chat`: the
6
+ authenticated chat surface keeps `Depends(require_auth)` untouched, and the
7
+ public surface is one narrow, rate-limited, read-only endpoint that is obvious
8
+ to audit. Widening the real endpoint would have been a smaller diff and a much
9
+ worse idea.
10
+
11
+ What this endpoint deliberately does NOT do:
12
+ - persist conversations or accept a conversation id
13
+ - accept conversation history (so it cannot be used to smuggle a long
14
+ context and run up model spend)
15
+ - expose document generation or any state-changing operation
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import time
22
+ from collections import deque
23
+ from typing import Deque, Dict, Final
24
+
25
+ from fastapi import APIRouter, HTTPException, Request
26
+ from pydantic import BaseModel, Field
27
+
28
+ from app.routes.chatbot import (
29
+ ChatMessageResponse,
30
+ ChatRequest,
31
+ _response_to_api,
32
+ chatbot_service,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ router = APIRouter(tags=["Demo"])
38
+
39
+ MAX_MESSAGE_LENGTH: Final[int] = 500
40
+ RATE_LIMIT_WINDOW_SECONDS: Final[float] = 60.0
41
+ RATE_LIMIT_MAX_REQUESTS: Final[int] = 5
42
+ MAX_TRACKED_CLIENTS: Final[int] = 10_000
43
+
44
+ # client key -> timestamps of recent requests, oldest first.
45
+ _request_log: Dict[str, Deque[float]] = {}
46
+
47
+
48
+ class DemoMessageRequest(BaseModel):
49
+ """A single demo question. No history, no conversation id, by design."""
50
+
51
+ message: str = Field(..., min_length=1, max_length=MAX_MESSAGE_LENGTH)
52
+
53
+
54
+ def _client_key(request: Request) -> str:
55
+ """Best-effort caller identity for throttling.
56
+
57
+ Behind a proxy the socket peer is the proxy, so prefer the forwarded
58
+ chain's first entry. This is spoofable and is not a security control —
59
+ it exists to blunt casual abuse of an unauthenticated endpoint.
60
+ """
61
+ forwarded = request.headers.get("x-forwarded-for")
62
+ if forwarded:
63
+ return forwarded.split(",")[0].strip()
64
+ return request.client.host if request.client else "unknown"
65
+
66
+
67
+ def _is_rate_limited(key: str, now: float) -> bool:
68
+ """Sliding window. Returns True when the caller has exhausted its quota."""
69
+ window_start = now - RATE_LIMIT_WINDOW_SECONDS
70
+
71
+ timestamps = _request_log.get(key)
72
+ if timestamps is None:
73
+ # Bound the map so a spray of distinct source addresses cannot grow it
74
+ # without limit. Dicts preserve insertion order, so this evicts oldest.
75
+ if len(_request_log) >= MAX_TRACKED_CLIENTS:
76
+ for stale_key in list(_request_log)[: MAX_TRACKED_CLIENTS // 10]:
77
+ _request_log.pop(stale_key, None)
78
+ timestamps = deque()
79
+ _request_log[key] = timestamps
80
+
81
+ while timestamps and timestamps[0] < window_start:
82
+ timestamps.popleft()
83
+
84
+ if len(timestamps) >= RATE_LIMIT_MAX_REQUESTS:
85
+ return True
86
+
87
+ timestamps.append(now)
88
+ return False
89
+
90
+
91
+ @router.post("/message", response_model=ChatMessageResponse)
92
+ async def demo_message(request: Request, body: DemoMessageRequest) -> ChatMessageResponse:
93
+ """Answer one legal question, with citations, without authentication."""
94
+ if _is_rate_limited(_client_key(request), time.monotonic()):
95
+ raise HTTPException(
96
+ status_code=429,
97
+ detail="Too many questions in a short time. Please wait a minute and try again.",
98
+ )
99
+
100
+ message = body.message.strip()
101
+ if not message:
102
+ raise HTTPException(status_code=400, detail="Please enter a question.")
103
+
104
+ try:
105
+ # Built directly rather than via chatbot._build_chat_request: that helper
106
+ # reads `context` and `conversation_history`, which this model
107
+ # intentionally does not carry.
108
+ chat_request = ChatRequest(message=message, context=None, conversation_history=None)
109
+ response = chatbot_service.get_legal_response(chat_request)
110
+ return _response_to_api(response)
111
+ except ValueError as exc:
112
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
113
+ except Exception:
114
+ # Log the detail server-side; return nothing specific. This endpoint is
115
+ # public, so upstream error text must not reach the caller.
116
+ logger.exception("Demo chat request failed")
117
+ raise HTTPException(
118
+ status_code=502,
119
+ detail="The legal assistant is unavailable right now. Please try again shortly.",
120
+ )