update the cv modal inference proxy server with optimization
This commit is contained in:
@@ -31,7 +31,7 @@ os.environ.setdefault("TRITON_ENDPOINT", DEFAULT_TRITON_ENDPOINT)
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import asyncio
|
||||
from backend.routers import cv_inference
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -44,10 +44,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Starting CV inference service on Triton: %s", os.getenv("TRITON_ENDPOINT"))
|
||||
from backend.services.triton_warmup import warmup_triton_models
|
||||
|
||||
try:
|
||||
await warmup_triton_models()
|
||||
except Exception as exc:
|
||||
logger.warning("Triton warmup failed — first clinical request may be slow: %s", exc)
|
||||
warmup_task = asyncio.create_task(warmup_triton_models())
|
||||
yield
|
||||
logger.info("Shutting down CV inference service")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
27
workspace/sprint_1_2/CODEBASE/backend/routers/run_cv_inference.sh
Executable file
27
workspace/sprint_1_2/CODEBASE/backend/routers/run_cv_inference.sh
Executable 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}
|
||||
22
workspace/sprint_1_2/CODEBASE/backend/services/celery_app.py
Normal file
22
workspace/sprint_1_2/CODEBASE/backend/services/celery_app.py
Normal 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"
|
||||
@@ -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)
|
||||
@@ -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,19 +289,30 @@ 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,
|
||||
)
|
||||
results.append(item)
|
||||
infer_modes.append(mode)
|
||||
triton_call_count += calls
|
||||
|
||||
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
|
||||
return CvBatchResult(
|
||||
results=results,
|
||||
triton_infer_calls=triton_call_count,
|
||||
|
||||
@@ -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,61 +315,57 @@ async def infer_angle_logits(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
|
||||
"""Run angle classification under the global Triton lock. Returns (logits, mode, call_count)."""
|
||||
async with _triton_infer_lock:
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_angle_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_angle_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
|
||||
|
||||
async def infer_inflammation_logits(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
|
||||
async with _triton_infer_lock:
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_inflammation_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_inflammation_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
|
||||
|
||||
async def infer_segmentation_logits(
|
||||
images: list[Image.Image],
|
||||
model_name: str,
|
||||
) -> tuple[np.ndarray, Literal["batched", "sequential"], int]:
|
||||
async with _triton_infer_lock:
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_segmentation_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
all_logits: list[np.ndarray] = []
|
||||
modes: list[str] = []
|
||||
call_count = 0
|
||||
for chunk in chunk_sequence(images):
|
||||
logits, mode = await asyncio.to_thread(_infer_segmentation_logits_chunk, chunk, model_name)
|
||||
all_logits.append(logits)
|
||||
modes.append(mode)
|
||||
call_count += 1 if mode == "batched" else len(chunk)
|
||||
combined = np.concatenate(all_logits, axis=0) if len(all_logits) > 1 else all_logits[0]
|
||||
infer_mode: Literal["batched", "sequential"] = (
|
||||
"sequential" if any(m == "sequential" for m in modes) else "batched"
|
||||
)
|
||||
return combined, infer_mode, call_count
|
||||
|
||||
|
||||
async def infer_angle_logits_single(image: Image.Image, model_name: str) -> tuple[np.ndarray, str, int]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
179
workspace/sprint_1_2/CODEBASE/backend/start_celery_workers.sh
Executable file
179
workspace/sprint_1_2/CODEBASE/backend/start_celery_workers.sh
Executable 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
|
||||
34
workspace/sprint_1_2/CODEBASE/frontend/implementation/.gitignore
vendored
Normal file
34
workspace/sprint_1_2/CODEBASE/frontend/implementation/.gitignore
vendored
Normal 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
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
|
||||
VITE_USE_BACKEND_SEGMENTATION: "true"
|
||||
VITE_SEGMENT_API_BASE: ""
|
||||
VITE_USE_CV_CELERY: "false"
|
||||
VITE_API_BASE_URL: ""
|
||||
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"
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
? `${loadCopy.ariaLabel}, ${modelInstallTransferLabel}`
|
||||
: `${loadCopy.ariaLabel}, ${progressPercent} phần trăm`
|
||||
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); }
|
||||
|
||||
@@ -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,27 +18,15 @@ 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);
|
||||
setExpanded(false);
|
||||
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]);
|
||||
|
||||
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">
|
||||
<StreamingPlainText
|
||||
text={content}
|
||||
streamTargetKey={streamTargetKey(messageId, 'thought')}
|
||||
className="clinical-chat__thought-md chat-md__plain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ function envFlag(name: string, defaultValue: boolean): boolean {
|
||||
|
||||
/** 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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
return 10 + Math.round((progress.bytesLoaded / progress.bytesTotal) * 58);
|
||||
// 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);
|
||||
}
|
||||
case 'hashing':
|
||||
return 72;
|
||||
return 97;
|
||||
case 'writing':
|
||||
return 76;
|
||||
return 98;
|
||||
case 'done':
|
||||
return 78;
|
||||
return 99;
|
||||
default:
|
||||
return 10;
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -206,7 +206,7 @@ function ClinicalWorkspaceContent({
|
||||
<WorkspaceShell
|
||||
zoneA={
|
||||
<div ref={canvasStackRef} className="workspace-canvas-stack">
|
||||
<DiagnosticCanvas
|
||||
<DiagnosticCanvas
|
||||
showMask={showMask}
|
||||
onToggleMask={() => {
|
||||
setShowMask((previous) => {
|
||||
@@ -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;
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,8 +1,27 @@
|
||||
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';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -52,4 +71,5 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
define: defineVars,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -205,6 +205,9 @@ async def forward_list_models():
|
||||
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()
|
||||
@@ -213,7 +216,11 @@ def unified_triton_server():
|
||||
|
||||
# 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.")
|
||||
|
||||
@@ -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
|
||||
@@ -95,8 +95,9 @@ 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
|
||||
redis==8.0.1
|
||||
Reference in New Issue
Block a user