Files
ragflow/internal/parser/parser/paddleocr.go
qinling0210 d549194562 Implement builtin chunk method as ingestion pipeline in GO (#16822)
### Summary

Implement builtin chunk mehtod as ingestion pipeline in GO
2026-07-13 13:51:40 +08:00

434 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// PaddleOCR client for the async Job API.
// Mirrors Python's deepdoc/parser/paddleocr_parser.py:
// - Submit job via POST /api/v2/ocr/jobs
// - Poll with exponential backoff until "done" or "failed"
// - Fetch result JSONL and extract text
//
// For image parsing, only parse_image() semantics are needed;
// parse_pdf() is handled by the PDF vision dispatch path.
package parser
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"regexp"
"strings"
"time"
"ragflow/internal/common"
)
// imgTagPattern matches HTML <img> tags and <div> wrappers, mirroring
// Python's _MARKDOWN_IMAGE_PATTERN in deepdoc/parser/paddleocr_parser.py.
var imgTagPattern = regexp.MustCompile(
`(?is)<div[^>]*>\s*<img[^>]*/>\s*</div>|<img[^>]*/>`,
)
const (
defaultPaddleOCRBaseURL = "https://paddleocr.aistudio-app.com"
defaultPaddleOCRTimeout = 600 * time.Second
defaultPaddleOCRAlgo = "PaddleOCR-VL"
paddleOCRSubmitPath = "/api/v2/ocr/jobs"
paddleOCRPollInterval = 3 * time.Second
paddleOCRPollMultiplier = 1.5
paddleOCRPollMaxInterval = 15 * time.Second
)
// PaddleOCRClient talks to the PaddleOCR async Job API.
type PaddleOCRClient struct {
BaseURL string
AccessToken string
Algorithm string
Timeout time.Duration
httpClient *http.Client
}
// NewPaddleOCRClientFromEnv creates a client from environment variables:
//
// PADDLEOCR_BASE_URL base URL (default https://paddleocr.aistudio-app.com)
// PADDLEOCR_ACCESS_TOKEN bearer token
// PADDLEOCR_ALGORITHM algorithm name (default PaddleOCR-VL)
func NewPaddleOCRClientFromEnv() *PaddleOCRClient {
baseURL := common.GetEnv(common.EnvPaddleOCRBaseUrl)
if baseURL == "" {
baseURL = defaultPaddleOCRBaseURL
}
return &PaddleOCRClient{
BaseURL: strings.TrimRight(baseURL, "/"),
AccessToken: common.GetEnv(common.EnvPaddleOCRAccessToken),
Algorithm: firstNonEmpty(common.GetEnv(common.EnvPaddleOCRAlgorithm), defaultPaddleOCRAlgo),
Timeout: defaultPaddleOCRTimeout,
httpClient: &http.Client{
Timeout: 30 * time.Second, // per-request timeout; polling uses deadline
},
}
}
// Enabled reports whether the client has a usable access token.
func (c *PaddleOCRClient) Enabled() bool {
return c != nil && c.AccessToken != ""
}
// ParseImage submits the image to PaddleOCR and returns extracted text.
// Mirrors Python's PaddleOCRParser.parse_image().
func (c *PaddleOCRClient) ParseImage(binary []byte, filename string) (string, error) {
deadline := time.Now().Add(c.Timeout)
// Step 1: Submit job
jobID, err := c.submitJob(binary, filename, deadline)
if err != nil {
return "", fmt.Errorf("paddleocr submit: %w", err)
}
// Step 2: Poll until done
resultData, err := c.pollJob(jobID, deadline)
if err != nil {
return "", fmt.Errorf("paddleocr poll: %w", err)
}
// Step 3: Fetch result JSONL
raw, err := c.fetchResult(resultData, deadline)
if err != nil {
return "", fmt.Errorf("paddleocr fetch: %w", err)
}
// Step 4: Parse result
return c.extractImageText(raw), nil
}
// submitJob POSTs the image file to /api/v2/ocr/jobs and returns the job ID.
func (c *PaddleOCRClient) submitJob(binary []byte, filename string, deadline time.Time) (string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
// Write the image file field
fw, err := w.CreateFormFile("file", filename)
if err != nil {
return "", err
}
if _, err := fw.Write(binary); err != nil {
return "", err
}
// Write form fields
if err := w.WriteField("model", c.Algorithm); err != nil {
return "", err
}
optionalPayload := map[string]any{"formatBlockContent": true}
payloadBytes, _ := json.Marshal(optionalPayload)
if err := w.WriteField("optionalPayload", string(payloadBytes)); err != nil {
return "", err
}
w.Close()
url := c.BaseURL + paddleOCRSubmitPath
req, err := http.NewRequest(http.MethodPost, url, &buf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Client-Platform", "ragflow")
if c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
req = req.WithContext(withDeadline(req.Context(), deadline))
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("POST %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var submitResp struct {
Data struct {
JobID string `json:"jobId"`
} `json:"data"`
JobID string `json:"jobId"`
}
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
return "", fmt.Errorf("decode submit response: %w", err)
}
jobID := submitResp.Data.JobID
if jobID == "" {
jobID = submitResp.JobID
}
if jobID == "" {
return "", fmt.Errorf("no jobId in submit response")
}
slog.Info("paddleocr: job submitted", "jobId", jobID)
return jobID, nil
}
// pollJob polls the job status until it reaches "done" or "failed".
// Uses exponential backoff: 3s initial, 1.5x multiplier, 15s max.
// Returns the final poll response data.
func (c *PaddleOCRClient) pollJob(jobID string, deadline time.Time) (map[string]any, error) {
pollURL := fmt.Sprintf("%s/%s", c.BaseURL+paddleOCRSubmitPath, jobID)
interval := paddleOCRPollInterval
for {
if time.Now().After(deadline) {
return nil, fmt.Errorf("job %s timed out after %v", jobID, c.Timeout)
}
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, fmt.Errorf("job %s timed out", jobID)
}
req, err := http.NewRequest(http.MethodGet, pollURL, nil)
if err != nil {
return nil, err
}
if c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
req.Header.Set("Client-Platform", "ragflow")
req = req.WithContext(withDeadline(req.Context(), deadline))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("poll %s: %w", jobID, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("poll HTTP %d: %s", resp.StatusCode, string(body))
}
var pollResp struct {
Data struct {
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
} `json:"data"`
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
}
bodyBytes, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
return nil, fmt.Errorf("decode poll response: %w", err)
}
state := pollResp.Data.State
if state == "" {
state = pollResp.State
}
switch state {
case "done":
slog.Info("paddleocr: job done", "jobId", jobID)
// Return full data so caller can extract resultJsonUrl
var fullData map[string]any
json.Unmarshal(bodyBytes, &fullData)
return fullData, nil
case "failed":
errMsg := pollResp.Data.ErrorMsg
if errMsg == "" {
errMsg = pollResp.ErrorMsg
}
return nil, fmt.Errorf("job %s failed: %s", jobID, errMsg)
}
// Exponential backoff
sleepTime := interval
if remaining < sleepTime {
sleepTime = remaining
}
time.Sleep(sleepTime)
interval = time.Duration(float64(interval) * paddleOCRPollMultiplier)
if interval > paddleOCRPollMaxInterval {
interval = paddleOCRPollMaxInterval
}
}
}
// fetchResult downloads and parses the JSONL result from resultJsonUrl.
func (c *PaddleOCRClient) fetchResult(pollData map[string]any, deadline time.Time) ([]map[string]any, error) {
data, _ := pollData["data"].(map[string]any)
if data == nil {
data = pollData
}
resultJSONURL, _ := data["resultJsonUrl"].(string)
if resultJSONURL == "" {
if resultURL, ok := data["resultUrl"].(map[string]any); ok {
resultJSONURL, _ = resultURL["jsonUrl"].(string)
}
}
if resultJSONURL == "" {
return nil, fmt.Errorf("no resultJsonUrl in completion response")
}
req, err := http.NewRequest(http.MethodGet, resultJSONURL, nil)
if err != nil {
return nil, err
}
if c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
req = req.WithContext(withDeadline(req.Context(), deadline))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch result: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("fetch result HTTP %d: %s", resp.StatusCode, string(body))
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read result: %w", err)
}
// Parse JSONL: one JSON object per line
var lines []map[string]any
for _, line := range strings.Split(strings.TrimSpace(string(content)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var obj map[string]any
if err := json.Unmarshal([]byte(line), &obj); err != nil {
return nil, fmt.Errorf("parse JSONL line: %w", err)
}
lines = append(lines, obj)
}
return lines, nil
}
// extractImageText extracts text from PaddleOCR JSONL results.
// Mirrors Python's parse_image() text extraction:
//
// layoutParsingResults[].prunedResult.parsing_res_list[].block_content
// Fallback: ocrResults[].prunedResult.rec_texts[]
func (c *PaddleOCRClient) extractImageText(resultLines []map[string]any) string {
var texts []string
for _, line := range resultLines {
result, _ := line["result"].(map[string]any)
if result == nil {
continue
}
// Primary: layoutParsingResults
if lpr, ok := result["layoutParsingResults"].([]any); ok {
for _, lr := range lpr {
lrMap, _ := lr.(map[string]any)
if lrMap == nil {
continue
}
pruned, _ := lrMap["prunedResult"].(map[string]any)
if pruned == nil {
continue
}
if prl, ok := pruned["parsing_res_list"].([]any); ok {
for _, block := range prl {
blockMap, _ := block.(map[string]any)
if blockMap == nil {
continue
}
content, _ := blockMap["block_content"].(string)
content = strings.TrimSpace(content)
// Remove markdown image blocks
content = removeMarkdownImages(content)
if content != "" {
texts = append(texts, content)
}
}
}
}
}
}
// Fallback: ocrResults for text-only models (e.g. PP-OCRv6)
if len(texts) == 0 {
for _, line := range resultLines {
result, _ := line["result"].(map[string]any)
if result == nil {
continue
}
if ocr, ok := result["ocrResults"].([]any); ok {
for _, o := range ocr {
ocrMap, _ := o.(map[string]any)
if ocrMap == nil {
continue
}
pruned, _ := ocrMap["prunedResult"].(map[string]any)
if pruned == nil {
continue
}
if recTexts, ok := pruned["rec_texts"].([]any); ok {
for _, t := range recTexts {
if s, ok := t.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
texts = append(texts, s)
}
}
}
}
}
}
}
}
if len(texts) == 0 {
return ""
}
return strings.Join(texts, "\n")
}
// removeMarkdownImages strips markdown image syntax (![](...)) and HTML
// img/div blocks, mirroring Python's _remove_images_from_markdown.
func removeMarkdownImages(md string) string {
// Strip HTML <img> and <div><img/></div> blocks
md = imgTagPattern.ReplaceAllString(md, "")
return strings.TrimSpace(md)
}
// withDeadline applies a deadline to a context, respecting the existing
// deadline if it is sooner.
func withDeadline(ctx context.Context, deadline time.Time) context.Context {
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
return ctx
}
newCtx, _ := context.WithDeadline(ctx, deadline)
return newCtx
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}