|
@@ -0,0 +1,750 @@
|
|
|
|
|
+//
|
|
|
|
|
+// 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. \
|
|
|
|
|
+ Output must strictly match the JSON schema — no markdown or extra keys.
|
|
|
|
|
+ """
|
|
|
|
|
+ static let userInput = """
|
|
|
|
|
+ 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.
|
|
|
|
|
+ """
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Loads templates from OpenAI (one automatic retry on transient network / parse failures).
|
|
|
|
|
+ func fetchTemplates(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()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ completion(.failure(error))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ session.dataTask(with: request) { data, response, error in
|
|
|
|
|
+ Self.handleFetchData(data, response: response, error: error, attempt: 0, completion: completion)
|
|
|
|
|
+ }.resume()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func encodeCatalogRequestBody() throws -> Data {
|
|
|
|
|
+ let payload = CVTemplateOpenAIRequest.catalogPayload(
|
|
|
|
|
+ model: "gpt-4o-mini",
|
|
|
|
|
+ instructions: CVTemplateCatalogPrompt.instructions,
|
|
|
|
|
+ input: CVTemplateCatalogPrompt.userInput
|
|
|
|
|
+ )
|
|
|
|
|
+ return try JSONEncoder().encode(payload)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func handleFetchData(
|
|
|
|
|
+ _ data: Data?,
|
|
|
|
|
+ response: URLResponse?,
|
|
|
|
|
+ error: Error?,
|
|
|
|
|
+ attempt: Int,
|
|
|
|
|
+ completion: @escaping (Result<[CVTemplate], Error>) -> Void
|
|
|
|
|
+ ) {
|
|
|
|
|
+ if let error {
|
|
|
|
|
+ if attempt == 0 {
|
|
|
|
|
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
|
|
|
|
|
+ refetchSameRequest(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(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(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<String>()
|
|
|
|
|
+ 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(attempt: 1, completion: completion)
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ completion(.failure(error))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func refetchSameRequest(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() 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, 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 Xcode Build Settings."]
|
|
|
|
|
+ )
|
|
|
|
|
+ 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[..<fence.lowerBound])
|
|
|
|
|
+ }
|
|
|
|
|
+ return s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func balancedJSONObject(from openBrace: String.Index, in s: String) -> 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[..<keyRange.lowerBound]
|
|
|
|
|
+ guard let open = head.lastIndex(of: "{") else { return nil }
|
|
|
|
|
+ return balancedJSONObject(from: open, in: s)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func extractJSONObject(from text: String) -> 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
|
|
|
|
|
+}
|