DocUA commited on
Commit
90baed6
·
1 Parent(s): 97a786e

Per-session cards: each visitor gets an isolated seeded card (cookie lmc_sid)

Browse files
Files changed (1) hide show
  1. livemedcard/api.py +104 -60
livemedcard/api.py CHANGED
@@ -25,12 +25,14 @@ sync-ендпойнти FastAPI виконуються в пулі потокі
25
  """
26
  from __future__ import annotations
27
 
 
28
  import threading
 
29
  from datetime import date
30
 
31
  import hashlib
32
 
33
- from fastapi import Body, FastAPI, HTTPException
34
  from fastapi.responses import FileResponse
35
  from pydantic import BaseModel
36
 
@@ -99,18 +101,61 @@ class _State:
99
  save_card(self.card, PERSIST_PATH)
100
 
101
 
102
- def _initial_card() -> LiveMedCard:
103
- if PERSIST_PATH:
104
- loaded = load_card(PERSIST_PATH, _make_extractor())
105
- if loaded is not None:
106
- return loaded
107
  card = LiveMedCard(_new_patient(), _make_extractor())
108
- if SEED_SAMPLES: # публічне демо (HF Space) стартує з заповненою карткою
109
  card.ingest_all([Document(**d) for d in load_sample_docs()])
110
  return card
111
 
112
 
113
- _state = _State(_initial_card())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  class AskRequest(BaseModel):
@@ -119,19 +164,20 @@ class AskRequest(BaseModel):
119
 
120
  @app.get("/")
121
  def index() -> FileResponse:
 
122
  return FileResponse(_STATIC / "index.html")
123
 
124
 
125
  @app.post("/ingest")
126
- def ingest(doc: Document):
127
  if len(doc.text) > MAX_DOC_CHARS:
128
  raise HTTPException(
129
  status_code=413,
130
  detail=f"Документ завеликий (> {MAX_DOC_CHARS} символів)",
131
  )
132
- with _state.lock:
133
- report = _state.card.ingest(doc)
134
- _state.autosave()
135
  return report
136
 
137
 
@@ -141,7 +187,7 @@ class ImageIngestRequest(BaseModel):
141
 
142
 
143
  @app.post("/ingest/image")
144
- def ingest_image(req: ImageIngestRequest):
145
  if not req.image_base64.strip():
146
  raise HTTPException(
147
  status_code=422,
@@ -191,9 +237,9 @@ def ingest_image(req: ImageIngestRequest):
191
  doc = Document(id=doc_id, text=text, kind=req.kind)
192
 
193
  try:
194
- with _state.lock:
195
- report = _state.card.ingest(doc, extractor=img_extractor)
196
- _state.autosave()
197
  except OcrLabError as exc: # витяг фактів Stage 2 у хмарному Lab
198
  raise HTTPException(status_code=503, detail=str(exc))
199
  return report
@@ -201,54 +247,54 @@ def ingest_image(req: ImageIngestRequest):
201
 
202
 
203
  @app.post("/ingest/samples")
204
- def ingest_samples():
205
  docs = [Document(**d) for d in load_sample_docs()]
206
- with _state.lock:
207
- reports = _state.card.ingest_all(docs)
208
- _state.autosave()
209
  return reports
210
 
211
 
212
  @app.post("/reset")
213
- def reset():
214
- with _state.lock:
215
- _state.card = LiveMedCard(_new_patient(), _make_extractor())
216
- _state.autosave()
217
- return {"status": "reset", "patient": _state.card.patient.id}
218
 
219
 
220
  @app.get("/export")
221
- def export_bundle():
222
  """Експорт картки як FHIR Bundle (для передачі лікарю / еЗдоров'ю)."""
223
- with _state.lock:
224
- return to_bundle(_state.card)
225
 
226
 
227
  @app.post("/import")
228
- def import_bundle(bundle: dict = Body(...)):
229
  """Імпорт картки з FHIR Bundle (замінює поточний стан)."""
230
  try:
231
  card = from_bundle(bundle, _make_extractor())
232
  except (ValueError, KeyError, TypeError) as exc:
233
  raise HTTPException(status_code=422, detail=f"Некоректний Bundle: {exc}")
234
- with _state.lock:
235
- _state.card = card
236
- _state.autosave()
237
- return {"status": "imported", "documents": _state.card.document_count}
238
 
239
 
240
  @app.get("/audit")
241
- def audit():
242
  """Журнал рішень L3 (спостережуваність)."""
243
- with _state.lock:
244
- return _state.card.audit_log()
245
 
246
 
247
  @app.get("/metrics")
248
- def metrics():
249
  """Агреговані метрики роботи конвеєра."""
250
- with _state.lock:
251
- card = _state.card
252
  log = card.audit_log()
253
  n = len(log)
254
  escalated = sum(1 for a in log if a["escalate"])
@@ -265,33 +311,33 @@ def metrics():
265
 
266
 
267
  @app.get("/timeline/{loinc}")
268
- def timeline(loinc: str):
269
- with _state.lock:
270
- return _state.card.factstore.series(loinc)
271
 
272
 
273
  @app.get("/signals")
274
- def signals():
275
- with _state.lock:
276
- return _state.card.current_signals()
277
 
278
 
279
  @app.get("/document/{doc_id}")
280
- def document(doc_id: str):
281
  """Free-text іпостась документа (вузол графа) — для span-провенансу в UI."""
282
- with _state.lock:
283
- node = _state.card.graph.g.nodes.get(f"Document/{doc_id}")
284
  if node is None or "text" not in node:
285
  raise HTTPException(status_code=404, detail=f"Документ {doc_id} не знайдено")
286
  return {"id": doc_id, "text": node["text"]}
287
 
288
 
289
  @app.get("/state")
290
- def state(lang: str = "uk"):
291
  """Агрегований знімок картки для інтерфейсу (`lang=uk|en` — мова текстів сигналів)."""
292
  lang = "en" if lang == "en" else "uk"
293
- with _state.lock:
294
- card = _state.card
295
  sigs = card.current_signals(lang)
296
  # Рішення L3 над поточним станом (сигнали детерміновані → впевненість 1.0).
297
  doc_kind = "card state" if lang == "en" else "стан картки"
@@ -331,18 +377,16 @@ def state(lang: str = "uk"):
331
 
332
 
333
  @app.post("/ask", response_model=Answer)
334
- def ask(req: AskRequest) -> Answer:
335
- with _state.lock:
336
- card = _state.card
337
  qa = QA(card.factstore, card.graph, card.current_signals(), sex=card.patient.gender)
338
  return qa.ask(req.question)
339
 
340
 
341
  @app.get("/health")
342
  def health():
343
- with _state.lock:
344
- return {
345
- "status": "ok",
346
- "patient": _state.card.patient.id,
347
- "documents": _state.card.document_count,
348
- }
 
25
  """
26
  from __future__ import annotations
27
 
28
+ import secrets
29
  import threading
30
+ from collections import OrderedDict
31
  from datetime import date
32
 
33
  import hashlib
34
 
35
+ from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response
36
  from fastapi.responses import FileResponse
37
  from pydantic import BaseModel
38
 
 
101
  save_card(self.card, PERSIST_PATH)
102
 
103
 
104
+ def _seeded_card() -> LiveMedCard:
105
+ """Свіжа картка; для публічного демо — одразу із сід-таймлайном (stub, миттєво)."""
 
 
 
106
  card = LiveMedCard(_new_patient(), _make_extractor())
107
+ if SEED_SAMPLES:
108
  card.ingest_all([Document(**d) for d in load_sample_docs()])
109
  return card
110
 
111
 
112
+ def _initial_card() -> LiveMedCard:
113
+ if PERSIST_PATH:
114
+ loaded = load_card(PERSIST_PATH, _make_extractor())
115
+ if loaded is not None:
116
+ return loaded
117
+ return _seeded_card()
118
+
119
+
120
+ # Дві моделі стану:
121
+ # • PERSIST_PATH заданий → персональний local-first режим: ОДНА персистентна
122
+ # картка на весь сервер (як раніше);
123
+ # • інакше (публічне демо) → картка НА СЕСІЮ (cookie), щоб відвідувачі не бачили
124
+ # й не скидали картки одне одного. LRU-кеп обмежує памʼять.
125
+ _SESSION_COOKIE = "lmc_sid"
126
+ _MAX_SESSIONS = 300
127
+ _single_state: "_State | None" = _State(_initial_card()) if PERSIST_PATH else None
128
+ _sessions: "OrderedDict[str, _State]" = OrderedDict()
129
+ _sessions_lock = threading.Lock()
130
+
131
+
132
+ def _get_state(request: Request, response: Response) -> _State:
133
+ """Стан для цього запиту: персональний (persist) або per-session (демо)."""
134
+ if _single_state is not None:
135
+ return _single_state
136
+ sid = request.cookies.get(_SESSION_COOKIE)
137
+ if sid:
138
+ with _sessions_lock:
139
+ state = _sessions.get(sid)
140
+ if state is not None:
141
+ _sessions.move_to_end(sid) # LRU: свіжий доступ — у кінець
142
+ return state
143
+ # Новий сеанс — сідаємо картку ПОЗА глобальним локом (мінімум контенції).
144
+ sid = secrets.token_urlsafe(16)
145
+ state = _State(_seeded_card())
146
+ with _sessions_lock:
147
+ _sessions[sid] = state
148
+ while len(_sessions) > _MAX_SESSIONS:
149
+ _sessions.popitem(last=False) # витісняємо найдавніший
150
+ # HF рендерить Space у cross-site iframe (huggingface.co → *.hf.space), тож для
151
+ # HTTPS потрібен SameSite=None; Secure, інакше cookie не долетить і кожен запит
152
+ # плодив би нову сесію. Локально (http) — Lax. Проксі HF ставить X-Forwarded-Proto.
153
+ https = request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
154
+ response.set_cookie(
155
+ _SESSION_COOKIE, sid, max_age=86400, httponly=True,
156
+ samesite="none" if https else "lax", secure=https,
157
+ )
158
+ return state
159
 
160
 
161
  class AskRequest(BaseModel):
 
164
 
165
  @app.get("/")
166
  def index() -> FileResponse:
167
+ # Без сесійної залежності: liveness-полінг платформи не має плодити сесії.
168
  return FileResponse(_STATIC / "index.html")
169
 
170
 
171
  @app.post("/ingest")
172
+ def ingest(doc: Document, state: _State = Depends(_get_state)):
173
  if len(doc.text) > MAX_DOC_CHARS:
174
  raise HTTPException(
175
  status_code=413,
176
  detail=f"Документ завеликий (> {MAX_DOC_CHARS} символів)",
177
  )
178
+ with state.lock:
179
+ report = state.card.ingest(doc)
180
+ state.autosave()
181
  return report
182
 
183
 
 
187
 
188
 
189
  @app.post("/ingest/image")
190
+ def ingest_image(req: ImageIngestRequest, state: _State = Depends(_get_state)):
191
  if not req.image_base64.strip():
192
  raise HTTPException(
193
  status_code=422,
 
237
  doc = Document(id=doc_id, text=text, kind=req.kind)
238
 
239
  try:
240
+ with state.lock:
241
+ report = state.card.ingest(doc, extractor=img_extractor)
242
+ state.autosave()
243
  except OcrLabError as exc: # витяг фактів Stage 2 у хмарному Lab
244
  raise HTTPException(status_code=503, detail=str(exc))
245
  return report
 
247
 
248
 
249
  @app.post("/ingest/samples")
250
+ def ingest_samples(state: _State = Depends(_get_state)):
251
  docs = [Document(**d) for d in load_sample_docs()]
252
+ with state.lock:
253
+ reports = state.card.ingest_all(docs)
254
+ state.autosave()
255
  return reports
256
 
257
 
258
  @app.post("/reset")
259
+ def reset(state: _State = Depends(_get_state)):
260
+ with state.lock:
261
+ state.card = LiveMedCard(_new_patient(), _make_extractor())
262
+ state.autosave()
263
+ return {"status": "reset", "patient": state.card.patient.id}
264
 
265
 
266
  @app.get("/export")
267
+ def export_bundle(state: _State = Depends(_get_state)):
268
  """Експорт картки як FHIR Bundle (для передачі лікарю / еЗдоров'ю)."""
269
+ with state.lock:
270
+ return to_bundle(state.card)
271
 
272
 
273
  @app.post("/import")
274
+ def import_bundle(bundle: dict = Body(...), state: _State = Depends(_get_state)):
275
  """Імпорт картки з FHIR Bundle (замінює поточний стан)."""
276
  try:
277
  card = from_bundle(bundle, _make_extractor())
278
  except (ValueError, KeyError, TypeError) as exc:
279
  raise HTTPException(status_code=422, detail=f"Некоректний Bundle: {exc}")
280
+ with state.lock:
281
+ state.card = card
282
+ state.autosave()
283
+ return {"status": "imported", "documents": state.card.document_count}
284
 
285
 
286
  @app.get("/audit")
287
+ def audit(state: _State = Depends(_get_state)):
288
  """Журнал рішень L3 (спостережуваність)."""
289
+ with state.lock:
290
+ return state.card.audit_log()
291
 
292
 
293
  @app.get("/metrics")
294
+ def metrics(state: _State = Depends(_get_state)):
295
  """Агреговані метрики роботи конвеєра."""
296
+ with state.lock:
297
+ card = state.card
298
  log = card.audit_log()
299
  n = len(log)
300
  escalated = sum(1 for a in log if a["escalate"])
 
311
 
312
 
313
  @app.get("/timeline/{loinc}")
314
+ def timeline(loinc: str, state: _State = Depends(_get_state)):
315
+ with state.lock:
316
+ return state.card.factstore.series(loinc)
317
 
318
 
319
  @app.get("/signals")
320
+ def signals(state: _State = Depends(_get_state)):
321
+ with state.lock:
322
+ return state.card.current_signals()
323
 
324
 
325
  @app.get("/document/{doc_id}")
326
+ def document(doc_id: str, state: _State = Depends(_get_state)):
327
  """Free-text іпостась документа (вузол графа) — для span-провенансу в UI."""
328
+ with state.lock:
329
+ node = state.card.graph.g.nodes.get(f"Document/{doc_id}")
330
  if node is None or "text" not in node:
331
  raise HTTPException(status_code=404, detail=f"Документ {doc_id} не знайдено")
332
  return {"id": doc_id, "text": node["text"]}
333
 
334
 
335
  @app.get("/state")
336
+ def get_state_endpoint(lang: str = "uk", state: _State = Depends(_get_state)):
337
  """Агрегований знімок картки для інтерфейсу (`lang=uk|en` — мова текстів сигналів)."""
338
  lang = "en" if lang == "en" else "uk"
339
+ with state.lock:
340
+ card = state.card
341
  sigs = card.current_signals(lang)
342
  # Рішення L3 над поточним станом (сигнали детерміновані → впевненість 1.0).
343
  doc_kind = "card state" if lang == "en" else "стан картки"
 
377
 
378
 
379
  @app.post("/ask", response_model=Answer)
380
+ def ask(req: AskRequest, state: _State = Depends(_get_state)) -> Answer:
381
+ with state.lock:
382
+ card = state.card
383
  qa = QA(card.factstore, card.graph, card.current_signals(), sex=card.patient.gender)
384
  return qa.ask(req.question)
385
 
386
 
387
  @app.get("/health")
388
  def health():
389
+ # Без сесійної залежності: не плодимо сесії на кожен liveness-пінг.
390
+ if _single_state is not None:
391
+ return {"status": "ok", "mode": "single", "documents": _single_state.card.document_count}
392
+ return {"status": "ok", "mode": "session", "sessions": len(_sessions)}