158 lines
5.2 KiB
Python

"""
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 "<unknown>"
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),
},
)