import logging import pandas as pd from typing import List from models.artifacts_loader import get_artifacts from schemas.boq import BoQRequest, BoQResponse, BoQPrediction from core.exceptions import ModelPredictionError from utils.inference import predict_with_cascade logger = logging.getLogger(__name__) def _safe_float(value) -> float | None: """ Safely coerce a value to float, returning None for any uncoercible input. Handles Excel overflow artifacts like '########' (column too narrow to display the number), empty strings, and any other non-numeric garbage that may arrive from poorly-formatted spreadsheets. """ if value is None: return None if isinstance(value, float) and pd.isna(value): return None try: return float(value) except (ValueError, TypeError): return None def predict_boq_batch_service(request: BoQRequest) -> BoQResponse: """ Core service logic to predict NRM categories for a batch of BoQ items. """ model = get_artifacts().model if not model: raise ModelPredictionError("ML model is currently unavailable.") fallback_engine = get_artifacts().fallback_engine if not fallback_engine: raise ModelPredictionError("Fallback engine is currently unavailable.") try: # 1. Convert JSON payload to DataFrame df_raw = pd.DataFrame(request.items) # 2. Delegate EVERYTHING to the Hermetic Production Object and Fallback Engine df_results = predict_with_cascade( df_raw=df_raw, ml_model=model, fallback_engine=fallback_engine ) # 3. Format the response predictions_list: List[BoQPrediction] = [] for _, row in df_results.iterrows(): predictions_list.append( BoQPrediction( predicted_category=str(row.get("Predicted_Category", "Unknown")), confidence=float(row.get("Prediction_Confidence", 0.0)), prediction_source=str(row.get("Prediction_Source", "ML_Model")), predicted_ge=str(row["Predicted_ge"]) if "Predicted_ge" in df_results.columns else str(row.get("ge", "")), predicted_group_element=str(row["Predicted_group_element"]) if "Predicted_group_element" in df_results.columns else str(row.get("group_element", "")), predicted_e=str(row["Predicted_e"]) if "Predicted_e" in df_results.columns else str(row.get("e", "")), predicted_element=str(row["Predicted_element"]) if "Predicted_element" in df_results.columns else str(row.get("element", "")), ge=str(row["ge"]) if "Predicted_ge" in df_results.columns and pd.notna(row["ge"]) else None, group_element=str(row["group_element"]) if "Predicted_group_element" in df_results.columns and pd.notna(row["group_element"]) else None, e=str(row["e"]) if "Predicted_e" in df_results.columns and pd.notna(row["e"]) else None, element=str(row["element"]) if "Predicted_element" in df_results.columns and pd.notna(row["element"]) else None, description=str(row.get("description", "Unknown")), item=str(row.get("item")) if pd.notna(row.get("item")) else None, notes=str(row.get("notes")) if pd.notna(row.get("notes")) else None, qty=_safe_float(row.get("qty")), rate_base_date=_safe_float(row.get("rate_base_date")), rate_q1_2025=_safe_float(row.get("rate_q1_2025")), uom=str(row.get("uom")) if pd.notna(row.get("uom")) else None, project=str(row.get("project")) if pd.notna(row.get("project")) else None, typology=str(row.get("typology")) if pd.notna(row.get("typology")) else None, location=str(row.get("location")) if pd.notna(row.get("location")) else None, base_date=str(row.get("base_date")) if pd.notna(row.get("base_date")) else None, package=str(row.get("package")) if pd.notna(row.get("package")) else None, rate_scope=str(row.get("rate_scope")) if pd.notna(row.get("rate_scope")) else None, client_location=str(row.get("client_location")) if pd.notna(row.get("client_location")) else None, basket_of_goods=str(row.get("basket_of_goods")) if pd.notna(row.get("basket_of_goods")) else None ) ) return BoQResponse( model_version=model.model_version, model_name=model.model_name, predictions=predictions_list, ) except Exception as e: logger.error(f"Inference error in service: {e}", exc_info=True) raise ModelPredictionError(f"Internal inference error: {str(e)}") from e