Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
300
backend/api/crossref.py
Normal file
300
backend/api/crossref.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""
|
||||
Cross-Reference Validation API endpoints
|
||||
Validate that related forecasts align with each other
|
||||
"""
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/check")
|
||||
async def run_cross_reference_check(
|
||||
check_date: date = Query(..., description="Date to run cross-reference checks for"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Run all cross-reference checks for a specific date.
|
||||
Returns validation results showing if forecasts are internally consistent.
|
||||
"""
|
||||
# Get all active cross-reference configurations
|
||||
config_query = """
|
||||
SELECT check_name, check_category, formula, compares_to, tolerance_pct, input_metrics
|
||||
FROM cross_reference_config
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order
|
||||
"""
|
||||
|
||||
config_result = await db.execute(text(config_query))
|
||||
configs = config_result.fetchall()
|
||||
|
||||
# Get all forecast values for the date
|
||||
forecast_query = """
|
||||
SELECT forecast_type, predicted_value
|
||||
FROM forecasts
|
||||
WHERE forecast_date = :check_date
|
||||
AND model_type = 'prophet'
|
||||
"""
|
||||
|
||||
forecast_result = await db.execute(text(forecast_query), {"check_date": check_date})
|
||||
forecasts = {row.forecast_type: float(row.predicted_value) for row in forecast_result.fetchall()}
|
||||
|
||||
results = []
|
||||
for config in configs:
|
||||
# For now, return placeholder results
|
||||
# In production, would evaluate formula against forecasts
|
||||
compares_to_value = forecasts.get(config.compares_to)
|
||||
|
||||
results.append({
|
||||
"check_name": config.check_name,
|
||||
"check_category": config.check_category,
|
||||
"formula": config.formula,
|
||||
"compares_to": config.compares_to,
|
||||
"forecasted_value": compares_to_value,
|
||||
"calculated_value": None, # Would be calculated from formula
|
||||
"difference": None,
|
||||
"difference_pct": None,
|
||||
"tolerance_pct": float(config.tolerance_pct),
|
||||
"status": "ok" # Would be evaluated based on tolerance
|
||||
})
|
||||
|
||||
return {
|
||||
"check_date": check_date,
|
||||
"results": results,
|
||||
"alignment_score": 100 # Would be calculated
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report")
|
||||
async def get_cross_reference_report(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get cross-reference validation report for date range.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
forecast_date,
|
||||
check_name,
|
||||
check_category,
|
||||
calculated_value,
|
||||
forecasted_value,
|
||||
difference,
|
||||
difference_pct,
|
||||
tolerance_pct,
|
||||
status
|
||||
FROM forecast_cross_reference
|
||||
WHERE forecast_date BETWEEN :from_date AND :to_date
|
||||
ORDER BY forecast_date, check_category, check_name
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.forecast_date,
|
||||
"check_name": row.check_name,
|
||||
"category": row.check_category,
|
||||
"calculated": float(row.calculated_value) if row.calculated_value else None,
|
||||
"forecasted": float(row.forecasted_value) if row.forecasted_value else None,
|
||||
"difference": float(row.difference) if row.difference else None,
|
||||
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
|
||||
"tolerance_pct": float(row.tolerance_pct),
|
||||
"status": row.status
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/discrepancies")
|
||||
async def get_discrepancies(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get list of dates with cross-reference discrepancies.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
forecast_date,
|
||||
check_name,
|
||||
check_category,
|
||||
difference_pct,
|
||||
tolerance_pct,
|
||||
possible_causes,
|
||||
recommendation
|
||||
FROM forecast_cross_reference
|
||||
WHERE forecast_date BETWEEN :from_date AND :to_date
|
||||
AND status = 'discrepancy'
|
||||
ORDER BY ABS(difference_pct) DESC
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.forecast_date,
|
||||
"check_name": row.check_name,
|
||||
"category": row.check_category,
|
||||
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
|
||||
"tolerance_pct": float(row.tolerance_pct),
|
||||
"possible_causes": row.possible_causes,
|
||||
"recommendation": row.recommendation
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/alignment-score")
|
||||
async def get_alignment_score(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get overall alignment score for date range.
|
||||
Higher score = more internally consistent forecasts.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_checks,
|
||||
SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as passed,
|
||||
SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warnings,
|
||||
SUM(CASE WHEN status = 'discrepancy' THEN 1 ELSE 0 END) as discrepancies
|
||||
FROM forecast_cross_reference
|
||||
WHERE forecast_date BETWEEN :from_date AND :to_date
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
row = result.fetchone()
|
||||
|
||||
if not row or row.total_checks == 0:
|
||||
return {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"alignment_score": 100,
|
||||
"total_checks": 0,
|
||||
"passed": 0,
|
||||
"warnings": 0,
|
||||
"discrepancies": 0
|
||||
}
|
||||
|
||||
# Score: 100 * (passed / total), with warnings counting as 0.5
|
||||
score = ((row.passed + row.warnings * 0.5) / row.total_checks) * 100
|
||||
|
||||
return {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"alignment_score": round(score, 1),
|
||||
"total_checks": row.total_checks,
|
||||
"passed": row.passed,
|
||||
"warnings": row.warnings,
|
||||
"discrepancies": row.discrepancies
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_crossref_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get cross-reference check configuration.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
check_name,
|
||||
check_category,
|
||||
description,
|
||||
formula,
|
||||
compares_to,
|
||||
tolerance_pct,
|
||||
input_metrics,
|
||||
is_correlation_check,
|
||||
expected_correlation,
|
||||
is_active,
|
||||
display_order
|
||||
FROM cross_reference_config
|
||||
ORDER BY display_order
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query))
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"check_name": row.check_name,
|
||||
"category": row.check_category,
|
||||
"description": row.description,
|
||||
"formula": row.formula,
|
||||
"compares_to": row.compares_to,
|
||||
"tolerance_pct": float(row.tolerance_pct),
|
||||
"input_metrics": row.input_metrics,
|
||||
"is_correlation_check": row.is_correlation_check,
|
||||
"expected_correlation": float(row.expected_correlation) if row.expected_correlation else None,
|
||||
"is_active": row.is_active
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.put("/config/{check_name}")
|
||||
async def update_crossref_config(
|
||||
check_name: str,
|
||||
tolerance_pct: Optional[float] = Query(None),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update tolerance or active status for a cross-reference check.
|
||||
"""
|
||||
updates = []
|
||||
params = {"check_name": check_name}
|
||||
|
||||
if tolerance_pct is not None:
|
||||
updates.append("tolerance_pct = :tolerance_pct")
|
||||
params["tolerance_pct"] = tolerance_pct
|
||||
|
||||
if is_active is not None:
|
||||
updates.append("is_active = :is_active")
|
||||
params["is_active"] = is_active
|
||||
|
||||
if not updates:
|
||||
return {"status": "no_changes", "check_name": check_name}
|
||||
|
||||
query = f"""
|
||||
UPDATE cross_reference_config
|
||||
SET {', '.join(updates)}
|
||||
WHERE check_name = :check_name
|
||||
RETURNING check_name
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
await db.commit()
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Check not found: {check_name}")
|
||||
|
||||
return {
|
||||
"status": "updated",
|
||||
"check_name": check_name,
|
||||
"updates": {k: v for k, v in params.items() if k != "check_name"}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue