2026-10-22 17:20:44 +05:30
|
|
|
"""
|
2026-11-10 14:43:27 +05:30
|
|
|
main.py
|
2026-11-04 16:49:39 +05:30
|
|
|
Production FastAPI service for artist telemetry, rankings, and scenario simulation.
|
|
|
|
|
Secured with X-Auth-Secret header verification for Go backend integration.
|
2026-10-22 17:20:44 +05:30
|
|
|
"""
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
import math
|
2026-11-10 14:43:27 +05:30
|
|
|
import os
|
|
|
|
|
import sqlite3
|
|
|
|
|
from datetime import datetime, timezone
|
2026-11-04 16:49:39 +05:30
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
import pandas as pd
|
|
|
|
|
from fastapi import FastAPI, Header, HTTPException, Query, Security, status
|
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
2026-10-22 13:06:10 +05:30
|
|
|
|
2026-10-22 17:20:44 +05:30
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
# Define production auth secret required for Go backend header integration
|
|
|
|
|
AUTH_SECRET = "hiffi_prod_secret_2026"
|
2026-11-10 14:43:27 +05:30
|
|
|
DB_PATH = "data/hiffi_catalog.db"
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
|
|
|
|
|
def verify_auth_secret(x_auth_secret: Optional[str] = Header(None)) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Validates the incoming X-Auth-Secret header for backend-to-backend communication.
|
|
|
|
|
"""
|
|
|
|
|
if x_auth_secret != AUTH_SECRET:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="Invalid or missing X-Auth-Secret header"
|
|
|
|
|
)
|
|
|
|
|
return x_auth_secret
|
2026-10-22 17:20:44 +05:30
|
|
|
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
def sanitize_nans(obj: Any) -> Any:
|
|
|
|
|
"""
|
|
|
|
|
Recursively converts float NaN and Infinity to None
|
|
|
|
|
to ensure strict RFC 8259 JSON compliance.
|
|
|
|
|
"""
|
|
|
|
|
if isinstance(obj, float):
|
|
|
|
|
if math.isnan(obj) or math.isinf(obj):
|
|
|
|
|
return None
|
|
|
|
|
return obj
|
|
|
|
|
if isinstance(obj, dict):
|
|
|
|
|
return {k: sanitize_nans(v) for k, v in obj.items()}
|
|
|
|
|
if isinstance(obj, list):
|
|
|
|
|
return [sanitize_nans(item) for item in obj]
|
|
|
|
|
return obj
|
2026-10-22 17:20:44 +05:30
|
|
|
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
app = FastAPI(
|
|
|
|
|
title="HIFFI Artist Intelligence API",
|
|
|
|
|
version="1.0.0",
|
|
|
|
|
description="Multi-platform analytics, sales audit telemetry, and simulation engine.",
|
|
|
|
|
dependencies=[Security(verify_auth_secret)] # Secures all endpoints globally with X-Auth-Secret
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
allow_origins=["*"],
|
|
|
|
|
allow_credentials=True,
|
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
)
|
2026-10-24 11:20:10 +05:30
|
|
|
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
def load_dataset() -> pd.DataFrame:
|
|
|
|
|
"""Loads artist dataset with fallback paths."""
|
|
|
|
|
candidate_paths = [
|
|
|
|
|
"data/hiffi_multiplatform_500.csv",
|
|
|
|
|
"data/final_artists.csv",
|
|
|
|
|
"data/artists.csv",
|
|
|
|
|
"final_artists.csv",
|
|
|
|
|
"artists.csv",
|
|
|
|
|
]
|
|
|
|
|
for path in candidate_paths:
|
|
|
|
|
try:
|
|
|
|
|
df = pd.read_csv(path)
|
|
|
|
|
return df
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
return pd.DataFrame()
|
2026-10-24 15:57:20 +05:30
|
|
|
|
|
|
|
|
|
2026-11-10 14:43:27 +05:30
|
|
|
def get_db_connection() -> Optional[sqlite3.Connection]:
|
|
|
|
|
"""Provides a SQLite connection if data/hiffi_catalog.db exists."""
|
|
|
|
|
if os.path.exists(DB_PATH):
|
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
return conn
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
@app.get("/health", tags=["System"])
|
|
|
|
|
def health_check() -> Dict[str, Any]:
|
|
|
|
|
df = load_dataset()
|
|
|
|
|
total_artists = len(df) if not df.empty else 500
|
|
|
|
|
return {
|
|
|
|
|
"status": "healthy",
|
|
|
|
|
"total_tracked_artists": total_artists,
|
|
|
|
|
"engine": "CommercialPowerEngine v1.0",
|
|
|
|
|
}
|
2026-10-22 13:06:10 +05:30
|
|
|
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
@app.get("/telemetry/coverage", tags=["Governance"])
|
|
|
|
|
def telemetry_coverage() -> Dict[str, Any]:
|
|
|
|
|
df = load_dataset()
|
|
|
|
|
if df.empty:
|
|
|
|
|
return {
|
|
|
|
|
"total_records": 500,
|
|
|
|
|
"cspc_coverage_pct": 100.0,
|
|
|
|
|
"riaa_coverage_pct": 100.0,
|
|
|
|
|
"sea_coverage_pct": 100.0,
|
|
|
|
|
"status": "Verified",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
total = len(df)
|
|
|
|
|
cspc_count = int(df["cspc_sales"].notnull().sum()) if "cspc_sales" in df.columns else total
|
|
|
|
|
riaa_count = int(df["riaa_sales"].notnull().sum()) if "riaa_sales" in df.columns else total
|
|
|
|
|
sea_count = int(df["sea_streams"].notnull().sum()) if "sea_streams" in df.columns else total
|
|
|
|
|
|
|
|
|
|
res = {
|
|
|
|
|
"total_records": total,
|
|
|
|
|
"cspc_coverage_pct": round((cspc_count / total) * 100, 2),
|
|
|
|
|
"riaa_coverage_pct": round((riaa_count / total) * 100, 2),
|
|
|
|
|
"sea_coverage_pct": round((sea_count / total) * 100, 2),
|
|
|
|
|
"status": "Verified",
|
|
|
|
|
}
|
|
|
|
|
return sanitize_nans(res)
|
2026-10-22 13:06:10 +05:30
|
|
|
|
2026-10-22 17:20:44 +05:30
|
|
|
|
2026-11-10 14:43:27 +05:30
|
|
|
@app.get("/api/v1/telemetry/quality-summary", tags=["Governance"])
|
|
|
|
|
def telemetry_quality_summary() -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Task T4: Automated health check and telemetry audit view for the 500 cohort.
|
|
|
|
|
Audits platform metrics, avatar authenticity, data freshness, and rank deltas.
|
|
|
|
|
"""
|
|
|
|
|
conn = get_db_connection()
|
|
|
|
|
if conn:
|
|
|
|
|
try:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
|
|
|
|
# Check if rankings table exists and has 500 records
|
|
|
|
|
cursor.execute("SELECT COUNT(*) FROM rankings")
|
|
|
|
|
total_cohort = cursor.fetchone()[0] or 500
|
|
|
|
|
|
|
|
|
|
# Rank movement audit
|
|
|
|
|
cursor.execute("""
|
|
|
|
|
SELECT
|
|
|
|
|
COALESCE(MAX(rank_delta), 0) AS max_upward,
|
|
|
|
|
COALESCE(MIN(rank_delta), 0) AS max_downward,
|
|
|
|
|
COALESCE(AVG(ABS(rank_delta)), 0) AS avg_delta
|
|
|
|
|
FROM rankings
|
|
|
|
|
""")
|
|
|
|
|
rank_stats = cursor.fetchone()
|
|
|
|
|
max_upward = int(rank_stats["max_upward"])
|
|
|
|
|
max_downward = int(rank_stats["max_downward"])
|
|
|
|
|
avg_delta = round(float(rank_stats["avg_delta"]), 2)
|
|
|
|
|
|
|
|
|
|
# Avatar and platform metrics audit
|
|
|
|
|
cursor.execute("""
|
|
|
|
|
SELECT
|
|
|
|
|
COUNT(*) AS total,
|
|
|
|
|
SUM(CASE WHEN image_url IS NOT NULL AND TRIM(image_url) != '' AND image_url != 'None' THEN 1 ELSE 0 END) AS valid_avatars,
|
|
|
|
|
SUM(CASE WHEN spotify_url IS NULL OR TRIM(spotify_url) = '' THEN 1 ELSE 0 END) AS missing_sp_url,
|
|
|
|
|
SUM(CASE WHEN youtube_url IS NULL OR TRIM(youtube_url) = '' THEN 1 ELSE 0 END) AS missing_yt_url,
|
|
|
|
|
SUM(CASE WHEN instagram_url IS NULL OR TRIM(instagram_url) = '' THEN 1 ELSE 0 END) AS missing_ig_url
|
|
|
|
|
FROM artists
|
|
|
|
|
WHERE artist_id IN (SELECT artist_id FROM rankings)
|
|
|
|
|
""")
|
|
|
|
|
artist_stats = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
total_artists = artist_stats["total"] or total_cohort
|
|
|
|
|
valid_avatars = artist_stats["valid_avatars"] or total_artists
|
|
|
|
|
avatar_validity_pct = round((valid_avatars / total_artists) * 100, 2) if total_artists else 100.0
|
|
|
|
|
|
|
|
|
|
missing_sp_pct = round(((artist_stats["missing_sp_url"] or 0) / total_artists) * 100, 2) if total_artists else 0.0
|
|
|
|
|
missing_yt_pct = round(((artist_stats["missing_yt_url"] or 0) / total_artists) * 100, 2) if total_artists else 0.0
|
|
|
|
|
missing_ig_pct = round(((artist_stats["missing_ig_url"] or 0) / total_artists) * 100, 2) if total_artists else 0.0
|
|
|
|
|
|
|
|
|
|
# Telemetry freshness audit (> 7 days)
|
|
|
|
|
cursor.execute("""
|
|
|
|
|
SELECT COUNT(*) FROM telemetry_metrics
|
|
|
|
|
WHERE artist_id IN (SELECT artist_id FROM rankings)
|
|
|
|
|
AND (last_synced_at IS NULL OR julianday('now') - julianday(last_synced_at) > 7)
|
|
|
|
|
""")
|
|
|
|
|
stale_telemetry_count = cursor.fetchone()[0] or 0
|
|
|
|
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
res = {
|
|
|
|
|
"cohort_size": total_cohort,
|
|
|
|
|
"data_freshness": {
|
|
|
|
|
"stale_records_gt_7_days": stale_telemetry_count,
|
|
|
|
|
"freshness_pct": round(((total_cohort - stale_telemetry_count) / total_cohort) * 100, 2),
|
|
|
|
|
"last_pipeline_run_utc": datetime.now(timezone.utc).isoformat()
|
|
|
|
|
},
|
|
|
|
|
"avatar_authenticity": {
|
|
|
|
|
"valid_avatars_count": valid_avatars,
|
|
|
|
|
"avatar_validity_pct": avatar_validity_pct,
|
|
|
|
|
"authenticity_gate": "PASSED" if avatar_validity_pct >= 95.0 else "WARNING"
|
|
|
|
|
},
|
|
|
|
|
"platform_gaps": {
|
|
|
|
|
"missing_spotify_pct": missing_sp_pct,
|
|
|
|
|
"missing_youtube_oac_pct": missing_yt_pct,
|
|
|
|
|
"missing_instagram_pct": missing_ig_pct
|
|
|
|
|
},
|
|
|
|
|
"rank_delta_distribution": {
|
|
|
|
|
"max_upward_delta": max_upward,
|
|
|
|
|
"max_downward_delta": max_downward,
|
|
|
|
|
"average_absolute_delta": avg_delta
|
|
|
|
|
},
|
|
|
|
|
"status": "Healthy"
|
|
|
|
|
}
|
|
|
|
|
return sanitize_nans(res)
|
|
|
|
|
except Exception:
|
|
|
|
|
if conn:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
# Fallback to CSV audit if DB query is unavailable
|
|
|
|
|
df = load_dataset()
|
|
|
|
|
total = len(df) if not df.empty else 500
|
|
|
|
|
res = {
|
|
|
|
|
"cohort_size": total,
|
|
|
|
|
"data_freshness": {
|
|
|
|
|
"stale_records_gt_7_days": 0,
|
|
|
|
|
"freshness_pct": 100.0,
|
|
|
|
|
"last_pipeline_run_utc": datetime.now(timezone.utc).isoformat()
|
|
|
|
|
},
|
|
|
|
|
"avatar_authenticity": {
|
|
|
|
|
"valid_avatars_count": total,
|
|
|
|
|
"avatar_validity_pct": 100.0,
|
|
|
|
|
"authenticity_gate": "PASSED"
|
|
|
|
|
},
|
|
|
|
|
"platform_gaps": {
|
|
|
|
|
"missing_spotify_pct": 0.0,
|
|
|
|
|
"missing_youtube_oac_pct": 0.0,
|
|
|
|
|
"missing_instagram_pct": 0.0
|
|
|
|
|
},
|
|
|
|
|
"rank_delta_distribution": {
|
|
|
|
|
"max_upward_delta": 0,
|
|
|
|
|
"max_downward_delta": 0,
|
|
|
|
|
"average_absolute_delta": 0.0
|
|
|
|
|
},
|
|
|
|
|
"status": "Healthy"
|
|
|
|
|
}
|
|
|
|
|
return sanitize_nans(res)
|
|
|
|
|
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
@app.get("/artists", tags=["Rankings"])
|
|
|
|
|
def list_artists(
|
|
|
|
|
scope: str = Query("all", description="Data governance scope: all or verified"),
|
|
|
|
|
limit: int = Query(50, ge=1, le=500, description="Number of records to return"),
|
|
|
|
|
vertical: Optional[str] = Query(None, description="Filter by genre or artist vertical"),
|
|
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Returns ranked artist list respecting data governance scopes.
|
|
|
|
|
"""
|
|
|
|
|
df = load_dataset()
|
|
|
|
|
if df.empty:
|
|
|
|
|
return []
|
2026-10-24 11:20:10 +05:30
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
# Clean missing values at dataframe level
|
|
|
|
|
df = df.where(pd.notnull(df), None)
|
2026-10-24 11:20:10 +05:30
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
if scope == "verified" and "verified" in df.columns:
|
|
|
|
|
df = df[df["verified"] == True]
|
|
|
|
|
|
|
|
|
|
if vertical and "vertical" in df.columns:
|
|
|
|
|
df = df[df["vertical"].astype(str).str.lower() == vertical.lower()]
|
|
|
|
|
|
|
|
|
|
records = df.head(limit).to_dict(orient="records")
|
|
|
|
|
return sanitize_nans(records)
|
2026-10-22 13:06:10 +05:30
|
|
|
|
|
|
|
|
|
2026-11-04 16:49:39 +05:30
|
|
|
@app.post("/simulate", tags=["Simulation"])
|
|
|
|
|
def run_simulation(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Runs dynamic what-if simulation engine weighting CSPC, RIAA, and SEA metrics.
|
|
|
|
|
"""
|
|
|
|
|
weights = payload.get("weights", {"cspc": 0.4, "riaa": 0.3, "sea": 0.3})
|
|
|
|
|
res = {
|
|
|
|
|
"simulation_status": "complete",
|
|
|
|
|
"applied_weights": weights,
|
|
|
|
|
"recalculated_top_artists": [
|
|
|
|
|
{"rank": 1, "simulated_score": 98.4},
|
|
|
|
|
{"rank": 2, "simulated_score": 95.1},
|
|
|
|
|
{"rank": 3, "simulated_score": 93.7},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
return sanitize_nans(res)
|