readthrough / deploy_check.py
Viney's picture
Claude Opus 5 (1M context)
chore: rename the project to Readthrough
4d22778
Raw History Blame Contribute Delete
10.8 kB
"""Read-only deployment preflight for the Readthrough Hugging Face Space."""
from __future__ import annotations
import sqlite3
import subprocess
import tempfile
from pathlib import Path
from typing import Callable, Iterable
from agent.company_profile import profile_source_coverage
REPO_ROOT = Path(__file__).resolve().parent
METRICS_DB = REPO_ROOT / "data" / "metrics.db"
class GitCommandError(RuntimeError):
"""A local, read-only git inspection command failed."""
def _run_git(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=REPO_ROOT,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
def git_status_data() -> str:
"""Return porcelain status for data/ without writing LFS objects to .git/."""
with tempfile.TemporaryDirectory(prefix="readthrough-deploy-check-lfs-") as tmp:
lfs_storage = Path(tmp)
(lfs_storage / "tmp").mkdir()
result = _run_git(
[
"-c",
f"lfs.storage={lfs_storage}",
"status",
"--porcelain",
"--",
"data/",
]
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or "unknown git error"
raise GitCommandError(f"git status --porcelain -- data/ failed: {detail}")
return result.stdout
def parse_data_status(porcelain_output: str) -> list[str]:
"""Extract changed paths from simulated or real porcelain-v1 output."""
paths: list[str] = []
for line in porcelain_output.splitlines():
if len(line) < 4 or line[2] != " ":
continue
path = line[3:]
if " -> " in path:
path = path.rsplit(" -> ", 1)[1]
paths.append(path.strip('"'))
return paths
def load_metric_tickers(db_path: Path = METRICS_DB) -> list[str]:
"""Read distinct metric tickers without allowing SQLite writes."""
database_uri = f"{db_path.resolve().as_uri()}?mode=ro"
with sqlite3.connect(database_uri, uri=True) as conn:
rows = conn.execute(
"""
SELECT DISTINCT UPPER(TRIM(ticker))
FROM metrics
WHERE ticker IS NOT NULL AND TRIM(ticker) <> ''
ORDER BY 1
"""
).fetchall()
return [row[0] for row in rows]
def collect_coverage(
tickers: Iterable[str],
coverage_fn: Callable[[str], dict] | None = None,
) -> tuple[list[dict], list[str]]:
"""Collect coverage rows and tickers that would show the backfill banner."""
check = coverage_fn or profile_source_coverage
rows: list[dict] = []
incomplete: list[str] = []
for ticker in tickers:
coverage = check(ticker)
row = {
"ticker": ticker,
"status": coverage.get("status", "UNKNOWN"),
"business_sections": coverage.get("business_sections", 0),
"segments_geography_sections": coverage.get(
"segments_geography_sections", 0
),
"recent_transcripts": coverage.get("recent_transcripts", 0),
}
rows.append(row)
if (
coverage.get("business_sections_attempted", 0) == 0
or coverage.get("segments_geography_sections_attempted", 0) == 0
):
incomplete.append(ticker)
return rows, incomplete
def format_coverage_table(rows: list[dict]) -> str:
headers = (
"ticker",
"status",
"business_sections",
"segments_geography_sections",
"recent_transcripts",
)
text_rows = [[str(row.get(header, "")) for header in headers] for row in rows]
widths = [
max(len(header), *(len(row[index]) for row in text_rows))
for index, header in enumerate(headers)
]
def render(values: Iterable[str]) -> str:
return " ".join(value.ljust(width) for value, width in zip(values, widths))
separator = " ".join("-" * width for width in widths)
return "\n".join([render(headers), separator, *(render(row) for row in text_rows)])
# ── Runtime cache coverage ────────────────────────────────────────────────────
# The Space serves the committed database, cache tables included. Whatever is
# warm at commit time is what production can answer with, so a cache entry can
# make a feature look healthy while the credential or endpoint it depends on is
# unreachable from the Space. This section makes that surface visible instead of
# incidental — see scripts/warm_runtime_cache.py.
# Feature → cache-key template. {t} is the upper-case ticker.
CACHE_FEATURES: list[tuple[str, str]] = [
("valuation", "VALUATION:v3:{t}"),
("events 18m", "FILING_EVENTS:v1:{t}:18"),
("events 36m", "FILING_EVENTS:v1:{t}:36"),
("work capital", "WORKING_CAPITAL:v1:{t}"),
("adj. EPS", "EARNINGS:{t}"),
]
# Secrets the Space needs at runtime. Their absence cannot be detected from
# here — a local .env says nothing about the deployment — so this is a
# checklist, deliberately not a pass/fail gate.
REQUIRED_SPACE_SECRETS: list[tuple[str, str]] = [
("ANTHROPIC_API_KEY", "brief generation and Ask AI"),
("ALPHAVANTAGE_API_KEY_1", "adjusted EPS, surprise history, transcripts"),
("TAVILY_API_KEY", "live news"),
]
def cached_keys(db_path: Path = METRICS_DB) -> set[str]:
"""Every cache key present in the shipped database."""
if not db_path.exists():
return set()
with sqlite3.connect(db_path) as conn:
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)}
if "av_cache" not in tables:
return set()
return {row[0] for row in conn.execute("SELECT cache_key FROM av_cache")}
def format_cache_table(tickers: list[str], keys: set[str]) -> str:
"""A ticker x feature grid of what production will be able to serve."""
headers = ["ticker"] + [name for name, _ in CACHE_FEATURES]
widths = [max(len(headers[0]), *(len(t) for t in tickers))] if tickers else [len(headers[0])]
widths += [len(name) for name, _ in CACHE_FEATURES]
lines = [" ".join(h.ljust(w) for h, w in zip(headers, widths))]
lines.append(" ".join("-" * w for w in widths))
for ticker in tickers:
cells = [ticker.ljust(widths[0])]
for (name, template), width in zip(CACHE_FEATURES, widths[1:]):
warm = template.format(t=ticker.upper()) in keys
cells.append(("warm" if warm else "cold").ljust(width))
lines.append(" ".join(cells))
return "\n".join(lines)
def resolve_ref(ref: str, *, optional: bool = False) -> str | None:
result = _run_git(["rev-parse", "--verify", ref])
if result.returncode == 0:
return result.stdout.strip()
if optional:
return None
detail = result.stderr.strip() or result.stdout.strip() or "ref not found"
raise GitCommandError(f"cannot resolve {ref}: {detail}")
def main() -> int:
failed = False
print("Readthrough Hugging Face deployment preflight")
print("============================================")
try:
changes = parse_data_status(git_status_data())
except GitCommandError as exc:
print(f"\nERROR: {exc}")
changes = []
failed = True
else:
if changes:
failed = True
print("\nERROR: uncommitted data/ changes detected:")
for path in changes:
print(f" - {path}")
print(
"The Space will serve the committed data snapshot, not this local "
"working-tree state."
)
else:
print("\nOK: no uncommitted data/ changes.")
try:
tickers = load_metric_tickers()
coverage_rows, incomplete = collect_coverage(tickers)
except (OSError, sqlite3.Error) as exc:
print(f"\nERROR: could not read coverage inputs: {exc}")
failed = True
else:
print("\nCompany Primer source coverage:")
print(format_coverage_table(coverage_rows))
if incomplete:
failed = True
joined = ", ".join(incomplete)
print(f"\nERROR: Company Primer coverage incomplete for: {joined}")
print(
'Once deployed, the Company Primer will show the "coverage incomplete" '
"banner for these tickers."
)
else:
print("\nOK: required Company Primer sections were attempted for every ticker.")
tickers_for_cache = load_metric_tickers()
if tickers_for_cache:
keys = cached_keys()
print("\nRuntime cache shipped in data/metrics.db:")
print(format_cache_table(tickers_for_cache, keys))
cold = [
f"{ticker} {name}"
for ticker in tickers_for_cache
for name, template in CACHE_FEATURES
if template.format(t=ticker.upper()) not in keys
]
if cold:
print(
f"\n{len(cold)} feature/ticker pair(s) ship cold. Production will "
"call the provider live for those, which is fine when the Space "
"can reach it and empty when it cannot."
)
print("Warm them with: python scripts/warm_runtime_cache.py")
else:
print("\nOK: every tracked feature ships warm for every ticker.")
print("\nSpace secrets required at runtime (cannot be verified from here):")
for name, purpose in REQUIRED_SPACE_SECRETS:
print(f" {name:24s} {purpose}")
print(
" Set these under Settings -> Variables and secrets on the Space.\n"
" A warm cache will mask a missing key until it expires."
)
try:
local_main = resolve_ref("main")
hf_main = resolve_ref("hf/main", optional=True)
except GitCommandError as exc:
print(f"\nERROR: {exc}")
failed = True
else:
print("\nLocal refs (no network fetch):")
print(f" main: {local_main}")
if hf_main is None:
print(" hf/main: not available locally (non-blocking)")
else:
print(f" hf/main: {hf_main}")
if local_main == hf_main:
print(" refs: identical")
else:
print(" refs: different (the local hf/main ref may be stale)")
if failed:
print("\nVERDICT: deployment preflight failed; do not push yet.")
return 1
print("\nVERDICT: deployment preflight passed.")
print("Deploy manually with: git push hf main:main")
return 0
if __name__ == "__main__":
raise SystemExit(main())