46 Commits

Author SHA1 Message Date
DatTT127
d904c775c7 update the backend_ecr workflow
Some checks failed
Backend EC - Modal Deployment / notify-deploy (push) Has been cancelled
Backend EC - Modal Deployment / build-and-push (push) Has been cancelled
2026-07-18 21:13:18 +07:00
DatTT127
12819ab579 update the backend_build_script
Some checks failed
Backend EC - Modal Deployment / build-and-push (push) Failing after 27s
Backend EC - Modal Deployment / notify-deploy (push) Has been cancelled
2026-07-18 21:09:33 +07:00
DatTT127
77aa5301bc test update
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 39s
Backend ECR Deployment / notify-deploy (push) Has been skipped
2026-07-18 18:57:31 +07:00
DatTT127
f335c9e6f2 update
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 25s
Backend ECR Deployment / notify-deploy (push) Has been skipped
2026-07-18 18:55:22 +07:00
DatTT127
5992d476f9 update
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 33s
Backend ECR Deployment / notify-deploy (push) Has been skipped
2026-07-18 18:52:18 +07:00
DatTT127
f6c57f745b update
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 28s
Backend ECR Deployment / notify-deploy (push) Has been skipped
2026-07-18 18:50:54 +07:00
DatTT127
4f2f34b559 update
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 25s
Backend ECR Deployment / notify-deploy (push) Has been skipped
2026-07-18 18:49:17 +07:00
DatTT127
a48afe12e4 update the the workflow 3
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 27s
Backend ECR Deployment / notify-deploy (push) Has been skipped
2026-07-18 18:44:03 +07:00
DatTT127
bc03eaed5d update the the workflow 3 2026-07-18 18:43:31 +07:00
DatTT127
1546389cae update the workflow 2
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 30s
Backend ECR Deployment / notify-deploy (push) Has been skipped
Backend Modal Build / Build & Push via Modal (push) Has been cancelled
Backend Modal Build / notify-deploy (push) Has been cancelled
2026-07-18 18:40:52 +07:00
DatTT127
75a8813999 test workflows
Some checks failed
Backend ECR Deployment / build-and-push (push) Failing after 4m4s
Frontend CI/CD / build-and-push (push) Failing after 1m55s
Backend ECR Deployment / notify-deploy (push) Has been skipped
Frontend CI/CD / notify-deploy (push) Has been skipped
Backend Modal Build / Build & Push via Modal (push) Has been cancelled
Backend Modal Build / notify-deploy (push) Has been cancelled
2026-07-18 18:15:49 +07:00
DatTT127
eae7bd1957 test workflow 1 2026-07-18 18:11:53 +07:00
DatTT127
1e9a564ad8 update the test_gitea 2026-07-18 18:09:53 +07:00
DatTT127
57a8bac1be test_workflow
Some checks failed
Backend ECR Deployment / build-and-push (push) Has been cancelled
Backend ECR Deployment / notify-deploy (push) Has been cancelled
Backend Modal Build / notify-deploy (push) Has been cancelled
Backend Modal Build / Build & Push via Modal (push) Has been cancelled
2026-07-18 17:48:19 +07:00
DatTT127
7d5c583475 update the modal deployment logic
All checks were successful
Triton Modal Trigger / deploy-to-modal (push) Successful in 11s
2026-07-17 13:51:58 +07:00
DatTT127
196d243e03 update the modal triton workflow
Some checks failed
Triton Modal Trigger / deploy-to-modal (push) Failing after 7s
2026-07-17 13:34:34 +07:00
DatTT127
77c32e93cd update the modal triton workflow
All checks were successful
Triton Modal Trigger / deploy-to-modal (push) Successful in 4s
2026-07-17 13:33:40 +07:00
DatTT127
f011435ab0 update the modal triton workflow
All checks were successful
Triton Modal Trigger / deploy-to-modal (push) Successful in 5s
2026-07-17 13:33:10 +07:00
DatTT127
f55d628e8d update the modal triton workflow
All checks were successful
Triton Modal Trigger / deploy-to-modal (push) Successful in 6s
2026-07-17 13:32:12 +07:00
DatTT127
f35329d6c0 update the modal triton workflow
All checks were successful
Triton Modal Trigger / deploy-to-modal (push) Successful in 8s
2026-07-17 13:30:58 +07:00
DatTT127
53980e2afc update the workflow
Some checks failed
Triton Modal Trigger / deploy-to-modal (push) Failing after 8s
2026-07-17 13:28:28 +07:00
DatTT127
86b135fdf0 update the modal-triton workflows
Some checks failed
Triton Modal Trigger / deploy-to-modal (push) Failing after 7s
2026-07-17 13:27:02 +07:00
DatTT127
9dce7ff426 update workflow
Some checks failed
Triton Modal Trigger / deploy-to-modal (push) Failing after 8s
2026-07-17 13:22:17 +07:00
DatTT127
900e2bb68b update the triton modal trigger
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Successful in 22s
Triton Modal Trigger / deploy-to-modal (push) Failing after 8s
2026-07-17 13:17:02 +07:00
DatTT127
06197447f7 update_again
All checks were successful
Test Gitea Secrets / deploy-to-modal (push) Successful in 19s
2026-07-17 00:29:00 +07:00
DatTT127
d38ca6bf23 update_again
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 16s
2026-07-17 00:26:13 +07:00
DatTT127
0f7f0dddd8 update_again
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 14s
2026-07-17 00:24:06 +07:00
DatTT127
bbdc205be2 update_again
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 18s
2026-07-17 00:22:57 +07:00
DatTT127
4906005cb9 update_again
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 15s
2026-07-17 00:22:00 +07:00
DatTT127
ee1ae91a7a update
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 13s
2026-07-17 00:17:12 +07:00
DatTT127
7913119351 update the test_secret.yaml
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 16s
2026-07-17 00:02:14 +07:00
DatTT127
2635ddfe8f update the test
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 32s
2026-07-16 23:59:43 +07:00
DatTT127
7b9db01f2a update the workflow test
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 16s
2026-07-16 23:41:57 +07:00
DatTT127
1e6953d5a1 update the workflows test
Some checks failed
Test Gitea Secrets / deploy-to-modal (push) Failing after 25s
2026-07-16 23:28:23 +07:00
DatTT127
f67fb7a135 update test_secret.yaml
Some checks failed
Test Gitea Secrets / print-secret (push) Failing after 3s
2026-07-16 22:54:32 +07:00
DatTT127
f896e5c932 update test_secret.yaml
Some checks failed
Test Gitea Secrets / print-secret (push) Failing after 10s
2026-07-16 22:50:40 +07:00
DatTT127
94f029eb19 update test_secret.yaml
Some checks failed
Test Gitea Secrets / print-secret (push) Failing after 12s
2026-07-16 22:48:09 +07:00
DatTT127
45972daf29 update test_secret.yaml
Some checks failed
Test Gitea Secrets / print-secret (push) Failing after 11s
2026-07-16 22:46:28 +07:00
DatTT127
8a8f91d99d update the test_gitea
All checks were successful
Test Gitea Secrets / print-secret (push) Successful in 50s
2026-07-16 22:34:16 +07:00
DatTT127
2e439d2787 include the test gitea secrets
Some checks failed
Test Gitea Secrets / print-secret (push) Failing after 2m41s
2026-07-16 16:41:32 +07:00
David Tran
7af1553032 Merge pull request #7 from DTJ-Tran/poc_1_3
add the session memory
2026-07-15 23:30:27 +07:00
DatTT127
1f6b015815 add the session memory 2026-07-15 23:29:26 +07:00
David Tran
eed2d39c2d Merge pull request #6 from DTJ-Tran/poc_1_3
update the cv modal inference proxy server with optimization
2026-07-15 23:25:26 +07:00
DatTT127
1f5e71b46a update the cv modal inference proxy server with optimization 2026-07-15 23:24:34 +07:00
David Tran
3435cfefa0 Merge pull request #5 from DTJ-Tran/poc2
update session_memory - for refer to the passwork
2026-07-07 22:03:18 +07:00
David Tran
4d89e55d86 Merge pull request #4 from DTJ-Tran/poc2
update the requirements.txt
2026-07-07 16:17:22 +07:00
89 changed files with 5912 additions and 562 deletions

View File

@@ -0,0 +1,124 @@
# Gitea Actions Workflow: Backend ECR Deployment
# Place at: .gitea/workflows/backend-ecr.yaml (in repository root)
name: Backend EC - Modal Deployment
on:
push:
branches: ['**']
workflow_dispatch: {}
permissions:
contents: read
packages: write
env:
# Build-time / workflow context
BACKEND_DIR: workspace/sprint_1_2/CODEBASE/backend
ECR_REGISTRY: public.ecr.aws
ECR_REPOSITORY: ${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}/msk-cv-inference-server
DOCKERFILE_PATH: deps/implementation/backend_deploy/Dockerfile
MODAL_SCRIPT: workspace/sprint_1_2/CODEBASE/deps/implementation/backend_deploy/gitea_modal_build.py
# CV inference server runtime configuration
# These are Gitea repository variables (or secrets) that are forwarded
# to the container at deploy time. The Docker image itself does NOT bake
# them in — the FastAPI app reads them from the environment via pydantic-settings.
TRITON_ENDPOINT: ${{ vars.TRITON_ENDPOINT }}
CV_INFERENCE_HOST: ${{ vars.CV_INFERENCE_HOST }}
CV_INFERENCE_PORT: ${{ vars.CV_INFERENCE_PORT }}
BASE_URL: ${{ vars.BASE_URL }}
CORS_ORIGINS: ${{ vars.CORS_ORIGINS }}
ECR_PUBLIC_REGISTRY_ALIAS: ${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}
jobs:
build-and-push:
runs-on: ubuntu-latest
container:
image: gitea-modal-runner:latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Lint backend code
run: |
python -m py_compile workspace/sprint_1_2/CODEBASE/backend/cv_inference_server.py
find workspace/sprint_1_2/CODEBASE/backend -name '*.py' -exec python -m py_compile {} +
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
# Full ECR Public image reference: public.ecr.aws/{alias}/{repo}
images: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.title=VKIST CV Inference Server
- name: Print image URI and digest
run: |
echo "Pushed ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.version }}"
echo "Digest: ${{ steps.meta.outputs.tags }}"
- name: Determine image tag
id: tag
run: |
if [ -n "${{ github.event.inputs.tag }}" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
elif [ "${{ github.ref_type }}" = "tag" ]; then
echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
fi
echo "tag=latest" >> $GITHUB_OUTPUT
- name: Build and push via Modal
id: modal-build
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_KEY }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_SECRET }}
run: |
modal token set --token-id ${{ env.MODAL_TOKEN_ID }} \
--token-secret ${{ env.MODAL_TOKEN_SECRET }}
modal run ${{ env.MODAL_SCRIPT }} \
--registry ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }} \
--image-name "" \
--tag ${{ steps.tag.outputs.tag }} \
--platform linux/amd64 \
--push
notify-deploy:
needs: modal-build
if: success() && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Trigger deployment webhook
if: env.DEPLOY_WEBHOOK_URL != ''
env:
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
run: |
cat <<EOF | curl -X POST "${DEPLOY_WEBHOOK_URL}" \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: sha256=$(echo -n '@-' | openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" | cut -d' ' -f2)" \
-d @-
{
"service": "cv-inference-server",
"image": "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.tag.outputs.tag }}",
"env": {
"TRITON_ENDPOINT": "${{ vars.TRITON_ENDPOINT }}",
"CV_INFERENCE_HOST": "${{ vars.CV_INFERENCE_HOST }}",
"CV_INFERENCE_PORT": "${{ vars.CV_INFERENCE_PORT }}",
"BASE_URL": "${{ vars.BASE_URL }}",
"CORS_ORIGINS": "${{ vars.CORS_ORIGINS }}",
"ECR_PUBLIC_REGISTRY_ALIAS": "${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}"
}
}
EOF

View File

@@ -0,0 +1,108 @@
# Gitea Actions Workflow: Frontend CI/CD to Amazon ECR Public
# Place at: .gitea/workflows/frontend.yaml (in repository root)
name: Frontend CI/CD
on:
# push:
# branches: ['**']
workflow_dispatch: {}
permissions:
contents: read
packages: write
env:
FRONTEND_DIR: workspace/sprint_1_2/CODEBASE/frontend/implementation
ECR_REGISTRY: public.ecr.aws
ECR_REPOSITORY: ${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}/msk_lumina_frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
# - name: Configure AWS credentials
# uses: aws-actions/configure-aws-credentials@v4
# with:
# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# aws-region: us-east-1
# - name: Login to Amazon ECR Public
# id: login-ecr
# uses: aws-actions/amazon-ecr-login@v2
# with:
# registry-type: public
# mask-password: 'true'
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20'
# cache: 'npm'
# cache-dependency-path: ${{ env.FRONTEND_DIR }}/package-lock.json
# - name: Install dependencies
# run: npm ci
# working-directory: ./${{ env.FRONTEND_DIR }}
# - name: Build frontend (verify)
# run: npm run build
# working-directory: ./${{ env.FRONTEND_DIR }}
# - name: Extract metadata for Docker
# id: meta
# uses: docker/metadata-action@v5
# with:
# images: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}
# tags: |
# type=ref,event=branch
# type=sha,prefix=
# type=raw,value=latest,enable={{is_default_branch}}
# labels: |
# org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
# org.opencontainers.image.revision=${{ github.sha }}
# org.opencontainers.image.title=Lumina MSK Frontend
# - name: Build and push Docker image
# uses: docker/build-push-action@v5
# with:
# context: ./${{ env.FRONTEND_DIR }}
# file: ./${{ env.FRONTEND_DIR }}/Dockerfile
# push: true
# tags: ${{ steps.meta.outputs.tags }}
# labels: ${{ steps.meta.outputs.labels }}
# cache-from: type=gha
# cache-to: type=gha,mode=max
# - name: Image digest
# run: echo "Pushed ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tags }}"
notify-deploy:
needs: build-and-push
if: success() && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Trigger Lightsail deployment webhook
if: env.DEPLOY_WEBHOOK_URL != ''
env:
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
run: |
cat <<EOF | curl -X POST "${DEPLOY_WEBHOOK_URL}" \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: sha256=$(echo -n '@-' | openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" | cut -d' ' -f2)" \
-d @-
{
"service": "lumina-frontend",
"image": "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.version }}",
"env": {
"ECR_PUBLIC_REGISTRY_ALIAS": "${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}",
"CORS_ORIGINS": "${{ vars.CORS_ORIGINS }}"
}
}
EOF

View File

@@ -0,0 +1,38 @@
name: Test Gitea Secrets
on: workflow_dispatch
jobs:
deploy-to-modal:
runs-on: ubuntu-latest
container:
image: gitea-modal-runner:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Debug Modal Credentials
run: |
echo "DEBUG: Token ID is: '${MODAL_TOKEN_ID}'"
echo "DEBUG: Token ID length is: ${#MODAL_TOKEN_ID}"
# Use 'cut' to get the first 5 characters safely
FIRST_5=$(echo "$MODAL_TOKEN_ID" | cut -c1-5)
echo "DEBUG: First 5 chars are: '$FIRST_5'"
env:
# This maps the Gitea Repository Secret to an Environment Variable
MODAL_TOKEN_ID: ${{ secrets.MODAL_KEY }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_SECRET }}
- name: Deploy worker pipeline to Modal
run: |
echo "DEBUG: Token ID is: '${MODAL_TOKEN_ID}'"
echo "DEBUG: Token ID length is: ${#MODAL_TOKEN_ID}"
# Use 'cut' to get the first 5 characters safely
FIRST_5=$(echo "$MODAL_TOKEN_ID" | cut -c1-5)
echo "DEBUG: First 5 chars are: '$FIRST_5'"
cd workspace/sprint_1_2/CODEBASE/deps/implementation
modal deploy test_worker.py
env:
# This maps the Gitea Repository Secret to an Environment Variable
MODAL_TOKEN_ID: ${{ secrets.MODAL_KEY }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_SECRET }}

View File

