patholens 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. patholens-2.0.0.dist-info/METADATA +40 -0
  2. patholens-2.0.0.dist-info/RECORD +45 -0
  3. patholens-2.0.0.dist-info/WHEEL +4 -0
  4. patholens-2.0.0.dist-info/entry_points.txt +2 -0
  5. patholens-backend/app/database.py +24 -0
  6. patholens-backend/app/main.py +541 -0
  7. patholens-backend/app/models.py +17 -0
  8. patholens-backend/requirements.txt +11 -0
  9. patholens-ui/.gitignore +41 -0
  10. patholens-ui/OncoGemma-logo-no-bg_low.png +0 -0
  11. patholens-ui/OncoGemma-no-bg-high_gemini.png +0 -0
  12. patholens-ui/README.md +36 -0
  13. patholens-ui/app/dashboard/page.tsx +245 -0
  14. patholens-ui/app/favicon.ico +0 -0
  15. patholens-ui/app/globals.css +80 -0
  16. patholens-ui/app/layout.tsx +35 -0
  17. patholens-ui/app/lib/auth.ts +34 -0
  18. patholens-ui/app/lib/config.ts +5 -0
  19. patholens-ui/app/login/page.tsx +112 -0
  20. patholens-ui/app/page.tsx +5 -0
  21. patholens-ui/app/viewer/page.tsx +310 -0
  22. patholens-ui/components/AIResults.tsx +180 -0
  23. patholens-ui/components/ClinicalChat.tsx +74 -0
  24. patholens-ui/components/OncoLLM.tsx +176 -0
  25. patholens-ui/components/WSIViewer.tsx +104 -0
  26. patholens-ui/eslint.config.mjs +18 -0
  27. patholens-ui/next.config.ts +10 -0
  28. patholens-ui/package-lock.json +7391 -0
  29. patholens-ui/package.json +34 -0
  30. patholens-ui/postcss.config.mjs +7 -0
  31. patholens-ui/public/file.svg +1 -0
  32. patholens-ui/public/globe.svg +1 -0
  33. patholens-ui/public/hawkfranklin_logo.png +0 -0
  34. patholens-ui/public/next.svg +1 -0
  35. patholens-ui/public/onco-gemma-logo.png +0 -0
  36. patholens-ui/public/oncogemma_full_high_crop.png +0 -0
  37. patholens-ui/public/oncogemma_logo_only.png +0 -0
  38. patholens-ui/public/oncogemma_text_only.png +0 -0
  39. patholens-ui/public/pathogemma_icon.png +0 -0
  40. patholens-ui/public/vercel.svg +1 -0
  41. patholens-ui/public/window.svg +1 -0
  42. patholens-ui/tsconfig.json +34 -0
  43. patholens-ui/types.d.ts +106 -0
  44. patholens_cli/__init__.py +2 -0
  45. patholens_cli/main.py +348 -0
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: patholens
3
+ Version: 2.0.0
4
+ Summary: A command-line tool to manage, deploy, and launch PathoLens with Google Colab or local backends.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0.0
7
+ Requires-Dist: fastapi>=0.110.0
8
+ Requires-Dist: google-colab-cli>=0.1.0
9
+ Requires-Dist: httpx>=0.24.0
10
+ Requires-Dist: numpy>=1.24.0
11
+ Requires-Dist: openslide-bin>=4.0.1.1
12
+ Requires-Dist: openslide-python>=1.3.0
13
+ Requires-Dist: pillow>=10.0.0
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Requires-Dist: pyjwt[crypto]>=2.8.0
16
+ Requires-Dist: python-multipart>=0.0.9
17
+ Requires-Dist: sqlalchemy>=2.0.0
18
+ Requires-Dist: uvicorn[standard]>=0.20.0
19
+ Requires-Dist: websockets>=12.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # PathoLens
23
+
24
+ PathoLens is a digital pathology workstation with a CLI launcher for local or Google Colab-backed execution.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ uv tool install patholens
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ patholens launch
36
+ ```
37
+
38
+ The launcher can run the backend locally or package and deploy the application to a Google Colab VM. Local launch builds the bundled Next.js UI if a static export is not already present.
39
+
40
+ For whole-slide image viewing, the backend uses OpenSlide.
@@ -0,0 +1,45 @@
1
+ patholens_cli/__init__.py,sha256=DjC2PAALKXLXdE-k7KEZE_2cTJ1Onz7ZQ462lvZe0Qk,45
2
+ patholens_cli/main.py,sha256=M1Q4FY9XcMWd0lNDC7nL4pwgKCpPMq8D4hnxZL4hBeg,14813
3
+ patholens-backend/requirements.txt,sha256=MsESWdUH1MlLjs2laxGDRfRvM7yaQ0Z9HAW08B8vQhY,116
4
+ patholens-backend/app/database.py,sha256=38Sp4NnyCqa8xYOQT6HT5HU677QNzq0IUIymOkaSxpk,612
5
+ patholens-backend/app/main.py,sha256=9N27gLdjBBTRvpLALzQZJXGd5HKPGT9IZMZn_7N74rc,20394
6
+ patholens-backend/app/models.py,sha256=RH6fukqLrrTSD_s9zOcQIYPRxAwRnhR4d2m--i5eDX0,783
7
+ patholens-ui/.gitignore,sha256=IH4mX_SQH5rZ-W2M4IUw4E-fxgCBVHKmbQpEYJbWVM0,480
8
+ patholens-ui/OncoGemma-logo-no-bg_low.png,sha256=F0Sv8De15WgZwEDfTwqkiG4DJN1ni71IQ4uXBl1sWpc,80575
9
+ patholens-ui/OncoGemma-no-bg-high_gemini.png,sha256=8yxHTdJl80v0Z11A5_YpnfUbt201aFZ478Bmbw3Mp-4,4690363
10
+ patholens-ui/README.md,sha256=YLVf9995r3JZD5UkII5GZCvDK9wXXNrUE0loHA4vlY8,1450
11
+ patholens-ui/eslint.config.mjs,sha256=hw8a3M7PMFHLzZ_TB871HXYzz1EJecGBqB9LF5cnNJM,465
12
+ patholens-ui/next.config.ts,sha256=mjm5jrSGX-oaxtqnHLZWYDdWJoVx_BjMwr6PxNQcSdA,165
13
+ patholens-ui/package-lock.json,sha256=hbndqurR7FrT3Fg9z-bsRnloqA0l7wRWKNc6_T6Y8_8,263654
14
+ patholens-ui/package.json,sha256=-CMJZWDGneIsBcSgs_-rc9iVX1WN6A1N71VOixVgGB4,772
15
+ patholens-ui/postcss.config.mjs,sha256=36x6wthtMmoOWtsCTnlDwYE5PtF6X8uPAxWyTH2m3d4,94
16
+ patholens-ui/tsconfig.json,sha256=vhhSOyO3i24ah23dEH0zDwFovd8J11G1fgte3OacZvM,666
17
+ patholens-ui/types.d.ts,sha256=0k2ku3FaCTnxalkLwihb3SLFYGwWzupkmyR3ZGwFySo,2088
18
+ patholens-ui/app/favicon.ico,sha256=K4rS0zRVqPc2_DqOv48L3qiEitTA20iigzvQ-c13WTI,25931
19
+ patholens-ui/app/globals.css,sha256=K7J-fRdhfeSsQXUdF4uZQ4_SdgkSc7XWloPxGQFtYJg,1634
20
+ patholens-ui/app/layout.tsx,sha256=RpaajHuKYeCyUwEEYTf3lQ0YTyillt6HvyuEwyaJU9E,757
21
+ patholens-ui/app/page.tsx,sha256=kWMNqbDdoeT5cHDxHYDM_0Lv92IXXCXSUfdHzJd4Oo0,102
22
+ patholens-ui/app/dashboard/page.tsx,sha256=qgmVpyXEQL4Zj05prrQvrZ38P3CiWyM-AYn_ujo4qCs,14883
23
+ patholens-ui/app/lib/auth.ts,sha256=DadfudKAkDNewK_I-pcsHjT20GtQCZLVfH0xcibph3s,1140
24
+ patholens-ui/app/lib/config.ts,sha256=f2Lu7p9iyhOBEvxxFrMTdFE50vg8iSeu94t_pnmiDbQ,215
25
+ patholens-ui/app/login/page.tsx,sha256=ffuO5za0CrCCeFGSNfjIbzLcOXb2JCs1Bm3pNOA0D8A,5516
26
+ patholens-ui/app/viewer/page.tsx,sha256=MCR5Zwfq9QjIumDv2qLwuwqqBBe7jYonUBTlCGeEHLA,16926
27
+ patholens-ui/components/AIResults.tsx,sha256=9oSyz6tkTrQSrdeiFPsoiuROvEjal513Wht2__tOKQk,8896
28
+ patholens-ui/components/ClinicalChat.tsx,sha256=SoDC6Pa0ovnuQl-elBIYHsZ2rxrJYTqSu77wXZy6Vb8,3128
29
+ patholens-ui/components/OncoLLM.tsx,sha256=LT3iej7X0JOoam9UNQlaTplQDtCaOaPGYzB1L4Cm3fI,8108
30
+ patholens-ui/components/WSIViewer.tsx,sha256=71-9EK_CYZcp-_ShhWAABe2ZTETdToY4dv-wJDq0KlQ,4142
31
+ patholens-ui/public/file.svg,sha256=K2eBLDJcGZoCU2zb7qDFk6cvcH0yO3LuPgjbqwZ1O9Q,391
32
+ patholens-ui/public/globe.svg,sha256=thS5vxg5JZV2YayFFJj-HYAp_UOmL7_thvniYkpX588,1035
33
+ patholens-ui/public/hawkfranklin_logo.png,sha256=JoICo2eNAgJvgSNzCBwqQFT4Dwh2h_1JR3La_lFcXyQ,1363659
34
+ patholens-ui/public/next.svg,sha256=VZld-tbstJRaHoVt3KA8XhaqW_E_0htN9qdK55NXvPw,1375
35
+ patholens-ui/public/onco-gemma-logo.png,sha256=OZFJ6KweMWYRNHhWF0NTsXIzUC7PMoE2KSJSg50b_W8,2110224
36
+ patholens-ui/public/oncogemma_full_high_crop.png,sha256=41rIKPUTkouVUgFmjj-zMPqOahODmZuZQhuqeEcinw8,1974556
37
+ patholens-ui/public/oncogemma_logo_only.png,sha256=ldo4ISCtJ8ucnCIyqWir8JAxsDzLVa4p4uvtx-WD8Vs,637917
38
+ patholens-ui/public/oncogemma_text_only.png,sha256=lXZLJs-TEmLhVGfBbGaqkkBO-EJFkHJkhxjKoBhuF28,389460
39
+ patholens-ui/public/pathogemma_icon.png,sha256=3NYUwgE-ESqjkkG_QqwDrtKVrENp4Txs0KGp4eNxiEg,303158
40
+ patholens-ui/public/vercel.svg,sha256=8IEzey_uY1tFW2MnVAaj5_OdagFOJa2Q2rWmfmKhKsQ,128
41
+ patholens-ui/public/window.svg,sha256=ZEdoxKrrR2e84pM0TusMEl-4BKlNgBRAQkByIC2F46E,385
42
+ patholens-2.0.0.dist-info/METADATA,sha256=rQ9i-CXIxvY2LeT47oq74n4dNJN30Gkwacgvx4h7wxs,1161
43
+ patholens-2.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
44
+ patholens-2.0.0.dist-info/entry_points.txt,sha256=DBH6XakxMTt4lHdHV8nCNvd-j1OdZdIj3viJrd5uxcY,53
45
+ patholens-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ patholens = patholens_cli.main:cli
@@ -0,0 +1,24 @@
1
+ import os
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import declarative_base, sessionmaker
4
+
5
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./patholens.db")
6
+
7
+ # For SQLite, we need to allow multithreaded access
8
+ if DATABASE_URL.startswith("sqlite"):
9
+ engine = create_engine(
10
+ DATABASE_URL, connect_args={"check_same_thread": False}
11
+ )
12
+ else:
13
+ engine = create_engine(DATABASE_URL)
14
+
15
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
16
+
17
+ Base = declarative_base()
18
+
19
+ def get_db():
20
+ db = SessionLocal()
21
+ try:
22
+ yield db
23
+ finally:
24
+ db.close()
@@ -0,0 +1,541 @@
1
+ import asyncio
2
+ import io
3
+ import json
4
+ import logging
5
+ import os
6
+ import random
7
+ import urllib.request
8
+ from typing import List, Optional
9
+
10
+ import httpx
11
+ import openslide
12
+ import pydantic
13
+ import jwt
14
+ from fastapi import FastAPI, HTTPException, WebSocket, Depends, Security
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
19
+ from PIL import Image
20
+
21
+ from .database import engine, Base, get_db
22
+ from .models import User, Slide
23
+
24
+ # Create DB tables on startup
25
+ Base.metadata.create_all(bind=engine)
26
+
27
+ try:
28
+ import litert_lm
29
+ LITERT_LM_AVAILABLE = True
30
+ except ImportError:
31
+ LITERT_LM_AVAILABLE = False
32
+ logger = logging.getLogger("patholens-backend")
33
+ logger.warning("litert_lm package not found. LiteRT model local inference is disabled.")
34
+
35
+ LITERT_ENGINE = None
36
+
37
+ def get_litert_engine():
38
+ global LITERT_ENGINE
39
+ if not LITERT_LM_AVAILABLE:
40
+ raise HTTPException(status_code=500, detail="litert_lm package is not installed on this server")
41
+ if LITERT_ENGINE is None:
42
+ model_path = os.getenv("LITERT_MODEL_PATH")
43
+ if not model_path:
44
+ raise HTTPException(status_code=500, detail="LITERT_MODEL_PATH env var is not configured")
45
+ backend_str = os.getenv("LITERT_BACKEND", "cpu").lower()
46
+ backend = litert_lm.Backend.GPU if backend_str == "gpu" else litert_lm.Backend.CPU
47
+ logger.info("Initializing LiteRT-LM Engine with model: %s, backend: %s", model_path, backend_str)
48
+ LITERT_ENGINE = litert_lm.Engine(model_path, backend=backend)
49
+ return LITERT_ENGINE
50
+
51
+ logging.basicConfig(level=logging.INFO)
52
+ logger = logging.getLogger("patholens-backend")
53
+
54
+ ALLOWED_ORIGINS = [
55
+ origin.strip()
56
+ for origin in os.getenv(
57
+ "CORS_ALLOW_ORIGINS",
58
+ "http://localhost:3000,http://127.0.0.1:3000",
59
+ ).split(",")
60
+ if origin.strip()
61
+ ]
62
+ if not ALLOWED_ORIGINS:
63
+ ALLOWED_ORIGINS = ["*"]
64
+ ALLOW_CREDENTIALS = not (len(ALLOWED_ORIGINS) == 1 and ALLOWED_ORIGINS[0] == "*")
65
+
66
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
67
+ DEFAULT_SLIDE_DIR = os.path.join(os.path.dirname(BASE_DIR), "cptac_samples")
68
+ SLIDE_DIR = os.path.abspath(os.getenv("SAMPLE_DATA_PATH") or DEFAULT_SLIDE_DIR)
69
+
70
+ STATIC_FRONTEND_DIR = os.getenv("STATIC_FRONTEND_DIR")
71
+ STATIC_INDEX = os.path.join(STATIC_FRONTEND_DIR, "index.html") if STATIC_FRONTEND_DIR else None
72
+
73
+ INFERENCE_API_URL = os.getenv("MEDGEMMA_API_URL") or os.getenv("INFERENCE_API_URL")
74
+ INFERENCE_API_TOKEN = os.getenv("HF_TOKEN") or os.getenv("INFERENCE_API_TOKEN")
75
+ INFERENCE_TIMEOUT = float(os.getenv("INFERENCE_TIMEOUT_SECONDS", "60"))
76
+ SAMPLE_DEMO_URL = os.getenv("SAMPLE_DEMO_URL")
77
+ SAMPLE_DEMO_FILENAME = os.getenv("SAMPLE_DEMO_FILENAME", "demo.svs")
78
+
79
+ # Authentication configurations
80
+ JWT_VERIFICATION_ENABLED = os.getenv("JWT_VERIFICATION_ENABLED", "false").lower() == "true"
81
+ FIREBASE_PROJECT_ID = os.getenv("FIREBASE_PROJECT_ID")
82
+ GOOGLE_PUBLIC_KEYS = {}
83
+
84
+ async def fetch_google_public_keys():
85
+ global GOOGLE_PUBLIC_KEYS
86
+ try:
87
+ async with httpx.AsyncClient() as client:
88
+ res = await client.get("https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com")
89
+ if res.status_code == 200:
90
+ GOOGLE_PUBLIC_KEYS = res.json()
91
+ except Exception as e:
92
+ logger.error("Failed to fetch Google public keys: %s", e)
93
+
94
+ async def verify_firebase_token(token: str) -> dict:
95
+ if not GOOGLE_PUBLIC_KEYS:
96
+ await fetch_google_public_keys()
97
+
98
+ try:
99
+ unverified_header = jwt.get_unverified_header(token)
100
+ kid = unverified_header.get("kid")
101
+ if not kid or kid not in GOOGLE_PUBLIC_KEYS:
102
+ raise HTTPException(status_code=401, detail="Invalid token header kid")
103
+
104
+ cert = GOOGLE_PUBLIC_KEYS[kid]
105
+ decoded = jwt.decode(
106
+ token,
107
+ cert,
108
+ algorithms=["RS256"],
109
+ audience=FIREBASE_PROJECT_ID,
110
+ issuer=f"https://securetoken.google.com/{FIREBASE_PROJECT_ID}"
111
+ )
112
+ return decoded
113
+ except Exception as e:
114
+ logger.error("Token verification failed: %s", e)
115
+ raise HTTPException(status_code=401, detail=f"Token verification failed: {str(e)}")
116
+
117
+ security = HTTPBearer(auto_error=False)
118
+
119
+ async def get_current_user(
120
+ credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
121
+ token: Optional[str] = None
122
+ ) -> dict:
123
+ if not JWT_VERIFICATION_ENABLED:
124
+ return {"user_id": "mock-user-id", "email": "doctor.sarah@oncogemma.com"}
125
+
126
+ auth_token = None
127
+ if credentials:
128
+ auth_token = credentials.credentials
129
+ elif token:
130
+ auth_token = token
131
+
132
+ if not auth_token:
133
+ raise HTTPException(status_code=401, detail="Authentication token required")
134
+
135
+ decoded = await verify_firebase_token(auth_token)
136
+ return {
137
+ "user_id": decoded.get("sub"),
138
+ "email": decoded.get("email")
139
+ }
140
+
141
+ def ensure_slide_storage():
142
+ os.makedirs(SLIDE_DIR, exist_ok=True)
143
+
144
+ def maybe_download_demo_slide():
145
+ if not SAMPLE_DEMO_URL:
146
+ return
147
+ dest = os.path.join(SLIDE_DIR, SAMPLE_DEMO_FILENAME)
148
+ if os.path.exists(dest):
149
+ return
150
+ try:
151
+ logger.info("Downloading demo slide to %s", dest)
152
+ urllib.request.urlretrieve(SAMPLE_DEMO_URL, dest)
153
+ except Exception as exc:
154
+ logger.warning("Could not download demo slide: %s", exc)
155
+
156
+ ensure_slide_storage()
157
+ maybe_download_demo_slide()
158
+
159
+ app = FastAPI()
160
+
161
+ # CORS configuration
162
+ app.add_middleware(
163
+ CORSMiddleware,
164
+ allow_origins=ALLOWED_ORIGINS,
165
+ allow_credentials=ALLOW_CREDENTIALS,
166
+ allow_methods=["*"],
167
+ allow_headers=["*"],
168
+ )
169
+
170
+ if STATIC_FRONTEND_DIR and os.path.isdir(STATIC_FRONTEND_DIR):
171
+ app.mount("/_static", StaticFiles(directory=STATIC_FRONTEND_DIR), name="frontend-static")
172
+
173
+ def get_user_slide_path(filename: str, user_id: str) -> str:
174
+ # Check in user-specific folder (created via GCS FUSE mount or local)
175
+ path_user = os.path.join(SLIDE_DIR, "users", user_id, "slides", filename)
176
+ if os.path.exists(path_user):
177
+ return path_user
178
+
179
+ # Fallback to local root (default slides/demo)
180
+ path_fallback = os.path.join(SLIDE_DIR, filename)
181
+ return path_fallback
182
+
183
+ class SlideManager:
184
+ def __init__(self):
185
+ # Maps user_id -> {'slide': OpenSlide, 'dz': DeepZoomGenerator, 'name': filename}
186
+ self.active_sessions = {}
187
+
188
+ def get_session(self, user_id: str):
189
+ return self.active_sessions.get(user_id)
190
+
191
+ def load(self, filename: str, user_id: str):
192
+ path = get_user_slide_path(filename, user_id)
193
+ if not os.path.exists(path):
194
+ raise HTTPException(status_code=404, detail=f"Slide not found: {filename}")
195
+
196
+ try:
197
+ slide = openslide.OpenSlide(path)
198
+ from openslide.deepzoom import DeepZoomGenerator
199
+ dz = DeepZoomGenerator(slide, tile_size=256, overlap=0, limit_bounds=False)
200
+ self.active_sessions[user_id] = {
201
+ 'slide': slide,
202
+ 'dz': dz,
203
+ 'name': filename
204
+ }
205
+ logger.info("Loaded slide for user %s: %s", user_id, path)
206
+ return True
207
+ except Exception as e:
208
+ logger.exception("Error loading slide: %s", e)
209
+ raise HTTPException(status_code=500, detail=f"Failed to load slide: {str(e)}")
210
+
211
+ slide_manager = SlideManager()
212
+
213
+ # Load default if available (fallback compatibility for local testing)
214
+ try:
215
+ if os.path.exists(SLIDE_DIR):
216
+ files = [f for f in os.listdir(SLIDE_DIR) if f.endswith('.svs')]
217
+ if files:
218
+ slide_manager.load(files[0], "mock-user-id")
219
+ logger.info("Loaded default slide %s for mock-user-id", files[0])
220
+ except Exception:
221
+ pass
222
+
223
+ @app.get("/slides")
224
+ async def list_slides(current_user: dict = Depends(get_current_user)):
225
+ """List all available SVS slides for the current user."""
226
+ user_id = current_user["user_id"]
227
+ user_slide_dir = os.path.join(SLIDE_DIR, "users", user_id, "slides")
228
+
229
+ slides = []
230
+ if os.path.exists(user_slide_dir):
231
+ slides = [f for f in os.listdir(user_slide_dir) if f.endswith('.svs')]
232
+
233
+ if os.path.exists(SLIDE_DIR):
234
+ root_slides = [f for f in os.listdir(SLIDE_DIR) if f.endswith('.svs')]
235
+ for s in root_slides:
236
+ if s not in slides:
237
+ slides.append(s)
238
+
239
+ session = slide_manager.get_session(user_id)
240
+ return {
241
+ "slides": slides,
242
+ "current": session['name'] if session else None
243
+ }
244
+
245
+ class LoadSlideRequest(pydantic.BaseModel):
246
+ filename: str
247
+
248
+ @app.post("/load_slide")
249
+ async def load_slide(request: LoadSlideRequest, current_user: dict = Depends(get_current_user)):
250
+ """Load a specific slide for the current user."""
251
+ user_id = current_user["user_id"]
252
+ slide_manager.load(request.filename, user_id)
253
+ return {"status": "success", "filename": request.filename}
254
+
255
+ @app.get("/")
256
+ async def root():
257
+ if STATIC_INDEX and os.path.isfile(STATIC_INDEX):
258
+ return FileResponse(STATIC_INDEX)
259
+ return {"message": "PathoLens Backend is running"}
260
+
261
+ @app.get("/metadata")
262
+ async def get_metadata(current_user: dict = Depends(get_current_user)):
263
+ user_id = current_user["user_id"]
264
+ session = slide_manager.get_session(user_id)
265
+ if not session or not session.get('slide'):
266
+ raise HTTPException(status_code=404, detail="Slide not loaded for current user")
267
+
268
+ slide = session['slide']
269
+ return {
270
+ "width": slide.dimensions[0],
271
+ "height": slide.dimensions[1],
272
+ "level_count": slide.level_count,
273
+ "level_dimensions": slide.level_dimensions,
274
+ "level_downsamples": slide.level_downsamples,
275
+ "filename": session['name']
276
+ }
277
+
278
+ @app.get("/tiles/{level}/{x}/{y}")
279
+ async def get_tile(level: int, x: int, y: int, current_user: dict = Depends(get_current_user)):
280
+ user_id = current_user["user_id"]
281
+ session = slide_manager.get_session(user_id)
282
+ if not session or not session.get('dz'):
283
+ raise HTTPException(status_code=404, detail="Slide not loaded for current user")
284
+
285
+ try:
286
+ dz = session['dz']
287
+ if level < 0 or level >= dz.level_count:
288
+ raise HTTPException(status_code=404, detail="Level out of bounds")
289
+
290
+ tile = dz.get_tile(level, (x, y))
291
+
292
+ img_byte_arr = io.BytesIO()
293
+ tile.save(img_byte_arr, format='JPEG')
294
+ img_byte_arr.seek(0)
295
+
296
+ return StreamingResponse(img_byte_arr, media_type="image/jpeg")
297
+
298
+ except ValueError:
299
+ raise HTTPException(status_code=404, detail="Tile out of bounds")
300
+ except Exception as e:
301
+ logger.exception("Error serving tile: %s", e)
302
+ raise HTTPException(status_code=500, detail=str(e))
303
+
304
+ @app.get("/dzi")
305
+ async def get_dzi(current_user: dict = Depends(get_current_user)):
306
+ """
307
+ Return DZI metadata for OpenSeadragon for current user session.
308
+ """
309
+ user_id = current_user["user_id"]
310
+ session = slide_manager.get_session(user_id)
311
+ if not session or not session.get('dz'):
312
+ raise HTTPException(status_code=404, detail="Slide not loaded for current user")
313
+
314
+ dz = session['dz']
315
+
316
+ return {
317
+ "Image": {
318
+ "xmlns": "http://schemas.microsoft.com/deepzoom/2008",
319
+ "Format": "jpeg",
320
+ "Overlap": "0",
321
+ "TileSize": "256",
322
+ "Size": {
323
+ "Width": str(dz.level_dimensions[-1][0]),
324
+ "Height": str(dz.level_dimensions[-1][1])
325
+ }
326
+ }
327
+ }
328
+
329
+ # Inference proxy (to external endpoints such as HF Inference Endpoint, Gradio Space, or custom vLLM/TGI).
330
+ class InferenceRequest(pydantic.BaseModel):
331
+ prompt: str
332
+ images: Optional[List[str]] = None
333
+ parameters: dict = {}
334
+
335
+ async def forward_inference(payload: dict, endpoint: Optional[str] = None):
336
+ target = endpoint or INFERENCE_API_URL
337
+ if not target:
338
+ raise HTTPException(status_code=503, detail="Inference endpoint not configured")
339
+
340
+ headers = {"Content-Type": "application/json"}
341
+ if INFERENCE_API_TOKEN:
342
+ headers["Authorization"] = f"Bearer {INFERENCE_API_TOKEN}"
343
+
344
+ try:
345
+ async with httpx.AsyncClient(timeout=INFERENCE_TIMEOUT) as client:
346
+ response = await client.post(target, json=payload, headers=headers)
347
+ response.raise_for_status()
348
+ except httpx.HTTPStatusError as exc:
349
+ detail = exc.response.text if exc.response else str(exc)
350
+ logger.error("Inference endpoint returned an error: %s", detail)
351
+ raise HTTPException(
352
+ status_code=exc.response.status_code if exc.response else 502,
353
+ detail=f"Inference failed: {detail}",
354
+ ) from exc
355
+ except httpx.HTTPError as exc:
356
+ logger.error("Inference request failed: %s", exc)
357
+ raise HTTPException(status_code=502, detail=f"Inference request failed: {exc}") from exc
358
+
359
+ try:
360
+ return response.json()
361
+ except Exception:
362
+ return {"raw": response.text}
363
+
364
+ @app.post("/api/ai/diagnose")
365
+ async def proxy_diagnose(request: InferenceRequest, current_user: dict = Depends(get_current_user)):
366
+ """
367
+ Proxy AI analysis to an external inference endpoint.
368
+ """
369
+ payload = {"prompt": request.prompt}
370
+ if request.images:
371
+ payload["images"] = request.images
372
+ if request.parameters:
373
+ payload["parameters"] = request.parameters
374
+
375
+ return await forward_inference(payload)
376
+
377
+ # Dummy AI Endpoints
378
+
379
+ @app.get("/ai/summary")
380
+ async def get_global_summary(current_user: dict = Depends(get_current_user)):
381
+ await asyncio.sleep(1) # Simulate latency
382
+ return {
383
+ "description": "The slide shows sections of squamous cell carcinoma with moderate differentiation. "
384
+ "There is significant keratin pearl formation and intercellular bridges. "
385
+ "The stroma shows a heavy inflammatory infiltrate, primarily lymphocytic. "
386
+ "Tumor margins appear infiltrative.",
387
+ "confidence": 0.92
388
+ }
389
+
390
+ @app.get("/ai/risk")
391
+ async def get_risk_score(current_user: dict = Depends(get_current_user)):
392
+ await asyncio.sleep(1.5)
393
+ return {
394
+ "score": 0.87,
395
+ "risk_level": "High",
396
+ "factors": [
397
+ {"name": "Tumor Budding", "value": "High"},
398
+ {"name": "Stromal Reaction", "value": "Desmoplastic"},
399
+ {"name": "Lymphocytic Infiltration", "value": "Moderate"}
400
+ ]
401
+ }
402
+
403
+ @app.get("/ai/biomarkers")
404
+ async def get_biomarkers(current_user: dict = Depends(get_current_user)):
405
+ await asyncio.sleep(0.5)
406
+ return {
407
+ "detected": [
408
+ {"name": "EGFR", "status": "Overexpression", "location": [10000, 10000]},
409
+ {"name": "TP53", "status": "Mutated", "location": [15000, 12000]},
410
+ {"name": "CD274 (PD-L1)", "status": "Positive", "location": [5000, 8000]}
411
+ ]
412
+ }
413
+
414
+ @app.websocket("/ws")
415
+ async def websocket_endpoint(websocket: WebSocket):
416
+ await websocket.accept()
417
+ try:
418
+ while True:
419
+ data = await websocket.receive_text()
420
+ if "analyze_region" in data:
421
+ await asyncio.sleep(0.5)
422
+ await websocket.send_json({
423
+ "type": "analysis_result",
424
+ "content": {
425
+ "cell_count": random.randint(50, 200),
426
+ "tissue_type": random.choice(["Tumor", "Stroma", "Necrosis", "Normal"]),
427
+ "mitotic_index": random.choice(["Low", "Moderate", "High"])
428
+ }
429
+ })
430
+ except Exception:
431
+ pass
432
+
433
+ # PathoLens 2.0 Mock Endpoints
434
+
435
+ @app.get("/ai/tumor_burden")
436
+ async def get_tumor_burden(current_user: dict = Depends(get_current_user)):
437
+ await asyncio.sleep(0.8)
438
+ return {
439
+ "percentage": 68.5,
440
+ "description": "High tumor burden observed in ROI.",
441
+ "location_count": 12
442
+ }
443
+
444
+ @app.get("/ai/subtyping")
445
+ async def get_subtyping(current_user: dict = Depends(get_current_user)):
446
+ await asyncio.sleep(1.2)
447
+ return [
448
+ {"name": "Invasive Ductal Carcinoma", "probability": 0.85, "description": "Most likely subtype based on glandular patterns."},
449
+ {"name": "Invasive Lobular Carcinoma", "probability": 0.10, "description": "Secondary possibility."},
450
+ {"name": "Ductal Carcinoma In Situ", "probability": 0.05, "description": "Minor component observed."}
451
+ ]
452
+
453
+ @app.get("/ai/toxicity")
454
+ async def get_toxicity(current_user: dict = Depends(get_current_user)):
455
+ await asyncio.sleep(1.0)
456
+ return [
457
+ {
458
+ "treatment_type": "Chemotherapy",
459
+ "risk_level": "Moderate",
460
+ "probability": 0.45,
461
+ "potential_side_effects": ["Neutropenia", "Fatigue"]
462
+ },
463
+ {
464
+ "treatment_type": "Immunotherapy",
465
+ "risk_level": "Low",
466
+ "probability": 0.15,
467
+ "potential_side_effects": ["Mild Rash"]
468
+ }
469
+ ]
470
+
471
+ class OncoLLMQuery(pydantic.BaseModel):
472
+ query: str
473
+ context: dict = {}
474
+
475
+ @app.post("/ai/oncollm/query")
476
+ async def query_oncollm(request: OncoLLMQuery, current_user: dict = Depends(get_current_user)):
477
+ model_path = os.getenv("LITERT_MODEL_PATH")
478
+ if model_path:
479
+ try:
480
+ engine = get_litert_engine()
481
+ with engine.create_conversation() as conversation:
482
+ response = conversation.send_message(request.query)
483
+
484
+ # Safe attribute check on the conversation response
485
+ raw_text = ""
486
+ if hasattr(response, 'text'):
487
+ raw_text = response.text
488
+ elif hasattr(response, 'content'):
489
+ raw_text = response.content
490
+ else:
491
+ raw_text = str(response)
492
+
493
+ return {
494
+ "content": raw_text,
495
+ "references": []
496
+ }
497
+ except Exception as e:
498
+ logger.error("LiteRT-LM local inference failed: %s", e)
499
+ raise HTTPException(status_code=500, detail=f"LiteRT-LM local inference failed: {str(e)}")
500
+
501
+ await asyncio.sleep(2.0)
502
+ query_lower = request.query.lower()
503
+
504
+ if "treatment" in query_lower:
505
+ return {
506
+ "content": f"Hello {current_user.get('email', 'Doctor')}. Based on the high tumor burden (68.5%) and EGFR overexpression, a combination of chemotherapy and targeted therapy (e.g., Cetuximab) is recommended. However, note the moderate risk of chemotherapy-induced neutropenia.",
507
+ "references": [
508
+ {"title": "EGFR Inhibition in Squamous Cell Carcinoma", "authors": "Smith et al.", "year": 2023, "url": "#", "relevance_score": 0.95},
509
+ {"title": "Managing Neutropenia in High-Burden Cases", "authors": "Doe et al.", "year": 2022, "url": "#", "relevance_score": 0.88}
510
+ ]
511
+ }
512
+ elif "prognosis" in query_lower:
513
+ return {
514
+ "content": f"For the patient case under review, the prognosis is guarded due to the 'High Risk' classification and infiltrative margins. The 5-year survival rate for this subtype with similar biomarkers is approximately 60% without aggressive intervention.",
515
+ "references": [
516
+ {"title": "Prognostic Factors in Invasive Carcinoma", "authors": "Lee et al.", "year": 2024, "url": "#", "relevance_score": 0.92}
517
+ ]
518
+ }
519
+ else:
520
+ return {
521
+ "content": "I've analyzed the slide context. The tissue shows clear signs of malignancy with significant inflammatory infiltration. Would you like to explore specific treatment options or biomarker implications?",
522
+ "references": []
523
+ }
524
+
525
+ @app.get("/{full_path:path}")
526
+ async def serve_frontend(full_path: str):
527
+ """
528
+ Serve the exported Next.js frontend when running in a single-container setup.
529
+ """
530
+ if not STATIC_FRONTEND_DIR or not os.path.isdir(STATIC_FRONTEND_DIR):
531
+ raise HTTPException(status_code=404, detail="Not Found")
532
+
533
+ candidate = os.path.join(STATIC_FRONTEND_DIR, full_path)
534
+ if os.path.isfile(candidate):
535
+ return FileResponse(candidate)
536
+
537
+ if STATIC_INDEX and os.path.isfile(STATIC_INDEX):
538
+ return FileResponse(STATIC_INDEX)
539
+
540
+ raise HTTPException(status_code=404, detail="Not Found")
541
+
@@ -0,0 +1,17 @@
1
+ from sqlalchemy import Column, String, ForeignKey, DateTime, Integer
2
+ from sqlalchemy.sql import func
3
+ from .database import Base
4
+
5
+ class User(Base):
6
+ __tablename__ = 'users'
7
+ id = Column(String, primary_key=True) # Matches Auth provider UID (Firebase/Identity Platform)
8
+ email = Column(String, unique=True, nullable=False)
9
+ created_at = Column(DateTime, server_default=func.now())
10
+
11
+ class Slide(Base):
12
+ __tablename__ = 'slides'
13
+ id = Column(Integer, primary_key=True, autoincrement=True)
14
+ filename = Column(String, nullable=False)
15
+ gcs_uri = Column(String, nullable=True) # Storage path in GCS if applicable
16
+ owner_id = Column(String, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
17
+ uploaded_at = Column(DateTime, server_default=func.now())