mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-13 08:28:22 +08:00
376 lines
11 KiB
Go
376 lines
11 KiB
Go
|
|
//
|
||
|
|
// 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 inference
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"image"
|
||
|
|
"image/color"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
doctype "ragflow/internal/deepdoc/parser/type"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ── fake store ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// fakeStore is an in-memory implementation of cacheStore used
|
||
|
|
// by tests. Safe for concurrent use.
|
||
|
|
type fakeStore struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
enabled bool
|
||
|
|
data map[string][]byte
|
||
|
|
sets int32
|
||
|
|
gets int32
|
||
|
|
}
|
||
|
|
|
||
|
|
func newFakeStore() *fakeStore {
|
||
|
|
return &fakeStore{enabled: true, data: map[string][]byte{}}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *fakeStore) Enabled() bool { return s.enabled }
|
||
|
|
|
||
|
|
func (s *fakeStore) GetObj(key string, dest any) bool {
|
||
|
|
atomic.AddInt32(&s.gets, 1)
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
raw, ok := s.data[key]
|
||
|
|
if !ok {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return json.Unmarshal(raw, dest) == nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *fakeStore) SetObj(key string, value any, _ time.Duration) bool {
|
||
|
|
atomic.AddInt32(&s.sets, 1)
|
||
|
|
raw, err := json.Marshal(value)
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
s.data[key] = raw
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── fake analyser ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// fakeAnalyzer is a deterministic, count-tracking DocAnalyzer.
|
||
|
|
// Each method increments a counter and returns a value shaped
|
||
|
|
// uniquely so a cache hit can be told apart from an inner call.
|
||
|
|
type fakeAnalyzer struct {
|
||
|
|
dlaCount int32
|
||
|
|
tsrCount int32
|
||
|
|
ocrDetCnt int32
|
||
|
|
ocrRecCnt int32
|
||
|
|
|
||
|
|
healthy bool
|
||
|
|
|
||
|
|
dlaOut []doctype.DLARegion
|
||
|
|
tsrOut []doctype.TSRCell
|
||
|
|
ocrDet []doctype.OCRBox
|
||
|
|
ocrRec []doctype.OCRText
|
||
|
|
dlaErr error
|
||
|
|
tsrErr error
|
||
|
|
ocrDetEr error
|
||
|
|
ocrRecEr error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeAnalyzer) DLA(_ context.Context, _ image.Image) ([]doctype.DLARegion, error) {
|
||
|
|
atomic.AddInt32(&f.dlaCount, 1)
|
||
|
|
if f.dlaErr != nil {
|
||
|
|
return nil, f.dlaErr
|
||
|
|
}
|
||
|
|
return f.dlaOut, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeAnalyzer) TSR(_ context.Context, _ image.Image) ([]doctype.TSRCell, error) {
|
||
|
|
atomic.AddInt32(&f.tsrCount, 1)
|
||
|
|
if f.tsrErr != nil {
|
||
|
|
return nil, f.tsrErr
|
||
|
|
}
|
||
|
|
return f.tsrOut, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeAnalyzer) OCRDetect(_ context.Context, _ image.Image) ([]doctype.OCRBox, error) {
|
||
|
|
atomic.AddInt32(&f.ocrDetCnt, 1)
|
||
|
|
if f.ocrDetEr != nil {
|
||
|
|
return nil, f.ocrDetEr
|
||
|
|
}
|
||
|
|
return f.ocrDet, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeAnalyzer) OCRRecognize(_ context.Context, _ image.Image) ([]doctype.OCRText, error) {
|
||
|
|
atomic.AddInt32(&f.ocrRecCnt, 1)
|
||
|
|
if f.ocrRecEr != nil {
|
||
|
|
return nil, f.ocrRecEr
|
||
|
|
}
|
||
|
|
return f.ocrRec, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeAnalyzer) Health() bool { return f.healthy }
|
||
|
|
|
||
|
|
// ── helpers ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// newImage returns a tiny 4x4 RGBA image with stable pixel
|
||
|
|
// content. JPEG encoding of identical pixel data is
|
||
|
|
// deterministic, so the cache key for the same newImage() result
|
||
|
|
// is the same across calls.
|
||
|
|
func newImage(seed byte) *image.RGBA {
|
||
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||
|
|
for y := 0; y < 4; y++ {
|
||
|
|
for x := 0; x < 4; x++ {
|
||
|
|
img.SetRGBA(x, y, color.RGBA{seed, seed * 2, seed * 3, 255})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return img
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── key tests ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func TestCacheKey_Deterministic(t *testing.T) {
|
||
|
|
a, errA := cacheKey("dla", newImage(0x10))
|
||
|
|
if errA != nil {
|
||
|
|
t.Fatalf("cacheKey(dla,#1): %v", errA)
|
||
|
|
}
|
||
|
|
b, errB := cacheKey("dla", newImage(0x10))
|
||
|
|
if errB != nil {
|
||
|
|
t.Fatalf("cacheKey(dla,#2): %v", errB)
|
||
|
|
}
|
||
|
|
if a != b {
|
||
|
|
t.Fatalf("same image → different key: %q vs %q", a, b)
|
||
|
|
}
|
||
|
|
if want := cacheKeyPrefix + "dla:"; len(a) < len(want) || a[:len(want)] != want {
|
||
|
|
t.Fatalf("key missing prefix: %q", a)
|
||
|
|
}
|
||
|
|
|
||
|
|
c, errC := cacheKey("tsr", newImage(0x10))
|
||
|
|
if errC != nil {
|
||
|
|
t.Fatalf("cacheKey(tsr): %v", errC)
|
||
|
|
}
|
||
|
|
if c == a {
|
||
|
|
t.Fatal("different methods should yield different keys")
|
||
|
|
}
|
||
|
|
|
||
|
|
d, errD := cacheKey("dla", newImage(0x11))
|
||
|
|
if errD != nil {
|
||
|
|
t.Fatalf("cacheKey(dla,#2): %v", errD)
|
||
|
|
}
|
||
|
|
if d == a {
|
||
|
|
t.Fatal("different image bytes should yield different keys")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCacheKey_NilImageErrors(t *testing.T) {
|
||
|
|
if _, err := cacheKey("dla", nil); err == nil {
|
||
|
|
t.Fatal("want error for nil image, got nil")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── cache hit / miss / disabled ────────────────────────────────────
|
||
|
|
|
||
|
|
func TestDocAnalyzerCache_MissThenHit(t *testing.T) {
|
||
|
|
inner := &fakeAnalyzer{
|
||
|
|
dlaOut: []doctype.DLARegion{{X0: 1, Y0: 2, X1: 3, Y1: 4, Label: "text"}},
|
||
|
|
}
|
||
|
|
s := newFakeStore()
|
||
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
||
|
|
|
||
|
|
img := newImage(0x20)
|
||
|
|
|
||
|
|
out1, err := c.DLA(context.Background(), img)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("DLA #1: %v", err)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 1 {
|
||
|
|
t.Fatalf("inner called %d times after first DLA, want 1", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&s.sets); got != 1 {
|
||
|
|
t.Fatalf("store.SetObj called %d times after first DLA, want 1", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
out2, err := c.DLA(context.Background(), img)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("DLA #2: %v", err)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 1 {
|
||
|
|
t.Fatalf("inner called %d times after hit, want 1 (cached)", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&s.gets); got < 1 {
|
||
|
|
t.Fatalf("store.GetObj not called, want ≥1")
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(out1) != len(out2) || out1[0].Label != out2[0].Label {
|
||
|
|
t.Fatalf("cached result diverged from inner: %v vs %v", out1, out2)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDocAnalyzerCache_StoreDisabledPassthrough(t *testing.T) {
|
||
|
|
inner := &fakeAnalyzer{
|
||
|
|
dlaOut: []doctype.DLARegion{{Label: "text"}},
|
||
|
|
}
|
||
|
|
s := newFakeStore()
|
||
|
|
s.enabled = false
|
||
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
||
|
|
|
||
|
|
img := newImage(0x30)
|
||
|
|
for i := 0; i < 3; i++ {
|
||
|
|
if _, err := c.DLA(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("DLA #%d: %v", i, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 3 {
|
||
|
|
t.Fatalf("inner called %d times, want 3 (no caching when disabled)", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&s.sets); got != 0 {
|
||
|
|
t.Fatalf("store.SetObj called %d times, want 0", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDocAnalyzerCache_NilImageBypassesCache(t *testing.T) {
|
||
|
|
inner := &fakeAnalyzer{
|
||
|
|
dlaOut: []doctype.DLARegion{{Label: "noop"}},
|
||
|
|
}
|
||
|
|
s := newFakeStore()
|
||
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
||
|
|
|
||
|
|
for i := 0; i < 2; i++ {
|
||
|
|
if _, err := c.DLA(context.Background(), nil); err != nil {
|
||
|
|
t.Fatalf("DLA nil #%d: %v", i, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&s.sets); got != 0 {
|
||
|
|
t.Fatalf("nil image should bypass store.SetObj, got count %d", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 2 {
|
||
|
|
t.Fatalf("inner called %d times, want 2 (nil → no cache)", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── all 4 methods ───────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func TestDocAnalyzerCache_AllMethods(t *testing.T) {
|
||
|
|
inner := &fakeAnalyzer{
|
||
|
|
dlaOut: []doctype.DLARegion{{Label: "title"}},
|
||
|
|
tsrOut: []doctype.TSRCell{{X0: 0, Y0: 0, X1: 1, Y1: 1, Text: "A"}},
|
||
|
|
ocrDet: []doctype.OCRBox{{X0: 0, Y0: 0, X1: 10, Y1: 10}},
|
||
|
|
ocrRec: []doctype.OCRText{{Text: "x", Confidence: 0.9}},
|
||
|
|
}
|
||
|
|
s := newFakeStore()
|
||
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
||
|
|
|
||
|
|
img := newImage(0x40)
|
||
|
|
|
||
|
|
if _, err := c.DLA(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("DLA: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := c.TSR(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("TSR: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := c.OCRDetect(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("OCRDetect: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := c.OCRRecognize(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("OCRRecognize: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if got := atomic.LoadInt32(&s.sets); got != 4 {
|
||
|
|
t.Fatalf("store.SetObj called %d times, want 4", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := c.DLA(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("DLA #2: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := c.TSR(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("TSR #2: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := c.OCRDetect(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("OCRDetect #2: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := c.OCRRecognize(context.Background(), img); err != nil {
|
||
|
|
t.Fatalf("OCRRecognize #2: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 1 {
|
||
|
|
t.Fatalf("DLA inner count = %d, want 1", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.tsrCount); got != 1 {
|
||
|
|
t.Fatalf("TSR inner count = %d, want 1", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.ocrDetCnt); got != 1 {
|
||
|
|
t.Fatalf("OCRDetect inner count = %d, want 1", got)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.ocrRecCnt); got != 1 {
|
||
|
|
t.Fatalf("OCRRecognize inner count = %d, want 1", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── inner error propagation ─────────────────────────────────────────
|
||
|
|
|
||
|
|
func TestDocAnalyzerCache_InnerErrorNotCached(t *testing.T) {
|
||
|
|
inner := &fakeAnalyzer{
|
||
|
|
dlaErr: errors.New("inference service down"),
|
||
|
|
}
|
||
|
|
s := newFakeStore()
|
||
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
||
|
|
|
||
|
|
img := newImage(0x50)
|
||
|
|
if _, err := c.DLA(context.Background(), img); err == nil {
|
||
|
|
t.Fatal("want error, got nil")
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&s.sets); got != 0 {
|
||
|
|
t.Fatalf("error result must not be cached, got sets=%d", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Health passthrough ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
func TestDocAnalyzerCache_HealthPassthrough(t *testing.T) {
|
||
|
|
for _, healthy := range []bool{true, false} {
|
||
|
|
inner := &fakeAnalyzer{healthy: healthy}
|
||
|
|
s := newFakeStore()
|
||
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
||
|
|
if got := c.Health(); got != healthy {
|
||
|
|
t.Fatalf("Health() = %v, want %v", got, healthy)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── different images → different keys ───────────────────────────────
|
||
|
|
|
||
|
|
func TestDocAnalyzerCache_DifferentImagesBypassCache(t *testing.T) {
|
||
|
|
inner := &fakeAnalyzer{
|
||
|
|
dlaOut: []doctype.DLARegion{{Label: "a"}},
|
||
|
|
}
|
||
|
|
s := newFakeStore()
|
||
|
|
c := newDocAnalyzerCacheWithStore(inner, s, DefaultCacheTTL)
|
||
|
|
|
||
|
|
if _, err := c.DLA(context.Background(), newImage(0x80)); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err := c.DLA(context.Background(), newImage(0x81)); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt32(&inner.dlaCount); got != 2 {
|
||
|
|
t.Fatalf("inner called %d times for 2 different images, want 2", got)
|
||
|
|
}
|
||
|
|
}
|