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? {

View File

@@ -0,0 +1,123 @@
//
// CognitiveDistortionDetector.swift
// Reflect
//
// Detects common cognitive distortions in a user's automatic-thought response.
// Used by the guided reflection flow to route to a distortion-specific reframe prompt.
//
// This is deterministic keyword matching, not ML chosen for offline support,
// privacy, and predictability. Keywords are sourced from localized strings so
// each language can tune its own detection rules.
//
import Foundation
enum CognitiveDistortionDetector {
/// Detects the most likely cognitive distortion in the given text.
/// Returns `.unknown` if no keywords match the caller should fall back
/// to the generic perspective-check prompt in that case.
///
/// When multiple distortions match, the first one in the priority order below wins.
/// This ordering puts more specific distortions before more general ones.
static func detect(in text: String) -> CognitiveDistortion {
let normalized = text.lowercased()
guard !normalized.trimmingCharacters(in: .whitespaces).isEmpty else {
return .unknown
}
// Priority order: specific general. First hit wins.
let checks: [(CognitiveDistortion, String)] = [
(.catastrophizing, "distortion_catastrophizing_keywords"),
(.mindReading, "distortion_mind_reading_keywords"),
(.personalization, "distortion_personalization_keywords"),
(.labeling, "distortion_labeling_keywords"),
(.shouldStatement, "distortion_should_keywords"),
(.overgeneralization, "distortion_overgeneralization_keywords"),
]
for (distortion, key) in checks {
let keywords = keywordList(forLocalizedKey: key)
if keywords.contains(where: { normalized.contains($0) }) {
return distortion
}
}
return .unknown
}
/// Loads a localized comma-separated keyword list, splits it, and lowercases each entry.
/// Whitespace around entries is trimmed.
private static func keywordList(forLocalizedKey key: String) -> [String] {
let raw = String(localized: String.LocalizationValue(key))
// Guard against an unresolved localization returning the key itself.
guard raw != key else { return [] }
return raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { !$0.isEmpty }
}
}
// MARK: - Distortion-Specific Question Templates
extension CognitiveDistortion {
/// Returns the perspective-check question template (Q3 in the negative path)
/// tailored to this distortion. The template takes the automatic-thought answer
/// as its `%@` placeholder (placeholderRef: 1).
var perspectiveCheckTemplate: QuestionTemplate {
switch self {
case .overgeneralization:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_overgeneralization"),
placeholderRef: 1
)
case .shouldStatement:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_should"),
placeholderRef: 1
)
case .labeling:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_labeling"),
placeholderRef: 1
)
case .personalization:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_personalization"),
placeholderRef: 1
)
case .catastrophizing:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_catastrophizing"),
placeholderRef: 1
)
case .mindReading:
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_mind_reading"),
placeholderRef: 1
)
case .unknown:
// Fallback the generic "what would you tell a friend" prompt.
return QuestionTemplate(
text: String(localized: "guided_reflection_negative_q3_templated"),
placeholderRef: 1
)
}
}
/// A short, user-facing name for the distortion used as the step label above
/// the perspective-check question so users learn the CBT vocabulary.
var stepLabel: String {
switch self {
case .overgeneralization: return String(localized: "Overgeneralization")
case .shouldStatement: return String(localized: "Should Statement")
case .labeling: return String(localized: "Labeling")
case .personalization: return String(localized: "Personalization")
case .catastrophizing: return String(localized: "Catastrophizing")
case .mindReading: return String(localized: "Mind Reading")
case .unknown: return String(localized: "Perspective Check")
}
}
}

View File

@@ -115,12 +115,38 @@ class FoundationModelsReflectionService {
}
.joined(separator: "\n\n")
// Intensity shift if captured, tells the AI how much the reflection helped.
var intensityLine = ""
if let pre = reflection.preIntensity, let post = reflection.postIntensity {
let delta = post - pre
let direction: String
if delta < 0 {
direction = "dropped by \(abs(delta))"
} else if delta > 0 {
direction = "rose by \(delta)"
} else {
direction = "stayed the same"
}
intensityLine = "\nEmotional intensity: \(pre)/10 before → \(post)/10 after (\(direction)).\n"
} else if let pre = reflection.preIntensity {
intensityLine = "\nStarting emotional intensity: \(pre)/10.\n"
}
// Detected cognitive distortion if present, helps the AI speak to the specific
// pattern the user worked through (e.g., "you caught yourself overgeneralizing").
var distortionLine = ""
if let distortion = reflection.detectedDistortion, distortion != .unknown {
distortionLine = "\nDetected cognitive distortion in their automatic thought: \(distortion.rawValue). " +
"Reference this pattern naturally in your observation without being clinical.\n"
}
return """
The user logged their mood as "\(moodName)" and completed a \(technique) reflection:
\(intensityLine)\(distortionLine)
\(qaPairs)
Respond with personalized feedback that references their specific answers.
Respond with personalized feedback that references their specific answers\
\(reflection.preIntensity != nil && reflection.postIntensity != nil ? " and acknowledges the shift in how they're feeling" : "").
"""
}
}

