Няма описание

CVTemplateFetchService.swift 29KB

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