""" FastAPI microservice that wraps `create_docx_report()` for use by n8n. Endpoints: - GET /health liveness probe - POST /generate accept the `Report: Build Payload` JSON and return a DOCX Run locally: uvicorn report_service.main:app --host 0.0.0.0 --port 8000 """ from __future__ import annotations import hmac import logging import os import tempfile from contextlib import contextmanager from typing import Any from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi.responses import JSONResponse, Response from src.reporting import docx as docx_module from src.reporting.docx import create_docx_report from src.reporting.filename_utils import slugify_ascii_underscore from report_service.adapter import apply_farm_config, build_dataframes logging.basicConfig( level=os.getenv("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger("report_service") app = FastAPI(title="Lightning Report Service", version="1.0.0") DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" def require_report_token(x_report_token: str | None = Header(default=None)) -> None: """ Shared-secret gate for the public `/generate` endpoint. Fails closed: if `REPORT_SERVICE_TOKEN` is unset in the environment the service refuses every request rather than silently serving open traffic. """ expected = os.getenv("REPORT_SERVICE_TOKEN") if not expected: logger.error("REPORT_SERVICE_TOKEN is not configured; refusing request") raise HTTPException(status_code=503, detail="Service is not configured") if not x_report_token or not hmac.compare_digest(x_report_token, expected): raise HTTPException(status_code=401, detail="Invalid or missing X-Report-Token") @contextmanager def _override_gemini_commentary(override_text: str | None): """ If n8n already called Gemini and forwarded the text, short-circuit `generate_gemini_paragraph` so the downstream report uses it verbatim. Restores the original function on exit even if the request fails. """ if not override_text: yield return original = docx_module.generate_gemini_paragraph docx_module.generate_gemini_paragraph = lambda _ctx, api_key=None: override_text try: yield finally: docx_module.generate_gemini_paragraph = original def _build_filename(payload: dict[str, Any]) -> str: safe_name = slugify_ascii_underscore(payload.get("customer_name") or "report") from src.config import config start = (config.analysis_start_date or "").replace(" ", "_").replace(":", "").replace("-", "") end = (config.analysis_end_date or "").replace(" ", "_").replace(":", "").replace("-", "") parts = [safe_name] if start: parts.append(start) if end: parts.append(end) parts.append("report.docx") return "_".join(parts) @app.get("/health") def health() -> JSONResponse: return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version}) @app.post("/generate", dependencies=[Depends(require_report_token)]) async def generate(request: Request) -> Response: try: payload: dict[str, Any] = await request.json() except Exception as exc: raise HTTPException(status_code=400, detail=f"Invalid JSON body: {exc}") from exc if not isinstance(payload, dict): raise HTTPException(status_code=400, detail="Request body must be a JSON object") customer_name = payload.get("customer_name") or "" n_strikes = int(payload.get("n_strikes") or 0) logger.info( "Generating report for customer=%s n_strikes=%s n_turbines=%s", customer_name, n_strikes, len(payload.get("turbines") or []), ) try: apply_farm_config(payload) turbine_df, lightning_df = build_dataframes(payload) except ValueError as exc: logger.warning("Payload validation failed: %s", exc) raise HTTPException(status_code=422, detail=str(exc)) from exc storm_records = payload.get("storm_records") or None filename = _build_filename(payload) tmp_fd, tmp_path = tempfile.mkstemp(suffix=".docx") os.close(tmp_fd) try: with _override_gemini_commentary(payload.get("gemini_text")): create_docx_report( tmp_path, turbine_df, lightning_df, storm_data_path=None, storm_data_records=storm_records, ) with open(tmp_path, "rb") as fh: data = fh.read() except Exception as exc: logger.exception("Report generation failed for %s", customer_name) raise HTTPException(status_code=500, detail=f"Report generation failed: {exc}") from exc finally: try: os.unlink(tmp_path) except OSError: pass logger.info("Generated %s (%d bytes) for %s", filename, len(data), customer_name) return Response( content=data, media_type=DOCX_MIME, headers={ "Content-Disposition": f'attachment; filename="{filename}"', "X-Report-Filename": filename, "X-Report-Customer": str(customer_name), "X-Report-Strikes": str(n_strikes), }, )