JournalFinderService.swift 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import Foundation
  2. protocol JournalFinderServicing {
  3. func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult
  4. }
  5. struct JournalFinderService: JournalFinderServicing {
  6. private static let abstractPromptWordLimit = 320
  7. private let openAI: OpenAIChatServicing
  8. init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
  9. self.openAI = openAI
  10. }
  11. func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult {
  12. let abstract = criteria.abstract.trimmingCharacters(in: .whitespacesAndNewlines)
  13. let title = criteria.title.trimmingCharacters(in: .whitespacesAndNewlines)
  14. let keywords = criteria.keywords.trimmingCharacters(in: .whitespacesAndNewlines)
  15. guard !abstract.isEmpty || !title.isEmpty else {
  16. throw JournalFinderServiceError.emptyInput
  17. }
  18. let userPrompt = Self.buildUserPrompt(from: criteria, title: title, abstract: abstract, keywords: keywords)
  19. do {
  20. let decoded: JournalFinderAPIResponse = try await openAI.completeJSON(
  21. systemPrompt: """
  22. You are an academic publishing advisor. Recommend real, well-known scholarly \
  23. journals that fit the user's research. Respond with JSON only using this schema:
  24. {
  25. "summary": "one sentence overview of the search",
  26. "recommendations": [
  27. {
  28. "name": "journal title",
  29. "publisher": "publisher name",
  30. "matchScore": 85,
  31. "scope": "brief scope description",
  32. "fitReason": "why this journal fits the manuscript",
  33. "isOpenAccess": true,
  34. "indexes": ["Scopus", "Web of Science"],
  35. "websiteURL": "https://example.com",
  36. "averageReviewWeeks": 12
  37. }
  38. ]
  39. }
  40. Return 5 to 8 journals sorted by matchScore descending. \
  41. matchScore must be 0-100. Use only real journals. \
  42. averageReviewWeeks may be null if unknown.
  43. """,
  44. userPrompt: userPrompt,
  45. as: JournalFinderAPIResponse.self
  46. )
  47. let recommendations = decoded.recommendations.map {
  48. JournalRecommendation(
  49. name: $0.name,
  50. publisher: $0.publisher,
  51. matchScore: min(100, max(0, $0.matchScore)),
  52. scope: $0.scope,
  53. fitReason: $0.fitReason,
  54. isOpenAccess: $0.isOpenAccess,
  55. indexes: $0.indexes,
  56. websiteURL: $0.websiteURL,
  57. averageReviewWeeks: $0.averageReviewWeeks
  58. )
  59. }
  60. return JournalFinderResult(
  61. recommendations: recommendations.sorted { $0.matchScore > $1.matchScore },
  62. summary: decoded.summary
  63. )
  64. } catch let error as OpenAIServiceError {
  65. throw JournalFinderServiceError.fromOpenAI(error)
  66. }
  67. }
  68. private static func buildUserPrompt(
  69. from criteria: JournalFinderSearchCriteria,
  70. title: String,
  71. abstract: String,
  72. keywords: String
  73. ) -> String {
  74. var lines: [String] = []
  75. if !title.isEmpty {
  76. lines.append("Title: \(title)")
  77. }
  78. if !abstract.isEmpty {
  79. let condensedAbstract = abstract.truncated(toMaxWords: Self.abstractPromptWordLimit)
  80. let isTruncated = condensedAbstract.wordCount < abstract.wordCount
  81. if isTruncated {
  82. lines.append("Abstract / Summary (first \(Self.abstractPromptWordLimit) words): \(condensedAbstract)")
  83. lines.append("Note: Use this abstract excerpt plus title/keywords to infer journal fit.")
  84. } else {
  85. lines.append("Abstract / Summary: \(condensedAbstract)")
  86. }
  87. }
  88. if !keywords.isEmpty {
  89. lines.append("Keywords: \(keywords)")
  90. }
  91. if criteria.field != .all {
  92. lines.append("Preferred field: \(criteria.field.displayName)")
  93. }
  94. if criteria.openAccessOnly {
  95. lines.append("Requirement: Open access journals only.")
  96. }
  97. if !criteria.selectedIndexes.isEmpty {
  98. let names = criteria.selectedIndexes.map(\.rawValue).sorted().joined(separator: ", ")
  99. lines.append("Must be indexed in: \(names)")
  100. }
  101. return lines.joined(separator: "\n")
  102. }
  103. }
  104. private struct JournalFinderAPIResponse: Decodable {
  105. struct Recommendation: Decodable {
  106. let name: String
  107. let publisher: String
  108. let matchScore: Int
  109. let scope: String
  110. let fitReason: String
  111. let isOpenAccess: Bool
  112. let indexes: [String]
  113. let websiteURL: String?
  114. let averageReviewWeeks: Int?
  115. }
  116. let summary: String
  117. let recommendations: [Recommendation]
  118. }
  119. private extension JournalFinderServiceError {
  120. static func fromOpenAI(_ error: OpenAIServiceError) -> JournalFinderServiceError {
  121. switch error {
  122. case .missingAPIKey:
  123. .missingAPIKey
  124. case .invalidResponse:
  125. .invalidResponse
  126. case .networkError(let message), .apiError(let message):
  127. .apiError(message)
  128. }
  129. }
  130. }