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>
550 lines
23 KiB
Swift
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)
|
|
}
|
|
}
|