浏览代码

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 3 周之前
父节点
当前提交
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 @@
1
+//
2
+//  CVTemplateFetchService.swift
3
+//  App for Indeed
4
+//
5
+//  Fetches CV template definitions from OpenAI (structured JSON) so the gallery
6
+//  is not limited to the built-in static catalog. Falls back are handled by callers.
7
+//
8
+
9
+import Foundation
10
+
11
+// MARK: - Public API
12
+
13
+final class CVTemplateFetchService {
14
+    static let shared = CVTemplateFetchService()
15
+
16
+    private let endpoint = URL(string: "https://api.openai.com/v1/responses")!
17
+    private let session = URLSession(configuration: .ephemeral)
18
+
19
+    private init() {}
20
+
21
+    private enum CVTemplateCatalogPrompt {
22
+        static let instructions = """
23
+        You are the CV template catalog service for a desktop résumé builder. Invent exactly 30 distinct, plausible template \
24
+        records for job seekers. Each must have a unique `id` (kebab-case ASCII, e.g. "river-stone"), a display `name` (1–3 words), \
25
+        and varied visual parameters so thumbnails differ. Spread entries across all five `family` values (six templates per family). \
26
+        Use creative but professional names; do not copy real commercial template trademarks. \
27
+        Vary `headline`, `accent`, `layoutType`, `sidebarSide`, `sidebarTinted`, and `sectionLabelStyle` across the set — do not repeat \
28
+        the same six-tuple of those fields on consecutive rows. \
29
+        Output must strictly match the JSON schema — no markdown or extra keys.
30
+        """
31
+        static let userInput = """
32
+        Generate the template catalog now. Exactly six entries per family: professional, modern, creative, minimal, executive. \
33
+        Use both singleColumn and twoColumn layouts across the 30 rows. For twoColumn rows vary leading vs trailing sidebars and tinted true/false.
34
+        """
35
+    }
36
+
37
+    /// Loads templates from OpenAI (one automatic retry on transient network / parse failures).
38
+    func fetchTemplates(completion: @escaping (Result<[CVTemplate], Error>) -> Void) {
39
+        let apiKey = OpenAIConfiguration.apiKey
40
+        guard OpenAIConfiguration.hasAPIKey else {
41
+            completion(.failure(Self.missingKeyError))
42
+            return
43
+        }
44
+
45
+        var request = URLRequest(url: endpoint)
46
+        request.httpMethod = "POST"
47
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
48
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
49
+        request.timeoutInterval = 60
50
+
51
+        do {
52
+            request.httpBody = try Self.encodeCatalogRequestBody()
53
+        } catch {
54
+            completion(.failure(error))
55
+            return
56
+        }
57
+
58
+        session.dataTask(with: request) { data, response, error in
59
+            Self.handleFetchData(data, response: response, error: error, attempt: 0, completion: completion)
60
+        }.resume()
61
+    }
62
+
63
+    private static func encodeCatalogRequestBody() throws -> Data {
64
+        let payload = CVTemplateOpenAIRequest.catalogPayload(
65
+            model: "gpt-4o-mini",
66
+            instructions: CVTemplateCatalogPrompt.instructions,
67
+            input: CVTemplateCatalogPrompt.userInput
68
+        )
69
+        return try JSONEncoder().encode(payload)
70
+    }
71
+
72
+    private static func handleFetchData(
73
+        _ data: Data?,
74
+        response: URLResponse?,
75
+        error: Error?,
76
+        attempt: Int,
77
+        completion: @escaping (Result<[CVTemplate], Error>) -> Void
78
+    ) {
79
+        if let error {
80
+            if attempt == 0 {
81
+                DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
82
+                    refetchSameRequest(attempt: 1, completion: completion)
83
+                }
84
+                return
85
+            }
86
+            completion(.failure(error))
87
+            return
88
+        }
89
+        guard let data else {
90
+            if attempt == 0 {
91
+                DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
92
+                    refetchSameRequest(attempt: 1, completion: completion)
93
+                }
94
+                return
95
+            }
96
+            completion(.failure(Self.emptyResponseError))
97
+            return
98
+        }
99
+        if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
100
+            if attempt == 0, (500...599).contains(http.statusCode) || http.statusCode == 429 {
101
+                DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.5) {
102
+                    refetchSameRequest(attempt: 1, completion: completion)
103
+                }
104
+                return
105
+            }
106
+            if let apiError = try? JSONDecoder().decode(OpenAITemplatesAPIErrorResponse.self, from: data) {
107
+                completion(.failure(NSError(
108
+                    domain: "CVTemplateFetchService",
109
+                    code: http.statusCode,
110
+                    userInfo: [NSLocalizedDescriptionKey: apiError.error.message]
111
+                )))
112
+            } else {
113
+                completion(.failure(NSError(
114
+                    domain: "CVTemplateFetchService",
115
+                    code: http.statusCode,
116
+                    userInfo: [NSLocalizedDescriptionKey: "Template request failed with status \(http.statusCode)."]
117
+                )))
118
+            }
119
+            return
120
+        }
121
+        do {
122
+            let modelText = try Self.extractModelTextFromResponsesBody(data)
123
+            let trimmed = modelText.trimmingCharacters(in: .whitespacesAndNewlines)
124
+            guard !trimmed.isEmpty else {
125
+                throw Self.emptyModelTextError
126
+            }
127
+            let decoded = try Self.decodeCatalog(fromModelText: trimmed)
128
+            let mapped = decoded.compactMap { CVTemplate(aiItem: $0) }
129
+            guard !mapped.isEmpty else {
130
+                throw Self.noValidTemplatesError
131
+            }
132
+            var seen = Set<String>()
133
+            let unique = mapped.filter { seen.insert($0.id).inserted }
134
+            completion(.success(unique))
135
+        } catch {
136
+            if attempt == 0 {
137
+                DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
138
+                    refetchSameRequest(attempt: 1, completion: completion)
139
+                }
140
+                return
141
+            }
142
+            completion(.failure(error))
143
+        }
144
+    }
145
+
146
+    private static func refetchSameRequest(attempt: Int, completion: @escaping (Result<[CVTemplate], Error>) -> Void) {
147
+        let apiKey = OpenAIConfiguration.apiKey
148
+        guard OpenAIConfiguration.hasAPIKey else {
149
+            completion(.failure(missingKeyError))
150
+            return
151
+        }
152
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/responses")!)
153
+        request.httpMethod = "POST"
154
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
155
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
156
+        request.timeoutInterval = 60
157
+        guard let body = try? encodeCatalogRequestBody() else {
158
+            completion(.failure(NSError(domain: "CVTemplateFetchService", code: 11, userInfo: [NSLocalizedDescriptionKey: "Could not encode request."])))
159
+            return
160
+        }
161
+        request.httpBody = body
162
+        URLSession(configuration: .ephemeral).dataTask(with: request) { data, response, error in
163
+            handleFetchData(data, response: response, error: error, attempt: attempt, completion: completion)
164
+        }.resume()
165
+    }
166
+
167
+    // MARK: - Errors
168
+
169
+    private static let missingKeyError = NSError(
170
+        domain: "CVTemplateFetchService",
171
+        code: 1,
172
+        userInfo: [NSLocalizedDescriptionKey: "Missing API key. Set OPENAI_API_KEY in Xcode Build Settings."]
173
+    )
174
+    private static let emptyResponseError = NSError(
175
+        domain: "CVTemplateFetchService",
176
+        code: 2,
177
+        userInfo: [NSLocalizedDescriptionKey: "API returned an empty response."]
178
+    )
179
+    private static let emptyModelTextError = NSError(
180
+        domain: "CVTemplateFetchService",
181
+        code: 4,
182
+        userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
183
+    )
184
+    private static let noValidTemplatesError = NSError(
185
+        domain: "CVTemplateFetchService",
186
+        code: 10,
187
+        userInfo: [NSLocalizedDescriptionKey: "The assistant reply did not include usable templates."]
188
+    )
189
+
190
+    // MARK: - Response parsing (mirrors OpenAI job search handling)
191
+
192
+    private static func extractModelTextFromResponsesBody(_ data: Data) throws -> String {
193
+        let rootObject: Any
194
+        do {
195
+            rootObject = try JSONSerialization.jsonObject(with: data, options: [])
196
+        } catch {
197
+            throw NSError(
198
+                domain: "CVTemplateFetchService",
199
+                code: 5,
200
+                userInfo: [NSLocalizedDescriptionKey: "The service returned data that was not valid JSON."]
201
+            )
202
+        }
203
+        guard let root = rootObject as? [String: Any] else {
204
+            throw NSError(
205
+                domain: "CVTemplateFetchService",
206
+                code: 5,
207
+                userInfo: [NSLocalizedDescriptionKey: "Unexpected response shape from the template service."]
208
+            )
209
+        }
210
+        if let status = root["status"] as? String {
211
+            if status == "failed" {
212
+                let message = (root["error"] as? [String: Any])?["message"] as? String ?? "The request failed."
213
+                throw NSError(domain: "CVTemplateFetchService", code: 7, userInfo: [NSLocalizedDescriptionKey: message])
214
+            }
215
+            if status == "incomplete",
216
+               let details = root["incomplete_details"] as? [String: Any],
217
+               let reason = details["reason"] as? String {
218
+                throw NSError(
219
+                    domain: "CVTemplateFetchService",
220
+                    code: 8,
221
+                    userInfo: [NSLocalizedDescriptionKey: "Request stopped early (\(reason)). Try again."]
222
+                )
223
+            }
224
+        }
225
+        if let direct = root["output_text"] as? String {
226
+            let trimmed = direct.trimmingCharacters(in: .whitespacesAndNewlines)
227
+            if !trimmed.isEmpty { return trimmed }
228
+        }
229
+        guard let output = root["output"] as? [Any] else {
230
+            if let fallback = Self.scavengeTemplatesJSONString(fromUTF8Data: data) {
231
+                return fallback
232
+            }
233
+            throw NSError(
234
+                domain: "CVTemplateFetchService",
235
+                code: 9,
236
+                userInfo: [NSLocalizedDescriptionKey: "The service returned no assistant text. Try again."]
237
+            )
238
+        }
239
+        var segments: [String] = []
240
+        for case let item as [String: Any] in output where (item["type"] as? String) == "message" {
241
+            collectOutputTextSegments(fromOutputItem: item, into: &segments)
242
+        }
243
+        if segments.isEmpty {
244
+            for case let item as [String: Any] in output {
245
+                collectOutputTextSegments(fromOutputItem: item, into: &segments)
246
+            }
247
+        }
248
+        let combined = segments.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
249
+        if !combined.isEmpty {
250
+            return combined
251
+        }
252
+        if let fallback = Self.scavengeTemplatesJSONString(fromUTF8Data: data) {
253
+            return fallback
254
+        }
255
+        throw NSError(
256
+            domain: "CVTemplateFetchService",
257
+            code: 9,
258
+            userInfo: [NSLocalizedDescriptionKey: "The model did not return readable text. Try again."]
259
+        )
260
+    }
261
+
262
+    /// When `output` is missing, scan the raw body for a JSON object that contains `"templates"`.
263
+    private static func scavengeTemplatesJSONString(fromUTF8Data data: Data) -> String? {
264
+        guard let raw = String(data: data, encoding: .utf8) else { return nil }
265
+        return extractTemplatesJSONObjectString(from: raw)
266
+    }
267
+
268
+    private static func collectOutputTextSegments(fromOutputItem item: [String: Any], into segments: inout [String]) {
269
+        guard let content = item["content"] as? [Any] else { return }
270
+        for case let part as [String: Any] in content {
271
+            let type = (part["type"] as? String) ?? ""
272
+            var candidate: String?
273
+            if let s = part["text"] as? String {
274
+                candidate = s
275
+            } else if let nested = part["text"] as? [String: Any], let value = nested["value"] as? String {
276
+                candidate = value
277
+            } else if let j = part["json"] as? String {
278
+                candidate = j
279
+            } else if let jObj = part["json"] as? [String: Any],
280
+                      let jData = try? JSONSerialization.data(withJSONObject: jObj, options: []),
281
+                      let jStr = String(data: jData, encoding: .utf8) {
282
+                candidate = jStr
283
+            }
284
+            guard let blob = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !blob.isEmpty else { continue }
285
+            let looksLikeCatalog = blob.contains("\"templates\"") && blob.contains("{")
286
+            if type == "output_text" || type == "output_json" || looksLikeCatalog {
287
+                segments.append(blob)
288
+            }
289
+        }
290
+    }
291
+
292
+    private static func decodeCatalog(fromModelText text: String) throws -> [CVTemplateAIItem] {
293
+        let stripped = text.trimmingCharacters(in: .whitespacesAndNewlines)
294
+        if stripped.hasPrefix("{"), let directData = stripped.data(using: .utf8) {
295
+            if let payload = try? JSONDecoder().decode(CVTemplateCatalogPayload.self, from: directData) {
296
+                return payload.templates
297
+            }
298
+            if let flex = flexibleTemplates(fromJSONData: directData) {
299
+                return flex
300
+            }
301
+        }
302
+        let jsonString = extractTemplatesJSONObjectString(from: text) ?? extractJSONObject(from: text)
303
+        let jsonData = Data(jsonString.utf8)
304
+        if let payload = try? JSONDecoder().decode(CVTemplateCatalogPayload.self, from: jsonData) {
305
+            return payload.templates
306
+        }
307
+        if let flex = flexibleTemplates(fromJSONData: jsonData) {
308
+            return flex
309
+        }
310
+        throw NSError(
311
+            domain: "CVTemplateFetchService",
312
+            code: 10,
313
+            userInfo: [NSLocalizedDescriptionKey: "The assistant reply was not valid template JSON."]
314
+        )
315
+    }
316
+
317
+    private static func flexibleTemplates(fromJSONData data: Data) -> [CVTemplateAIItem]? {
318
+        guard let obj = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
319
+        if let root = obj as? [String: Any] {
320
+            if let items = templatesArray(from: root) {
321
+                let mapped = items.compactMap { CVTemplateAIItem.fromFlexibleDictionary($0) }
322
+                return mapped.isEmpty ? nil : mapped
323
+            }
324
+        }
325
+        return nil
326
+    }
327
+
328
+    private static func templatesArray(from root: [String: Any]) -> [[String: Any]]? {
329
+        if let arr = root["templates"] as? [[String: Any]] { return arr }
330
+        for (_, value) in root {
331
+            if let inner = value as? [String: Any], let arr = inner["templates"] as? [[String: Any]] {
332
+                return arr
333
+            }
334
+        }
335
+        return nil
336
+    }
337
+
338
+    private static func stripMarkdownCodeFence(_ text: String) -> String {
339
+        var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
340
+        guard s.hasPrefix("```") else { return s }
341
+        s.removeFirst(3)
342
+        if s.lowercased().hasPrefix("json") {
343
+            s.removeFirst(4)
344
+        }
345
+        s = s.trimmingCharacters(in: .whitespacesAndNewlines)
346
+        if let fence = s.range(of: "```", options: .backwards) {
347
+            s = String(s[..<fence.lowerBound])
348
+        }
349
+        return s.trimmingCharacters(in: .whitespacesAndNewlines)
350
+    }
351
+
352
+    private static func balancedJSONObject(from openBrace: String.Index, in s: String) -> String? {
353
+        var depth = 0
354
+        var inString = false
355
+        var escaped = false
356
+        var i = openBrace
357
+        while i < s.endIndex {
358
+            let ch = s[i]
359
+            if inString {
360
+                if escaped {
361
+                    escaped = false
362
+                } else if ch == "\\" {
363
+                    escaped = true
364
+                } else if ch == "\"" {
365
+                    inString = false
366
+                }
367
+            } else {
368
+                switch ch {
369
+                case "\"":
370
+                    inString = true
371
+                case "{":
372
+                    depth += 1
373
+                case "}":
374
+                    depth -= 1
375
+                    if depth == 0 {
376
+                        return String(s[openBrace...i])
377
+                    }
378
+                default:
379
+                    break
380
+                }
381
+            }
382
+            i = s.index(after: i)
383
+        }
384
+        return nil
385
+    }
386
+
387
+    private static func extractTemplatesJSONObjectString(from text: String) -> String? {
388
+        let s = stripMarkdownCodeFence(text)
389
+        guard let keyRange = s.range(of: "\"templates\"", options: .caseInsensitive) else { return nil }
390
+        let head = s[..<keyRange.lowerBound]
391
+        guard let open = head.lastIndex(of: "{") else { return nil }
392
+        return balancedJSONObject(from: open, in: s)
393
+    }
394
+
395
+    private static func extractJSONObject(from text: String) -> String {
396
+        if let extracted = extractTemplatesJSONObjectString(from: text) {
397
+            return extracted
398
+        }
399
+        let stripped = stripMarkdownCodeFence(text)
400
+        if let first = stripped.firstIndex(of: "{"), let balanced = balancedJSONObject(from: first, in: stripped) {
401
+            return balanced
402
+        }
403
+        if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
404
+            return String(text[range])
405
+        }
406
+        return text
407
+    }
408
+}
409
+
410
+// MARK: - AI DTO → CVTemplate
411
+
412
+private struct CVTemplateCatalogPayload: Codable {
413
+    let templates: [CVTemplateAIItem]
414
+}
415
+
416
+private struct CVTemplateAIItem: Codable {
417
+    let id: String
418
+    let name: String
419
+    let family: String
420
+    let headline: String
421
+    let accent: String
422
+    let layoutType: String
423
+    let sidebarSide: String
424
+    let sidebarTinted: Bool
425
+    let sectionLabelStyle: String
426
+
427
+    /// Decodes rows when keys differ slightly or booleans arrive as numbers.
428
+    fileprivate static func fromFlexibleDictionary(_ dict: [String: Any]) -> CVTemplateAIItem? {
429
+        func firstString(keys: [String]) -> String? {
430
+            for wanted in keys {
431
+                for (dk, dv) in dict {
432
+                    guard dk.caseInsensitiveCompare(wanted) == .orderedSame else { continue }
433
+                    if let s = dv as? String { return s }
434
+                }
435
+            }
436
+            return nil
437
+        }
438
+        func firstBool(keys: [String]) -> Bool {
439
+            for wanted in keys {
440
+                for (dk, dv) in dict {
441
+                    guard dk.caseInsensitiveCompare(wanted) == .orderedSame else { continue }
442
+                    if let b = dv as? Bool { return b }
443
+                    if let i = dv as? Int { return i != 0 }
444
+                    if let s = dv as? String {
445
+                        let t = s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
446
+                        if ["true", "1", "yes", "y"].contains(t) { return true }
447
+                        if ["false", "0", "no", "n"].contains(t) { return false }
448
+                    }
449
+                }
450
+            }
451
+            return false
452
+        }
453
+        guard let id = firstString(keys: ["id"]),
454
+              let name = firstString(keys: ["name"]),
455
+              let family = firstString(keys: ["family"]),
456
+              let headline = firstString(keys: ["headline"]),
457
+              let accent = firstString(keys: ["accent"]),
458
+              let layoutType = firstString(keys: ["layoutType", "layout_type"]),
459
+              let sidebarSide = firstString(keys: ["sidebarSide", "sidebar_side"]),
460
+              let sectionLabelStyle = firstString(keys: ["sectionLabelStyle", "section_label_style"])
461
+        else { return nil }
462
+
463
+        let tinted = firstBool(keys: ["sidebarTinted", "sidebar_tinted"])
464
+        return CVTemplateAIItem(
465
+            id: id,
466
+            name: name,
467
+            family: family,
468
+            headline: headline,
469
+            accent: accent,
470
+            layoutType: layoutType,
471
+            sidebarSide: sidebarSide,
472
+            sidebarTinted: tinted,
473
+            sectionLabelStyle: sectionLabelStyle
474
+        )
475
+    }
476
+}
477
+
478
+extension CVTemplate {
479
+    fileprivate init?(aiItem: CVTemplateAIItem) {
480
+        let id = Self.slugifyID(aiItem.id)
481
+        let name = aiItem.name.trimmingCharacters(in: .whitespacesAndNewlines)
482
+        guard !id.isEmpty, !name.isEmpty else { return nil }
483
+
484
+        let familyNorm = aiItem.family.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
485
+        guard let family = Self.parseDesignFamily(familyNorm) else { return nil }
486
+
487
+        let headline = CVTemplate.parseHeadline(aiItem.headline)
488
+        let accent = CVTemplate.parseAccent(aiItem.accent)
489
+        let section = CVTemplate.parseSectionLabelStyle(aiItem.sectionLabelStyle)
490
+
491
+        let layoutNorm = Self.normalizedSchemaToken(aiItem.layoutType)
492
+        let layout: Layout
493
+        switch layoutNorm {
494
+        case "singlecolumn":
495
+            layout = .singleColumn
496
+        case "twocolumn":
497
+            let sideKey = Self.normalizedSchemaToken(aiItem.sidebarSide)
498
+            let side: SidebarSide = (sideKey == "trailing") ? .trailing : .leading
499
+            layout = .twoColumn(sidebar: side, tinted: aiItem.sidebarTinted)
500
+        default:
501
+            layout = .singleColumn
502
+        }
503
+
504
+        self.init(
505
+            id: id,
506
+            name: name,
507
+            family: family,
508
+            headline: headline,
509
+            accent: accent,
510
+            layout: layout,
511
+            sectionLabelStyle: section
512
+        )
513
+    }
514
+
515
+    /// Collapses spaces and underscores so values like "Two Column" still map.
516
+    private static func normalizedSchemaToken(_ raw: String) -> String {
517
+        raw.trimmingCharacters(in: .whitespacesAndNewlines)
518
+            .lowercased()
519
+            .filter { !$0.isWhitespace }
520
+            .replacingOccurrences(of: "_", with: "")
521
+    }
522
+
523
+    private static func parseDesignFamily(_ raw: String) -> CVDesignFamily? {
524
+        let key = normalizedSchemaToken(raw)
525
+        if let f = CVDesignFamily(rawValue: key) { return f }
526
+        if key == "minimalist" { return .minimal }
527
+        if key == "exec" { return .executive }
528
+        return nil
529
+    }
530
+
531
+    private static func slugifyID(_ raw: String) -> String {
532
+        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
533
+        guard !trimmed.isEmpty else { return "" }
534
+        var out = ""
535
+        var lastWasHyphen = false
536
+        for ch in trimmed {
537
+            if ch.isLetter || ch.isNumber {
538
+                out.append(ch)
539
+                lastWasHyphen = false
540
+            } else if ch == "-" || ch == "_" || ch == " " {
541
+                if !out.isEmpty, !lastWasHyphen {
542
+                    out.append("-")
543
+                    lastWasHyphen = true
544
+                }
545
+            }
546
+        }
547
+        while out.last == "-" { out.removeLast() }
548
+        return out
549
+    }
550
+
551
+    private static func parseHeadline(_ s: String) -> Headline {
552
+        let key = normalizedSchemaToken(s)
553
+        switch key {
554
+        case "centered": return .centered
555
+        case "leftaligned", "left": return .leftAligned
556
+        case "leftwithinitials", "initials": return .leftWithInitials
557
+        case "avatarstacked", "stacked": return .avatarStacked
558
+        default: return .leftAligned
559
+        }
560
+    }
561
+
562
+    private static func parseAccent(_ s: String) -> Accent {
563
+        let key = normalizedSchemaToken(s)
564
+        switch key {
565
+        case "none": return .none
566
+        case "redunderline": return .redUnderline
567
+        case "redbar": return .redBar
568
+        case "bluebar": return .blueBar
569
+        default: return .none
570
+        }
571
+    }
572
+
573
+    private static func parseSectionLabelStyle(_ s: String) -> SectionLabelStyle {
574
+        let key = normalizedSchemaToken(s)
575
+        switch key {
576
+        case "uppercase", "caps": return .uppercase
577
+        case "slashed", "slash": return .slashed
578
+        case "bracketed", "brackets": return .bracketed
579
+        default: return .uppercase
580
+        }
581
+    }
582
+}
583
+
584
+// MARK: - OpenAI request types
585
+
586
+private struct OpenAITemplatesAPIErrorResponse: Codable {
587
+    let error: APIErrorPayload
588
+
589
+    struct APIErrorPayload: Codable {
590
+        let message: String
591
+    }
592
+}
593
+
594
+private struct CVTemplateOpenAIRequest: Encodable {
595
+    let model: String
596
+    let instructions: String
597
+    let input: String
598
+    let text: CVTemplateOpenAITextOutputConfig
599
+
600
+    static func catalogPayload(model: String, instructions: String, input: String) -> CVTemplateOpenAIRequest {
601
+        let itemProperties = CVTemplateAIItemSchemaProperties(
602
+            id: OpenAIJSONSchemaStringField(type: "string", description: "Unique kebab-case id."),
603
+            name: OpenAIJSONSchemaStringField(type: "string", description: "Short display name."),
604
+            family: OpenAIJSONSchemaStringEnum(
605
+                type: "string",
606
+                description: "Template style family.",
607
+                enumValues: CVTemplateSchemaEnumValues.families
608
+            ),
609
+            headline: OpenAIJSONSchemaStringEnum(
610
+                type: "string",
611
+                description: "Header arrangement for the mini preview.",
612
+                enumValues: CVTemplateSchemaEnumValues.headlines
613
+            ),
614
+            accent: OpenAIJSONSchemaStringEnum(
615
+                type: "string",
616
+                description: "Accent decoration.",
617
+                enumValues: CVTemplateSchemaEnumValues.accents
618
+            ),
619
+            layoutType: OpenAIJSONSchemaStringEnum(
620
+                type: "string",
621
+                description: "Page layout.",
622
+                enumValues: CVTemplateSchemaEnumValues.layouts
623
+            ),
624
+            sidebarSide: OpenAIJSONSchemaStringEnum(
625
+                type: "string",
626
+                description: "Sidebar column side when twoColumn.",
627
+                enumValues: CVTemplateSchemaEnumValues.sides
628
+            ),
629
+            sidebarTinted: OpenAIJSONSchemaBooleanField(
630
+                type: "boolean",
631
+                description: "Tint sidebar column when twoColumn."
632
+            ),
633
+            sectionLabelStyle: OpenAIJSONSchemaStringEnum(
634
+                type: "string",
635
+                description: "Section heading style.",
636
+                enumValues: CVTemplateSchemaEnumValues.sectionStyles
637
+            )
638
+        )
639
+        let itemSchema = CVTemplateAIItemSchema(
640
+            type: "object",
641
+            properties: itemProperties,
642
+            required: [
643
+                "id", "name", "family", "headline", "accent",
644
+                "layoutType", "sidebarSide", "sidebarTinted", "sectionLabelStyle"
645
+            ],
646
+            additionalProperties: false
647
+        )
648
+        let templatesProperty = CVTemplateTemplatesArrayProperty(
649
+            type: "array",
650
+            description: "Exactly 30 CV template definitions.",
651
+            items: itemSchema
652
+        )
653
+        let rootProperties = CVTemplateCatalogRootProperties(templates: templatesProperty)
654
+        let rootSchema = CVTemplateCatalogRootSchema(
655
+            type: "object",
656
+            properties: rootProperties,
657
+            required: ["templates"],
658
+            additionalProperties: false
659
+        )
660
+        let format = CVTemplateCatalogResponseJSONSchemaFormat(
661
+            type: "json_schema",
662
+            name: "cv_template_catalog",
663
+            strict: true,
664
+            schema: rootSchema
665
+        )
666
+        return CVTemplateOpenAIRequest(
667
+            model: model,
668
+            instructions: instructions,
669
+            input: input,
670
+            text: CVTemplateOpenAITextOutputConfig(format: format)
671
+        )
672
+    }
673
+}
674
+
675
+private struct CVTemplateOpenAITextOutputConfig: Encodable {
676
+    let format: CVTemplateCatalogResponseJSONSchemaFormat
677
+}
678
+
679
+private struct CVTemplateCatalogResponseJSONSchemaFormat: Encodable {
680
+    let type: String
681
+    let name: String
682
+    let strict: Bool
683
+    let schema: CVTemplateCatalogRootSchema
684
+}
685
+
686
+private struct CVTemplateCatalogRootSchema: Encodable {
687
+    let type: String
688
+    let properties: CVTemplateCatalogRootProperties
689
+    let required: [String]
690
+    let additionalProperties: Bool
691
+}
692
+
693
+private struct CVTemplateCatalogRootProperties: Encodable {
694
+    let templates: CVTemplateTemplatesArrayProperty
695
+}
696
+
697
+private enum CVTemplateSchemaEnumValues {
698
+    static let families = ["creative", "executive", "minimal", "modern", "professional"]
699
+    static let headlines = ["avatarStacked", "centered", "leftAligned", "leftWithInitials"]
700
+    static let accents = ["blueBar", "none", "redBar", "redUnderline"]
701
+    static let layouts = ["singleColumn", "twoColumn"]
702
+    static let sides = ["leading", "trailing"]
703
+    static let sectionStyles = ["bracketed", "slashed", "uppercase"]
704
+}
705
+
706
+private struct CVTemplateTemplatesArrayProperty: Encodable {
707
+    let type: String
708
+    let description: String
709
+    let items: CVTemplateAIItemSchema
710
+}
711
+
712
+private struct CVTemplateAIItemSchema: Encodable {
713
+    let type: String
714
+    let properties: CVTemplateAIItemSchemaProperties
715
+    let required: [String]
716
+    let additionalProperties: Bool
717
+}
718
+
719
+private struct CVTemplateAIItemSchemaProperties: Encodable {
720
+    let id: OpenAIJSONSchemaStringField
721
+    let name: OpenAIJSONSchemaStringField
722
+    let family: OpenAIJSONSchemaStringEnum
723
+    let headline: OpenAIJSONSchemaStringEnum
724
+    let accent: OpenAIJSONSchemaStringEnum
725
+    let layoutType: OpenAIJSONSchemaStringEnum
726
+    let sidebarSide: OpenAIJSONSchemaStringEnum
727
+    let sidebarTinted: OpenAIJSONSchemaBooleanField
728
+    let sectionLabelStyle: OpenAIJSONSchemaStringEnum
729
+}
730
+
731
+private struct OpenAIJSONSchemaStringEnum: Encodable {
732
+    let type: String
733
+    let description: String
734
+    let enumValues: [String]
735
+
736
+    enum CodingKeys: String, CodingKey {
737
+        case type, description
738
+        case enumValues = "enum"
739
+    }
740
+}
741
+
742
+private struct OpenAIJSONSchemaStringField: Encodable {
743
+    let type: String
744
+    let description: String
745
+}
746
+
747
+private struct OpenAIJSONSchemaBooleanField: Encodable {
748
+    let type: String
749
+    let description: String
750
+}

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

