Compare commits
1 Commits
feat/136-c
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf23e93787 |
9
Makefile
9
Makefile
@@ -46,12 +46,3 @@ ssl:
|
||||
|
||||
service:
|
||||
ssh root@$(VPS) "cd /opt/the-door && bash deploy/deploy.sh --service"
|
||||
|
||||
# Crisis metrics
|
||||
.PHONY: metrics metrics-json
|
||||
|
||||
metrics: ## Show crisis metrics summary (last 7 days)
|
||||
python3 -m crisis.metrics --summary
|
||||
|
||||
metrics-json: ## Export crisis metrics as JSON
|
||||
python3 -m crisis.metrics --json
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Crisis metrics — aggregate detection data for operators.
|
||||
|
||||
Tracks crisis detection events and provides summary reports.
|
||||
|
||||
Usage:
|
||||
python3 -m crisis.metrics --summary # weekly report
|
||||
python3 -m crisis.metrics --json # raw JSON export
|
||||
python3 -m crisis.metrics --last 7d # last 7 days
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Data directory for metrics storage
|
||||
_DATA_DIR = Path(os.getenv("CRISIS_DATA_DIR", str(Path.home() / ".the-door")))
|
||||
_METRICS_FILE = _DATA_DIR / "crisis-metrics.jsonl"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisEvent:
|
||||
"""A single crisis detection event."""
|
||||
timestamp: float
|
||||
level: str # NONE, LOW, MODERATE, HIGH, CRITICAL
|
||||
indicators: list
|
||||
session_id: str = ""
|
||||
source: str = "" # "chat", "gateway", "cli"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricsSummary:
|
||||
"""Aggregated metrics summary."""
|
||||
period_days: int
|
||||
total_events: int
|
||||
by_level: Dict[str, int]
|
||||
top_indicators: List[tuple]
|
||||
sessions_affected: int
|
||||
avg_daily: float
|
||||
peak_day: str
|
||||
peak_count: int
|
||||
generated_at: str
|
||||
|
||||
|
||||
def log_event(event: CrisisEvent) -> None:
|
||||
"""Log a crisis event to the metrics file."""
|
||||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(_METRICS_FILE, "a") as f:
|
||||
f.write(json.dumps(asdict(event)) + "\n")
|
||||
|
||||
|
||||
def load_events(days: int = 7) -> List[CrisisEvent]:
|
||||
"""Load crisis events from the last N days."""
|
||||
if not _METRICS_FILE.exists():
|
||||
return []
|
||||
|
||||
cutoff = time.time() - (days * 86400)
|
||||
events = []
|
||||
|
||||
try:
|
||||
with open(_METRICS_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
data = json.loads(line)
|
||||
if data.get("timestamp", 0) >= cutoff:
|
||||
events.append(CrisisEvent(**data))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def compute_summary(days: int = 7) -> MetricsSummary:
|
||||
"""Compute metrics summary for the given period."""
|
||||
events = load_events(days)
|
||||
now = time.time()
|
||||
|
||||
# By level
|
||||
by_level = Counter(e.level for e in events)
|
||||
|
||||
# Top indicators
|
||||
indicator_counts = Counter()
|
||||
for e in events:
|
||||
for ind in e.indicators:
|
||||
indicator_counts[ind] += 1
|
||||
top_indicators = indicator_counts.most_common(10)
|
||||
|
||||
# Sessions
|
||||
sessions = set(e.session_id for e in events if e.session_id)
|
||||
|
||||
# Peak day
|
||||
from collections import defaultdict
|
||||
daily = defaultdict(int)
|
||||
for e in events:
|
||||
day = time.strftime("%Y-%m-%d", time.localtime(e.timestamp))
|
||||
daily[day] += 1
|
||||
peak_day = max(daily, key=daily.get) if daily else "N/A"
|
||||
peak_count = daily.get(peak_day, 0)
|
||||
|
||||
return MetricsSummary(
|
||||
period_days=days,
|
||||
total_events=len(events),
|
||||
by_level=dict(by_level),
|
||||
top_indicators=top_indicators,
|
||||
sessions_affected=len(sessions),
|
||||
avg_daily=round(len(events) / max(days, 1), 1),
|
||||
peak_day=peak_day,
|
||||
peak_count=peak_count,
|
||||
generated_at=time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
|
||||
def format_summary(summary: MetricsSummary) -> str:
|
||||
"""Format metrics summary as human-readable report."""
|
||||
lines = [
|
||||
"Crisis Metrics Summary",
|
||||
"=" * 40,
|
||||
f"Period: Last {summary.period_days} days",
|
||||
f"Generated: {summary.generated_at}",
|
||||
"",
|
||||
f"Total events: {summary.total_events}",
|
||||
f"Daily avg: {summary.avg_daily}",
|
||||
f"Sessions: {summary.sessions_affected}",
|
||||
f"Peak day: {summary.peak_day} ({summary.peak_count} events)",
|
||||
"",
|
||||
]
|
||||
|
||||
if summary.by_level:
|
||||
lines.append("By severity:")
|
||||
for level in ["CRITICAL", "HIGH", "MODERATE", "LOW", "NONE"]:
|
||||
count = summary.by_level.get(level, 0)
|
||||
if count > 0:
|
||||
bar = "█" * min(count, 30)
|
||||
lines.append(f" {level:10s} {count:4d} {bar}")
|
||||
lines.append("")
|
||||
|
||||
if summary.top_indicators:
|
||||
lines.append("Top indicators:")
|
||||
for indicator, count in summary.top_indicators[:5]:
|
||||
lines.append(f" {indicator}: {count}")
|
||||
lines.append("")
|
||||
|
||||
if summary.total_events == 0:
|
||||
lines.append("No crisis events in this period.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Crisis metrics summary")
|
||||
parser.add_argument("--summary", action="store_true", help="Print summary report")
|
||||
parser.add_argument("--json", action="store_true", dest="as_json", help="Output JSON")
|
||||
parser.add_argument("--last", default="7d", help="Time period (e.g., 7d, 30d)")
|
||||
parser.add_argument("--log", nargs=2, metavar=("LEVEL", "INDICATOR"), help="Log a test event")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse period
|
||||
period_str = args.last.rstrip("d")
|
||||
try:
|
||||
days = int(period_str)
|
||||
except ValueError:
|
||||
days = 7
|
||||
|
||||
# Log mode
|
||||
if args.log:
|
||||
level, indicator = args.log
|
||||
event = CrisisEvent(
|
||||
timestamp=time.time(),
|
||||
level=level.upper(),
|
||||
indicators=[indicator],
|
||||
session_id="cli-test",
|
||||
source="cli",
|
||||
)
|
||||
log_event(event)
|
||||
print(f"Logged: {level.upper()} / {indicator}")
|
||||
return 0
|
||||
|
||||
# Compute summary
|
||||
summary = compute_summary(days)
|
||||
|
||||
if args.as_json:
|
||||
print(json.dumps(asdict(summary), indent=2))
|
||||
else:
|
||||
print(format_summary(summary))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
43
index.html
43
index.html
@@ -475,6 +475,26 @@ html, body {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-status {
|
||||
min-height: 22px;
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.modal-status.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-status.success {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.modal-status.error {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -737,6 +757,7 @@ html, body {
|
||||
<textarea id="sp-environment" placeholder="e.g., Giving my car keys to a friend, locking away meds..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="safety-plan-status" class="modal-status" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
|
||||
@@ -818,6 +839,7 @@ Sovereignty and service always.`;
|
||||
var closeSafetyPlan = document.getElementById('close-safety-plan');
|
||||
var cancelSafetyPlan = document.getElementById('cancel-safety-plan');
|
||||
var saveSafetyPlan = document.getElementById('save-safety-plan');
|
||||
var safetyPlanStatus = document.getElementById('safety-plan-status');
|
||||
var clearChatBtn = document.getElementById('clear-chat-btn');
|
||||
|
||||
// ===== STATE =====
|
||||
@@ -1183,12 +1205,24 @@ Sovereignty and service always.`;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function setSafetyPlanStatus(message, type) {
|
||||
safetyPlanStatus.textContent = message;
|
||||
safetyPlanStatus.className = 'modal-status is-visible ' + (type || '');
|
||||
}
|
||||
|
||||
function clearSafetyPlanStatus() {
|
||||
safetyPlanStatus.textContent = '';
|
||||
safetyPlanStatus.className = 'modal-status';
|
||||
}
|
||||
|
||||
closeSafetyPlan.addEventListener('click', function() {
|
||||
clearSafetyPlanStatus();
|
||||
safetyPlanModal.classList.remove('active');
|
||||
_restoreSafetyPlanFocus();
|
||||
});
|
||||
|
||||
cancelSafetyPlan.addEventListener('click', function() {
|
||||
clearSafetyPlanStatus();
|
||||
safetyPlanModal.classList.remove('active');
|
||||
_restoreSafetyPlanFocus();
|
||||
});
|
||||
@@ -1203,11 +1237,9 @@ Sovereignty and service always.`;
|
||||
};
|
||||
try {
|
||||
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
|
||||
safetyPlanModal.classList.remove('active');
|
||||
_restoreSafetyPlanFocus();
|
||||
alert('Safety plan saved locally.');
|
||||
setSafetyPlanStatus('Safety plan saved locally.', 'success');
|
||||
} catch (e) {
|
||||
alert('Error saving plan.');
|
||||
setSafetyPlanStatus('Error saving plan.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1285,6 +1317,7 @@ Sovereignty and service always.`;
|
||||
|
||||
// Wire open buttons to activate focus trap
|
||||
safetyPlanBtn.addEventListener('click', function() {
|
||||
clearSafetyPlanStatus();
|
||||
loadSafetyPlan();
|
||||
safetyPlanModal.classList.add('active');
|
||||
_activateSafetyPlanFocusTrap(safetyPlanBtn);
|
||||
@@ -1293,6 +1326,8 @@ Sovereignty and service always.`;
|
||||
// Crisis panel safety plan button (if crisis panel is visible)
|
||||
if (crisisSafetyPlanBtn) {
|
||||
crisisSafetyPlanBtn.addEventListener('click', function() {
|
||||
clearSafetyPlanStatus();
|
||||
clearSafetyPlanStatus();
|
||||
loadSafetyPlan();
|
||||
safetyPlanModal.classList.add('active');
|
||||
_activateSafetyPlanFocusTrap(crisisSafetyPlanBtn);
|
||||
|
||||
52
tests/test_safety_plan_save_feedback.py
Normal file
52
tests/test_safety_plan_save_feedback.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import pathlib
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
INDEX_HTML = ROOT / 'index.html'
|
||||
|
||||
|
||||
class TestSafetyPlanSaveFeedback(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = INDEX_HTML.read_text()
|
||||
|
||||
def test_modal_has_inline_status_live_region(self):
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
r'<div[^>]+id="safety-plan-status"[^>]+role="status"[^>]+aria-live="polite"[^>]*>',
|
||||
'Expected an inline polite live region for safety plan save feedback.',
|
||||
)
|
||||
|
||||
def test_save_feedback_does_not_use_blocking_alerts(self):
|
||||
self.assertNotIn(
|
||||
"alert('Safety plan saved locally.')",
|
||||
self.html,
|
||||
'Expected success feedback to stop using blocking alert().',
|
||||
)
|
||||
self.assertNotIn(
|
||||
"alert('Error saving plan.')",
|
||||
self.html,
|
||||
'Expected error feedback to stop using blocking alert().',
|
||||
)
|
||||
|
||||
def test_save_logic_updates_inline_status_for_success_and_error(self):
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
r'function\s+setSafetyPlanStatus\s*\(',
|
||||
'Expected a helper to update inline save feedback.',
|
||||
)
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
r"setSafetyPlanStatus\('Safety plan saved locally\.'\s*,\s*'success'\)",
|
||||
'Expected success path to update inline status.',
|
||||
)
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
r"setSafetyPlanStatus\('Error saving plan\.'\s*,\s*'error'\)",
|
||||
'Expected error path to update inline status.',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user