From 3a7604dec0d72a3ec2e9312e8f95531418e973f9 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 4 Aug 2026 20:03:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(P3):=20powerbi=20export=20=E2=80=94=20CSV/?= =?UTF-8?q?JSON=20views=20+=208=20placeholder=20views=20+=20data=20diction?= =?UTF-8?q?ary=20(REQ-190,199,208,209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 (Wave 2, feat) — REQ-190, REQ-199, REQ-208, REQ-209 New components: - core/metrics/powerbi_export.py — exports fact/dim tables + 8 placeholder views to CSV/JSON - tests/test_powerbi_export.py — 6 tests (all pass) - docs/METRICS_VIEWS.md — column-level data dictionary (REQ-209) - metrics/powerbi/NOVA_DASHBOARD_README.md — folder-connector import guide + starter visual model (REQ-208) 8 placeholder views (deferred metrics, headers only): - placeholder_live_infra_health (D-096) - placeholder_live_outbox_rate (D-096) - placeholder_tamper_evident_checkpoints (D-083) - placeholder_onboarding_funnel (D-113/D-114/D-119) - placeholder_drift_detection (D-096 + no scheduler) - placeholder_live_cur_reconciliation (D-096) - placeholder_sla_downtime (D-096) - placeholder_predictive_reactive (future emitter) D-120: Nova-native (CSV/JSON files, no live connector) D-129: PowerBI ingests via folder connector ---ci--- project: acdl phase: 3 milestone: v1.17 status: execute ---/ci--- --- core/metrics/powerbi_export.py | 198 +++++++++++++++++++++++ docs/METRICS_VIEWS.md | 143 ++++++++++++++++ metrics/powerbi/NOVA_DASHBOARD_README.md | 66 ++++++++ tests/test_powerbi_export.py | 91 +++++++++++ 4 files changed, 498 insertions(+) create mode 100644 core/metrics/powerbi_export.py create mode 100644 docs/METRICS_VIEWS.md create mode 100644 metrics/powerbi/NOVA_DASHBOARD_README.md create mode 100644 tests/test_powerbi_export.py diff --git a/core/metrics/powerbi_export.py b/core/metrics/powerbi_export.py new file mode 100644 index 0000000..585ed80 --- /dev/null +++ b/core/metrics/powerbi_export.py @@ -0,0 +1,198 @@ +"""Nova PowerBI Export (REQ-190, P3). + +Emits CSV/JSON views to metrics/powerbi/ from the SQLite cold store. +Fact + dimension tables + 8 empty placeholder views for deferred metrics +(with documented schemas ready to fill when their blocking decisions lift). + +D-120: Nova-native (CSV/JSON files, no live connector) +D-129: PowerBI ingests via the folder connector +D-128: metrics/ at repo root +""" + +import csv +import datetime +import json +import os +import sqlite3 +import sys + +_METRICS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "metrics") +_STORE_PATH = os.path.join(_METRICS_DIR, "nova_metrics.db") +_EXPORT_DIR = os.path.join(_METRICS_DIR, "powerbi") + +FACT_VIEWS = [ + "fact_run", + "fact_capability", + "fact_policy_check", + "fact_confidence", + "fact_test", + "fact_decision", + "fact_cost_estimate", + "fact_lifecycle", +] + +DIM_VIEWS = [ + "dim_capability", + "dim_milestone", +] + +PLACEHOLDER_VIEWS = { + "placeholder_live_infra_health": { + "columns": ["timestamp", "resource_id", "resource_type", "running_count", "healthy", "downtime_seconds"], + "blocking_decision": "D-096", + "description": "Live infrastructure health (ECS running count, ALB 5xx, RPS). Blocked: live AWS torn down.", + }, + "placeholder_live_outbox_rate": { + "columns": ["timestamp", "contract_id", "write_latency_ms", "append_count"], + "blocking_decision": "D-096", + "description": "Live outbox write rate / ledger append latency. Blocked: DynamoDB outbox table absent.", + }, + "placeholder_tamper_evident_checkpoints": { + "columns": ["timestamp", "checkpoint_id", "jws_signed", "object_lock_enabled"], + "blocking_decision": "D-083", + "description": "Tamper-evident ledger checkpoints / JWS signature rate. Blocked: S3 Object Lock + JWS deferred.", + }, + "placeholder_onboarding_funnel": { + "columns": ["timestamp", "consumer_repo", "requested_environment", "status", "granted_at"], + "blocking_decision": "D-113/D-114/D-119", + "description": "Onboarding funnel: requested → granted conversion. Blocked: no auto-grant event.", + }, + "placeholder_drift_detection": { + "columns": ["timestamp", "workspace_id", "drift_count", "auto_reverted", "detection_cycle"], + "blocking_decision": "D-096 + no scheduler", + "description": "Drift detection (scheduled terraform plan -detailed-exitcode). Blocked: live AWS + scheduler.", + }, + "placeholder_live_cur_reconciliation": { + "columns": ["timestamp", "resource_address", "actual_usd", "baseline_usd", "saved_usd"], + "blocking_decision": "D-096", + "description": "Live cost CUR reconciliation. Blocked: live AWS billing. Infracost pre-apply estimates are in fact_cost_estimate.", + }, + "placeholder_sla_downtime": { + "columns": ["timestamp", "service", "uptime_pct", "downtime_minutes", "slo_target"], + "blocking_decision": "D-096", + "description": "SLA / unplanned downtime. Blocked: needs live service uptime monitoring.", + }, + "placeholder_predictive_reactive": { + "columns": ["timestamp", "action_id", "label", "trigger", "count"], + "blocking_decision": "future emitter", + "description": "Predictive vs Reactive ratio. Blocked: requires ML anomaly-forecasting service.", + }, +} + + +def _iso8601_now(): + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _export_table_csv(conn, table_name, export_dir): + """Export a SQLite table to a CSV file.""" + rows = conn.execute(f"SELECT * FROM {table_name}").fetchall() + if not rows: + return 0 + columns = [desc[0] for desc in conn.execute(f"SELECT * FROM {table_name} LIMIT 0").description] + csv_path = os.path.join(export_dir, f"{table_name}.csv") + with open(csv_path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(columns) + writer.writerows(rows) + return len(rows) + + +def _export_table_json(conn, table_name, export_dir): + """Export a SQLite table to a JSON file.""" + rows = conn.execute(f"SELECT * FROM {table_name}").fetchall() + if not rows: + return 0 + columns = [desc[0] for desc in conn.execute(f"SELECT * FROM {table_name} LIMIT 0").description] + records = [dict(zip(columns, row)) for row in rows] + json_path = os.path.join(export_dir, f"{table_name}.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(records, f, indent=2, default=str) + return len(rows) + + +def _export_placeholder_csv(view_name, schema, export_dir): + """Export a placeholder CSV with headers only (no data rows).""" + csv_path = os.path.join(export_dir, f"{view_name}.csv") + with open(csv_path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(schema["columns"]) + return 0 + + +def _export_placeholder_json(view_name, schema, export_dir): + """Export a placeholder JSON with schema metadata (no data rows).""" + json_path = os.path.join(export_dir, f"{view_name}.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump({"schema": schema, "data": []}, f, indent=2) + return 0 + + +def export_all(store_path=None, export_dir=None, fmt="both"): + """Export all fact/dim tables + placeholder views to CSV and/or JSON. + + Args: + store_path: path to the SQLite cold store + export_dir: directory for exported files + fmt: "csv", "json", or "both" + + Returns: + Summary dict with export counts. + """ + if store_path is None: + store_path = _STORE_PATH + if export_dir is None: + export_dir = _EXPORT_DIR + os.makedirs(export_dir, exist_ok=True) + + summary = {"exported_at": _iso8601_now(), "fact_tables": {}, "dim_tables": {}, "placeholder_views": {}} + + if not os.path.isfile(store_path): + summary["error"] = f"SQLite store not found: {store_path}" + for view_name, schema in PLACEHOLDER_VIEWS.items(): + if fmt in ("csv", "both"): + _export_placeholder_csv(view_name, schema, export_dir) + if fmt in ("json", "both"): + _export_placeholder_json(view_name, schema, export_dir) + summary["placeholder_views"][view_name] = 0 + return summary + + conn = sqlite3.connect(store_path) + + for table in FACT_VIEWS: + count = 0 + try: + if fmt in ("csv", "both"): + count = _export_table_csv(conn, table, export_dir) + if fmt in ("json", "both"): + count = _export_table_json(conn, table, export_dir) + except sqlite3.OperationalError: + count = 0 + summary["fact_tables"][table] = count + + for table in DIM_VIEWS: + count = 0 + try: + if fmt in ("csv", "both"): + count = _export_table_csv(conn, table, export_dir) + if fmt in ("json", "both"): + count = _export_table_json(conn, table, export_dir) + except sqlite3.OperationalError: + count = 0 + summary["dim_tables"][table] = count + + conn.close() + + for view_name, schema in PLACEHOLDER_VIEWS.items(): + if fmt in ("csv", "both"): + _export_placeholder_csv(view_name, schema, export_dir) + if fmt in ("json", "both"): + _export_placeholder_json(view_name, schema, export_dir) + summary["placeholder_views"][view_name] = 0 + + return summary + + +if __name__ == "__main__": + result = export_all() + print(json.dumps(result, indent=2)) \ No newline at end of file diff --git a/docs/METRICS_VIEWS.md b/docs/METRICS_VIEWS.md new file mode 100644 index 0000000..855e4dd --- /dev/null +++ b/docs/METRICS_VIEWS.md @@ -0,0 +1,143 @@ +# Nova Metrics Views — PowerBI Data Dictionary + +> v1.17 — Strategic Direction, Leadership Metrics & Unified Story (REQ-190, REQ-209) +> Generated: 2026-08-04 + +This document is the column-level data dictionary for the PowerBI export +views in `metrics/powerbi/`. Each fact/dimension table and placeholder +view is documented with: column, type, source/formula, unit, and +grounded/derived/deferred status. + +## Fact tables (grounded) + +### fact_run +| Column | Type | Source | Unit | Status | +|--------|------|--------|------|--------| +| run_id | TEXT | run_manifest.py | — | grounded | +| contract_id | TEXT | run_manifest.py | — | grounded | +| environment | TEXT | run_manifest.py | dev/qa/prod/dr | grounded | +| started_at | TEXT | run_manifest.py | ISO8601 | grounded | +| completed_at | TEXT | run_manifest.py | ISO8601 | grounded | +| exit_code | INTEGER | run_manifest.py | — | grounded | +| outcome | TEXT | run_manifest.py | succeeded/failed | grounded | +| confidence_score | REAL | confidence_signal.py | 0.0–1.0 | grounded | +| confidence_band | TEXT | confidence_signal.py | pass/warn/block | grounded | +| hitl_block | INTEGER | hitl_gates.py | 0/1 | grounded | +| cost_estimate_usd | REAL | infracost_adapter.py | USD | grounded (Infracost) | +| decision_id | TEXT | decision_ledger.py | — | grounded | + +### fact_capability +| Column | Type | Source | Unit | Status | +|--------|------|--------|------|--------| +| capability_id | TEXT | REGRESSION_REPORT.json | CAP-NNN | grounded | +| run_id | TEXT | REGRESSION_REPORT.json | — | grounded | +| name | TEXT | REGRESSION_REPORT.json | — | grounded | +| status | TEXT | REGRESSION_REPORT.json | Verified/Decayed/Broken/Skipped | grounded | +| tier | TEXT | REGRESSION_REPORT.json | local/live-aws/lifecycle-pipeline | grounded | +| duration_ms | REAL | REGRESSION_REPORT.json | milliseconds | grounded | +| detail | TEXT | REGRESSION_REPORT.json | — | grounded | +| run_at_utc | TEXT | REGRESSION_REPORT.json | ISO8601 | grounded | + +### fact_decision +| Column | Type | Source | Unit | Status | +|--------|------|--------|------|--------| +| decision_id | TEXT | decision_ledger.py | = run_id | grounded | +| run_id | TEXT | decision_ledger.py | — | grounded | +| chosen_action | TEXT | confidence_signal.py | pass/warn/block | grounded | +| confidence | REAL | confidence_signal.py | 0.0–1.0 | grounded | +| alternatives | TEXT (JSON) | confidence_signal.py | perInput breakdown | grounded | +| human_override | INTEGER | hitl_gates.py | 0/1 | grounded | +| outcome | TEXT | decision_ledger.py | succeeded/failed/pending | grounded | +| event_time | TEXT | decision_ledger.py | ISO8601 | grounded | + +### fact_test +| Column | Type | Source | Unit | Status | +|--------|------|--------|------|--------| +| run_id | TEXT | junit XML | — | grounded | +| total_tests | INTEGER | junit XML | count | grounded | +| passed | INTEGER | junit XML | count | grounded | +| failed | INTEGER | junit XML | count | grounded | +| errors | INTEGER | junit XML | count | grounded | +| skipped | INTEGER | junit XML | count | grounded | +| duration_s | REAL | junit XML | seconds | grounded | +| coverage_pct | REAL | coverage.json | % | grounded | +| collected_at | TEXT | collector.py | ISO8601 | grounded | + +### fact_cost_estimate +| Column | Type | Source | Unit | Status | +|--------|------|--------|------|--------| +| run_id | TEXT | infracost_adapter.py | — | grounded | +| delta_usd | REAL | Infracost | USD/month | grounded (pre-apply) | +| total_monthly_usd | REAL | Infracost | USD/month | grounded (pre-apply) | +| available | INTEGER | infracost_adapter.py | 0/1 | grounded | +| estimated_at | TEXT | infracost_adapter.py | ISO8601 | grounded | + +### fact_lifecycle +| Column | Type | Source | Unit | Status | +|--------|------|--------|------|--------| +| module | TEXT | lifecycle report | — | grounded | +| environment | TEXT | lifecycle report | — | grounded | +| phase | TEXT | lifecycle report | apply/modify/destroy | grounded | +| result | TEXT | lifecycle report | pass/fail | grounded | +| duration_ms | REAL | lifecycle report | milliseconds | grounded | +| run_at | TEXT | lifecycle report | ISO8601 | grounded | + +## Dimension tables + +### dim_capability +| Column | Type | Source | Status | +|--------|------|--------|--------| +| capability_id | TEXT | REGRESSION_REPORT.json | grounded | +| name | TEXT | REGRESSION_REPORT.json | grounded | +| tier | TEXT | REGRESSION_REPORT.json | grounded | +| source_milestone | TEXT | REGRESSION_REPORT.json | grounded | + +### dim_milestone +| Column | Type | Source | Status | +|--------|------|--------|--------| +| milestone | TEXT | REGRESSION_REPORT.json | grounded | +| phase | INTEGER | REGRESSION_REPORT.json | grounded | +| tag | TEXT | — | grounded | +| completed_at | TEXT | REGRESSION_REPORT.json | grounded | + +## Placeholder views (deferred — 8 views, headers only, no data) + +### placeholder_live_infra_health +- **Blocking decision:** D-096 +- **Description:** Live infrastructure health (ECS running count, ALB 5xx, RPS) +- **Columns:** timestamp, resource_id, resource_type, running_count, healthy, downtime_seconds + +### placeholder_live_outbox_rate +- **Blocking decision:** D-096 +- **Description:** Live outbox write rate / ledger append latency +- **Columns:** timestamp, contract_id, write_latency_ms, append_count + +### placeholder_tamper_evident_checkpoints +- **Blocking decision:** D-083 +- **Description:** Tamper-evident ledger checkpoints / JWS signature rate +- **Columns:** timestamp, checkpoint_id, jws_signed, object_lock_enabled + +### placeholder_onboarding_funnel +- **Blocking decision:** D-113/D-114/D-119 +- **Description:** Onboarding funnel: requested → granted conversion +- **Columns:** timestamp, consumer_repo, requested_environment, status, granted_at + +### placeholder_drift_detection +- **Blocking decision:** D-096 + no scheduler +- **Description:** Drift detection (scheduled terraform plan -detailed-exitcode) +- **Columns:** timestamp, workspace_id, drift_count, auto_reverted, detection_cycle + +### placeholder_live_cur_reconciliation +- **Blocking decision:** D-096 +- **Description:** Live cost CUR reconciliation +- **Columns:** timestamp, resource_address, actual_usd, baseline_usd, saved_usd + +### placeholder_sla_downtime +- **Blocking decision:** D-096 +- **Description:** SLA / unplanned downtime +- **Columns:** timestamp, service, uptime_pct, downtime_minutes, slo_target + +### placeholder_predictive_reactive +- **Blocking decision:** future emitter +- **Description:** Predictive vs Reactive ratio +- **Columns:** timestamp, action_id, label, trigger, count \ No newline at end of file diff --git a/metrics/powerbi/NOVA_DASHBOARD_README.md b/metrics/powerbi/NOVA_DASHBOARD_README.md new file mode 100644 index 0000000..6752cc4 --- /dev/null +++ b/metrics/powerbi/NOVA_DASHBOARD_README.md @@ -0,0 +1,66 @@ +# Nova PowerBI Dashboard — Import Guide + +> v1.17 — Strategic Direction, Leadership Metrics & Unified Story (REQ-208) +> Generated: 2026-08-04 + +This guide documents how to import Nova's metrics views into PowerBI +via the folder connector, and suggests a starter visual model. + +## Import via folder connector + +1. Open PowerBI Desktop. +2. **Get Data** → **Folder** → navigate to `metrics/powerbi/`. +3. PowerBI discovers all CSV/JSON files in the folder. +4. Combine the files — PowerBI creates a single query per file. + +## Starter visual model + +### Suggested joins +- `fact_run` ←→ `fact_decision` on `run_id` (run-level decision path) +- `fact_run` ←→ `fact_cost_estimate` on `run_id` (run-level cost) +- `fact_capability` ←→ `dim_capability` on `capability_id` (capability lookup) +- `fact_capability` ←→ `dim_milestone` on `milestone` (milestone lookup) + +### Suggested visuals (4 starter visuals) + +1. **Capability Health over Time** — bar chart: `fact_capability.status` + grouped by `run_at_utc`. Shows Verified/Skipped/Broken/Decayed trend. + Source: `fact_capability.csv`. + +2. **Confidence Distribution** — histogram: `fact_confidence.score`. + Shows the distribution of confidence scores across all runs. + Source: `fact_confidence.csv`. + +3. **Decision Accuracy** — KPI card: count of `fact_decision` where + `outcome = 'succeeded'` ÷ total `fact_decision` rows. Shows AI + Decision Accuracy (NORTH_STAR target ≥99.5%). + Source: `fact_decision.csv`. + +4. **Cost Trend** — line chart: `fact_cost_estimate.delta_usd` over + `estimated_at`. Shows pre-apply cost estimate trend (Infracost). + Source: `fact_cost_estimate.csv`. + +## Placeholder views (deferred metrics) + +The 8 `placeholder_*.csv` files contain headers only (no data rows). +Each has a companion `placeholder_*.json` with the schema metadata +(columns, blocking decision, description). When the blocking decision +lifts (e.g., D-096 for live AWS), the collector will populate these +views and PowerBI will automatically pick up the data. + +## Data refresh + +The export is regenerated by running: +```bash +python3 core/metrics/collector.py # rebuilds nova_metrics.db +python3 core/metrics/powerbi_export.py # exports to metrics/powerbi/ +``` + +In PowerBI, click **Refresh** to pick up the updated CSV/JSON files. + +## Honesty model + +Every metric in the export is `grounded` (cites a source file), `derived` +(documented formula), or `deferred` (cites a blocking decision ID). See +`docs/METRICS.md` (P4) for the canonical catalog and `docs/METRICS_VIEWS.md` +for the column-level data dictionary. \ No newline at end of file diff --git a/tests/test_powerbi_export.py b/tests/test_powerbi_export.py new file mode 100644 index 0000000..9153c01 --- /dev/null +++ b/tests/test_powerbi_export.py @@ -0,0 +1,91 @@ +"""Tests for Nova PowerBI export (P3, REQ-190).""" + +import json +import os +import sqlite3 +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def tmp_export(tmp_path, monkeypatch): + metrics_dir = tmp_path / "metrics" + metrics_dir.mkdir() + export_dir = metrics_dir / "powerbi" + store_db = metrics_dir / "nova_metrics.db" + monkeypatch.setattr("core.metrics.powerbi_export._METRICS_DIR", str(metrics_dir)) + monkeypatch.setattr("core.metrics.powerbi_export._STORE_PATH", str(store_db)) + monkeypatch.setattr("core.metrics.powerbi_export._EXPORT_DIR", str(export_dir)) + return {"metrics_dir": metrics_dir, "store_db": store_db, "export_dir": export_dir} + + +def _init_store_with_data(db_path): + conn = sqlite3.connect(str(db_path)) + conn.executescript(""" + CREATE TABLE fact_run (run_id TEXT PRIMARY KEY, contract_id TEXT, environment TEXT, exit_code INTEGER); + CREATE TABLE dim_capability (capability_id TEXT PRIMARY KEY, name TEXT, tier TEXT); + INSERT INTO fact_run VALUES ('run-1', 'cid-1', 'dev', 0); + INSERT INTO dim_capability VALUES ('CAP-001', 'test cap', 'local'); + """) + conn.commit() + conn.close() + + +def test_export_csv(tmp_export): + from core.metrics.powerbi_export import export_all + _init_store_with_data(tmp_export["store_db"]) + result = export_all(fmt="csv") + assert (tmp_export["export_dir"] / "fact_run.csv").exists() + assert (tmp_export["export_dir"] / "dim_capability.csv").exists() + assert result["fact_tables"]["fact_run"] == 1 + + +def test_export_json(tmp_export): + from core.metrics.powerbi_export import export_all + _init_store_with_data(tmp_export["store_db"]) + result = export_all(fmt="json") + assert (tmp_export["export_dir"] / "fact_run.json").exists() + data = json.loads((tmp_export["export_dir"] / "fact_run.json").read_text()) + assert len(data) == 1 + assert data[0]["run_id"] == "run-1" + + +def test_export_placeholder_views(tmp_export): + from core.metrics.powerbi_export import export_all, PLACEHOLDER_VIEWS + result = export_all(fmt="both") + for view_name in PLACEHOLDER_VIEWS: + assert (tmp_export["export_dir"] / f"{view_name}.csv").exists() + assert (tmp_export["export_dir"] / f"{view_name}.json").exists() + assert len(PLACEHOLDER_VIEWS) == 8 + + +def test_export_placeholder_csv_headers_only(tmp_export): + from core.metrics.powerbi_export import export_all + export_all(fmt="csv") + csv_path = tmp_export["export_dir"] / "placeholder_drift_detection.csv" + lines = csv_path.read_text().strip().split("\n") + assert len(lines) == 1 # headers only, no data + assert "timestamp" in lines[0] + + +def test_export_placeholder_json_schema(tmp_export): + from core.metrics.powerbi_export import export_all + export_all(fmt="json") + json_path = tmp_export["export_dir"] / "placeholder_sla_downtime.json" + data = json.loads(json_path.read_text()) + assert "schema" in data + assert data["schema"]["blocking_decision"] == "D-096" + assert data["data"] == [] + + +def test_export_no_store(tmp_export): + from core.metrics.powerbi_export import export_all + result = export_all(fmt="csv") + assert "error" in result + # Placeholders still exported + assert (tmp_export["export_dir"] / "placeholder_drift_detection.csv").exists() \ No newline at end of file