| 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 | } |