@@ -0,0 +1,29 @@
# What is the purpose of this workflows
# for a modal GPU instance that hosting the CV model's on Nvidia Triton
name: Triton Modal Trigger
on: workflow_dispatch # set to on [push] for check after each code push what happens
jobs:
deploy-to-modal:
runs-on: ubuntu-latest
container:
image: gitea-modal-runner:latest # where build the code
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
sparse-checkout: |
workspace/sprint_1_2/CODEBASE/infra/implementation/triton_run/*
sparse-checkout-cone-mode: false
- name: Deploy Triton model to Modal
run: |
echo "Deploying Triton..."
cd workspace/sprint_1_2/CODEBASE/infra/implementation/triton_run
modal deploy modal_triton.py
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_KEY }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_SECRET }}

7
.gitignore vendored
View File

@@ -227,7 +227,6 @@ Policy_Analysis/
package-lock.json
package.json
.env.development
dist/
run.sh
workspace/sprint_1_2/CODEBASE/knowledge/implementation/model/
*.onnx_data
@@ -236,3 +235,9 @@ workspace/sprint_1_2/CODEBASE/knowledge/implementation/model/
*.safetensors
*.pt
*.pth
*.pdf
*.xz
logs/
*.rdb
dist/
distribute_asset/*

View File

@@ -0,0 +1,654 @@
# VKIST MSK Ultrasound Stack — Investment Proposal & System Specification
> **Audience:** CTO / Department Head / Executive Sponsor (technical-fluent, outcome-driven, risk-averse on healthcare investments)
> **Author:** VKIST MSK Pilot Engineering Team
> **Status:** Pilot proposal — active cycle June 2 September 2, 2026 (strict 3-month window)
> **Reading time:** Section 0 (Executive Summary) is a self-contained one-pager. Sections 18 provide the full technical and financial justification.
---
## Table of Contents
0. [Executive Summary (one page)](#0-executive-summary-one-page)
1. [Project Context — Who, What Pain, What Constraints](#1-project-context)
2. [How the System Addresses the Problem](#2-how-the-system-addresses-the-problem)
3. [Qualitative ROI — Behavioral & Workflow Change](#3-qualitative-roi--behavioral--workflow-change)
4. [Quantitative ROI — Projected Metrics & Measurement Plan](#4-quantitative-roi--projected-metrics--measurement-plan)
5. [Risk & Failure-Mode Handling — Why This Is Not Risky](#5-risk--failure-mode-handling--why-this-is-not-risky)
6. [Compliance & Data Governance](#6-compliance--data-governance)
7. [Roadmap & Milestones](#7-roadmap--milestones)
8. [Investment Ask & Scope Boundaries](#8-investment-ask--scope-boundaries)
---
## 0. Executive Summary (one page)
**The problem, in one sentence.** Vietnamese public-hospital musculoskeletal (MSK) care runs on clinicians processing 100+ evaluations per shift with no time for objective double-checks, no shared visibility across the radiologist → surgeon → physiotherapist → patient chain, and patients so information-starved that they turn to dangerous folk remedies — while VKIST's proven computer-vision research models sit idle as static academic checkpoints.
**What we are building.** The **VKIST MSK Ultrasound Stack** is an **air-gapped-first, single-hospital AI platform** that turns knee ultrasound scans into automated, explainable **synovitis grading (03)** with Vietnamese-language reasoning, every clinical statement **cited back to Ministry of Health (MOH) guidelines**, and a **human clinician signature required** before anything is finalized. It migrates VKIST's existing DeepLabv3/UNet/ConvNeXt vision models from research into a referenceable, production-grade medical software ecosystem.
**Why it is a sound investment (the four pillars):**
| Pillar | What it means for the leader |
| :--- | :--- |
| **Zero-friction adoption** | The AI is an *invisible background validation layer* — 0 extra clicks to see explanations (NFR-12/13). Clinicians keep their workflow; the system removes liability risk instead of adding work. |
| **Non-negotiable safety** | Human-in-the-Loop signature gate (NFR-19), 100% MOH-cited explanations (NFR-18), immutable audit log (NFR-17), and a 4-quadrant safety framework that explicitly defends against automation bias *and* AI hallucination. |
| **Regulatory sovereignty** | Runs inside the hospital LAN. Decree 13/2023 PII protection + Circular 46/2018 EMR compliance are built into the architecture, not bolted on. No patient data leaves the hospital in production. |
| **Capital efficiency** | Built on proven open-source (K3s, Triton, Postgres/pgvector, Keycloak, MinIO) and existing VKIST models. No SaaS lock-in, no per-seat licensing, delivered in a fixed 3-month pilot. |
**The ask.** Sponsorship of a fixed 3-month pilot on a single hospital's on-premise infrastructure (one Dell PowerEdge class node + one GPU node), a small cross-functional team, and a *governed, time-boxed* cloud allowance for the proof-of-concept LLM tiers (NFR-16a, retired at sign-off). See [Section 8](#8-investment-ask--scope-boundaries).
**The expected return.** A validated, referenceable clinical AI product that (projected) removes tedious measurement work from overloaded specialists, gives physiotherapists diagnostic visibility they have never had, converts patient anxiety into institutional trust, and establishes VKIST's operational credibility in the local healthcare community — with every claim instrumented and measurable during the pilot itself (see [Section 4](#4-quantitative-roi--projected-metrics--measurement-plan)).
> **Bottom line for the decision-maker:** This is a low-blast-radius, standards-compliant, single-site pilot that converts already-funded research into a safe, auditable clinical tool. The downside is bounded (one hospital, 3 months, open-source stack); the upside is a defensible product line and clinical-community trust.
---
## 1. Project Context
### 1.1 Who the System Is Designed For
The platform coordinates a **four-persona clinical care chain**. The pilot (Sprint 12, FR-25) is centered on the **Diagnostic Radiologist (UP5)** as the primary human actor, with the other three personas as governance/future-sprint stakeholders whose needs shape the non-functional requirements.
| Persona | Who they are | Environment | Core responsibility | What the system must respect |
| :--- | :--- | :--- | :--- | :--- |
| **UP5 — Diagnostic Radiologist** | Specialist MDs, age 2855, from digitally native attendings to department heads | Low-light reading rooms; massive daily DICOM backlogs; asynchronous, detached from patients | Translate images into definitive reports that gate all downstream care | *Invisible workflow preservation* — no extra clicks, no DICOM render lag, no uncompensated patient chat |
| **UP7 — Rheumatologist / Orthopedic Surgeon** | Treatment architects, age 3060; seniors control procurement | Split across OR, chaotic outpatient clinics (>100 patients/shift), inpatient wards | Synthesize fragmented data into a treatment plan; bear full legal/clinical liability | *Clinical force multiplier* — rapid dashboards, automated patient-education visuals, strictly gated communication |
| **UP6 — Physical Therapist** | Young (3640% aged 2029), highly stratified (≈54% vocational tier), digitally literate | Cramped (<20 m²), high-noise public units; 1120+ patients/shift; shared/scarce workstations | Execute physician scripts into kinetic rehab programs | *Digital exoskeleton* gel-proof knuckle-tap UI, zero-GPU mobile, localized language, objective progress data |
| **UP8 — MSK Patient & Family Caregiver** | Elderly patients (4580+) via younger caregiver proxies (2045) | Home, pharmacy, crowded waiting rooms; saturated with health misinformation | Manage chronic pain; adhere to protocols; make family-centric decisions | *Empathy + accessibility* jargon-free, high-contrast, large-font, dual-profile family access on legacy phones |
*Source: [User_Research_Result.md](../Requirement_Analysis/USER_RESEARCH/User_Research_Result.md), [CONTEXT_VISION_SCOPE.md §3](../PLAN/CONTEXT_VISION_SCOPE.md).*
### 1.2 The Pain Points That Drive the Need
These are not hypothetical they are drawn from ethnographic user research (professional forums, patient forums, and published studies).
- **Clinical exhaustion & volume:** Central referral hospitals (Bach Mai, Viet Duc) regularly exceed **100 evaluations per shift**, leaving no time for tedious measurements or objective double-checks. This creates the risk of *automation-bias blind-concurrence* a clinician rubber-stamping an AI estimate under time pressure.
- **The physiotherapist information bottleneck:** PTs receive only brief text prescriptions no DICOM, no scan findings forcing **semi-blind therapeutic execution** against estimated surface anatomy.
- **The physiotherapist body & literacy crisis:** **76.4%** WMSD prevalence (career-shortening physical strain); **84%** report a foreign-language barrier (only 25% adequate English); 96%/93%/86% rely on personal experience/peer chat/old textbooks rather than formal evidence.
- **Dangerous folk interventions:** Information-starved patients turn to leaf-wrapping or violent manual adjustment, causing tissue infection or permanent anatomical failure because no one translated their scan into understandable terms.
- **Stranded research value:** VKIST's CV models (DeepLabv3/UNet/ConvNeXt/MedViT) are proven in the lab but have no production pathway, so they generate zero operational or reputational value.
### 1.3 Constraints the System Must Meet
**Functional Requirements (pilot scope — FR-25 pipeline):**
| FR | Requirement |
| :--- | :--- |
| **FR-23/24/25** | Segment knee joint structures measure synovium thickness (mm) grade synovitis **03** |
| **FR-25 / Grad-CAM** | Spatial activation overlay on the primary viewport with **zero extra clicks** |
| **FR-25 / Safety loop** | Conversational Circuit Breaker + Socratic dialogue before finalization; BERT drift monitor; RAG-Referee arbitration |
| **FR-25 / Reporting** | Circular 46/2018 compliant PDF report generation |
| **FR-25 / Finalization** | Cryptographically signed, HITL-gated electronic record EMR |
**Non-Functional Requirements (the "why it won't fail" constraints):**
| NFR | Constraint | Why the leader cares |
| :--- | :--- | :--- |
| **NFR-4** | 150 MB idle app bundle | Runs on cheap, legacy hospital/consumer hardware |
| **NFR-5** | 1.5 s core inference (on-prem) | No workflow slowdown |
| **NFR-7** | 200 ms token streaming (TTFT) | Feels instantaneous to the clinician |
| **NFR-9** | 99.9% availability (≤ 45 s downtime/day) | Clinically dependable |
| **NFR-10** | 100% of patient-facing LLM text passes safety verification | No unsafe generative output reaches a human |
| **NFR-11** | Onboarding 45 minutes | Cheap to roll out to busy staff |
| **NFR-12/13** | 0 extra clicks for explainability; native spatial activation maps | Anti-black-box; builds clinician trust |
| **NFR-14** | No client-side GPU/accelerator required (Android 10+, 3 GB RAM) | Works on the fleet that actually exists |
| **NFR-15** | Circular 46/2018 EMR compliance | Legally deployable |
| **NFR-16** | Air-gapped primary (no PHI over public internet in production) | Data sovereignty (Decree 13) |
| **NFR-17** | Immutable audit log | Accountability & liability defense |
| **NFR-18** | 100% LLM clinical text cites MOH protocol via RAG | Every claim is traceable & defensible |
| **NFR-19** | HITL digital signature before FINALIZED/ARCHIVED | A licensed human always owns the decision |
*Source: [SOLUTION_ARCHITECTURE_SPEC.md §2](../ARCHITECT/SOLUTION_ARCHITECTURE_SPEC.md), [SOFTWARE_ARCHITECTURE_SPEC.md §2](../ARCHITECT/SOFTWARE_ARCHITECTURE_SPEC.md).*
### 1.4 System Context — C4 Level 1 (PlantUML)
The context diagram summarizes *who* and *what* the system boundary talks to. The pilot activates UP5 (and governance actors UP1/UP4); UP6/UP7/UP8 are shown as future-sprint boundaries.
```plantuml
@startuml VKIST_MSK_System_Context
!include <C4/C4_Context>
title System Context Diagram — VKIST MSK Ultrasound Stack (Pilot: FR-25 Synovitis Grading)
' === PRIMARY HUMAN ACTOR (in pilot scope) ===
Person(radiologist, "Diagnostic Radiologist (UP5)", "Loads knee ultrasound, reviews AI grading (0-3), confirms/overrides, engages Socratic circuit breaker, finalizes & signs the report, reads GradCAM + RAG evidence")
Person(admin, "System Administrator", "Deploys/monitors on-prem K3s, model updates, observability, NGINX failover")
' === GOVERNANCE / NFR-ALIGNMENT ACTORS ===
Person_Ext(senior_expert, "Healthcare Senior Expert (UP1)", "Clinical protocol validation, model-threshold approval, pilot sponsor")
Person_Ext(support_staff, "Support Staff (UP4)", "Patient registration, case-queue management")
' === FUTURE-SPRINT PERSONAS (out of pilot scope) ===
Person_Ext(therapist, "Physical Therapist (UP6)", "Future: read-only diagnostic visibility, kinetic overlays, progress tracking")
Person_Ext(surgeon, "Surgeon / Rheumatologist (UP7)", "Future: aggregated dashboards, treatment planning")
Person_Ext(patient, "Patient & Caregiver (UP8)", "Future: patient portal, plain-language education, self-monitoring")
' === THE SYSTEM ===
System(mpps, "VKIST MSK Ultrasound Stack (FR-25)", "Air-gapped-first: knee ultrasound AI (angle -> inflammation -> segmentation), synovitis grading 0-3 with GradCAM, 4-quadrant clinical safety, MOH-cited Vietnamese explanations, HITL-signed Circular 46 reports")
' === EXTERNAL SYSTEMS ===
System_Ext(pacs, "Hospital PACS / Ultrasound Machine", "DICOM source (C-MOVE + direct capture)")
System_Ext(emr, "Hospital EMR / HIS", "Signed report storage, prescription/lab sync (HL7/FHIR)")
System_Ext(triton, "Triton Inference Server", "GPU node: 3-step ML pipeline + RAG embeddings (gRPC)")
System_Ext(knowledge, "Clinical Knowledge Systems", "ladybugDB ontology (SNOMED-CT), pgvector MOH guideline index, Gemma/MedGemma LLM tiers")
' === RELATIONSHIPS ===
Rel(radiologist, mpps, "Reviews grade, overrides, finalizes & signs, reads explanations", "HTTPS")
Rel(admin, mpps, "Deploys, monitors, updates models", "HTTPS / SSH")
Rel(senior_expert, mpps, "Validates protocols, approves thresholds", "HTTPS (governance)")
Rel(support_staff, mpps, "Registration, case queue", "HTTPS")
Rel(therapist, mpps, "Future sprint scope", "—")
Rel(surgeon, mpps, "Future sprint scope", "—")
Rel(patient, mpps, "Future sprint scope", "—")
Rel(mpps, pacs, "Imports DICOM", "DICOM / C-MOVE")
Rel(mpps, emr, "Pushes signed reports + audit records", "HL7/FHIR")
Rel(mpps, triton, "ML inference offload", "gRPC 8001")
Rel(mpps, knowledge, "RAG retrieval, ontology traversal, LLM generation", "SQL (pgvector) + in-process")
@enduml
```
*Adapted from [SOLUTION_ARCHITECTURE_SPEC.md §8.1](../ARCHITECT/SOLUTION_ARCHITECTURE_SPEC.md).*
### 1.5 Applicability — Use-Case View (PlantUML)
The pilot's functional scope is organized around a **4-Quadrant Human-AI Interaction Framework** that deliberately defends against both *automation bias* (blindly trusting AI) and *clinician subservience* (AI overriding a correct human). This is the applicability model what the radiologist can actually *do* with the system.
```plantuml
@startuml VKIST_MSK_UseCases
left to right direction
skinparam packageStyle rectangle
actor "Diagnostic Radiologist (UP5)" as Rad
actor "MSK Vision Grader" as Grader <<system>>
actor "LLM Explainer" as LLM <<system>>
actor "RAG-Referee Arbitrator" as Referee <<system>>
actor "Hospital EMR" as EMR <<system>>
rectangle "VKIST MSK Workspace (FR-25)" {
usecase "UC-48376\nLoad Patient Scan Session" as U1
usecase "UC-47988\nReview Suggested Synovitis Grade (0-3)" as U2
usecase "UC-92006\nFinalize & Sign Electronic Record" as U3
rectangle "Q1 — True Agreement (AI correct / Doctor correct)" {
usecase "UC-25776\nGradCAM + CoT Explanation" as Q1a
usecase "UC-02423\nLog High-Trust Concur Block" as Q1b
}
rectangle "Q2 — Automation Override (AI correct / Doctor oversight)" {
usecase "UC-22159\nTrigger Circuit Breaker" as Q2a
usecase "UC-55146\nSocratic Reasoning Dialogue" as Q2b
usecase "UC-74821\nBERT Context-Drift Monitor" as Q2c
usecase "UC-65473\nArbitrate via RAG-Referee" as Q2d
}
rectangle "Q3 — Clinician Subservience (AI hallucinates / Doctor correct)" {
usecase "UC-25637\nExpose Pixel-Level Activation" as Q3a
usecase "UC-60739\nIsolate Visual Noise/Artifacts" as Q3b
usecase "UC-62864\nCommit Validated Ground-Truth" as Q3c
}
rectangle "Q4 — Double-Blind Failure (AI faulty / edge case)" {
usecase "UC-35956\nActivate Clinical Investigation Mode" as Q4a
usecase "UC-47796\nStructured Morphology Annotation" as Q4b
usecase "UC-01580\nSerialize to Telemetry Queue" as Q4c
}
}
Rad --> U1
Rad --> U2
Rad --> U3
Rad --> Q2b : Argue observations
Rad --> Q3b : Tag artifacts
Rad --> Q4b : Document manual findings
Grader --> U1 : Feeds vision tensors + scores
Grader --> U2 : Generates prediction
LLM --> Q1a : Generates clinical CoT
LLM --> Q2b : Generates Socratic checks
Referee --> Q2d : Retrieves MOH guidelines
U3 --> EMR : Syncs signed report
Q1b --> EMR : Stores consensus log
Q4c --> EMR : Routes anomaly to engineering telemetry
U1 ..> Q1a : <<include>>
Q1a ..> Q1b : <<include>>
U2 <.. Q2a : <<extend>> (friction detected)
Q2a ..> Q2b : <<include>>
Q2a ..> Q2c : <<include>>
Q2c ..> Q2d : <<extend>> (impasse/drift)
U2 <.. Q3a : <<extend>> (clinician contests score)
Q3a ..> Q3b : <<include>>
Q3b ..> Q3c : <<include>>
U2 <.. Q4a : <<extend>> (low confidence + empty RAG)
Q4a ..> Q4b : <<include>>
Q4b ..> Q4c : <<include>>
@enduml
```
*Adapted from [full_usecase_planuml.md](../../workspace/sprint_1_2/Design_Material/FR_25_UC_DESIGN/Use_Case/full_usecase_planuml.md) and [CONTEXT_FR_25_UC_ELIT.md](../Requirement_Analysis/UC_Design/FR_25_UC_DESIGN/CONTEXT_FR_25_UC_ELIT.md).*
---
## 2. How the System Addresses the Problem
### 2.1 Technology Choices — Problem → Solution → Why Optimal → Limitation
Each technology is selected against a specific user problem or NFR, not for novelty. The table states the honest limitation of each choice so the leader sees a balanced picture.
| Technology | User problem / NFR it solves | Why it is the optimal choice | Limitation / trade-off |
| :--- | :--- | :--- | :--- |
| **React PWA + LiteRT (MediaPipe) + WebLLM edge** | NFR-14 (no client GPU), NFR-4 (150 MB), zero-friction on legacy phones | Installable, offline-capable, runs a local angle pre-classifier + edge LLM with no server round-trip; degrades gracefully on weak hardware | Browser memory ceilings; edge LLM is small (GemmaE2B-Q4 ~1.3 GB) so deep reasoning must escalate |
| **FastAPI edge-inference-svc (Python 3.12)** | FR-25 pipeline orchestration, Decree 13 middleware, SSE streaming | Async, lightweight, tight control over PII redaction + audit at the boundary; in-house domain logic | Python GIL for CPU-bound work mitigated by offloading inference to Triton |
| **Triton Inference Server (ONNX/TensorRT)** | NFR-5 (≤1.5 s inference), reuse of VKIST CV models | Industry-standard, GPU-efficient, self-hosted (no SaaS); serves the 3-step ensemble + embeddings | Requires a GPU node (capex); one more component to operate |
| **Postgres + pgvector (HNSW)** | NFR-18 MOH-cited RAG, transactional consistency of clinical records | One store for records *and* vectors (~15K guideline vectors) ~520 ms retrieval, no separate vector DB to run | Not optimal beyond ~100K vectors / 500 QPS (documented Phase-2 path to Qdrant) |
| **RAG-Referee (BERT classifier)** | NFR-10/18 reject unsupported LLM claims | Validates attribution + logical cohesion of every explanation before it reaches a human | Adds latency to the cloud tier; thresholds need clinical tuning |
| **3-Tier LLM (Edge Gemma → Gemini → MedGemma)** | Vietnamese explanations, deep clinical reasoning, cost control | Cheapest-capable-tier routing: local first, cloud only when needed, <20% MedGemma usage cap | Cloud tiers are PoC-only (NFR-16a) and must be redacted retired at sign-off |
| **Keycloak (OIDC/RBAC) on-prem** | NFR-16 sovereignty, role separation (PT read-only) | No SaaS identity; realm-scoped RBAC inside the LAN | Operational overhead vs. managed Auth0/Okta (accepted for sovereignty) |
| **K3s on Dell PowerEdge** | NFR-16 air-gap, NFR-9 availability | Lightweight Kubernetes for a single hospital; rolling updates, self-healing pods; NGINX+Keepalived VIP failover (≤2 s) | Single-site scale; not a multi-region HA design (by design for the pilot) |
| **MinIO + Redis (5 scoped types)** | Object/blob store + session/cache | S3-compatible, self-hosted, no cloud egress; Redis scoped to prevent sprawl | Self-managed durability (mitigated by backup cron to MinIO) |
**Honest, system-level limitations (stated up front for the leader):**
- **Single-hospital scale by design.** The pilot targets one site; multi-site federation is a documented Phase-2 evolution, not a pilot promise.
- **Cloud LLM tiers are proof-of-concept only** under NFR-16a, gated by redaction + consent + audit, and **formally retired at PoC sign-off** production reverts to fully air-gapped NFR-16.
- **Edge model is intentionally small** deep reasoning escalates through the tier chain, which is why the cost guard (<20% MedGemma) and RAG-Referee exist.
*Source: [SOFTWARE_ARCHITECTURE_SPEC.md §7 Build-vs-Buy](../ARCHITECT/SOFTWARE_ARCHITECTURE_SPEC.md), [SOLUTION_ARCHITECTURE_SPEC.md §5](../ARCHITECT/SOLUTION_ARCHITECTURE_SPEC.md).*
### 2.2 Deployment Environment — C4 Container Diagram (PlantUML)
The system runs entirely inside the hospital LAN. The only external touchpoints are the governed, PoC-scoped cloud fallbacks (NFR-16a).
```plantuml
@startuml VKIST_MSK_Containers
!include <C4/C4_Container>
title C4 Container Diagram — VKIST MSK Ultrasound Stack
Person(radiologist, "Diagnostic Radiologist (UP5)", "Loads DICOM, reviews grading, finalizes reports")
Person(admin, "System Administrator", "K3s ops, model updates, observability, failover")
Person_Ext(senior_expert, "Healthcare Senior Expert (UP1)", "Protocol validation, threshold approval")
Person_Ext(support_staff, "Support Staff (UP4)", "Registration, case queue")
System_Boundary(hospital_lan, "Hospital LAN (Air-Gapped, <=10 Mbps)") {
Container(pwa, "React PWA Frontend", "React 18, TS, Zustand, LiteRT, MediaPipe, Dexie.js", "View-angle validation (WASM); DICOM preview; Grad-CAM overlay; Decree 13 scrubber; encrypted IndexedDB sessions")
Container(nginx, "Active-Passive Gateway", "NGINX + Keepalived", "SSL termination, VIP load balancing, <=2 s failover")
System_Boundary(k3s_cluster, "K3s Orchestration Cluster") {
Container(edge_inference, "Edge Inference Service", "FastAPI (Python 3.12)", "DICOM ingest, 3-step ML orchestration, Grad-CAM, report assembly, SSE streaming")
Container(cloud_llm_gateway, "Cloud LLM Gateway", "FastAPI (Python 3.12)", "Routes to Gemini (orchestration) / MedGemma (clinical); NFR-16a redaction, consent, audit, cost guard")
Container(rag_svc, "RAG & Knowledge Service", "FastAPI (asyncpg)", "pgvector top-k, ladybugDB ontology, RAG-Referee validation, mandatory RAG pre-processing")
Container(audit_svc, "Audit & EMR Service", "Node.js / FastAPI Worker", "Immutable append-only audit; HL7/FHIR EMR push with outbox retry")
Container(api_gw, "API Gateway", "Envoy", "TLS, rate limiting, routing, OIDC pass-through")
Container(auth_svc, "Identity & Access", "Keycloak", "OIDC, RBAC, realm vkist-msk")
Container(obs_stack, "Observability Stack", "Prometheus + Grafana", "Metrics, dashboards, alerting")
}
System_Boundary(db_vm, "Database VM") {
ContainerDb(postgres, "PostgreSQL + pgvector", "PostgreSQL 16 + pgvector", "EMR records, spatial markers, MOH HNSW index, embeddings, audit ledger")
ContainerDb(redis, "Redis Cache", "Redis 7 (AOF+RDB)", "Sessions, guideline chunks, DICOM metadata, consult_mode state, rate-limit")
ContainerDb(minio, "MinIO Object Store", "MinIO S3-compatible", "DICOM payloads, overlays, Grad-CAM heatmaps, report PDFs, model staging")
}
}
System_Ext(triton, "Triton Inference Server", "NVIDIA Triton (ONNX/TensorRT)", "3-step ML pipeline + EmbeddingGemma RAG embeddings")
System_Ext(emr, "Hospital EMR / HIS", "HL7/FHIR", "Signed report storage, prescription sync")
System_Ext(pacs, "PACS / Ultrasound Device", "DICOM / C-MOVE", "Image capture & retrieval")
System_Ext(vertex_ai, "GCP Vertex AI (Gemini)", "REST", "PoC-only NFR-16a: orchestration, translation; redacted payloads only")
System_Ext(modal_medgemma, "Modal MedGemma", "T4 GPU", "PoC-only NFR-16a: clinical deep-reasoning; redacted + RAG-Referee validated")
Rel(radiologist, pwa, "Loads scan, reviews grade, finalizes, reads explanations", "HTTPS 443")
Rel(admin, nginx, "Deploys, monitors, configures", "HTTPS / SSH")
Rel(senior_expert, pwa, "Validates protocols, approves thresholds", "HTTPS 443")
Rel(support_staff, pwa, "Registration, case queue", "HTTPS 443")
Rel(pwa, nginx, "API requests + pre-validated DICOM", "HTTPS 443")
Rel(nginx, api_gw, "Routes upstream", "HTTP 8000")
Rel(api_gw, edge_inference, "Forwards /api/*", "HTTP 8000")
Rel(api_gw, rag_svc, "Forwards /rag/*", "HTTP 8001")
Rel(api_gw, auth_svc, "Validates OIDC tokens", "HTTP 8080")
Rel(edge_inference, triton, "3-step inference + embeddings", "gRPC 8001")
Rel(edge_inference, postgres, "Reads guidelines; writes cases/embeddings/audit", "SQL 5432")
Rel(edge_inference, redis, "Session, rate-limit, consult_mode", "TCP 6379")
Rel(edge_inference, minio, "DICOM, overlays, reports", "S3 API")
Rel(edge_inference, cloud_llm_gateway, "Routes cloud consult", "HTTP 8000")
Rel(cloud_llm_gateway, vertex_ai, "Gemini proxy (NFR-16a redacted)", "HTTPS 443")
Rel(cloud_llm_gateway, modal_medgemma, "MedGemma proxy (NFR-16a redacted)", "HTTPS 443")
Rel(cloud_llm_gateway, postgres, "Audit log append", "SQL 5432")
Rel(rag_svc, postgres, "pgvector HNSW queries", "SQL 5432")
Rel(rag_svc, redis, "Guideline cache, pub/sub invalidation", "TCP 6379")
Rel(audit_svc, postgres, "Appends immutable audit events", "SQL 5432")
Rel(audit_svc, emr, "Finalized report push", "HL7/FHIR")
Rel(pwa, pacs, "Direct DICOM capture / C-MOVE", "DICOM")
@enduml
```
*Reused from [SOFTWARE_ARCHITECTURE_SPEC.md §4.2](../ARCHITECT/SOFTWARE_ARCHITECTURE_SPEC.md).*
### 2.3 Deployment Topology — C4 Deployment Diagram (PlantUML)
Physical placement: everything on hospital hardware; the only external node is the governed NFR-16a fallback.
```plantuml
@startuml VKIST_MSK_Deployment
!include <C4/C4_Deployment>
LAYOUT_LEFT_RIGHT()
Deployment_Node(hw, "Dell PowerEdge (Hospital)") {
Deployment_Node(k3s, "K3s Cluster") {
Deployment_Node(pod_edge, "Pod: edge-inference-svc") {
Container(ci, "FastAPI", "Python 3.12, Uvicorn")
}
Deployment_Node(pod_rag, "Pod: rag-svc") {
Container(cr, "FastAPI", "Python 3.12, asyncpg")
}
Deployment_Node(pod_audit, "Pod: audit-svc") {
Container(ca, "Node.js", "WAL writer")
}
Deployment_Node(pod_gw, "Pod: api-gateway") {
Container(cg, "Envoy", "TLS, rate limit")
}
Deployment_Node(pod_auth, "Pod: auth-svc") {
Container(ck, "Keycloak", "OIDC, RBAC")
}
Deployment_Node(pod_obs, "Pod: observability") {
Container(cp, "Prometheus")
Container(cf, "Grafana")
}
}
Deployment_Node(db_vm, "DB VM") {
ContainerDb(pg, "PostgreSQL + pgvector HNSW")
ContainerDb(rd, "Redis")
ContainerDb(mn, "MinIO")
}
}
Deployment_Node(triton_node, "GPU Node (Triton)", "NVIDIA A10/T4, 24 GB VRAM") {
Container(tc, "Triton Inference Server", "ONNX/TensorRT, gRPC 8001")
}
Deployment_Node(ext, "External (NFR-16a governed, PoC only)") {
ContainerDb(s3v, "GCP Vertex AI / Modal MedGemma / CDN fallback")
}
Rel(ci, pg, "SQL", "TCP 5432")
Rel(ci, rd, "Cache", "TCP 6379")
Rel(ci, mn, "Blobs", "S3 API")
Rel(ci, tc, "Inference", "gRPC 8001")
Rel(ci, ck, "Auth", "OIDC")
Rel(ci, ca, "Audit", "HTTP")
Rel(k3s, db_vm, "SQL / Cache / Blobs", "TCP")
@enduml
```
*Reused from [SOFTWARE_ARCHITECTURE_SPEC.md §4.4](../ARCHITECT/SOFTWARE_ARCHITECTURE_SPEC.md).*
### 2.4 Components & Interactions — C4 Component Diagram (PlantUML)
Inside the core `edge-inference-svc`, each component maps to a specific functional requirement. The **Inference Router** implements the tiered LLM escalation; the **Circuit Breaker** guarantees the system fails safely to MOH templates.
```plantuml
@startuml VKIST_MSK_Component_EdgeInference
!include <C4/C4_Component>
Container_Boundary(edge_svc, "edge-inference-svc (FastAPI)") {
Component(api, "API Controller", "REST + SSE", "/api/analyze, cloud-orchestrate redirect")
Component(stream, "SSE Token Streamer", "StreamingResponse", "200 ms TTFT, 30 s heartbeat")
Component(preproc, "Image Preprocessor", "OpenCV + pydicom", "CLAHE, rescale, DICOM header scrub")
Component(router, "Inference Router", "consult_mode state", "Tier selection: Edge Gemma -> Gemini -> MedGemma -> Templates")
Component(breaker, "Circuit Breaker", "pybreaker", "Wraps Triton + EMR + Cloud LLM; fail-open to templates")
Component(pipeline, "ML Pipeline", "gRPC client", "Angle -> Inflammation -> Segmentation -> Measurement")
Component(gradcam, "Grad-CAM Generator", "OpenCV", "Spatial activation overlay -> base64 PNG")
Component(report, "Report Builder", "WeasyPrint", "Circular 46 PDF; HITL signature gate")
Component(rag, "RAG Service", "pgvector SQL", "Top-5 MOH chunks; mandatory pre-generation; NFR-18")
Component(referee, "RAG-Referee", "BERT classifier", "Reject LLM text if citation confidence < threshold")
Component(nlp, "NLP Scrubber", "Microsoft Presidio", "Re-verify edge redaction; clean residual PII")
Component(audit, "Audit Logger", "Append-only writer", "Every tier transition, consent, finalize, override")
}
Rel(api, stream, "delegates", "SSE")
Rel(api, preproc, "validates image", "sync")
Rel(api, router, "selects tier", "sync")
Rel(router, breaker, "guards calls", "sync")
Rel(router, pipeline, "invokes", "gRPC")
Rel(router, stream, "fallback text", "SSE")
Rel(api, gradcam, "requests overlay", "sync")
Rel(api, report, "generates PDF", "sync")
Rel(api, rag, "queries MOH", "SQL")
Rel(rag, referee, "validates citations", "sync")
Rel(api, nlp, "scrubs output", "sync")
Rel(api, audit, "writes event", "sync")
@enduml
```
The **Cloud LLM Gateway** enforces the NFR-16a governance boundary nothing egresses without consent, redaction, RAG grounding, and an audit record.
```plantuml
@startuml VKIST_MSK_Component_CloudGateway
!include <C4/C4_Component>
Container_Boundary(cloud_gw, "cloud-llm-gateway (FastAPI)") {
Component(capi, "Cloud API Controller", "REST + SSE", "/api/cloud-orchestrate, /api/cloud-consult")
Component(gateway, "Cloud LLM Router", "task_type matcher", "Orchestration/translation -> Gemini; clinical -> MedGemma")
Component(consent, "Consent Enforcer", "Redis + NFR-16a checklist", "Validates consent token + redaction manifest before egress")
Component(caudit, "Cloud Audit Emitter", "PostgreSQL append-only", "egress_consent, egress_response, escalation events")
Component(cost, "Cost Guard", "Redis + Prometheus", "Tracks MedGemma usage; alerts if >20% over 24h")
Component(creferee, "RAG-Referee Trigger", "BERT classifier", "Mandatory validation of MedGemma output")
Component(crag, "RAG Pre-processing", "pgvector SQL", "Mandatory top-k injection before generation")
}
Rel(capi, consent, "verifies", "sync")
Rel(capi, gateway, "routes", "sync")
Rel(gateway, crag, "injects chunks", "sync")
Rel(gateway, creferee, "validates output", "sync")
Rel(gateway, caudit, "logs egress", "sync")
Rel(gateway, cost, "increments counter", "sync")
@enduml
```
*Reused from [SOFTWARE_ARCHITECTURE_SPEC.md §4.3](../ARCHITECT/SOFTWARE_ARCHITECTURE_SPEC.md).*
---
## 3. Qualitative ROI — Behavioral & Workflow Change
The core value thesis: the system does not add a tool to a workflow it **enriches the existing workflow and reconnects a fragmented care chain**. The following before/after captures the behavioral change per persona.
| Persona | Before (current behavior) | After (with the system) | Behavioral shift |
| :--- | :--- | :--- | :--- |
| **UP5 Radiologist** | Manual, repetitive measurements under fatigue; risk of missing subtle findings; isolated in a reading room; liability anxiety | AI pre-measures + grades; Grad-CAM shows *why* with 0 extra clicks; a Socratic circuit breaker catches rushed sign-offs; every report is MOH-cited and audit-backed | From lone, fatigued gatekeeper to a *validated* decision-maker shielded from liability |
| **UP7 Surgeon** | Synthesizes fragmented silos by hand; no time for patient education; fears uncompensated patient messaging | Aggregated view of signed findings; auto-generated patient-friendly visuals; strictly gated (no open chat) communication | From data-assembler to *clinical force multiplier* with protected time |
| **UP6 Physiotherapist** | Semi-blind execution from a one-line prescription; no scan visibility; language barrier; manual charting | Read-only diagnostic visibility down the chain; objective progress metrics; gel-proof, localized, zero-GPU UI | From guesswork executor to *data-backed* practitioner with professional standing |
| **UP8 Patient/Caregiver** | Anxiety + confusion folk remedies; crowdsources raw scans on social media | Scan translated into plain-language, high-tech visual validation of the doctor's diagnosis | From anxious misinformation-seeker to a *trusting, informed* participant |
**System-level qualitative returns:**
- **Institutional trust & credibility:** A high-trust, objective, MOH-anchored tool displaces both dangerous folk interventions (patients) and unsecured external messaging (clinicians bypassing the system).
- **Academic-to-production migration:** VKIST research models become a live, referenceable clinical asset establishing the team's operational value in the local healthcare community.
- **Care-chain cohesion:** The radiologist's sign-off *ripples* to the surgeon, PT, and patient turning isolated actions into a connected clinical relationship (the "Synchronized Ritual" concept).
*Source: [Motivation.md](../DEMO_EXP/Motivation.md), [User_Research_Result.md](../Requirement_Analysis/USER_RESEARCH/User_Research_Result.md).*
---
## 4. Quantitative ROI — Projected Metrics & Measurement Plan
> **Important framing for the leader:** The pilot has not yet deployed (runs through Sep 2026), so the figures below are **modeled projections**, not measured outcomes. Each row states its **baseline source** and its **assumption**, and — critically — **how the pilot will actually measure it**. The value of the pilot is precisely to *convert these projections into evidence*.
### 4.1 Projected Before → After Metrics
| Metric | Baseline (before) | Target (after) | Assumption / basis | How the pilot measures it |
| :--- | :--- | :--- | :--- | :--- |
| **Tedious measurement time per scan** | Manual synovium measurement + grading, several minutes/scan under load | Automated pre-measurement; radiologist verifies | FR-25 automates FR-23/24 (segment + measure) | Time-on-task instrumentation (session telemetry, NFR audit timestamps) |
| **Core inference latency** | N/A (no automated pipeline today) | 1.5 s (on-prem) | NFR-5 target on Triton GPU node | Prometheus p50/p99 latency dashboards |
| **Explainability access cost** | Not available / manual literature lookup | **0 extra clicks** (Grad-CAM + citation inline) | NFR-12/13 design mandate | UI event audit (clicks-to-explanation = 0) |
| **Clinical claim traceability** | Ad-hoc, uncited | **100%** of LLM clinical text cites MOH protocol | NFR-18 mandatory RAG | RAG-Referee pass rate; citation coverage log |
| **Unsafe generative output reaching a human** | N/A | **0%** (100% pass safety verification) | NFR-10 3-tier guardrail | Guardrail rejection log; audit events |
| **Onboarding time per staff member** | Ad-hoc, hours | 45 minutes | NFR-11 target | Timed onboarding sessions during rollout |
| **System availability** | Dependent on manual workflow | 99.9% (≤ 45 s downtime/day) | NFR-9 target | Uptime monitoring (Prometheus/Grafana) |
| **Cloud (MedGemma) usage ratio** | N/A | < 20% of consults | Cost-guard target (capital efficiency) | Cost Guard Redis counter + alert |
| **PT diagnostic visibility** | ~0 (text prescription only) | Read-only access to signed findings | RBAC role (future sprint) | Access-log coverage per PT-linked case |
| **Auditability of AI accept/override** | None | 100% immutable capture | NFR-17 | Immutable audit ledger completeness check |
### 4.2 Behavioral-Change Indicators (Adoption Proxies)
Beyond raw performance, the pilot measures whether behavior actually changed the true ROI signal for a leader:
- **Retention / anti-bypass:** % of target clinicians using the structured workspace instead of unsecured external messaging (a stated success criterion).
- **Override rate & ground-truth capture:** frequency of clinician overrides (Q3) feeding the retraining corpus proves the human-AI loop works both ways.
- **Circuit-breaker engagement:** how often the Socratic breaker prevents a rushed sign-off (a direct automation-bias safety signal).
- **Compliance events:** zero unencrypted PHI leak events (Decree 13) a binary go/no-go for production.
*Baselines from [User_Research_Result.md](../Requirement_Analysis/USER_RESEARCH/User_Research_Result.md); targets from NFRs in [SOLUTION_ARCHITECTURE_SPEC.md §2.2](../ARCHITECT/SOLUTION_ARCHITECTURE_SPEC.md); success criteria from [CONTEXT_VISION_SCOPE.md §6](../PLAN/CONTEXT_VISION_SCOPE.md).*
---
## 5. Risk & Failure-Mode Handling — Why This Is Not Risky
The single most important message for an investment decision: **this system is engineered around the assumption that either the AI *or* the human can be wrong.** It handles all four combinations explicitly via the **4-Quadrant Human-AI Interaction Framework**.
```plantuml
@startuml VKIST_MSK_Risk_Quadrants
skinparam packageStyle rectangle
title 4-Quadrant Human-AI Safety Framework (FR-25)
rectangle "Q1 — True Agreement\n(AI correct / Doctor correct)" as Q1
rectangle "Q2 — Automation Override\n(AI correct / Doctor oversight)" as Q2
rectangle "Q3 — Clinician Subservience\n(AI hallucinates / Doctor correct)" as Q3
rectangle "Q4 — Double-Blind Failure\n(AI faulty / edge case)" as Q4
Q1 --> Q1 : GradCAM + CoT -> High-Trust Concur log
Q2 --> Q2 : Circuit Breaker -> Socratic -> BERT drift -> RAG-Referee
Q3 --> Q3 : Expose activations -> isolate artifacts -> commit ground-truth
Q4 --> Q4 : Investigation mode -> manual annotation -> telemetry queue
@enduml
```
| Risk quadrant | The danger | How the system mitigates it |
| :--- | :--- | :--- |
| **Q1 — True Agreement** | Even correct consensus needs a trace for liability | Grad-CAM + Chain-of-Thought explanation, then an immutable High-Trust Concur log block |
| **Q2 — Automation Bias** | Overloaded doctor rubber-stamps AI | Conversational Circuit Breaker intercepts rushed finalization Socratic dialogue BERT drift monitor RAG-Referee arbitration against MOH guidelines |
| **Q3 — AI Hallucination** | AI is wrong, doctor is right, but AI "authority" pressures the human | Expose pixel-level activation logic; let the clinician mask artifacts and **commit the corrected ground-truth** (which also feeds retraining) |
| **Q4 — Double-Blind Edge Case** | Both AI confidence and guideline coverage are low | Auto-switch to strict manual **Clinical Investigation Mode**; force structured morphology annotation; route the anomaly to an engineering telemetry queue (bypassing the EMR) |
**Cross-cutting safety guarantees:**
- **HITL gate (NFR-19):** No AI output becomes FINALIZED/ARCHIVED without a licensed clinician's cryptographic signature. *A human always owns the decision.*
- **Fail-safe by design:** The Circuit Breaker fails **open to deterministic MOH templates** if Triton, EMR, or a cloud LLM is down, the system degrades to a safe rule-based floor, not to an error.
- **Immutable audit (NFR-17):** Every accept/override/tier-transition/consent event is append-only and tamper-proof a complete liability defense record.
- **RAG-Referee (NFR-18):** No uncited clinical claim reaches a human.
*Source: [full_usecase_planuml.md](../../workspace/sprint_1_2/Design_Material/FR_25_UC_DESIGN/Use_Case/full_usecase_planuml.md), and the Q1Q4 scenario docs under [FR_25_UC_DESIGN/](../Requirement_Analysis/UC_Design/FR_25_UC_DESIGN/).*
---
## 6. Compliance & Data Governance
Healthcare compliance is treated as a first-class architectural constraint, not an afterthought.
| Regulation | Requirement | How the architecture satisfies it |
| :--- | :--- | :--- |
| **Decree 13/2023/ND-CP** (personal health data) | Sensitive PII must be protected; data sovereignty | Client-side WebCrypto scrubbing + FastAPI Presidio redaction middleware; role-hash tokens `[BN:7f3a2c]`; all production data resides in-hospital / sovereign VN data centers |
| **Circular 46/2018/TT-BYT** (EMR) | National EMR compliance for stored records | Circular-46-compliant PDF reports; HL7/FHIR EMR push; NFR-15 (not relaxed even under NFR-16a) |
| **Air-gap (NFR-16, permanent)** | No diagnostic/identifiable data over public internet in production | Everything runs on the hospital LAN; K3s network policies isolate workloads; local-only container registries |
| **Governed cloud PoC (NFR-16a, temporary)** | Cloud LLM only as an emergency/PoC fallback | Invoked only when edge LLM unavailable **and** user consents; mandatory pre-egress redaction + audit commit; CSEK keys, 30-day lifecycle, 1-year auto-delete; **retired at PoC sign-off** |
**The compliance headline for a leader:** in production, **no patient data leaves the hospital**, every clinical statement is cited, every action is audit-logged, and a human signature is legally required to finalize anything.
*Source: [SOLUTION_ARCHITECTURE_SPEC.md §2.2, §5.8](../ARCHITECT/SOLUTION_ARCHITECTURE_SPEC.md), [CONTEXT_VISION_SCOPE.md §4](../PLAN/CONTEXT_VISION_SCOPE.md).*
---
## 7. Roadmap & Milestones
A strict, time-boxed 3-month cycle (June 2 September 2, 2026), executed as a hybrid of waterfall macro-phases and agile sprints. This bounds the investment and provides clear go/no-go checkpoints - However it can be tentative to change based on the actual resource of development team.
```mermaid
gantt
title VKIST MSK Pilot - 3-Month Delivery Roadmap
dateFormat YYYY-MM-DD
axisFormat %b %d
section Foundation
Sprint 0 Pitch and Architecture Spike :done, sp0, 2026-06-02, 2026-06-12
Win project pitch :milestone, m1, 2026-06-09
Sprint 1 Fast PoC Baseline :active, sp1, 2026-06-15, 2026-06-26
section Core Build
Sprint 2 Multi-Modal and NLP Integration :sp2, 2026-06-29, 2026-07-10
Sprint 3 Collaborative Workspace :sp3, 2026-07-13, 2026-07-24
section Patient and Hardening
Sprint 4 Patient-Facing PWA :sp4, 2026-07-27, 2026-08-07
Sprint 5 Feedback Pipeline and Hardening :sp5, 2026-08-10, 2026-08-21
Sprint 6 Beta Release and Evolution :sp6, 2026-08-24, 2026-09-02
Pilot cycle complete :milestone, m2, 2026-09-02
```
| Sprint | Window | Focus & key deliverable |
| :--- | :--- | :--- |
| **0** | Jun 0212 | Pitch & architecture clearance; VKIST ML model audit; CI/CD baseline |
| **1** | Jun 1526 | Fast end-to-end PoC: FastAPI pipeline + inference mask preview |
| **2** | Jun 29Jul 10 | Multi-modal NLP + Decree 13 client-side scrubbing |
| **3** | Jul 1324 | Collaborative canvas; RBAC (PT read-only); usability testing |
| **4** | Jul 27Aug 07 | Installable PWA; zero-GPU fallback for legacy phones |
| **5** | Aug 1021 | Feedback instrumentation; performance/caching; cleanup |
| **6** | Aug 24Sep 02 | Beta launch to MSK cohort; telemetry analysis; Phase-2 backlog |
**Definition of success (pilot go/no-go):** zero workflow friction added; deterministic cross-device stability (incl. CPU-bound rendering); zero Decree 13 leak events; sustained clinician retention over external messaging; successful academic-to-production model migration.
*Source: [CONTEXT_VISION_SCOPE.md §56](../PLAN/CONTEXT_VISION_SCOPE.md).*
---
## 8. Investment Ask & Scope Boundaries
### 8.1 The Resource Envelope (qualitative)
| Category | Pilot ask | Rationale |
| :--- | :--- | :--- |
| **On-prem compute** | 1 × Dell PowerEdge-class node (K3s + DB VM) + 1 × GPU node (A10/T4, 24 GB VRAM) for Triton | Single-site air-gapped deployment; capex-only, no recurring SaaS |
| **Team** | Small cross-functional squad (backend/ML, frontend, infra/DevOps) + clinical champion (UP1/UP5) for validation | Matches the 7-sprint scope; clinical sponsor is essential for governance & buy-in |
| **Software** | Open-source stack (K3s, Triton, Postgres/pgvector, Redis, MinIO, Keycloak, Prometheus/Grafana) | Zero licensing cost; no vendor lock-in |
| **Governed cloud (PoC only)** | Time-boxed GCP Vertex AI (Gemini) + Modal (MedGemma) budget, capped at <20% consult usage | NFR-16a fallback; **retired at sign-off** not a recurring production cost |
| **Data** | Access to de-identified MSK ultrasound + MOH guideline corpus | Needed to tune grading thresholds and RAG index |
### 8.2 In Scope vs. Out of Scope (Pilot)
| In scope (pilot) | Out of scope (Phase 2+) |
| :--- | :--- |
| FR-25 knee synovitis grading (UP5 primary) | Multi-hospital federation / multi-region HA |
| Grad-CAM explainability, Socratic safety loop, RAG-Referee | Full UP7 surgeon planning & UP8 patient portal at production scale |
| Decree 13 + Circular 46 compliance, HITL sign-off, audit log | Predictive analytics (cartilage degeneration, hardware failure forecasting) |
| Single-hospital air-gapped deployment | Qdrant hot-tier vector cache (only if >100K vectors / >500 QPS) |
| PoC cloud LLM tiers (NFR-16a, retired at sign-off) | Permanent cloud dependency (explicitly prohibited by NFR-16) |
### 8.3 Go / No-Go Decision Criteria
At the end of the 3-month cycle, the leader decides on Phase 2 based on objective evidence:
1. **Safety:** zero Decree 13 leak events; 100% HITL-gated finalizations; 100% MOH-cited clinical text.
2. **Performance:** inference ≤ 1.5 s; availability ≥ 99.9%; onboarding ≤ 45 min.
3. **Adoption:** measurable clinician retention within the structured workspace vs. external messaging.
4. **Strategic:** VKIST models successfully running in a referenceable production form.
> **The investment is bounded and reversible:** one hospital, one quarter, open-source stack, no production cloud dependency. If the go/no-go criteria are not met, the blast radius is a single controlled pilot — not an enterprise commitment.
---
## Appendix — Source Documents
- Vision, personas, constraints, milestones: [CONTEXT_VISION_SCOPE.md](../PLAN/CONTEXT_VISION_SCOPE.md)
- Deep personas + quantitative baselines: [User_Research_Result.md](../Requirement_Analysis/USER_RESEARCH/User_Research_Result.md)
- Architecture, FR/NFR, C4 diagrams, build-vs-buy: [SOFTWARE_ARCHITECTURE_SPEC.md](../ARCHITECT/SOFTWARE_ARCHITECTURE_SPEC.md), [SOLUTION_ARCHITECTURE_SPEC.md](../ARCHITECT/SOLUTION_ARCHITECTURE_SPEC.md)
- Use cases + 4-quadrant framework: [full_usecase_planuml.md](../../workspace/sprint_1_2/Design_Material/FR_25_UC_DESIGN/Use_Case/full_usecase_planuml.md), [CONTEXT_FR_25_UC_ELIT.md](../Requirement_Analysis/UC_Design/FR_25_UC_DESIGN/CONTEXT_FR_25_UC_ELIT.md)
- Risk scenarios: [FR_25_UC_DESIGN/](../Requirement_Analysis/UC_Design/FR_25_UC_DESIGN/) (Q1Q4 scenario docs)
- Impact narrative: [Motivation.md](../DEMO_EXP/Motivation.md)
*End of proposal.*

View File

@@ -0,0 +1 @@
# Replace with actual MedGemma Modal endpoint API key (never commit real secrets)

View File

@@ -0,0 +1,3 @@
modal secret create gitea-runner-secrets \
MODAL_KEY=your-modal-key \
MODAL_SECRET=your-modal-secret

View File

@@ -0,0 +1,71 @@
# Session Knowledge — 2026-07-15
## Goal
Debug why Celery batch API results are not displaying on the client UI and fix the full pipeline: server startup, worker task registration, Triton endpoint routing, polling storm, stale result re-submission.
## Constraints & Preferences
- Client should not hammer backend with polls; need request coalescing and caching.
- Already-completed batch results should not be re-submitted or re-queried on every page view.
- Worker must target Modal Triton endpoint, not localhost.
## Progress
### Done
- Fixed `GroupResult.restore()` returning `None` by adding `result.save()` after `apply_async()` in `submit_celery_batch()`.
- Fixed server startup `ModuleNotFoundError: No module named 'backend'` by correcting `CODEBASE_ROOT` path math in `backend/routers/run_cv_inference.sh` (changed `SCRIPT_DIR/..``SCRIPT_DIR/../..`).
- Added `celery_app.autodiscover_tasks(["backend.implementation.tasks"])` to `backend/services/celery_app.py` so worker registers `run_cv_chunk`.
- Added `export TRITON_ENDPOINT=...modal.run` to `backend/start_workers.sh` so worker targets Modal instead of `localhost:8000`.
- Added server-side TTL poll cache (`_batch_poll_cache`) in `backend/services/cv_celery_service.py` with default 2000ms TTL to coalesce rapid repeat polls.
- Increased client poll interval from 1000ms → 2000ms and removed unused `pollIntervalMs` parameter from `getCvAnalyzeResultsForProfileCelery()` in `frontend/implementation/src/lib/cvAnalyzeApi.ts`.
- Fixed poll endpoint HTTP status semantics: `GET /api/test/analyze/batch/celery/{job_id}` now returns `202 Accepted` for pending, `404 Not Found` for unknown, `200 OK` for completed/failed. This removes the misleading-200 problem for in-flight jobs.
- Added backend timing logs in `cv_celery_service.py`: submission logs chunk/image counts; completion/failure logs `duration_ms` from submission to terminal state.
- Added frontend timing logs in `getCvAnalyzeResultsForProfileCelery()` for completed/failed/unknown outcomes.
- Migrated frontend config from `.env`/`.env.development` to single YAML source of truth at `config/frontend.config.yaml`, loaded via `js-yaml` in `vite.config.ts` using Vite `define`. Removed old `.env` files. Added `VITE_MODAL_OLLAMA_TARGET` to YAML so proxy target is config-driven.
- Fixed TypeScript errors in `vite.config.ts` by adding `"types": ["node"]` and including `vite.config.ts` in `tsconfig.json`.
- Added client-side in-flight coalescing + completed-result cache in `getCvAnalyzeResultsForProfileCelery()` so repeat page views dont re-submit identical work.
### In Progress
- Worker needs force-restart to pick up `autodiscover_tasks` + `TRITON_ENDPOINT` changes.
- One submitted job reached `completed: 1/2` but never finished — need worker restart + fresh batch test.
- Client-side ETA estimation for pending batch jobs is planned but not yet implemented.
### Blocked
- Worker process restart — old PID/log still active without the new fixes applied.
## Key Decisions
- Used `result.save()` because Celery does not auto-persist `GroupResult` metadata to Redis.
- Added `autodiscover_tasks` instead of manual imports because worker is launched via `-A backend.services.celery_app` and never imports `cv_tasks.py` otherwise.
- Set worker `TRITON_ENDPOINT` in `start_workers.sh` because worker is separate process that doesnt run `cv_inference_server.py`s `os.environ.setdefault()`.
- Poll cache TTL set to 2000ms to match 2s client poll interval.
- Single YAML config replaces `.env` + `.env.development` to eliminate precedence confusion.
- `VITE_MODAL_OLLAMA_TARGET` lives in YAML so proxy target is editable without touching `vite.config.ts` code.
## Next Steps
- Force-restart Celery worker (`kill -9` stale PID, remove `celery.pid`, run `./start_workers.sh start`).
- Verify worker log shows `backend.implementation.tasks.cv_tasks.run_cv_chunk` under `[tasks]`.
- Submit fresh batch and confirm it transitions `pending``completed` with actual results.
- Implement ETA estimation in backend poll response based on observed chunk throughput.
- Address client behavior that submits new jobs while previous jobs are still `pending` (7+ concurrent jobs observed in logs).
## Critical Context
- Latest server logs show 7+ concurrent jobs all stuck at `pending` with `completed: 0/2` or `1/2`, plus new POST submissions every ~10s — indicating worker is not processing tasks.
- Worker log still shows stale startup at `16:22:18` with empty `[tasks]`; fixes in `celery_app.py` and `start_workers.sh` have not been loaded by running worker.
- One job (`13fd014e`) reached `completed: 1/2, progress: 0.5` and stalled — second chunk likely failed or was never picked up.
- Modal Triton endpoint: `https://dtj-tran--triton-s3-service-unified-triton-server.modal.run`.
- Redis broker/backend: `redis://localhost:6379/0`.
- Chunk size default: 4 frames per chunk (`CELERY_CHUNK_SIZE`).
- `cv_result_cache.py` already exists with in-flight coalescing pattern; new batch poll cache follows same design.
- Frontend not currently in Celery mode (`VITE_USE_CV_CELERY=false` in YAML).
- Direct batch route still shows noisy fallback logs when Triton returns 502 on batched inference — fallback works, but should log at `info` not `warning`.
## Relevant Files
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/backend/services/cv_celery_service.py`: Contains `submit_celery_batch()` (fixed with `result.save()`), `get_celery_batch_result()` (now with 2000ms TTL poll cache + timing logs), and poll cache constants.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/backend/services/celery_app.py`: Added `autodiscover_tasks(["backend.implementation.tasks"])`; routes `cv_inference.run_chunk` to `cv-inference` queue.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/backend/start_workers.sh`: Added `TRITON_ENDPOINT` export so worker targets Modal.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/backend/routers/run_cv_inference.sh`: Fixed `CODEBASE_ROOT` path math (`SCRIPT_DIR/../..`) so `PYTHONPATH` resolves `backend` package.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/backend/implementation/tasks/cv_tasks.py`: Defines `run_cv_chunk` Celery task.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/backend/routers/cv_inference.py`: Contains `/api/test/analyze/batch/celery` endpoints. Poll endpoint now returns 202/404/200.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/frontend/implementation/src/lib/cvAnalyzeApi.ts`: Poll interval 2000ms; client-side cache + in-flight coalescing; timing logs.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/frontend/implementation/src/hooks/useSegmentationOverlay.ts`: Calls `getCvAnalyzeResultsForProfileCelery()`.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/frontend/implementation/config/frontend.config.yaml`: Single source of truth for frontend feature flags and URLs.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/frontend/implementation/vite.config.ts`: Loads YAML config and injects as `import.meta.env.*`.
- `/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/frontend/implementation/tsconfig.json`: Added `"types": ["node"]` and included `vite.config.ts`.

View File

@@ -0,0 +1,35 @@
.git
.gitignore
README.md
LICENSE
__pycache__
*.pyc
*.pyo
.pytest_cache
.coverage
htmlcov
.tox
.venv
venv
env
.env
.env.*
!.env.example
node_modules
frontend
infra
knowledge
ml
docs
data
logs
tests
.dockerignore
Dockerfile
docker-compose*.yaml
*.md

View File

@@ -5,7 +5,7 @@ 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 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,
@@ -94,7 +94,7 @@ async def circuit_breaker(session_id: str, payload: dict, user_id: str = Depends
@router.post(
"/sessions/{session_id}/chat/socratic",
response_model=ChatResponse,
responses={401: {"model": ErrorResponse}, 404: {"model: ErrorResponse}},
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def socratic_chat(
session_id: str,

View File

@@ -12,42 +12,41 @@ Or the backward-compatible launcher:
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
TRITON_ENDPOINT Modal Triton KServe v2 HTTP URL (required)
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)
CORS_ORIGINS comma-separated allowed origins
"""
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
import asyncio
from backend.implementation.config import settings
from backend.logging.logging_config import setup_logging
from backend.routers import cv_inference
logger = logging.getLogger(__name__)
# Initialise logging as early as possible so any import-time or
# startup logs are captured consistently in both local and Docker runs.
setup_logging()
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting CV inference service on Triton: %s", os.getenv("TRITON_ENDPOINT"))
if not settings.triton_endpoint:
raise RuntimeError("TRITON_ENDPOINT is not set. Set it via environment variable.")
logger.info("Starting CV inference service on Triton: %s", settings.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)
warmup_task = asyncio.create_task(warmup_triton_models())
yield
logger.info("Shutting down CV inference service")
@@ -65,10 +64,7 @@ def create_app() -> FastAPI:
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_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -82,11 +78,18 @@ 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")
"""Entrypoint used by Docker ENTRYPOINT and local development."""
logger.info(
"CV inference service listening on %s:%s",
settings.cv_inference_host,
settings.cv_inference_port,
)
uvicorn.run(
app,
host=settings.cv_inference_host,
port=settings.cv_inference_port,
log_level="info",
)
if __name__ == "__main__":

View File

@@ -3,16 +3,34 @@ import json
from typing import Any
import numpy as np
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class TritonAdapter:
def __init__(self, endpoint_url: str, timeout: float = 60.0):
self.endpoint_url = endpoint_url.rstrip("/")
self.timeout = timeout
self._session = self._build_session()
@staticmethod
def _build_session() -> requests.Session:
session = requests.Session()
retry = Retry(
total=0,
connect=0,
read=0,
redirect=0,
status=0,
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=50)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
async def close(self):
pass
await asyncio.to_thread(self._session.close)
async def infer(
@@ -61,7 +79,7 @@ class TritonAdapter:
}
url = f"{self.endpoint_url}/v2/models/{model_name}/infer"
response = requests.post(url, data=body, headers=headers, timeout=self.timeout)
response = self._session.post(url, data=body, headers=headers, timeout=self.timeout)
response.raise_for_status()
return self._parse_binary_response(response.headers, response.content)
@@ -91,7 +109,7 @@ class TritonAdapter:
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)
response = self._session.get(url, timeout=self.timeout)
if response.status_code == 404:
return False
response.raise_for_status()
@@ -113,7 +131,7 @@ class TritonAdapter:
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 = self._session.post(url, json={}, timeout=self.timeout)
response.raise_for_status()
data = response.json()

View File

@@ -1,47 +1,131 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Dict
from typing import Dict, List, Optional, Tuple
SECRETS_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent.parent / "secrets"
from pydantic import Field, HttpUrl, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
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()
def _require_env(name: str) -> str:
"""Require a secret from environment variable only.
In production/Gitea Actions, this comes from repository secrets.
No file fallback to avoid accidental secret leakage into the repo.
"""
value = os.getenv(name)
if not value:
raise RuntimeError(
f"Required secret {name} not found at {file_path} or via {name}_FILE env var"
f"Required secret {name} not found. Set {name} environment variable. "
f"In Gitea Actions, add it as a repository secret."
)
return value
_CORS_ORIGINS_DEFAULT = ",".join(
[
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:4173",
"http://127.0.0.1:5173",
]
)
def _parse_cors_origins(value: Optional[str]) -> List[str]:
if not value:
return [o.strip() for o in _CORS_ORIGINS_DEFAULT.split(",") if o.strip()]
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
except (json.JSONDecodeError, TypeError):
pass
return [origin.strip() for origin in value.split(",") if origin.strip()]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Endpoints (environment-provided, no hardcoded fallback for production)
# Triton
triton_endpoint: Optional[HttpUrl] = Field(default=None, validation_alias="TRITON_ENDPOINT")
# Server
cv_inference_host: str = Field(default="127.0.0.1", validation_alias="CV_INFERENCE_HOST")
cv_inference_port: int = Field(
default=8001, ge=1, le=65535, validation_alias="CV_INFERENCE_PORT"
)
# CORS - keep raw string to avoid pydantic-settings JSON-list parsing pitfalls
cors_origins_raw: str = Field(
default=_CORS_ORIGINS_DEFAULT,
validation_alias="CORS_ORIGINS",
)
@property
def cors_origins(self) -> List[str]:
return _parse_cors_origins(self.cors_origins_raw)
# Domain
# external_host: Optional[HttpUrl] = Field(default=None, validation_alias="EXTERNAL_HOST") # currently deprecated for no use
base_url: Optional[HttpUrl] = Field(default=None, validation_alias="BASE_URL") # can use for routing toward other API later
# Other settings
project_id: str = Field(default="vkist-project", validation_alias="VERTEX_AI_PROJECT")
location: str = Field(default="asia-southeast1", validation_alias="VERTEX_AI_LOCATION")
temp_dir: str = Field(default="/tmp/analysis_jobs", validation_alias="TEMP_DIR")
vertex_ai_model: str = Field(default="medgemma", validation_alias="VERTEX_AI_MODEL")
redis_host: str = Field(default="localhost", validation_alias="REDIS_HOST")
redis_port: int = Field(default=6379, validation_alias="REDIS_PORT")
redis_db: int = Field(default=0, validation_alias="REDIS_DB")
clahe_clip_limit: float = Field(default=2.0, validation_alias="CLAHE_CLIP_LIMIT")
clahe_tile_size: Tuple[int, int] = Field(default=(8, 8), validation_alias="CLAHE_TILE_SIZE")
@field_validator("clahe_tile_size", mode="before")
@classmethod
def parse_tile_size(cls, v):
if isinstance(v, str):
parts = v.split(",")
if len(parts) == 2:
return (int(parts[0].strip()), int(parts[1].strip()))
return v
settings = Settings()
# Endpoints
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")
# Secrets - must come from environment variables only
# In Gitea Actions, these are set via repository secrets
# In local development, set via .env or shell environment
GCP_ACCESS_TOKEN = os.getenv("GCP_ACCESS_TOKEN")
MEDGEMMA_API_KEY = os.getenv("MEDGEMMA_API_KEY")
PROJECT_ID = os.getenv("VERTEX_AI_PROJECT", "vkist-project")
LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
# Legacy module-level constants for backward compatibility.
# These now derive from the validated settings model instead of raw os.getenv().
PROJECT_ID = settings.project_id
LOCATION = settings.location
TRITON_ENDPOINT = os.getenv("TRITON_ENDPOINT", "http://localhost:8000")
TEMP_DIR = os.getenv("TEMP_DIR", "/tmp/analysis_jobs")
TRITON_ENDPOINT = (
str(settings.triton_endpoint).rstrip("/") if settings.triton_endpoint else None
)
# 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")
TEMP_DIR = settings.temp_dir
# 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"))
VERTEX_AI_PROJECT = settings.project_id
VERTEX_AI_LOCATION = settings.location
VERTEX_AI_MODEL = settings.vertex_ai_model
REDIS_HOST = settings.redis_host
REDIS_PORT = settings.redis_port
REDIS_DB = settings.redis_db
DEFAULT_MODEL_VERSIONS = {
"angle": "angle_classify_convnext_tiny",
@@ -50,8 +134,8 @@ DEFAULT_MODEL_VERSIONS = {
"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(","))
CLAHE_CLIP_LIMIT = settings.clahe_clip_limit
CLAHE_TILE_SIZE = settings.clahe_tile_size
def get_model_name(task: str, model_versions: Dict[str, str] | None = None) -> str:
@@ -72,4 +156,3 @@ def get_segmentation_model(angle_class: str, model_versions: Dict[str, str] | No
angle_type = get_angle_type(angle_class)
task = "segmentation_sup" if angle_type == "sup" else "segmentation_post"
return get_model_name(task, model_versions)

View File

@@ -0,0 +1,50 @@
import asyncio
import base64
import io
import logging
from typing import Any
from PIL import Image
from backend.implementation.postprocessing.calibration import calibration_config_from_params
from backend.services.cv_inference_service import CvInferenceOptions, run_single
from backend.services.celery_app import celery_app
logger = logging.getLogger(__name__)
def _decode_base64_to_pil(b64_str: str) -> Image.Image:
raw = base64.b64decode(b64_str)
return Image.open(io.BytesIO(raw)).convert("RGB")
@celery_app.task(bind=True, name="cv_inference.run_chunk", max_retries=2, default_retry_delay=10)
def run_cv_chunk(self, chunk_payload: dict) -> list[dict]:
images_b64 = chunk_payload.get("images_b64", [])
frame_ids = chunk_payload.get("frame_ids", [])
calibration_params = chunk_payload.get("calibration", {})
model_versions = chunk_payload.get("model_versions")
if not images_b64:
return []
images = [_decode_base64_to_pil(b64) for b64 in images_b64]
calibration = calibration_config_from_params(calibration_params)
options = CvInferenceOptions(
calibration=calibration,
model_versions=model_versions,
use_cache=False,
)
async def _run():
return await asyncio.gather(*[
run_single(img, frame_id=fid, options=options)
for img, fid in zip(images, frame_ids)
])
try:
results = asyncio.run(_run())
return results
except Exception as exc:
logger.exception("Celery chunk task failed: %s", exc)
raise self.retry(exc=exc, countdown=10, max_retries=2)

View File

@@ -0,0 +1,29 @@
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
def setup_logging():
root = logging.getLogger()
root.setLevel(logging.DEBUG)
root.handlers.clear()
formatter = logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(formatter)
root.addHandler(console)
file_handler = RotatingFileHandler(
LOG_DIR / "app.log",
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.INFO)

View File

@@ -18,6 +18,10 @@ 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
from backend.services import cv_celery_service
from backend.logging.logging_config import setup_logging
setup_logging()
logger = logging.getLogger(__name__)
@@ -135,14 +139,15 @@ async def cv_inference_health():
)
@router.post("/analyze")
@router.post("/analyze") # deprecated
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)
options = _build_options(_parse_calibration_form(calibration), use_cache=True)
try:
result = await run_single(image_pil, frame_id=None, options=options)
@@ -168,11 +173,13 @@ async def analyze_batch_upload(
calibration: str | None = Form(default=None),
):
"""Spec-compliant CV batch — one full pipeline per frame (angle-first, gated segmentation)."""
logger.info("Starting analyze batch upload")
if not images:
raise HTTPException(status_code=400, detail="At least one image is required")
try:
id_list = json.loads(frame_ids)
# logger.info("Start to check the id_list")
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="frame_ids must be a JSON array of strings") from exc
@@ -213,6 +220,68 @@ async def analyze_batch_upload(
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/analyze/batch/celery")
async def analyze_batch_celery(
images: list[UploadFile] = File(...),
frame_ids: str = Form(...),
calibration: str | None = Form(default=None),
):
"""Experiment: async chunk fan-out via Celery + Redis."""
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:
job_id = cv_celery_service.submit_celery_batch(image_pils, id_list, options)
return JSONResponse({
"success": True,
"job_id": job_id,
"image_count": len(image_pils),
"mode": "celery-chunk-fanout",
"chunk_size": cv_celery_service.CELERY_CHUNK_SIZE,
})
except Exception as exc:
logger.exception("Celery batch submit failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/analyze/batch/celery/{job_id}")
async def analyze_batch_celery_result(job_id: str):
"""Poll result for a Celery chunk-fan-out batch job."""
try:
result = await cv_celery_service.get_celery_batch_result(job_id)
status = result.get("status")
if status == "pending":
return JSONResponse(
status_code=202,
content=result,
)
if status == "unknown":
return JSONResponse(
status_code=404,
content=result,
)
return JSONResponse(result)
except Exception as exc:
logger.exception("Celery batch result fetch failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/segment")
@router.post("/segment/batch")
@router.post("/angle")

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
#
# Launch the standalone CV inference FastAPI server (Modal Triton).
#
# Usage:
# ./backend/run_cv_inference.sh
#
# Works from any directory: it resolves the CODEBASE root relative to this
# script, sets PYTHONPATH so `import backend...` resolves, then runs the
# server as a module.
#
# Override defaults via env vars, e.g.:
# CV_INFERENCE_PORT=8080 ./backend/run_cv_inference.sh
#
set -euo pipefail
# CODEBASE root = grandparent dir of this script's directory (script lives in backend/routers/).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CODEBASE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "${CODEBASE_ROOT}"
export PYTHONPATH="${CODEBASE_ROOT}:${PYTHONPATH:-}"
# exec python -m backend.cv_inference_server
exec uvicorn backend.cv_inference_server:app --host 0.0.0.0 --port ${CV_INFERENCE_PORT:-8001}

View File

@@ -0,0 +1,22 @@
from celery import Celery
from backend.implementation import config
celery_app = Celery(
"cv-inference",
broker=f"redis://{config.REDIS_HOST}:{config.REDIS_PORT}/{config.REDIS_DB}",
backend=f"redis://{config.REDIS_HOST}:{config.REDIS_PORT}/{config.REDIS_DB}",
# Explicit include — autodiscover looks for tasks.py, not cv_tasks.py
include=["backend.implementation.tasks.cv_tasks"],
)
# Must match the @task(name=...) value, not the Python module path
celery_app.conf.task_routes = {
"cv_inference.run_chunk": {"queue": "cv-inference"},
}
celery_app.conf.task_serializer = "json"
celery_app.conf.result_serializer = "json"
celery_app.conf.accept_content = ["json"]
celery_app.conf.result_expires = 3600
celery_app.conf.task_default_queue = "cv-inference"

View File

@@ -3,7 +3,7 @@ import httpx
import json
from typing import AsyncGenerator
from datetime import datetime
import asyncio
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 (

View File

@@ -0,0 +1,130 @@
import base64
import dataclasses
import logging
import os
import time
from typing import Any
from backend.services.cv_inference_service import CvInferenceOptions, _encode_image_to_bytes
from backend.implementation.tasks.cv_tasks import run_cv_chunk
from backend.services.celery_app import celery_app
logger = logging.getLogger(__name__)
CELERY_CHUNK_SIZE = int(os.getenv("CELERY_CHUNK_SIZE", "4"))
CELERY_BATCH_POLL_CACHE_TTL_MS = float(os.getenv("CELERY_BATCH_POLL_CACHE_TTL_MS", "2000"))
_batch_poll_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_batch_timings: dict[str, float] = {}
def submit_celery_batch(images, frame_ids, options):
if not images:
raise ValueError("images must not be empty")
if len(frame_ids) != len(images):
raise ValueError("frame_ids length must match images length")
from celery import group
chunks = []
for i in range(0, len(images), CELERY_CHUNK_SIZE):
chunk_imgs = images[i : i + CELERY_CHUNK_SIZE]
chunk_fids = frame_ids[i : i + CELERY_CHUNK_SIZE]
b64_images = [
base64.b64encode(_encode_image_to_bytes(img)).decode() for img in chunk_imgs
]
chunk_payload = {
"images_b64": b64_images,
"frame_ids": chunk_fids,
"calibration": dataclasses.asdict(options.calibration) if options.calibration else {},
"model_versions": options.model_versions,
}
chunks.append(chunk_payload)
job = group(run_cv_chunk.s(chunk) for chunk in chunks)
result = job.apply_async()
try:
result.save()
except Exception:
logger.exception("Failed to persist Celery GroupResult for job %s", result.id)
_batch_timings[result.id] = time.monotonic()
logger.info(
"Celery batch submitted job_id=%s chunks=%d images=%d",
result.id,
len(chunks),
len(images),
)
return result.id
async def get_celery_batch_result(job_id):
from celery.result import GroupResult
now = time.monotonic()
cached = _batch_poll_cache.get(job_id)
if cached and cached[0] > now:
return cached[1]
result = GroupResult.restore(job_id, app=celery_app)
if result is None:
payload = {
"status": "unknown",
"detail": "Job ID not found in result backend",
}
_batch_poll_cache[job_id] = (now + CELERY_BATCH_POLL_CACHE_TTL_MS / 1000, payload)
return payload
completed = result.completed_count()
total = len(result.results)
if not result.ready():
payload = {
"status": "pending",
"completed": completed,
"total": total,
"progress": round(completed / total, 2) if total > 0 else 0,
}
_batch_poll_cache[job_id] = (now + CELERY_BATCH_POLL_CACHE_TTL_MS / 1000, payload)
return payload
if result.failed():
errors = []
for r in result.results:
if r.failed():
errors.append(str(r.result))
payload = {
"status": "failed",
"errors": errors,
}
_batch_poll_cache[job_id] = (now + CELERY_BATCH_POLL_CACHE_TTL_MS / 1000, payload)
_log_batch_timing(job_id, "failed")
return payload
all_results = []
for r in result.results:
chunk_results = r.get()
all_results.extend(chunk_results)
payload = {
"status": "completed",
"image_count": len(all_results),
"results": all_results,
}
_batch_poll_cache[job_id] = (now + CELERY_BATCH_POLL_CACHE_TTL_MS / 1000, payload)
_log_batch_timing(job_id, "completed")
return payload
def _log_batch_timing(job_id: str, status: str) -> None:
start = _batch_timings.pop(job_id, None)
if start is not None:
duration_ms = (time.monotonic() - start) * 1000
logger.info(
"Celery batch %s job_id=%s duration_ms=%.0f",
status,
job_id,
duration_ms,
)
else:
logger.info("Celery batch %s job_id=%s (no start time recorded)", status, job_id)

View File

@@ -5,6 +5,7 @@ import asyncio
import base64
import io
import logging
import os
from dataclasses import dataclass
from typing import Any
@@ -52,8 +53,6 @@ SEGMENT_CLASSES_POST = {
6: "baker's cyst",
}
_triton_pipeline_lock = asyncio.Lock()
@dataclass
class CvInferenceOptions:
@@ -76,6 +75,12 @@ def _encode_image_to_data_url(image_pil: Image.Image) -> str:
return f"data:image/png;base64,{encoded}"
def _encode_image_to_bytes(image_pil: Image.Image) -> bytes:
buffered = io.BytesIO()
image_pil.save(buffered, format="PNG")
return buffered.getvalue()
def _build_inflammation_payload(logits_row: np.ndarray, config: CalibrationConfig) -> dict:
interpreted = interpret_inflammation_logits(logits_row, config)
return {
@@ -147,6 +152,9 @@ def _build_segmentation_result(
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)
segmented_png_bytes = _encode_image_to_bytes(overlay)
segmented_b64 = base64.b64encode(segmented_png_bytes).decode()
result: dict[str, Any] = {
"success": True,
"angle": angle_payload,
@@ -161,7 +169,7 @@ def _build_segmentation_result(
},
"images": {
"enhanced": enhanced_data_url,
"segmented": _encode_image_to_data_url(overlay),
"segmented": f"data:image/png;base64,{segmented_b64}",
},
"models_used": {
"angle": angle_model,
@@ -281,16 +289,27 @@ async def _run_batch_uncached(
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(
concurrency = int(os.getenv("CV_BATCH_CONCURRENCY", "2"))
semaphore = asyncio.Semaphore(concurrency)
async def process_one(image_pil: Image.Image, fid: str, index: int):
async with semaphore:
if index > 0:
await asyncio.sleep(min(index * 0.15, 1.0))
return await _run_spec_cv_pipeline_single(
image_pil,
frame_id=fid,
options=options,
)
outcomes = await asyncio.gather(
*[process_one(img, fid, idx) for idx, (img, fid) in enumerate(zip(images, frame_ids))],
)
results: list[dict[str, Any]] = []
infer_modes: list[str] = []
triton_call_count = 0
for item, mode, calls in outcomes:
results.append(item)
infer_modes.append(mode)
triton_call_count += calls

View File

@@ -25,12 +25,11 @@ 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_INFER_RETRIES = int(os.getenv("TRITON_INFER_RETRIES", "2"))
TRITON_RETRY_BASE_S = float(os.getenv("TRITON_RETRY_BASE_S", "2.0"))
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
@@ -43,6 +42,11 @@ def _get_adapter() -> TritonAdapter:
global _adapter, _adapter_endpoint
endpoint = get_triton_endpoint()
if _adapter is None or _adapter_endpoint != endpoint:
if _adapter is not None:
try:
asyncio.get_event_loop().run_until_complete(_adapter.close())
except RuntimeError:
pass
_adapter = TritonAdapter(endpoint_url=endpoint, timeout=TRITON_INFER_TIMEOUT)
_adapter_endpoint = endpoint
return _adapter
@@ -201,7 +205,7 @@ def _infer_angle_logits_chunk(images: list[Image.Image], model_name: str) -> tup
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"
return _infer_angle_logits_batch(images, model_name), "batched"
except Exception as exc:
logger.warning(
"Batched angle infer×%s failed (%s); falling back to sequential single-image calls",
@@ -249,7 +253,7 @@ def _infer_inflammation_logits_chunk(
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"
return _infer_inflammation_logits_batch(images, model_name), "batched"
except Exception as exc:
logger.warning(
"Batched inflammation infer×%s failed (%s); falling back to sequential",
@@ -297,7 +301,7 @@ def _infer_segmentation_logits_chunk(
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"
return _infer_segmentation_logits_batch(images, model_name), "batched"
except Exception as exc:
logger.warning(
"Batched segmentation infer×%s failed (%s); falling back to sequential",
@@ -311,8 +315,6 @@ 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
@@ -332,7 +334,6 @@ 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
@@ -352,7 +353,6 @@ 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

View File

@@ -1,6 +1,7 @@
"""Warm up Modal Triton on CV service startup — reduces cold-start 502s and first-frame latency."""
from __future__ import annotations
import asyncio
import logging
import os
@@ -23,6 +24,20 @@ def _warmup_model_versions() -> dict[str, str]:
return versions
async def _warmup_one(
name: str,
coro,
timeout: float,
) -> None:
try:
await asyncio.wait_for(coro, timeout=timeout)
logger.info("Triton warmup %s complete", name)
except asyncio.TimeoutError:
logger.warning("Triton warmup %s timed out after %.1fs", name, timeout)
except Exception as exc:
logger.warning("Triton warmup %s failed: %s", name, exc)
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)")
@@ -36,13 +51,29 @@ async def warmup_triton_models() -> None:
img224 = Image.new("RGB", (224, 224), color=(128, 128, 128))
img512 = Image.new("RGB", (512, 512), color=(128, 128, 128))
warmup_timeout = float(os.getenv("TRITON_WARMUP_TIMEOUT", "15"))
logger.info(
"Warming up Triton (angle=%s, inflammation=%s, segmentation=%s)…",
"Warming up Triton (angle=%s, inflammation=%s, segmentation=%s, timeout=%.1fs)…",
angle_model,
inflam_model,
seg_model,
warmup_timeout,
)
await _warmup_one(
"angle",
triton_runtime.infer_angle_logits_single(img224, angle_model),
warmup_timeout,
)
await _warmup_one(
"inflammation",
triton_runtime.infer_inflammation_logits_single(img224, inflam_model),
warmup_timeout,
)
await _warmup_one(
"segmentation",
triton_runtime.infer_segmentation_logits_single(img512, seg_model),
warmup_timeout,
)
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")

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env bash
#
# Start Redis + Celery worker for CV inference experiments.
#
# Usage:
# ./backend/start_celery_workers.sh # start both
# ./backend/start_celery_workers.sh stop # stop both
# ./backend/start_celery_workers.sh restart # restart both
# ./backend/start_celery_workers.sh status # show status
#
# Logs:
# logs/redis.log
# logs/celery.log
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CODEBASE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
LOG_DIR="${CODEBASE_ROOT}/logs"
REDIS_PID_FILE="${LOG_DIR}/redis.pid"
CELERY_PID_FILE="${LOG_DIR}/celery.pid"
mkdir -p "${LOG_DIR}"
cd "${CODEBASE_ROOT}"
export PYTHONPATH="${CODEBASE_ROOT}:${PYTHONPATH:-}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_DB="${REDIS_DB:-0}"
CELERY_CHUNK_SIZE="${CELERY_CHUNK_SIZE:-4}"
export TRITON_ENDPOINT="${TRITON_ENDPOINT:-https://dtj-tran--triton-s3-service-unified-triton-server.modal.run}"
is_redis_running() {
if [ -f "${REDIS_PID_FILE}" ]; then
local pid
pid=$(cat "${REDIS_PID_FILE}")
if kill -0 "${pid}" 2>/dev/null; then
return 0
else
rm -f "${REDIS_PID_FILE}"
fi
fi
return 1
}
start_redis() {
if is_redis_running; then
echo "Redis already running (PID $(cat ${REDIS_PID_FILE}))"
return 0
fi
echo "Starting Redis on port ${REDIS_PORT}..."
redis-server \
--port "${REDIS_PORT}" \
--daemonize yes \
--pidfile "${REDIS_PID_FILE}" \
--logfile "${LOG_DIR}/redis.log" \
--save "" \
--appendonly no
sleep 0.5
if is_redis_running; then
echo "Redis started (PID $(cat ${REDIS_PID_FILE}))"
else
echo "Redis failed to start. Check ${LOG_DIR}/redis.log"
exit 1
fi
}
start_celery() {
if [ -f "${CELERY_PID_FILE}" ] && kill -0 "$(cat "${CELERY_PID_FILE}")" 2>/dev/null; then
echo "Celery already running (PID $(cat ${CELERY_PID_FILE}))"
return 0
fi
echo "Starting Celery worker..."
nohup celery \
-A backend.services.celery_app \
worker \
--loglevel=info \
-Q cv-inference \
--concurrency=2 \
> "${LOG_DIR}/celery.log" 2>&1 &
echo $! > "${CELERY_PID_FILE}"
sleep 1
if kill -0 "$(cat "${CELERY_PID_FILE}")" 2>/dev/null; then
echo "Celery started (PID $(cat ${CELERY_PID_FILE}))"
echo " Logs: ${LOG_DIR}/celery.log"
else
echo "Celery failed to start. Check ${LOG_DIR}/celery.log"
rm -f "${CELERY_PID_FILE}"
exit 1
fi
}
stop_redis() {
if is_redis_running; then
local pid
pid=$(cat "${REDIS_PID_FILE}")
echo "Stopping Redis (PID ${pid})..."
kill "${pid}" 2>/dev/null || true
sleep 0.5
rm -f "${REDIS_PID_FILE}"
echo "Redis stopped"
else
echo "Redis not running"
fi
}
stop_celery() {
if [ -f "${CELERY_PID_FILE}" ] && kill -0 "$(cat "${CELERY_PID_FILE}")" 2>/dev/null; then
local pid
pid=$(cat "${CELERY_PID_FILE}")
echo "Stopping Celery (PID ${pid})..."
kill "${pid}" 2>/dev/null || true
sleep 1
# Force kill if still running
if kill -0 "${pid}" 2>/dev/null; then
kill -9 "${pid}" 2>/dev/null || true
fi
rm -f "${CELERY_PID_FILE}"
echo "Celery stopped"
else
echo "Celery not running"
fi
}
status() {
echo "=== Worker Status ==="
if is_redis_running; then
echo "Redis: running (PID $(cat ${REDIS_PID_FILE}))"
else
echo "Redis: stopped"
fi
if [ -f "${CELERY_PID_FILE}" ] && kill -0 "$(cat "${CELERY_PID_FILE}")" 2>/dev/null; then
echo "Celery: running (PID $(cat ${CELERY_PID_FILE}))"
else
echo "Celery: stopped"
fi
}
case "${1:-start}" in
start)
start_redis
start_celery
echo ""
echo "Workers ready. Test with:"
echo " curl http://localhost:8001/api/test/analyze/batch/celery"
;;
stop)
stop_celery
stop_redis
;;
restart)
stop_celery
stop_redis
start_redis
start_celery
;;
status)
status
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac

View File

@@ -0,0 +1,118 @@
# =============================================================================
# Stage 1: Wheelhouse Builder
# Pre-compiles all Python dependencies into .whl files into /wheels.
# This layer is cached independently and only rebuilds when requirements.txt
# changes. Heavy packages (torch, opencv, pycocotools, etc.) are compiled once
# per platform (amd64/arm64) and reused by all subsequent builds.
# =============================================================================
FROM python:3.12-slim-bookworm AS wheelhouse
WORKDIR /wheels
# Install build tools required for packages with C extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
make \
libgl1 \
libglib2.0-0 \
libxcb1 \
libx11-6 \
libxext6 \
libsm6 \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first so this layer is cached unless deps change
COPY requirements.txt .
# Build wheel files for all dependencies AND their transitive dependencies.
# --wheel-dir=/wheels: write .whl files to the wheelhouse directory
# (no --no-deps: we want the full dependency tree pre-compiled)
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
# =============================================================================
# Stage 2: Application Builder
# Creates a venv and installs dependencies from the local wheelhouse.
# Because pip installs from /wheels (not PyPI), this layer stays cached as
# long as the wheelhouse is unchanged — application code changes do NOT
# trigger re-downloading or recompiling any dependency.
# =============================================================================
FROM python:3.12-slim-bookworm AS builder
WORKDIR /app
# Install build tools (kept for safety; wheels are pre-compiled but some
# packages may still invoke build steps during install)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
make \
libgl1 \
libglib2.0-0 \
libxcb1 \
libx11-6 \
libxext6 \
libsm6 \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment so we can copy a single tree to runtime
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
# Copy requirements for pip install validation
COPY requirements.txt .
# Copy pre-built wheels from wheelhouse stage (platform-specific)
COPY --from=wheelhouse /wheels /wheels
# Install dependencies FROM LOCAL WHEELS ONLY (no network access needed)
# --no-index: do not query PyPI or any external index
# --find-links: use only the local wheelhouse at /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt
# =============================================================================
# Stage 3: Runtime
# Minimal image containing only the venv and application code.
# =============================================================================
FROM python:3.12-slim-bookworm AS runtime
WORKDIR /app
# Create non-root user
# RUN groupadd -r appgroup && useradd -r -g appgroup -d /app -s /sbin/nologin appuser
# # Copy the entire venv from builder
# COPY --from=builder --chown=appuser:appgroup /opt/venv /opt/venv
# Step 2: Assign an explicit numeric ID (like 999) when creating the user/group
RUN groupadd -g 999 appgroup && \
useradd -r -u 999 -g appgroup -d /app -s /sbin/nologin appuser
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
libxcb1 \
libx11-6 \
libxext6 \
libsm6 \
&& rm -rf /var/lib/apt/lists/*
# Step 3: Use the exact matching numeric IDs for --chown
COPY --from=builder --chown=999:999 /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
# Copy application code
COPY --chown=999:999 backend ./backend
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8001
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')" || exit 1
# Run the CV inference server
ENTRYPOINT ["python", "-m", "backend.cv_inference_server"]

View File

@@ -0,0 +1,22 @@
version: '3.8'
services:
cv-inference-server:
image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/msk-cv-inference-server:latest
container_name: cv-inference-server
restart: unless-stopped
ports:
- "8001:8001"
# Environment variables passed from shell (injected by webhook listener from Gitea vars)
# No .env file needed — all config comes from Gitea repo variables via webhook payload
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
default:
name: pilot-network
external: true

View File

@@ -0,0 +1,328 @@
"""
Modal script to build and push VKIST backend Docker image to container registry.
Run from Gitea runner on Lightsail (2GB RAM) - offloads heavy build to Modal serverless.
Usage:
modal run gitea_modal_build.py \
--registry public.ecr.aws/vkist-project \
--image-name vkist-backend \
--tag v1.2.3 \
--push
# Or deploy as reusable Modal app:
modal deploy gitea_modal_build.py
"""
import os
from pathlib import Path
import modal
# =============================================================================
# Configuration - Dynamic paths relative to this script
# =============================================================================
# Get the directory where this script actually lives on the machine running it
SCRIPT_DIR = Path(__file__).resolve().parent
# Automatically resolve the root of the repository (climbing up 3 levels)
# From: deps/implementation/backend_deploy/gitea_modal_build.py
# To: CODEBASE/
PROJECT_ROOT = SCRIPT_DIR.parents[2]
# Explicitly find your local Dockerfile
DOCKERFILE_PATH = SCRIPT_DIR / "Dockerfile"
print(f"--- Environment Verification ---")
print(f"Script location: {SCRIPT_DIR}")
print(f"Resolved PROJECT_ROOT: {PROJECT_ROOT} (Exists: {PROJECT_ROOT.exists()})")
print(f"Resolved DOCKERFILE_PATH: {DOCKERFILE_PATH} (Exists: {DOCKERFILE_PATH.exists()})")
print(f"----------------------------------------")
# Project layout variables
BACKEND_SOURCE = "backend"
REQUIREMENTS_FILE = "requirements.txt"
# Default registry/image settings
DEFAULT_REGISTRY = "public.ecr.aws/i9a4e3f6/msk-cv-inference-server"
DEFAULT_IMAGE_NAME = "msk-lumina-backend"
DEFAULT_TAG = "latest"
# Modal app configuration
APP_NAME = "msk-lumina-backend-builder"
# =============================================================================
# Modal Image Definition - uses the project's Dockerfile for consistency
# =============================================================================
# Build the image using the existing multi-stage Dockerfile
# We set context_dir to PROJECT_ROOT so the Dockerfile can see ../../../../backend
backend_image = modal.Image.from_dockerfile(
str(DOCKERFILE_PATH),
context_dir=str(PROJECT_ROOT),
).pip_install(
"docker", # for test_image function
"boto3", # for ECR auth if needed
)
app = modal.App(APP_NAME, image=backend_image)
# =============================================================================
# Build and Push Function
# =============================================================================
@app.function(
timeout=900, # 15 min for heavy ML deps (torch, transformers, etc.)
cpu=4,
memory=8192, # 8GB RAM - plenty for wheel building
secrets=[
modal.Secret.from_name("aws-secrets"), # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
# modal.Secret.from_name("dockerhub-credentials"), # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
# modal.Secret.from_name("gcp-artifact-registry"), # GCP credentials (optional)
],
)
def build_and_push(
registry: str = DEFAULT_REGISTRY,
image_name: str = DEFAULT_IMAGE_NAME,
tag: str = DEFAULT_TAG,
push: bool = True,
platform: str = "linux/amd64",
) -> dict:
"""
Build the backend Docker image and push to registry.
Args:
registry: Container registry hostname (e.g., public.ecr.aws/vkist-project)
image_name: Image name (e.g., vkist-backend)
tag: Image tag (e.g., v1.2.3, latest, git-sha)
push: Whether to push to registry
platform: Target platform (linux/amd64, linux/arm64)
Returns:
Dict with image reference, digest, and size info
"""
import docker
full_image = f"{registry}/{image_name}:{tag}"
print(f"Building {full_image} for {platform}")
# Initialize Docker client (uses Modal's container runtime)
client = docker.from_env()
# Build using the Dockerfile from project root context
print(f"Build context: {PROJECT_ROOT}")
print(f"Dockerfile: {DOCKERFILE_PATH}")
# Build the image
image, build_logs = client.images.build(
path=PROJECT_ROOT,
dockerfile=DOCKERFILE_PATH,
tag=full_image,
platform=platform,
rm=True,
pull=True,
buildargs={
"BUILDKIT_INLINE_CACHE": "1",
},
)
# Stream build logs
for chunk in build_logs:
if "stream" in chunk:
print(chunk["stream"].strip())
elif "error" in chunk:
raise RuntimeError(f"Build failed: {chunk['error']}")
print(f"Build complete: {image.id[:12]}")
# Get image details
image_info = client.images.get(full_image)
size_bytes = image_info.attrs["Size"]
digest = image_info.attrs.get("RepoDigests", ["unknown"])[0]
result = {
"image": full_image,
"digest": digest,
"size_bytes": size_bytes,
"image_id": image.id,
}
if push:
print(f"Pushing to {registry}...")
# Authenticate with registry based on hostname
_authenticate_registry(registry, client)
# Push image
push_logs = client.images.push(
repository=f"{registry}/{image_name}",
tag=tag,
stream=True,
decode=True,
)
for chunk in push_logs:
if "status" in chunk:
print(f" {chunk['status']}: {chunk.get('progress', '')}")
if "error" in chunk:
raise RuntimeError(f"Push failed: {chunk['error']}")
print(f"Push complete: {full_image}")
# Get digest after push
pushed_image = client.images.get(full_image)
result["digest"] = pushed_image.attrs.get("RepoDigests", ["unknown"])[0]
return result
def _authenticate_registry(registry: str, client):
"""Authenticate Docker client with the target registry."""
import subprocess
if "ecr.aws" in registry or "ecr." in registry:
# AWS ECR (public or private)
region = os.getenv("AWS_REGION", "us-east-1")
print(f"Authenticating with AWS ECR ({region})...")
subprocess.run([
"aws", "ecr", "get-login-password", "--region", region
], check=True, capture_output=True)
# Note: Modal's aws-ecr-credentials secret should handle this via env vars
elif "docker.io" in registry or "index.docker.io" in registry or "/" not in registry.split("/")[0]:
# Docker Hub
username = os.getenv("DOCKERHUB_USERNAME")
password = os.getenv("DOCKERHUB_TOKEN")
if username and password:
print("Authenticating with Docker Hub...")
client.login(username=username, password=password)
elif "gcr.io" in registry or "pkg.dev" in registry:
# Google Container Registry / Artifact Registry
print("Authenticating with GCP Artifact Registry...")
subprocess.run(["gcloud", "auth", "configure-docker", "--quiet"], check=True)
@app.function(
image=backend_image,
timeout=120,
secrets=[modal.Secret.from_name("aws-secrets")],
)
def test_image(registry: str, image_name: str, tag: str) -> dict:
"""Test the built image by running a quick health check."""
import docker
full_image = f"{registry}/{image_name}:{tag}"
client = docker.from_env()
print(f"Pulling {full_image} for testing...")
client.images.pull(full_image)
# print("Running health check...")
# output = client.containers.run(
# full_image,
# command=["python", "-c", "import backend.cv_inference_server; print('Import OK')"],
# remove=True,
# detach=False,
# )
print("Running basic smoke test...")
output = client.containers.run(
full_image,
command=[
"python", "-c",
"import sys; print('Python', sys.version); "
"import cv2; print('OpenCV', cv2.__version__); "
# "import backend.cv_inference_server; print('Import OK')",
],
remove=True,
detach=False,
)
return {
"status": "healthy",
"output": output.decode().strip(),
"image": full_image,
}
# =============================================================================
# CLI Entry Point
# =============================================================================
@app.local_entrypoint()
def main(
registry: str = DEFAULT_REGISTRY,
image_name: str = DEFAULT_IMAGE_NAME,
tag: str = DEFAULT_TAG,
push: bool = True,
platform: str = "linux/amd64",
test: bool = False,
):
"""
Build and push backend Docker image via Modal.
Args:
registry: Container registry (default: public.ecr.aws/vkist-project)
image_name: Image name (default: vkist-backend)
tag: Image tag (default: latest)
push: Push to registry (default: true)
platform: Target platform (default: linux/amd64)
test: Run health check after build (default: false)
"""
print("=" * 60)
print("VKIST Backend Docker Build on Modal")
print("=" * 60)
print(f"Registry: {registry}")
print(f"Image: {image_name}")
print(f"Tag: {tag}")
print(f"Push: {push}")
print(f"Platform: {platform}")
print(f"Test: {test}")
print("=" * 60)
# Build and push
result = build_and_push.remote(
registry=registry,
image_name=image_name,
tag=tag,
push=push,
platform=platform,
)
print("\n" + "=" * 60)
print("BUILD COMPLETE")
print("=" * 60)
print(f"Image: {result['image']}")
print(f"Digest: {result['digest']}")
print(f"Size: {result['size_bytes'] / 1024 / 1024:.1f} MB")
print("=" * 60)
# Output for CI/CD parsing (GitHub Actions / Gitea Actions format)
print(f"::set-output name=image::{result['image']}")
print(f"::set-output name=digest::{result['digest']}")
print(f"::set-output name=size_mb::{result['size_bytes'] / 1024 / 1024:.1f}")
# Optional test
if test and push:
print("\nRunning health check...")
test_result = test_image.remote(registry, image_name, tag)
print(f"Health check: {test_result['status']}")
print(f"Output: {test_result['output']}")
if __name__ == "__main__":
# Allow direct execution for debugging
import sys
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
print(__doc__)
print("\nExamples:")
print(" modal run gitea_modal_build.py --tag v1.0.0")
print(" modal run gitea_modal_build.py --registry docker.io/myuser --image-name myapp --tag latest")
print(" modal deploy gitea_modal_build.py")
else:
print("Use: modal run gitea_modal_build.py [options]")
print("Or: modal deploy gitea_modal_build.py")

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Wrapper script for Gitea runner to invoke Modal build.
# Runs on Lightsail VM (2GB RAM) - offloads Docker build to Modal serverless.
#
# Usage: ./modal_build.sh [TAG] [REGISTRY] [IMAGE_NAME] [PLATFORM]
# TAG - Image tag (default: git SHA or 'latest')
# REGISTRY - Container registry (default: public.ecr.aws/vkist-project)
# IMAGE_NAME - Image name (default: vkist-backend)
# PLATFORM - Target platform (default: linux/amd64)
#
# Environment variables (set as Gitea repository secrets):
# MODAL_TOKEN - Modal API token (from modal.com/settings)
# AWS_ACCESS_KEY_ID - For ECR push
# AWS_SECRET_ACCESS_KEY
# AWS_REGION - Default: us-east-1
# DOCKERHUB_USERNAME - Optional, for Docker Hub
# DOCKERHUB_TOKEN - Optional, for Docker Hub
#
# Example:
# ./modal_build.sh v1.2.3
# ./modal_build.sh latest public.ecr.aws/my-project my-app linux/arm64
set -euo pipefail
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODAL_SCRIPT="${SCRIPT_DIR}/gitea_modal_build.py"
# Defaults
DEFAULT_REGISTRY="public.ecr.aws/vkist-project"
DEFAULT_IMAGE_NAME="vkist-backend"
DEFAULT_PLATFORM="linux/amd64"
# Parse arguments
TAG="${1:-${GITEA_SHA:-latest}}"
REGISTRY="${2:-${DEFAULT_REGISTRY}}"
IMAGE_NAME="${3:-${DEFAULT_IMAGE_NAME}}"
PLATFORM="${4:-${DEFAULT_PLATFORM}}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
# Check prerequisites
check_prereqs() {
log_info "Checking prerequisites..."
if ! command -v modal &> /dev/null; then
log_error "Modal CLI not found. Install with: pip install modal"
exit 1
fi
if [ ! -f "$MODAL_SCRIPT" ]; then
log_error "Modal script not found at: $MODAL_SCRIPT"
exit 1
fi
# Check Modal auth
if [ -z "${MODAL_TOKEN:-}" ]; then
log_warn "MODAL_TOKEN not set. Ensure 'modal setup' was run or token is in environment."
fi
log_info "Prerequisites OK"
}
# Build via Modal
build_via_modal() {
log_info "Starting Modal build..."
log_info "Registry: $REGISTRY"
log_info "Image: $IMAGE_NAME"
log_info "Tag: $TAG"
log_info "Platform: $PLATFORM"
# Run modal build
modal run "$MODAL_SCRIPT" \
--registry "$REGISTRY" \
--image-name "$IMAGE_NAME" \
--tag "$TAG" \
--platform "$PLATFORM" \
--push
local exit_code=$?
if [ $exit_code -eq 0 ]; then
log_info "Build and push completed successfully!"
log_info "Image: $REGISTRY/$IMAGE_NAME:$TAG"
else
log_error "Modal build failed with exit code $exit_code"
exit $exit_code
fi
}
# Main
main() {
log_info "=== VKIST Modal Docker Build Wrapper ==="
check_prereqs
build_via_modal
log_info "=== Done ==="
}
main "$@"

View File

@@ -0,0 +1,64 @@
#!/bin/bash
# Deploy script for VKIST CV Inference Server (Backend)
# Triggered by Gitea webhook — env vars come from webhook payload, NOT .env file
set -euo pipefail
# Configuration
PROJECT_DIR="/opt/pilot-project"
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.backend.yaml"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
main() {
log "Starting CV inference server deployment"
# Verify compose file exists
if [[ ! -f "${COMPOSE_FILE}" ]]; then
log "ERROR: Compose file not found at ${COMPOSE_FILE}"
exit 1
fi
cd "${PROJECT_DIR}"
# Pull latest compose file (in case it was updated)
log "Pulling latest compose file from git"
git pull --quiet origin main || log "WARNING: git pull failed, using local compose file"
# Pull latest image
# ECR_PUBLIC_REGISTRY_ALIAS comes from webhook env payload
log "Pulling latest image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/msk-cv-inference-server:latest"
docker compose -f "${COMPOSE_FILE}" pull cv-inference-server
# Deploy with zero-downtime recreation
# All required env vars (TRITON_ENDPOINT, CV_INFERENCE_HOST, etc.) are already
# in the shell environment, passed from webhook payload
log "Recreating cv-inference-server container"
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate cv-inference-server
# Clean up old images
log "Pruning unused images"
docker image prune -f
# Verify health
log "Waiting for health check..."
for i in {1..30}; do
if docker compose -f "${COMPOSE_FILE}" exec -T cv-inference-server \
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')" 2>/dev/null; then
log "Health check passed"
break
fi
if [[ $i -eq 30 ]]; then
log "ERROR: Health check failed after 30 attempts"
docker compose -f "${COMPOSE_FILE}" logs cv-inference-server
exit 1
fi
sleep 2
done
log "Deployment completed successfully"
}
main "$@"

View File

@@ -0,0 +1,18 @@
# /opt/pilot-project/Caddyfile
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}

View File

@@ -0,0 +1,25 @@
version: '3.8'
services:
lumina-frontend:
image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/lumina-frontend:latest
container_name: lumina-frontend
restart: unless-stopped
ports:
- "8080:80"
deploy:
resources:
limits:
memory: 128m
cpus: '0.5'
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
default:
name: pilot-network
external: true

View File

@@ -0,0 +1,14 @@
# Lightsail Production Environment
# Copy to /opt/pilot-project/.env on Lightsail VM
# chmod 600 /opt/pilot-project/.env
# ECR Public Registry Alias (e.g., vkist-pilot, your-account-alias)
# Get from: AWS Console > ECR Public > Registries > [your registry] > Alias
ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
# Webhook Secret (generate with: openssl rand -hex 32)
# Must match Gitea webhook secret configuration
WEBHOOK_SECRET=your-generated-secret-here
# Optional: Backend API URL for nginx proxy (if different from frontend)
# BACKEND_API_URL=http://localhost:8001

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Gitea Webhook Listener for Lumina Frontend Deploy
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=/opt/pilot-project
EnvironmentFile=/opt/pilot-project/.env
ExecStart=/usr/bin/python3 /opt/pilot-project/webhook-listener.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/pilot-project
ProtectHome=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,82 @@
#!/bin/bash
# Deploy script for Lumina MSK Frontend
# Triggered by Gitea webhook - receives env vars from Gitea repo variables
set -euo pipefail
# Configuration
PROJECT_DIR="/opt/pilot-project"
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.frontend.yaml"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
# Required env vars (passed from webhook / Gitea variables)
REQUIRED_VARS=(
"ECR_PUBLIC_REGISTRY_ALIAS"
"CORS_ORIGINS"
)
# Write .env from passed environment variables (so docker compose env_file works)
write_env_file() {
local env_file="${PROJECT_DIR}/.env"
log "Writing .env from passed environment variables"
for var in "${REQUIRED_VARS[@]}"; do
if [[ -n "${!var:-}" ]]; then
echo "${var}=${!var}" >> "${env_file}"
else
log "WARNING: ${var} not set in environment"
fi
done
}
main() {
log "Starting frontend deployment"
# Verify compose file exists
if [[ ! -f "${COMPOSE_FILE}" ]]; then
log "ERROR: Compose file not found at ${COMPOSE_FILE}"
exit 1
fi
cd "${PROJECT_DIR}"
# Pull latest compose file (in case it was updated)
log "Pulling latest compose file from git"
git pull --quiet origin main || log "WARNING: git pull failed, using local compose file"
# Write .env from passed env vars
write_env_file
# Pull latest image
log "Pulling latest image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/lumina-frontend:latest"
docker compose -f "${COMPOSE_FILE}" pull lumina-frontend
# Deploy with zero-downtime recreation
log "Recreating frontend container"
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate lumina-frontend
# Clean up old images
log "Pruning unused images"
docker image prune -f
# Verify health
log "Waiting for health check..."
for i in {1..30}; do
if docker compose -f "${COMPOSE_FILE}" exec -T lumina-frontend wget --quiet --tries=1 --spider http://localhost/ 2>/dev/null; then
log "Health check passed"
break
fi
if [[ $i -eq 30 ]]; then
log "ERROR: Health check failed after 30 attempts"
docker compose -f "${COMPOSE_FILE}" logs lumina-frontend
exit 1
fi
sleep 2
done
log "Deployment completed successfully"
}
main "$@"

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
Gitea Webhook Listener for Lumina Deployment
Runs on Lightsail VM, receives webhook from Gitea, triggers the appropriate
deploy script based on which workflow completed.
"""
import os
import hmac
import hashlib
import subprocess
import logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
# Configuration
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
DEPLOY_SCRIPTS = {
'Frontend CI/CD': '/opt/pilot-project/webhook-deploy.sh',
'Backend ECR Deployment': '/opt/pilot-project/webhook-deploy-backend.sh',
'Backend Modal Build': '/opt/pilot-project/webhook-deploy-backend.sh',
}
LISTEN_HOST = '127.0.0.1'
LISTEN_PORT = 3333
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
# Verify path
if self.path != '/deploy':
self.send_response(404)
self.end_headers()
return
# Read body
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
# Verify signature
signature = self.headers.get('X-Gitea-Signature', '')
if not self.verify_signature(body, signature):
logger.warning("Invalid webhook signature")
self.send_response(401)
self.end_headers()
self.wfile.write(b'Invalid signature')
return
# Parse JSON payload
try:
import json
payload = json.loads(body.decode('utf-8'))
except json.JSONDecodeError:
logger.error("Invalid JSON payload")
self.send_response(400)
self.end_headers()
return
# Check if workflow completed successfully
deploy_info = self.should_deploy(payload)
if not deploy_info:
logger.info("Event does not trigger deploy: %s", payload.get('action', 'unknown'))
self.send_response(200)
self.end_headers()
self.wfile.write(b'Event ignored')
return
deploy_script = deploy_info['script']
deploy_env = deploy_info.get('env', {})
# Trigger deployment with env vars from payload
logger.info("Triggering deployment with script: %s", deploy_script)
logger.debug("Passing env vars: %s", list(deploy_env.keys()))
try:
# Merge payload env vars with current process env
proc_env = os.environ.copy()
proc_env.update(deploy_env)
result = subprocess.run(
[deploy_script],
capture_output=True,
text=True,
timeout=300,
env=proc_env
)
if result.returncode == 0:
logger.info("Deployment successful")
logger.debug("Deploy output: %s", result.stdout)
self.send_response(200)
self.end_headers()
self.wfile.write(b'Deployment triggered successfully')
else:
logger.error("Deployment failed: %s", result.stderr)
self.send_response(500)
self.end_headers()
self.wfile.write(f'Deployment failed: {result.stderr}'.encode())
except subprocess.TimeoutExpired:
logger.error("Deployment timed out")
self.send_response(500)
self.end_headers()
self.wfile.write(b'Deployment timed out')
except Exception as e:
logger.exception("Deployment error")
self.send_response(500)
self.end_headers()
self.wfile.write(f'Deployment error: {str(e)}'.encode())
def verify_signature(self, body: bytes, signature: str) -> bool:
"""Verify Gitea HMAC-SHA256 signature"""
if not WEBHOOK_SECRET:
logger.error("WEBHOOK_SECRET not configured")
return False
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
def should_deploy(self, payload: dict) -> dict | None:
"""Determine if this webhook event should trigger a deploy.
Returns dict with 'script' and optional 'env' if yes, None if no."""
# Gitea workflow_run event
if payload.get('action') == 'completed':
workflow = payload.get('workflow', {})
workflow_name = workflow.get('name', '')
conclusion = payload.get('conclusion')
if conclusion == 'success' and workflow_name in DEPLOY_SCRIPTS:
return {
'script': DEPLOY_SCRIPTS[workflow_name],
'env': payload.get('env', {})
}
# Direct webhook payload (for manual triggers or extended format)
if 'service' in payload:
service = payload.get('service')
if service == 'cv-inference-server':
return {
'script': DEPLOY_SCRIPTS['Backend ECR Deployment'],
'env': payload.get('env', {})
}
if service == 'lumina-frontend':
return {
'script': DEPLOY_SCRIPTS['Frontend CI/CD'],
'env': payload.get('env', {})
}
return None
def log_message(self, format, *args):
logger.info("%s - %s", self.address_string(), format % args)
def main():
if not WEBHOOK_SECRET:
logger.error("WEBHOOK_SECRET environment variable not set")
exit(1)
# Verify deploy scripts exist
for name, path in DEPLOY_SCRIPTS.items():
if not os.path.exists(path):
logger.warning("Deploy script not found for %s at %s", name, path)
server = HTTPServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler)
logger.info("Webhook listener starting on %s:%s", LISTEN_HOST, LISTEN_PORT)
logger.info("Watching workflows: %s", ', '.join(DEPLOY_SCRIPTS.keys()))
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("Shutting down")
server.shutdown()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,28 @@
# --- Stage 1: Builder ---
FROM python:3.12-slim AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Install python dependencies into a local folder
RUN pip install --user modal
# --- Stage 2: Runner ---
FROM python:3.12-slim
# Install only the runtime dependencies needed for Gitea Actions
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy the installed packages from the builder stage
COPY --from=builder /root/.local /root/.local
# Ensure the local bin is in the PATH
ENV PATH=/root/.local/bin:$PATH

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

View File

@@ -0,0 +1,342 @@
# ECR Public CI/CD Setup Guide (with Caddy Reverse Proxy)
Complete setup for Lumina MSK Frontend: Gitea CI → Amazon ECR Public → Lightsail Deploy with Caddy (auto-HTTPS)
---
## Prerequisites
| Requirement | Version/Details |
|-------------|-----------------|
| AWS Account | With ECR Public access |
| Lightsail VM | 2 GB, Ubuntu 22.04+, Docker + Docker Compose installed |
| Gitea Server | Running on Lightsail (or accessible), Actions enabled |
| Domain | For HTTPS via Let's Encrypt (Caddy handles this automatically) |
---
## 1. Amazon ECR Public Setup
### 1.1 Create Public Registry
```bash
# AWS Console → ECR Public → Create registry
# Note the Registry Alias (e.g., vkist-pilot)
# Region: us-east-1 (only region for ECR Public)
```
### 1.2 Create Repository
```bash
aws ecr-public create-repository \
--repository-name lumina-frontend \
--region us-east-1
```
### 1.3 Create IAM User for CI
```bash
aws iam create-user --user-name gitea-ci-ecr-public
```
### 1.4 Attach Policy
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr-public:GetAuthorizationToken",
"ecr-public:InitiateLayerUpload",
"ecr-public:UploadLayerPart",
"ecr-public:CompleteLayerUpload",
"ecr-public:PutImage"
],
"Resource": "*"
}
]
}
```
```bash
aws iam put-user-policy \
--user-name gitea-ci-ecr-public \
--policy-name ECRPublicPush \
--policy-document file://ecr-public-push-policy.json
```
### 1.5 Create Access Keys
```bash
aws iam create-access-key --user-name gitea-ci-ecr-public
# Save: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
```
---
## 2. Gitea Secrets Configuration
Go to: **Repository → Settings → Secrets → Actions**
| Secret Name | Value |
|-------------|-------|
| `AWS_ACCESS_KEY_ID` | From step 1.5 |
| `AWS_SECRET_ACCESS_KEY` | From step 1.5 |
| `ECR_PUBLIC_REGISTRY_ALIAS` | Your registry alias (e.g., `vkist-pilot`) |
| `WEBHOOK_SECRET` | `openssl rand -hex 32` |
| `DEPLOY_WEBHOOK_URL` | `http://<lightsail-ip>:3333/deploy` |
---
## 3. Lightsail VM Setup
### 3.1 Install Docker + Compose
```bash
sudo apt update
sudo apt install -y docker.io docker-compose-plugin python3
sudo usermod -aG docker ubuntu
newgrp docker
```
### 3.2 Deploy Project Files
```bash
cd /opt
sudo git clone <your-gitea-repo-url> pilot-project
sudo chown -R ubuntu:ubuntu pilot-project
cd pilot-project
```
### 3.3 Configure Environment
```bash
cp workspace/sprint_1_2/CODEBASE/deps/spec/env.example .env
# Edit .env with your values:
# ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
# WEBHOOK_SECRET=<same-as-gitea-secret>
```
### 3.4 Deploy Compose File
```bash
cp workspace/sprint_1_2/CODEBASE/deps/spec/docker-compose.frontend.yaml .
# Create external network
docker network create pilot-network
```
### 3.5 Install Deploy Script + Webhook Listener
```bash
cp workspace/sprint_1_2/CODEBASE/deps/spec/webhook-deploy.sh .
cp workspace/sprint_1_2/CODEBASE/deps/spec/webhook-listener.py .
chmod +x webhook-deploy.sh
```
### 3.6 Install Systemd Service
```bash
sudo cp workspace/sprint_1_2/CODEBASE/deps/spec/gitea-webhook-listener.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable gitea-webhook-listener
sudo systemctl start gitea-webhook-listener
sudo systemctl status gitea-webhook-listener
```
### 3.7 Verify Webhook Listener
```bash
curl -X POST http://localhost:3333/deploy \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: sha256=$(echo -n '{}' | openssl dgst -sha256 -hmac "$(grep WEBHOOK_SECRET .env | cut -d= -f2)" | cut -d' ' -f2)" \
-d '{}'
# Should return "Event ignored" (no workflow completed)
```
---
## 4. Gitea Webhook Configuration
### 4.1 Add Webhook
**Repository → Settings → Webhooks → Add Webhook**
| Field | Value |
|-------|-------|
| Target URL | `http://<lightsail-ip>:3333/deploy` |
| Secret | `<same WEBHOOK_SECRET from step 2>` |
| Events | ✅ Workflow Run |
| Active | ✅ |
### 4.2 Test Webhook
Use Gitea's "Test Delivery" button → should return 200 with "Event ignored"
---
## 5. Caddy Reverse Proxy (Auto-HTTPS)
### 5.1 Install Caddy
```bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```
### 5.2 Configure Caddyfile
```bash
# Copy Caddyfile template
cp workspace/sprint_1_2/CODEBASE/deps/spec/Caddyfile.example /opt/pilot-project/Caddyfile
# Edit with your domain
sudo vim /opt/pilot-project/Caddyfile
```
### 5.3 Caddyfile Content
```text
# /opt/pilot-project/Caddyfile
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
### 5.4 Run Caddy (Docker Compose - Recommended)
Add to your `docker-compose.frontend.yaml` or create separate `docker-compose.caddy.yaml`:
```yaml
# docker-compose.caddy.yaml
version: '3.8'
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /opt/pilot-project/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- pilot-network
volumes:
caddy_data:
caddy_config:
networks:
pilot-network:
external: true
```
```bash
# Start Caddy
docker compose -f docker-compose.caddy.yaml up -d
# Or run directly on host (if not using Docker)
# sudo systemctl enable --now caddy
```
### 5.5 Verify HTTPS
```bash
# Wait ~30s for Let's Encrypt cert issuance
curl -I https://app.example.com/health
# Should return 200 with strict-transport-security header
```
---
## 6. First Deployment Test
### 6.1 Trigger CI
```bash
cd <local-repo>
# Make a small change
echo "# test" >> workspace/sprint_1_2/CODEBASE/frontend/implementation/README.md
git add . && git commit -m "test: trigger CI" && git push origin main
```
### 6.2 Monitor
- **Gitea Actions tab** → Watch workflow run
- **Lightsail** → `journalctl -u gitea-webhook-listener -f`
- **ECR Public Console** → Verify image pushed
### 6.3 Verify
```bash
# Via Caddy (HTTPS)
curl https://app.example.com/health
# Should return "healthy"
# Direct to frontend (HTTP, for debugging)
curl http://<lightsail-ip>:8080/health
# Should return "healthy"
```
---
## 7. Rollback Procedure
```bash
# On Lightsail
docker images public.ecr.aws/vkist-pilot/lumina-frontend
# Tag previous SHA as latest
docker tag public.ecr.aws/vkist-pilot/lumina-frontend:<old-sha> \
public.ecr.aws/vkist-pilot/lumina-frontend:latest
# Redeploy
cd /opt/pilot-project
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
---
## 8. Cost Estimate (Monthly)
| Component | Cost |
|-----------|------|
| Lightsail 2 GB | $10 |
| ECR Public storage (1 GB) | ~$0.10 |
| ECR Public data transfer | Free (public pulls) |
| **Total** | **~$10.10/month** |
---
## 9. Troubleshooting
| Issue | Check |
|-------|-------|
| CI fails at ECR login | AWS credentials in Gitea secrets, IAM policy |
| Image not found on pull | Registry alias correct, image pushed to ECR |
| Webhook 401 | `WEBHOOK_SECRET` matches in .env and Gitea |
| Container unhealthy | `docker logs lumina-frontend`, check nginx config |
| Caddy cert not issued | Port 80/443 open on Lightsail firewall, DNS points to Lightsail IP |
| OOM on 2 GB VM | Reduce `memory: 128m` in compose, add swap |
---
## 10. File Inventory
| File | Location | Purpose |
|------|----------|---------|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow |
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
| `nginx.conf` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | SPA routing + caching (inside container) |
| `docker-compose.frontend.yaml` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Production runtime |
| `webhook-deploy.sh` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Deploy script |
| `webhook-listener.py` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Webhook HTTP server |
| `gitea-webhook-listener.service` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Systemd unit |
| `env.example` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Environment template |
| `Caddyfile.example` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Caddy config template |
| `ci_cd_docker_registry_flow.md` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Full documentation |
| `ci_cd_ecr_public_flow.md` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | ECR Public specific docs |
---
*Generated for VKIST Pilot Project — MSK Ultrasound Stack*
*Compatible with: Gitea 1.21+, Docker 24+, Node 20, AWS ECR Public, Caddy 2*

View File

@@ -0,0 +1,478 @@
# CI/CD Flow: Docker Registry-Based Deployment (with Caddy)
## Overview
This document specifies the complete CI/CD flow where the Gitea CI pipeline builds the frontend Docker image, pushes it to a container registry, and the Lightsail VM pulls and deploys the image via Docker Compose. **Caddy** handles automatic HTTPS termination and reverse proxying.
---
## Architecture Diagram
```mermaid
sequenceDiagram
autonumber
actor Dev as Developer
participant Git as Local Git
participant Gitea as Gitea (Lightsail VM)
participant CI as Gitea Actions Runner
participant Registry as Container Registry
participant Lightsail as Lightsail VM (Production)
participant Caddy as Caddy (HTTPS + Reverse Proxy)
Dev->>Git: git push origin main
Git->>Gitea: Push commits
Note over Gitea: Receives push event
Gitea->>CI: Dispatch workflow job
par CI Build Stage
CI->>CI: Checkout code (actions/checkout@v4)
CI->>CI: Setup Node.js 26 (actions/setup-node@v4)
CI->>CI: npm ci (install deps)
CI->>CI: npm run build (verify build)
CI->>CI: Login to registry (docker/login-action)
CI->>CI: Build Docker image (docker/build-push-action)
CI->>Registry: Push image (tagged with SHA + latest)
end
Note over Registry: Image stored: registry.example.com/lumina-frontend:sha123
par Deploy Stage
Lightsail->>Registry: Pull latest image
Lightsail->>Lightsail: docker compose pull
Lightsail->>Lightsail: docker compose up -d --force-recreate
Lightsail->>Caddy: Automatic HTTPS (no reload needed)
end
Dev->>Caddy: Access https://app.example.com
Caddy->>Lightsail: Proxy to frontend container (localhost:8080)
Lightsail->>Dev: Serves frontend
```
---
## Component Diagram (C4)
```mermaid
C4Container
title Container Diagram — Docker Registry CI/CD with Caddy
Person(dev, "Developer", "Writes code, pushes to Gitea")
System_Boundary(gitea_boundary, "Gitea Server (Lightsail VM)") {
Container(gitea, "Gitea", "Git + CI/CD", "Hosts repos, runs Actions workflows")
Container(runner, "Gitea Actions Runner", "Ephemeral", "Executes CI jobs in containers")
}
System_Boundary(registry_boundary, "Container Registry") {
Container(registry, "Registry", "Docker Hub / GCR / ECR / Harbor", "Stores built images")
}
System_Boundary(prod_boundary, "Production (Lightsail VM)") {
Container(compose, "Docker Compose", "Orchestrator", "Manages frontend container lifecycle")
Container(frontend, "Frontend Container", "nginx:alpine", "Serves Lumina MSK static assets")
Container(caddy, "Caddy", "HTTPS + Reverse Proxy", "Auto-TLS via Let's Encrypt, proxies to frontend")
}
Rel(dev, gitea, "git push", "HTTPS/SSH")
Rel(gitea, runner, "Dispatches job", "Internal API")
Rel(runner, registry, "docker push", "HTTPS")
Rel(compose, registry, "docker pull", "HTTPS")
Rel(compose, frontend, "Manages container", "Docker API")
Rel(caddy, frontend, "Reverse proxy", "HTTP (localhost:8080)")
Rel(dev, caddy, "Accesses app", "HTTPS (auto-TLS)")
```
---
## Flow Description
### 1. Trigger Phase
| Event | Condition | Workflow File |
|-------|-----------|---------------|
| Push to `main` | Changes under `workspace/sprint_1_2/CODEBASE/frontend/implementation/**` | `.gitea/workflows/frontend.yaml` |
| Manual dispatch | Via Gitea UI | Same workflow |
### 2. CI Build Stage (Runs on Gitea Actions Runner)
| Step | Tool | Purpose |
|------|------|---------|
| Checkout | `actions/checkout@v4` | Clone repository |
| Setup Node | `actions/setup-node@v4` | Node.js 26 + cache `node_modules` |
| Install deps | `npm ci` | Clean, reproducible install |
| Verify build | `npm run build` | Runs `sync-wasm``tsc -b``vite build`; fails if TypeScript errors |
| Login registry | `docker/login-action@v3` | Authenticate to registry using secrets |
| Build & push | `docker/build-push-action@v5` | Multi-stage build, push with SHA + `latest` tags |
**Image tags pushed:**
- `registry.example.com/lumina-frontend:<git-sha>` — immutable, traceable
- `registry.example.com/lumina-frontend:latest` — rolling pointer for deploy
### 3. Registry
Supported registries (pick one):
- **Docker Hub** — public/private, generous free tier
- **GitHub Container Registry (GHCR)** — integrated with GitHub, but Gitea can push
- **Google Container Registry (GCR)** — if on GCP
- **Amazon ECR Public** — free public images, authenticated pushes
- **Harbor / self-hosted** — full control, on Lightsail or separate VM
**Required secrets in Gitea:**
| Secret Name | Value |
|-------------|-------|
| `REGISTRY_URL` | e.g., `docker.io` or `ghcr.io` |
| `REGISTRY_USER` | Registry username |
| `REGISTRY_PASS` | Registry password / token |
### 4. Deploy Stage (Runs on Lightsail VM)
Two sub-options:
#### 4a. Polling / Cron (Simplest)
```bash
# /etc/cron.hourly/deploy-frontend
#!/bin/bash
cd /opt/pilot-project
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
docker image prune -f
```
#### 4b. Webhook Listener (Event-driven)
A small HTTP server on Lightsail receives a webhook from Gitea after CI succeeds and triggers deploy immediately.
```bash
# webhook-deploy.sh (triggered by webhook)
cd /opt/pilot-project
git pull # optional: ensure compose file is current
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
Gitea webhook config:
- **URL**: `http://<lightsail-ip>:3333/deploy`
- **Events**: Workflow run completed (success)
- **Secret**: Shared HMAC secret
### 5. Runtime on Lightsail (with Caddy)
#### Docker Compose
```yaml
# docker-compose.frontend.yaml
version: '3.8'
services:
lumina-frontend:
image: registry.example.com/lumina-frontend:latest
container_name: lumina-frontend
restart: unless-stopped
expose:
- "80" # Internal only, Caddy proxies to this
deploy:
resources:
limits:
memory: 128m
cpus: '0.5'
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- lumina-frontend
volumes:
caddy_data:
caddy_config:
networks:
default:
name: pilot-network
external: true
```
#### Caddyfile (Auto-HTTPS)
```text
# Caddyfile
# Replace with your actual domain
app.example.com {
# Reverse proxy to frontend container
reverse_proxy lumina-frontend:80
# Optional: Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Cache static assets
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
**Key benefits of Caddy:**
- **Automatic HTTPS** — No manual cert management, Let's Encrypt handled automatically
- **HTTP/2 & HTTP/3** — Enabled by default
- **Zero-downtime reloads** — Config changes don't drop connections
- **No external ACME client needed** — Built-in
---
## Required Artifacts to Create
| File | Location | Purpose |
|------|----------|---------|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow definition |
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
| `docker-compose.frontend.yaml` | `/opt/pilot-project/` on Lightsail | Production runtime (frontend + Caddy) |
| `Caddyfile` | `/opt/pilot-project/` on Lightsail | Caddy config with domain |
| `webhook-deploy.sh` (optional) | `/opt/pilot-project/` on Lightsail | Deploy script |
| systemd unit (optional) | `/etc/systemd/system/` on Lightsail | Run webhook listener |
| Gitea secrets | Gitea UI → Repo → Settings → Secrets | Registry credentials |
---
## Secrets Management
| Secret | Where | Scope |
|--------|-------|-------|
| `REGISTRY_URL` | Gitea repo secrets | CI build step |
| `REGISTRY_USER` | Gitea repo secrets | CI build step |
| `REGISTRY_PASS` | Gitea repo secrets | CI build step |
| `WEBHOOK_SECRET` | Gitea webhook config + Lightsail env | Deploy trigger auth |
---
## Failure Scenarios & Mitigations
| Scenario | Detection | Mitigation |
|----------|-----------|------------|
| CI build fails | Workflow red in Gitea | Fix code, re-push |
| Registry push fails | CI step fails | Check credentials, quota |
| Pull fails on Lightsail | Deploy script logs | Check network, image tag exists |
| Container crash loop | Healthcheck fails | `docker logs lumina-frontend` |
| Caddy cert failure | Caddy logs (`docker logs caddy`) | Check domain DNS, port 80/443 reachable |
| OOM on 2 GB VM | `free -h`, `docker stats` | Reduce `memory` limit, add swap |
---
## Rollback
```bash
# On Lightsail: list available tags
docker images registry.example.com/lumina-frontend
# Rollback to specific SHA
docker tag registry.example.com/lumina-frontend:<old-sha> registry.example.com/lumina-frontend:latest
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
---
## Cost Estimate (Monthly)
| Component | Estimate |
|-----------|----------|
| Lightsail 2 GB instance | $1012 |
| Registry (Docker Hub free / GHCR) | $05 |
| Data transfer | < $1 |
| **Total** | **~$1018 / month** |
---
## Next Steps
1. Choose a container registry and create repo/namespace
2. Add registry secrets to Gitea
3. Create `.gitea/workflows/frontend.yaml` (see appendix)
4. Create `Dockerfile` in frontend implementation dir
5. Deploy `docker-compose.frontend.yaml` + `Caddyfile` to Lightsail
6. Point domain DNS to Lightsail public IP
7. Test end-to-end: push to `main` → verify CI → verify deploy → verify HTTPS
---
## Appendix: Minimal Workflow File
```yaml
# .gitea/workflows/frontend.yaml
name: Frontend CI/CD
on:
push:
branches: [main]
paths:
- 'workspace/sprint_1_2/CODEBASE/frontend/implementation/**'
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: workspace/sprint_1_2/CODEBASE/frontend/implementation/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Build frontend (verify)
run: npm run build
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/lumina-frontend
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
file: ./workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
---
## Appendix: Minimal Dockerfile
```dockerfile
# workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
# Stage 1: Build
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist /usr/share/nginx/html
# Custom nginx config for SPA + caching
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
---
## Appendix: nginx.conf (for Frontend Container)
```nginx
# workspace/sprint_1_2/CODEBASE/frontend/implementation/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache static assets aggressively
location ~* \.(js|css|wasm|png|jpg|jpeg|svg|woff2|ico|map)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/wasm;
}
```
---
## Appendix: Caddyfile (for Lightsail)
```text
# /opt/pilot-project/Caddyfile
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
---
*Document version: 2.0 (Caddy integration)*
*Last updated: 2026-07-17*
*Owner: Platform Engineering / VKIST Team*

View File

@@ -0,0 +1,756 @@
# CI/CD Flow: Docker Registry-Based Deployment (Amazon ECR Public)
## Overview
This document specifies the complete CI/CD flow where the Gitea CI pipeline builds the frontend Docker image, pushes it to **Amazon ECR Public**, and the Lightsail VM pulls and deploys the image via Docker Compose with **Caddy** as the reverse proxy (auto-HTTPS via Let's Encrypt).
---
## Architecture Diagram
```mermaid
sequenceDiagram
autonumber
actor Dev as Developer
participant Git as Local Git
participant Gitea as Gitea (Lightsail VM)
participant CI as Gitea Actions Runner
participant ECR as Amazon ECR Public
participant Lightsail as Lightsail VM (Production)
participant Caddy as Caddy (Reverse Proxy + TLS)
Dev->>Git: git push origin main
Git->>Gitea: Push commits
Note over Gitea: Receives push event
Gitea->>CI: Dispatch workflow job
par CI Build Stage
CI->>CI: Checkout code (actions/checkout@v4)
CI->>CI: Configure AWS credentials (aws-actions/configure-aws-credentials)
CI->>CI: Login to ECR Public (aws-actions/amazon-ecr-login)
CI->>CI: Setup Node.js 20 (actions/setup-node@v4)
CI->>CI: npm ci (install deps)
CI->>CI: npm run build (verify build)
CI->>CI: Build Docker image (docker/build-push-action)
CI->>ECR: Push image (tagged with SHA + latest)
end
Note over ECR: Image stored: public.ecr.aws/<alias>/lumina-frontend:sha123
par Deploy Stage
Lightsail->>ECR: Pull latest image (public, no auth needed for pull)
Lightsail->>Lightsail: docker compose pull
Lightsail->>Lightsail: docker compose up -d --force-recreate
Lightsail->>Caddy: Reload config (if needed)
end
Dev->>Lightsail: Access https://app.example.com
Lightsail->>Dev: Serves frontend (auto-HTTPS)
```
---
## Component Diagram (C4)
```mermaid
C4Container
title Container Diagram — ECR Public CI/CD with Caddy
Person(dev, "Developer", "Writes code, pushes to Gitea")
System_Boundary(gitea_boundary, "Gitea Server (Lightsail VM)") {
Container(gitea, "Gitea", "Git + CI/CD", "Hosts repos, runs Actions workflows")
Container(runner, "Gitea Actions Runner", "Ephemeral", "Executes CI jobs in containers")
}
System_Boundary(aws_boundary, "AWS Cloud") {
Container(ecr, "Amazon ECR Public", "public.ecr.aws", "Stores public container images")
}
System_Boundary(prod_boundary, "Production (Lightsail VM)") {
Container(compose, "Docker Compose", "Orchestrator", "Manages frontend container lifecycle")
Container(caddy, "Caddy", "Reverse Proxy + TLS", "Auto-HTTPS via Let's Encrypt, serves Lumina MSK frontend")
}
Rel(dev, gitea, "git push", "HTTPS/SSH")
Rel(gitea, runner, "Dispatches job", "Internal API")
Rel(runner, ecr, "docker push (authenticated)", "HTTPS + AWS SigV4")
Rel(compose, ecr, "docker pull (public)", "HTTPS")
Rel(compose, caddy, "Proxies to frontend", "Docker network")
Rel(dev, caddy, "Accesses app", "HTTPS (auto)")
```
---
## Flow Description
### 1. Trigger Phase
| Event | Condition | Workflow File |
|-------|-----------|---------------|
| Push to `main` | Changes under `workspace/sprint_1_2/CODEBASE/frontend/implementation/**` | `.gitea/workflows/frontend.yaml` |
| Manual dispatch | Via Gitea UI | Same workflow |
### 2. CI Build Stage (Runs on Gitea Actions Runner)
| Step | Tool | Purpose |
|------|------|---------|
| Checkout | `actions/checkout@v4` | Clone repository |
| Configure AWS | `aws-actions/configure-aws-credentials@v4` | Set AWS credentials for ECR access |
| Login to ECR Public | `aws-actions/amazon-ecr-login@v2` | Authenticate Docker to ECR Public |
| Setup Node | `actions/setup-node@v4` | Node.js 20 + cache `node_modules` |
| Install deps | `npm ci` | Clean, reproducible install |
| Verify build | `npm run build` | Runs `sync-wasm``tsc -b``vite build`; fails if TypeScript errors |
| Build & push | `docker/build-push-action@v5` | Multi-stage build, push with SHA + `latest` tags |
**Image tags pushed:**
- `public.ecr.aws/<registry-alias>/lumina-frontend:<git-sha>` — immutable, traceable
- `public.ecr.aws/<registry-alias>/lumina-frontend:latest` — rolling pointer for deploy
### 3. Amazon ECR Public
**Repository URI format:**
```
public.ecr.aws/<registry-alias>/lumina-frontend
```
**Key properties:**
- **Public pulls** — No authentication required for `docker pull`
- **Authenticated pushes** — Requires AWS credentials with `ecr-public:PutImage`, `ecr-public:InitiateLayerUpload`, `ecr-public:UploadLayerPart`, `ecr-public:CompleteLayerUpload`
- **Registry alias** — Created once per AWS account (e.g., `vkist-pilot`)
- **Region** — `us-east-1` (ECR Public is only in us-east-1)
**Required IAM permissions for CI user/role:**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr-public:GetAuthorizationToken",
"ecr-public:InitiateLayerUpload",
"ecr-public:UploadLayerPart",
"ecr-public:CompleteLayerUpload",
"ecr-public:PutImage"
],
"Resource": "*"
}
]
}
```
**Required secrets in Gitea:**
| Secret Name | Value |
|-------------|-------|
| `AWS_ACCESS_KEY_ID` | IAM user access key with ECR Public permissions |
| `AWS_SECRET_ACCESS_KEY` | Corresponding secret key |
| `AWS_ECR_REGION` | `us-east-1` (ECR Public region) |
| `ECR_PUBLIC_REGISTRY_ALIAS` | Your ECR Public registry alias (e.g., `vkist-pilot`) |
### 4. Deploy Stage (Runs on Lightsail VM)
Since ECR Public images are publicly pullable, **no AWS credentials needed on Lightsail** for deployment.
Two sub-options:
#### 4a. Polling / Cron (Simplest)
```bash
#!/bin/bash
# /etc/cron.hourly/deploy-frontend
cd /opt/pilot-project
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
docker image prune -f
```
#### 4b. Webhook Listener (Event-driven)
A small HTTP server on Lightsail receives a webhook from Gitea after CI succeeds and triggers deploy immediately.
```bash
#!/bin/bash
# /opt/pilot-project/webhook-deploy.sh
set -e
cd /opt/pilot-project
git pull # optional: ensure compose file is current
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
```python
#!/usr/bin/env python3
"""
Gitea Webhook Listener - secrets from AWS SSM Parameter Store
"""
import os
import hmac
import hashlib
import json
import subprocess
import logging
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler
try:
import boto3
except ImportError:
print("ERROR: boto3 required. Install: pip install boto3")
exit(1)
# Configuration
SSM_PREFIX = "/lumina"
AWS_ECR_REGION = os.environ.get("AWS_ECR_REGION", "us-east-1")
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 3333
DEPLOY_SCRIPT = Path(__file__).parent / "webhook-deploy.sh"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
def load_secrets_from_ssm() -> dict:
"""Fetch all parameters under /lumina prefix from SSM Parameter Store."""
client = boto3.client("ssm", region_name=AWS_ECR_REGION)
response = client.get_parameters_by_path(
Path=SSM_PREFIX,
WithDecryption=True,
Recursive=True
)
secrets = {}
for param in response.get("Parameters", []):
key = param["Name"].split("/")[-1].upper()
secrets[key] = param["Value"]
return secrets
SECRETS = load_secrets_from_ssm()
WEBHOOK_SECRET = SECRETS.get("WEBHOOK_SECRET", "")
ECR_REGISTRY_ALIAS = SECRETS.get("ECR_REGISTRY_ALIAS", "")
if not WEBHOOK_SECRET:
logger.error("WEBHOOK_SECRET not found in SSM at %s/webhook-secret", SSM_PREFIX)
exit(1)
def verify_signature(payload: bytes, signature_header: str) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/deploy":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
payload = self.rfile.read(content_length)
signature = self.headers.get("X-Gitea-Signature", "")
if not verify_signature(payload, signature):
logger.warning("Invalid signature from %s", self.client_address[0])
self.send_response(401)
self.end_headers()
self.wfile.write(b"Invalid signature")
return
try:
event = json.loads(payload)
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
return
# Only deploy on successful frontend workflow
if event.get("workflow_run", {}).get("conclusion") != "success":
logger.info("Workflow not successful, ignoring")
self.send_response(200)
self.end_headers()
self.wfile.write(b"Ignored")
return
logger.info("Triggering deployment")
try:
result = subprocess.run(
[str(DEPLOY_SCRIPT)],
capture_output=True,
text=True,
timeout=300,
env={**os.environ, "ECR_PUBLIC_REGISTRY_ALIAS": ECR_REGISTRY_ALIAS}
)
if result.returncode == 0:
self.send_response(200)
self.end_headers()
self.wfile.write(b"Deployed")
else:
self.send_response(500)
self.end_headers()
self.wfile.write(f"Failed: {result.stderr}".encode())
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(f"Error: {e}".encode())
def log_message(self, format, *args):
logger.info("%s - %s", self.client_address[0], format % args)
def main():
if not DEPLOY_SCRIPT.exists():
logger.error("Deploy script not found: %s", DEPLOY_SCRIPT)
exit(1)
os.chmod(DEPLOY_SCRIPT, 0o755)
server = HTTPServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler)
logger.info("Listener on %s:%d (registry: %s)", LISTEN_HOST, LISTEN_PORT, ECR_REGISTRY_ALIAS)
try:
server.serve_forever()
except KeyboardInterrupt:
server.shutdown()
if __name__ == "__main__":
main()
```
**systemd unit** (`/etc/systemd/system/gitea-webhook-listener.service`):
```ini
[Unit]
Description=Gitea Webhook Listener (Lumina Frontend)
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/pilot-project
ExecStart=/usr/bin/python3 /opt/pilot-project/webhook-listener.py
Restart=always
RestartSec=10
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/pilot-project
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
**Gitea webhook config:**
- **URL**: `http://<lightsail-public-ip>:3333/deploy` (or via Caddy proxy)
- **Events**: Workflow Run → Completed
- **Secret**: Same `WEBHOOK_SECRET` value (stored in SSM)
- **Content-Type**: `application/json`
### 5. Runtime on Lightsail with Caddy
**Docker Compose** (`docker-compose.frontend.yaml`):
```yaml
version: '3.8'
services:
lumina-frontend:
image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/lumina-frontend:latest
container_name: lumina-frontend
restart: unless-stopped
expose:
- "80"
deploy:
resources:
limits:
memory: 128m
cpus: '0.5'
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- lumina-frontend
networks:
default:
name: pilot-network
external: true
volumes:
caddy_data:
caddy_config:
```
**Caddyfile** (`/opt/pilot-project/Caddyfile`):
```text
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
**Environment file** (`/opt/pilot-project/.env`):
```bash
ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
```
---
## Required Artifacts to Create
| File | Location | Purpose |
|------|----------|---------|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow definition |
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
| `docker-compose.frontend.yaml` | `/opt/pilot-project/` on Lightsail | Production runtime (frontend + Caddy) |
| `Caddyfile` | `/opt/pilot-project/` on Lightsail | Caddy reverse proxy config |
| `webhook-deploy.sh` (optional) | `/opt/pilot-project/` on Lightsail | Deploy script |
| `webhook-listener.py` (optional) | `/opt/pilot-project/` on Lightsail | Webhook server (secrets from SSM) |
| systemd unit (optional) | `/etc/systemd/system/` on Lightsail | Run webhook listener |
| `.env` | `/opt/pilot-project/` on Lightsail | Registry alias variable |
| Gitea secrets | Gitea UI → Repo → Settings → Secrets | AWS credentials + alias |
| SSM Parameters | AWS Console → Systems Manager → Parameter Store | `WEBHOOK_SECRET`, `ECR_REGISTRY_ALIAS` |
---
## Secrets Management
| Secret | Where | Scope |
|--------|-------|-------|
| `AWS_ACCESS_KEY_ID` | Gitea repo secrets | CI: ECR authentication |
| `AWS_SECRET_ACCESS_KEY` | Gitea repo secrets | CI: ECR authentication |
| `AWS_ECR_REGION` | Gitea repo secrets | CI: `us-east-1` |
| `ECR_PUBLIC_REGISTRY_ALIAS` | Gitea repo secrets + Lightsail `.env` | CI tag + Compose image ref |
| `WEBHOOK_SECRET` | **AWS SSM SecureString** (`/lumina/webhook-secret`) | Deploy trigger auth |
| `ECR_REGISTRY_ALIAS` | **AWS SSM String** (`/lumina/ecr-registry-alias`) | Deploy script env var |
**Lightsail IAM Instance Profile** must have:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
"Resource": "arn:aws:ssm:us-east-1:<account-id>:parameter/lumina/*"
}
]
}
```
---
## ECR Public Setup Steps
### 1. Create ECR Public Repository
```bash
aws ecr-public create-repository \
--repository-name lumina-frontend \
--region us-east-1
```
Note the `registryAlias` from output (e.g., `vkist-pilot`).
### 2. Create IAM User for CI
```bash
aws iam create-user --user-name gitea-ci-ecr-public
aws iam attach-user-policy \
--user-name gitea-ci-ecr-public \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPublicFullAccess
# Or use custom least-privilege policy above
aws iam create-access-key --user-name gitea-ci-ecr-public
```
Save the Access Key ID and Secret Access Key.
### 3. Add Secrets to Gitea
Gitea UI → Repository → Settings → Secrets → Add:
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_ECR_REGION` = `us-east-1`
- `ECR_PUBLIC_REGISTRY_ALIAS` = your alias (e.g., `vkist-pilot`)
### 4. Store Secrets in SSM Parameter Store
```bash
# Webhook secret (generate once)
aws ssm put-parameter \
--name "/lumina/webhook-secret" \
--value "$(openssl rand -hex 32)" \
--type "SecureString" \
--region us-east-1
# Registry alias (not sensitive, but convenient)
aws ssm put-parameter \
--name "/lumina/ecr-registry-alias" \
--value "vkist-pilot" \
--type "String" \
--region us-east-1
```
### 5. Attach IAM Instance Profile to Lightsail
AWS Console → Lightsail → Your Instance → Networking → Attach IAM instance profile → Select profile with SSM read access.
---
## Failure Scenarios & Mitigations
| Scenario | Detection | Mitigation |
|----------|-----------|------------|
| CI build fails | Workflow red in Gitea | Fix code, re-push |
| ECR login fails | CI step fails | Check AWS credentials, permissions |
| Push fails (throttling) | CI step fails | Retry, check ECR Public rate limits |
| Pull fails on Lightsail | Deploy script logs | Check network, image tag exists |
| Container crash loop | Healthcheck fails | `docker logs lumina-frontend` |
| OOM on 2 GB VM | `free -h`, `docker stats` | Reduce `memory` limit, add swap |
| Caddy TLS fails | Caddy logs | Check DNS, port 80/443 reachable |
---
## Rollback
```bash
# On Lightsail: list available tags (requires AWS CLI for ECR Public)
aws ecr-public describe-images \
--repository-name lumina-frontend \
--region us-east-1 \
--query 'imageDetails[*].imageTags' \
--output text
# Rollback to specific SHA
docker tag public.ecr.aws/vkist-pilot/lumina-frontend:<old-sha> public.ecr.aws/vkist-pilot/lumina-frontend:latest
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
---
## Cost Estimate (Monthly)
| Component | Estimate |
|-----------|----------|
| Lightsail 2 GB instance | $1012 |
| ECR Public storage (first 50 GB) | Free |
| ECR Public data transfer (first 5 TB/mo to internet) | Free |
| Data transfer (Lightsail ↔ ECR Public) | < $1 |
| **Total** | **~$1013 / month** |
---
## Next Steps
1. Create ECR Public repository `lumina-frontend` in `us-east-1`
2. Create IAM user `gitea-ci-ecr-public` with ECR Public permissions
3. Add AWS credentials + registry alias to Gitea secrets
4. Store `WEBHOOK_SECRET` and `ECR_REGISTRY_ALIAS` in AWS SSM Parameter Store
5. Create `.gitea/workflows/frontend.yaml` (see appendix)
6. Create `Dockerfile` in frontend implementation dir
7. Deploy `docker-compose.frontend.yaml`, `Caddyfile`, `.env` to Lightsail
8. Attach IAM instance profile with SSM read access to Lightsail
9. Test end-to-end: push to `main` → verify CI → verify deploy + HTTPS
---
## Appendix: Workflow File for ECR Public
```yaml
# .gitea/workflows/frontend.yaml
name: Frontend CI/CD (ECR Public)
on:
push:
branches: [main]
paths:
- 'workspace/sprint_1_2/CODEBASE/frontend/implementation/**'
workflow_dispatch: {}
permissions:
contents: read
id-token: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_ECR_REGION }}
- name: Login to Amazon ECR Public
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
with:
registry-type: public
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: workspace/sprint_1_2/CODEBASE/frontend/implementation/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Build frontend (verify)
run: npm run build
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: public.ecr.aws/${{ secrets.ECR_PUBLIC_REGISTRY_ALIAS }}/lumina-frontend
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
file: ./workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
---
## Appendix: Minimal Dockerfile
```dockerfile
# workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
# Stage 1: Build
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
---
## Appendix: nginx.conf for SPA (inside container)
```nginx
# workspace/sprint_1_2/CODEBASE/frontend/implementation/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache static assets aggressively
location ~* \.(js|css|wasm|png|jpg|jpeg|svg|woff2|ico|map)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Main location - SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/wasm;
}
```
---
## Appendix: Caddyfile (Lightsail)
```text
# /opt/pilot-project/Caddyfile
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
---
*Document version: 2.0 (Caddy + SSM integration)*
*Last updated: 2026-07-17*
*Registry: Amazon ECR Public (us-east-1)*
*Reverse Proxy: Caddy (auto-HTTPS via Let's Encrypt)*
*Secrets: AWS SSM Parameter Store*
*Owner: Platform Engineering / VKIST Team*

View File

@@ -0,0 +1,142 @@
* The build was for creating the build pipeline in the CI/CD system of the project
that involve: Lightsail instance as broker, Modal-Serverless as the build-worker, and Gitea as the source repository hosted on the same Lightsail VM.
```mermaid
sequenceDiagram
autonumber
actor Developer
participant Git as Codebase (Local Git)
participant Gitea as Gitea (on Lightsail VM)
participant Webhook as Webhook Listener (Lightsail VM)
participant Modal as Modal Serverless (VM Sandbox)
Developer->>Git: git push code
Git->>Gitea: Push commits to remote repo (Lightsail)
Note over Gitea: Gitea registers a new pending job<br/>in the Actions queue
Gitea->>Webhook: HTTP POST Webhook (Push event)
rect rgb(240, 248, 255)
Note over Webhook: Triggered by Webhook (same Lightsail VM)
Webhook->>Modal: Spawn sandbox container via Modal SDK
end
rect rgb(245, 245, 245)
Note over Modal: Boot Sandbox VM (<2 seconds)
Modal->>Gitea: ./act_runner register (using registration token)
Modal->>Gitea: ./act_runner daemon --once (asks for work)
Gitea->>Modal: Delivers the queued build job
Note over Modal: Runner clones codebase,<br/>builds Docker container, & runs test/compile
Modal->>Gitea: Reports build results (Success/Failure)
end
Note over Modal: act_runner exits automatically (--once)<br/>Modal Sandbox self-destructs
Gitea->>Developer: Displays build status in UI
```
---
### Phase 1: Gitea Preparation (Lightsail VM)
This phase configures the Gitea instance running on the Lightsail VM to accept serverless runners.
- [ ] **Task 1.1: Enable Gitea Actions**
- SSH into the Lightsail VM.
- Open `/etc/gitea/app.ini` on the Lightsail instance.
- Add or update the following block to enable the Actions feature:Ini, TOML
```
[actions]
ENABLED = true
```
- Restart Gitea (`sudo systemctl restart gitea`).
- [ ] **Task 1.2: Retrieve Gitea Runner Registration Token**
- Access the Gitea web UI running on the Lightsail VM (e.g., `http://<lightsail-ip>:3000`) as an administrator.
- Navigate to **Site Administration** > **Actions** > **Runners**.
- Click **Registration Token** and copy this token (you will need it for the Modal Sandbox registration).
> **Note:** All Gitea administration tasks in this plan assume you are managing the Gitea instance hosted on the Lightsail VM, either via SSH or its web UI.
### Phase 2: Create the Webhook Listener (Lightsail VM)
The listener runs on the same Lightsail VM as Gitea and catches the git push notification from Gitea to call Modal.
- [ ] **Task 2.1: Write the Express/Node.js Webhook Server**
- Create a simple script on the Lightsail VM that listens on a port (e.g., `3333`) and exposes a `/deploy` route.
- The route must verify Gitea's Webhook Secret header to ensure requests are authentic.
- When a valid POST request arrives, use the `child_process` module to run a local command: `modal run deploy_runner.py`.
- [ ] **Task 2.2: Configure Webhook in Gitea**
- In your Gitea repository settings (on the Lightsail-hosted Gitea), create a Gitea Webhook pointing to `http://127.0.0.1:3333/deploy` (or your Lightsail public domain).
- Set the trigger event to **Push Events** and assign a strong webhook secret token.
### Phase 3: Build the Modal Serverless Runner (Modal Cloud)
This python script defines the sandbox container and coordinates the registration, execution, and termination with the Gitea instance on Lightsail.
- [ ] **Task 3.1: Define the Modal Image (`deploy_runner.py`)**
- Configure a Modal image containing:
- `git`
- `docker` (or `podman`/`kaniko` if you are building Docker images inside the sandbox)
- The Gitea `act_runner` binary (downloaded directly from Gitea's release page).
- **Build dependencies for the project:**
- Node.js 18+ and npm (for frontend build)
- Python 3.10+ and pip (for any Python-based build steps)
- Build essentials (`build-essential`, `python3-dev`, `pkg-config`) for native module compilation
- `ca-certificates` and `curl` (for downloading dependencies and act_runner)
- [ ] **Task 3.2: Implement the Modal Function Logic**
- Set up a Modal function (e.g., `@app.function()`) that executes the following shell sequence inside the container when invoked:
1. **Register the runner:**Bash
```
./act_runner register --no-interactive --instance <YOUR_GITEA_URL> --token <YOUR_REGISTRATION_TOKEN> --name modal-sandbox-runner --labels "ubuntu-latest:docker://node:18-bookworm"
```
2. **Run once and block until job completion:**Bash
```
./act_runner daemon --once
```
> **Note:** `<YOUR_GITEA_URL>` must be the publicly reachable address of the Gitea instance on your Lightsail VM (e.g., `http://<lightsail-ip>:3000`), since the Modal sandbox runs outside of your Lightsail network.
- [ ] **Task 3.3: Deploy and Test the Modal App**
- Authenticate your Lightsail server with Modal using `modal setup` (run this on the Lightsail VM).
- Run `modal run deploy_runner.py` manually to verify the sandbox boots, registers in Gitea, checks for a job, and exits gracefully.
### Phase 4: Configure the Repository CI/CD Workflow
Define what the runner should actually do when it receives the codebase from the Lightsail-hosted Gitea.
- [ ] **Task 4.1: Create Gitea Action Workflow File**
- In your repository (hosted on the Lightsail Gitea instance), create a directory structure: `.gitea/workflows/demo.yaml`.
- Define a basic test pipeline:YAML
#
```
name: Gitea CI/CD
on: [push]
jobs:
test-build:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Build Application
run: |
echo "Compiling and testing..."
# Add your test or build commands here
```
### Phase 5: End-to-End Integration Test
- [ ] **Task 5.1: Push a Commit**
- Run `git push` from your local machine to the Gitea instance on Lightsail.
- Verify the sequence:
1. Gitea (on Lightsail) displays a pending job badge.
2. Webhook triggers on Lightsail (same VM as Gitea).
3. Modal sandbox boots up.
4. The sandbox logs into Gitea (via public Lightsail URL), processes the workflow, and turns off.
5. Gitea UI (on Lightsail) updates to green (Success) or red (Failure).

View File

@@ -0,0 +1,211 @@
# Secrets Management Guideline
This document defines how to handle secrets, environment variables, and sensitive configuration across the PILOT project CI/CD pipeline and infrastructure.
## Principles
1. **Never commit secrets to version control** — Use `.gitignore` and secret stores
2. **Least-privilege access** — Each component gets only the secrets it needs
3. **Environment isolation** — Separate secrets for local, staging, and production
4. **Auditability** — Track secret creation, rotation, and access
## Secret Storage Locations
### 1. Gitea Repository Secrets (CI/CD Workflows)
Store secrets used by GitHub Actions / Gitea Actions workflows in Gitea's built-in secrets manager.
**Access path:** Gitea UI → Repository Settings → Secrets → Actions
```bash
# Repository-level secrets (per project)
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
MODAL_TOKEN_ID
MODAL_TOKEN_SECRET
POSTGRES_PASSWORD
GITEA_WEBHOOK_SECRET
CADDY_API_TOKEN
```
**Access in workflow:**
```yaml
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
```
### 2. Modal Secrets (Serverless Workers)
Store Modal-specific configuration in Modal's secrets system.
**Create secret:**
```bash
modal secret create gitea-runner-secrets \
MODAL_KEY=your-modal-key \
MODAL_SECRET=your-modal-secret
```
**Use in deploy_runner.py:**
```python
import os
import modal
app = modal.App("gitea-runner")
secret = modal.Secret.from_name("gitea-runner-secrets")
@app.function(secret=secret)
def run_runner():
modal_key = os.environ["MODAL_KEY"]
modal_secret = os.environ["MODAL_SECRET"]
```
### 3. AWS Secrets Manager (Production Infrastructure)
Store AWS credentials and infrastructure secrets in AWS Secrets Manager.
**Create secret:**
```bash
aws secretsmanager create-secret \
--name gitea/prod/aws-credentials \
--secret-string '{"access_key":"...","secret_key":"..."}'
```
**Retrieve secret:**
```bash
aws secretsmanager get-secret-value \
--secret-id gitea/prod/aws-credentials \
--query SecretString --output text
```
**Use in Python (boto3):**
```python
import boto3
import json
client = boto3.client('secretsmanager')
secret = client.get_secret_value(SecretId='gitea/prod/aws-credentials')
credentials = json.loads(secret['SecretString'])
```
### 4. Local Development (.env)
Use `.env` files for local development with strict `.gitignore` rules.
```bash
# .env (NEVER commit this file)
AWS_ACCESS_KEY_ID=local-dev-key
AWS_SECRET_ACCESS_KEY=local-dev-secret
AWS_DEFAULT_REGION=us-east-1
HF_TOKEN=hf-local-token
MODAL_KEY=local-modal-key
MODAL_SECRET=local-modal-secret
FIGMA_ACCESS_TOKEN=local-figma-token
SUPABASE_URL=http://localhost:54321
SUPABASE_PUBLISHABLE_KEY=local-supabase-key
SUPABASE_DB_URL=postgresql://postgres:postgres@localhost:54322/postgres
SUPABASE_SERVICE_ROLE_KEY=local-supabase-service-key
EXA_API_KEY=local-exa-key
GITHUB_PAT=local-github-pat
GITHUB_GITEA_CLIENT_SECRET=local-gitea-client-secret
POSTGRES_PASSWORD=local-password
GITEA_URL=http://localhost:3000
GITEA_WEBHOOK_SECRET=local-webhook-secret
```
```bash
# .gitignore
.env
.env.local
.env.*.local
secrets/
*.pem
*.key
```
## Gitea Workflow Secrets Template
Use this template to reference secrets in `.gitea/workflows/*.yml` files:
```yaml
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
MODAL_KEY: ${{ secrets.MODAL_KEY }}
MODAL_SECRET: ${{ secrets.MODAL_SECRET }}
FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }}
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_PUBLISHABLE_KEY: ${{ secrets.SUPABASE_PUBLISHABLE_KEY }}
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
GITHUB_PAT: ${{ secrets.GITHUB_PAT }}
GITHUB_GITEA_CLIENT_SECRET: ${{ secrets.GITHUB_GITEA_CLIENT_SECRET }}
```
All secrets must be registered in Gitea under **Repository Settings → Secrets → Actions** before the workflow runs.
## Secret Reference Matrix
| Secret | Local Dev | Gitea CI/CD | Modal Worker | AWS Infra |
|--------|-----------|-------------|--------------|-----------|
| AWS_ACCESS_KEY_ID | .env | Gitea Secret | — | Secrets Manager |
| AWS_SECRET_ACCESS_KEY | .env | Gitea Secret | — | Secrets Manager |
| AWS_DEFAULT_REGION | .env | Gitea Secret | — | — |
| HF_TOKEN | .env | Gitea Secret | — | — |
| MODAL_KEY | .env | Gitea Secret | Modal Secret | — |
| MODAL_SECRET | .env | Gitea Secret | Modal Secret | — |
| FIGMA_ACCESS_TOKEN | .env | Gitea Secret | — | — |
| SUPABASE_URL | .env | Gitea Secret | — | — |
| SUPABASE_PUBLISHABLE_KEY | .env | Gitea Secret | — | — |
| SUPABASE_DB_URL | .env | Gitea Secret | — | Secrets Manager |
| SUPABASE_SERVICE_ROLE_KEY | .env | Gitea Secret | — | — |
| EXA_API_KEY | .env | Gitea Secret | — | — |
| GITHUB_PAT | .env | Gitea Secret | — | — |
| GITHUB_GITEA_CLIENT_SECRET | .env | Gitea Secret | — | — |
| POSTGRES_PASSWORD | .env | Gitea Secret | — | Secrets Manager |
| GITEA_WEBHOOK_SECRET | .env | Gitea Secret | — | — |
| CADDY_API_TOKEN | .env | Gitea Secret | — | — |
## Security Best Practices
1. **Rotate secrets regularly** — Especially AWS keys and tokens (every 90 days)
2. **Use IAM roles** — Prefer IAM roles over hardcoded AWS keys in production
3. **Encrypt at rest** — Use AWS KMS for Secrets Manager encryption
4. **Audit access** — Enable CloudTrail for AWS Secrets Manager, Gitea audit logs
5. **Pre-commit hooks** — Use `git-secrets` or `pre-commit` to block secret commits
6. **Separate environments** — Use different secret namespaces for dev/staging/prod
7. **Minimal scope** — AWS keys should have only required permissions
## Pre-commit Hook Setup (Optional)
Install `git-secrets` to prevent committing secrets:
```bash
# Install git-secrets
git secrets --install
# Register common patterns
git secrets --register-aws
git secrets --add 'GITEA_TOKEN'
git secrets --add 'MODAL_TOKEN'
```
## Emergency: Secret Exposure
If a secret is accidentally committed:
1. **Rotate immediately** — Generate a new secret value
2. **Revoke the old secret** — Invalidate the exposed credential
3. **Audit access logs** — Check for unauthorized usage
4. **Update all references** — Deploy the new secret to all environments
5. **Force push cleanup** — Use `git filter-branch` or `BFG` to remove from history
## Related Files
- `build_server_plan.md` — CI/CD pipeline architecture
- `CICD_architecture.png` — Architecture diagram

View File

@@ -0,0 +1,33 @@
import modal
import subprocess
# 1. Define the App
app = modal.App("hello-world-build-worker")
# 2. Define a clean container image representing your worker environment
image = modal.Image.debian_slim().pip_install("requests")
# 3. Define the worker function
@app.function(
image=image,
# This keeps the worker container alive for 5 minutes after finishing,
# so subsequent runs start instantly (avoiding cold starts).
scaledown_window=300
)
def run_worker_job():
print("--- WORKER CONTAINER STARTED ---")
# Mimic fetching project files or setting up environment variables
test_env_var = "Lumina-Build-Sandbox"
print(f"Environment initialized. Target deployment: {test_env_var}")
# Run a simple shell command inside the container to mimic building/testing
print("Running build script simulation...")
result = subprocess.run(
["echo", "Hello World! Your Modal container is successfully building your Python app."],
capture_output=True,
text=True
)
print(f"Build output: {result.stdout.strip()}")
print("--- WORKER JOB COMPLETED SUCCESSFULLY ---")

View File

@@ -0,0 +1,4 @@
cd workspace/sprint_1_2/CODEBASE/frontend/implementation
npm install
npm run build
npm run preview

View File

@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this .gitignore file itself, which forces Git to keep the folder
!.gitignore

View File

@@ -0,0 +1,34 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Vite
.vite/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment files (migrated to config/frontend.config.yaml)
.env
.env.*
!.env.example
# Test coverage
coverage/
# Misc
*.local

View File

@@ -0,0 +1,30 @@
# Stage 1: Build
FROM node:26-bookworm-slim AS builder
WORKDIR /app
# Copy package files and install scripts first (better cache)
COPY package.json package-lock.json* ./
COPY scripts/ ./scripts/
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# Stage 2: Runtime (nginx)
FROM nginx:alpine
# Remove default nginx static files
RUN rm -rf /usr/share/nginx/html/*
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Custom nginx config for SPA routing + caching
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,25 @@
# Frontend application configuration
# This is the single source of truth for frontend feature flags and URLs.
# Edit this file directly instead of using .env / .env.development.
#
# Deployment note: the backend servers (main.py, cv_inference_server.py) enforce
# CORS via the CORS_ORIGINS environment variable. Make sure the origins listed here
# (especially the production frontend origin) are included in that env var.
VITE_USE_BACKEND_SEGMENTATION: "true"
# Leave empty in dev — Vite proxy handles /api/* → backend.
# Set to your production API origin (e.g. "https://api.your-domain.com") for builds.
VITE_API_BASE_URL: "https://api.lumina-msk.io.vn"
# Leave empty in dev — Vite proxy handles /api/test/* → CV inference server.
# Set to your production CV inference origin (e.g. "https://cv.your-domain.com") for builds.
VITE_SEGMENT_API_BASE: "https://cv.lumina-msk.io.vn"
# Comma-separated list of origins the frontend may be served from.
# Used by deployment scripts to populate backend CORS_ORIGINS env var.
VITE_ALLOWED_ORIGINS: "https://lumina-msk.io.vn,https://www.lumina-msk.io.vn"
VITE_USE_CV_CELERY: "false"
VITE_CLINICAL_CHAT_USE_LLM: "true"
VITE_CLINICAL_CHAT_MOCK_TOOLS: "true"
VITE_OLLAMA_CHAT_URL: "/api/ollama-chat/api/chat"
VITE_OLLAMA_MODEL: "gemma4:e4b"
VITE_MODAL_OLLAMA_TARGET: "https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run"
VITE_DISTRIBUTE_DIRRECTORY: "workspace/sprint_1_2/CODEBASE/frontend/distribute_asset"

View File

@@ -1,16 +1,24 @@
import React, { Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { PatientStoreProvider } from './lib/patientStore';
import PatientWorklistPage from './pages/PatientWorklistPage';
import ClinicalWorkspacePage from './pages/ClinicalWorkspacePage';
const PatientWorklistPage = React.lazy(() => import('./pages/PatientWorklistPage'));
const ClinicalWorkspacePage = React.lazy(() => import('./pages/ClinicalWorkspacePage'));
function LoadingFallback() {
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading</div>;
}
export default function App() {
return (
<PatientStoreProvider>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<PatientWorklistPage />} />
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</PatientStoreProvider>
);
}

View File

@@ -27,7 +27,7 @@ export default function CalibrationControls({ config, onChange }: CalibrationCon
return (
<div className="cal-ctrl">
<div className="cal-ctrl__header">
<span className="cal-ctrl__title">Điều chỉnh nhiệt đ (T)</span>
<span className="cal-ctrl__title">Điều chỉnh đ chắc chắn (T)</span>
<span className="cal-ctrl__hint tnum">T = {config.temperature.toFixed(2)}</span>
</div>
<CalibrationMetricHelp layout="block" />

View File

@@ -32,10 +32,17 @@ const MODEL_LOAD_COPY: Record<
'installing-gemma': {
title: 'Đang cài đặt Gemma 4 E2B về máy…',
subtitle:
'Mô hình trò chuyện chính (~2 GB). Nếu bị gián đoạn, lần mở sau sẽ tiếp tục từ chỗ đã tải.',
'Mô hình trò chuyện chính (~1.87 GB). Nếu bị gián đoạn, lần mở sau sẽ tiếp tục từ chỗ đã tải.',
ariaLabel: 'Đang cài đặt Gemma 4 E2B',
composerPlaceholder: 'Đang cài đặt Gemma 4 E2B, vui lòng đợi…',
},
'resuming-gemma': {
title: 'Đang tiếp tục tải Gemma 4 E2B…',
subtitle:
'Lần tải trước bị gián đoạn — đang tiếp tục từ chỗ đã tải, không tải lại từ đầu.',
ariaLabel: 'Đang tiếp tục tải Gemma 4 E2B',
composerPlaceholder: 'Đang tiếp tục tải Gemma 4 E2B, vui lòng đợi…',
},
// 'installing-qwen': { ... },
'loading-gemma': {
title: 'Đang nạp Gemma 4 E2B…',
@@ -78,6 +85,7 @@ export default function ClinicalChatPanel({
modelLoadPhase,
modelLoadProgress,
modelInstallTransferLabel,
modelLoadStalled,
modelLoadFading,
sendMessage,
stopGeneration,
@@ -150,8 +158,17 @@ export default function ClinicalChatPanel({
const showLoadBubble = isModelLoading && modelLoadPhase !== null;
const loadCopy = modelLoadPhase ? MODEL_LOAD_COPY[modelLoadPhase] : null;
const progressPercent = Math.min(100, Math.max(0, Math.round(modelLoadProgress)));
const installProgressLabel =
modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
const isInstallStalled =
modelLoadStalled && modelLoadPhase !== null && isModelInstallPhase(modelLoadPhase);
const loadTitle = isInstallStalled ? 'Tải Gemma bị gián đoạn' : loadCopy?.title;
const loadSubtitle = isInstallStalled
? `Mất kết nối — hiện không tải được. Đang thử lại tự động${
modelInstallTransferLabel ? ` · đã lưu ${modelInstallTransferLabel}` : ''
}. Có thể tải lại trang để tiếp tục từ chỗ đã tải.`
: loadCopy?.subtitle;
const installProgressLabel = isInstallStalled
? 'Gián đoạn'
: modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
? modelInstallTransferLabel
: `${progressPercent}%`;
const activeModeMeta = getInferenceModeMeta(inferenceMode);
@@ -340,20 +357,37 @@ export default function ClinicalChatPanel({
aria-hidden
/>
<div
className={`clinical-chat__load-bubble clinical-chat__load-bubble--${modelLoadPhase} ${modelLoadFading ? 'clinical-chat__load-bubble--fade-out' : ''}`}
className={`clinical-chat__load-bubble clinical-chat__load-bubble--${modelLoadPhase} ${isInstallStalled ? 'clinical-chat__load-bubble--stalled' : ''} ${modelLoadFading ? 'clinical-chat__load-bubble--fade-out' : ''}`}
role="status"
aria-live="polite"
aria-label={
modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
isInstallStalled
? `${loadCopy.ariaLabel} bị gián đoạn, đang thử lại`
: modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
? `${loadCopy.ariaLabel}, ${modelInstallTransferLabel}`
: `${loadCopy.ariaLabel}, ${progressPercent} phần trăm`
}
>
<div
className={`clinical-chat__load-icon clinical-chat__load-icon--${modelLoadPhase}`}
className={`clinical-chat__load-icon clinical-chat__load-icon--${modelLoadPhase} ${isInstallStalled ? 'clinical-chat__load-icon--stalled' : ''}`}
aria-hidden
>
{modelLoadPhase && isModelInstallPhase(modelLoadPhase) ? (
{isInstallStalled ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 8v5M12 16.5v.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
<path
d="M10.3 3.9 2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
) : modelLoadPhase && isModelInstallPhase(modelLoadPhase) ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 3v3M12 18v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M3 12h3M18 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"
@@ -373,16 +407,16 @@ export default function ClinicalChatPanel({
</svg>
)}
</div>
<p className="clinical-chat__load-title">{loadCopy.title}</p>
<p className="clinical-chat__load-subtitle">{loadCopy.subtitle}</p>
<p className="clinical-chat__load-title">{loadTitle}</p>
<p className="clinical-chat__load-subtitle">{loadSubtitle}</p>
<div className="clinical-chat__load-progress-row">
<div className="clinical-chat__load-progress-track">
<div
className={`clinical-chat__load-progress-fill clinical-chat__load-progress-fill--${modelLoadPhase}`}
className={`clinical-chat__load-progress-fill clinical-chat__load-progress-fill--${modelLoadPhase} ${isInstallStalled ? 'clinical-chat__load-progress-fill--stalled' : ''}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="clinical-chat__load-progress-label tnum">{installProgressLabel}</span>
<span className={`clinical-chat__load-progress-label tnum ${isInstallStalled ? 'clinical-chat__load-progress-label--stalled' : ''}`}>{installProgressLabel}</span>
</div>
</div>
</>
@@ -781,12 +815,43 @@ const styles = `
color: var(--color-secondary);
}
.clinical-chat__load-icon--installing-gemma,
.clinical-chat__load-icon--resuming-gemma,
.clinical-chat__load-icon--installing-qwen {
animation: clinical-chat-spin 2.4s linear infinite;
}
.clinical-chat__load-icon--installing {
animation: clinical-chat-spin 2.4s linear infinite;
}
/* Stalled: stop the spin so a frozen download never looks like active progress. */
.clinical-chat__load-bubble--stalled {
border-color: rgba(180, 120, 40, 0.4);
}
.clinical-chat__load-icon--stalled {
animation: clinical-chat-stall-pulse 1.6s ease-in-out infinite;
background: rgba(180, 120, 40, 0.14);
color: #9a6b1f;
}
@keyframes clinical-chat-stall-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
.clinical-chat__load-progress-fill--stalled {
background: repeating-linear-gradient(
45deg,
rgba(180, 120, 40, 0.55),
rgba(180, 120, 40, 0.55) 6px,
rgba(180, 120, 40, 0.3) 6px,
rgba(180, 120, 40, 0.3) 12px
);
animation: clinical-chat-stall-stripes 0.8s linear infinite;
}
@keyframes clinical-chat-stall-stripes {
from { background-position: 0 0; }
to { background-position: 17px 0; }
}
.clinical-chat__load-progress-label--stalled {
color: #9a6b1f;
}
@keyframes clinical-chat-pulse-icon {
0%, 100% { opacity: 0.55; transform: scale(0.94); }
50% { opacity: 1; transform: scale(1); }

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { memo, useEffect, useId, useRef, useState } from 'react';
import StreamingPlainText from '../atoms/StreamingPlainText';
import { streamTargetKey } from '../../lib/llm/clinicalChatStreamRegistry';
@@ -18,26 +18,14 @@ function ClinicalChatThought({
thoughtStreaming = false,
}: ClinicalChatThoughtProps) {
const panelId = useId();
const wasThoughtStreamingRef = useRef(thoughtStreaming);
const userToggledRef = useRef(false);
const [expanded, setExpanded] = useState(true);
// Collapsed by default — keeps the clinical answer prominent; click to expand.
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setExpanded(true);
userToggledRef.current = false;
wasThoughtStreamingRef.current = false;
}, [messageId]);
useLayoutEffect(() => {
if (thoughtStreaming) {
if (!userToggledRef.current) {
setExpanded(true);
}
} else if (wasThoughtStreamingRef.current && !thoughtStreaming && !userToggledRef.current) {
setExpanded(false);
}
wasThoughtStreamingRef.current = thoughtStreaming;
}, [thoughtStreaming]);
userToggledRef.current = false;
}, [messageId]);
if (!content.trim() && !thoughtStreaming) {
return null;
@@ -49,7 +37,8 @@ function ClinicalChatThought({
};
const label = thoughtStreaming ? 'Đang suy luận' : 'Suy luận';
const preview = !thoughtStreaming && !expanded ? content.replace(/\s+/g, ' ').trim().slice(0, 100) : '';
const preview =
!expanded && !thoughtStreaming ? content.replace(/\s+/g, ' ').trim().slice(0, 100) : '';
return (
<div
@@ -77,15 +66,15 @@ function ClinicalChatThought({
{!expanded && preview ? (
<p className="clinical-chat__thought-preview">{preview}</p>
) : null}
{expanded ? (
<div id={panelId} className="clinical-chat__thought-body">
{/* Body stays mounted even when collapsed so the imperative stream target keeps
receiving tokens; visibility is toggled via the hidden attribute. */}
<div id={panelId} className="clinical-chat__thought-body" hidden={!expanded}>
<StreamingPlainText
text={content}
streamTargetKey={streamTargetKey(messageId, 'thought')}
className="clinical-chat__thought-md chat-md__plain"
/>
</div>
) : null}
</div>
);
}

View File

@@ -48,32 +48,35 @@ export default function RecordingModeSelector({ value, onChange, disabled }: Rec
padding: 0;
}
.recording-mode-selector legend {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 6px;
font-size: 13px;
font-weight: 700;
color: #e2e8f0;
margin-bottom: 8px;
}
.recording-mode-selector__options {
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
}
.recording-mode-selector__option {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #e2e8f0;
gap: 10px;
font-size: 14px;
font-weight: 500;
color: #f1f5f9;
cursor: pointer;
}
.recording-mode-selector__option input {
width: 16px;
height: 16px;
accent-color: #76c8b1;
}
.recording-mode-selector__hint {
margin: 6px 0 0;
font-size: 11px;
line-height: 1.45;
color: #64748b;
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #cbd5e1;
}
.recording-mode-selector:disabled .recording-mode-selector__option {
opacity: 0.55;

View File

@@ -71,7 +71,7 @@ export default function SeverityBadge({
<div className="severity-panel">
{severityLoading ? (
<div className="severity-panel__block glass">
<span className="severity-panel__eyebrow">Viêm màng hoạt dịch đ nặng (từ phân đoạn)</span>
<span className="severity-panel__eyebrow">Viêm màng hoạt dịch đ nặng (từ nh phân đoạn)</span>
<p className="severity-panel__pending">Đang chờ kết quả đ nặng từ phân đoạn</p>
</div>
) : grade != null && gradeLabel ? (
@@ -80,7 +80,7 @@ export default function SeverityBadge({
{grade}
</span>
<div>
<span className="severity-badge__label">Viêm màng hoạt dịch đ nặng (từ phân đoạn)</span>
<span className="severity-badge__label">Viêm màng hoạt dịch đ nặng (từ nh phân đoạn)</span>
<strong>{gradeLabel}</strong>
{severity?.description && (
<p className="severity-panel__desc">{severity.description}</p>

View File

@@ -34,6 +34,7 @@ interface DiagnosticCanvasProps {
patientMrn?: string;
patientId?: string;
scanFrames?: ScanFrame[];
useCelery?: boolean;
}
export default function DiagnosticCanvas({
@@ -54,6 +55,7 @@ export default function DiagnosticCanvas({
patientMrn,
patientId,
scanFrames: scanFramesProp,
useCelery,
}: DiagnosticCanvasProps) {
const activeFrames =
(scanFramesProp && scanFramesProp.length > 0)
@@ -146,7 +148,7 @@ export default function DiagnosticCanvas({
const lockFrameNav = isSingleFrameNavLocked;
const { overlaySrc, interpretation, angleClassification, inflammationClassification, synovitisSeverity, isLoading, isSegmentationLoading, error, source, retry } =
useSegmentationOverlay(frame.id, frame.src, framesForCanvas, patientMrn);
useSegmentationOverlay(frame.id, frame.src, framesForCanvas, patientMrn, useCelery);
useEffect(() => {
applyFrameIndex(0);
@@ -360,13 +362,6 @@ export default function DiagnosticCanvas({
{showMask && (
<SegmentationOverlay overlaySrc={overlaySrc} isLoading={isLoading} />
)}
{showMask && error && !isLoading && (
<MlServiceErrorPanel
error={error}
frameLabel={frameLabel}
onRetry={source === 'backend' ? retry : undefined}
/>
)}
<canvas
ref={canvasRef}
className="diagnostic-canvas__annotation-canvas"
@@ -377,6 +372,13 @@ export default function DiagnosticCanvas({
{...drawHandlers}
/>
</div>
{showMask && error && !isLoading && (
<MlServiceErrorPanel
error={error}
frameLabel={frameLabel}
onRetry={source === 'backend' ? retry : undefined}
/>
)}
{closedLoopPrompt && closedLoopPromptViewportAnchor && (
<ClosedLoopPrompt
anchor={closedLoopPromptViewportAnchor}

View File

@@ -249,7 +249,7 @@ export default function ReviewDiagnosticSessionPanel({
return (
<div className="review-session">
<header className="review-session__header">
<h2 className="review-session__title">Xem lại phiên chẩn đoán</h2>
<h2 className="review-session__title">XEM LẠI PHIÊN CHUẨN ĐOÁN</h2>
<RecordingModeSelector
value={lifecycle.recordingMode}
onChange={lifecycle.setRecordingMode}
@@ -572,7 +572,7 @@ export default function ReviewDiagnosticSessionPanel({
padding-bottom: 8px;
}
.review-session__title {
margin: 0;
margin: 0 0 16px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;

View File

@@ -155,7 +155,7 @@ export default function SideNavBar({
<>
<aside className="side-nav glass-elevated">
<section className="side-nav__section">
<h3>Đ xuất AI</h3>
<h3>Đ xuất của AI</h3>
{hasCalibratableOutput && (
<CalibrationControls config={userConfig} onChange={setUserConfig} />

View File

@@ -153,9 +153,11 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
<style>{`
.workspace-shell {
display: flex;
flex: 1;
flex: 0 0 calc(100dvh - var(--topbar-h) - var(--bottombar-h));
height: calc(100dvh - var(--topbar-h) - var(--bottombar-h));
min-height: 0;
padding: var(--space-md);
overflow: hidden;
user-select: ${isDragging ? 'none' : 'auto'};
}
.workspace-shell--dragging {
@@ -164,8 +166,10 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
.workspace-shell__zone-a {
flex: 0 0 var(--workspace-zone-a-pct);
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: flex-basis 0.05s linear;
}
.workspace-shell--dragging .workspace-shell__zone-a {
@@ -174,8 +178,10 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
.workspace-shell__zone-b {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: calc(1rem * var(--workspace-panel-scale, 1));
}
.workspace-shell__divider {
@@ -246,13 +252,17 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
}
@media (max-width: 1024px) {
.workspace-shell {
flex: 1 1 auto;
height: auto;
flex-direction: column;
gap: var(--space-md);
overflow: visible;
}
.workspace-shell__zone-a,
.workspace-shell__zone-b {
flex: 1 1 auto;
font-size: 1rem;
overflow: visible;
}
}
`}</style>

View File

@@ -27,7 +27,7 @@ export const CALIBRATION_METRIC_HELP_KEY_POINTS: readonly CalibrationKeyPoint[]
{
title: 'Cơ chế bóp méo của AI hiện đại',
body:
'Các mạng càng sâu và thông minh thì xu hướng phân tách các điểm thô càng xa (ví dụ 20 điểm và 1 điểm). Khi quy đổi, khoảng cách quá lớn này bị ép thành xác suất cực đoan như 99,9% — hiện tượng “quá tự tin” (over-confidence).',
'Các mô hình AI càng phức tạp (kích thuớc lớn & sâu) và thông minh thì xu hướng phân tách các điểm thô càng xa (ví dụ 20 điểm và 1 điểm). Khi quy đổi, khoảng cách quá lớn này bị ép thành xác suất cực đoan như 99,9% — hiện tượng “quá tự tin” (over-confidence).',
},
{
title: 'Bác sĩ trưởng khoa hạ hỏa (tham số T)',

View File

@@ -22,19 +22,19 @@ export const CALIBRATION_TIERS: CalibrationTier[] = [
tier: 1,
labelVi: 'Nhạy cao / Sàng lọc',
labelEn: 'Aggressive / Screening',
triggerVi: 'Bác sĩ nghi ngờ viêm cao từ triệu chứng, chưa thấy trên ảnh.',
ruleVi: 'T = 0.7 (Sharpening)',
triggerVi: 'Bác sĩ nghi ngờ mức độ viêm cao từ triệu chứng, chưa thấy trên ảnh.',
ruleVi: 'T = 0.7',
recommendedT: 0.7,
uiEffectVi:
'Đẩy xác suất biên lên — hạ ngưỡng cảnh báo viêm để không bỏ sót ca ranh giới.',
'Đẩy xác suất biên lên — hạ ngưỡng cảnh báo viêm để không bỏ sót ca hiếm gặp.',
},
{
id: 'standard',
tier: 2,
labelVi: 'Chuẩn / Mặc định',
labelEn: 'Standard Baseline',
triggerVi: 'Vận hành mặc định — chưa có prior lâm sàng từ người dùng.',
ruleVi: 'T = 1.4 (Smoothing)',
triggerVi: 'Vận hành mặc định — chưa có dự đoán từ trước từ người dùng.',
ruleVi: 'T = 1.4',
recommendedT: 1.4,
uiEffectVi:
'Giảm overconfidence của mạng — phân bố thực tế, cân bằng toán học.',
@@ -45,7 +45,7 @@ export const CALIBRATION_TIERS: CalibrationTier[] = [
labelVi: 'Bảo thủ / Hoài nghi',
labelEn: 'Conservative / Skeptical',
triggerVi: 'Bác sĩ tin bệnh nhân khỏe; dùng AI chỉ để kiểm tra lại.',
ruleVi: 'T = 2.2 (Heavy flattening)',
ruleVi: 'T = 2.2',
recommendedT: 2.2,
uiEffectVi:
'Làm phẳng phân bố — chỉ báo dương khi tín hiệu mô hình cực kỳ mạnh.',

View File

@@ -84,12 +84,17 @@ export type ClinicalChatRuntime = 'loading' | 'llm' | 'mock';
export type ModelLoadPhase =
| 'installing-gemma'
| 'resuming-gemma'
// | 'installing-qwen'
| 'loading-gemma';
// | 'loading-qwen';
/** No download progress for this long ⇒ treat the install as stalled (not "loading"). */
export const MODEL_INSTALL_STALL_MS = 15_000;
const MODEL_INSTALL_STALL_POLL_MS = 3_000;
export function isModelInstallPhase(phase: ModelLoadPhase): boolean {
return phase === 'installing-gemma';
return phase === 'installing-gemma' || phase === 'resuming-gemma';
// || phase === 'installing-qwen';
}
@@ -137,6 +142,8 @@ export interface UseClinicalChatResult {
modelLoadProgress: number;
/** Byte transfer label during OPFS install (e.g. "842.3 MB / 1.87 GB"). */
modelInstallTransferLabel: string | null;
/** True when an install/resume has received no bytes for a while — not actually progressing. */
modelLoadStalled: boolean;
modelLoadFading: boolean;
sendMessage: () => void;
stopGeneration: () => void;
@@ -212,6 +219,7 @@ export function useClinicalChat({
const [modelLoadPhase, setModelLoadPhase] = useState<ModelLoadPhase | null>(null);
const [modelLoadProgress, setModelLoadProgress] = useState(0);
const [modelInstallTransferLabel, setModelInstallTransferLabel] = useState<string | null>(null);
const [modelLoadStalled, setModelLoadStalled] = useState(false);
const [modelLoadFading, setModelLoadFading] = useState(false);
const [modeSuggestion, setModeSuggestion] = useState<ModeSuggestion | null>(null);
@@ -365,11 +373,49 @@ export function useClinicalChat({
let cancelled = false;
let fadeTimer: number | undefined;
let stopLoadTicker: (() => void) | undefined;
let stallWatchdogId: number | undefined;
let lastInstallProgressAt = Date.now();
// Highest byte offset seen so far. Retry "heartbeats" re-emit the same offset;
// only a real increase counts as progress.
let lastInstallBytes = -1;
const clearStallWatchdog = () => {
if (stallWatchdogId !== undefined) {
window.clearInterval(stallWatchdogId);
stallWatchdogId = undefined;
}
};
// Flip the overlay from "downloading" to "stalled" when no *new bytes* arrive for a
// while, so a frozen bar (or a resume stuck retrying the same offset) never
// masquerades as active progress.
const startStallWatchdog = () => {
clearStallWatchdog();
lastInstallProgressAt = Date.now();
lastInstallBytes = -1;
setModelLoadStalled(false);
stallWatchdogId = window.setInterval(() => {
if (cancelled) {
return;
}
if (Date.now() - lastInstallProgressAt > MODEL_INSTALL_STALL_MS) {
setModelLoadStalled(true);
setStatusLabel('Tải Gemma bị gián đoạn — đang thử kết nối lại…');
}
}, MODEL_INSTALL_STALL_POLL_MS);
};
const noteInstallProgress = () => {
lastInstallProgressAt = Date.now();
setModelLoadStalled(false);
};
async function finishModelLoad(): Promise<void> {
if (cancelled) {
return;
}
clearStallWatchdog();
setModelLoadStalled(false);
setModelLoadProgress(100);
setModelLoadFading(true);
await new Promise<void>((resolve) => {
@@ -469,9 +515,25 @@ export function useClinicalChat({
if (!cancelled) {
setModelLoadProgress(8);
if (!initialGemma.loadable) {
setModelLoadPhase('installing-gemma');
// A partial checkpoint already on disk means we resume, not start fresh.
const isPartialResume = initialGemma.bytes > 0;
setModelLoadPhase(isPartialResume ? 'resuming-gemma' : 'installing-gemma');
setIsModelLoading(true);
setStatusLabel('Đang cài đặt Gemma 4 E2B về máy…');
setStatusLabel(
isPartialResume
? 'Đang tiếp tục tải Gemma 4 E2B…'
: 'Đang cài đặt Gemma 4 E2B về máy…',
);
if (isPartialResume) {
setModelInstallTransferLabel(
formatInstallTransferLabel({
phase: 'resuming',
bytesLoaded: initialGemma.bytes,
bytesTotal: initialGemma.manifest?.bytes ?? null,
}),
);
}
startStallWatchdog();
}
// else if (!initialQwen.loadable) {
// setModelLoadPhase('installing-qwen');
@@ -486,11 +548,26 @@ export function useClinicalChat({
if (cancelled) {
return;
}
setModelLoadPhase('installing-gemma');
setIsModelLoading(true);
setStatusLabel('Đang cài đặt Gemma 4 E2B về máy…');
setModelInstallTransferLabel(formatInstallTransferLabel(progress));
setModelLoadProgress(mapDownloadProgress(progress));
// Retry heartbeats re-emit the same offset — those must NOT reset the
// watchdog or clear the stalled state, otherwise a stuck resume keeps
// looking like active loading.
const advanced = progress.bytesLoaded > lastInstallBytes;
if (!advanced) {
return;
}
lastInstallBytes = progress.bytesLoaded;
noteInstallProgress();
const resuming = progress.phase === 'resuming';
setModelLoadPhase(resuming ? 'resuming-gemma' : 'installing-gemma');
setStatusLabel(
resuming
? 'Đang tiếp tục tải Gemma 4 E2B…'
: 'Đang cài đặt Gemma 4 E2B về máy…',
);
},
// onQwenDownloadProgress: (progress: QwenDownloadProgress) => {
// if (cancelled) {
@@ -537,17 +614,20 @@ export function useClinicalChat({
);
void preloadGemmaIntoMemory();
} catch (error) {
clearStallWatchdog();
if (!cancelled) {
setIsModelLoading(false);
setModelLoadFading(false);
setModelLoadPhase(null);
setModelInstallTransferLabel(null);
setModelLoadStalled(false);
setRuntime('mock');
const message = error instanceof Error ? error.message : 'không tải được mô hình';
const isNetwork =
error instanceof TypeError ||
(error instanceof DOMException && error.name === 'NetworkError') ||
/network|failed to fetch|interrupted|gián đoạn|network_changed|err_network_changed/i.test(
(error instanceof DOMException &&
(error.name === 'NetworkError' || error.name === 'TimeoutError')) ||
/network|failed to fetch|interrupted|timed out|timeout|gián đoạn|network_changed|err_network_changed/i.test(
message,
);
setStatusLabel(
@@ -563,6 +643,7 @@ export function useClinicalChat({
return () => {
cancelled = true;
stopLoadTicker?.();
clearStallWatchdog();
if (fadeTimer !== undefined) {
window.clearTimeout(fadeTimer);
}
@@ -765,22 +846,26 @@ export function useClinicalChat({
let ollamaThoughtAcc = '';
let ollamaAnswerAcc = '';
const assistantId = createChatMessageId();
const assistantMessage: ClinicalChatMessage = {
id: assistantId,
// A single generation may spawn continuation segments; each segment renders as
// its own bubble with its own reasoning, so continuation thinking never leaks
// into a prior answer. currentAssistantId tracks the bubble being streamed.
let currentAssistantId = createChatMessageId();
let segmentCount = 1;
const spawnAssistantBubble = (id: string, pondering: boolean): ClinicalChatMessage => ({
id,
role: 'assistant',
content: '',
timestamp: new Date(),
streaming: true,
tracksThought: thoughtActive,
pondering: useRemote,
pondering,
ponderingVariant: mode === 'agent' ? 'agent' : 'chat',
};
setMessages((prev) => [...prev, assistantMessage]);
});
setMessages((prev) => [...prev, spawnAssistantBubble(currentAssistantId, useRemote)]);
setStatusLabel(generationStatusLabel(mode, activeLevel));
let plainContentAccumulator = '';
const thoughtParser =
let thoughtParser =
thoughtActive && !useOllamaThoughtStream ? createCotStreamParser() : null;
let thoughtCompleteEmitted = false;
let ponderingCleared = false;
@@ -790,7 +875,7 @@ export function useClinicalChat({
return;
}
ponderingCleared = true;
updateMessage(assistantId, { pondering: false });
updateMessage(currentAssistantId, { pondering: false });
};
const useImperativeStreamPaint = useRemote || thoughtActive;
@@ -801,8 +886,9 @@ export function useClinicalChat({
thoughtComplete?: boolean;
tracksThought?: boolean;
}>((patch) => {
const id = currentAssistantId;
setMessages((prev) =>
prev.map((msg) => (msg.id === assistantId ? { ...msg, ...patch } : msg)),
prev.map((msg) => (msg.id === id ? { ...msg, ...patch } : msg)),
);
});
@@ -814,6 +900,7 @@ export function useClinicalChat({
tracksThought?: boolean;
},
) => {
const id = currentAssistantId;
const hasStreamText =
patch.thoughtContent !== undefined || patch.content !== undefined;
@@ -822,13 +909,13 @@ export function useClinicalChat({
if (patch.thoughtContent !== undefined) {
domHandled =
setClinicalStreamText(
streamTargetKey(assistantId, 'thought'),
streamTargetKey(id, 'thought'),
patch.thoughtContent,
) && domHandled;
}
if (patch.content !== undefined) {
domHandled =
setClinicalStreamText(streamTargetKey(assistantId, 'answer'), patch.content) &&
setClinicalStreamText(streamTargetKey(id, 'answer'), patch.content) &&
domHandled;
}
@@ -840,7 +927,7 @@ export function useClinicalChat({
if (needsReact) {
flushSync(() => {
setMessages((prev) =>
prev.map((msg) => (msg.id === assistantId ? { ...msg, ...patch } : msg)),
prev.map((msg) => (msg.id === id ? { ...msg, ...patch } : msg)),
);
});
}
@@ -861,6 +948,45 @@ export function useClinicalChat({
}
};
// Freeze the bubble being streamed using its OWN parser/accumulator, so its
// reasoning + answer slice stay self-contained.
const finalizeCurrentBubble = () => {
if (thoughtParser) {
const snapshot = thoughtParser.finalize();
updateMessage(currentAssistantId, {
content: snapshot.content,
thoughtContent: snapshot.thoughtContent || undefined,
tracksThought: true,
thoughtComplete: true,
streaming: false,
pondering: false,
});
} else {
updateMessage(currentAssistantId, {
content: plainContentAccumulator,
streaming: false,
pondering: false,
});
}
clearClinicalStreamTargetsForMessage(currentAssistantId);
};
// Continuation → close the current bubble and open a fresh one for the next segment.
const startNextSegmentBubble = () => {
finalizeCurrentBubble();
if (!useImperativeStreamPaint) {
edgeStreamRaf.cancel();
}
const nextId = createChatMessageId();
currentAssistantId = nextId;
segmentCount += 1;
thoughtParser =
thoughtActive && !useOllamaThoughtStream ? createCotStreamParser() : null;
plainContentAccumulator = '';
thoughtCompleteEmitted = false;
setMessages((prev) => [...prev, spawnAssistantBubble(nextId, false)]);
};
try {
const runTurn = () =>
runClinicalChatTurn(
@@ -884,6 +1010,13 @@ export function useClinicalChat({
},
(event: ClinicalChatStreamEvent) => {
try {
if (event.type === 'segment_boundary') {
// segment 1 is the bubble we already opened; only continuations spawn a new one.
if (event.segment >= 2 && !useOllamaThoughtStream) {
startNextSegmentBubble();
}
return;
}
if (event.type === 'thought_token') {
if (!event.partial || !useOllamaThoughtStream) {
return;
@@ -955,15 +1088,15 @@ export function useClinicalChat({
if (abortController.signal.aborted) {
disposeStreamThrottle();
updateMessage(assistantId, { streaming: false });
updateMessage(currentAssistantId, { streaming: false });
return;
}
disposeStreamThrottle();
clearClinicalStreamTargetsForMessage(assistantId);
clearClinicalStreamTargetsForMessage(currentAssistantId);
if (useOllamaThoughtStream) {
updateMessage(assistantId, {
updateMessage(currentAssistantId, {
content: ollamaAnswerAcc || result.finalAnswer,
thoughtContent: ollamaThoughtAcc || undefined,
tracksThought: true,
@@ -973,8 +1106,11 @@ export function useClinicalChat({
});
} else if (thoughtActive && thoughtParser) {
const snapshot = thoughtParser.finalize();
const finalContent = snapshot.content || result.finalAnswer;
updateMessage(assistantId, {
// Only the sole segment may borrow the merged finalAnswer; continuation
// bubbles must keep just their own parsed slice to avoid duplication.
const finalContent =
snapshot.content || (segmentCount === 1 ? result.finalAnswer : '');
updateMessage(currentAssistantId, {
content: finalContent,
thoughtContent: snapshot.thoughtContent || undefined,
tracksThought: true,
@@ -983,8 +1119,9 @@ export function useClinicalChat({
pondering: false,
});
} else {
updateMessage(assistantId, {
content: result.finalAnswer,
updateMessage(currentAssistantId, {
content:
plainContentAccumulator || (segmentCount === 1 ? result.finalAnswer : ''),
streaming: false,
pondering: false,
});
@@ -999,12 +1136,12 @@ export function useClinicalChat({
);
} catch (error) {
disposeStreamThrottle();
clearClinicalStreamTargetsForMessage(assistantId);
clearClinicalStreamTargetsForMessage(currentAssistantId);
if (error instanceof DOMException && error.name === 'AbortError') {
updateMessage(assistantId, { streaming: false, pondering: false });
updateMessage(currentAssistantId, { streaming: false, pondering: false });
return;
}
updateMessage(assistantId, {
updateMessage(currentAssistantId, {
content:
error instanceof Error
? `Không thể trả lời: ${error.message}`
@@ -1132,6 +1269,7 @@ export function useClinicalChat({
modelLoadPhase,
modelLoadProgress,
modelInstallTransferLabel,
modelLoadStalled,
modelLoadFading,
sendMessage,
stopGeneration,

View File

@@ -12,7 +12,10 @@ import {
normalizeBackendSeverity,
type SynovitisSeverityResult,
} from '../data/synovitisSeverity';
import { getCachedCvAnalyzeResult, getCvAnalyzeResultsForProfile } from '../lib/cvAnalyzeApi';
import { getCachedCvAnalyzeResult, getCvAnalyzeResultsForProfile, getCvAnalyzeResultsForProfileCelery, clearCvAnalyzeResultCache, type CvFrameAnalyzeResult } from '../lib/cvAnalyzeApi';
import { clearSegmentationResultCache } from '../lib/segmentationApi';
import { clearAngleClassificationResultCache } from '../lib/angleClassificationApi';
import { clearMlInferenceCacheForPatient } from '../lib/mlInferenceCacheStore';
import type { ProfileMlContext } from '../lib/mlInferenceCacheKeys';
import type { ScanFrame } from '../data/scanFrames';
import { interpretSegmentationForDisplay } from '../lib/interpretSegmentationResult';
@@ -104,6 +107,7 @@ export function useSegmentationOverlay(
imageSrc: string,
profileFrames?: ScanFrame[],
patientMrn?: string,
useCelery?: boolean,
): UseSegmentationOverlayResult {
const [overlaySrc, setOverlaySrc] = useState<string | null>(null);
const [interpretation, setInterpretation] = useState<SegmentationDisplayInterpretation | null>(null);
@@ -117,7 +121,15 @@ export function useSegmentationOverlay(
const [source, setSource] = useState<'backend' | null>(null);
const [retryNonce, setRetryNonce] = useState(0);
const retry = () => setRetryNonce((n) => n + 1);
const retry = () => {
clearCvAnalyzeResultCache();
clearSegmentationResultCache();
clearAngleClassificationResultCache();
if (patientMrn) {
clearMlInferenceCacheForPatient(patientMrn);
}
setRetryNonce((n) => n + 1);
};
const latestSegmentationRef = useRef<SegmentationApiResult | undefined>(undefined);
@@ -161,7 +173,14 @@ export function useSegmentationOverlay(
const mlContext: ProfileMlContext | undefined = patientMrn
? { patientMrn }
: undefined;
const results = await getCvAnalyzeResultsForProfile(frameRefs, mlContext);
let results: Map<string, CvFrameAnalyzeResult>;
if (useCelery) {
results = await getCvAnalyzeResultsForProfileCelery(frameRefs, mlContext);
} else {
results = await getCvAnalyzeResultsForProfile(frameRefs, mlContext);
}
if (cancelled) return;
const cvResult = results.get(frameId);
@@ -235,7 +254,7 @@ export function useSegmentationOverlay(
return () => {
cancelled = true;
};
}, [frameId, imageSrc, profileFrames, patientMrn, retryNonce]);
}, [frameId, imageSrc, profileFrames, patientMrn, retryNonce, useCelery]);
return {
overlaySrc,

View File

@@ -31,6 +31,26 @@ export interface BackendCvAnalyzeBatchResponse {
detail?: string;
}
export interface BackendCvCelerySubmitResponse {
success: boolean;
job_id: string;
image_count: number;
mode: string;
chunk_size: number;
detail?: string;
}
export interface BackendCvCeleryStatusResponse {
status: 'pending' | 'completed' | 'failed' | 'unknown';
completed?: number;
total?: number;
progress?: number;
image_count?: number;
results?: BackendSegmentationResponse[];
errors?: string[];
detail?: string;
}
export interface CvFrameAnalyzeResult {
raw: BackendSegmentationResponse;
segmentation: SegmentationApiResult;
@@ -222,3 +242,166 @@ export async function getCvAnalyzeResultsForProfile(
}
return resolved;
}
/**
* Submit CV batch for async Celery chunk fan-out.
* Returns job_id immediately — poll with pollCvAnalyzeBatchCelery().
*/
export async function submitCvAnalyzeBatchCelery(
frames: ProfileFrameRef[],
apiBase = getSegmentApiBase(),
): Promise<string> {
if (frames.length === 0) {
throw new Error('frames must not be empty');
}
const formData = new FormData();
const files = await Promise.all(
frames.map((frame, index) =>
imageUrlToFile(frame.src, `${frame.id || `frame-${index}`}.png`),
),
);
files.forEach((file) => formData.append('images', file));
formData.append('frame_ids', JSON.stringify(frames.map((frame) => frame.id)));
const response = await fetch(`${apiBase}/api/test/analyze/batch/celery`, {
method: 'POST',
body: formData,
});
const payload = await readMlApiJson<BackendCvCelerySubmitResponse>(response);
if (!response.ok) {
throw new Error(`Celery batch submit error (${response.status})`);
}
if (!payload.success || !payload.job_id) {
throw new Error('Celery batch submit returned success=false or missing job_id');
}
return payload.job_id;
}
/**
* Poll Celery batch job status.
* Returns status object — call again if status === 'pending'.
*/
export async function pollCvAnalyzeBatchCelery(
jobId: string,
apiBase = getSegmentApiBase(),
): Promise<BackendCvCeleryStatusResponse> {
const response = await fetch(`${apiBase}/api/test/analyze/batch/celery/${encodeURIComponent(jobId)}`);
const payload = await readMlApiJson<BackendCvCeleryStatusResponse>(response);
if (!response.ok) {
throw new Error(payload.detail ?? `Celery batch poll error (${response.status})`);
}
return payload;
}
/**
* Async CV pipeline via Celery chunk fan-out.
* Submits job, polls until completed/failed, maps results into cache.
* Returns cached results immediately if all frames are already available.
*/
export async function getCvAnalyzeResultsForProfileCelery(
frames: ProfileFrameRef[],
mlContext?: ProfileMlContext,
apiBase = getSegmentApiBase(),
signal?: AbortSignal,
): Promise<Map<string, CvFrameAnalyzeResult>> {
if (frames.length === 0) {
return new Map();
}
const cacheKey = buildBatchCacheKey(frames.map((f) => f.id));
// Return fully cached results without hitting the backend.
const cached = new Map<string, CvFrameAnalyzeResult>();
for (const frame of frames) {
const hit = cvAnalyzeResultCache.get(frame.id);
if (hit) {
cached.set(frame.id, hit);
}
}
if (cached.size === frames.length) {
return cached;
}
// Coalesce in-flight requests for the same batch of frames.
const existing = inflightBatchByKey.get(cacheKey);
if (existing) {
return existing;
}
const batchPromise = (async (): Promise<Map<string, CvFrameAnalyzeResult>> => {
const jobId = await submitCvAnalyzeBatchCelery(frames, apiBase);
const pollIntervalMs = 2000;
const submitTime = Date.now();
while (true) {
if (signal?.aborted) {
throw new Error('Celery batch poll aborted');
}
const status = await pollCvAnalyzeBatchCelery(jobId, apiBase);
if (status.status === 'completed') {
const byFrameId = new Map<string, CvFrameAnalyzeResult>();
if (status.results) {
for (const item of status.results) {
const frameId = item.frame_id;
if (!frameId) continue;
const cvResult = mapPayloadToCvResult(item);
byFrameId.set(frameId, cvResult);
cvAnalyzeResultCache.set(frameId, cvResult);
segmentationResultCache.set(frameId, cvResult.segmentation);
if (cvResult.angle) {
angleResultCache.set(frameId, cvResult.angle);
}
}
}
if (mlContext?.patientMrn && status.results) {
await persistCvBatch(
frames,
new Map([...byFrameId.entries()]),
mlContext,
);
}
const totalMs = Date.now() - submitTime;
console.log(
`[cvAnalyzeApi] Celery batch completed jobId=${jobId} frames=${frames.length} totalMs=${totalMs}`,
);
return byFrameId;
}
if (status.status === 'failed') {
const totalMs = Date.now() - submitTime;
console.error(
`[cvAnalyzeApi] Celery batch failed jobId=${jobId} frames=${frames.length} totalMs=${totalMs}`,
);
throw new Error(status.errors?.join('; ') ?? 'Celery batch job failed');
}
if (status.status === 'unknown') {
const totalMs = Date.now() - submitTime;
console.error(
`[cvAnalyzeApi] Celery batch unknown jobId=${jobId} frames=${frames.length} totalMs=${totalMs}`,
);
throw new Error(status.detail ?? `Celery job ${jobId} not found`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
})();
inflightBatchByKey.set(cacheKey, batchPromise);
try {
return await batchPromise;
} finally {
inflightBatchByKey.delete(cacheKey);
}
}

View File

@@ -19,8 +19,12 @@ export async function probeCapabilities(): Promise<CapabilityReport> {
if (!secureContext) {
notes.push('Not a secure context. Use https:// or http://localhost.');
}
if (location.hostname === '127.0.0.1') {
notes.push('OPFS is origin-scoped: 127.0.0.1 and localhost use separate storage.');
if (opfs && location.protocol !== 'https:' && location.hostname !== 'localhost') {
notes.push(
'OPFS is origin-scoped: caches are isolated per scheme+host+port. ' +
'Models downloaded on one origin (e.g. localhost, 127.0.0.1, preview domains) ' +
'are not available on another. Expect a full re-download when the origin changes.',
);
}
const ready = webgpu && opfs && worker && secureContext;

View File

@@ -14,26 +14,38 @@ export const BOOTSTRAP_DECODE: DecodeParams = {
maxTokens: MIN_COT_MAX_TOKENS,
};
function envFlag(name: string, defaultValue: boolean): boolean {
const raw = import.meta.env[name];
if (raw === undefined || raw === '') {
return defaultValue;
}
return raw === '1' || raw.toLowerCase() === 'true';
}
// function envFlag(name: string, defaultValue: boolean): boolean {
// const raw = import.meta.env[name];
// if (raw === undefined || raw === '') {
// return defaultValue;
// }
// return raw === '1' || raw.toLowerCase() === 'true';
// }
/** Try local Gemma worker when OPFS model is available. Falls back to mock replies otherwise. */
export function useLocalLlmWhenAvailable(): boolean {
return envFlag('VITE_CLINICAL_CHAT_USE_LLM', true);
return import.meta.env.VITE_CLINICAL_CHAT_USE_LLM !== 'false';
}
/** Use fixture tool results instead of BFF (default on in dev). */
export function useMockAgentTools(): boolean {
return envFlag('VITE_CLINICAL_CHAT_MOCK_TOOLS', true);
return import.meta.env.VITE_CLINICAL_CHAT_MOCK_TOOLS !== 'false';
}
export function clinicalChatBffBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL ?? '';
const raw = import.meta.env.VITE_API_BASE_URL;
const trimmed = raw != null ? String(raw).trim() : '';
if (trimmed) {
return trimmed;
}
if (import.meta.env.DEV) {
return '';
}
console.warn(
'[clinicalChatConfig] VITE_API_BASE_URL is not set. ' +
'Clinical chat BFF calls will fail. Set it in config/frontend.config.yaml for production builds.',
);
return '';
}
const DEFAULT_OLLAMA_CHAT_URL = '/api/ollama-chat/api/chat';

View File

@@ -122,6 +122,7 @@ export class LlmWorkerClient {
promptOptions: PromptOptions,
decode: DecodeParams,
onToken?: (partial: string) => void,
onSegmentStart?: (segment: number) => void,
): Promise<{ rawOutput: string; stats: GenerationStats }> {
const id = requestId();
this.activeGenerateRequestId = id;
@@ -136,6 +137,7 @@ export class LlmWorkerClient {
},
reject,
onToken: (partial) => onToken?.(partial),
onSegmentStart: (segment) => onSegmentStart?.(segment),
});
this.worker.postMessage({
type: 'generate',

View File

@@ -16,19 +16,23 @@ export function formatInstallTransferLabel(progress: DownloadProgress): string {
export function mapDownloadProgress(progress: DownloadProgress): number {
switch (progress.phase) {
case 'downloading':
case 'resuming':
case 'resuming': {
if (!progress.bytesTotal || progress.bytesTotal <= 0) {
return progress.phase === 'resuming' ? 14 : 12;
return progress.phase === 'resuming' ? 6 : 4;
}
// Track the real byte fraction so the bar matches the "X GB / Y GB" label,
// reserving the last few percent for verify/write before completion.
const fraction = Math.min(1, progress.bytesLoaded / progress.bytesTotal);
return 4 + Math.round(fraction * 92);
}
return 10 + Math.round((progress.bytesLoaded / progress.bytesTotal) * 58);
case 'hashing':
return 72;
return 97;
case 'writing':
return 76;
return 98;
case 'done':
return 78;
return 99;
default:
return 10;
return 4;
}
}

View File

@@ -66,12 +66,26 @@ interface DownloadProbe {
supportsRange: boolean;
}
/** HEAD/range probes should return fast; time out so a hung socket surfaces as a retriable error. */
const PROBE_TIMEOUT_MS = 12_000;
function probeTimeoutSignal(): AbortSignal | undefined {
return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(PROBE_TIMEOUT_MS)
: undefined;
}
async function probeModelDownloadUrl(url: string): Promise<DownloadProbe> {
let response = await fetch(url, { method: 'HEAD', redirect: 'follow' });
let response = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
signal: probeTimeoutSignal(),
});
if (!response.ok) {
response = await fetch(url, {
headers: { Range: 'bytes=0-0' },
redirect: 'follow',
signal: probeTimeoutSignal(),
});
}
@@ -164,7 +178,7 @@ function isRetriableDownloadError(error: unknown): boolean {
if (error instanceof TypeError) {
return true;
}
if (error instanceof DOMException && error.name === 'NetworkError') {
if (error instanceof DOMException && (error.name === 'NetworkError' || error.name === 'TimeoutError')) {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
@@ -175,6 +189,8 @@ function isRetriableDownloadError(error: unknown): boolean {
message.includes('failed to fetch') ||
message.includes('load failed') ||
message.includes('interrupted') ||
message.includes('timed out') ||
message.includes('timeout') ||
message.includes('gián đoạn') ||
message.includes('http 502') ||
message.includes('http 503') ||
@@ -328,6 +344,17 @@ export async function checkOpfsModelLoadable(): Promise<OpfsModelLoadableStatus>
const file = await readOpfsModelFile(MODEL_TASK_FILENAME);
if (file && file.size > 0 && (await isReadable(file))) {
// An interrupted download never wrote a manifest (it is written last). Such a
// file can still clear the >500 MB header check while being a truncated,
// unloadable checkpoint — treat it as resumable, not loadable, so we resume the
// download instead of trying to init MediaPipe on a fragmented model.
if (file.size < EXPECTED_MODEL_TASK_BYTES) {
return invalidStatus(
`Partial download in OPFS (${formatBytes(file.size)} of ${formatBytes(EXPECTED_MODEL_TASK_BYTES)}). Will resume on next install.`,
manifest,
file,
);
}
const validationError = await validateTaskCandidate(file);
if (validationError) {
return invalidStatus(validationError, manifest, file);

View File

@@ -30,7 +30,9 @@ export interface DirectChatTurnResult {
export type ClinicalChatStreamEvent =
| AgentEvent
| { type: 'thought_token'; partial: string };
| { type: 'thought_token'; partial: string }
/** A new local-model generation segment started (continuation) → spawn a new bubble. */
| { type: 'segment_boundary'; segment: number };
export interface RunClinicalChatTurnInput {
inferenceMode: InferenceMode;
@@ -56,10 +58,17 @@ function buildChatHistory(
if (message.streaming || !message.content.trim()) {
continue;
}
if (message.role === 'user') {
turns.push({ role: 'user', text: message.content });
} else if (message.role === 'assistant') {
turns.push({ role: 'assistant', text: message.content });
if (message.role !== 'user' && message.role !== 'assistant') {
continue;
}
const role: GemmaHistoryTurn['role'] = message.role === 'user' ? 'user' : 'assistant';
// Continuation segments render as separate assistant bubbles; coalesce consecutive
// same-role turns so Gemma sees one well-formed alternating turn.
const previous = turns[turns.length - 1];
if (previous && previous.role === role) {
previous.text = `${previous.text}${message.content}`;
} else {
turns.push({ role, text: message.content });
}
}
return turns.slice(-maxHistoryTurns * 2);
@@ -126,6 +135,7 @@ export async function runDirectChatTurn(
historyMessages: ClinicalChatMessage[];
onToken?: (partial: string) => void;
onThoughtToken?: (partial: string) => void;
onSegmentStart?: (segment: number) => void;
},
signal?: AbortSignal,
): Promise<DirectChatTurnResult> {
@@ -206,6 +216,7 @@ export async function runDirectChatTurn(
promptOptions,
decode,
input.onToken,
// input.onSegmentStart,
);
if (signal?.aborted) {
@@ -252,6 +263,7 @@ export async function runClinicalChatTurn(
historyMessages: input.historyMessages,
onToken: (partial) => onEvent?.({ type: 'final_token', partial }),
onThoughtToken: (partial) => onEvent?.({ type: 'thought_token', partial }),
onSegmentStart: (segment) => onEvent?.({ type: 'segment_boundary', segment }),
},
signal,
);

View File

@@ -71,7 +71,7 @@ export interface SegmentationApiResult {
}
/** Resolve API base: dev uses Vite proxy (empty); preview/build targets local test server. */
/** Resolve API base: dev uses Vite proxy (empty); production uses configured URL. */
export function getSegmentApiBase(): string {
const configured = import.meta.env.VITE_SEGMENT_API_BASE;
if (configured != null && String(configured).trim() !== '') {
@@ -80,7 +80,11 @@ export function getSegmentApiBase(): string {
if (import.meta.env.DEV) {
return '';
}
return 'http://127.0.0.1:8001';
console.warn(
'[segmentationApi] VITE_SEGMENT_API_BASE is not set. ' +
'Segmentation API calls will fail. Set it in config/frontend.config.yaml for production builds.',
);
return '';
}
/** ML inference always uses the test proxy / production API — mock path detached. */

View File

@@ -227,6 +227,7 @@ function ClinicalWorkspaceContent({
onSegmentationLoadingChange={setIsSegmentationLoading}
patientMrn={patient.mrn}
patientId={patient.id}
useCelery={import.meta.env.VITE_USE_CV_CELERY !== 'false'}
onRegisterSnapshotCapture={(capture) => {
captureSnapshotRef.current = capture;
}}

View File

@@ -32,7 +32,9 @@ interface ImportMetaEnv {
readonly VITE_OLLAMA_CHAT_URL?: string;
readonly VITE_OLLAMA_MODEL?: string;
readonly VITE_USE_BACKEND_SEGMENTATION?: string;
readonly VITE_USE_CV_CELERY?: string;
readonly VITE_SEGMENT_API_BASE?: string;
readonly VITE_MODAL_OLLAMA_TARGET?: string;
}
interface ImportMeta {

View File

@@ -338,16 +338,16 @@ async function generateResponse(
}
const baseBeforeSegment = combinedOutput;
let emittedLength = combinedOutput.length;
const tokenBatcher = createTokenBatcher(requestId, segmentNumber);
let emittedSegmentLength = 0;
// Stream each segment's RAW tokens (its own thought channel + answer)
// independently. The client renders one bubble per segment and parses its own
// channel, so continuation thinking never leaks into a prior answer. The
// cross-segment merge below is only for the model's continuation prompt + stats.
const segmentRaw = await streamSegment(prompt, requestId, (_partial, segmentSoFar) => {
const merged =
segments === 0
? segmentSoFar
: mergeContinuationOutput(baseBeforeSegment, segmentSoFar, chainOfThought);
const delta = merged.slice(emittedLength);
emittedLength = merged.length;
const delta = segmentSoFar.slice(emittedSegmentLength);
emittedSegmentLength = segmentSoFar.length;
if (delta.length > 0) {
tokenBatcher.push(delta);
}

View File

@@ -15,7 +15,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"types": ["node"]
},
"include": ["src"]
"include": ["src", "vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,53 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import fs from 'fs';
import { load } from 'js-yaml';
function loadFrontendConfig(): Record<string, string> {
try {
const raw = fs.readFileSync('config/frontend.config.yaml', 'utf8');
return load(raw) as Record<string, string>;
} catch {
return {};
}
}
const frontendConfig = loadFrontendConfig();
const defineVars: Record<string, string> = {};
for (const [key, value] of Object.entries(frontendConfig)) {
defineVars[`import.meta.env.${key}`] = JSON.stringify(String(value));
}
const MODAL_OLLAMA_TARGET =
frontendConfig.VITE_MODAL_OLLAMA_TARGET ??
'https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run';
// API base URLs from YAML — empty in dev so Vite proxy handles them.
const apiBaseUrl = (frontendConfig.VITE_API_BASE_URL ?? '').trim();
const segmentApiBase = (frontendConfig.VITE_SEGMENT_API_BASE ?? '').trim();
// Resolve build output directory from YAML config, falling back to 'dist'
function findProjectRoot(startDir: string): string {
let current = startDir;
while (current !== path.parse(current).root) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
current = path.dirname(current);
}
return startDir;
}
const projectRoot = findProjectRoot(__dirname);
const configuredOutDir = frontendConfig.VITE_DISTRIBUTE_DIRRECTORY;
const buildOutDir = configuredOutDir
? path.isAbsolute(configuredOutDir)
? configuredOutDir
: path.resolve(projectRoot, configuredOutDir)
: path.resolve(__dirname, 'dist');
export default defineConfig({
plugins: [react()],
worker: {
@@ -14,7 +57,7 @@ export default defineConfig({
alias: {
'@vkist/agent-runtime': path.resolve(
__dirname,
'../../ml/implementation/nlp/agent_runtime/src/index.ts',
'../../ml/implementation/nlp/agent_runtime/src'
),
},
},
@@ -22,7 +65,7 @@ export default defineConfig({
host: '127.0.0.1',
port: 5173,
strictPort: true,
// Keep HMR WebSocket on the same host/port as the page (avoids localhost vs 127.0.0.1 mismatch).
// Keep HMR WebSocket consistent (avoid localhost vs 127.0.0.1 mismatch).
hmr: {
host: '127.0.0.1',
port: 5173,
@@ -39,17 +82,31 @@ export default defineConfig({
proxyTimeout: 300_000,
},
'/api/test': {
target: 'http://127.0.0.1:8001',
target: segmentApiBase || 'http://127.0.0.1:8001',
changeOrigin: true,
timeout: 300_000,
proxyTimeout: 300_000,
},
'/api': {
target: 'http://127.0.0.1:8001',
target: apiBaseUrl || 'http://127.0.0.1:8001',
changeOrigin: true,
timeout: 300_000,
proxyTimeout: 300_000,
},
},
},
build: {
outDir: buildOutDir,
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ml': ['@litert-lm/core', '@mediapipe/tasks-genai'],
'agent-runtime': ['@vkist/agent-runtime'],
},
},
},
chunkSizeWarningLimit: 250,
},
define: defineVars,
});

View File

@@ -1,31 +0,0 @@
# Install Docker image
docker network create jenkins
# install docker-integratable image
docker run --name jenkins-docker --rm --detach \
-p 8080:8080 -p 50000:50000 \
--restart=on-failure \
--privileged --network jenkins --network-alias docker \
--env DOCKER_TLS_CERTDIR=/certs \
--volume jenkins-docker-certs:/certs/client \
--volume jenkins-data:/var/jenkins_home \
--publish 2376:2376 \
docker:dind --storage-driver overlay2 \
-v jenkins_home:/var/jenkins_home jenkins/jenkins:lts
# install the docker images
docker run \
--name jenkins-blueocean \
--restart=on-failure \
--detach \
--network jenkins \
--env DOCKER_HOST=tcp://docker:2376 \
--env DOCKER_CERT_PATH=/certs/client \
--env DOCKER_TLS_VERIFY=1 \
--publish 8080:8080 \
--publish 50000:50000 \
--volume jenkins-data:/var/jenkins_home \
--volume jenkins-docker-certs:/certs/client:ro \
jenkins/jenkins:lts

View File

@@ -0,0 +1,42 @@
### 1. The Core Application Stack (Replacing the streaming engine)
* **Database:** **PostgreSQL** or **SQLite**
* *Why:* Extremely lightweight, universally supported, and standard for medical/relational data (like patient exercises, joint ranges of motion, and logs).
* **Backend:** **Node.js (Express)**, **Python (FastAPI)**, or **Go**
* *Why:* Fast to build for a PoC, easily containerized via Docker, and highly efficient. FastAPI is excellent if you plan to incorporate any musculoskeletal data analysis or ML later.
### 2. CI/CD & Infrastructure Components
* **Codebase:** **Gitea**
* *Why:* A lightweight, self-hosted Git platform that runs in a simple local Docker container using very little memory.
* **CI/CD Orchestrator & Build Server:** **Woodpecker CI** or **GitHub Actions** (if you're okay using GitHub until migrating to a private cloud).
* *Why:* Woodpecker is an open-source, container-first CI engine that runs locally with almost zero overhead.
* **Artifact Repository:** **Gitea Packages** or **Docker Registry**
* *Why:* You can store your built application images right inside Gitea or a tiny local Docker registry container.
### 3. Environments & Deployment
* **Deployment-Manager:** **Docker Compose** or **Portainer**
* *Why:* To deploy your musculoskeletal app, you just need Docker Compose to orchestrate your backend and database containers. Portainer gives you a simple web GUI to manage them locally.
* **Staging & Production Env:** Isolated local containers or separate virtual machines.
### 4. Feedback & Monitoring Loop
* **User Feedback Collector:** A custom-built form widget inside your app or an open-source tool like **Feedback Fish** / **Air Table**
* **Feedback-Resolve:** **Focalboard** or **Leantime** (Self-hosted, lightweight project boards to track bugs and user feature requests).
* **Monitoring & Logging:** **Prometheus + Grafana**
* *Why:* Perfect for tracking application uptime, API response times, and server health.

View File

@@ -0,0 +1,49 @@
```planUML
@startuml C4_Elements
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml
title Component Diagram for CI/CD Pipeline Architecture
Person(developer, "Developer(s)", "Writes code and pushes changes.")
Person(enduser, "End-User(s)", "Interacts with the production application.")
Container_Boundary(pipeline_boundary, "CI/CD Pipeline System") {
Component(codebase, "Codebase", "Git Repository", "Stores the source code and tracks version history.")
Component(orchestrator, "CI/CD Orchestrator", "Workflow Engine", "Triggers actions based on repository events.")
Component(build_server, "Build Server", "Compiler/Packager", "Compiles code and builds release packages.")
Component(artifact_repo, "Artifact Repository", "Storage", "Stores compiled binaries or container images.")
Component(test_pipeline, "Test-Pipeline", "Automation Suite", "Runs unit, integration, and security tests.")
Component(deploy_manager, "Deployment-Manager", "CD Engine", "Orchestrates deployment to various environments.")
Component(user_feedback, "User Feedback Collector", "In-App/Portal Widget", "Collects explicit feature requests and bug reports from users.")
Component(feedback_resolve, "Feedback-Resolve", "Tracking System", "Manages issues, bugs, and deployment logs.")
Component(monitoring, "Monitoring & Logging", "Observability", "Monitors application health and metrics.")
Container_Boundary(deploy_env, "Deployment Environments") {
Component(staging_env, "Staging/QA Env", "Environment", "Pre-production environment for testing.")
Component(prod_env, "Production Env", "Environment", "Live environment hosting the customer application.")
}
}
' Directional Flow Links
Rel(developer, codebase, "1. Pushes code")
Rel(codebase, orchestrator, "2. Triggers event webhook")
Rel(orchestrator, build_server, "3. Triggers build job")
Rel(build_server, artifact_repo, "4. Stores compiled artifact")
Rel(orchestrator, test_pipeline, "5. Runs automated tests")
Rel(orchestrator, deploy_manager, "6. Signals ready for deployment")
Rel(deploy_manager, artifact_repo, "7. Pulls latest artifact")
Rel(deploy_manager, staging_env, "8. Deploys to")
Rel(deploy_manager, prod_env, "9. Promotes approved changes to")
Rel(enduser, prod_env, "10. Interacts with application")
Rel(enduser, user_feedback, "11. Submits requests & feedback")
Rel(user_feedback, feedback_resolve, "12. Forwards user tickets")
Rel(monitoring, prod_env, "13. Reads metrics and errors")
Rel(monitoring, feedback_resolve, "14. Feeds performance/error logs")
Rel(feedback_resolve, developer, "15. Alerts for loop closure")
@enduml
```

View File

@@ -25,16 +25,36 @@ triton_image = (
)
app = modal.App("triton-s3-service", image=triton_image)
from fastapi import FastAPI, Response, Request,HTTPException
from fastapi.responses import StreamingResponse # 👈 ADD THIS IMPORT
import httpx
web_app = FastAPI()
# -------------------------------------------------------------
# FASTAPI PROXY ROUTING (Living inside the container)
# THE UNIFIED SERVICE FUNCTION (1 Container, 1 GPU, 1 Triton Process)
# -------------------------------------------------------------
@web_app.get("/v2/health/ready")
async def forward_health():
@app.function(
gpu="T4", # for the expense
timeout=3600,
max_containers=3, # Strict production capping
min_containers=1, # for keeping warm and prevention,
buffer_containers=2, # Number of additional idle containers to maintain under active load.
scaledown_window=30, # Max time (in seconds) a container can remain idle while scaling down.
volumes= {
'/mnt/vkist-ml-model' : modal.CloudBucketMount(bucket_name="vkist-ml-model", secret=modal.Secret.from_name("aws-secrets"))
},
secrets=[modal.Secret.from_name("aws-secrets")]
)
@modal.asgi_app()
def unified_triton_server():
from fastapi import FastAPI, Response, Request,HTTPException
from fastapi.responses import StreamingResponse # 👈 ADD THIS IMPORT
import httpx
web_app = FastAPI()
# -------------------------------------------------------------
# FASTAPI PROXY ROUTING (Living inside the container)
# -------------------------------------------------------------
@web_app.get("/v2/health/ready")
async def forward_health():
"""Proxies external HTTP REST calls straight to Triton's internal inference engine"""
async with httpx.AsyncClient() as client:
try:
@@ -43,9 +63,9 @@ async def forward_health():
except Exception as e:
return Response(content=f"Triton booting models from S3... Error: {str(e)}", status_code=503)
@web_app.get("/metrics")
@web_app.get("/")
async def forward_metrics():
@web_app.get("/metrics")
@web_app.get("/")
async def forward_metrics():
"""Proxies external metric calls straight to Triton's internal metrics engine"""
async with httpx.AsyncClient() as client:
try:
@@ -54,9 +74,9 @@ async def forward_metrics():
except Exception as e:
return Response(content=f"Waiting for metrics channel... Error: {str(e)}", status_code=503)
# 👇 ADD THIS CATCH-ALL ROUTE HERE 👇
@web_app.api_route("/v2/{path:path}", methods=["GET", "POST"])
async def proxy_all_triton_request(path: str, request: Request):
# 👇 ADD THIS CATCH-ALL ROUTE HERE 👇
@web_app.api_route("/v2/{path:path}", methods=["GET", "POST"])
async def proxy_all_triton_request(path: str, request: Request):
import tritonclient.grpc.aio as grpcclient
from tritonclient.grpc import service_pb2, service_pb2_grpc
from tritonclient.grpc import _utils as grpc_utils #InferenceServerClient
@@ -188,32 +208,21 @@ async def proxy_all_triton_request(path: str, request: Request):
status_code=502
)
@web_app.get("/v2/models")
async def forward_list_models():
@web_app.get("/v2/models")
async def forward_list_models():
async with httpx.AsyncClient() as client:
r = await client.get("http://127.0.0.1:8000/v2/models")
return Response(content=r.content, status_code=r.status_code, media_type=r.headers.get("content-type"))
# -------------------------------------------------------------
# THE UNIFIED SERVICE FUNCTION (1 Container, 1 GPU, 1 Triton Process)
# -------------------------------------------------------------
@app.function(
gpu="T4", # for the expense
timeout=3600,
max_containers=3, # Strict production capping
min_containers=1, # for keeping warm and prevention,
buffer_containers=2, # Number of additional idle containers to maintain under active load.
scaledown_window=30, # Max time (in seconds) a container can remain idle while scaling down.
secrets=[modal.Secret.from_name("aws-secrets")]
)
@modal.asgi_app()
def unified_triton_server():
print("🚀 Booting ONE Triton Instance inside ONE A100 Container...")
# Spawns Triton in the background. It will automatically read
# your "aws-secrets" environment keys to mount s3://vkist-ml-model/
cmd = ["tritonserver", "--model-repository=s3://vkist-ml-model/"]
# cmd = ["tritonserver", "--model-repository=s3://vkist-ml-model/"] # bad pattern causing latency
cmd = ["tritonserver", "--model-repository=/mnt/vkist-ml-model/"]
# triton issue network connection -> AWS -> adding cold-off latency
# idea mounting the model to Modal Volume
subprocess.Popen(cmd)
print("📋 Triton background process delegated. Handing routing control over to FastAPI.")

View File

@@ -1,2 +1,2 @@
cd ../PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/infra/implementation/triton_run
MODAL_PROFILE=dtj-tran modal deploy PILOT_PROJECT/workspace/sprint_1_2/codebase/infra/implementation/triton_run/modal_triton.py
MODAL_PROFILE=dtj-tran modal deploy modal_triton.py

View File

@@ -1,5 +1,8 @@
# Automatically generated by https://github.com/damnever/pigar.
# Generated by pigar — manually cleaned to remove false positives.
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.12.1
torchvision==0.27.1
torchinfo==1.8.0
aiosqlite==0.22.1
docling==2.70.0
fastapi==0.135.1
@@ -7,14 +10,13 @@ fastembed==0.8.0
gliner==0.2.27
grpcio==1.81.1
httpx==0.28.1
ingestion==0.0.42
langchain==1.3.7
langchain-text-splitters==1.1.2
modal==1.5.0
numpy==2.1.3
opencv-python==4.13.0.92
pgvector==0.4.2
pillow==12.3.0
pillow>=10.0.0,<12.0.0
psycopg2-binary==2.9.12
pycocotools==2.0.11
pydantic==2.13.4
@@ -28,75 +30,11 @@ requests==2.34.2
starlette==1.2.1
supabase==2.31.0
timm==1.0.25
torch==2.12.1
torchinfo==1.8.0
torchvision==0.27.1
transformers==4.57.6
tritonclient==2.69.0
uvicorn==0.41.0
# WARNING(pigar): some manual fixes might be required as pigar has detected duplicate requirements for the same import name (possibly for different submodules).
# WARNING(pigar): the following duplicate requirements are for the import name: backend
agentbeats==1.2.6
AlgoVision-Quant-Research==0.0.2
alles-apin==0.0.1
asamba==1.0.8
backend==0.2.4.1
backendcatraca-tektek==0.0.7
BackupBackup==0.0.6
cinder-ml==1.8.5
cmbagent==0.0.1.post63
conting-researcher==0.14.0
cwyod-base==0.0.2
deepread==0.0.3
devshare==1.0.0
django-nextcloud-storage==2024.5.20
doccano==1.8.5
doccano-multi-label==1.8.4.2
docforge-ai==2.0.3
equation-phase-portrait-tool==0.1.0
excellxgene==2.9.6
flaskreactapp==1.0.21
flet-devtools==0.0.0
food-fighters==1.0.1
Fred-Frechet==1.14.5
gpt-researcher==0.15.1
hermes-revision-system==0.1.0
hotel-spider==1.1.5
latch-excellxgene==1.0.0
leap-backend==0.1.0
lemon-auto-saver==1.0.1
LPP0D==0.1.1
mcp-feedback-pipe==3.0.15
mmsbm==1.0.7
nia-cli==0.1.1
nmuwd==0.10.3
omnbot==2017.5
personal-site-msilvasy==1.0.5
pipebio==5.1.0
prastut-ai==1.0.0
productiongraph==0.2.0
pyapptest==1.0.0
python-backend==0.0.1
pyWikiCMS==0.5.0
quantum-finance==0.2.0
qutritium==1.5.2
rasa-storyteller==0.2.1
reapply-workflows==0.1.0
recall-core==0.1.1
Researcher==0.4.3
reusing-intent==0.1.2
sparseprop==0.1.14
terminal-note==0.10.0
traiNER-loop==0.1.0
typernexrad-cli==0.1.0
viasp==2.3.0.post3
vibe-reader==0.2.1
work-with-database==0.0.1
x-lib==0.0.27
# WARNING(pigar): the following duplicate requirements are for the import name: langchain_google_vertexai
gigachain-google-vertexai==2.0.0
langchain-google-vertexai==3.2.4
# WARNING(pigar): the following duplicate requirements are for the import name: optimum
optimum==2.1.0
optimum-onnx==0.1.0
celery==5.6.3
python-multipart==0.0.22

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB