Sin descripción

CVTemplateFetchService.swift 30KB

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