test_workflow
Some checks failed
Some checks failed
This commit is contained in:
0
workspace/sprint_1_2/CODEBASE/backend/__init__.py
Normal file
0
workspace/sprint_1_2/CODEBASE/backend/__init__.py
Normal 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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user