diff --git a/evolution/__init__.py b/evolution/__init__.py new file mode 100644 index 0000000..238ff50 --- /dev/null +++ b/evolution/__init__.py @@ -0,0 +1 @@ +"""Evolution package for learning-oriented the-door modules.""" diff --git a/tests/test_crisis_synthesizer_integration.py b/tests/test_crisis_synthesizer_integration.py new file mode 100644 index 0000000..400712a --- /dev/null +++ b/tests/test_crisis_synthesizer_integration.py @@ -0,0 +1,90 @@ +"""Regression tests for crisis_synthesizer integration (issue #121).""" + +from __future__ import annotations + +import json +import os +from unittest.mock import Mock, patch + +from crisis_detector import CrisisResult +from crisis_responder import CrisisResponder +from crisis.synthesizer_integration import CrisisSynthesizerIntegration +from evolution.crisis_synthesizer import load_interaction_events + + +def _make_detection(level: str, keywords: list[str]) -> CrisisResult: + return CrisisResult(risk_level=level, matched_keywords=keywords, context=[], score=0.9) + + +def test_responder_auto_logs_anonymized_event(tmp_path): + integration = CrisisSynthesizerIntegration(enabled=True, log_dir=tmp_path) + responder = CrisisResponder(synth_integration=integration, session_id="session-1", async_synth_logging=False) + + detection = _make_detection("HIGH", ["hopeless", "can't go on"]) + response = responder.respond(detection) + + log_path = tmp_path / "events.jsonl" + lines = log_path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + event = json.loads(lines[0]) + assert event["level"] == "HIGH" + assert event["matched_keywords"] == ["hopeless", "can't go on"] + assert event["response_type"] == response.risk_level + assert isinstance(event["timestamp"], float) + assert event["user_continued"] is False + assert event["session_hash"] + assert "message" not in event + assert "session_id" not in event + assert log_path.stat().st_mode & 0o777 == 0o600 + + + +def test_next_non_crisis_message_marks_user_continued_append_only(tmp_path): + integration = CrisisSynthesizerIntegration(enabled=True, log_dir=tmp_path) + responder = CrisisResponder(synth_integration=integration, session_id="session-1", async_synth_logging=False) + + responder.respond(_make_detection("CRITICAL", ["want to die"])) + log_path = tmp_path / "events.jsonl" + before_size = log_path.stat().st_size + + responder.respond(_make_detection("NONE", [])) + after_size = log_path.stat().st_size + + assert after_size > before_size + + raw_lines = log_path.read_text(encoding="utf-8").splitlines() + assert len(raw_lines) == 2 + continuation = json.loads(raw_lines[1]) + assert continuation["event_type"] == "continuation" + assert continuation["user_continued"] is True + + folded_events = load_interaction_events(log_path) + assert len(folded_events) == 1 + assert folded_events[0]["continued_conversation"] is True + assert folded_events[0]["user_continued"] is True + + + +def test_env_var_can_disable_logging_entirely(tmp_path): + with patch.dict(os.environ, {"CRISIS_SYNTH_ENABLED": "0"}, clear=False): + integration = CrisisSynthesizerIntegration(enabled=None, log_dir=tmp_path) + responder = CrisisResponder(synth_integration=integration, session_id="session-1", async_synth_logging=False) + responder.respond(_make_detection("HIGH", ["hopeless"])) + + assert not (tmp_path / "events.jsonl").exists() + + +@patch("crisis_responder.threading.Thread") +def test_async_logging_dispatches_to_background_thread(thread_cls): + integration = Mock() + integration.enabled = True + integration.log_crisis_event = Mock() + integration.log_user_continued = Mock() + + responder = CrisisResponder(synth_integration=integration, session_id="session-1", async_synth_logging=True) + responder.respond(_make_detection("HIGH", ["hopeless"])) + + thread_cls.assert_called_once() + _, kwargs = thread_cls.call_args + assert kwargs["daemon"] is True + thread_cls.return_value.start.assert_called_once()