← Back to index

ClaudeAPIService

SpotsShare
CodeWhat It DoesHow It Does It
▶ IMPORTS
import FoundationFramework importsImports Foundation.
struct ClaudeExtractedInfo { let showName: String let isoDateTime: String // ISO 8601, e.g. "2026-03-15T19:30:00" let cuisine: String let address: String let phone: String let website: String let neighborhood: String let hours: String let contentCategory: String // "Food", "Place", "Shop", or "Show" — for classification }`ClaudeExtractedInfo` structDefines the `ClaudeExtractedInfo` struct.
struct RestaurantInfo { let name: String let address: String let hours: String // free-form text describing opening hours let neighborhood: String // area/district of the city let cuisine: String // type of food (e.g. "Italian", "Japanese") }`RestaurantInfo` structDefines the `RestaurantInfo` struct.
class ClaudeAPIService { // TODO: Move this to a secure location (Keychain, config file, etc.) private static let apiKey = Secrets.anthropicAPIKey private static let apiURL = URL(string: "https://api.anthropic.com/v1/messages")! private static let model = "claude-sonnet-4-5-20250929" /// Extract entry info from PDF data using the Claude API (document vision) static func extractEntryInfo(fromPDFData pdfData: Data, completion: @escaping (Result<ClaudeExtractedInfo, Error>) -> Void) { let base64PDF = pdfData.base64EncodedString() let requestBody = buildPDFRequestBody(base64PDF: base64PDF) guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { completion(.failure(ClaudeAPIError.serializationFailed)) return } var request = URLRequest(url: apiURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.httpBody = jsonData request.timeoutInterval = 30 let session = URLSession(configuration: .ephemeral) session.dataTask(with: request) { data, response, error in if let error = error { print("⚠️ Network error: \(error.localizedDescription)") completion(.failure(error)) return } if let http = response as? HTTPURLResponse { print("ℹ️ Claude API HTTP \(http.statusCode)") } guard let data = data else { completion(.failure(ClaudeAPIError.noData)) return } if let rawString = String(data: data, encoding: .utf8) { print("ℹ️ Claude raw response: \(rawString.prefix(500))") } do { let info = try parseResponse(data: data) completion(.success(info)) } catch { print("⚠️ Parse error: \(error.localizedDescription)") completion(.failure(error)) } }.resume() } /// Extract entry info from a parsed email using the Claude API static func extractEntryInfo(from email: ParsedEmail, completion: @escaping (Result<ClaudeExtractedInfo, Error>) -> Void) { let emailContent = buildEmailContent(from: email) let requestBody = buildRequestBody(emailContent: emailContent) guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { completion(.failure(ClaudeAPIError.serializationFailed)) return } var request = URLRequest(url: apiURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.httpBody = jsonData request.timeoutInterval = 30 // Use ephemeral session — works more reliably inside share extensions let session = URLSession(configuration: .ephemeral) session.dataTask(with: request) { data, response, error in if let error = error { print("⚠️ Network error: \(error.localizedDescription)") completion(.failure(error)) return } // Log HTTP status for debugging if let http = response as? HTTPURLResponse { print("ℹ️ Claude API HTTP \(http.statusCode)") } guard let data = data else { completion(.failure(ClaudeAPIError.noData)) return } // Always log the raw response during development so we can see failures if let rawString = String(data: data, encoding: .utf8) { print("ℹ️ Claude raw response: \(rawString.prefix(500))") } do { let info = try parseResponse(data: data) completion(.success(info)) } catch { print("⚠️ Parse error: \(error.localizedDescription)") completion(.failure(error)) } }.resume() }`ClaudeAPIService` classDefines the `ClaudeAPIService` class.
▶ WEB PAGE PARSING (UNIFIED — FOOD / PLACE / SHOW)
/// Extract entry info from any web page. Classifies as Food, Place, or Show and extracts /// the appropriate fields. Returns ClaudeExtractedInfo so the same struct covers all tabs. static func extractWebPageInfo(fromWebPageText pageText: String, url: URL, completion: @escaping (Result<ClaudeExtractedInfo, Error>) -> Void) { let requestBody = buildWebPageRequestBody(pageText: pageText, url: url) guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { completion(.failure(ClaudeAPIError.serializationFailed)) return } var request = URLRequest(url: apiURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.httpBody = jsonData request.timeoutInterval = 30 let session = URLSession(configuration: .ephemeral) session.dataTask(with: request) { data, response, error in if let error = error { print("⚠️ [WebPage] Network error: \(error.localizedDescription)") completion(.failure(error)) return } if let http = response as? HTTPURLResponse { print("ℹ️ [WebPage] Claude API HTTP \(http.statusCode)") } guard let data = data else { completion(.failure(ClaudeAPIError.noData)) return } if let rawString = String(data: data, encoding: .utf8) { print("ℹ️ [WebPage] Claude raw response: \(rawString.prefix(1000))") } do { let info = try parseResponse(data: data) print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print("🌐 WEB PAGE PARSE DEBUG") print(" URL: \(url.absoluteString)") print(" Category: \(info.contentCategory.isEmpty ? "(not found)" : info.contentCategory)") print(" Name: \(info.showName.isEmpty ? "(not found)" : info.showName)") print(" Venue/Cuisine:\(info.cuisine.isEmpty ? "(not found)" : info.cuisine)") print(" Address: \(info.address.isEmpty ? "(not found)" : info.address)") print(" Phone: \(info.phone.isEmpty ? "(not found)" : info.phone)") print(" Website: \(info.website.isEmpty ? "(not found)" : info.website)") print(" Neighborhood: \(info.neighborhood.isEmpty ? "(not found)" : info.neighborhood)") print(" DateTime: \(info.isoDateTime.isEmpty ? "(not found)" : info.isoDateTime)") print(" Hours: \(info.hours.isEmpty ? "(not found)" : info.hours)") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") completion(.success(info)) } catch { print("⚠️ [WebPage] Parse error: \(error.localizedDescription)") completion(.failure(error)) } }.resume() } private static func buildWebPageRequestBody(pageText: String, url: URL) -> [String: Any] { let systemPrompt = """ You extract structured information from web pages about shows, restaurants, and places. \ Always respond with valid JSON and nothing else. """ // Take the first 4000 chars (business name, category, main content) and the // last 3000 chars (footer, which typically contains address, phone, hours). // Addresses are often only in the footer and get cut off by a simple prefix limit. let headText = String(pageText.prefix(4000)) let tailText = pageText.count > 4000 ? String(pageText.suffix(3000)) : "" let trimmedText = tailText.isEmpty ? headText : headText + "\n\n[...]\n\n" + tailText let userPrompt = """ Analyze this web page and extract information. First determine the content type, then extract the relevant fields. Page URL: \(url.absoluteString) Return ONLY this JSON, no other text: { "contentCategory": "Food, Place, Shop, Show, or Event", "showName": "...", "isoDateTime": "YYYY-MM-DDTHH:MM:SS or empty string", "cuisine": "...", "address": "...", "phone": "...", "website": "...", "neighborhood": "...", "hours": "..." } Rules for contentCategory: - "Show": a theatrical production, play, musical, opera, or dance performance. Pages from theater/show listing sites (playbill.com, broadwayworld.com, tdf.org, theatermania.com, stagetime.com, or any blog/critic reviewing a specific show) are ALWAYS "Show". - "Event": a concert, talk, lecture, festival, fair, exhibition, art display, or any non-theatrical ticketed or timed event. - "Food": a restaurant, cafe, bar, bakery, or any food/drink establishment. - "Shop": a retail store, boutique, nail salon, hair salon, barber, spa, clothing store, stationery shop, sporting goods store, or any primarily retail or personal-care service establishment. - "Place": a museum, park, attraction, hotel, or any destination that is not primarily food, retail, a theatrical show, or a ticketed event. If contentCategory is "Show": - showName: The title of the theatrical production (e.g. "Hamilton", "Heartbreak Hotel", "Jesa"). NOT the theater or venue name. NOT a generic word like "Tickets" or "Show". - cuisine: The name of the theater or venue (e.g. "Lyceum Theatre", "Manhattan Theatre Club", "Lucille Lortel Theatre"). If not found, return "". - address: Street address of the theater/venue, including city and state if present. If not found, return "". - phone: Phone number of the theater/venue (e.g. "(212) 555-1234"). If not found, return "". - website: The official website URL of the theater/venue (e.g. "https://www.lyceum.com"). If not found, return "". - neighborhood: The neighborhood of the theater (e.g. "West Village", "Midtown", "Theater District"). If not found, return "". - isoDateTime: The next upcoming performance date and time shown on the page (format: YYYY-MM-DDTHH:MM:SS, local time). Use T19:00:00 if time is not given. Return "" if no specific date is shown. - hours: Leave empty ("") for shows. If contentCategory is "Event": - showName: The title of the event (e.g. "Coldplay World Tour", "TED NYC", "Brooklyn Folk Festival"). NOT the venue name. - cuisine: The name of the venue (e.g. "Madison Square Garden", "Carnegie Hall", "Brooklyn Museum"). If not found, return "". - address: Street address of the venue, including city and state if present. If not found, return "". - phone: Phone number of the venue. If not found, return "". - website: The official website URL of the event or venue. If not found, return "". - neighborhood: The neighborhood of the venue. If not found, return "". - isoDateTime: The event date and time (format: YYYY-MM-DDTHH:MM:SS, local time). Use T19:00:00 if time is not given. Return "" if no specific date is shown. - hours: Leave empty ("") for events. If contentCategory is "Food": - showName: The name of the restaurant or food establishment. - cuisine: The type of food or cuisine (e.g. "Italian", "Japanese", "Farm-to-Table American"). Return "" if not found. - address: Full street address including city and state if present. - phone: Phone number of the restaurant (e.g. "(212) 555-1234"). If not found, return "". - website: The official website URL of the restaurant (e.g. "https://www.restaurantname.com"). If not found, return "". - neighborhood: The specific neighborhood (e.g. "West Village", "SoHo", "Hell's Kitchen"). Use your knowledge of the city to infer from the address if not explicit. Never return a borough or city name. - hours: Opening hours in a concise readable format (e.g. "Mon-Fri 12pm-10pm, Sat-Sun 11am-11pm"). Return "" if not found. - isoDateTime: Leave empty ("") for food. If contentCategory is "Place": - showName: The name of the place or attraction. - cuisine: The category (e.g. "Museum", "Park", "Hotel", "Gallery"). Return "" if not found. - address: Full street address. - phone: Phone number of the place (e.g. "(212) 555-1234"). If not found, return "". - website: The official website URL of the place. If not found, return "". - neighborhood: The specific neighborhood. Never return a borough or city name. - hours: Opening hours if available. Return "" if not found. - isoDateTime: Leave empty ("") for places. If contentCategory is "Shop": - showName: The name of the shop or store. - cuisine: The type of shop (e.g. "Clothing", "Nail Salon", "Stationery", "Sporting Goods", "Tailor"). Return "" if not found. - address: Full street address including city and state if present. Use your knowledge to infer the full address if only a partial address or intersection is given. - phone: Phone number of the shop (e.g. "(212) 555-1234"). If not found, return "". - website: The official website URL of the shop. If not found, return "". - neighborhood: The specific neighborhood (e.g. "Midtown", "SoHo", "Upper East Side"). Use your knowledge of the city to infer from the address if not explicit. Never return a borough or city name. - hours: Opening hours in a concise readable format. Return "" if not found. - isoDateTime: Leave empty ("") for shops. Page text: \(trimmedText) """ return [ "model": model, "max_tokens": 800, "system": systemPrompt, "messages": [ ["role": "user", "content": userPrompt] ] ] }Documentation commentDescribes the following declaration.
▶ RESTAURANT URL PARSING (LEGACY — KEPT FOR REFERENCE)
/// Extract restaurant info from web page text. Results are printed as debug output. static func extractRestaurantInfo(fromWebPageText pageText: String, url: URL, completion: @escaping (Result<RestaurantInfo, Error>) -> Void) { let requestBody = buildRestaurantRequestBody(pageText: pageText, url: url) guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { completion(.failure(ClaudeAPIError.serializationFailed)) return } var request = URLRequest(url: apiURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.httpBody = jsonData request.timeoutInterval = 30 let session = URLSession(configuration: .ephemeral) session.dataTask(with: request) { data, response, error in if let error = error { print("⚠️ [Restaurant] Network error: \(error.localizedDescription)") completion(.failure(error)) return } if let http = response as? HTTPURLResponse { print("ℹ️ [Restaurant] Claude API HTTP \(http.statusCode)") } guard let data = data else { completion(.failure(ClaudeAPIError.noData)) return } if let rawString = String(data: data, encoding: .utf8) { print("ℹ️ [Restaurant] Claude raw response: \(rawString.prefix(1500))") } do { let info = try parseRestaurantResponse(data: data) // 🐛 DEBUG PRINT print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print("🍽️ RESTAURANT PARSE DEBUG") print(" URL: \(url.absoluteString)") print(" Name: \(info.name.isEmpty ? "(not found)" : info.name)") print(" Address: \(info.address.isEmpty ? "(not found)" : info.address)") print(" Hours: \(info.hours.isEmpty ? "(not found)" : info.hours)") print(" Neighborhood: \(info.neighborhood.isEmpty ? "(not found)" : info.neighborhood)") print(" Cuisine: \(info.cuisine.isEmpty ? "(not found)" : info.cuisine)") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") completion(.success(info)) } catch { print("⚠️ [Restaurant] Parse error: \(error.localizedDescription)") completion(.failure(error)) } }.resume() } private static func buildRestaurantRequestBody(pageText: String, url: URL) -> [String: Any] { let systemPrompt = """ You extract restaurant information from web page text. \ Always respond with valid JSON and nothing else. """ let headText2 = String(pageText.prefix(4000)) let tailText2 = pageText.count > 4000 ? String(pageText.suffix(3000)) : "" let trimmedText = tailText2.isEmpty ? headText2 : headText2 + "\n\n[...]\n\n" + tailText2 let userPrompt = """ Extract the restaurant name, address, opening hours, neighborhood, and cuisine type from this web page. Page URL: \(url.absoluteString) Return ONLY this JSON, no other text: { "name": "restaurant name or empty string", "address": "full street address or empty string", "hours": "opening hours as a single readable string or empty string", "neighborhood": "neighborhood or area/district of the city or empty string", "cuisine": "type of food served (e.g. Italian, Japanese, Mexican) or empty string" } Rules: - name: The primary name of the restaurant or dining establishment. - address: Full street address including city, state, ZIP if present. - hours: All opening hours in a concise readable format, e.g. "Mon-Thu 11am-10pm, Fri-Sat 11am-11pm, Sun 12pm-9pm". If multiple sets of hours exist (lunch/dinner), include both. - neighborhood: The specific neighborhood where the place is located (e.g. "West Village", "SoHo", "Hell's Kitchen", "Tribeca", "Nolita", "Midtown East"). First look for an explicit mention in the page text. If not found, use your knowledge of the city to infer the neighborhood from the street address — for example, 687 Broadway New York is in SoHo, 11 Madison Ave New York is in Flatiron. Always return a neighborhood-level name, not a borough or city name. Never return "Manhattan", "Brooklyn", "New York City", etc. - cuisine: The type or style of food served (e.g. "Italian", "Japanese", "Farm-to-Table American", "Thai"). Look for explicit mentions or infer from the menu description. - If a field is not found, return "". Page text: \(trimmedText) """ return [ "model": model, "max_tokens": 512, "system": systemPrompt, "messages": [ ["role": "user", "content": userPrompt] ] ] } private static func parseRestaurantResponse(data: Data) throws -> RestaurantInfo { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ClaudeAPIError.invalidResponse } if let errorInfo = json["error"] as? [String: Any], let message = errorInfo["message"] as? String { throw ClaudeAPIError.apiError(message) } guard let content = json["content"] as? [[String: Any]], let firstBlock = content.first, let text = firstBlock["text"] as? String else { throw ClaudeAPIError.invalidResponse } let cleanedText = text .replacingOccurrences(of: "```json", with: "") .replacingOccurrences(of: "```", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) guard let responseData = cleanedText.data(using: .utf8), let parsed = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else { throw ClaudeAPIError.parsingFailed } return RestaurantInfo( name: parsed["name"] as? String ?? "", address: parsed["address"] as? String ?? "", hours: parsed["hours"] as? String ?? "", neighborhood: parsed["neighborhood"] as? String ?? "", cuisine: parsed["cuisine"] as? String ?? "" ) }Documentation commentDescribes the following declaration.
▶ PRIVATE HELPERS
private static func buildEmailContent(from email: ParsedEmail) -> String { var content = "" if !email.subject.isEmpty { content += "Subject: \(email.subject)\n" } if !email.from.isEmpty { content += "From: \(email.from)\n" } if !email.dateHeader.isEmpty { content += "Date sent: \(email.dateHeader)\n" } content += "\n" let bodyLimit = 5000 content += String(email.bodyText.prefix(bodyLimit)) return content } private static func buildPDFRequestBody(base64PDF: String) -> [String: Any] { let systemPrompt = """ You extract spot entry information from entry confirmation documents. \ Always respond with valid JSON and nothing else. """ let userPrompt = """ Extract the show name, performance date/time, cuisine name, cuisine address, and neighborhood info from this spot entry PDF. Return ONLY this JSON, no other text: { "showName": "title of the show or empty string", "isoDateTime": "YYYY-MM-DDTHH:MM:SS", "cuisine": "name of the restaurant or empty string", "address": "street address of the cuisine or empty string", "phone": "phone number or empty string", "website": "official website URL or empty string", "neighborhood": "neighborhood or empty string", "hours": "opening hours as a single readable string or empty string", "contentCategory": "Food, Place, Shop, or Show" } Rules for showName: - The artistic title of the show being performed (e.g. "Hamilton", "Macbeth", "The Lion King"). - NOT the cuisine name. - NOT generic words like "Entries", "Order Confirmation", "Invoice". - If not clearly present, return "". Rules for isoDateTime: - The date and time you will physically ATTEND the performance. - NOT the order date, invoice date, or payment date. - Format as ISO 8601 local time (e.g. "2026-03-29T15:00:00"). No timezone offset. - If no time is given, use T19:00:00. - If genuinely not found, return "". Rules for cuisine: - The name of the restaurant or venue (e.g. "Lucille Lortel Theatre", "Shubert Theatre"). - If not found, return "". Rules for address: - The street address of the cuisine including city, state, ZIP if present. - If not found, return "". Rules for phone: - The phone number of the venue or establishment (e.g. "(212) 555-1234"). - If not found, return "". Rules for website: - The official website URL of the venue or establishment (e.g. "https://www.venue.com"). - If not found, return "". Rules for neighborhood: - Row and seat number(s) (e.g. "Row D, Seat 12" or "Orch Row C Seats 101-102"). - If not found, return "". Rules for contentCategory: - Classify the overall content as exactly one of: "Food", "Place", "Shop", "Show", or "Event". - "Show": a theatrical production, play, musical, opera, or dance performance. - "Event": a concert, talk, lecture, festival, fair, exhibition, art display, or any non-theatrical ticketed or timed event. - "Food": a restaurant, cafe, bar, bakery, or any food/drink establishment. - "Shop": a retail store, boutique, nail salon, hair salon, barber, spa, clothing store, stationery shop, sporting goods store, or any primarily retail or personal-care service establishment. - "Place": a museum, park, attraction, hotel, or any other destination that is not primarily food, retail, a theatrical show, or a ticketed event. """ return [ "model": model, "max_tokens": 300, "system": systemPrompt, "messages": [ [ "role": "user", "content": [ [ "type": "document", "source": [ "type": "base64", "media_type": "application/pdf", "data": base64PDF ] ], [ "type": "text", "text": userPrompt ] ] ] ] ] } private static func buildRequestBody(emailContent: String) -> [String: Any] { let systemPrompt = """ You extract spot entry information from confirmation emails. \ Always respond with valid JSON and nothing else. """ let userPrompt = """ Extract the show name, performance date/time, cuisine name, and cuisine address from this spot entry confirmation email. Return ONLY this JSON, no other text: { "showName": "title of the show or empty string", "isoDateTime": "YYYY-MM-DDTHH:MM:SS", "cuisine": "name of the restaurant or empty string", "address": "street address of the cuisine or empty string", "phone": "phone number or empty string", "website": "official website URL or empty string", "neighborhood": "neighborhood or empty string", "hours": "opening hours as a single readable string or empty string", "contentCategory": "Food, Place, Shop, or Show" } Rules for showName: - The artistic title of the show being performed (e.g. "Hamilton", "Macbeth", "The Lion King", "Data"). - NOT the cuisine name (e.g. not "Lucille Lortel Theatre", not "Lincoln Center"). - NOT generic administrative words like "Entries", "Order Confirmation", "Invoice". - Show titles can be any word or phrase — even common words like "Data", "Fun", "War", etc. - If the show title is not clearly present in the email, return "". Rules for isoDateTime: - The date and time you will physically ATTEND the performance. - This is NOT the order date, invoice date, payment date, or email sent date. - In entry emails, the performance date is listed in a "TICKETS" or "Event" section alongside the cuisine address and seat information. - If multiple dates appear (order date, invoice date, performance date), choose the one in the entries/event section — usually the latest future date. - Format as ISO 8601 local time (e.g. "2026-03-29T15:00:00"). No timezone offset. - If no time is given, use T19:00:00. - If you genuinely cannot find a performance date, return "". Rules for cuisine: - The name of the restaurant or venue where the performance takes place (e.g. "Lucille Lortel Theatre", "Shubert Theatre", "Lincoln Center"). - If not found, return "". Rules for address: - The street address of the cuisine (e.g. "121 Christopher St, New York, NY 10014"). - Include city, state, and ZIP if present. - If not found, return "". Rules for phone: - The phone number of the venue or establishment (e.g. "(212) 555-1234"). - If not found, return "". Rules for website: - The official website URL of the venue or establishment (e.g. "https://www.venue.com"). - If not found, return "". Rules for neighborhood: - The row and seat number(s) for the entry (e.g. "Row D, Seat 12" or "Orch Row C Seats 101-102"). - Look for labels like Row, Seat, Orch, Mezzanine, Balcony, Section, etc. - If multiple seats, include all (e.g. "Row D, Seats 5-6"). - If not found, return "". Rules for contentCategory: - Classify the overall content as exactly one of: "Food", "Place", "Shop", "Show", or "Event". - "Show": a theatrical production, play, musical, opera, or dance performance. - "Event": a concert, talk, lecture, festival, fair, exhibition, art display, or any non-theatrical ticketed or timed event. - "Food": a restaurant, cafe, bar, bakery, or any food/drink establishment. - "Shop": a retail store, boutique, nail salon, hair salon, barber, spa, clothing store, stationery shop, sporting goods store, or any primarily retail or personal-care service establishment. - "Place": a museum, park, attraction, hotel, or any other destination that is not primarily food, retail, a theatrical show, or a ticketed event. Email: \(emailContent) """ return [ "model": model, "max_tokens": 300, "system": systemPrompt, "messages": [ ["role": "user", "content": userPrompt] ] ] } private static func parseResponse(data: Data) throws -> ClaudeExtractedInfo { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ClaudeAPIError.invalidResponse } // Check for API error if let errorInfo = json["error"] as? [String: Any], let message = errorInfo["message"] as? String { throw ClaudeAPIError.apiError(message) } // Extract the text content from Claude's response guard let content = json["content"] as? [[String: Any]], let firstBlock = content.first, let text = firstBlock["text"] as? String else { throw ClaudeAPIError.invalidResponse } // Strip any markdown code fences if present let cleanedText = text .replacingOccurrences(of: "```json", with: "") .replacingOccurrences(of: "```", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) // Try to parse as-is first, then fall back to stripping any preamble text before the '{'. // Using lastIndex(of:"}") is intentionally avoided — it breaks when '}' appears inside // a string value (e.g. hours like "Mon-Thu 11am-10pm {bar open late}"). // Instead, find the first '{' and let JSONSerialization find the matching closing brace. func parseJSON(_ s: String) -> [String: Any]? { guard let d = s.data(using: .utf8) else { return nil } return (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] } let entryJSON: [String: Any] if let parsed = parseJSON(cleanedText) { entryJSON = parsed } else if let jsonStart = cleanedText.firstIndex(of: "{"), let parsed = parseJSON(String(cleanedText[jsonStart...])) { entryJSON = parsed } else { print("⚠️ [parseResponse] inner JSON parse failed. Full text: \(text.prefix(500))") throw ClaudeAPIError.parsingFailed } let showName = entryJSON["showName"] as? String ?? "" let isoDateTime = entryJSON["isoDateTime"] as? String ?? "" let cuisine = entryJSON["cuisine"] as? String ?? "" let address = entryJSON["address"] as? String ?? "" let phone = entryJSON["phone"] as? String ?? "" let website = entryJSON["website"] as? String ?? "" let neighborhood = entryJSON["neighborhood"] as? String ?? "" let hours = entryJSON["hours"] as? String ?? "" let contentCategory = entryJSON["contentCategory"] as? String ?? "" return ClaudeExtractedInfo(showName: showName, isoDateTime: isoDateTime, cuisine: cuisine, address: address, phone: phone, website: website, neighborhood: neighborhood, hours: hours, contentCategory: contentCategory) } } enum ClaudeAPIError: LocalizedError { case serializationFailed case noData case invalidResponse case parsingFailed case apiError(String) var errorDescription: String? { switch self { case .serializationFailed: return "Failed to serialize request" case .noData: return "No data received from API" case .invalidResponse: return "Invalid API response format" case .parsingFailed: return "Failed to parse extracted information" case .apiError(let message): return "API error: \(message)" } } }`buildEmailContent()` functionImplements `buildEmailContent`. Returns `String`.