Files
terra-view/backend/services/report_orchestrator.py
T
serversdown c1b5efae56 feat(reports): reference-baseline mode (typed limits / prior averages)
Baseline can now come from fixed values typed per location, not just captured
data — for a spec limit ("L10 = 85") or a prior report's averages when the raw
data isn't available.

- SoundReportConfig.baseline_mode ("captured" | "reference").
- report_pipeline: _location_reference_baseline() reads per-location values from
  location_metadata; build_*_night_report honor baseline_mode (reference cells
  use the typed value; unset metrics compare against nothing).
- reports router: GET/PUT /reports/baseline (mode on config + per-location values
  in location_metadata); config carries baseline_mode; manual view/run fall back
  to the saved config's baseline when no explicit dates are given.
- orchestrator + scheduler tick thread baseline_mode through.

Verified end-to-end: PUT/GET /baseline, reference deltas (L10 66.6 vs 85 -> -18.4),
unset metrics compare against nothing, captured-mode regression intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:26:23 +00:00

134 lines
4.8 KiB
Python

"""
Nightly Report Orchestrator.
Ties the pieces together: compute → render → write-to-disk → email.
This is what the daily cycle (or a manual trigger) calls. It ALWAYS writes the
rendered report to disk — `data/reports/{project_id}/{night_date}/report.html`
(+ `report.json` with the raw numbers) — so there's a viewable artifact even
when email is in dry-run (SMTP not configured yet). The email step is
best-effort and never aborts the run.
"""
from __future__ import annotations
import json
import logging
from datetime import date
from pathlib import Path
from typing import Optional
from sqlalchemy.orm import Session
from backend.services.report_pipeline import (
ProjectNightReport, build_project_night_report, Window,
)
from backend.services.report_renderers import render_html_summary
from backend.services.report_email import send_report_email, Attachment
logger = logging.getLogger(__name__)
DEFAULT_OUTPUT_ROOT = "data/reports"
def _report_to_dict(report: ProjectNightReport) -> dict:
"""Serialise the report data model to plain JSON (for the on-disk record)."""
return {
"project_id": report.project_id,
"project_name": report.project_name,
"night_date": report.night_date.isoformat(),
"metrics": [m.key for m in report.metrics],
"locations": [
{
"name": loc.location_name,
"night_interval_count": loc.night_interval_count,
"baseline_nights_used": loc.baseline_nights_used,
"notes": loc.notes,
"windows": {
w.key: {
"label": w.label,
"metrics": {
m.key: {
"label": m.label,
"last_night": loc.table[w.key][m.key].last_night,
"baseline": loc.table[w.key][m.key].baseline,
"delta": loc.table[w.key][m.key].delta,
}
for m in loc.metrics
},
}
for w in loc.windows
},
}
for loc in report.locations
],
}
def run_nightly_report(
db: Session,
project_id: str,
night_date: date,
*,
metric_keys: Optional[list[str]] = None,
windows: Optional[list[Window]] = None,
baseline_mode: str = "captured",
baseline_start: Optional[date] = None,
baseline_end: Optional[date] = None,
recipients: Optional[list[str]] = None,
output_root: str = DEFAULT_OUTPUT_ROOT,
send: bool = True,
) -> dict:
"""Build, persist, and (dry-run) email the night report for a project.
Returns a result dict with the on-disk artifact paths and the email result.
Designed to be called from the daily cycle or a manual trigger.
"""
report = build_project_night_report(
db, project_id, night_date,
metric_keys=metric_keys, windows=windows,
baseline_mode=baseline_mode,
baseline_start=baseline_start, baseline_end=baseline_end,
)
html = render_html_summary(report)
subject = f"{report.project_name} — night report {night_date:%m/%d/%y}"
# --- Always persist a viewable copy ---
out_dir = Path(output_root) / project_id / f"{night_date:%Y-%m-%d}"
out_dir.mkdir(parents=True, exist_ok=True)
html_path = out_dir / "report.html"
html_path.write_text(html, encoding="utf-8")
json_path = out_dir / "report.json"
json_path.write_text(json.dumps(_report_to_dict(report), indent=2), encoding="utf-8")
# --- Excel attachment (renderer not built yet — held until metric set is final) ---
attachments: list[Attachment] = []
# --- Email (best-effort; dry-run until SMTP is configured) ---
email_result = {"sent": False, "dry_run": False, "skipped": True, "error": None}
if send:
email_result = send_report_email(
subject, html, attachments=attachments, recipients=recipients,
)
result = {
"project_id": project_id,
"project_name": report.project_name,
"night_date": night_date.isoformat(),
"subject": subject,
"location_count": len(report.locations),
"html_path": str(html_path),
"json_path": str(json_path),
"html": html, # for callers that want to display it inline
"email": email_result,
}
logger.info(
"Nightly report for %s (%s): %d location(s) → %s; email=%s",
report.project_name, night_date, len(report.locations), html_path,
"sent" if email_result.get("sent") else
("dry-run" if email_result.get("dry_run") else
("skipped" if email_result.get("skipped") else f"error: {email_result.get('error')}")),
)
return result