View File

@@ -171,10 +171,28 @@ struct GuidedReflectionView: View {
VStack(alignment: .leading, spacing: 24) {
progressSection
// Pre-intensity rating shown only on the first step, once.
// Captures the baseline emotional intensity so we can measure shift.
if currentStepIndex == 0 {
intensityCard(
title: String(localized: "guided_reflection_pre_intensity_title"),
value: preIntensityBinding
)
}
if let step = currentStep {
stepCard(step)
.id(step.id)
}
// Post-intensity rating shown on the final step, below the question.
// Measures how much the reflection shifted the feeling.
if isLastStep {
intensityCard(
title: String(localized: "guided_reflection_post_intensity_title"),
value: postIntensityBinding
)
}
}
.padding(.horizontal)
.padding(.top, 20)
@@ -184,6 +202,62 @@ struct GuidedReflectionView: View {
.onScrollPhaseChange(handleScrollPhaseChange)
}
// MARK: - Intensity Rating UI
private var preIntensityBinding: Binding<Int> {
Binding(
get: { draft.preIntensity ?? 5 },
set: { draft.preIntensity = $0 }
)
}
private var postIntensityBinding: Binding<Int> {
Binding(
get: { draft.postIntensity ?? 5 },
set: { draft.postIntensity = $0 }
)
}
@ViewBuilder
private func intensityCard(title: String, value: Binding<Int>) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(textColor)
HStack {
Text(String(localized: "guided_reflection_intensity_low"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("\(value.wrappedValue) / 10")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(accentColor)
Spacer()
Text(String(localized: "guided_reflection_intensity_high"))
.font(.caption)
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(value.wrappedValue) },
set: { value.wrappedValue = Int($0.rounded()) }
),
in: 0...10,
step: 1
)
.tint(accentColor)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(.secondarySystemBackground))
)
}
@ToolbarContentBuilder
private var navigationToolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
@@ -270,7 +344,9 @@ struct GuidedReflectionView: View {
.tracking(1.5)
}
Text(step.question)
// Resolve the template against current answers so Socratic back-references
// (e.g., "Looking at '<your thought>' again...") reflect edits in real time.
Text(draft.resolvedQuestion(for: step))
.font(.title3)
.fontWeight(.medium)
.foregroundColor(textColor)
@@ -279,6 +355,12 @@ struct GuidedReflectionView: View {
editor(for: step)
// Specificity probe gentle nudge if the Q1 (situation) answer is too vague.
// CBT works better on concrete events than generalized feelings.
if step.id == 0 && needsSpecificityProbe(for: step.answer) {
specificityProbe
}
if let chips = step.chips {
ChipSelectionView(
chips: chips,
@@ -297,6 +379,42 @@ struct GuidedReflectionView: View {
)
}
// MARK: - Specificity Probe
/// Vague phrases that should trigger the specificity nudge even if the text is
/// technically long enough. Matched case-insensitively against a trimmed answer.
private static let vaguePhrases: Set<String> = [
"idk", "i don't know", "i dont know",
"nothing", "everything", "nothing really",
"same as always", "same old", "dunno", "no idea"
]
private func needsSpecificityProbe(for answer: String) -> Bool {
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false } // don't nag before they've started
if trimmed.count < 25 { return true }
let lower = trimmed.lowercased()
return Self.vaguePhrases.contains(where: { lower == $0 || lower.hasPrefix($0 + " ") })
}
private var specificityProbe: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(accentColor)
.font(.footnote)
Text(String(localized: "guided_reflection_specificity_probe"))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(accentColor.opacity(0.08))
)
}
private func editor(for step: GuidedReflectionDraft.Step) -> some View {
VStack(alignment: .leading, spacing: 10) {
AutoSizingReflectionTextEditor(
@@ -421,6 +539,14 @@ struct GuidedReflectionView: View {
private func navigateForward() {
guard let nextStepID = draft.stepID(after: currentStepID) else { return }
focusedStepID = nil
// When leaving Q2 on the negative path, classify the automatic thought and
// swap Q3's template to the tailored reframe prompt. Idempotent and safe
// to run on every forward navigation.
if draft.moodCategory == .negative && currentStepID == 1 {
draft.recomputeDistortion()
}
updateCurrentStep(to: nextStepID)
}
@@ -535,8 +661,11 @@ struct GuidedReflectionView: View {
private struct GuidedReflectionDraft: Equatable {
struct Step: Identifiable, Equatable {
let id: Int
let question: String
let label: String?
/// The template this step renders from. Contains the raw localized text and
/// optional placeholder ref. The user-visible question is computed by calling
/// `GuidedReflectionDraft.resolvedQuestion(for:)` which injects prior answers.
var template: QuestionTemplate
var label: String?
let chips: QuestionChips?
var answer: String
var selectedChips: [String]
@@ -551,7 +680,7 @@ private struct GuidedReflectionDraft: Equatable {
static func == (lhs: Step, rhs: Step) -> Bool {
lhs.id == rhs.id &&
lhs.question == rhs.question &&
lhs.template == rhs.template &&
lhs.label == rhs.label &&
lhs.answer == rhs.answer &&
lhs.selectedChips == rhs.selectedChips
@@ -561,27 +690,86 @@ private struct GuidedReflectionDraft: Equatable {
let moodCategory: MoodCategory
var steps: [Step]
var completedAt: Date?
var preIntensity: Int?
var postIntensity: Int?
var detectedDistortion: CognitiveDistortion?
init(reflection: GuidedReflection) {
moodCategory = reflection.moodCategory
completedAt = reflection.completedAt
preIntensity = reflection.preIntensity
postIntensity = reflection.postIntensity
detectedDistortion = reflection.detectedDistortion
let questions = GuidedReflection.questions(for: reflection.moodCategory)
let templates = GuidedReflection.questionTemplates(for: reflection.moodCategory)
let labels = reflection.moodCategory.stepLabels
steps = questions.enumerated().map { index, question in
steps = templates.enumerated().map { index, template in
// Preserve existing answers if reflection is being resumed.
let existingResponse = reflection.responses.first(where: { $0.id == index })
?? (reflection.responses.indices.contains(index) ? reflection.responses[index] : nil)
return Step(
id: index,
question: question,
template: template,
label: labels.indices.contains(index) ? labels[index] : nil,
chips: QuestionChips.chips(for: reflection.moodCategory, questionIndex: index),
answer: existingResponse?.answer ?? "",
selectedChips: existingResponse?.selectedChips ?? []
)
}
// Re-apply any previously-detected distortion so Q3 restores its tailored template.
if let distortion = detectedDistortion, moodCategory == .negative {
applyDistortion(distortion)
}
}
/// Produces (index, answer) tuples suitable for `QuestionTemplate.resolved(with:)`.
private var answerTuples: [(index: Int, text: String)] {
steps.map { ($0.id, $0.answer) }
}
/// Resolves the user-visible question text for a step, injecting the latest
/// value of any referenced prior answer. Called at render time by the view.
func resolvedQuestion(for step: Step) -> String {
step.template.resolved(with: answerTuples)
}
func resolvedQuestion(forStepID stepID: Int) -> String {
guard let step = step(forStepID: stepID) else { return "" }
return resolvedQuestion(for: step)
}
/// Mutating: detect the cognitive distortion in the current Q2 answer (negative path only)
/// and swap Q3's template to the tailored prompt. Safe to call repeatedly if Q2 is empty
/// or detection yields `.unknown` this resets to the fallback template.
mutating func recomputeDistortion() {
guard moodCategory == .negative,
let q2 = steps.first(where: { $0.id == 1 }),
!q2.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
detectedDistortion = nil
applyDistortion(.unknown) // reset Q3 label to generic
return
}
let distortion = CognitiveDistortionDetector.detect(in: q2.answer)
detectedDistortion = distortion == .unknown ? nil : distortion
applyDistortion(distortion)
}
/// Overwrites Q3's template + label based on the detected distortion.
private mutating func applyDistortion(_ distortion: CognitiveDistortion) {
guard let q3Index = steps.firstIndex(where: { $0.id == 2 }) else { return }
steps[q3Index].template = distortion.perspectiveCheckTemplate
if distortion != .unknown {
steps[q3Index].label = distortion.stepLabel
} else {
// Reset to the default "Perspective Check" label from MoodCategory.stepLabels.
let defaults = moodCategory.stepLabels
steps[q3Index].label = defaults.indices.contains(2) ? defaults[2] : nil
}
}
var firstUnansweredStepID: Int? {
@@ -630,14 +818,19 @@ private struct GuidedReflectionDraft: Equatable {
GuidedReflection(
moodCategory: moodCategory,
responses: steps.map { step in
// Persist the user-visible resolved question text not the raw template
// so downstream consumers (AI feedback, history view) see what the user saw.
GuidedReflection.Response(
id: step.id,
question: step.question,
question: resolvedQuestion(for: step),
answer: step.answer,
selectedChips: step.selectedChips
)
},
completedAt: completedAt
completedAt: completedAt,
preIntensity: preIntensity,
postIntensity: postIntensity,
detectedDistortion: detectedDistortion
)
}
}