-
-
Notifications
You must be signed in to change notification settings - Fork 80
Improves OCR UI and adds more options for LLMs #573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,10 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| package main | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||||||||||||||||||
| "bytes" | ||||||||||||||||||||||||||||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||||||||||||||
| "os" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -252,11 +254,12 @@ func (app *App) getJobStatusHandler(c *gin.Context) { | |||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| response := gin.H{ | ||||||||||||||||||||||||||||||||||||||||||||||
| "job_id": job.ID, | ||||||||||||||||||||||||||||||||||||||||||||||
| "status": job.Status, | ||||||||||||||||||||||||||||||||||||||||||||||
| "created_at": job.CreatedAt, | ||||||||||||||||||||||||||||||||||||||||||||||
| "updated_at": job.UpdatedAt, | ||||||||||||||||||||||||||||||||||||||||||||||
| "pages_done": job.PagesDone, | ||||||||||||||||||||||||||||||||||||||||||||||
| "job_id": job.ID, | ||||||||||||||||||||||||||||||||||||||||||||||
| "status": job.Status, | ||||||||||||||||||||||||||||||||||||||||||||||
| "created_at": job.CreatedAt, | ||||||||||||||||||||||||||||||||||||||||||||||
| "updated_at": job.UpdatedAt, | ||||||||||||||||||||||||||||||||||||||||||||||
| "pages_done": job.PagesDone, | ||||||||||||||||||||||||||||||||||||||||||||||
| "total_pages": job.TotalPages, | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if job.Status == "completed" { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -293,6 +296,20 @@ func (app *App) getAllJobsHandler(c *gin.Context) { | |||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusOK, jobList) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // POST /api/ocr/jobs/:job_id/stop | ||||||||||||||||||||||||||||||||||||||||||||||
| func (app *App) stopOCRJobHandler(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||||||||||||
| jobID := c.Param("job_id") | ||||||||||||||||||||||||||||||||||||||||||||||
| jobCancellersMu.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||
| cancel, exists := jobCancellers[jobID] | ||||||||||||||||||||||||||||||||||||||||||||||
| jobCancellersMu.Unlock() | ||||||||||||||||||||||||||||||||||||||||||||||
| if !exists { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusNotFound, gin.H{"error": "No running job with this ID"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| cancel() | ||||||||||||||||||||||||||||||||||||||||||||||
| c.Status(http.StatusNoContent) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // getDocumentHandler handles the retrieval of a document by its ID | ||||||||||||||||||||||||||||||||||||||||||||||
| func (app *App) getDocumentHandler() gin.HandlerFunc { | ||||||||||||||||||||||||||||||||||||||||||||||
| return func(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -312,6 +329,158 @@ func (app *App) getDocumentHandler() gin.HandlerFunc { | |||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // getOCRPagesHandler returns per-page OCR results for a document | ||||||||||||||||||||||||||||||||||||||||||||||
| func (app *App) getOCRPagesHandler(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||||||||||||
| id := c.Param("id") | ||||||||||||||||||||||||||||||||||||||||||||||
| parsedID, err := strconv.Atoi(id) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| dbResults, err := GetOcrPageResults(app.Database, parsedID) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch OCR page results"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| type OCRPageResult struct { | ||||||||||||||||||||||||||||||||||||||||||||||
| Text string `json:"text"` | ||||||||||||||||||||||||||||||||||||||||||||||
| OcrLimitHit bool `json:"ocrLimitHit"` | ||||||||||||||||||||||||||||||||||||||||||||||
| GenerationInfo map[string]interface{} `json:"generationInfo,omitempty"` | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| var pages []OCRPageResult | ||||||||||||||||||||||||||||||||||||||||||||||
| for _, res := range dbResults { | ||||||||||||||||||||||||||||||||||||||||||||||
| var genInfo map[string]interface{} | ||||||||||||||||||||||||||||||||||||||||||||||
| if res.GenerationInfo != "" { | ||||||||||||||||||||||||||||||||||||||||||||||
| _ = json.Unmarshal([]byte(res.GenerationInfo), &genInfo) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| pages = append(pages, OCRPageResult{ | ||||||||||||||||||||||||||||||||||||||||||||||
| Text: res.Text, | ||||||||||||||||||||||||||||||||||||||||||||||
| OcrLimitHit: res.OcrLimitHit, | ||||||||||||||||||||||||||||||||||||||||||||||
| GenerationInfo: genInfo, | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusOK, gin.H{ | ||||||||||||||||||||||||||||||||||||||||||||||
| "pages": pages, | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func (app *App) reOCRPageHandler(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||||||||||||
| id := c.Param("id") | ||||||||||||||||||||||||||||||||||||||||||||||
| pageIdxStr := c.Param("pageIndex") | ||||||||||||||||||||||||||||||||||||||||||||||
| parsedID, err := strconv.Atoi(id) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| pageIdx, err := strconv.Atoi(pageIdxStr) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page index"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Download all images for the document, but only process the requested page | ||||||||||||||||||||||||||||||||||||||||||||||
| imagePaths, _, err := app.Client.DownloadDocumentAsImages(c.Request.Context(), parsedID, limitOcrPages) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil || pageIdx < 0 || pageIdx >= len(imagePaths) { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page index or failed to download images"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+386
to
+390
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Verify image index bounds before file access The code downloads images and then checks if Separate the error conditions for clarity: imagePaths, _, err := app.Client.DownloadDocumentAsImages(c.Request.Context(), parsedID, limitOcrPages)
- if err != nil || pageIdx < 0 || pageIdx >= len(imagePaths) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page index or failed to download images"})
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to download images"})
+ return
+ }
+ if pageIdx < 0 || pageIdx >= len(imagePaths) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid page index %d, document has %d pages", pageIdx, len(imagePaths))})
return
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| imageContent, err := os.ReadFile(imagePaths[pageIdx]) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read image file"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| cancelKey := fmt.Sprintf("%d-%d", parsedID, pageIdx) | ||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCtx, cancelReOcr := context.WithCancel(c.Request.Context()) | ||||||||||||||||||||||||||||||||||||||||||||||
| defer cancelReOcr() | ||||||||||||||||||||||||||||||||||||||||||||||
icereed marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCancellersMu.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||
| if existingCancel, ok := reOcrCancellers[cancelKey]; ok { | ||||||||||||||||||||||||||||||||||||||||||||||
| existingCancel() | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCancellers[cancelKey] = cancelReOcr | ||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCancellersMu.Unlock() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| defer func() { | ||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCancellersMu.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||
| delete(reOcrCancellers, cancelKey) | ||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCancellersMu.Unlock() | ||||||||||||||||||||||||||||||||||||||||||||||
| }() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| result, err := app.ocrProvider.ProcessImage(reOcrCtx, imageContent, pageIdx+1) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| if errors.Is(err, context.Canceled) { | ||||||||||||||||||||||||||||||||||||||||||||||
| log.Infof("Re-OCR for doc %d page %d cancelled.", parsedID, pageIdx) | ||||||||||||||||||||||||||||||||||||||||||||||
| c.Status(499) | ||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("Failed to re-OCR doc %d page %d: %v", parsedID, pageIdx, err) | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to re-OCR page"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| if result == nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("Re-OCR for doc %d page %d returned nil result.", parsedID, pageIdx) | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Re-OCR returned no result"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| var genInfoJSON string | ||||||||||||||||||||||||||||||||||||||||||||||
| if result.GenerationInfo != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| if b, err := json.Marshal(result.GenerationInfo); err == nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| genInfoJSON = string(b) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+432
to
+437
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add error handling for JSON marshaling The JSON marshaling at line 367 could fail, but errors are silently ignored, which could result in empty Handle JSON marshaling errors: var genInfoJSON string
if result.GenerationInfo != nil {
- if b, err := json.Marshal(result.GenerationInfo); err == nil {
+ if b, err := json.Marshal(result.GenerationInfo); err != nil {
+ log.Errorf("Failed to marshal GenerationInfo for doc %d page %d: %v", parsedID, pageIdx, err)
+ } else {
genInfoJSON = string(b)
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| saveErr := SaveSingleOcrPageResult(app.Database, parsedID, pageIdx, result.Text, result.OcrLimitHit, genInfoJSON) | ||||||||||||||||||||||||||||||||||||||||||||||
| if saveErr != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| log.Errorf("Failed to save re-OCR result for doc %d page %d: %v", parsedID, pageIdx, saveErr) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusOK, gin.H{ | ||||||||||||||||||||||||||||||||||||||||||||||
| "text": result.Text, | ||||||||||||||||||||||||||||||||||||||||||||||
| "ocrLimitHit": result.OcrLimitHit, | ||||||||||||||||||||||||||||||||||||||||||||||
| "generationInfo": result.GenerationInfo, | ||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+438
to
+447
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Surface DB write failures to the client If persisting the page result fails we currently return 200, so the UI shows success even though nothing was saved. Please fail fast and tell the client so they can retry. saveErr := SaveSingleOcrPageResult(app.Database, parsedID, pageIdx, result.Text, result.OcrLimitHit, genInfoJSON)
if saveErr != nil {
log.Errorf("Failed to save re-OCR result for doc %d page %d: %v", parsedID, pageIdx, saveErr)
- }
-
- c.JSON(http.StatusOK, gin.H{
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist re-OCR result"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // cancelReOCRPageHandler handles the DELETE request to cancel an ongoing re-OCR for a specific page. | ||||||||||||||||||||||||||||||||||||||||||||||
| func (app *App) cancelReOCRPageHandler(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||||||||||||
| id := c.Param("id") | ||||||||||||||||||||||||||||||||||||||||||||||
| pageIdxStr := c.Param("pageIndex") | ||||||||||||||||||||||||||||||||||||||||||||||
| parsedID, err := strconv.Atoi(id) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| pageIdx, err := strconv.Atoi(pageIdxStr) | ||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page index"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| cancelKey := fmt.Sprintf("%d-%d", parsedID, pageIdx) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCancellersMu.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||
| cancel, exists := reOcrCancellers[cancelKey] | ||||||||||||||||||||||||||||||||||||||||||||||
| if exists { | ||||||||||||||||||||||||||||||||||||||||||||||
| delete(reOcrCancellers, cancelKey) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| reOcrCancellersMu.Unlock() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if exists { | ||||||||||||||||||||||||||||||||||||||||||||||
| cancel() | ||||||||||||||||||||||||||||||||||||||||||||||
| log.Infof("Cancellation requested for re-OCR doc %d page %d", parsedID, pageIdx) | ||||||||||||||||||||||||||||||||||||||||||||||
| c.Status(http.StatusNoContent) | ||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||
| log.Warnf("No active re-OCR found to cancel for doc %d page %d", parsedID, pageIdx) | ||||||||||||||||||||||||||||||||||||||||||||||
| c.JSON(http.StatusNotFound, gin.H{"error": "No active re-OCR operation found for this page"}) | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Section for local-db actions | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| func (app *App) getModificationHistoryHandler(c *gin.Context) { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add error handling for JSON unmarshaling
The JSON unmarshaling of
GenerationInfoat line 290 ignores errors silently. While this may be intentional for backward compatibility, it could hide data corruption issues.Add error logging for failed JSON unmarshaling:
var genInfo map[string]interface{} if res.GenerationInfo != "" { - _ = json.Unmarshal([]byte(res.GenerationInfo), &genInfo) + if err := json.Unmarshal([]byte(res.GenerationInfo), &genInfo); err != nil { + log.Warnf("Failed to unmarshal GenerationInfo for doc %d page %d: %v", parsedID, i, err) + } }🤖 Prompt for AI Agents