mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-16 20:57:21 +08:00
feat(agent): Go port — canvas engine, 22 components, DSL v2, 13 endpoints (#15952)
Ports the agent canvas subsystem from Python to Go.
## What's included
### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages
### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |
### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7
### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)
### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs
### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
This commit is contained in:
220
internal/deepdoc/client.go
Normal file
220
internal/deepdoc/client.go
Normal file
@@ -0,0 +1,220 @@
|
||||
//
|
||||
// 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"
|
||||
"os"
|
||||
"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 := os.Getenv("DEEPDOC_URL")
|
||||
if url == "" {
|
||||
url = os.Getenv("TENSORRT_DLA_SVR")
|
||||
}
|
||||
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
|
||||
}
|
||||
187
internal/deepdoc/client_test.go
Normal file
187
internal/deepdoc/client_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// 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"
|
||||
"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) {
|
||||
c := NewClientWithURL("")
|
||||
_, err := c.DLA(context.Background(), [][]byte{[]byte("jpg")})
|
||||
if err != ErrNoURL {
|
||||
t.Errorf("DLA() error=%v, want ErrNoURL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_OCRReturnsNoRemoteEndpoint(t *testing.T) {
|
||||
c := NewClientWithURL("http://x:1")
|
||||
_, err := c.OCR(context.Background(), [][]byte{[]byte("jpg")})
|
||||
if err != ErrNoRemoteEndpoint {
|
||||
t.Errorf("OCR() error=%v, want ErrNoRemoteEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_OCRNoRemoteEndpointEvenWithUnsetURL(t *testing.T) {
|
||||
c := NewClientWithURL("")
|
||||
_, err := c.OCR(context.Background(), nil)
|
||||
if err != ErrNoRemoteEndpoint {
|
||||
t.Errorf("OCR() error=%v, want ErrNoRemoteEndpoint (call should not fall through to ErrNoURL)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_TSRReturnsNoRemoteEndpoint(t *testing.T) {
|
||||
c := NewClientWithURL("http://x:1")
|
||||
_, err := c.TSR(context.Background(), [][]byte{[]byte("jpg")})
|
||||
if err != ErrNoRemoteEndpoint {
|
||||
t.Errorf("TSR() error=%v, want ErrNoRemoteEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DLAEmptyInput(t *testing.T) {
|
||||
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(context.Background(), 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])
|
||||
}
|
||||
}
|
||||
183
internal/deepdoc/dla.go
Normal file
183
internal/deepdoc/dla.go
Normal file
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
435
internal/deepdoc/dla_test.go
Normal file
435
internal/deepdoc/dla_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
//
|
||||
// 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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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(context.Background(), [][]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) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "down", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 5s backoff × 5 attempts = 25s of pure sleep if cancel is
|
||||
// ignored. Assert the call returns in well under that — the
|
||||
// meaningful property here is "ctx cancel short-circuits the
|
||||
// retry loop", not the specific error value, because DLA
|
||||
// collapses per-image failures into empty slots by design
|
||||
// (matches the Python contract from layout_recognizer.py:74-76).
|
||||
c := NewClientWithURL(srv.URL, WithBackoff(5*time.Second), WithMaxAttempts(5))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
_, err := c.DLA(ctx, [][]byte{[]byte("img")})
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Logf("DLA err=%v (acceptable)", err)
|
||||
}
|
||||
// Allow generous slack for CI scheduling jitter — 2s is still
|
||||
// 12× shorter than the unsuppressed 25s total backoff.
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("DLA took %v with ctx cancelled at 50ms; retry loop ignored cancel", elapsed)
|
||||
}
|
||||
}
|
||||
29
internal/deepdoc/ocr.go
Normal file
29
internal/deepdoc/ocr.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
30
internal/deepdoc/tsr.go
Normal file
30
internal/deepdoc/tsr.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user