Resolves #18 KI-Gesprächsvorschläge beim Anlegen eines Treffens

Neues Max-Plan-Feature: Beim Anlegen eines Treffens können KI-gestützte
Gesprächsvorschläge abgerufen werden. Die Vorschläge umfassen Themen-
vorschläge, Gesprächsretter (bei Stockungen) und Tipps um Smalltalk
in bedeutsame Gespräche zu verwandeln.

- AIAnalysisService: ConversationSuggestionResult, CachedConversationSuggestion,
  suggestConversation(person:), parseConversationResult (internal),
  buildPrompt auf PromptType-Enum umgestellt
- SettingsView: AppLanguage.conversationInstruction (DE + EN)
- AddMomentView: KI-Sektion (idle/loading/result/error) nur bei Treffen-Typ
- PaywallView: Gesprächsvorschläge in Max-Feature-Liste
- Localizable.xcstrings: 10 neue DE/EN-Strings
- Tests: 8 neue Unit-Tests für Parsing und Codable Round-Trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 06:17:25 +02:00
parent 18112cb52c
commit 319b59c12e
6 changed files with 564 additions and 5 deletions
+135 -4
View File
@@ -159,6 +159,34 @@ struct CachedGiftSuggestion: Codable {
let generatedAt: Date let generatedAt: Date
} }
// MARK: - Conversation Suggestion Result
struct ConversationSuggestionResult {
let topics: String // THEMEN:
let rescue: String // GESPRAECHSRETTER:
let depth: String // TIEFE:
}
// MARK: - Cached Conversation Suggestion
struct CachedConversationSuggestion: Codable {
let topics: String
let rescue: String
let depth: String
let generatedAt: Date
var asResult: ConversationSuggestionResult {
ConversationSuggestionResult(topics: topics, rescue: rescue, depth: depth)
}
init(result: ConversationSuggestionResult, date: Date = Date()) {
self.topics = result.topics
self.rescue = result.rescue
self.depth = result.depth
self.generatedAt = date
}
}
// MARK: - Service // MARK: - Service
class AIAnalysisService { class AIAnalysisService {
@@ -283,9 +311,12 @@ class AIAnalysisService {
// MARK: - Prompt Builder // MARK: - Prompt Builder
private enum PromptType {
case analysis, gift, conversation
}
/// Baut den vollständigen User-Prompt sprachabhängig auf. /// Baut den vollständigen User-Prompt sprachabhängig auf.
/// - `isGift`: true Geschenkideen-Instruktion, false Analyse-Instruktion private func buildPrompt(for person: Person, promptType: PromptType = .analysis) -> String {
private func buildPrompt(for person: Person, isGift: Bool = false) -> String {
let lang = AppLanguage.current let lang = AppLanguage.current
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -304,7 +335,13 @@ class AIAnalysisService {
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n" let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n"
let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? "" let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? ""
let culturalBg = person.culturalBackground.map { "\(lang.culturalBackgroundLabel): \($0)\n" } ?? "" let culturalBg = person.culturalBackground.map { "\(lang.culturalBackgroundLabel): \($0)\n" } ?? ""
let instruction = isGift ? lang.giftInstruction : lang.analysisInstruction
let instruction: String
switch promptType {
case .analysis: instruction = lang.analysisInstruction
case .gift: instruction = lang.giftInstruction
case .conversation: instruction = lang.conversationInstruction
}
return "Person: \(person.firstName)\n" return "Person: \(person.firstName)\n"
+ birthYearContext(for: person, language: lang) + birthYearContext(for: person, language: lang)
@@ -351,7 +388,7 @@ class AIAnalysisService {
throw URLError(.badURL) throw URLError(.badURL)
} }
let prompt = buildPrompt(for: person, isGift: true) let prompt = buildPrompt(for: person, promptType: .gift)
let body: [String: Any] = [ let body: [String: Any] = [
"model": config.model, "model": config.model,
@@ -397,6 +434,72 @@ class AIAnalysisService {
return normalized return normalized
} }
// MARK: - Conversation Cache
private func conversationCacheKey(for person: Person) -> String { "ai_conversation_\(person.id.uuidString)" }
func loadCachedConversation(for person: Person) -> CachedConversationSuggestion? {
guard let data = UserDefaults.standard.data(forKey: conversationCacheKey(for: person)),
let cached = try? JSONDecoder().decode(CachedConversationSuggestion.self, from: data)
else { return nil }
return cached
}
private func saveConversationCache(_ result: ConversationSuggestionResult, for person: Person) {
let cached = CachedConversationSuggestion(result: result)
if let data = try? JSONEncoder().encode(cached) {
UserDefaults.standard.set(data, forKey: conversationCacheKey(for: person))
}
}
// MARK: - Conversation Suggestion
func suggestConversation(person: Person) async throws -> ConversationSuggestionResult {
let config = AIConfig.load()
guard let url = URL(string: config.completionsURL) else {
throw URLError(.badURL)
}
let prompt = buildPrompt(for: person, promptType: .conversation)
let body: [String: Any] = [
"model": config.model,
"stream": false,
"messages": [
["role": "system", "content": AppLanguage.current.systemPrompt],
["role": "user", "content": prompt]
]
]
var request = URLRequest(url: url, timeoutInterval: config.timeoutSeconds)
request.httpMethod = "POST"
request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
guard
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let first = choices.first,
let message = first["message"] as? [String: Any],
let content = message["content"] as? String
else {
throw URLError(.cannotParseResponse)
}
let result = parseConversationResult(content)
recordRequest()
saveConversationCache(result, for: person)
return result
}
// MARK: - Result Parser // MARK: - Result Parser
private func parseResult(_ text: String) -> AIAnalysisResult { private func parseResult(_ text: String) -> AIAnalysisResult {
@@ -426,4 +529,32 @@ class AIAnalysisService {
recommendation: extract("EMPFEHLUNG") recommendation: extract("EMPFEHLUNG")
) )
} }
/// Extrahiert die drei Gesprächsvorschlag-Sektionen aus der KI-Antwort.
/// Internal (nicht private) damit Unit-Tests direkten Zugriff haben.
func parseConversationResult(_ text: String) -> ConversationSuggestionResult {
var normalized = text
for label in ["THEMEN", "GESPRAECHSRETTER", "TIEFE"] {
normalized = normalized
.replacingOccurrences(of: "**\(label):**", with: "\(label):")
.replacingOccurrences(of: "**\(label)**:", with: "\(label):")
.replacingOccurrences(of: "**\(label)** :", with: "\(label):")
}
func extract(_ label: String) -> String {
let pattern = "\(label):\\s*(.+?)(?=\\n(?:THEMEN|GESPRAECHSRETTER|TIEFE):|\\z)"
guard
let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]),
let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)),
let range = Range(match.range(at: 1), in: normalized)
else { return "" }
return String(normalized[range]).trimmingCharacters(in: .whitespacesAndNewlines)
}
return ConversationSuggestionResult(
topics: extract("THEMEN"),
rescue: extract("GESPRAECHSRETTER"),
depth: extract("TIEFE")
)
}
} }
+199 -1
View File
@@ -31,6 +31,13 @@ struct AddMomentView: View {
@State private var selectedCalendarID: String = "" @State private var selectedCalendarID: String = ""
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung @State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
// KI-Gesprächsvorschläge
@StateObject private var store = StoreManager.shared
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
@State private var conversationState: ConversationSuggestionUIState = .idle
@State private var showConversationPaywall = false
@State private var showConversationConsent = false
// Vorhaben: Erinnerung // Vorhaben: Erinnerung
@State private var addReminder = false @State private var addReminder = false
@State private var reminderDate: Date = { @State private var reminderDate: Date = {
@@ -132,6 +139,12 @@ struct AddMomentView: View {
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20) .padding(.horizontal, 20)
// KI-Gesprächsvorschläge (nur bei Treffen)
if selectedType == .meeting {
conversationSuggestionsSection
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Treffen: Kalendertermin // Treffen: Kalendertermin
if showsCalendarSection { if showsCalendarSection {
calendarSection calendarSection
@@ -148,6 +161,7 @@ struct AddMomentView: View {
} }
.animation(.easeInOut(duration: 0.2), value: showsCalendarSection) .animation(.easeInOut(duration: 0.2), value: showsCalendarSection)
.animation(.easeInOut(duration: 0.2), value: showsReminderSection) .animation(.easeInOut(duration: 0.2), value: showsReminderSection)
.animation(.easeInOut(duration: 0.2), value: selectedType)
.animation(.easeInOut(duration: 0.2), value: addToCalendar) .animation(.easeInOut(duration: 0.2), value: addToCalendar)
.animation(.easeInOut(duration: 0.2), value: addReminder) .animation(.easeInOut(duration: 0.2), value: addReminder)
.background(theme.backgroundPrimary.ignoresSafeArea()) .background(theme.backgroundPrimary.ignoresSafeArea())
@@ -167,7 +181,12 @@ struct AddMomentView: View {
} }
} }
} }
.onAppear { isFocused = true } .onAppear {
isFocused = true
if let cached = AIAnalysisService.shared.loadCachedConversation(for: person) {
conversationState = .result(cached.asResult, cached.generatedAt)
}
}
} }
// MARK: - Kalender-Sektion (Treffen) // MARK: - Kalender-Sektion (Treffen)
@@ -454,4 +473,183 @@ struct AddMomentView: View {
CalendarEventStore.save(momentID: momentID, eventIdentifier: identifier) CalendarEventStore.save(momentID: momentID, eventIdentifier: identifier)
} }
} }
// MARK: - KI-Gesprächsvorschläge
private var canUseAI: Bool {
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
}
private var conversationSuggestionsSection: some View {
Group {
switch conversationState {
case .idle:
conversationIdleButton
case .loading:
conversationLoadingView
case .result(let result, _):
conversationResultView(result: result)
case .error(let message):
conversationErrorView(message: message)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.sheet(isPresented: $showConversationPaywall) { PaywallView(targeting: .max) }
.sheet(isPresented: $showConversationConsent) {
AIConsentSheet {
aiConsentGiven = true
Task { await loadConversationSuggestions() }
}
}
}
private var conversationIdleButton: some View {
Button {
guard canUseAI else { showConversationPaywall = true; return }
if aiConsentGiven {
Task { await loadConversationSuggestions() }
} else {
showConversationConsent = true
}
} label: {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 13))
Text("Gesprächsvorschläge")
.font(.system(size: 14, weight: .medium))
Spacer()
if store.isMax {
MaxBadge()
} else if canUseAI {
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(theme.backgroundSecondary)
.clipShape(Capsule())
} else {
MaxBadge()
}
}
.foregroundStyle(canUseAI ? theme.accent : theme.contentSecondary)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
private var conversationLoadingView: some View {
HStack(spacing: 10) {
ProgressView().tint(theme.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Vorschläge werden generiert…")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
Text("Das kann einen Moment dauern.")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(16)
}
private func conversationResultView(result: ConversationSuggestionResult) -> some View {
VStack(alignment: .leading, spacing: 0) {
conversationSection(icon: "text.bubble", title: "Themenvorschläge", text: result.topics)
RowDivider()
conversationSection(icon: "lifepreserver", title: "Gesprächsretter", text: result.rescue)
RowDivider()
conversationSection(icon: "arrow.down.heart", title: "Tiefe erreichen", text: result.depth)
RowDivider()
Button {
Task { await loadConversationSuggestions() }
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise").font(.system(size: 12))
Text("Neue Vorschläge").font(.system(size: 13))
}
.foregroundStyle(theme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
}
private func conversationSection(icon: String, title: String, text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13))
.foregroundStyle(theme.accent)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(LocalizedStringKey(title))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.contentSecondary)
Text(text)
.font(.system(size: 14, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func conversationErrorView(message: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label("Vorschläge fehlgeschlagen", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(theme.contentSecondary)
Text(message)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Button {
Task { await loadConversationSuggestions() }
} label: {
Text("Erneut versuchen")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.accent)
}
}
.padding(16)
}
private func loadConversationSuggestions() async {
guard !AIAnalysisService.shared.isRateLimited else { return }
conversationState = .loading
do {
let result = try await AIAnalysisService.shared.suggestConversation(person: person)
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
conversationState = .result(result, Date())
} catch {
if let cached = AIAnalysisService.shared.loadCachedConversation(for: person) {
conversationState = .result(cached.asResult, cached.generatedAt)
} else {
conversationState = .error(error.localizedDescription)
}
}
}
}
// MARK: - Conversation Suggestion UI State
private enum ConversationSuggestionUIState {
case idle
case loading
case result(ConversationSuggestionResult, Date)
case error(String)
}
extension ConversationSuggestionUIState: Equatable {
static func == (lhs: ConversationSuggestionUIState, rhs: ConversationSuggestionUIState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading): return true
case (.error(let a), .error(let b)): return a == b
default: return false
}
}
} }
+103
View File
@@ -1208,6 +1208,17 @@
} }
} }
}, },
"Das kann einen Moment dauern." : {
"comment" : "AddMomentView conversation suggestions loading subtitle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This may take a moment."
}
}
}
},
"Daten werden geräteübergreifend synchronisiert" : { "Daten werden geräteübergreifend synchronisiert" : {
"comment" : "SettingsView iCloud sync enabled subtitle", "comment" : "SettingsView iCloud sync enabled subtitle",
"localizations" : { "localizations" : {
@@ -2586,6 +2597,41 @@
} }
} }
}, },
"Gesprächsretter" : {
"comment" : "AddMomentView conversation suggestions section title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conversation Rescue"
}
}
}
},
"Gesprächsvorschläge" : {
"comment" : "AddMomentView AI conversation suggestions button label",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conversation Suggestions"
}
}
}
},
"Gesprächsvorschläge: KI-Impulse für bessere Treffen" : {
"comment" : "PaywallView Max feature list item",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conversation Suggestions: AI impulses for better meetings"
}
}
}
},
"Gesprächszeit" : { "Gesprächszeit" : {
"comment" : "SettingsView section header / CallWindowSetupView nav title", "comment" : "SettingsView section header / CallWindowSetupView nav title",
"localizations" : { "localizations" : {
@@ -3902,6 +3948,17 @@
} }
} }
}, },
"Neue Vorschläge" : {
"comment" : "AddMomentView conversation suggestions reload button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New Suggestions"
}
}
}
},
"Neurodivers" : { "Neurodivers" : {
"comment" : "ThemePickerView neurodiverse themes group header", "comment" : "ThemePickerView neurodiverse themes group header",
"localizations" : { "localizations" : {
@@ -4812,6 +4869,18 @@
} }
} }
}, },
"Themenvorschläge" : {
"comment" : "AddMomentView conversation suggestions section title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Topic Suggestions"
}
}
}
},
"Tief & fokussiert · ND" : { "Tief & fokussiert · ND" : {
"comment" : "Theme tagline for Abyss", "comment" : "Theme tagline for Abyss",
"localizations" : { "localizations" : {
@@ -4823,6 +4892,18 @@
} }
} }
}, },
"Tiefe erreichen" : {
"comment" : "AddMomentView conversation suggestions section title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Going Deeper"
}
}
}
},
"Tippe auf + um jemanden hinzuzufügen." : { "Tippe auf + um jemanden hinzuzufügen." : {
"comment" : "A description of how to add a new contact.", "comment" : "A description of how to add a new contact.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -5347,6 +5428,28 @@
}, },
"Vorschau Geburtstage & Termine" : { "Vorschau Geburtstage & Termine" : {
},
"Vorschläge fehlgeschlagen" : {
"comment" : "AddMomentView conversation suggestions error title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suggestions Failed"
}
}
}
},
"Vorschläge werden generiert…" : {
"comment" : "AddMomentView conversation suggestions loading state",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Generating suggestions…"
}
}
}
}, },
"Wähle bis zu 3 Menschen aus deinem Adressbuch, die dir wichtig sind." : { "Wähle bis zu 3 Menschen aus deinem Adressbuch, die dir wichtig sind." : {
+1
View File
@@ -27,6 +27,7 @@ struct PaywallView: View {
private let maxExtraFeatures: [(icon: String, text: String)] = [ private let maxExtraFeatures: [(icon: String, text: String)] = [
("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"), ("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"),
("gift.fill", "Geschenkideen: KI-Vorschläge bei Geburtstagen"), ("gift.fill", "Geschenkideen: KI-Vorschläge bei Geburtstagen"),
("text.bubble.fill", "Gesprächsvorschläge: KI-Impulse für bessere Treffen"),
("infinity", "Unbegrenzte KI-Abfragen ohne Limit"), ("infinity", "Unbegrenzte KI-Abfragen ohne Limit"),
] ]
+9
View File
@@ -803,6 +803,15 @@ enum AppLanguage: String, CaseIterable {
} }
} }
var conversationInstruction: String {
switch self {
case .german:
return "Du bereitest mich auf ein bevorstehendes Treffen mit dieser Person vor. Basierend auf unserer bisherigen Beziehung und den gespeicherten Momenten, gib mir konkrete Gesprächsvorschläge. Sei persönlich und beziehe dich auf konkrete gemeinsame Erlebnisse. Kein Smalltalk, keine langen Einleitungen. Antworte in exakt diesem Format:\n\nTHEMEN: [2-3 konkrete Themen die ich ansprechen könnte]\nGESPRAECHSRETTER: [2-3 kurze Impulse falls das Gespräch stockt]\nTIEFE: [1-2 Sätze: ein konkreter Tipp wie ich das Gespräch vertiefen kann]"
case .english:
return "You are preparing me for an upcoming meeting with this person. Based on our relationship history and saved moments, give me concrete conversation suggestions. Be personal and reference specific shared experiences. No small talk, no long introductions. Respond in exactly this format:\n\nTHEMEN: [2-3 concrete topics I could bring up]\nGESPRAECHSRETTER: [2-3 short impulses in case the conversation stalls]\nTIEFE: [1-2 sentences: one concrete tip on how to deepen the conversation]"
}
}
var momentsLabel: String { self == .english ? "Moments" : "Momente" } var momentsLabel: String { self == .english ? "Moments" : "Momente" }
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" } var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" } var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
@@ -0,0 +1,117 @@
import Testing
import Foundation
@testable import nahbar
@Suite("ConversationSuggestionResult")
struct ConversationSuggestionTests {
let service = AIAnalysisService.shared
// MARK: - Parsing
@Test("Alle drei Sektionen werden korrekt extrahiert")
func parsesAllThreeSections() {
let input = """
THEMEN: Erinnerst du dich an euren Wandertrip letzten Herbst? Frag nach den Fotos.
GESPRAECHSRETTER: Stell eine offene Frage zu ihrer Arbeit. Erzähl von deiner letzten Begegnung mit X.
TIEFE: Frag sie, was ihr aktuell am meisten Energie gibt das öffnet meist viele Türen.
"""
let result = service.parseConversationResult(input)
#expect(result.topics.contains("Wandertrip"))
#expect(result.rescue.contains("offene Frage"))
#expect(result.depth.contains("Energie"))
}
@Test("Fettdruck-Markierungen werden vor dem Parsing normalisiert")
func normalizesBoldMarkers() {
let input = """
**THEMEN:** Letzte Reise gemeinsam besprechen.
**GESPRAECHSRETTER:** Nach dem Haustier fragen.
**TIEFE:** Frag nach einem Traum oder Ziel.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(!result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Alternativer Fettdruck-Stil wird ebenfalls normalisiert")
func normalizesAlternativeBoldStyle() {
let input = """
**THEMEN**: Thema eins.
**GESPRAECHSRETTER**: Rettungsanker.
**TIEFE**: Tiefer gehen.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(!result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Fehlende Sektion gibt leeren String zurück (kein Crash)")
func missingRescueSectionReturnsEmpty() {
let input = """
THEMEN: Nur dieses Thema.
TIEFE: Nur diese Tiefe.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Vollständig leere Eingabe gibt drei leere Strings zurück")
func emptyInputReturnsAllEmpty() {
let result = service.parseConversationResult("")
#expect(result.topics.isEmpty)
#expect(result.rescue.isEmpty)
#expect(result.depth.isEmpty)
}
@Test("Mehrzeiliger Sektionsinhalt bleibt vollständig erhalten")
func multilineTopicsPreserved() {
let input = """
THEMEN: Zeile 1
Zeile 2
Zeile 3
GESPRAECHSRETTER: Rettung.
TIEFE: Tiefe.
"""
let result = service.parseConversationResult(input)
#expect(result.topics.contains("Zeile 1"))
#expect(result.topics.contains("Zeile 2"))
}
// MARK: - Codable Round-Trip
@Test("CachedConversationSuggestion Codable Round-Trip")
func cacheRoundTrip() throws {
let original = ConversationSuggestionResult(
topics: "Reise besprechen",
rescue: "Nach Hobbys fragen",
depth: "Was gibt dir Energie?"
)
let cached = CachedConversationSuggestion(result: original, date: Date(timeIntervalSince1970: 1_000_000))
let data = try JSONEncoder().encode(cached)
let decoded = try JSONDecoder().decode(CachedConversationSuggestion.self, from: data)
#expect(decoded.topics == original.topics)
#expect(decoded.rescue == original.rescue)
#expect(decoded.depth == original.depth)
#expect(decoded.generatedAt.timeIntervalSince1970 == 1_000_000)
}
@Test("asResult gibt korrekte ConversationSuggestionResult zurück")
func asResultRoundTrip() {
let original = ConversationSuggestionResult(
topics: "Thema A",
rescue: "Rettung B",
depth: "Tiefe C"
)
let cached = CachedConversationSuggestion(result: original)
let result = cached.asResult
#expect(result.topics == "Thema A")
#expect(result.rescue == "Rettung B")
#expect(result.depth == "Tiefe C")
}
}