Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
187 lines
6.8 KiB
Go
187 lines
6.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/Tencent/WeKnora/internal/models/chat"
|
|
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/hibiken/asynq"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
const graphBudgetSource = "Trust supports learning."
|
|
|
|
func graphBudgetPayload() types.ExtractChunkPayload {
|
|
return types.ExtractChunkPayload{TenantID: 7, ChunkID: "chunk", ModelID: "model",
|
|
ExpectedSource: &types.GraphSourceIdentity{KnowledgeBaseID: "kb", KnowledgeID: "doc", ContentSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte(graphBudgetSource)))}}
|
|
}
|
|
|
|
func openGraphBudgetDB(t *testing.T, path string) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
sqlDB.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
require.NoError(t, db.Exec(`CREATE TABLE IF NOT EXISTS native_graph_backfill_failures (
|
|
task_id TEXT PRIMARY KEY, tenant_id INTEGER, knowledge_base_id TEXT, chunk_id TEXT,
|
|
knowledge_id TEXT, content_hash TEXT, state TEXT, manual_retries INTEGER, reason TEXT, updated_at DATETIME)`).Error)
|
|
return db
|
|
}
|
|
|
|
func seedGraphBudget(t *testing.T, db *gorm.DB) {
|
|
t.Helper()
|
|
p := graphBudgetPayload()
|
|
require.NoError(t, db.Exec(`INSERT INTO native_graph_backfill_failures VALUES(?,?,?,?,?,?,'dispatch_requested',0,'',CURRENT_TIMESTAMP)`,
|
|
"task", p.TenantID, p.ExpectedSource.KnowledgeBaseID, p.ChunkID, p.ExpectedSource.KnowledgeID, p.ExpectedSource.ContentSHA256).Error)
|
|
}
|
|
|
|
func graphBudgetDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db := openGraphBudgetDB(t, filepath.Join(t.TempDir(), "budget.sqlite"))
|
|
seedGraphBudget(t, db)
|
|
return db
|
|
}
|
|
|
|
func TestGraphExecutionClaimSurvivesRestartAndConcurrentDelivery(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "budget.sqlite")
|
|
db := openGraphBudgetDB(t, path)
|
|
seedGraphBudget(t, db)
|
|
svc := &ChunkExtractService{executionDB: db}
|
|
var successes atomic.Int32
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if svc.claimGraphExecution(context.Background(), graphBudgetPayload(), "task") == nil {
|
|
successes.Add(1)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
require.Equal(t, int32(1), successes.Load())
|
|
sqlDB, _ := db.DB()
|
|
require.NoError(t, sqlDB.Close())
|
|
svc = &ChunkExtractService{executionDB: openGraphBudgetDB(t, path)}
|
|
require.ErrorIs(t, svc.claimGraphExecution(context.Background(), graphBudgetPayload(), "task"), requestbudget.ErrStopped)
|
|
}
|
|
|
|
func TestGraphExecutionClaimRejectsChangedOrFailedReceipt(t *testing.T) {
|
|
for _, changed := range []string{"task", "tenant", "base", "chunk", "document", "source", "failed", "retry", "reason", "missing db"} {
|
|
t.Run(changed, func(t *testing.T) {
|
|
db := graphBudgetDB(t)
|
|
p, task := graphBudgetPayload(), "task"
|
|
svc := &ChunkExtractService{executionDB: db}
|
|
switch changed {
|
|
case "task":
|
|
task = "other"
|
|
case "tenant":
|
|
p.TenantID++
|
|
case "base":
|
|
p.ExpectedSource.KnowledgeBaseID = "other"
|
|
case "chunk":
|
|
p.ChunkID = "other"
|
|
case "document":
|
|
p.ExpectedSource.KnowledgeID = "other"
|
|
case "source":
|
|
p.ExpectedSource.ContentSHA256 = "other"
|
|
case "failed":
|
|
require.NoError(t, db.Exec("UPDATE native_graph_backfill_failures SET state='failed'").Error)
|
|
case "retry":
|
|
require.NoError(t, db.Exec("UPDATE native_graph_backfill_failures SET manual_retries=1").Error)
|
|
case "reason":
|
|
require.NoError(t, db.Exec("UPDATE native_graph_backfill_failures SET reason='semantic_review_rejected'").Error)
|
|
case "missing db":
|
|
svc.executionDB = nil
|
|
}
|
|
require.ErrorIs(t, svc.claimGraphExecution(context.Background(), p, task), requestbudget.ErrStopped)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGraphExecutionCorrectionSharesActualSendBudget(t *testing.T) {
|
|
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
|
utils.ResetSSRFWhitelistForTest()
|
|
t.Cleanup(utils.ResetSSRFWhitelistForTest)
|
|
for _, tc := range []struct {
|
|
name string
|
|
everyRedirect bool
|
|
wrongModel bool
|
|
}{
|
|
{name: "three sends complete correction"},
|
|
{name: "fourth send refuses graph write", everyRedirect: true},
|
|
{name: "wrong concrete model sends nothing", wrongModel: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var sends, calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sends.Add(1)
|
|
if r.URL.Path != "/result" {
|
|
call := calls.Add(1)
|
|
if tc.everyRedirect || call == 2 {
|
|
http.Redirect(w, r, "/result", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
}
|
|
content := `[]`
|
|
if calls.Load() == 2 {
|
|
content = `[{"entity1":"Trust","entity2":"learning","relation":"supports","evidence":"Trust supports learning."}]`
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": content}, "finish_reason": "stop"}}})
|
|
}))
|
|
defer server.Close()
|
|
modelID := "model"
|
|
if tc.wrongModel {
|
|
modelID = "other"
|
|
}
|
|
model, err := chat.NewChat(&chat.ChatConfig{Source: types.ModelSourceRemote, BaseURL: server.URL, ModelID: modelID, ModelName: "gpt-4o", APIKey: "local-only", Provider: "openai"}, nil)
|
|
require.NoError(t, err)
|
|
db := graphBudgetDB(t)
|
|
writer := &extractionWriter{}
|
|
svc := &ChunkExtractService{template: &types.PromptTemplateStructured{Description: "Extract graph"}, modelService: extractionModels{model: model},
|
|
knowledgeBaseRepo: extractionKB{}, knowledgeRepo: &extractionKnowledge{}, executionDB: db,
|
|
chunkRepo: extractionChunks{chunk: &types.Chunk{ID: "chunk", TenantID: 7, KnowledgeBaseID: "kb", KnowledgeID: "doc", Content: graphBudgetSource, IsEnabled: true, ChunkType: types.ChunkTypeText}}, graphEngine: writer}
|
|
payload, _ := json.Marshal(graphBudgetPayload())
|
|
task := asynq.NewTask(types.TypeChunkExtract, payload)
|
|
err = svc.handle(context.Background(), task, "task")
|
|
if tc.wrongModel {
|
|
require.Error(t, err)
|
|
require.Zero(t, sends.Load())
|
|
require.Nil(t, writer.saved)
|
|
} else if tc.everyRedirect {
|
|
require.Error(t, err)
|
|
require.Equal(t, int32(3), sends.Load())
|
|
require.Nil(t, writer.saved)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(3), sends.Load())
|
|
require.True(t, writer.complete)
|
|
}
|
|
if !writer.complete {
|
|
before := sends.Load()
|
|
err = svc.handle(context.Background(), task, "task")
|
|
require.True(t, errors.Is(err, asynq.SkipRetry))
|
|
require.Equal(t, before, sends.Load(), "redelivery must not replenish sends")
|
|
}
|
|
})
|
|
}
|
|
}
|