Bez popisu

CVTemplateFetchService.swift 32KB

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