erdemerikci 45d80dfaa6 Initial import: Lightning_Report with n8n integration
Fork of Lightning_Report adding:
- n8n_report_branch.json: workflow branch for storm-triggered report delivery
- report_service/: FastAPI microservice wrapping create_docx_report() so n8n
  can produce byte-identical reports without fighting the Python Code sandbox

Made-with: Cursor
2026-04-22 15:13:08 +03:00

142 lines
4.5 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 logging
import os
import tempfile
from contextlib import contextmanager
from typing import Any
from fastapi import FastAPI, 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"
@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")
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),
},
)