| 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: |
| |
| df_raw = pd.DataFrame(request.items) |
|
|
| |
| df_results = predict_with_cascade( |
| df_raw=df_raw, ml_model=model, fallback_engine=fallback_engine |
| ) |
|
|
| |
| 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 |
|
|