diff --git a/backend/domain/entities/docker/dockerfile_template.py b/backend/domain/entities/docker/dockerfile_template.py index c08e240..7de4b2e 100644 --- a/backend/domain/entities/docker/dockerfile_template.py +++ b/backend/domain/entities/docker/dockerfile_template.py @@ -16,6 +16,7 @@ def __init__(self, python_version: str): # Set environment variables ENV IMAGE_NAME={image_name} ENV OTEL_METRICS_EXPORTER_LABELS="project_name={project_name},model_name={model_name},model_version={model_version}" + ENV ROOT_PATH="" # Setup uv RUN wget -qO- https://astral.sh/uv/install.sh | sh && which uv || echo "UV installation failed" @@ -47,7 +48,7 @@ def __init__(self, python_version: str): EXPOSE 8000 # Activate conda environment and start the application - CMD ["bash", "-c", "uv run opentelemetry-instrument --service_name $IMAGE_NAME uvicorn fast_api_template:app --host 0.0.0.0 --port 8000 --log-level debug"] + CMD ["bash", "-c", "uv run opentelemetry-instrument --service_name $IMAGE_NAME uvicorn fast_api_template:app --host 0.0.0.0 --port 8000 --root-path $ROOT_PATH --log-level debug"] """ def generate_dockerfile( diff --git a/backend/domain/entities/docker/fast_api_template.py b/backend/domain/entities/docker/fast_api_template.py index 48da1f7..0999037 100644 --- a/backend/domain/entities/docker/fast_api_template.py +++ b/backend/domain/entities/docker/fast_api_template.py @@ -12,7 +12,7 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.resources import SERVICE_NAME, Resource from prometheus_client import generate_latest -from pydantic import BaseModel +from pydantic import BaseModel, create_model try: logger.info("Starting up and loading model...") @@ -20,23 +20,105 @@ logger.info("Model loaded successfully") except Exception as e: logger.error(f"Error loading model: {e}") + model = None image_name = os.environ["IMAGE_NAME"] tracer = trace.get_tracer(f"model_platform_{image_name}") -app = FastAPI(reload=True) +# --------------------------------------------------------------------------- +# Dynamic schema discovery from MLflow model signature +# --------------------------------------------------------------------------- +MLFLOW_TYPE_MAP = { + "double": float, + "float": float, + "long": int, + "integer": int, + "string": str, + "boolean": bool, +} + +DynamicInputs = None +DynamicOutputs = None +signature_description = "" +try: + if model is not None and hasattr(model, "metadata") and model.metadata and model.metadata.signature: + sig = model.metadata.signature + + # Build input schema + if sig.inputs and hasattr(sig.inputs, "inputs") and sig.inputs.inputs: + fields = {} + field_descriptions = [] + for col in sig.inputs.inputs: + col_type_str = str(col.type) + py_type = MLFLOW_TYPE_MAP.get(col_type_str, Any) + fields[col.name] = (py_type, ...) + field_descriptions.append(f"- **{col.name}**: `{col_type_str}`") + if fields: + DynamicInputs = create_model("ModelInputs", **fields) + signature_description = "### Input schema (from MLflow signature)\n" + "\n".join(field_descriptions) + logger.info(f"Built dynamic input schema with {len(fields)} fields: {list(fields.keys())}") + + # Build output schema (best-effort, less critical) + if sig.outputs and hasattr(sig.outputs, "inputs") and sig.outputs.inputs: + out_fields = {} + for col in sig.outputs.inputs: + col_type_str = str(col.type) + py_type = MLFLOW_TYPE_MAP.get(col_type_str, Any) + out_fields[col.name] = (py_type, ...) + if out_fields: + DynamicOutputs = create_model("ModelOutputs", **out_fields) + logger.info(f"Built dynamic output schema with {len(out_fields)} fields: {list(out_fields.keys())}") + +except Exception as e: + logger.warning(f"Could not build dynamic schema from model signature, falling back to generic schema: {e}") + DynamicInputs = None + DynamicOutputs = None + signature_description = "" + +# --------------------------------------------------------------------------- +# Request / Response models (dynamic if signature available, generic fallback) +# --------------------------------------------------------------------------- +if DynamicInputs is not None: + + class PredictionRequest(BaseModel): + inputs: DynamicInputs + +else: -class PredictionRequest(BaseModel): - inputs: Dict[str, Any] + class PredictionRequest(BaseModel): + inputs: Dict[str, Any] class PredictionResponse(BaseModel): outputs: Any -@app.post("/predict", response_model=PredictionResponse) +# --------------------------------------------------------------------------- +# FastAPI application +# --------------------------------------------------------------------------- +api_description = f"""Inference API for deployed ML model **{image_name}**. + +Accepts JSON payloads or file uploads for prediction. + +{signature_description} +""" + +app = FastAPI( + title=f"Model API - {image_name}", + description=api_description, + version="1.0.0", + root_path=os.getenv("ROOT_PATH", ""), +) + + +@app.post( + "/predict", + response_model=PredictionResponse, + summary="Run inference", + description="Send input features as JSON or upload a file for prediction.", +) async def predict(request: Request, file: Optional[UploadFile] = File(None)): tracer = trace.get_tracer(__name__) try: @@ -58,6 +140,8 @@ async def predict(request: Request, file: Optional[UploadFile] = File(None)): if isinstance(input_data, dict): input_df = pd.DataFrame([input_data]) + elif isinstance(input_data, BaseModel): + input_df = pd.DataFrame([input_data.model_dump()]) else: input_df = pd.DataFrame(input_data) @@ -77,18 +161,18 @@ async def predict(request: Request, file: Optional[UploadFile] = File(None)): raise HTTPException(status_code=500, detail=str(e)) -@app.get("/health") +@app.get("/health", summary="Health check") async def health_check(): return {"status": "healthy"} -@app.get("/metrics") +@app.get("/metrics", summary="Prometheus metrics") def metrics_endpoint(): return Response(content=generate_latest(), media_type="text/plain") FastAPIInstrumentor.instrument_app(app) -# Check si on devrait mettre le service name lié à k8s +# Check si on devrait mettre le service name lie a k8s resource = Resource.create(attributes={SERVICE_NAME: f"model-platform-{image_name}"}) # Prometheus client diff --git a/backend/infrastructure/k8s_model_deployment_adapter.py b/backend/infrastructure/k8s_model_deployment_adapter.py index cc09081..1d35242 100644 --- a/backend/infrastructure/k8s_model_deployment_adapter.py +++ b/backend/infrastructure/k8s_model_deployment_adapter.py @@ -80,6 +80,12 @@ def _create_model_service_deployment(self): image=f"{self.docker_image_name}:latest", image_pull_policy="IfNotPresent", # Ajouté pour éviter les erreurs de pull ports=[client.V1ContainerPort(container_port=self.port)], + env=[ + client.V1EnvVar( + name="ROOT_PATH", + value=f"/deploy/{self.namespace}/{self.service_name}", + ), + ], ) ], restart_policy="Always", # Bonne pratique pour un Deployment diff --git a/frontend/js/pages/monitoring.js b/frontend/js/pages/monitoring.js index 98965a5..6759247 100644 --- a/frontend/js/pages/monitoring.js +++ b/frontend/js/pages/monitoring.js @@ -248,6 +248,7 @@ const MonitoringPage = (() => {