Files
weichenandCursor db76a28689 feat(mineru): chunk Agent parse by page_range past 20-page cap
The free Agent lightweight API rejects files over 20 pages (-30003)
and the precision token is expired, so a real 28-page PDF could not
parse at all. On -30003, parse in 20-page windows and renumber chunk
anchors to global pages; report engine mode agent_chunked.

Also log skipped assets in ResolveMaterials and return a bool from
EnqueueAsync so parse_state is only set when a job is enqueued.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 10:11:08 +00:00

270 lines
7.9 KiB
Go
Raw Permalink 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.
package mineru
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
)
var chunkAnchorRe = regexp.MustCompile(`(?m)^<!--\s*page\s+(\d+)\s*-->\s*$`)
// bumpPageAnchors rewrites chunk-local `<!-- page N -->` anchors to global
// numbering by adding offset (start-1) to each page number.
func BumpPageAnchors(md string, offset int) string {
if offset <= 0 {
return md
}
return chunkAnchorRe.ReplaceAllStringFunc(md, func(s string) string {
m := chunkAnchorRe.FindStringSubmatch(s)
if len(m) == 2 {
var n int
if _, err := fmt.Sscanf(m[1], "%d", &n); err == nil {
return fmt.Sprintf("<!-- page %d -->", n+offset)
}
}
return s
})
}
// agentMaxPagesPerRequest is the documented Agent lightweight API page cap.
const agentMaxPagesPerRequest = 20
// agentMaxChunks bounds chunked parsing (20 pages × 40 = 800 pages max).
const agentMaxChunks = 40
// parseAgentFile uses MinerU Agent lightweight API (no token; IP rate-limited).
// Limits: ≤10MB, ≤20 pages per request. Output is markdown only (CDN URL).
// When the whole file exceeds the page cap (-30003), fall back to page_range
// chunking so documents without a valid precision token still parse.
func (c *Client) parseAgentFile(ctx context.Context, fileName string, data []byte) (*ParseResult, error) {
if len(data) > 10<<20 {
return nil, &PermanentError{Code: "-30001", Msg: "file exceeds agent lightweight 10MB limit"}
}
md, pages, taskID, err := c.parseAgentRange(ctx, fileName, data, "")
if err == nil {
if pages == 0 {
// Agent markdown lacks page markers; keep as single logical page.
md = "<!-- page 1 -->\n" + md
pages = 1
}
return &ParseResult{BatchID: taskID, Markdown: md, TotalPages: pages, EngineMode: "agent"}, nil
}
if !IsPageLimitError(err) {
return nil, err
}
// Whole file exceeded the 20-page cap: parse in page_range windows.
var sb strings.Builder
totalPages := 0
firstTaskID := ""
for i := 0; i < agentMaxChunks; i++ {
start := 1 + i*agentMaxPagesPerRequest
end := start + agentMaxPagesPerRequest - 1
rng := fmt.Sprintf("%d-%d", start, end)
md, _, tid, cerr := c.parseAgentRange(ctx, fileName, data, rng)
if cerr != nil {
if IsPageLimitError(cerr) || i > 0 {
// Beyond document end (after at least one success) or re-limit: stop.
break
}
return nil, cerr
}
if firstTaskID == "" {
firstTaskID = tid
}
if countPageAnchors(md) > 0 {
// Chunk-local anchors: renumber to global pages.
sb.WriteString("\n")
sb.WriteString(BumpPageAnchors(md, start-1))
sb.WriteString("\n")
} else {
sb.WriteString(fmt.Sprintf("\n<!-- page %d -->\n", start))
sb.WriteString(md)
sb.WriteString("\n")
}
totalPages = end
}
out := strings.TrimSpace(sb.String())
if out == "" {
return nil, fmt.Errorf("mineru agent: chunked parse produced no markdown")
}
return &ParseResult{
BatchID: firstTaskID,
Markdown: out,
TotalPages: totalPages,
EngineMode: "agent_chunked",
}, nil
}
// isPageLimitError reports the Agent API "-30003 file page count exceeds
// API limit" condition (both submit-time and poll-time shapes).
func IsPageLimitError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "-30003") ||
strings.Contains(msg, "page count exceeds")
}
// parseAgentRange parses fileName/data via Agent API with optional page_range
// ("" = whole file). Returns markdown, anchor count and task id.
func (c *Client) parseAgentRange(ctx context.Context, fileName string, data []byte, pageRange string) (md string, pages int, taskID string, err error) {
name := filepath.Base(fileName)
if name == "" || name == "." {
name = "upload.bin"
}
body := map[string]any{
"file_name": name,
"language": c.Language,
"enable_table": true,
"enable_formula": true,
"is_ocr": false,
}
if pageRange != "" {
body["page_range"] = pageRange
}
raw, err := c.postJSONNoAuth(ctx, c.Endpoint+"/api/v1/agent/parse/file", body)
if err != nil {
return "", 0, "", err
}
var env apiEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return "", 0, "", fmt.Errorf("mineru agent: decode apply: %w", err)
}
if !codeOK(env.Code) {
return "", 0, "", classifyAPIError(env.Code, env.Msg)
}
var dataOut struct {
TaskID string `json:"task_id"`
FileURL string `json:"file_url"`
}
if err := json.Unmarshal(env.Data, &dataOut); err != nil {
return "", 0, "", fmt.Errorf("mineru agent: decode apply data: %w", err)
}
if dataOut.TaskID == "" || dataOut.FileURL == "" {
return "", 0, "", fmt.Errorf("mineru agent: empty task_id or file_url")
}
if err := c.putFile(ctx, dataOut.FileURL, data); err != nil {
return "", 0, dataOut.TaskID, err
}
mdURL, err := c.pollAgent(ctx, dataOut.TaskID)
if err != nil {
return "", 0, dataOut.TaskID, err
}
mdBytes, err := c.download(ctx, mdURL)
if err != nil {
return "", 0, dataOut.TaskID, err
}
md = EnsureUTF8(strings.TrimSpace(string(mdBytes)))
if md == "" {
return "", 0, dataOut.TaskID, fmt.Errorf("mineru agent: empty markdown")
}
pages = countPageAnchors(md)
return md, pages, dataOut.TaskID, nil
}
func (c *Client) pollAgent(ctx context.Context, taskID string) (markdownURL string, err error) {
url := fmt.Sprintf("%s/api/v1/agent/parse/%s", c.Endpoint, taskID)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
raw, err := c.getJSONNoAuth(ctx, url)
if err != nil {
return "", err
}
var env apiEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return "", fmt.Errorf("mineru agent: decode poll: %w", err)
}
if !codeOK(env.Code) {
return "", classifyAPIError(env.Code, env.Msg)
}
var data struct {
TaskID string `json:"task_id"`
State string `json:"state"`
MarkdownURL string `json:"markdown_url"`
ErrMsg string `json:"err_msg"`
ErrCode int `json:"err_code"`
}
if err := json.Unmarshal(env.Data, &data); err != nil {
return "", fmt.Errorf("mineru agent: decode poll data: %w", err)
}
switch data.State {
case "done":
if data.MarkdownURL == "" {
return "", fmt.Errorf("mineru agent: done without markdown_url")
}
return data.MarkdownURL, nil
case "failed":
return "", fmt.Errorf("mineru agent: failed (%d): %s", data.ErrCode, data.ErrMsg)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-ticker.C:
}
}
}
func (c *Client) postJSONNoAuth(ctx context.Context, url string, body any) ([]byte, error) {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode == http.StatusTooManyRequests {
return nil, &RetryableError{Msg: "mineru agent: rate limited (429)"}
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("mineru agent: http %s: %s", resp.Status, truncate(string(raw), 300))
}
return raw, nil
}
func (c *Client) getJSONNoAuth(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode == http.StatusTooManyRequests {
return nil, &RetryableError{Msg: "mineru agent: rate limited (429)"}
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("mineru agent: http %s: %s", resp.Status, truncate(string(raw), 300))
}
return raw, nil
}
func isPrecisionAuthFailure(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "A0202") ||
strings.Contains(msg, "A0211") ||
strings.Contains(strings.ToLower(msg), "token") ||
strings.Contains(msg, "401")
}