|
|
@@ -5,15 +5,10 @@ protocol JournalFinderServicing {
|
|
|
}
|
|
|
|
|
|
struct JournalFinderService: JournalFinderServicing {
|
|
|
- private let session: URLSession
|
|
|
- private let apiKeyProvider: () -> String?
|
|
|
+ private let openAI: OpenAIChatServicing
|
|
|
|
|
|
- init(
|
|
|
- session: URLSession = .shared,
|
|
|
- apiKeyProvider: @escaping () -> String? = { APIConfiguration.openAIAPIKey }
|
|
|
- ) {
|
|
|
- self.session = session
|
|
|
- self.apiKeyProvider = apiKeyProvider
|
|
|
+ init(openAI: OpenAIChatServicing = OpenAIChatService()) {
|
|
|
+ self.openAI = openAI
|
|
|
}
|
|
|
|
|
|
func findJournals(for criteria: JournalFinderSearchCriteria) async throws -> JournalFinderResult {
|
|
|
@@ -25,87 +20,58 @@ struct JournalFinderService: JournalFinderServicing {
|
|
|
throw JournalFinderServiceError.emptyInput
|
|
|
}
|
|
|
|
|
|
- guard let apiKey = apiKeyProvider() else {
|
|
|
- throw JournalFinderServiceError.missingAPIKey
|
|
|
- }
|
|
|
-
|
|
|
- var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
|
|
|
- request.httpMethod = "POST"
|
|
|
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
- request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
|
-
|
|
|
let userPrompt = Self.buildUserPrompt(from: criteria, title: title, abstract: abstract, keywords: keywords)
|
|
|
|
|
|
- let body = JournalFinderChatRequest(
|
|
|
- model: "gpt-4o-mini",
|
|
|
- messages: [
|
|
|
- .init(
|
|
|
- role: "system",
|
|
|
- content: """
|
|
|
- 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:
|
|
|
+ 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": [
|
|
|
{
|
|
|
- "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
|
|
|
- }
|
|
|
- ]
|
|
|
+ "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.
|
|
|
- """
|
|
|
- ),
|
|
|
- .init(role: "user", content: userPrompt)
|
|
|
- ],
|
|
|
- responseFormat: .init(type: "json_object")
|
|
|
- )
|
|
|
-
|
|
|
- request.httpBody = try JSONEncoder().encode(body)
|
|
|
-
|
|
|
- let (data, response) = try await session.data(for: request)
|
|
|
-
|
|
|
- if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
|
|
|
- let message = Self.errorMessage(from: data) ?? "Request failed with status \(httpResponse.statusCode)."
|
|
|
- throw JournalFinderServiceError.apiError(message)
|
|
|
- }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ 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 completion = try JSONDecoder().decode(JournalFinderChatResponse.self, from: data)
|
|
|
- guard
|
|
|
- let content = completion.choices.first?.message.content,
|
|
|
- let contentData = content.data(using: .utf8)
|
|
|
- else {
|
|
|
- throw JournalFinderServiceError.invalidResponse
|
|
|
- }
|
|
|
+ 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
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
- let decoded = try JSONDecoder().decode(JournalFinderAPIResponse.self, from: contentData)
|
|
|
- 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)
|
|
|
}
|
|
|
-
|
|
|
- return JournalFinderResult(
|
|
|
- recommendations: recommendations.sorted { $0.matchScore > $1.matchScore },
|
|
|
- summary: decoded.summary
|
|
|
- )
|
|
|
}
|
|
|
|
|
|
private static func buildUserPrompt(
|
|
|
@@ -138,18 +104,6 @@ struct JournalFinderService: JournalFinderServicing {
|
|
|
|
|
|
return lines.joined(separator: "\n")
|
|
|
}
|
|
|
-
|
|
|
- private static func errorMessage(from data: Data) -> String? {
|
|
|
- struct APIErrorResponse: Decodable {
|
|
|
- struct APIError: Decodable {
|
|
|
- let message: String
|
|
|
- }
|
|
|
-
|
|
|
- let error: APIError
|
|
|
- }
|
|
|
-
|
|
|
- return (try? JSONDecoder().decode(APIErrorResponse.self, from: data))?.error.message
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
private struct JournalFinderAPIResponse: Decodable {
|
|
|
@@ -169,35 +123,15 @@ private struct JournalFinderAPIResponse: Decodable {
|
|
|
let recommendations: [Recommendation]
|
|
|
}
|
|
|
|
|
|
-private struct JournalFinderChatRequest: Encodable {
|
|
|
- struct Message: Encodable {
|
|
|
- let role: String
|
|
|
- let content: String
|
|
|
- }
|
|
|
-
|
|
|
- struct ResponseFormat: Encodable {
|
|
|
- let type: String
|
|
|
- }
|
|
|
-
|
|
|
- let model: String
|
|
|
- let messages: [Message]
|
|
|
- let responseFormat: ResponseFormat
|
|
|
-
|
|
|
- enum CodingKeys: String, CodingKey {
|
|
|
- case model
|
|
|
- case messages
|
|
|
- case responseFormat = "response_format"
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-private struct JournalFinderChatResponse: Decodable {
|
|
|
- struct Choice: Decodable {
|
|
|
- struct Message: Decodable {
|
|
|
- let content: String
|
|
|
+private extension JournalFinderServiceError {
|
|
|
+ static func fromOpenAI(_ error: OpenAIServiceError) -> JournalFinderServiceError {
|
|
|
+ switch error {
|
|
|
+ case .missingAPIKey:
|
|
|
+ .missingAPIKey
|
|
|
+ case .invalidResponse:
|
|
|
+ .invalidResponse
|
|
|
+ case .apiError(let message):
|
|
|
+ .apiError(message)
|
|
|
}
|
|
|
-
|
|
|
- let message: Message
|
|
|
}
|
|
|
-
|
|
|
- let choices: [Choice]
|
|
|
}
|