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? {
|
||||
|
||||
123
Shared/Services/CognitiveDistortionDetector.swift
Normal file
123
Shared/Services/CognitiveDistortionDetector.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" : "").
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user