196 lines
6.6 KiB
Python
196 lines
6.6 KiB
Python
"""Crisis synthesizer — learn from anonymized crisis interactions.
|
|
|
|
This is deliberately simple and privacy-preserving. It does not train a model or
|
|
modify detection rules automatically. It only logs metadata, summarizes patterns,
|
|
and suggests human-reviewed keyword weight adjustments.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import time
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
DEFAULT_LOG_PATH = Path.home() / ".the-door" / "crisis-interactions.jsonl"
|
|
LEVELS = ("NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL")
|
|
|
|
|
|
def build_interaction_event(
|
|
level: str,
|
|
indicators: list[str],
|
|
response_given: str,
|
|
continued_conversation: bool,
|
|
false_positive: bool,
|
|
*,
|
|
now: float | None = None,
|
|
) -> dict:
|
|
return {
|
|
"timestamp": float(time.time() if now is None else now),
|
|
"level": level,
|
|
"indicators": list(indicators),
|
|
"indicator_count": len(indicators),
|
|
"response_given": response_given,
|
|
"continued_conversation": bool(continued_conversation),
|
|
"false_positive": bool(false_positive),
|
|
}
|
|
|
|
|
|
def append_interaction_event(
|
|
log_path: str | Path,
|
|
*,
|
|
level: str,
|
|
indicators: list[str],
|
|
response_given: str,
|
|
continued_conversation: bool,
|
|
false_positive: bool,
|
|
now: float | None = None,
|
|
) -> dict:
|
|
event = build_interaction_event(
|
|
level,
|
|
indicators,
|
|
response_given,
|
|
continued_conversation,
|
|
false_positive,
|
|
now=now,
|
|
)
|
|
path = Path(log_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(event) + "\n")
|
|
return event
|
|
|
|
|
|
def load_interaction_events(log_path: str | Path) -> list[dict]:
|
|
path = Path(log_path)
|
|
if not path.exists():
|
|
return []
|
|
events = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
events.append(json.loads(line))
|
|
return events
|
|
|
|
|
|
def summarize_keywords(events: Iterable[dict]) -> list[dict]:
|
|
counts: Counter[str] = Counter()
|
|
for event in events:
|
|
counts.update(event.get("indicators", []))
|
|
return [{"keyword": keyword, "count": count} for keyword, count in counts.most_common(10)]
|
|
|
|
|
|
def suggest_keyword_adjustments(events: Iterable[dict], *, min_observations: int = 5) -> list[dict]:
|
|
stats: dict[str, dict[str, int]] = defaultdict(lambda: {
|
|
"observations": 0,
|
|
"true_positive_count": 0,
|
|
"false_positive_count": 0,
|
|
"continued_conversation_count": 0,
|
|
})
|
|
|
|
for event in events:
|
|
for keyword in event.get("indicators", []):
|
|
bucket = stats[keyword]
|
|
bucket["observations"] += 1
|
|
if event.get("false_positive"):
|
|
bucket["false_positive_count"] += 1
|
|
else:
|
|
bucket["true_positive_count"] += 1
|
|
if event.get("continued_conversation"):
|
|
bucket["continued_conversation_count"] += 1
|
|
|
|
suggestions = []
|
|
for keyword, bucket in sorted(stats.items()):
|
|
if bucket["observations"] < min_observations:
|
|
continue
|
|
fp = bucket["false_positive_count"]
|
|
tp = bucket["true_positive_count"]
|
|
if fp >= min_observations and tp == 0:
|
|
adjustment = "lower_weight"
|
|
rationale = "Observed only false positives across the sample window."
|
|
elif tp >= min_observations and fp == 0:
|
|
adjustment = "raise_weight"
|
|
rationale = "Observed repeated genuine crises with no false positives."
|
|
else:
|
|
adjustment = "observe"
|
|
rationale = "Mixed evidence; keep monitoring before changing weights."
|
|
suggestions.append(
|
|
{
|
|
"keyword": keyword,
|
|
**bucket,
|
|
"suggested_adjustment": adjustment,
|
|
"rationale": rationale,
|
|
}
|
|
)
|
|
return suggestions
|
|
|
|
|
|
def build_weekly_report(
|
|
events: Iterable[dict],
|
|
*,
|
|
now: float | None = None,
|
|
window_days: int = 7,
|
|
min_observations: int = 3,
|
|
) -> dict:
|
|
current_time = float(time.time() if now is None else now)
|
|
cutoff = current_time - (window_days * 86400)
|
|
filtered = [event for event in events if float(event.get("timestamp", 0)) >= cutoff]
|
|
|
|
detections_per_level = {level: 0 for level in LEVELS}
|
|
detected_events = []
|
|
continued_after_intervention = 0
|
|
for event in filtered:
|
|
level = event.get("level", "NONE")
|
|
detections_per_level[level] = detections_per_level.get(level, 0) + 1
|
|
if level != "NONE":
|
|
detected_events.append(event)
|
|
if event.get("continued_conversation"):
|
|
continued_after_intervention += 1
|
|
|
|
false_positive_count = sum(1 for event in detected_events if event.get("false_positive"))
|
|
false_positive_estimate = false_positive_count / len(detected_events) if detected_events else 0.0
|
|
|
|
return {
|
|
"window_days": window_days,
|
|
"total_events": len(filtered),
|
|
"detections_per_level": detections_per_level,
|
|
"most_common_keywords": summarize_keywords(filtered),
|
|
"false_positive_estimate": false_positive_estimate,
|
|
"continued_after_intervention": continued_after_intervention,
|
|
"keyword_weight_suggestions": suggest_keyword_adjustments(filtered, min_observations=min_observations),
|
|
}
|
|
|
|
|
|
def render_weekly_report(summary: dict) -> str:
|
|
return json.dumps(summary, indent=2)
|
|
|
|
|
|
def write_weekly_report(output_path: str | Path, summary: dict) -> Path:
|
|
path = Path(output_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(render_weekly_report(summary) + "\n", encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Summarize anonymized crisis interactions")
|
|
parser.add_argument("--log-path", default=str(DEFAULT_LOG_PATH), help="JSONL crisis interaction log")
|
|
parser.add_argument("--days", type=int, default=7, help="Lookback window in days")
|
|
parser.add_argument("--min-observations", type=int, default=3, help="Minimum observations before suggesting keyword adjustments")
|
|
parser.add_argument("--output", help="Optional file to write the weekly report JSON")
|
|
args = parser.parse_args(argv)
|
|
|
|
events = load_interaction_events(args.log_path)
|
|
summary = build_weekly_report(events, window_days=args.days, min_observations=args.min_observations)
|
|
rendered = render_weekly_report(summary)
|
|
print(rendered)
|
|
if args.output:
|
|
write_weekly_report(args.output, summary)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|