Spaces:
Sleeping
Sleeping
Enable anonymous app access and OpenAI query embeddings
Browse files- README.md +2 -2
- backend/app.py +76 -14
README.md
CHANGED
|
@@ -4,6 +4,6 @@ sdk: docker
|
|
| 4 |
app_port: 7860
|
| 5 |
---
|
| 6 |
|
| 7 |
-
|
| 8 |
|
| 9 |
-
Configure OPENAI_API_KEY
|
|
|
|
| 4 |
app_port: 7860
|
| 5 |
---
|
| 6 |
|
| 7 |
+
Public classification, GPT review and EDF embedding through OpenAI, plus terminology streaming.
|
| 8 |
|
| 9 |
+
Configure OPENAI_API_KEY and HF_TOKEN as Space secrets. Set PIPELINE_PUBLIC_ACCESS=true for anonymous app access. Configure PIPELINE_HF_DATASET and PIPELINE_ALLOWED_ORIGINS as variables. No terminology vectors are stored in the Space.
|
backend/app.py
CHANGED
|
@@ -5,17 +5,20 @@ Run: python -m uvicorn backend.app:app --host 127.0.0.1 --port 8000
|
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
from dataclasses import asdict, fields
|
|
|
|
| 8 |
import hmac
|
| 9 |
import json
|
| 10 |
import logging
|
| 11 |
import math
|
| 12 |
import os
|
| 13 |
import re
|
|
|
|
|
|
|
| 14 |
from typing import Annotated, Literal
|
| 15 |
|
| 16 |
import httpx
|
| 17 |
from dotenv import load_dotenv
|
| 18 |
-
from fastapi import Depends, FastAPI, Header, HTTPException
|
| 19 |
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
from fastapi.responses import StreamingResponse
|
| 21 |
from starlette.background import BackgroundTask
|
|
@@ -23,7 +26,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
| 23 |
|
| 24 |
from src.data.loaders import EventDefinitionForm
|
| 25 |
from src.llm.classify import llm_classify
|
| 26 |
-
from src.llm.client import LLMCallError, default_model_for
|
| 27 |
|
| 28 |
load_dotenv()
|
| 29 |
logger = logging.getLogger(__name__)
|
|
@@ -34,10 +37,13 @@ app.add_middleware(
|
|
| 34 |
"PIPELINE_ALLOWED_ORIGINS", "http://127.0.0.1:5173,http://localhost:5173,https://ruurdkuiper.github.io"
|
| 35 |
).split(",") if origin.strip()],
|
| 36 |
allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"],
|
|
|
|
| 37 |
)
|
| 38 |
|
| 39 |
|
| 40 |
def authorize(authorization: Annotated[str | None, Header()] = None):
|
|
|
|
|
|
|
| 41 |
token = os.getenv("PIPELINE_API_TOKEN", "")
|
| 42 |
if not token:
|
| 43 |
raise HTTPException(503, "The classification service needs an access token configured by its administrator.")
|
|
@@ -46,6 +52,35 @@ def authorize(authorization: Annotated[str | None, Header()] = None):
|
|
| 46 |
raise HTTPException(401, "Enter a valid classification service access token.")
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
class Candidate(BaseModel):
|
| 50 |
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 51 |
code: str = Field(min_length=1, max_length=128)
|
|
@@ -53,11 +88,9 @@ class Candidate(BaseModel):
|
|
| 53 |
description: str = Field(min_length=1, max_length=4096)
|
| 54 |
|
| 55 |
|
| 56 |
-
class
|
| 57 |
model_config = ConfigDict(extra="forbid")
|
| 58 |
edf: dict
|
| 59 |
-
candidates: list[Candidate] = Field(min_length=1, max_length=20)
|
| 60 |
-
use_possible_category: bool = False
|
| 61 |
|
| 62 |
@model_validator(mode="after")
|
| 63 |
def validate_edf(self):
|
|
@@ -78,6 +111,15 @@ class Batch(BaseModel):
|
|
| 78 |
raise ValueError(f"{key} must be text")
|
| 79 |
if not self.edf.get("preferred_name", "").strip():
|
| 80 |
raise ValueError("preferred_name is required")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
if len({(row.code, row.vocabulary) for row in self.candidates}) != len(self.candidates):
|
| 82 |
raise ValueError("Duplicate candidates in batch")
|
| 83 |
return self
|
|
@@ -85,18 +127,18 @@ class Batch(BaseModel):
|
|
| 85 |
|
| 86 |
@app.get("/health")
|
| 87 |
def health():
|
| 88 |
-
return {"status": "ok", "retrieval": "browser-minilm"}
|
| 89 |
|
| 90 |
|
| 91 |
_ASSET_PATH = re.compile(
|
| 92 |
-
r"(?:manifest\.json|(?:ICD10CM|ICD9CM|ICPC|MDR|RCD2|SNOMEDCT_US)-[0-9a-f]{20}/"
|
| 93 |
r"(?:lexical\.json\.gz|[0-9]{7}\.(?:json\.gz|f32)))\Z"
|
| 94 |
)
|
| 95 |
|
| 96 |
|
| 97 |
@app.get("/api/assets/{asset_path:path}", dependencies=[Depends(authorize)])
|
| 98 |
def terminology_asset(asset_path: str):
|
| 99 |
-
"""Stream
|
| 100 |
if not _ASSET_PATH.fullmatch(asset_path):
|
| 101 |
raise HTTPException(404, "Unknown terminology asset.")
|
| 102 |
repo = os.getenv("PIPELINE_HF_DATASET", "")
|
|
@@ -134,10 +176,11 @@ def terminology_asset(asset_path: str):
|
|
| 134 |
finally:
|
| 135 |
close()
|
| 136 |
|
| 137 |
-
#
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 141 |
background=BackgroundTask(close))
|
| 142 |
|
| 143 |
|
|
@@ -168,13 +211,32 @@ def classify_batch(batch: Batch, review: bool):
|
|
| 168 |
return {"decisions": [asdict(row) for row in records], "provider": provider, "model": model}
|
| 169 |
|
| 170 |
|
| 171 |
-
@app.post("/api/classify", dependencies=[Depends(authorize)])
|
| 172 |
def classify(batch: Batch):
|
| 173 |
return classify_batch(batch, review=False)
|
| 174 |
|
| 175 |
|
| 176 |
-
@app.post("/api/review", dependencies=[Depends(authorize)])
|
| 177 |
def review(batch: Batch):
|
| 178 |
if len(batch.candidates) > 10:
|
| 179 |
raise HTTPException(422, "Review accepts at most ten candidates per batch.")
|
| 180 |
return classify_batch(batch, review=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
from dataclasses import asdict, fields
|
| 8 |
+
from collections import deque
|
| 9 |
import hmac
|
| 10 |
import json
|
| 11 |
import logging
|
| 12 |
import math
|
| 13 |
import os
|
| 14 |
import re
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
from typing import Annotated, Literal
|
| 18 |
|
| 19 |
import httpx
|
| 20 |
from dotenv import load_dotenv
|
| 21 |
+
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
| 22 |
from fastapi.middleware.cors import CORSMiddleware
|
| 23 |
from fastapi.responses import StreamingResponse
|
| 24 |
from starlette.background import BackgroundTask
|
|
|
|
| 26 |
|
| 27 |
from src.data.loaders import EventDefinitionForm
|
| 28 |
from src.llm.classify import llm_classify
|
| 29 |
+
from src.llm.client import LLMCallError, default_model_for, _get_openai_client
|
| 30 |
|
| 31 |
load_dotenv()
|
| 32 |
logger = logging.getLogger(__name__)
|
|
|
|
| 37 |
"PIPELINE_ALLOWED_ORIGINS", "http://127.0.0.1:5173,http://localhost:5173,https://ruurdkuiper.github.io"
|
| 38 |
).split(",") if origin.strip()],
|
| 39 |
allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"],
|
| 40 |
+
expose_headers=["Retry-After"],
|
| 41 |
)
|
| 42 |
|
| 43 |
|
| 44 |
def authorize(authorization: Annotated[str | None, Header()] = None):
|
| 45 |
+
if os.getenv("PIPELINE_PUBLIC_ACCESS", "").lower() == "true":
|
| 46 |
+
return
|
| 47 |
token = os.getenv("PIPELINE_API_TOKEN", "")
|
| 48 |
if not token:
|
| 49 |
raise HTTPException(503, "The classification service needs an access token configured by its administrator.")
|
|
|
|
| 52 |
raise HTTPException(401, "Enter a valid classification service access token.")
|
| 53 |
|
| 54 |
|
| 55 |
+
_model_windows = {}
|
| 56 |
+
_model_usage_lock = threading.Lock()
|
| 57 |
+
_daily_model_usage = {"day": None, "count": 0}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def model_rate_limit(request: Request):
|
| 61 |
+
"""Bound anonymous paid requests; settings are server-side only."""
|
| 62 |
+
if os.getenv("PIPELINE_PUBLIC_ACCESS", "").lower() != "true":
|
| 63 |
+
return
|
| 64 |
+
now = time.time()
|
| 65 |
+
client = request.client.host if request.client else "unknown"
|
| 66 |
+
with _model_usage_lock:
|
| 67 |
+
if _daily_model_usage["day"] != int(now // 86400):
|
| 68 |
+
_daily_model_usage.update(day=int(now // 86400), count=0)
|
| 69 |
+
if _daily_model_usage["count"] >= int(os.getenv("PIPELINE_DAILY_MODEL_REQUESTS", "10000")):
|
| 70 |
+
raise HTTPException(429, "The shared daily model allowance has been reached. Please try again tomorrow.", headers={"Retry-After": "3600"})
|
| 71 |
+
window = _model_windows.setdefault(client, deque())
|
| 72 |
+
while window and window[0] <= now - 60:
|
| 73 |
+
window.popleft()
|
| 74 |
+
if len(window) >= int(os.getenv("PIPELINE_MODEL_REQUESTS_PER_MINUTE", "60")):
|
| 75 |
+
raise HTTPException(429, "The model service is busy. Please retry shortly.", headers={"Retry-After": "60"})
|
| 76 |
+
window.append(now)
|
| 77 |
+
_daily_model_usage["count"] += 1
|
| 78 |
+
if len(_model_windows) > 2048:
|
| 79 |
+
for address in list(_model_windows):
|
| 80 |
+
if not _model_windows[address] or _model_windows[address][-1] <= now - 60:
|
| 81 |
+
del _model_windows[address]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
class Candidate(BaseModel):
|
| 85 |
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 86 |
code: str = Field(min_length=1, max_length=128)
|
|
|
|
| 88 |
description: str = Field(min_length=1, max_length=4096)
|
| 89 |
|
| 90 |
|
| 91 |
+
class EventRequest(BaseModel):
|
| 92 |
model_config = ConfigDict(extra="forbid")
|
| 93 |
edf: dict
|
|
|
|
|
|
|
| 94 |
|
| 95 |
@model_validator(mode="after")
|
| 96 |
def validate_edf(self):
|
|
|
|
| 111 |
raise ValueError(f"{key} must be text")
|
| 112 |
if not self.edf.get("preferred_name", "").strip():
|
| 113 |
raise ValueError("preferred_name is required")
|
| 114 |
+
return self
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class Batch(EventRequest):
|
| 118 |
+
candidates: list[Candidate] = Field(min_length=1, max_length=20)
|
| 119 |
+
use_possible_category: bool = False
|
| 120 |
+
|
| 121 |
+
@model_validator(mode="after")
|
| 122 |
+
def validate_candidates(self):
|
| 123 |
if len({(row.code, row.vocabulary) for row in self.candidates}) != len(self.candidates):
|
| 124 |
raise ValueError("Duplicate candidates in batch")
|
| 125 |
return self
|
|
|
|
| 127 |
|
| 128 |
@app.get("/health")
|
| 129 |
def health():
|
| 130 |
+
return {"status": "ok", "retrieval": ["browser-minilm", "browser-openai"]}
|
| 131 |
|
| 132 |
|
| 133 |
_ASSET_PATH = re.compile(
|
| 134 |
+
r"(?:openai/)?(?:manifest\.json|(?:ICD10CM|ICD9CM|ICPC|MDR|RCD2|SNOMEDCT_US)-[0-9a-f]{20}/"
|
| 135 |
r"(?:lexical\.json\.gz|[0-9]{7}\.(?:json\.gz|f32)))\Z"
|
| 136 |
)
|
| 137 |
|
| 138 |
|
| 139 |
@app.get("/api/assets/{asset_path:path}", dependencies=[Depends(authorize)])
|
| 140 |
def terminology_asset(asset_path: str):
|
| 141 |
+
"""Stream HF dataset assets without exposing the server's dataset credential."""
|
| 142 |
if not _ASSET_PATH.fullmatch(asset_path):
|
| 143 |
raise HTTPException(404, "Unknown terminology asset.")
|
| 144 |
repo = os.getenv("PIPELINE_HF_DATASET", "")
|
|
|
|
| 176 |
finally:
|
| 177 |
close()
|
| 178 |
|
| 179 |
+
# Public mode permits shared caching; authenticated deployments stay private.
|
| 180 |
+
public = os.getenv("PIPELINE_PUBLIC_ACCESS", "").lower() == "true"
|
| 181 |
+
cache_control = ("public, max-age=60" if asset_path.endswith("manifest.json") else "public, max-age=31536000, immutable") if public else "private, no-store"
|
| 182 |
+
return StreamingResponse(chunks(), media_type="application/json" if asset_path.endswith("manifest.json") else "application/octet-stream",
|
| 183 |
+
headers={"Cache-Control": cache_control, "X-Content-Type-Options": "nosniff"},
|
| 184 |
background=BackgroundTask(close))
|
| 185 |
|
| 186 |
|
|
|
|
| 211 |
return {"decisions": [asdict(row) for row in records], "provider": provider, "model": model}
|
| 212 |
|
| 213 |
|
| 214 |
+
@app.post("/api/classify", dependencies=[Depends(authorize), Depends(model_rate_limit)])
|
| 215 |
def classify(batch: Batch):
|
| 216 |
return classify_batch(batch, review=False)
|
| 217 |
|
| 218 |
|
| 219 |
+
@app.post("/api/review", dependencies=[Depends(authorize), Depends(model_rate_limit)])
|
| 220 |
def review(batch: Batch):
|
| 221 |
if len(batch.candidates) > 10:
|
| 222 |
raise HTTPException(422, "Review accepts at most ten candidates per batch.")
|
| 223 |
return classify_batch(batch, review=True)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@app.post("/api/embed", dependencies=[Depends(authorize), Depends(model_rate_limit)])
|
| 227 |
+
def embed(event: EventRequest):
|
| 228 |
+
"""Encode one EDF query; terminology vectors are always precomputed."""
|
| 229 |
+
query = EventDefinitionForm(name="browser-edf", **event.edf).to_embedding_query()
|
| 230 |
+
try:
|
| 231 |
+
response = _get_openai_client().embeddings.create(
|
| 232 |
+
model="text-embedding-3-large", input=query, dimensions=3072, encoding_format="float")
|
| 233 |
+
except Exception as exc:
|
| 234 |
+
logger.warning("Embedding request failed (%s)", type(exc).__name__)
|
| 235 |
+
raise HTTPException(502, "The event definition could not be embedded. Please retry; very long definitions may need shortening.") from None
|
| 236 |
+
vector = response.data[0].embedding
|
| 237 |
+
if len(vector) != 3072 or any(not math.isfinite(value) for value in vector):
|
| 238 |
+
raise HTTPException(502, "The embedding service returned an invalid vector.")
|
| 239 |
+
norm = math.sqrt(sum(value * value for value in vector))
|
| 240 |
+
if norm < 1e-12:
|
| 241 |
+
raise HTTPException(502, "The embedding service returned an empty vector.")
|
| 242 |
+
return {"vector": [value / norm for value in vector], "model": "text-embedding-3-large", "dimensions": 3072}
|