alwaysEpic commited on
Commit
ca8accf
·
verified ·
1 Parent(s): f63fd03

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +15 -7
  2. README.md +5 -4
  3. app.py +319 -38
Dockerfile CHANGED
@@ -1,13 +1,21 @@
1
- FROM python:3.12-slim
 
 
 
 
 
 
 
 
2
 
3
  RUN pip install --no-cache-dir \
4
- fastapi==0.115.* \
5
- uvicorn==0.34.* \
6
- presidio-analyzer==2.2.* \
7
- presidio-anonymizer==2.2.* \
8
- spacy==3.7.*
9
 
10
- RUN python -m spacy download en_core_web_sm
11
 
12
  COPY app.py .
13
 
 
1
+ # Pinned by digest so the build is reproducible and can't be swapped under the
2
+ # tag. Digest is the multi-arch index for python:3.12-slim (Docker resolves the
3
+ # right arch from it). To refresh: docker manifest inspect python:3.12-slim, or
4
+ # curl -sI -H "Authorization: Bearer $(curl -s 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/python:pull' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')" \
5
+ # -H 'Accept: application/vnd.oci.image.index.v1+json' \
6
+ # https://registry-1.docker.io/v2/library/python/manifests/3.12-slim | grep -i docker-content-digest
7
+ FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9
8
+
9
+ ARG SPACY_MODEL=en_core_web_sm
10
 
11
  RUN pip install --no-cache-dir \
12
+ fastapi==0.115.14 \
13
+ uvicorn==0.34.3 \
14
+ presidio-analyzer==2.2.362 \
15
+ presidio-anonymizer==2.2.362 \
16
+ spacy==3.7.5
17
 
18
+ RUN python -m spacy download ${SPACY_MODEL}
19
 
20
  COPY app.py .
21
 
README.md CHANGED
@@ -11,7 +11,7 @@ app_port: 7860
11
 
