Ruurd commited on
Commit
a06517a
·
verified ·
1 Parent(s): c2b7494

Enable anonymous app access and OpenAI query embeddings

Browse files
Files changed (2) hide show
  1. README.md +2 -2
  2. backend/app.py +76 -14
README.md CHANGED
@@ -4,6 +4,6 @@ sdk: docker
4
  app_port: 7860
5
  ---
6
 
7
- Token-protected classification and GPT review through OpenAI, plus streaming access to a private terminology dataset.
8
 
9
- Configure OPENAI_API_KEY, PIPELINE_API_TOKEN and HF_TOKEN as Space secrets. Configure PIPELINE_HF_DATASET and PIPELINE_ALLOWED_ORIGINS as variables. No terminology vectors are stored in the Space.
 
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 Batch(BaseModel):
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 private HF dataset assets to authorized browsers without storing them."""
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
- # Private terminology data must not be cached by shared HTTP proxies.
138
- # The authorized browser maintains its own content-addressed IndexedDB cache.
139
- return StreamingResponse(chunks(), media_type="application/json" if asset_path == "manifest.json" else "application/octet-stream",
140
- headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"},
 
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}