Нет описания

CVTemplateFetchService.swift 32KB

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