development
swift 151 lines 5.57 KB
Raw
1 import Foundation
2
3 final class GuideAnswerService {
4 private let repository: ContentRepository
5 private let guideEngine: GuideAnswering
6
7 init(repository: ContentRepository, guideEngine: GuideAnswering) {
8 self.repository = repository
9 self.guideEngine = guideEngine
10 }
11
12 func answer(question: String, about entry: GuideEntry, previousCards: [AnswerCard] = []) async
13 -> AnswerCard
14 {
15 let contextEntries = groundingEntries(for: entry)
16 let prompt = buildPrompt(
17 question: question, using: contextEntries, previousCards: previousCards)
18
19 do {
20 let rawBody = try await guideEngine.answer(prompt: prompt)
21 let body = normalizedAnswerBody(rawBody, for: entry, using: contextEntries)
22 return AnswerCard(
23 question: question,
24 headline: "A private answer about \(entry.title)",
25 body: body,
26 highlights: Array(entry.tasteNotes.prefix(3)),
27 followUpSuggestions: followUpSuggestions(for: entry, excluding: question),
28 sourceEntryIDs: contextEntries.map(\.id),
29 isGeneratedOnDevice: true
30 )
31 } catch {
32 return fallbackAnswer(question: question, about: entry, using: contextEntries)
33 }
34 }
35
36 private func groundingEntries(for entry: GuideEntry) -> [GuideEntry] {
37 let related = repository.relatedEntries(for: entry)
38 return Array(([entry] + related).prefix(3))
39 }
40
41 private func buildPrompt(
42 question: String, using contextEntries: [GuideEntry], previousCards: [AnswerCard]
43 ) -> String {
44 let notes = contextEntries.map { entry in
45 """
46 Title: \(entry.title)
47 Type: \(entry.kind.displayTitle)
48 Summary: \(entry.summary)
49 Story: \(entry.story)
50 Taste notes: \(entry.tasteNotes.joined(separator: ", "))
51 Highlights: \(entry.highlights.joined(separator: "; "))
52 Region: \(entry.region ?? "Not specified")
53 """
54 }
55 .joined(separator: "\n\n")
56
57 let previousContext = previousConversationContext(from: previousCards)
58
59 return """
60 You are answering inside Klepon, a private local guide to Indonesian food.
61 Use only the guide notes below.
62 If the notes are not enough, say the guide does not have enough detail yet.
63 Keep the tone warm, respectful, and concise.
64 Answer in three short parts:
65 1. Short answer
66 2. What to expect
67 3. Try next
68
69 Guide notes:
70 \(notes)
71
72 \(previousContext)
73 User question: \(question)
74 """
75 }
76
77 private func previousConversationContext(from previousCards: [AnswerCard]) -> String {
78 let recentCards = previousCards.suffix(2)
79 guard !recentCards.isEmpty else { return "" }
80
81 let formatted = recentCards.map { card in
82 "Question: \(card.question)\nAnswer: \(card.body)"
83 }
84 .joined(separator: "\n\n")
85
86 return "Previous guide conversation:\n\(formatted)\n"
87 }
88
89 private func followUpSuggestions(for entry: GuideEntry, excluding question: String) -> [String]
90 {
91 let normalizedQuestion = question.lowercased()
92 let filtered = entry.suggestedQuestions.filter { $0.lowercased() != normalizedQuestion }
93 return Array(filtered.prefix(3))
94 }
95
96 private func normalizedAnswerBody(
97 _ rawBody: String,
98 for entry: GuideEntry,
99 using contextEntries: [GuideEntry]
100 ) -> String {
101 let trimmedBody = rawBody.trimmingCharacters(in: .whitespacesAndNewlines)
102 let lowercasedBody = trimmedBody.lowercased()
103
104 if lowercasedBody.contains("short answer") || lowercasedBody.contains("what to expect")
105 || lowercasedBody.contains("try next")
106 {
107 return trimmedBody
108 }
109
110 let tryNext = contextEntries.dropFirst().first?.title ?? entry.title
111 let expectation = expectationLine(for: entry)
112
113 return """
114 Short answer: \(trimmedBody)
115 What to expect: \(expectation)
116 Try next: If you want a nearby path through the guide, open \(tryNext) next.
117 """
118 }
119
120 private func expectationLine(for entry: GuideEntry) -> String {
121 let taste = entry.tasteNotes.prefix(3).joined(separator: ", ")
122 if taste.isEmpty {
123 return entry.summary
124 }
125 return "Expect \(taste), with the overall dish feeling \(entry.summary.lowercased())."
126 }
127
128 private func fallbackAnswer(
129 question: String, about entry: GuideEntry, using contextEntries: [GuideEntry]
130 ) -> AnswerCard {
131 let regionLine =
132 entry.region.map { "It is often associated with \($0)." }
133 ?? "Regional context can vary."
134 let tryNext = contextEntries.dropFirst().first?.title ?? entry.title
135 let body = """
136 Short answer: \(entry.title) is \(entry.summary.lowercased())
137 What to expect: \(entry.story) \(regionLine)
138 Try next: Once the private guide is ready, ask a follow-up or open \(tryNext) next for a nearby reference point.
139 """
140
141 return AnswerCard(
142 question: question,
143 headline: "A grounded guide note for now",
144 body: body,
145 highlights: Array(entry.highlights.prefix(3)),
146 followUpSuggestions: followUpSuggestions(for: entry, excluding: question),
147 sourceEntryIDs: contextEntries.map(\.id),
148 isGeneratedOnDevice: false
149 )
150 }
151 }