Expand guided reflection with CBT thought record and distortion routing

Adds a 5-step negative-mood reflection flow with an evidence-examination
step, Socratic templated questions that back-reference prior answers, and
a deterministic cognitive-distortion detector that routes the perspective-
check prompt to a distortion-specific reframe. Includes CBT plan docs,
flowchart, stats research notes, and MCP config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-14 18:49:39 -05:00
parent e6a34a0f25
commit cc4143d3ea
9 changed files with 2235 additions and 33 deletions

View File

@@ -12,7 +12,7 @@ import Foundation
enum MoodCategory: String, Codable {
case positive // great, good 3 questions (Behavioral Activation)
case neutral // average 4 questions (ACT Cognitive Defusion)
case negative // bad, horrible 4 questions (CBT Thought Record)
case negative // bad, horrible 5 questions (CBT Thought Record with evidence step)
init(from mood: Mood) {
switch mood {
@@ -26,7 +26,8 @@ enum MoodCategory: String, Codable {
var questionCount: Int {
switch self {
case .positive: return 3
case .neutral, .negative: return 4
case .neutral: return 4
case .negative: return 5
}
}
@@ -47,6 +48,7 @@ enum MoodCategory: String, Codable {
String(localized: "Situation"),
String(localized: "Automatic Thought"),
String(localized: "Perspective Check"),
String(localized: "Evidence"),
String(localized: "Reframe"),
]
case .neutral:
@@ -66,6 +68,52 @@ enum MoodCategory: String, Codable {
}
}
// MARK: - Cognitive Distortion
/// Detected cognitive distortion type in a user's automatic thought.
/// Used to route the perspective-check question to a distortion-specific reframe.
enum CognitiveDistortion: String, Codable {
case overgeneralization
case shouldStatement
case labeling
case personalization
case catastrophizing
case mindReading
case unknown
}
// MARK: - Question Template
/// A guided reflection question. May contain `%@` placeholders resolved at render time
/// by substituting the answer from a prior question (Socratic back-reference).
struct QuestionTemplate: Equatable {
/// Localized template text may contain a single `%@` format specifier.
let text: String
/// Zero-based index of the question whose answer to inject in place of `%@`.
/// Nil if this template is static (no placeholder).
let placeholderRef: Int?
/// Resolve the template against the provided ordered list of answers.
/// - Parameter answers: Array of (index, answer) pairs where `index` matches `placeholderRef`.
func resolved(with answers: [(index: Int, text: String)]) -> String {
guard let ref = placeholderRef else { return text }
let referenced = answers
.first(where: { $0.index == ref })?
.text
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !referenced.isEmpty, text.contains("%@") else {
// Fallback strip the placeholder marker so we never show a literal "%@".
return text.replacingOccurrences(of: "%@", with: "").trimmingCharacters(in: .whitespaces)
}
let injected = GuidedReflection.truncatedForInjection(referenced)
return String(format: text, injected)
}
}
// MARK: - Question Chips
struct QuestionChips {
@@ -221,8 +269,10 @@ struct QuestionChips {
expanded: []
)
// Q4: "More balanced way to see it?" cognitive reframes first, grounding actions expanded
case (.negative, 3):
// Q3 NEW: Evidence no chips (user explores both sides in free text)
// Q4 NEW Q5: "More balanced way to see it?" cognitive reframes first, grounding actions expanded
case (.negative, 4):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neg_act_worst_case"),
@@ -282,11 +332,72 @@ struct GuidedReflection: Codable, Equatable {
var responses: [Response]
var completedAt: Date?
// MARK: - New Fields (optional for back-compat with older saved reflections)
/// Emotional intensity rating before the reflection (0-10 scale).
var preIntensity: Int?
/// Emotional intensity rating after the reflection (0-10 scale). Measures change.
var postIntensity: Int?
/// Cognitive distortion detected in the automatic-thought response (negative path only).
var detectedDistortion: CognitiveDistortion?
// MARK: - Codable (tolerant of old JSON without new fields)
enum CodingKeys: String, CodingKey {
case moodCategory, responses, completedAt, preIntensity, postIntensity, detectedDistortion
}
init(
moodCategory: MoodCategory,
responses: [Response],
completedAt: Date?,
preIntensity: Int? = nil,
postIntensity: Int? = nil,
detectedDistortion: CognitiveDistortion? = nil
) {
self.moodCategory = moodCategory
self.responses = responses
self.completedAt = completedAt
self.preIntensity = preIntensity
self.postIntensity = postIntensity
self.detectedDistortion = detectedDistortion
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
moodCategory = try container.decode(MoodCategory.self, forKey: .moodCategory)
responses = try container.decode([Response].self, forKey: .responses)
completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt)
preIntensity = try container.decodeIfPresent(Int.self, forKey: .preIntensity)
postIntensity = try container.decodeIfPresent(Int.self, forKey: .postIntensity)
detectedDistortion = try container.decodeIfPresent(CognitiveDistortion.self, forKey: .detectedDistortion)
}
// MARK: - Computed Properties
/// A reflection is complete when every required question has a non-empty answer.
/// Intensity ratings are optional and do not gate completion.
///
/// Back-compat: old negative reflections saved with 4 responses are still considered
/// complete we detect the old shape and treat it as valid rather than forcing a re-prompt.
var isComplete: Bool {
responses.count == moodCategory.questionCount &&
responses.allSatisfy { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
let expectedCount = moodCategory.questionCount
let legacyNegativeCount = 4 // pre-evidence-step shape
let nonEmpty = responses.filter {
!$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}.count
if responses.count == expectedCount {
return nonEmpty == expectedCount
}
// Legacy negative reflection (pre-evidence-step) still valid.
if moodCategory == .negative && responses.count == legacyNegativeCount {
return nonEmpty == legacyNegativeCount
}
return false
}
var answeredCount: Int {
@@ -301,38 +412,129 @@ struct GuidedReflection: Codable, Equatable {
static func createNew(for mood: Mood) -> GuidedReflection {
let category = MoodCategory(from: mood)
let questionTexts = questions(for: category)
let responses = questionTexts.enumerated().map { index, question in
Response(id: index, question: question, answer: "")
let templates = questionTemplates(for: category)
let responses = templates.enumerated().map { index, template in
// Store the raw template text on creation the view layer will resolve
// and overwrite this with the user-visible text before saving.
Response(id: index, question: template.text, answer: "")
}
return GuidedReflection(moodCategory: category, responses: responses, completedAt: nil)
return GuidedReflection(
moodCategory: category,
responses: responses,
completedAt: nil
)
}
static func questions(for category: MoodCategory) -> [String] {
// MARK: - Question Templates
/// Returns the ordered template list for a mood category. Templates may contain
/// `%@` placeholders that the view layer fills in with prior answers at render time
/// (Socratic back-reference each question builds on the previous one).
static func questionTemplates(for category: MoodCategory) -> [QuestionTemplate] {
switch category {
case .positive:
// Behavioral Activation: situation savor plan
return [
String(localized: "guided_reflection_positive_q1"),
String(localized: "guided_reflection_positive_q2"),
String(localized: "guided_reflection_positive_q3"),
QuestionTemplate(
text: String(localized: "guided_reflection_positive_q1"),
placeholderRef: nil
),
QuestionTemplate(
text: String(localized: "guided_reflection_positive_q2"),
placeholderRef: nil
),
// Q3 references Q2's "moment that stood out" so the plan is specific.
QuestionTemplate(
text: String(localized: "guided_reflection_positive_q3_templated"),
placeholderRef: 1
),
]
case .neutral:
// ACT: awareness thought defusion values
return [
String(localized: "guided_reflection_neutral_q1"),
String(localized: "guided_reflection_neutral_q2"),
String(localized: "guided_reflection_neutral_q3"),
String(localized: "guided_reflection_neutral_q4"),
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q1"),
placeholderRef: nil
),
// Q2 references the feeling from Q1.
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q2_templated"),
placeholderRef: 0
),
// Q3 references the thought from Q2 (the thing to defuse from).
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q3_templated"),
placeholderRef: 1
),
QuestionTemplate(
text: String(localized: "guided_reflection_neutral_q4"),
placeholderRef: nil
),
]
case .negative:
// CBT Thought Record: situation thought perspective evidence reframe
return [
String(localized: "guided_reflection_negative_q1"),
String(localized: "guided_reflection_negative_q2"),
String(localized: "guided_reflection_negative_q3"),
String(localized: "guided_reflection_negative_q4"),
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q1"),
placeholderRef: nil
),
// Q2 references the situation from Q1.
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q2_templated"),
placeholderRef: 0
),
// Q3 is distortion-specific the view layer picks the right template
// based on the detected distortion in Q2. This default is the fallback.
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_templated"),
placeholderRef: 1
),
// Q4 is the new evidence-examination step (core of CBT Thought Record).
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q_evidence_templated"),
placeholderRef: 1
),
// Q5 is the balanced reframe, still referencing the original thought.
QuestionTemplate(
text: String(localized: "guided_reflection_negative_q4_templated"),
placeholderRef: 1
),
]
}
}
/// Legacy accessor returns templates resolved as static strings (no injection).
/// Kept for any callers that want plain text without a response context.
static func questions(for category: MoodCategory) -> [String] {
questionTemplates(for: category).map { $0.text }
}
// MARK: - Answer Injection Helper
/// Truncates a prior answer for injection into a follow-up question template.
/// Prefers breaking at a sentence boundary or word boundary within `maxLength`.
static func truncatedForInjection(_ text: String, maxLength: Int = 60) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count > maxLength else { return trimmed }
let prefix = String(trimmed.prefix(maxLength))
// Prefer a sentence boundary within the window.
let sentenceEnders: [Character] = [".", "!", "?"]
if let lastSentenceEnd = prefix.lastIndex(where: { sentenceEnders.contains($0) }) {
let candidate = String(prefix[..<lastSentenceEnd]).trimmingCharacters(in: .whitespaces)
if candidate.count >= 15 { // Avoid chopping too short.
return candidate + ""
}
}
// Fallback: last word boundary.
if let lastSpace = prefix.lastIndex(of: " ") {
return String(prefix[..<lastSpace]).trimmingCharacters(in: .whitespaces) + ""
}
return prefix + ""
}
// MARK: - JSON Helpers
func encode() -> String? {