Skip to content

Commit 102d3ed

Browse files
committed
feat(scripts): add prod vs staging parity comparison utility
Adds a standalone script to compare production and staging database snapshots and emit structured parity summaries for operational review.
1 parent 5bfe5a2 commit 102d3ed

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Compare OpenCRE **production** vs **staging** SQL snapshots using the same engine as
4+
``scripts/benchmark_import_parity.py`` (structural graph parity + content samples).
5+
6+
- **Imported** side (first DB) = staging — pass ``--staging-db``.
7+
- **Upstream** side (second DB) = production — pass ``--prod-db``.
8+
9+
Connection strings may be ``sqlite:///...`` or ``postgresql://...`` (SQLAlchemy URLs).
10+
11+
Environment defaults (after loading ``<repo>/.env`` if present):
12+
``STAGING_DATABASE_URL``, ``PROD_DATABASE_URL`` (Heroku: ``heroku config:get DATABASE_URL -a <app>``).
13+
Existing shell variables override ``.env`` (``override=False``).
14+
15+
Relationship noise: internal CRE–CRE edges classify *reversed-only* flips separately from
16+
true add/remove (see ``internal_edge_remodeling`` in the report).
17+
18+
Optional: Vertex AI Gemini summary (needs ADC or service account; project from
19+
``GOOGLE_CLOUD_PROJECT`` / ``GOOGLE_PROJECT_ID``).
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import argparse
25+
import importlib.util
26+
import json
27+
import os
28+
import sys
29+
from pathlib import Path
30+
from typing import Any, Dict, Optional
31+
32+
_REPO_ROOT = Path(__file__).resolve().parents[1]
33+
34+
35+
def _load_dotenv_at_startup() -> None:
36+
"""Populate os.environ from repo ``.env`` before argparse reads defaults."""
37+
try:
38+
from dotenv import load_dotenv
39+
except ImportError:
40+
return
41+
path = _REPO_ROOT / ".env"
42+
if path.is_file():
43+
load_dotenv(path, override=False)
44+
45+
46+
def _load_parity_module():
47+
path = _REPO_ROOT / "scripts" / "benchmark_import_parity.py"
48+
spec = importlib.util.spec_from_file_location("benchmark_import_parity", path)
49+
assert spec and spec.loader
50+
mod = importlib.util.module_from_spec(spec)
51+
spec.loader.exec_module(mod)
52+
return mod
53+
54+
55+
def _gemini_summary(report: Dict[str, Any], model_name: str) -> str:
56+
try:
57+
import vertexai # type: ignore
58+
from vertexai.generative_models import GenerativeModel # type: ignore
59+
except ImportError as e:
60+
return f"(Gemini skipped: {e})"
61+
62+
project = os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get(
63+
"GOOGLE_PROJECT_ID"
64+
)
65+
if not project:
66+
return "(Gemini skipped: set GOOGLE_CLOUD_PROJECT or GOOGLE_PROJECT_ID)"
67+
68+
location = os.environ.get("VERTEX_LOCATION", "us-central1")
69+
vertexai.init(project=project, location=location)
70+
model = GenerativeModel(model_name)
71+
72+
payload = json.dumps(report, indent=2, default=str)
73+
if len(payload) > 120_000:
74+
payload = payload[:120_000] + "\n…(truncated for model context)…"
75+
76+
prompt = f"""You are helping engineers compare OpenCRE database snapshots.
77+
78+
Context:
79+
- "imported" / staging data is the first database (typically staging.opencre.org).
80+
- "upstream" / production data is the second database (typically opencre.org).
81+
82+
The JSON uses the parity engine from benchmark_import_parity:
83+
- internal_edge_remodeling.reversed_only = CRE–CRE edges that exist in both DBs but with opposite direction (usually NOT meaningful for product decisions).
84+
- internal_edge_remodeling.type_changed = same ordered pair but link type set differs (meaningful).
85+
- internal_edge_remodeling.true_add_remove = connectivity that only exists on one side (meaningful).
86+
- cre_node_edges = links between CREs and standards/tools/code nodes (meaningful if counts non-zero).
87+
88+
Write:
89+
1) A short executive summary (2–6 sentences).
90+
2) Bullet list: **Meaningful differences** (structural / missing nodes / GA payload drift / property diffs).
91+
3) Bullet list: **Likely benign / noise** (e.g. reversed-only internal edges, cosmetic fields if obvious).
92+
4) **Risk level**: Low / Medium / High and one-line rationale.
93+
94+
JSON report:
95+
{payload}
96+
"""
97+
resp = model.generate_content(prompt)
98+
text = getattr(resp, "text", None)
99+
if text:
100+
return text
101+
try:
102+
return resp.candidates[0].content.parts[0].text # type: ignore[index]
103+
except Exception:
104+
return str(resp)
105+
106+
107+
def main() -> None:
108+
_load_dotenv_at_startup()
109+
parser = argparse.ArgumentParser(
110+
description="Compare prod vs staging OpenCRE DBs (parity engine + optional Gemini summary)."
111+
)
112+
parser.add_argument(
113+
"--staging-db",
114+
default=os.environ.get("STAGING_DATABASE_URL", ""),
115+
help="Staging DB URL (default: env STAGING_DATABASE_URL)",
116+
)
117+
parser.add_argument(
118+
"--prod-db",
119+
default=os.environ.get("PROD_DATABASE_URL", ""),
120+
help="Production DB URL (default: env PROD_DATABASE_URL)",
121+
)
122+
parser.add_argument(
123+
"--log-file",
124+
default="prod-staging-parity.log",
125+
help="Full parity log (same format as benchmark_import_parity)",
126+
)
127+
parser.add_argument(
128+
"--json-out",
129+
default="prod-staging-summary.json",
130+
help="Write structured summary JSON for CI or review",
131+
)
132+
parser.add_argument(
133+
"--skip-gemini",
134+
action="store_true",
135+
help="Do not call Vertex Gemini",
136+
)
137+
parser.add_argument(
138+
"--gemini-model",
139+
default=os.environ.get("GEMINI_SUMMARY_MODEL", "gemini-2.0-flash-001"),
140+
help="Vertex model id (default: gemini-2.0-flash-001)",
141+
)
142+
args = parser.parse_args()
143+
144+
if not args.staging_db.strip():
145+
print(
146+
"ERROR: --staging-db or STAGING_DATABASE_URL is required.\n"
147+
"Example: export STAGING_DATABASE_URL=$(heroku config:get DATABASE_URL -a opencre-staging)",
148+
file=sys.stderr,
149+
)
150+
sys.exit(2)
151+
if not args.prod_db.strip():
152+
print(
153+
"ERROR: --prod-db or PROD_DATABASE_URL is required.\n"
154+
"Example: export PROD_DATABASE_URL=$(heroku config:get DATABASE_URL -a opencreorg)",
155+
file=sys.stderr,
156+
)
157+
sys.exit(2)
158+
159+
parity = _load_parity_module()
160+
summary: Dict[str, Any] = {}
161+
ok = parity.diff_databases(
162+
args.staging_db,
163+
args.prod_db,
164+
args.log_file,
165+
summary_out=summary,
166+
)
167+
168+
envelope: Dict[str, Any] = {
169+
"legend": {
170+
"imported_side": "staging (first DB argument)",
171+
"upstream_side": "production (second DB argument)",
172+
"staging_db": args.staging_db,
173+
"prod_db": args.prod_db,
174+
},
175+
"parity_engine": summary,
176+
"fundamental_structural_match": ok,
177+
}
178+
179+
Path(args.json_out).write_text(
180+
json.dumps(envelope, indent=2, default=str), encoding="utf-8"
181+
)
182+
print(f"Wrote structured summary: {args.json_out}")
183+
print(f"Wrote full parity log: {args.log_file}")
184+
185+
gemini_text: Optional[str] = None
186+
if not args.skip_gemini:
187+
print("Calling Gemini for executive summary…")
188+
gemini_text = _gemini_summary(envelope, args.gemini_model)
189+
out_md = Path(args.json_out).with_suffix(".gemini.md")
190+
out_md.write_text(gemini_text or "", encoding="utf-8")
191+
print(f"Wrote Gemini summary: {out_md}")
192+
print("\n--- Gemini summary ---\n")
193+
print(gemini_text or "")
194+
else:
195+
print(
196+
"(Gemini skipped; use compare_prod_staging_data.py without --skip-gemini)"
197+
)
198+
199+
sys.exit(0 if ok else 1)
200+
201+
202+
if __name__ == "__main__":
203+
main()

0 commit comments

Comments
 (0)