diff --git a/internal/deepdoc/client.go b/internal/deepdoc/client.go deleted file mode 100644 index 6c25c1ecaf..0000000000 --- a/internal/deepdoc/client.go +++ /dev/null @@ -1,220 +0,0 @@ -// -// 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. -// - -// Package deepdoc — Go client for the optional deepdoc vision service -// (DLA / OCR / TSR). -// -// Wire contract reconstructed from `deepdoc/vision/dla_cli.py` (fork) -// and the Phase 0 research deliverable -// `docs/agent-port/deepdoc-endpoints.md`. Only DLA has a remote HTTP -// endpoint; OCR and TSR are 100% local ONNX in Python and stubbed -// here as ErrNoRemoteEndpoint. -package deepdoc - -import ( - "context" - "errors" - "io" - "net/http" - "ragflow/internal/common" - "time" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" -) - -// ErrNoURL is returned by methods when the client was constructed -// without a base URL (DEEPDOC_URL / TENSORRT_DLA_SVR unset). -var ErrNoURL = errors.New("deepdoc: not configured (set DEEPDOC_URL or TENSORRT_DLA_SVR)") - -// ErrNoRemoteEndpoint is returned by OCR/TSR because the Python -// deepdoc service exposes no remote endpoint for those — they're -// local ONNX only (deepdoc/vision/ocr.py:542, table_structure_recognizer.py:30). -var ErrNoRemoteEndpoint = errors.New("deepdoc: no remote endpoint exists (Python deepdoc is local-ONNX only)") - -// ErrInvalidResponse is returned when the server returns a payload -// that doesn't validate (e.g. DLA response missing "bboxes" key). -// Per the Python contract, this triggers the retry loop. -var ErrInvalidResponse = errors.New("deepdoc: invalid response") - -// DefaultPerAttemptTimeout matches Python's @timeout(18) decorator -// on DLAClient.predict (deepdoc/vision/dla_cli.py:23). -const DefaultPerAttemptTimeout = 18 * time.Second - -// DefaultMaxAttempts matches Python's `for _ in range(3)` retry loop. -const DefaultMaxAttempts = 3 - -// DefaultBackoff is the initial backoff between retries; doubled -// each attempt, capped at MaxBackoff. -const DefaultBackoff = 200 * time.Millisecond - -// MaxBackoff caps the exponential backoff between retries. -const MaxBackoff = 3 * time.Second - -// predictPath is the DLA endpoint per -// docs/agent-port/deepdoc-endpoints.md §2.1. -const predictPath = "/predict" - -// Client talks to an optional deepdoc service. When baseURL is empty, -// Enabled() reports false and HTTP methods return ErrNoURL without -// making any network call. -type Client struct { - baseURL string - httpClient *http.Client - maxAttempts int - backoff time.Duration -} - -// Option mutates a Client at construction time. Used by tests to -// point at httptest servers, override timeouts, etc. -type Option func(*Client) - -// WithHTTPClient overrides the underlying *http.Client. -func WithHTTPClient(hc *http.Client) Option { - return func(c *Client) { c.httpClient = hc } -} - -// WithMaxAttempts overrides the per-call retry count (default 3). -func WithMaxAttempts(n int) Option { - return func(c *Client) { c.maxAttempts = n } -} - -// WithBackoff overrides the initial backoff between retries. -func WithBackoff(d time.Duration) Option { - return func(c *Client) { c.backoff = d } -} - -// NewClient returns a Client configured from the environment. The -// base URL is read from DEEPDOC_URL (preferred) or TENSORRT_DLA_SVR -// (legacy alias per deepdoc/vision/layout_recognizer.py:52). When -// both are unset, Enabled() reports false. -func NewClient(opts ...Option) *Client { - url := common.GetEnv(common.EnvDeepDocURL) - if url == "" { - url = common.GetEnv(common.EnvTensorrtDLAServer) - } - return NewClientWithURL(url, opts...) -} - -// NewClientWithURL is NewClient with an explicit base URL. Primarily -// used by tests pointing at httptest servers. -func NewClientWithURL(baseURL string, opts ...Option) *Client { - c := &Client{ - baseURL: baseURL, - maxAttempts: DefaultMaxAttempts, - backoff: DefaultBackoff, - } - for _, opt := range opts { - opt(c) - } - if c.httpClient == nil { - // otelhttp.NewTransport is a no-op when no OTel exporter is - // configured (see plan §2.10.4) — safe default. - c.httpClient = &http.Client{ - Timeout: DefaultPerAttemptTimeout, - Transport: otelhttp.NewTransport(http.DefaultTransport), - } - } - return c -} - -// Enabled reports whether a remote deepdoc URL is configured. When -// false, HTTP methods return ErrNoURL immediately. -func (c *Client) Enabled() bool { - return c != nil && c.baseURL != "" -} - -// bodyBuilder is a factory that produces a fresh request body per -// retry attempt. Returns (body, contentType). The body is consumed -// by http.Client.Do and must be readable (multipart.Writer writes -// into a buffer so this is straightforward). -type bodyBuilder func() (io.Reader, string) - -// doPost issues a POST with retry + exponential backoff, matching -// the Python DLAClient semantics (3 attempts, session rebuild on -// failure, 18s per-attempt timeout, 200ms initial backoff). -// -// Retries on: network errors, 5xx, validation failure (validate -// non-nil + returns error). Does NOT retry on: 4xx, ctx done. -// Returns the validated response body bytes on success. -func (c *Client) doPost(ctx context.Context, url string, buildBody bodyBuilder, validate func([]byte) error) ([]byte, error) { - if !c.Enabled() { - return nil, ErrNoURL - } - var lastErr error - backoff := c.backoff - for attempt := 1; attempt <= c.maxAttempts; attempt++ { - body, contentType := buildBody() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) - if err != nil { - return nil, err - } - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - resp, err := c.httpClient.Do(req) - if err != nil { - lastErr = err - } else { - data, readErr := io.ReadAll(resp.Body) - resp.Body.Close() - switch { - case readErr != nil: - lastErr = readErr - case resp.StatusCode >= 500: - lastErr = &httpError{Status: resp.Status, Body: string(data), retryable: true} - case resp.StatusCode >= 400: - // 4xx is a config error, not transient — surface - // immediately without retrying. - return nil, &httpError{Status: resp.Status, Body: string(data), retryable: false} - case validate != nil: - if vErr := validate(data); vErr != nil { - lastErr = vErr - } else { - return data, nil - } - default: - return data, nil - } - } - if attempt < c.maxAttempts { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(backoff): - } - backoff *= 2 - if backoff > MaxBackoff { - backoff = MaxBackoff - } - } - } - if lastErr == nil { - lastErr = ErrInvalidResponse - } - return nil, lastErr -} - -// httpError carries the HTTP status + body so callers can inspect. -// retryable=true means the doPost loop already exhausted retries. -type httpError struct { - Status string - Body string - retryable bool -} - -func (e *httpError) Error() string { - return "deepdoc: " + e.Status + ": " + e.Body -} diff --git a/internal/deepdoc/client_test.go b/internal/deepdoc/client_test.go deleted file mode 100644 index 464875f980..0000000000 --- a/internal/deepdoc/client_test.go +++ /dev/null @@ -1,192 +0,0 @@ -// -// 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. -// - -package deepdoc - -import ( - "errors" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" -) - -// withEnv unsets DEEPDOC_URL and TENSORRT_DLA_SVR for the duration -// of t, restoring whatever values were present before. NewClient -// reads these env vars, so tests must isolate the env to be -// deterministic. -func withEnv(t *testing.T) { - t.Helper() - for _, k := range []string{"DEEPDOC_URL", "TENSORRT_DLA_SVR"} { - prev, had := os.LookupEnv(k) - os.Unsetenv(k) - t.Cleanup(func() { - if had { - os.Setenv(k, prev) - } else { - os.Unsetenv(k) - } - }) - } -} - -func TestNewClient_NoEnvVars(t *testing.T) { - withEnv(t) - c := NewClient() - if c == nil { - t.Fatal("NewClient returned nil") - } - if c.Enabled() { - t.Errorf("Enabled()=true with no env vars; want false") - } - if c.maxAttempts != DefaultMaxAttempts { - t.Errorf("maxAttempts=%d, want %d", c.maxAttempts, DefaultMaxAttempts) - } - if c.backoff != DefaultBackoff { - t.Errorf("backoff=%v, want %v", c.backoff, DefaultBackoff) - } - if c.httpClient == nil { - t.Error("httpClient is nil; default should be applied") - } - if c.httpClient.Timeout != DefaultPerAttemptTimeout { - t.Errorf("httpClient.Timeout=%v, want %v", c.httpClient.Timeout, DefaultPerAttemptTimeout) - } -} - -func TestNewClient_DeepdocURLPreferred(t *testing.T) { - withEnv(t) - os.Setenv("DEEPDOC_URL", "http://deepdoc:8001") - os.Setenv("TENSORRT_DLA_SVR", "http://legacy:8001") - c := NewClient() - if got, want := c.baseURL, "http://deepdoc:8001"; got != want { - t.Errorf("baseURL=%q, want %q (DEEPDOC_URL should win over TENSORRT_DLA_SVR)", got, want) - } - if !c.Enabled() { - t.Errorf("Enabled()=false with DEEPDOC_URL set; want true") - } -} - -func TestNewClient_LegacyAlias(t *testing.T) { - withEnv(t) - os.Setenv("TENSORRT_DLA_SVR", "http://legacy:8001") - c := NewClient() - if got, want := c.baseURL, "http://legacy:8001"; got != want { - t.Errorf("baseURL=%q, want %q (TENSORRT_DLA_SVR should populate baseURL)", got, want) - } - if !c.Enabled() { - t.Errorf("Enabled()=false with TENSORRT_DLA_SVR set; want true") - } -} - -func TestNewClientWithURL_Empty(t *testing.T) { - c := NewClientWithURL("") - if c.Enabled() { - t.Errorf("Enabled()=true with empty URL; want false") - } -} - -func TestOptions_Override(t *testing.T) { - hc := &http.Client{Timeout: 7 * time.Second} - c := NewClientWithURL("http://x:1", - WithHTTPClient(hc), - WithMaxAttempts(5), - WithBackoff(50*time.Millisecond), - ) - if c.httpClient != hc { - t.Errorf("WithHTTPClient did not apply") - } - if c.maxAttempts != 5 { - t.Errorf("maxAttempts=%d, want 5", c.maxAttempts) - } - if c.backoff != 50*time.Millisecond { - t.Errorf("backoff=%v, want 50ms", c.backoff) - } -} - -func TestClient_DLAWithoutURL(t *testing.T) { - ctx := t.Context() - c := NewClientWithURL("") - _, err := c.DLA(ctx, [][]byte{[]byte("jpg")}) - if !errors.Is(err, ErrNoURL) { - t.Errorf("DLA() error=%v, want ErrNoURL", err) - } -} - -func TestClient_OCRReturnsNoRemoteEndpoint(t *testing.T) { - ctx := t.Context() - c := NewClientWithURL("http://x:1") - _, err := c.OCR(ctx, [][]byte{[]byte("jpg")}) - if !errors.Is(err, ErrNoRemoteEndpoint) { - t.Errorf("OCR() error=%v, want ErrNoRemoteEndpoint", err) - } -} - -func TestClient_OCRNoRemoteEndpointEvenWithUnsetURL(t *testing.T) { - ctx := t.Context() - c := NewClientWithURL("") - _, err := c.OCR(ctx, nil) - if !errors.Is(err, ErrNoRemoteEndpoint) { - t.Errorf("OCR() error=%v, want ErrNoRemoteEndpoint (call should not fall through to ErrNoURL)", err) - } -} - -func TestClient_TSRReturnsNoRemoteEndpoint(t *testing.T) { - ctx := t.Context() - c := NewClientWithURL("http://x:1") - _, err := c.TSR(ctx, [][]byte{[]byte("jpg")}) - if !errors.Is(err, ErrNoRemoteEndpoint) { - t.Errorf("TSR() error=%v, want ErrNoRemoteEndpoint", err) - } -} - -func TestClient_DLAEmptyInput(t *testing.T) { - ctx := t.Context() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Errorf("DLA() with empty input should not hit the server") - })) - defer srv.Close() - c := NewClientWithURL(srv.URL) - res, err := c.DLA(ctx, nil) - if err != nil { - t.Errorf("DLA(nil) error=%v, want nil", err) - } - if len(res) != 0 { - t.Errorf("DLA(nil) len=%d, want 0", len(res)) - } -} - -func TestDLAClasses_Layout(t *testing.T) { - if len(DLAClasses) != 10 { - t.Fatalf("DLAClasses len=%d, want 10", len(DLAClasses)) - } - want := []string{ - "title", "text", "reference", "figure", "figure caption", - "table", "table caption", "table caption", "equation", "figure caption", - } - for i, w := range want { - if DLAClasses[i] != w { - t.Errorf("DLAClasses[%d]=%q, want %q", i, DLAClasses[i], w) - } - } - // duplicates are intentional and must be preserved. - if DLAClasses[6] != DLAClasses[7] { - t.Errorf("DLAClasses[6]=%q vs [7]=%q; duplicates at indices 6,7 must match", DLAClasses[6], DLAClasses[7]) - } - if DLAClasses[4] != DLAClasses[9] { - t.Errorf("DLAClasses[4]=%q vs [9]=%q; duplicates at indices 4,9 must match", DLAClasses[4], DLAClasses[9]) - } -} diff --git a/internal/deepdoc/dla.go b/internal/deepdoc/dla.go deleted file mode 100644 index 3764ee7a11..0000000000 --- a/internal/deepdoc/dla.go +++ /dev/null @@ -1,183 +0,0 @@ -// -// 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. -// - -package deepdoc - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/url" - "strings" -) - -// BBox is a 4-tuple [left, top, right, bottom] in the image's -// native pixel coordinates. Float to preserve sub-pixel accuracy -// from the upstream server (the Python client lowercases+indexes -// without rounding). -type BBox [4]float64 - -// DLAResult is one detected layout region. Type is the normalized -// class name (lowercased, per Python `dla_cli.py:43`); TypeIdx is -// the raw class index into DLAClasses (preserved so callers can -// disambiguate the documented duplicate class slots). -type DLAResult struct { - Type string `json:"type"` - Score float64 `json:"score"` - BBox BBox `json:"bbox"` - TypeIdx int `json:"type_idx"` -} - -// DLAClasses is the 10-entry class taxonomy from -// deepdoc/vision/dla_cli.py:10-21. Order is significant — TypeIdx -// in the wire payload is an index into this slice. The duplicates -// at indices 4/6/7/9 are kept verbatim for backward compatibility -// with existing inference servers. -var DLAClasses = []string{ - "title", // 0 - "text", // 1 - "reference", // 2 - "figure", // 3 - "figure caption", // 4 - "table", // 5 - "table caption", // 6 - "table caption", // 7 duplicate - "equation", // 8 - "figure caption", // 9 duplicate -} - -// rawDLA is the wire format the DLA server returns -// (docs/agent-port/deepdoc-endpoints.md §2.3). -type rawDLA struct { - BBoxes [][]float64 `json:"bboxes"` -} - -// DLA calls the remote DLA service for layout analysis of one or -// more JPEG-encoded images. The Python contract -// (dla_cli.py:25-50) is replicated: -// -// - one HTTP POST per image -// - 3 attempts per image, 18s per attempt, 200ms initial backoff -// - failed images return an empty DLAResult (caller does not -// have to handle per-image errors — the Python -// `layout_recognizer.py:74-76` is happy with empty results) -// -// When no DEEPDOC_URL is set, returns ErrNoURL without any network -// call. When the base URL is set but the service is unreachable -// after 3 attempts, the failed image's slot is an empty DLAResult -// and the rest still process (matches Python's "len(res) == i" -// append-empty pattern). -func (c *Client) DLA(ctx context.Context, images [][]byte) ([]DLAResult, error) { - if !c.Enabled() { - return nil, ErrNoURL - } - if len(images) == 0 { - return []DLAResult{}, nil - } - predictURL, err := c.predictURL() - if err != nil { - return nil, err - } - out := make([]DLAResult, 0, len(images)) - for _, img := range images { - res := c.predictOne(ctx, predictURL, img) - // Per Python: a failed image yields an empty slot rather - // than aborting the whole batch. Surface the first hard - // error at the end if the user wants it. - if len(res) == 0 { - out = append(out, DLAResult{}) - } else { - out = append(out, res...) - } - } - return out, nil -} - -// predictURL resolves the DLA endpoint URL from the configured base. -// Trims trailing slash to avoid `//predict` on `http://host/`. -func (c *Client) predictURL() (string, error) { - base := strings.TrimRight(c.baseURL, "/") - u, err := url.Parse(base + predictPath) - if err != nil { - return "", fmt.Errorf("deepdoc: parse predict url: %w", err) - } - return u.String(), nil -} - -// predictOne runs the retry loop for a single image. Returns the -// list of bboxes the server returned, or an empty slice if all -// attempts failed. Errors are NOT returned for retry exhaustion — -// the caller maps "empty slice" to "no detections" per the Python -// contract; a hard error (4xx, bad URL) is returned immediately. -func (c *Client) predictOne(ctx context.Context, predictURL string, image []byte) []DLAResult { - buildBody := func() (io.Reader, string) { - // Each retry needs a fresh multipart body — multipart.Writer - // consumes its underlying buffer on Close. CreatePart lets - // us set both a filename (so Go's net/http server-side - // parser routes the part to MultipartForm.File) and the - // image/jpeg Content-Type the DLA server expects (matches - // the Python `files={'request': ('image.jpg', ...)}` - // contract from dla_cli.py:35). - buf := &bytes.Buffer{} - w := multipart.NewWriter(buf) - fw, _ := w.CreatePart(map[string][]string{ - "Content-Disposition": {`form-data; name="request"; filename="image.jpg"`}, - "Content-Type": {"image/jpeg"}, - }) - _, _ = fw.Write(image) - _ = w.Close() - return buf, w.FormDataContentType() - } - validate := func(data []byte) error { - var r rawDLA - if err := json.Unmarshal(data, &r); err != nil { - return fmt.Errorf("%w: %v", ErrInvalidResponse, err) - } - if r.BBoxes == nil { - return fmt.Errorf("%w: missing bboxes key", ErrInvalidResponse) - } - return nil - } - data, err := c.doPost(ctx, predictURL, buildBody, validate) - if err != nil { - return nil - } - var r rawDLA - _ = json.Unmarshal(data, &r) // already validated above - results := make([]DLAResult, 0, len(r.BBoxes)) - for _, b := range r.BBoxes { - if len(b) < 6 { - continue - } - // [l, t, r, b, score, type_idx] per docs/agent-port/deepdoc-endpoints.md §2.3. - bbox := BBox{b[0], b[1], b[2], b[3]} - idx := int(b[5]) - cls := "" - if idx >= 0 && idx < len(DLAClasses) { - cls = DLAClasses[idx] - } - results = append(results, DLAResult{ - Type: cls, - Score: b[4], - BBox: bbox, - TypeIdx: idx, - }) - } - return results -} diff --git a/internal/deepdoc/dla_test.go b/internal/deepdoc/dla_test.go deleted file mode 100644 index f5f60bccfe..0000000000 --- a/internal/deepdoc/dla_test.go +++ /dev/null @@ -1,454 +0,0 @@ -// -// 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. -// - -package deepdoc - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" -) - -// fastBackoff returns a Client that uses a 1ms backoff so retry-loop -// tests finish in milliseconds rather than the production 200ms -// default. Keeps the assertions tight without giving up the real -// retry semantics. -func fastBackoff() Option { return WithBackoff(1 * time.Millisecond) } - -// readMultipart parses the request body and returns the file -// uploaded under the "request" field plus the detected -// content-type. The DLA server expects a single image/jpeg part -// named "request" (deepdoc/vision/dla_cli.py:25-40). Returns -// errors instead of using t.Fatal so the recordingServer handler -// (which has no *testing.T) can call it without NPEs. -func readMultipart(r *http.Request) (fileBytes []byte, contentType string, err error) { - if err = r.ParseMultipartForm(1 << 20); err != nil { - return nil, "", err - } - fh, ok := r.MultipartForm.File["request"] - if !ok || len(fh) == 0 { - keys := make([]string, 0, len(r.MultipartForm.File)) - for k := range r.MultipartForm.File { - keys = append(keys, k) - } - return nil, "", &multipartFieldError{Field: "request", Keys: keys} - } - f, err := fh[0].Open() - if err != nil { - return nil, "", err - } - defer f.Close() - fileBytes, err = io.ReadAll(f) - if err != nil { - return nil, "", err - } - contentType = fh[0].Header.Get("Content-Type") - return -} - -// multipartFieldError is returned by readMultipart when the -// expected "request" field is missing — the test helpers turn -// it into a t.Fatalf with a useful message. -type multipartFieldError struct { - Field string - Keys []string -} - -func (e *multipartFieldError) Error() string { - return "missing '" + e.Field + "' field; got keys=" + strings.Join(e.Keys, ",") -} - -// recordingServer is a tiny wrapper that lets a test count requests -// and stage the responses the retry loop should observe. -type recordingServer struct { - requests int64 - calls []recordedCall - handler func(w http.ResponseWriter, r *http.Request, call int) -} - -type recordedCall struct { - body []byte - contentType string -} - -func (s *recordingServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt64(&s.requests, 1) - // The recording handler deliberately ignores readMultipart - // errors — callers that care about the body assert it - // themselves in their own http.HandlerFunc. - body, ct, _ := readMultipart(r) - s.calls = append(s.calls, recordedCall{body: body, contentType: ct}) - s.handler(w, r, int(n)) -} - -func TestDLA_SuccessfulResponse(t *testing.T) { - srv := httptest.NewServer(&recordingServer{ - handler: func(w http.ResponseWriter, r *http.Request, call int) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "bboxes": [][]float64{ - {10, 20, 100, 200, 0.95, 0}, // title - {10, 220, 500, 400, 0.88, 1}, // text - {50, 420, 600, 700, 0.77, 3}, // figure - }, - }) - }, - }) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff()) - res, err := c.DLA(t.Context(), [][]byte{[]byte("jpg-bytes")}) - if err != nil { - t.Fatalf("DLA: %v", err) - } - if len(res) != 3 { - t.Fatalf("len(res)=%d, want 3", len(res)) - } - wantTypes := []string{"title", "text", "figure"} - wantScores := []float64{0.95, 0.88, 0.77} - wantBBox := []BBox{{10, 20, 100, 200}, {10, 220, 500, 400}, {50, 420, 600, 700}} - for i, r := range res { - if r.Type != wantTypes[i] { - t.Errorf("[%d] Type=%q, want %q", i, r.Type, wantTypes[i]) - } - if r.Score != wantScores[i] { - t.Errorf("[%d] Score=%v, want %v", i, r.Score, wantScores[i]) - } - if r.BBox != wantBBox[i] { - t.Errorf("[%d] BBox=%v, want %v", i, r.BBox, wantBBox[i]) - } - } - // TypeIdx is preserved for downstream callers that care about - // the duplicate-class disambiguation. - if res[0].TypeIdx != 0 { - t.Errorf("res[0].TypeIdx=%d, want 0", res[0].TypeIdx) - } - if res[1].TypeIdx != 1 { - t.Errorf("res[1].TypeIdx=%d, want 1", res[1].TypeIdx) - } -} - -func TestDLA_MultipartFieldNameAndContentType(t *testing.T) { - var gotReqCT, gotPartCT, gotBody string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bytes, ct, err := readMultipart(r) - if err != nil { - t.Fatalf("readMultipart: %v", err) - } - gotReqCT = r.Header.Get("Content-Type") - gotPartCT = ct - gotBody = string(bytes) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"bboxes":[]}`)) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff()) - if _, err := c.DLA(t.Context(), [][]byte{[]byte("PAYLOAD")}); err != nil { - t.Fatalf("DLA: %v", err) - } - if gotBody != "PAYLOAD" { - t.Errorf("body=%q, want PAYLOAD (DLA must round-trip the JPEG bytes unchanged)", gotBody) - } - if !strings.HasPrefix(gotReqCT, "multipart/form-data") { - t.Errorf("request content-type=%q, want multipart/form-data (set by multipart.Writer.FormDataContentType)", gotReqCT) - } - // The part's own content-type is image/jpeg, asserted via - // readMultipart returning it; we already covered the boundary - // case in TestDLA_PartContentType. We just record it here as a - // sanity check. - if gotPartCT != "image/jpeg" { - t.Errorf("part content-type=%q, want image/jpeg", gotPartCT) - } -} - -func TestDLA_PartContentType(t *testing.T) { - var gotCT string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, ct, err := readMultipart(r) - if err != nil { - t.Fatalf("readMultipart: %v", err) - } - gotCT = ct - _, _ = w.Write([]byte(`{"bboxes":[]}`)) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff()) - if _, err := c.DLA(t.Context(), [][]byte{[]byte("img")}); err != nil { - t.Fatalf("DLA: %v", err) - } - if gotCT != "image/jpeg" { - t.Errorf("part Content-Type=%q, want image/jpeg (matches dla_cli.py:35)", gotCT) - } -} - -func TestDLA_TrimsTrailingSlash(t *testing.T) { - var hit string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hit = r.URL.Path - _, _ = w.Write([]byte(`{"bboxes":[]}`)) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL+"/", fastBackoff()) - if _, err := c.DLA(t.Context(), [][]byte{[]byte("img")}); err != nil { - t.Fatalf("DLA: %v", err) - } - if hit != "/predict" { - t.Errorf("path=%q, want /predict (no double slash)", hit) - } -} - -func TestDLA_RetriesOn5xxThenSucceeds(t *testing.T) { - srv := httptest.NewServer(&recordingServer{ - handler: func(w http.ResponseWriter, r *http.Request, call int) { - if call < 2 { - http.Error(w, "transient", http.StatusServiceUnavailable) - return - } - _, _ = w.Write([]byte(`{"bboxes":[[0,0,1,1,0.5,5]]}`)) - }, - }) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff(), WithMaxAttempts(3)) - res, err := c.DLA(t.Context(), [][]byte{[]byte("img")}) - if err != nil { - t.Fatalf("DLA: %v", err) - } - if len(res) != 1 || res[0].Type != "table" { - t.Errorf("res=%+v, want one 'table' result after 2nd-attempt success", res) - } -} - -func TestDLA_ExhaustsRetriesOnPersistent5xx(t *testing.T) { - srv := httptest.NewServer(&recordingServer{ - handler: func(w http.ResponseWriter, r *http.Request, call int) { - http.Error(w, "down", http.StatusServiceUnavailable) - }, - }) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff(), WithMaxAttempts(3)) - res, err := c.DLA(t.Context(), [][]byte{[]byte("img")}) - if err != nil { - t.Fatalf("DLA error=%v, want nil (per-image failures map to empty slot)", err) - } - if len(res) != 1 { - t.Fatalf("len(res)=%d, want 1 (failed image yields empty slot)", len(res)) - } - if res[0].Type != "" || res[0].Score != 0 { - t.Errorf("res[0]=%+v, want zero-value DLAResult for failed image", res[0]) - } -} - -func TestDLA_4xxReturnsEmpty(t *testing.T) { - var calls int64 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&calls, 1) - http.Error(w, "bad image", http.StatusBadRequest) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff(), WithMaxAttempts(5)) - res, err := c.DLA(t.Context(), [][]byte{[]byte("img")}) - if err != nil { - t.Fatalf("DLA error=%v, want nil", err) - } - if len(res) != 1 || res[0].Type != "" { - t.Errorf("res=%+v, want single empty slot", res) - } - // 4xx is not retried. - if got := atomic.LoadInt64(&calls); got != 1 { - t.Errorf("calls=%d, want 1 (4xx is a config error, not transient)", got) - } -} - -func TestDLA_RetriesOnMalformedJSON(t *testing.T) { - srv := httptest.NewServer(&recordingServer{ - handler: func(w http.ResponseWriter, r *http.Request, call int) { - if call < 2 { - _, _ = w.Write([]byte(`<<< not json`)) - return - } - _, _ = w.Write([]byte(`{"bboxes":[[0,0,1,1,0.5,2]]}`)) - }, - }) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff(), WithMaxAttempts(3)) - res, err := c.DLA(t.Context(), [][]byte{[]byte("img")}) - if err != nil { - t.Fatalf("DLA: %v", err) - } - if len(res) != 1 || res[0].Type != "reference" { - t.Errorf("res=%+v, want one 'reference' after retry", res) - } -} - -func TestDLA_RetriesOnMissingBBoxes(t *testing.T) { - srv := httptest.NewServer(&recordingServer{ - handler: func(w http.ResponseWriter, r *http.Request, call int) { - if call < 2 { - _, _ = w.Write([]byte(`{"wrong":"key"}`)) - return - } - _, _ = w.Write([]byte(`{"bboxes":[]}`)) - }, - }) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff(), WithMaxAttempts(3)) - res, err := c.DLA(t.Context(), [][]byte{[]byte("img")}) - if err != nil { - t.Fatalf("DLA: %v", err) - } - if len(res) != 1 { - t.Errorf("len(res)=%d, want 1 (empty bboxes is success)", len(res)) - } -} - -func TestDLA_PerImageIsolation(t *testing.T) { - // First image: 503 on both attempts (exhausts with - // MaxAttempts=2). Second image: 200. Verifies that a failed - // image does not abort the batch (matches the Python - // "len(res) == i" append-empty pattern). - srv := httptest.NewServer(&recordingServer{ - handler: func(w http.ResponseWriter, r *http.Request, call int) { - if call < 3 { - http.Error(w, "boom", http.StatusServiceUnavailable) - return - } - _, _ = w.Write([]byte(`{"bboxes":[[0,0,10,10,0.9,1]]}`)) - }, - }) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff(), WithMaxAttempts(2)) - res, err := c.DLA(t.Context(), [][]byte{[]byte("a"), []byte("b")}) - if err != nil { - t.Fatalf("DLA: %v", err) - } - if len(res) != 2 { - t.Fatalf("len(res)=%d, want 2", len(res)) - } - if res[0].Type != "" { - t.Errorf("res[0]=%+v, want empty (first image failed)", res[0]) - } - if res[1].Type != "text" { - t.Errorf("res[1]=%+v, want text (second image succeeded)", res[1]) - } -} - -func TestDLA_SkipsShortBBoxes(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // One well-formed bbox, one too-short, one well-formed. - _, _ = w.Write([]byte(`{"bboxes":[[0,0,1,1,0.5,1],[1,2,3], [0,0,1,1,0.5,3]]}`)) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff()) - res, err := c.DLA(t.Context(), [][]byte{[]byte("img")}) - if err != nil { - t.Fatalf("DLA: %v", err) - } - if len(res) != 2 { - t.Fatalf("len(res)=%d, want 2 (short bbox must be skipped, not panic)", len(res)) - } - if res[0].Type != "text" || res[1].Type != "figure" { - t.Errorf("types=%q,%q, want text,figure", res[0].Type, res[1].Type) - } -} - -func TestDLA_OutOfRangeTypeIdxMapsToEmptyString(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TypeIdx 42 is beyond DLAClasses (len 10) — must not panic. - _, _ = w.Write([]byte(`{"bboxes":[[0,0,1,1,0.5,42]]}`)) - })) - defer srv.Close() - - c := NewClientWithURL(srv.URL, fastBackoff()) - res, err := c.DLA(t.Context(), [][]byte{[]byte("img")}) - if err != nil { - t.Fatalf("DLA: %v", err) - } - if len(res) != 1 { - t.Fatalf("len(res)=%d, want 1", len(res)) - } - if res[0].Type != "" { - t.Errorf("Type=%q, want empty string for out-of-range TypeIdx", res[0].Type) - } - if res[0].TypeIdx != 42 { - t.Errorf("TypeIdx=%d, want 42 (raw value preserved)", res[0].TypeIdx) - } -} - -func TestDLA_ContextCancelDuringBackoff(t *testing.T) { - var hits int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&hits, 1) - http.Error(w, "down", http.StatusServiceUnavailable) - })) - defer srv.Close() - - // 5s backoff × 5 attempts = 25s of pure sleep if cancel is - // ignored. The semantic property under test is "ctx cancel - // short-circuits the retry loop". The load-bearing assertions are: - // 1. DLA returns promptly from a watchdog-bounded goroutine. - // 2. The server is hit exactly once — no second retry attempt - // starts after ctx cancellation lands during the first backoff. - c := NewClientWithURL(srv.URL, WithBackoff(5*time.Second), WithMaxAttempts(5)) - ctx, cancel := context.WithCancel(t.Context()) - go func() { - time.Sleep(50 * time.Millisecond) - cancel() - }() - - type result struct { - res []DLAResult - err error - } - done := make(chan result, 1) - go func() { - res, err := c.DLA(ctx, [][]byte{[]byte("img")}) - done <- result{res: res, err: err} - }() - - select { - case r := <-done: - if r.err != nil { - t.Logf("DLA err=%v (acceptable — per-image failures map to empty slots)", r.err) - } - if got := atomic.LoadInt32(&hits); got != 1 { - t.Fatalf("server hits = %d, want 1 (ctx cancel during backoff must suppress retries)", got) - } - if len(r.res) != 1 { - t.Fatalf("len(res)=%d, want 1 empty result slot", len(r.res)) - } - if r.res[0] != (DLAResult{}) { - t.Fatalf("result=%+v, want empty slot on cancelled image", r.res[0]) - } - case <-time.After(3 * time.Second): - t.Fatal("DLA did not return within 3s — ctx cancel did not short-circuit backoff") - } -} diff --git a/internal/deepdoc/ocr.go b/internal/deepdoc/ocr.go deleted file mode 100644 index e31938003e..0000000000 --- a/internal/deepdoc/ocr.go +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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. -// - -package deepdoc - -import "context" - -// OCR is a stub. The Python deepdoc service has no remote OCR -// endpoint — OCR is a 100% local ONNX pipeline -// (deepdoc/vision/ocr.py:542). Callers that need OCR must keep -// using the Python deepdoc service directly; this Go client -// exists for DLA only. Returns ErrNoRemoteEndpoint unconditionally -// so the absence of a remote endpoint is loud rather than silent. -func (c *Client) OCR(_ context.Context, _ [][]byte) ([][]byte, error) { - return nil, ErrNoRemoteEndpoint -} diff --git a/internal/deepdoc/tsr.go b/internal/deepdoc/tsr.go deleted file mode 100644 index 2bdd48dcb8..0000000000 --- a/internal/deepdoc/tsr.go +++ /dev/null @@ -1,30 +0,0 @@ -// -// 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. -// - -package deepdoc - -import "context" - -// TSR is a stub. The Python deepdoc service has no remote TSR -// endpoint — table structure recognition is a 100% local ONNX -// pipeline (deepdoc/vision/table_structure_recognizer.py:30). -// Callers that need TSR must keep using the Python deepdoc -// service directly; this Go client exists for DLA only. Returns -// ErrNoRemoteEndpoint unconditionally so the absence of a remote -// endpoint is loud rather than silent. -func (c *Client) TSR(_ context.Context, _ [][]byte) ([][]byte, error) { - return nil, ErrNoRemoteEndpoint -}