瀏覽代碼

Fetch CV templates from OpenAI with resilient parsing and UI feedback

Wire the CV Maker gallery to OpenAI Responses (structured JSON) via
CVTemplateFetchService, with catalog fallback, retry, flexible decoding,
schema enums, and clearer subtitle messaging on failure. Thumbnail
previews gain per-template visual jitter so similar layouts read as
distinct in the grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 月之前
父節點
當前提交
f8ecdc96e8
共有 2 個文件被更改,包括 799 次插入6 次删除
  1. 750 0
      App for Indeed/Services/CVTemplateFetchService.swift
  2. 49 6
      App for Indeed/Views/CVMakerPageView.swift

+ 750 - 0
App for Indeed/Services/CVTemplateFetchService.swift

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

+ 49 - 6
App for Indeed/Views/CVMakerPageView.swift

@@ -419,6 +419,8 @@ final class CVMakerPageView: NSView {
     private var selectedGroup: CVCategoryGroup = .designBased
     private var selectedFamily: CVDesignFamily? = nil // nil == "All"
     private var selectedTemplateID: String? = "paper-white"
+    /// Shown immediately; replaced when `CVTemplateFetchService` returns AI-generated entries.
+    private var activeCatalog: [CVTemplate] = CVTemplateCatalog.all
     private var groupTabButtons: [CVCategoryGroup: CVChipButton] = [:]
     private var familyChipButtons: [CVDesignFamily?: CVChipButton] = [:]
     private var templateCardsByID: [String: CVTemplateCard] = [:]
@@ -429,6 +431,7 @@ final class CVMakerPageView: NSView {
         reloadFamilyChips()
         reloadTemplateGrid()
         updateSelectedChipStates()
+        beginLoadingAICatalogIfPossible()
     }
 
     @available(*, unavailable)
@@ -593,7 +596,7 @@ final class CVMakerPageView: NSView {
     private func templates(forGroup group: CVCategoryGroup, family: CVDesignFamily?) -> [CVTemplate] {
         // The catalog is design-driven; profession-based reuses the same templates
         // so the gallery is fully populated for both groups in this preview build.
-        let base = CVTemplateCatalog.all
+        let base = activeCatalog
         guard let family else { return base }
         _ = group
         return base.filter { $0.family == family }
@@ -714,7 +717,7 @@ final class CVMakerPageView: NSView {
     private func didPreviewTemplate(_ id: String) {
         selectedTemplateID = id
         applySelectionToCards()
-        guard let template = CVTemplateCatalog.all.first(where: { $0.id == id }) else { return }
+        guard let template = activeCatalog.first(where: { $0.id == id }) else { return }
         presentPlaceholderAlert(
             title: "Preview \"\(template.name)\"",
             message: "Full-page previews and PDF export are coming soon."
@@ -723,7 +726,7 @@ final class CVMakerPageView: NSView {
 
     @objc private func didTapUseTemplate() {
         guard let id = selectedTemplateID,
-              let template = CVTemplateCatalog.all.first(where: { $0.id == id }) else {
+              let template = activeCatalog.first(where: { $0.id == id }) else {
             presentPlaceholderAlert(title: "Pick a template", message: "Select a template first, then choose a profile to continue.")
             return
         }
@@ -763,6 +766,33 @@ final class CVMakerPageView: NSView {
         }
     }
 
+    private func beginLoadingAICatalogIfPossible() {
+        guard OpenAIConfiguration.hasAPIKey else { return }
+        let defaultSubtitle = subtitleLabel.stringValue
+        subtitleLabel.stringValue = "Fetching AI-curated templates…"
+        CVTemplateFetchService.shared.fetchTemplates { [weak self] result in
+            DispatchQueue.main.async {
+                guard let self else { return }
+                switch result {
+                case .success(let list):
+                    self.subtitleLabel.stringValue = defaultSubtitle
+                    if !list.isEmpty {
+                        self.activeCatalog = list
+                        self.configureGroupTabs()
+                        self.reloadFamilyChips()
+                        self.reloadTemplateGrid()
+                        self.updateSelectedChipStates()
+                    }
+                case .failure:
+                    self.subtitleLabel.stringValue = "Couldn’t load AI templates — showing the built-in gallery."
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 5.5) { [weak self] in
+                        self?.subtitleLabel.stringValue = defaultSubtitle
+                    }
+                }
+            }
+        }
+    }
+
     private func presentPlaceholderAlert(title: String, message: String) {
         let alert = NSAlert()
         alert.messageText = title
@@ -1279,9 +1309,12 @@ private final class CVTemplatePreviewView: NSView {
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false
 
-        let nameStrip = makeLine(color: palette.previewInk, height: 5.5, widthFraction: 0.6)
-        let roleStrip = makeLine(color: palette.previewMuted, height: 3, widthFraction: 0.42)
-        let contactStrip = makeLine(color: palette.previewMuted.withAlphaComponent(0.7), height: 2, widthFraction: 0.55)
+        let jitterA = templateVisualJitter(template.id)
+        let jitterB = templateVisualJitter(template.id + "-b")
+        let nameInk = palette.previewInk.blended(withFraction: CGFloat(jitterA * 0.14), of: palette.previewAccentBlue) ?? palette.previewInk
+        let nameStrip = makeLine(color: nameInk, height: 5.2 + CGFloat(jitterB * 0.9), widthFraction: 0.52 + CGFloat(jitterA * 0.14))
+        let roleStrip = makeLine(color: palette.previewMuted, height: 3, widthFraction: 0.36 + CGFloat(jitterB * 0.12))
+        let contactStrip = makeLine(color: palette.previewMuted.withAlphaComponent(0.7), height: 2, widthFraction: 0.48 + CGFloat(jitterA * 0.1))
 
         let textStack = NSStackView(views: [nameStrip, roleStrip, contactStrip])
         textStack.orientation = .vertical
@@ -1378,6 +1411,16 @@ private final class CVTemplatePreviewView: NSView {
         return container
     }
 
+    /// Stable 0…1 value per template so thumbnails are not visually identical when metadata is similar.
+    private func templateVisualJitter(_ salt: String) -> Double {
+        var hash: UInt64 = 1469598103934665603
+        for b in salt.utf8 {
+            hash ^= UInt64(b)
+            hash &*= 1_099_511_628_211
+        }
+        return Double(hash % 10_007) / 10_006.0
+    }
+
     private func makeAvatar() -> NSView {
         let avatar = NSView()
         avatar.translatesAutoresizingMaskIntoConstraints = false