diff --git a/src/dashboard/app.py b/src/dashboard/app.py
index 3ee815f9..85910187 100644
--- a/src/dashboard/app.py
+++ b/src/dashboard/app.py
@@ -54,6 +54,7 @@ from dashboard.routes.system import router as system_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.telegram import router as telegram_router
from dashboard.routes.thinking import router as thinking_router
+from dashboard.routes.self_correction import router as self_correction_router
from dashboard.routes.three_strike import router as three_strike_router
from dashboard.routes.tools import router as tools_router
from dashboard.routes.tower import router as tower_router
@@ -678,6 +679,7 @@ app.include_router(scorecards_router)
app.include_router(sovereignty_metrics_router)
app.include_router(sovereignty_ws_router)
app.include_router(three_strike_router)
+app.include_router(self_correction_router)
@app.websocket("/ws")
diff --git a/src/dashboard/routes/self_correction.py b/src/dashboard/routes/self_correction.py
new file mode 100644
index 00000000..91848fe9
--- /dev/null
+++ b/src/dashboard/routes/self_correction.py
@@ -0,0 +1,58 @@
+"""Self-Correction Dashboard routes.
+
+GET /self-correction/ui — HTML dashboard
+GET /self-correction/timeline — HTMX partial: recent event timeline
+GET /self-correction/patterns — HTMX partial: recurring failure patterns
+"""
+
+import logging
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+
+from dashboard.templating import templates
+from infrastructure.self_correction import get_corrections, get_patterns, get_stats
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/self-correction", tags=["self-correction"])
+
+
+@router.get("/ui", response_class=HTMLResponse)
+async def self_correction_ui(request: Request):
+ """Render the Self-Correction Dashboard."""
+ stats = get_stats()
+ corrections = get_corrections(limit=20)
+ patterns = get_patterns(top_n=10)
+ return templates.TemplateResponse(
+ request,
+ "self_correction.html",
+ {
+ "stats": stats,
+ "corrections": corrections,
+ "patterns": patterns,
+ },
+ )
+
+
+@router.get("/timeline", response_class=HTMLResponse)
+async def self_correction_timeline(request: Request):
+ """HTMX partial: recent self-correction event timeline."""
+ corrections = get_corrections(limit=30)
+ return templates.TemplateResponse(
+ request,
+ "partials/self_correction_timeline.html",
+ {"corrections": corrections},
+ )
+
+
+@router.get("/patterns", response_class=HTMLResponse)
+async def self_correction_patterns(request: Request):
+ """HTMX partial: recurring failure patterns."""
+ patterns = get_patterns(top_n=10)
+ stats = get_stats()
+ return templates.TemplateResponse(
+ request,
+ "partials/self_correction_patterns.html",
+ {"patterns": patterns, "stats": stats},
+ )
diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html
index 0456d976..d30c990f 100644
--- a/src/dashboard/templates/base.html
+++ b/src/dashboard/templates/base.html
@@ -71,6 +71,7 @@
SPARK
MEMORY
MARKET
+ SELF-CORRECT
@@ -132,6 +133,7 @@
SPARK
MEMORY
MARKET
+
SELF-CORRECT
AGENTS
HANDS
WORK ORDERS
diff --git a/src/dashboard/templates/partials/self_correction_patterns.html b/src/dashboard/templates/partials/self_correction_patterns.html
new file mode 100644
index 00000000..ba938832
--- /dev/null
+++ b/src/dashboard/templates/partials/self_correction_patterns.html
@@ -0,0 +1,28 @@
+{% if patterns %}
+
+
+
+ | ERROR TYPE |
+ COUNT |
+ CORRECTED |
+ FAILED |
+ LAST SEEN |
+
+
+
+ {% for p in patterns %}
+
+ | {{ p.error_type }} |
+
+ {{ p.count }}
+ |
+ {{ p.success_count }} |
+ {{ p.failed_count }} |
+ {{ p.last_seen[:16] if p.last_seen else '—' }} |
+
+ {% endfor %}
+
+
+{% else %}
+
No patterns detected yet.
+{% endif %}
diff --git a/src/dashboard/templates/partials/self_correction_timeline.html b/src/dashboard/templates/partials/self_correction_timeline.html
new file mode 100644
index 00000000..7a215f86
--- /dev/null
+++ b/src/dashboard/templates/partials/self_correction_timeline.html
@@ -0,0 +1,26 @@
+{% if corrections %}
+ {% for ev in corrections %}
+
+
+
{{ ev.error_type }}
+
INTENT: {{ ev.original_intent[:120] }}{% if ev.original_intent | length > 120 %}…{% endif %}
+
ERROR: {{ ev.detected_error[:120] }}{% if ev.detected_error | length > 120 %}…{% endif %}
+
STRATEGY: {{ ev.correction_strategy[:120] }}{% if ev.correction_strategy | length > 120 %}…{% endif %}
+
OUTCOME: {{ ev.final_outcome[:120] }}{% if ev.final_outcome | length > 120 %}…{% endif %}
+ {% if ev.task_id %}
+
task: {{ ev.task_id[:8] }}
+ {% endif %}
+
+ {% endfor %}
+{% else %}
+
No self-correction events recorded yet.
+{% endif %}
diff --git a/src/dashboard/templates/self_correction.html b/src/dashboard/templates/self_correction.html
new file mode 100644
index 00000000..4f273952
--- /dev/null
+++ b/src/dashboard/templates/self_correction.html
@@ -0,0 +1,102 @@
+{% extends "base.html" %}
+{% from "macros.html" import panel %}
+
+{% block title %}Timmy Time — Self-Correction Dashboard{% endblock %}
+
+{% block extra_styles %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TOTAL
+ {{ stats.total }}
+
+
+ CORRECTED
+ {{ stats.success_count }}
+
+
+ PARTIAL
+ {{ stats.partial_count }}
+
+
+ FAILED
+ {{ stats.failed_count }}
+
+
+
+
+ Correction Rate
+ {{ stats.success_rate }}%
+
+
+
+
+
+
+
+
+
+
+ {% include "partials/self_correction_patterns.html" %}
+
+
+
+
+
+
+
+
+
+
+ {% include "partials/self_correction_timeline.html" %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/src/infrastructure/self_correction.py b/src/infrastructure/self_correction.py
new file mode 100644
index 00000000..e7166739
--- /dev/null
+++ b/src/infrastructure/self_correction.py
@@ -0,0 +1,247 @@
+"""Self-correction event logger.
+
+Records instances where the agent detected its own errors and the steps
+it took to correct them. Used by the Self-Correction Dashboard to visualise
+these events and surface recurring failure patterns.
+
+Usage::
+
+ from infrastructure.self_correction import log_self_correction, get_corrections, get_patterns
+
+ log_self_correction(
+ source="agentic_loop",
+ original_intent="Execute step 3: deploy service",
+ detected_error="ConnectionRefusedError: port 8080 unavailable",
+ correction_strategy="Retry on alternate port 8081",
+ final_outcome="Success on retry",
+ task_id="abc123",
+ )
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+import uuid
+from collections.abc import Generator
+from contextlib import closing, contextmanager
+from datetime import UTC, datetime
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Database
+# ---------------------------------------------------------------------------
+
+_DB_PATH: Path | None = None
+
+
+def _get_db_path() -> Path:
+ global _DB_PATH
+ if _DB_PATH is None:
+ from config import settings
+
+ _DB_PATH = Path(settings.repo_root) / "data" / "self_correction.db"
+ return _DB_PATH
+
+
+@contextmanager
+def _get_db() -> Generator[sqlite3.Connection, None, None]:
+ db_path = _get_db_path()
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ with closing(sqlite3.connect(str(db_path))) as conn:
+ conn.row_factory = sqlite3.Row
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS self_correction_events (
+ id TEXT PRIMARY KEY,
+ source TEXT NOT NULL,
+ task_id TEXT DEFAULT '',
+ original_intent TEXT NOT NULL,
+ detected_error TEXT NOT NULL,
+ correction_strategy TEXT NOT NULL,
+ final_outcome TEXT NOT NULL,
+ outcome_status TEXT DEFAULT 'success',
+ error_type TEXT DEFAULT '',
+ created_at TEXT DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_sc_created ON self_correction_events(created_at)"
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_sc_error_type ON self_correction_events(error_type)"
+ )
+ conn.commit()
+ yield conn
+
+
+# ---------------------------------------------------------------------------
+# Write
+# ---------------------------------------------------------------------------
+
+
+def log_self_correction(
+ *,
+ source: str,
+ original_intent: str,
+ detected_error: str,
+ correction_strategy: str,
+ final_outcome: str,
+ task_id: str = "",
+ outcome_status: str = "success",
+ error_type: str = "",
+) -> str:
+ """Record a self-correction event and return its ID.
+
+ Args:
+ source: Module or component that triggered the correction.
+ original_intent: What the agent was trying to do.
+ detected_error: The error or problem that was detected.
+ correction_strategy: How the agent attempted to correct the error.
+ final_outcome: What the result of the correction attempt was.
+ task_id: Optional task/session ID for correlation.
+ outcome_status: 'success', 'partial', or 'failed'.
+ error_type: Short category label for pattern analysis (e.g.
+ 'ConnectionError', 'TimeoutError').
+
+ Returns:
+ The ID of the newly created record.
+ """
+ event_id = str(uuid.uuid4())
+ if not error_type:
+ # Derive a simple type from the first word of the detected error
+ error_type = detected_error.split(":")[0].strip()[:64]
+
+ try:
+ with _get_db() as conn:
+ conn.execute(
+ """
+ INSERT INTO self_correction_events
+ (id, source, task_id, original_intent, detected_error,
+ correction_strategy, final_outcome, outcome_status, error_type)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ event_id,
+ source,
+ task_id,
+ original_intent[:2000],
+ detected_error[:2000],
+ correction_strategy[:2000],
+ final_outcome[:2000],
+ outcome_status,
+ error_type,
+ ),
+ )
+ conn.commit()
+ logger.info(
+ "Self-correction logged [%s] source=%s error_type=%s status=%s",
+ event_id[:8],
+ source,
+ error_type,
+ outcome_status,
+ )
+ except Exception as exc:
+ logger.warning("Failed to log self-correction event: %s", exc)
+
+ return event_id
+
+
+# ---------------------------------------------------------------------------
+# Read
+# ---------------------------------------------------------------------------
+
+
+def get_corrections(limit: int = 50) -> list[dict]:
+ """Return the most recent self-correction events, newest first."""
+ try:
+ with _get_db() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM self_correction_events
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+ except Exception as exc:
+ logger.warning("Failed to fetch self-correction events: %s", exc)
+ return []
+
+
+def get_patterns(top_n: int = 10) -> list[dict]:
+ """Return the most common recurring error types with counts.
+
+ Each entry has:
+ - error_type: category label
+ - count: total occurrences
+ - success_count: corrected successfully
+ - failed_count: correction also failed
+ - last_seen: ISO timestamp of most recent occurrence
+ """
+ try:
+ with _get_db() as conn:
+ rows = conn.execute(
+ """
+ SELECT
+ error_type,
+ COUNT(*) AS count,
+ SUM(CASE WHEN outcome_status = 'success' THEN 1 ELSE 0 END) AS success_count,
+ SUM(CASE WHEN outcome_status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
+ MAX(created_at) AS last_seen
+ FROM self_correction_events
+ GROUP BY error_type
+ ORDER BY count DESC
+ LIMIT ?
+ """,
+ (top_n,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+ except Exception as exc:
+ logger.warning("Failed to fetch self-correction patterns: %s", exc)
+ return []
+
+
+def get_stats() -> dict:
+ """Return aggregate statistics for the summary panel."""
+ try:
+ with _get_db() as conn:
+ row = conn.execute(
+ """
+ SELECT
+ COUNT(*) AS total,
+ SUM(CASE WHEN outcome_status = 'success' THEN 1 ELSE 0 END) AS success_count,
+ SUM(CASE WHEN outcome_status = 'partial' THEN 1 ELSE 0 END) AS partial_count,
+ SUM(CASE WHEN outcome_status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
+ COUNT(DISTINCT error_type) AS unique_error_types,
+ COUNT(DISTINCT source) AS sources
+ FROM self_correction_events
+ """
+ ).fetchone()
+ if row is None:
+ return _empty_stats()
+ d = dict(row)
+ total = d.get("total") or 0
+ if total:
+ d["success_rate"] = round((d.get("success_count") or 0) / total * 100)
+ else:
+ d["success_rate"] = 0
+ return d
+ except Exception as exc:
+ logger.warning("Failed to fetch self-correction stats: %s", exc)
+ return _empty_stats()
+
+
+def _empty_stats() -> dict:
+ return {
+ "total": 0,
+ "success_count": 0,
+ "partial_count": 0,
+ "failed_count": 0,
+ "unique_error_types": 0,
+ "sources": 0,
+ "success_rate": 0,
+ }
diff --git a/src/timmy/agentic_loop.py b/src/timmy/agentic_loop.py
index 14b52bf2..8d4334a9 100644
--- a/src/timmy/agentic_loop.py
+++ b/src/timmy/agentic_loop.py
@@ -312,6 +312,13 @@ async def _handle_step_failure(
"adaptation": step.result[:200],
},
)
+ _log_self_correction(
+ task_id=task_id,
+ step_desc=step_desc,
+ exc=exc,
+ outcome=step.result,
+ outcome_status="success",
+ )
if on_progress:
await on_progress(f"[Adapted] {step_desc}", step_num, total_steps)
except Exception as adapt_exc: # broad catch intentional
@@ -325,9 +332,42 @@ async def _handle_step_failure(
duration_ms=int((time.monotonic() - step_start) * 1000),
)
)
+ _log_self_correction(
+ task_id=task_id,
+ step_desc=step_desc,
+ exc=exc,
+ outcome=f"Adaptation also failed: {adapt_exc}",
+ outcome_status="failed",
+ )
completed_results.append(f"Step {step_num}: FAILED")
+def _log_self_correction(
+ *,
+ task_id: str,
+ step_desc: str,
+ exc: Exception,
+ outcome: str,
+ outcome_status: str,
+) -> None:
+ """Best-effort: log a self-correction event (never raises)."""
+ try:
+ from infrastructure.self_correction import log_self_correction
+
+ log_self_correction(
+ source="agentic_loop",
+ original_intent=step_desc,
+ detected_error=f"{type(exc).__name__}: {exc}",
+ correction_strategy="Adaptive re-plan via LLM",
+ final_outcome=outcome[:500],
+ task_id=task_id,
+ outcome_status=outcome_status,
+ error_type=type(exc).__name__,
+ )
+ except Exception as log_exc:
+ logger.debug("Self-correction log failed: %s", log_exc)
+
+
# ---------------------------------------------------------------------------
# Core loop
# ---------------------------------------------------------------------------
diff --git a/static/css/mission-control.css b/static/css/mission-control.css
index fc333da0..28afa8ac 100644
--- a/static/css/mission-control.css
+++ b/static/css/mission-control.css
@@ -2714,3 +2714,74 @@
padding: 0.3rem 0.6rem;
margin-bottom: 0.5rem;
}
+
+/* ── Self-Correction Dashboard ─────────────────────────────── */
+.sc-event {
+ border-left: 3px solid var(--border);
+ padding: 0.6rem 0.8rem;
+ margin-bottom: 0.75rem;
+ background: rgba(255,255,255,0.02);
+ border-radius: 0 4px 4px 0;
+ font-size: 0.82rem;
+}
+.sc-event.sc-status-success { border-left-color: var(--green); }
+.sc-event.sc-status-partial { border-left-color: var(--amber); }
+.sc-event.sc-status-failed { border-left-color: var(--red); }
+
+.sc-event-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.4rem;
+ flex-wrap: wrap;
+}
+.sc-status-badge {
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ padding: 0.15rem 0.45rem;
+ border-radius: 3px;
+}
+.sc-status-badge.sc-status-success { color: var(--green); background: rgba(0,255,136,0.08); }
+.sc-status-badge.sc-status-partial { color: var(--amber); background: rgba(255,179,0,0.08); }
+.sc-status-badge.sc-status-failed { color: var(--red); background: rgba(255,59,59,0.08); }
+
+.sc-source-badge {
+ font-size: 0.68rem;
+ color: var(--purple);
+ background: rgba(168,85,247,0.1);
+ padding: 0.1rem 0.4rem;
+ border-radius: 3px;
+}
+.sc-event-time { font-size: 0.68rem; color: var(--text-dim); margin-left: auto; }
+.sc-event-error-type {
+ font-size: 0.72rem;
+ color: var(--amber);
+ font-weight: 600;
+ margin-bottom: 0.3rem;
+ letter-spacing: 0.04em;
+}
+.sc-label {
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: var(--text-dim);
+ margin-right: 0.3rem;
+}
+.sc-event-intent, .sc-event-error, .sc-event-strategy, .sc-event-outcome {
+ color: var(--text);
+ margin-bottom: 0.2rem;
+ line-height: 1.4;
+ word-break: break-word;
+}
+.sc-event-error { color: var(--red); }
+.sc-event-strategy { color: var(--text-dim); font-style: italic; }
+.sc-event-outcome { color: var(--text-bright); }
+.sc-event-meta { font-size: 0.68rem; color: var(--text-dim); margin-top: 0.3rem; }
+
+.sc-pattern-type {
+ font-family: var(--font);
+ font-size: 0.8rem;
+ color: var(--text-bright);
+ word-break: break-all;
+}
diff --git a/tests/unit/test_self_correction.py b/tests/unit/test_self_correction.py
new file mode 100644
index 00000000..98c6a8fd
--- /dev/null
+++ b/tests/unit/test_self_correction.py
@@ -0,0 +1,269 @@
+"""Unit tests for infrastructure.self_correction."""
+
+import os
+import tempfile
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(autouse=True)
+def _isolated_db(tmp_path, monkeypatch):
+ """Point the self-correction module at a fresh temp database per test."""
+ import infrastructure.self_correction as sc_mod
+
+ # Reset the cached path so each test gets a clean DB
+ sc_mod._DB_PATH = tmp_path / "self_correction.db"
+ yield
+ sc_mod._DB_PATH = None
+
+
+# ---------------------------------------------------------------------------
+# log_self_correction
+# ---------------------------------------------------------------------------
+
+
+class TestLogSelfCorrection:
+ def test_returns_event_id(self):
+ from infrastructure.self_correction import log_self_correction
+
+ eid = log_self_correction(
+ source="test",
+ original_intent="Do X",
+ detected_error="ValueError: bad input",
+ correction_strategy="Try Y instead",
+ final_outcome="Y succeeded",
+ )
+ assert isinstance(eid, str)
+ assert len(eid) == 36 # UUID format
+
+ def test_derives_error_type_from_error_string(self):
+ from infrastructure.self_correction import get_corrections, log_self_correction
+
+ log_self_correction(
+ source="test",
+ original_intent="Connect",
+ detected_error="ConnectionRefusedError: port 80",
+ correction_strategy="Use port 8080",
+ final_outcome="ok",
+ )
+ rows = get_corrections(limit=1)
+ assert rows[0]["error_type"] == "ConnectionRefusedError"
+
+ def test_explicit_error_type_preserved(self):
+ from infrastructure.self_correction import get_corrections, log_self_correction
+
+ log_self_correction(
+ source="test",
+ original_intent="Run task",
+ detected_error="Some weird error",
+ correction_strategy="Fix it",
+ final_outcome="done",
+ error_type="CustomError",
+ )
+ rows = get_corrections(limit=1)
+ assert rows[0]["error_type"] == "CustomError"
+
+ def test_task_id_stored(self):
+ from infrastructure.self_correction import get_corrections, log_self_correction
+
+ log_self_correction(
+ source="test",
+ original_intent="intent",
+ detected_error="err",
+ correction_strategy="strat",
+ final_outcome="outcome",
+ task_id="task-abc-123",
+ )
+ rows = get_corrections(limit=1)
+ assert rows[0]["task_id"] == "task-abc-123"
+
+ def test_outcome_status_stored(self):
+ from infrastructure.self_correction import get_corrections, log_self_correction
+
+ log_self_correction(
+ source="test",
+ original_intent="i",
+ detected_error="e",
+ correction_strategy="s",
+ final_outcome="o",
+ outcome_status="failed",
+ )
+ rows = get_corrections(limit=1)
+ assert rows[0]["outcome_status"] == "failed"
+
+ def test_long_strings_truncated(self):
+ from infrastructure.self_correction import get_corrections, log_self_correction
+
+ long = "x" * 3000
+ log_self_correction(
+ source="test",
+ original_intent=long,
+ detected_error=long,
+ correction_strategy=long,
+ final_outcome=long,
+ )
+ rows = get_corrections(limit=1)
+ assert len(rows[0]["original_intent"]) <= 2000
+
+
+# ---------------------------------------------------------------------------
+# get_corrections
+# ---------------------------------------------------------------------------
+
+
+class TestGetCorrections:
+ def test_empty_db_returns_empty_list(self):
+ from infrastructure.self_correction import get_corrections
+
+ assert get_corrections() == []
+
+ def test_returns_newest_first(self):
+ from infrastructure.self_correction import get_corrections, log_self_correction
+
+ for i in range(3):
+ log_self_correction(
+ source="test",
+ original_intent=f"intent {i}",
+ detected_error="err",
+ correction_strategy="fix",
+ final_outcome="done",
+ error_type=f"Type{i}",
+ )
+ rows = get_corrections(limit=10)
+ assert len(rows) == 3
+ # Newest first — Type2 should appear before Type0
+ types = [r["error_type"] for r in rows]
+ assert types.index("Type2") < types.index("Type0")
+
+ def test_limit_respected(self):
+ from infrastructure.self_correction import get_corrections, log_self_correction
+
+ for _ in range(5):
+ log_self_correction(
+ source="test",
+ original_intent="i",
+ detected_error="e",
+ correction_strategy="s",
+ final_outcome="o",
+ )
+ rows = get_corrections(limit=3)
+ assert len(rows) == 3
+
+
+# ---------------------------------------------------------------------------
+# get_patterns
+# ---------------------------------------------------------------------------
+
+
+class TestGetPatterns:
+ def test_empty_db_returns_empty_list(self):
+ from infrastructure.self_correction import get_patterns
+
+ assert get_patterns() == []
+
+ def test_counts_by_error_type(self):
+ from infrastructure.self_correction import get_patterns, log_self_correction
+
+ for _ in range(3):
+ log_self_correction(
+ source="test",
+ original_intent="i",
+ detected_error="e",
+ correction_strategy="s",
+ final_outcome="o",
+ error_type="TimeoutError",
+ )
+ log_self_correction(
+ source="test",
+ original_intent="i",
+ detected_error="e",
+ correction_strategy="s",
+ final_outcome="o",
+ error_type="ValueError",
+ )
+ patterns = get_patterns(top_n=10)
+ by_type = {p["error_type"]: p for p in patterns}
+ assert by_type["TimeoutError"]["count"] == 3
+ assert by_type["ValueError"]["count"] == 1
+
+ def test_success_vs_failed_counts(self):
+ from infrastructure.self_correction import get_patterns, log_self_correction
+
+ log_self_correction(
+ source="test", original_intent="i", detected_error="e",
+ correction_strategy="s", final_outcome="o",
+ error_type="Foo", outcome_status="success",
+ )
+ log_self_correction(
+ source="test", original_intent="i", detected_error="e",
+ correction_strategy="s", final_outcome="o",
+ error_type="Foo", outcome_status="failed",
+ )
+ patterns = get_patterns(top_n=5)
+ foo = next(p for p in patterns if p["error_type"] == "Foo")
+ assert foo["success_count"] == 1
+ assert foo["failed_count"] == 1
+
+ def test_ordered_by_count_desc(self):
+ from infrastructure.self_correction import get_patterns, log_self_correction
+
+ for _ in range(2):
+ log_self_correction(
+ source="t", original_intent="i", detected_error="e",
+ correction_strategy="s", final_outcome="o", error_type="Rare",
+ )
+ for _ in range(5):
+ log_self_correction(
+ source="t", original_intent="i", detected_error="e",
+ correction_strategy="s", final_outcome="o", error_type="Common",
+ )
+ patterns = get_patterns(top_n=5)
+ assert patterns[0]["error_type"] == "Common"
+
+
+# ---------------------------------------------------------------------------
+# get_stats
+# ---------------------------------------------------------------------------
+
+
+class TestGetStats:
+ def test_empty_db_returns_zeroes(self):
+ from infrastructure.self_correction import get_stats
+
+ stats = get_stats()
+ assert stats["total"] == 0
+ assert stats["success_rate"] == 0
+
+ def test_counts_outcomes(self):
+ from infrastructure.self_correction import get_stats, log_self_correction
+
+ log_self_correction(
+ source="t", original_intent="i", detected_error="e",
+ correction_strategy="s", final_outcome="o", outcome_status="success",
+ )
+ log_self_correction(
+ source="t", original_intent="i", detected_error="e",
+ correction_strategy="s", final_outcome="o", outcome_status="failed",
+ )
+ stats = get_stats()
+ assert stats["total"] == 2
+ assert stats["success_count"] == 1
+ assert stats["failed_count"] == 1
+ assert stats["success_rate"] == 50
+
+ def test_success_rate_100_when_all_succeed(self):
+ from infrastructure.self_correction import get_stats, log_self_correction
+
+ for _ in range(4):
+ log_self_correction(
+ source="t", original_intent="i", detected_error="e",
+ correction_strategy="s", final_outcome="o", outcome_status="success",
+ )
+ stats = get_stats()
+ assert stats["success_rate"] == 100