forecasting/backend/main.py
jtricerolph 2a7ee1d6b8 Expand AI insight: fix broken competitor query, add continuity, 30-day horizon, holidays
- Fix gather_competitor_data() tier filter: it queried tier IN ('primary','secondary'),
  values that never exist (real values are 'own'/'competitor'/'market'), so the
  cheapest-competitor comparison has always silently returned nothing.
- Feed the previous insight back into the prompt so the model can note what's
  changed/resolved instead of repeating itself.
- Extend forecast horizon from 14 to 30 days; add a per-day revenue table
  alongside the existing occupancy table.
- Annotate the occupancy table with UK (England) bank holidays.
- Add a same-channel market-movement section (B.com vs B.com, rack vs rack)
  diffing rates against the last insight's snapshot, threshold £3.
- Add a parsed headline field + insight history list on the Dashboard,
  collapsed to headline/age and expandable to full content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:03:31 +00:00

164 lines
6.6 KiB
Python

"""
Forecasting Application — FastAPI Backend
Auth is handled by the central HNF stack cookie (hnf_session).
"""
import logging
import sys
import os
import time
from contextlib import asynccontextmanager
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from database import async_engine
from api import (
forecast, sync, export, budget, accuracy, evolution, crossref,
explain, config, historical, resos, backtest, sync_bookings,
resos_sync, reports, special_dates, backup, public, ai_insights,
)
from scheduler import start_scheduler, shutdown_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
# Apply database schema (idempotent CREATE TABLE IF NOT EXISTS)
# Uses psycopg2 directly — schema.sql contains PL/pgSQL with $1/$2 syntax
# that SQLAlchemy text() misinterprets as bindparams.
try:
import os, psycopg2
from database import DATABASE_URL
schema_path = os.path.join(os.path.dirname(__file__), 'schema.sql')
if os.path.exists(schema_path):
with open(schema_path) as f:
sql = f.read()
conn = psycopg2.connect(DATABASE_URL)
try:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute(sql)
logging.getLogger(__name__).info("Schema applied successfully")
finally:
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"Schema init failed: {e}")
# Startup: clean up stale scrape batches
try:
from services.booking_scraper import cleanup_stale_batches
from database import SyncSessionLocal
db = SyncSessionLocal()
try:
cleanup_stale_batches(db, max_age_minutes=10)
finally:
db.close()
except Exception as e:
logging.getLogger(__name__).warning(f"Stale batch cleanup failed: {e}")
# Startup: mark any sync_log entries stuck as 'running' as failed
# (these are orphaned by container restarts mid-job)
try:
import psycopg2
from database import DATABASE_URL
conn = psycopg2.connect(DATABASE_URL)
try:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute("""
UPDATE sync_log
SET status = 'failed',
completed_at = NOW(),
error_message = 'Interrupted by container restart'
WHERE status = 'running' AND completed_at IS NULL
""")
if cur.rowcount:
logging.getLogger(__name__).info(f"Cleared {cur.rowcount} stale sync_log entries")
finally:
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"Stale sync_log cleanup failed: {e}")
# Ensure ai_insights table exists
try:
from database import SyncSessionLocal
db = SyncSessionLocal()
try:
db.execute(text("""
CREATE TABLE IF NOT EXISTS ai_insights (
id SERIAL PRIMARY KEY,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
insight_type VARCHAR(50) NOT NULL DEFAULT 'daily_summary',
content TEXT NOT NULL,
model VARCHAR(100),
input_tokens INTEGER,
output_tokens INTEGER,
data_snapshot JSONB,
triggered_by VARCHAR(50) DEFAULT 'scheduler'
)
"""))
db.execute(text("""
CREATE INDEX IF NOT EXISTS idx_ai_insights_generated
ON ai_insights(generated_at DESC)
"""))
db.execute(text("""
ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS headline TEXT
"""))
db.commit()
finally:
db.close()
except Exception as e:
logging.getLogger(__name__).warning(f"AI insights table creation failed: {e}")
start_scheduler()
yield
shutdown_scheduler()
app = FastAPI(
title="Forecasting API",
description="Hotel & Restaurant Forecasting Service",
version="2.0.0",
lifespan=lifespan,
)
STARTED_AT = str(int(time.time() * 1000))
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# All routes — nginx strips /forecasting/api/ prefix before reaching here
app.include_router(forecast.router, prefix="/forecast", tags=["Forecasts"])
app.include_router(sync.router, prefix="/sync", tags=["Data Sync"])
app.include_router(export.router, prefix="/export", tags=["Exports"])
app.include_router(budget.router, prefix="/budget", tags=["Budgets"])
app.include_router(accuracy.router, prefix="/accuracy", tags=["Accuracy"])
app.include_router(evolution.router, prefix="/evolution", tags=["Forecast Evolution"])
app.include_router(crossref.router, prefix="/crossref", tags=["Cross-Reference"])
app.include_router(explain.router, prefix="/explain", tags=["Explainability"])
app.include_router(config.router, prefix="/config", tags=["Configuration"])
app.include_router(historical.router, prefix="/historical", tags=["Historical Data"])
app.include_router(resos.router, prefix="/resos", tags=["Resos Mapping"])
app.include_router(backtest.router, prefix="/backtest", tags=["Backtesting"])
app.include_router(sync_bookings.router, prefix="/sync", tags=["Data Sync"])
app.include_router(resos_sync.router, prefix="/sync", tags=["Data Sync"])
app.include_router(reports.router, prefix="/reports", tags=["Reports"])
app.include_router(special_dates.router, prefix="/settings", tags=["Settings"])
app.include_router(backup.router, prefix="/backup", tags=["Backup & Restore"])
app.include_router(public.router, prefix="/public", tags=["Public API"])
app.include_router(ai_insights.router, prefix="/ai-insights", tags=["AI Insights"])
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "forecasting-api", "version": os.environ.get("BUILD_VERSION", STARTED_AT)}