update the codebase poc ver1
@@ -11,15 +11,11 @@ Knee Ultrasound Analysis API được xây dựng bằng FastAPI, chuyên phục
|
||||
|
||||
### 1.1 Các chức năng chính
|
||||
|
||||
* **Phân loại góc chụp siêu âm (Angle Classification):** Tự động nhận dạng mặt cắt/góc chụp từ ảnh đầu vào bao gồm các nhãn: `med-lat`, `post-trans`, `sup-trans-flex`, và `sup-up-long`.
|
||||
|
||||
* **Phát hiện viêm (Inflammation Detection):** Xác định sự hiện diện của tình trạng viêm khớp gối qua hai góc chụp chính là `sup-up-long` và `post-trans`.
|
||||
|
||||
* **Phân đoạn ảnh ngữ nghĩa (Segmentation):** Tách biệt các cấu trúc giải phẫu đích (dịch khớp, gân, xương, màng hoạt dịch...) thành các phân vùng mặt nạ màu riêng biệt.
|
||||
|
||||
* **Đo độ dày tự động (Thickness Measurement):** Tự động tính toán khoảng cách hình học theo đơn vị milimét ($mm$) giữa các phân vùng mô mềm đã được phân đoạn (chỉ áp dụng đối với mặt cắt góc `sup-up-long`).
|
||||
|
||||
* **Đánh giá mức độ viêm (Severity Analysis):** Xếp hạng thang điểm mức độ nghiêm trọng của viêm từ cấp độ 0 (Rất nhẹ) đến cấp độ 3 (Nặng) dựa trên tỷ lệ diện tích dịch khớp và sự tăng sinh màng hoạt dịch.
|
||||
- **Phân loại góc chụp siêu âm (Angle Classification):** Tự động nhận dạng mặt cắt/góc chụp từ ảnh đầu vào bao gồm các nhãn: `med-lat`, `post-trans`, `sup-trans-flex`, và `sup-up-long`.
|
||||
- **Phát hiện viêm (Inflammation Detection):** Xác định sự hiện diện của tình trạng viêm khớp gối qua hai góc chụp chính là `sup-up-long` và `post-trans`.
|
||||
- **Phân đoạn ảnh ngữ nghĩa (Segmentation):** Tách biệt các cấu trúc giải phẫu đích (dịch khớp, gân, xương, màng hoạt dịch...) thành các phân vùng mặt nạ màu riêng biệt.
|
||||
- **Đo độ dày tự động (Thickness Measurement):** Tự động tính toán khoảng cách hình học theo đơn vị milimét ($mm$) giữa các phân vùng mô mềm đã được phân đoạn (chỉ áp dụng đối với mặt cắt góc `sup-up-long`).
|
||||
- **Đánh giá mức độ viêm (Severity Analysis):** Xếp hạng thang điểm mức độ nghiêm trọng của viêm từ cấp độ 0 (Rất nhẹ) đến cấp độ 3 (Nặng) dựa trên tỷ lệ diện tích dịch khớp và sự tăng sinh màng hoạt dịch.
|
||||
|
||||
|
||||
|
||||
@@ -27,11 +23,13 @@ Knee Ultrasound Analysis API được xây dựng bằng FastAPI, chuyên phục
|
||||
|
||||
Khi nhận tập tin ảnh từ máy trạm (Client), hệ thống sẽ thực hiện phân nhánh xử lý logic động dựa trên kết quả của khối phân loại góc chụp:
|
||||
|
||||
| Góc chụp phát hiện | Quy trình xử lý chi tiết trong Backend pipeline |
|
||||
| --- | --- |
|
||||
| **`post-trans`** | Phân loại góc $\rightarrow$ Phát hiện viêm $\rightarrow$ Phân đoạn ảnh POST $\rightarrow$ Trả kết quả JSON & Mask.|
|
||||
| **`sup-up-long`** | Phân loại góc $\rightarrow$ Phát hiện viêm $\rightarrow$ Phân đoạn ảnh SUP $\rightarrow$ Đo độ dày mô $\rightarrow$ Đánh giá mức độ nặng $\rightarrow$ Trả kết quả.|
|
||||
| **`med-lat`** or **`sup-trans-flex`** | Chỉ thực hiện phân loại góc $\rightarrow$ Trả kết quả trực tiếp (Bỏ qua nhánh phân đoạn & đo lường).|
|
||||
|
||||
| Góc chụp phát hiện | Quy trình xử lý chi tiết trong Backend pipeline |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `post-trans` | Phân loại góc $\rightarrow$ Phát hiện viêm $\rightarrow$ Phân đoạn ảnh POST $\rightarrow$ Trả kết quả JSON & Mask. |
|
||||
| `sup-up-long` | Phân loại góc $\rightarrow$ Phát hiện viêm $\rightarrow$ Phân đoạn ảnh SUP $\rightarrow$ Đo độ dày mô $\rightarrow$ Đánh giá mức độ nặng $\rightarrow$ Trả kết quả. |
|
||||
| `med-lat` or `sup-trans-flex` | Chỉ thực hiện phân loại góc $\rightarrow$ Trả kết quả trực tiếp (Bỏ qua nhánh phân đoạn & đo lường). |
|
||||
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
@@ -70,19 +68,27 @@ stop
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 2. Hướng dẫn cài đặt & Triển khai môi trường
|
||||
|
||||
|
||||
|
||||
### 2.1 Yêu cầu hệ thống tối thiểu
|
||||
|
||||
| Thành phần cấu phần | Thông số kỹ thuật yêu cầu tối thiểu |
|
||||
| --- | --- |
|
||||
| **Hệ điều hành** | Ubuntu 20.04+ / Windows 10+ / macOS 12+ |
|
||||
| **Môi trường Python** | Phiên bản 3.10 cố định |
|
||||
| **Bộ nhớ RAM** | 16 GB trở lên |
|
||||
| **Bộ xử lý đồ họa (GPU)** | NVIDIA GPU hỗ trợ nền tảng CUDA 12.4 (Khuyến nghị để tối ưu tốc độ) |
|
||||
| **Dung lượng VRAM** | Tối thiểu 8 GB (Khuyến nghị 16 GB nếu chạy song song đồng thời nhiều mô hình)|
|
||||
| **Ổ cứng lưu trữ** | Tối thiểu 15 GB dung lượng trống (Dành cho bộ cài đặt và file weights `.pth`) |
|
||||
| **Bộ công cụ bổ trợ** | CUDA Toolkit 12.4 & cuDNN 9.x tương thích tương ứng|
|
||||
|
||||
| Thành phần cấu phần | Thông số kỹ thuật yêu cầu tối thiểu |
|
||||
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||
| **Hệ điều hành** | Ubuntu 20.04+ / Windows 10+ / macOS 12+ |
|
||||
| **Môi trường Python** | Phiên bản 3.10 cố định |
|
||||
| **Bộ nhớ RAM** | 16 GB trở lên |
|
||||
| **Bộ xử lý đồ họa (GPU)** | NVIDIA GPU hỗ trợ nền tảng CUDA 12.4 (Khuyến nghị để tối ưu tốc độ) |
|
||||
| **Dung lượng VRAM** | Tối thiểu 8 GB (Khuyến nghị 16 GB nếu chạy song song đồng thời nhiều mô hình) |
|
||||
| **Ổ cứng lưu trữ** | Tối thiểu 15 GB dung lượng trống (Dành cho bộ cài đặt và file weights `.pth`) |
|
||||
| **Bộ công cụ bổ trợ** | CUDA Toolkit 12.4 & cuDNN 9.x tương thích tương ứng |
|
||||
|
||||
|
||||
|
||||
|
||||
### 2.2 Khởi tạo môi trường ảo
|
||||
|
||||
@@ -102,7 +108,7 @@ conda activate vkist-ultrasound
|
||||
|
||||
```
|
||||
|
||||
*Hoặc khởi tạo nhanh bằng mô-đun thư viện chuẩn `venv` nếu hệ thống chưa cài đặt Anaconda*:
|
||||
*Hoặc khởi tạo nhanh bằng mô-đun thư viện chuẩn* `venv` *nếu hệ thống chưa cài đặt Anaconda*:
|
||||
|
||||
```bash
|
||||
# Trên nền tảng hệ điều hành Linux / macOS
|
||||
@@ -114,6 +120,8 @@ venv\Scripts\activate
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 2.3 Cài đặt các gói thư viện phụ thuộc (Dependencies)
|
||||
|
||||
Thực hiện cài đặt các thư viện lõi quy định trong tệp cấu hình:
|
||||
@@ -133,8 +141,8 @@ pip install torch==2.5.0+cu124 torchvision==0.20.0+cu124 --index-url https://dow
|
||||
> ⚠️ **LƯU Ý QUAN TRỌNG VỀ PACKAGE NATTEN:**
|
||||
> Dòng cấu hình cài đặt gói `natten==0.17.3+torch250cu124` mặc định đã bị gắn chú thích (`#` comment out) trong tệp `requirements.txt`. Nếu bạn sử dụng các kiến trúc mạng Transformer nâng cao yêu cầu gói này, bắt buộc cài đặt thủ công qua liên kết phân phối bánh xe (wheels) chính thức:
|
||||
> `pip install natten==0.17.3+torch250cu124 -f https://shi-labs.com/natten/wheels/`
|
||||
>
|
||||
>
|
||||
|
||||
|
||||
|
||||
### 2.4 Cấu trúc cây thư mục dự án chuẩn
|
||||
|
||||
@@ -168,6 +176,8 @@ project/
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 2.5 Khởi động máy chủ dịch vụ
|
||||
|
||||
Thực thi lệnh chạy máy chủ tại thư mục gốc:
|
||||
@@ -187,26 +197,23 @@ python app.py
|
||||
|
||||
```
|
||||
|
||||
* **Giao diện Web UI kiểm thử trực quan:** `http://localhost:8000`
|
||||
|
||||
|
||||
* **Tài liệu API tương tác tự động (Swagger UI):** `http://localhost:8000/docs`
|
||||
|
||||
|
||||
- **Giao diện Web UI kiểm thử trực quan:** `http://localhost:8000`
|
||||
- **Tài liệu API tương tác tự động (Swagger UI):** `http://localhost:8000/docs`
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 3. Tài liệu đặc tả API (API Reference)
|
||||
|
||||
|
||||
|
||||
### 3.1 Trạng thái hoạt động (Health Check)
|
||||
|
||||
* **Endpoint:** `GET /api/health`
|
||||
- **Endpoint:** `GET /api/health`
|
||||
- **Chức năng:** Kiểm tra tính sẵn sàng phục vụ của cụm dịch vụ API Backend.
|
||||
- **Định dạng dữ liệu phản hồi (Response JSON):**
|
||||
|
||||
|
||||
* **Chức năng:** Kiểm tra tính sẵn sàng phục vụ của cụm dịch vụ API Backend.
|
||||
|
||||
|
||||
* **Định dạng dữ liệu phản hồi (Response JSON):**
|
||||
```json
|
||||
{
|
||||
"status": "healthy"
|
||||
@@ -222,28 +229,36 @@ Mã hóa dữ liệu đầu vào dưới dạng tệp tin `multipart/form-data`
|
||||
|
||||
#### Các tham số yêu cầu (Request Parameters)
|
||||
|
||||
| Tham số cấu hình | Phương thức truyền | Kiểu dữ liệu | Giá trị mặc định | Định nghĩa chức năng chi tiết |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **`image`** | Multipart Form | Binary File | *Bắt buộc* | Tệp tin ảnh siêu âm đầu gối cần xử lý (Hỗ trợ mở rộng định dạng: `.jpg`, `.png`, `.bmp`).|
|
||||
| **`angle_model`** | Query String | String | `convnext` | Tên định danh mô hình đảm nhận tác vụ phân loại góc chụp.|
|
||||
| **`inflammation_model`** | Query String | String | `efficientnet_b0` | Mô hình phát hiện tình trạng viêm (Hiện tại cố định cấu hình mạng).|
|
||||
| **`segment_model_sup`** | Query String | String | `deeplabv3` | Mô hình phân đoạn cấu trúc giải phẫu dành cho mặt cắt góc `sup-up-long`.|
|
||||
| **`segment_model_post`** | Query String | String | `deeplabv3_resnet101` | Mô hình phân đoạn cấu trúc giải phẫu dành cho mặt cắt góc `post-trans`.|
|
||||
|
||||
| Tham số cấu hình | Phương thức truyền | Kiểu dữ liệu | Giá trị mặc định | Định nghĩa chức năng chi tiết |
|
||||
| -------------------- | ------------------ | ------------ | --------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `image` | Multipart Form | Binary File | *Bắt buộc* | Tệp tin ảnh siêu âm đầu gối cần xử lý (Hỗ trợ mở rộng định dạng: `.jpg`, `.png`, `.bmp`). |
|
||||
| `angle_model` | Query String | String | `convnext` | Tên định danh mô hình đảm nhận tác vụ phân loại góc chụp. |
|
||||
| `inflammation_model` | Query String | String | `efficientnet_b0` | Mô hình phát hiện tình trạng viêm (Hiện tại cố định cấu hình mạng). |
|
||||
| `segment_model_sup` | Query String | String | `deeplabv3` | Mô hình phân đoạn cấu trúc giải phẫu dành cho mặt cắt góc `sup-up-long`. |
|
||||
| `segment_model_post` | Query String | String | `deeplabv3_resnet101` | Mô hình phân đoạn cấu trúc giải phẫu dành cho mặt cắt góc `post-trans`. |
|
||||
|
||||
|
||||
|
||||
|
||||
#### Danh sách định danh mô hình khả dụng trong hệ thống
|
||||
|
||||
| Phân nhóm Task | Tên tham số truyền vào | Kiến trúc mạng nơ-ron gốc | Mô tả đặc tính đầu ra |
|
||||
| --- | --- | --- | --- |
|
||||
| **Phân loại Góc chụp** | `convnext` | ConvNeXt Tiny | Phân cấp phân loại ra 4 lớp nhãn đầu ra.|
|
||||
| | `densenet` | DenseNet-121 | Mạng kết nối dày đặc.|
|
||||
| | `resnet50` | ResNet-50 | Kiến trúc mạng dư thừa tiêu chuẩn.|
|
||||
| | `efficientnet_b2` | EfficientNet-B2 | Tối ưu hóa đa quy mô tài nguyên mạng.|
|
||||
| | `swin` | Swin Transformer V2-S | Kiến trúc Attention cửa sổ dịch chuyển.|
|
||||
| **Phân đoạn góc SUP** | `deeplabv3` | DeepLabV3 ResNet-50 | Trích xuất đặc trưng đa tỷ lệ với 7 lớp đầu ra.|
|
||||
| | `unet_resnet101` | UNet + ResNet-101 | Kiến trúc Encoder-Decoder kết hợp ResNet.|
|
||||
| | `efficientfeedback` | EfficientFeedbackNetwork | Thiết kế tùy biến riêng có liên kết phản hồi dữ liệu.|
|
||||
| | `unet3plus` | UNet3+ with Attention | Cơ chế Attention kết hợp kết nối toàn diện Full-scale.|
|
||||
| **Phân đoạn góc POST** | `deeplabv3_resnet101` | DeepLabV3 ResNet-101 | Cấu trúc chuyên sâu phân đoạn góc nhìn mặt sau.|
|
||||
|
||||
| Phân nhóm Task | Tên tham số truyền vào | Kiến trúc mạng nơ-ron gốc | Mô tả đặc tính đầu ra |
|
||||
| ---------------------- | ---------------------- | ------------------------- | ------------------------------------------------------ |
|
||||
| **Phân loại Góc chụp** | `convnext` | ConvNeXt Tiny | Phân cấp phân loại ra 4 lớp nhãn đầu ra. |
|
||||
| | `densenet` | DenseNet-121 | Mạng kết nối dày đặc. |
|
||||
| | `resnet50` | ResNet-50 | Kiến trúc mạng dư thừa tiêu chuẩn. |
|
||||
| | `efficientnet_b2` | EfficientNet-B2 | Tối ưu hóa đa quy mô tài nguyên mạng. |
|
||||
| | `swin` | Swin Transformer V2-S | Kiến trúc Attention cửa sổ dịch chuyển. |
|
||||
| **Phân đoạn góc SUP** | `deeplabv3` | DeepLabV3 ResNet-50 | Trích xuất đặc trưng đa tỷ lệ với 7 lớp đầu ra. |
|
||||
| | `unet_resnet101` | UNet + ResNet-101 | Kiến trúc Encoder-Decoder kết hợp ResNet. |
|
||||
| | `efficientfeedback` | EfficientFeedbackNetwork | Thiết kế tùy biến riêng có liên kết phản hồi dữ liệu. |
|
||||
| | `unet3plus` | UNet3+ with Attention | Cơ chế Attention kết hợp kết nối toàn diện Full-scale. |
|
||||
| **Phân đoạn góc POST** | `deeplabv3_resnet101` | DeepLabV3 ResNet-101 | Cấu trúc chuyên sâu phân đoạn góc nhìn mặt sau. |
|
||||
|
||||
|
||||
|
||||
|
||||
#### Cấu trúc dữ liệu JSON phản hồi (Response Body Schema)
|
||||
|
||||
@@ -300,9 +315,11 @@ Mã hóa dữ liệu đầu vào dưới dạng tệp tin `multipart/form-data`
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### Ví dụ mã triển khai gọi dịch vụ (Client Invocations)
|
||||
|
||||
* **Sử dụng lệnh Client cURL CLI:**
|
||||
- **Sử dụng lệnh Client cURL CLI:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/api/analyze?angle_model=convnext&segment_model_sup=deeplabv3" \
|
||||
@@ -310,7 +327,7 @@ curl -X POST "http://localhost:8000/api/analyze?angle_model=convnext&segment_mod
|
||||
|
||||
```
|
||||
|
||||
* **Triển khai ứng dụng gọi qua script Python (Requests):**
|
||||
- **Triển khai ứng dụng gọi qua script Python (Requests):**
|
||||
|
||||
```python
|
||||
import requests
|
||||
@@ -334,53 +351,71 @@ print("Số liệu đo lường hình học:", parsed_result.get("measurement"))
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 4. Các thông số cấu hình lõi hệ thống
|
||||
|
||||
|
||||
|
||||
### 4.1 Hằng số hệ thống trong `app.py`
|
||||
|
||||
| Tên định danh hằng số | Giá trị mặc định | Diễn giải chức năng kỹ thuật |
|
||||
| --- | --- | --- |
|
||||
| `UPLOAD_FOLDER` | `'uploads'` | Đường dẫn cục bộ lưu trữ file ảnh thô nhận từ máy trạm.|
|
||||
| `RESULTS_FOLDER` | `'results'` | Đường dẫn lưu ảnh màu sau phân đoạn (Color Mask Overlayed).|
|
||||
| `TEMPLATES_FOLDER` | `'templates'` | Thư mục chứa mã nguồn giao diện phân tích Web UI.|
|
||||
| `PIXEL_TO_MM` | $\frac{45.0}{655.0} \approx 0.0687$ | Hệ số chuyển đổi từ độ phân giải pixel sang kích thước thực tế ($mm$). Phụ thuộc cố định vào cấu hình đầu ra của phần cứng máy quét siêu âm.|
|
||||
| `DEFAULT_MEASURE_IDS` | `[1, 5]` | Danh sách mảng chứa ID nhãn lớp cấu trúc giải phẫu kích hoạt thuật toán đo độ dày: `1 = effusion` (Dịch khớp), `5 = synovium` (Màng hoạt dịch).|
|
||||
| `device` | `cuda` hoặc `cpu` | Khối phần cứng thực thi tính toán đồ họa (Tự động thiết lập dựa trên tính khả dụng của driver NVIDIA).|
|
||||
|
||||
| Tên định danh hằng số | Giá trị mặc định | Diễn giải chức năng kỹ thuật |
|
||||
| --------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `UPLOAD_FOLDER` | `'uploads'` | Đường dẫn cục bộ lưu trữ file ảnh thô nhận từ máy trạm. |
|
||||
| `RESULTS_FOLDER` | `'results'` | Đường dẫn lưu ảnh màu sau phân đoạn (Color Mask Overlayed). |
|
||||
| `TEMPLATES_FOLDER` | `'templates'` | Thư mục chứa mã nguồn giao diện phân tích Web UI. |
|
||||
| `PIXEL_TO_MM` | $\frac{45.0}{655.0} \approx 0.0687$ | Hệ số chuyển đổi từ độ phân giải pixel sang kích thước thực tế ($mm$). Phụ thuộc cố định vào cấu hình đầu ra của phần cứng máy quét siêu âm. |
|
||||
| `DEFAULT_MEASURE_IDS` | `[1, 5]` | Danh sách mảng chứa ID nhãn lớp cấu trúc giải phẫu kích hoạt thuật toán đo độ dày: `1 = effusion` (Dịch khớp), `5 = synovium` (Màng hoạt dịch). |
|
||||
| `device` | `cuda` hoặc `cpu` | Khối phần cứng thực thi tính toán đồ họa (Tự động thiết lập dựa trên tính khả dụng của driver NVIDIA). |
|
||||
|
||||
|
||||
|
||||
|
||||
### 4.2 Cấu hình Pipeline tiền xử lý và biến đổi ma trận ảnh (Transforms)
|
||||
|
||||
Hệ thống phân tách ảnh đầu vào thành các luồng biến đổi riêng biệt trước khi nạp vào tensor mô hình tùy thuộc vào mục tiêu xử lý chuyên biệt:
|
||||
|
||||
| Luồng xử lý Pipeline ảnh | Kích thước chuyển đổi (Resize) | Quy định chuẩn hóa phân phối ma trận (Normalization) |
|
||||
| --- | --- | --- |
|
||||
| **Phân loại góc & Phát hiện viêm** | <br>$224 \times 224$ pixel | Áp dụng phân phối phân cấp:<br> $\text{mean} = [0.485, 0.456, 0.406]$, <br> $\text{std} = [0.229, 0.224, 0.225]$ |
|
||||
| **Phân đoạn cấu trúc (Segmentation)** | <br>$512 \times 512$ pixel | Không áp dụng chuẩn hóa phân phối (Chỉ thực thi hàm chuyển đổi tensor `ToTensor()`) |
|
||||
|
||||
| Luồng xử lý Pipeline ảnh | Kích thước chuyển đổi (Resize) | Quy định chuẩn hóa phân phối ma trận (Normalization) |
|
||||
| ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| **Phân loại góc & Phát hiện viêm** | $224 \times 224$ pixel | Áp dụng phân phối phân cấp: $\text{mean} = [0.485, 0.456, 0.406]$, $\text{std} = [0.229, 0.224, 0.225]$ |
|
||||
| **Phân đoạn cấu trúc (Segmentation)** | $512 \times 512$ pixel | Không áp dụng chuẩn hóa phân phối (Chỉ thực thi hàm chuyển đổi tensor `ToTensor()`) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 5. Ràng buộc kỹ thuật & Quy tắc thiết kế hệ thống
|
||||
|
||||
|
||||
|
||||
### 5.1 Quản lý và giải phóng tài nguyên bộ nhớ GPU (VRAM Leak Warning)
|
||||
|
||||
Trong phiên bản hiện tại, logic xử lý nội tại của API kích hoạt các hàm `load_angle_model()`, `load_inflammation_model()`, và `load_segmentation_model_*()` trực tiếp bên trong vòng đời của mỗi phiên request nhận về. Hành vi này ép buộc GPU liên tục nạp lại dữ liệu tệp `.pth` vào VRAM cho mỗi giao dịch HTTP, sinh ra độ trễ (Overhead) I/O lớn và tiềm ẩn nguy cơ tràn bộ nhớ hệ thống. Khi triển khai môi trường Production, bắt buộc phải tái cấu trúc chuyển các hàm này thành Singleton dịch vụ (Tải một lần duy nhất lúc khởi động tiến trình Web Server).
|
||||
|
||||
### 5.2 Ràng buộc phi tuyến tính của tham số vật lý `PIXEL_TO_MM`
|
||||
|
||||
Hằng số quy đổi $\text{PIXEL\_TO\_MM} = \frac{45.0}{655.0}$ là một giá trị được cấu hình cứng (Hardcoded) trong mã nguồn, đặc trưng duy nhất cho một dòng máy siêu âm lâm sàng có tỷ lệ hiển thị $45mm$ tương đương với độ phân giải vùng quét $655\text{ px}$. Khi hệ thống thu thập ảnh siêu âm từ các thiết bị chuẩn đoán hình ảnh khác, hoặc thay đổi độ phân giải ảnh xuất ra, số liệu đo khoảng cách tổn thương sẽ sai lệch nghiêm trọng nếu hằng số này không được hiệu chuẩn lại thông qua ma trận nội quan của máy quét mới.
|
||||
Hằng số quy đổi $\text{PIXELTOMM} = \frac{45.0}{655.0}$ là một giá trị được cấu hình cứng (Hardcoded) trong mã nguồn, đặc trưng duy nhất cho một dòng máy siêu âm lâm sàng có tỷ lệ hiển thị $45mm$ tương đương với độ phân giải vùng quét $655\text{ px}$. Khi hệ thống thu thập ảnh siêu âm từ các thiết bị chuẩn đoán hình ảnh khác, hoặc thay đổi độ phân giải ảnh xuất ra, số liệu đo khoảng cách tổn thương sẽ sai lệch nghiêm trọng nếu hằng số này không được hiệu chuẩn lại thông qua ma trận nội quan của máy quét mới.
|
||||
|
||||
### 5.3 Quy tắc ánh xạ phân lớp (Class Remapping Matrix) đối với mô hình Custom
|
||||
|
||||
Hai mô hình tùy biến sâu phục vụ mặt cắt góc nhìn phía trên bánh chè (`UNet3+` và `EfficientFeedback Network`) được huấn luyện trên tập dữ liệu đặc thù sở hữu thứ tự cấu trúc mảng nhãn đầu ra lệch pha hoàn toàn so với kiến trúc phân cấp chuẩn của hệ thống. Để thống nhất dữ liệu trả về cho Client, khối Backend API thực hiện cơ chế tự động chuyển đổi chỉ mục mảng (Index Remapping) theo bảng đặc tả logic dưới đây:
|
||||
|
||||
|
||||
| Chỉ mục Mô hình gốc (Output Model Index) | Chỉ mục chuẩn hóa hệ thống (Standard System Index) | Tên nhãn lớp giải phẫu tương ứng (Anatomical Label Class) |
|
||||
| --- | --- | --- |
|
||||
| `0` | `0` | **`background`** (Nền ảnh không chứa cấu trúc) |
|
||||
| `1` | `2` | **`fat`** (Lớp mô mỡ dưới da) |
|
||||
| `2` | `6` | **`tendon`** (Cấu trúc gân cơ) |
|
||||
| `3` | `1` | **`effusion`** (Vùng tụ dịch khớp gối ổ viêm) |
|
||||
| `4` | `4` | **`femur`** (Ranh giới cấu trúc xương đùi) |
|
||||
| `5` | `5` | **`synovium`** (Màng hoạt dịch bao quanh khớp) |
|
||||
| `6` | `3` | **`fat-pat`** (Tổ chức mỡ Hoffa) |
|
||||
| ---------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `0` | `0` | `background` (Nền ảnh không chứa cấu trúc) |
|
||||
| `1` | `2` | `fat` (Lớp mô mỡ dưới da) |
|
||||
| `2` | `6` | `tendon` (Cấu trúc gân cơ) |
|
||||
| `3` | `1` | `effusion` (Vùng tụ dịch khớp gối ổ viêm) |
|
||||
| `4` | `4` | `femur` (Ranh giới cấu trúc xương đùi) |
|
||||
| `5` | `5` | `synovium` (Màng hoạt dịch bao quanh khớp) |
|
||||
| `6` | `3` | `fat-pat` (Tổ chức mỡ Hoffa) |
|
||||
|
||||
|
||||
|
||||
|
||||
### 5.4 Cơ chế tự động dọn dẹp tập tin tồn đọng (Garbage Collection Task)
|
||||
|
||||
@@ -388,8 +423,12 @@ Các tập tin ảnh thô tải lên thư mục `uploads/` và ảnh xử lý nh
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 6. Giải pháp mở rộng tính năng mã nguồn (Backend Optimization Guide)
|
||||
|
||||
|
||||
|
||||
### 6.1 Tăng tốc độ phản hồi bằng Cơ chế Caching Mô hình Toàn cục
|
||||
|
||||
Thay thế kiến trúc nạp tải mô hình cũ bằng một kho lưu trữ Cache tĩnh trong bộ nhớ RAM, tối ưu hóa thời gian xử lý request từ mức giây xuống mức mili-giây:
|
||||
@@ -409,11 +448,13 @@ def get_cached_angle_model(selected_model_name: str):
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 6.2 Thêm mới một kiến trúc phân loại góc chụp (Ví dụ: Vision Transformer - ViT)
|
||||
|
||||
Để tích hợp một mạng nơ-ron mới vào hệ thống xử lý, tuân thủ nghiêm ngặt quy trình 3 bước sau:
|
||||
|
||||
* **Bước 1:** Bổ sung khối xử lý điều kiện rẽ nhánh logic vào hàm khởi tạo mô hình `load_angle_model()`:
|
||||
- **Bước 1:** Bổ sung khối xử lý điều kiện rẽ nhánh logic vào hàm khởi tạo mô hình `load_angle_model()`:
|
||||
|
||||
```python
|
||||
elif model_name == 'vit':
|
||||
@@ -428,9 +469,8 @@ elif model_name == 'vit':
|
||||
|
||||
```
|
||||
|
||||
* **Bước 2:** Di chuyển tệp trọng số huấn luyện nhị phân của mạng (`best_vit_b16.pth`) vào chính xác không gian lưu trữ của thư mục `/models/`.
|
||||
|
||||
* **Bước 3:** Ứng dụng phía Client có thể kích hoạt mạng mới bằng cách truyền giá trị định danh qua tham số URL: `/api/analyze?angle_model=vit`.
|
||||
- **Bước 2:** Di chuyển tệp trọng số huấn luyện nhị phân của mạng (`best_vit_b16.pth`) vào chính xác không gian lưu trữ của thư mục `/models/`.
|
||||
- **Bước 3:** Ứng dụng phía Client có thể kích hoạt mạng mới bằng cách truyền giá trị định danh qua tham số URL: `/api/analyze?angle_model=vit`.
|
||||
|
||||
|
||||
|
||||
@@ -460,6 +500,8 @@ async def analyze_batch_images(images: List[UploadFile] = File(...)):
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 6.4 Bản đóng gói container hóa ứng dụng (Production Dockerfile)
|
||||
|
||||
Đóng gói toàn bộ ML Stack bao gồm trình điều khiển GPU NVIDIA CUDA để triển khai đồng bộ trên các hạ tầng Cloud hoặc máy chủ On-Premise của bệnh viện:
|
||||
@@ -490,15 +532,16 @@ CMD ["python", "app.py"]
|
||||
|
||||
```
|
||||
|
||||
* **Lệnh khởi dựng Image hệ thống:** `docker build -t medical-api-service .`
|
||||
|
||||
* **Lệnh kích hoạt Container chia sẻ tài nguyên phần cứng GPU vật lý:**
|
||||
- **Lệnh khởi dựng Image hệ thống:** `docker build -t medical-api-service .`
|
||||
- **Lệnh kích hoạt Container chia sẻ tài nguyên phần cứng GPU vật lý:**
|
||||
|
||||
```bash
|
||||
docker run --gpus all -p 8000:8000 -v $(pwd)/models:/app/models medical-api-service
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 6.5 Bộ chuyển đổi tiếp nhận trực tiếp luồng dữ liệu ảnh y tế chuẩn DICOM
|
||||
|
||||
Mở rộng chức năng cho phép hệ thống API đọc trực tiếp tệp tin ảnh gốc dạng `.dcm` trích xuất trực tiếp từ các thiết bị siêu âm chuẩn lâm sàng trong bệnh viện mà không cần qua bước chuyển đổi định dạng thủ công:
|
||||
@@ -527,52 +570,61 @@ async def analyze_dicom_file(file: UploadFile = File(...)):
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Phụ lục: Đặc tả Dữ liệu định lượng lâm sàng
|
||||
|
||||
|
||||
|
||||
### Phụ lục A: Bảng phân định mã màu mặt nạ phân đoạn ngữ nghĩa (Color Map Legend)
|
||||
|
||||
1. Cấu trúc Mặt cắt mặt trên bánh chè - Góc SUP (`sup-up-long`)
|
||||
1. Cấu trúc Mặt cắt mặt trên bánh chè - Góc SUP (`sup-up-long`)
|
||||
|
||||
Góc SUP tập trung khoanh vùng các lớp mô mềm phía trước đầu gối phục vụ thuật toán tính toán độ dày dịch tụ.
|
||||
|
||||
| Từ khóa nhãn (Key) | Cấu trúc giải phẫu đích | Mã màu hiển thị (RGB) | Trực quan hóa màu sắc |
|
||||
| --- | --- | --- | --- |
|
||||
| `background` | Nền ảnh siêu âm | `[0, 0, 0]` | ⬛ Đen (Không chứa dữ liệu) |
|
||||
| `effusion` | Vùng dịch khớp tụ ổ viêm | `[255, 0, 0]` | 🟥 Đỏ |
|
||||
| `fat` | Tổ chức mô mỡ dưới da | `[255, 255, 0]` | 🟨 Vàng |
|
||||
| `fat-pat` | Khối mỡ Hoffa | `[0, 255, 255]` | 🟦 Lam sáng |
|
||||
| `femur` | Cấu trúc bề mặt xương đùi | `[0, 255, 0]` | 🟩 Xanh lá |
|
||||
| `synovium` | Lớp màng hoạt dịch tăng sinh | `[255, 0, 255]` | 🟪 Tím |
|
||||
| `tendon` | Vùng bó gân cơ | `[0, 0, 255]` | 🟦 Xanh dương |
|
||||
|
||||
| Từ khóa nhãn (Key) | Cấu trúc giải phẫu đích | Mã màu hiển thị (RGB) | Trực quan hóa màu sắc |
|
||||
| ------------------ | ---------------------------- | --------------------- | -------------------------- |
|
||||
| `background` | Nền ảnh siêu âm | `[0, 0, 0]` | ⬛ Đen (Không chứa dữ liệu) |
|
||||
| `effusion` | Vùng dịch khớp tụ ổ viêm | `[255, 0, 0]` | 🟥 Đỏ |
|
||||
| `fat` | Tổ chức mô mỡ dưới da | `[255, 255, 0]` | 🟨 Vàng |
|
||||
| `fat-pat` | Khối mỡ Hoffa | `[0, 255, 255]` | 🟦 Lam sáng |
|
||||
| `femur` | Cấu trúc bề mặt xương đùi | `[0, 255, 0]` | 🟩 Xanh lá |
|
||||
| `synovium` | Lớp màng hoạt dịch tăng sinh | `[255, 0, 255]` | 🟪 Tím |
|
||||
| `tendon` | Vùng bó gân cơ | `[0, 0, 255]` | 🟦 Xanh dương |
|
||||
|
||||
|
||||
> 🔄 **QUY TẮC CHUYỂN ĐỔI CHUYỂN GÓC (SUP $\rightarrow$ POST):**
|
||||
> Khi hệ thống chuyển đổi trạng thái phân tích sang mặt cắt phía sau khớp gối (Góc `POST`), ma trận thuật toán phân đoạn sẽ tự động tái cấu trúc màu sắc ngữ nghĩa: Vùng tổn thương chứa **`effusion`** (màu đỏ) sẽ chuyển trạng thái biểu diễn thành **`baker's cyst`** (Kén Baker), và tổ chức cấu trúc vùng **`fat-pat`** (màu lam sáng) sẽ hoán đổi ý nghĩa thành vùng **`muscle`** (Cơ bắp vùng khoeo).
|
||||
>
|
||||
>
|
||||
> Khi hệ thống chuyển đổi trạng thái phân tích sang mặt cắt phía sau khớp gối (Góc `POST`), ma trận thuật toán phân đoạn sẽ tự động tái cấu trúc màu sắc ngữ nghĩa: Vùng tổn thương chứa `effusion` (màu đỏ) sẽ chuyển trạng thái biểu diễn thành `baker's cyst` (Kén Baker), và tổ chức cấu trúc vùng `fat-pat` (màu lam sáng) sẽ hoán đổi ý nghĩa thành vùng `muscle` (Cơ bắp vùng khoeo).
|
||||
|
||||
2. Cấu trúc Mặt cắt mặt sau vùng khoeo chân - Góc POST (`post-trans`)
|
||||
1. Cấu trúc Mặt cắt mặt sau vùng khoeo chân - Góc POST (`post-trans`)
|
||||
|
||||
|
||||
| Từ khóa nhãn (Key) | Cấu trúc giải phẫu đích | Mã màu hiển thị (RGB) | Trực quan hóa màu sắc |
|
||||
| ------------------ | ---------------------------------------- | --------------------- | --------------------- |
|
||||
| `background` | Nền ảnh siêu âm | `[0, 0, 0]` | ⬛ Đen |
|
||||
| `baker's cyst` | Tổ chức kén hoạt dịch vùng khoeo (Baker) | `[255, 0, 0]` | 🟥 Đỏ |
|
||||
| `fat` | Lớp mô mỡ | `[255, 255, 0]` | 🟨 Vàng |
|
||||
| `muscle` | Các nhóm cơ bắp vùng sau gối | `[0, 255, 255]` | 🟦 Lam sáng |
|
||||
| `femur` | Cấu trúc xương đùi sau | `[0, 255, 0]` | 🟩 Xanh lá |
|
||||
| `synovium` | Màng hoạt dịch mặt sau | `[255, 0, 255]` | 🟪 Tím |
|
||||
| `tendon` | Hệ thống gân cơ mặt sau | `[0, 0, 255]` | 🟦 Xanh dương |
|
||||
|
||||
| Từ khóa nhãn (Key) | Cấu trúc giải phẫu đích | Mã màu hiển thị (RGB) | Trực quan hóa màu sắc |
|
||||
| --- | --- | --- | --- |
|
||||
| `background` | Nền ảnh siêu âm | `[0, 0, 0]` | ⬛ Đen |
|
||||
| `baker's cyst` | Tổ chức kén hoạt dịch vùng khoeo (Baker) | `[255, 0, 0]` | 🟥 Đỏ |
|
||||
| `fat` | Lớp mô mỡ | `[255, 255, 0]` | 🟨 Vàng |
|
||||
| `muscle` | Các nhóm cơ bắp vùng sau gối | `[0, 255, 255]` | 🟦 Lam sáng |
|
||||
| `femur` | Cấu trúc xương đùi sau | `[0, 255, 0]` | 🟩 Xanh lá |
|
||||
| `synovium` | Màng hoạt dịch mặt sau | `[255, 0, 255]` | 🟪 Tím |
|
||||
| `tendon` | Hệ thống gân cơ mặt sau | `[0, 0, 255]` | 🟦 Xanh dương |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Phụ lục B: Thang điểm đánh giá mức độ nghiêm trọng của ổ viêm (Clinical Severity Score)
|
||||
|
||||
Hệ thống chấm điểm toán học tự động căn cứ trên trọng số diện tích và độ dày phân tách để đưa ra kết luận mức độ bệnh lý lâm sàng thông qua phương trình tuyến tính tổng hợp:
|
||||
|
||||
$$\text{combined\_score} = \text{effusion\_score} \times 0.6 + \text{synovium\_score} \times 0.4$$
|
||||
$$\text{combinedscore} = \text{effusionscore} \times 0.6 + \text{synoviumscore} \times 0.4$$
|
||||
|
||||
Dựa trên kết quả giá trị của biến số $\text{combined\_score}$, hệ thống tự động phân cấp thành 4 ngưỡng trạng thái lâm sàng tương ứng:
|
||||
Dựa trên kết quả giá trị của biến số $\text{combinedscore}$, hệ thống tự động phân cấp thành 4 ngưỡng trạng thái lâm sàng tương ứng:
|
||||
|
||||
- **Mức 0 - Rất nhẹ ($\text{score} < 3$):** Trạng thái ổ dịch khớp và cấu trúc màng hoạt dịch nằm hoàn toàn trong giới hạn sinh lý bình thường của cơ thể.
|
||||
- **Mức 1 - Nhẹ ($\text{score}$ từ $3$ đến $7.9$):** Xuất hiện hiện tượng tụ dịch khớp lớp mỏng, màng hoạt dịch có dấu hiệu tăng sinh nhẹ cấu trúc màng.
|
||||
- **Mức 2 - Trung bình ($\text{score}$ từ $8$ đến $15$):** Lượng dịch tụ khớp gối ở mức độ vừa phải, màng hoạt dịch bắt đầu phì đại và tăng sinh rõ nét.
|
||||
- **Mức 3 - Nặng ($\text{score} > 15$):** Lớp tụ dịch khớp gối dày kích thước lớn, màng hoạt dịch tăng sinh phì đại mạnh, lan rộng diện tích cấu trúc giải phẫu xung quanh.
|
||||
|
||||
* **Mức 0 - Rất nhẹ ($\text{score} < 3$):** Trạng thái ổ dịch khớp và cấu trúc màng hoạt dịch nằm hoàn toàn trong giới hạn sinh lý bình thường của cơ thể.
|
||||
* **Mức 1 - Nhẹ ($\text{score}$ từ $3$ đến $7.9$):** Xuất hiện hiện tượng tụ dịch khớp lớp mỏng, màng hoạt dịch có dấu hiệu tăng sinh nhẹ cấu trúc màng.
|
||||
* **Mức 2 - Trung bình ($\text{score}$ từ $8$ đến $15$):** Lượng dịch tụ khớp gối ở mức độ vừa phải, màng hoạt dịch bắt đầu phì đại và tăng sinh rõ nét.
|
||||
* **Mức 3 - Nặng ($\text{score} > 15$):** Lớp tụ dịch khớp gối dày kích thước lớn, màng hoạt dịch tăng sinh phì đại mạnh, lan rộng diện tích cấu trúc giải phẫu xung quanh.
|
||||
233
workspace/sprint_1_2/CODEBASE/backend/api/analysis_api.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from fastapi.responses import StreamingResponse
|
||||
from datetime import datetime
|
||||
from data.spec.schemas import (
|
||||
AnalysisJobSubmit, JobStatus, PipelineStep, StepEvent,
|
||||
ModelCatalog, ModelRegistrationResult, HealthStatus,
|
||||
AnalysisJobSyncSubmit, JobResult, ErrorResponse,
|
||||
)
|
||||
from backend.implementation.analysis_jobs import service as analysis_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["analysis"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
_event_queues: dict[str, asyncio.Queue] = {}
|
||||
_queue_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _get_queue(job_id: str):
|
||||
async with _queue_lock:
|
||||
if job_id not in _event_queues:
|
||||
_event_queues[job_id] = asyncio.Queue()
|
||||
yield _event_queues[job_id]
|
||||
|
||||
|
||||
def _get_queue_sync(job_id: str) -> asyncio.Queue:
|
||||
if job_id not in _event_queues:
|
||||
_event_queues[job_id] = asyncio.Queue()
|
||||
return _event_queues[job_id]
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def _sse_format(event: StepEvent) -> str:
|
||||
lines = [f"event: {event.event_type}"]
|
||||
payload = event.model_dump(mode="json")
|
||||
lines.append(f"data: {payload}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/analysis-jobs",
|
||||
response_model=dict,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
|
||||
)
|
||||
async def submit_analysis_job(
|
||||
payload: AnalysisJobSubmit,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
job_id = await analysis_service.submit_job(
|
||||
session_id=payload.session_id,
|
||||
params=payload.params or {},
|
||||
model_versions=payload.model_versions,
|
||||
)
|
||||
return {"job_id": job_id}
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analysis-jobs/{job_id}",
|
||||
response_model=JobStatus,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def get_job_status(job_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await analysis_service.job_status(job_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analysis-jobs/{job_id}/steps",
|
||||
response_model=list[PipelineStep],
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def get_job_steps(job_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await analysis_service.job_steps(job_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/analysis",
|
||||
response_model=JobResult,
|
||||
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
|
||||
)
|
||||
async def submit_sync_analysis(
|
||||
payload: AnalysisJobSyncSubmit,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await analysis_service.submit_sync(
|
||||
session_id=payload.session_id,
|
||||
params=payload.params or {},
|
||||
model_versions=payload.model_versions,
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analysis-jobs/{job_id}/stream",
|
||||
responses={
|
||||
401: {"model": ErrorResponse},
|
||||
404: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def stream_job_events(job_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
queue = _get_queue_sync(job_id)
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
yield _sse_format(event)
|
||||
if event.event_type == "completed" or event.event_type == "failed":
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"SSE stream cancelled for job_id={job_id}")
|
||||
finally:
|
||||
async with _queue_lock:
|
||||
_event_queues.pop(job_id, None)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/analysis-jobs/{job_id}/events",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def internal_push_event(job_id: str, event: dict):
|
||||
queue = _get_queue_sync(job_id)
|
||||
try:
|
||||
step_event = StepEvent(
|
||||
step_id=event.get("step_id", ""),
|
||||
job_id=job_id,
|
||||
event_type=event.get("event_type", "progress"),
|
||||
task_type=event.get("task_type", ""),
|
||||
status=event.get("status", "running"),
|
||||
data=event.get("data"),
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
await queue.put(step_event)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to push event for job {job_id}: {exc}")
|
||||
return {"queued": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
response_model=HealthStatus,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def health_check():
|
||||
try:
|
||||
return await analysis_service.health()
|
||||
except NotImplementedError:
|
||||
return HealthStatus(
|
||||
status="ok",
|
||||
version="0.1.0",
|
||||
dependencies={},
|
||||
uptime_seconds=0.0,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/model-registry",
|
||||
response_model=ModelCatalog,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_models(user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await analysis_service.list_registered_models()
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/models/register",
|
||||
response_model=ModelRegistrationResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def register_model(
|
||||
model_id: str,
|
||||
file: UploadFile | None = None,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await analysis_service.register_model(model_id, file)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
82
workspace/sprint_1_2/CODEBASE/backend/api/auth_api.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import LoginRequest, Token, UserProfile, UserUpdateRequest, RefreshRequest, ErrorResponse
|
||||
from backend.implementation.auth import service as auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["auth"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
profile = await auth_service.me(token)
|
||||
return profile.user_id
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auth/login",
|
||||
response_model=Token,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def login(payload: LoginRequest):
|
||||
try:
|
||||
return await auth_service.login(payload.username, payload.password)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auth/logout",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def logout(token: str = Depends(oauth2_scheme)):
|
||||
try:
|
||||
await auth_service.logout(token)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auth/refresh",
|
||||
response_model=Token,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def refresh(payload: RefreshRequest):
|
||||
try:
|
||||
return await auth_service.refresh(payload.refresh_token)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users/me",
|
||||
response_model=UserProfile,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def get_me(user_id: str = Depends(verify_jwt_token)):
|
||||
try:
|
||||
return await auth_service.me(user_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/users/me",
|
||||
response_model=UserProfile,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def update_me(payload: UserUpdateRequest, user_id: str = Depends(verify_jwt_token)):
|
||||
try:
|
||||
return await auth_service.update_me(user_id, payload.model_dump(exclude_none=True))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
47
workspace/sprint_1_2/CODEBASE/backend/api/ingestion_api.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import IngestionRecord, RecordDetail, ErrorResponse
|
||||
from backend.implementation.ingestion_history import service as ingestion_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["ingestion-history"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ingestion-history",
|
||||
response_model=list[IngestionRecord],
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_ingestion_records(user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await ingestion_service.list_records(user_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ingestion-history/{record_id}",
|
||||
response_model=RecordDetail,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def get_ingestion_record(record_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await ingestion_service.get_record(record_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
@@ -0,0 +1,59 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import NotificationItem, NotificationPreferences, ErrorResponse
|
||||
from backend.implementation.notification import service as notification_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["notification"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notifications",
|
||||
response_model=list[NotificationItem],
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_notifications(user_id: str = Depends(_verify_jwt_token), filters: dict | None = None):
|
||||
try:
|
||||
return await notification_service.list_notifications(user_id, filters)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/notifications/{notification_id}/read",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def mark_read(notification_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
await notification_service.mark_read(notification_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/preferences",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def set_preferences(payload: NotificationPreferences, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
await notification_service.set_preferences(user_id, payload.model_dump(exclude_none=True))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
91
workspace/sprint_1_2/CODEBASE/backend/api/patient_api.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import Patient, PatientCreate, PatientListResponse, ErrorResponse
|
||||
from backend.implementation.patient import service as patient_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["patient"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/patients",
|
||||
response_model=PatientListResponse,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_patients(user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
items = await patient_service.list_patients(user_id)
|
||||
return PatientListResponse(items=items, total=len(items))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/patients",
|
||||
response_model=Patient,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_patient(payload: PatientCreate, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await patient_service.create_patient(payload.model_dump(exclude_none=True))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/patients/{patient_id}",
|
||||
response_model=Patient,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def get_patient(patient_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await patient_service.get_patient(patient_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/patients/{patient_id}/sessions",
|
||||
response_model=list[dict],
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_patient_sessions(patient_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await patient_service.list_sessions(patient_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/patients/{patient_id}/history",
|
||||
response_model=list[dict],
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def patient_ingestion_history(patient_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await patient_service.ingestion_history(patient_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
320
workspace/sprint_1_2/CODEBASE/backend/api/safety_api.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
import httpx
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PILOT_PROJECT.workspace.sprint_1_2.CODEBASE.data.spec.schemas.safety_schemas import ChatResponse
|
||||
from data.spec.schemas import (
|
||||
HeatmapResult, RationaleResult, ChatEvent, DriftCheckResult,
|
||||
EvidenceList, ActivationMeta, AnnotationArtifact, EscalationTicket,
|
||||
GuardrailResult, ErrorResponse, CorrectionSubmit, CorrectionRecord,
|
||||
)
|
||||
|
||||
from backend.implementation.safety import service as safety_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["safety"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def _sse_chat_format(event: ChatEvent) -> str:
|
||||
lines = [f"event: {event.event_type}"]
|
||||
payload = event.model_dump(mode="json")
|
||||
lines.append(f"data: {payload}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/explanations/gradcam",
|
||||
response_model=HeatmapResult,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def gradcam(session_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await safety_service.gradcam(session_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/explanations/rationale",
|
||||
response_model=RationaleResult,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def rationale(
|
||||
session_id: str,
|
||||
redaction_hash: str | None = None,
|
||||
user_id: str = Depends(_verify_jwt_token)
|
||||
):
|
||||
try:
|
||||
return await safety_service.rationale(session_id, redaction_hash)
|
||||
except HTTPException:
|
||||
raise
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/safety/circuit-breaker",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def circuit_breaker(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
await safety_service.circuit_break(session_id, payload.get("flag", False))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/chat/socratic",
|
||||
response_model=ChatResponse,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model: ErrorResponse}},
|
||||
)
|
||||
async def socratic_chat(
|
||||
session_id: str,
|
||||
payload: dict,
|
||||
redaction_hash: str | None = None,
|
||||
user_id: str = Depends(_verify_jwt_token)
|
||||
):
|
||||
try:
|
||||
return await safety_service.socratic_chat(
|
||||
session_id,
|
||||
payload.get("prompt", ""),
|
||||
redaction_hash=redaction_hash
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/drift/check",
|
||||
response_model=DriftCheckResult,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def drift_check(session_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await safety_service.drift_check(session_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/rag/evidence",
|
||||
response_model=EvidenceList,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def rag_evidence(session_id: str, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await safety_service.rag_evidence(session_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/activations",
|
||||
response_model=ActivationMeta,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def activations(session_id: str, params: dict, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await safety_service.activations(session_id, params)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/annotations/artifacts",
|
||||
response_model=AnnotationArtifact,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def upload_artifact(
|
||||
session_id: str,
|
||||
file: UploadFile = File(...),
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await safety_service.upload_artifact(session_id, file)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/ground-truth",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def ground_truth(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
await safety_service.ground_truth(session_id, payload.get("label", {}))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/escalation",
|
||||
response_model=EscalationTicket,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def escalate(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await safety_service.escalate(session_id, payload.get("reason", ""))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/annotations/morphology",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def morphology(session_id: str, payload: dict, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
await safety_service.morphology(session_id, payload.get("annotation", {}))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/safety/guardrail-check",
|
||||
response_model=GuardrailResult,
|
||||
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
|
||||
)
|
||||
async def guardrail_check(payload: dict, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
session_id = payload.get("session_id", "")
|
||||
return await safety_service.guardrail_check(
|
||||
session_id=session_id,
|
||||
prompt=payload.get("prompt", ""),
|
||||
score=float(payload.get("score", 0.0)),
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/feedback",
|
||||
response_model=CorrectionRecord,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
401: {"model": ErrorResponse},
|
||||
404: {"model": ErrorResponse},
|
||||
422: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def submit_correction(
|
||||
session_id: str,
|
||||
payload: CorrectionSubmit,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await safety_service.submit_correction(session_id, payload.dict())
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/chat/stream",
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def chat_stream(
|
||||
session_id: str,
|
||||
prompt: str,
|
||||
redaction_hash: str | None = None,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
async def generate():
|
||||
try:
|
||||
async for chunk in safety_service.chat_stream(session_id, prompt, redaction_hash):
|
||||
event = ChatEvent(
|
||||
session_id=session_id,
|
||||
event_type="chunk",
|
||||
content=chunk,
|
||||
is_final=False,
|
||||
)
|
||||
yield _sse_chat_format(event)
|
||||
final_event = ChatEvent(
|
||||
session_id=session_id,
|
||||
event_type="completed",
|
||||
content="",
|
||||
is_final=True,
|
||||
)
|
||||
yield _sse_chat_format(final_event)
|
||||
except HTTPException as exc:
|
||||
error_event = ChatEvent(
|
||||
session_id=session_id,
|
||||
event_type="error",
|
||||
content=exc.detail,
|
||||
is_final=True,
|
||||
)
|
||||
yield _sse_chat_format(error_event)
|
||||
except NotImplementedError:
|
||||
fallback_event = ChatEvent(
|
||||
session_id=session_id,
|
||||
event_type="error",
|
||||
content="Chat streaming service not yet implemented",
|
||||
is_final=True,
|
||||
)
|
||||
yield _sse_chat_format(fallback_event)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Chat stream cancelled for session_id={session_id}")
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
214
workspace/sprint_1_2/CODEBASE/backend/api/session_api.py
Normal file
@@ -0,0 +1,214 @@
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import (
|
||||
Session, SessionDetail, SessionCreate, SessionPatchReview,
|
||||
FrameMetadata, PersistResult, ExportResult, ScrubResult,
|
||||
ReportCreate, ReportSignRequest, ReportSyncEMRRequest, SyncResult,
|
||||
ErrorResponse,
|
||||
)
|
||||
from backend.implementation.session import service as session_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["session"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions",
|
||||
response_model=Session,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_session(payload: SessionCreate, user_id: str = Depends(verify_jwt_token)):
|
||||
try:
|
||||
return await session_service.create_session(
|
||||
user_id=user_id,
|
||||
patient_id=payload.patient_id,
|
||||
case_id=getattr(payload, "case_id", None),
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}",
|
||||
response_model=SessionDetail,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def get_session(session_id: str, user_id: str = Depends(verify_jwt_token)):
|
||||
try:
|
||||
return await session_service.get_session(session_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/frames",
|
||||
response_model=FrameMetadata,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def add_frame(
|
||||
session_id: str,
|
||||
file: UploadFile = File(...),
|
||||
frame_number: int | None = None,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await session_service.add_frame(session_id, file, frame_number)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/sessions/{session_id}/review",
|
||||
response_model=Session,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def patch_review(
|
||||
session_id: str,
|
||||
payload: SessionPatchReview,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await session_service.patch_review(session_id, payload.model_dump(exclude_none=True))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reports",
|
||||
response_model=dict,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_report(payload: ReportCreate, user_id: str = Depends(verify_jwt_token)):
|
||||
try:
|
||||
result = await session_service.persist(payload.session_id, payload.payload)
|
||||
return {"report_id": result.session_id, "status": result.status, "updated_at": result.updated_at.isoformat()}
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reports/{report_id}/sign",
|
||||
response_model=dict,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def sign_report(
|
||||
report_id: str,
|
||||
payload: ReportSignRequest,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
result = await session_service.persist(report_id, {"signed": True, "signature": payload.signature})
|
||||
return {"report_id": report_id, "signed": True, "updated_at": result.updated_at.isoformat()}
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reports/{report_id}/emr-sync",
|
||||
response_model=SyncResult,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def sync_emr(
|
||||
report_id: str,
|
||||
payload: ReportSyncEMRRequest,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
from datetime import datetime
|
||||
try:
|
||||
result = await session_service.persist(report_id, {"emr_sync": True})
|
||||
return SyncResult(
|
||||
report_id=report_id,
|
||||
emr_status="pending",
|
||||
emr_reference=None,
|
||||
synced_at=datetime.utcnow(),
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/persist",
|
||||
response_model=PersistResult,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def persist_session(
|
||||
session_id: str,
|
||||
review: dict,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await session_service.persist(session_id, review)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/export-pdf",
|
||||
response_model=ExportResult,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def export_pdf(
|
||||
session_id: str,
|
||||
params: dict,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await session_service.export_pdf(session_id, params)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/scrub-validate",
|
||||
response_model=ScrubResult,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def scrub_validate(
|
||||
session_id: str,
|
||||
metadata: dict,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await session_service.scrub_validate(session_id, metadata)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
47
workspace/sprint_1_2/CODEBASE/backend/api/settings_api.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import UserSettings, SettingsUpdate, ErrorResponse
|
||||
from backend.implementation.settings import service as settings_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["settings"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/settings",
|
||||
response_model=UserSettings,
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def get_settings(user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await settings_service.get_settings(user_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/settings",
|
||||
response_model=UserSettings,
|
||||
responses={401: {"model": ErrorResponse}, 422: {"model": ErrorResponse}},
|
||||
)
|
||||
async def update_settings(payload: SettingsUpdate, user_id: str = Depends(_verify_jwt_token)):
|
||||
try:
|
||||
return await settings_service.update_settings(user_id, payload.model_dump(exclude_none=True))
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
40
workspace/sprint_1_2/CODEBASE/backend/api/telemetry_api.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import AnomalyReport, AnomalyRecord, ErrorResponse
|
||||
from backend.implementation.telemetry import service as telemetry_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["telemetry"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/analysis-jobs/{job_id}/anomalies",
|
||||
response_model=AnomalyRecord,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def report_anomaly(
|
||||
job_id: str,
|
||||
payload: AnomalyReport,
|
||||
user_id: str = Depends(verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await telemetry_service.report_anomaly(job_id, payload.data)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
93
workspace/sprint_1_2/CODEBASE/backend/cv_inference_server.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Standalone FastAPI server for CV inference (Modal Triton).
|
||||
|
||||
Run from CODEBASE root:
|
||||
|
||||
PYTHONPATH=. python -m backend.cv_inference_server
|
||||
|
||||
Or the backward-compatible launcher:
|
||||
|
||||
PYTHONPATH=. python backend/tests/test_fast_api_proxy.py
|
||||
|
||||
Default: http://127.0.0.1:8001 — point the frontend Vite proxy here (see .env.development).
|
||||
|
||||
Env:
|
||||
TRITON_ENDPOINT Modal Triton KServe v2 HTTP URL
|
||||
CV_INFERENCE_HOST bind host (default 127.0.0.1)
|
||||
CV_INFERENCE_PORT bind port (default 8001)
|
||||
ANGLE_MODEL / INFLAMMATION_MODEL / SEGMENT_MODEL optional overrides
|
||||
CV_PIPELINE_VERSION cache invalidation fingerprint (default poc-v2-spec-cv)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Must run before backend imports — config reads TRITON_ENDPOINT at import time.
|
||||
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
|
||||
os.environ.setdefault("TRITON_ENDPOINT", DEFAULT_TRITON_ENDPOINT)
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from backend.routers import cv_inference
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting CV inference service on Triton: %s", os.getenv("TRITON_ENDPOINT"))
|
||||
from backend.services.triton_warmup import warmup_triton_models
|
||||
|
||||
try:
|
||||
await warmup_triton_models()
|
||||
except Exception as exc:
|
||||
logger.warning("Triton warmup failed — first clinical request may be slow: %s", exc)
|
||||
yield
|
||||
logger.info("Shutting down CV inference service")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="VKIST CV Inference Service",
|
||||
version="0.2.0",
|
||||
description=(
|
||||
"Spec-compliant musculoskeletal ultrasound CV pipeline "
|
||||
"(CLAHE → angle → inflammation → conditional segmentation)."
|
||||
),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.getenv(
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:3000,http://localhost:5173,http://localhost:4173,http://127.0.0.1:5173",
|
||||
).split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(cv_inference.router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
host = os.getenv("CV_INFERENCE_HOST", os.getenv("SEGMENT_TEST_HOST", "127.0.0.1"))
|
||||
port = int(os.getenv("CV_INFERENCE_PORT", os.getenv("SEGMENT_TEST_PORT", "8001")))
|
||||
logger.info("CV inference service listening on %s:%s", host, port)
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
from typing import NamedTuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class DriftResult:
|
||||
score: float
|
||||
is_drifted: bool
|
||||
threshold: float
|
||||
|
||||
@dataclass
|
||||
class GuardrailResult:
|
||||
verdict: str # "PASS" | "MITIGATE"
|
||||
reason: Optional[str] = None
|
||||
|
||||
class BERTAdapter:
|
||||
"""
|
||||
Adapter for BERT-based safety checks (drift, referee, guardrails).
|
||||
Current implementation provides stubs.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.drift_threshold = 0.7
|
||||
|
||||
def drift_check(self, text: str) -> DriftResult:
|
||||
"""
|
||||
Checks if the input text drifts from the clinical domain.
|
||||
"""
|
||||
# Stub: Always return no drift
|
||||
return DriftResult(score=0.9, is_drifted=False, threshold=self.drift_threshold)
|
||||
|
||||
def referee_check(self, text: str, retrieved_chunks: list) -> bool:
|
||||
"""
|
||||
RAG-Referee: Validates if LLM response is grounded in provided chunks.
|
||||
"""
|
||||
# Stub: Always return grounded
|
||||
return True
|
||||
|
||||
def guardrail_check(self, text: str) -> GuardrailResult:
|
||||
"""
|
||||
Token/Chunk level guardrail check for hallucinations or scope violations.
|
||||
"""
|
||||
# Stub: Always return PASS
|
||||
return GuardrailResult(verdict="PASS")
|
||||
|
||||
def get_bert_adapter() -> BERTAdapter:
|
||||
return BERTAdapter()
|
||||
@@ -0,0 +1,71 @@
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, List, Optional
|
||||
from langchain_google_vertexai import VertexAI
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
from langchain.schema import LLMResult
|
||||
from backend.implementation import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuditCallbackHandler(BaseCallbackHandler):
|
||||
"""
|
||||
Langchain callback to enforce audit logging before LLM calls per NFR-16a.
|
||||
"""
|
||||
def __init__(self, session_id: str, metadata: Optional[dict] = None):
|
||||
self.session_id = session_id
|
||||
self.metadata = metadata or {}
|
||||
|
||||
def on_llm_start(self, serialized: Any, prompts: List[str], **kwargs) -> None:
|
||||
# MANDATORY: Write egress_consent + egress_redact_manifest to immutable audit log
|
||||
# In a real implementation, this would call a database service to commit to Postgres.
|
||||
logger.info(f"[AUDIT] Pre-egress audit commit for session {self.session_id}. "
|
||||
f"Prompts: {prompts}. Metadata: {self.metadata}")
|
||||
|
||||
def on_llm_end(self, response: LLMResult, **kwargs) -> None:
|
||||
# Log actual egress event after completion
|
||||
logger.info(f"[AUDIT] LLM egress completed for session {self.session_id}")
|
||||
|
||||
def on_llm_error(self, error: Exception, **kwargs) -> None:
|
||||
logger.error(f"[AUDIT] LLM error for session {self.session_id}: {str(error)}")
|
||||
|
||||
class VertexAILangchainAdapter:
|
||||
def __init__(self):
|
||||
self.llm = VertexAI(
|
||||
model_name=config.VERTEX_AI_MODEL,
|
||||
project_id=config.VERTEX_AI_PROJECT,
|
||||
location=config.VERTEX_AI_LOCATION,
|
||||
max_output_tokens=256,
|
||||
temperature=0.2,
|
||||
top_p=0.8,
|
||||
top_k=40,
|
||||
)
|
||||
|
||||
async def generate(self, prompt: str, session_id: str, metadata: Optional[dict] = None) -> str:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
callback_handler = AuditCallbackHandler(session_id, metadata)
|
||||
|
||||
def _sync_generate():
|
||||
result = self.llm.generate(
|
||||
prompts=[prompt],
|
||||
callbacks=[callback_handler]
|
||||
)
|
||||
return result.generations[0][0].text
|
||||
|
||||
return await loop.run_in_executor(None, _sync_generate)
|
||||
|
||||
async def stream_generate(self, prompt: str, session_id: str, metadata: Optional[dict] = None) -> AsyncGenerator[str, None]:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
callback_handler = AuditCallbackHandler(session_id, metadata)
|
||||
|
||||
def _sync_stream():
|
||||
return self.llm.stream(prompt, callbacks=[callback_handler])
|
||||
|
||||
stream = await loop.run_in_executor(None, _sync_stream)
|
||||
for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
def get_llm_adapter() -> VertexAILangchainAdapter:
|
||||
return VertexAILangchainAdapter()
|
||||
@@ -0,0 +1,40 @@
|
||||
import redis
|
||||
import logging
|
||||
from backend.implementation import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisClient:
|
||||
"""
|
||||
Singleton Redis client for managing session state and consult_mode.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RedisClient, cls).__new__(cls)
|
||||
try:
|
||||
cls._instance.client = redis.Redis(
|
||||
host=config.REDIS_HOST,
|
||||
port=config.REDIS_PORT,
|
||||
db=config.REDIS_DB,
|
||||
decode_responses=True
|
||||
)
|
||||
logger.info("Connected to Redis at %s:%s", config.REDIS_HOST, config.REDIS_PORT)
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Redis: %s", e)
|
||||
cls._instance.client = None
|
||||
return cls._instance
|
||||
|
||||
def get(self, key: str):
|
||||
return self.client.get(key) if self.client else None
|
||||
|
||||
def set(self, key: str, value: str, ex: int = None):
|
||||
if self.client:
|
||||
self.client.set(key, value, ex=ex)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
return bool(self.client.exists(key)) if self.client else False
|
||||
|
||||
def get_redis_client() -> RedisClient:
|
||||
return RedisClient()
|
||||
@@ -0,0 +1,121 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
|
||||
class TritonAdapter:
|
||||
def __init__(self, endpoint_url: str, timeout: float = 60.0):
|
||||
self.endpoint_url = endpoint_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
async def infer(
|
||||
self, model_name: str, inputs: dict, outputs: list[str] | None = None
|
||||
) -> dict:
|
||||
return await asyncio.to_thread(
|
||||
self._infer_sync, model_name, inputs, outputs
|
||||
)
|
||||
|
||||
def _infer_sync(
|
||||
self, model_name: str, inputs: dict, outputs: list[str] | None = None
|
||||
) -> dict:
|
||||
metadata_inputs = []
|
||||
binary_chunks = []
|
||||
|
||||
for name, spec in inputs.items():
|
||||
data = spec["data"]
|
||||
shape = spec.get("shape", [])
|
||||
datatype = spec.get("datatype", "FP32")
|
||||
|
||||
arr = np.asarray(data, dtype=np.float32)
|
||||
binary = arr.tobytes()
|
||||
|
||||
metadata_inputs.append(
|
||||
{
|
||||
"name": name,
|
||||
"shape": shape,
|
||||
"datatype": datatype,
|
||||
"parameters": {"binary_data_size": len(binary)},
|
||||
}
|
||||
)
|
||||
binary_chunks.append(binary)
|
||||
|
||||
metadata_outputs = [{"name": o} for o in (outputs or [])]
|
||||
metadata = {
|
||||
"inputs": metadata_inputs,
|
||||
"outputs": metadata_outputs,
|
||||
}
|
||||
|
||||
metadata_bytes = json.dumps(metadata).encode("utf-8")
|
||||
body = metadata_bytes + b"".join(binary_chunks)
|
||||
|
||||
headers = {
|
||||
"Inference-Header-Content-Length": str(len(metadata_bytes)),
|
||||
"Content-Type": "application/octet-stream",
|
||||
}
|
||||
|
||||
url = f"{self.endpoint_url}/v2/models/{model_name}/infer"
|
||||
response = requests.post(url, data=body, headers=headers, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return self._parse_binary_response(response.headers, response.content)
|
||||
|
||||
@staticmethod
|
||||
def _parse_binary_response(headers: dict, body: bytes) -> dict:
|
||||
header_len = int(headers.get("Inference-Header-Content-Length", "0"))
|
||||
metadata = json.loads(body[:header_len].decode("utf-8"))
|
||||
|
||||
result = {}
|
||||
offset = 0
|
||||
for output in metadata.get("outputs", []):
|
||||
name = output["name"]
|
||||
shape = output.get("shape", [])
|
||||
params = output.get("parameters", {})
|
||||
binary_size = params.get("binary_data_size", 0)
|
||||
|
||||
if binary_size > 0:
|
||||
chunk = body[header_len + offset : header_len + offset + binary_size]
|
||||
arr = np.frombuffer(chunk, dtype=np.float32).reshape(shape)
|
||||
result[name] = arr.tolist()
|
||||
offset += binary_size
|
||||
|
||||
return result
|
||||
|
||||
async def model_ready(self, model_name: str) -> bool:
|
||||
return await asyncio.to_thread(self._model_ready_sync, model_name)
|
||||
|
||||
def _model_ready_sync(self, model_name: str) -> bool:
|
||||
url = f"{self.endpoint_url}/v2/models/{model_name}"
|
||||
response = requests.get(url, timeout=self.timeout)
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("ready", False)
|
||||
|
||||
async def list_models(self) -> list[dict]:
|
||||
return await asyncio.to_thread(self._list_models_sync)
|
||||
|
||||
# def _list_models_sync(self) -> list[dict]:
|
||||
# url = f"{self.endpoint_url}/v2/models"
|
||||
# response = requests.get(url, timeout=self.timeout)
|
||||
# response.raise_for_status()
|
||||
# data = response.json()
|
||||
# return data.get("models", [])
|
||||
|
||||
def _list_models_sync(self) -> list[dict]:
|
||||
# 1. Change the endpoint to Triton's repository index path
|
||||
url = f"{self.endpoint_url}/v2/repository/index"
|
||||
|
||||
# 2. Change requests.get to requests.post with an empty json payload {}
|
||||
response = requests.post(url, json={}, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
# KServe v2 returns a list directly: [{"name": "model_a", "version": "1", "state": "READY"}]
|
||||
return data
|
||||
@@ -0,0 +1,323 @@
|
||||
import asyncio
|
||||
import io
|
||||
import base64
|
||||
import uuid
|
||||
import logging
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from data.spec.schemas import (
|
||||
AnalysisJobSubmit, JobStatus, JobResult, PipelineStep,
|
||||
StepEvent, ModelCatalog, ModelRegistrationResult,
|
||||
HealthStatus,
|
||||
)
|
||||
from PIL import Image
|
||||
|
||||
from backend.implementation.preprocessing.clahe import apply_clahe
|
||||
from backend.implementation.preprocessing.tensor_prep import (
|
||||
prepare_angle_tensor,
|
||||
prepare_inflammation_tensor,
|
||||
prepare_segmentation_tensor,
|
||||
)
|
||||
from backend.implementation.postprocessing.measurement import calculate_thickness
|
||||
from backend.implementation.postprocessing.severity import calculate_severity
|
||||
from backend.implementation.postprocessing.overlay import create_overlay
|
||||
from backend.implementation.postprocessing.calibration import (
|
||||
calibration_config_from_params,
|
||||
interpret_angle_logits,
|
||||
interpret_inflammation_logits,
|
||||
)
|
||||
from backend.implementation.config import (
|
||||
get_model_name,
|
||||
get_segmentation_model,
|
||||
get_angle_type,
|
||||
TRITON_ENDPOINT,
|
||||
)
|
||||
from backend.implementation.adapters.triton_adapter import TritonAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_job_registry: dict[str, dict] = {}
|
||||
_job_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _interpret_angle_result(result: dict, params: dict | None = None) -> dict:
|
||||
logits = result.get("logits", [])
|
||||
if not logits:
|
||||
raise ValueError("Empty angle logits")
|
||||
config = calibration_config_from_params(params)
|
||||
return interpret_angle_logits(logits, config)
|
||||
|
||||
|
||||
def _interpret_inflammation_result(result: dict, params: dict | None = None) -> dict:
|
||||
logits = result.get("logits", [])
|
||||
if not logits:
|
||||
raise ValueError("Empty inflammation logits")
|
||||
config = calibration_config_from_params(params)
|
||||
return interpret_inflammation_logits(logits, config)
|
||||
|
||||
|
||||
def _process_segmentation_result(result: dict, angle_class: str) -> tuple:
|
||||
logits = result.get("logits", [])
|
||||
if not logits:
|
||||
raise ValueError("Empty segmentation logits")
|
||||
logits_arr = np.array(logits)
|
||||
if logits_arr.ndim < 3:
|
||||
raise ValueError("Unexpected segmentation output shape")
|
||||
preds = logits_arr.argmax(axis=1)[0]
|
||||
angle_type = get_angle_type(angle_class)
|
||||
if angle_type == "sup":
|
||||
class_map = {
|
||||
0: "background", 1: "effusion", 2: "fat", 3: "fat-pat",
|
||||
4: "femur", 5: "synovium", 6: "tendon",
|
||||
}
|
||||
else:
|
||||
class_map = {
|
||||
0: "background", 1: "fat", 2: "tendon", 3: "muscle",
|
||||
4: "femur", 5: "artery", 6: "baker's cyst",
|
||||
}
|
||||
masks = {}
|
||||
for class_id, class_name in class_map.items():
|
||||
masks[class_name] = (preds == class_id).astype(np.uint8)
|
||||
return preds, masks
|
||||
|
||||
|
||||
async def _get_triton_adapter() -> TritonAdapter:
|
||||
return TritonAdapter(endpoint_url=TRITON_ENDPOINT)
|
||||
|
||||
|
||||
def _encode_image_to_base64(image_pil: Image.Image) -> str:
|
||||
buffered = io.BytesIO()
|
||||
image_pil.save(buffered, format="PNG")
|
||||
return base64.b64encode(buffered.getvalue()).decode()
|
||||
|
||||
|
||||
async def _run_pipeline(image_pil: Image.Image, session_id: str, params: dict, model_versions: dict | None = None) -> dict:
|
||||
enhanced_pil = apply_clahe(image_pil)
|
||||
angle_tensor = prepare_angle_tensor(image_pil)
|
||||
inflammation_tensor = prepare_inflammation_tensor(image_pil)
|
||||
segmentation_tensor = prepare_segmentation_tensor(image_pil)
|
||||
|
||||
triton = await _get_triton_adapter()
|
||||
angle_model = get_model_name("angle", model_versions)
|
||||
angle_result = await triton.infer(
|
||||
model_name=angle_model,
|
||||
inputs={"input": {"data": angle_tensor.tolist(), "shape": list(angle_tensor.shape), "datatype": "FP32"}},
|
||||
)
|
||||
angle_interpreted = _interpret_angle_result(angle_result, params)
|
||||
|
||||
result = {
|
||||
"angle": {
|
||||
"class": angle_interpreted["class"],
|
||||
"confidence": angle_interpreted["confidence"],
|
||||
"calibration": angle_interpreted["calibration"],
|
||||
},
|
||||
"models_used": {"angle": angle_model},
|
||||
}
|
||||
|
||||
if angle_interpreted["class"] in ("post-trans", "sup-up-long"):
|
||||
inflam_model = get_model_name("inflammation", model_versions)
|
||||
inflammation_result = await triton.infer(
|
||||
model_name=inflam_model,
|
||||
inputs={"input": {"data": inflammation_tensor.tolist(), "shape": list(inflammation_tensor.shape), "datatype": "FP32"}},
|
||||
)
|
||||
inflammation_interpreted = _interpret_inflammation_result(inflammation_result, params)
|
||||
result["inflammation"] = {
|
||||
"detected": inflammation_interpreted["detected"],
|
||||
"confidence": inflammation_interpreted["confidence"],
|
||||
"calibration": inflammation_interpreted["calibration"],
|
||||
}
|
||||
result["models_used"]["inflammation"] = inflam_model
|
||||
|
||||
if inflammation_interpreted["detected"]:
|
||||
seg_model_name = get_segmentation_model(angle_interpreted["class"], model_versions)
|
||||
seg_result = await triton.infer(
|
||||
model_name=seg_model_name,
|
||||
inputs={"input": {"data": segmentation_tensor.tolist(), "shape": list(segmentation_tensor.shape), "datatype": "FP32"}},
|
||||
)
|
||||
preds, masks = _process_segmentation_result(seg_result, angle_interpreted["class"])
|
||||
angle_type = get_angle_type(angle_interpreted["class"])
|
||||
measurement = calculate_thickness(masks, image_pil.size)
|
||||
severity = calculate_severity(masks, image_pil.size)
|
||||
segmented_overlay = create_overlay(image_pil, masks, measurement, angle_type)
|
||||
|
||||
result.update({
|
||||
"measurement": measurement,
|
||||
"severity": severity,
|
||||
"segmentation": {
|
||||
"performed": True,
|
||||
"classes_detected": [k for k, v in masks.items() if np.sum(v) > 0],
|
||||
"angle_type": angle_type,
|
||||
},
|
||||
"images": {
|
||||
"enhanced": _encode_image_to_base64(enhanced_pil),
|
||||
"segmented": _encode_image_to_base64(segmented_overlay),
|
||||
},
|
||||
})
|
||||
result["models_used"]["segmentation"] = seg_model_name
|
||||
else:
|
||||
from backend.implementation.pipeline.cv_spec_pipeline import (
|
||||
build_segmentation_skipped,
|
||||
build_severity_zero,
|
||||
)
|
||||
|
||||
result["segmentation"] = build_segmentation_skipped("no_inflammation")
|
||||
result["severity"] = build_severity_zero("no_inflammation")
|
||||
result["images"] = {"enhanced": _encode_image_to_base64(enhanced_pil)}
|
||||
else:
|
||||
from backend.implementation.pipeline.cv_spec_pipeline import (
|
||||
build_segmentation_skipped,
|
||||
build_severity_zero,
|
||||
)
|
||||
|
||||
result["segmentation"] = build_segmentation_skipped("angle_only")
|
||||
result["severity"] = build_severity_zero("angle_only")
|
||||
result["images"] = {"enhanced": _encode_image_to_base64(enhanced_pil)}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def submit_sync(session_id: str, params: dict, model_versions: dict | None = None) -> JobResult:
|
||||
if "local_image_path" in params:
|
||||
image_pil = Image.open(params["local_image_path"]).convert("RGB")
|
||||
elif "local_image_bytes" in params:
|
||||
image_pil = Image.open(io.BytesIO(params["local_image_bytes"])).convert("RGB")
|
||||
else:
|
||||
from backend.implementation.session import service as session_service
|
||||
frame_metadata = await session_service.get_frame(session_id, params.get("frame_id"))
|
||||
raise NotImplementedError("S3 frame retrieval not yet integrated; use local_image_path for testing")
|
||||
|
||||
pipeline_result = await _run_pipeline(image_pil, session_id, params, model_versions)
|
||||
job_id = str(uuid.uuid4())
|
||||
return JobResult(
|
||||
job_id=job_id,
|
||||
session_id=session_id,
|
||||
status="completed",
|
||||
result=pipeline_result,
|
||||
duration_ms=0,
|
||||
)
|
||||
|
||||
|
||||
async def submit_job(session_id: str, params: dict, model_versions: dict | None = None) -> str:
|
||||
job_id = str(uuid.uuid4())
|
||||
async with _job_lock:
|
||||
_job_registry[job_id] = {
|
||||
"session_id": session_id,
|
||||
"params": params,
|
||||
"model_versions": model_versions,
|
||||
"status": "queued",
|
||||
"result": None,
|
||||
"steps": [],
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
|
||||
async def _background():
|
||||
try:
|
||||
await push_step_event(job_id, {
|
||||
"step_id": str(uuid.uuid4()),
|
||||
"job_id": job_id,
|
||||
"event_type": "progress",
|
||||
"task_type": "analysis",
|
||||
"status": "running",
|
||||
})
|
||||
async with _job_lock:
|
||||
_job_registry[job_id]["status"] = "running"
|
||||
result = await submit_sync(session_id, params, model_versions)
|
||||
async with _job_lock:
|
||||
_job_registry[job_id].update({
|
||||
"status": "completed",
|
||||
"result": result.model_dump(),
|
||||
})
|
||||
await push_step_event(job_id, {
|
||||
"step_id": str(uuid.uuid4()),
|
||||
"job_id": job_id,
|
||||
"event_type": "completed",
|
||||
"task_type": "analysis",
|
||||
"status": "completed",
|
||||
"data": {"job_result": result.model_dump()},
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception(f"Job {job_id} failed")
|
||||
async with _job_lock:
|
||||
_job_registry[job_id].update({
|
||||
"status": "failed",
|
||||
"result": {"error": str(exc)},
|
||||
})
|
||||
await push_step_event(job_id, {
|
||||
"step_id": str(uuid.uuid4()),
|
||||
"job_id": job_id,
|
||||
"event_type": "failed",
|
||||
"task_type": "analysis",
|
||||
"status": "failed",
|
||||
"data": {"error": str(exc)},
|
||||
})
|
||||
|
||||
asyncio.create_task(_background())
|
||||
return job_id
|
||||
|
||||
|
||||
async def job_status(job_id: str) -> JobStatus:
|
||||
async with _job_lock:
|
||||
job = _job_registry.get(job_id)
|
||||
if not job:
|
||||
raise LookupError(f"Job {job_id} not found")
|
||||
return JobStatus(
|
||||
job_id=job_id,
|
||||
session_id=job["session_id"],
|
||||
status=job["status"],
|
||||
result=job.get("result"),
|
||||
steps=job.get("steps", []),
|
||||
created_at=job["created_at"],
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
async def job_steps(job_id: str) -> list[PipelineStep]:
|
||||
async with _job_lock:
|
||||
job = _job_registry.get(job_id)
|
||||
if not job:
|
||||
raise LookupError(f"Job {job_id} not found")
|
||||
return job.get("steps", [])
|
||||
|
||||
|
||||
async def list_registered_models() -> ModelCatalog:
|
||||
return ModelCatalog(models=[], total=0)
|
||||
|
||||
|
||||
async def register_model(model_id: str, file: Any) -> ModelRegistrationResult:
|
||||
raise NotImplementedError("Model registration not yet implemented")
|
||||
|
||||
|
||||
async def health() -> HealthStatus:
|
||||
try:
|
||||
triton = await _get_triton_adapter()
|
||||
models = await triton.list_models()
|
||||
ready = any(m.get("state") == "READY" for m in models)
|
||||
status = "ok" if ready else "degraded"
|
||||
return HealthStatus(
|
||||
status=status,
|
||||
version="0.1.0",
|
||||
dependencies={"triton": str(ready)},
|
||||
uptime_seconds=0.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Health check failed: {exc}")
|
||||
return HealthStatus(status="error", version="0.1.0", dependencies={"triton": "False"}, uptime_seconds=0.0)
|
||||
|
||||
|
||||
async def push_step_event(job_id: str, event: dict) -> None:
|
||||
from backend.api.analysis_api import _event_queues, _queue_lock
|
||||
async with _queue_lock:
|
||||
if job_id not in _event_queues:
|
||||
_event_queues[job_id] = asyncio.Queue()
|
||||
step_event = StepEvent(
|
||||
step_id=event.get("step_id", ""),
|
||||
job_id=job_id,
|
||||
event_type=event.get("event_type", "progress"),
|
||||
task_type=event.get("task_type", ""),
|
||||
status=event.get("status", "running"),
|
||||
data=event.get("data"),
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
await _event_queues[job_id].put(step_event)
|
||||
@@ -0,0 +1,21 @@
|
||||
from data.spec.schemas import LoginRequest, Token, UserProfile, UserUpdateRequest
|
||||
|
||||
|
||||
async def login(username: str, password: str) -> Token:
|
||||
raise NotImplementedError("Auth service not yet implemented")
|
||||
|
||||
|
||||
async def logout(token: str) -> None:
|
||||
raise NotImplementedError("Auth service not yet implemented")
|
||||
|
||||
|
||||
async def refresh(refresh_token: str) -> Token:
|
||||
raise NotImplementedError("Auth service not yet implemented")
|
||||
|
||||
|
||||
async def me(token: str) -> UserProfile:
|
||||
raise NotImplementedError("Auth service not yet implemented")
|
||||
|
||||
|
||||
async def update_me(token: str, updates: dict) -> UserProfile:
|
||||
raise NotImplementedError("Auth service not yet implemented")
|
||||
@@ -0,0 +1,75 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
SECRETS_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent.parent / "secrets"
|
||||
|
||||
def _load_secret(name: str, filename: str) -> str:
|
||||
file_path = SECRETS_DIR / filename
|
||||
env_file = os.getenv(f"{name}_FILE")
|
||||
if env_file:
|
||||
resolved = Path(env_file)
|
||||
if resolved.exists():
|
||||
with open(resolved, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
raise RuntimeError(
|
||||
f"Required secret {name} not found at {file_path} or via {name}_FILE env var"
|
||||
)
|
||||
|
||||
# Endpoints (environment-provided, no hardcoded fallback for production)
|
||||
MODAL_MEDGEMMA_ENDPOINT = os.getenv("MODAL_MEDGEMMA_ENDPOINT")
|
||||
VERTEX_AI_GEMINI_ENDPOINT = os.getenv("VERTEX_AI_GEMINI_ENDPOINT")
|
||||
|
||||
# Secrets (must be present in PILOT_PROJECT/secrets or env)
|
||||
GCP_ACCESS_TOKEN = _load_secret("GCP_ACCESS_TOKEN", "gcp_access_token.txt")
|
||||
MEDGEMMA_API_KEY = _load_secret("MEDGEMMA_API_KEY", "modal_api_key.txt")
|
||||
|
||||
PROJECT_ID = os.getenv("VERTEX_AI_PROJECT", "vkist-project")
|
||||
LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
|
||||
|
||||
TRITON_ENDPOINT = os.getenv("TRITON_ENDPOINT", "http://localhost:8000")
|
||||
TEMP_DIR = os.getenv("TEMP_DIR", "/tmp/analysis_jobs")
|
||||
|
||||
# LLM Configuration
|
||||
VERTEX_AI_PROJECT = os.getenv("VERTEX_AI_PROJECT", "vkist-project")
|
||||
VERTEX_AI_LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
|
||||
VERTEX_AI_MODEL = os.getenv("VERTEX_AI_MODEL", "medgemma")
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||
REDIS_DB = int(os.getenv("REDIS_DB", "0"))
|
||||
|
||||
DEFAULT_MODEL_VERSIONS = {
|
||||
"angle": "angle_classify_convnext_tiny",
|
||||
"inflammation": "inflammation_model_efficientnet_b0_ultrasound_2_cls",
|
||||
"segmentation_sup": "segmentation_model_unet_resnet101",
|
||||
"segmentation_post": "segmentation_model_post_deeplabv3_resnet101",
|
||||
}
|
||||
|
||||
CLAHE_CLIP_LIMIT = float(os.getenv("CLAHE_CLIP_LIMIT", "2.0"))
|
||||
CLAHE_TILE_SIZE = tuple(int(x) for x in os.getenv("CLAHE_TILE_SIZE", "8,8").split(","))
|
||||
|
||||
|
||||
def get_model_name(task: str, model_versions: Dict[str, str] | None = None) -> str:
|
||||
if model_versions and task in model_versions:
|
||||
return model_versions[task]
|
||||
return DEFAULT_MODEL_VERSIONS.get(task, task)
|
||||
|
||||
|
||||
def get_angle_type(angle_class: str) -> str:
|
||||
if angle_class in ("sup-trans-flex", "sup-up-long"):
|
||||
return "sup"
|
||||
if angle_class == "post-trans":
|
||||
return "post"
|
||||
return "other"
|
||||
|
||||
|
||||
def get_segmentation_model(angle_class: str, model_versions: Dict[str, str] | None = None) -> str:
|
||||
angle_type = get_angle_type(angle_class)
|
||||
task = "segmentation_sup" if angle_type == "sup" else "segmentation_post"
|
||||
return get_model_name(task, model_versions)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from data.spec.schemas import IngestionRecord, RecordDetail
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def list_records(user_id: str) -> list[IngestionRecord]:
|
||||
raise NotImplementedError("Ingestion history service not yet implemented")
|
||||
|
||||
|
||||
async def get_record(record_id: str) -> RecordDetail:
|
||||
raise NotImplementedError("Ingestion history service not yet implemented")
|
||||
@@ -0,0 +1,13 @@
|
||||
from data.spec.schemas import NotificationItem, NotificationPreferences
|
||||
|
||||
|
||||
async def list_notifications(user_id: str, filters: dict | None = None) -> list[NotificationItem]:
|
||||
raise NotImplementedError("Notification service not yet implemented")
|
||||
|
||||
|
||||
async def mark_read(notification_id: str) -> None:
|
||||
raise NotImplementedError("Notification service not yet implemented")
|
||||
|
||||
|
||||
async def set_preferences(user_id: str, prefs: dict) -> None:
|
||||
raise NotImplementedError("Notification service not yet implemented")
|
||||
@@ -0,0 +1,21 @@
|
||||
from data.spec.schemas import Patient, PatientCreate, PatientListResponse
|
||||
|
||||
|
||||
async def list_patients(user_id: str) -> list[Patient]:
|
||||
raise NotImplementedError("Patient service not yet implemented")
|
||||
|
||||
|
||||
async def create_patient(data: dict) -> Patient:
|
||||
raise NotImplementedError("Patient service not yet implemented")
|
||||
|
||||
|
||||
async def get_patient(patient_id: str) -> Patient:
|
||||
raise NotImplementedError("Patient service not yet implemented")
|
||||
|
||||
|
||||
async def list_sessions(patient_id: str) -> list[dict]:
|
||||
raise NotImplementedError("Patient service not yet implemented")
|
||||
|
||||
|
||||
async def ingestion_history(patient_id: str) -> list[dict]:
|
||||
raise NotImplementedError("Patient service not yet implemented")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""CV inference orchestration (Sprint 1–2 spec)."""
|
||||
|
||||
from backend.implementation.pipeline.cv_spec_pipeline import (
|
||||
BRANCH_ANGLE_CLASSES,
|
||||
build_segmentation_skipped,
|
||||
build_severity_zero,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BRANCH_ANGLE_CLASSES",
|
||||
"build_segmentation_skipped",
|
||||
"build_severity_zero",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared CV pipeline helpers — Sprint 1–2 architecture spec §7."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Angles that may run inflammation → conditional segmentation.
|
||||
BRANCH_ANGLE_CLASSES = frozenset({"post-trans", "sup-up-long"})
|
||||
|
||||
|
||||
def build_severity_zero(reason: str) -> dict:
|
||||
descriptions = {
|
||||
"angle_only": "Góc quét không yêu cầu phân đoạn viêm",
|
||||
"no_inflammation": "Không phát hiện viêm — bỏ qua phân đoạn",
|
||||
}
|
||||
return {
|
||||
"level": 0,
|
||||
"severity": "Rất nhẹ",
|
||||
"color": "#28a745",
|
||||
"description": descriptions.get(reason, "Không phân đoạn"),
|
||||
"effusion": {"pixels": 0, "ratio": 0.0, "thickness": 0},
|
||||
"synovium": {"pixels": 0, "ratio": 0.0},
|
||||
"combined_score": 0.0,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def build_segmentation_skipped(reason: str) -> dict:
|
||||
notes = {
|
||||
"angle_only": "Chỉ phân loại góc — med-lat / sup-trans-flex",
|
||||
"no_inflammation": "Không phát hiện viêm — bỏ qua phân đoạn",
|
||||
}
|
||||
return {
|
||||
"performed": False,
|
||||
"reason": reason,
|
||||
"note": notes.get(reason, reason),
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Temperature-scaled softmax, entropy guardrails, and risk-first prediction payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
ANGLE_CLASSES = ["med-lat", "post-trans", "sup-trans-flex", "sup-up-long"]
|
||||
INFLAMMATION_CLASSES = ["no_inflammation", "inflammation"]
|
||||
CALIBRATION_TIERS = frozenset({"aggressive", "standard", "conservative"})
|
||||
# Legacy API aliases
|
||||
CALIBRATION_MODES = CALIBRATION_TIERS | frozenset({"screening", "diagnostic"})
|
||||
|
||||
TIER_RECOMMENDED_T = {
|
||||
"aggressive": 0.7,
|
||||
"standard": 1.4,
|
||||
"conservative": 2.2,
|
||||
# legacy → tier
|
||||
"screening": 2.2,
|
||||
"diagnostic": 1.4,
|
||||
}
|
||||
|
||||
TIER_BOUNDARY_AGGRESSIVE_MAX = (0.7 + 1.4) / 2
|
||||
TIER_BOUNDARY_STANDARD_MAX = (1.4 + 2.2) / 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationConfig:
|
||||
"""User-adjustable calibration context (maps to UI mode / clinical prior)."""
|
||||
|
||||
temperature: float = 1.4
|
||||
mode: str = "standard"
|
||||
clinical_suspicion: float = 0.0
|
||||
alpha_margin: float = 0.05
|
||||
ood_entropy_threshold: float = 0.88
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.clinical_suspicion = float(np.clip(self.clinical_suspicion, 0.0, 1.0))
|
||||
if self.mode not in CALIBRATION_TIERS:
|
||||
if self.mode in ("screening",):
|
||||
self.mode = "conservative"
|
||||
elif self.mode in ("diagnostic",):
|
||||
self.mode = "standard"
|
||||
else:
|
||||
self.mode = "standard"
|
||||
if self.temperature <= 0:
|
||||
self.temperature = 1.0
|
||||
|
||||
|
||||
def logits_to_array(logits: Any) -> np.ndarray:
|
||||
arr = np.asarray(logits, dtype=np.float32).reshape(-1)
|
||||
if arr.size == 0:
|
||||
raise ValueError("Empty logits")
|
||||
return arr
|
||||
|
||||
|
||||
def resolve_tier_from_temperature(temperature: float) -> str:
|
||||
if temperature <= TIER_BOUNDARY_AGGRESSIVE_MAX:
|
||||
return "aggressive"
|
||||
if temperature <= TIER_BOUNDARY_STANDARD_MAX:
|
||||
return "standard"
|
||||
return "conservative"
|
||||
|
||||
|
||||
def effective_temperature(config: CalibrationConfig) -> float:
|
||||
if config.temperature > 0:
|
||||
return max(0.25, float(config.temperature))
|
||||
return TIER_RECOMMENDED_T.get(config.mode, 1.4)
|
||||
|
||||
|
||||
def temperature_scaled_softmax(logits: np.ndarray, temperature: float) -> np.ndarray:
|
||||
scaled = logits / max(temperature, 1e-6)
|
||||
shifted = scaled - np.max(scaled)
|
||||
exp = np.exp(shifted)
|
||||
return exp / np.sum(exp)
|
||||
|
||||
|
||||
def shannon_entropy(probs: np.ndarray) -> float:
|
||||
safe = np.clip(probs, 1e-12, 1.0)
|
||||
return float(-np.sum(safe * np.log(safe)))
|
||||
|
||||
|
||||
def normalized_entropy(probs: np.ndarray) -> float:
|
||||
if probs.size <= 1:
|
||||
return 0.0
|
||||
return shannon_entropy(probs) / float(np.log(probs.size))
|
||||
|
||||
|
||||
def ambiguous_class_set(probs: np.ndarray, class_names: list[str], alpha_margin: float) -> list[str]:
|
||||
idx_sorted = np.argsort(probs)[::-1]
|
||||
max_prob = float(probs[idx_sorted[0]])
|
||||
return [class_names[i] for i in idx_sorted if float(probs[i]) >= max_prob - alpha_margin]
|
||||
|
||||
|
||||
def estimate_misclassification_rate(max_prob: float) -> float:
|
||||
"""Placeholder empirical mapping until validation-set calibration bins are wired."""
|
||||
if max_prob >= 0.95:
|
||||
return 0.05
|
||||
if max_prob >= 0.90:
|
||||
return 0.08
|
||||
if max_prob >= 0.85:
|
||||
return 0.12
|
||||
if max_prob >= 0.75:
|
||||
return 0.18
|
||||
if max_prob >= 0.65:
|
||||
return 0.25
|
||||
return 0.35
|
||||
|
||||
|
||||
def _risk_framing_vi(
|
||||
predicted_class: str,
|
||||
class_names: list[str],
|
||||
probs: np.ndarray,
|
||||
decision_state: str,
|
||||
error_rate: float,
|
||||
ambiguous_set: list[str],
|
||||
norm_entropy: float,
|
||||
) -> str:
|
||||
if decision_state == "ood_warning":
|
||||
return (
|
||||
"Mô hình chưa được huấn luyện với loại ảnh tương tự, nên kết quả AI có thể không đáng tin. "
|
||||
"Hãy kiểm tra chất lượng ảnh và đối chiếu lâm sàng trước khi dựa vào nhãn tự động."
|
||||
)
|
||||
if decision_state == "ambiguous":
|
||||
alt = ", ".join(c for c in ambiguous_set if c != predicted_class)
|
||||
return (
|
||||
f"Dự đoán chính: {predicted_class}. Tập mơ hồ (α): {', '.join(ambiguous_set)}"
|
||||
+ (f" — các lựa chọn khả dĩ gồm {alt}." if alt else ".")
|
||||
+ " Cần đối chiếu lâm sàng trước khi khóa kết quả."
|
||||
)
|
||||
return (
|
||||
f"Dự đoán: {predicted_class}. "
|
||||
f"Trong các ca có phân bố thống kê tương tự, tỷ lệ phân loại sai ước tính ~{error_rate * 100:.0f}% "
|
||||
f"(entropy chuẩn hóa {norm_entropy:.2f})."
|
||||
)
|
||||
|
||||
|
||||
def decision_state_from(probs: np.ndarray, norm_entropy: float, config: CalibrationConfig) -> str:
|
||||
if norm_entropy >= config.ood_entropy_threshold:
|
||||
return "ood_warning"
|
||||
ambiguous = ambiguous_class_set(probs, [str(i) for i in range(probs.size)], config.alpha_margin)
|
||||
if len(ambiguous) > 1:
|
||||
return "ambiguous"
|
||||
return "confident"
|
||||
|
||||
|
||||
def interpret_classification_logits(
|
||||
logits: Any,
|
||||
class_names: list[str],
|
||||
config: CalibrationConfig | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if len(class_names) == 0:
|
||||
raise ValueError("class_names must not be empty")
|
||||
|
||||
cfg = config or CalibrationConfig()
|
||||
arr = logits_to_array(logits)
|
||||
if arr.size != len(class_names):
|
||||
raise ValueError(f"Expected {len(class_names)} logits, got {arr.size}")
|
||||
|
||||
temperature = effective_temperature(cfg)
|
||||
tier = resolve_tier_from_temperature(temperature)
|
||||
probs = temperature_scaled_softmax(arr, temperature)
|
||||
pred_idx = int(np.argmax(probs))
|
||||
predicted_class = class_names[pred_idx]
|
||||
max_prob = float(probs[pred_idx])
|
||||
entropy = shannon_entropy(probs)
|
||||
norm_entropy = normalized_entropy(probs)
|
||||
ambiguous = ambiguous_class_set(probs, class_names, cfg.alpha_margin)
|
||||
state = decision_state_from(probs, norm_entropy, cfg)
|
||||
error_rate = estimate_misclassification_rate(max_prob)
|
||||
|
||||
risk_vi = _risk_framing_vi(
|
||||
predicted_class,
|
||||
class_names,
|
||||
probs,
|
||||
state,
|
||||
error_rate,
|
||||
ambiguous,
|
||||
norm_entropy,
|
||||
)
|
||||
|
||||
class_probabilities = {
|
||||
name: round(float(probs[i]) * 100, 2) for i, name in enumerate(class_names)
|
||||
}
|
||||
|
||||
return {
|
||||
"class": predicted_class,
|
||||
"confidence": round(max_prob * 100, 2),
|
||||
"calibration": {
|
||||
"raw_logits": [round(float(x), 6) for x in arr.tolist()],
|
||||
"temperature": round(temperature, 4),
|
||||
"base_temperature": cfg.temperature,
|
||||
"mode": tier,
|
||||
"clinical_suspicion": round(cfg.clinical_suspicion, 3),
|
||||
"alpha_margin": cfg.alpha_margin,
|
||||
"class_probabilities": class_probabilities,
|
||||
"entropy": round(entropy, 4),
|
||||
"normalized_entropy": round(norm_entropy, 4),
|
||||
"ambiguous_set": ambiguous,
|
||||
"decision_state": state,
|
||||
"predicted_error_rate": round(error_rate, 4),
|
||||
"risk_framing_vi": risk_vi,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def interpret_angle_logits(logits: Any, config: CalibrationConfig | None = None) -> dict[str, Any]:
|
||||
return interpret_classification_logits(logits, ANGLE_CLASSES, config)
|
||||
|
||||
|
||||
def interpret_inflammation_logits(logits: Any, config: CalibrationConfig | None = None) -> dict[str, Any]:
|
||||
payload = interpret_classification_logits(logits, INFLAMMATION_CLASSES, config)
|
||||
detected = payload["class"] == "inflammation"
|
||||
payload["detected"] = detected
|
||||
return payload
|
||||
|
||||
|
||||
def calibration_config_from_params(params: dict[str, Any] | None) -> CalibrationConfig:
|
||||
if not params:
|
||||
return CalibrationConfig()
|
||||
calibration = params.get("calibration") or {}
|
||||
return CalibrationConfig(
|
||||
temperature=float(calibration.get("temperature", 1.0)),
|
||||
mode=str(calibration.get("mode", "standard")),
|
||||
clinical_suspicion=float(calibration.get("clinical_suspicion", 0.0)),
|
||||
alpha_margin=float(calibration.get("alpha_margin", 0.05)),
|
||||
ood_entropy_threshold=float(calibration.get("ood_entropy_threshold", 0.88)),
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
__all__ = ["calculate_thickness", "get_mask_bounding_box", "find_max_continuous_segment"]
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
SEGMENT_CLASSES_SUPRAPAT = {
|
||||
0: "background", 1: "effusion", 2: "fat", 3: "fat-pat",
|
||||
4: "femur", 5: "synovium", 6: "tendon"
|
||||
}
|
||||
SEGMENT_CLASSES_POST = {
|
||||
0: "background", 1: "fat", 2: "tendon", 3: "muscle",
|
||||
4: "femur", 5: "artery", 6: "baker's cyst"
|
||||
}
|
||||
PIXEL_TO_MM = 45.0 / 655.0
|
||||
|
||||
|
||||
def get_mask_bounding_box(mask, dist_percent: float = 0.01):
|
||||
if mask is None or np.sum(mask) == 0:
|
||||
return None
|
||||
mask_uint8 = mask.astype(np.uint8)
|
||||
if np.max(mask_uint8) == 1:
|
||||
mask_uint8 *= 255
|
||||
img_width = mask_uint8.shape[1]
|
||||
dist_threshold = img_width * dist_percent
|
||||
kernel = np.ones((5, 5), np.uint8)
|
||||
clean_mask = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel)
|
||||
contours, _ = cv2.findContours(clean_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
return None
|
||||
contour_info = sorted(
|
||||
[{"cnt": cnt, "area": cv2.contourArea(cnt)} for cnt in contours],
|
||||
key=lambda x: x["area"], reverse=True,
|
||||
)
|
||||
main_block = contour_info[0]
|
||||
max_area = main_block["area"]
|
||||
if max_area < 50:
|
||||
return None
|
||||
main_mask = np.zeros_like(mask_uint8)
|
||||
cv2.drawContours(main_mask, [main_block["cnt"]], -1, 255, -1)
|
||||
dist_map = cv2.distanceTransform(255 - main_mask, cv2.DIST_L2, 3)
|
||||
significant_contours = [main_block["cnt"]]
|
||||
area_threshold = max_area / 4.0
|
||||
for i in range(1, len(contour_info)):
|
||||
other = contour_info[i]
|
||||
other_mask = np.zeros_like(mask_uint8)
|
||||
cv2.drawContours(other_mask, [other["cnt"]], -1, 255, -1)
|
||||
min_dist = np.min(dist_map[other_mask > 0])
|
||||
if other["area"] >= area_threshold or min_dist <= dist_threshold:
|
||||
significant_contours.append(other["cnt"])
|
||||
all_points = np.concatenate(significant_contours)
|
||||
x, y, w, h = cv2.boundingRect(all_points)
|
||||
return x, y, w, h
|
||||
|
||||
|
||||
def find_max_continuous_segment(col_array):
|
||||
padded = np.concatenate(([0], col_array, [0]))
|
||||
diffs = np.diff(padded)
|
||||
starts = np.where(diffs == 1)[0]
|
||||
ends = np.where(diffs == -1)[0]
|
||||
if len(starts) == 0:
|
||||
return 0, -1, -1
|
||||
lengths = ends - starts
|
||||
max_idx = int(np.argmax(lengths))
|
||||
max_len = int(lengths[max_idx])
|
||||
return max_len, int(starts[max_idx]), int(ends[max_idx])
|
||||
|
||||
|
||||
def calculate_thickness(masks: dict, image_size, measure_ids=None):
|
||||
if measure_ids is None:
|
||||
measure_ids = [1, 5]
|
||||
width, height = image_size
|
||||
mask_all_labels = np.zeros((height, width), dtype=np.uint8)
|
||||
mask_measure = np.zeros((height, width), dtype=np.uint8)
|
||||
has_any_label = False
|
||||
if "fat-pat" in masks:
|
||||
class_map = SEGMENT_CLASSES_SUPRAPAT
|
||||
else:
|
||||
class_map = SEGMENT_CLASSES_POST
|
||||
for class_id, class_name in class_map.items():
|
||||
if class_name not in masks or class_name == "background":
|
||||
continue
|
||||
mask = masks[class_name]
|
||||
if np.sum(mask) > 0:
|
||||
has_any_label = True
|
||||
mask_all_labels = np.logical_or(mask_all_labels, mask).astype(np.uint8)
|
||||
if class_id in measure_ids:
|
||||
mask_measure = np.logical_or(mask_measure, mask).astype(np.uint8)
|
||||
if not has_any_label or np.sum(mask_measure) == 0:
|
||||
return None
|
||||
bbox_all = get_mask_bounding_box(mask_all_labels)
|
||||
if bbox_all is None:
|
||||
return None
|
||||
x_all, y_all, w_all, h_all = bbox_all
|
||||
roi_start = x_all + (w_all // 3)
|
||||
roi_end = x_all + (2 * w_all // 3)
|
||||
roi_strip = mask_measure[:, roi_start:roi_end]
|
||||
global_max_len_px = 0
|
||||
best_x_rel = 0
|
||||
best_y_start = 0
|
||||
best_y_end = 0
|
||||
for x in range(roi_strip.shape[1]):
|
||||
col = roi_strip[:, x]
|
||||
if not np.any(col):
|
||||
continue
|
||||
length, y_s, y_e = find_max_continuous_segment(col)
|
||||
if length > global_max_len_px:
|
||||
global_max_len_px = length
|
||||
best_x_rel = x
|
||||
best_y_start = y_s
|
||||
best_y_end = y_e
|
||||
if global_max_len_px == 0:
|
||||
return None
|
||||
thickness_mm = global_max_len_px * PIXEL_TO_MM
|
||||
real_x = roi_start + best_x_rel
|
||||
return {
|
||||
"thickness_px": int(global_max_len_px),
|
||||
"thickness_mm": float(round(thickness_mm, 2)),
|
||||
"x": int(real_x),
|
||||
"y_start": int(best_y_start),
|
||||
"y_end": int(best_y_end),
|
||||
"roi_start": int(roi_start),
|
||||
"roi_end": int(roi_end),
|
||||
"bbox": {"x": int(x_all), "y": int(y_all), "w": int(w_all), "h": int(h_all)},
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
__all__ = ["create_overlay"]
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
COLOR_MAP_SUP = {
|
||||
"background": [0, 0, 0],
|
||||
"effusion": [255, 0, 0],
|
||||
"fat": [255, 255, 0],
|
||||
"fat-pat": [0, 255, 255],
|
||||
"femur": [0, 255, 0],
|
||||
"synovium": [255, 0, 255],
|
||||
"tendon": [0, 0, 255],
|
||||
}
|
||||
|
||||
COLOR_MAP_POST = {
|
||||
"background": [0, 0, 0],
|
||||
"baker's cyst": [255, 0, 0],
|
||||
"fat": [255, 255, 0],
|
||||
"muscle": [0, 255, 255],
|
||||
"femur": [0, 255, 0],
|
||||
"artery": [255, 0, 255],
|
||||
"synovium": [255, 0, 255],
|
||||
"tendon": [0, 0, 255],
|
||||
}
|
||||
|
||||
|
||||
def create_overlay(image_pil: Image.Image, masks: dict, measurement, angle_type: str = "sup") -> Image.Image:
|
||||
if masks is None:
|
||||
return image_pil
|
||||
color_map = COLOR_MAP_SUP if angle_type == "sup" else COLOR_MAP_POST
|
||||
img_array = np.array(image_pil)
|
||||
overlay = img_array.copy()
|
||||
for class_name, mask in masks.items():
|
||||
if class_name in color_map and np.sum(mask) > 0:
|
||||
color = color_map[class_name]
|
||||
for i in range(3):
|
||||
overlay[:, :, i] = np.where(
|
||||
mask > 0,
|
||||
(overlay[:, :, i] * 0.6 + color[i] * 0.4).astype(np.uint8),
|
||||
overlay[:, :, i],
|
||||
)
|
||||
overlay_pil = Image.fromarray(overlay)
|
||||
draw = ImageDraw.Draw(overlay_pil)
|
||||
for class_name in ["effusion", "synovium"]:
|
||||
mask = masks.get(class_name)
|
||||
if mask is not None and np.sum(mask) > 0:
|
||||
mask_uint8 = (mask * 255).astype(np.uint8)
|
||||
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
for contour in contours:
|
||||
points = contour.reshape(-1, 2).tolist()
|
||||
if len(points) > 2:
|
||||
points = [(int(p[0]), int(p[1])) for p in points]
|
||||
draw.line(points + [points[0]], fill=(255, 255, 255), width=3)
|
||||
if measurement and angle_type == "sup":
|
||||
x = measurement["x"]
|
||||
y_start = measurement["y_start"]
|
||||
y_end = measurement["y_end"]
|
||||
thickness_mm = measurement["thickness_mm"]
|
||||
roi_start = measurement["roi_start"]
|
||||
roi_end = measurement["roi_end"]
|
||||
bbox = measurement["bbox"]
|
||||
draw.rectangle(
|
||||
[bbox["x"], bbox["y"], bbox["x"] + bbox["w"], bbox["y"] + bbox["h"]],
|
||||
outline=(0, 255, 0), width=3,
|
||||
)
|
||||
h = image_pil.size[1]
|
||||
draw.line([(roi_start, 0), (roi_start, h)], fill=(0, 255, 255), width=2)
|
||||
draw.line([(roi_end, 0), (roi_end, h)], fill=(0, 255, 255), width=2)
|
||||
draw.line([(x, y_start), (x, y_end)], fill=(255, 0, 0), width=4)
|
||||
radius = 4
|
||||
draw.ellipse([x - radius, y_start - radius, x + radius, y_start + radius],
|
||||
fill=(0, 255, 0), outline=(255, 255, 255), width=2)
|
||||
draw.ellipse([x - radius, y_end - radius, x + radius, y_end + radius],
|
||||
fill=(0, 255, 0), outline=(255, 255, 255), width=2)
|
||||
text = f"{thickness_mm:.2f} mm"
|
||||
try:
|
||||
from PIL import ImageFont
|
||||
font = ImageFont.load_default()
|
||||
bbox_text = draw.textbbox((0, 0), text, font=font)
|
||||
text_w = bbox_text[2] - bbox_text[0]
|
||||
text_h = bbox_text[3] - bbox_text[1]
|
||||
except Exception:
|
||||
text_w, text_h = 100, 20
|
||||
text_x = x + 8
|
||||
text_y = y_start - text_h - 8
|
||||
draw.rectangle([text_x - 2, text_y - 2, text_x + text_w + 2, text_y + text_h + 2], fill=(0, 0, 0))
|
||||
draw.text((text_x, text_y), text, fill=(255, 0, 0))
|
||||
return overlay_pil
|
||||
@@ -0,0 +1,64 @@
|
||||
__all__ = ["calculate_severity"]
|
||||
import numpy as np
|
||||
|
||||
SEVERITY_LEVELS = [
|
||||
(15, 3, "Nặng", "#dc3545", "Dịch khớp dày, màng hoạt dịch tăng sinh rõ"),
|
||||
(8, 2, "Trung bình", "#fd7e14", "Dịch khớp trung bình, màng hoạt dịch tăng sinh vừa"),
|
||||
(3, 1, "Nhẹ", "#ffc107", "Dịch khớp mỏng, màng hoạt dịch tăng sinh nhẹ"),
|
||||
(0, 0, "Rất nhẹ", "#28a745", "Lượng dịch và màng hoạt dịch trong giới hạn bình thường"),
|
||||
]
|
||||
|
||||
|
||||
def calculate_severity(masks: dict, image_size) -> dict | None:
|
||||
if not masks:
|
||||
return None
|
||||
width, height = image_size
|
||||
total_pixels = width * height
|
||||
effusion_mask = masks.get("effusion", np.zeros((height, width), dtype=np.uint8))
|
||||
effusion_pixels = int(np.sum(effusion_mask))
|
||||
effusion_ratio = (effusion_pixels / total_pixels) * 100
|
||||
effusion_thickness = 0
|
||||
if effusion_pixels > 0:
|
||||
rows_with_effusion = np.any(effusion_mask > 0, axis=1)
|
||||
if np.any(rows_with_effusion):
|
||||
effusion_thickness = int(np.sum(rows_with_effusion))
|
||||
synovium_mask = masks.get("synovium", np.zeros((height, width), dtype=np.uint8))
|
||||
synovium_pixels = int(np.sum(synovium_mask))
|
||||
synovium_ratio = (synovium_pixels / total_pixels) * 100
|
||||
effusion_score = min(effusion_thickness / height * 100, 100)
|
||||
synovium_score = synovium_ratio
|
||||
combined_score = effusion_score * 0.6 + synovium_score * 0.4
|
||||
for threshold, level, severity, color, description in SEVERITY_LEVELS:
|
||||
if combined_score > threshold:
|
||||
return {
|
||||
"level": int(level),
|
||||
"severity": severity,
|
||||
"color": color,
|
||||
"description": description,
|
||||
"effusion": {
|
||||
"pixels": effusion_pixels,
|
||||
"ratio": float(round(effusion_ratio, 2)),
|
||||
"thickness": effusion_thickness,
|
||||
},
|
||||
"synovium": {
|
||||
"pixels": synovium_pixels,
|
||||
"ratio": float(round(synovium_ratio, 2)),
|
||||
},
|
||||
"combined_score": float(round(combined_score, 2)),
|
||||
}
|
||||
return {
|
||||
"level": 0,
|
||||
"severity": "Rất nhẹ",
|
||||
"color": "#28a745",
|
||||
"description": "Lượng dịch và màng hoạt dịch trong giới hạn bình thường",
|
||||
"effusion": {
|
||||
"pixels": effusion_pixels,
|
||||
"ratio": float(round(effusion_ratio, 2)),
|
||||
"thickness": effusion_thickness,
|
||||
},
|
||||
"synovium": {
|
||||
"pixels": synovium_pixels,
|
||||
"ratio": float(round(synovium_ratio, 2)),
|
||||
},
|
||||
"combined_score": float(round(combined_score, 2)),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
__all__ = ["apply_clahe"]
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def apply_clahe(image_pil: Image.Image, clip_limit: float = 2.0, tile_grid_size: tuple[int, int] = (8, 8)) -> Image.Image:
|
||||
img_array = np.array(image_pil)
|
||||
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
||||
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
|
||||
enhanced_gray = clahe.apply(gray)
|
||||
enhanced_rgb = cv2.cvtColor(enhanced_gray, cv2.COLOR_GRAY2RGB)
|
||||
return Image.fromarray(enhanced_rgb)
|
||||
@@ -0,0 +1,35 @@
|
||||
__all__ = ["prepare_angle_tensor", "prepare_inflammation_tensor", "prepare_segmentation_tensor"]
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from .transforms import Resize, Normalize
|
||||
|
||||
ANGLE_TRANSFORM = Resize((224, 224))
|
||||
ANGLE_NORMALIZE = Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
||||
|
||||
INFLAMMATION_TRANSFORM = Resize((224, 224))
|
||||
INFLAMMATION_NORMALIZE = Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
||||
|
||||
SEGMENTATION_TRANSFORM = Resize((512, 512))
|
||||
|
||||
|
||||
def _to_nchw(arr_hwc: np.ndarray) -> np.ndarray:
|
||||
arr = arr_hwc.transpose(2, 0, 1)
|
||||
return np.expand_dims(arr, axis=0)
|
||||
|
||||
|
||||
def prepare_angle_tensor(image_pil: Image.Image) -> np.ndarray:
|
||||
img = ANGLE_TRANSFORM(image_pil)
|
||||
arr = ANGLE_NORMALIZE(img)
|
||||
return _to_nchw(arr)
|
||||
|
||||
|
||||
def prepare_inflammation_tensor(image_pil: Image.Image) -> np.ndarray:
|
||||
img = INFLAMMATION_TRANSFORM(image_pil)
|
||||
arr = INFLAMMATION_NORMALIZE(img)
|
||||
return _to_nchw(arr)
|
||||
|
||||
|
||||
def prepare_segmentation_tensor(image_pil: Image.Image) -> np.ndarray:
|
||||
img = SEGMENTATION_TRANSFORM(image_pil)
|
||||
arr = np.asarray(img).astype(np.float32) / 255.0
|
||||
return _to_nchw(arr)
|
||||
@@ -0,0 +1,22 @@
|
||||
__all__ = ["Resize", "Normalize"]
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
|
||||
class Resize:
|
||||
def __init__(self, size: tuple[int, int]):
|
||||
self.size = size
|
||||
|
||||
def __call__(self, image: Image.Image) -> Image.Image:
|
||||
return image.resize(self.size, Image.Resampling.BILINEAR)
|
||||
|
||||
|
||||
class Normalize:
|
||||
def __init__(self, mean: list[float], std: list[float]):
|
||||
self.mean = np.array(mean, dtype=np.float32)
|
||||
self.std = np.array(std, dtype=np.float32)
|
||||
|
||||
def __call__(self, image_pil: Image.Image) -> np.ndarray:
|
||||
arr = np.asarray(image_pil).astype(np.float32) / 255.0
|
||||
arr = (arr - self.mean) / self.std
|
||||
return arr
|
||||
@@ -0,0 +1,148 @@
|
||||
from typing import Any, AsyncGenerator
|
||||
import logging
|
||||
from fastapi import HTTPException, status
|
||||
from data.spec.schemas import (
|
||||
HeatmapResult, RationaleResult, ChatResponse, DriftCheckResult,
|
||||
EvidenceList, ActivationMeta, AnnotationArtifact, EscalationTicket,
|
||||
GuardrailResult, CorrectionRecord,
|
||||
)
|
||||
from backend.implementation.adapters.llm_adapter import get_llm_adapter
|
||||
from backend.implementation.adapters.bert_adapter import get_bert_adapter
|
||||
from backend.implementation.adapters.redis_adapter import get_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
llm_adapter = get_llm_adapter()
|
||||
bert_adapter = get_bert_adapter()
|
||||
redis_client = get_redis_client()
|
||||
|
||||
async def _verify_pre_egress(session_id: str, redaction_hash: str | None = None):
|
||||
"""
|
||||
Enforce NFR-16a Pre-Egress Checklist.
|
||||
"""
|
||||
# 1. Consent Verification
|
||||
consent_key = f"consent:{session_id}"
|
||||
if not redis_client.exists(consent_key):
|
||||
logger.error(f"Consent missing for session {session_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User consent for cloud LLM egress is required."
|
||||
)
|
||||
|
||||
# 2. Redaction Verification (if hash provided)
|
||||
if redaction_hash:
|
||||
# In real impl: Run Presidio on the prompt and compare hashes
|
||||
# For now, we assume a simple check or stub
|
||||
if redaction_hash == "FAIL_HASH":
|
||||
logger.error(f"Redaction hash mismatch for session {session_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Redaction verification failed. PHI may be present."
|
||||
)
|
||||
|
||||
# Note: Audit Log commit is handled via the LLM adapter's AuditCallbackHandler
|
||||
# to ensure it happens exactly before the call.
|
||||
|
||||
async def gradcam(session_id: str) -> HeatmapResult:
|
||||
raise NotImplementedError("Safety service not yet implemented")
|
||||
|
||||
|
||||
async def rationale(session_id: str, redaction_hash: str | None = None) -> RationaleResult:
|
||||
# Pre-egress check
|
||||
await _verify_pre_egress(session_id, redaction_hash)
|
||||
|
||||
# 1. Fetch session context (simplified for stub)
|
||||
context = {"grade": "moderate", "joint_site": "wrist"}
|
||||
|
||||
# 2. Construct prompt
|
||||
prompt = f"Based on MOH guidelines, explain the synovitis grade {context['grade']} for {context['joint_site']}..."
|
||||
|
||||
# 3. Call LLM adapter
|
||||
text = await llm_adapter.generate(prompt, session_id)
|
||||
|
||||
redis_client.set(f"consult_mode:{session_id}", "tier_3")
|
||||
|
||||
return RationaleResult(text=text)
|
||||
|
||||
|
||||
async def circuit_break(session_id: str, flag: bool) -> None:
|
||||
if flag:
|
||||
logger.warning(f"Circuit breaker triggered for session {session_id}")
|
||||
return None
|
||||
|
||||
|
||||
async def socratic_chat(session_id: str, prompt: str, redaction_hash: str | None = None) -> ChatResponse:
|
||||
# Pre-egress check
|
||||
await _verify_pre_egress(session_id, redaction_hash)
|
||||
|
||||
# 1. Retrieve conversation history (stub)
|
||||
history = []
|
||||
|
||||
# 2. Construct prompt
|
||||
full_prompt = f"History: {history}\nUser: {prompt}\nAssistant: "
|
||||
|
||||
# 3. Call LLM adapter
|
||||
response_text = await llm_adapter.generate(full_prompt, session_id)
|
||||
|
||||
# 4. BERT Referee check (stub)
|
||||
is_grounded = bert_adapter.referee_check(response_text, [])
|
||||
if not is_grounded:
|
||||
response_text = "I'm sorry, I couldn't verify this answer against the guidelines."
|
||||
|
||||
# Post-egress: update consult mode
|
||||
redis_client.set(f"consult_mode:{session_id}", "tier_3")
|
||||
|
||||
return ChatResponse(response=response_text)
|
||||
|
||||
|
||||
async def drift_check(session_id: str) -> DriftCheckResult:
|
||||
res = bert_adapter.drift_check("mock clinical text")
|
||||
return DriftCheckResult(score=res.score, is_drifted=res.is_drifted)
|
||||
|
||||
|
||||
async def rag_evidence(session_id: str) -> EvidenceList:
|
||||
raise NotImplementedError("Safety service not yet implemented")
|
||||
|
||||
|
||||
async def activations(session_id: str, params: dict) -> ActivationMeta:
|
||||
raise NotImplementedError("Safety service not yet implemented")
|
||||
|
||||
|
||||
async def upload_artifact(session_id: str, file: Any) -> AnnotationArtifact:
|
||||
raise NotImplementedError("Safety service not yet implemented")
|
||||
|
||||
|
||||
async def ground_truth(session_id: str, label: dict) -> None:
|
||||
raise NotImplementedError("Safety service not yet implemented")
|
||||
|
||||
|
||||
async def escalate(session_id: str, reason: str) -> EscalationTicket:
|
||||
raise NotImplementedError("Safety service not yet implemented")
|
||||
|
||||
|
||||
async def morphology(session_id: str, annotation: dict) -> None:
|
||||
raise NotImplementedError("Safety service not yet implemented")
|
||||
|
||||
|
||||
async def guardrail_check(session_id: str, prompt: str, score: float) -> GuardrailResult:
|
||||
res = bert_adapter.guardrail_check(prompt)
|
||||
return GuardrailResult(verdict=res.verdict, reason=res.reason)
|
||||
|
||||
|
||||
async def submit_correction(session_id: str, correction: dict) -> CorrectionRecord:
|
||||
raise NotImplementedError("Safety correction service not yet implemented")
|
||||
|
||||
|
||||
async def chat_stream(session_id: str, prompt: str, redaction_hash: str | None = None) -> AsyncGenerator[str, None]:
|
||||
# Pre-egress check
|
||||
await _verify_pre_egress(session_id, redaction_hash)
|
||||
|
||||
async for chunk in llm_adapter.stream_generate(prompt, session_id):
|
||||
res = bert_adapter.guardrail_check(chunk)
|
||||
if res.verdict == "MITIGATE":
|
||||
yield "[Content Filtered]"
|
||||
return
|
||||
yield chunk
|
||||
|
||||
# Post-egress
|
||||
redis_client.set(f"consult_mode:{session_id}", "tier_3")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from data.spec.schemas import (
|
||||
Session, SessionCreate, SessionDetail, SessionPatchReview,
|
||||
FrameMetadata, PersistResult, ExportResult, ScrubResult,
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def create_session(user_id: str, patient_id: str, case_id: str | None = None) -> Session:
|
||||
raise NotImplementedError("Session service not yet implemented")
|
||||
|
||||
|
||||
async def get_session(session_id: str) -> SessionDetail:
|
||||
raise NotImplementedError("Session service not yet implemented")
|
||||
|
||||
|
||||
async def add_frame(session_id: str, file: Any, frame_number: int | None = None) -> FrameMetadata:
|
||||
raise NotImplementedError("Session service not yet implemented")
|
||||
|
||||
|
||||
async def patch_review(session_id: str, review: dict) -> Session:
|
||||
raise NotImplementedError("Session service not yet implemented")
|
||||
|
||||
|
||||
async def persist(session_id: str, review: dict) -> PersistResult:
|
||||
raise NotImplementedError("Session service not yet implemented")
|
||||
|
||||
|
||||
async def export_pdf(session_id: str, params: dict) -> ExportResult:
|
||||
raise NotImplementedError("Session service not yet implemented")
|
||||
|
||||
|
||||
async def scrub_validate(session_id: str, metadata: dict) -> ScrubResult:
|
||||
raise NotImplementedError("Session service not yet implemented")
|
||||
@@ -0,0 +1,9 @@
|
||||
from data.spec.schemas import UserSettings, SettingsUpdate
|
||||
|
||||
|
||||
async def get_settings(user_id: str) -> UserSettings:
|
||||
raise NotImplementedError("Settings service not yet implemented")
|
||||
|
||||
|
||||
async def update_settings(user_id: str, updates: dict) -> UserSettings:
|
||||
raise NotImplementedError("Settings service not yet implemented")
|
||||
@@ -0,0 +1,6 @@
|
||||
from data.spec.schemas import AnomalyRecord
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def report_anomaly(session_id: str, data: dict) -> AnomalyRecord:
|
||||
raise NotImplementedError("Telemetry service not yet implemented")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Triton batching helpers — aligned with config.pbtxt max_batch_size: 8."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator, Sequence
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
TRITON_MAX_BATCH_SIZE = int(os.getenv("TRITON_MAX_BATCH_SIZE", "8"))
|
||||
|
||||
|
||||
def chunk_sequence(items: Sequence[T], batch_size: int | None = None) -> Iterator[list[T]]:
|
||||
"""Split a sequence into chunks of at most ``batch_size`` (default: TRITON_MAX_BATCH_SIZE)."""
|
||||
size = batch_size if batch_size is not None else TRITON_MAX_BATCH_SIZE
|
||||
if size < 1:
|
||||
raise ValueError(f"batch_size must be >= 1, got {size}")
|
||||
for start in range(0, len(items), size):
|
||||
yield list(items[start : start + size])
|
||||
|
||||
|
||||
def batch_count(item_count: int, batch_size: int | None = None) -> int:
|
||||
"""Number of Triton infer calls needed (e.g. 10 images -> 2 batches when size=8)."""
|
||||
if item_count <= 0:
|
||||
return 0
|
||||
size = batch_size if batch_size is not None else TRITON_MAX_BATCH_SIZE
|
||||
return (item_count + size - 1) // size
|
||||
89
workspace/sprint_1_2/CODEBASE/backend/main.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from backend.api import (
|
||||
auth_api,
|
||||
patient_api,
|
||||
session_api,
|
||||
analysis_api,
|
||||
safety_api,
|
||||
notification_api,
|
||||
settings_api,
|
||||
ingestion_api,
|
||||
telemetry_api,
|
||||
)
|
||||
from backend.routers import cloud_orchestrate, cloud_consult, agent_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting medical imaging AI platform API")
|
||||
yield
|
||||
logger.info("Shutting down medical imaging AI platform API")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Medical Imaging & AI Safety Platform",
|
||||
description="Clinical diagnostic imaging platform with AI safety analysis",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.getenv(
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:4173",
|
||||
).split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request, exc):
|
||||
from fastapi.responses import JSONResponse
|
||||
from data.spec.schemas import ErrorResponse
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ErrorResponse(detail=str(exc), code="VALIDATION_ERROR").model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request, exc):
|
||||
from fastapi.responses import JSONResponse
|
||||
from data.spec.schemas import ErrorResponse
|
||||
content = ErrorResponse(detail=exc.detail, code="HTTP_ERROR").model_dump()
|
||||
return JSONResponse(status_code=exc.status_code, content=content)
|
||||
|
||||
|
||||
@app.exception_handler(NotImplementedError)
|
||||
async def not_implemented_handler(request, exc):
|
||||
from fastapi.responses import JSONResponse
|
||||
from data.spec.schemas import ErrorResponse
|
||||
content = ErrorResponse(detail=str(exc), code="NOT_IMPLEMENTED").model_dump()
|
||||
return JSONResponse(status_code=501, content=content)
|
||||
|
||||
|
||||
app.include_router(cloud_orchestrate.router)
|
||||
app.include_router(cloud_consult.router)
|
||||
app.include_router(agent_tools.router)
|
||||
|
||||
app.include_router(auth_api.router)
|
||||
app.include_router(patient_api.router)
|
||||
app.include_router(session_api.router)
|
||||
app.include_router(analysis_api.router)
|
||||
app.include_router(safety_api.router)
|
||||
app.include_router(notification_api.router)
|
||||
app.include_router(settings_api.router)
|
||||
app.include_router(ingestion_api.router)
|
||||
app.include_router(telemetry_api.router)
|
||||
118
workspace/sprint_1_2/CODEBASE/backend/routers/agent_tools.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.services import agent_tools_service
|
||||
from backend.services import embed_service
|
||||
from data.spec.schemas import ErrorResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["agent-tools"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
|
||||
|
||||
|
||||
class ExaSearchRequest(BaseModel):
|
||||
query: str = Field(..., max_length=512)
|
||||
type: str = "auto"
|
||||
numResults: int = Field(default=10, ge=1, le=10)
|
||||
includeDomains: list[str] | None = None
|
||||
excludeDomains: list[str] | None = None
|
||||
session_id: str
|
||||
|
||||
|
||||
class SupabaseQueryRequest(BaseModel):
|
||||
rpc: str
|
||||
args: dict[str, Any] = Field(default_factory=dict)
|
||||
session_id: str
|
||||
|
||||
|
||||
class EmbedRequest(BaseModel):
|
||||
text: str = Field(..., max_length=8192)
|
||||
task: str = "retrieval-query"
|
||||
title: str | None = None
|
||||
|
||||
|
||||
async def _verify_jwt_token_optional(token: str | None = Depends(oauth2_scheme)) -> str | None:
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/tools/exa/search",
|
||||
responses={
|
||||
401: {"model": ErrorResponse},
|
||||
422: {"model": ErrorResponse},
|
||||
502: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def exa_search(
|
||||
body: ExaSearchRequest,
|
||||
user_id: str | None = Depends(_verify_jwt_token_optional),
|
||||
):
|
||||
try:
|
||||
return await agent_tools_service.exa_search(body.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
except httpx.HTTPError as exc:
|
||||
logger.exception("Exa upstream error")
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/embed",
|
||||
responses={
|
||||
401: {"model": ErrorResponse},
|
||||
422: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def embed(
|
||||
body: EmbedRequest,
|
||||
user_id: str | None = Depends(_verify_jwt_token_optional),
|
||||
):
|
||||
task = body.task if body.task in {"retrieval-query", "retrieval-document"} else "retrieval-query"
|
||||
try:
|
||||
return await embed_service.embed_text(body.text, task=task, title=body.title)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/tools/supabase/query",
|
||||
responses={
|
||||
401: {"model": ErrorResponse},
|
||||
422: {"model": ErrorResponse},
|
||||
501: {"model": ErrorResponse},
|
||||
502: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def supabase_query(
|
||||
body: SupabaseQueryRequest,
|
||||
user_id: str | None = Depends(_verify_jwt_token_optional),
|
||||
):
|
||||
try:
|
||||
return await agent_tools_service.supabase_query(body.model_dump())
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel
|
||||
from data.spec.schemas import ErrorResponse
|
||||
from backend.services.cloud_llm_gateway import route_medgemma_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["cloud-consult"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
class ConsultStreamRequest(BaseModel):
|
||||
session_id: str
|
||||
prompt: str
|
||||
task_type: str = "clinical_deep_reasoning"
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cloud-consult",
|
||||
responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 502: {"model": ErrorResponse}},
|
||||
)
|
||||
async def cloud_consult(
|
||||
payload: dict,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await route_medgemma_request(payload, user_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cloud-consult/stream",
|
||||
responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}},
|
||||
)
|
||||
async def cloud_consult_stream(
|
||||
body: ConsultStreamRequest,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
async def generate():
|
||||
async for chunk in route_medgemma_request(
|
||||
{
|
||||
"session_id": body.session_id,
|
||||
"prompt": body.prompt,
|
||||
"task_type": body.task_type,
|
||||
"stream": True,
|
||||
},
|
||||
user_id,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from data.spec.schemas import ErrorResponse
|
||||
from backend.services.cloud_llm_gateway import route_gemini_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["cloud-orchestrate"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def _verify_jwt_token(token: str = Depends(oauth2_scheme)) -> str:
|
||||
try:
|
||||
from backend.api.auth_api import verify_jwt_token as _verify
|
||||
return await _verify(token)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cloud-orchestrate",
|
||||
responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 502: {"model": ErrorResponse}},
|
||||
)
|
||||
async def cloud_orchestrate(
|
||||
payload: dict,
|
||||
user_id: str = Depends(_verify_jwt_token),
|
||||
):
|
||||
try:
|
||||
return await route_gemini_request(payload, user_id)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
223
workspace/sprint_1_2/CODEBASE/backend/routers/cv_inference.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""HTTP routes for spec-compliant CV inference (CLAHE → angle → inflammation → seg)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from PIL import Image
|
||||
|
||||
from backend.implementation.adapters.triton_adapter import TritonAdapter
|
||||
from backend.implementation.config import get_model_name, get_segmentation_model
|
||||
from backend.implementation.postprocessing.calibration import CalibrationConfig, calibration_config_from_params
|
||||
from backend.implementation.triton_batch import TRITON_MAX_BATCH_SIZE
|
||||
from backend.services import cv_result_cache
|
||||
from backend.services import triton_runtime_service as triton_runtime
|
||||
from backend.services.cv_inference_service import CvBatchResult, CvInferenceOptions, run_batch, run_single
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/test", tags=["cv-inference"])
|
||||
|
||||
LEGACY_DEPRECATION_DETAIL = (
|
||||
"This endpoint is deprecated. Use POST /api/test/analyze or POST /api/test/analyze/batch "
|
||||
"for the spec-compliant CV pipeline."
|
||||
)
|
||||
|
||||
|
||||
def _is_image_upload(content_type: str | None, filename: str | None) -> bool:
|
||||
if content_type and content_type.startswith("image/"):
|
||||
return True
|
||||
if content_type in (None, "", "application/octet-stream", "binary/octet-stream"):
|
||||
name = (filename or "").lower()
|
||||
return name.endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"))
|
||||
return False
|
||||
|
||||
|
||||
def _parse_calibration_form(calibration_json: str | None) -> CalibrationConfig:
|
||||
if not calibration_json:
|
||||
return CalibrationConfig()
|
||||
try:
|
||||
data = json.loads(calibration_json)
|
||||
except json.JSONDecodeError:
|
||||
return CalibrationConfig()
|
||||
if not isinstance(data, dict):
|
||||
return CalibrationConfig()
|
||||
return calibration_config_from_params({"calibration": data})
|
||||
|
||||
|
||||
def _default_model_versions() -> dict[str, str] | None:
|
||||
versions: dict[str, str] = {}
|
||||
if angle := os.getenv("ANGLE_MODEL"):
|
||||
versions["angle"] = angle
|
||||
elif os.getenv("CV_USE_CONFIG_ANGLE_MODEL", "").lower() not in {"1", "true", "yes"}:
|
||||
# Match legacy test proxy default for PoC clinical accuracy
|
||||
versions["angle"] = "angle_classify_resnet50"
|
||||
if inflam := os.getenv("INFLAMMATION_MODEL"):
|
||||
versions["inflammation"] = inflam
|
||||
if seg := os.getenv("SEGMENT_MODEL"):
|
||||
versions["segmentation_sup"] = seg
|
||||
versions["segmentation_post"] = seg
|
||||
return versions or None
|
||||
|
||||
|
||||
def _build_options(
|
||||
calibration: CalibrationConfig,
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
) -> CvInferenceOptions:
|
||||
return CvInferenceOptions(
|
||||
calibration=calibration,
|
||||
model_versions=_default_model_versions(),
|
||||
use_cache=use_cache,
|
||||
)
|
||||
|
||||
|
||||
async def _load_upload_image(upload: UploadFile) -> Image.Image:
|
||||
if not _is_image_upload(upload.content_type, upload.filename):
|
||||
raise HTTPException(status_code=400, detail=f"Expected images, got {upload.filename}")
|
||||
try:
|
||||
raw = await upload.read()
|
||||
return Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image {upload.filename}: {exc}") from exc
|
||||
|
||||
|
||||
def _triton_http_error_detail(exc: requests.HTTPError, operation: str) -> str:
|
||||
status = exc.response.status_code if exc.response is not None else 503
|
||||
detail = (
|
||||
f"{operation} failed ({status}). "
|
||||
"Modal server may be cold-starting — retry in a few seconds."
|
||||
)
|
||||
if exc.response is not None and exc.response.text:
|
||||
detail = f"{detail} Server: {exc.response.text[:300]}"
|
||||
return detail
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def cv_inference_health():
|
||||
angle_model = get_model_name("angle", _default_model_versions())
|
||||
inflam_model = get_model_name("inflammation", _default_model_versions())
|
||||
seg_model = get_segmentation_model("sup-up-long", _default_model_versions())
|
||||
triton_endpoint = triton_runtime.get_triton_endpoint()
|
||||
try:
|
||||
adapter = TritonAdapter(endpoint_url=triton_endpoint, timeout=triton_runtime.TRITON_INFER_TIMEOUT)
|
||||
angle_ready = await adapter.model_ready(angle_model)
|
||||
inflam_ready = await adapter.model_ready(inflam_model)
|
||||
seg_ready = await adapter.model_ready(seg_model)
|
||||
status = "ok" if angle_ready and inflam_ready and seg_ready else "degraded"
|
||||
cache = cv_result_cache.cache_stats()
|
||||
return {
|
||||
"status": status,
|
||||
"service": "cv-inference",
|
||||
"triton": triton_endpoint,
|
||||
"angle_model": angle_model,
|
||||
"angle_ready": angle_ready,
|
||||
"inflammation_model": inflam_model,
|
||||
"inflammation_ready": inflam_ready,
|
||||
"segmentation_model": seg_model,
|
||||
"segmentation_ready": seg_ready,
|
||||
"triton_max_batch_size": TRITON_MAX_BATCH_SIZE,
|
||||
"triton_infer_timeout": triton_runtime.TRITON_INFER_TIMEOUT,
|
||||
"triton_infer_retries": triton_runtime.TRITON_INFER_RETRIES,
|
||||
"triton_use_batch_infer": triton_runtime.TRITON_USE_BATCH_INFER,
|
||||
**cache,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception("CV inference health check failed")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "error", "service": "cv-inference", "detail": str(exc), "triton": triton_endpoint},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_upload(
|
||||
image: UploadFile = File(...),
|
||||
calibration: str | None = Form(default=None),
|
||||
):
|
||||
"""Spec-compliant CV pipeline: CLAHE → angle → inflammation → conditional segmentation."""
|
||||
image_pil = await _load_upload_image(image)
|
||||
options = _build_options(_parse_calibration_form(calibration), use_cache=False)
|
||||
|
||||
try:
|
||||
result = await run_single(image_pil, frame_id=None, options=options)
|
||||
return JSONResponse(result)
|
||||
except requests.HTTPError as exc:
|
||||
logger.exception("Analyze pipeline failed (Triton HTTP)")
|
||||
raise HTTPException(status_code=503, detail=_triton_http_error_detail(exc, "Triton analyze pipeline")) from exc
|
||||
except (requests.ConnectionError, requests.Timeout) as exc:
|
||||
logger.exception("Analyze pipeline failed (Triton network)")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Triton unreachable: {exc}. Check TRITON_ENDPOINT and Modal deployment.",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Analyze pipeline failed")
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/analyze/batch")
|
||||
async def analyze_batch_upload(
|
||||
images: list[UploadFile] = File(...),
|
||||
frame_ids: str = Form(...),
|
||||
calibration: str | None = Form(default=None),
|
||||
):
|
||||
"""Spec-compliant CV batch — one full pipeline per frame (angle-first, gated segmentation)."""
|
||||
if not images:
|
||||
raise HTTPException(status_code=400, detail="At least one image is required")
|
||||
|
||||
try:
|
||||
id_list = json.loads(frame_ids)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="frame_ids must be a JSON array of strings") from exc
|
||||
|
||||
if not isinstance(id_list, list) or not all(isinstance(x, str) for x in id_list):
|
||||
raise HTTPException(status_code=400, detail="frame_ids must be a JSON array of strings")
|
||||
if len(id_list) != len(images):
|
||||
raise HTTPException(status_code=400, detail="frame_ids length must match images count")
|
||||
|
||||
image_pils: list[Image.Image] = []
|
||||
for upload in images:
|
||||
image_pils.append(await _load_upload_image(upload))
|
||||
|
||||
options = _build_options(_parse_calibration_form(calibration))
|
||||
|
||||
try:
|
||||
batch: CvBatchResult = await run_batch(image_pils, id_list, options=options)
|
||||
cache = cv_result_cache.cache_stats()
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"image_count": len(batch.results),
|
||||
"pipeline": "spec-cv-v1",
|
||||
"triton_infer_calls": batch.triton_infer_calls,
|
||||
"triton_infer_mode": batch.triton_infer_modes,
|
||||
"pipeline_version": cache["pipeline_version"],
|
||||
"results": batch.results,
|
||||
})
|
||||
except requests.HTTPError as exc:
|
||||
logger.exception("Analyze batch pipeline failed (Triton HTTP)")
|
||||
raise HTTPException(status_code=503, detail=_triton_http_error_detail(exc, "Triton analyze batch")) from exc
|
||||
except (requests.ConnectionError, requests.Timeout) as exc:
|
||||
logger.exception("Analyze batch pipeline failed (Triton network)")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Triton unreachable: {exc}. Check TRITON_ENDPOINT and Modal deployment.",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Analyze batch pipeline failed")
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/segment")
|
||||
@router.post("/segment/batch")
|
||||
@router.post("/angle")
|
||||
@router.post("/angle/batch")
|
||||
@router.post("/inflammation")
|
||||
@router.post("/inflammation/batch")
|
||||
async def legacy_cv_endpoints_deprecated():
|
||||
raise HTTPException(status_code=410, detail=LEGACY_DEPRECATION_DETAIL)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Agent tool BFF services — Exa search and Supabase knowledge queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXA_SEARCH_URL = "https://api.exa.ai/search"
|
||||
EXA_API_KEY = os.getenv("EXA_API_KEY", "").strip()
|
||||
|
||||
SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
|
||||
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip()
|
||||
|
||||
ALLOWED_EXA_TYPES = frozenset(
|
||||
{"auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"}
|
||||
)
|
||||
ALLOWED_SUPABASE_RPC = frozenset({"match_semantic_chunks", "get_corpus_citation"})
|
||||
|
||||
|
||||
def _audit(event: str, session_id: str, payload: dict[str, Any]) -> None:
|
||||
query_hash = payload.get("query_hash")
|
||||
logger.info(
|
||||
"[AUDIT] event=%s session=%s query_hash=%s payload_keys=%s",
|
||||
event,
|
||||
session_id,
|
||||
query_hash,
|
||||
list(payload.keys()),
|
||||
)
|
||||
|
||||
|
||||
def _query_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
async def exa_search(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session_id = str(payload.get("session_id", ""))
|
||||
query = str(payload.get("query", "")).strip()
|
||||
if not query:
|
||||
raise ValueError("query is required")
|
||||
if len(query) > 512:
|
||||
raise ValueError("query exceeds 512 characters")
|
||||
|
||||
search_type = str(payload.get("type", "auto"))
|
||||
if search_type not in ALLOWED_EXA_TYPES:
|
||||
raise ValueError(f"unsupported Exa type: {search_type}")
|
||||
|
||||
num_results = int(payload.get("numResults", 10))
|
||||
num_results = max(1, min(num_results, 10))
|
||||
|
||||
if not EXA_API_KEY:
|
||||
raise RuntimeError("EXA_API_KEY is not configured on the backend")
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"query": query,
|
||||
"type": search_type,
|
||||
"numResults": num_results,
|
||||
"contents": {"highlights": True},
|
||||
}
|
||||
|
||||
include_domains = payload.get("includeDomains")
|
||||
exclude_domains = payload.get("excludeDomains")
|
||||
if include_domains:
|
||||
body["includeDomains"] = include_domains
|
||||
if exclude_domains:
|
||||
body["excludeDomains"] = exclude_domains
|
||||
|
||||
_audit(
|
||||
"exa_search",
|
||||
session_id,
|
||||
{"query_hash": _query_hash(query), "type": search_type, "numResults": num_results},
|
||||
)
|
||||
|
||||
headers = {"x-api-key": EXA_API_KEY, "Content-Type": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(EXA_SEARCH_URL, headers=headers, json=body)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
hits = []
|
||||
for index, row in enumerate(data.get("results", [])):
|
||||
hits.append(
|
||||
{
|
||||
"id": row.get("id") or f"exa-{index}",
|
||||
"url": row.get("url") or "",
|
||||
"title": row.get("title") or row.get("url") or "Untitled",
|
||||
"highlights": row.get("highlights") or [],
|
||||
"publishedDate": row.get("publishedDate"),
|
||||
"score": row.get("score"),
|
||||
}
|
||||
)
|
||||
|
||||
return {"hits": hits, "requestId": data.get("requestId")}
|
||||
|
||||
|
||||
async def supabase_query(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session_id = str(payload.get("session_id", ""))
|
||||
rpc = str(payload.get("rpc", ""))
|
||||
if rpc not in ALLOWED_SUPABASE_RPC:
|
||||
raise ValueError(f"rpc not allowlisted: {rpc}")
|
||||
|
||||
if not SUPABASE_URL or not SUPABASE_SERVICE_ROLE_KEY:
|
||||
raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be configured")
|
||||
|
||||
args = payload.get("args") or {}
|
||||
if rpc == "match_semantic_chunks":
|
||||
query_text = str(args.get("query_text", "")).strip()
|
||||
if not query_text:
|
||||
raise ValueError("args.query_text is required for match_semantic_chunks")
|
||||
|
||||
_audit(
|
||||
"supabase_query",
|
||||
session_id,
|
||||
{"query_hash": _query_hash(query_text), "rpc": rpc},
|
||||
)
|
||||
|
||||
embedding = await _embed_query_text(query_text)
|
||||
rpc_body = {
|
||||
"query_embedding": embedding,
|
||||
"match_count": int(args.get("match_count", 5)),
|
||||
"filter_book_ids": args.get("filter_book_ids"),
|
||||
"filter_edition_ids": args.get("filter_edition_ids"),
|
||||
}
|
||||
else:
|
||||
_audit("supabase_query", session_id, {"rpc": rpc})
|
||||
rpc_body = args
|
||||
|
||||
url = f"{SUPABASE_URL}/rest/v1/rpc/{rpc}"
|
||||
headers = {
|
||||
"apikey": SUPABASE_SERVICE_ROLE_KEY,
|
||||
"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Profile": "knowledge",
|
||||
"Content-Profile": "knowledge",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=headers, json=rpc_body)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text
|
||||
try:
|
||||
detail = json.dumps(response.json())
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"Supabase RPC failed ({response.status_code}): {detail}")
|
||||
rows = response.json()
|
||||
|
||||
normalized_rows = []
|
||||
for row in rows if isinstance(rows, list) else []:
|
||||
normalized_rows.append(
|
||||
{
|
||||
"chunk_id": str(row.get("chunk_id", "")),
|
||||
"content": row.get("content") or "",
|
||||
"book_id": row.get("book_id") or "",
|
||||
"parent_title": row.get("parent_title"),
|
||||
"page_start": row.get("page_start"),
|
||||
"page_end": row.get("page_end"),
|
||||
"similarity": row.get("similarity"),
|
||||
}
|
||||
)
|
||||
|
||||
return {"rpc": rpc, "rows": normalized_rows}
|
||||
|
||||
|
||||
async def _embed_query_text(query_text: str) -> list[float]:
|
||||
"""Compute 768-d EmbeddingGemma vector for Supabase RPC.
|
||||
|
||||
PoC: returns NotImplemented until Triton/on-prem embedder is wired.
|
||||
Set EMBED_QUERY_MOCK=1 to return a zero vector for integration testing only.
|
||||
"""
|
||||
if os.getenv("EMBED_QUERY_MOCK") == "1":
|
||||
logger.warning("Using EMBED_QUERY_MOCK zero vector — not for production search quality")
|
||||
return [0.0] * 768
|
||||
|
||||
raise NotImplementedError(
|
||||
"Server-side query embedding is not wired yet. "
|
||||
"Configure Triton EmbeddingGemma or set EMBED_QUERY_MOCK=1 for integration tests."
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
import logging
|
||||
import httpx
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
from datetime import datetime
|
||||
|
||||
from backend.implementation.adapters.redis_adapter import get_redis_client
|
||||
from backend.implementation.adapters.llm_adapter import get_llm_adapter, AuditCallbackHandler
|
||||
from backend.implementation.config import (
|
||||
MODAL_MEDGEMMA_ENDPOINT,
|
||||
VERTEX_AI_GEMINI_ENDPOINT,
|
||||
GCP_ACCESS_TOKEN,
|
||||
PROJECT_ID,
|
||||
LOCATION,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
redis_client = get_redis_client()
|
||||
llm_adapter = get_llm_adapter()
|
||||
|
||||
|
||||
def _set_consult_mode(session_id: str, mode: str):
|
||||
redis_client.set(f"consult_mode:{session_id}", mode, ex=7200)
|
||||
|
||||
|
||||
async def _verify_consent(session_id: str) -> bool:
|
||||
consent_key = f"consent:{session_id}"
|
||||
return bool(await asyncio.to_thread(redis_client.exists, consent_key))
|
||||
|
||||
|
||||
async def _verify_session_ownership(session_id: str, user_id: str) -> bool:
|
||||
owner_key = f"session_owner:{session_id}"
|
||||
owner_id = await asyncio.to_thread(redis_client.get, owner_key)
|
||||
if not owner_id:
|
||||
return False
|
||||
return owner_id == user_id
|
||||
|
||||
|
||||
def _audit_event(session_id: str, event_type: str, payload: dict):
|
||||
key = f"audit:{session_id}:{event_type}"
|
||||
redis_client.set(key, json.dumps(payload), ex=86400)
|
||||
logger.info(
|
||||
"[AUDIT] event=%s session=%s payload=%s",
|
||||
event_type,
|
||||
session_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
|
||||
async def route_gemini_request(payload: dict, user_id: str) -> dict:
|
||||
session_id = payload.get("session_id", "")
|
||||
task_type = payload.get("task_type", "orchestration")
|
||||
prompt = payload.get("prompt", "")
|
||||
redaction_hash = payload.get("redaction_hash")
|
||||
|
||||
if not await _verify_consent(session_id):
|
||||
raise PermissionError("User consent for cloud LLM egress is required.")
|
||||
|
||||
if not await _verify_session_ownership(session_id, user_id):
|
||||
raise PermissionError("You do not own this session.")
|
||||
|
||||
_audit_event(session_id, "egress_consent_gemini", {
|
||||
"user_id": user_id,
|
||||
"task_type": task_type,
|
||||
"redaction_hash": redaction_hash,
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {GCP_ACCESS_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
vertex_payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"temperature": 0.2,
|
||||
"topP": 0.8,
|
||||
"topK": 40,
|
||||
"maxOutputTokens": 1024,
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=22.0) as client:
|
||||
response = await client.post(
|
||||
VERTEX_AI_GEMINI_ENDPOINT,
|
||||
headers=headers,
|
||||
json=vertex_payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
_audit_event(session_id, "egress_response_gemini", {
|
||||
"task_type": task_type,
|
||||
"status": "success",
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
})
|
||||
_set_consult_mode(session_id, "tier_2")
|
||||
|
||||
return {
|
||||
"text": result.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", ""),
|
||||
"tier": "gemini",
|
||||
"task_type": task_type,
|
||||
}
|
||||
|
||||
|
||||
async def route_medgemma_request(payload: dict, user_id: str) -> dict | AsyncGenerator[str, None]:
|
||||
session_id = payload.get("session_id", "")
|
||||
task_type = payload.get("task_type", "clinical_deep_reasoning")
|
||||
prompt = payload.get("prompt", "")
|
||||
stream = payload.get("stream", False)
|
||||
redaction_hash = payload.get("redaction_hash")
|
||||
|
||||
if not await _verify_consent(session_id):
|
||||
raise PermissionError("User consent for cloud LLM egress is required.")
|
||||
|
||||
if not await _verify_session_ownership(session_id, user_id):
|
||||
raise PermissionError("You do not own this session.")
|
||||
|
||||
_audit_event(session_id, "egress_consent_medgemma", {
|
||||
"user_id": user_id,
|
||||
"task_type": task_type,
|
||||
"redaction_hash": redaction_hash,
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
modal_payload = {
|
||||
"model": payload.get("model", "medgemma:4b"),
|
||||
"prompt": prompt,
|
||||
"stream": stream,
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"top_p": 0.8,
|
||||
"top_k": 40,
|
||||
"num_predict": 2048,
|
||||
},
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if stream:
|
||||
return _stream_medgemma(session_id, modal_payload, headers, task_type)
|
||||
|
||||
async with httpx.AsyncClient(timeout=22.0) as client:
|
||||
response = await client.post(
|
||||
f"{MODAL_MEDGEMMA_ENDPOINT}/api/generate",
|
||||
headers=headers,
|
||||
json=modal_payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
_audit_event(session_id, "egress_response_medgemma", {
|
||||
"task_type": task_type,
|
||||
"status": "success",
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
})
|
||||
_set_consult_mode(session_id, "tier_3")
|
||||
|
||||
return {
|
||||
"text": result.get("response", ""),
|
||||
"tier": "medgemma",
|
||||
"task_type": task_type,
|
||||
}
|
||||
|
||||
|
||||
async def _stream_medgemma(
|
||||
session_id: str,
|
||||
modal_payload: dict,
|
||||
headers: dict,
|
||||
task_type: str,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
async with httpx.AsyncClient(timeout=22.0) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{MODAL_MEDGEMMA_ENDPOINT}/api/generate",
|
||||
headers=headers,
|
||||
json=modal_payload,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[len("data:"):].strip()
|
||||
if not data_str:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
chunk = data.get("response", "")
|
||||
if chunk:
|
||||
yield chunk
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
_audit_event(session_id, "egress_response_medgemma_stream", {
|
||||
"task_type": task_type,
|
||||
"status": "success",
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
})
|
||||
_set_consult_mode(session_id, "tier_3")
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Spec-compliant CV inference orchestration — Sprint 1–2 architecture §7."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from backend.implementation.config import get_angle_type, get_model_name, get_segmentation_model
|
||||
from backend.implementation.pipeline.cv_spec_pipeline import (
|
||||
BRANCH_ANGLE_CLASSES,
|
||||
build_segmentation_skipped,
|
||||
build_severity_zero,
|
||||
)
|
||||
from backend.implementation.postprocessing.calibration import (
|
||||
CalibrationConfig,
|
||||
calibration_config_from_params,
|
||||
interpret_angle_logits,
|
||||
interpret_inflammation_logits,
|
||||
)
|
||||
from backend.implementation.postprocessing.measurement import calculate_thickness
|
||||
from backend.implementation.postprocessing.overlay import COLOR_MAP_POST, COLOR_MAP_SUP, create_overlay
|
||||
from backend.implementation.postprocessing.severity import calculate_severity
|
||||
from backend.implementation.preprocessing.clahe import apply_clahe
|
||||
from backend.services import cv_result_cache
|
||||
from backend.services import triton_runtime_service as triton_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEGMENT_CLASSES_SUP = {
|
||||
0: "background",
|
||||
1: "effusion",
|
||||
2: "fat",
|
||||
3: "fat-pat",
|
||||
4: "femur",
|
||||
5: "synovium",
|
||||
6: "tendon",
|
||||
}
|
||||
SEGMENT_CLASSES_POST = {
|
||||
0: "background",
|
||||
1: "fat",
|
||||
2: "tendon",
|
||||
3: "muscle",
|
||||
4: "femur",
|
||||
5: "artery",
|
||||
6: "baker's cyst",
|
||||
}
|
||||
|
||||
_triton_pipeline_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CvInferenceOptions:
|
||||
calibration: CalibrationConfig | None = None
|
||||
model_versions: dict[str, str] | None = None
|
||||
use_cache: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CvBatchResult:
|
||||
results: list[dict[str, Any]]
|
||||
triton_infer_calls: int
|
||||
triton_infer_modes: list[str]
|
||||
|
||||
|
||||
def _encode_image_to_data_url(image_pil: Image.Image) -> str:
|
||||
buffered = io.BytesIO()
|
||||
image_pil.save(buffered, format="PNG")
|
||||
encoded = base64.b64encode(buffered.getvalue()).decode()
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _build_inflammation_payload(logits_row: np.ndarray, config: CalibrationConfig) -> dict:
|
||||
interpreted = interpret_inflammation_logits(logits_row, config)
|
||||
return {
|
||||
"detected": interpreted["detected"],
|
||||
"confidence": interpreted["confidence"],
|
||||
"calibration": interpreted["calibration"],
|
||||
}
|
||||
|
||||
|
||||
def _logits_to_masks(
|
||||
logits: np.ndarray,
|
||||
angle_class: str,
|
||||
image_size: tuple[int, int],
|
||||
batch_idx: int = 0,
|
||||
) -> dict[str, np.ndarray]:
|
||||
logits_arr = np.asarray(logits, dtype=np.float32)
|
||||
while logits_arr.ndim < 4:
|
||||
logits_arr = np.expand_dims(logits_arr, 0)
|
||||
if logits_arr.ndim != 4:
|
||||
raise ValueError(f"Unexpected segmentation logits shape: {logits_arr.shape}")
|
||||
if batch_idx >= logits_arr.shape[0]:
|
||||
raise IndexError(f"batch_idx {batch_idx} out of range for shape {logits_arr.shape}")
|
||||
|
||||
preds_lowres = logits_arr.argmax(axis=1)[batch_idx]
|
||||
width, height = image_size
|
||||
preds = cv2.resize(
|
||||
preds_lowres.astype(np.uint8),
|
||||
(width, height),
|
||||
interpolation=cv2.INTER_NEAREST,
|
||||
)
|
||||
|
||||
angle_type = get_angle_type(angle_class)
|
||||
class_map = SEGMENT_CLASSES_SUP if angle_type == "sup" else SEGMENT_CLASSES_POST
|
||||
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for class_id, class_name in class_map.items():
|
||||
masks[class_name] = (preds == class_id).astype(np.uint8)
|
||||
return masks
|
||||
|
||||
|
||||
def _build_color_legend(classes_detected: list[str], angle_type: str) -> dict[str, list[int]]:
|
||||
color_map = COLOR_MAP_SUP if angle_type == "sup" else COLOR_MAP_POST
|
||||
legend: dict[str, list[int]] = {}
|
||||
for class_name in classes_detected:
|
||||
if class_name in color_map:
|
||||
legend[class_name] = color_map[class_name]
|
||||
return legend
|
||||
|
||||
|
||||
def _build_segmentation_result(
|
||||
image_pil: Image.Image,
|
||||
logits: np.ndarray,
|
||||
angle_class: str,
|
||||
seg_model: str,
|
||||
*,
|
||||
frame_id: str | None,
|
||||
inflammation: dict,
|
||||
angle_payload: dict,
|
||||
enhanced_data_url: str,
|
||||
inflam_model: str,
|
||||
angle_model: str,
|
||||
) -> dict:
|
||||
angle_type = get_angle_type(angle_class)
|
||||
masks = _logits_to_masks(logits, angle_class, image_pil.size)
|
||||
measurement = calculate_thickness(masks, image_pil.size)
|
||||
severity = calculate_severity(masks, image_pil.size)
|
||||
overlay = create_overlay(image_pil, masks, measurement, angle_type)
|
||||
|
||||
classes_detected = [name for name, mask in masks.items() if np.sum(mask) > 0 and name != "background"]
|
||||
color_legend = _build_color_legend(classes_detected, angle_type)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"angle": angle_payload,
|
||||
"inflammation": inflammation,
|
||||
"measurement": measurement,
|
||||
"severity": severity,
|
||||
"segmentation": {
|
||||
"performed": True,
|
||||
"angle_type": angle_type,
|
||||
"classes_detected": classes_detected,
|
||||
"color_legend": color_legend,
|
||||
},
|
||||
"images": {
|
||||
"enhanced": enhanced_data_url,
|
||||
"segmented": _encode_image_to_data_url(overlay),
|
||||
},
|
||||
"models_used": {
|
||||
"angle": angle_model,
|
||||
"inflammation": inflam_model,
|
||||
"segmentation": seg_model,
|
||||
},
|
||||
}
|
||||
if frame_id is not None:
|
||||
result["frame_id"] = frame_id
|
||||
return result
|
||||
|
||||
|
||||
async def _run_spec_cv_pipeline_single(
|
||||
image_pil: Image.Image,
|
||||
*,
|
||||
frame_id: str | None,
|
||||
options: CvInferenceOptions,
|
||||
) -> tuple[dict[str, Any], str, int]:
|
||||
"""
|
||||
Per-image spec path:
|
||||
CLAHE → angle → (post-trans|sup-up-long only) inflammation → conditional segmentation.
|
||||
"""
|
||||
config = options.calibration or CalibrationConfig()
|
||||
model_versions = options.model_versions
|
||||
angle_model = get_model_name("angle", model_versions)
|
||||
inflam_model = get_model_name("inflammation", model_versions)
|
||||
|
||||
triton_calls = 0
|
||||
modes: list[str] = []
|
||||
|
||||
enhanced_pil = apply_clahe(image_pil)
|
||||
enhanced_data_url = _encode_image_to_data_url(enhanced_pil)
|
||||
|
||||
angle_logits, angle_mode, angle_calls = await triton_runtime.infer_angle_logits_single(
|
||||
image_pil, angle_model
|
||||
)
|
||||
modes.append(angle_mode)
|
||||
triton_calls += angle_calls
|
||||
|
||||
angle_interpreted = interpret_angle_logits(angle_logits, config)
|
||||
angle_payload = {
|
||||
"class": angle_interpreted["class"],
|
||||
"confidence": angle_interpreted["confidence"],
|
||||
"calibration": angle_interpreted["calibration"],
|
||||
}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"angle": angle_payload,
|
||||
"models_used": {"angle": angle_model},
|
||||
"images": {"enhanced": enhanced_data_url},
|
||||
}
|
||||
if frame_id is not None:
|
||||
result["frame_id"] = frame_id
|
||||
|
||||
angle_class = angle_interpreted["class"]
|
||||
if angle_class not in BRANCH_ANGLE_CLASSES:
|
||||
result["segmentation"] = build_segmentation_skipped("angle_only")
|
||||
result["severity"] = build_severity_zero("angle_only")
|
||||
return result, "+".join(modes), triton_calls
|
||||
|
||||
inflam_logits, inflam_mode, inflam_calls = await triton_runtime.infer_inflammation_logits_single(
|
||||
image_pil, inflam_model
|
||||
)
|
||||
modes.append(inflam_mode)
|
||||
triton_calls += inflam_calls
|
||||
|
||||
inflammation = _build_inflammation_payload(inflam_logits, config)
|
||||
result["inflammation"] = inflammation
|
||||
result["models_used"]["inflammation"] = inflam_model
|
||||
|
||||
if not inflammation.get("detected"):
|
||||
result["segmentation"] = build_segmentation_skipped("no_inflammation")
|
||||
result["severity"] = build_severity_zero("no_inflammation")
|
||||
return result, "+".join(modes), triton_calls
|
||||
|
||||
seg_model = get_segmentation_model(angle_class, model_versions)
|
||||
seg_logits, seg_mode, seg_calls = await triton_runtime.infer_segmentation_logits_single(
|
||||
image_pil, seg_model
|
||||
)
|
||||
modes.append(seg_mode)
|
||||
triton_calls += seg_calls
|
||||
|
||||
seg_result = _build_segmentation_result(
|
||||
image_pil,
|
||||
seg_logits,
|
||||
angle_class,
|
||||
seg_model,
|
||||
frame_id=frame_id,
|
||||
inflammation=inflammation,
|
||||
angle_payload=angle_payload,
|
||||
enhanced_data_url=enhanced_data_url,
|
||||
inflam_model=inflam_model,
|
||||
angle_model=angle_model,
|
||||
)
|
||||
return seg_result, "+".join(modes), triton_calls
|
||||
|
||||
|
||||
async def run_single(
|
||||
image: Image.Image,
|
||||
*,
|
||||
frame_id: str | None = None,
|
||||
options: CvInferenceOptions | None = None,
|
||||
) -> dict[str, Any]:
|
||||
opts = options or CvInferenceOptions()
|
||||
result, _, _ = await _run_spec_cv_pipeline_single(image, frame_id=frame_id, options=opts)
|
||||
return result
|
||||
|
||||
|
||||
async def _run_batch_uncached(
|
||||
images: list[Image.Image],
|
||||
frame_ids: list[str],
|
||||
options: CvInferenceOptions,
|
||||
) -> CvBatchResult:
|
||||
if not images:
|
||||
return CvBatchResult(results=[], triton_infer_calls=0, triton_infer_modes=[])
|
||||
if len(frame_ids) != len(images):
|
||||
raise ValueError("frame_ids length must match images length")
|
||||
|
||||
async with _triton_pipeline_lock:
|
||||
results: list[dict[str, Any]] = []
|
||||
infer_modes: list[str] = []
|
||||
triton_call_count = 0
|
||||
for image_pil, fid in zip(images, frame_ids, strict=True):
|
||||
item, mode, calls = await _run_spec_cv_pipeline_single(
|
||||
image_pil,
|
||||
frame_id=fid,
|
||||
options=options,
|
||||
)
|
||||
results.append(item)
|
||||
infer_modes.append(mode)
|
||||
triton_call_count += calls
|
||||
return CvBatchResult(
|
||||
results=results,
|
||||
triton_infer_calls=triton_call_count,
|
||||
triton_infer_modes=infer_modes,
|
||||
)
|
||||
|
||||
|
||||
async def run_batch(
|
||||
images: list[Image.Image],
|
||||
frame_ids: list[str],
|
||||
options: CvInferenceOptions | None = None,
|
||||
) -> CvBatchResult:
|
||||
opts = options or CvInferenceOptions()
|
||||
if not opts.use_cache or not images:
|
||||
return await _run_batch_uncached(images, frame_ids, opts)
|
||||
|
||||
image_hashes = []
|
||||
for image in images:
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
image_hashes.append(cv_result_cache.hash_image_bytes(buf.getvalue()))
|
||||
|
||||
cache_key = cv_result_cache.analyze_batch_cache_key(frame_ids, image_hashes)
|
||||
|
||||
async def compute():
|
||||
return await _run_batch_uncached(images, frame_ids, opts)
|
||||
|
||||
return await cv_result_cache.with_result_cache(cache_key, compute, enabled=opts.use_cache)
|
||||
|
||||
|
||||
def options_from_params(params: dict[str, Any] | None) -> CvInferenceOptions:
|
||||
params = params or {}
|
||||
calibration = calibration_config_from_params(params)
|
||||
model_versions = params.get("model_versions")
|
||||
use_cache = params.get("use_cache", True)
|
||||
return CvInferenceOptions(
|
||||
calibration=calibration,
|
||||
model_versions=model_versions,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""In-memory CV inference result cache with in-flight request coalescing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, TypeVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CV_PIPELINE_VERSION = os.getenv("CV_PIPELINE_VERSION", "poc-v2-spec-cv-seg-norm")
|
||||
CV_RESULT_CACHE_TTL_S = float(os.getenv("CV_RESULT_CACHE_TTL_S", "3600"))
|
||||
CV_CACHE_ENABLED = os.getenv("CV_CACHE_ENABLED", "true").lower() in {"1", "true", "yes"}
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_result_cache: dict[str, tuple[float, Any]] = {}
|
||||
_inflight: dict[str, asyncio.Future] = {}
|
||||
|
||||
|
||||
def hash_image_bytes(raw: bytes) -> str:
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def analyze_batch_cache_key(frame_ids: list[str], image_hashes: list[str]) -> str:
|
||||
pairs = sorted(zip(frame_ids, image_hashes, strict=True), key=lambda item: item[0])
|
||||
payload = "|".join(f"{frame_id}:{digest}" for frame_id, digest in pairs)
|
||||
return f"analyze|{CV_PIPELINE_VERSION}|{payload}"
|
||||
|
||||
|
||||
async def with_result_cache(cache_key: str, compute: Callable[[], Awaitable[T]], *, enabled: bool = True) -> T:
|
||||
if not enabled or not CV_CACHE_ENABLED:
|
||||
return await compute()
|
||||
|
||||
now = time.monotonic()
|
||||
cached = _result_cache.get(cache_key)
|
||||
if cached and cached[0] > now:
|
||||
logger.info("CV cache HIT: %s", cache_key[:96])
|
||||
return cached[1]
|
||||
|
||||
inflight = _inflight.get(cache_key)
|
||||
if inflight is not None:
|
||||
logger.info("CV in-flight coalesce: %s", cache_key[:96])
|
||||
return await inflight
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
fut: asyncio.Future = loop.create_future()
|
||||
_inflight[cache_key] = fut
|
||||
try:
|
||||
result = await compute()
|
||||
_result_cache[cache_key] = (time.monotonic() + CV_RESULT_CACHE_TTL_S, result)
|
||||
fut.set_result(result)
|
||||
logger.info("CV cache STORE: %s", cache_key[:96])
|
||||
return result
|
||||
except Exception as exc:
|
||||
if not fut.done():
|
||||
fut.set_exception(exc)
|
||||
raise
|
||||
finally:
|
||||
_inflight.pop(cache_key, None)
|
||||
|
||||
|
||||
def cache_stats() -> dict[str, int | bool | float | str]:
|
||||
return {
|
||||
"cache_enabled": CV_CACHE_ENABLED,
|
||||
"pipeline_version": CV_PIPELINE_VERSION,
|
||||
"cache_ttl_s": CV_RESULT_CACHE_TTL_S,
|
||||
"cache_entries": len(_result_cache),
|
||||
"inflight_batches": len(_inflight),
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""EmbeddingGemma-compatible embed endpoint for episodic memory and RAG queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EMBEDDING_DIMENSIONS = 768
|
||||
|
||||
|
||||
def _format_embed_input(text: str, task: str, title: str | None = None) -> str:
|
||||
if task == "retrieval-document":
|
||||
title_value = title.strip() if title and title.strip() else "none"
|
||||
return f"title: {title_value} | text: {text}"
|
||||
return f"task: search result | query: {text}"
|
||||
|
||||
|
||||
def deterministic_embed(text: str, dimensions: int = EMBEDDING_DIMENSIONS) -> list[float]:
|
||||
"""PoC fallback — matches gemma4_e2b deterministicEmbed (SHA-256 + char histogram)."""
|
||||
vec = [0.0] * dimensions
|
||||
normalized = text.lower().strip()
|
||||
if not normalized:
|
||||
return vec
|
||||
|
||||
for index, char in enumerate(normalized):
|
||||
code = ord(char)
|
||||
bucket = (code * (index + 17)) % dimensions
|
||||
vec[bucket] += 1.0
|
||||
|
||||
digest = hashlib.sha256(normalized.encode("utf-8")).digest()
|
||||
for i in range(dimensions):
|
||||
vec[i] += digest[i % len(digest)] / 255.0
|
||||
|
||||
norm = math.sqrt(sum(value * value for value in vec))
|
||||
if norm == 0:
|
||||
return vec
|
||||
return [value / norm for value in vec]
|
||||
|
||||
|
||||
def _try_gemma_embedder(formatted: str) -> list[float] | None:
|
||||
"""Optional real EmbeddingGemma via knowledge ingestion pipeline."""
|
||||
if os.getenv("EMBED_QUERY_MOCK") == "1":
|
||||
return None
|
||||
try:
|
||||
from knowledge.implementation.ingestion.embedding import GemmaEmbedder, EmbedTask
|
||||
|
||||
embedder = GemmaEmbedder()
|
||||
task = (
|
||||
EmbedTask.RETRIEVAL_DOCUMENT
|
||||
if formatted.startswith("title:")
|
||||
else EmbedTask.RETRIEVAL_QUERY
|
||||
)
|
||||
raw = formatted
|
||||
title = None
|
||||
if task == EmbedTask.RETRIEVAL_DOCUMENT and "| text: " in formatted:
|
||||
prefix, body = formatted.split("| text: ", 1)
|
||||
title = prefix.replace("title:", "").strip()
|
||||
raw = body
|
||||
vector = embedder.embed(raw, task, title=title if title and title != "none" else None)
|
||||
return vector.tolist()
|
||||
except Exception as exc:
|
||||
logger.debug("GemmaEmbedder unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def embed_text(
|
||||
text: str,
|
||||
task: Literal["retrieval-query", "retrieval-document"] = "retrieval-query",
|
||||
title: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
formatted = _format_embed_input(text, task, title)
|
||||
vector = _try_gemma_embedder(formatted)
|
||||
if vector is not None:
|
||||
return {"vector": vector, "model": "embeddinggemma-300m", "source": "gemma"}
|
||||
|
||||
if os.getenv("EMBED_QUERY_MOCK") == "1":
|
||||
logger.warning("Using deterministic embed fallback (EMBED_QUERY_MOCK or no embedder)")
|
||||
|
||||
return {
|
||||
"vector": deterministic_embed(formatted),
|
||||
"model": "embeddinggemma-300m-deterministic",
|
||||
"source": "deterministic",
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Triton inference runtime — lock, retry, batching with batched→sequential fallback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from backend.implementation.adapters.triton_adapter import TritonAdapter
|
||||
from backend.implementation.preprocessing.tensor_prep import (
|
||||
prepare_angle_tensor,
|
||||
prepare_inflammation_tensor,
|
||||
prepare_segmentation_tensor,
|
||||
)
|
||||
from backend.implementation.triton_batch import TRITON_MAX_BATCH_SIZE, chunk_sequence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INPUT_NAME = "input_image"
|
||||
OUTPUT_NAME = "logits"
|
||||
|
||||
TRITON_INFER_TIMEOUT = float(os.getenv("TRITON_INFER_TIMEOUT", "120"))
|
||||
TRITON_INFER_RETRIES = int(os.getenv("TRITON_INFER_RETRIES", "5"))
|
||||
TRITON_RETRY_BASE_S = float(os.getenv("TRITON_RETRY_BASE_S", "4"))
|
||||
TRITON_USE_BATCH_INFER = os.getenv("TRITON_USE_BATCH_INFER", "true").lower()
|
||||
RETRYABLE_STATUS = {429, 502, 503, 504}
|
||||
|
||||
_triton_infer_lock = asyncio.Lock()
|
||||
_adapter: TritonAdapter | None = None
|
||||
_adapter_endpoint: str | None = None
|
||||
|
||||
|
||||
def get_triton_endpoint() -> str:
|
||||
return os.getenv("TRITON_ENDPOINT", "http://localhost:8000").rstrip("/")
|
||||
|
||||
|
||||
def _get_adapter() -> TritonAdapter:
|
||||
global _adapter, _adapter_endpoint
|
||||
endpoint = get_triton_endpoint()
|
||||
if _adapter is None or _adapter_endpoint != endpoint:
|
||||
_adapter = TritonAdapter(endpoint_url=endpoint, timeout=TRITON_INFER_TIMEOUT)
|
||||
_adapter_endpoint = endpoint
|
||||
return _adapter
|
||||
|
||||
|
||||
def _retry_backoff_seconds(attempt: int) -> float:
|
||||
return TRITON_RETRY_BASE_S * (2 ** (attempt - 1))
|
||||
|
||||
|
||||
def _should_try_batched_infer(image_count: int) -> bool:
|
||||
if image_count <= 1:
|
||||
return True
|
||||
if TRITON_USE_BATCH_INFER in {"1", "true", "yes"}:
|
||||
return True
|
||||
if TRITON_USE_BATCH_INFER in {"0", "false", "no"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _logits_array_from_adapter(result: dict) -> np.ndarray:
|
||||
logits = result.get(OUTPUT_NAME, [])
|
||||
if not logits:
|
||||
raise ValueError(f"Empty {OUTPUT_NAME} in Triton response")
|
||||
return np.asarray(logits, dtype=np.float32)
|
||||
|
||||
|
||||
def _infer_sync_with_retry(
|
||||
model_name: str,
|
||||
batch_tensor: np.ndarray,
|
||||
*,
|
||||
operation: str,
|
||||
max_retries: int | None = None,
|
||||
) -> np.ndarray:
|
||||
adapter = _get_adapter()
|
||||
attempts = max_retries if max_retries is not None else TRITON_INFER_RETRIES
|
||||
last_error: Exception | None = None
|
||||
|
||||
inputs = {
|
||||
INPUT_NAME: {
|
||||
"data": batch_tensor,
|
||||
"shape": list(batch_tensor.shape),
|
||||
"datatype": "FP32",
|
||||
}
|
||||
}
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
result = adapter._infer_sync(model_name, inputs, outputs=[OUTPUT_NAME])
|
||||
if attempt > 1:
|
||||
logger.info("%s succeeded on attempt %s/%s", operation, attempt, attempts)
|
||||
return _logits_array_from_adapter(result)
|
||||
except requests.HTTPError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
if status not in RETRYABLE_STATUS:
|
||||
raise
|
||||
last_error = exc
|
||||
except (requests.ConnectionError, requests.Timeout) as exc:
|
||||
last_error = exc
|
||||
|
||||
if attempt >= attempts:
|
||||
logger.error("%s failed on final attempt %s/%s: %s", operation, attempt, attempts, last_error)
|
||||
break
|
||||
wait_s = _retry_backoff_seconds(attempt)
|
||||
logger.warning(
|
||||
"%s attempt %s/%s failed (%s); exponential retry in %.1fs",
|
||||
operation,
|
||||
attempt,
|
||||
attempts,
|
||||
last_error,
|
||||
wait_s,
|
||||
)
|
||||
time.sleep(wait_s)
|
||||
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
|
||||
def _stack_angle_tensors(images: list[Image.Image]) -> np.ndarray:
|
||||
if not images:
|
||||
raise ValueError("images must not be empty")
|
||||
if len(images) > TRITON_MAX_BATCH_SIZE:
|
||||
raise ValueError(f"At most {TRITON_MAX_BATCH_SIZE} images per Triton batch, got {len(images)}")
|
||||
tensors = [prepare_angle_tensor(img) for img in images]
|
||||
return np.concatenate(tensors, axis=0).astype(np.float32)
|
||||
|
||||
|
||||
def _stack_inflammation_tensors(images: list[Image.Image]) -> np.ndarray:
|
||||
if not images:
|
||||
raise ValueError("images must not be empty")
|
||||
if len(images) > TRITON_MAX_BATCH_SIZE:
|
||||
raise ValueError(f"At most {TRITON_MAX_BATCH_SIZE} images per Triton batch, got {len(images)}")
|
||||
tensors = [prepare_inflammation_tensor(img) for img in images]
|
||||
return np.concatenate(tensors, axis=0).astype(np.float32)
|
||||
|
||||
|
||||
def _stack_segmentation_tensors(images: list[Image.Image]) -> np.ndarray:
|
||||
if not images:
|
||||
raise ValueError("images must not be empty")
|
||||
if len(images) > TRITON_MAX_BATCH_SIZE:
|
||||
raise ValueError(f"At most {TRITON_MAX_BATCH_SIZE} images per Triton batch, got {len(images)}")
|
||||
tensors = [prepare_segmentation_tensor(img) for img in images]
|
||||
return np.concatenate(tensors, axis=0).astype(np.float32)
|
||||
|
||||
|
||||
def _normalize_batched_angle_logits(logits: np.ndarray, expected_count: int) -> np.ndarray:
|
||||
if logits.ndim == 1:
|
||||
logits = np.expand_dims(logits, axis=0)
|
||||
if logits.ndim != 2:
|
||||
raise ValueError(f"Unexpected batched angle logits shape: {logits.shape}")
|
||||
if logits.shape[0] != expected_count:
|
||||
raise ValueError(
|
||||
f"Triton returned batch {logits.shape[0]} but expected {expected_count} angle rows",
|
||||
)
|
||||
return logits
|
||||
|
||||
|
||||
def _normalize_batched_segmentation_logits(logits: np.ndarray, expected_count: int) -> np.ndarray:
|
||||
if logits.ndim == 3:
|
||||
logits = np.expand_dims(logits, axis=0)
|
||||
if logits.ndim != 4:
|
||||
raise ValueError(f"Unexpected batched segmentation logits shape: {logits.shape}")
|
||||
if logits.shape[0] != expected_count:
|
||||
raise ValueError(
|
||||
f"Triton returned batch {logits.shape[0]} but expected {expected_count} images",
|
||||
)
|
||||
return logits
|
||||
|
||||
|
||||
def _infer_angle_logits_batch(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
*,
|
||||
max_retries: int | None = None,
|
||||
) -> np.ndarray:
|
||||
batch_tensor = _stack_angle_tensors(images)
|
||||
logits = _infer_sync_with_retry(
|
||||
model_name,
|
||||
batch_tensor,
|
||||
operation=f"Triton angle batch×{len(images)} ({model_name})",
|
||||
max_retries=max_retries,
|
||||
)
|
||||
return _normalize_batched_angle_logits(logits, len(images))
|
||||
|
||||
|
||||
def _infer_angle_logits_sequential(images: list[Image.Image], model_name: str) -> np.ndarray:
|
||||
rows: list[np.ndarray] = []
|
||||
for index, image in enumerate(images, start=1):
|
||||
logits = _infer_angle_logits_batch([image], model_name)
|
||||
row = logits[0] if logits.ndim == 2 else logits
|
||||
rows.append(row)
|
||||
logger.info("Triton sequential angle infer %s/%s complete for %s", index, len(images), model_name)
|
||||
return np.stack(rows, axis=0)
|
||||
|
||||
|
||||
def _infer_angle_logits_chunk(images: list[Image.Image], model_name: str) -> tuple[np.ndarray, Literal["batched", "sequential"]]:
|
||||
if not _should_try_batched_infer(len(images)):
|
||||
return _infer_angle_logits_sequential(images, model_name), "sequential"
|
||||
try:
|
||||
return _infer_angle_logits_batch(images, model_name, max_retries=1), "batched"
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Batched angle infer×%s failed (%s); falling back to sequential single-image calls",
|
||||
len(images),
|
||||
exc,
|
||||
)
|
||||
return _infer_angle_logits_sequential(images, model_name), "sequential"
|
||||
|
||||
|
||||
def _infer_inflammation_logits_batch(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
*,
|
||||
max_retries: int | None = None,
|
||||
) -> np.ndarray:
|
||||
batch_tensor = _stack_inflammation_tensors(images)
|
||||
logits = _infer_sync_with_retry(
|
||||
model_name,
|
||||
batch_tensor,
|
||||
operation=f"Triton inflammation batch×{len(images)} ({model_name})",
|
||||
max_retries=max_retries,
|
||||
)
|
||||
return _normalize_batched_angle_logits(logits, len(images))
|
||||
|
||||
|
||||
def _infer_inflammation_logits_sequential(images: list[Image.Image], model_name: str) -> np.ndarray:
|
||||
rows: list[np.ndarray] = []
|
||||
for index, image in enumerate(images, start=1):
|
||||
logits = _infer_inflammation_logits_batch([image], model_name)
|
||||
row = logits[0] if logits.ndim == 2 else logits
|
||||
rows.append(row)
|
||||
logger.info(
|
||||
"Triton sequential inflammation infer %s/%s complete for %s",
|
||||
index,
|
||||
len(images),
|
||||
model_name,
|
||||
)
|
||||
return np.stack(rows, axis=0)
|
||||
|
||||
|
||||
def _infer_inflammation_logits_chunk(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"]]:
|
||||
if not _should_try_batched_infer(len(images)):
|
||||
return _infer_inflammation_logits_sequential(images, model_name), "sequential"
|
||||
try:
|
||||
return _infer_inflammation_logits_batch(images, model_name, max_retries=1), "batched"
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Batched inflammation infer×%s failed (%s); falling back to sequential",
|
||||
len(images),
|
||||
exc,
|
||||
)
|
||||
return _infer_inflammation_logits_sequential(images, model_name), "sequential"
|
||||
|
||||
|
||||
def _infer_segmentation_logits_batch(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
*,
|
||||
max_retries: int | None = None,
|
||||
) -> np.ndarray:
|
||||
batch_tensor = _stack_segmentation_tensors(images)
|
||||
logits = _infer_sync_with_retry(
|
||||
model_name,
|
||||
batch_tensor,
|
||||
operation=f"Triton segmentation batch×{len(images)} ({model_name})",
|
||||
max_retries=max_retries,
|
||||
)
|
||||
return _normalize_batched_segmentation_logits(logits, len(images))
|
||||
|
||||
|
||||
def _infer_segmentation_logits_sequential(images: list[Image.Image], model_name: str) -> np.ndarray:
|
||||
rows: list[np.ndarray] = []
|
||||
for index, image in enumerate(images, start=1):
|
||||
logits = _infer_segmentation_logits_batch([image], model_name)
|
||||
row = logits[0] if logits.ndim == 4 else logits
|
||||
rows.append(row)
|
||||
logger.info(
|
||||
"Triton sequential segmentation infer %s/%s complete for %s",
|
||||
index,
|
||||
len(images),
|
||||
model_name,
|
||||
)
|
||||
return np.stack(rows, axis=0)
|
||||
|
||||
|
||||
def _infer_segmentation_logits_chunk(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"]]:
|
||||
if not _should_try_batched_infer(len(images)):
|
||||
return _infer_segmentation_logits_sequential(images, model_name), "sequential"
|
||||
try:
|
||||
return _infer_segmentation_logits_batch(images, model_name, max_retries=1), "batched"
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Batched segmentation infer×%s failed (%s); falling back to sequential",
|
||||
len(images),
|
||||
exc,
|
||||
)
|
||||
return _infer_segmentation_logits_sequential(images, model_name), "sequential"
|
||||
|
||||
|
||||
async def infer_angle_logits(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
|
||||
"""Run angle classification under the global Triton lock. Returns (logits, mode, call_count)."""
|
||||
async with _triton_infer_lock:
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_angle_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
|
||||
|
||||
async def infer_inflammation_logits(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
|
||||
async with _triton_infer_lock:
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_inflammation_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
|
||||
|
||||
async def infer_segmentation_logits(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
|
||||
async with _triton_infer_lock:
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_segmentation_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
|
||||
|
||||
async def infer_angle_logits_single(image: Image.Image, model_name: str) -> tuple[np.ndarray, str, int]:
|
||||
logits, mode, calls = await infer_angle_logits([image], model_name)
|
||||
return logits[0], f"angle:{mode}", calls
|
||||
|
||||
|
||||
async def infer_inflammation_logits_single(image: Image.Image, model_name: str) -> tuple[np.ndarray, str, int]:
|
||||
logits, mode, calls = await infer_inflammation_logits([image], model_name)
|
||||
return logits[0], f"inflam:{mode}", calls
|
||||
|
||||
|
||||
async def infer_segmentation_logits_single(image: Image.Image, model_name: str) -> tuple[np.ndarray, str, int]:
|
||||
logits, mode, calls = await infer_segmentation_logits([image], model_name)
|
||||
row = logits[0] if logits.ndim == 4 else logits
|
||||
return row, f"seg:{mode}", calls
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Warm up Modal Triton on CV service startup — reduces cold-start 502s and first-frame latency."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from backend.implementation.config import get_model_name, get_segmentation_model
|
||||
from backend.services import triton_runtime_service as triton_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _warmup_model_versions() -> dict[str, str]:
|
||||
versions: dict[str, str] = {}
|
||||
if angle := os.getenv("ANGLE_MODEL"):
|
||||
versions["angle"] = angle
|
||||
else:
|
||||
versions["angle"] = "angle_classify_resnet50"
|
||||
if inflam := os.getenv("INFLAMMATION_MODEL"):
|
||||
versions["inflammation"] = inflam
|
||||
return versions
|
||||
|
||||
|
||||
async def warmup_triton_models() -> None:
|
||||
if os.getenv("CV_SKIP_TRITON_WARMUP", "").lower() in {"1", "true", "yes"}:
|
||||
logger.info("Triton warmup skipped (CV_SKIP_TRITON_WARMUP)")
|
||||
return
|
||||
|
||||
model_versions = _warmup_model_versions()
|
||||
angle_model = get_model_name("angle", model_versions)
|
||||
inflam_model = get_model_name("inflammation", model_versions)
|
||||
seg_model = get_segmentation_model("sup-up-long", model_versions)
|
||||
|
||||
img224 = Image.new("RGB", (224, 224), color=(128, 128, 128))
|
||||
img512 = Image.new("RGB", (512, 512), color=(128, 128, 128))
|
||||
|
||||
logger.info(
|
||||
"Warming up Triton (angle=%s, inflammation=%s, segmentation=%s)…",
|
||||
angle_model,
|
||||
inflam_model,
|
||||
seg_model,
|
||||
)
|
||||
await triton_runtime.infer_angle_logits_single(img224, angle_model)
|
||||
await triton_runtime.infer_inflammation_logits_single(img224, inflam_model)
|
||||
await triton_runtime.infer_segmentation_logits_single(img512, seg_model)
|
||||
logger.info("Triton warmup complete")
|
||||
@@ -0,0 +1,77 @@
|
||||
# Agent Tools BFF Contract
|
||||
|
||||
Base path: `/api/v1/agent/tools`
|
||||
|
||||
## POST /exa/search
|
||||
|
||||
Proxies Exa `/search` with server-held `EXA_API_KEY`.
|
||||
|
||||
**Request**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "synovitis grading power doppler",
|
||||
"type": "auto",
|
||||
"numResults": 10,
|
||||
"includeDomains": ["pubmed.ncbi.nlm.nih.gov"],
|
||||
"session_id": "sess-001"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"id": "…",
|
||||
"url": "https://…",
|
||||
"title": "…",
|
||||
"highlights": ["…"],
|
||||
"publishedDate": "…",
|
||||
"score": 0.92
|
||||
}
|
||||
],
|
||||
"requestId": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## POST /supabase/query
|
||||
|
||||
Allowlisted RPC only. Embedding computed server-side.
|
||||
|
||||
**Request**
|
||||
|
||||
```json
|
||||
{
|
||||
"rpc": "match_semantic_chunks",
|
||||
"args": {
|
||||
"query_text": "synovitis grade 2 knee ultrasound",
|
||||
"match_count": 5,
|
||||
"filter_book_ids": ["mor", "oho"]
|
||||
},
|
||||
"session_id": "sess-001"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"rpc": "match_semantic_chunks",
|
||||
"rows": [
|
||||
{
|
||||
"chunk_id": "uuid",
|
||||
"content": "…",
|
||||
"book_id": "mor",
|
||||
"parent_title": "…",
|
||||
"similarity": 0.88
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Exa: https://docs.exa.ai/reference/search-api-guide-for-coding-agents
|
||||
- Supabase schema: [`knowledge/spec/pg_semantic_vector_db/supabase_schema.md`](../../knowledge/spec/pg_semantic_vector_db/supabase_schema.md)
|
||||
@@ -1,47 +1,122 @@
|
||||
# Backend Specification
|
||||
|
||||
## Purpose
|
||||
Orchestrates API routers, role checks, Socratic circuit-breaker state evaluations, and coordinates ML inference, telemetry collection, and data persistence.
|
||||
## Code‑base Tree View (implementation)
|
||||
|
||||
## Owner
|
||||
Core Backend Team
|
||||
```
|
||||
backend/
|
||||
├─ api/ # FastAPI routers (expose HTTP endpoints)
|
||||
│ ├─ auth_api.py
|
||||
│ ├─ patient_api.py
|
||||
│ ├─ session_api.py
|
||||
│ ├─ analysis_api.py
|
||||
│ ├─ safety_api.py
|
||||
│ ├─ notification_api.py
|
||||
│ ├─ settings_api.py
|
||||
│ ├─ ingestion_api.py
|
||||
│ ├─ telemetry_api.py
|
||||
├─ routers/ # Cloud LLM API routers
|
||||
│ ├─ cloud_orchestrate.py # POST /api/cloud-orchestrate → Gemini proxy
|
||||
│ └─ cloud_consult.py # POST /api/cloud-consult + GET /stream → MedGemma proxy
|
||||
├─ services/ # Cloud LLM Gateway business logic
|
||||
│ └─ cloud_llm_gateway.py # Routes Gemini/MedGemma based on task_type + consult_mode
|
||||
├─ implementation/ # Deep modules (seams) – each provides a small interface
|
||||
│ ├─ auth/ # Auth Module
|
||||
│ │ ├─ __init__.py
|
||||
│ │ └─ service.py # login, logout, refresh, me, update_me
|
||||
│ ├─ patient/ # Patient Module
|
||||
│ │ ├─ __init__.py
|
||||
│ │ └─ service.py
|
||||
│ ├─ session/ # Session & Frame handling
|
||||
│ │ ├─ __init__.py
|
||||
│ │ ├─ service.py
|
||||
│ │ └─ frame_storage.py # S3 adapter
|
||||
│ ├─ analysis_jobs/ # Async analysis orchestration
|
||||
│ │ ├─ __init__.py
|
||||
│ │ ├─ service.py
|
||||
│ │ └─ triton_client.py # gRPC wrapper
|
||||
│ ├─ safety/ # Internal safety stack
|
||||
│ │ ├─ __init__.py
|
||||
│ │ ├─ gradcam.py
|
||||
│ │ ├─ rationale.py
|
||||
│ │ ├─ circuit_breaker.py
|
||||
│ │ ├─ socratic_chat.py
|
||||
│ │ ├─ drift_check.py
|
||||
│ │ ├─ rag_evidence.py
|
||||
│ │ ├─ activations.py
|
||||
│ │ └─ annotations.py
|
||||
│ ├─ notification/ # Notification Module
|
||||
│ │ ├─ __init__.py
|
||||
│ │ └─ service.py
|
||||
│ ├─ settings/ # User settings module
|
||||
│ │ ├─ __init__.py
|
||||
│ │ └─ service.py
|
||||
│ ├─ ingestion_history/ # Ingestion history module
|
||||
│ │ ├─ __init__.py
|
||||
│ │ └─ service.py
|
||||
│ ├─ telemetry/ # Telemetry & anomaly reporting
|
||||
│ │ ├─ __init__.py
|
||||
│ │ └─ service.py
|
||||
│ └─ adapters/ # Low‑level adapters used by modules
|
||||
│ ├─ s3_adapter.py # Generic S3 wrapper
|
||||
│ ├─ triton_adapter.py # Adapter toward the Triton serving module hosted on Modal (KServe v2 HTTP / binary inference)
|
||||
│ ├─ llm_adapter.py
|
||||
│ └─ bert_adapter.py
|
||||
└─ main.py # FastAPI app entry point, wires routers to modules
|
||||
```
|
||||
|
||||
## Boundary
|
||||
FastAPI server, API routers, authentication middleware, circuit breaker engine, report generator, RAG coordinator, RAG-Referee (BERT), ledger logger, and connections to Postgres + pgvector, S3 (MinIO), Redis, Triton, ladybugDB.
|
||||
|
||||
## Internal Design
|
||||
- Built with FastAPI (Python) and Uvicorn for async HTTP server.
|
||||
- Authentication middleware validates JWT tokens and enforces RBAC (roles: RO_RADIOLOGIST, RO_THERAPIST).
|
||||
- Socratic circuit-breaker engine monitors interaction telemetry (hover duration, decision time, override magnitude) and triggers safety dialogs.
|
||||
- Clinical Report Engine uses ReportLab to generate bilingual PDF reports per Circular 46/2018/TT-BYT.
|
||||
- RAG Coordinator orchestrates Retrieval-Augmented Generation: dense vector lookup in pgvector (PostgreSQL HNSW), graph traversal in ladybugDB, mandatory pre-generation retrieval, prompt enrichment, LLM generation on browser WebLLM (GemmaE2B) or cloud Vertex AI (MedGemma via NFR-16a), and hallucination guarding via BERT RAG-Referee.
|
||||
- NLP Scrubber (Microsoft Presidio): re-verifies client edge redaction, refines residual PII, and returns error if unresolvable.
|
||||
- Ledger Logger appends immutable, cryptographically chained audit logs to Postgres via triggers preventing UPDATE/DELETE.
|
||||
- Connections: Postgres + pgvector (via SQLAlchemy), S3 (via boto3), Redis (via redis-py), Triton (via gRPC — CV + EmbeddingGemma only), ladybugDB (via in-process C++ bindings).
|
||||
- Model weights loaded at startup from internal registry; cached in memory.
|
||||
- API endpoints layered: public clinical (sessions, analysis, reports, feedback) and internal/local safety (explanations, safety, drift, RAG, activations, annotations, ground-truth, escalation, morphology, telemetry).
|
||||
|
||||
## Interface Contract
|
||||
See `bento/backend/spec/interface-contract.md`.
|
||||
## Overview
|
||||
The backend is a **FastAPI** application that orchestrates several **deep modules**. Each module presents a **seam** (the module’s public interface) that callers – the FastAPI router – use. Below is the module map, its **interface**, **implementation**, and the external **services** it depends on.
|
||||
|
||||
## Consumers
|
||||
- frontend
|
||||
| Module | Interface (public API) | Implementation | External Services (dependencies) |
|
||||
|--------|------------------------|----------------|-----------------------------------|
|
||||
| **Auth Module** | `login`, `logout`, `refresh`, `me`, `update_me` | JWT handling, password hashing, session store | PostgreSQL (`users` table), Redis (optional token blacklist) |
|
||||
| **Patient Module** | CRUD for patients, list sessions, ingestion history | ORM models, business rules | PostgreSQL (`patients`, `sessions`, `ingestion_history`) |
|
||||
| **Session Module** | Create session, add frames, retrieve, patch review | Transactional management, validation | PostgreSQL (`sessions`, `frames`), S3 adapter (frame storage) |
|
||||
| **Frame Storage Adapter** | `store_frame`, `generate_presigned_url` | Modal‑based S3 client wrapper | AWS S3 (object store) |
|
||||
| **Analysis Jobs Module** | `submit_job`, `job_status`, `job_steps` | Async job scheduler, Triton inference HTTP client, result aggregator | Triton inference server (KServe v2 HTTP, Modal serverless endpoint), PostgreSQL (`analysis_jobs`), S3 (artifact storage) |
|
||||
| **Safety Module** | GradCAM, rationale, circuit‑breaker, Socratic chat, drift check, RAG evidence, activations, annotations, escalation, telemetry | Calls to local LLM/RAG/BERT services, post‑processing utilities | Local LLM container, BERT drift detector, RAG knowledge base, PostgreSQL (`safety_events`), S3 (heatmaps, masks) |
|
||||
| **Cloud LLM Gateway Module** | `route_gemini_request`, `route_medgemma_request` | task_type matcher, NFR-16a consent/redaction/audit enforcement, consult_mode state extension, cost guarding | GCP Vertex AI (Gemini), Modal (MedGemma), Redis (consult_mode, consent, rate-limit), PostgreSQL (audit) |
|
||||
| **Agent Tools Module** | `exa_search`, `supabase_query` | Exa web search proxy, Supabase allowlisted RPC, PHI-safe audit logging | Exa API, Supabase (`knowledge` schema), EmbeddingGemma (embedder TBD) |
|
||||
| **Notification Module** | List, mark‑read, set preferences | Simple DB queries, push service stub | PostgreSQL (`notifications`), optional WebSocket push service |
|
||||
| **Settings Module** | Get/patch user settings | DB reads/writes | PostgreSQL (`user_settings`) |
|
||||
| **Ingestion History Module** | List uploads, get details | Query on `ingestion_history` table | PostgreSQL, S3 (original DICOM/frame) |
|
||||
| **Telemetry Module** | Anomaly reporting | Write to telemetry tables, async queue | PostgreSQL (`telemetry`), optional analytics pipeline |
|
||||
|
||||
## Breaking-change Policy
|
||||
See `bento/backend/spec/interface-contract.md`.
|
||||
## Design Principles Applied
|
||||
- **Depth**: Each module hides complex orchestration (e.g., Triton gRPC, S3 multipart upload) behind a small, well‑defined interface.
|
||||
- **Seams**: Interfaces live in `backend/api/<module>_api.py`; adapters implement them in `backend/services/<module>_service.py`.
|
||||
- **Deletion Test**: Removing any module concentrates its complexity inside callers, confirming the module’s value.
|
||||
- **Locality**: All error handling, logging, and retry logic resides inside the module implementation, giving callers a clean contract.
|
||||
- **Leverage**: Callers (FastAPI routes) only need to know request/response shapes; the module provides the full workflow.
|
||||
|
||||
## References
|
||||
- NFR-7 (Real-Time UI Screen Refresh ≤200ms)
|
||||
- NFR-10 (Generative Safety Guardrails)
|
||||
- NFR-11 (Frontline Usability & Training)
|
||||
- UC-48376 (Load Patient Scan Session)
|
||||
- UC-47988 (Review Suggested Synovitis Grade)
|
||||
- UC-25776 (Generate GradCAM & CoT Explanation Panel)
|
||||
- UC-02423 (Log High-Trust Concur Block)
|
||||
- UC_Q2_* (All Quadrant 2 safety workflows)
|
||||
- UC_Q3_* (All Quadrant 3 subservience workflows)
|
||||
- UC_Q4_* (All Quadrant 4 double-blind workflows)
|
||||
- SOFTWARE_SYSTEM_DESIGN_FR_25.md (Sections 1.2, 2.1-2.6)
|
||||
- SOLUTION_ARCHITECTURE_SPEC.md (Sections 2.1-2.6)
|
||||
- DATA_ENGINEERING_SPEC.md (Sections 4-12 for domain objects)
|
||||
- CI_CD_DEPLOYMENT_PIPELINE.md (Section 9.2 for docker-compose)
|
||||
## Module Dependency Graph (Mermaid)
|
||||
```mermaid
|
||||
graph TD;
|
||||
Auth --> DB[PostgreSQL];
|
||||
Patient --> DB;
|
||||
Session --> DB;
|
||||
Session --> S3[AWS S3];
|
||||
AnalysisJobs --> Triton[KServe v2 HTTP Triton = Modal Serverless];
|
||||
AnalysisJobs --> DB;
|
||||
AnalysisJobs --> S3;
|
||||
Safety --> LLM[Local LLM];
|
||||
Safety --> BERT[Drift Detector];
|
||||
Safety --> RAG[Local RAG];
|
||||
Safety --> DB;
|
||||
Safety --> S3;
|
||||
CloudLLM --> Gemini[GCP Vertex AI Gemini];
|
||||
CloudLLM --> MedGemma[Modal MedGemma];
|
||||
CloudLLM --> Redis;
|
||||
CloudLLM --> DB;
|
||||
CloudLLM --> CostGuard[MedGemma Usage Counter];
|
||||
Notification --> DB;
|
||||
Settings --> DB;
|
||||
IngestionHistory --> DB;
|
||||
IngestionHistory --> S3;
|
||||
Telemetry --> DB;
|
||||
```
|
||||
|
||||
---
|
||||
*Generated for AI‑navigability and testability.*
|
||||
@@ -1,46 +1,98 @@
|
||||
# Backend Interface Contract
|
||||
# Interface Contract Catalog
|
||||
|
||||
## Purpose
|
||||
Orchestrates API routers, role checks, Socratic circuit-breaker state evaluations, and coordinates ML inference, telemetry collection, and data persistence.
|
||||
This document enumerates every public **API contract** (HTTP endpoint) defined in `API_CONTRACT_DRAFT.md` and maps it to the **seam** (module) that fulfills it, together with the **external services** that the module interacts with.
|
||||
|
||||
## Owner
|
||||
Core Backend Team
|
||||
## 0. Health & Model Registry (Infrastructure / Analysis Jobs Module)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| GET | /api/v1/health | `system.health() -> HealthStatus` | All backend dependencies |
|
||||
| GET | /api/v1/model-registry | `analysis.list_registered_models() -> ModelCatalog` | Triton (Modal serverless), S3 (model artifacts) |
|
||||
| POST | /api/v1/models/register | `analysis.register_model(model_id: str, file: Binary) -> RegistrationResult` | S3 (model storage), Modal (serverless provisioning) |
|
||||
|
||||
## Provides
|
||||
- api endpoints (session management, frame upload, analysis jobs, reporting, feedback, safety endpoints)
|
||||
- model inference orchestration (dispatches to Triton, aggregates results)
|
||||
- telemetry collection (edge-based behavioral summaries, audit logs)
|
||||
- data persistence coordination (writes to Postgres, S3, Redis)
|
||||
## 1. Authentication Endpoints (Auth Module)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| POST | /api/v1/auth/login | `auth.login(username: str, password: str) -> JWT` | PostgreSQL (users), bcrypt, optional Redis blacklist |
|
||||
| POST | /api/v1/auth/logout | `auth.logout(token: str) -> None` | Redis (token revocation) |
|
||||
| POST | /api/v1/auth/refresh | `auth.refresh(refresh_token: str) -> JWT` | PostgreSQL, Redis |
|
||||
| GET | /api/v1/users/me | `auth.me(token: str) -> UserProfile` | PostgreSQL |
|
||||
| PATCH | /api/v1/users/me | `auth.update_me(token: str, updates: dict) -> UserProfile` | PostgreSQL |
|
||||
|
||||
## Consumes
|
||||
- data:storage-spec (Postgres DB, S3 object store, Redis cache)
|
||||
- ml:inference-spec (Triton server for angle, inflammation, segmentation, severity)
|
||||
- knowledge:guideline-spec (Qdrant vector DB, ladybugDB graph DB for grounded explanations)
|
||||
## 2. Patient Management (Patient Module)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| GET | /api/v1/patients | `patient.list(user_id: str) -> List[Patient]` | PostgreSQL |
|
||||
| POST | /api/v1/patients | `patient.create(data: dict) -> Patient` | PostgreSQL |
|
||||
| GET | /api/v1/patients/{patient_id} | `patient.get(id: str) -> Patient` | PostgreSQL |
|
||||
| GET | /api/v1/patients/{patient_id}/sessions | `patient.list_sessions(id: str) -> List[Session]` | PostgreSQL |
|
||||
| GET | /api/v1/patients/{patient_id}/history | `patient.ingestion_history(id: str) -> List[IngestionRecord]` | PostgreSQL, S3 |
|
||||
|
||||
## Consumers
|
||||
- frontend
|
||||
## 3. Notification Endpoints (Notification Module)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| GET | /api/v1/notifications | `notification.list(user_id: str, filters: dict) -> List[Notification]` | PostgreSQL |
|
||||
| PATCH | /api/v1/notifications/{notification_id}/read | `notification.mark_read(id: str) -> None` | PostgreSQL |
|
||||
| POST | /api/v1/notifications/preferences | `notification.set_preferences(user_id: str, prefs: dict) -> None` | PostgreSQL |
|
||||
|
||||
## Not Directly Consumable
|
||||
- data internals (Postgres tables, S3 object layout, Redis keys)
|
||||
- ml internals (Triton model details, GPU kernels)
|
||||
- knowledge internals (Qdrant vectors, ladybugDB graph)
|
||||
## 4. Settings & Preferences (Settings Module)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| GET | /api/v1/settings | `settings.get(user_id: str) -> Settings` | PostgreSQL |
|
||||
| PATCH | /api/v1/settings | `settings.update(user_id: str, updates: dict) -> Settings` | PostgreSQL |
|
||||
|
||||
## Breaking-change Policy
|
||||
- API versioning via path (e.g., /api/v1/).
|
||||
- Backward compatibility maintained for one minor version.
|
||||
- Deprecation notices issued in release notes.
|
||||
- Model interface changes (input/output tensors) require version bump.
|
||||
## 5. Ingestion History (Ingestion History Module)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| GET | /api/v1/ingestion-history | `ingestion.list(user_id: str) -> List[Record]` | PostgreSQL, S3 |
|
||||
| GET | /api/v1/ingestion-history/{record_id} | `ingestion.get(id: str) -> RecordDetail` | PostgreSQL, S3 |
|
||||
|
||||
## References
|
||||
- NFR-7 (Real-Time UI Screen Refresh ≤200ms)
|
||||
- NFR-10 (Generative Safety Guardrails)
|
||||
- NFR-11 (Frontline Usability & Training)
|
||||
- UC-48376 (Load Patient Scan Session)
|
||||
- UC-47988 (Review Suggested Synovitis Grade)
|
||||
- UC-25776 (Generate GradCAM & CoT Explanation Panel)
|
||||
- UC-02423 (Log High-Trust Concur Block)
|
||||
- UC_Q2_* (All Quadrant 2 safety workflows)
|
||||
- UC_Q3_* (All Quadrant 3 subservience workflows)
|
||||
- UC_Q4_* (All Quadrant 4 double-blind workflows)
|
||||
- SOFTWARE_SYSTEM_DESIGN_FR_25.md (Sections 1.2, 2.1-2.6)
|
||||
- SOLUTION_ARCHITECTURE_SPEC.md (Sections 2.1-2.6)
|
||||
## 6. Clinical Workflow Endpoints (Session & Analysis Modules)
|
||||
| Method | Path | Interface Function | Module | Dependencies |
|
||||
|--------|------|-------------------|--------|--------------|
|
||||
| POST | /api/v1/sessions | `session.create(user_id: str, patient_id: str) -> Session` | Session Module | PostgreSQL |
|
||||
| GET | /api/v1/sessions/{session_id} | `session.get(id: str) -> SessionDetail` | Session Module | PostgreSQL |
|
||||
| POST | /api/v1/sessions/{session_id}/frames | `session.add_frame(id: str, file: UploadFile) -> FrameMeta` | Session Module (via Frame Storage Adapter) | S3, PostgreSQL |
|
||||
| PATCH | /api/v1/sessions/{session_id}/review | `session.patch_review(id: str, review: dict) -> Session` | Session Module | PostgreSQL |
|
||||
| POST | /api/v1/analysis-jobs | `analysis.submit(session_id: str, params: dict) -> JobID` | Analysis Jobs Module | Triton, PostgreSQL, S3 |
|
||||
| GET | /api/v1/analysis-jobs/{job_id} | `analysis.status(job_id: str) -> JobStatus` | Analysis Jobs Module | PostgreSQL |
|
||||
| GET | /api/v1/analysis-jobs/{job_id}/steps | `analysis.steps(job_id: str) -> List[Step]` | Analysis Jobs Module | PostgreSQL |
|
||||
| POST | /api/v1/reports | `report.create(session_id: str, payload: dict) -> ReportID` | Session Module | PostgreSQL, S3 |
|
||||
| POST | /api/v1/reports/{report_id}/sign | `report.sign(id: str, signature: dict) -> Report` | Session Module | PostgreSQL |
|
||||
| POST | /api/v1/reports/{report_id}/emr-sync | `report.sync_emr(id: str) -> SyncResult` | Session Module | External EMR connector (REST) |
|
||||
| POST | /api/v1/sessions/{session_id}/feedback | `safety.submit_correction(session_id: str, correction: dict) -> None` | Safety Module | PostgreSQL, async analytics pipeline |
|
||||
| POST | /api/v1/analysis | `analysis.submit_sync(session_id: str, params: dict) -> JobResult` | Analysis Jobs Module | Triton, PostgreSQL, S3 |
|
||||
| POST | /api/v1/sessions/{session_id}/persist | `session.persist(session_id: str, review: dict) -> PersistResult` | Session Module | PostgreSQL, S3 |
|
||||
| POST | /api/v1/sessions/{session_id}/export-pdf | `session.export_pdf(session_id: str, params: dict) -> ExportResult` | Session Module | S3 |
|
||||
| POST | /api/v1/sessions/{session_id}/scrub-validate | `session.scrub_validate(session_id: str, metadata: dict) -> ScrubResult` | Session Module | - |
|
||||
| GET | /api/v1/analysis-jobs/{job_id}/stream | `analysis.stream(job_id: str) -> SSE[StepEvent]` | Analysis Jobs Module | - |
|
||||
|
||||
## 8. Cloud LLM Orchestration (Cloud LLM Gateway)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| POST | /api/v1/cloud-orchestrate | `cloud_llm_gateway.route_gemini_request(payload, user_id) -> dict` | Vertex AI (Gemini), Redis (consult_mode, consent), audit log |
|
||||
| POST | /api/v1/cloud-consult | `cloud_llm_gateway.route_medgemma_request(payload, user_id) -> dict` | Modal MedGemma, Redis (consult_mode, consent), audit log |
|
||||
| GET | /api/v1/cloud-consult/stream | `cloud_llm_gateway.route_medgemma_request(payload, user_id) -> SSE[chunk]` | Modal MedGemma streaming, Redis, audit log |
|
||||
|
||||
## 9. Internal/Local Safety Endpoints (Safety Module)
|
||||
| Method | Path | Interface Function | Dependencies |
|
||||
|--------|------|-------------------|--------------|
|
||||
| POST | /api/v1/sessions/{session_id}/explanations/gradcam | `safety.gradcam(session_id: str) -> HeatmapURL` | Triton (model output), S3 (store heatmap) |
|
||||
| POST | /api/v1/sessions/{session_id}/explanations/rationale | `safety.rationale(session_id: str) -> Text` | Local LLM service |
|
||||
| POST | /api/v1/sessions/{session_id}/safety/circuit-breaker | `safety.circuit_break(session_id: str, flag: bool) -> None` | PostgreSQL |
|
||||
| POST | /api/v1/sessions/{session_id}/chat/socratic | `safety.socratic_chat(session_id: str, prompt: str) -> ChatResponse` | Local LLM, PostgreSQL |
|
||||
| POST | /api/v1/sessions/{session_id}/drift/check | `safety.drift_check(session_id: str) -> DriftResult` | BERT (Drift Detector) |
|
||||
| POST | /api/v1/sessions/{session_id}/rag/evidence | `safety.rag_evidence(session_id: str) -> EvidenceList` | Local RAG |
|
||||
| POST | /api/v1/sessions/{session_id}/activations | `safety.activations(session_id: str, params: dict) -> ActivationMeta` | Triton, S3 |
|
||||
| POST | /api/v1/sessions/{session_id}/annotations/artifacts | `safety.upload_artifact(session_id: str, file: UploadFile) -> ArtifactMeta` | S3 |
|
||||
| POST | /api/v1/sessions/{session_id}/ground-truth | `safety.ground_truth(session_id: str, label: dict) -> None` | PostgreSQL |
|
||||
| POST | /api/v1/sessions/{session_id}/escalation | `safety.escalate(session_id: str, reason: str) -> EscalationTicket` | PostgreSQL, external ticketing stub |
|
||||
| POST | /api/v1/sessions/{session_id}/annotations/morphology | `safety.morphology(session_id: str, annotation: dict) -> None` | PostgreSQL |
|
||||
| POST | /api/v1/sessions/{session_id}/telemetry/anomalies | `telemetry.anomaly(session_id: str, data: dict) -> None` | PostgreSQL, async analytics pipeline |
|
||||
| POST | /api/v1/safety/guardrail-check | `safety.guardrail_check(session_id: str, prompt: str, score: float) -> GuardrailResult` | Safety Module | - |
|
||||
| GET | /api/v1/sessions/{session_id}/chat/stream | `safety.chat_stream(session_id: str) -> SSE[ChatEvent]` | Safety Module | - |
|
||||
|
||||
---
|
||||
|
||||
**Notation**: each row describes the **seam** (module) that implements the endpoint. Callers only need to know the request/response signature; the module encapsulates all orchestration, giving high **leverage** and good **testability**.
|
||||
|
||||
*Generated to aid AI‑navigability and automated test generation.*
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stratified sampling of test_images into per-patient scan profiles.
|
||||
|
||||
Each patient receives the same number of frames: (images_per_stratum × 5 strata).
|
||||
Strata = folder names under backend/tests/test_images/:
|
||||
- sup-up-long_positive
|
||||
- sup-up-long_negative
|
||||
- post_trans_positive
|
||||
- post_trans_negative
|
||||
- other_angle
|
||||
|
||||
Sampling is with replacement within each stratum (per patient).
|
||||
|
||||
Outputs:
|
||||
- backend/tests/test_images/profiles/manifest.json
|
||||
- frontend/implementation/public/assets/patient-profiles/{patient_id}/...
|
||||
- frontend/implementation/src/data/patientScanProfiles.generated.ts
|
||||
|
||||
Run from CODEBASE root:
|
||||
|
||||
python backend/tests/sample_patient_profiles.py
|
||||
python backend/tests/sample_patient_profiles.py --per-stratum 2 --seed 42
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
TEST_IMAGES_ROOT = CODEBASE_ROOT / "backend/tests/test_images"
|
||||
PROFILES_MANIFEST = TEST_IMAGES_ROOT / "profiles" / "manifest.json"
|
||||
PUBLIC_PROFILES_ROOT = CODEBASE_ROOT / "frontend/implementation/public/assets/patient-profiles"
|
||||
GENERATED_TS = CODEBASE_ROOT / "frontend/implementation/src/data/patientScanProfiles.generated.ts"
|
||||
|
||||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
|
||||
STRATA: list[str] = [
|
||||
"sup-up-long_positive",
|
||||
"sup-up-long_negative",
|
||||
"post_trans_positive",
|
||||
"post_trans_negative",
|
||||
"other_angle",
|
||||
]
|
||||
|
||||
STRATUM_SLUG: dict[str, str] = {
|
||||
"sup-up-long_positive": "sup-long-pos",
|
||||
"sup-up-long_negative": "sup-long-neg",
|
||||
"post_trans_positive": "post-trans-pos",
|
||||
"post_trans_negative": "post-trans-neg",
|
||||
"other_angle": "other",
|
||||
}
|
||||
|
||||
STRATUM_LABEL_VI: dict[str, str] = {
|
||||
"sup-up-long_positive": "Sup dọc — viêm (+)",
|
||||
"sup-up-long_negative": "Sup dọc — không viêm (−)",
|
||||
"post_trans_positive": "Sau ngang — viêm (+)",
|
||||
"post_trans_negative": "Sau ngang — không viêm (−)",
|
||||
"other_angle": "Góc khác (med-lat / sup-trans-flex)",
|
||||
}
|
||||
|
||||
EXPECTED_ANGLE_BY_STRATUM: dict[str, str | None] = {
|
||||
"sup-up-long_positive": "sup-up-long",
|
||||
"sup-up-long_negative": "sup-up-long",
|
||||
"post_trans_positive": "post-trans",
|
||||
"post_trans_negative": "post-trans",
|
||||
"other_angle": None,
|
||||
}
|
||||
|
||||
PATIENTS: list[dict[str, str]] = [
|
||||
{"id": "p-001", "name": "Nguyễn Văn An", "mrn": "BN-2024-1847"},
|
||||
{"id": "p-002", "name": "Trần Thị Bích", "mrn": "BN-2024-1923"},
|
||||
{"id": "p-003", "name": "Lê Hoàng Minh", "mrn": "BN-2024-2011"},
|
||||
{"id": "p-004", "name": "Phạm Thu Hà", "mrn": "BN-2024-2088"},
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoolImage:
|
||||
path: Path
|
||||
stratum: str
|
||||
|
||||
|
||||
def _list_images(folder: Path) -> list[Path]:
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
files = [
|
||||
p
|
||||
for p in sorted(folder.iterdir())
|
||||
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES
|
||||
]
|
||||
return files
|
||||
|
||||
|
||||
def _infer_other_angle_class(filename: str) -> str:
|
||||
lower = filename.lower()
|
||||
if "trans" in lower and "flex" in lower:
|
||||
return "sup-trans-flex"
|
||||
if "med" in lower and "lat" in lower:
|
||||
return "med-lat"
|
||||
return "med-lat"
|
||||
|
||||
|
||||
def _build_pools() -> dict[str, list[PoolImage]]:
|
||||
pools: dict[str, list[PoolImage]] = {}
|
||||
for stratum in STRATA:
|
||||
folder = TEST_IMAGES_ROOT / stratum
|
||||
images = _list_images(folder)
|
||||
if not images:
|
||||
raise FileNotFoundError(f"No images in stratum folder: {folder}")
|
||||
pools[stratum] = [PoolImage(path=p, stratum=stratum) for p in images]
|
||||
return pools
|
||||
|
||||
|
||||
def _sample_profile(
|
||||
patient: dict[str, str],
|
||||
pools: dict[str, list[PoolImage]],
|
||||
*,
|
||||
per_stratum: int,
|
||||
rng: random.Random,
|
||||
) -> list[dict]:
|
||||
frames: list[dict] = []
|
||||
for stratum in STRATA:
|
||||
pool = pools[stratum]
|
||||
slug = STRATUM_SLUG[stratum]
|
||||
for index in range(per_stratum):
|
||||
chosen = rng.choice(pool)
|
||||
source_name = chosen.path.name
|
||||
expected = EXPECTED_ANGLE_BY_STRATUM[stratum]
|
||||
if expected is None:
|
||||
expected = _infer_other_angle_class(source_name)
|
||||
|
||||
frame_id = f"{patient['id']}-{slug}-{index}"
|
||||
ext = chosen.path.suffix.lower()
|
||||
asset_name = f"{slug}-{index}{ext}"
|
||||
rel_asset = f"/assets/patient-profiles/{patient['id']}/{asset_name}"
|
||||
|
||||
frames.append(
|
||||
{
|
||||
"id": frame_id,
|
||||
"patient_id": patient["id"],
|
||||
"stratum": stratum,
|
||||
"stratum_index": index,
|
||||
"label": f"{STRATUM_LABEL_VI[stratum]} · #{index + 1}",
|
||||
"expected_angle_class": expected,
|
||||
"source_path": str(chosen.path.relative_to(CODEBASE_ROOT)),
|
||||
"source_filename": source_name,
|
||||
"asset_path": rel_asset,
|
||||
"asset_filename": asset_name,
|
||||
}
|
||||
)
|
||||
return frames
|
||||
|
||||
|
||||
def _materialize_assets(patient_id: str, frames: list[dict]) -> None:
|
||||
out_dir = PUBLIC_PROFILES_ROOT / patient_id
|
||||
if out_dir.exists():
|
||||
shutil.rmtree(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for frame in frames:
|
||||
src = CODEBASE_ROOT / frame["source_path"]
|
||||
dst = out_dir / frame["asset_filename"]
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def _write_manifest(payload: dict) -> None:
|
||||
PROFILES_MANIFEST.parent.mkdir(parents=True, exist_ok=True)
|
||||
PROFILES_MANIFEST.write_text(
|
||||
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _ts_string(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _write_generated_ts(payload: dict) -> None:
|
||||
lines = [
|
||||
"/** Auto-generated by backend/tests/sample_patient_profiles.py — do not edit. */",
|
||||
"import type { ScanFrame } from './scanFrames';",
|
||||
"",
|
||||
"export interface PatientScanProfileFrame extends ScanFrame {",
|
||||
" stratum: string;",
|
||||
" expectedAngleClass?: string;",
|
||||
" sourcePath: string;",
|
||||
"}",
|
||||
"",
|
||||
"export const PATIENT_SCAN_PROFILES: Record<string, PatientScanProfileFrame[]> = {",
|
||||
]
|
||||
|
||||
for patient in payload["patients"]:
|
||||
pid = patient["id"]
|
||||
lines.append(f" {_ts_string(pid)}: [")
|
||||
for frame in patient["frames"]:
|
||||
lines.append(
|
||||
" {"
|
||||
f" id: {_ts_string(frame['id'])},"
|
||||
f" src: {_ts_string(frame['asset_path'])},"
|
||||
f" label: {_ts_string(frame['label'])},"
|
||||
f" stratum: {_ts_string(frame['stratum'])},"
|
||||
f" expectedAngleClass: {_ts_string(frame['expected_angle_class'])},"
|
||||
f" sourcePath: {_ts_string(frame['source_path'])},"
|
||||
" },"
|
||||
)
|
||||
lines.append(" ],")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"};",
|
||||
"",
|
||||
"export function getScanFramesForPatient(patientId: string): PatientScanProfileFrame[] {",
|
||||
" return PATIENT_SCAN_PROFILES[patientId] ?? PATIENT_SCAN_PROFILES['p-001'] ?? [];",
|
||||
"}",
|
||||
"",
|
||||
f"export const FRAMES_PER_PATIENT = {payload['frames_per_patient']};",
|
||||
f"export const IMAGES_PER_STRATUM = {payload['images_per_stratum']};",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
GENERATED_TS.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Sample stratified ultrasound frames per patient profile.")
|
||||
parser.add_argument(
|
||||
"--per-stratum",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Images sampled per stratum folder per patient (default: 1 → 5 frames/patient).",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=2026, help="RNG seed for reproducible sampling.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.per_stratum < 1:
|
||||
raise SystemExit("--per-stratum must be >= 1")
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
pools = _build_pools()
|
||||
|
||||
print("Stratum pool sizes:")
|
||||
for stratum in STRATA:
|
||||
print(f" {stratum}: {len(pools[stratum])} images")
|
||||
|
||||
profiles: list[dict] = []
|
||||
for patient in PATIENTS:
|
||||
frames = _sample_profile(patient, pools, per_stratum=args.per_stratum, rng=rng)
|
||||
_materialize_assets(patient["id"], frames)
|
||||
profiles.append({**patient, "frames": frames})
|
||||
print(f"Patient {patient['id']}: {len(frames)} frames")
|
||||
|
||||
frames_per_patient = args.per_stratum * len(STRATA)
|
||||
payload = {
|
||||
"seed": args.seed,
|
||||
"images_per_stratum": args.per_stratum,
|
||||
"frames_per_patient": frames_per_patient,
|
||||
"strata": STRATA,
|
||||
"patients": profiles,
|
||||
}
|
||||
|
||||
_write_manifest(payload)
|
||||
_write_generated_ts(payload)
|
||||
|
||||
print(f"\nWrote manifest: {PROFILES_MANIFEST.relative_to(CODEBASE_ROOT)}")
|
||||
print(f"Wrote assets: {PUBLIC_PROFILES_ROOT.relative_to(CODEBASE_ROOT)}/")
|
||||
print(f"Wrote TS: {GENERATED_TS.relative_to(CODEBASE_ROOT)}")
|
||||
print(f"Total: {len(PATIENTS)} patients × {frames_per_patient} frames = {len(PATIENTS) * frames_per_patient} images")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Minimal BFF for agent-tool smoke tests (no GCP secrets, no Redis).
|
||||
|
||||
Mounts only:
|
||||
POST /api/v1/embed
|
||||
POST /api/v1/agent/tools/exa/search
|
||||
POST /api/v1/agent/tools/supabase/query
|
||||
|
||||
Run from CODEBASE root:
|
||||
# loads PILOT_PROJECT/secrets/aws_secret/.env if present
|
||||
PYTHONPATH=. python backend/tests/smoke_agent_tools_server.py
|
||||
|
||||
Then:
|
||||
cd ml/tests/agent_tools && npm run smoke:bff
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(CODEBASE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(CODEBASE_ROOT))
|
||||
|
||||
SECRETS_ENV = CODEBASE_ROOT.parents[2] / "secrets" / "aws_secret" / ".env"
|
||||
|
||||
|
||||
def _load_dotenv_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
_load_dotenv_file(SECRETS_ENV)
|
||||
os.environ.setdefault("EMBED_QUERY_MOCK", "1")
|
||||
|
||||
from backend.routers import agent_tools # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Agent Tools Smoke BFF", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(agent_tools.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.getenv("SMOKE_BFF_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("SMOKE_BFF_PORT", "8000"))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Agent tools smoke BFF on http://%s:%s", host, port)
|
||||
logger.info("Secrets env: %s (%s)", SECRETS_ENV, "found" if SECRETS_ENV.exists() else "missing")
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""HTTP layer tests for the CV inference FastAPI router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CODEBASE_ROOT))
|
||||
|
||||
from backend.cv_inference_server import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (32, 24), color=(100, 120, 140)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_health_route(client: TestClient):
|
||||
with patch(
|
||||
"backend.routers.cv_inference.TritonAdapter.model_ready",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
response = client.get("/api/test/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["service"] == "cv-inference"
|
||||
assert body["status"] == "ok"
|
||||
|
||||
|
||||
def test_analyze_batch_route(client: TestClient):
|
||||
mock_result = type(
|
||||
"Batch",
|
||||
(),
|
||||
{
|
||||
"results": [{"success": True, "frame_id": "f1", "angle": {"class": "med-lat"}}],
|
||||
"triton_infer_calls": 1,
|
||||
"triton_infer_modes": ["angle:batched"],
|
||||
},
|
||||
)()
|
||||
|
||||
with patch(
|
||||
"backend.routers.cv_inference.run_batch",
|
||||
new=AsyncMock(return_value=mock_result),
|
||||
):
|
||||
response = client.post(
|
||||
"/api/test/analyze/batch",
|
||||
data={"frame_ids": json.dumps(["f1"])},
|
||||
files=[("images", ("f1.png", _png_bytes(), "image/png"))],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["image_count"] == 1
|
||||
assert body["results"][0]["frame_id"] == "f1"
|
||||
|
||||
|
||||
def test_legacy_segment_route_returns_410(client: TestClient):
|
||||
response = client.post(
|
||||
"/api/test/segment/batch",
|
||||
data={"frame_ids": json.dumps(["f1"]), "angle_type": "sup"},
|
||||
files=[("images", ("f1.png", _png_bytes(), "image/png"))],
|
||||
)
|
||||
assert response.status_code == 410
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for backend.services.cv_inference_service — structure, gating, cache keys."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CODEBASE_ROOT))
|
||||
|
||||
MANIFEST_PATH = Path(__file__).resolve().parent / "test_images" / "profiles" / "manifest.json"
|
||||
|
||||
REQUIRED_RESULT_KEYS = {
|
||||
"success",
|
||||
"angle",
|
||||
"segmentation",
|
||||
"severity",
|
||||
"images",
|
||||
"models_used",
|
||||
}
|
||||
|
||||
|
||||
def _load_manifest_frames() -> list[dict]:
|
||||
if not MANIFEST_PATH.exists():
|
||||
return []
|
||||
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
frames: list[dict] = []
|
||||
for patient in data.get("patients", []):
|
||||
frames.extend(patient.get("frames", []))
|
||||
return frames
|
||||
|
||||
|
||||
def _frame_image_path(frame: dict) -> Path:
|
||||
return CODEBASE_ROOT / frame["source_path"]
|
||||
|
||||
|
||||
def _make_angle_logits(class_index: int, num_classes: int = 4) -> np.ndarray:
|
||||
row = np.full(num_classes, -2.0, dtype=np.float32)
|
||||
row[class_index] = 5.0
|
||||
return row
|
||||
|
||||
|
||||
ANGLE_CLASS_INDEX = {
|
||||
"med-lat": 0,
|
||||
"post-trans": 1,
|
||||
"sup-trans-flex": 2,
|
||||
"sup-up-long": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_rgb_image() -> Image.Image:
|
||||
return Image.new("RGB", (128, 96), color=(80, 120, 160))
|
||||
|
||||
|
||||
def test_analyze_batch_cache_key_stable_order():
|
||||
from backend.services.cv_result_cache import analyze_batch_cache_key
|
||||
|
||||
key_a = analyze_batch_cache_key(
|
||||
["frame-b", "frame-a"],
|
||||
["hash-b", "hash-a"],
|
||||
)
|
||||
key_b = analyze_batch_cache_key(
|
||||
["frame-a", "frame-b"],
|
||||
["hash-a", "hash-b"],
|
||||
)
|
||||
assert key_a == key_b
|
||||
assert key_a.startswith("analyze|")
|
||||
|
||||
|
||||
def test_cv_result_cache_coalesces_inflight():
|
||||
from backend.services import cv_result_cache
|
||||
|
||||
calls = 0
|
||||
|
||||
async def slow_compute():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await asyncio.sleep(0.05)
|
||||
return {"ok": True}
|
||||
|
||||
async def run():
|
||||
return await asyncio.gather(
|
||||
cv_result_cache.with_result_cache("test-key-coalesce", slow_compute),
|
||||
cv_result_cache.with_result_cache("test-key-coalesce", slow_compute),
|
||||
)
|
||||
|
||||
results = asyncio.run(run())
|
||||
assert results == [{"ok": True}, {"ok": True}]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"angle_class,inflammation_detected,expect_seg_performed",
|
||||
[
|
||||
("med-lat", False, False),
|
||||
("sup-trans-flex", False, False),
|
||||
("post-trans", False, False),
|
||||
("post-trans", True, True),
|
||||
("sup-up-long", False, False),
|
||||
("sup-up-long", True, True),
|
||||
],
|
||||
)
|
||||
def test_run_single_gating_logic(
|
||||
sample_rgb_image: Image.Image,
|
||||
angle_class: str,
|
||||
inflammation_detected: bool,
|
||||
expect_seg_performed: bool,
|
||||
):
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_single
|
||||
|
||||
angle_idx = ANGLE_CLASS_INDEX[angle_class]
|
||||
inflam_logits = _make_angle_logits(1 if inflammation_detected else 0, num_classes=2)
|
||||
seg_logits = np.zeros((1, 7, 64, 64), dtype=np.float32)
|
||||
|
||||
async def mock_angle_single(image, model_name):
|
||||
return _make_angle_logits(angle_idx), "angle:batched", 1
|
||||
|
||||
async def mock_inflam_single(image, model_name):
|
||||
return inflam_logits, "inflam:batched", 1
|
||||
|
||||
async def mock_seg_single(image, model_name):
|
||||
return seg_logits[0], "seg:batched", 1
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_angle_logits_single",
|
||||
new=AsyncMock(side_effect=mock_angle_single),
|
||||
),
|
||||
patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_inflammation_logits_single",
|
||||
new=AsyncMock(side_effect=mock_inflam_single),
|
||||
),
|
||||
patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_segmentation_logits_single",
|
||||
new=AsyncMock(side_effect=mock_seg_single),
|
||||
),
|
||||
):
|
||||
result = asyncio.run(
|
||||
run_single(
|
||||
sample_rgb_image,
|
||||
frame_id="test-frame",
|
||||
options=CvInferenceOptions(use_cache=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert REQUIRED_RESULT_KEYS.issubset(result.keys())
|
||||
assert result["angle"]["class"] == angle_class
|
||||
assert result["segmentation"]["performed"] is expect_seg_performed
|
||||
if expect_seg_performed:
|
||||
assert "segmented" in result["images"]
|
||||
assert "inflammation" in result["models_used"]
|
||||
assert "segmentation" in result["models_used"]
|
||||
elif angle_class in {"post-trans", "sup-up-long"}:
|
||||
assert result["inflammation"]["detected"] is inflammation_detected
|
||||
assert result["severity"]["level"] == 0
|
||||
|
||||
|
||||
def test_run_batch_result_shape(sample_rgb_image: Image.Image):
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_batch
|
||||
|
||||
async def mock_angle_single(image, model_name):
|
||||
return _make_angle_logits(ANGLE_CLASS_INDEX["med-lat"]), "angle:batched", 1
|
||||
|
||||
with patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_angle_logits_single",
|
||||
new=AsyncMock(side_effect=mock_angle_single),
|
||||
):
|
||||
batch = asyncio.run(
|
||||
run_batch(
|
||||
[sample_rgb_image, sample_rgb_image],
|
||||
["f1", "f2"],
|
||||
options=CvInferenceOptions(use_cache=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(batch.results) == 2
|
||||
assert batch.triton_infer_calls == 2
|
||||
assert len(batch.triton_infer_modes) == 2
|
||||
for item in batch.results:
|
||||
assert REQUIRED_RESULT_KEYS.issubset(item.keys())
|
||||
assert item["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("RUN_CV_INTEGRATION"),
|
||||
reason="Set RUN_CV_INTEGRATION=1 to call live Modal Triton",
|
||||
)
|
||||
def test_run_single_live_other_angle_frame():
|
||||
frames = _load_manifest_frames()
|
||||
other_frames = [f for f in frames if f.get("stratum") == "other_angle"]
|
||||
if not other_frames:
|
||||
pytest.skip("No other_angle frames in manifest")
|
||||
|
||||
frame = other_frames[0]
|
||||
image_path = _frame_image_path(frame)
|
||||
if not image_path.exists():
|
||||
pytest.skip(f"Test image missing: {image_path}")
|
||||
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_single
|
||||
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
result = asyncio.run(
|
||||
run_single(
|
||||
image,
|
||||
frame_id=frame["id"],
|
||||
options=CvInferenceOptions(use_cache=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert REQUIRED_RESULT_KEYS.issubset(result.keys())
|
||||
assert result["segmentation"]["performed"] is False
|
||||
assert result["severity"]["level"] == 0
|
||||
assert "enhanced" in result["images"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("RUN_CV_INTEGRATION"),
|
||||
reason="Set RUN_CV_INTEGRATION=1 to call live Modal Triton",
|
||||
)
|
||||
def test_run_batch_live_from_manifest():
|
||||
frames = _load_manifest_frames()
|
||||
if not frames:
|
||||
pytest.skip("Manifest not found or empty")
|
||||
|
||||
selected = frames[:2]
|
||||
images: list[Image.Image] = []
|
||||
frame_ids: list[str] = []
|
||||
for frame in selected:
|
||||
path = _frame_image_path(frame)
|
||||
if not path.exists():
|
||||
pytest.skip(f"Test image missing: {path}")
|
||||
images.append(Image.open(path).convert("RGB"))
|
||||
frame_ids.append(frame["id"])
|
||||
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_batch
|
||||
|
||||
batch = asyncio.run(
|
||||
run_batch(images, frame_ids, options=CvInferenceOptions(use_cache=False))
|
||||
)
|
||||
|
||||
assert len(batch.results) == len(images)
|
||||
assert batch.triton_infer_calls >= len(images)
|
||||
for item in batch.results:
|
||||
assert item["success"] is True
|
||||
assert REQUIRED_RESULT_KEYS.issubset(item.keys())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Backward-compatible launcher for the CV inference FastAPI service.
|
||||
|
||||
Prefer:
|
||||
|
||||
PYTHONPATH=. python -m backend.cv_inference_server
|
||||
|
||||
This module remains so existing docs/scripts that invoke
|
||||
`backend/tests/test_fast_api_proxy.py` keep working.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault(
|
||||
"TRITON_ENDPOINT",
|
||||
"https://dtj-tran--triton-s3-service-unified-triton-server.modal.run",
|
||||
)
|
||||
|
||||
from backend.cv_inference_server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,282 @@
|
||||
{
|
||||
"seed": 2026,
|
||||
"images_per_stratum": 1,
|
||||
"frames_per_patient": 5,
|
||||
"strata": [
|
||||
"sup-up-long_positive",
|
||||
"sup-up-long_negative",
|
||||
"post_trans_positive",
|
||||
"post_trans_negative",
|
||||
"other_angle"
|
||||
],
|
||||
"patients": [
|
||||
{
|
||||
"id": "p-001",
|
||||
"name": "Nguyễn Văn An",
|
||||
"mrn": "BN-2024-1847",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-001-sup-long-pos-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"source_filename": "58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-sup-long-neg-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"source_filename": "58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-post-trans-pos-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-post-trans-neg-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-other-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "sup-trans-flex",
|
||||
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
|
||||
"source_filename": "trans_flex.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p-002",
|
||||
"name": "Trần Thị Bích",
|
||||
"mrn": "BN-2024-1923",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-002-sup-long-pos-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"source_filename": "72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-sup-long-neg-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"source_filename": "72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-post-trans-pos-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-post-trans-neg-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb322d-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb322d-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-other-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "sup-trans-flex",
|
||||
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
|
||||
"source_filename": "trans_flex.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p-003",
|
||||
"name": "Lê Hoàng Minh",
|
||||
"mrn": "BN-2024-2011",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-003-sup-long-pos-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"source_filename": "72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-sup-long-neg-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"source_filename": "53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-post-trans-pos-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-post-trans-neg-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-other-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "med-lat",
|
||||
"source_path": "backend/tests/test_images/other_angle/med-lat_1.png",
|
||||
"source_filename": "med-lat_1.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p-004",
|
||||
"name": "Phạm Thu Hà",
|
||||
"mrn": "BN-2024-2088",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-004-sup-long-pos-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png",
|
||||
"source_filename": "72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-sup-long-neg-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"source_filename": "53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-post-trans-pos-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-post-trans-neg-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-other-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "sup-trans-flex",
|
||||
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
|
||||
"source_filename": "trans_flex.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 261 KiB |
@@ -0,0 +1,297 @@
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Add the project root to sys.path
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# Test config module
|
||||
def test_config():
|
||||
from backend.implementation.config import get_model_name, get_angle_type, get_segmentation_model
|
||||
|
||||
# Test default model names
|
||||
assert get_model_name("angle", None) == "angle_classify_convnext_tiny"
|
||||
assert get_model_name("inflammation", None) == "inflammation_model_efficientnet_b0_ultrasound_2_cls"
|
||||
assert get_model_name("segmentation_sup", None) == "segmentation_model_unet_resnet101"
|
||||
assert get_model_name("segmentation_post", None) == "segmentation_model_post_deeplabv3_resnet101"
|
||||
|
||||
# Test model_versions override
|
||||
custom = {"angle": "custom_angle_model"}
|
||||
assert get_model_name("angle", custom) == "custom_angle_model"
|
||||
assert get_model_name("inflammation", None) == "inflammation_model_efficientnet_b0_ultrasound_2_cls" # unchanged
|
||||
|
||||
# Test angle type
|
||||
assert get_angle_type("med-lat") == "other"
|
||||
assert get_angle_type("post-trans") == "post"
|
||||
assert get_angle_type("sup-trans-flex") == "sup"
|
||||
assert get_angle_type("sup-up-long") == "sup"
|
||||
|
||||
# Test segmentation model selection for angles that actually get segmentation
|
||||
# Only post-trans and sup-up-long trigger inflammation->segmentation
|
||||
assert get_segmentation_model("post-trans", None) == "segmentation_model_post_deeplabv3_resnet101" # post
|
||||
assert get_segmentation_model("sup-up-long", None) == "segmentation_model_unet_resnet101" # sup
|
||||
assert get_segmentation_model("sup-trans-flex", None) == "segmentation_model_unet_resnet101" # sup
|
||||
|
||||
# For other angles, the function still works but result isn't used in practice
|
||||
assert get_segmentation_model("med-lat", None) == "segmentation_model_post_deeplabv3_resnet101" # defaults to post
|
||||
|
||||
# Test transforms module
|
||||
def test_transforms():
|
||||
from backend.implementation.preprocessing.transforms import Resize, Normalize
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Create test image
|
||||
img = Image.new('RGB', (100, 50), color='red')
|
||||
|
||||
# Test resize
|
||||
resizer = Resize((50, 25))
|
||||
resized = resizer(img)
|
||||
assert resized.size == (50, 25)
|
||||
|
||||
# Test normalize
|
||||
normalizer = Normalize(mean=[0.5, 0.5, 0.5], std=[0.2, 0.2, 0.2])
|
||||
arr = np.array(img).astype(np.float32) / 255.0
|
||||
normalized = normalizer(img)
|
||||
expected = (arr - 0.5) / 0.2
|
||||
np.testing.assert_allclose(normalized, expected)
|
||||
|
||||
# Test tensor prep
|
||||
def test_tensor_prep():
|
||||
from backend.implementation.preprocessing.tensor_prep import (
|
||||
prepare_angle_tensor, prepare_inflammation_tensor, prepare_segmentation_tensor
|
||||
)
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# Create test image
|
||||
img = Image.new('RGB', (64, 64), color=(100, 150, 200))
|
||||
|
||||
# Test angle tensor
|
||||
angle_tensor = prepare_angle_tensor(img)
|
||||
assert angle_tensor.shape == (1, 3, 224, 224)
|
||||
assert angle_tensor.dtype == np.float32
|
||||
|
||||
# Test inflammation tensor
|
||||
inflam_tensor = prepare_inflammation_tensor(img)
|
||||
assert inflam_tensor.shape == (1, 3, 224, 224)
|
||||
assert inflam_tensor.dtype == np.float32
|
||||
|
||||
# Test segmentation tensor (0–1 normalized — matches infra preprocess_512 / Triton training)
|
||||
seg_tensor = prepare_segmentation_tensor(img)
|
||||
assert seg_tensor.shape == (1, 3, 512, 512)
|
||||
assert seg_tensor.dtype == np.float32
|
||||
assert seg_tensor.max() <= 1.0
|
||||
assert seg_tensor.min() >= 0.0
|
||||
|
||||
# Test measurement module
|
||||
def test_measurement():
|
||||
from backend.implementation.postprocessing.measurement import (
|
||||
calculate_thickness, get_mask_bounding_box, find_max_continuous_segment
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
# Test find_max_continuous_segment
|
||||
arr = np.array([0, 0, 1, 1, 1, 0, 1, 1, 0, 0])
|
||||
length, start, end = find_max_continuous_segment(arr)
|
||||
assert length == 3
|
||||
assert start == 2
|
||||
assert end == 5 # end is exclusive (like Python slicing)
|
||||
|
||||
# Test get_mask_bounding_box with simple square
|
||||
mask = np.zeros((14, 14), dtype=np.uint8)
|
||||
# 10x10 square (leaving 2-pixel border) to ensure it survives morphology operations with area >= 50
|
||||
mask[2:12, 2:12] = 1 # 10x10 square at (2,2) to (11,11)
|
||||
bbox = get_mask_bounding_box(mask)
|
||||
assert bbox is not None
|
||||
# Should be (2, 2, 10, 10) - x, y, width, height
|
||||
x, y, w, h = bbox
|
||||
assert x == 2 and y == 2 and w == 10 and h == 10
|
||||
|
||||
# Test calculate_thickness with horizontal bar
|
||||
masks = {
|
||||
'fat': np.zeros((14, 14), dtype=np.uint8),
|
||||
'tendon': np.zeros((14, 14), dtype=np.uint8)
|
||||
}
|
||||
# Make a 6-pixel wide horizontal bar at row 6-11 in FAT (class 1)
|
||||
masks['fat'][6:12, :] = 1
|
||||
thickness = calculate_thickness(masks, (14, 14), measure_ids=[1]) # fat is class 1 in POST
|
||||
assert thickness is not None
|
||||
# Should detect approximately 6 pixels width (allowing for some variation)
|
||||
assert thickness['thickness_px'] >= 4
|
||||
# Note: thickness_mm calculation uses the pixel count directly
|
||||
assert thickness['thickness_mm'] == round(6 * 45.0 / 655.0, 2)
|
||||
|
||||
# Test severity module
|
||||
def test_severity():
|
||||
from backend.implementation.postprocessing.severity import calculate_severity
|
||||
import numpy as np
|
||||
|
||||
# Test empty masks
|
||||
result = calculate_severity({}, (100, 100))
|
||||
assert result is None
|
||||
|
||||
# Test low severity
|
||||
masks = {
|
||||
'effusion': np.zeros((100, 100), dtype=np.uint8),
|
||||
'synovium': np.zeros((100, 100), dtype=np.uint8)
|
||||
}
|
||||
# Very small effusion: 5 pixels in a column (thickness=5)
|
||||
masks['effusion'][40:45, 50] = 1
|
||||
# Very small synovium: 5x5 square = 25 pixels
|
||||
masks['synovium'][40:45, 40:45] = 1
|
||||
result = calculate_severity(masks, (100, 100))
|
||||
# Debug: print values
|
||||
# print(f"effusion_pixels: {np.sum(masks['effusion'])}")
|
||||
# print(f"synovium_pixels: {np.sum(masks['synovium'])}")
|
||||
assert result is not None
|
||||
# With minimal effusion and synovium, should be low severity (level 1)
|
||||
assert result['level'] == 1 # Should be mild
|
||||
|
||||
# Test high severity
|
||||
masks['effusion'][:, :] = 1 # Full effusion
|
||||
masks['synovium'][:, :] = 1 # Full synovium
|
||||
result = calculate_severity(masks, (100, 100))
|
||||
assert result['level'] == 3 # Severe
|
||||
assert result['severity'] == "Nặng"
|
||||
|
||||
# Test overlay module
|
||||
def test_overlay():
|
||||
from backend.implementation.postprocessing.overlay import create_overlay
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
|
||||
# Create test image
|
||||
img = Image.new('RGB', (100, 100), color='white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([20, 20, 80, 80], fill='gray') # Add a gray square
|
||||
|
||||
# Create simple masks
|
||||
masks = {
|
||||
'background': np.zeros((100, 100), dtype=np.uint8),
|
||||
'effusion': np.zeros((100, 100), dtype=np.uint8),
|
||||
'fat': np.zeros((100, 100), dtype=np.uint8)
|
||||
}
|
||||
# Make a red blob in the center
|
||||
masks['effusion'][40:60, 40:60] = 1
|
||||
|
||||
# Test without measurement
|
||||
overlay = create_overlay(img, masks, None, angle_type='sup')
|
||||
assert overlay.size == img.size
|
||||
assert overlay.mode == 'RGB'
|
||||
|
||||
# Test with measurement
|
||||
measurement = {
|
||||
'x': 50,
|
||||
'y_start': 30,
|
||||
'y_end': 70,
|
||||
'thickness_mm': 2.5,
|
||||
'roi_start': 20,
|
||||
'roi_end': 80,
|
||||
'bbox': {'x': 10, 'y': 10, 'w': 80, 'h': 80}
|
||||
}
|
||||
overlay_with_meas = create_overlay(img, masks, measurement, angle_type='sup')
|
||||
assert overlay_with_meas.size == img.size
|
||||
|
||||
# Test CLAHE module (requires cv2)
|
||||
def test_clahe():
|
||||
pytest.importorskip("cv2")
|
||||
from backend.implementation.preprocessing.clahe import apply_clahe
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# Create test image with low contrast
|
||||
img = Image.new('RGB', (50, 50), color=(128, 128, 128))
|
||||
# Add some variation
|
||||
pixels = []
|
||||
for y in range(50):
|
||||
for x in range(50):
|
||||
v = 128 + int(20 * np.sin(x/10.0) * np.cos(y/10.0))
|
||||
pixels.append((v, v, v))
|
||||
img.putdata(pixels)
|
||||
|
||||
# Apply CLAHE
|
||||
enhanced = apply_clahe(img)
|
||||
assert enhanced.size == img.size
|
||||
assert enhanced.mode == 'RGB'
|
||||
# Enhanced image should have different pixel values (not identical)
|
||||
orig_arr = np.array(img)
|
||||
enh_arr = np.array(enhanced)
|
||||
# Not exactly equal due to CLAHE processing
|
||||
assert not np.array_equal(orig_arr, enh_arr)
|
||||
|
||||
|
||||
# Test calibration module
|
||||
def test_calibration():
|
||||
from backend.implementation.postprocessing.calibration import (
|
||||
CalibrationConfig,
|
||||
interpret_angle_logits,
|
||||
interpret_inflammation_logits,
|
||||
normalized_entropy,
|
||||
temperature_scaled_softmax,
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
logits = np.array([3.0, 1.0, 0.5, 0.2], dtype=np.float32)
|
||||
result = interpret_angle_logits(logits)
|
||||
assert result["class"] == "med-lat"
|
||||
assert "calibration" in result
|
||||
cal = result["calibration"]
|
||||
assert len(cal["raw_logits"]) == 4
|
||||
assert len(cal["class_probabilities"]) == 4
|
||||
assert cal["class_probabilities"]["med-lat"] > cal["class_probabilities"]["post-trans"]
|
||||
assert 0 <= cal["normalized_entropy"] <= 1
|
||||
assert cal["decision_state"] in ("confident", "ambiguous", "ood_warning")
|
||||
|
||||
flat = interpret_angle_logits(np.array([0.1, 0.1, 0.1, 0.1]))
|
||||
assert flat["calibration"]["normalized_entropy"] > 0.9
|
||||
assert flat["calibration"]["decision_state"] == "ood_warning"
|
||||
|
||||
screening = interpret_angle_logits(
|
||||
logits,
|
||||
CalibrationConfig(temperature=2.2),
|
||||
)
|
||||
aggressive = interpret_angle_logits(
|
||||
logits,
|
||||
CalibrationConfig(temperature=0.7),
|
||||
)
|
||||
aggressive_probs = aggressive["calibration"]["class_probabilities"]
|
||||
screening_probs = screening["calibration"]["class_probabilities"]
|
||||
assert aggressive_probs["med-lat"] > screening_probs["med-lat"]
|
||||
|
||||
inflam = interpret_inflammation_logits(np.array([-1.0, 2.0]))
|
||||
assert inflam["detected"] is True
|
||||
assert inflam["calibration"]["class_probabilities"]["inflammation"] > 50
|
||||
|
||||
probs = temperature_scaled_softmax(logits, 1.0)
|
||||
assert abs(float(np.sum(probs)) - 1.0) < 1e-5
|
||||
assert normalized_entropy(probs) < normalized_entropy(np.full(4, 0.25))
|
||||
|
||||
|
||||
def test_triton_batch_chunking():
|
||||
from backend.implementation.triton_batch import (
|
||||
TRITON_MAX_BATCH_SIZE,
|
||||
batch_count,
|
||||
chunk_sequence,
|
||||
)
|
||||
|
||||
assert TRITON_MAX_BATCH_SIZE == 8
|
||||
assert batch_count(0) == 0
|
||||
assert batch_count(4) == 1
|
||||
assert batch_count(8) == 1
|
||||
assert batch_count(10) == 2
|
||||
assert batch_count(11) == 2
|
||||
assert batch_count(16) == 2
|
||||
assert batch_count(17) == 3
|
||||
|
||||
chunks = list(chunk_sequence(list(range(10))))
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0] == list(range(8))
|
||||
assert chunks[1] == [8, 9]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,87 @@
|
||||
import asyncio
|
||||
from backend.implementation.adapters.triton_adapter import TritonAdapter
|
||||
from infra.tests.test_1_model import preprocess_224, preprocess_512, load_image
|
||||
from pathlib import Path
|
||||
|
||||
inference_server = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
|
||||
endpoint_url = f"{inference_server}"
|
||||
adapter = TritonAdapter(endpoint_url=endpoint_url)
|
||||
|
||||
test_img_path = "/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/infra/tests/test_images/other_angle/med_lat_2.png"
|
||||
|
||||
|
||||
async def main():
|
||||
img = load_image(Path(test_img_path))
|
||||
preprocessed_img_224 = preprocess_224(img)
|
||||
|
||||
# Test 4: list_models
|
||||
models = await adapter.list_models()
|
||||
assert isinstance(models, list)
|
||||
print(f"[OK] list_models count={len(models)}")
|
||||
|
||||
# Test 3: model_ready
|
||||
ready = await adapter.model_ready("angle_classify_convnext_tiny")
|
||||
print(f"[OK] model_ready={ready}")
|
||||
|
||||
# Test 3: model_ready
|
||||
ready = await adapter.model_ready("msk_vision_pipeline_ensemble")
|
||||
print(f"[OK] model_ready={ready}")
|
||||
|
||||
# Test 1: single model, infer without output filter
|
||||
result = await adapter.infer(
|
||||
model_name="angle_classify_convnext_tiny",
|
||||
inputs={
|
||||
"input_image": {
|
||||
"data": preprocessed_img_224.tolist(),
|
||||
"shape": list(preprocessed_img_224.shape),
|
||||
"datatype": "FP32",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
|
||||
assert "logits" in result, f"Expected 'logits' key, got keys: {list(result.keys())}"
|
||||
assert isinstance(result["logits"], list), "logits should be a list"
|
||||
print(f"[OK] single model: logits={result['logits']}")
|
||||
|
||||
preprocessed_img_512 = preprocess_512(img)
|
||||
|
||||
# Test 2: ensemble with all outputs (Triton ensemble requires all outputs to avoid deadlock)
|
||||
result2 = await adapter.infer(
|
||||
model_name="msk_vision_pipeline_ensemble",
|
||||
inputs={
|
||||
"input_224": {
|
||||
"data": preprocessed_img_224.tolist(),
|
||||
"shape": list(preprocessed_img_224.shape),
|
||||
"datatype": "FP32",
|
||||
},
|
||||
"input_512": {
|
||||
"data": preprocessed_img_512.tolist(),
|
||||
"shape": list(preprocessed_img_512.shape),
|
||||
"datatype": "FP32",
|
||||
},
|
||||
},
|
||||
outputs=[
|
||||
"angle_classify_convnext_tiny_logits",
|
||||
"angle_classify_resnet50_logits",
|
||||
"angle_classify_swin_v2_s_logits",
|
||||
"angle_classify_densenet_logits",
|
||||
"angle_classify_efficientnet_logits",
|
||||
"inflammation_model_efficientnet_b0_ultrasound_2_cls_logits",
|
||||
"segmentation_model_unet_resnet101_logits",
|
||||
"segmentation_model_unet3plus_att_logits",
|
||||
"segmentation_model_post_deeplabv3_resnet101_logits",
|
||||
"segmentation_model_post_deeplabv3_logits",
|
||||
"segmentation_model_post_efficientfeedback_logits",
|
||||
],
|
||||
)
|
||||
assert "angle_classify_convnext_tiny_logits" in result2
|
||||
assert "segmentation_model_unet_resnet101_logits" in result2
|
||||
print(f"[OK] ensemble: {list(result2.keys())}")
|
||||
for elements in result2:
|
||||
print(elements, ":", result2[elements].shape)
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
115
workspace/sprint_1_2/CODEBASE/data/spec/oop/dependencies.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Dependencies, Orchestration, and Integration — Sprint 1_2
|
||||
|
||||
## Data Engineering Alignment
|
||||
|
||||
### Storage Strategy
|
||||
- **Structured metadata**: PostgreSQL (aligned with backend modules)
|
||||
- **Artifacts** (DICOM, images, masks, overlays, models): S3-compatible bucket (MinIO)
|
||||
- **Naming convention**: UUIDs only — no PHI in filenames, keys, or URLs
|
||||
- **Access**: Presigned URLs for temporary access
|
||||
|
||||
### Canonical JSON Schemas
|
||||
All serialized domain objects must validate against canonical schemas defined in `data/schemas/`. Key schemas:
|
||||
- `session.schema.json`
|
||||
- `frame.schema.json`
|
||||
- `prediction.schema.json`
|
||||
- `measurement.schema.json`
|
||||
- `audit.schema.json`
|
||||
|
||||
### Model Output Normalization
|
||||
All model adapters must normalize outputs to canonical labels.
|
||||
|
||||
**Segmentation classes:**
|
||||
- `background`
|
||||
- `effusion`
|
||||
- `fat`
|
||||
- `fat-pat`
|
||||
- `femur`
|
||||
- `synovium`
|
||||
- `tendon`
|
||||
|
||||
**Angle classes:**
|
||||
- `med-lat`
|
||||
- `post-trans`
|
||||
- `sup-trans-flex`
|
||||
- `sup-up-long`
|
||||
|
||||
**Severity grades:**
|
||||
- 0: Rất nhẹ
|
||||
- 1: Nhẹ
|
||||
- 2: Trung bình
|
||||
- 3: Nặng
|
||||
|
||||
---
|
||||
|
||||
## Orchestrators and Use Cases
|
||||
|
||||
Orchestrators coordinate the workflow by sequencing agents and enforcing state machines.
|
||||
|
||||
### Key Use Cases
|
||||
|
||||
1. **Upload and Ingest**
|
||||
- Input: multipart DICOM or image upload
|
||||
- Steps: `DICOMIngestAgent` / `ImageUploadIngestAgent` → `FrameStorageAdapter`
|
||||
- Output: `DiagnosticSession`, `ScanFrame`, `ImageAsset`
|
||||
|
||||
2. **Run Analysis Pipeline**
|
||||
- Input: `DiagnosticSession`
|
||||
- Steps: `VisionPipelineAgent` → `InferenceRunner` → `MeasurementAgent` → `SeverityScorerAgent`
|
||||
- Output: `AnalysisJob` with completed results
|
||||
|
||||
3. **Review and Finalize**
|
||||
- Input: Clinician review data
|
||||
- Steps: `LedgerWriterAgent` → `ReviewDecision`
|
||||
- Output: Updated session state
|
||||
|
||||
---
|
||||
|
||||
## Integration with Backend Architecture
|
||||
|
||||
This OOP design maps to the backend specification modules:
|
||||
|
||||
| OOP Layer | Backend Module |
|
||||
|-----------|---------------|
|
||||
| Orchestrators & APIs | `api/` routers (session_api, analysis_api, etc.) |
|
||||
| Agents/Services | `implementation/` services |
|
||||
| Adapters | `implementation/` adapters |
|
||||
| Domain Objects | ORM models (PostgreSQL) + S3 references |
|
||||
| Orchestration | `implementation/analysis_jobs/service.py` (async jobs) |
|
||||
|
||||
---
|
||||
|
||||
## Validation and Testing
|
||||
|
||||
### Structural Validation
|
||||
- All candidate objects must map to either a PostgreSQL table or an S3 artifact reference.
|
||||
- No object may contain PHI fields that bypass scrubbing.
|
||||
|
||||
### Behavioral Validation
|
||||
- Adapter interfaces must support both mock and real implementations.
|
||||
- Agents must be stateless and idempotent where possible.
|
||||
|
||||
### End-to-End Flow
|
||||
```
|
||||
image/DICOM upload → secure local ingest → frame extraction → preprocessing → model inference → structured metrics/mask → API result → browser mask preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Object–Object and Object–Service Relationship Summary
|
||||
|
||||
- `ClinicianUser` owns `DiagnosticSession` and authors `ReviewDecision`
|
||||
- `PatientCase` groups many `DiagnosticSession` records
|
||||
- `DiagnosticSession` contains `ScanFrame`s, spawns `AnalysisJob`s, and tracks `Calibration` and `ReviewDecision` records
|
||||
- `AnalysisJob` consists of `PipelineStep`s and produces prediction, mask, measurement, and grade objects
|
||||
- `ScanFrame` becomes a `PreprocessedImage` via `FramePreprocessor`
|
||||
- `ImageAsset` stores the raw binary artifact for a `ScanFrame`
|
||||
- `ArtifactReference` can point to any `ScanFrame` or mask/overlay S3 object
|
||||
- `LedgerWriterAgent` writes `AuditLedgerEntry` for all state changes
|
||||
|
||||
## Agent–Adapter Dependencies
|
||||
|
||||
- `DICOMIngestAgent` and `ImageUploadIngestAgent` → `FrameStorageAdapter`
|
||||
- `ArtifactStoreAgent` → `FrameStorageAdapter` and `ArtifactStorageAdapter`
|
||||
- `InferenceRunner` → `InferenceAdapter` (PyTorch, Triton, or Mock)
|
||||
- `ModelRegistryAgent` → `ArtifactStorageAdapter`
|
||||
393
workspace/sprint_1_2/CODEBASE/data/spec/oop/objects.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Object Specifications — Sprint 1_2
|
||||
|
||||
## Overview
|
||||
|
||||
Domain objects represent **persistable clinical and analysis facts**. They are pure data structures with minimal behavior, focused on encapsulating business rules and state. They are persisted via PostgreSQL (structured metadata) and S3-compatible storage (artifacts).
|
||||
|
||||
## OOP Boundary
|
||||
|
||||
```
|
||||
Domain objects = persistable clinical/analysis facts.
|
||||
Agents/services = runtime workers that transform facts.
|
||||
Orchestrators = coordinate use cases and enforce workflow state.
|
||||
Adapters = hide PyTorch, filesystem, image, and API details.
|
||||
```
|
||||
|
||||
## Layer Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ API Layer (FastAPI) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Orchestrators (Use Cases) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Agents & Services (Workers) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Domain Objects │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Adapters │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Objects
|
||||
|
||||
### ClinicianUser
|
||||
Represents an authenticated medical professional.
|
||||
|
||||
**Fields:**
|
||||
- `user_id`: UUID / primary key
|
||||
- `username`: str
|
||||
- `hashed_password`: str
|
||||
- `name`: str
|
||||
- `role`: str (e.g., "radiologist", "support")
|
||||
- `credentials`: dict | None
|
||||
- `specialization`: str
|
||||
- `created_at`: datetime
|
||||
- `last_login`: datetime | None
|
||||
|
||||
**Relationships:**
|
||||
- owns many `DiagnosticSession`s
|
||||
- author of many `ReviewDecision`s
|
||||
|
||||
**Responsibilities:**
|
||||
- Authentication (via `AuthModule`)
|
||||
- Session ownership
|
||||
- Review and sign decisions
|
||||
|
||||
---
|
||||
|
||||
### 2. PatientCase
|
||||
Represents a patient's overall medical case.
|
||||
|
||||
**Fields:**
|
||||
- `case_id`: UUID
|
||||
- `patient_identifier`: str (hashed / pseudonymized)
|
||||
- `demographic_info`: dict
|
||||
- `medical_history_summary`: dict
|
||||
- `created_by`: `ClinicianUser`
|
||||
- `created_at`: datetime
|
||||
|
||||
**Relationships:**
|
||||
- has many `DiagnosticSession`s
|
||||
|
||||
**Responsibilities:**
|
||||
- Case registration and tracking
|
||||
- Session grouping
|
||||
|
||||
---
|
||||
|
||||
### 3. DiagnosticSession
|
||||
Represents a single ultrasound examination session.
|
||||
|
||||
**Fields:**
|
||||
- `session_id`: UUID
|
||||
- `case_id`: ForeignKey → PatientCase
|
||||
- `clinician_id`: ForeignKey → ClinicianUser
|
||||
- `status`: str (e.g., "created", "uploaded", "in_progress", "completed", "reviewed")
|
||||
- `created_at`: datetime
|
||||
- `updated_at`: datetime
|
||||
|
||||
**Relationships:**
|
||||
- belongs to `PatientCase`
|
||||
- belongs to `ClinicianUser`
|
||||
- has many `ScanFrame`s
|
||||
- has many `AnalysisJob`s
|
||||
- has many `ReviewDecision`s
|
||||
|
||||
**Responsibilities:**
|
||||
- Session lifecycle management
|
||||
- Frame and job grouping
|
||||
- Review state enforcement
|
||||
|
||||
---
|
||||
|
||||
### 4. ScanFrame
|
||||
Represents a single ultrasound image frame extracted from DICOM or standard image upload.
|
||||
|
||||
**Fields:**
|
||||
- `frame_id`: UUID
|
||||
- `session_id`: ForeignKey → DiagnosticSession
|
||||
- `storage_reference`: str (S3 key)
|
||||
- `original_format`: str (e.g., "dicom", "png", "jpeg")
|
||||
- `frame_number`: int | None
|
||||
- `metadata`: dict (DICOM tags, image dimensions, etc.)
|
||||
- `checksum`: str (SHA-256)
|
||||
- `created_at`: datetime
|
||||
|
||||
**Relationships:**
|
||||
- belongs to `DiagnosticSession`
|
||||
- has one `ImageAsset` (the raw artifact)
|
||||
- has one `PreprocessedImage`
|
||||
|
||||
**Responsibilities:**
|
||||
- Frame metadata capture
|
||||
- PHI-safe storage reference management
|
||||
|
||||
---
|
||||
|
||||
### 5. ImageAsset
|
||||
Represents the raw storage artifact for a frame.
|
||||
|
||||
**Fields:**
|
||||
- `asset_id`: UUID
|
||||
- `frame_id`: ForeignKey → ScanFrame
|
||||
- `storage_key`: str (S3 / MinIO key, UUID-based, no PHI)
|
||||
- `content_type`: str
|
||||
- `size_bytes`: int
|
||||
- `checksum`: str (SHA-256)
|
||||
- `uploaded_at`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- Binary artifact storage reference
|
||||
- Integrity verification
|
||||
|
||||
---
|
||||
|
||||
### 6. Calibration
|
||||
Device-specific calibration parameters for a session.
|
||||
|
||||
**Fields:**
|
||||
- `calibration_id`: UUID
|
||||
- `session_id`: ForeignKey → DiagnosticSession
|
||||
- `pixel_to_mm_ratio`: float
|
||||
- `parameters`: dict
|
||||
- `recorded_at`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- Measurement calibration
|
||||
- ROI metric scaling
|
||||
|
||||
---
|
||||
|
||||
### 7. AnalysisJob
|
||||
Request for AI/ML analysis on session frame(s).
|
||||
|
||||
**Fields:**
|
||||
- `job_id`: UUID
|
||||
- `session_id`: ForeignKey → DiagnosticSession
|
||||
- `parameters`: dict (e.g., selected models, flags)
|
||||
- `model_versions`: dict (task → model_id + version)
|
||||
- `status`: str (e.g., "pending", "running", "completed", "failed")
|
||||
- `result`: dict | None
|
||||
- `created_at`: datetime
|
||||
- `updated_at`: datetime
|
||||
|
||||
**Relationships:**
|
||||
- belongs to `DiagnosticSession`
|
||||
- has many `PipelineStep`s
|
||||
- produces angle, inflammation, segmentation, measurement, and grade results
|
||||
|
||||
**Responsibilities:**
|
||||
- Async job orchestration
|
||||
- Result aggregation
|
||||
|
||||
---
|
||||
|
||||
### 8. PipelineStep
|
||||
Single step in the analysis pipeline.
|
||||
|
||||
**Fields:**
|
||||
- `step_id`: UUID
|
||||
- `job_id`: ForeignKey → AnalysisJob
|
||||
- `task_type`: str (e.g., "angle_classification", "inflammation_detection", "segmentation_sup", "segmentation_post", "measurement", "severity_scoring")
|
||||
- `status`: str
|
||||
- `output`: dict | None
|
||||
- `duration_ms`: int | None
|
||||
- `started_at`: datetime | None
|
||||
- `completed_at`: datetime | None
|
||||
|
||||
**Responsibilities:**
|
||||
- Step-level progress tracking
|
||||
- Error isolation
|
||||
|
||||
---
|
||||
|
||||
### 9. ModelRegistryEntry
|
||||
Metadata record for a registered ML model.
|
||||
|
||||
**Fields:**
|
||||
- `model_id`: str
|
||||
- `name`: str
|
||||
- `task_type`: str
|
||||
- `version`: str
|
||||
- `description`: str
|
||||
- `framework`: str (e.g., "pytorch", "onnx")
|
||||
- `labels`: list[str]
|
||||
- `registered_at`: datetime
|
||||
- `is_active`: bool
|
||||
|
||||
**Responsibilities:**
|
||||
- Model discovery and selection
|
||||
- Version tracking
|
||||
|
||||
---
|
||||
|
||||
### 10. ModelArtifact
|
||||
Actual stored ML model artifact.
|
||||
|
||||
**Fields:**
|
||||
- `artifact_id`: UUID
|
||||
- `model_id`: ForeignKey → ModelRegistryEntry
|
||||
- `storage_key`: str (S3 key, UUID-based)
|
||||
- `format`: str (e.g., ".pth", ".onnx")
|
||||
- `size_bytes`: int
|
||||
- `checksum`: str
|
||||
- `uploaded_at`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- Secure storage of model weights
|
||||
- Integrity verification
|
||||
|
||||
---
|
||||
|
||||
### 11. PreprocessedImage
|
||||
Frame after preprocessing transformations.
|
||||
|
||||
**Fields:**
|
||||
- `preprocessed_id`: UUID
|
||||
- `frame_id`: ForeignKey → ScanFrame
|
||||
- `preprocessing_steps`: list[str]
|
||||
- `storage_reference`: str (S3 key, or inline base64 for small artifacts)
|
||||
- `width`: int
|
||||
- `height`: int
|
||||
- `created_at`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- Intermediate processing artifact management
|
||||
|
||||
---
|
||||
|
||||
### 12. AnglePrediction
|
||||
Output of the angle classification model.
|
||||
|
||||
**Fields:**
|
||||
- `prediction_id`: UUID
|
||||
- `job_id`: ForeignKey → AnalysisJob
|
||||
- `step_id`: ForeignKey → PipelineStep
|
||||
- `angle_class`: str (e.g., "med-lat", "post-trans", "sup-trans-flex", "sup-up-long")
|
||||
- `confidence`: float
|
||||
- `metadata`: dict
|
||||
|
||||
**Responsibilities:**
|
||||
- Classification result encapsulation
|
||||
|
||||
---
|
||||
|
||||
### 13. InflammationPrediction
|
||||
Output of the inflammation detection model.
|
||||
|
||||
**Fields:**
|
||||
- `prediction_id`: UUID
|
||||
- `job_id`: ForeignKey → AnalysisJob
|
||||
- `step_id`: ForeignKey → PipelineStep
|
||||
- `detected`: bool
|
||||
- `confidence`: float
|
||||
|
||||
**Responsibilities:**
|
||||
- Binary detection result encapsulation
|
||||
|
||||
---
|
||||
|
||||
### 14. SegmentationMask
|
||||
Output of the segmentation model.
|
||||
|
||||
**Fields:**
|
||||
- `mask_id`: UUID
|
||||
- `job_id`: ForeignKey → AnalysisJob
|
||||
- `step_id`: ForeignKey → PipelineStep
|
||||
- `storage_reference`: str (S3 key)
|
||||
- `overlay_reference`: str (S3 key)
|
||||
- `color_legend`: dict (class → color)
|
||||
- `metadata`: dict
|
||||
|
||||
**Responsibilities:**
|
||||
- Segmentation result storage and retrieval
|
||||
|
||||
---
|
||||
|
||||
### 15. Measurement
|
||||
Quantitative measurement derived from a segmentation mask.
|
||||
|
||||
**Fields:**
|
||||
- `measurement_id`: UUID
|
||||
- `job_id`: ForeignKey → AnalysisJob
|
||||
- `step_id`: ForeignKey → PipelineStep
|
||||
- `thickness_mm`: float | None
|
||||
- `pixel_to_mm_ratio`: float
|
||||
- `roi_specification`: dict (e.g., bounding box, region)
|
||||
- `created_at`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- Measurement calculation and storage
|
||||
|
||||
---
|
||||
|
||||
### 16. SynovitisGrade
|
||||
Final severity grade (0–3) for synovitis.
|
||||
|
||||
**Fields:**
|
||||
- `grade_id`: UUID
|
||||
- `job_id`: ForeignKey → AnalysisJob
|
||||
- `step_id`: ForeignKey → PipelineStep
|
||||
- `level`: int (0–3)
|
||||
- `label`: str
|
||||
- `combined_score`: float | None
|
||||
- `confidence`: float | None
|
||||
|
||||
**Responsibilities:**
|
||||
- Severity scoring encapsulation
|
||||
|
||||
---
|
||||
|
||||
### 17. ReviewDecision
|
||||
Clinician's approval, correction, or rejection of AI results.
|
||||
|
||||
**Fields:**
|
||||
- `decision_id`: UUID
|
||||
- `session_id`: ForeignKey → DiagnosticSession
|
||||
- `job_id`: ForeignKey → AnalysisJob
|
||||
- `reviewer_id`: ForeignKey → ClinicianUser
|
||||
- `decision_type`: str ("approve", "correct", "reject")
|
||||
- `justification`: str | None
|
||||
- `created_at`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- HITL decision capture
|
||||
- Review audit trail
|
||||
|
||||
---
|
||||
|
||||
### 18. ArtifactReference
|
||||
Polymorphic reference to any stored artifact.
|
||||
|
||||
**Fields:**
|
||||
- `reference_id`: UUID
|
||||
- `artifact_type`: str
|
||||
- `associated_entity_id`: UUID
|
||||
- `storage_key`: str (S3 key)
|
||||
- `content_type`: str
|
||||
- `created_at`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- Unified artifact reference management
|
||||
|
||||
---
|
||||
|
||||
### 19. AuditLedgerEntry
|
||||
Immutable audit trail entry for any significant event.
|
||||
|
||||
**Fields:**
|
||||
- `entry_id`: UUID
|
||||
- `entity_type`: str
|
||||
- `entity_id`: UUID
|
||||
- `action`: str
|
||||
- `user_id`: UUID | None
|
||||
- `checksum`: str (SHA-256 of the event payload)
|
||||
- `metadata`: dict
|
||||
- `timestamp`: datetime
|
||||
|
||||
**Responsibilities:**
|
||||
- Immutable audit trail
|
||||
- Compliance (Decree 13 / Circular 46)
|
||||
1119
workspace/sprint_1_2/CODEBASE/data/spec/oop/oop_spec.md
Normal file
136
workspace/sprint_1_2/CODEBASE/data/spec/oop/services.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Services, Agents, and Adapters — Sprint 1_2
|
||||
|
||||
Agents and services are the **runtime workers** that transform domain objects. Each agent has a single, focused responsibility and collaborates via well-defined interfaces.
|
||||
|
||||
## Ingestion Agents
|
||||
|
||||
### DICOMIngestAgent
|
||||
- **Responsibility:** Parse and validate DICOM files, extract metadata and renderable frames.
|
||||
- **Input:** `UploadFile` (DICOM bytes)
|
||||
- **Output:** `ScanFrame`, `ImageAsset`
|
||||
- **Collaborators:** `FrameStorageAdapter` (S3)
|
||||
|
||||
### ImageUploadIngestAgent
|
||||
- **Responsibility:** Handle standard image uploads (JPEG, PNG, etc.).
|
||||
- **Input:** `UploadFile` (image bytes)
|
||||
- **Output:** `ScanFrame`, `ImageAsset`
|
||||
- **Collaborators:** `FrameStorageAdapter`
|
||||
|
||||
## Preprocessing and Validation Agents
|
||||
|
||||
### FramePreprocessor
|
||||
- **Responsibility:** Apply preprocessing transformations (CLAHE, resizing, normalization).
|
||||
- **Input:** `ScanFrame`
|
||||
- **Output:** `PreprocessedImage`
|
||||
- **Collaborators:** Image libraries via adapter
|
||||
|
||||
### AngleValidatorAgent
|
||||
- **Responsibility:** Validate angle classification results against clinical rules.
|
||||
- **Input:** `AnglePrediction`
|
||||
- **Output:** `AnglePrediction` (possibly adjusted confidence)
|
||||
- **Collaborators:** Clinical rule engine
|
||||
|
||||
### ROICropperAgent
|
||||
- **Responsibility:** Extract regions of interest for specialized models.
|
||||
- **Input:** `PreprocessedImage`
|
||||
- **Output:** Cropped image segments
|
||||
- **Collaborators:** Frame storage, preprocessing
|
||||
|
||||
## Core Analysis Agents
|
||||
|
||||
### VisionPipelineAgent
|
||||
- **Responsibility:** Orchestrate the end-to-end vision inference pipeline for a session.
|
||||
- **Input:** `DiagnosticSession`, list of `ScanFrame`s
|
||||
- **Output:** `AnalysisJob` with completed `PipelineStep`s and results
|
||||
- **Collaborators:** `InferenceRunner`, `MeasurementAgent`, `SeverityScorerAgent`, `ModelRegistryAgent`
|
||||
|
||||
### InferenceRunner
|
||||
- **Responsibility:** Execute ML model inference via adapters (PyTorch, Triton, or Mock).
|
||||
- **Input:** `ModelReference` (id + version), `ProcessedImage` data
|
||||
- **Output:** Raw prediction payloads
|
||||
- **Collaborators:** `PyTorchAdapter`, `TritonAdapter`, `MockAdapter`
|
||||
|
||||
### MeasurementAgent
|
||||
- **Responsibility:** Calculate quantitative measurements from `SegmentationMask` using `Calibration`.
|
||||
- **Input:** `SegmentationMask`, `Calibration`
|
||||
- **Output:** `Measurement`
|
||||
- **Collaborators:** Calibration service, segmentation model geometry
|
||||
|
||||
### SeverityScorerAgent
|
||||
- **Responsibility:** Compute synovitis grade (0–3) from effusion and synovium measurements and inflammation prediction.
|
||||
- **Input:** `Measurement`, `InflammationPrediction`
|
||||
- **Output:** `SynovitisGrade`
|
||||
- **Collaborators:** Clinical scoring rules
|
||||
|
||||
## Management Agents
|
||||
|
||||
### ModelRegistryAgent
|
||||
- **Responsibility:** Manage model registration, versioning, and availability checks.
|
||||
- **Input:** `ModelRegistryEntry` data, `ModelArtifact` binaries
|
||||
- **Output:** `ModelRegistryEntry`, `ModelArtifact`
|
||||
- **Collaborators:** `ArtifactStoreAgent`, database persistence
|
||||
|
||||
### ArtifactStoreAgent
|
||||
- **Responsibility:** Store and retrieve large artifacts via S3-compatible storage.
|
||||
- **Input:** Binary data, storage key
|
||||
- **Output:** Storage confirmation, presigned URLs or S3 references
|
||||
- **Collaborators:** `FrameStorageAdapter`, S3 / MinIO
|
||||
|
||||
### LedgerWriterAgent
|
||||
- **Responsibility:** Write immutable `AuditLedgerEntry` records for state changes.
|
||||
- **Input:** Audit event payloads
|
||||
- **Output:** `AuditLedgerEntry`
|
||||
- **Collaborators:** PostgreSQL persistence
|
||||
|
||||
---
|
||||
|
||||
## Adapter Interfaces
|
||||
|
||||
Adapters encapsulate external system details and provide a uniform internal interface.
|
||||
|
||||
### Storage Adapters
|
||||
|
||||
```python
|
||||
class FrameStorageAdapter(ABC):
|
||||
@abstractmethod
|
||||
def store_frame(self, frame_id: UUID, data: bytes, content_type: str) -> str:
|
||||
"""Returns S3 storage key"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_presigned_url(self, storage_key: str, expires_in: int) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_frame(self, storage_key: str) -> None:
|
||||
pass
|
||||
```
|
||||
|
||||
```python
|
||||
class ArtifactStorageAdapter(ABC):
|
||||
@abstractmethod
|
||||
def store_artifact(self, artifact_id: UUID, data: bytes, content_type: str) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def retrieve_artifact(self, storage_key: str) -> bytes:
|
||||
pass
|
||||
```
|
||||
|
||||
### ML Inference Adapters
|
||||
|
||||
```python
|
||||
class InferenceAdapter(ABC):
|
||||
@abstractmethod
|
||||
def load_model(self, model_reference: str) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def infer(self, input_data: ProcessedImage) -> dict:
|
||||
"""Returns standardized prediction dict"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def unload_model(self, model_reference: str) -> None:
|
||||
pass
|
||||
```
|
||||
501
workspace/sprint_1_2/CODEBASE/data/spec/oop/visualization.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Visualization — Sprint 1_2 Class and Architecture Diagrams
|
||||
|
||||
## Layer Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ API Layer (FastAPI) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Orchestrators (Use Cases) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Agents & Services (Workers) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Domain Objects │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Adapters │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Full Class Diagram
|
||||
|
||||
```plantuml
|
||||
@startuml Sprint 1_2 OOP Class Diagram
|
||||
skinparam classAttributeAlignment left
|
||||
skinparam classFontSize 11
|
||||
skinparam backgroundColor #FEFEFF
|
||||
skinparam handwritten false
|
||||
|
||||
package "Domain Objects" {
|
||||
class ClinicianUser {
|
||||
-user_id: UUID
|
||||
-username: str
|
||||
-hashed_password: str
|
||||
-name: str
|
||||
-role: str
|
||||
-credentials: dict | None
|
||||
-specialization: str
|
||||
-created_at: datetime
|
||||
-last_login: datetime | None
|
||||
--
|
||||
+authenticate(password: str): bool
|
||||
+owns_sessions(): List[DiagnosticSession]
|
||||
+creates_review(decision: ReviewDecision): ReviewDecision
|
||||
}
|
||||
|
||||
class PatientCase {
|
||||
-case_id: UUID
|
||||
-patient_identifier: str
|
||||
-demographic_info: dict
|
||||
-medical_history_summary: dict
|
||||
-created_at: datetime
|
||||
--
|
||||
+add_session(session: DiagnosticSession): void
|
||||
+list_sessions(): List[DiagnosticSession]
|
||||
}
|
||||
|
||||
class DiagnosticSession {
|
||||
-session_id: UUID
|
||||
-case_id: UUID
|
||||
-clinician_id: UUID
|
||||
-status: str
|
||||
-created_at: datetime
|
||||
-updated_at: datetime
|
||||
--
|
||||
+add_frame(frame: ScanFrame): void
|
||||
+add_job(job: AnalysisJob): void
|
||||
+add_review(decision: ReviewDecision): void
|
||||
+can_upload(): bool
|
||||
+can_analyze(): bool
|
||||
+can_review(): bool
|
||||
}
|
||||
|
||||
class ScanFrame {
|
||||
-frame_id: UUID
|
||||
-session_id: UUID
|
||||
-storage_reference: str
|
||||
-original_format: str
|
||||
-frame_number: int | None
|
||||
-metadata: dict
|
||||
-checksum: str
|
||||
-created_at: datetime
|
||||
--
|
||||
+get_image_data(): bytes
|
||||
+get_metadata(): dict
|
||||
+has_preprocessed(): bool
|
||||
+calculate_checksum(): str
|
||||
}
|
||||
|
||||
class ImageAsset {
|
||||
-asset_id: UUID
|
||||
-frame_id: UUID
|
||||
-storage_key: str
|
||||
-content_type: str
|
||||
-size_bytes: int
|
||||
-checksum: str
|
||||
-uploaded_at: datetime
|
||||
--
|
||||
+get_storage_key(): str
|
||||
+verify_checksum(): bool
|
||||
}
|
||||
|
||||
class Calibration {
|
||||
-calibration_id: UUID
|
||||
-session_id: UUID
|
||||
-pixel_to_mm_ratio: float
|
||||
-parameters: dict
|
||||
-recorded_at: datetime
|
||||
--
|
||||
+scale_pixels_to_mm(pixels: float): float
|
||||
+get_parameters(): dict
|
||||
}
|
||||
|
||||
class AnalysisJob {
|
||||
-job_id: UUID
|
||||
-session_id: UUID
|
||||
-parameters: dict
|
||||
-model_versions: dict
|
||||
-status: str
|
||||
-result: dict | None
|
||||
-created_at: datetime
|
||||
-updated_at: datetime
|
||||
--
|
||||
+add_step(step: PipelineStep): void
|
||||
+set_status(status: str): void
|
||||
+set_result(result: dict): void
|
||||
+is_running(): bool
|
||||
+is_completed(): bool
|
||||
+is_failed(): bool
|
||||
+get_steps(): List[PipelineStep]
|
||||
}
|
||||
|
||||
class PipelineStep {
|
||||
-step_id: UUID
|
||||
-job_id: UUID
|
||||
-task_type: str
|
||||
-status: str
|
||||
-output: dict | None
|
||||
-duration_ms: int | None
|
||||
-started_at: datetime | None
|
||||
-completed_at: datetime | None
|
||||
--
|
||||
+start(model: ModelReference): void
|
||||
+complete(output: dict): void
|
||||
+fail(error: str): void
|
||||
+get_duration(): int | None
|
||||
}
|
||||
|
||||
class ModelRegistryEntry {
|
||||
-model_id: str
|
||||
-name: str
|
||||
-task_type: str
|
||||
-version: str
|
||||
-description: str
|
||||
-framework: str
|
||||
-labels: list[str]
|
||||
-registered_at: datetime
|
||||
-is_active: bool
|
||||
--
|
||||
+get_labels(): list[str]
|
||||
+is_compatible_with(task: str): bool
|
||||
+activate(): void
|
||||
+deactivate(): void
|
||||
}
|
||||
|
||||
class ModelArtifact {
|
||||
-artifact_id: UUID
|
||||
-model_id: str
|
||||
-storage_key: str
|
||||
-format: str
|
||||
-size_bytes: int
|
||||
-checksum: str
|
||||
-uploaded_at: datetime
|
||||
--
|
||||
+get_model_file(): bytes
|
||||
+verify_checksum(): bool
|
||||
+get_format(): str
|
||||
}
|
||||
|
||||
class PreprocessedImage {
|
||||
-preprocessed_id: UUID
|
||||
-frame_id: UUID
|
||||
-preprocessing_steps: list[str]
|
||||
-storage_reference: str
|
||||
-width: int
|
||||
-height: int
|
||||
-created_at: datetime
|
||||
--
|
||||
+get_image_data(): bytes
|
||||
+get_dimensions(): tuple[int, int]
|
||||
+applied_steps(): list[str]
|
||||
}
|
||||
|
||||
class AnglePrediction {
|
||||
-prediction_id: UUID
|
||||
-job_id: UUID
|
||||
-step_id: UUID
|
||||
-angle_class: str
|
||||
-confidence: float
|
||||
-metadata: dict
|
||||
--
|
||||
+get_class(): str
|
||||
+get_confidence(): float
|
||||
+is_confident(threshold: float): bool
|
||||
}
|
||||
|
||||
class InflammationPrediction {
|
||||
-prediction_id: UUID
|
||||
-job_id: UUID
|
||||
-step_id: UUID
|
||||
-detected: bool
|
||||
-confidence: float
|
||||
--
|
||||
+is_detected(): bool
|
||||
+get_confidence(): float
|
||||
}
|
||||
|
||||
class SegmentationMask {
|
||||
-mask_id: UUID
|
||||
-job_id: UUID
|
||||
-step_id: UUID
|
||||
-storage_reference: str
|
||||
-overlay_reference: str
|
||||
-color_legend: dict
|
||||
-metadata: dict
|
||||
--
|
||||
+get_mask_data(): bytes
|
||||
+get_overlay_reference(): str
|
||||
+get_color_legend(): dict
|
||||
+get_classes(): list[str]
|
||||
}
|
||||
|
||||
class Measurement {
|
||||
-measurement_id: UUID
|
||||
-job_id: UUID
|
||||
-step_id: UUID
|
||||
-thickness_mm: float | None
|
||||
-pixel_to_mm_ratio: float
|
||||
-roi_specification: dict
|
||||
-created_at: datetime
|
||||
--
|
||||
+get_thickness_mm(): float | None
|
||||
+get_pixel_to_mm_ratio(): float
|
||||
+calculate_area(pixels: int): float
|
||||
+get_roi_specification(): dict
|
||||
}
|
||||
|
||||
class SynovitisGrade {
|
||||
-grade_id: UUID
|
||||
-job_id: UUID
|
||||
-step_id: UUID
|
||||
-level: int
|
||||
-label: str
|
||||
-combined_score: float | None
|
||||
-confidence: float | None
|
||||
--
|
||||
+get_level(): int
|
||||
+get_label(): str
|
||||
+get_combined_score(): float | None
|
||||
+get_confidence(): float | None
|
||||
+is_severe(): bool
|
||||
}
|
||||
|
||||
class ReviewDecision {
|
||||
-decision_id: UUID
|
||||
-session_id: UUID
|
||||
-job_id: UUID
|
||||
-reviewer_id: UUID
|
||||
-decision_type: str
|
||||
-justification: str | None
|
||||
-created_at: datetime
|
||||
--
|
||||
+is_approved(): bool
|
||||
+is_corrected(): bool
|
||||
+is_rejected(): bool
|
||||
+get_justification(): str | None
|
||||
}
|
||||
|
||||
class ArtifactReference {
|
||||
-reference_id: UUID
|
||||
-artifact_type: str
|
||||
-associated_entity_id: UUID
|
||||
-storage_key: str
|
||||
-content_type: str
|
||||
-created_at: datetime
|
||||
--
|
||||
+get_storage_key(): str
|
||||
+get_content_type(): str
|
||||
+get_entity_id(): UUID
|
||||
}
|
||||
|
||||
class AuditLedgerEntry {
|
||||
-entry_id: UUID
|
||||
-entity_type: str
|
||||
-entity_id: UUID
|
||||
-action: str
|
||||
-user_id: UUID | None
|
||||
-checksum: str
|
||||
-metadata: dict
|
||||
-timestamp: datetime
|
||||
--
|
||||
+get_action(): str
|
||||
+get_entity(): str
|
||||
+verify_checksum(payload: dict): bool
|
||||
+to_immutable(): AuditLedgerEntry
|
||||
}
|
||||
}
|
||||
|
||||
package "Agents / Services" {
|
||||
class DICOMIngestAgent {
|
||||
+ingest(source: UploadFile): ScanFrame
|
||||
+validate_dicom(data: bytes): bool
|
||||
+extract_metadata(data: bytes): dict
|
||||
+extract_frames(data: bytes): List[bytes]
|
||||
}
|
||||
|
||||
class ImageUploadIngestAgent {
|
||||
+ingest(source: UploadFile): ScanFrame
|
||||
+validate_image(data: bytes): bool
|
||||
+extract_metadata(data: bytes): dict
|
||||
}
|
||||
|
||||
class FramePreprocessor {
|
||||
+preprocess(frame: ScanFrame): PreprocessedImage
|
||||
+apply_clahe(image: bytes): bytes
|
||||
+normalize(image: bytes): bytes
|
||||
+resize(image: bytes, size: tuple[int, int]): bytes
|
||||
}
|
||||
|
||||
class AngleValidatorAgent {
|
||||
+validate(prediction: AnglePrediction): AnglePrediction
|
||||
+adjust_confidence(prediction: AnglePrediction, adjustment: float): AnglePrediction
|
||||
+check_clinical_rules(angle_class: str): bool
|
||||
}
|
||||
|
||||
class ROICropperAgent {
|
||||
+crop_for_inflammation(image: PreprocessedImage): PreprocessedImage
|
||||
+crop_for_segmentation(image: PreprocessedImage, angle: str): PreprocessedImage
|
||||
+extract_bounding_box(image: bytes): dict
|
||||
}
|
||||
|
||||
class VisionPipelineAgent {
|
||||
+run_pipeline(session: DiagnosticSession, frames: List[ScanFrame]): AnalysisJob
|
||||
+coordinate_models(frames: List[ScanFrame], models: dict): dict
|
||||
+should_apply_inflammation(angle: str): bool
|
||||
+should_apply_segmentation(angle: str): bool
|
||||
}
|
||||
|
||||
class InferenceRunner {
|
||||
+infer(model: ModelReference, image: bytes): dict
|
||||
+load_model(model_id: str, version: str): void
|
||||
+unload_model(model_id: str): void
|
||||
+get_model_status(model_id: str): str
|
||||
}
|
||||
|
||||
class MeasurementAgent {
|
||||
+measure(mask: SegmentationMask, calibration: Calibration): Measurement
|
||||
+calculate_thickness(mask: bytes, ratio: float): float
|
||||
+calculate_roi(mask: bytes): dict
|
||||
+validate_measurement(measurement: Measurement): bool
|
||||
}
|
||||
|
||||
class SeverityScorerAgent {
|
||||
+score(measurement: Measurement, inflammation: InflammationPrediction): SynovitisGrade
|
||||
+calculate_combined_score(thickness: float, detected: bool): float
|
||||
+get_grade_label(score: float): str
|
||||
+validate_grade(grade: SynovitisGrade): bool
|
||||
}
|
||||
|
||||
class ModelRegistryAgent {
|
||||
+register_model(entry: ModelRegistryEntry, artifact: ModelArtifact): ModelRegistryEntry
|
||||
+get_model(task: str, version: str = "latest"): ModelReference
|
||||
+list_models(): List[ModelRegistryEntry]
|
||||
+activate_model(model_id: str): void
|
||||
+deactivate_model(model_id: str): void
|
||||
+verify_artifact(model_id: str, checksum: str): bool
|
||||
}
|
||||
|
||||
class ArtifactStoreAgent {
|
||||
+store_artifact(artifact_id: UUID, data: bytes, content_type: str): str
|
||||
+retrieve_artifact(storage_key: str): bytes
|
||||
+delete_artifact(storage_key: str): void
|
||||
+generate_presigned_url(storage_key: str, expires_in: int = 3600): str
|
||||
+verify_integrity(storage_key: str, checksum: str): bool
|
||||
}
|
||||
|
||||
class LedgerWriterAgent {
|
||||
+write(event_type: str, entity_type: str, entity_id: UUID, payload: dict, user_id: UUID | None = None): AuditLedgerEntry
|
||||
+verify_integrity(entry: AuditLedgerEntry): bool
|
||||
+query_by_entity(entity_type: str, entity_id: UUID): List[AuditLedgerEntry]
|
||||
}
|
||||
}
|
||||
|
||||
package "Adapters" {
|
||||
class FrameStorageAdapter {
|
||||
{abstract} +store_frame(frame_id: UUID, data: bytes, content_type: str) -> str
|
||||
{abstract} +generate_presigned_url(storage_key: str, expires_in: int) -> str
|
||||
{abstract} +delete_frame(storage_key: str) -> None
|
||||
}
|
||||
|
||||
class ArtifactStorageAdapter {
|
||||
{abstract} +store_artifact(artifact_id: UUID, data: bytes, content_type: str) -> str
|
||||
{abstract} +retrieve_artifact(storage_key: str) -> bytes
|
||||
}
|
||||
|
||||
class InferenceAdapter {
|
||||
{abstract} +load_model(model_reference: str) -> None
|
||||
{abstract} +infer(input_data: bytes) -> dict
|
||||
{abstract} +unload_model(model_reference: str) -> None
|
||||
}
|
||||
|
||||
class PyTorchAdapter {
|
||||
+load_model(model_reference: str) -> None
|
||||
+infer(input_data: bytes) -> dict
|
||||
+unload_model(model_reference: str) -> None
|
||||
}
|
||||
|
||||
class TritonAdapter {
|
||||
+load_model(model_reference: str) -> None
|
||||
+infer(input_data: bytes) -> dict
|
||||
+unload_model(model_reference: str) -> None
|
||||
}
|
||||
|
||||
class MockAdapter {
|
||||
+load_model(model_reference: str) -> None
|
||||
+infer(input_data: bytes) -> dict
|
||||
+unload_model(model_reference: str) -> None
|
||||
}
|
||||
}
|
||||
|
||||
' Relationships: Domain Objects
|
||||
PatientCase "1" --> "*" DiagnosticSession : has
|
||||
DiagnosticSession "1" --> "*" ScanFrame : contains
|
||||
DiagnosticSession "1" --> "*" AnalysisJob : initiated
|
||||
DiagnosticSession "1" --> "*" ReviewDecision : reviewed_by
|
||||
DiagnosticSession "1" --> "*" Calibration : has
|
||||
DiagnosticSession "*" --> "1" ClinicianUser : conducted_by
|
||||
ScanFrame "1" --> "1" ImageAsset : stored_as
|
||||
ScanFrame "1" --> "1" PreprocessedImage : becomes
|
||||
AnalysisJob "1" --> "*" PipelineStep : consists_of
|
||||
AnalysisJob "1" --> "*" AnglePrediction : produces
|
||||
AnalysisJob "1" --> "*" InflammationPrediction : produces
|
||||
AnalysisJob "1" --> "*" SegmentationMask : produces
|
||||
AnalysisJob "1" --> "*" Measurement : produces
|
||||
AnalysisJob "1" --> "*" SynovitisGrade : produces
|
||||
ModelRegistryEntry "1" --> "*" ModelArtifact : has
|
||||
PipelineStep "*" --> "1" ModelRegistryEntry : uses
|
||||
ArtifactReference "1" --> "1" ScanFrame : references
|
||||
|
||||
' Relationships: Agents depend on Adapters
|
||||
DICOMIngestAgent --> FrameStorageAdapter : uses
|
||||
ImageUploadIngestAgent --> FrameStorageAdapter : uses
|
||||
ArtifactStoreAgent --> FrameStorageAdapter : uses
|
||||
ArtifactStoreAgent --> ArtifactStorageAdapter : uses
|
||||
InferenceRunner --> InferenceAdapter : uses
|
||||
ModelRegistryAgent --> ArtifactStorageAdapter : uses
|
||||
|
||||
' Relationships: Agents operate on Domain Objects
|
||||
DICOMIngestAgent --> ScanFrame : creates
|
||||
DICOMIngestAgent --> ImageAsset : creates
|
||||
ImageUploadIngestAgent --> ScanFrame : creates
|
||||
ImageUploadIngestAgent --> ImageAsset : creates
|
||||
FramePreprocessor --> ScanFrame : reads
|
||||
FramePreprocessor --> PreprocessedImage : creates
|
||||
AngleValidatorAgent --> AnglePrediction : validates
|
||||
ROICropperAgent --> PreprocessedImage : modifies
|
||||
VisionPipelineAgent --> AnalysisJob : orchestrates
|
||||
InferenceRunner --> AnalysisJob : populates
|
||||
MeasurementAgent --> SegmentationMask : reads
|
||||
MeasurementAgent --> Calibration : uses
|
||||
MeasurementAgent --> Measurement : creates
|
||||
SeverityScorerAgent --> InflammationPrediction : reads
|
||||
SeverityScorerAgent --> Measurement : reads
|
||||
SeverityScorerAgent --> SynovitisGrade : creates
|
||||
ModelRegistryAgent --> ModelRegistryEntry : manages
|
||||
ModelRegistryAgent --> ModelArtifact : manages
|
||||
LedgerWriterAgent --> AuditLedgerEntry : creates
|
||||
|
||||
' Relationships: Adapters
|
||||
PyTorchAdapter ..|> InferenceAdapter
|
||||
TritonAdapter ..|> InferenceAdapter
|
||||
MockAdapter ..|> InferenceAdapter
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
The diagram above shows:
|
||||
- **19 Domain Objects** with their attributes, methods, and relationships
|
||||
- **12 Agents/Services** with their interface methods and collaborators
|
||||
- **3 Adapter hierarchies** (Storage and Inference) showing abstraction relationships
|
||||
- **Dependency arrows** showing what objects depend on what adapters or other objects
|
||||
|
||||
Key relationships:
|
||||
- `PatientCase` 1→* `DiagnosticSession` (case has many sessions)
|
||||
- `DiagnosticSession` 1→* `ScanFrame` (session has many frames)
|
||||
- `AnalysisJob` 1→* `PipelineStep` (job has many steps)
|
||||
- All prediction/measurement objects belong to exactly one `AnalysisJob`
|
||||
- Agents depend on adapters (e.g., `DICOMIngestAgent` uses `FrameStorageAdapter`)
|
||||
|
||||
Legend:
|
||||
- `-->` : Association (uses or references)
|
||||
- `..|>` : Realization (implements interface)
|
||||
- Package groupings: Domain Objects, Agents/Services, Adapters
|
||||
89
workspace/sprint_1_2/CODEBASE/data/spec/schemas/__init__.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from .auth_schemas import Token, TokenPayload, LoginRequest, UserProfile, UserUpdateRequest, RefreshRequest
|
||||
from .patient_schemas import Patient, PatientCreate, PatientListResponse, DemographicInfo
|
||||
from .session_schemas import (
|
||||
Session, SessionCreate, SessionDetail, SessionPatchReview,
|
||||
FrameMetadata, PersistResult, ExportResult, ScrubResult,
|
||||
)
|
||||
from .analysis_schemas import (
|
||||
AnalysisJobSubmit, AnalysisJobSyncSubmit, JobStatus, PipelineStep,
|
||||
StepEvent, JobResult, ModelRegistryEntry, ModelCatalog,
|
||||
ModelRegistrationResult,
|
||||
)
|
||||
from .telemetry_schemas import CorrectionSubmit, CorrectionRecord, AnomalyReport, AnomalyRecord
|
||||
from .report_schemas import ReportCreate, ReportSignRequest, ReportSyncEMRRequest, SyncResult
|
||||
from .safety_schemas import (
|
||||
GradCAMRequest, HeatmapResult, RationaleRequest, RationaleResult,
|
||||
CircuitBreakerRequest, ChatStreamRequest, ChatEvent, ChatResponse,
|
||||
DriftCheckResult, RAGEvidenceRequest, EvidenceList, ActivationMeta,
|
||||
AnnotationArtifact, GroundTruthLabel, EscalationRequest, EscalationTicket,
|
||||
MorphologyAnnotation, GuardrailCheckRequest, GuardrailResult,
|
||||
)
|
||||
from .notification_schemas import NotificationItem, NotificationPreferences
|
||||
from .settings_schemas import UserSettings, SettingsUpdate
|
||||
from .ingestion_schemas import IngestionRecord, RecordDetail
|
||||
from .common_schemas import HealthStatus, ErrorResponse
|
||||
|
||||
__all__ = [
|
||||
"Token",
|
||||
"TokenPayload",
|
||||
"LoginRequest",
|
||||
"UserProfile",
|
||||
"UserUpdateRequest",
|
||||
"RefreshRequest",
|
||||
"Patient",
|
||||
"PatientCreate",
|
||||
"PatientListResponse",
|
||||
"DemographicInfo",
|
||||
"Session",
|
||||
"SessionCreate",
|
||||
"SessionDetail",
|
||||
"SessionPatchReview",
|
||||
"FrameMetadata",
|
||||
"PersistResult",
|
||||
"ExportResult",
|
||||
"ScrubResult",
|
||||
"AnalysisJobSubmit",
|
||||
"AnalysisJobSyncSubmit",
|
||||
"JobStatus",
|
||||
"PipelineStep",
|
||||
"StepEvent",
|
||||
"JobResult",
|
||||
"ModelRegistryEntry",
|
||||
"ModelCatalog",
|
||||
"ModelRegistrationResult",
|
||||
"CorrectionSubmit",
|
||||
"CorrectionRecord",
|
||||
"AnomalyReport",
|
||||
"AnomalyRecord",
|
||||
"ReportCreate",
|
||||
"ReportSignRequest",
|
||||
"ReportSyncEMRRequest",
|
||||
"SyncResult",
|
||||
"GradCAMRequest",
|
||||
"HeatmapResult",
|
||||
"RationaleRequest",
|
||||
"RationaleResult",
|
||||
"CircuitBreakerRequest",
|
||||
"ChatStreamRequest",
|
||||
"ChatEvent",
|
||||
"ChatResponse",
|
||||
"DriftCheckResult",
|
||||
"RAGEvidenceRequest",
|
||||
"EvidenceList",
|
||||
"ActivationMeta",
|
||||
"AnnotationArtifact",
|
||||
"GroundTruthLabel",
|
||||
"EscalationRequest",
|
||||
"EscalationTicket",
|
||||
"MorphologyAnnotation",
|
||||
"GuardrailCheckRequest",
|
||||
"GuardrailResult",
|
||||
"NotificationItem",
|
||||
"NotificationPreferences",
|
||||
"UserSettings",
|
||||
"SettingsUpdate",
|
||||
"IngestionRecord",
|
||||
"RecordDetail",
|
||||
"HealthStatus",
|
||||
"ErrorResponse",
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AnalysisJobSubmit(BaseModel):
|
||||
session_id: str
|
||||
params: dict[str, Any] | None = None
|
||||
model_versions: dict[str, str] | None = None
|
||||
|
||||
|
||||
class AnalysisJobSyncSubmit(BaseModel):
|
||||
session_id: str
|
||||
params: dict[str, Any] | None = None
|
||||
model_versions: dict[str, str] | None = None
|
||||
|
||||
|
||||
class PipelineStep(BaseModel):
|
||||
step_id: str
|
||||
job_id: str
|
||||
task_type: str
|
||||
status: str
|
||||
output: dict | None = None
|
||||
duration_ms: int | None = None
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class JobStatus(BaseModel):
|
||||
job_id: str
|
||||
session_id: str
|
||||
status: str
|
||||
result: dict | None = None
|
||||
steps: list[PipelineStep] | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StepEvent(BaseModel):
|
||||
step_id: str
|
||||
job_id: str
|
||||
event_type: str
|
||||
task_type: str
|
||||
status: str
|
||||
data: dict | None = None
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class JobResult(BaseModel):
|
||||
job_id: str
|
||||
session_id: str
|
||||
status: str
|
||||
result: dict | None = None
|
||||
duration_ms: int | None = None
|
||||
|
||||
|
||||
class ModelRegistryEntry(BaseModel):
|
||||
model_id: str
|
||||
name: str
|
||||
task_type: str
|
||||
version: str
|
||||
description: str
|
||||
framework: str
|
||||
labels: list[str]
|
||||
registered_at: datetime
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ModelCatalog(BaseModel):
|
||||
models: list[ModelRegistryEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class ModelRegistrationResult(BaseModel):
|
||||
model_id: str
|
||||
status: str
|
||||
s3_key: str
|
||||
registered_at: datetime
|
||||
@@ -0,0 +1,37 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: str
|
||||
exp: int
|
||||
role: str = "clinician"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
user_id: str
|
||||
username: str
|
||||
name: str
|
||||
role: str
|
||||
credentials: dict | None = None
|
||||
specialization: str | None = None
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
specialization: str | None = None
|
||||
credentials: dict | None = None
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HealthStatus(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
dependencies: dict[str, str]
|
||||
uptime_seconds: float
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
code: str | None = None
|
||||
@@ -0,0 +1,22 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class IngestionRecord(BaseModel):
|
||||
record_id: str
|
||||
user_id: str
|
||||
patient_id: str
|
||||
session_id: str | None = None
|
||||
filename: str
|
||||
file_type: str
|
||||
size_bytes: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class RecordDetail(IngestionRecord):
|
||||
s3_key: str
|
||||
checksum: str
|
||||
frame_count: int | None = None
|
||||