| """ |
| Aggregate session events into compact analytics summaries. |
| |
| Two scopes: |
| |
| session_summary one session — turn count, per-action |
| use counts, final node, mood/affinity end |
| state, per-scheme progress. |
| experience_summary all sessions of one experience — |
| completion rate, total turns, popular |
| actions, action block rate. |
| |
| No percentile / histogram work here — that belongs in the studio |
| dashboard if / when it needs it. These summaries are the minimum |
| viable signal for the ship-readiness review. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| from collections import Counter |
| from dataclasses import dataclass |
| from typing import Any, Dict, List, Optional |
|
|
| from .. import store |
|
|
|
|
| @dataclass(frozen=True) |
| class SessionSummary: |
| """Per-session analytic snapshot.""" |
|
|
| session_id: str |
| experience_id: str |
| turns: int |
| events: int |
| action_uses: Dict[str, int] |
| decisions: Dict[str, int] |
| intents: Dict[str, int] |
| final_node_id: str |
| final_mood: str |
| final_affinity: float |
| progress: Dict[str, Dict[str, float]] |
| completed: bool |
|
|
|
|
| @dataclass(frozen=True) |
| class ExperienceSummary: |
| """Aggregate across every session of one experience.""" |
|
|
| experience_id: str |
| session_count: int |
| completed_sessions: int |
| completion_rate: float |
| total_turns: int |
| total_events: int |
| popular_actions: List[Dict[str, Any]] |
| block_rate: float |
|
|
|
|
| def _parse_payload(raw: Any) -> Dict[str, Any]: |
| if not raw: |
| return {} |
| if isinstance(raw, dict): |
| return raw |
| try: |
| return json.loads(raw) |
| except (TypeError, ValueError): |
| return {} |
|
|
|
|
| def session_summary(session_id: str) -> Optional[SessionSummary]: |
| """Compute the summary for one session. Returns None if missing.""" |
| store.ensure_schema() |
| with store._conn() as con: |
| sess = con.execute( |
| "SELECT * FROM ix_sessions WHERE id = ?", (session_id,), |
| ).fetchone() |
| if not sess: |
| return None |
| turns = con.execute( |
| "SELECT COUNT(*) FROM ix_session_turns WHERE session_id = ?", (session_id,), |
| ).fetchone()[0] |
| events = con.execute( |
| "SELECT event_kind, payload, action_id FROM ix_session_events WHERE session_id = ?", |
| (session_id,), |
| ).fetchall() |
| cs = con.execute( |
| "SELECT * FROM ix_character_state WHERE session_id = ?", (session_id,), |
| ).fetchone() |
| progress_rows = con.execute( |
| "SELECT scheme, metric_key, metric_value FROM ix_session_progress " |
| "WHERE session_id = ?", |
| (session_id,), |
| ).fetchall() |
|
|
| action_uses: Counter = Counter() |
| decisions: Counter = Counter() |
| intents: Counter = Counter() |
| for ev in events: |
| if ev["event_kind"] != "turn_resolved": |
| continue |
| if ev["action_id"]: |
| action_uses[ev["action_id"]] += 1 |
| payload = _parse_payload(ev["payload"]) |
| if "decision" in payload: |
| decisions[str(payload["decision"])] += 1 |
| if "intent_code" in payload and payload["intent_code"]: |
| intents[str(payload["intent_code"])] += 1 |
|
|
| progress: Dict[str, Dict[str, float]] = {} |
| for row in progress_rows: |
| progress.setdefault(row["scheme"], {})[row["metric_key"]] = float(row["metric_value"]) |
|
|
| mood = cs["mood"] if cs else "neutral" |
| aff = float(cs["affinity_score"]) if cs else 0.5 |
|
|
| return SessionSummary( |
| session_id=session_id, |
| experience_id=str(sess["experience_id"]), |
| turns=int(turns), |
| events=len(events), |
| action_uses=dict(action_uses), |
| decisions=dict(decisions), |
| intents=dict(intents), |
| final_node_id=str(sess["current_node_id"] or ""), |
| final_mood=str(mood), |
| final_affinity=aff, |
| progress=progress, |
| completed=sess["completed_at"] is not None, |
| ) |
|
|
|
|
| def experience_summary(experience_id: str) -> ExperienceSummary: |
| """Aggregate across every session of ``experience_id``.""" |
| store.ensure_schema() |
| with store._conn() as con: |
| sessions = con.execute( |
| "SELECT id, completed_at FROM ix_sessions WHERE experience_id = ?", |
| (experience_id,), |
| ).fetchall() |
| total_turns = con.execute( |
| "SELECT COUNT(*) FROM ix_session_turns t " |
| "JOIN ix_sessions s ON t.session_id = s.id " |
| "WHERE s.experience_id = ?", |
| (experience_id,), |
| ).fetchone()[0] |
| events = con.execute( |
| "SELECT e.payload, e.action_id FROM ix_session_events e " |
| "JOIN ix_sessions s ON e.session_id = s.id " |
| "WHERE s.experience_id = ? AND e.event_kind = 'turn_resolved'", |
| (experience_id,), |
| ).fetchall() |
|
|
| sc = len(sessions) |
| completed = sum(1 for s in sessions if s["completed_at"] is not None) |
| action_uses: Counter = Counter() |
| decisions: Counter = Counter() |
| for ev in events: |
| if ev["action_id"]: |
| action_uses[ev["action_id"]] += 1 |
| payload = _parse_payload(ev["payload"]) |
| if "decision" in payload: |
| decisions[str(payload["decision"])] += 1 |
| popular = [{"action_id": aid, "uses": n} for aid, n in action_uses.most_common(10)] |
| total_decisions = sum(decisions.values()) |
| block_rate = 0.0 |
| if total_decisions > 0: |
| block_rate = decisions.get("block", 0) / total_decisions |
|
|
| return ExperienceSummary( |
| experience_id=experience_id, |
| session_count=sc, |
| completed_sessions=completed, |
| completion_rate=(completed / sc) if sc > 0 else 0.0, |
| total_turns=int(total_turns), |
| total_events=len(events), |
| popular_actions=popular, |
| block_rate=block_rate, |
| ) |
|
|