diff --git a/agentprof/report/runner.py b/agentprof/report/runner.py index 6b5c703..4656abb 100644 --- a/agentprof/report/runner.py +++ b/agentprof/report/runner.py @@ -88,6 +88,7 @@ def generate_report( summary=summary, report_md_path=str(markdown_path), report_json_path=str(json_path), + report_html_path=None, ) ) @@ -100,6 +101,7 @@ def generate_report( total_wasted_cost_usd=_sum_decimals(issue.wasted_cost_usd for issue in issues), report_md_path=markdown_path, report_json_path=json_path, + report_html_path=None, ) diff --git a/agentprof/report/schema.py b/agentprof/report/schema.py index 1659f1c..cf8c44b 100644 --- a/agentprof/report/schema.py +++ b/agentprof/report/schema.py @@ -15,3 +15,4 @@ class ReportBuildResult(BaseModel): total_wasted_cost_usd: Decimal = Field(default=Decimal("0")) report_md_path: Path report_json_path: Path + report_html_path: Path | None = None diff --git a/agentprof/store/duckdb_store.py b/agentprof/store/duckdb_store.py index 8de3aa4..837a7c1 100644 --- a/agentprof/store/duckdb_store.py +++ b/agentprof/store/duckdb_store.py @@ -147,6 +147,7 @@ class ReportRecord: summary: dict[str, Any] report_md_path: str | None report_json_path: str | None + report_html_path: str | None = None MIGRATIONS = ( @@ -289,6 +290,7 @@ class ReportRecord: summary_json TEXT NOT NULL DEFAULT '{}', report_md_path TEXT, report_json_path TEXT, + report_html_path TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp ); """, @@ -299,6 +301,13 @@ class ReportRecord: sql=""" ALTER TABLE normalized_spans ADD COLUMN IF NOT EXISTS input_retry_fingerprint TEXT; ALTER TABLE normalized_spans ADD COLUMN IF NOT EXISTS output_retry_fingerprint TEXT; +""", + ), + Migration( + version=3, + name="add_report_html_path", + sql=""" +ALTER TABLE reports ADD COLUMN IF NOT EXISTS report_html_path TEXT; """, ), ) @@ -927,8 +936,9 @@ def upsert_report(self, record: ReportRecord) -> None: window_end, summary_json, report_md_path, - report_json_path - ) VALUES (?, ?, ?, ?, ?, ?, ?) + report_json_path, + report_html_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, [ record.report_id, @@ -943,6 +953,7 @@ def upsert_report(self, record: ReportRecord) -> None: ), record.report_md_path, record.report_json_path, + record.report_html_path, ], ) connection.execute("COMMIT") @@ -955,7 +966,7 @@ def fetch_reports(self, *, report_id: str | None = None) -> list[ReportRecord]: query = """ SELECT report_id, project, CAST(window_start AS VARCHAR), CAST(window_end AS VARCHAR), summary_json, report_md_path, - report_json_path + report_json_path, report_html_path FROM reports """ params: list[str] = [] @@ -976,6 +987,7 @@ def fetch_reports(self, *, report_id: str | None = None) -> list[ReportRecord]: summary=json.loads(row[4]), report_md_path=row[5], report_json_path=row[6], + report_html_path=row[7], ) for row in rows ] diff --git a/tests/test_report.py b/tests/test_report.py index 6a76b98..fb8859b 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -44,6 +44,7 @@ def test_generate_report_writes_markdown_json_and_store_row(tmp_path: Path) -> N assert result.total_wasted_cost_usd == Decimal("0.020000000") assert result.report_md_path.is_file() assert result.report_json_path.is_file() + assert result.report_html_path is None assert payload["report_id"] == "test-report" assert payload["project"] == "tracer" @@ -61,6 +62,8 @@ def test_generate_report_writes_markdown_json_and_store_row(tmp_path: Path) -> N assert reports[0].project == "tracer" assert reports[0].summary["issue_count"] == 1 assert reports[0].report_md_path == str(result.report_md_path) + assert reports[0].report_json_path == str(result.report_json_path) + assert reports[0].report_html_path is None assert store.stats()["reports"] == 1 diff --git a/tests/test_store.py b/tests/test_store.py index 57a2f16..2e98653 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -4,7 +4,7 @@ import pytest -from agentprof.store.duckdb_store import TABLES, DuckDBStore +from agentprof.store.duckdb_store import TABLES, DuckDBStore, ReportRecord def test_store_creates_schema(tmp_path: Path) -> None: @@ -16,6 +16,7 @@ def test_store_creates_schema(tmp_path: Path) -> None: assert store.migrations() == [ (1, "initial_store_schema"), (2, "add_normalized_span_retry_fingerprints"), + (3, "add_report_html_path"), ] @@ -38,9 +39,84 @@ def test_store_reset_recreates_schema(tmp_path: Path) -> None: assert store.migrations() == [ (1, "initial_store_schema"), (2, "add_normalized_span_retry_fingerprints"), + (3, "add_report_html_path"), ] +def test_report_html_path_migration_updates_existing_reports_table( + tmp_path: Path, +) -> None: + store = DuckDBStore(tmp_path / "agentprof.duckdb") + with store.connect() as connection: + connection.execute( + """ + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp + ) + """ + ) + connection.execute( + """ + CREATE TABLE reports ( + report_id TEXT PRIMARY KEY, + project TEXT, + window_start TIMESTAMPTZ, + window_end TIMESTAMPTZ, + summary_json TEXT NOT NULL DEFAULT '{}', + report_md_path TEXT, + report_json_path TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp + ) + """ + ) + connection.executemany( + "INSERT INTO schema_migrations (version, name) VALUES (?, ?)", + [ + (1, "initial_store_schema"), + (2, "add_normalized_span_retry_fingerprints"), + ], + ) + + store.ensure_schema() + + with store.connect() as connection: + columns = { + row[1] + for row in connection.execute("PRAGMA table_info('reports')").fetchall() + } + + assert store.migrations() == [ + (1, "initial_store_schema"), + (2, "add_normalized_span_retry_fingerprints"), + (3, "add_report_html_path"), + ] + assert "report_html_path" in columns + + +def test_store_round_trips_report_html_path(tmp_path: Path) -> None: + store = DuckDBStore(tmp_path / "agentprof.duckdb") + + store.upsert_report( + ReportRecord( + report_id="html-report", + project="tracer", + window_start=None, + window_end=None, + summary={"issue_count": 0}, + report_md_path=".agentprof/reports/html-report.md", + report_json_path=".agentprof/reports/html-report.json", + report_html_path=".agentprof/reports/html-report.html", + ) + ) + + reports = store.fetch_reports(report_id="html-report") + + assert len(reports) == 1 + assert reports[0].report_html_path == ".agentprof/reports/html-report.html" + + def test_migrations_is_read_only_for_missing_store(tmp_path: Path) -> None: store = DuckDBStore(tmp_path / "missing.duckdb")