|
|
@@ -10,6 +10,25 @@ protocol SpellCheckServicing {
|
|
|
}
|
|
|
|
|
|
struct SpellCheckService: SpellCheckServicing {
|
|
|
+ private static let chunkMaxWords = 280
|
|
|
+ private static let maxConcurrentChunks = 4
|
|
|
+ private static let requestTimeoutSeconds: UInt64 = 45
|
|
|
+ private static let maxResponseTokens = 900
|
|
|
+ private static let systemPrompt = """
|
|
|
+ You are a professional spelling checker. Analyze the user's text for spelling mistakes only. \
|
|
|
+ Respond with JSON only using this schema:
|
|
|
+ {
|
|
|
+ "correctedText": "full corrected version of this chunk only",
|
|
|
+ "mistakes": [
|
|
|
+ {
|
|
|
+ "original": "misspelled word",
|
|
|
+ "suggestion": "correct spelling"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ If there are no mistakes, return the original chunk in correctedText and an empty mistakes array.
|
|
|
+ Preserve punctuation, markdown, code blocks, table syntax, URLs, and file paths.
|
|
|
+ """
|
|
|
private let openAI: OpenAIChatServicing
|
|
|
|
|
|
init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
|
|
|
@@ -22,32 +41,84 @@ struct SpellCheckService: SpellCheckServicing {
|
|
|
throw OpenAIServiceError.apiError("Enter some text to check.")
|
|
|
}
|
|
|
|
|
|
- let response: SpellCheckResponse = try await openAI.completeJSON(
|
|
|
- systemPrompt: """
|
|
|
- You are a professional spelling checker. Analyze the user's text for spelling mistakes only. \
|
|
|
- Respond with JSON only using this schema:
|
|
|
- {
|
|
|
- "correctedText": "full corrected version of the input",
|
|
|
- "mistakes": [
|
|
|
- {
|
|
|
- "original": "misspelled word",
|
|
|
- "suggestion": "correct spelling"
|
|
|
+ let chunks = trimmed.chunked(maxWords: Self.chunkMaxWords)
|
|
|
+ if chunks.count == 1 {
|
|
|
+ let response = try await checkChunk(chunks[0])
|
|
|
+ return SpellCheckResult(
|
|
|
+ correctedText: response.correctedText,
|
|
|
+ mistakes: response.mistakes.map {
|
|
|
+ SpellingMistake(original: $0.original, suggestion: $0.suggestion)
|
|
|
+ }
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ var correctedChunks: [String] = []
|
|
|
+ var allMistakes: [SpellingMistake] = []
|
|
|
+
|
|
|
+ for batchStart in stride(from: 0, to: chunks.count, by: Self.maxConcurrentChunks) {
|
|
|
+ let batchEnd = min(batchStart + Self.maxConcurrentChunks, chunks.count)
|
|
|
+ let batch = Array(chunks[batchStart..<batchEnd])
|
|
|
+
|
|
|
+ let batchResults = try await withThrowingTaskGroup(of: (Int, SpellCheckResponse).self) { group in
|
|
|
+ for (offset, chunk) in batch.enumerated() {
|
|
|
+ let originalIndex = batchStart + offset
|
|
|
+ group.addTask {
|
|
|
+ let response = try await self.checkChunk(chunk)
|
|
|
+ return (originalIndex, response)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var indexedResults: [(Int, SpellCheckResponse)] = []
|
|
|
+ for try await item in group {
|
|
|
+ indexedResults.append(item)
|
|
|
}
|
|
|
- ]
|
|
|
+ return indexedResults.sorted { $0.0 < $1.0 }
|
|
|
}
|
|
|
- If there are no mistakes, return the original text in correctedText and an empty mistakes array.
|
|
|
- """,
|
|
|
- userPrompt: trimmed,
|
|
|
- as: SpellCheckResponse.self
|
|
|
- )
|
|
|
+
|
|
|
+ correctedChunks.append(contentsOf: batchResults.map(\.1.correctedText))
|
|
|
+ allMistakes.append(contentsOf: batchResults.flatMap { result in
|
|
|
+ result.1.mistakes.map {
|
|
|
+ SpellingMistake(original: $0.original, suggestion: $0.suggestion)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
return SpellCheckResult(
|
|
|
- correctedText: response.correctedText,
|
|
|
- mistakes: response.mistakes.map {
|
|
|
- SpellingMistake(original: $0.original, suggestion: $0.suggestion)
|
|
|
- }
|
|
|
+ correctedText: correctedChunks.joined(separator: "\n\n"),
|
|
|
+ mistakes: allMistakes
|
|
|
)
|
|
|
}
|
|
|
+
|
|
|
+ private func checkChunk(_ text: String) async throws -> SpellCheckResponse {
|
|
|
+ do {
|
|
|
+ return try await withThrowingTaskGroup(of: SpellCheckResponse.self) { group in
|
|
|
+ group.addTask {
|
|
|
+ try await openAI.completeJSON(
|
|
|
+ systemPrompt: Self.systemPrompt,
|
|
|
+ userPrompt: text,
|
|
|
+ maxTokens: Self.maxResponseTokens,
|
|
|
+ as: SpellCheckResponse.self
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ group.addTask {
|
|
|
+ try await Task.sleep(nanoseconds: Self.requestTimeoutSeconds * 1_000_000_000)
|
|
|
+ throw OpenAIServiceError.networkError("Spelling check timed out. Please try again.")
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let firstCompleted = try await group.next() else {
|
|
|
+ throw OpenAIServiceError.invalidResponse
|
|
|
+ }
|
|
|
+
|
|
|
+ group.cancelAll()
|
|
|
+ return firstCompleted
|
|
|
+ }
|
|
|
+ } catch let error as DecodingError {
|
|
|
+ throw OpenAIServiceError.apiError("Spelling response format was invalid. Please try again.")
|
|
|
+ } catch {
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private struct SpellCheckResponse: Decodable {
|