test_workflow
Some checks failed
Backend ECR Deployment / build-and-push (push) Has been cancelled
Backend ECR Deployment / notify-deploy (push) Has been cancelled
Backend Modal Build / notify-deploy (push) Has been cancelled
Backend Modal Build / Build & Push via Modal (push) Has been cancelled

This commit is contained in:
DatTT127
2026-07-18 17:48:19 +07:00
parent 7d5c583475
commit 57a8bac1be
87 changed files with 36291 additions and 155 deletions

View File

@@ -12,36 +12,38 @@ Or the backward-compatible launcher:
Default: http://127.0.0.1:8001 — point the frontend Vite proxy here (see .env.development).
Env:
TRITON_ENDPOINT Modal Triton KServe v2 HTTP URL
TRITON_ENDPOINT Modal Triton KServe v2 HTTP URL (required)
CV_INFERENCE_HOST bind host (default 127.0.0.1)
CV_INFERENCE_PORT bind port (default 8001)
ANGLE_MODEL / INFLAMMATION_MODEL / SEGMENT_MODEL optional overrides
CV_PIPELINE_VERSION cache invalidation fingerprint (default poc-v2-spec-cv)
CORS_ORIGINS comma-separated allowed origins
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
# Must run before backend imports — config reads TRITON_ENDPOINT at import time.
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
os.environ.setdefault("TRITON_ENDPOINT", DEFAULT_TRITON_ENDPOINT)
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import asyncio
from backend.implementation.config import settings
from backend.logging.logging_config import setup_logging
from backend.routers import cv_inference
logger = logging.getLogger(__name__)
# Initialise logging as early as possible so any import-time or
# startup logs are captured consistently in both local and Docker runs.
setup_logging()
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting CV inference service on Triton: %s", os.getenv("TRITON_ENDPOINT"))
if not settings.triton_endpoint:
raise RuntimeError("TRITON_ENDPOINT is not set. Set it via environment variable.")
logger.info("Starting CV inference service on Triton: %s", settings.triton_endpoint)
from backend.services.triton_warmup import warmup_triton_models
warmup_task = asyncio.create_task(warmup_triton_models())
@@ -62,10 +64,7 @@ def create_app() -> FastAPI:
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv(
"CORS_ORIGINS",
"http://localhost:3000,http://localhost:5173,http://localhost:4173,http://127.0.0.1:5173",
).split(","),
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -79,11 +78,18 @@ app = create_app()
def main() -> None:
logging.basicConfig(level=logging.INFO)
host = os.getenv("CV_INFERENCE_HOST", os.getenv("SEGMENT_TEST_HOST", "127.0.0.1"))
port = int(os.getenv("CV_INFERENCE_PORT", os.getenv("SEGMENT_TEST_PORT", "8001")))
logger.info("CV inference service listening on %s:%s", host, port)
uvicorn.run(app, host=host, port=port, log_level="info")
"""Entrypoint used by Docker ENTRYPOINT and local development."""
logger.info(
"CV inference service listening on %s:%s",
settings.cv_inference_host,
settings.cv_inference_port,
)
uvicorn.run(
app,
host=settings.cv_inference_host,
port=settings.cv_inference_port,
log_level="info",
)
if __name__ == "__main__":

View File

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

View File

@@ -3,7 +3,7 @@ import httpx
import json
from typing import AsyncGenerator
from datetime import datetime
import asyncio
from backend.implementation.adapters.redis_adapter import get_redis_client
from backend.implementation.adapters.llm_adapter import get_llm_adapter, AuditCallbackHandler
from backend.implementation.config import (