From d1aaa5820c707dfef35c9f114639f4b49753bcbd Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 10 Apr 2026 18:05:06 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20stats=20schema=20v2=20=E2=80=94=20group?= =?UTF-8?q?ed=20output=20structure=20with=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure `to_dict()` from 12 flat top-level keys to a grouped schema: `totals` (Counter + durations), `tokens` (by_day, by_model), `agents` (counts, minutes, counts_by_day), `facets` (same). New `think/stats_schema.py` defines SCHEMA_VERSION=2, field constants, and `validate()`. `save_json()` now validates before writing and raises ValueError on schema mismatch. API response includes `file_mtime`. --- apps/stats/routes.py | 1 + tests/test_journal_stats.py | 13 +++-- tests/test_stats_schema.py | 104 ++++++++++++++++++++++++++++++++++++ think/journal_stats.py | 48 ++++++++++++----- think/stats_schema.py | 76 ++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 19 deletions(-) create mode 100644 tests/test_stats_schema.py create mode 100644 think/stats_schema.py diff --git a/apps/stats/routes.py b/apps/stats/routes.py index 9f2913c90..5bfa1a26b 100644 --- a/apps/stats/routes.py +++ b/apps/stats/routes.py @@ -37,6 +37,7 @@ def stats_data() -> Any: try: with open(stats_path, "r", encoding="utf-8") as f: response["stats"] = json.load(f) + response["file_mtime"] = os.path.getmtime(stats_path) except Exception: logger.exception("Failed to read stats data") response["error"] = "Failed to read stats data" diff --git a/tests/test_journal_stats.py b/tests/test_journal_stats.py index 58720b8ba..adb6afe20 100644 --- a/tests/test_journal_stats.py +++ b/tests/test_journal_stats.py @@ -169,12 +169,15 @@ def test_token_usage(tmp_path, monkeypatch): # Test JSON output includes token usage data = js.to_dict() - assert "token_usage_by_day" in data - assert "token_totals_by_model" in data - assert "total_transcript_duration" in data - assert "total_percept_duration" in data + assert data["schema_version"] == 2 + assert "generated_at" in data + assert data["day_count"] == 2 + assert "tokens" in data + assert "by_day" in data["tokens"] + assert "total_transcript_duration" in data["totals"] + assert "total_percept_duration" in data["totals"] assert ( - data["token_usage_by_day"]["20240101"]["gemini-2.5-flash"]["total_tokens"] + data["tokens"]["by_day"]["20240101"]["gemini-2.5-flash"]["total_tokens"] == 495 ) diff --git a/tests/test_stats_schema.py b/tests/test_stats_schema.py new file mode 100644 index 000000000..ee91c7cb1 --- /dev/null +++ b/tests/test_stats_schema.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import importlib + +import pytest + + +def test_validate_passes_on_valid_output(tmp_path, monkeypatch): + """Build a JournalStats from fixture data, call to_dict(), validate.""" + stats_mod = importlib.import_module("think.journal_stats") + schema_mod = importlib.import_module("think.stats_schema") + journal = tmp_path + day = journal / "20240101" + day.mkdir() + + # Create minimal transcript fixture + ts_dir = day / "default" / "123456_300" + ts_dir.mkdir(parents=True) + (ts_dir / "audio.jsonl").write_text( + '{"raw": "raw.flac"}\n' + '{"start": "10:00:00", "text": "hello"}\n' + ) + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + js = stats_mod.JournalStats() + js.scan(str(journal)) + + data = js.to_dict() + errors = schema_mod.validate(data) + assert errors == [], f"Validation errors: {errors}" + + +def test_validate_rejects_missing_fields(): + """Incomplete dicts should produce non-empty error lists.""" + schema_mod = importlib.import_module("think.stats_schema") + + # Empty dict + errors = schema_mod.validate({}) + assert len(errors) > 0 + assert any("schema_version" in e for e in errors) + + # Missing days + errors = schema_mod.validate( + {"schema_version": 2, "generated_at": "2026-04-10T00:00:00+00:00"} + ) + assert any("days" in e for e in errors) + + # Wrong schema version + errors = schema_mod.validate( + { + "schema_version": 99, + "generated_at": "x", + "day_count": 0, + "days": {}, + "totals": {}, + "heatmap": [], + "tokens": {}, + "agents": {}, + "facets": {}, + } + ) + assert any("schema_version" in e for e in errors) + + +def test_save_json_raises_on_invalid(tmp_path, monkeypatch): + """save_json() must raise ValueError when validation fails.""" + stats_mod = importlib.import_module("think.journal_stats") + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + js = stats_mod.JournalStats() + # Corrupt the schema version so validation fails + original = js.to_dict + js.to_dict = lambda: {**original(), "schema_version": 99} + with pytest.raises(ValueError, match="Stats validation failed"): + js.save_json(str(tmp_path)) + + +def test_day_fields_present_in_scan_day(tmp_path, monkeypatch): + """Verify every key in DAY_FIELDS appears in scan_day output.""" + stats_mod = importlib.import_module("think.journal_stats") + schema_mod = importlib.import_module("think.stats_schema") + journal = tmp_path + day = journal / "20240101" + day.mkdir() + + # Create transcript and percept fixtures + ts_dir = day / "default" / "123456_300" + ts_dir.mkdir(parents=True) + (ts_dir / "audio.jsonl").write_text( + '{"raw": "raw.flac"}\n' + '{"start": "10:00:00", "text": "hello"}\n' + ) + (ts_dir / "screen.jsonl").write_text( + '{"header": true}\n' + '{"frame_id": 1, "timestamp": "10:00:00"}\n' + ) + + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + js = stats_mod.JournalStats() + day_data = js.scan_day("20240101", str(day)) + + stats = day_data["stats"] + for field in schema_mod.DAY_FIELDS: + assert field in stats, f"DAY_FIELDS field '{field}' missing from scan_day output" diff --git a/think/journal_stats.py b/think/journal_stats.py index f35205b53..6538764a6 100644 --- a/think/journal_stats.py +++ b/think/journal_stats.py @@ -6,13 +6,14 @@ import json import logging import os from collections import Counter -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Dict from observe.sense import scan_day as sense_scan_day from observe.utils import VIDEO_EXTENSIONS, load_analysis_frames from think.agents import scan_day as generate_scan_day +from think.stats_schema import DAY_FIELDS, SCHEMA_VERSION, validate as validate_stats from think.utils import day_dirs, get_journal, setup_cli logger = logging.getLogger(__name__) @@ -466,27 +467,46 @@ class JournalStats: def to_dict(self) -> dict: """Return a dictionary with all collected statistics.""" + days = { + day: {field: stats.get(field, 0) for field in DAY_FIELDS} + for day, stats in self.days.items() + } return { - "days": self.days, - "totals": dict(self.totals), - "total_transcript_duration": self.total_transcript_duration, - "total_percept_duration": self.total_percept_duration, - "agent_counts": dict(self.agent_counts), - "agent_minutes": {k: round(v, 2) for k, v in self.agent_minutes.items()}, - "agent_counts_by_day": self.agent_counts_by_day, - "facet_counts": dict(self.facet_counts), - "facet_minutes": {k: round(v, 2) for k, v in self.facet_minutes.items()}, - "facet_counts_by_day": self.facet_counts_by_day, + "schema_version": SCHEMA_VERSION, + "generated_at": datetime.now(timezone.utc).isoformat(), + "day_count": len(self.days), + "days": days, + "totals": { + **dict(self.totals), + "total_transcript_duration": self.total_transcript_duration, + "total_percept_duration": self.total_percept_duration, + }, "heatmap": self.heatmap, - "token_usage_by_day": self.token_usage, - "token_totals_by_model": self.token_totals, + "tokens": { + "by_day": self.token_usage, + "by_model": self.token_totals, + }, + "agents": { + "counts": dict(self.agent_counts), + "minutes": {k: round(v, 2) for k, v in self.agent_minutes.items()}, + "counts_by_day": self.agent_counts_by_day, + }, + "facets": { + "counts": dict(self.facet_counts), + "minutes": {k: round(v, 2) for k, v in self.facet_minutes.items()}, + "counts_by_day": self.facet_counts_by_day, + }, } def save_json(self, journal: str) -> None: """Write full statistics to ``stats.json`` in ``journal``.""" + data = self.to_dict() + errors = validate_stats(data) + if errors: + raise ValueError(f"Stats validation failed: {'; '.join(errors)}") path = os.path.join(journal, "stats.json") with open(path, "w", encoding="utf-8") as f: - json.dump(self.to_dict(), f, indent=2) + json.dump(data, f, indent=2) def main() -> None: diff --git a/think/stats_schema.py b/think/stats_schema.py new file mode 100644 index 000000000..bf0127879 --- /dev/null +++ b/think/stats_schema.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +SCHEMA_VERSION = 2 + +DAY_FIELDS = ( + "transcript_sessions", + "transcript_segments", + "transcript_duration", + "percept_sessions", + "percept_frames", + "percept_duration", + "pending_segments", + "outputs_processed", + "outputs_pending", + "day_bytes", +) + +TOTAL_FIELDS = ( + "transcript_sessions", + "transcript_segments", + "transcript_duration", + "percept_sessions", + "percept_frames", + "percept_duration", + "pending_segments", + "outputs_processed", + "outputs_pending", + "day_bytes", + "total_transcript_duration", + "total_percept_duration", +) + +REQUIRED_TOP_LEVEL = ( + "schema_version", + "generated_at", + "day_count", + "days", + "totals", + "heatmap", + "tokens", + "agents", + "facets", +) + + +def validate(data: dict) -> list[str]: + """Validate stats output against schema v2. Returns list of error strings (empty = valid).""" + errors = [] + + # Check schema_version + if "schema_version" not in data: + errors.append("missing 'schema_version'") + elif data["schema_version"] != SCHEMA_VERSION: + errors.append(f"schema_version is {data['schema_version']}, expected {SCHEMA_VERSION}") + + # Check generated_at + if "generated_at" not in data: + errors.append("missing 'generated_at'") + elif not isinstance(data["generated_at"], str): + errors.append("'generated_at' must be a string") + + # Check required top-level keys + for key in REQUIRED_TOP_LEVEL: + if key not in data: + errors.append(f"missing required key '{key}'") + + # Spot-check one day entry if days is non-empty + days = data.get("days", {}) + if isinstance(days, dict) and days: + first_day = next(iter(days.values())) + for field in DAY_FIELDS: + if field not in first_day: + errors.append(f"day entry missing field '{field}'") + + return errors -- 2.51.2