Surya Prakash commited on
Commit
44b44cb
Β·
1 Parent(s): 04f6e2a

-Ui deployement

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +24 -0
  2. .env +1 -0
  3. .gitignore +16 -0
  4. Dockerfile +30 -9
  5. app.yaml +268 -0
  6. azure/deploy.md +98 -0
  7. backend/__init__.py +0 -0
  8. backend/main.py +130 -0
  9. backend/rag_service.py +204 -0
  10. backend/schemas.py +41 -0
  11. frontend/.gitignore +4 -0
  12. frontend/index.html +18 -0
  13. frontend/package-lock.json +0 -0
  14. frontend/package.json +33 -0
  15. frontend/postcss.config.js +6 -0
  16. frontend/src/App.tsx +31 -0
  17. frontend/src/components/ChatInput.tsx +77 -0
  18. frontend/src/components/ChatThread.tsx +35 -0
  19. frontend/src/components/ExamplePrompts.tsx +32 -0
  20. frontend/src/components/MessageBubble.tsx +91 -0
  21. frontend/src/components/PdfViewer.tsx +131 -0
  22. frontend/src/components/ReportPicker.tsx +83 -0
  23. frontend/src/components/Sidebar.tsx +64 -0
  24. frontend/src/components/SourceCard.tsx +38 -0
  25. frontend/src/components/ThemeToggle.tsx +12 -0
  26. frontend/src/components/UploadPanel.tsx +119 -0
  27. frontend/src/components/ui/button.tsx +34 -0
  28. frontend/src/index.css +94 -0
  29. frontend/src/lib/api.ts +126 -0
  30. frontend/src/lib/sse.ts +39 -0
  31. frontend/src/lib/theme.tsx +36 -0
  32. frontend/src/lib/useChat.ts +82 -0
  33. frontend/src/lib/utils.ts +6 -0
  34. frontend/src/main.tsx +20 -0
  35. frontend/src/views/DocumentChat.tsx +117 -0
  36. frontend/src/views/GlobalSearch.tsx +86 -0
  37. frontend/src/vite-env.d.ts +1 -0
  38. frontend/tailwind.config.js +55 -0
  39. frontend/tsconfig.json +23 -0
  40. frontend/tsconfig.node.json +11 -0
  41. frontend/tsconfig.node.tsbuildinfo +1 -0
  42. frontend/tsconfig.tsbuildinfo +1 -0
  43. frontend/vite.config.d.ts +2 -0
  44. frontend/vite.config.js +20 -0
  45. frontend/vite.config.ts +21 -0
  46. output/biomedbert_vector_db/faiss.index +2 -2
  47. output/biomedbert_vector_db/metadata.pkl +2 -2
  48. requirements.txt +28 -17
  49. src/__pycache__/document_processor.cpython-313.pyc +0 -0
  50. src/__pycache__/retriever.cpython-313.pyc +0 -0
.dockerignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Frontend build artifacts / deps (rebuilt inside the image)
2
+ frontend/node_modules
3
+ frontend/dist
4
+
5
+ # Python caches / envs
6
+ **/__pycache__
7
+ *.pyc
8
+ rag_env
9
+ .venv
10
+ venv
11
+
12
+ # Legacy Streamlit UI (superseded by the React frontend; not run in the container)
13
+ app.py
14
+ ui/
15
+
16
+ # Local runtime data (mounted via Azure Files in prod)
17
+ uploaded_reports
18
+
19
+ # Repo/tooling noise
20
+ .git
21
+ .gitattributes
22
+ image.png
23
+ README.md
24
+ azure
.env ADDED
@@ -0,0 +1 @@
 
 
1
+ GEMINI_API_KEY=AIzaSyD6LIwVuyNnAF7oe5jzwoN5mAc6Z6FXjFY
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ venv/
6
+ rag_env/
7
+
8
+ # Node / frontend
9
+ frontend/node_modules/
10
+ frontend/dist/
11
+
12
+ # Local runtime data
13
+ uploaded_reports/
14
+
15
+ # OS
16
+ .DS_Store
Dockerfile CHANGED
@@ -1,20 +1,41 @@
1
- FROM python:3.13.5-slim
 
 
 
 
 
 
2
 
 
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
  && rm -rf /var/lib/apt/lists/*
10
 
 
11
  COPY requirements.txt ./
 
 
 
 
 
12
  COPY src/ ./src/
 
 
13
 
14
- RUN pip3 install -r requirements.txt
 
15
 
16
- EXPOSE 8501
 
 
 
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
 
 
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
1
+ # ── Stage 1: build the React frontend ───────────────────────────────────────
2
+ FROM node:20-slim AS frontend
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package.json frontend/package-lock.json* ./
5
+ RUN npm ci
6
+ COPY frontend/ ./
7
+ RUN npm run build # emits /app/frontend/dist
8
 
9
+ # ── Stage 2: python runtime (FastAPI serves API + built SPA) ─────────────────
10
+ FROM python:3.11-slim AS runtime
11
  WORKDIR /app
12
 
13
+ # Build deps for faiss / paddle / native wheels
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ build-essential curl libgl1 libglib2.0-0 libgomp1 \
 
16
  && rm -rf /var/lib/apt/lists/*
17
 
18
+ # Python deps first (better layer caching)
19
  COPY requirements.txt ./
20
+ RUN pip install "torch>=2.3" --index-url https://download.pytorch.org/whl/cpu && \
21
+ pip install -r requirements.txt
22
+
23
+
24
+ # App code + the base vector DB
25
  COPY src/ ./src/
26
+ COPY backend/ ./backend/
27
+ COPY output/ ./output/
28
 
29
+ # Built frontend from stage 1
30
+ COPY --from=frontend /app/frontend/dist ./frontend/dist
31
 
32
+ ENV CUDA_VISIBLE_DEVICES="" \
33
+ DB_PATH=output/biomedbert_vector_db \
34
+ UPLOAD_DIR=uploaded_reports \
35
+ PORT=8000
36
 
37
+ EXPOSE 8000
38
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
39
+ CMD curl -fsS http://localhost:8000/api/health || exit 1
40
 
41
+ CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${PORT}"]
app.yaml ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ id: /subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/patho-rag/providers/Microsoft.App/containerapps/pathorag
2
+ identity:
3
+ principalId: a2dd65f8-ce7c-42ac-982e-d42dbe6dbe14
4
+ tenantId: 61f3e3b8-9b52-433a-a4eb-c67334ce54d5
5
+ type: SystemAssigned
6
+ location: France Central
7
+ name: pathorag
8
+ properties:
9
+ configuration:
10
+ activeRevisionsMode: Single
11
+ dapr: null
12
+ identitySettings: []
13
+ ingress:
14
+ additionalPortMappings: null
15
+ allowInsecure: false
16
+ clientCertificateMode: null
17
+ corsPolicy: null
18
+ customDomains: null
19
+ exposedPort: 0
20
+ external: true
21
+ fqdn: pathorag.agreeablebeach-ed9dda6b.francecentral.azurecontainerapps.io
22
+ ipSecurityRestrictions: null
23
+ stickySessions: null
24
+ targetPort: 8000
25
+ targetPortHttpScheme: null
26
+ traffic:
27
+ - latestRevision: true
28
+ weight: 100
29
+ transport: Auto
30
+ maxInactiveRevisions: null
31
+ registries:
32
+ - identity: system
33
+ passwordSecretRef: ''
34
+ server: pathoragacr.azurecr.io
35
+ username: ''
36
+ revisionTransitionThreshold: null
37
+ runtime: null
38
+ secrets:
39
+ - name: google-api-key
40
+ service: null
41
+ targetLabel: ''
42
+ customDomainVerificationId: 8B0174A32E63E74188290E5173041F60F4E8386B5C95F7487A60CD2B2BC53587
43
+ delegatedIdentities: []
44
+ environmentId: /subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/lidc-rg/providers/Microsoft.App/managedEnvironments/managedEnvironment-lidcrg-a5f0
45
+ eventStreamEndpoint: https://francecentral.azurecontainerapps.dev/subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/patho-rag/containerApps/pathorag/eventstream
46
+ latestReadyRevisionName: pathorag--0000001
47
+ latestRevisionFqdn: pathorag--0000001.agreeablebeach-ed9dda6b.francecentral.azurecontainerapps.io
48
+ latestRevisionName: pathorag--0000001
49
+ managedEnvironmentId: /subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/lidc-rg/providers/Microsoft.App/managedEnvironments/managedEnvironment-lidcrg-a5f0
50
+ outboundIpAddresses:
51
+ - 20.74.16.183
52
+ - 20.74.94.128
53
+ - 20.74.94.150
54
+ - 20.74.93.40
55
+ - 98.66.249.192
56
+ - 98.66.250.10
57
+ - 98.66.248.181
58
+ - 98.66.249.135
59
+ - 98.66.248.214
60
+ - 98.66.250.141
61
+ - 20.19.239.83
62
+ - 172.189.12.75
63
+ - 172.189.174.219
64
+ - 4.178.216.19
65
+ - 172.189.173.164
66
+ - 4.178.14.102
67
+ - 20.216.214.144
68
+ - 20.19.159.158
69
+ - 20.216.214.132
70
+ - 172.189.28.145
71
+ - 40.66.62.27
72
+ - 4.251.149.121
73
+ - 20.19.159.161
74
+ - 4.176.16.39
75
+ - 172.189.105.120
76
+ - 40.66.63.35
77
+ - 4.233.27.154
78
+ - 4.176.16.32
79
+ - 4.233.27.155
80
+ - 172.189.165.195
81
+ - 172.189.15.42
82
+ - 4.233.27.160
83
+ - 20.19.232.87
84
+ - 172.189.15.26
85
+ - 172.189.28.153
86
+ - 172.189.14.215
87
+ - 4.251.149.128
88
+ - 172.189.158.145
89
+ - 172.189.107.176
90
+ - 4.176.46.125
91
+ - 172.189.28.148
92
+ - 4.176.16.37
93
+ - 4.176.16.44
94
+ - 4.176.46.141
95
+ - 20.216.214.197
96
+ - 172.189.28.156
97
+ - 172.189.14.226
98
+ - 4.251.149.135
99
+ - 20.19.236.225
100
+ - 172.189.14.230
101
+ - 4.178.14.133
102
+ - 20.19.159.180
103
+ - 4.176.46.129
104
+ - 172.189.107.188
105
+ - 20.216.221.212
106
+ - 4.233.27.166
107
+ - 4.176.46.151
108
+ - 20.216.214.182
109
+ - 4.251.201.207
110
+ - 20.216.214.169
111
+ - 20.216.221.231
112
+ - 4.251.201.192
113
+ - 172.189.14.240
114
+ - 4.176.46.122
115
+ - 4.178.14.148
116
+ - 4.176.46.177
117
+ - 4.176.46.173
118
+ - 4.251.149.136
119
+ - 4.233.27.161
120
+ - 172.189.107.190
121
+ - 172.189.165.198
122
+ - 172.189.28.167
123
+ - 172.189.28.164
124
+ - 20.19.159.183
125
+ - 20.216.221.229
126
+ - 4.178.14.135
127
+ - 20.19.232.86
128
+ - 20.216.221.203
129
+ - 20.216.214.158
130
+ - 20.216.221.205
131
+ - 20.40.148.255
132
+ - 4.211.69.69
133
+ - 4.251.202.168
134
+ - 4.251.186.182
135
+ - 4.178.14.42
136
+ - 4.251.138.82
137
+ - 172.189.13.56
138
+ - 4.211.69.75
139
+ - 172.189.46.179
140
+ - 20.216.196.172
141
+ - 20.216.214.129
142
+ - 4.251.201.171
143
+ - 51.103.5.3
144
+ - 20.199.32.9
145
+ - 172.189.158.203
146
+ - 51.11.233.150
147
+ - 172.189.14.180
148
+ - 172.189.27.233
149
+ - 20.74.47.218
150
+ - 172.189.28.133
151
+ - 20.216.216.188
152
+ - 20.216.216.219
153
+ - 20.216.217.5
154
+ - 20.216.216.182
155
+ - 98.66.242.126
156
+ - 98.66.242.144
157
+ - 98.66.242.209
158
+ - 98.66.242.230
159
+ - 98.66.242.239
160
+ - 98.66.242.232
161
+ - 172.189.12.72
162
+ - 20.19.223.96
163
+ - 20.19.220.136
164
+ - 20.19.221.135
165
+ - 4.233.16.221
166
+ - 4.176.46.82
167
+ - 4.251.201.180
168
+ - 20.74.96.18
169
+ - 172.189.14.188
170
+ - 20.19.207.214
171
+ - 4.251.149.8
172
+ - 4.233.27.143
173
+ - 20.216.221.100
174
+ - 20.19.207.234
175
+ - 4.233.27.148
176
+ - 20.19.207.210
177
+ - 4.233.28.23
178
+ - 20.216.221.171
179
+ - 172.189.28.136
180
+ - 4.233.27.142
181
+ - 20.216.221.185
182
+ - 20.216.221.177
183
+ - 4.178.14.70
184
+ - 172.189.159.19
185
+ - 20.19.159.153
186
+ - 4.178.14.41
187
+ - 172.189.107.169
188
+ - 20.19.159.143
189
+ - 4.176.46.112
190
+ - 172.189.107.149
191
+ - 172.189.165.187
192
+ - 4.176.16.0
193
+ - 172.189.165.182
194
+ - 4.176.16.27
195
+ - 4.251.149.91
196
+ - 4.176.16.14
197
+ - 172.189.158.226
198
+ - 4.178.13.255
199
+ - 4.178.14.18
200
+ - 4.178.14.63
201
+ - 4.178.14.36
202
+ - 172.189.14.208
203
+ - 172.189.158.239
204
+ - 20.19.159.41
205
+ - 20.216.221.200
206
+ - 4.176.16.21
207
+ - 20.19.207.222
208
+ - 4.233.27.244
209
+ - 172.189.158.230
210
+ - 4.176.46.94
211
+ - 4.251.149.99
212
+ - 4.251.149.50
213
+ - 4.251.149.109
214
+ - 4.251.201.186
215
+ - 172.189.107.139
216
+ - 20.216.214.131
217
+ - 172.189.28.140
218
+ - 172.189.14.205
219
+ - 20.19.159.140
220
+ - 172.189.28.143
221
+ - 172.189.28.135
222
+ - 20.19.159.136
223
+ - 172.189.158.223
224
+ - 40.66.60.105
225
+ - 4.233.26.37
226
+ - 4.178.14.2
227
+ - 20.216.221.164
228
+ - 172.189.105.174
229
+ - 4.233.27.134
230
+ - 20.216.219.52
231
+ - 172.189.44.67
232
+ patchingMode: Automatic
233
+ provisioningState: Succeeded
234
+ runningStatus: Running
235
+ template:
236
+ containers:
237
+ - env:
238
+ - name: GOOGLE_API_KEY
239
+ secretRef: google-api-key
240
+ image: pathoragacr.azurecr.io/pathorag:latest
241
+ imageType: ContainerImage
242
+ name: pathorag
243
+ resources:
244
+ cpu: 2.0
245
+ ephemeralStorage: 8Gi
246
+ memory: 4Gi
247
+ customMetricsSettings: null
248
+ initContainers: null
249
+ revisionSuffix: ''
250
+ scale:
251
+ cooldownPeriod: 300
252
+ maxReplicas: 3
253
+ minReplicas: 1
254
+ pollingInterval: 30
255
+ rules: null
256
+ serviceBinds: null
257
+ terminationGracePeriodSeconds: null
258
+ volumes: null
259
+ workloadProfileName: Consumption
260
+ resourceGroup: patho-rag
261
+ systemData:
262
+ createdAt: '2026-07-10T20:52:23.0352323'
263
+ createdBy: surya-prakash.selva@student-cs.fr
264
+ createdByType: User
265
+ lastModifiedAt: '2026-07-10T20:52:42.5113595'
266
+ lastModifiedBy: surya-prakash.selva@student-cs.fr
267
+ lastModifiedByType: User
268
+ type: Microsoft.App/containerApps
azure/deploy.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying to Azure Container Apps
2
+
3
+ Single Docker image: FastAPI (`backend/main.py`) serves both the `/api/*` routes
4
+ and the built React SPA at `/`. These steps build the image in Azure Container
5
+ Registry (ACR) and run it on Azure Container Apps (ACA).
6
+
7
+ ## Prerequisites
8
+
9
+ - `az` CLI logged in (`az login`) with a subscription selected.
10
+ - A resource group, e.g. `az group create -n patho-rag -l eastus`.
11
+ - Your `GOOGLE_API_KEY` for Gemini.
12
+ - **Vector DB present locally**: `output/biomedbert_vector_db/{faiss.index,metadata.pkl}`
13
+ are tracked with git-LFS. Run `git lfs pull` so the image bakes real files,
14
+ not LFS pointer stubs (otherwise the app boots "degraded").
15
+
16
+ ## 1. Create an ACR and build the image
17
+
18
+ ```bash
19
+ az acr create -g patho-rag -n pathoragacr --sku Basic
20
+ az acr build -r pathoragacr -t pathorag:latest . # builds Dockerfile in the cloud
21
+ ```
22
+
23
+ ## 2. Create the Container Apps environment
24
+
25
+ ```bash
26
+ az extension add --name containerapp --upgrade
27
+ az containerapp env create -g patho-rag -n patho-env -l eastus
28
+ ```
29
+
30
+ ## 3. Create the app
31
+
32
+ ```bash
33
+ az containerapp create \
34
+ -g patho-rag -n pathorag \
35
+ --environment patho-env \
36
+ --image pathoragacr.azurecr.io/pathorag:latest \
37
+ --registry-server pathoragacr.azurecr.io \
38
+ --target-port 8000 --ingress external \
39
+ --min-replicas 1 --max-replicas 3 \
40
+ --cpu 2 --memory 4Gi \
41
+ --secrets google-api-key=<YOUR_GEMINI_KEY> \
42
+ --env-vars GOOGLE_API_KEY=secretref:google-api-key
43
+ ```
44
+
45
+ - `--min-replicas 1` avoids scale-to-zero so the models stay warm (cold start
46
+ reloads BiomedBERT + cross-encoder + PaddleOCR, which is slow).
47
+ - Bump `--cpu/--memory` if OCR/embedding is heavy; torch + paddle want headroom.
48
+
49
+ The public URL is the app's FQDN:
50
+
51
+ ```bash
52
+ az containerapp show -g patho-rag -n pathorag --query properties.configuration.ingress.fqdn -o tsv
53
+ ```
54
+
55
+ ## 4. Persist uploads + index across restarts (important)
56
+
57
+ The container filesystem is **ephemeral** β€” uploaded PDFs and the FAISS index
58
+ updates from new documents are lost on restart/redeploy unless mounted.
59
+
60
+ 1. Create a storage account + file share, and register it with the ACA env:
61
+
62
+ ```bash
63
+ az storage account create -g patho-rag -n pathoragstore --sku Standard_LRS
64
+ az storage share create --account-name pathoragstore -n ragdata
65
+ az containerapp env storage set -g patho-rag -n patho-env \
66
+ --storage-name ragdata --azure-file-account-name pathoragstore \
67
+ --azure-file-account-key <KEY> --azure-file-share-name ragdata \
68
+ --access-mode ReadWrite
69
+ ```
70
+
71
+ 2. Mount it into the app (via `az containerapp update --yaml` volume/volumeMounts)
72
+ at both `uploaded_reports/` and `output/biomedbert_vector_db/`, and seed the
73
+ share with the initial `output/biomedbert_vector_db/` contents so the base
74
+ corpus is available on first boot.
75
+
76
+ ## 5. Model cache (optional, faster cold starts)
77
+
78
+ BiomedBERT, the cross-encoder, and PaddleOCR weights download on first use. To
79
+ avoid re-downloading on every new replica, either bake a warm cache into the
80
+ image (pre-download during `docker build`) or point `HF_HOME` / the paddle cache
81
+ dir at the mounted Azure Files share.
82
+
83
+ ## Update / redeploy
84
+
85
+ ```bash
86
+ az acr build -r pathoragacr -t pathorag:latest .
87
+ az containerapp update -g patho-rag -n pathorag \
88
+ --image pathoragacr.azurecr.io/pathorag:latest
89
+ ```
90
+
91
+ ## Smoke test
92
+
93
+ - Open the FQDN β†’ the SPA loads.
94
+ - `GET /api/health` β†’ `{"status":"ok","num_documents":N}`.
95
+ - Ask a question β†’ answer streams token-by-token with citations.
96
+ - Upload a PDF β†’ progress bar β†’ the doc becomes selectable and highlights work.
97
+ - Restart a replica β†’ previously uploaded docs still present (confirms the mount).
98
+ ```
backend/__init__.py ADDED
File without changes
backend/main.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI app for the Pathology RAG system.
2
+
3
+ Serves a streaming chat API over the existing RAG pipeline and (in the
4
+ container) the built React SPA from the same origin.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ from pathlib import Path
10
+ from contextlib import asynccontextmanager
11
+
12
+ from fastapi import FastAPI, UploadFile, File, HTTPException
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import FileResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+ from sse_starlette.sse import EventSourceResponse
17
+
18
+ from .schemas import (
19
+ ChatRequest, ReportsResponse, HealthResponse, UploadStartResponse,
20
+ )
21
+ from .rag_service import service, UPLOAD_DIR
22
+
23
+ FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
24
+
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(app: FastAPI):
28
+ # Load the (expensive) pipeline once at startup.
29
+ service.load()
30
+ yield
31
+
32
+
33
+ app = FastAPI(title="Pathology RAG API", lifespan=lifespan)
34
+
35
+ # Permissive CORS only matters for local dev (Vite on :5173 β†’ API on :8000).
36
+ # In the container the SPA is same-origin so this is a no-op.
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_methods=["*"],
41
+ allow_headers=["*"],
42
+ )
43
+
44
+
45
+ async def _sse(threaded_stream):
46
+ """Adapt a ThreadedEventStream ({"event","data":dict}) to the dict shape
47
+ EventSourceResponse expects ({"event", "data": <json str>})."""
48
+ async for item in threaded_stream:
49
+ yield {"event": item["event"], "data": json.dumps(item["data"])}
50
+
51
+
52
+ # ── API ─────────────────────────────────────────────────────────────────────
53
+ @app.get("/api/health", response_model=HealthResponse)
54
+ async def health():
55
+ if service.ready:
56
+ return HealthResponse(status="ok", num_documents=len(service.get_reports()))
57
+ return HealthResponse(status="degraded", num_documents=0, detail=service.load_error)
58
+
59
+
60
+ @app.get("/api/reports", response_model=ReportsResponse)
61
+ async def reports():
62
+ return ReportsResponse(reports=service.get_reports())
63
+
64
+
65
+ @app.post("/api/chat")
66
+ async def chat(req: ChatRequest):
67
+ if not service.ready:
68
+ raise HTTPException(503, detail=f"Pipeline not ready: {service.load_error}")
69
+
70
+ history = [m.model_dump() for m in req.history] if req.history else None
71
+ producer = service.make_chat_producer(
72
+ question=req.question,
73
+ report_name=req.report_name,
74
+ report_names=req.report_names,
75
+ top_k=req.top_k,
76
+ history=history,
77
+ )
78
+ from .rag_service import ThreadedEventStream
79
+ stream = ThreadedEventStream()
80
+ stream.run(producer)
81
+ return EventSourceResponse(_sse(stream))
82
+
83
+
84
+ @app.post("/api/upload", response_model=UploadStartResponse)
85
+ async def upload(file: UploadFile = File(...)):
86
+ if not service.ready:
87
+ raise HTTPException(503, detail=f"Pipeline not ready: {service.load_error}")
88
+ if not file.filename.lower().endswith(".pdf"):
89
+ raise HTTPException(400, detail="Only PDF files are supported.")
90
+
91
+ upload_dir = Path(UPLOAD_DIR)
92
+ upload_dir.mkdir(exist_ok=True)
93
+ # Strip any path components from the client-supplied name.
94
+ safe_name = Path(file.filename).name
95
+ dest = upload_dir / safe_name
96
+ dest.write_bytes(await file.read())
97
+
98
+ job_id = service.start_upload(str(dest))
99
+ return UploadStartResponse(job_id=job_id, filename=safe_name)
100
+
101
+
102
+ @app.get("/api/upload/{job_id}/events")
103
+ async def upload_events(job_id: str):
104
+ stream = service.get_job(job_id)
105
+ if stream is None:
106
+ raise HTTPException(404, detail="Unknown or expired upload job.")
107
+
108
+ async def gen():
109
+ async for evt in _sse(stream):
110
+ yield evt
111
+ service.pop_job(job_id)
112
+
113
+ return EventSourceResponse(gen())
114
+
115
+
116
+ @app.get("/api/pdf/{name}")
117
+ async def get_pdf(name: str):
118
+ """Serve a raw uploaded PDF for the viewer. `name` may be the report stem
119
+ or the full file name; path components are stripped to prevent traversal."""
120
+ stem = Path(name).name
121
+ upload_dir = Path(UPLOAD_DIR)
122
+ candidate = upload_dir / (stem if stem.lower().endswith(".pdf") else f"{stem}.pdf")
123
+ if not candidate.exists():
124
+ raise HTTPException(404, detail="PDF not found.")
125
+ return FileResponse(str(candidate), media_type="application/pdf")
126
+
127
+
128
+ # ── Static SPA (only when a build exists; in dev Vite serves the frontend) ──
129
+ if FRONTEND_DIST.exists():
130
+ app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="spa")
backend/rag_service.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service layer around the existing RAG pipeline.
2
+
3
+ Wraps `CompleteRAGPipeline` (retrieval + streaming generation) and
4
+ `DynamicRAGUpdater` (OCR upload + index update), and provides a small
5
+ thread→async bridge so the blocking ML work streams cleanly over SSE
6
+ without blocking the event loop.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import uuid
12
+ import queue
13
+ import asyncio
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional, Callable
17
+
18
+ # Reuse the existing, working RAG code in src/
19
+ sys.path.append(str(Path(__file__).resolve().parent.parent / "src"))
20
+
21
+ DB_PATH = os.getenv("DB_PATH", "output/biomedbert_vector_db")
22
+ EMBEDDING_MODEL = os.getenv(
23
+ "EMBEDDING_MODEL",
24
+ "microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract-fulltext",
25
+ )
26
+ UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploaded_reports")
27
+
28
+
29
+ # ────────────────────────────────────────────────────────────────────────────
30
+ # Thread β†’ async SSE bridge
31
+ # ────────────────────────────────────────────────────────────────────────────
32
+ class ThreadedEventStream:
33
+ """Runs a blocking `producer(emit)` in a daemon thread and exposes an async
34
+ iterator of the events it emits. The producer calls `emit({"event","data"})`
35
+ for each event; iteration ends when the producer returns (or raises, which
36
+ is surfaced as a final error event)."""
37
+
38
+ _DONE = object()
39
+
40
+ def __init__(self):
41
+ self._q: "queue.Queue" = queue.Queue()
42
+
43
+ def run(self, producer: Callable[[Callable[[dict], None]], None]):
44
+ def worker():
45
+ try:
46
+ producer(self._q.put)
47
+ except Exception as exc: # noqa: BLE001 - surface to the client
48
+ self._q.put({"event": "error", "data": {"message": str(exc)}})
49
+ finally:
50
+ self._q.put(self._DONE)
51
+
52
+ threading.Thread(target=worker, daemon=True).start()
53
+
54
+ async def __aiter__(self):
55
+ while True:
56
+ try:
57
+ item = self._q.get_nowait()
58
+ except queue.Empty:
59
+ await asyncio.sleep(0.05)
60
+ continue
61
+ if item is self._DONE:
62
+ break
63
+ yield item
64
+
65
+
66
+ class RagService:
67
+ def __init__(self):
68
+ self.pipeline = None
69
+ self._updater = None
70
+ self._ocr_engine = None
71
+ self.load_error: Optional[str] = None
72
+ self._jobs: Dict[str, ThreadedEventStream] = {}
73
+ self._job_files: Dict[str, str] = {}
74
+
75
+ # ── lifecycle ──────────────────────────────────────────────────────────
76
+ def load(self):
77
+ """Load the pipeline once at startup. Failures (missing API key / DB)
78
+ are captured so the app can still serve the frontend and report a
79
+ degraded health status instead of crashing."""
80
+ try:
81
+ from retriever import CompleteRAGPipeline
82
+ self.pipeline = CompleteRAGPipeline(
83
+ faiss_db_path=DB_PATH,
84
+ embedding_model=EMBEDDING_MODEL,
85
+ )
86
+ self.load_error = None
87
+ except Exception as exc: # noqa: BLE001
88
+ import traceback
89
+ self.pipeline = None
90
+ self.load_error = str(exc)
91
+ # Print the full traceback so the real failing import shows up in
92
+ # container logs instead of just the top-level message.
93
+ print("=== PIPELINE LOAD FAILED ===", flush=True)
94
+ traceback.print_exc()
95
+
96
+ @property
97
+ def ready(self) -> bool:
98
+ return self.pipeline is not None
99
+
100
+ def _get_updater(self):
101
+ """Lazily build the OCR-based updater on first upload, reusing the
102
+ pipeline's already-loaded embedder so only one embedding model lives
103
+ in memory (same pattern the Streamlit app used)."""
104
+ if self._updater is None:
105
+ from paddleocr import PaddleOCR
106
+ from document_processor import DynamicRAGUpdater
107
+ if self._ocr_engine is None:
108
+ # PaddleOCR 3.x API (see document_processor for rationale).
109
+ self._ocr_engine = PaddleOCR(
110
+ lang="en",
111
+ use_doc_orientation_classify=False,
112
+ use_doc_unwarping=False,
113
+ use_textline_orientation=False,
114
+ )
115
+ self._updater = DynamicRAGUpdater(
116
+ vector_db_path=DB_PATH,
117
+ upload_dir=UPLOAD_DIR,
118
+ ocr_engine=self._ocr_engine,
119
+ embedder=self.pipeline.query_processor.model,
120
+ )
121
+ return self._updater
122
+
123
+ # ── reads ───────────────────────��──────────────────────────────────────
124
+ def get_reports(self) -> List[str]:
125
+ if not self.ready:
126
+ return []
127
+ return self.pipeline.get_available_reports()
128
+
129
+ # ── retrieval + streaming generation ────────────────────────────────────
130
+ @staticmethod
131
+ def _sources_payload(top_chunks: List[Dict]) -> List[dict]:
132
+ out = []
133
+ for i, src in enumerate(top_chunks, 1):
134
+ chunk = src["chunk"]
135
+ out.append({
136
+ "index": i,
137
+ "filename": chunk.get("filename", "unknown"),
138
+ "page": chunk.get("page"),
139
+ "line_bboxes": chunk.get("line_bboxes", []),
140
+ "text": chunk.get("text", ""),
141
+ "score": float(src.get("ce_score", src.get("score", 0.0))),
142
+ })
143
+ return out
144
+
145
+ def make_chat_producer(
146
+ self,
147
+ question: str,
148
+ report_name: Optional[str],
149
+ report_names: Optional[List[str]],
150
+ top_k: int,
151
+ history: Optional[List[dict]],
152
+ ):
153
+ """Returns a producer(emit) that: retrieves β†’ emits `sources` β†’
154
+ streams `token` deltas β†’ emits `done`."""
155
+
156
+ def producer(emit: Callable[[dict], None]):
157
+ top_chunks = self.pipeline.retrieve(
158
+ question,
159
+ report_name=report_name,
160
+ report_names=report_names,
161
+ top_k=top_k,
162
+ )
163
+ emit({"event": "sources", "data": {"sources": self._sources_payload(top_chunks)}})
164
+
165
+ if not top_chunks:
166
+ emit({"event": "token", "data": {"text": "No relevant information found for this question."}})
167
+ emit({"event": "done", "data": {"num_sources": 0}})
168
+ return
169
+
170
+ for delta in self.pipeline.llm.generate_stream(question, top_chunks, history):
171
+ emit({"event": "token", "data": {"text": delta}})
172
+
173
+ emit({"event": "done", "data": {"num_sources": len(top_chunks)}})
174
+
175
+ return producer
176
+
177
+ # ── upload as a background job with progress SSE ─────────────────────────
178
+ def start_upload(self, pdf_path: str) -> str:
179
+ job_id = uuid.uuid4().hex
180
+ stream = ThreadedEventStream()
181
+
182
+ def producer(emit: Callable[[dict], None]):
183
+ def cb(stage: str, detail: dict):
184
+ emit({"event": "progress", "data": {"stage": stage, **(detail or {})}})
185
+
186
+ updater = self._get_updater()
187
+ stats = updater.process_and_add_pdf(pdf_path, progress_callback=cb)
188
+ self.pipeline.reload_index()
189
+ emit({"event": "complete", "data": stats})
190
+
191
+ stream.run(producer)
192
+ self._jobs[job_id] = stream
193
+ self._job_files[job_id] = pdf_path
194
+ return job_id
195
+
196
+ def get_job(self, job_id: str) -> Optional[ThreadedEventStream]:
197
+ return self._jobs.get(job_id)
198
+
199
+ def pop_job(self, job_id: str):
200
+ self._jobs.pop(job_id, None)
201
+ self._job_files.pop(job_id, None)
202
+
203
+
204
+ service = RagService()
backend/schemas.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic request/response models for the Pathology RAG API."""
2
+
3
+ from typing import List, Optional
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class ChatMessage(BaseModel):
8
+ role: str # "user" | "assistant"
9
+ content: str
10
+
11
+
12
+ class ChatRequest(BaseModel):
13
+ question: str
14
+ report_name: Optional[str] = None # single-document scope (Document Chat)
15
+ report_names: Optional[List[str]] = None # multi-document scope (Global Search)
16
+ top_k: int = 5
17
+ history: Optional[List[ChatMessage]] = None
18
+
19
+
20
+ class SourceOut(BaseModel):
21
+ index: int
22
+ filename: str
23
+ page: Optional[int] = None
24
+ line_bboxes: List[List[float]] = []
25
+ text: str
26
+ score: float
27
+
28
+
29
+ class ReportsResponse(BaseModel):
30
+ reports: List[str]
31
+
32
+
33
+ class HealthResponse(BaseModel):
34
+ status: str # "ok" | "degraded"
35
+ num_documents: int
36
+ detail: Optional[str] = None
37
+
38
+
39
+ class UploadStartResponse(BaseModel):
40
+ job_id: str
41
+ filename: str
frontend/.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ *.local
4
+ .DS_Store
frontend/index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Pathology RAG</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="/src/main.tsx"></script>
17
+ </body>
18
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "pathology-rag-frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@tanstack/react-query": "^5.51.0",
13
+ "class-variance-authority": "^0.7.0",
14
+ "clsx": "^2.1.1",
15
+ "lucide-react": "^0.412.0",
16
+ "react": "^18.3.1",
17
+ "react-dom": "^18.3.1",
18
+ "react-markdown": "^9.0.1",
19
+ "react-pdf": "^9.1.1",
20
+ "tailwind-merge": "^2.4.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.19.43",
24
+ "@types/react": "^18.3.3",
25
+ "@types/react-dom": "^18.3.0",
26
+ "@vitejs/plugin-react": "^4.3.1",
27
+ "autoprefixer": "^10.4.19",
28
+ "postcss": "^8.4.40",
29
+ "tailwindcss": "^3.4.7",
30
+ "typescript": "^5.5.4",
31
+ "vite": "^5.3.5"
32
+ }
33
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
frontend/src/App.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { AlertTriangle } from "lucide-react";
4
+ import { getHealth } from "@/lib/api";
5
+ import { Sidebar, type View } from "@/components/Sidebar";
6
+ import { DocumentChat } from "@/views/DocumentChat";
7
+ import { GlobalSearch } from "@/views/GlobalSearch";
8
+
9
+ export default function App() {
10
+ const [view, setView] = useState<View>("document");
11
+ const { data: health } = useQuery({ queryKey: ["health"], queryFn: getHealth });
12
+
13
+ return (
14
+ <div className="flex h-screen overflow-hidden">
15
+ <Sidebar view={view} onView={setView} />
16
+ <main className="flex min-w-0 flex-1 flex-col">
17
+ {health?.status === "degraded" && (
18
+ <div className="flex items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-600 dark:text-amber-400">
19
+ <AlertTriangle className="h-4 w-4 shrink-0" />
20
+ <span className="truncate">
21
+ Backend not fully ready: {health.detail ?? "pipeline unavailable"}
22
+ </span>
23
+ </div>
24
+ )}
25
+ <div className="min-h-0 flex-1">
26
+ {view === "document" ? <DocumentChat /> : <GlobalSearch />}
27
+ </div>
28
+ </main>
29
+ </div>
30
+ );
31
+ }
frontend/src/components/ChatInput.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState, type KeyboardEvent } from "react";
2
+ import { ArrowUp, Square } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export function ChatInput({
6
+ onSend,
7
+ onStop,
8
+ busy,
9
+ disabled,
10
+ placeholder,
11
+ }: {
12
+ onSend: (text: string) => void;
13
+ onStop?: () => void;
14
+ busy?: boolean;
15
+ disabled?: boolean;
16
+ placeholder?: string;
17
+ }) {
18
+ const [text, setText] = useState("");
19
+ const ref = useRef<HTMLTextAreaElement>(null);
20
+
21
+ const submit = () => {
22
+ const t = text.trim();
23
+ if (!t || busy || disabled) return;
24
+ onSend(t);
25
+ setText("");
26
+ if (ref.current) ref.current.style.height = "auto";
27
+ };
28
+
29
+ const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
30
+ if (e.key === "Enter" && !e.shiftKey) {
31
+ e.preventDefault();
32
+ submit();
33
+ }
34
+ };
35
+
36
+ return (
37
+ <div
38
+ className={cn(
39
+ "flex items-end gap-2 rounded-2xl border border-border bg-card p-2 shadow-sm transition-colors focus-within:border-primary/60",
40
+ disabled && "opacity-60",
41
+ )}
42
+ >
43
+ <textarea
44
+ ref={ref}
45
+ value={text}
46
+ rows={1}
47
+ disabled={disabled}
48
+ placeholder={placeholder ?? "Ask a question…"}
49
+ onChange={(e) => {
50
+ setText(e.target.value);
51
+ e.target.style.height = "auto";
52
+ e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px";
53
+ }}
54
+ onKeyDown={onKey}
55
+ className="max-h-[200px] flex-1 resize-none bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground"
56
+ />
57
+ {busy ? (
58
+ <button
59
+ onClick={onStop}
60
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground hover:bg-accent"
61
+ title="Stop"
62
+ >
63
+ <Square className="h-4 w-4" />
64
+ </button>
65
+ ) : (
66
+ <button
67
+ onClick={submit}
68
+ disabled={!text.trim() || disabled}
69
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
70
+ title="Send"
71
+ >
72
+ <ArrowUp className="h-4 w-4" />
73
+ </button>
74
+ )}
75
+ </div>
76
+ );
77
+ }
frontend/src/components/ChatThread.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import { MessageBubble, type Message } from "./MessageBubble";
3
+
4
+ export function ChatThread({
5
+ messages,
6
+ onCitation,
7
+ renderExtra,
8
+ empty,
9
+ }: {
10
+ messages: Message[];
11
+ onCitation?: (message: Message, index: number) => void;
12
+ renderExtra?: (message: Message) => React.ReactNode;
13
+ empty?: React.ReactNode;
14
+ }) {
15
+ const endRef = useRef<HTMLDivElement>(null);
16
+
17
+ useEffect(() => {
18
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
19
+ }, [messages]);
20
+
21
+ if (messages.length === 0 && empty) {
22
+ return <div className="flex flex-1 items-center justify-center p-6">{empty}</div>;
23
+ }
24
+
25
+ return (
26
+ <div className="flex-1 space-y-5 overflow-y-auto px-1 py-4">
27
+ {messages.map((m) => (
28
+ <MessageBubble key={m.id} message={m} onCitation={(idx) => onCitation?.(m, idx)}>
29
+ {renderExtra?.(m)}
30
+ </MessageBubble>
31
+ ))}
32
+ <div ref={endRef} />
33
+ </div>
34
+ );
35
+ }
frontend/src/components/ExamplePrompts.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Sparkles } from "lucide-react";
2
+
3
+ export function ExamplePrompts({
4
+ title,
5
+ prompts,
6
+ onPick,
7
+ }: {
8
+ title: string;
9
+ prompts: string[];
10
+ onPick: (p: string) => void;
11
+ }) {
12
+ return (
13
+ <div className="max-w-md text-center">
14
+ <div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
15
+ <Sparkles className="h-5 w-5" />
16
+ </div>
17
+ <h2 className="mb-1 text-lg font-semibold">{title}</h2>
18
+ <p className="mb-4 text-sm text-muted-foreground">Try one of these to get started</p>
19
+ <div className="flex flex-col gap-2">
20
+ {prompts.map((p) => (
21
+ <button
22
+ key={p}
23
+ onClick={() => onPick(p)}
24
+ className="rounded-lg border border-border bg-card px-3 py-2 text-left text-sm transition-colors hover:border-primary/60 hover:bg-muted"
25
+ >
26
+ {p}
27
+ </button>
28
+ ))}
29
+ </div>
30
+ </div>
31
+ );
32
+ }
frontend/src/components/MessageBubble.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ReactMarkdown from "react-markdown";
2
+ import { Microscope, User } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+ import type { Source } from "@/lib/api";
5
+
6
+ export interface Message {
7
+ id: string;
8
+ role: "user" | "assistant";
9
+ content: string;
10
+ sources?: Source[];
11
+ streaming?: boolean;
12
+ }
13
+
14
+ /** Turn `[3]` reference markers into markdown links (#cite-3) so we can render
15
+ * them as interactive citation chips via the `a` component override. */
16
+ function withCitationLinks(text: string): string {
17
+ return text.replace(/\[(\d+)\]/g, "[$1](#cite-$1)");
18
+ }
19
+
20
+ export function MessageBubble({
21
+ message,
22
+ onCitation,
23
+ children,
24
+ }: {
25
+ message: Message;
26
+ onCitation?: (index: number) => void;
27
+ children?: React.ReactNode;
28
+ }) {
29
+ const isUser = message.role === "user";
30
+
31
+ return (
32
+ <div className={cn("flex gap-3 animate-fade-in", isUser && "flex-row-reverse")}>
33
+ <div
34
+ className={cn(
35
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
36
+ isUser ? "bg-muted text-foreground" : "bg-primary text-primary-foreground",
37
+ )}
38
+ >
39
+ {isUser ? <User className="h-4 w-4" /> : <Microscope className="h-4 w-4" />}
40
+ </div>
41
+
42
+ <div className={cn("min-w-0 max-w-[85%] space-y-2", isUser && "flex flex-col items-end")}>
43
+ <div
44
+ className={cn(
45
+ "rounded-2xl px-4 py-2.5 text-sm",
46
+ isUser
47
+ ? "bg-primary text-primary-foreground"
48
+ : "border border-border bg-card text-card-foreground",
49
+ )}
50
+ >
51
+ {isUser ? (
52
+ <span className="whitespace-pre-wrap">{message.content}</span>
53
+ ) : (
54
+ <div className="markdown">
55
+ <ReactMarkdown
56
+ components={{
57
+ a: ({ href, children: c }) => {
58
+ const m = href?.match(/^#cite-(\d+)$/);
59
+ if (m) {
60
+ const idx = Number(m[1]);
61
+ return (
62
+ <button
63
+ onClick={() => onCitation?.(idx)}
64
+ className="mx-0.5 inline-flex h-5 min-w-5 items-center justify-center rounded-md bg-primary/15 px-1 align-middle text-xs font-semibold text-primary hover:bg-primary/25"
65
+ title={`Jump to source ${idx}`}
66
+ >
67
+ {idx}
68
+ </button>
69
+ );
70
+ }
71
+ return (
72
+ <a href={href} target="_blank" rel="noreferrer" className="text-primary underline">
73
+ {c}
74
+ </a>
75
+ );
76
+ },
77
+ }}
78
+ >
79
+ {withCitationLinks(message.content) || "​"}
80
+ </ReactMarkdown>
81
+ {message.streaming && (
82
+ <span className="ml-0.5 inline-block h-4 w-1.5 translate-y-0.5 animate-pulse rounded-sm bg-primary" />
83
+ )}
84
+ </div>
85
+ )}
86
+ </div>
87
+ {children}
88
+ </div>
89
+ </div>
90
+ );
91
+ }
frontend/src/components/PdfViewer.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Document, Page, pdfjs } from "react-pdf";
3
+ import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from "lucide-react";
4
+ import { pdfUrl } from "@/lib/api";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ // pdf.js worker (bundled by Vite)
8
+ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
9
+ "pdfjs-dist/build/pdf.worker.min.mjs",
10
+ import.meta.url,
11
+ ).toString();
12
+
13
+ export function PdfViewer({
14
+ filename,
15
+ page,
16
+ highlights,
17
+ onPageChange,
18
+ }: {
19
+ filename: string | null;
20
+ page: number;
21
+ highlights: number[][];
22
+ onPageChange: (p: number) => void;
23
+ }) {
24
+ const containerRef = useRef<HTMLDivElement>(null);
25
+ const [containerWidth, setContainerWidth] = useState(600);
26
+ const [numPages, setNumPages] = useState(0);
27
+ const [zoom, setZoom] = useState(1);
28
+ const [renderScale, setRenderScale] = useState(1); // px per PDF point
29
+
30
+ useEffect(() => {
31
+ if (!containerRef.current) return;
32
+ const el = containerRef.current;
33
+ const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth - 32));
34
+ ro.observe(el);
35
+ setContainerWidth(el.clientWidth - 32);
36
+ return () => ro.disconnect();
37
+ }, []);
38
+
39
+ const file = useMemo(() => (filename ? pdfUrl(filename) : null), [filename]);
40
+ const pageWidth = Math.max(200, containerWidth * zoom);
41
+
42
+ if (!file) {
43
+ return (
44
+ <div className="flex h-full items-center justify-center rounded-xl border-2 border-dashed border-border text-sm text-muted-foreground">
45
+ Upload or select a document to preview it here
46
+ </div>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <div className="flex h-full flex-col rounded-xl border border-border bg-muted/30">
52
+ {/* toolbar */}
53
+ <div className="flex items-center gap-1 border-b border-border px-3 py-2">
54
+ <Button
55
+ variant="ghost"
56
+ size="icon"
57
+ disabled={page <= 1}
58
+ onClick={() => onPageChange(page - 1)}
59
+ >
60
+ <ChevronLeft className="h-4 w-4" />
61
+ </Button>
62
+ <span className="min-w-[90px] text-center text-xs text-muted-foreground">
63
+ Page {page} / {numPages || "…"}
64
+ </span>
65
+ <Button
66
+ variant="ghost"
67
+ size="icon"
68
+ disabled={numPages > 0 && page >= numPages}
69
+ onClick={() => onPageChange(page + 1)}
70
+ >
71
+ <ChevronRight className="h-4 w-4" />
72
+ </Button>
73
+ <div className="ml-auto flex items-center gap-1">
74
+ <Button variant="ghost" size="icon" onClick={() => setZoom((z) => Math.max(0.6, z - 0.15))}>
75
+ <ZoomOut className="h-4 w-4" />
76
+ </Button>
77
+ <span className="w-10 text-center text-xs text-muted-foreground">{Math.round(zoom * 100)}%</span>
78
+ <Button variant="ghost" size="icon" onClick={() => setZoom((z) => Math.min(2.5, z + 0.15))}>
79
+ <ZoomIn className="h-4 w-4" />
80
+ </Button>
81
+ </div>
82
+ </div>
83
+
84
+ {/* page */}
85
+ <div ref={containerRef} className="flex-1 overflow-auto p-4">
86
+ <Document
87
+ file={file}
88
+ onLoadSuccess={(pdf) => setNumPages(pdf.numPages)}
89
+ loading={
90
+ <div className="flex h-40 items-center justify-center text-muted-foreground">
91
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading document…
92
+ </div>
93
+ }
94
+ error={
95
+ <div className="flex h-40 items-center justify-center text-sm text-red-500">
96
+ Failed to load PDF.
97
+ </div>
98
+ }
99
+ className="flex justify-center"
100
+ >
101
+ <div className="relative shadow-lg" style={{ width: pageWidth }}>
102
+ <Page
103
+ pageNumber={page}
104
+ width={pageWidth}
105
+ renderTextLayer={false}
106
+ renderAnnotationLayer={false}
107
+ onLoadSuccess={(p) => setRenderScale(pageWidth / p.originalWidth)}
108
+ />
109
+ {/* highlight overlay (PDF points β†’ pixels via renderScale) */}
110
+ <div className="pointer-events-none absolute inset-0">
111
+ {highlights.map((b, i) => (
112
+ <div
113
+ key={i}
114
+ className="absolute rounded-sm animate-pulse-ring"
115
+ style={{
116
+ left: b[0] * renderScale,
117
+ top: b[1] * renderScale,
118
+ width: (b[2] - b[0]) * renderScale,
119
+ height: (b[3] - b[1]) * renderScale,
120
+ background: "hsl(var(--highlight) / 0.38)",
121
+ outline: "1px solid hsl(var(--highlight) / 0.8)",
122
+ }}
123
+ />
124
+ ))}
125
+ </div>
126
+ </div>
127
+ </Document>
128
+ </div>
129
+ </div>
130
+ );
131
+ }
frontend/src/components/ReportPicker.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Check, ChevronDown, X } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export function ReportPicker({
6
+ reports,
7
+ selected,
8
+ onChange,
9
+ }: {
10
+ reports: string[];
11
+ selected: string[];
12
+ onChange: (next: string[]) => void;
13
+ }) {
14
+ const [open, setOpen] = useState(false);
15
+
16
+ const toggle = (r: string) =>
17
+ onChange(selected.includes(r) ? selected.filter((x) => x !== r) : [...selected, r]);
18
+
19
+ return (
20
+ <div className="relative">
21
+ <button
22
+ onClick={() => setOpen((o) => !o)}
23
+ className="flex w-full items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm"
24
+ >
25
+ <span className="text-muted-foreground">Scope:</span>
26
+ <span className="flex-1 truncate text-left">
27
+ {selected.length === 0 ? "All documents" : `${selected.length} selected`}
28
+ </span>
29
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
30
+ </button>
31
+
32
+ {selected.length > 0 && (
33
+ <div className="mt-2 flex flex-wrap gap-1.5">
34
+ {selected.map((r) => (
35
+ <span
36
+ key={r}
37
+ className="inline-flex items-center gap-1 rounded-full bg-primary/12 px-2 py-0.5 text-xs text-primary"
38
+ >
39
+ {r}
40
+ <button onClick={() => toggle(r)}>
41
+ <X className="h-3 w-3" />
42
+ </button>
43
+ </span>
44
+ ))}
45
+ </div>
46
+ )}
47
+
48
+ {open && (
49
+ <>
50
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
51
+ <div className="absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-lg border border-border bg-card p-1 shadow-lg">
52
+ {reports.length === 0 && (
53
+ <div className="px-3 py-2 text-xs text-muted-foreground">No documents indexed.</div>
54
+ )}
55
+ {reports.map((r) => {
56
+ const on = selected.includes(r);
57
+ return (
58
+ <button
59
+ key={r}
60
+ onClick={() => toggle(r)}
61
+ className={cn(
62
+ "flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-muted",
63
+ on && "text-primary",
64
+ )}
65
+ >
66
+ <span
67
+ className={cn(
68
+ "flex h-4 w-4 items-center justify-center rounded border",
69
+ on ? "border-primary bg-primary text-primary-foreground" : "border-border",
70
+ )}
71
+ >
72
+ {on && <Check className="h-3 w-3" />}
73
+ </span>
74
+ <span className="truncate">{r}</span>
75
+ </button>
76
+ );
77
+ })}
78
+ </div>
79
+ </>
80
+ )}
81
+ </div>
82
+ );
83
+ }
frontend/src/components/Sidebar.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FileText, Globe, Microscope } from "lucide-react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { getReports } from "@/lib/api";
4
+ import { cn } from "@/lib/utils";
5
+ import { ThemeToggle } from "./ThemeToggle";
6
+
7
+ export type View = "document" | "global";
8
+
9
+ const NAV: { id: View; label: string; icon: typeof FileText; desc: string }[] = [
10
+ { id: "document", label: "Document Chat", icon: FileText, desc: "Chat with one report" },
11
+ { id: "global", label: "Global Search", icon: Globe, desc: "Search across reports" },
12
+ ];
13
+
14
+ export function Sidebar({ view, onView }: { view: View; onView: (v: View) => void }) {
15
+ const { data: reports } = useQuery({ queryKey: ["reports"], queryFn: getReports });
16
+
17
+ return (
18
+ <aside className="flex w-64 shrink-0 flex-col border-r border-border bg-card/50 backdrop-blur">
19
+ <div className="flex items-center gap-2.5 px-5 py-5">
20
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
21
+ <Microscope className="h-5 w-5" />
22
+ </div>
23
+ <div>
24
+ <div className="text-sm font-semibold leading-tight">Pathology RAG</div>
25
+ <div className="text-xs text-muted-foreground">Clinical document AI</div>
26
+ </div>
27
+ </div>
28
+
29
+ <nav className="flex flex-col gap-1 px-3">
30
+ {NAV.map((item) => {
31
+ const Icon = item.icon;
32
+ const active = view === item.id;
33
+ return (
34
+ <button
35
+ key={item.id}
36
+ onClick={() => onView(item.id)}
37
+ className={cn(
38
+ "group flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
39
+ active ? "bg-primary/10 text-primary" : "hover:bg-muted text-foreground",
40
+ )}
41
+ >
42
+ <Icon className={cn("mt-0.5 h-4 w-4", active ? "text-primary" : "text-muted-foreground")} />
43
+ <span>
44
+ <span className="block text-sm font-medium">{item.label}</span>
45
+ <span className="block text-xs text-muted-foreground">{item.desc}</span>
46
+ </span>
47
+ </button>
48
+ );
49
+ })}
50
+ </nav>
51
+
52
+ <div className="mt-auto space-y-3 px-5 py-5">
53
+ <div className="rounded-lg border border-border bg-background/60 px-3 py-2.5">
54
+ <div className="text-xs text-muted-foreground">Documents indexed</div>
55
+ <div className="text-lg font-semibold">{reports?.length ?? "β€”"}</div>
56
+ </div>
57
+ <div className="flex items-center justify-between">
58
+ <span className="text-xs text-muted-foreground">Appearance</span>
59
+ <ThemeToggle />
60
+ </div>
61
+ </div>
62
+ </aside>
63
+ );
64
+ }
frontend/src/components/SourceCard.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FileText } from "lucide-react";
2
+ import { cn } from "@/lib/utils";
3
+ import type { Source } from "@/lib/api";
4
+
5
+ export function SourceCard({
6
+ source,
7
+ onClick,
8
+ active,
9
+ }: {
10
+ source: Source;
11
+ onClick?: () => void;
12
+ active?: boolean;
13
+ }) {
14
+ return (
15
+ <button
16
+ onClick={onClick}
17
+ className={cn(
18
+ "w-full rounded-lg border bg-card px-3 py-2.5 text-left transition-colors",
19
+ active ? "border-primary ring-1 ring-primary" : "border-border hover:border-primary/60",
20
+ )}
21
+ >
22
+ <div className="mb-1 flex items-center gap-2 text-xs">
23
+ <span className="flex h-5 w-5 items-center justify-center rounded bg-primary/15 font-semibold text-primary">
24
+ {source.index}
25
+ </span>
26
+ <FileText className="h-3.5 w-3.5 text-muted-foreground" />
27
+ <span className="truncate font-medium">{source.filename}</span>
28
+ {source.page != null && (
29
+ <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
30
+ page {source.page}
31
+ </span>
32
+ )}
33
+ <span className="ml-auto text-[11px] text-muted-foreground">{source.score.toFixed(3)}</span>
34
+ </div>
35
+ <p className="line-clamp-2 text-xs text-muted-foreground">{source.text}</p>
36
+ </button>
37
+ );
38
+ }
frontend/src/components/ThemeToggle.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Moon, Sun } from "lucide-react";
2
+ import { useTheme } from "@/lib/theme";
3
+ import { Button } from "@/components/ui/button";
4
+
5
+ export function ThemeToggle() {
6
+ const { theme, toggle } = useTheme();
7
+ return (
8
+ <Button variant="ghost" size="icon" onClick={toggle} title="Toggle theme" aria-label="Toggle theme">
9
+ {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
10
+ </Button>
11
+ );
12
+ }
frontend/src/components/UploadPanel.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { UploadCloud, Loader2, CheckCircle2 } from "lucide-react";
3
+ import { uploadAndTrack, type UploadProgress } from "@/lib/api";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const STAGE_LABEL: Record<string, string> = {
7
+ ocr: "Reading pages (OCR)",
8
+ embedding: "Generating embeddings",
9
+ indexing: "Updating index",
10
+ done: "Finishing up",
11
+ };
12
+
13
+ export function UploadPanel({ onIndexed }: { onIndexed: (filename: string) => void }) {
14
+ const inputRef = useRef<HTMLInputElement>(null);
15
+ const [drag, setDrag] = useState(false);
16
+ const [busy, setBusy] = useState(false);
17
+ const [progress, setProgress] = useState<UploadProgress | null>(null);
18
+ const [doneName, setDoneName] = useState<string | null>(null);
19
+ const [error, setError] = useState<string | null>(null);
20
+
21
+ const handle = useCallback(
22
+ (file: File) => {
23
+ if (!file.name.toLowerCase().endsWith(".pdf")) {
24
+ setError("Please choose a PDF file.");
25
+ return;
26
+ }
27
+ setBusy(true);
28
+ setError(null);
29
+ setDoneName(null);
30
+ setProgress({ stage: "ocr" });
31
+ const stem = file.name.replace(/\.pdf$/i, "");
32
+ uploadAndTrack(
33
+ file,
34
+ (p) => setProgress(p),
35
+ () => {
36
+ setBusy(false);
37
+ setProgress(null);
38
+ setDoneName(stem);
39
+ onIndexed(stem);
40
+ },
41
+ (msg) => {
42
+ setBusy(false);
43
+ setProgress(null);
44
+ setError(msg);
45
+ },
46
+ );
47
+ },
48
+ [onIndexed],
49
+ );
50
+
51
+ return (
52
+ <div>
53
+ <div
54
+ onDragOver={(e) => {
55
+ e.preventDefault();
56
+ setDrag(true);
57
+ }}
58
+ onDragLeave={() => setDrag(false)}
59
+ onDrop={(e) => {
60
+ e.preventDefault();
61
+ setDrag(false);
62
+ if (e.dataTransfer.files[0]) handle(e.dataTransfer.files[0]);
63
+ }}
64
+ onClick={() => !busy && inputRef.current?.click()}
65
+ className={cn(
66
+ "flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors",
67
+ drag ? "border-primary bg-primary/5" : "border-border hover:border-primary/50",
68
+ busy && "cursor-default opacity-80",
69
+ )}
70
+ >
71
+ {busy ? (
72
+ <>
73
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
74
+ <div className="text-sm font-medium">
75
+ {progress ? STAGE_LABEL[progress.stage] ?? "Processing" : "Processing"}
76
+ {progress?.page && progress?.total ? ` Β· page ${progress.page}/${progress.total}` : ""}
77
+ </div>
78
+ <ProgressBar progress={progress} />
79
+ </>
80
+ ) : doneName ? (
81
+ <>
82
+ <CheckCircle2 className="h-6 w-6 text-emerald-500" />
83
+ <div className="text-sm font-medium">Indexed β€œ{doneName}”</div>
84
+ <div className="text-xs text-muted-foreground">Click to upload another</div>
85
+ </>
86
+ ) : (
87
+ <>
88
+ <UploadCloud className="h-6 w-6 text-muted-foreground" />
89
+ <div className="text-sm font-medium">Drop a pathology PDF or click to browse</div>
90
+ <div className="text-xs text-muted-foreground">OCR + embedding runs automatically</div>
91
+ </>
92
+ )}
93
+ <input
94
+ ref={inputRef}
95
+ type="file"
96
+ accept="application/pdf"
97
+ className="hidden"
98
+ onChange={(e) => e.target.files?.[0] && handle(e.target.files[0])}
99
+ />
100
+ </div>
101
+ {error && <p className="mt-2 text-xs text-red-500">{error}</p>}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function ProgressBar({ progress }: { progress: UploadProgress | null }) {
107
+ // Coarse stage-based fill; OCR uses page fraction when available.
108
+ let pct = 10;
109
+ if (progress?.stage === "ocr") {
110
+ pct = progress.page && progress.total ? 10 + (progress.page / progress.total) * 50 : 30;
111
+ } else if (progress?.stage === "embedding") pct = 70;
112
+ else if (progress?.stage === "indexing") pct = 88;
113
+ else if (progress?.stage === "done") pct = 96;
114
+ return (
115
+ <div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
116
+ <div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${pct}%` }} />
117
+ </div>
118
+ );
119
+ }
frontend/src/components/ui/button.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { forwardRef } from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const buttonVariants = cva(
6
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "bg-primary text-primary-foreground hover:opacity-90 shadow-sm",
11
+ outline: "border border-border bg-transparent hover:bg-muted",
12
+ ghost: "hover:bg-muted",
13
+ subtle: "bg-muted text-foreground hover:bg-accent",
14
+ },
15
+ size: {
16
+ default: "h-10 px-4 py-2",
17
+ sm: "h-8 px-3 text-xs",
18
+ icon: "h-9 w-9",
19
+ },
20
+ },
21
+ defaultVariants: { variant: "default", size: "default" },
22
+ },
23
+ );
24
+
25
+ export interface ButtonProps
26
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
27
+ VariantProps<typeof buttonVariants> {}
28
+
29
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
30
+ ({ className, variant, size, ...props }, ref) => (
31
+ <button ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />
32
+ ),
33
+ );
34
+ Button.displayName = "Button";
frontend/src/index.css ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 210 40% 98%;
8
+ --foreground: 222 47% 11%;
9
+ --card: 0 0% 100%;
10
+ --card-foreground: 222 47% 11%;
11
+ --muted: 210 40% 94%;
12
+ --muted-foreground: 215 16% 47%;
13
+ --primary: 221 83% 53%;
14
+ --primary-foreground: 0 0% 100%;
15
+ --accent: 210 40% 92%;
16
+ --accent-foreground: 222 47% 11%;
17
+ --border: 214 32% 88%;
18
+ --input: 214 32% 88%;
19
+ --ring: 221 83% 53%;
20
+ --radius: 0.75rem;
21
+ --highlight: 45 93% 58%;
22
+ }
23
+
24
+ .dark {
25
+ --background: 222 47% 7%;
26
+ --foreground: 210 40% 96%;
27
+ --card: 222 40% 10%;
28
+ --card-foreground: 210 40% 96%;
29
+ --muted: 217 33% 16%;
30
+ --muted-foreground: 215 20% 65%;
31
+ --primary: 217 91% 60%;
32
+ --primary-foreground: 222 47% 11%;
33
+ --accent: 217 33% 18%;
34
+ --accent-foreground: 210 40% 96%;
35
+ --border: 217 33% 20%;
36
+ --input: 217 33% 20%;
37
+ --ring: 217 91% 60%;
38
+ --highlight: 45 93% 58%;
39
+ }
40
+
41
+ * {
42
+ border-color: hsl(var(--border));
43
+ }
44
+ html,
45
+ body,
46
+ #root {
47
+ height: 100%;
48
+ }
49
+ body {
50
+ background-color: hsl(var(--background));
51
+ color: hsl(var(--foreground));
52
+ font-family: Inter, system-ui, sans-serif;
53
+ -webkit-font-smoothing: antialiased;
54
+ }
55
+ }
56
+
57
+ /* Slim, theme-aware scrollbars */
58
+ ::-webkit-scrollbar {
59
+ width: 10px;
60
+ height: 10px;
61
+ }
62
+ ::-webkit-scrollbar-thumb {
63
+ background: hsl(var(--border));
64
+ border-radius: 999px;
65
+ }
66
+ ::-webkit-scrollbar-track {
67
+ background: transparent;
68
+ }
69
+
70
+ /* Markdown answer typography */
71
+ .markdown p {
72
+ margin: 0 0 0.7rem;
73
+ line-height: 1.7;
74
+ }
75
+ .markdown p:last-child {
76
+ margin-bottom: 0;
77
+ }
78
+ .markdown ul,
79
+ .markdown ol {
80
+ margin: 0 0 0.7rem 1.2rem;
81
+ list-style: revert;
82
+ }
83
+ .markdown li {
84
+ margin: 0.2rem 0;
85
+ }
86
+ .markdown strong {
87
+ font-weight: 600;
88
+ }
89
+ .markdown code {
90
+ background: hsl(var(--muted));
91
+ padding: 0.1rem 0.35rem;
92
+ border-radius: 6px;
93
+ font-size: 0.85em;
94
+ }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { parseSSE } from "./sse";
2
+
3
+ export interface Source {
4
+ index: number;
5
+ filename: string;
6
+ page: number | null;
7
+ line_bboxes: number[][];
8
+ text: string;
9
+ score: number;
10
+ }
11
+
12
+ export interface ChatTurn {
13
+ role: "user" | "assistant";
14
+ content: string;
15
+ }
16
+
17
+ export interface ChatRequest {
18
+ question: string;
19
+ report_name?: string | null;
20
+ report_names?: string[] | null;
21
+ top_k?: number;
22
+ history?: ChatTurn[];
23
+ }
24
+
25
+ export interface ChatCallbacks {
26
+ onSources?: (sources: Source[]) => void;
27
+ onToken?: (text: string) => void;
28
+ onDone?: (numSources: number) => void;
29
+ onError?: (message: string) => void;
30
+ }
31
+
32
+ export async function getReports(): Promise<string[]> {
33
+ const r = await fetch("/api/reports");
34
+ if (!r.ok) throw new Error("Failed to load reports");
35
+ return (await r.json()).reports as string[];
36
+ }
37
+
38
+ export interface Health {
39
+ status: "ok" | "degraded";
40
+ num_documents: number;
41
+ detail?: string | null;
42
+ }
43
+
44
+ export async function getHealth(): Promise<Health> {
45
+ const r = await fetch("/api/health");
46
+ return r.json();
47
+ }
48
+
49
+ /** Stream a chat answer. Resolves when the stream ends. */
50
+ export async function streamChat(
51
+ req: ChatRequest,
52
+ cb: ChatCallbacks,
53
+ signal?: AbortSignal,
54
+ ): Promise<void> {
55
+ const resp = await fetch("/api/chat", {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify(req),
59
+ signal,
60
+ });
61
+
62
+ if (!resp.ok) {
63
+ const detail = await resp.text().catch(() => "");
64
+ cb.onError?.(`Request failed (${resp.status}). ${detail}`);
65
+ return;
66
+ }
67
+
68
+ for await (const evt of parseSSE(resp)) {
69
+ const data = safeParse(evt.data);
70
+ if (evt.event === "sources") cb.onSources?.(data.sources ?? []);
71
+ else if (evt.event === "token") cb.onToken?.(data.text ?? "");
72
+ else if (evt.event === "done") cb.onDone?.(data.num_sources ?? 0);
73
+ else if (evt.event === "error") cb.onError?.(data.message ?? "Unknown error");
74
+ }
75
+ }
76
+
77
+ export interface UploadProgress {
78
+ stage: string; // ocr | embedding | indexing | done | complete
79
+ page?: number;
80
+ total?: number;
81
+ num_chunks?: number;
82
+ vectors_added?: number;
83
+ }
84
+
85
+ /** Upload a PDF, then stream OCR/embedding/indexing progress until complete. */
86
+ export async function uploadAndTrack(
87
+ file: File,
88
+ onProgress: (p: UploadProgress) => void,
89
+ onComplete: (stats: Record<string, unknown>) => void,
90
+ onError: (message: string) => void,
91
+ ): Promise<void> {
92
+ const form = new FormData();
93
+ form.append("file", file);
94
+
95
+ const start = await fetch("/api/upload", { method: "POST", body: form });
96
+ if (!start.ok) {
97
+ onError(`Upload failed (${start.status}).`);
98
+ return;
99
+ }
100
+ const { job_id } = await start.json();
101
+
102
+ const events = await fetch(`/api/upload/${job_id}/events`);
103
+ if (!events.ok) {
104
+ onError("Failed to open progress stream.");
105
+ return;
106
+ }
107
+
108
+ for await (const evt of parseSSE(events)) {
109
+ const data = safeParse(evt.data);
110
+ if (evt.event === "progress") onProgress(data as UploadProgress);
111
+ else if (evt.event === "complete") onComplete(data);
112
+ else if (evt.event === "error") onError(data.message ?? "Processing failed");
113
+ }
114
+ }
115
+
116
+ export function pdfUrl(filename: string): string {
117
+ return `/api/pdf/${encodeURIComponent(filename)}`;
118
+ }
119
+
120
+ function safeParse(s: string): any {
121
+ try {
122
+ return JSON.parse(s);
123
+ } catch {
124
+ return {};
125
+ }
126
+ }
frontend/src/lib/sse.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface SSEvent {
2
+ event: string;
3
+ data: string;
4
+ }
5
+
6
+ /**
7
+ * Parse a Server-Sent Events stream from a fetch Response body into an async
8
+ * iterator of {event, data}. Used for POST-based SSE where EventSource (GET
9
+ * only) can't be used.
10
+ */
11
+ export async function* parseSSE(response: Response): AsyncGenerator<SSEvent> {
12
+ if (!response.body) throw new Error("Response has no body");
13
+ const reader = response.body.getReader();
14
+ const decoder = new TextDecoder();
15
+ let buffer = "";
16
+
17
+ while (true) {
18
+ const { done, value } = await reader.read();
19
+ if (done) break;
20
+ // Normalize CRLF (sse-starlette uses "\r\n" line endings) so a single
21
+ // "\n\n" boundary check works regardless of the server's separator.
22
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
23
+
24
+ let sep: number;
25
+ // Events are separated by a blank line.
26
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
27
+ const raw = buffer.slice(0, sep);
28
+ buffer = buffer.slice(sep + 2);
29
+
30
+ let event = "message";
31
+ const dataLines: string[] = [];
32
+ for (const line of raw.split("\n")) {
33
+ if (line.startsWith("event:")) event = line.slice(6).trim();
34
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
35
+ }
36
+ if (dataLines.length) yield { event, data: dataLines.join("\n") };
37
+ }
38
+ }
39
+ }
frontend/src/lib/theme.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
2
+
3
+ type Theme = "light" | "dark";
4
+
5
+ interface ThemeCtx {
6
+ theme: Theme;
7
+ toggle: () => void;
8
+ }
9
+
10
+ const Ctx = createContext<ThemeCtx | null>(null);
11
+
12
+ function initialTheme(): Theme {
13
+ const stored = localStorage.getItem("theme");
14
+ if (stored === "light" || stored === "dark") return stored;
15
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
16
+ }
17
+
18
+ export function ThemeProvider({ children }: { children: ReactNode }) {
19
+ const [theme, setTheme] = useState<Theme>(initialTheme);
20
+
21
+ useEffect(() => {
22
+ const root = document.documentElement;
23
+ root.classList.toggle("dark", theme === "dark");
24
+ localStorage.setItem("theme", theme);
25
+ }, [theme]);
26
+
27
+ const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
28
+
29
+ return <Ctx.Provider value={{ theme, toggle }}>{children}</Ctx.Provider>;
30
+ }
31
+
32
+ export function useTheme() {
33
+ const ctx = useContext(Ctx);
34
+ if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
35
+ return ctx;
36
+ }
frontend/src/lib/useChat.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { streamChat, type Source, type ChatTurn } from "./api";
3
+ import type { Message } from "@/components/MessageBubble";
4
+
5
+ let idCounter = 0;
6
+ const nextId = () => `m${++idCounter}`;
7
+
8
+ export interface SendScope {
9
+ report_name?: string | null;
10
+ report_names?: string[] | null;
11
+ top_k?: number;
12
+ }
13
+
14
+ export function useChat(onSources?: (sources: Source[], forMessageId: string) => void) {
15
+ const [messages, setMessages] = useState<Message[]>([]);
16
+ const [busy, setBusy] = useState(false);
17
+ const abortRef = useRef<AbortController | null>(null);
18
+
19
+ const patch = (id: string, fn: (m: Message) => Message) =>
20
+ setMessages((prev) => prev.map((m) => (m.id === id ? fn(m) : m)));
21
+
22
+ const send = useCallback(
23
+ (text: string, scope: SendScope) => {
24
+ if (busy) return;
25
+ const history: ChatTurn[] = messages.map((m) => ({ role: m.role, content: m.content }));
26
+
27
+ const userMsg: Message = { id: nextId(), role: "user", content: text };
28
+ const botId = nextId();
29
+ const botMsg: Message = { id: botId, role: "assistant", content: "", streaming: true };
30
+ setMessages((prev) => [...prev, userMsg, botMsg]);
31
+ setBusy(true);
32
+
33
+ const ctrl = new AbortController();
34
+ abortRef.current = ctrl;
35
+
36
+ streamChat(
37
+ {
38
+ question: text,
39
+ report_name: scope.report_name ?? null,
40
+ report_names: scope.report_names ?? null,
41
+ top_k: scope.top_k ?? 5,
42
+ history,
43
+ },
44
+ {
45
+ onSources: (sources) => {
46
+ patch(botId, (m) => ({ ...m, sources }));
47
+ onSources?.(sources, botId);
48
+ },
49
+ onToken: (t) => patch(botId, (m) => ({ ...m, content: m.content + t })),
50
+ onDone: () => {
51
+ patch(botId, (m) => ({ ...m, streaming: false }));
52
+ setBusy(false);
53
+ },
54
+ onError: (msg) => {
55
+ patch(botId, (m) => ({
56
+ ...m,
57
+ streaming: false,
58
+ content: m.content || `⚠️ ${msg}`,
59
+ }));
60
+ setBusy(false);
61
+ },
62
+ },
63
+ ctrl.signal,
64
+ );
65
+ },
66
+ [busy, messages, onSources],
67
+ );
68
+
69
+ const stop = useCallback(() => {
70
+ abortRef.current?.abort();
71
+ setBusy(false);
72
+ setMessages((prev) => prev.map((m) => (m.streaming ? { ...m, streaming: false } : m)));
73
+ }, []);
74
+
75
+ const reset = useCallback(() => {
76
+ abortRef.current?.abort();
77
+ setMessages([]);
78
+ setBusy(false);
79
+ }, []);
80
+
81
+ return { messages, busy, send, stop, reset };
82
+ }
frontend/src/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ThemeProvider } from "./lib/theme";
5
+ import App from "./App";
6
+ import "./index.css";
7
+
8
+ const queryClient = new QueryClient({
9
+ defaultOptions: { queries: { refetchOnWindowFocus: false } },
10
+ });
11
+
12
+ ReactDOM.createRoot(document.getElementById("root")!).render(
13
+ <React.StrictMode>
14
+ <QueryClientProvider client={queryClient}>
15
+ <ThemeProvider>
16
+ <App />
17
+ </ThemeProvider>
18
+ </QueryClientProvider>
19
+ </React.StrictMode>,
20
+ );
frontend/src/views/DocumentChat.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { getReports, type Source } from "@/lib/api";
4
+ import { useChat } from "@/lib/useChat";
5
+ import { PdfViewer } from "@/components/PdfViewer";
6
+ import { UploadPanel } from "@/components/UploadPanel";
7
+ import { ChatThread } from "@/components/ChatThread";
8
+ import { ChatInput } from "@/components/ChatInput";
9
+ import { ExamplePrompts } from "@/components/ExamplePrompts";
10
+ import type { Message } from "@/components/MessageBubble";
11
+
12
+ const EXAMPLES = [
13
+ "What are the key abnormal findings?",
14
+ "What is the tumor grade and stage?",
15
+ "Summarize the ER / PR / HER2 receptor status.",
16
+ ];
17
+
18
+ export function DocumentChat() {
19
+ const qc = useQueryClient();
20
+ const { data: reports } = useQuery({ queryKey: ["reports"], queryFn: getReports });
21
+ const [activeDoc, setActiveDoc] = useState<string | null>(null);
22
+ const [page, setPage] = useState(1);
23
+ const [highlights, setHighlights] = useState<number[][]>([]);
24
+
25
+ // Focus the viewer on the top source's page and highlight its lines.
26
+ const focusSources = useCallback((sources: Source[]) => {
27
+ const top = sources.find((s) => s.page != null);
28
+ if (!top || top.page == null) {
29
+ setHighlights([]);
30
+ return;
31
+ }
32
+ const boxes = sources
33
+ .filter((s) => s.page === top.page)
34
+ .flatMap((s) => s.line_bboxes);
35
+ setPage(top.page);
36
+ setHighlights(boxes);
37
+ }, []);
38
+
39
+ const { messages, busy, send, stop, reset } = useChat((sources) => focusSources(sources));
40
+
41
+ // When the active document changes, start a fresh conversation.
42
+ useEffect(() => {
43
+ reset();
44
+ setPage(1);
45
+ setHighlights([]);
46
+ }, [activeDoc, reset]);
47
+
48
+ const onCitation = (message: Message, index: number) => {
49
+ const src = message.sources?.find((s) => s.index === index);
50
+ if (src?.page != null) {
51
+ setPage(src.page);
52
+ setHighlights(src.line_bboxes);
53
+ }
54
+ };
55
+
56
+ const submit = (text: string) => {
57
+ if (!activeDoc) return;
58
+ send(text, { report_name: activeDoc });
59
+ };
60
+
61
+ return (
62
+ <div className="flex h-full gap-4 p-4">
63
+ {/* LEFT β€” document */}
64
+ <div className="flex w-[54%] flex-col gap-3">
65
+ <div className="flex items-center gap-2">
66
+ <select
67
+ value={activeDoc ?? ""}
68
+ onChange={(e) => setActiveDoc(e.target.value || null)}
69
+ className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-sm outline-none"
70
+ >
71
+ <option value="">Select a document…</option>
72
+ {reports?.map((r) => (
73
+ <option key={r} value={r}>
74
+ {r}
75
+ </option>
76
+ ))}
77
+ </select>
78
+ </div>
79
+ <div className="min-h-0 flex-1">
80
+ <PdfViewer filename={activeDoc} page={page} highlights={highlights} onPageChange={setPage} />
81
+ </div>
82
+ <UploadPanel
83
+ onIndexed={(name) => {
84
+ qc.invalidateQueries({ queryKey: ["reports"] });
85
+ setActiveDoc(name);
86
+ }}
87
+ />
88
+ </div>
89
+
90
+ {/* RIGHT β€” chat */}
91
+ <div className="flex min-w-0 flex-1 flex-col">
92
+ <ChatThread
93
+ messages={messages}
94
+ onCitation={onCitation}
95
+ empty={
96
+ activeDoc ? (
97
+ <ExamplePrompts title={`Ask about ${activeDoc}`} prompts={EXAMPLES} onPick={submit} />
98
+ ) : (
99
+ <div className="max-w-sm text-center text-sm text-muted-foreground">
100
+ Upload a pathology PDF or select an indexed document to start chatting with it.
101
+ </div>
102
+ )
103
+ }
104
+ />
105
+ <div className="pt-2">
106
+ <ChatInput
107
+ onSend={submit}
108
+ onStop={stop}
109
+ busy={busy}
110
+ disabled={!activeDoc}
111
+ placeholder={activeDoc ? "Ask about this document…" : "Select a document first"}
112
+ />
113
+ </div>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
frontend/src/views/GlobalSearch.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { X } from "lucide-react";
4
+ import { getReports, type Source } from "@/lib/api";
5
+ import { useChat } from "@/lib/useChat";
6
+ import { ChatThread } from "@/components/ChatThread";
7
+ import { ChatInput } from "@/components/ChatInput";
8
+ import { ReportPicker } from "@/components/ReportPicker";
9
+ import { SourceCard } from "@/components/SourceCard";
10
+ import { PdfViewer } from "@/components/PdfViewer";
11
+ import { ExamplePrompts } from "@/components/ExamplePrompts";
12
+
13
+ const EXAMPLES = [
14
+ "What ER/PR/HER2 receptor patterns appear across reports?",
15
+ "Which reports mention lymph node involvement?",
16
+ "Summarize the most common diagnoses.",
17
+ ];
18
+
19
+ interface Preview {
20
+ filename: string;
21
+ page: number;
22
+ highlights: number[][];
23
+ }
24
+
25
+ export function GlobalSearch() {
26
+ const { data: reports } = useQuery({ queryKey: ["reports"], queryFn: getReports });
27
+ const [selected, setSelected] = useState<string[]>([]);
28
+ const [preview, setPreview] = useState<Preview | null>(null);
29
+ const { messages, busy, send, stop } = useChat();
30
+
31
+ const submit = (text: string) =>
32
+ send(text, { report_names: selected.length ? selected : null });
33
+
34
+ const openSource = (s: Source) =>
35
+ setPreview({ filename: s.filename, page: s.page ?? 1, highlights: s.line_bboxes });
36
+
37
+ return (
38
+ <div className="mx-auto flex h-full w-full max-w-3xl flex-col p-4">
39
+ <div className="pb-3">
40
+ <ReportPicker reports={reports ?? []} selected={selected} onChange={setSelected} />
41
+ </div>
42
+
43
+ <ChatThread
44
+ messages={messages}
45
+ empty={<ExamplePrompts title="Search across all reports" prompts={EXAMPLES} onPick={submit} />}
46
+ renderExtra={(m) =>
47
+ m.role === "assistant" && m.sources && m.sources.length > 0 ? (
48
+ <div className="grid gap-2 sm:grid-cols-2">
49
+ {m.sources.map((s) => (
50
+ <SourceCard key={s.index} source={s} onClick={() => openSource(s)} />
51
+ ))}
52
+ </div>
53
+ ) : null
54
+ }
55
+ />
56
+
57
+ <div className="pt-2">
58
+ <ChatInput onSend={submit} onStop={stop} busy={busy} placeholder="Ask across your reports…" />
59
+ </div>
60
+
61
+ {preview && (
62
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6" onClick={() => setPreview(null)}>
63
+ <div
64
+ className="flex h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background"
65
+ onClick={(e) => e.stopPropagation()}
66
+ >
67
+ <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
68
+ <span className="truncate text-sm font-medium">{preview.filename}</span>
69
+ <button onClick={() => setPreview(null)} className="rounded-md p-1 hover:bg-muted">
70
+ <X className="h-4 w-4" />
71
+ </button>
72
+ </div>
73
+ <div className="min-h-0 flex-1 p-3">
74
+ <PdfViewer
75
+ filename={preview.filename}
76
+ page={preview.page}
77
+ highlights={preview.highlights}
78
+ onPageChange={(p) => setPreview((prev) => (prev ? { ...prev, page: p, highlights: [] } : prev))}
79
+ />
80
+ </div>
81
+ </div>
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+ }
frontend/src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
frontend/tailwind.config.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: "class",
4
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
5
+ theme: {
6
+ extend: {
7
+ colors: {
8
+ border: "hsl(var(--border))",
9
+ input: "hsl(var(--input))",
10
+ ring: "hsl(var(--ring))",
11
+ background: "hsl(var(--background))",
12
+ foreground: "hsl(var(--foreground))",
13
+ muted: {
14
+ DEFAULT: "hsl(var(--muted))",
15
+ foreground: "hsl(var(--muted-foreground))",
16
+ },
17
+ card: {
18
+ DEFAULT: "hsl(var(--card))",
19
+ foreground: "hsl(var(--card-foreground))",
20
+ },
21
+ primary: {
22
+ DEFAULT: "hsl(var(--primary))",
23
+ foreground: "hsl(var(--primary-foreground))",
24
+ },
25
+ accent: {
26
+ DEFAULT: "hsl(var(--accent))",
27
+ foreground: "hsl(var(--accent-foreground))",
28
+ },
29
+ },
30
+ borderRadius: {
31
+ lg: "var(--radius)",
32
+ md: "calc(var(--radius) - 2px)",
33
+ sm: "calc(var(--radius) - 4px)",
34
+ },
35
+ fontFamily: {
36
+ sans: ["Inter", "system-ui", "sans-serif"],
37
+ },
38
+ keyframes: {
39
+ "fade-in": {
40
+ from: { opacity: "0", transform: "translateY(4px)" },
41
+ to: { opacity: "1", transform: "translateY(0)" },
42
+ },
43
+ "pulse-ring": {
44
+ "0%, 100%": { opacity: "0.55" },
45
+ "50%": { opacity: "0.9" },
46
+ },
47
+ },
48
+ animation: {
49
+ "fade-in": "fade-in 0.25s ease-out",
50
+ "pulse-ring": "pulse-ring 1.2s ease-in-out 2",
51
+ },
52
+ },
53
+ },
54
+ plugins: [],
55
+ };
frontend/tsconfig.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "baseUrl": ".",
19
+ "paths": { "@/*": ["./src/*"] }
20
+ },
21
+ "include": ["src"],
22
+ "references": [{ "path": "./tsconfig.node.json" }]
23
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true
9
+ },
10
+ "include": ["vite.config.ts"]
11
+ }
frontend/tsconfig.node.tsbuildinfo ADDED
@@ -0,0 +1 @@
 
 
1
+ {"fileNames":["./node_modules/typescript/lib/lib.d.ts","./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts","./node_modules/@types/ms/index.d.ts","./node_modules/@types/debug/index.d.ts","./node_modules/@types/estree-jsx/index.d.ts","./node_modules/@types/unist/index.d.ts","./node_modules/@types/hast/index.d.ts","./node_modules/@types/mdast/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[55,102,183],[55,102],[55,102,183,184,185,186,187],[55,102,183,185],[55,102,191],[55,102,151,152,193],[55,102,194],[55,99,102],[55,101,102],[102],[55,102,107,135],[55,102,103,108,113,121,132,143],[55,102,103,104,113,121],[50,51,52,55,102],[55,102,105,144],[55,102,106,107,114,122],[55,102,107,132,140],[55,102,108,110,113,121],[55,101,102,109],[55,102,110,111],[55,102,112,113],[55,101,102,113],[55,102,113,114,115,132,143],[55,102,113,114,115,128,132,135],[55,102,110,113,116,121,132,143],[55,102,113,114,116,117,121,132,140,143],[55,102,116,118,132,140,143],[53,54,55,56,57,58,59,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149],[55,102,113,119],[55,102,120,143,148],[55,102,110,113,121,132],[55,102,122],[55,102,123],[55,101,102,124],[55,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149],[55,102,126],[55,102,127],[55,102,113,128,129],[55,102,128,130,144,146],[55,102,113,132,133,135],[55,102,134,135],[55,102,132,133],[55,102,135],[55,102,136],[55,99,102,132,137],[55,102,113,138,139],[55,102,138,139],[55,102,107,121,132,140],[55,102,141],[55,102,121,142],[55,102,116,127,143],[55,102,107,144],[55,102,132,145],[55,102,120,146],[55,102,147],[55,97,102],[55,97,102,113,115,124,132,135,143,146,148],[55,102,132,149],[55,102,200],[55,102,197,198,199],[55,102,182,188],[55,102,174],[55,102,172,174],[55,102,163,171,172,173,175,177],[55,102,161],[55,102,164,169,174,177],[55,102,160,177],[55,102,164,165,168,169,170,177],[55,102,164,165,166,168,169,177],[55,102,161,162,163,164,165,169,170,171,173,174,175,177],[55,102,177],[55,102,159,161,162,163,164,165,166,168,169,170,171,172,173,174,175,176],[55,102,159,177],[55,102,164,166,167,169,170,177],[55,102,168,177],[55,102,169,170,174,177],[55,102,162,172],[55,102,152,181],[55,69,73,102,143],[55,69,102,132,143],[55,64,102],[55,66,69,102,140,143],[55,102,121,140],[55,102,150],[55,64,102,150],[55,66,69,102,121,143],[55,61,62,65,68,102,113,132,143],[55,69,76,102],[55,61,67,102],[55,69,90,91,102],[55,65,69,102,135,143,150],[55,90,102,150],[55,63,64,102,150],[55,69,102],[55,63,64,65,66,67,68,69,70,71,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,91,92,93,94,95,96,102],[55,69,84,102],[55,69,76,77,102],[55,67,69,77,78,102],[55,68,102],[55,61,64,69,102],[55,69,73,77,78,102],[55,73,102],[55,67,69,72,102,143],[55,61,66,69,76,102],[55,102,132],[55,64,69,90,102,148,150],[55,102,113,114,116,117,118,121,132,140,143,149,150,152,153,154,155,156,157,158,178,179,180,181],[55,102,154,155,156,157],[55,102,154,155,156],[55,102,154],[55,102,155],[55,102,152],[55,102,123,182,189]],"fileInfos":[{"version":"a7297ff837fcdf174a9524925966429eb8e5feecc2cc55cc06574e6b092c1eaa","impliedFormat":1},{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"e3cf0611709328b449ec13f8c436712d62003620ce480139fae46ce001c2ee9f","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"c3e01ed1d025a74b084b9bcc0436479db22df816130bdfd894df1b02c15c4f55","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"},{"version":"fb893a0dfc3c9fb0f9ca93d0648694dd95f33cbad2c0f2c629f842981dfd4e2e","impliedFormat":1},{"version":"89e326922cadcc2331d7e851011cf9f0456a681aaf3c95b48b81f8d80e8cdfba","impliedFormat":1},{"version":"5d08a179b846f5ee674624b349ebebe2121c455e3a265dc93da4e8d9e89722b4","impliedFormat":1},{"version":"89121c1bf2990f5219bfd802a3e7fc557de447c62058d6af68d6b6348d64499a","impliedFormat":1},{"version":"79b4369233a12c6fa4a07301ecb7085802c98f3a77cf9ab97eee27e1656f82e6","impliedFormat":1},{"version":"d4a22007b481fe2a2e6bfd3a42c00cd62d41edb36d30fc4697df2692e9891fc8","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1}],"root":[190],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"strict":true},"referencedMap":[[185,1],[183,2],[188,3],[184,1],[186,4],[187,1],[192,5],[193,6],[151,2],[195,7],[196,7],[191,2],[99,8],[100,8],[101,9],[55,10],[102,11],[103,12],[104,13],[50,2],[53,14],[51,2],[52,2],[105,15],[106,16],[107,17],[108,18],[109,19],[110,20],[111,20],[112,21],[113,22],[114,23],[115,24],[56,2],[54,2],[116,25],[117,26],[118,27],[150,28],[119,29],[120,30],[121,31],[122,32],[123,33],[124,34],[125,35],[126,36],[127,37],[128,38],[129,38],[130,39],[131,2],[132,40],[134,41],[133,42],[135,43],[136,44],[137,45],[138,46],[139,47],[140,48],[141,49],[142,50],[143,51],[144,52],[145,53],[146,54],[147,55],[57,2],[58,2],[59,2],[98,56],[148,57],[149,58],[197,2],[201,59],[198,2],[200,60],[194,2],[189,61],[60,2],[199,2],[158,2],[175,62],[173,63],[174,64],[162,65],[163,63],[170,66],[161,67],[166,68],[176,2],[167,69],[172,70],[178,71],[177,72],[160,73],[168,74],[169,75],[164,76],[171,62],[165,77],[153,78],[152,6],[159,2],[1,2],[48,2],[49,2],[9,2],[13,2],[12,2],[3,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[4,2],[22,2],[23,2],[5,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[6,2],[32,2],[33,2],[34,2],[35,2],[7,2],[39,2],[36,2],[37,2],[38,2],[40,2],[8,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[2,2],[11,2],[10,2],[76,79],[86,80],[75,79],[96,81],[67,82],[66,83],[95,84],[89,85],[94,86],[69,87],[83,88],[68,89],[92,90],[64,91],[63,84],[93,92],[65,93],[70,94],[71,2],[74,94],[61,2],[97,95],[87,96],[78,97],[79,98],[81,99],[77,100],[80,101],[90,84],[72,102],[73,103],[82,104],[62,105],[85,96],[84,94],[88,2],[91,106],[182,107],[179,108],[157,109],[155,110],[154,2],[156,111],[180,2],[181,112],[190,113]],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"}
frontend/tsconfig.tsbuildinfo ADDED
@@ -0,0 +1 @@
 
 
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/chatinput.tsx","./src/components/chatthread.tsx","./src/components/exampleprompts.tsx","./src/components/messagebubble.tsx","./src/components/pdfviewer.tsx","./src/components/reportpicker.tsx","./src/components/sidebar.tsx","./src/components/sourcecard.tsx","./src/components/themetoggle.tsx","./src/components/uploadpanel.tsx","./src/components/ui/button.tsx","./src/lib/api.ts","./src/lib/sse.ts","./src/lib/theme.tsx","./src/lib/usechat.ts","./src/lib/utils.ts","./src/views/documentchat.tsx","./src/views/globalsearch.tsx"],"version":"5.9.3"}
frontend/vite.config.d.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
frontend/vite.config.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import path from "path";
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ resolve: {
7
+ alias: { "@": path.resolve(__dirname, "./src") },
8
+ },
9
+ server: {
10
+ port: 5173,
11
+ // Dev-only: proxy API calls to the FastAPI backend so the SPA and API
12
+ // share an origin during development (mirrors the container in prod).
13
+ proxy: {
14
+ "/api": {
15
+ target: "http://localhost:8000",
16
+ changeOrigin: true,
17
+ },
18
+ },
19
+ },
20
+ });
frontend/vite.config.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import path from "path";
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ resolve: {
8
+ alias: { "@": path.resolve(__dirname, "./src") },
9
+ },
10
+ server: {
11
+ port: 5173,
12
+ // Dev-only: proxy API calls to the FastAPI backend so the SPA and API
13
+ // share an origin during development (mirrors the container in prod).
14
+ proxy: {
15
+ "/api": {
16
+ target: "http://localhost:8000",
17
+ changeOrigin: true,
18
+ },
19
+ },
20
+ },
21
+ });
output/biomedbert_vector_db/faiss.index CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:13e528783241822262d42f011636618870f54d84c8cbbaffdf7a21fa92dd0d2f
3
- size 30584877
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bccf824362c2bc66295313f5c1d327c0409a9b9e86b6767fd9f46de9de9c39df
3
+ size 30591021
output/biomedbert_vector_db/metadata.pkl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:1138cbbb3cd4e2cec0f626bb88d412b95e22a0cc77c4d291cb35f5757a86c925
3
- size 10202202
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b7cd713d5984a5bbee9eb14706f4e6a6daa7e2e13116c860ef3dbb369fd69275
3
+ size 10205100
requirements.txt CHANGED
@@ -1,15 +1,24 @@
1
- streamlit>=1.32.0
2
- jinja2
3
-
4
- numpy>=1.26.4
5
-
6
- torch>=2.3.0
7
- torchvision>=0.18.0
8
- torchaudio>=2.3.0
9
-
10
- transformers>=4.41.0
11
- sentence-transformers>=3.0.0
12
- accelerate>=0.30.0
 
 
 
 
 
 
 
 
 
13
 
14
  faiss-cpu>=1.8.0
15
  rank-bm25
@@ -20,11 +29,13 @@ pandas
20
  scikit-learn
21
  tqdm
22
 
 
23
  pillow
24
- pdf2image
25
- pytesseract
26
  pymupdf
27
- paddlepaddle
28
- paddleocr
 
 
29
 
30
- gradio>=4.0
 
 
1
+ # ── Web API (React + FastAPI deployment) ──
2
+ fastapi>=0.110.0
3
+ uvicorn[standard]>=0.29.0
4
+ python-multipart>=0.0.9
5
+ sse-starlette>=2.0.0
6
+
7
+ # numpy 2.3.x: matches the set paddlepaddle 3.x is happy with.
8
+ numpy>=1.26,<2.4
9
+
10
+ # ── ML / RAG stack ──
11
+ # torch is installed separately in the Dockerfile from the CPU wheel index
12
+ # (https://download.pytorch.org/whl/cpu) so we never pull the ~2GB CUDA build.
13
+ # The transformers stack below is pinned to a known-good, mutually-compatible
14
+ # set (verified importing cleanly locally) so a fresh container build can't
15
+ # resolve an incompatible combination that breaks `PreTrainedModel` at import.
16
+ transformers==4.57.3
17
+ sentence-transformers==5.2.0
18
+ huggingface-hub==0.36.0
19
+ tokenizers==0.22.2
20
+ safetensors==0.7.0
21
+ accelerate==1.13.0
22
 
23
  faiss-cpu>=1.8.0
24
  rank-bm25
 
29
  scikit-learn
30
  tqdm
31
 
32
+ # ── PDF + OCR ──
33
  pillow
 
 
34
  pymupdf
35
+ # PaddleOCR 3.x β€” the OCR extraction code targets the 3.x predict() API
36
+ # (rec_texts / dt_polys). Do not drop below 3.0.
37
+ paddlepaddle>=3.0.0,<4.0.0
38
+ paddleocr>=3.0.0,<4.0.0
39
 
40
+ # Note: the legacy Streamlit UI (app.py / ui/) is not run in the container and
41
+ # is excluded via .dockerignore; install `streamlit` separately to run it.
src/__pycache__/document_processor.cpython-313.pyc CHANGED
Binary files a/src/__pycache__/document_processor.cpython-313.pyc and b/src/__pycache__/document_processor.cpython-313.pyc differ
 
src/__pycache__/retriever.cpython-313.pyc CHANGED
Binary files a/src/__pycache__/retriever.cpython-313.pyc and b/src/__pycache__/retriever.cpython-313.pyc differ