Files
Reflect/Shared/Models/GuidedReflection.swift
Trey T cc4143d3ea 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>
2026-04-14 18:49:39 -05:00

550 lines
23 KiB
Swift

//
// GuidedReflection.swift
// Reflect
//
// Codable model for guided reflection responses, stored as JSON in MoodEntryModel.
//
import Foundation
// MARK: - Mood Category
enum MoodCategory: String, Codable {
case positive // great, good 3 questions (Behavioral Activation)
case neutral // average 4 questions (ACT Cognitive Defusion)
case negative // bad, horrible 5 questions (CBT Thought Record with evidence step)
init(from mood: Mood) {
switch mood {
case .great, .good: self = .positive
case .average: self = .neutral
case .horrible, .bad: self = .negative
default: self = .neutral
}
}
var questionCount: Int {
switch self {
case .positive: return 3
case .neutral: return 4
case .negative: return 5
}
}
/// The therapeutic technique name for display purposes.
var techniqueName: String {
switch self {
case .positive: return "Behavioral Activation"
case .neutral: return "Acceptance & Commitment Therapy"
case .negative: return "Cognitive Behavioral Therapy"
}
}
/// Short CBT step labels shown above each question.
var stepLabels: [String] {
switch self {
case .negative:
return [
String(localized: "Situation"),
String(localized: "Automatic Thought"),
String(localized: "Perspective Check"),
String(localized: "Evidence"),
String(localized: "Reframe"),
]
case .neutral:
return [
String(localized: "Awareness"),
String(localized: "Thought"),
String(localized: "Defusion"),
String(localized: "Values"),
]
case .positive:
return [
String(localized: "Activity"),
String(localized: "Awareness"),
String(localized: "Planning"),
]
}
}
}
// 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 {
let topRow: [String]
let expanded: [String]
var hasExpanded: Bool { !expanded.isEmpty }
static func chips(for category: MoodCategory, questionIndex: Int) -> QuestionChips? {
switch (category, questionIndex) {
// MARK: Positive (Great/Good) Behavioral Activation
// Q1: "What did you do today?" no chips (situational)
// Q2: "What thought or moment stands out?" memorable moments to savor
case (.positive, 1):
return QuestionChips(
topRow: [
String(localized: "guided_chip_pos_moment_conversation"),
String(localized: "guided_chip_pos_moment_accomplished"),
String(localized: "guided_chip_pos_moment_calm"),
String(localized: "guided_chip_pos_moment_laugh"),
String(localized: "guided_chip_pos_moment_grateful_person"),
String(localized: "guided_chip_pos_moment_small_win"),
],
expanded: [
String(localized: "guided_chip_pos_moment_beauty"),
String(localized: "guided_chip_pos_moment_connected"),
String(localized: "guided_chip_pos_moment_progress"),
String(localized: "guided_chip_pos_moment_like_myself"),
String(localized: "guided_chip_pos_moment_kindness"),
String(localized: "guided_chip_pos_moment_time_well_spent"),
]
)
// Q3: "How could you create more days like this?" reinforcing actions
case (.positive, 2):
return QuestionChips(
topRow: [
String(localized: "guided_chip_pos_act_more_of_this"),
String(localized: "guided_chip_pos_act_time_with_people"),
String(localized: "guided_chip_pos_act_get_outside"),
String(localized: "guided_chip_pos_act_stay_active"),
String(localized: "guided_chip_pos_act_keep_routine"),
String(localized: "guided_chip_pos_act_practice_gratitude"),
],
expanded: [
String(localized: "guided_chip_pos_act_celebrate_wins"),
String(localized: "guided_chip_pos_act_hobbies"),
String(localized: "guided_chip_pos_act_help_someone"),
String(localized: "guided_chip_pos_act_say_yes"),
String(localized: "guided_chip_pos_act_rest"),
String(localized: "guided_chip_pos_act_limit_doomscroll"),
]
)
// MARK: Neutral (Average) ACT Cognitive Defusion
// Q1: "What feeling has been sitting with you?" ambivalent feelings
case (.neutral, 0):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neu_boredom"),
String(localized: "guided_chip_neu_restlessness"),
String(localized: "guided_chip_neu_uncertainty"),
String(localized: "guided_chip_neu_numbness"),
String(localized: "guided_chip_neu_indifference"),
String(localized: "guided_chip_neu_distraction"),
],
expanded: [
String(localized: "guided_chip_neu_flatness"),
String(localized: "guided_chip_neu_disconnection"),
String(localized: "guided_chip_neu_ambivalence"),
String(localized: "guided_chip_neu_weariness"),
String(localized: "guided_chip_neu_apathy"),
String(localized: "guided_chip_neu_autopilot"),
]
)
// Q2: "What thought is connected to that feeling?" no chips (specific thought)
// Q3: "Is that thought true, or something your mind is telling you?" defusion reframes
case (.neutral, 2):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neu_def_just_a_thought"),
String(localized: "guided_chip_neu_def_storytelling"),
String(localized: "guided_chip_neu_def_thought_passed"),
String(localized: "guided_chip_neu_def_more_weight"),
String(localized: "guided_chip_neu_def_not_helpful"),
String(localized: "guided_chip_neu_def_notice_not_act"),
String(localized: "guided_chip_neu_def_dont_solve_now"),
String(localized: "guided_chip_neu_def_doesnt_define"),
],
expanded: []
)
// Q4: "What matters to you about tomorrow?" values-aligned intentions
case (.neutral, 3):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neu_val_be_present"),
String(localized: "guided_chip_neu_val_connect"),
String(localized: "guided_chip_neu_val_body"),
String(localized: "guided_chip_neu_val_meaningful_work"),
String(localized: "guided_chip_neu_val_kind_to_self"),
String(localized: "guided_chip_neu_val_small_goal"),
],
expanded: [
String(localized: "guided_chip_neu_val_less_autopilot"),
String(localized: "guided_chip_neu_val_one_thing_better"),
String(localized: "guided_chip_neu_val_ask_what_i_need"),
String(localized: "guided_chip_neu_val_let_go"),
]
)
// MARK: Negative (Bad/Horrible) CBT Thought Record
// Q1: "What happened today?" no chips (situational)
// Q2: "What thought kept coming back?" common automatic negative thoughts
case (.negative, 1):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neg_not_good_enough"),
String(localized: "guided_chip_neg_nothing_goes_right"),
String(localized: "guided_chip_neg_all_my_fault"),
String(localized: "guided_chip_neg_cant_handle"),
String(localized: "guided_chip_neg_no_one_understands"),
String(localized: "guided_chip_neg_should_have"),
],
expanded: [
String(localized: "guided_chip_neg_never_change"),
String(localized: "guided_chip_neg_falling_behind"),
String(localized: "guided_chip_neg_dont_matter"),
String(localized: "guided_chip_neg_something_wrong"),
String(localized: "guided_chip_neg_letting_down"),
String(localized: "guided_chip_neg_cant_do_right"),
]
)
// Q3: "What would you tell a friend?" compassionate reframes
case (.negative, 2):
return QuestionChips(
topRow: [
String(localized: "guided_chip_neg_too_hard"),
String(localized: "guided_chip_neg_one_bad_day"),
String(localized: "guided_chip_neg_better_than_think"),
String(localized: "guided_chip_neg_ok_to_struggle"),
String(localized: "guided_chip_neg_feeling_will_pass"),
String(localized: "guided_chip_neg_dont_need_figured"),
String(localized: "guided_chip_neg_not_whole_story"),
String(localized: "guided_chip_neg_give_grace"),
],
expanded: []
)
// 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"),
String(localized: "guided_chip_neg_act_facts_feelings"),
String(localized: "guided_chip_neg_act_matter_in_week"),
String(localized: "guided_chip_neg_act_got_through"),
String(localized: "guided_chip_neg_ref_one_chapter"),
String(localized: "guided_chip_neg_ref_doing_my_best"),
],
expanded: [
String(localized: "guided_chip_neg_act_talk_someone"),
String(localized: "guided_chip_neg_act_write_it_out"),
String(localized: "guided_chip_neg_act_take_walk"),
String(localized: "guided_chip_neg_act_step_away"),
String(localized: "guided_chip_neg_act_get_rest"),
String(localized: "guided_chip_neg_act_one_small_thing"),
]
)
default:
return nil
}
}
}
// MARK: - Guided Reflection
struct GuidedReflection: Codable, Equatable {
struct Response: Codable, Equatable, Identifiable {
var id: Int // question index (0-based)
let question: String
var answer: String
var selectedChips: [String] = []
enum CodingKeys: String, CodingKey {
case id, question, answer, selectedChips
}
init(id: Int, question: String, answer: String, selectedChips: [String] = []) {
self.id = id
self.question = question
self.answer = answer
self.selectedChips = selectedChips
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
question = try container.decode(String.self, forKey: .question)
answer = try container.decode(String.self, forKey: .answer)
selectedChips = try container.decodeIfPresent([String].self, forKey: .selectedChips) ?? []
}
}
let moodCategory: MoodCategory
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 {
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 {
responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
}
var totalQuestions: Int {
moodCategory.questionCount
}
// MARK: - Factory
static func createNew(for mood: Mood) -> GuidedReflection {
let category = MoodCategory(from: mood)
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
)
}
// 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 [
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 [
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 [
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? {
guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
static func decode(from json: String) -> GuidedReflection? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(GuidedReflection.self, from: data)
}
}