Bez popisu

CVTemplateFetchService.swift 31KB

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