vicfeuga's picture
First upload
4a09f2e verified
Raw
History Blame Contribute Delete
13.7 kB
"""
SQLite-backed database layer for the Hugging Face Space demo.
Mirrors the original Aurora PostgreSQL API surface (`get_metrics_distinct_values`,
`get_metrics_from_db_filtered`, `load_param_from_db`) so the agents and tools can be
re-used unchanged, but talks to a single-file `demo.sqlite` instead of a cloud DB.
"""
from __future__ import annotations
import os
import re
import sqlite3
from pathlib import Path
from typing import Iterable
import pandas as pd
HERE = Path(__file__).resolve().parent
DB_PATH = Path(os.environ.get("DEMO_DB_PATH", HERE / "demo.sqlite"))
# Column order matches the original sentinel_metrics CSV/Postgres schema.
METRICS_TABLE_COLUMNS = [
"Period",
"Region",
"Level",
"Product",
"Country",
"Cluster",
"TA Market",
"Class",
"Current_Value",
"Current_Value_MS",
"Value_MS_Change",
"Current_Value_Rank",
"Value_Rank_Change",
"Value_Growth",
"Value_Trend_Reversal",
"Current_Volume",
"Current_Volume_MS",
"Volume_MS_Change",
"Current_Volume_Rank",
"Volume_Rank_Change",
"Volume_Growth",
"Volume_Trend_Reversal",
"ASP_Growth",
"Calculation_Type",
]
# SQLite uses lowercase identifiers (no quoting headache) and we rename back when
# returning DataFrames to the agents.
_DF_TO_DB_COL: dict[str, str] = {
"Period": "period",
"Region": "region",
"Level": "level",
"Product": "product",
"Country": "country",
"Cluster": "cluster",
"TA Market": "ta_market",
"Class": "class",
"Current_Value": "current_value",
"Current_Value_MS": "current_value_ms",
"Value_MS_Change": "value_ms_change",
"Current_Value_Rank": "current_value_rank",
"Value_Rank_Change": "value_rank_change",
"Value_Growth": "value_growth",
"Value_Trend_Reversal": "value_trend_reversal",
"Current_Volume": "current_volume",
"Current_Volume_MS": "current_volume_ms",
"Volume_MS_Change": "volume_ms_change",
"Current_Volume_Rank": "current_volume_rank",
"Volume_Rank_Change": "volume_rank_change",
"Volume_Growth": "volume_growth",
"Volume_Trend_Reversal": "volume_trend_reversal",
"ASP_Growth": "asp_growth",
"Calculation_Type": "calculation_type",
}
_DB_TO_DF_COL: dict[str, str] = {v: k for k, v in _DF_TO_DB_COL.items()}
_METRICS_DDL = """
CREATE TABLE IF NOT EXISTS sentinel_metrics (
period TEXT,
region TEXT,
level TEXT,
product TEXT,
country TEXT,
cluster TEXT,
ta_market TEXT,
class TEXT,
current_value REAL,
current_value_ms REAL,
value_ms_change REAL,
current_value_rank INTEGER,
value_rank_change INTEGER,
value_growth REAL,
value_trend_reversal REAL,
current_volume REAL,
current_volume_ms REAL,
volume_ms_change REAL,
current_volume_rank INTEGER,
volume_rank_change INTEGER,
volume_growth REAL,
volume_trend_reversal REAL,
asp_growth REAL,
calculation_type TEXT
);
"""
_METRICS_INDEXES = (
"CREATE INDEX IF NOT EXISTS idx_metrics_region ON sentinel_metrics(region);",
"CREATE INDEX IF NOT EXISTS idx_metrics_period ON sentinel_metrics(period);",
"CREATE INDEX IF NOT EXISTS idx_metrics_cluster ON sentinel_metrics(cluster);",
"CREATE INDEX IF NOT EXISTS idx_metrics_product ON sentinel_metrics(product);",
"CREATE INDEX IF NOT EXISTS idx_metrics_calc ON sentinel_metrics(calculation_type);",
"CREATE INDEX IF NOT EXISTS idx_metrics_class ON sentinel_metrics(class);",
)
# Parameter tables — same key names as the original code so callers don't need to change.
PARAM_TABLE_MAP: dict[str, str] = {
"cluster_mapping": "sentinel_cluster_mapping",
"market_summary_mapping": "sentinel_market_summary_mapping",
"threshold_matrix": "sentinel_threshold_matrix",
"country_region_mapping": "sentinel_country_region_mapping",
"merck_products": "sentinel_company_products", # legacy key kept for parameter_tools compatibility
"company_products": "sentinel_company_products",
}
_PARAM_DDL: dict[str, str] = {
"sentinel_cluster_mapping": """
CREATE TABLE IF NOT EXISTS sentinel_cluster_mapping (
country TEXT,
market TEXT,
cluster TEXT,
region TEXT
);
""",
"sentinel_market_summary_mapping": """
CREATE TABLE IF NOT EXISTS sentinel_market_summary_mapping (
region TEXT,
market TEXT,
value_summary TEXT,
volume_summary TEXT,
timeframe_summary TEXT
);
""",
"sentinel_threshold_matrix": """
CREATE TABLE IF NOT EXISTS sentinel_threshold_matrix (
cluster TEXT,
market TEXT,
value_ms_change REAL,
value_growth REAL,
volume_ms_change REAL,
volume_growth REAL,
asp_growth REAL,
region TEXT
);
""",
"sentinel_country_region_mapping": """
CREATE TABLE IF NOT EXISTS sentinel_country_region_mapping (
country_code TEXT,
country_name TEXT,
region TEXT
);
""",
"sentinel_company_products": """
CREATE TABLE IF NOT EXISTS sentinel_company_products (
product TEXT
);
""",
}
def _connect() -> sqlite3.Connection:
"""Open a SQLite connection with row factory and foreign keys enabled."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA journal_mode = WAL;")
return conn
def create_all_tables() -> None:
"""Create the metrics table, indexes, and all parameter tables if missing."""
with _connect() as conn:
conn.execute(_METRICS_DDL)
for stmt in _METRICS_INDEXES:
conn.execute(stmt)
for ddl in _PARAM_DDL.values():
conn.execute(ddl)
conn.commit()
def db_is_seeded() -> bool:
"""Return True when the metrics table has at least one row."""
if not DB_PATH.exists():
return False
try:
with _connect() as conn:
row = conn.execute("SELECT COUNT(1) FROM sentinel_metrics;").fetchone()
return bool(row and row[0] > 0)
except sqlite3.OperationalError:
return False
# ---------------------------------------------------------------------------
# Metrics queries
# ---------------------------------------------------------------------------
def _rename_db_to_df(df: pd.DataFrame) -> pd.DataFrame:
"""Rename SQLite (lowercase) columns back to the canonical DataFrame names."""
return df.rename(columns=_DB_TO_DF_COL)
def _period_sort_key(period: str) -> tuple[int, int]:
match = re.match(r"^\s*(\d{2})\s*Q([1-4])\s*$", str(period or ""), re.IGNORECASE)
return (int(match.group(1)), int(match.group(2))) if match else (-1, -1)
def get_metrics_distinct_values() -> dict:
"""
Return distinct values per filterable column in sentinel_metrics.
Also returns ``_region_latest_period``: a dict mapping each region to its most
recent period, used by the filter extractor to default smartly when the user
doesn't specify a period.
"""
if not DB_PATH.exists():
return {}
with _connect() as conn:
cols_lower = ["region", "period", "cluster", "product", "calculation_type",
"ta_market", "class", "level"]
result: dict = {}
for db_col in cols_lower:
df_col = _DB_TO_DF_COL[db_col]
rows = conn.execute(
f"SELECT DISTINCT {db_col} FROM sentinel_metrics "
f"WHERE {db_col} IS NOT NULL ORDER BY {db_col};"
).fetchall()
result[df_col] = [str(r[0]) for r in rows if r[0] is not None]
region_period_rows = conn.execute(
"SELECT region, period FROM sentinel_metrics "
"WHERE region IS NOT NULL AND period IS NOT NULL "
"GROUP BY region, period;"
).fetchall()
region_periods: dict[str, list[str]] = {}
for region, period in region_period_rows:
region_periods.setdefault(str(region), []).append(str(period))
region_latest: dict[str, str] = {
r: sorted(ps, key=_period_sort_key)[-1] for r, ps in region_periods.items() if ps
}
result["_region_latest_period"] = region_latest
return result
_FILTER_COL_TO_DB: dict[str, str] = {
"Region": "region",
"Period": "period",
"Cluster": "cluster",
"Product": "product",
"Calculation_Type": "calculation_type",
"TA Market": "ta_market",
"Class": "class",
"Level": "level",
}
def get_metrics_from_db_filtered(filters: dict) -> pd.DataFrame:
"""
Return the sentinel_metrics rows that match *filters*.
*filters* keys must be DataFrame column names (e.g. ``Region``, ``Period``,
``Cluster``, ``Product``, ``Calculation_Type``, ``TA Market``, ``Class``).
Unrecognised keys are silently ignored. Empty *filters* returns all rows.
"""
where_clauses: list[str] = []
params: list = []
for col, filter_values in (filters or {}).items():
db_col = _FILTER_COL_TO_DB.get(col)
if not db_col or not filter_values:
continue
clean = [str(v).strip() for v in filter_values if str(v).strip()]
if col == "Period":
clean = [v.replace(" ", "") for v in clean]
if not clean:
continue
placeholders = ", ".join(["?"] * len(clean))
where_clauses.append(f"{db_col} IN ({placeholders})")
params.extend(clean)
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
query = f"SELECT * FROM sentinel_metrics {where_sql};"
with _connect() as conn:
df = pd.read_sql_query(query, conn, params=params if params else None)
df = _rename_db_to_df(df)
# Synthesize Cluster from Country/Region when missing (kept for backward compat
# with the original Postgres helper, even though our schema always populates it).
if "Cluster" not in df.columns:
if {"Level", "Country", "Region"}.issubset(df.columns):
df["Cluster"] = df.apply(
lambda r: r["Country"] if r["Level"] == "Country" else r["Region"], axis=1
)
elif "Country" in df.columns:
df["Cluster"] = df["Country"].fillna("").astype(str)
return df
# ---------------------------------------------------------------------------
# Parameter tables
# ---------------------------------------------------------------------------
def load_param_from_db(key: str) -> pd.DataFrame:
"""
Load a parameter table by its logical key.
Keys: ``cluster_mapping``, ``market_summary_mapping``, ``threshold_matrix``,
``country_region_mapping``, ``merck_products`` (alias of ``company_products``).
Returns a DataFrame matching the original CSV column casing.
"""
if key not in PARAM_TABLE_MAP:
raise ValueError(
f"Unknown parameter key: {key!r}. Valid keys: {sorted(set(PARAM_TABLE_MAP))}"
)
table = PARAM_TABLE_MAP[key]
with _connect() as conn:
df = pd.read_sql_query(f"SELECT * FROM {table};", conn)
if key == "threshold_matrix":
df = df.rename(columns={
"cluster": "Cluster",
"value_ms_change": "Value_MS_Change",
"value_growth": "Value_Growth",
"volume_ms_change": "Volume_MS_Change",
"volume_growth": "Volume_Growth",
"asp_growth": "ASP_Growth",
})
return df
# ---------------------------------------------------------------------------
# Bulk insert helpers (used by seed_data.py)
# ---------------------------------------------------------------------------
def replace_param_table(key: str, df: pd.DataFrame) -> int:
"""Replace the contents of a parameter table with *df*. Returns rows inserted."""
if key not in PARAM_TABLE_MAP:
raise ValueError(f"Unknown parameter key: {key!r}")
table = PARAM_TABLE_MAP[key]
df = df.copy()
df.columns = [c.lower() for c in df.columns]
with _connect() as conn:
conn.execute(f"DELETE FROM {table};")
df.to_sql(table, conn, if_exists="append", index=False)
conn.commit()
return len(df)
def insert_metrics(df: pd.DataFrame) -> int:
"""Insert metrics rows (DataFrame uses mixed-case column names)."""
if df is None or df.empty:
return 0
db_df = df.rename(columns=_DF_TO_DB_COL)
keep_cols = [c for c in _DF_TO_DB_COL.values() if c in db_df.columns]
db_df = db_df[keep_cols]
with _connect() as conn:
db_df.to_sql("sentinel_metrics", conn, if_exists="append", index=False)
conn.commit()
return len(db_df)
def replace_metrics(df: pd.DataFrame) -> int:
"""Truncate the metrics table and insert *df*. Returns rows inserted."""
with _connect() as conn:
conn.execute("DELETE FROM sentinel_metrics;")
conn.commit()
return insert_metrics(df)