// // CVTemplateFetchService.swift // App for Indeed // // Fetches CV template definitions from OpenAI (structured JSON) so the gallery // is not limited to the built-in static catalog. Falls back are handled by callers. // import Foundation // MARK: - Public API final class CVTemplateFetchService { static let shared = CVTemplateFetchService() private let endpoint = URL(string: "https://api.openai.com/v1/responses")! private let session = URLSession(configuration: .ephemeral) private init() {} private enum CVTemplateCatalogPrompt { static let instructions = """ You are the CV template catalog service for a desktop résumé builder. Invent exactly 30 distinct, plausible template \ records for job seekers. Each must have a unique `id` (kebab-case ASCII, e.g. "river-stone"), a display `name` (1–3 words), \ and varied visual parameters so thumbnails differ. Spread entries across all five `family` values (six templates per family). \ Use creative but professional names; do not copy real commercial template trademarks. \ Vary `headline`, `accent`, `layoutType`, `sidebarSide`, `sidebarTinted`, and `sectionLabelStyle` across the set — do not repeat \ the same six-tuple of those fields on consecutive rows. \ Niche split for the in-app gallery: `modern` and `creative` families are shown under Design-Based (portfolio / UX / visual roles); \ `minimal`, `professional`, and `executive` appear under Profession-Based (ATS-friendly / corporate / leadership). \ Output must strictly match the JSON schema — no markdown or extra keys. """ static func userInput(language: AppLanguage) -> String { """ Generate the template catalog now. Exactly six entries per family: professional, modern, creative, minimal, executive. \ Use both singleColumn and twoColumn layouts across the 30 rows. For twoColumn rows vary leading vs trailing sidebars and tinted true/false. \ Keep modern and creative entries suitable for design-led résumés; keep minimal, professional, and executive suitable for traditional industries. \ \(displayNameRule(for: language)) """ } /// English `name` values double as localization keys in the app; keep them ASCII words. static func displayNameRule(for language: AppLanguage) -> String { switch language { case .english: return "Each `name` must be 1–3 English words (ASCII letters and spaces only), e.g. \"Creative Cascade\"." case .chineseTraditional: return "Each `name` must be 1–3 English words (ASCII letters and spaces only) suitable as localization keys, e.g. \"Creative Cascade\" — do not use Chinese characters in `name`." case .chineseSimplified: return "Each `name` must be 1–3 English words (ASCII letters and spaces only) suitable as localization keys, e.g. \"Design Dynamo\" — do not use Chinese characters in `name`." case .arabic: return "Each `name` must be 1–3 English words (ASCII letters and spaces only) suitable as localization keys, e.g. \"UI Sculptor\" — do not use Arabic script in `name`." case .french: return "Each `name` must be 1–3 English words (ASCII letters and spaces only) suitable as localization keys, e.g. \"Creative Cascade\" — do not use French characters in `name`." } } } /// Loads templates from OpenAI (one automatic retry on transient network / parse failures). func fetchTemplates( language: AppLanguage = currentAppLanguage(), completion: @escaping (Result<[CVTemplate], Error>) -> Void ) { let apiKey = OpenAIConfiguration.apiKey guard OpenAIConfiguration.hasAPIKey else { completion(.failure(Self.missingKeyError)) return } var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 60 do { request.httpBody = try Self.encodeCatalogRequestBody(language: language) } catch { completion(.failure(error)) return } session.dataTask(with: request) { data, response, error in Self.handleFetchData( data, response: response, error: error, language: language, attempt: 0, completion: completion ) }.resume() } private static func encodeCatalogRequestBody(language: AppLanguage) throws -> Data { let payload = CVTemplateOpenAIRequest.catalogPayload( model: "gpt-4o-mini", instructions: CVTemplateCatalogPrompt.instructions, input: CVTemplateCatalogPrompt.userInput(language: language) ) return try JSONEncoder().encode(payload) } private static func handleFetchData( _ data: Data?, response: URLResponse?, error: Error?, language: AppLanguage, attempt: Int, completion: @escaping (Result<[CVTemplate], Error>) -> Void ) { if let error { if attempt == 0 { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) { refetchSameRequest(language: language, attempt: 1, completion: completion) } return } completion(.failure(error)) return } guard let data else { if attempt == 0 { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) { refetchSameRequest(language: language, attempt: 1, completion: completion) } return } completion(.failure(Self.emptyResponseError)) return } if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { if attempt == 0, (500...599).contains(http.statusCode) || http.statusCode == 429 { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.5) { refetchSameRequest(language: language, attempt: 1, completion: completion) } return } if let apiError = try? JSONDecoder().decode(OpenAITemplatesAPIErrorResponse.self, from: data) { completion(.failure(NSError( domain: "CVTemplateFetchService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: apiError.error.message] ))) } else { completion(.failure(NSError( domain: "CVTemplateFetchService", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "Template request failed with status \(http.statusCode)."] ))) } return } do { let modelText = try Self.extractModelTextFromResponsesBody(data) let trimmed = modelText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw Self.emptyModelTextError } let decoded = try Self.decodeCatalog(fromModelText: trimmed) let mapped = decoded.compactMap { CVTemplate(aiItem: $0) } guard !mapped.isEmpty else { throw Self.noValidTemplatesError } var seen = Set() let unique = mapped.filter { seen.insert($0.id).inserted } completion(.success(unique)) } catch { if attempt == 0 { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) { refetchSameRequest(language: language, attempt: 1, completion: completion) } return } completion(.failure(error)) } } private static func refetchSameRequest( language: AppLanguage, attempt: Int, completion: @escaping (Result<[CVTemplate], Error>) -> Void ) { let apiKey = OpenAIConfiguration.apiKey guard OpenAIConfiguration.hasAPIKey else { completion(.failure(missingKeyError)) return } var request = URLRequest(url: URL(string: "https://api.openai.com/v1/responses")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 60 guard let body = try? encodeCatalogRequestBody(language: language) else { completion(.failure(NSError(domain: "CVTemplateFetchService", code: 11, userInfo: [NSLocalizedDescriptionKey: "Could not encode request."]))) return } request.httpBody = body URLSession(configuration: .ephemeral).dataTask(with: request) { data, response, error in handleFetchData( data, response: response, error: error, language: language, attempt: attempt, completion: completion ) }.resume() } // MARK: - Errors private static let missingKeyError = NSError( domain: "CVTemplateFetchService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing API key. Set OPENAI_API_KEY in the Run scheme environment for local debug."] ) private static let emptyResponseError = NSError( domain: "CVTemplateFetchService", code: 2, userInfo: [NSLocalizedDescriptionKey: "API returned an empty response."] ) private static let emptyModelTextError = NSError( domain: "CVTemplateFetchService", code: 4, userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."] ) private static let noValidTemplatesError = NSError( domain: "CVTemplateFetchService", code: 10, userInfo: [NSLocalizedDescriptionKey: "The assistant reply did not include usable templates."] ) // MARK: - Response parsing (mirrors OpenAI job search handling) private static func extractModelTextFromResponsesBody(_ data: Data) throws -> String { let rootObject: Any do { rootObject = try JSONSerialization.jsonObject(with: data, options: []) } catch { throw NSError( domain: "CVTemplateFetchService", code: 5, userInfo: [NSLocalizedDescriptionKey: "The service returned data that was not valid JSON."] ) } guard let root = rootObject as? [String: Any] else { throw NSError( domain: "CVTemplateFetchService", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unexpected response shape from the template service."] ) } if let status = root["status"] as? String { if status == "failed" { let message = (root["error"] as? [String: Any])?["message"] as? String ?? "The request failed." throw NSError(domain: "CVTemplateFetchService", code: 7, userInfo: [NSLocalizedDescriptionKey: message]) } if status == "incomplete", let details = root["incomplete_details"] as? [String: Any], let reason = details["reason"] as? String { throw NSError( domain: "CVTemplateFetchService", code: 8, userInfo: [NSLocalizedDescriptionKey: "Request stopped early (\(reason)). Try again."] ) } } if let direct = root["output_text"] as? String { let trimmed = direct.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } guard let output = root["output"] as? [Any] else { if let fallback = Self.scavengeTemplatesJSONString(fromUTF8Data: data) { return fallback } throw NSError( domain: "CVTemplateFetchService", code: 9, userInfo: [NSLocalizedDescriptionKey: "The service returned no assistant text. Try again."] ) } var segments: [String] = [] for case let item as [String: Any] in output where (item["type"] as? String) == "message" { collectOutputTextSegments(fromOutputItem: item, into: &segments) } if segments.isEmpty { for case let item as [String: Any] in output { collectOutputTextSegments(fromOutputItem: item, into: &segments) } } let combined = segments.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) if !combined.isEmpty { return combined } if let fallback = Self.scavengeTemplatesJSONString(fromUTF8Data: data) { return fallback } throw NSError( domain: "CVTemplateFetchService", code: 9, userInfo: [NSLocalizedDescriptionKey: "The model did not return readable text. Try again."] ) } /// When `output` is missing, scan the raw body for a JSON object that contains `"templates"`. private static func scavengeTemplatesJSONString(fromUTF8Data data: Data) -> String? { guard let raw = String(data: data, encoding: .utf8) else { return nil } return extractTemplatesJSONObjectString(from: raw) } private static func collectOutputTextSegments(fromOutputItem item: [String: Any], into segments: inout [String]) { guard let content = item["content"] as? [Any] else { return } for case let part as [String: Any] in content { let type = (part["type"] as? String) ?? "" var candidate: String? if let s = part["text"] as? String { candidate = s } else if let nested = part["text"] as? [String: Any], let value = nested["value"] as? String { candidate = value } else if let j = part["json"] as? String { candidate = j } else if let jObj = part["json"] as? [String: Any], let jData = try? JSONSerialization.data(withJSONObject: jObj, options: []), let jStr = String(data: jData, encoding: .utf8) { candidate = jStr } guard let blob = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !blob.isEmpty else { continue } let looksLikeCatalog = blob.contains("\"templates\"") && blob.contains("{") if type == "output_text" || type == "output_json" || looksLikeCatalog { segments.append(blob) } } } private static func decodeCatalog(fromModelText text: String) throws -> [CVTemplateAIItem] { let stripped = text.trimmingCharacters(in: .whitespacesAndNewlines) if stripped.hasPrefix("{"), let directData = stripped.data(using: .utf8) { if let payload = try? JSONDecoder().decode(CVTemplateCatalogPayload.self, from: directData) { return payload.templates } if let flex = flexibleTemplates(fromJSONData: directData) { return flex } } let jsonString = extractTemplatesJSONObjectString(from: text) ?? extractJSONObject(from: text) let jsonData = Data(jsonString.utf8) if let payload = try? JSONDecoder().decode(CVTemplateCatalogPayload.self, from: jsonData) { return payload.templates } if let flex = flexibleTemplates(fromJSONData: jsonData) { return flex } throw NSError( domain: "CVTemplateFetchService", code: 10, userInfo: [NSLocalizedDescriptionKey: "The assistant reply was not valid template JSON."] ) } private static func flexibleTemplates(fromJSONData data: Data) -> [CVTemplateAIItem]? { guard let obj = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } if let root = obj as? [String: Any] { if let items = templatesArray(from: root) { let mapped = items.compactMap { CVTemplateAIItem.fromFlexibleDictionary($0) } return mapped.isEmpty ? nil : mapped } } return nil } private static func templatesArray(from root: [String: Any]) -> [[String: Any]]? { if let arr = root["templates"] as? [[String: Any]] { return arr } for (_, value) in root { if let inner = value as? [String: Any], let arr = inner["templates"] as? [[String: Any]] { return arr } } return nil } private static func stripMarkdownCodeFence(_ text: String) -> String { var s = text.trimmingCharacters(in: .whitespacesAndNewlines) guard s.hasPrefix("```") else { return s } s.removeFirst(3) if s.lowercased().hasPrefix("json") { s.removeFirst(4) } s = s.trimmingCharacters(in: .whitespacesAndNewlines) if let fence = s.range(of: "```", options: .backwards) { s = String(s[.. String? { var depth = 0 var inString = false var escaped = false var i = openBrace while i < s.endIndex { let ch = s[i] if inString { if escaped { escaped = false } else if ch == "\\" { escaped = true } else if ch == "\"" { inString = false } } else { switch ch { case "\"": inString = true case "{": depth += 1 case "}": depth -= 1 if depth == 0 { return String(s[openBrace...i]) } default: break } } i = s.index(after: i) } return nil } private static func extractTemplatesJSONObjectString(from text: String) -> String? { let s = stripMarkdownCodeFence(text) guard let keyRange = s.range(of: "\"templates\"", options: .caseInsensitive) else { return nil } let head = s[.. String { if let extracted = extractTemplatesJSONObjectString(from: text) { return extracted } let stripped = stripMarkdownCodeFence(text) if let first = stripped.firstIndex(of: "{"), let balanced = balancedJSONObject(from: first, in: stripped) { return balanced } if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) { return String(text[range]) } return text } } // MARK: - AI DTO → CVTemplate private struct CVTemplateCatalogPayload: Codable { let templates: [CVTemplateAIItem] } private struct CVTemplateAIItem: Codable { let id: String let name: String let family: String let headline: String let accent: String let layoutType: String let sidebarSide: String let sidebarTinted: Bool let sectionLabelStyle: String /// Decodes rows when keys differ slightly or booleans arrive as numbers. fileprivate static func fromFlexibleDictionary(_ dict: [String: Any]) -> CVTemplateAIItem? { func firstString(keys: [String]) -> String? { for wanted in keys { for (dk, dv) in dict { guard dk.caseInsensitiveCompare(wanted) == .orderedSame else { continue } if let s = dv as? String { return s } } } return nil } func firstBool(keys: [String]) -> Bool { for wanted in keys { for (dk, dv) in dict { guard dk.caseInsensitiveCompare(wanted) == .orderedSame else { continue } if let b = dv as? Bool { return b } if let i = dv as? Int { return i != 0 } if let s = dv as? String { let t = s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if ["true", "1", "yes", "y"].contains(t) { return true } if ["false", "0", "no", "n"].contains(t) { return false } } } } return false } guard let id = firstString(keys: ["id"]), let name = firstString(keys: ["name"]), let family = firstString(keys: ["family"]), let headline = firstString(keys: ["headline"]), let accent = firstString(keys: ["accent"]), let layoutType = firstString(keys: ["layoutType", "layout_type"]), let sidebarSide = firstString(keys: ["sidebarSide", "sidebar_side"]), let sectionLabelStyle = firstString(keys: ["sectionLabelStyle", "section_label_style"]) else { return nil } let tinted = firstBool(keys: ["sidebarTinted", "sidebar_tinted"]) return CVTemplateAIItem( id: id, name: name, family: family, headline: headline, accent: accent, layoutType: layoutType, sidebarSide: sidebarSide, sidebarTinted: tinted, sectionLabelStyle: sectionLabelStyle ) } } extension CVTemplate { fileprivate init?(aiItem: CVTemplateAIItem) { let id = Self.slugifyID(aiItem.id) let name = aiItem.name.trimmingCharacters(in: .whitespacesAndNewlines) guard !id.isEmpty, !name.isEmpty else { return nil } let familyNorm = aiItem.family.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard let family = Self.parseDesignFamily(familyNorm) else { return nil } let headline = CVTemplate.parseHeadline(aiItem.headline) let accent = CVTemplate.parseAccent(aiItem.accent) let section = CVTemplate.parseSectionLabelStyle(aiItem.sectionLabelStyle) let layoutNorm = Self.normalizedSchemaToken(aiItem.layoutType) let layout: Layout switch layoutNorm { case "singlecolumn": layout = .singleColumn case "twocolumn": let sideKey = Self.normalizedSchemaToken(aiItem.sidebarSide) let side: SidebarSide = (sideKey == "trailing") ? .trailing : .leading layout = .twoColumn(sidebar: side, tinted: aiItem.sidebarTinted) default: layout = .singleColumn } self.init( id: id, name: name, family: family, headline: headline, accent: accent, layout: layout, sectionLabelStyle: section ) } /// Collapses spaces and underscores so values like "Two Column" still map. private static func normalizedSchemaToken(_ raw: String) -> String { raw.trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .filter { !$0.isWhitespace } .replacingOccurrences(of: "_", with: "") } private static func parseDesignFamily(_ raw: String) -> CVDesignFamily? { let key = normalizedSchemaToken(raw) if let f = CVDesignFamily(rawValue: key) { return f } if key == "minimalist" { return .minimal } if key == "exec" { return .executive } return nil } private static func slugifyID(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !trimmed.isEmpty else { return "" } var out = "" var lastWasHyphen = false for ch in trimmed { if ch.isLetter || ch.isNumber { out.append(ch) lastWasHyphen = false } else if ch == "-" || ch == "_" || ch == " " { if !out.isEmpty, !lastWasHyphen { out.append("-") lastWasHyphen = true } } } while out.last == "-" { out.removeLast() } return out } private static func parseHeadline(_ s: String) -> Headline { let key = normalizedSchemaToken(s) switch key { case "centered": return .centered case "leftaligned", "left": return .leftAligned case "leftwithinitials", "initials": return .leftWithInitials case "avatarstacked", "stacked": return .avatarStacked default: return .leftAligned } } private static func parseAccent(_ s: String) -> Accent { let key = normalizedSchemaToken(s) switch key { case "none": return .none case "redunderline": return .redUnderline case "redbar": return .redBar case "bluebar": return .blueBar default: return .none } } private static func parseSectionLabelStyle(_ s: String) -> SectionLabelStyle { let key = normalizedSchemaToken(s) switch key { case "uppercase", "caps": return .uppercase case "slashed", "slash": return .slashed case "bracketed", "brackets": return .bracketed default: return .uppercase } } } // MARK: - OpenAI request types private struct OpenAITemplatesAPIErrorResponse: Codable { let error: APIErrorPayload struct APIErrorPayload: Codable { let message: String } } private struct CVTemplateOpenAIRequest: Encodable { let model: String let instructions: String let input: String let text: CVTemplateOpenAITextOutputConfig static func catalogPayload(model: String, instructions: String, input: String) -> CVTemplateOpenAIRequest { let itemProperties = CVTemplateAIItemSchemaProperties( id: OpenAIJSONSchemaStringField(type: "string", description: "Unique kebab-case id."), name: OpenAIJSONSchemaStringField(type: "string", description: "Short display name."), family: OpenAIJSONSchemaStringEnum( type: "string", description: "Template style family.", enumValues: CVTemplateSchemaEnumValues.families ), headline: OpenAIJSONSchemaStringEnum( type: "string", description: "Header arrangement for the mini preview.", enumValues: CVTemplateSchemaEnumValues.headlines ), accent: OpenAIJSONSchemaStringEnum( type: "string", description: "Accent decoration.", enumValues: CVTemplateSchemaEnumValues.accents ), layoutType: OpenAIJSONSchemaStringEnum( type: "string", description: "Page layout.", enumValues: CVTemplateSchemaEnumValues.layouts ), sidebarSide: OpenAIJSONSchemaStringEnum( type: "string", description: "Sidebar column side when twoColumn.", enumValues: CVTemplateSchemaEnumValues.sides ), sidebarTinted: OpenAIJSONSchemaBooleanField( type: "boolean", description: "Tint sidebar column when twoColumn." ), sectionLabelStyle: OpenAIJSONSchemaStringEnum( type: "string", description: "Section heading style.", enumValues: CVTemplateSchemaEnumValues.sectionStyles ) ) let itemSchema = CVTemplateAIItemSchema( type: "object", properties: itemProperties, required: [ "id", "name", "family", "headline", "accent", "layoutType", "sidebarSide", "sidebarTinted", "sectionLabelStyle" ], additionalProperties: false ) let templatesProperty = CVTemplateTemplatesArrayProperty( type: "array", description: "Exactly 30 CV template definitions.", items: itemSchema ) let rootProperties = CVTemplateCatalogRootProperties(templates: templatesProperty) let rootSchema = CVTemplateCatalogRootSchema( type: "object", properties: rootProperties, required: ["templates"], additionalProperties: false ) let format = CVTemplateCatalogResponseJSONSchemaFormat( type: "json_schema", name: "cv_template_catalog", strict: true, schema: rootSchema ) return CVTemplateOpenAIRequest( model: model, instructions: instructions, input: input, text: CVTemplateOpenAITextOutputConfig(format: format) ) } } private struct CVTemplateOpenAITextOutputConfig: Encodable { let format: CVTemplateCatalogResponseJSONSchemaFormat } private struct CVTemplateCatalogResponseJSONSchemaFormat: Encodable { let type: String let name: String let strict: Bool let schema: CVTemplateCatalogRootSchema } private struct CVTemplateCatalogRootSchema: Encodable { let type: String let properties: CVTemplateCatalogRootProperties let required: [String] let additionalProperties: Bool } private struct CVTemplateCatalogRootProperties: Encodable { let templates: CVTemplateTemplatesArrayProperty } private enum CVTemplateSchemaEnumValues { static let families = ["creative", "executive", "minimal", "modern", "professional"] static let headlines = ["avatarStacked", "centered", "leftAligned", "leftWithInitials"] static let accents = ["blueBar", "none", "redBar", "redUnderline"] static let layouts = ["singleColumn", "twoColumn"] static let sides = ["leading", "trailing"] static let sectionStyles = ["bracketed", "slashed", "uppercase"] } private struct CVTemplateTemplatesArrayProperty: Encodable { let type: String let description: String let items: CVTemplateAIItemSchema } private struct CVTemplateAIItemSchema: Encodable { let type: String let properties: CVTemplateAIItemSchemaProperties let required: [String] let additionalProperties: Bool } private struct CVTemplateAIItemSchemaProperties: Encodable { let id: OpenAIJSONSchemaStringField let name: OpenAIJSONSchemaStringField let family: OpenAIJSONSchemaStringEnum let headline: OpenAIJSONSchemaStringEnum let accent: OpenAIJSONSchemaStringEnum let layoutType: OpenAIJSONSchemaStringEnum let sidebarSide: OpenAIJSONSchemaStringEnum let sidebarTinted: OpenAIJSONSchemaBooleanField let sectionLabelStyle: OpenAIJSONSchemaStringEnum } private struct OpenAIJSONSchemaStringEnum: Encodable { let type: String let description: String let enumValues: [String] enum CodingKeys: String, CodingKey { case type, description case enumValues = "enum" } } private struct OpenAIJSONSchemaStringField: Encodable { let type: String let description: String } private struct OpenAIJSONSchemaBooleanField: Encodable { let type: String let description: String }