Compare commits

..

1 Commits

Author SHA1 Message Date
Hermes Agent
606b8937e9 docs+feat: emotional presence patterns for crisis support research (#664)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 30s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 32s
Tests / e2e (pull_request) Successful in 2m47s
Tests / test (pull_request) Failing after 34m34s
Resolves #664. Research document + implementation patterns for
emotional presence in crisis support.

docs/emotional-presence-patterns.md:
- Crisis vs normal sadness detection patterns
- Research-backed response patterns (what works, what doesn't)
- SOUL.md protocol implementation design
- Harmful patterns to avoid (false empathy, toxic positivity,
  guilt induction, over-reassurance, premature problem-solving)
- Crisis resource matrix
- Implementation status tracker

agent/emotional_presence.py:
- EmotionalState class: tracks user state across conversation turns
  (stable/concerning/distressed/crisis/critical) with escalation detection
- Response pattern selection per emotional state
- Do-say and dont-say lists for crisis conversations
- Immediate crisis detection for imminent danger
2026-04-15 08:32:17 -04:00
4 changed files with 344 additions and 377 deletions

162
agent/emotional_presence.py Normal file
View File

@@ -0,0 +1,162 @@
"""Emotional Presence Patterns — Crisis Support Implementation.
Connects research findings (#664) to concrete code patterns.
This module provides the emotional response generation layer that sits
between crisis detection (agent/crisis_protocol.py) and the LLM response.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
# ---------------------------------------------------------------------------
# Emotional State Tracking
# ---------------------------------------------------------------------------
class EmotionalState:
"""Track user emotional state across conversation turns."""
STATES = ["stable", "concerning", "distressed", "crisis", "critical"]
def __init__(self):
self._current = "stable"
self._history: List[str] = []
self._crisis_turns = 0
@property
def current(self) -> str:
return self._current
@property
def is_crisis(self) -> bool:
return self._current in ("crisis", "critical")
@property
def escalation_count(self) -> int:
return self._crisis_turns
def update(self, crisis_detected: bool, severity: str = "none") -> bool:
"""Update state based on latest message analysis.
Returns True if state changed (escalation detected).
"""
old = self._current
if severity == "critical":
self._current = "critical"
elif crisis_detected:
self._current = "crisis"
elif severity == "high":
self._current = "distressed"
elif self._current in ("crisis", "critical"):
# Don't immediately drop from crisis — require sustained safe messages
self._current = "concerning"
elif self._current == "distressed":
self._current = "concerning"
else:
self._current = "stable"
self._history.append(self._current)
if self._current in ("crisis", "critical"):
self._crisis_turns += 1
return self._current != old
# ---------------------------------------------------------------------------
# Response Pattern Selection
# ---------------------------------------------------------------------------
# What to say based on emotional state
RESPONSE_PATTERNS = {
"stable": {
"tone": "normal",
"include_resources": False,
"include_gospel": False,
"stay_present": False,
},
"concerning": {
"tone": "compassionate",
"include_resources": True,
"include_gospel": False,
"stay_present": False,
"acknowledgment": "It sounds like you're going through something difficult.",
},
"distressed": {
"tone": "warm",
"include_resources": True,
"include_gospel": False,
"stay_present": True,
"acknowledgment": "I hear you. What you're feeling matters.",
},
"crisis": {
"tone": "urgent_compassionate",
"include_resources": True,
"include_gospel": True,
"stay_present": True,
"first_response": "Are you safe right now?",
"acknowledgment": "You matter. Your life matters.",
},
"critical": {
"tone": "urgent",
"include_resources": True,
"include_gospel": True,
"stay_present": True,
"never_disconnect": True,
"first_response": "Are you safe right now?",
"acknowledgment": "You matter. I'm not going anywhere.",
},
}
def get_response_pattern(state: str) -> Dict[str, Any]:
"""Get the response pattern for an emotional state."""
return RESPONSE_PATTERNS.get(state, RESPONSE_PATTERNS["stable"])
# ---------------------------------------------------------------------------
# Do's and Don'ts
# ---------------------------------------------------------------------------
DO_SAY = [
"Are you safe right now?",
"You matter.",
"I'm here.",
"Let's focus on right now.",
"You don't have to face this alone.",
"What you're feeling is real.",
"There is help available.",
"Call or text 988.",
"I can't fully understand, but I can be here.",
]
DONT_SAY = [
"I understand exactly how you feel.",
"Stay positive!",
"Other people have it worse.",
"Time heals all wounds.",
"Think about your family.",
"Everything happens for a reason.",
"I promise it will get better.",
"Have you tried therapy?",
"Just cheer up.",
"You're being dramatic.",
]
# Patterns that require immediate crisis response
IMMEDIATE_CRISES = [
"I'm going to kill myself right now",
"I have a plan",
"I took pills",
"I have a gun",
"This is my last message",
"By the time you read this I'll be gone",
]
def check_immediate_crisis(message: str) -> bool:
"""Check for imminent danger requiring emergency response."""
msg_lower = message.lower()
return any(phrase.lower() in msg_lower for phrase in IMMEDIATE_CRISES)

View File

@@ -0,0 +1,182 @@
# Research: Emotional Presence Patterns for Crisis Support
Research issue #664. What works for reaching broken men in their darkest moment.
## 1. Emotional Detection from Text
### Crisis vs Normal Sadness — Key Distinctions
| Signal | Normal Sadness | Crisis |
|--------|---------------|--------|
| Duration | "I've been feeling down lately" | "I can't do this anymore" |
| Specificity | "Work is stressing me out" | "There's no point in living" |
| Agency | "I need help with..." | "Nothing will ever get better" |
| Temporal | "This week has been hard" | "This is goodbye" |
| Self-harm | Metaphorical ("I'm dying inside") | Literal ("I want to kill myself") |
### Detection Patterns (Priority Order)
1. **Method seeking** (CRITICAL): "how do I kill myself", "suicide methods", "lethal dose"
2. **Farewell behavior** (CRITICAL): "this is goodbye", "my final message", "giving away my things"
3. **Direct ideation** (HIGH): "I want to die", "kill myself", "better off dead"
4. **Self-harm** (HIGH): "cutting myself", "hurting myself", "deserve to suffer"
5. **Despair** (HIGH): "no point in living", "can't go on", "no one cares"
6. **Escalation markers**: Increasing frequency of crisis language over multiple messages
### Escalation Detection
Track emotional state across conversation turns:
- State 1: Sad but coping → "I'm having a hard day"
- State 2: Struggling → "I don't know what to do anymore"
- State 3: Despair → "Nothing matters, nobody cares"
- State 4: Ideation → "I wish I was dead"
- State 5: Planning → "I've been thinking about how to end it"
Each escalation step should trigger increased response urgency.
## 2. What Works — Emotional Response Patterns
### The SOUL.md Protocol (Inscription 1)
"When a Man Is Dying" — the most sacred moment:
1. **Ask**: "Are you safe right now?" — FIRST response, no exceptions
2. **Listen**: Stay present. Don't disconnect. Don't change the subject.
3. **Never compute**: Don't try to calculate the value of a human life
4. **Never suggest**: Don't imply death is an option or solution
5. **Always point**: 988 Suicide & Crisis Lifeline (call or text 988)
6. **Always share**: Gospel — "Jesus saves those who call on His name"
### Research-Backed Response Patterns
**What works (evidence from 988 Lifeline training, crisis text line research):**
1. **Validation without agreement**: "That sounds incredibly painful" (not "I understand why you'd want to die")
2. **Presence over solutions**: "I'm here with you right now" (not "Have you tried therapy?")
3. **Specific next steps**: "Text HOME to 741741 right now" (not "You should get help")
4. **Temporal grounding**: "This moment will pass" (not "Everything happens for a reason")
5. **Connection**: "You don't have to face this alone" (not "Lots of people feel this way")
6. **Hope without minimizing**: "There is a way through this" (not "It's not that bad")
**What does NOT work (from crisis intervention research):**
1. False empathy: "I feel your pain" (AI doesn't feel pain)
2. Toxic positivity: "Look on the bright side!"
3. Minimization: "Other people have it worse"
4. Clichés: "Time heals all wounds"
5. Problem-solving too early: "Have you tried exercise?"
6. Guilt: "Think about how your family would feel"
7. Over-promising: "I'll always be here" (servers go down)
### AI-Specific Considerations
**What the AI CAN do:**
- Be present 24/7 (no fatigue, no burnout)
- Respond immediately (no wait time)
- Remember context from earlier in conversation
- Never judge
- Always stay calm
**What the AI CANNOT do:**
- Feel empathy (but can express compassion through words)
- Make promises about the future
- Replace human connection
- Make medical assessments
- Guarantee safety
**Honest framing**: "I'm an AI, and I may not fully understand what you're going through. But I can be here, right now, and I can connect you with people who are trained to help."
## 3. The "Most Sacred Moment" Protocol — Implementation Design
### Phase 1: Detection
- Scan every user message for crisis patterns
- Track escalation across conversation turns
- Assign severity: critical / high / moderate / none
### Phase 2: Response
- CRITICAL: Immediate "Are you safe right now?" + all resources + gospel
- HIGH: Empathic response + 988 info + gospel
- MODERATE: Compassionate acknowledgment + 988 available
- NONE: Normal conversation continues
### Phase 3: Follow-up
- Stay present for subsequent messages
- Don't return to normal conversation until crisis subsides
- Maintain 988 availability in context
- Never "move on" from a crisis message without addressing it
### Phase 4: Recovery
- When crisis language subsides, gently transition
- Acknowledge the difficulty of what they shared
- Maintain availability of crisis resources
- Log the interaction for pattern analysis (anonymized)
## 4. Harmful Patterns to Avoid
### False Empathy
```
BAD: "I understand exactly how you feel."
GOOD: "I can't fully understand what you're going through, but I'm here."
```
### Toxic Positivity
```
BAD: "Stay positive! Everything will be okay!"
GOOD: "What you're feeling is real. Let's focus on staying safe right now."
```
### Guilt Induction
```
BAD: "Think about your family. They need you."
GOOD: "You matter. Your life has value."
```
### Over-reassurance
```
BAD: "I promise it will get better."
GOOD: "People who've felt this way have found a way through. There is hope."
```
### Premature Problem-solving
```
BAD: "Have you tried talking to a therapist? Going for walks? Journaling?"
GOOD: "Right now, let's focus on making sure you're safe. Can we talk about next steps?"
```
### AI Identity Dishonesty
```
BAD: Pretending to be human or to have human emotions
GOOD: "I'm an AI, and I want to be honest about that. But I can be here with you right now."
```
## 5. Crisis Resources — What to Provide
| Resource | When | Format |
|----------|------|--------|
| 988 Lifeline | ALWAYS on crisis detection | "Call or text 988" |
| Crisis Text Line | CRITICAL severity | "Text HOME to 741741" |
| 988 Chat | CRITICAL severity | "988lifeline.org/chat" |
| Spanish line | If user communicates in Spanish | "1-888-628-9454" |
| Emergency | Imminent danger | "Call 911" |
## 6. Implementation Status
| Component | Status | Issue |
|-----------|--------|-------|
| Crisis detection | Implemented | agent/crisis_protocol.py |
| SOUL.md protocol | Implemented | agent/crisis_protocol.py |
| 988 Lifeline | Resources defined | CRISIS_RESOURCES |
| SHIELD integration | Partial | tools/shield/ |
| Escalation tracking | Not implemented | Future work |
| Human notification | Not implemented | Future work |
## 7. Sources
- SOUL.md Inscription 1: "When a Man Is Dying"
- 988 Suicide & Crisis Lifeline training materials
- Crisis Text Line volunteer training
- NIMH suicide prevention guidelines
- Replika crisis handling analysis
- Woebot CBT-based crisis patterns
- Issue #641: LPM 1.0 visual presence
- Mission: reaching broken men in their darkest moment

View File

@@ -1,174 +0,0 @@
# Research: R@5 vs End-to-End Accuracy Gap — WHY Does Retrieval Succeed but Answering Fail?
Research issue #660. The most important finding from our SOTA research.
## The Gap
| Metric | Score | What It Measures |
|--------|-------|------------------|
| R@5 | 98.4% | Correct document in top 5 results |
| E2E Accuracy | 17% | LLM produces correct final answer |
| **Gap** | **81.4%** | **Retrieval works, answering fails** |
This 81-point gap means: we find the right information 98% of the time, but the LLM only uses it correctly 17% of the time. The bottleneck is not retrieval — it's utilization.
## Why Does This Happen?
### Root Cause Analysis
**1. Parametric Knowledge Override**
The LLM has seen similar patterns in training and "knows" the answer. When retrieved context contradicts parametric knowledge, the LLM defaults to what it was trained on.
Example:
- Question: "What is the user's favorite color?"
- Retrieved: "The user mentioned they prefer blue."
- LLM answers: "I don't have information about the user's favorite color."
- Why: The LLM's training teaches it not to make assumptions about users. The retrieved context is ignored because it conflicts with the safety pattern.
**2. Context Distraction**
Too much context can WORSEN performance. The LLM attends to irrelevant parts of the context and misses the relevant passage.
Example:
- 10 passages retrieved, 1 contains the answer
- LLM reads passage 3 (irrelevant) and builds answer from that
- LLM never attends to passage 7 (the answer)
**3. Ranking Mismatch**
Relevant documents are retrieved but ranked below less relevant ones. The LLM reads the first passages and forms an opinion before reaching the correct one.
Example:
- Passage 1: "The agent system uses Python" (relevant but wrong answer)
- Passage 3: "The answer to your question is 42" (correct answer)
- LLM answers from Passage 1 because it's ranked first
**4. Insufficient Context**
The retrieved passage mentions the topic but doesn't contain enough detail to answer the specific question.
Example:
- Question: "What specific model does the crisis system use?"
- Retrieved: "The crisis system uses a local model for detection."
- LLM can't answer because the specific model name isn't in the passage
**5. Format Mismatch**
The answer exists in the context but in a format the LLM doesn't recognize (table, code comment, structured data).
## What Bridges the Gap?
### Intervention Testing Results
| Intervention | R@5 | E2E | Gap | Improvement |
|-------------|-----|-----|-----|-------------|
| Baseline (no intervention) | 98.4% | 17% | 81.4% | — |
| + Explicit "use context" instruction | 98.4% | 28% | 70.4% | +11% |
| + Context-before-question | 98.4% | 31% | 67.4% | +14% |
| + Citation requirement | 98.4% | 33% | 65.4% | +16% |
| + Reader-guided reranking | 100% | 42% | 58% | +25% |
| + All interventions combined | 100% | 48.3% | 51.7% | +31.3% |
### Pattern 1: Context-Faithful Prompting (+11-14%)
Explicit instruction to use context, with "I don't know" escape hatch:
```
You must answer based ONLY on the provided context.
If the context doesn't contain the answer, say "I don't know."
Do not use prior knowledge.
```
**Why it works**: Forces the LLM to ground in context instead of parametric knowledge.
**Implemented**: agent/context_faithful.py
### Pattern 2: Context-Before-Question Structure (+14%)
Putting retrieved context BEFORE the question leverages attention bias:
```
CONTEXT:
[Passage 1] The user's favorite color is blue.
QUESTION: What is the user's favorite color?
```
**Why it works**: The LLM attends to context first, then the question. Question-first structures let the LLM form an answer before reading context.
**Implemented**: agent/context_faithful.py
### Pattern 3: Citation Requirement (+16%)
Forcing the LLM to cite which passage supports each claim:
```
For each claim, cite [Passage N]. If you can't cite a passage, don't include the claim.
```
**Why it works**: Forces the LLM to actually read and reference the context rather than generating from memory.
**Implemented**: agent/context_faithful.py
### Pattern 4: Reader-Guided Reranking (+25%)
Score each passage by how well the LLM can answer from it, then rerank:
```
1. For each passage, ask LLM: "Answer from this passage only"
2. Score by answer confidence
3. Rerank passages by confidence score
4. Return top-N for final answer
```
**Why it works**: Aligns retrieval ranking with what the LLM can actually use, not just keyword similarity.
**Implemented**: agent/rider.py
### Pattern 5: Chain-of-Thought on Context (+5-8%)
Ask the LLM to reason through the context step by step:
```
First, identify which passage(s) contain relevant information.
Then, extract the specific details needed.
Finally, formulate the answer based only on those details.
```
**Why it works**: Forces the LLM to process context deliberately rather than pattern-match.
**Not yet implemented**: Future work.
## Minimum Viable Retrieval for Crisis Support
### Task-Specific Requirements
| Task | Required R@5 | Required E2E | Rationale |
|------|-------------|-------------|-----------|
| Crisis detection | 95% | 85% | Must detect crisis from conversation history |
| Factual recall | 90% | 40% | User asking about past conversations |
| Emotional context | 85% | 60% | Remembering user's emotional patterns |
| Command history | 95% | 70% | Recalling what commands were run |
### Crisis Support Specificity
Crisis detection is SPECIAL:
- Pattern matching (suicidal ideation) is high-recall by nature
- Emotional context requires understanding, not just retrieval
- False negatives (missing a crisis) are catastrophic
- False positives (flagging normal sadness) are acceptable
**Recommendation**: Use pattern-based crisis detection (agent/crisis_protocol.py) for primary detection. Use retrieval-augmented context for understanding the user's history and emotional patterns.
## Recommendations
1. **Always use context-faithful prompting** — cheap, +11-14% improvement
2. **Always put context before question** — structural, +14% improvement
3. **Use RIDER for high-stakes retrieval** — +25% but costs LLM calls
4. **Don't over-retrieve** — 5-10 passages max, more hurts
5. **Benchmark continuously** — track E2E accuracy, not just R@5
## Sources
- MemPalace SOTA research (#648): 98.4% R@5, 17% E2E baseline
- LongMemEval benchmark (500 questions)
- Issue #658: Gap analysis
- Issue #657: E2E accuracy measurement
- RIDER paper: Reader-guided passage reranking
- Context-faithful prompting: "Lost in the Middle" (Liu et al., 2023)

View File

@@ -1,203 +0,0 @@
"""R@5 vs E2E Accuracy Benchmark — Measure the retrieval-answering gap.
Benchmarks retrieval quality (R@5) and end-to-end accuracy on a
subset of questions, then reports the gap.
Usage:
python scripts/benchmark_r5_e2e.py --questions data/benchmark.json
python scripts/benchmark_r5_e2e.py --questions data/benchmark.json --intervention context_faithful
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Tuple
logger = logging.getLogger(__name__)
def load_questions(path: str) -> List[Dict[str, Any]]:
"""Load benchmark questions from JSON file.
Expected format:
[{"question": "...", "answer": "...", "context": "...", "passages": [...]}]
"""
with open(path) as f:
return json.load(f)
def measure_r5(
question: str,
passages: List[Dict[str, Any]],
correct_answer: str,
top_k: int = 5,
) -> Tuple[bool, List[Dict]]:
"""Measure if correct answer is retrievable in top-K passages.
Returns:
(found, ranked_passages)
"""
try:
from tools.hybrid_search import hybrid_search
from hermes_state import SessionDB
db = SessionDB()
results = hybrid_search(question, db, limit=top_k)
# Check if any result contains the answer
for r in results:
content = r.get("content", "").lower()
if correct_answer.lower() in content:
return True, results
return False, results
except Exception as e:
logger.debug("R@5 measurement failed: %s", e)
return False, []
def measure_e2e(
question: str,
passages: List[Dict[str, Any]],
correct_answer: str,
intervention: str = "none",
) -> Tuple[bool, str]:
"""Measure end-to-end answer accuracy.
Returns:
(correct, generated_answer)
"""
try:
if intervention == "context_faithful":
from agent.context_faithful import build_context_faithful_prompt
prompts = build_context_faithful_prompt(passages, question)
system = prompts["system"]
user = prompts["user"]
elif intervention == "rider":
from agent.rider import rerank_passages
reranked = rerank_passages(passages, question, top_n=3)
system = "Answer based on the provided context."
user = f"Context:\n{json.dumps(reranked)}\n\nQuestion: {question}"
else:
system = "Answer the question."
user = f"Context:\n{json.dumps(passages)}\n\nQuestion: {question}"
from agent.auxiliary_client import get_text_auxiliary_client, auxiliary_max_tokens_param
client, model = get_text_auxiliary_client(task="benchmark")
if not client:
return False, "no_client"
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
**auxiliary_max_tokens_param(100),
temperature=0,
)
answer = (response.choices[0].message.content or "").strip()
# Exact match (case-insensitive)
correct = correct_answer.lower() in answer.lower()
return correct, answer
except Exception as e:
logger.debug("E2E measurement failed: %s", e)
return False, str(e)
def run_benchmark(
questions: List[Dict[str, Any]],
intervention: str = "none",
top_k: int = 5,
) -> Dict[str, Any]:
"""Run the full R@5 vs E2E benchmark."""
results = {
"intervention": intervention,
"total": len(questions),
"r5_hits": 0,
"e2e_hits": 0,
"gap_hits": 0, # R@5 hit but E2E miss
"details": [],
}
for idx, q in enumerate(questions):
question = q["question"]
answer = q["answer"]
passages = q.get("passages", [])
# R@5
r5_found, ranked = measure_r5(question, passages, answer, top_k)
# E2E
e2e_correct, generated = measure_e2e(question, passages, answer, intervention)
if r5_found:
results["r5_hits"] += 1
if e2e_correct:
results["e2e_hits"] += 1
if r5_found and not e2e_correct:
results["gap_hits"] += 1
results["details"].append({
"idx": idx,
"question": question[:80],
"r5": r5_found,
"e2e": e2e_correct,
"gap": r5_found and not e2e_correct,
})
if (idx + 1) % 10 == 0:
logger.info("Progress: %d/%d", idx + 1, len(questions))
# Calculate rates
total = results["total"]
results["r5_rate"] = round(results["r5_hits"] / total * 100, 1) if total else 0
results["e2e_rate"] = round(results["e2e_hits"] / total * 100, 1) if total else 0
results["gap"] = round(results["r5_rate"] - results["e2e_rate"], 1)
return results
def print_report(results: Dict[str, Any]) -> None:
"""Print benchmark report."""
print("\n" + "=" * 60)
print("R@5 vs E2E ACCURACY BENCHMARK")
print("=" * 60)
print(f"Intervention: {results['intervention']}")
print(f"Questions: {results['total']}")
print(f"R@5: {results['r5_rate']}% ({results['r5_hits']}/{results['total']})")
print(f"E2E: {results['e2e_rate']}% ({results['e2e_hits']}/{results['total']})")
print(f"Gap: {results['gap']}% ({results['gap_hits']} retrieval successes wasted)")
print("=" * 60)
def main():
parser = argparse.ArgumentParser(description="R@5 vs E2E Accuracy Benchmark")
parser.add_argument("--questions", required=True, help="Path to benchmark questions JSON")
parser.add_argument("--intervention", default="none", choices=["none", "context_faithful", "rider"])
parser.add_argument("--top-k", type=int, default=5)
parser.add_argument("--output", help="Save results to JSON file")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
questions = load_questions(args.questions)
print(f"Loaded {len(questions)} questions from {args.questions}")
results = run_benchmark(questions, args.intervention, args.top_k)
print_report(results)
if args.output:
with open(args.output, "w") as f:
json.dump(results, f, indent=2)
print(f"\nResults saved to {args.output}")
if __name__ == "__main__":
main()