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:
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user