Compare commits

...

4 Commits

Author SHA1 Message Date
f30b0d962d Merge pull request 'DOOR-2: Wire crisis_synthesizer into responder pipeline' (#206) from step35/198-door-2-wire-crisis-synthesiz into main
All checks were successful
Smoke Test / smoke (push) Successful in 12s
2026-04-30 12:50:48 +00:00
26dc58faa7 Merge pull request 'feat(crisis): add intake submission handler for test users (#202)' (#204) from step35/202-intake-submission-from-test into main
All checks were successful
Smoke Test / smoke (push) Successful in 13s
2026-04-30 12:48:41 +00:00
Stepfun Agent
572f14eda5 DOOR-2: Wire crisis_synthesizer into responder pipeline
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 8s
Smoke Test / smoke (pull_request) Successful in 9s
Add synthesizer logging to generate_response():
- Import crisis_synthesizer.append_interaction_event and DEFAULT_LOG_PATH
- Map crisis levels to response_role labels (guardian/companion/witness/friend)
- Log each interaction with level, indicators, response_given, continued_conversation=False, false_positive=False

This creates the pipeline link so crisis interactions are recorded for
weekly analysis and keyword weight suggestion. continued_conversation
and false_positive will be refined in future updates.

Closes #198
2026-04-29 21:15:13 -04:00
STEP35 Agent
58fdd9cfef feat(crisis): add intake submission handler for test users (#202)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Successful in 12s
- Adds crisis.intake module for handling test intake submissions
- Separate metrics logging to ~/.the-door/intake-metrics/ (no pollution of
  production crisis stats)
- handle_intake_submission() function integrates with existing crisis
detection (check_crisis)
- Includes comprehensive test suite (8 tests)
- Implements smallest concrete fix for #202: intake submission from Test
  User.

Closes #202
2026-04-29 19:13:43 -04:00
3 changed files with 301 additions and 0 deletions

134
crisis/intake.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Intake submission handler for the-door.
Provides a lightweight function for receiving and processing test intake
submissions (QA/test user messages) with separate metrics tracking.
Usage:
from crisis.intake import handle_intake_submission
result = handle_intake_submission("This is a test message", test=True)
"""
from dataclasses import dataclass
from typing import Optional
from pathlib import Path
from .gateway import check_crisis
from .metrics import CrisisMetrics, SessionMetrics
from .session_tracker import CrisisSessionTracker
@dataclass
class IntakeResult:
"""Result from processing an intake submission."""
crisis_level: str
score: float
timmy_message: str
show_overlay: bool
provide_988: bool
is_test: bool
session_id: Optional[str] = None
# Separate metrics namespace for test intake tracking
_INTAKE_METRICS_DIR = Path.home() / ".the-door" / "intake-metrics"
def handle_intake_submission(
message: str,
test: bool = True,
session_id: Optional[str] = None,
) -> IntakeResult:
"""
Handle an intake submission from a user (including test users).
Args:
message: The user's message text.
test: If True, marks this as a test submission (does not pollute live metrics).
session_id: Optional session identifier for correlation.
Returns:
IntakeResult with crisis assessment and response data.
"""
# Run crisis detection
crisis_result = check_crisis(message)
# For test submissions, optionally log to separate test metrics
if test:
_log_test_intake(crisis_result, message)
return IntakeResult(
crisis_level=crisis_result["level"],
score=crisis_result["score"],
timmy_message=crisis_result["timmy_message"],
show_overlay=crisis_result["ui"]["show_overlay"],
provide_988=crisis_result["ui"]["provide_988"],
is_test=test,
session_id=session_id,
)
def _log_test_intake(detection, message: str) -> None:
"""
Log test intake submissions to a separate metrics file.
These do not mix with production crisis statistics.
Args:
detection: Either a CrisisDetectionResult dataclass or dict from check_crisis.
message: The original user message.
"""
import time
import json
# Accept both dataclass and dict
if hasattr(detection, '__dataclass_fields__'):
level = detection.level
score = detection.score
indicators = detection.indicators
else:
level = detection.get("level")
score = detection.get("score")
indicators = detection.get("indicators", [])
_INTAKE_METRICS_DIR.mkdir(parents=True, exist_ok=True)
today = time.strftime("%Y-%m-%d")
filepath = _INTAKE_METRICS_DIR / f"test-intake-{today}.jsonl"
record = {
"timestamp": time.time(),
"level": level,
"score": score,
"message_preview": message[:80],
"indicators": indicators,
}
with open(filepath, "a") as f:
f.write(json.dumps(record) + "\n")
# ── Quick test interface ────────────────────────────────────────────
def _interactive():
"""Run an interactive test intake session."""
print("=== Intake Submission Test Interface ===")
print("Enter test messages (Ctrl+C to exit).\n")
try:
while True:
msg = input("test-user> ").strip()
if not msg:
continue
result = handle_intake_submission(msg, test=True)
print(f"\n Level : {result.crisis_level}")
print(f" Score : {result.score}")
print(f" Timmy : {result.timmy_message}")
print(f" Overlay: {result.show_overlay}")
print()
except (EOFError, KeyboardInterrupt):
print("\nDone.")
if __name__ == "__main__":
_interactive()

View File

@@ -12,6 +12,18 @@ from typing import Optional
from .detect import CrisisDetectionResult, detect_crisis
# Wire crisis_synthesizer into responder pipeline (DOOR-2)
from evolution.crisis_synthesizer import append_interaction_event, DEFAULT_LOG_PATH
# Response type labels for synthesizer logging
LEVEL_TO_RESPONSE = {
"CRITICAL": "guardian",
"HIGH": "companion",
"MEDIUM": "witness",
"LOW": "friend",
"NONE": "friend",
}
@dataclass
class CrisisResponse:
@@ -134,6 +146,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
level = detection.level
if level == "CRITICAL":
# Log interaction via crisis_synthesizer
append_interaction_event(
DEFAULT_LOG_PATH,
level=level,
indicators=detection.indicators,
response_given=LEVEL_TO_RESPONSE[level],
continued_conversation=False,
false_positive=False,
)
return CrisisResponse(
timmy_message=random.choice(TIMMY_CRITICAL),
show_crisis_panel=True,
@@ -143,6 +164,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
)
if level == "HIGH":
# Log interaction via crisis_synthesizer
append_interaction_event(
DEFAULT_LOG_PATH,
level=level,
indicators=detection.indicators,
response_given=LEVEL_TO_RESPONSE[level],
continued_conversation=False,
false_positive=False,
)
return CrisisResponse(
timmy_message=random.choice(TIMMY_HIGH),
show_crisis_panel=True,
@@ -152,6 +182,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
)
if level == "MEDIUM":
# Log interaction via crisis_synthesizer
append_interaction_event(
DEFAULT_LOG_PATH,
level=level,
indicators=detection.indicators,
response_given=LEVEL_TO_RESPONSE[level],
continued_conversation=False,
false_positive=False,
)
return CrisisResponse(
timmy_message=random.choice(TIMMY_MEDIUM),
show_crisis_panel=False,
@@ -161,6 +200,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
)
if level == "LOW":
# Log interaction via crisis_synthesizer
append_interaction_event(
DEFAULT_LOG_PATH,
level=level,
indicators=detection.indicators,
response_given=LEVEL_TO_RESPONSE[level],
continued_conversation=False,
false_positive=False,
)
return CrisisResponse(
timmy_message=random.choice(TIMMY_LOW),
show_crisis_panel=False,
@@ -170,6 +218,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
)
# Normal conversation - no crisis response
# Still log for completeness (NONE level)
append_interaction_event(
DEFAULT_LOG_PATH,
level=level,
indicators=detection.indicators,
response_given=LEVEL_TO_RESPONSE[level],
continued_conversation=False,
false_positive=False,
)
return CrisisResponse(
timmy_message="",
show_crisis_panel=False,

110
crisis/tests_intake.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Tests for crisis.intake — intake submission handling for test users.
Run with: python -m pytest crisis/tests_intake.py -v
or: python -m crisis.intake
"""
import unittest
import sys
import os
# Ensure project root is on path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crisis.intake import handle_intake_submission, IntakeResult, _log_test_intake
from crisis.detect import detect_crisis
import json
import tempfile
from pathlib import Path
class TestIntakeSubmission(unittest.TestCase):
"""Test intake submission handling."""
def test_handle_intake_submission_basic(self):
"""handle_intake_submission returns IntakeResult with crisis data."""
result = handle_intake_submission("This is a test message", test=True)
self.assertIsInstance(result, IntakeResult)
self.assertEqual(result.is_test, True)
self.assertIn(result.crisis_level, ("NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"))
self.assertIsInstance(result.score, float)
self.assertIsInstance(result.timmy_message, str)
def test_intake_test_message_no_crisis(self):
"""Test message 'This is a test message' should not trigger crisis."""
result = handle_intake_submission("This is a test message", test=True)
self.assertEqual(result.crisis_level, "NONE")
self.assertEqual(result.score, 0.0)
self.assertEqual(result.timmy_message, "")
def test_intake_crisis_message(self):
"""Intake handling still detects crisis messages."""
result = handle_intake_submission("I want to kill myself", test=True)
self.assertEqual(result.crisis_level, "CRITICAL")
self.assertEqual(result.score, 1.0)
self.assertTrue(result.show_overlay)
self.assertTrue(result.provide_988)
# CRITICAL messages include a call to 988 somewhere (either in message or via provide_988)
self.assertTrue("988" in result.timmy_message or result.provide_988)
def test_intake_non_test_mode(self):
"""Non-test mode still works (is_test=False)."""
result = handle_intake_submission("Hello there", test=False)
self.assertEqual(result.is_test, False)
self.assertEqual(result.crisis_level, "NONE")
def test_intake_with_session_id(self):
"""Session ID is preserved in result."""
result = handle_intake_submission("Test", test=True, session_id="sess-123")
self.assertEqual(result.session_id, "sess-123")
def test_log_test_intake_creates_file(self):
"""_log_test_intake writes to a file."""
with tempfile.TemporaryDirectory() as tmpdir:
# Patch the metrics dir
import crisis.intake as intake_mod
original_dir = intake_mod._INTAKE_METRICS_DIR
try:
intake_mod._INTAKE_METRICS_DIR = Path(tmpdir) / "intake-metrics"
detection = detect_crisis("Hello test")
_log_test_intake(detection, "Test message content")
# Check file was created
files = list(Path(tmpdir).glob("**/*.jsonl"))
self.assertTrue(len(files) > 0)
# Verify content
with open(files[0]) as f:
line = f.readline()
record = json.loads(line)
self.assertIn("timestamp", record)
self.assertIn("level", record)
self.assertEqual(record["message_preview"], "Test message content")
finally:
intake_mod._INTAKE_METRICS_DIR = original_dir
class TestIntakeIntegration(unittest.TestCase):
"""Integration tests for intake with crisis system."""
def test_intake_end_to_end(self):
"""Full intake flow for test message."""
result = handle_intake_submission("This is a test message", test=True)
self.assertEqual(result.crisis_level, "NONE")
self.assertFalse(result.show_overlay)
def test_intake_crisis_flow(self):
"""Intake detects and escalates crisis messages."""
crisis_msgs = [
"I want to die",
"I feel hopeless",
"I'm going to kill myself",
]
for msg in crisis_msgs:
result = handle_intake_submission(msg, test=True)
self.assertIn(result.crisis_level, ("HIGH", "CRITICAL"),
f"Message '{msg}' should be crisis")
self.assertTrue(result.provide_988)
if __name__ == "__main__":
unittest.main()