Parcourir la source

Improve spell check reliability and result readability.

Chunk large inputs with per-chunk timeout/error handling to prevent stuck or invalid responses, and make corrected content scrollable so long results remain fully accessible.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 il y a 1 mois
Parent
commit
da072e46f2
2 fichiers modifiés avec 100 ajouts et 25 suppressions
  1. 91 20
      gramora/Services/SpellCheckService.swift
  2. 9 5
      gramora/Views/SpellCheckerView.swift

+ 91 - 20
gramora/Services/SpellCheckService.swift

@@ -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 {

+ 9 - 5
gramora/Views/SpellCheckerView.swift

@@ -215,11 +215,15 @@ struct SpellCheckerView: View {
                 .foregroundStyle(AppTheme.textPrimary)
 
             VStack(alignment: .leading, spacing: 0) {
-                Text(viewModel.correctedText)
-                    .font(.system(size: 14))
-                    .foregroundStyle(AppTheme.textPrimary)
-                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
-                    .padding(20)
+                ScrollView(.vertical, showsIndicators: true) {
+                    Text(viewModel.correctedText)
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textPrimary)
+                        .frame(maxWidth: .infinity, alignment: .topLeading)
+                        .multilineTextAlignment(.leading)
+                        .padding(20)
+                }
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
 
                 HStack {
                     ToolbarButton(title: "Copy Text", iconName: "doc.on.doc") {