@@ -419,6 +419,8 @@ final class CVMakerPageView: NSView {
419 419
     private var selectedGroup: CVCategoryGroup = .designBased
420 420
     private var selectedFamily: CVDesignFamily? = nil // nil == "All"
421 421
     private var selectedTemplateID: String? = "paper-white"
422
+    /// Shown immediately; replaced when `CVTemplateFetchService` returns AI-generated entries.
423
+    private var activeCatalog: [CVTemplate] = CVTemplateCatalog.all
422 424
     private var groupTabButtons: [CVCategoryGroup: CVChipButton] = [:]
423 425
     private var familyChipButtons: [CVDesignFamily?: CVChipButton] = [:]
424 426
     private var templateCardsByID: [String: CVTemplateCard] = [:]
@@ -429,6 +431,7 @@ final class CVMakerPageView: NSView {
429 431
         reloadFamilyChips()
430 432
         reloadTemplateGrid()
431 433
         updateSelectedChipStates()
434
+        beginLoadingAICatalogIfPossible()
432 435
     }
433 436
 
434 437
     @available(*, unavailable)
@@ -593,7 +596,7 @@ final class CVMakerPageView: NSView {
593 596
     private func templates(forGroup group: CVCategoryGroup, family: CVDesignFamily?) -> [CVTemplate] {
594 597
         // The catalog is design-driven; profession-based reuses the same templates
595 598
         // so the gallery is fully populated for both groups in this preview build.
596
-        let base = CVTemplateCatalog.all
599
+        let base = activeCatalog
597 600
         guard let family else { return base }
598 601
         _ = group
599 602
         return base.filter { $0.family == family }
@@ -714,7 +717,7 @@ final class CVMakerPageView: NSView {
714 717
     private func didPreviewTemplate(_ id: String) {
715 718
         selectedTemplateID = id
716 719
         applySelectionToCards()
717
-        guard let template = CVTemplateCatalog.all.first(where: { $0.id == id }) else { return }
720
+        guard let template = activeCatalog.first(where: { $0.id == id }) else { return }
718 721
         presentPlaceholderAlert(
719 722
             title: "Preview \"\(template.name)\"",
720 723
             message: "Full-page previews and PDF export are coming soon."
@@ -723,7 +726,7 @@ final class CVMakerPageView: NSView {
723 726
 
724 727
     @objc private func didTapUseTemplate() {
725 728
         guard let id = selectedTemplateID,
726
-              let template = CVTemplateCatalog.all.first(where: { $0.id == id }) else {
729
+              let template = activeCatalog.first(where: { $0.id == id }) else {
727 730
             presentPlaceholderAlert(title: "Pick a template", message: "Select a template first, then choose a profile to continue.")
728 731
             return
729 732
         }
@@ -763,6 +766,33 @@ final class CVMakerPageView: NSView {
763 766
         }
764 767
     }
765 768
 
769
+    private func beginLoadingAICatalogIfPossible() {
770
+        guard OpenAIConfiguration.hasAPIKey else { return }
771
+        let defaultSubtitle = subtitleLabel.stringValue
772
+        subtitleLabel.stringValue = "Fetching AI-curated templates…"
773
+        CVTemplateFetchService.shared.fetchTemplates { [weak self] result in
774
+            DispatchQueue.main.async {
775
+                guard let self else { return }
776
+                switch result {
777
+                case .success(let list):
778
+                    self.subtitleLabel.stringValue = defaultSubtitle
779
+                    if !list.isEmpty {
780
+                        self.activeCatalog = list
781
+                        self.configureGroupTabs()
782
+                        self.reloadFamilyChips()
783
+                        self.reloadTemplateGrid()
784
+                        self.updateSelectedChipStates()
785
+                    }
786
+                case .failure:
787
+                    self.subtitleLabel.stringValue = "Couldn’t load AI templates — showing the built-in gallery."
788
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 5.5) { [weak self] in
789
+                        self?.subtitleLabel.stringValue = defaultSubtitle
790
+                    }
791
+                }
792
+            }
793
+        }
794
+    }
795
+
766 796
     private func presentPlaceholderAlert(title: String, message: String) {
767 797
         let alert = NSAlert()
768 798
         alert.messageText = title
@@ -1279,9 +1309,12 @@ private final class CVTemplatePreviewView: NSView {
1279 1309
         let container = NSView()
1280 1310
         container.translatesAutoresizingMaskIntoConstraints = false
1281 1311
 
1282
-        let nameStrip = makeLine(color: palette.previewInk, height: 5.5, widthFraction: 0.6)
1283
-        let roleStrip = makeLine(color: palette.previewMuted, height: 3, widthFraction: 0.42)
1284
-        let contactStrip = makeLine(color: palette.previewMuted.withAlphaComponent(0.7), height: 2, widthFraction: 0.55)
1312
+        let jitterA = templateVisualJitter(template.id)
1313
+        let jitterB = templateVisualJitter(template.id + "-b")
1314
+        let nameInk = palette.previewInk.blended(withFraction: CGFloat(jitterA * 0.14), of: palette.previewAccentBlue) ?? palette.previewInk
1315
+        let nameStrip = makeLine(color: nameInk, height: 5.2 + CGFloat(jitterB * 0.9), widthFraction: 0.52 + CGFloat(jitterA * 0.14))
1316
+        let roleStrip = makeLine(color: palette.previewMuted, height: 3, widthFraction: 0.36 + CGFloat(jitterB * 0.12))
1317
+        let contactStrip = makeLine(color: palette.previewMuted.withAlphaComponent(0.7), height: 2, widthFraction: 0.48 + CGFloat(jitterA * 0.1))
1285 1318
 
1286 1319
         let textStack = NSStackView(views: [nameStrip, roleStrip, contactStrip])
1287 1320
         textStack.orientation = .vertical
@@ -1378,6 +1411,16 @@ private final class CVTemplatePreviewView: NSView {
1378 1411
         return container
1379 1412
     }
1380 1413
 
1414
+    /// Stable 0…1 value per template so thumbnails are not visually identical when metadata is similar.
1415
+    private func templateVisualJitter(_ salt: String) -> Double {
1416
+        var hash: UInt64 = 1469598103934665603
1417
+        for b in salt.utf8 {
1418
+            hash ^= UInt64(b)
1419
+            hash &*= 1_099_511_628_211
1420
+        }
1421
+        return Double(hash % 10_007) / 10_006.0
1422
+    }
1423
+
1381 1424
     private func makeAvatar() -> NSView {
1382 1425
         let avatar = NSView()
1383 1426
         avatar.translatesAutoresizingMaskIntoConstraints = false