12
  Server-side Named Entity Recognition for the [Common Parlance](https://github.com/common-parlance/common-parlance) project.
13
 
14
- Catches names, locations, and organizations that client-side regex scrubbing can't detect. This is a defense-in-depth layer -- the client already strips emails, phones, SSNs, IPs, file paths, and API keys before data reaches this service.
15
 
16
  ## API
17
 
@@ -20,18 +20,19 @@ Catches names, locations, and organizations that client-side regex scrubbing can
20
  ```json
21
  {
22
  "turns": [
23
- {"role": "user", "content": "My friend Alice at Google helped me debug this"},
24
  {"role": "assistant", "content": "That's great! Here's how to fix it..."}
25
  ]
26
  }
27
  ```
28
 
29
- Response:
 
30
 
31
  ```json
32
  {
33
  "turns": [
34
- {"role": "user", "content": "My friend [NAME] at [ORG] helped me debug this"},
35
  {"role": "assistant", "content": "That's great! Here's how to fix it..."}
36
  ],
37
  "entities_found": 2
 
11
 
12
  Server-side Named Entity Recognition for the [Common Parlance](https://github.com/common-parlance/common-parlance) project.
13
 
14
+ Catches names and locations that client-side regex scrubbing can't detect. This is a defense-in-depth layer -- the client already strips emails, phones, SSNs, IPs, file paths, and API keys before data reaches this service. (Organization/product names are intentionally **not** redacted: the NER is noisy on them and they are high-utility, low-risk in technical text; genuinely sensitive internal names are caught at review.)
15
 
16
  ## API
17
 
 
20
  ```json
21
  {
22
  "turns": [
23
+ {"role": "user", "content": "My friend Alice in Paris helped me debug this"},
24
  {"role": "assistant", "content": "That's great! Here's how to fix it..."}
25
  ]
26
  }
27
  ```
28
 
29
+ Response (only PERSON and LOCATION are detected — ORG is intentionally not, so
30
+ e.g. "Google" would pass through unredacted):
31
 
32
  ```json
33
  {
34
  "turns": [
35
+ {"role": "user", "content": "My friend [NAME_1] in [LOCATION] helped me debug this"},
36
  {"role": "assistant", "content": "That's great! Here's how to fix it..."}
37
  ],
38
  "entities_found": 2
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """Server-side NER scrubbing service.
2
 
3
- Runs Presidio + spaCy to catch names, locations, and organizations
4
  that client-side regex scrubbing can't detect. Deployed on HuggingFace
5
  Spaces (free tier) as a Docker SDK Space.
6
 
@@ -9,36 +9,271 @@ file paths, API keys). This service is a defense-in-depth layer that
9
  catches unstructured PII (names mentioned in conversation text).
10
  """
11
 
 
12
  import os
 
 
13
 
14
- from fastapi import FastAPI, Header, HTTPException
 
15
  from presidio_analyzer import AnalyzerEngine
16
  from presidio_analyzer.nlp_engine import NlpEngineProvider
17
  from presidio_anonymizer import AnonymizerEngine
18
  from presidio_anonymizer.entities import OperatorConfig
19
  from pydantic import BaseModel
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  app = FastAPI(title="Common Parlance NER Service", docs_url=None, redoc_url=None)
22
 
23
- # Initialize once at startup (not per-request)
24
- nlp_provider = NlpEngineProvider(nlp_configuration={
25
- "nlp_engine_name": "spacy",
26
- "models": [{"lang_code": "en", "model_name": "en_core_web_sm"}],
27
- })
 
 
 
 
 
28
  analyzer = AnalyzerEngine(nlp_engine=nlp_provider.create_engine())
29
  anonymizer = AnonymizerEngine()
 
30
 
31
  API_KEY = os.environ.get("API_KEY", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  # Only detect entity types that regex can't handle.
34
  # Emails, phones, IPs, etc. are already scrubbed client-side.
35
- NER_ENTITIES = ["PERSON", "LOCATION", "ORGANIZATION"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- OPERATORS = {
38
- "PERSON": OperatorConfig("replace", {"new_value": "[NAME]"}),
39
- "LOCATION": OperatorConfig("replace", {"new_value": "[LOCATION]"}),
40
- "ORGANIZATION": OperatorConfig("replace", {"new_value": "[ORG]"}),
41
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
 
44
  class ScrubRequest(BaseModel):
@@ -48,6 +283,7 @@ class ScrubRequest(BaseModel):
48
  class ScrubResponse(BaseModel):
49
  turns: list[dict]
50
  entities_found: int
 
51
 
52
 
53
  MAX_TURNS = 200
@@ -55,46 +291,91 @@ MAX_CONTENT_LENGTH = 100_000 # 100KB per turn
55
 
56
 
57
  @app.post("/scrub", response_model=ScrubResponse)
58
- async def scrub(payload: ScrubRequest, x_api_key: str = Header(None)):
59
- if API_KEY and x_api_key != API_KEY:
 
 
 
 
 
 
60
  raise HTTPException(status_code=401, detail="Invalid API key")
61
 
62
- if len(payload.turns) > MAX_TURNS:
63
- raise HTTPException(status_code=413, detail=f"Too many turns (max {MAX_TURNS})")
64
 
65
- total_entities = 0
66
  scrubbed_turns = []
 
67
 
68
- for turn in payload.turns:
69
- if len(turn.get("content", "")) > MAX_CONTENT_LENGTH:
70
- raise HTTPException(
71
- status_code=413,
72
- detail=f"Turn content too large (max {MAX_CONTENT_LENGTH} bytes)",
73
- )
74
  text = turn.get("content", "")
75
  role = turn.get("role", "")
76
 
77
- results = analyzer.analyze(
78
- text=text,
79
- entities=NER_ENTITIES,
80
- language="en",
81
- score_threshold=0.5,
 
 
 
 
82
  )
83
 
84
- if results:
85
- anonymized = anonymizer.anonymize(
86
- text=text,
87
- analyzer_results=results,
88
- operators=OPERATORS,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  )
90
- text = anonymized.text
91
- total_entities += len(results)
92
 
 
93
  scrubbed_turns.append({"role": role, "content": text})
94
 
95
- return ScrubResponse(turns=scrubbed_turns, entities_found=total_entities)
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
 
98
  @app.get("/health")
99
  async def health():
100
- return {"ok": True, "model": "en_core_web_sm", "entities": NER_ENTITIES}
 
1
  """Server-side NER scrubbing service.
2
 
3
+ Runs Presidio + spaCy to catch names and locations
4
  that client-side regex scrubbing can't detect. Deployed on HuggingFace
5
  Spaces (free tier) as a Docker SDK Space.
6
 
 
9
  catches unstructured PII (names mentioned in conversation text).
10
  """
11
 
12
+ import logging
13
  import os
14
+ import re
15
+ import unicodedata
16
 
17
+ from fastapi import FastAPI, Header, HTTPException, Request
18
+ from fastapi.responses import JSONResponse
19
  from presidio_analyzer import AnalyzerEngine
20
  from presidio_analyzer.nlp_engine import NlpEngineProvider
21
  from presidio_anonymizer import AnonymizerEngine
22
  from presidio_anonymizer.entities import OperatorConfig
23
  from pydantic import BaseModel
24
 
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # --- Unicode normalization (adversarial PII evasion defense) ---
28
+ # Homoglyphs (Cyrillic а vs Latin a), zero-width characters, and bidi
29
+ # overrides can bypass NER. NFKC normalization + control character
30
+ # stripping defeats these attacks. Must run before any NER analysis.
31
+ _INVISIBLE_RE = re.compile(
32
+ "["
33
+ "\u061c" # arabic letter mark (bidi control)
34
+ "\u200b" # zero-width space
35
+ "\u200c" # zero-width non-joiner
36
+ "\u200d" # zero-width joiner
37
+ "\u200e" # left-to-right mark
38
+ "\u200f" # right-to-left mark
39
+ "\u202a" # left-to-right embedding
40
+ "\u202b" # right-to-left embedding
41
+ "\u202c" # pop directional formatting
42
+ "\u202d" # left-to-right override
43
+ "\u202e" # right-to-left override
44
+ "\u2060" # word joiner
45
+ "\u2061" # function application
46
+ "\u2062" # invisible times
47
+ "\u2063" # invisible separator
48
+ "\u2064" # invisible plus
49
+ "\u2066" # left-to-right isolate
50
+ "\u2067" # right-to-left isolate
51
+ "\u2068" # first strong isolate
52
+ "\u2069" # pop directional isolate
53
+ "\ufeff" # byte order mark / zero-width no-break space
54
+ "\ufff9" # interlinear annotation anchor
55
+ "\ufffa" # interlinear annotation separator
56
+ "\ufffb" # interlinear annotation terminator
57
+ "]+",
58
+ )
59
+
60
+
61
+ def _normalize_text(text: str) -> str:
62
+ """Normalize text to defeat adversarial PII evasion.
63
+
64
+ NFKC normalization maps homoglyphs to canonical Latin forms.
65
+ Invisible character stripping removes zero-width spaces, joiners,
66
+ and bidi overrides that break NER tokenization.
67
+ """
68
+ text = unicodedata.normalize("NFKC", text)
69
+ text = _INVISIBLE_RE.sub("", text)
70
+ return text
71
+
72
+
73
  app = FastAPI(title="Common Parlance NER Service", docs_url=None, redoc_url=None)
74
 
75
+ # Initialize once at startup (not per-request).
76
+ # SPACY_MODEL env var allows using en_core_web_lg locally for better
77
+ # accuracy while keeping en_core_web_sm on HF Spaces (free tier RAM).
78
+ SPACY_MODEL = os.environ.get("SPACY_MODEL", "en_core_web_sm")
79
+ nlp_provider = NlpEngineProvider(
80
+ nlp_configuration={
81
+ "nlp_engine_name": "spacy",
82
+ "models": [{"lang_code": "en", "model_name": SPACY_MODEL}],
83
+ }
84
+ )
85
  analyzer = AnalyzerEngine(nlp_engine=nlp_provider.create_engine())
86
  anonymizer = AnonymizerEngine()
87
+ logger.info("Loaded spaCy model: %s", SPACY_MODEL)
88
 
89
  API_KEY = os.environ.get("API_KEY", "")
90
+ if not API_KEY:
91
+ logger.warning(
92
+ "API_KEY not set — /scrub will REJECT all requests (fail closed). "
93
+ "Set API_KEY to enable the endpoint."
94
+ )
95
+
96
+ # Hard cap on the request body. Enforced here (a middleware that checks
97
+ # Content-Length up front) rather than via a uvicorn flag — uvicorn has no such
98
+ # option, so the previously-documented limit did not exist.
99
+ MAX_REQUEST_BYTES = 2 * 1024 * 1024 # 2MB
100
+
101
+
102
+ @app.middleware("http")
103
+ async def limit_body_size(request: Request, call_next):
104
+ # Fast path: reject a declared Content-Length over the cap up front.
105
+ cl = request.headers.get("content-length")
106
+ if cl is not None and cl.isdigit() and int(cl) > MAX_REQUEST_BYTES:
107
+ return JSONResponse(status_code=413, content={"detail": "Request too large"})
108
+ # Robust path: a chunked or absent/non-numeric Content-Length would bypass
109
+ # the header check, so also bound the body as we read it. Buffer up to the
110
+ # cap and cache it on the request so the route can still parse it (the
111
+ # stream is consumed here); reject the moment the cap is exceeded.
112
+ body = b""
113
+ async for chunk in request.stream():
114
+ body += chunk
115
+ if len(body) > MAX_REQUEST_BYTES:
116
+ return JSONResponse(
117
+ status_code=413, content={"detail": "Request too large"}
118
+ )
119
+ request._body = body
120
+ return await call_next(request)
121
+
122
 
123
  # Only detect entity types that regex can't handle.
124
  # Emails, phones, IPs, etc. are already scrubbed client-side.
125
+ # ORGANIZATION is intentionally excluded: spaCy ORG NER is noisy
126
+ # (misclassifies products/tools), org names are high-utility and low-risk in
127
+ # coding data, and sensitive internal names are caught at review. Keep in
128
+ # sync with scrub.py's analyzed entity set.
129
+ NER_ENTITIES = ["PERSON", "LOCATION"]
130
+
131
+ # Programming terms spaCy NER misclassifies as PERSON/LOCATION ("Django", "Go",
132
+ # "Jenkins"). Presidio's spaCy recognizer stamps every NER hit with a fixed
133
+ # score (0.85), so score thresholds can't separate a library name from a real
134
+ # person — only an allow_list can. Without this the server re-redacts code terms
135
+ # the client deliberately kept (scrub.py passes the same allow_list), so a
136
+ # reviewed "Django" silently becomes "[NAME_1]" in the published trace.
137
+ # DUPLICATED from scrub.py:_PROGRAMMING_ALLOW_LIST as a stopgap until the shared
138
+ # cp-scrub engine (Go-To-Market roadmap Phase 1). Keep the two lists in sync.
139
+ _PROGRAMMING_ALLOW_LIST = [
140
+ # languages
141
+ "Python",
142
+ "Java",
143
+ "Ruby",
144
+ "Rust",
145
+ "Swift",
146
+ "Kotlin",
147
+ "Scala",
148
+ "Julia",
149
+ "Perl",
150
+ "Lua",
151
+ "Dart",
152
+ "Elixir",
153
+ "Fortran",
154
+ "Pascal",
155
+ "Haskell",
156
+ "Erlang",
157
+ "Clojure",
158
+ "Groovy",
159
+ "C",
160
+ "R",
161
+ "Go",
162
+ # tools / platforms
163
+ "Git",
164
+ "Docker",
165
+ "Kubernetes",
166
+ "Terraform",
167
+ "Ansible",
168
+ "Jenkins",
169
+ "Gradle",
170
+ "Maven",
171
+ "Cargo",
172
+ "Helm",
173
+ "Vagrant",
174
+ "Nginx",
175
+ "Apache",
176
+ "Redis",
177
+ "Kafka",
178
+ "Celery",
179
+ "Pandas",
180
+ "NumPy",
181
+ "Flask",
182
+ "Django",
183
+ "FastAPI",
184
+ "Rails",
185
+ "Spring",
186
+ "Node",
187
+ "Deno",
188
+ "Bun",
189
+ # CS terms
190
+ "Boolean",
191
+ "Lambda",
192
+ "Mutex",
193
+ "Regex",
194
+ # AI/ML
195
+ "Transformer",
196
+ "BERT",
197
+ "GPT",
198
+ "LLM",
199
+ "CUDA",
200
+ "PyTorch",
201
+ "TensorFlow",
202
+ "Keras",
203
+ "Llama",
204
+ "Claude",
205
+ "Gemini",
206
+ # frameworks / libraries
207
+ "React",
208
+ "Angular",
209
+ "Vue",
210
+ "Svelte",
211
+ "jQuery",
212
+ "Bootstrap",
213
+ "Tailwind",
214
+ "Express",
215
+ "Nest",
216
+ "Next",
217
+ "Nuxt",
218
+ "Remix",
219
+ # math / algorithms
220
+ "Fibonacci",
221
+ "Dijkstra",
222
+ "Euler",
223
+ ]
224
+ # Presidio's allow_list is case-sensitive, so include original + lowercase forms.
225
+ _ALLOW_LIST = sorted(
226
+ {t for term in _PROGRAMMING_ALLOW_LIST for t in (term, term.lower())}
227
+ )
228
+
229
+ # Per-entity score thresholds, mirroring scrub.py's _filter_results. spaCy NER
230
+ # is fixed at 0.85, so these are mostly a guard for pattern/context-scored
231
+ # entities; kept for parity with the client's documented thresholds.
232
+ _ENTITY_SCORE_THRESHOLDS = {"PERSON": 0.85, "LOCATION": 0.70}
233
+ _DEFAULT_SCORE_THRESHOLD = 0.5
234
+
235
 
236
+ def build_operators(results: list, text: str) -> dict:
237
+ """Operators for a single turn. PERSON entities get consistent numbered
238
+ [NAME_1]/[NAME_2] placeholders (reading order) to preserve coreference,
239
+ matching scrub.py and the documented design; LOCATION/ORG stay flat.
240
+ """
241
+ mapping: dict[str, str] = {}
242
+ persons = sorted(
243
+ (r for r in results if r.entity_type == "PERSON"),
244
+ key=lambda r: r.start,
245
+ )
246
+ for r in persons:
247
+ key = " ".join(text[r.start : r.end].split()).casefold()
248
+ if key not in mapping:
249
+ mapping[key] = f"[NAME_{len(mapping) + 1}]"
250
+
251
+ def _replace(value: str) -> str:
252
+ key = " ".join(value.split()).casefold()
253
+ placeholder = mapping.get(key)
254
+ if placeholder is None:
255
+ placeholder = f"[NAME_{len(mapping) + 1}]"
256
+ mapping[key] = placeholder
257
+ return placeholder
258
+
259
+ return {
260
+ "PERSON": OperatorConfig("custom", {"lambda": _replace}),
261
+ "LOCATION": OperatorConfig("replace", {"new_value": "[LOCATION]"}),
262
+ }
263
+
264
+
265
+ def _filter_by_threshold(results: list) -> list:
266
+ """Drop entities scoring below their per-entity threshold.
267
+
268
+ Parity with scrub.py._filter_results — over-redaction guard for any
269
+ pattern/context-scored entity that comes back below the cutoff.
270
+ """
271
+ return [
272
+ r
273
+ for r in results
274
+ if r.score
275
+ >= _ENTITY_SCORE_THRESHOLDS.get(r.entity_type, _DEFAULT_SCORE_THRESHOLD)
276
+ ]
277
 
278
 
279
  class ScrubRequest(BaseModel):
 
283
  class ScrubResponse(BaseModel):
284
  turns: list[dict]
285
  entities_found: int
286
+ entities_per_turn: list[int]
287
 
288
 
289
  MAX_TURNS = 200
 
291
 
292
 
293
  @app.post("/scrub", response_model=ScrubResponse)
294
+ async def scrub(
295
+ payload: ScrubRequest,
296
+ x_api_key: str = Header(None),
297
+ ):
298
+ # Fail closed: with no API_KEY configured, reject everything (don't run as
299
+ # an open public Presidio endpoint). Body size is bounded by the
300
+ # limit_body_size middleware.
301
+ if not API_KEY or x_api_key != API_KEY:
302
  raise HTTPException(status_code=401, detail="Invalid API key")
303
 
304
+ # Best-effort: process up to MAX_TURNS, skip the rest
305
+ turns_to_process = payload.turns[:MAX_TURNS]
306
 
 
307
  scrubbed_turns = []
308
+ per_turn_counts = []
309
 
310
+ for turn in turns_to_process:
 
 
 
 
 
311
  text = turn.get("content", "")
312
  role = turn.get("role", "")
313
 
314
+ # Unicode normalization before NER (defeats homoglyph/zero-width evasion)
315
+ text = _normalize_text(text)
316
+
317
+ # For oversized turns, run NER on the first chunk only.
318
+ # The full content is still passed through — we just scan
319
+ # what we can. A missed entity past the limit is acceptable
320
+ # since client-side regex already handled structured PII.
321
+ scan_text = (
322
+ text[:MAX_CONTENT_LENGTH] if len(text) > MAX_CONTENT_LENGTH else text
323
  )
324
 
325
+ try:
326
+ results = analyzer.analyze(
327
+ text=scan_text,
328
+ entities=NER_ENTITIES,
329
+ language="en",
330
+ score_threshold=_DEFAULT_SCORE_THRESHOLD,
331
+ allow_list=_ALLOW_LIST,
332
+ )
333
+ # Allow-list + per-entity thresholds for parity with scrub.py, so the
334
+ # server doesn't re-redact code terms the client deliberately kept.
335
+ results = _filter_by_threshold(results)
336
+
337
+ if results:
338
+ if len(text) <= MAX_CONTENT_LENGTH:
339
+ # Normal case: scrub the full text
340
+ anonymized = anonymizer.anonymize(
341
+ text=text,
342
+ analyzer_results=results,
343
+ operators=build_operators(results, text),
344
+ )
345
+ text = anonymized.text
346
+ else:
347
+ # Oversized: scrub the scanned prefix, reattach the tail
348
+ tail = text[MAX_CONTENT_LENGTH:]
349
+ anonymized = anonymizer.anonymize(
350
+ text=scan_text,
351
+ analyzer_results=results,
352
+ operators=build_operators(results, scan_text),
353
+ )
354
+ text = anonymized.text + tail
355
+ except Exception:
356
+ logger.error(
357
+ "Presidio error on turn, passing through unscrubbed", exc_info=True
358
  )
359
+ results = []
 
360
 
361
+ per_turn_counts.append(len(results) if results else 0)
362
  scrubbed_turns.append({"role": role, "content": text})
363
 
364
+ # Truncate turns beyond MAX_TURNS rather than passing them unscrubbed
365
+ if len(payload.turns) > MAX_TURNS:
366
+ logger.warning(
367
+ "Truncated %d turns beyond MAX_TURNS (%d)",
368
+ len(payload.turns) - MAX_TURNS,
369
+ MAX_TURNS,
370
+ )
371
+
372
+ return ScrubResponse(
373
+ turns=scrubbed_turns,
374
+ entities_found=sum(per_turn_counts),
375
+ entities_per_turn=per_turn_counts,
376
+ )
377
 
378
 
379
  @app.get("/health")
380
  async def health():
381
+ return {"ok": True, "model": SPACY_MODEL, "entities": NER_ENTITIES}