Sin descripción

CVTemplateFetchService.swift 31KB

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