Ei kuvausta

CVTemplateFetchService.swift 32KB

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