| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146 |
- import Foundation
- protocol JournalFinderServicing {
- func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult
- }
- struct JournalFinderService: JournalFinderServicing {
- private static let abstractPromptWordLimit = 320
- private let openAI: OpenAIChatServicing
- init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
- self.openAI = openAI
- }
- func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult {
- let abstract = criteria.abstract.trimmingCharacters(in: .whitespacesAndNewlines)
- let title = criteria.title.trimmingCharacters(in: .whitespacesAndNewlines)
- let keywords = criteria.keywords.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !abstract.isEmpty || !title.isEmpty else {
- throw JournalFinderServiceError.emptyInput
- }
- let userPrompt = Self.buildUserPrompt(from: criteria, title: title, abstract: abstract, keywords: keywords)
- do {
- let decoded: JournalFinderAPIResponse = try await openAI.completeJSON(
- systemPrompt: """
- You are an academic publishing advisor. Recommend real, well-known scholarly \
- journals that fit the user's research. Respond with JSON only using this schema:
- {
- "summary": "one sentence overview of the search",
- "recommendations": [
- {
- "name": "journal title",
- "publisher": "publisher name",
- "matchScore": 85,
- "scope": "brief scope description",
- "fitReason": "why this journal fits the manuscript",
- "isOpenAccess": true,
- "indexes": ["Scopus", "Web of Science"],
- "websiteURL": "https://example.com",
- "averageReviewWeeks": 12
- }
- ]
- }
- Return 5 to 8 journals sorted by matchScore descending. \
- matchScore must be 0-100. Use only real journals. \
- averageReviewWeeks may be null if unknown.
- """,
- userPrompt: userPrompt,
- as: JournalFinderAPIResponse.self
- )
- let recommendations = decoded.recommendations.map {
- JournalRecommendation(
- name: $0.name,
- publisher: $0.publisher,
- matchScore: min(100, max(0, $0.matchScore)),
- scope: $0.scope,
- fitReason: $0.fitReason,
- isOpenAccess: $0.isOpenAccess,
- indexes: $0.indexes,
- websiteURL: $0.websiteURL,
- averageReviewWeeks: $0.averageReviewWeeks
- )
- }
- return JournalFinderResult(
- recommendations: recommendations.sorted { $0.matchScore > $1.matchScore },
- summary: decoded.summary
- )
- } catch let error as OpenAIServiceError {
- throw JournalFinderServiceError.fromOpenAI(error)
- }
- }
- private static func buildUserPrompt(
- from criteria: JournalFinderSearchCriteria,
- title: String,
- abstract: String,
- keywords: String
- ) -> String {
- var lines: [String] = []
- if !title.isEmpty {
- lines.append("Title: \(title)")
- }
- if !abstract.isEmpty {
- let condensedAbstract = abstract.truncated(toMaxWords: Self.abstractPromptWordLimit)
- let isTruncated = condensedAbstract.wordCount < abstract.wordCount
- if isTruncated {
- lines.append("Abstract / Summary (first \(Self.abstractPromptWordLimit) words): \(condensedAbstract)")
- lines.append("Note: Use this abstract excerpt plus title/keywords to infer journal fit.")
- } else {
- lines.append("Abstract / Summary: \(condensedAbstract)")
- }
- }
- if !keywords.isEmpty {
- lines.append("Keywords: \(keywords)")
- }
- if criteria.field != .all {
- lines.append("Preferred field: \(criteria.field.displayName)")
- }
- if criteria.openAccessOnly {
- lines.append("Requirement: Open access journals only.")
- }
- if !criteria.selectedIndexes.isEmpty {
- let names = criteria.selectedIndexes.map(\.rawValue).sorted().joined(separator: ", ")
- lines.append("Must be indexed in: \(names)")
- }
- return lines.joined(separator: "\n")
- }
- }
- private struct JournalFinderAPIResponse: Decodable {
- struct Recommendation: Decodable {
- let name: String
- let publisher: String
- let matchScore: Int
- let scope: String
- let fitReason: String
- let isOpenAccess: Bool
- let indexes: [String]
- let websiteURL: String?
- let averageReviewWeeks: Int?
- }
- let summary: String
- let recommendations: [Recommendation]
- }
- private extension JournalFinderServiceError {
- static func fromOpenAI(_ error: OpenAIServiceError) -> JournalFinderServiceError {
- switch error {
- case .missingAPIKey:
- .missingAPIKey
- case .invalidResponse:
- .invalidResponse
- case .networkError(let message), .apiError(let message):
- .apiError(message)
- }
- }
- }
|