diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 9d1a51d0a4..6b451f4fe9 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -29,12 +29,15 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "go.uber.org/zap" "ragflow/internal/agent/canvas" "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/entity" + pipelinepkg "ragflow/internal/ingestion/pipeline" + "ragflow/internal/ingestion/task" "ragflow/internal/service" "ragflow/internal/service/file" "ragflow/internal/utility" @@ -91,6 +94,28 @@ type AgentHandler struct { // tenant-only authorization (i.e. cannot verify the doc, so the // check is skipped — same shape as the pre-port behaviour). documentService documentAccessChecker + // redisGet fetches a raw string from Redis. Defaults to the global + // client (redis.Get) so production behaviour is unchanged; tests inject + // a miniredis-backed getter to exercise GetAgentLogs without a live + // Redis (mirrors the newExecutor injection pattern). + redisGet func(key string) (string, error) + // redisStore writes the debug-run log array. Defaults to the global + // client (redis.Get, which satisfies task.DebugLogStore); tests inject a + // miniredis-backed writer so runCanvasPipelineDebug can be exercised without a + // live Redis. + redisStore task.DebugLogStore + // newExecutor builds the pipeline executor for a debug run. Defaults to + // task.NewPipelineExecutor; tests inject a fake so the debug path can be + // driven without a real canvas/DSL. + newExecutor func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) +} + +// debugExecutor is the minimal executor surface runCanvasPipelineDebug needs. It is an +// interface (not *task.PipelineExecutor) so tests can substitute a fake that +// emits progress through the attached sink. *task.PipelineExecutor satisfies it. +type debugExecutor interface { + WithProgressSink(sink pipelinepkg.ProgressSink) *task.PipelineExecutor + Execute(ctx context.Context) (*task.PipelineResult, error) } // WithDocumentService injects the document service used by @@ -110,9 +135,37 @@ func NewAgentHandler(agentService *service.AgentService, fileService *file.FileS chatRunner: agentService, fileService: fileService, loader: agentService, + redisGet: func(key string) (string, error) { return redis.Get().Get(key) }, + redisStore: redis.Get(), + newExecutor: func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) { + return task.NewPipelineExecutor(taskCtx, canvasID, docBulkSize) + }, } } +// WithRedisGetter overrides the Redis string getter used by GetAgentLogs. +// Tests pass a miniredis-backed getter so the debug-log polling endpoint can +// be exercised end-to-end without a live Redis. +func (h *AgentHandler) WithRedisGetter(f func(key string) (string, error)) *AgentHandler { + h.redisGet = f + return h +} + +// WithRedisStore overrides the Redis writer used by runCanvasPipelineDebug to persist +// the debug-run log. Tests pass a miniredis-backed writer. +func (h *AgentHandler) WithRedisStore(s task.DebugLogStore) *AgentHandler { + h.redisStore = s + return h +} + +// WithNewExecutor overrides the pipeline executor factory used by +// runCanvasPipelineDebug. Tests inject a fake that emits progress through the +// attached sink without a real canvas/DSL. +func (h *AgentHandler) WithNewExecutor(f func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error)) *AgentHandler { + h.newExecutor = f + return h +} + // ListAgents lists agent canvases for the current user. // @Summary List Agents // @Description List agent canvases accessible to the current user (Home dashboard tile) @@ -486,6 +539,127 @@ func readUserInput(c *gin.Context) string { return c.Query("user_input") } +// runCanvasPipelineDebug runs a canvas in pipeline dry-run (debug) mode: it builds +// an in-memory debug TaskContext and executes the pipeline synchronously, +// returning the produced chunks. The run is fully synchronous: the complete +// debug log (including the terminal END marker) is flushed to Redis BEFORE +// this method returns, so the message_id it yields is for *replay* — +// re-fetching the log after a page refresh or from a shared link — not for +// incremental streaming; the front-end's poll therefore receives a finished +// log on the first request. +// +// It performs no persistence (no MinIO upload, index insert, or pipeline_log) +// because the debug context carries no KB (KB.ID == ""), which is the single +// debug signal used across the ingestion pipeline: the tokenizer skips +// embedding and the executor skips the persist stage. kb_id == "" occurs ONLY +// in debug mode; production ingestion always supplies a KB, so this path is +// never taken in normal operation. Debug takes no kb_id at all: it never +// resolves an embedder or writes to a KB, and parse/chunk work without it. +// Callers extract the (fileName, fileData) pair from their own request shape +// and pass them in. +func (h *AgentHandler) runCanvasPipelineDebug(ctx context.Context, user *entity.User, + canvasID, fileName string, fileData []byte) (*task.PipelineResult, error) { + // messageID is the polling key the front-end reads from the run response + // (see web/src/pages/agent/hooks/use-run-dataflow.ts:40) to fetch the debug + // log. It is generated up-front so the response always carries a stable key. + messageID := uuid.New().String() + + // Force an empty KB: a debug context has no knowledgebase, so kb_id == "" + // holds and the debug signal is unambiguous. + taskCtx := task.NewDebugTaskContext(user.ID, canvasID, fileName, fileData) + + exec, err := h.newExecutor(taskCtx, canvasID, 0) + if err != nil { + return nil, err + } + + // Attach a sink that records component progress into the + // [{component_id, trace}] array the front-end polls for. + sink := task.NewDebugLogSink(canvasID, messageID, h.redisStore) + exec.WithProgressSink(sink) + + result, runErr := exec.Execute(ctx) + // Flush even on error so the front-end can poll the failure log; the END + // marker carries the error and keeps the run detectable as finished. + sink.Flush(ctx, runErr) + if result == nil { + // The executor may return (nil, err) on failure; synthesize a result so + // the polling key survives to the response and the front-end can still + // reach the failure log written just above. + result = &task.PipelineResult{} + } + result.MessageID = messageID + return result, runErr +} + +// respondWithDebugResult writes the outcome of a dataflow debug run. On a run +// failure it keeps a non-success code (errors must not be masked as success) +// but still carries message_id in the error envelope's data, because +// runCanvasPipelineDebug already flushed the failure log to Redis — the front-end +// needs that key to poll the failure timeline. On success it returns the +// message_id (the polling key) together with the produced chunks. Empty chunk +// slices become `[]` rather than `null` so the response shape stays stable. +// This is the single wire-shape helper for the chat/completions DataFlow debug +// entry point, and it matches the contract the front-end expects: +// `data.data.message_id` drives the log-poll loop and `data.data.chunks` +// renders the debug output. +func respondWithDebugResult(c *gin.Context, result *task.PipelineResult, err error) { + if err != nil { + messageID := "" + if result != nil { + messageID = result.MessageID + } + common.ResponseWithCodeData(c, common.CodeServerError, gin.H{"message_id": messageID}, err.Error()) + return + } + messageID := "" + var chunks []map[string]any + if result != nil { + messageID = result.MessageID + chunks = result.Chunks + } + if len(chunks) == 0 { + chunks = []map[string]any{} + } + common.SuccessWithData(c, gin.H{"message_id": messageID, "chunks": chunks}, "success") +} + +// extractChatDebugFile pulls the first uploaded file from a chat/completions +// request into (fileName, bytes) for a dataflow debug run. Unlike a debug +// multipart upload, chat/completions `files` are storage references +// (id/name/created_by); the raw bytes are fetched from the per-user downloads +// bucket via the file service. When no usable file is present it returns a +// default name and a nil slice so the pipeline can still run file-less. +func (h *AgentHandler) extractChatDebugFile(ctx context.Context, files [][]map[string]interface{}, user *entity.User) (string, []byte) { + if len(files) == 0 { + return "debug", nil + } + fileList := files[0] + if len(fileList) == 0 { + return "debug", nil + } + fd := fileList[0] + name, _ := fd["name"].(string) + id, _ := fd["id"].(string) + if id == "" { + if name == "" { + return "debug", nil + } + return name, nil + } + data, err := h.fileService.DownloadAgentFile(ctx, user.ID, id) + if err != nil || len(data) == 0 { + if name == "" { + return "debug", nil + } + return name, nil + } + if name == "" { + name = "debug" + } + return name, data +} + // sanitiseRunEventError passes through the error event payload // unchanged. The runner serialises canvas.ErrorEvent ({"message": ...}) // before push, so when the payload round-trips through JSON the @@ -889,7 +1063,16 @@ type agentChatCompletionsRequest struct { Model string `json:"model"` Messages []map[string]interface{} `json:"messages"` ReturnTrace bool `json:"return_trace"` - Files []map[string]interface{} `json:"files"` + // Files carries the uploaded file references for a run. The RAGFlow web + // front-end wraps the file list one extra level (a list of per-turn file + // lists), so the wire shape is `[[{id, name, ...}]]`. This mirrors the + // Python agent_api.py:1611 contract `queue_dataflow(..., files[0], 0)`, + // where `files[0]` (the first inner list) is the set of files for this + // run. The dataflow debug extractor and the agent run path both unwrap + // the outer layer: `Files[0]` reaches the file dicts. The web contract + // is 2D, so a plain 1D `[]map[string]interface{}` would fail to decode + // and 400 the whole request. + Files [][]map[string]interface{} `json:"files"` } // extractLastUserContent returns the content of the last message in @@ -1028,6 +1211,29 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) { return } + // DataFlow canvas: run a synchronous, side-effect-free debug (dry-run) + // and return the parsed chunks inline — reusing this existing + // chat/completions endpoint instead of a dedicated dataflow/debug route. + // This mirrors the Python agent_api.py:1569 DataFlow branch, which is + // reached only on the non-openai, non-session path (openai-compatible + // requests return their choices shape above and never enter debug). The + // The canvas is loaded only to read its category, and the debug path is + // skipped when the loader is absent or the canvas is not a DataFlow. No + // kb_id is involved: the debug context always runs KB-less (see + // runCanvasPipelineDebug). + if h.loader != nil { + if cv, lerr := h.loader.LoadCanvasByID(c.Request.Context(), user.ID, req.AgentID); lerr == nil && cv.CanvasCategory == "dataflow_canvas" { + fileName, fileData := h.extractChatDebugFile(c.Request.Context(), req.Files, user) + if len(fileData) == 0 { + common.ResponseWithCodeData(c, common.CodeDataError, nil, "dataflow debug requires an uploaded file") + return + } + result, derr := h.runCanvasPipelineDebug(c.Request.Context(), user, req.AgentID, fileName, fileData) + respondWithDebugResult(c, result, derr) + return + } + } + // Real canvas run — derive userInput from `query` first, then fall // back to the last user message (covers the front-end that posts // running_hint_text without a top-level `query`). @@ -1052,7 +1258,16 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) { req.SessionID = utility.GenerateToken() } - events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput, req.Files) + // The web contract wraps `files` one level ([[{...}]]); unwrap the + // outer layer so RunAgent receives the inner 1D file list, matching the + // Python canvas file component's `kwargs.get("file")[0]` unwrap. When no + // files are present (the common agent-chat case) pass nil so RunAgent's + // `len(files) > 0` guard sees an empty run. + var chatFiles []map[string]interface{} + if len(req.Files) > 0 { + chatFiles = req.Files[0] + } + events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput, chatFiles) if err != nil { common.Warn("agent chat completions: RunAgent failed", append([]zap.Field{ @@ -1314,12 +1529,35 @@ func (h *AgentHandler) TestDBConnection(c *gin.Context) { common.SuccessWithData(c, true, "success") } +// parseAgentLogs decodes the debug-log payload stored in Redis by the pipeline +// run. The Python pipeline (rag/flow/pipeline.py callback) writes a JSON +// *array* ([{component_id, trace:[...]}]) that the front-end's +// useFetchMessageTrace (web/src/hooks/use-agent-request.ts) requires as +// response.data. A missing, empty, or corrupt payload collapses to an empty +// object so the contract from get_agent_logs (api/apps/restful_apis/agent_api.py:1087) +// — `code == 0` and `data` is a (possibly empty) dict/array — stays satisfied +// instead of 500ing on bad bytes. +func parseAgentLogs(payload string) interface{} { + if strings.TrimSpace(payload) == "" { + return map[string]interface{}{} + } + var data interface{} + if err := json.Unmarshal([]byte(payload), &data); err != nil { + return map[string]interface{}{} + } + return data +} + // GetAgentLogs GET /api/v1/agents/:canvas_id/logs/:message_id // // Reads "{agent_id}-{message_id}-logs" from Redis (same key format -// used by the Python agent API in api/apps/restful_apis/agent_api.py -// line 920). Missing key returns an empty dict so the test contract -// `data is dict` and `code == 0` are both satisfied. +// used by the Python agent API in api/apps/restful_apis/agent_api.py) and +// returns it as-is. Because runCanvasPipelineDebug flushes the ENTIRE log (including +// the END marker) before returning the run response, this endpoint returns a +// completed snapshot on the first poll — it is a replay/fetch of an already +// finished debug log, not a streaming subscription. Missing key returns an +// empty dict so the test contract `data is dict` and `code == 0` are both +// satisfied when nothing has been written yet. func (h *AgentHandler) GetAgentLogs(c *gin.Context) { user, code, msg := GetUser(c) if code != common.CodeSuccess { @@ -1335,10 +1573,10 @@ func (h *AgentHandler) GetAgentLogs(c *gin.Context) { } key := fmt.Sprintf("%s-%s-logs", canvasID, messageID) - payload, rerr := redis.Get().Get(key) - data := map[string]interface{}{} - if rerr == nil && payload != "" { - _ = json.Unmarshal([]byte(payload), &data) + payload, rerr := h.redisGet(key) + var data interface{} = map[string]interface{}{} + if rerr == nil { + data = parseAgentLogs(payload) } common.SuccessWithData(c, data, "success") } diff --git a/internal/handler/agent_logs_test.go b/internal/handler/agent_logs_test.go new file mode 100644 index 0000000000..793b4fe9f0 --- /dev/null +++ b/internal/handler/agent_logs_test.go @@ -0,0 +1,675 @@ +// +// 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 handler + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + goredis "github.com/redis/go-redis/v9" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" + pipelinepkg "ragflow/internal/ingestion/pipeline" + "ragflow/internal/ingestion/task" + "ragflow/internal/service" +) + +// TestParseAgentLogs pins the contract between GetAgentLogs and the +// front-end DataFlow Log box. The Python pipeline (rag/flow/pipeline.py +// callback) stores a JSON *array* ([{component_id, trace:[...]}]); the +// front-end's useFetchMessageTrace (web/src/hooks/use-agent-request.ts) +// requires response.data to be that array. The original handler unmarshalled +// into a map[string]interface{} and silently dropped the array, leaving the +// Log box stuck on an empty SkeletonCard. A missing/empty/corrupt payload must +// still collapse to an empty object to mirror the Python get_agent_logs +// missing-key contract (api/apps/restful_apis/agent_api.py:1087). +func TestParseAgentLogs(t *testing.T) { + cases := []struct { + name string + payload string + wantKind byte // '[' for array, '{' for object + }{ + { + name: "missing_key_returns_empty_object", + payload: "", + wantKind: '{', + }, + { + name: "present_array_returns_array", + payload: `[` + + `{"component_id":"File","trace":[` + + `{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}` + + `]},` + + `{"component_id":"END","trace":[{"progress":1,"message":"done","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` + + `]`, + wantKind: '[', + }, + { + name: "corrupt_payload_falls_back_to_empty_object", + payload: `{"component_id":`, + wantKind: '{', + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseAgentLogs(tc.payload) + b, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if len(b) == 0 || b[0] != tc.wantKind { + t.Fatalf("want JSON kind %q, got %s", tc.wantKind, string(b)) + } + if tc.wantKind == '[' { + var arr []map[string]interface{} + if err := json.Unmarshal(b, &arr); err != nil { + t.Fatalf("round-trip: %v", err) + } + if arr[0]["component_id"] != "File" { + t.Fatalf("component_id lost after round-trip: %v", arr[0]) + } + if arr[len(arr)-1]["component_id"] != "END" { + t.Fatalf("END marker lost after round-trip: %v", arr[len(arr)-1]) + } + } + }) + } +} + +// TestGetAgentLogs_E2EViaMiniredis exercises the full HTTP handler path +// (auth -> canvas-access -> Redis fetch -> shape) against an in-memory +// miniredis so we can assert the wire response shape the front-end depends on: +// - present key -> response.data is the JSON array ([...]) with the END marker +// - missing key -> response.data is an empty object ("{}"), matching the +// Python get_agent_logs missing-key contract. +// +// The Redis client is injected via WithRedisGetter (a miniredis-backed +// go-redis client); the canvas-access gate is satisfied by a real in-memory +// UserCanvas row, mirroring TestGetAgentWebhookLogsReturnsEmptyPoll. +func TestGetAgentLogs_E2EViaMiniredis(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupHandlerAgentsTestDB(t) + orig := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = orig }) + + db.Create(&entity.UserCanvas{ID: "c1", UserID: "u1", Title: sptr("Test")}) + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis.Run: %v", err) + } + t.Cleanup(mr.Close) + + rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + logKey := "c1-msg1-logs" + arrayPayload := `[` + + `{"component_id":"File","trace":[{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}]},` + + `{"component_id":"END","trace":[{"progress":1,"message":"done","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` + + `]` + if err := rdb.Set(context.Background(), logKey, arrayPayload, 0).Err(); err != nil { + t.Fatalf("seed redis: %v", err) + } + + h := NewAgentHandler(service.NewAgentService(), nil). + WithRedisGetter(func(key string) (string, error) { + return rdb.Get(context.Background(), key).Result() + }) + + run := func(messageID string) map[string]interface{} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/agents/c1/logs/"+messageID, nil) + c.Set("user", &entity.User{ID: "u1"}) + c.Set("user_id", "u1") + c.Params = gin.Params{ + {Key: "canvas_id", Value: "c1"}, + {Key: "message_id", Value: messageID}, + } + h.GetAgentLogs(c) + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode (msg=%s): %v body=%s", messageID, err, w.Body.String()) + } + return resp + } + + // Present key -> data must be the array, with File first and END last. + present := run("msg1") + if code, _ := present["code"].(float64); int(code) != int(common.CodeSuccess) { + t.Fatalf("present code=%v want 0 body=%s", code, mustJSON(present)) + } + dataRaw, _ := json.Marshal(present["data"]) + trimmed := strings.TrimSpace(string(dataRaw)) + if len(trimmed) == 0 || trimmed[0] != '[' { + t.Fatalf("present response.data must be a JSON array, got %s", string(dataRaw)) + } + var arr []map[string]interface{} + if err := json.Unmarshal(dataRaw, &arr); err != nil { + t.Fatalf("present data not array: %v body=%s", err, string(dataRaw)) + } + if arr[0]["component_id"] != "File" { + t.Errorf("first component_id=%v want File", arr[0]["component_id"]) + } + if arr[len(arr)-1]["component_id"] != "END" { + t.Errorf("last component_id=%v want END", arr[len(arr)-1]["component_id"]) + } + + // Missing key -> data must be an empty object (Python parity), code 0. + missing := run("missing") + if code, _ := missing["code"].(float64); int(code) != int(common.CodeSuccess) { + t.Fatalf("missing code=%v want 0 body=%s", code, mustJSON(missing)) + } + dataRawMissing, _ := json.Marshal(missing["data"]) + if strings.TrimSpace(string(dataRawMissing)) != "{}" { + t.Fatalf("missing response.data must be {} (object), got %s", string(dataRawMissing)) + } +} + +// clientConsidersComplete replicates the front-end completion predicate from +// web/src/pages/agent/hooks/use-fetch-pipeline-log.ts: the run is "really +// done" only when the LAST array element has component_id == "END" AND its +// first trace entry carries a non-empty message. If any part of that signal +// is missing, the Log box keeps polling forever. +func clientConsidersComplete(arr []map[string]interface{}) bool { + if len(arr) == 0 { + return false + } + last := arr[len(arr)-1] + if last["component_id"] != "END" { + return false + } + trace, ok := last["trace"].([]interface{}) + if !ok || len(trace) == 0 { + return false + } + first, ok := trace[0].(map[string]interface{}) + if !ok { + return false + } + msg, _ := first["message"].(string) + return strings.TrimSpace(msg) != "" +} + +// TestGetAgentLogs_EndSignalCompletion pins the exact signal the front-end +// polls for: the response array must end with an END element whose first +// trace message is non-empty. The handler must preserve that signal +// byte-for-byte through JSON round-tripping. +func TestGetAgentLogs_EndSignalCompletion(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupHandlerAgentsTestDB(t) + orig := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = orig }) + + db.Create(&entity.UserCanvas{ID: "c1", UserID: "u1", Title: sptr("Test")}) + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis.Run: %v", err) + } + t.Cleanup(mr.Close) + + rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + // End element with a NON-empty message -> client must consider it done. + goodPayload := `[` + + `{"component_id":"File","trace":[{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}]},` + + `{"component_id":"END","trace":[{"progress":1,"message":"run finished","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` + + `]` + if err := rdb.Set(context.Background(), "c1-msg-good-logs", goodPayload, 0).Err(); err != nil { + t.Fatalf("seed redis: %v", err) + } + + // End element with an EMPTY message -> client must NOT consider it done + // (would poll forever). Locks the contract that the debug writer must + // emit a non-empty END message. + badPayload := `[` + + `{"component_id":"File","trace":[{"progress":1,"message":"parsed","datetime":"10:00:00","timestamp":1.0,"elapsed_time":0}]},` + + `{"component_id":"END","trace":[{"progress":1,"message":"","datetime":"10:00:01","timestamp":2.0,"elapsed_time":1.0}]}` + + `]` + if err := rdb.Set(context.Background(), "c1-msg-bad-logs", badPayload, 0).Err(); err != nil { + t.Fatalf("seed redis: %v", err) + } + + h := NewAgentHandler(service.NewAgentService(), nil). + WithRedisGetter(func(key string) (string, error) { + return rdb.Get(context.Background(), key).Result() + }) + + call := func(messageID string) []map[string]interface{} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/agents/c1/logs/"+messageID, nil) + c.Set("user", &entity.User{ID: "u1"}) + c.Set("user_id", "u1") + c.Params = gin.Params{ + {Key: "canvas_id", Value: "c1"}, + {Key: "message_id", Value: messageID}, + } + h.GetAgentLogs(c) + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode (msg=%s): %v body=%s", messageID, err, w.Body.String()) + } + if code, _ := resp["code"].(float64); int(code) != int(common.CodeSuccess) { + t.Fatalf("code=%v want 0 body=%s", code, mustJSON(resp)) + } + dataRaw, _ := json.Marshal(resp["data"]) + var arr []map[string]interface{} + if err := json.Unmarshal(dataRaw, &arr); err != nil { + t.Fatalf("data not array (msg=%s): %v body=%s", messageID, err, string(dataRaw)) + } + return arr + } + + good := call("msg-good") + if !clientConsidersComplete(good) { + t.Fatalf("expected client to consider run complete for non-empty END message; arr=%s", mustJSON(good)) + } + + bad := call("msg-bad") + if clientConsidersComplete(bad) { + t.Fatalf("client must NOT consider run complete for empty END message (would poll forever); arr=%s", mustJSON(bad)) + } +} + +// capturedStore is an in-memory task.DebugLogStore used to assert that +// runCanvasPipelineDebug actually wrote the debug log array under the expected key. +type capturedStore struct { + mu sync.Mutex + data map[string]string +} + +func (s *capturedStore) Set(key, value string, _ time.Duration) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.data == nil { + s.data = map[string]string{} + } + s.data[key] = value + return true +} + +func (s *capturedStore) get(key string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.data[key] + return v, ok +} + +// fakeDebugExecutor is a debugExecutor stand-in for runCanvasPipelineDebug. It +// captures the sink attached via WithProgressSink and replays a couple of +// component lifecycle events on Execute, mirroring what the real +// PipelineExecutor emits through its progress callback. +type fakeDebugExecutor struct { + capturedSink pipelinepkg.ProgressSink + result *task.PipelineResult + runErr error +} + +func (f *fakeDebugExecutor) WithProgressSink(sink pipelinepkg.ProgressSink) *task.PipelineExecutor { + f.capturedSink = sink + return nil // return ignored by runCanvasPipelineDebug +} + +func (f *fakeDebugExecutor) Execute(ctx context.Context) (*task.PipelineResult, error) { + if f.capturedSink != nil { + f.capturedSink.OnComponentTotal(ctx, "t1", 1) + f.capturedSink.OnComponentProgress(ctx, pipelinepkg.ProgressEvent{ + TaskID: "t1", Component: "File", Phase: 0, Message: "File Started", + }) + f.capturedSink.OnComponentProgress(ctx, pipelinepkg.ProgressEvent{ + TaskID: "t1", Component: "File", Phase: 1, Message: "File Done", + }) + } + return f.result, f.runErr +} + +// TestRespondWithDebugResult_Shape pins the wire contract the front-end reads +// after kicking off a debug run: response.data must carry both message_id (the +// polling key) and chunks (the rendered output). Empty chunks must stay an +// empty array (never null), and a run error must surface as a server-error +// code rather than success. +func TestRespondWithDebugResult_Shape(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Success with chunks: data.message_id + data.chunks both present. + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + respondWithDebugResult(c, &task.PipelineResult{ + MessageID: "m-abc", + Chunks: []map[string]any{{"content": "hello"}}, + }, nil) + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + if code, _ := resp["code"].(float64); int(code) != int(common.CodeSuccess) { + t.Fatalf("code=%v want 0 body=%s", code, mustJSON(resp)) + } + data, ok := resp["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data not object: %v", resp) + } + if data["message_id"] != "m-abc" { + t.Fatalf("data.message_id=%v want m-abc", data["message_id"]) + } + chunks, ok := data["chunks"].([]interface{}) + if !ok { + t.Fatalf("data.chunks not array: %v", data["chunks"]) + } + if len(chunks) != 1 { + t.Fatalf("data.chunks len=%d want 1", len(chunks)) + } + + // Empty chunks: still an object with message_id and an empty array. + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + respondWithDebugResult(c2, &task.PipelineResult{MessageID: "m-empty"}, nil) + var resp2 map[string]interface{} + if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("decode empty: %v body=%s", err, w2.Body.String()) + } + data2, ok := resp2["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data not object: %v", resp2) + } + if data2["message_id"] != "m-empty" { + t.Fatalf("data.message_id=%v want m-empty", data2["message_id"]) + } + chunks2, ok := data2["chunks"].([]interface{}) + if !ok { + t.Fatalf("data.chunks not array: %v", data2["chunks"]) + } + if len(chunks2) != 0 { + t.Fatalf("data.chunks len=%d want 0", len(chunks2)) + } + + // Error: must NOT be a success code. + w3 := httptest.NewRecorder() + c3, _ := gin.CreateTestContext(w3) + respondWithDebugResult(c3, nil, errors.New("boom")) + var resp3 map[string]interface{} + if err := json.Unmarshal(w3.Body.Bytes(), &resp3); err != nil { + t.Fatalf("decode err: %v body=%s", err, w3.Body.String()) + } + if code, _ := resp3["code"].(float64); int(code) == int(common.CodeSuccess) { + t.Fatalf("error case must not be success: %v", resp3) + } +} + +// TestRespondWithDebugResult_ErrorCarriesMessageID pins that a run failure +// still surfaces the polling key: the response must keep a non-success code +// (the existing contract forbids masking errors as success) BUT the error +// envelope's data must carry message_id so the front-end can poll the failure +// timeline that DebugLogSink.Flush already wrote to Redis. +func TestRespondWithDebugResult_ErrorCarriesMessageID(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + respondWithDebugResult(c, &task.PipelineResult{MessageID: "m-err"}, errors.New("boom")) + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + if code, _ := resp["code"].(float64); int(code) == int(common.CodeSuccess) { + t.Fatalf("error case must not be success: %v", resp) + } + data, ok := resp["data"].(map[string]interface{}) + if !ok { + t.Fatalf("error envelope data must be an object carrying message_id: %v", resp) + } + if data["message_id"] != "m-err" { + t.Fatalf("error envelope data.message_id=%v want m-err", data["message_id"]) + } +} + +// TestRunCanvasPipelineDebug_ErrorStillExposesMessageID asserts the full write-side +// wiring when the executor fails: runCanvasPipelineDebug must (a) still return a +// non-nil result carrying MessageID even though exec.Execute returned nil, and +// (b) have already flushed the failure log under "{canvasID}-{messageID}-logs" +// so the front-end can poll that exact key. This is the scenario that broke +// before — the failure log was written but unreachable because message_id was +// dropped on the error path. +func TestRunCanvasPipelineDebug_ErrorStillExposesMessageID(t *testing.T) { + ctx := context.Background() + store := &capturedStore{} + + h := NewAgentHandler(service.NewAgentService(), nil). + WithRedisStore(store). + WithNewExecutor(func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) { + return &fakeDebugExecutor{ + // Simulate an executor that fails and returns no result. + result: nil, + runErr: errors.New("executor blew up"), + }, nil + }) + + result, err := h.runCanvasPipelineDebug(ctx, &entity.User{ID: "u1"}, "c1", "f.txt", []byte("data")) + if err == nil { + t.Fatalf("expected run error") + } + if result == nil { + t.Fatalf("result is nil; front-end would have no polling key on failure") + } + if result.MessageID == "" { + t.Fatalf("result.MessageID empty on failure; front-end cannot poll the failure log") + } + + // The failure log must be written under the composed key. + key := "c1-" + result.MessageID + "-logs" + raw, ok := store.get(key) + if !ok { + t.Fatalf("failure log not written under key %q; store keys=%v", key, keysOf(store)) + } + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + t.Fatalf("stored failure log not array: %v raw=%s", err, raw) + } + if !clientConsidersComplete(arr) { + t.Fatalf("stored failure log not completion-complete (END last + non-empty msg); arr=%s", raw) + } +} + +// TestRunCanvasPipelineDebug_WiresMessageIDAndLog asserts the full write-side wiring +// of runCanvasPipelineDebug without a live Redis or real canvas: a fake executor +// emits component progress, the injected DebugLogSink flushes the +// [{component_id, trace}] array (with the END marker last) to the captured +// store under the key "{canvasID}-{messageID}-logs", and the returned result +// carries message_id so the front-end can poll that exact key. The array must +// satisfy the completion predicate (END last, non-empty END message) so the +// Log box stops polling. +func TestRunCanvasPipelineDebug_WiresMessageIDAndLog(t *testing.T) { + ctx := context.Background() + store := &capturedStore{} + + h := NewAgentHandler(service.NewAgentService(), nil). + WithRedisStore(store). + WithNewExecutor(func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) { + return &fakeDebugExecutor{ + result: &task.PipelineResult{ + Chunks: []map[string]any{{"content": "chunk-1"}}, + }, + }, nil + }) + + result, err := h.runCanvasPipelineDebug(ctx, &entity.User{ID: "u1"}, "c1", "f.txt", []byte("data")) + if err != nil { + t.Fatalf("runCanvasPipelineDebug: %v", err) + } + if result == nil { + t.Fatalf("result is nil") + } + if result.MessageID == "" { + t.Fatalf("result.MessageID empty; front-end would have no polling key") + } + + // The log array must be written under the composed key. + key := "c1-" + result.MessageID + "-logs" + raw, ok := store.get(key) + if !ok { + t.Fatalf("log not written under key %q; store keys=%v", key, keysOf(store)) + } + + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + t.Fatalf("stored log not array: %v raw=%s", err, raw) + } + if !clientConsidersComplete(arr) { + t.Fatalf("stored log not completion-complete (END last + non-empty msg); arr=%s", raw) + } +} + +func keysOf(s *capturedStore) []string { + s.mu.Lock() + defer s.mu.Unlock() + keys := make([]string, 0, len(s.data)) + for k := range s.data { + keys = append(keys, k) + } + return keys +} + +// miniredisDebugStore adapts a go-redis client to task.DebugLogStore so the +// write path can be exercised against a real (in-memory) Redis in tests. It is +// the production-faithful counterpart of capturedStore: capturedStore proves +// the key shape, miniredisDebugStore proves the bytes survive a real Redis +// round-trip that the read path consumes. +type miniredisDebugStore struct { + rdb *goredis.Client +} + +func (s miniredisDebugStore) Set(key, value string, ttl time.Duration) bool { + if err := s.rdb.Set(context.Background(), key, value, ttl).Err(); err != nil { + return false + } + return true +} + +// TestRunCanvasPipelineDebug_WriteThenReadViaMiniredis locks the write/read contract +// end-to-end: runCanvasPipelineDebug writes the debug log array under +// "{canvasID}-{messageID}-logs" into a real (miniredis) Redis via the injected +// DebugLogStore, and the SAME handler's GetAgentLogs reads it back through the +// injected redisGetter — proving the writer's key shape exactly matches the +// reader's and that the stored bytes round-trip into the [{component_id, +// trace}] array the front-end polls for (File first, END last, non-empty END +// message). This is the only test that would catch a key-format drift between +// the two sides, since the write test uses a capturedStore and the read test +// seeds Redis directly rather than going through the writer. +func TestRunCanvasPipelineDebug_WriteThenReadViaMiniredis(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + + db := setupHandlerAgentsTestDB(t) + orig := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = orig }) + db.Create(&entity.UserCanvas{ID: "c1", UserID: "u1", Title: sptr("Test")}) + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis.Run: %v", err) + } + t.Cleanup(mr.Close) + rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + // Both seams point at the same miniredis: the writer stores via + // WithRedisStore and the reader fetches via WithRedisGetter, mirroring + // production where both hit one Redis. + h := NewAgentHandler(service.NewAgentService(), nil). + WithRedisStore(miniredisDebugStore{rdb: rdb}). + WithRedisGetter(func(key string) (string, error) { + return rdb.Get(ctx, key).Result() + }). + WithNewExecutor(func(taskCtx *task.TaskContext, canvasID string, docBulkSize int) (debugExecutor, error) { + return &fakeDebugExecutor{ + result: &task.PipelineResult{Chunks: []map[string]any{{"content": "chunk-1"}}}, + }, nil + }) + + // Write side. + writeResult, werr := h.runCanvasPipelineDebug(ctx, &entity.User{ID: "u1"}, "c1", "f.txt", []byte("data")) + if werr != nil { + t.Fatalf("runCanvasPipelineDebug: %v", werr) + } + if writeResult == nil || writeResult.MessageID == "" { + t.Fatalf("runCanvasPipelineDebug returned no message_id") + } + messageID := writeResult.MessageID + + // The key must exist in the same miniredis the reader will query. + if !mr.Exists("c1-" + messageID + "-logs") { + t.Fatalf("log key c1-%s-logs not present in redis after write", messageID) + } + + // Read side: same handler, same miniredis, driven through the real + // GetAgentLogs HTTP path. + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/agents/c1/logs/"+messageID, nil) + c.Set("user", &entity.User{ID: "u1"}) + c.Set("user_id", "u1") + c.Params = gin.Params{ + {Key: "canvas_id", Value: "c1"}, + {Key: "message_id", Value: messageID}, + } + h.GetAgentLogs(c) + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode read response: %v body=%s", err, w.Body.String()) + } + if code, _ := resp["code"].(float64); int(code) != int(common.CodeSuccess) { + t.Fatalf("read code=%v want 0 body=%s", code, mustJSON(resp)) + } + dataRaw, _ := json.Marshal(resp["data"]) + trimmed := strings.TrimSpace(string(dataRaw)) + if len(trimmed) == 0 || trimmed[0] != '[' { + t.Fatalf("read response.data must be a JSON array, got %s", string(dataRaw)) + } + var arr []map[string]interface{} + if err := json.Unmarshal(dataRaw, &arr); err != nil { + t.Fatalf("read data not array: %v body=%s", err, string(dataRaw)) + } + if arr[0]["component_id"] != "File" { + t.Errorf("first component_id=%v want File", arr[0]["component_id"]) + } + if arr[len(arr)-1]["component_id"] != "END" { + t.Errorf("last component_id=%v want END", arr[len(arr)-1]["component_id"]) + } + if !clientConsidersComplete(arr) { + t.Errorf("stored log not completion-complete (END last + non-empty msg); arr=%s", string(dataRaw)) + } +} diff --git a/internal/handler/agent_pipeline_debug_test.go b/internal/handler/agent_pipeline_debug_test.go new file mode 100644 index 0000000000..21d961b0a2 --- /dev/null +++ b/internal/handler/agent_pipeline_debug_test.go @@ -0,0 +1,92 @@ +// +// 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 handler + +import ( + "encoding/json" + "net/http" + "testing" + + "ragflow/internal/common" + "ragflow/internal/entity" + "ragflow/internal/ingestion/task" +) + +func dataflowCanvas(id, userID string) *entity.UserCanvas { + cv := makeWebhookCanvas(id, userID, "Webhook", nil) + cv.CanvasCategory = "dataflow_canvas" + return cv +} + +func decodeSuccess(t *testing.T, body []byte) (int, string, []map[string]any, string) { + t.Helper() + var resp struct { + Code int `json:"code"` + Data struct { + MessageID string `json:"message_id"` + Chunks []map[string]any `json:"chunks"` + } `json:"data"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode response: %v (body=%s)", err, body) + } + return resp.Code, resp.Data.MessageID, resp.Data.Chunks, resp.Message +} + +// TestAgentChatCompletions_DataFlowReturnsChunks pins that the existing +// chat/completions endpoint serves the pipeline debug (dry-run) for DataFlow +// canvases: it returns the parsed chunks inline, synchronously, without a +// dedicated debug route. The webhook entry point is intentionally NOT a +// debug surface — see TestWebhook_DataFlowRejected in agent_webhook_test.go. +func TestAgentChatCompletions_DataFlowReturnsChunks(t *testing.T) { + h := &AgentHandler{ + loader: &fakeCanvasLoader{canvas: dataflowCanvas("c1", "u-1")}, + fileService: &fakeAgentFileService{blob: []byte("file-bytes")}, + } + h.WithNewExecutor(func(_ *task.TaskContext, _ string, _ int) (debugExecutor, error) { + return &fakeDebugExecutor{result: &task.PipelineResult{Chunks: []map[string]any{{"text": "dbg-chunk"}}}}, nil + }) + // runCanvasPipelineDebug flushes the debug log via redisStore; inject a no-op + // store so the (skipped in this test) log write does not nil-panic. + h.WithRedisStore(&capturedStore{}) + + // The web contract sends the file list wrapped one level: + // `files: [[{id, name}]]` (see use-run-dataflow.ts:34). The pipeline + // debug path unwraps both layers and downloads the referenced bytes. + c, w := webhookCtx("POST", "/api/v1/agents/c1/chat/completions", + `{"agent_id":"c1","files":[[{"id":"f1","name":"doc.txt"}]]}`, "application/json") + + h.AgentChatCompletions(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String()) + } + code, msgID, data, msg := decodeSuccess(t, w.Body.Bytes()) + if code != int(common.CodeSuccess) { + t.Errorf("code = %d, want %d", code, common.CodeSuccess) + } + if msg != "success" { + t.Errorf("message = %q, want %q", msg, "success") + } + if msgID == "" { + t.Errorf("message_id empty; front-end needs it to poll the debug log") + } + if len(data) != 1 || data[0]["text"] != "dbg-chunk" { + t.Errorf("data.chunks = %#v, want one chunk with text %q", data, "dbg-chunk") + } +} diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index bfd2d4301c..8e7501fdb1 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -1228,9 +1228,11 @@ func (s *stubDocService) Accessible(_, _ string) bool { } // TestAgentChatCompletions_FilesDeserialized verifies that when the -// JSON request body contains a `files` field, the -// agentChatCompletionsRequest struct deserializes it correctly. -// Mirrors Python's req.get("files", []) at agent_api.py:1313. +// JSON request body contains the web-contract 2D `files` field +// (`[[{...}]]`), the agentChatCompletionsRequest struct deserializes it +// and the inner file list reaches RunAgent. Mirrors Python's +// agent_api.py:1611 `queue_dataflow(..., files[0], 0)`, where the first +// inner list is the set of files for the run. func TestAgentChatCompletions_FilesDeserialized(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -1238,9 +1240,9 @@ func TestAgentChatCompletions_FilesDeserialized(t *testing.T) { body := `{ "agent_id": "a1", "query": "hi", - "files": [ + "files": [[ {"id": "file-1", "name": "resume.txt", "mime_type": "text/plain", "created_by": "u1"} - ] + ]] }` c.Request = httptest.NewRequest("POST", "/api/v1/agents/chat/completions", strings.NewReader(body)) diff --git a/internal/handler/agent_webhook.go b/internal/handler/agent_webhook.go index 2173533300..ef2ded7725 100644 --- a/internal/handler/agent_webhook.go +++ b/internal/handler/agent_webhook.go @@ -133,8 +133,11 @@ func (h *AgentHandler) Webhook(c *gin.Context) { return } - // 2. Reject DataFlow. - if cv.CanvasCategory == "DataFlow" { + // 2. Reject DataFlow. DataFlow canvases are ingestion pipelines, not + // interactive agents, and must not be triggered by an external webhook. + // Mirrors Python agent_api.py:1786, which returns + // "Dataflow can not be triggered by webhook." for the same case. + if cv.CanvasCategory == "dataflow_canvas" { common.ResponseWithCodeData(c, common.CodeDataError, nil, "Dataflow can not be triggered by webhook.") return } diff --git a/internal/handler/agent_webhook_test.go b/internal/handler/agent_webhook_test.go index ff4a768d4d..3f08cad01e 100644 --- a/internal/handler/agent_webhook_test.go +++ b/internal/handler/agent_webhook_test.go @@ -153,12 +153,14 @@ func TestWebhook_RejectsUnknownCanvas(t *testing.T) { } } -// TestWebhook_RejectsDataFlowCanvas covers the second guard: the canvas -// exists but its category is DataFlow (which python -// agent_api.py:1575 explicitly rejects). -func TestWebhook_RejectsDataFlowCanvas(t *testing.T) { +// TestWebhook_DataFlowRejected pins the second guard: a canvas whose category +// is DataFlow must NOT be triggered by an external webhook. The handler returns +// CodeDataError with "Dataflow can not be triggered by webhook." — mirroring +// Python agent_api.py:1786. DataFlow canvases are ingestion pipelines, not +// interactive agents, and have no chat/debug surface over the webhook entry. +func TestWebhook_DataFlowRejected(t *testing.T) { cv := makeWebhookCanvas("c1", "u-1", "Webhook", nil) - cv.CanvasCategory = "DataFlow" + cv.CanvasCategory = "dataflow_canvas" h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}} c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json") @@ -166,7 +168,7 @@ func TestWebhook_RejectsDataFlowCanvas(t *testing.T) { code, msg := errBody(t, w.Body.Bytes()) if code != int(common.CodeDataError) { - t.Errorf("code = %d, want %d", code, common.CodeDataError) + t.Errorf("code = %d, want %d (CodeDataError)", code, common.CodeDataError) } if msg != "Dataflow can not be triggered by webhook." { t.Errorf("message = %q, want %q", msg, "Dataflow can not be triggered by webhook.") diff --git a/internal/ingestion/component/chunker/image_upload_test.go b/internal/ingestion/component/chunker/image_upload_test.go index 2a11e05e65..261b105bb1 100644 --- a/internal/ingestion/component/chunker/image_upload_test.go +++ b/internal/ingestion/component/chunker/image_upload_test.go @@ -276,3 +276,48 @@ func waitForInflight(t *testing.T, counter *int64, target int64) { } } } + +// TestImageUploadDecorator_DebugSkipsUpload verifies that a debug/dry-run run +// (empty kb_id) does NOT upload the chunk image to storage, while still +// dropping the raw image bytes. This keeps the debug path free of MinIO side +// effects and preserves the current memory behaviour (raw bytes discarded, not +// held until a persist stage). An empty kb_id only occurs in canvas debug +// (dry-run) mode; production ingestion always supplies a KB. +func TestImageUploadDecorator_DebugSkipsUpload(t *testing.T) { + prev := ChunkImageUploader + ChunkImageUploader = func(_ context.Context, _, _ string, _ []byte) (string, error) { + t.Fatalf("ChunkImageUploader must not be called in a debug run") + return "", nil + } + t.Cleanup(func() { ChunkImageUploader = prev }) + + comp, err := NewOneChunker(nil) + if err != nil { + t.Fatalf("NewOneChunker: %v", err) + } + decorated := &imageUploadDecorator{inner: comp} + + inputs := map[string]any{ + "name": "doc.pdf", + "kb_id": "", // debug mode: no KB -> no upload + "doc_id": testDocID, + "chunks": []map[string]any{ + {"content_with_weight": "a cropped figure", "image": "data:image/png;base64," + pngBase64}, + }, + } + out, err := decorated.Invoke(context.Background(), nil, inputs) + if err != nil { + t.Fatalf("decorated Invoke: %v", err) + } + chunks, ok := out["chunks"].([]map[string]any) + if !ok || len(chunks) == 0 { + t.Fatalf("expected chunks in output, got %#v", out) + } + ck := chunks[0] + if _, stillHas := ck["image"]; stillHas { + t.Errorf("raw image should be dropped in debug run, but ck[\"image\"] is still present") + } + if id, _ := ck["img_id"].(string); id != "" { + t.Errorf("img_id should not be set in debug run, got %q", id) + } +} diff --git a/internal/ingestion/component/chunker/pdfcrop_nocgo.go b/internal/ingestion/component/chunker/pdfcrop_nocgo.go index 179e199c33..1034dc13e1 100644 --- a/internal/ingestion/component/chunker/pdfcrop_nocgo.go +++ b/internal/ingestion/component/chunker/pdfcrop_nocgo.go @@ -12,6 +12,8 @@ import ( deepdoctype "ragflow/internal/deepdoc/parser/type" "ragflow/internal/ingestion/component/schema" + + "gorm.io/gorm" ) func newPDFEngineFromUpstream(_ context.Context, _ *gorm.DB, _ schema.ChunkerFromUpstream) (deepdoctype.PDFEngine, error) { diff --git a/internal/ingestion/component/chunker/register.go b/internal/ingestion/component/chunker/register.go index 6b716fa76c..3c28a24223 100644 --- a/internal/ingestion/component/chunker/register.go +++ b/internal/ingestion/component/chunker/register.go @@ -82,8 +82,19 @@ func (d *imageUploadDecorator) Invoke(ctx context.Context, db *gorm.DB, inputs m ck["id"] = common.ChunkID(docID, text) } - if err := uploadChunkImages(ctx, chunks, ChunkImageUploader, kbID); err != nil { - return nil, err + // kb_id is empty only in canvas debug (dry-run) mode; production ingestion + // always supplies a KB, so kb_id == "" never occurs in normal operation. + // Image bytes are uploaded to MinIO only when a KB is present (i.e. a + // persist run); in debug we drop the raw bytes so they are not held in + // memory until a persist stage that will never run. + if kbID != "" { + if err := uploadChunkImages(ctx, chunks, ChunkImageUploader, kbID); err != nil { + return nil, err + } + } else { + for _, ck := range chunks { + delete(ck, "image") + } } return out, nil } diff --git a/internal/ingestion/component/file.go b/internal/ingestion/component/file.go index bd037079cb..f6a803a3fc 100644 --- a/internal/ingestion/component/file.go +++ b/internal/ingestion/component/file.go @@ -151,6 +151,14 @@ func (c *FileComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map[stri if in.fileDesc != nil { out["file"] = in.fileDesc } + // Pass through in-memory bytes when present (debug / dataflow dry-run + // where no doc row exists in storage). The downstream Parser reads + // `binary` first and skips the doc_id → storage lookup, so a debug run + // can parse the uploaded file without a persisted document. Persist + // runs set no `binary` here, so they keep resolving from storage. + if len(in.binary) > 0 { + out["binary"] = in.binary + } // Publish the resolved run-level metadata into the workflow-wide // CanvasState.Globals bag so downstream components (Tokenizer, // Chunker, ...) read it from ctx instead of relying on this output @@ -169,6 +177,7 @@ type fileInputs struct { bucket string path string fileDesc map[string]any + binary []byte } // parseFileInputs parses and validates the upstream input map. @@ -180,6 +189,19 @@ func parseFileInputs(ctx context.Context, inputs map[string]any) (fileInputs, er } out := fileInputs{} + // Debug / dataflow dry-run fast path: in-memory bytes were supplied + // via the graph input `binary` (no persisted document exists). Use + // them directly and skip the doc_id → storage resolution so a debug + // run parses the uploaded file without a DB/storage round-trip. The + // executor only sets `binary` for the non-persist (debug) marker doc. + if b, ok := inputs["binary"].([]byte); ok && len(b) > 0 { + out.binary = b + if n, ok := getString(inputs, "name"); ok && n != "" { + out.name = n + } + return out, nil + } + if v, ok := getString(inputs, "doc_id"); ok && v != "" { out.docID = v out.name = v diff --git a/internal/ingestion/component/parser.go b/internal/ingestion/component/parser.go index dacfc6713d..9fef42ab57 100644 --- a/internal/ingestion/component/parser.go +++ b/internal/ingestion/component/parser.go @@ -535,6 +535,12 @@ func (c *ParserComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map[st } } + // 3b. (pages-based page selection is handled upstream via the + // component setup: ParserConfig[cpnID][filetype]["pages"] is a + // list of page ranges delivered through override_params and + // consumed by the deepdoc/pdf parser. No inputs-level handling + // here.) + // 4. Build the page slice sequentially. Per-page parallelism now // lives in the parser backends (e.g. internal/deepdoc/parser/pdf // fans out one worker per page and assembles in page order), so diff --git a/internal/ingestion/component/parser_dispatch.go b/internal/ingestion/component/parser_dispatch.go index 8eea5e131c..05b9f5b042 100644 --- a/internal/ingestion/component/parser_dispatch.go +++ b/internal/ingestion/component/parser_dispatch.go @@ -320,6 +320,16 @@ func pythonFamilyName(raw string) string { return "" } +// ParserFileFamily normalises a free-form file-type/extension hint to the +// python-side family identifier used as the key into a Parser component's +// setups (e.g. "pdf", "docx", "slides", "text&code"). It is the exported +// entry point for callers outside this package (e.g. the ingestion task +// executor that injects the debug page cap into override_params) that need +// to build the canonical ParserConfig[cpnID][family]["pages"] shape. +func ParserFileFamily(ext string) string { + return pythonFamilyName(ext) +} + // jsonItemsToPages reshapes a parsed JSON payload into the // schema.Page layout the chunker side consumes. Each JSON item // becomes one page carrying `text`, `doc_type_kwd`, and any diff --git a/internal/ingestion/component/tokenizer.go b/internal/ingestion/component/tokenizer.go index 8e8318b796..007d4a3d52 100644 --- a/internal/ingestion/component/tokenizer.go +++ b/internal/ingestion/component/tokenizer.go @@ -360,7 +360,12 @@ func (c *TokenizerComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map "chunks": schema.ChunkDocsToMaps(chunks), } - if contains(c.param.SearchMethod, "embedding") { + // Embedding requires a KB: the embedder (and its embd_id) is configured + // on the knowledgebase, so without kb_id there is nothing to resolve + // against. A canvas-debug (dry-run) run has kb_id == "" by construction, + // so embedding is skipped there — debug only exercises parse+chunk and + // must stay side-effect free. + if shouldHaveEmbedding(c.param.SearchMethod, kbID) { chunks, tokenCount, err := c.embedChunks(ctx, tenantID, kbID, embeddingModel, name, chunks) if err != nil { return nil, err @@ -368,7 +373,7 @@ func (c *TokenizerComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map out["embedding_token_consumption"] = tokenCount out["chunks"] = schema.ChunkDocsToMaps(chunks) } - if err := validateTokenizerOutputs(chunks, c.param.SearchMethod, c.param.Fields); err != nil { + if err := validateTokenizerOutputs(chunks, c.param.SearchMethod, c.param.Fields, kbID); err != nil { return nil, err } @@ -776,9 +781,15 @@ func concatFields(ck schema.ChunkDoc, fields []string) string { return b.String() } -func validateTokenizerOutputs(chunks []schema.ChunkDoc, searchMethods, fields []string) error { +// shouldHaveEmbedding reports whether the tokenizer must attach embedding +// vectors: the search method requests embedding AND a KB is present. +func shouldHaveEmbedding(searchMethods []string, kbID string) bool { + return contains(searchMethods, "embedding") && kbID != "" +} + +func validateTokenizerOutputs(chunks []schema.ChunkDoc, searchMethods, fields []string, kbID string) error { needFullText := contains(searchMethods, "full_text") - needEmbedding := contains(searchMethods, "embedding") + needEmbedding := shouldHaveEmbedding(searchMethods, kbID) if !needFullText && !needEmbedding { return nil } diff --git a/internal/ingestion/component/tokenizer_nonpersist_test.go b/internal/ingestion/component/tokenizer_nonpersist_test.go new file mode 100644 index 0000000000..1d9a8c2457 --- /dev/null +++ b/internal/ingestion/component/tokenizer_nonpersist_test.go @@ -0,0 +1,109 @@ +// +// 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. +// + +// Regression guard for the canvas-debug (dry-run) run contract: a debug run +// carries no KB (kb_id == ""), so the tokenizer must skip embedding rather than +// resolving an embedder. It lives in the canvas-debug branch (not the +// ingestion-fixes branch) so the ingestion-fixes branch stays compilable on its +// own. + +package component + +import ( + "context" + "testing" +) + +// TestTokenizerComponent_DebugSkipsEmbedding is a regression gate for the +// canvas-debug (dry-run) contract: a debug run has no KB (kb_id == ""), so +// embedding MUST be skipped — there is no embedder (and no embd_id) to resolve +// against, and a debug run must stay side-effect free. The tokenizer must +// return its chunks without error, without invoking the embedder, and without +// attaching embedding vectors. Conversely, when a kb_id is present the embedding +// path must run. +// +// It uses embedding-only search_method so it runs without the C++ RAGAnalyzer +// pool (plain `go test`, no -tags integration), keeping the regression gate +// executable in every environment. +func TestTokenizerComponent_DebugSkipsEmbedding(t *testing.T) { + stub := newStubEmbedder(4) + cIntf, err := NewTokenizerComponentWithResolver( + map[string]any{"search_method": []any{"embedding"}, "fields": []any{"text"}}, + func(ctx context.Context, _, _, _ string) (Embedder, error) { return stub, nil }, + ) + if err != nil { + t.Fatalf("NewTokenizerComponentWithResolver: %v", err) + } + c := cIntf.(*TokenizerComponent) + + run := func(kbID string) (int32, map[string]any) { + inputs := map[string]any{ + "name": "doc.pdf", + "output_format": "chunks", + "chunks": []map[string]any{ + {"text": "alpha bravo"}, + {"text": "charlie delta"}, + }, + } + if kbID != "" { + inputs["kb_id"] = kbID + } + out, err := c.Invoke(context.Background(), nil, inputs) + if err != nil { + t.Fatalf("Invoke(kb_id=%q): %v", kbID, err) + } + return stub.calls.Load(), out + } + + // Debug (kb_id == ""): embedding must be skipped entirely. + stub.calls.Store(0) + debugCalls, debugOut := run("") + if debugCalls != 0 { + t.Fatalf("embedder called under debug (kb_id=\"\"): %d calls; embedding must be skipped", debugCalls) + } + if got := debugOut["embedding_token_consumption"]; got != nil { + t.Errorf("embedding_token_consumption = %v under debug; must be omitted when embedding is skipped", got) + } + got, ok := debugOut["chunks"].([]map[string]any) + if !ok || len(got) != 2 { + t.Fatalf("chunks malformed under debug: %v", debugOut["chunks"]) + } + for i, ck := range got { + if _, has := ck["q_4_vec"]; has { + t.Errorf("chunk[%d] carries q_4_vec under debug; embedding must be skipped", i) + } + } + + // With a KB: embedding must run. + stub.calls.Store(0) + kbCalls, kbOut := run("kb-1") + if kbCalls != 2 { + t.Fatalf("embedder calls = %d with kb_id set; want 2", kbCalls) + } + if got := kbOut["embedding_token_consumption"]; got == nil { + t.Error("embedding_token_consumption missing with kb_id set; embedding accounting must run") + } + kbChunks, ok := kbOut["chunks"].([]map[string]any) + if !ok || len(kbChunks) != 2 { + t.Fatalf("chunks malformed with kb_id: %v", kbOut["chunks"]) + } + for i, ck := range kbChunks { + vec, ok := ck["q_4_vec"].([]float64) + if !ok || len(vec) != 4 { + t.Fatalf("chunk[%d] missing q_4_vec with kb_id set: %v", i, ck["q_4_vec"]) + } + } +} diff --git a/internal/ingestion/component/tokenizer_test.go b/internal/ingestion/component/tokenizer_test.go index 5dd986e6d2..5ab725a6a1 100644 --- a/internal/ingestion/component/tokenizer_test.go +++ b/internal/ingestion/component/tokenizer_test.go @@ -70,6 +70,7 @@ func TestTokenizerComponent_Invoke_HappyPath(t *testing.T) { out, err := c.Invoke(context.Background(), nil, map[string]any{ "tenant_id": "t1", "model_id": "embd-1", + "kb_id": "kb-1", "name": "doc.pdf", "output_format": "chunks", "chunks": chunks, @@ -222,6 +223,7 @@ func TestTokenizerComponent_Invoke_BatchedEmbedding(t *testing.T) { } if _, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.txt", + "kb_id": "kb-1", "output_format": "chunks", "chunks": chunks, }); err != nil { @@ -298,6 +300,7 @@ func TestTokenizerComponent_Invoke_FullTextAndEmbedding(t *testing.T) { c, _ := withStubEmbedder(t, 4) out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha bravo"}}, }) @@ -327,6 +330,7 @@ func TestTokenizerComponent_Invoke_EmbedNoResolver(t *testing.T) { requireTokenizerPool(t) c, _ := NewTokenizerComponent(nil) _, err := c.Invoke(context.Background(), nil, map[string]any{ + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }) @@ -346,6 +350,7 @@ func TestTokenizerComponent_Invoke_EmbedderError(t *testing.T) { stub.err = errors.New("simulated upstream error") _, err := c.Invoke(context.Background(), nil, map[string]any{ + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }) @@ -372,6 +377,7 @@ func TestTokenizerComponent_Invoke_EncoderCountMismatch(t *testing.T) { c := cIntf.(*TokenizerComponent) _ = stub _, err = c.Invoke(context.Background(), nil, map[string]any{ + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "a"}, {"text": "b"}, {"text": "c"}}, }) @@ -432,6 +438,7 @@ func TestTokenizerComponent_Smoke_EndToEnd(t *testing.T) { out, err := c.Invoke(context.Background(), nil, map[string]any{ "tenant_id": "tenant-smoke", "model_id": "embd-smoke", + "kb_id": "kb-1", "name": "smoke.pdf", "output_format": "chunks", "chunks": []map[string]any{{"text": chunkText}}, @@ -479,6 +486,7 @@ func TestTokenizerComponent_Embedding_MergesTitleAndContentVectors(t *testing.T) } out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}, {"text": "beta"}}, }) @@ -514,6 +522,7 @@ func TestTokenizerComponent_Embedding_UsesFilenameWeight(t *testing.T) { c := cIntf.(*TokenizerComponent) out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }) @@ -544,6 +553,7 @@ func TestTokenizerComponent_Embedding_EmptyNameWarnsAndUsesContentVector(t *test out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": " ", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }) @@ -577,6 +587,7 @@ func TestTokenizerComponent_Embedding_UsesRawNameNotTrimmed(t *testing.T) { if _, err := c.Invoke(context.Background(), nil, map[string]any{ "name": " report.pdf ", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }); err != nil { @@ -599,6 +610,7 @@ func TestTokenizerComponent_Embedding_TruncatesByMaxTokensMinus10(t *testing.T) longText := strings.Repeat("hello world ", 20) if _, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": longText}}, }); err != nil { @@ -620,6 +632,7 @@ func TestTokenizerComponent_Embedding_SkipsEmptyCleanedTextsButReturnsZeroWhenAl c, stub := withStubEmbedder(t, 2) out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{ {"text": "
"}, @@ -651,6 +664,7 @@ func TestTokenizerComponent_Embedding_SetsTokenConsumptionIncludingTitleCall(t * out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}, {"text": "beta"}}, }) @@ -669,6 +683,7 @@ func TestTokenizerComponent_Embedding_BatchesByConfiguredBatchSize(t *testing.T) if _, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{ {"text": "one"}, @@ -722,11 +737,11 @@ func TestTokenizerComponent_InstanceResolversDoNotLeakAcrossComponents(t *testin compA := compAIntf.(*TokenizerComponent) compB := compBIntf.(*TokenizerComponent) - outA, err := compA.Invoke(context.Background(), nil, map[string]any{"name": "docA", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}}) + outA, err := compA.Invoke(context.Background(), nil, map[string]any{"kb_id": "kb-1", "name": "docA", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}}) if err != nil { t.Fatalf("Invoke A: %v", err) } - outB, err := compB.Invoke(context.Background(), nil, map[string]any{"name": "docB", "output_format": "chunks", "chunks": []map[string]any{{"text": "beta"}}}) + outB, err := compB.Invoke(context.Background(), nil, map[string]any{"kb_id": "kb-1", "name": "docB", "output_format": "chunks", "chunks": []map[string]any{{"text": "beta"}}}) if err != nil { t.Fatalf("Invoke B: %v", err) } diff --git a/internal/ingestion/component/tokenizer_unit_test.go b/internal/ingestion/component/tokenizer_unit_test.go index 25dcc1f5f7..2bbd48c199 100644 --- a/internal/ingestion/component/tokenizer_unit_test.go +++ b/internal/ingestion/component/tokenizer_unit_test.go @@ -140,6 +140,7 @@ func TestTokenizerComponent_Invoke_EmptyChunks(t *testing.T) { _ = stub out, err := c.Invoke(context.Background(), nil, map[string]any{ + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{}, }) @@ -190,6 +191,7 @@ func TestTokenizerComponent_Invoke_EmbeddingOnly(t *testing.T) { } out, err := cIntf.(*TokenizerComponent).Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha bravo"}}, }) @@ -217,6 +219,7 @@ func TestTokenizerComponent_Embedding_ZeroChunksStillEmitsConsumptionZero(t *tes c, stub := withStubEmbedder(t, 2) out, err := c.Invoke(context.Background(), nil, map[string]any{ "name": "doc.pdf", + "kb_id": "kb-1", "output_format": "chunks", "chunks": []map[string]any{}, }) @@ -286,14 +289,14 @@ func TestTokenizerComponent_NewTokenizerComponent_BadParam(t *testing.T) { } func TestValidateTokenizerOutputs_FullTextMissingReturnsError(t *testing.T) { - err := validateTokenizerOutputs([]schema.ChunkDoc{{Text: "alpha"}}, []string{"full_text"}, []string{"text"}) + err := validateTokenizerOutputs([]schema.ChunkDoc{{Text: "alpha"}}, []string{"full_text"}, []string{"text"}, "") if err == nil || !strings.Contains(err.Error(), "missing full_text tokens") { t.Fatalf("err = %v, want missing full_text tokens", err) } } func TestValidateTokenizerOutputs_EmbeddingMissingReturnsError(t *testing.T) { - err := validateTokenizerOutputs([]schema.ChunkDoc{{Text: "alpha"}}, []string{"embedding"}, []string{"text"}) + err := validateTokenizerOutputs([]schema.ChunkDoc{{Text: "alpha"}}, []string{"embedding"}, []string{"text"}, "kb-1") if err == nil || !strings.Contains(err.Error(), "missing embedding vector") { t.Fatalf("err = %v, want missing embedding vector", err) } @@ -301,7 +304,7 @@ func TestValidateTokenizerOutputs_EmbeddingMissingReturnsError(t *testing.T) { func TestValidateTokenizerOutputs_BothModesFailWhenOneMissing(t *testing.T) { ck := schema.ChunkDoc{Text: "alpha", ContentLtks: "tok", ContentSmLtks: "sm"} - err := validateTokenizerOutputs([]schema.ChunkDoc{ck}, []string{"full_text", "embedding"}, []string{"text"}) + err := validateTokenizerOutputs([]schema.ChunkDoc{ck}, []string{"full_text", "embedding"}, []string{"text"}, "kb-1") if err == nil || !strings.Contains(err.Error(), "missing embedding vector") { t.Fatalf("err = %v, want missing embedding vector", err) } @@ -317,7 +320,7 @@ func TestValidateTokenizerOutputs_SymbolOnlyContentLtksIsEmptyFails(t *testing.T ContentLtks: "", ContentSmLtks: "", } - err := validateTokenizerOutputs([]schema.ChunkDoc{ck}, []string{"full_text"}, []string{"text"}) + err := validateTokenizerOutputs([]schema.ChunkDoc{ck}, []string{"full_text"}, []string{"text"}, "") if err == nil || !strings.Contains(err.Error(), "missing full_text tokens") { t.Fatalf("err = %v, want missing full_text tokens", err) } diff --git a/internal/ingestion/pipeline/template_integration_test.go b/internal/ingestion/pipeline/template_integration_test.go index 1a5be500e3..bee1d14632 100644 --- a/internal/ingestion/pipeline/template_integration_test.go +++ b/internal/ingestion/pipeline/template_integration_test.go @@ -39,6 +39,7 @@ import ( "ragflow/internal/common" componentpkg "ragflow/internal/ingestion/component" _ "ragflow/internal/ingestion/component/chunker" + "ragflow/internal/ingestion/testutil" "ragflow/internal/storage" "github.com/signintech/gopdf" @@ -89,6 +90,7 @@ func TestPipelineRun_TemplateGeneral_RealComponents(t *testing.T) { attachFixedEmbedderFactory(t, pipe) out, err := pipe.Run(context.Background(), map[string]any{ "doc_id": docID, + "kb_id": "test-kb", }, nil) if err != nil { t.Fatalf("Run: %v", err) @@ -180,6 +182,7 @@ func TestPipelineRun_TemplateOne_RealComponents(t *testing.T) { attachFixedEmbedderFactory(t, pipe) out, err := pipe.Run(context.Background(), map[string]any{ "doc_id": docID, + "kb_id": "test-kb", }, nil) if err != nil { t.Fatalf("Run: %v", err) @@ -268,6 +271,7 @@ func TestPipelineRun_TemplateOne_RealComponents_PDFDeepdocChunking(t *testing.T) attachFixedEmbedderFactory(t, pipe) out, err := pipe.Run(context.Background(), map[string]any{ "doc_id": docID, + "kb_id": "test-kb", }, nil) if err != nil { t.Fatalf("Run: %v", err) @@ -366,6 +370,7 @@ func TestPipelineRun_TemplateManual_RealComponents(t *testing.T) { attachFixedEmbedderFactory(t, pipe) out, err := pipe.Run(context.Background(), map[string]any{ "doc_id": docID, + "kb_id": "test-kb", }, nil) if err != nil { t.Fatalf("Run: %v", err) @@ -463,6 +468,7 @@ func TestPipelineRun_TemplateLaws_RealComponents(t *testing.T) { attachFixedEmbedderFactory(t, pipe) out, err := pipe.Run(context.Background(), map[string]any{ "doc_id": docID, + "kb_id": "test-kb", }, nil) if err != nil { t.Fatalf("Run: %v", err) @@ -545,6 +551,7 @@ func TestPipelineRun_TemplatePaper_RealComponents(t *testing.T) { attachFixedEmbedderFactory(t, pipe) out, err := pipe.Run(context.Background(), map[string]any{ "doc_id": docID, + "kb_id": "test-kb", }, nil) if err != nil { t.Fatalf("Run: %v", err) @@ -625,6 +632,7 @@ func TestPipelineRun_TemplateBook_RealComponents(t *testing.T) { attachFixedEmbedderFactory(t, pipe) out, err := pipe.Run(context.Background(), map[string]any{ "doc_id": docID, + "kb_id": "test-kb", }, nil) if err != nil { t.Fatalf("Run: %v", err) @@ -867,6 +875,15 @@ func withRealTemplateDeps(t *testing.T) storage.Storage { storage.GetStorageFactory().SetStorage(mem) t.Cleanup(func() { storage.GetStorageFactory().SetStorage(origStorage) }) + // The runtime invokes every component with the package-level dao.DB + // (see node_body.go). These "real components" tests exercise the + // Parser/Extractor model-resolution paths, so wire an in-memory sqlite DB + // via the shared testutil helper. It has no tenant/model rows, so + // resolution gracefully skips — but the nil-db panic from a headless run + // (db never initialized) is avoided without touching production code. + db := testutil.SetupTestDB(t) + t.Cleanup(testutil.ReplaceDBForTest(t, db)) + refs := map[string]componentpkg.DocumentStorageRef{} componentpkg.ResolveDocumentStorageOverride = func(docID string) (*componentpkg.DocumentStorageRef, error) { ref, ok := refs[docID] diff --git a/internal/ingestion/task/constants.go b/internal/ingestion/task/constants.go index 7d9eefc6af..e0450e56f2 100644 --- a/internal/ingestion/task/constants.go +++ b/internal/ingestion/task/constants.go @@ -18,9 +18,6 @@ package task // Special doc_id values used in task messages (mirrors Python constants). const ( - // CANVAS_DEBUG_DOC_ID marks a canvas debug pipeline task — no persistence. - CANVAS_DEBUG_DOC_ID = "dataflow_x" - // GRAPH_RAPTOR_FAKE_DOC_ID is the fake doc_id used for RAPTOR-generated chunks. GRAPH_RAPTOR_FAKE_DOC_ID = "graph_raptor_fake_doc" diff --git a/internal/ingestion/task/debug.go b/internal/ingestion/task/debug.go new file mode 100644 index 0000000000..b1e6ab5cbc --- /dev/null +++ b/internal/ingestion/task/debug.go @@ -0,0 +1,92 @@ +// +// 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 task + +import ( + "strings" + + "github.com/google/uuid" + "ragflow/internal/entity" +) + +// NewDebugTaskContext builds an in-memory TaskContext for a canvas-debug +// (dataflow dry-run) request. The returned context is intentionally +// side-effect free: +// +// - It carries no knowledgebase: KB.ID and Doc.KbID are empty. A canvas +// debug run has no KB, so kb_id == "" — this is the single debug signal +// used throughout the ingestion pipeline (the tokenizer skips embedding, +// the chunker skips image upload, the executor skips the persist stage). +// kb_id == "" occurs ONLY in debug mode; production ingestion always +// supplies a KB, so this branch is never reached in normal operation. +// - Doc.ID is a fresh uuid (a throwaway marker, not a persisted row); it +// only needs to be non-empty for the executor's input validation. +// - The parser page cap (debug preview only parses the first few pages) +// is injected at run time by the executor via Run's override_params +// channel, keyed by the Parser component's cpnID and the file's family +// (see injectDebugPageCap). It deliberately does NOT live in +// ParserConfig as a flat key, because flat keys are dropped by +// override_params merging and never reach the parser. +// +// fileData is the raw bytes of the uploaded debug document (may be nil for a +// file-less dry-run). It is stored on TaskContext.File and threaded into the +// pipeline run as the "file" / "binary" input. +// +// The caller-supplied kb_id is deliberately ignored: debug never resolves an +// embedder or writes to a KB, so a debug context has none. +func NewDebugTaskContext(tenantID, canvasID, fileName string, fileData []byte) *TaskContext { + doc := entity.Document{ + ID: uuid.New().String(), + KbID: "", + Name: &fileName, + } + if suffix, docType := deriveDocSuffixAndType(fileName); suffix != "" { + doc.Suffix = suffix + doc.Type = docType + } + + return &TaskContext{ + Doc: doc, + KB: entity.Knowledgebase{ID: ""}, + Tenant: entity.Tenant{ID: tenantID}, + PipelineID: canvasID, + File: fileData, + } +} + +// IsDebug reports whether this context is a canvas-debug (dry-run) context. +// A debug context carries no knowledgebase (KB.ID == "", forced by +// NewDebugTaskContext) — the single debug signal used across the ingestion +// pipeline. Production ingestion always supplies a KB (LoadFromIngestionTask +// follows doc -> kb), so KB.ID == "" occurs ONLY in debug mode. A debug run +// must produce no persistent side effect: the executor skips the persist +// stage, the tokenizer skips embedding, and the chunker skips image upload. +func (t *TaskContext) IsDebug() bool { + return t.KB.ID == "" +} + +// deriveDocSuffixAndType extracts the file extension (e.g. ".pdf") and a +// lower-cased document type from a file name. It is best-effort: when the +// name has no usable extension both return "". +func deriveDocSuffixAndType(fileName string) (suffix, docType string) { + dot := strings.LastIndex(fileName, ".") + if dot < 0 || dot == len(fileName)-1 { + return "", "" + } + ext := strings.ToLower(fileName[dot+1:]) + return "." + ext, ext +} diff --git a/internal/ingestion/task/debug_log_sink.go b/internal/ingestion/task/debug_log_sink.go new file mode 100644 index 0000000000..ce55ae406a --- /dev/null +++ b/internal/ingestion/task/debug_log_sink.go @@ -0,0 +1,286 @@ +// +// 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 task + +import ( + "context" + "encoding/json" + "sync" + "time" + + "ragflow/internal/ingestion/pipeline" +) + +// DebugLogTTL is the Redis expiry for a debug-run log. It mirrors the Python +// pipeline's 30-minute update TTL (rag/flow/pipeline.py callback: set_obj(..., +// 60*30)); the Go side writes the log once at the end of the synchronous run. +const DebugLogTTL = 30 * time.Minute + +// DebugLogStore is the minimal Redis surface the debug log sink needs. In +// production it is satisfied by *redis.Client (which exposes Set(key, value, +// ttl)); tests pass a capturing closure. Keeping it to a single method keeps the +// sink free of any redis-package import. +type DebugLogStore interface { + Set(key, value string, ttl time.Duration) bool +} + +// funcStore adapts a plain function to DebugLogStore so tests (and callers that +// already hold a go-redis client) can wire a sink without a named type. +type funcStore func(key, value string, ttl time.Duration) bool + +// Set implements DebugLogStore. +func (f funcStore) Set(key, value string, ttl time.Duration) bool { return f(key, value, ttl) } + +// component phases mirror runtime.ProgressPhase (Enter=0, Exit=1, Error=2). +// They are copied locally so the sink stays decoupled from the agent runtime +// package; the integer values are part of the pipeline's stable event contract. +const ( + phaseEnter = 0 + phaseExit = 1 + phaseError = 2 +) + +// Debug-log size guards. A debug run may emit an unbounded number of component +// lifecycle events (e.g. a misbehaving component looping), so the sink caps the +// number of components, the traces per component, and the per-message length. +// This stops a single run from exhausting memory or writing an oversized entry +// into the shared Redis. The bounds are far above any legitimate canvas and only +// catch pathological runs. +const ( + maxLogEntries = 1000 // distinct components recorded per run + maxTracePerEntry = 200 // progress events kept per component + maxMessageRunes = 1024 // rune length cap for a single trace message + maxPayloadBytes = 1 << 20 // hard ceiling on the JSON written to Redis (1 MiB) +) + +// debugLogEntry is one component's row in the front-end Log box: a component id +// plus an ordered list of trace lines. It matches the Python pipeline callback +// shape exactly (rag/flow/pipeline.py) so GetAgentLogs can return it verbatim +// and the front-end (useFetchMessageTrace) can read it unchanged. +type debugLogEntry struct { + ComponentID string `json:"component_id"` + Trace []debugTrace `json:"trace"` +} + +type debugTrace struct { + Progress float64 `json:"progress"` + Message string `json:"message"` + Datetime string `json:"datetime"` + Timestamp float64 `json:"timestamp"` + ElapsedTime float64 `json:"elapsed_time"` + // Dsl carries the debug-run result (per-component outputs) on the END + // marker's single trace entry, so the front-end "View result" page can + // render parsed chunks. It mirrors Python's END marker `dsl` + // (rag/flow/pipeline.py:98). Nil/empty when unset, in which case + // encoding/json omits it (json:"omitempty"). + Dsl json.RawMessage `json:"dsl,omitempty"` +} + +// DebugLogSink implements pipeline.ProgressSink by accumulating component +// lifecycle events into the [{component_id, trace}] array the front-end polls +// for. It deliberately owns no Redis I/O until Flush: all events are buffered +// in memory, so the sink is fully testable with a fake DebugLogStore and the +// executor can drive it without a live Redis. A run always ends with an END +// marker (appended in Flush) carrying a non-empty message, which is the exact +// signal the front-end uses to consider the run finished. +type DebugLogSink struct { + canvasID string + messageID string + store DebugLogStore + ttl time.Duration + + mu sync.Mutex + entries []debugLogEntry + lastTS float64 + // resultDSL is the debug-run result built by BuildDebugResultDSL and handed + // to the sink via SetResult (the ResultSink capability). It is written onto + // the END marker's trace entry in Flush. Empty until SetResult is called. + resultDSL json.RawMessage +} + +// NewDebugLogSink builds a sink that writes to "{canvasID}-{messageID}-logs". +func NewDebugLogSink(canvasID, messageID string, store DebugLogStore) *DebugLogSink { + return &DebugLogSink{ + canvasID: canvasID, + messageID: messageID, + store: store, + ttl: DebugLogTTL, + } +} + +// OnComponentTotal is a no-op for the debug log: the array shape does not use +// the component-count denominator (it is only consumed by the DB-backed sink +// for progress aggregation). +func (s *DebugLogSink) OnComponentTotal(_ context.Context, _ string, _ int) {} + +// SetResult receives the debug-run result DSL (built by BuildDebugResultDSL) +// and stores it for the END marker. It implements the optional ResultSink +// capability the executor probes for, so the sink stays decoupled from how the +// DSL is produced. Safe to call at most once per run; a later call replaces the +// previous value. +func (s *DebugLogSink) SetResult(dsl map[string]any) { + b, err := json.Marshal(dsl) + if err != nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.resultDSL = b +} + +// OnComponentProgress maps one component lifecycle event to a trace line and +// merges it into the running array. Consecutive events for the same component +// append to that component's trace (matching the Python callback's merge +// behaviour); a new component opens a fresh array element. +func (s *DebugLogSink) OnComponentProgress(_ context.Context, ev pipeline.ProgressEvent) { + now := time.Now() + ts := float64(now.UnixMicro()) + progress := 0.0 + switch ev.Phase { + case phaseExit: + progress = 1.0 + case phaseError: + progress = -1.0 + } + + message := ev.Message + if ev.Phase == phaseError { + // Prefix so the front-end timeline renders the line red + // (web/src/pages/agent/pipeline-log-sheet/dataflow-timeline.tsx). + message = "[ERROR] " + message + } + // Clamp the message so a runaway component cannot blow up the stored entry. + message = truncateRunes(message, maxMessageRunes) + + entry := debugTrace{ + Progress: progress, + Message: message, + Datetime: now.Format("15:04:05"), + Timestamp: float64(now.UnixNano()) / 1e9, + ElapsedTime: 0, + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.lastTS != 0 { + // ts and s.lastTS are UnixMicro(); report the delta in SECONDS so the + // front-end's verbatim "s" rendering (dataflow-timeline.tsx) + // and the "< 0.000001" gates in the other timeline views stay correct. + entry.ElapsedTime = (ts - s.lastTS) / 1e6 + } + s.lastTS = ts + + if n := len(s.entries); n > 0 && s.entries[n-1].ComponentID == ev.Component { + if len(s.entries[n-1].Trace) >= maxTracePerEntry { + // This component already has its full quota of trace lines; drop the + // rest so the buffer stays bounded. + return + } + s.entries[n-1].Trace = append(s.entries[n-1].Trace, entry) + return + } + if len(s.entries) >= maxLogEntries { + // Component quota reached; ignore further components. + return + } + s.entries = append(s.entries, debugLogEntry{ + ComponentID: ev.Component, + Trace: []debugTrace{entry}, + }) +} + +// Flush serialises the accumulated array (plus a terminal END marker) and +// persists it to the store. finalErr, when non-nil, is surfaced in the END +// message so a failed run is still reported and detectable as complete. All +// Redis I/O is best-effort: a store failure is swallowed (the run must not +// abort because the log could not be written). +func (s *DebugLogSink) Flush(ctx context.Context, finalErr error) { + s.mu.Lock() + entries := append([]debugLogEntry(nil), s.entries...) + + now := time.Now() + endTS := float64(now.UnixMicro()) + elapsed := 0.0 + if s.lastTS != 0 { + // endTS and s.lastTS are UnixMicro(); report the delta in SECONDS + // (see OnComponentProgress for the matching conversion). + elapsed = (endTS - s.lastTS) / 1e6 + } + endMsg := "Debug run completed" + if finalErr != nil { + endMsg = "[ERROR] " + finalErr.Error() + } + entries = append(entries, debugLogEntry{ + ComponentID: "END", + Trace: []debugTrace{{ + Progress: 1.0, + Message: endMsg, + Datetime: now.Format("15:04:05"), + Timestamp: float64(now.UnixNano()) / 1e9, + ElapsedTime: elapsed, + Dsl: s.resultDSL, + }}, + }) + s.mu.Unlock() + + payload, err := json.Marshal(entries) + if err != nil { + return + } + // Hard ceiling: if a buggy/abusive run still exceeds it, drop the middle + // entries (keeping the first few and the END marker) until under budget. + if len(payload) > maxPayloadBytes { + entries = trimToPayloadBudget(entries, maxPayloadBytes) + if payload, err = json.Marshal(entries); err != nil { + return + } + } + key := s.canvasID + "-" + s.messageID + "-logs" + s.store.Set(key, string(payload), s.ttl) +} + +// truncateRunes returns s truncated to at most max runes, preserving the original +// bytes when shorter. Mirrors internal/service/agent_sessions.go's truncateRunes. +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} + +// trimToPayloadBudget collapses the middle of the log (keeping the first few +// entries plus the END marker at the tail) until the marshaled size fits the +// byte budget. The END marker is always the last element, so it is preserved. +// The loop terminates because each pass shrinks the slice; once only the head +// and END remain the payload is tiny. +func trimToPayloadBudget(entries []debugLogEntry, budget int) []debugLogEntry { + for len(entries) > 2 { + if b, err := json.Marshal(entries); err == nil && len(b) <= budget { + return entries + } + keep := len(entries) / 4 + if keep < 1 { + keep = 1 + } + entries = append(append([]debugLogEntry{}, entries[:keep]...), entries[len(entries)-1]) + } + return entries +} diff --git a/internal/ingestion/task/debug_log_sink_test.go b/internal/ingestion/task/debug_log_sink_test.go new file mode 100644 index 0000000000..97388a33d6 --- /dev/null +++ b/internal/ingestion/task/debug_log_sink_test.go @@ -0,0 +1,766 @@ +// +// 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 task + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + "gorm.io/gorm" + "ragflow/internal/agent/runtime" + "ragflow/internal/ingestion/pipeline" +) + +// capturedStore is a fake DebugLogStore that records every write so tests can +// assert the exact JSON the sink would persist to Redis — no live Redis needed. +type capturedStore struct { + mu sync.Mutex + data map[string]string +} + +func (c *capturedStore) Set(key, value string, ttl time.Duration) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.data == nil { + c.data = map[string]string{} + } + c.data[key] = value + return true +} + +func (c *capturedStore) get(key string) string { + c.mu.Lock() + defer c.mu.Unlock() + return c.data[key] +} + +// clientConsidersComplete mirrors the front-end completion predicate in +// web/src/pages/agent/hooks/use-fetch-pipeline-log.ts: the run is done only +// when the LAST array element has component_id == "END" and its first trace +// message is non-empty. +func clientConsidersComplete(arr []map[string]any) bool { + if len(arr) == 0 { + return false + } + last := arr[len(arr)-1] + if last["component_id"] != "END" { + return false + } + trace, ok := last["trace"].([]any) + if !ok || len(trace) == 0 { + return false + } + first, ok := trace[0].(map[string]any) + if !ok { + return false + } + msg, _ := first["message"].(string) + return strings.TrimSpace(msg) != "" +} + +func loadArray(t *testing.T, raw string) []map[string]any { + t.Helper() + var arr []map[string]any + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + t.Fatalf("stored value is not a JSON array: %v body=%s", err, raw) + } + return arr +} + +// TestDebugLogSink_RecordsTraceAndEndMarker covers the core shape contract: +// same-component events merge into one element's trace, a distinct component +// opens a new element, and Flush appends an END marker so the front-end can +// detect completion. +func TestDebugLogSink_RecordsTraceAndEndMarker(t *testing.T) { + store := &capturedStore{} + sink := NewDebugLogSink("c1", "m1", store) + + sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{ + Component: "File", Message: "File Started", Phase: phaseEnter, + }) + sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{ + Component: "File", Message: "File Done", Phase: phaseExit, + }) + sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{ + Component: "Chunker", Message: "Chunker Done", Phase: phaseExit, + }) + + sink.Flush(context.Background(), nil) + + raw := store.get("c1-m1-logs") + if raw == "" { + t.Fatalf("expected key c1-m1-logs to be written") + } + arr := loadArray(t, raw) + // File (merged, 2 trace entries) + Chunker + END = 3 elements. + if len(arr) != 3 { + t.Fatalf("want 3 elements, got %d: %s", len(arr), raw) + } + if arr[0]["component_id"] != "File" { + t.Errorf("elem0.component_id=%v want File", arr[0]["component_id"]) + } + fileTrace, _ := arr[0]["trace"].([]any) + if len(fileTrace) != 2 { + t.Errorf("File trace should merge 2 entries, got %d: %s", len(fileTrace), raw) + } + if arr[1]["component_id"] != "Chunker" { + t.Errorf("elem1.component_id=%v want Chunker", arr[1]["component_id"]) + } + if arr[2]["component_id"] != "END" { + t.Errorf("last component_id=%v want END", arr[2]["component_id"]) + } + endTrace, _ := arr[2]["trace"].([]any) + endFirst, _ := endTrace[0].(map[string]any) + if strings.TrimSpace(endFirst["message"].(string)) == "" { + t.Errorf("END message must be non-empty for completion signal: %s", raw) + } + if !clientConsidersComplete(arr) { + t.Errorf("client would NOT consider run complete: %s", raw) + } +} + +// TestDebugLogSink_ErrorPrefix verifies that error-phase events get a [ERROR] +// prefix (so the front-end timeline renders them red) and that Flush with a +// non-nil error yields an END marker carrying the error, keeping the run +// detectable as finished. +func TestDebugLogSink_ErrorPrefix(t *testing.T) { + store := &capturedStore{} + sink := NewDebugLogSink("c1", "m1", store) + + sink.OnComponentProgress(context.Background(), pipeline.ProgressEvent{ + Component: "Tokenizer", Message: "Tokenizer: boom", Phase: phaseError, + }) + sink.Flush(context.Background(), errors.New("boom")) + + raw := store.get("c1-m1-logs") + arr := loadArray(t, raw) + + // First element is the errored component; its message must carry [ERROR]. + erroredTrace, _ := arr[0]["trace"].([]any) + erroredFirst, _ := erroredTrace[0].(map[string]any) + if !strings.HasPrefix(erroredFirst["message"].(string), "[ERROR] ") { + t.Errorf("error trace message should be prefixed [ERROR] , got %q", erroredFirst["message"]) + } + + // Last element is END with the error surfaced, so the run still completes. + lastTrace, _ := arr[len(arr)-1]["trace"].([]any) + lastFirst, _ := lastTrace[0].(map[string]any) + if !strings.HasPrefix(lastFirst["message"].(string), "[ERROR] ") { + t.Errorf("END message on failure should be prefixed [ERROR] , got %q", lastFirst["message"]) + } + if !clientConsidersComplete(arr) { + t.Errorf("failed run must still be detectable as complete: %s", raw) + } +} + +// TestDebugLogSink_OnComponentTotalIsNoOp ensures the denominator callback +// (used by the DB-backed sink for progress aggregation) is a safe no-op here: +// the debug log array shape does not use it. +func TestDebugLogSink_OnComponentTotalIsNoOp(t *testing.T) { + store := &capturedStore{} + sink := NewDebugLogSink("c1", "m1", store) + // Must not panic and must not write. + sink.OnComponentTotal(context.Background(), "task-1", 5) + if len(store.data) != 0 { + t.Errorf("OnComponentTotal must not write to the store, wrote %v", store.data) + } +} + +// TestPipelineExecutor_DebugRunWritesLogViaSink drives the real +// PipelineExecutor debug branch (KB.ID == "") with an injected fake pipeline +// that emits progress through the sink. It proves the end-to-end wiring inside +// the executor: a ProgressSink attached via WithProgressSink is forwarded to the +// pipeline and its events land in the persisted debug-log array (with the END +// marker), without touching the DB/index persist path. +func TestPipelineExecutor_DebugRunWritesLogViaSink(t *testing.T) { + ctx := context.Background() + taskCtx := NewDebugTaskContext("tenant-1", "c1", "doc.pdf", nil) + exec, err := NewPipelineExecutor(taskCtx, "c1", 0) + if err != nil { + t.Fatalf("NewPipelineExecutor: %v", err) + } + + store := &capturedStore{} + sink := NewDebugLogSink("c1", "m1", store) + exec.WithProgressSink(sink) + + // Fake the canvas/DSL boundary: loadDSLFunc returns a dummy DSL and + // runPipelineFunc emits progress through the (now attached) sink instead of + // running a real pipeline. + exec.loadDSLFunc = func(_ context.Context, _ string) (string, string, error) { + return "dummy-dsl", "", nil + } + exec.runPipelineFunc = func(ctx context.Context, _ string) (map[string]any, string, error) { + exec.progressSink.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: "File", Message: "File Done", Phase: phaseExit, + }) + exec.progressSink.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: "Chunker", Message: "Chunker Done", Phase: phaseExit, + }) + return map[string]any{}, "", nil + } + + if _, err := exec.Execute(ctx); err != nil { + t.Fatalf("Execute: %v", err) + } + // Mirrors runCanvasPipelineDebug: flush (incl. END marker) after the run. + sink.Flush(ctx, nil) + + raw := store.get("c1-m1-logs") + if raw == "" { + t.Fatalf("expected debug log written to c1-m1-logs") + } + arr := loadArray(t, raw) + if arr[len(arr)-1]["component_id"] != "END" { + t.Errorf("last element must be END marker, got %s", raw) + } + if !clientConsidersComplete(arr) { + t.Errorf("client would not consider run complete: %s", raw) + } +} + +// traceStubComponent is a no-op runtime.Component used to exercise the REAL +// pipeline → TrackProgress → DebugLogSink wiring (the enter→exit pairing) without +// a live DB or any real ingestion work. +type traceStubComponent struct{} + +func (traceStubComponent) Invoke(_ context.Context, _ *gorm.DB, _ map[string]any) (map[string]any, error) { + return map[string]any{"ok": true}, nil +} + +// traceChunkComponent emits a real `chunks` output so the end-to-end wiring can +// be asserted to populate the END-marker DSL's params.outputs — the exact +// contract the front-end "View result" needs and the one the nested-state bug +// broke (empty Result tabs). +type traceChunkComponent struct{} + +func (traceChunkComponent) Invoke(_ context.Context, _ *gorm.DB, _ map[string]any) (map[string]any, error) { + // Use the real component output shape ([]map[string]any as produced by + // ChunkDocsToMaps) so the end-to-end strip/recurse path for []map[string]any + // is actually exercised — a []any stub hides the deepCopyStrip type gap. + return map[string]any{ + "chunks": []map[string]any{ + {"text": "real chunk", "vector": []float64{0.5}}, + }, + }, nil +} + +// TestDebugLogSink_RealPipeline_EachComponentTraceHasStartedAndDone drives a +// REAL pipeline (not the fake runPipelineFunc used elsewhere) through the same +// wiring a dataflow debug run uses: NewPipelineFromDSL + WithProgressSink +// (DebugLogSink) + Run. The canvas framework wraps each component in +// realComponentBody → runtime.TrackProgress, which MUST emit BOTH PhaseEnter +// (" Started", progress 0) and PhaseExit (" Done", progress 1) on +// success. +// +// This locks the contract the front-end depends on: every component ends with a +// Done line, not just a Started line. It is the regression guard for the +// observation that a debug run's polled log appeared to stay at progress 0 — +// per the code, each component's trace must contain [Started, Done]. +func TestDebugLogSink_RealPipeline_EachComponentTraceHasStartedAndDone(t *testing.T) { + const ( + compA = "trace.RealStubA" + compB = "trace.RealStubB" + ) + runtime.MustRegister(compA, runtime.CategoryIngestion, + func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil }, + runtime.Metadata{Version: "1.0.0"}) + runtime.MustRegister(compB, runtime.CategoryIngestion, + func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil }, + runtime.Metadata{Version: "1.0.0"}) + + store := &capturedStore{} + sink := NewDebugLogSink("c-trace", "m-trace", store) + + dsl := []byte(`{"dsl":{"components":{ + "begin":{"obj":{"component_name":"Begin","params":{}},"downstream":["a"]}, + "a":{"obj":{"component_name":"` + compA + `","params":{}},"upstream":["begin"],"downstream":["b"]}, + "b":{"obj":{"component_name":"` + compB + `","params":{}},"upstream":["a"]} + },"path":["begin","a","b"],"graph":{"nodes":[]}}}`) + + pipe, err := pipeline.NewPipelineFromDSL(dsl, "task-trace", + pipeline.WithProgressSink(sink), pipeline.WithDocumentID("doc-trace")) + if err != nil { + t.Fatalf("NewPipelineFromDSL: %v", err) + } + if _, err := pipe.Run(context.Background(), map[string]any{"name": "doc-trace"}, nil); err != nil { + t.Fatalf("Run: %v", err) + } + sink.Flush(context.Background(), nil) + + raw := store.get("c-trace-m-trace-logs") + if raw == "" { + t.Fatalf("expected debug log written to c-trace-m-trace-logs") + } + arr := loadArray(t, raw) + + // Completion predicate must hold: END is last with a non-empty message. + if arr[len(arr)-1]["component_id"] != "END" { + t.Fatalf("last element must be END marker: %s", raw) + } + if !clientConsidersComplete(arr) { + t.Fatalf("client would not consider run complete: %s", raw) + } + + // Every real component (a, b) must show BOTH Started (progress 0) and Done + // (progress 1) within its own trace — proving TrackProgress emitted the full + // enter→exit lifecycle, not just enter. + for _, comp := range []string{"a", "b"} { + var gotStart, gotDone bool + for _, el := range arr { + if el["component_id"] != comp { + continue + } + traces, _ := el["trace"].([]any) + for _, tr := range traces { + tm, _ := tr.(map[string]any) + if tm == nil { + continue + } + prog, _ := tm["progress"].(float64) + msg, _ := tm["message"].(string) + switch { + case prog == 0 && msg == comp+" Started": + gotStart = true + case prog == 1 && msg == comp+" Done": + gotDone = true + } + } + } + if !gotStart || !gotDone { + t.Fatalf("component %q missing Started/Done in trace: %s", comp, raw) + } + } +} + +// TestDebugLogSink_RealPipeline_EndMarkerCarriesDSL locks the end-to-end +// contract the front-end "View result" depends on: the debug log's END marker +// trace[0] MUST carry a `dsl` (built by BuildDebugResultDSL) whose `components` +// map mirrors the canvas. The front-end `useFetchPipelineResult` picks +// `latest.trace.at(0)` where `latest.component_id == "END"` and reads +// `dsl.components[].obj` to render parsed chunks (parser.tsx:41). Without +// this, "View result" shows only the file preview — the bug this change fixes. +// +// It drives a REAL pipeline (same wiring as a dataflow debug run) so the `dsl` +// is built from an actual run output map, not a hand-built one. +func TestDebugLogSink_RealPipeline_EndMarkerCarriesDSL(t *testing.T) { + const ( + compC = "trace.RealStubC" + compD = "trace.RealStubD" + ) + runtime.MustRegister(compC, runtime.CategoryIngestion, + func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil }, + runtime.Metadata{Version: "1.0.0"}) + runtime.MustRegister(compD, runtime.CategoryIngestion, + func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil }, + runtime.Metadata{Version: "1.0.0"}) + + store := &capturedStore{} + sink := NewDebugLogSink("c-dsl", "m-dsl", store) + + dsl := `{"dsl":{"components":{ + "begin":{"obj":{"component_name":"Begin","params":{}},"downstream":["c"]}, + "c":{"obj":{"component_name":"` + compC + `","params":{}},"upstream":["begin"],"downstream":["d"]}, + "d":{"obj":{"component_name":"` + compD + `","params":{}},"upstream":["c"]} + },"path":["begin","c","d"],"graph":{"nodes":[{"id":"begin","data":{"name":"开始"}},{"id":"c","data":{"name":"解析"}},{"id":"d","data":{"name":"分词"}}]}}}` + + pipe, err := pipeline.NewPipelineFromDSL([]byte(dsl), "task-dsl", + pipeline.WithProgressSink(sink), pipeline.WithDocumentID("doc-dsl")) + if err != nil { + t.Fatalf("NewPipelineFromDSL: %v", err) + } + output, err := pipe.Run(context.Background(), map[string]any{"name": "doc-dsl"}, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + + // The executor would call this after Run; here we invoke it directly. + resultDSL, err := BuildDebugResultDSL(dsl, output) + if err != nil { + t.Fatalf("BuildDebugResultDSL: %v", err) + } + sink.SetResult(resultDSL) + sink.Flush(context.Background(), nil) + + raw := store.get("c-dsl-m-dsl-logs") + if raw == "" { + t.Fatalf("expected debug log written to c-dsl-m-dsl-logs") + } + arr := loadArray(t, raw) + + // END must be the last element. + if arr[len(arr)-1]["component_id"] != "END" { + t.Fatalf("last element must be END marker: %s", raw) + } + endTrace, _ := arr[len(arr)-1]["trace"].([]any) + if len(endTrace) == 0 { + t.Fatalf("END trace is empty: %s", raw) + } + endFirst, _ := endTrace[0].(map[string]any) + dslVal := endFirst["dsl"] + if dslVal == nil { + t.Fatalf("END marker trace[0] must carry a non-empty dsl: %s", raw) + } + + // json.RawMessage decodes into a generic map as map[string]any (not a + // string), so accept either form. + var dslDoc map[string]any + switch v := dslVal.(type) { + case string: + if err := json.Unmarshal([]byte(v), &dslDoc); err != nil { + t.Fatalf("END dsl is not valid JSON: %v body=%s", err, v) + } + case map[string]any: + dslDoc = v + default: + t.Fatalf("END dsl unexpected type %T: %s", v, raw) + } + components, ok := dslDoc["components"].(map[string]any) + if !ok || len(components) == 0 { + t.Fatalf("END dsl.components missing or empty: %s", raw) + } + // The dsl must mirror every component in the canvas (begin, c, d). + for _, id := range []string{"begin", "c", "d"} { + if _, exists := components[id]; !exists { + t.Errorf("END dsl.components missing component %q: %s", id, raw) + } + } +} + +// TestDebugLogSink_RealPipeline_EndMarkerDSLShowsChunks is the strongest +// regression guard for the nested-state bug: it drives a REAL pipeline whose +// component "c" emits actual `chunks`, then asserts the END-marker DSL carries +// a non-empty params.outputs for that component. With the bug (reading +// output[id] at the top level instead of output["state"][id]), the run output +// is nested and params.outputs stays empty — the front-end "View result" shows +// blank tabs. The companion TestDebugLogSink_RealPipeline_EndMarkerCarriesDSL +// only checks component keys exist (its stubs return {"ok":true}, no chunks), +// so it would not catch this. This test closes that end-to-end gap. +// +// It also pins the inverse: component "d" (stub returning {"ok":true}) has NO +// recognized output key, so its params.outputs must be absent — the safe-empty +// contract the front-end renders as a blank step. +func TestDebugLogSink_RealPipeline_EndMarkerDSLShowsChunks(t *testing.T) { + const ( + compC = "trace.RealStubChunks" + compD = "trace.RealStubD2" + ) + runtime.MustRegister(compC, runtime.CategoryIngestion, + func(_ string, _ map[string]any) (runtime.Component, error) { return traceChunkComponent{}, nil }, + runtime.Metadata{Version: "1.0.0"}) + runtime.MustRegister(compD, runtime.CategoryIngestion, + func(_ string, _ map[string]any) (runtime.Component, error) { return traceStubComponent{}, nil }, + runtime.Metadata{Version: "1.0.0"}) + + store := &capturedStore{} + sink := NewDebugLogSink("c-chunk", "m-chunk", store) + + dsl := `{"dsl":{"components":{ + "begin":{"obj":{"component_name":"Begin","params":{}},"downstream":["c"]}, + "c":{"obj":{"component_name":"` + compC + `","params":{"setups":{"pdf":{"parse_method":"general"}}}},"upstream":["begin"],"downstream":["d"]}, + "d":{"obj":{"component_name":"` + compD + `","params":{}},"upstream":["c"]} + },"path":["begin","c","d"],"graph":{"nodes":[{"id":"begin","data":{"name":"开始"}},{"id":"c","data":{"name":"解析"}},{"id":"d","data":{"name":"分词"}}]}}}` + + pipe, err := pipeline.NewPipelineFromDSL([]byte(dsl), "task-chunk", + pipeline.WithProgressSink(sink), pipeline.WithDocumentID("doc-chunk")) + if err != nil { + t.Fatalf("NewPipelineFromDSL: %v", err) + } + output, err := pipe.Run(context.Background(), map[string]any{"name": "doc-chunk"}, nil) + if err != nil { + t.Fatalf("Run: %v", err) + } + + resultDSL, err := BuildDebugResultDSL(dsl, output) + if err != nil { + t.Fatalf("BuildDebugResultDSL: %v", err) + } + sink.SetResult(resultDSL) + sink.Flush(context.Background(), nil) + + raw := store.get("c-chunk-m-chunk-logs") + if raw == "" { + t.Fatalf("expected debug log written") + } + arr := loadArray(t, raw) + if arr[len(arr)-1]["component_id"] != "END" { + t.Fatalf("last element must be END marker: %s", raw) + } + endTrace, _ := arr[len(arr)-1]["trace"].([]any) + endFirst, _ := endTrace[0].(map[string]any) + + var dslDoc map[string]any + switch v := endFirst["dsl"].(type) { + case string: + if err := json.Unmarshal([]byte(v), &dslDoc); err != nil { + t.Fatalf("END dsl not valid JSON: %v", err) + } + case map[string]any: + dslDoc = v + default: + t.Fatalf("END dsl unexpected type %T", v) + } + components, _ := dslDoc["components"].(map[string]any) + + // Component "c" emits chunks -> params.outputs MUST be populated. + cParams := components["c"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any) + cOutputs, ok := cParams["outputs"].(map[string]any) + if !ok { + t.Fatalf("REGRESSION: chunk-emitting component c has empty params.outputs; "+ + "the front-end 'View result' would render blank tabs. params=%#v", cParams) + } + if of, _ := cOutputs["output_format"].(map[string]any); of["value"] != "chunks" { + t.Errorf("c output_format=%#v want {value:\"chunks\"}", cOutputs["output_format"]) + } + chunksVal := cOutputs["chunks"].(map[string]any)["value"].([]any) + if len(chunksVal) != 1 { + t.Fatalf("c chunks.value len=%d want 1", len(chunksVal)) + } + chunk0 := chunksVal[0].(map[string]any) + if chunk0["text"] != "real chunk" { + t.Errorf("chunk text=%v want 'real chunk'", chunk0["text"]) + } + if _, exists := chunk0["vector"]; exists { + t.Errorf("vector must be stripped from chunk payload") + } + if _, ok := cParams["setups"].(map[string]any); !ok { + t.Errorf("c params.setups must be carried from DSL: %#v", cParams) + } + + // Component "d" emits {"ok":true} -> no recognized format -> outputs absent. + dParams := components["d"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any) + if _, exists := dParams["outputs"]; exists { + t.Errorf("d has no recognized output, outputs must be absent: %#v", dParams) + } +} + +// TestDebugLogSink_ElapsedTimeIsInSeconds locks the unit contract for +// `elapsed_time`: it MUST be expressed in SECONDS, not the raw UnixMicro delta. +// This is the regression guard for the dataflow timeline showing values like +// "19951s" — the sink stored microseconds while the front-end renders the number +// verbatim with an "s" suffix (dataflow-timeline.tsx) and the rest of the app +// (webhook/workflow timelines) gates on `elapsed_time < 0.000001`, i.e. seconds. +// +// The test drives two progress events ~20ms apart and asserts the recorded +// elapsed_time is close to the measured wall-clock gap in seconds (not 1e6x +// larger, which is what an unfixed microsecond delta would produce). +func TestDebugLogSink_ElapsedTimeIsInSeconds(t *testing.T) { + store := &capturedStore{} + sink := NewDebugLogSink("c-elapsed", "m-elapsed", store) + ctx := context.Background() + + t0 := time.Now() + sink.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: "A", Message: "A Started", Phase: phaseEnter, + }) + time.Sleep(20 * time.Millisecond) + t1 := time.Now() + sink.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: "A", Message: "A Done", Phase: phaseExit, + }) + sink.Flush(ctx, nil) + + raw := store.get("c-elapsed-m-elapsed-logs") + if raw == "" { + t.Fatalf("expected debug log written to c-elapsed-m-elapsed-logs") + } + arr := loadArray(t, raw) + if len(arr) < 2 { + t.Fatalf("expected at least A + END elements, got %d: %s", len(arr), raw) + } + aTrace, _ := arr[0]["trace"].([]any) + if len(aTrace) < 2 { + t.Fatalf("A trace should have [Started, Done], got %d entries: %s", len(aTrace), raw) + } + doneTrace, _ := aTrace[1].(map[string]any) + elapsed, ok := doneTrace["elapsed_time"].(float64) + if !ok { + t.Fatalf("elapsed_time missing or wrong type: %s", raw) + } + + gapSeconds := t1.Sub(t0).Seconds() + // Tolerance absorbs call-overhead jitter; the key is that elapsed is in the + // same SECONDS scale as gapSeconds, not 1e6x larger (microseconds). + // With an unfixed microsecond delta, elapsed would be ~gapSeconds*1e6 and + // this assertion fails. + if math.Abs(elapsed-gapSeconds) > math.Max(gapSeconds*0.5, 0.01) { + t.Fatalf("elapsed_time=%v want ~%v seconds (sink must divide the UnixMicro delta by 1e6)", elapsed, gapSeconds) + } +} + +// TestDebugLogSink_TimestampIsInSeconds locks the unit contract for the +// `timestamp` field: it MUST be a Unix epoch in SECONDS, not the raw UnixMicro +// value. Although the dataflow timeline currently renders the `datetime` string +// rather than `timestamp`, the field is part of the stable log contract the +// front-end can rely on; emitting microseconds would put it 1e6 off from any +// consumer expecting a Unix timestamp. This matches the SECONDS convention +// already established by elapsed_time and the other (webhook/workflow) timeline +// views. +func TestDebugLogSink_TimestampIsInSeconds(t *testing.T) { + store := &capturedStore{} + sink := NewDebugLogSink("c-ts", "m-ts", store) + ctx := context.Background() + + before := time.Now().Unix() + sink.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: "A", Message: "A Started", Phase: phaseEnter, + }) + sink.Flush(ctx, nil) + + raw := store.get("c-ts-m-ts-logs") + if raw == "" { + t.Fatalf("expected debug log written to c-ts-m-ts-logs") + } + arr := loadArray(t, raw) + aTrace, _ := arr[0]["trace"].([]any) + if len(aTrace) == 0 { + t.Fatalf("A trace is empty: %s", raw) + } + firstTrace, _ := aTrace[0].(map[string]any) + ts, ok := firstTrace["timestamp"].(float64) + if !ok { + t.Fatalf("timestamp missing or wrong type: %s", raw) + } + + // If the sink emitted UnixMicro, ts would be ~1.7e15; a Unix-seconds value + // is ~1.7e9 and must be within a few seconds of `before`. + diff := math.Abs(ts - float64(before)) + if diff > 5 { + t.Fatalf("timestamp=%v want ~%d (Unix seconds, not UnixMicro)", ts, before) + } +} + +// TestDebugLogSink_RespectsCaps pins the size guards: a pathological run that +// emits far more components / trace lines / over-long messages than any +// legitimate canvas ever would must be clamped, while the END completion marker +// and the front-end's completion predicate stay intact. This is the regression +// guard for the unbounded-memory / oversized-Redis-entry DoS surface. +// +// Counts are deliberately bounded so the test stays fast: we only need to exceed +// each cap by a small margin to prove clamping, not replay millions of events. +func TestDebugLogSink_RespectsCaps(t *testing.T) { + ctx := context.Background() + + // Component cap: more distinct components than maxLogEntries (each one trace, + // one over-long message that must be truncated to the rune cap). + storeA := &capturedStore{} + sinkA := NewDebugLogSink("c1", "mA", storeA) + for i := 0; i < maxLogEntries+200; i++ { + sinkA.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: fmt.Sprintf("Comp-%d", i), + Message: strings.Repeat("x", 5000), // truncated to maxMessageRunes + Phase: phaseExit, + }) + } + sinkA.Flush(ctx, nil) + + arrA := loadArray(t, storeA.get("c1-mA-logs")) + // Flush always appends the terminal END marker, so count only real components. + var compCountA int + for _, el := range arrA { + if el["component_id"] == "END" { + continue + } + compCountA++ + } + if compCountA > maxLogEntries { + t.Fatalf("components=%d want <= %d", compCountA, maxLogEntries) + } + for _, el := range arrA { + traces, _ := el["trace"].([]any) + for _, tr := range traces { + tm, _ := tr.(map[string]any) + if tm == nil { + continue + } + msg, _ := tm["message"].(string) + if utf8.RuneCountInString(msg) > maxMessageRunes { + t.Fatalf("message rune len=%d want <= %d", utf8.RuneCountInString(msg), maxMessageRunes) + } + } + } + if !clientConsidersComplete(arrA) { + t.Errorf("clamped log must still satisfy completion predicate; raw=%s", storeA.get("c1-mA-logs")) + } + + // Trace cap: a single component with more traces than maxTracePerEntry. + storeB := &capturedStore{} + sinkB := NewDebugLogSink("c1", "mB", storeB) + for j := 0; j < maxTracePerEntry+50; j++ { + sinkB.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: "Busy", + Message: "ok", + Phase: phaseExit, + }) + } + sinkB.Flush(ctx, nil) + + arrB := loadArray(t, storeB.get("c1-mB-logs")) + // Busy (the only real component) + the END completion marker = 2 elements. + if len(arrB) != 2 { + t.Fatalf("Busy + END expected, got %d elements: %s", len(arrB), storeB.get("c1-mB-logs")) + } + busyTrace, _ := arrB[0]["trace"].([]any) + if len(busyTrace) > maxTracePerEntry { + t.Fatalf("Busy trace len=%d want <= %d", len(busyTrace), maxTracePerEntry) + } + if !clientConsidersComplete(arrB) { + t.Errorf("trace-capped log must still satisfy completion predicate; raw=%s", storeB.get("c1-mB-logs")) + } +} + +// TestDebugLogSink_PayloadHardCap pins the absolute byte ceiling on the JSON +// written to Redis. It fills to the per-entry caps (maxLogEntries components x +// maxTracePerEntry traces) — which pre-trim is ~16 MiB — and asserts the stored +// value never exceeds maxPayloadBytes, and still ends with the END marker. +func TestDebugLogSink_PayloadHardCap(t *testing.T) { + store := &capturedStore{} + sink := NewDebugLogSink("c1", "m1", store) + ctx := context.Background() + + for i := 0; i < maxLogEntries; i++ { + comp := fmt.Sprintf("Comp-%d", i) + for j := 0; j < maxTracePerEntry; j++ { + sink.OnComponentProgress(ctx, pipeline.ProgressEvent{ + Component: comp, + Message: "short", + Phase: phaseExit, + }) + } + } + sink.Flush(ctx, nil) + + raw := store.get("c1-m1-logs") + if raw == "" { + t.Fatalf("expected debug log written to c1-m1-logs") + } + if len(raw) > maxPayloadBytes { + t.Fatalf("stored payload bytes=%d want <= %d", len(raw), maxPayloadBytes) + } + arr := loadArray(t, raw) + if !clientConsidersComplete(arr) { + t.Errorf("hard-capped log must still satisfy completion predicate; arr=%s", raw) + } +} diff --git a/internal/ingestion/task/debug_pages_integration_test.go b/internal/ingestion/task/debug_pages_integration_test.go new file mode 100644 index 0000000000..6d38d2dbc2 --- /dev/null +++ b/internal/ingestion/task/debug_pages_integration_test.go @@ -0,0 +1,213 @@ +//go:build integration + +// +// 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 task + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "ragflow/internal/dao" + "ragflow/internal/entity" + "ragflow/internal/ingestion/component" + "ragflow/internal/ingestion/pipeline" + "ragflow/internal/storage" +) + +// TestExecute_DebugViaEntry_HonorsPagesCap_Integration is the end-to-end +// proof that the canvas-debug page cap travels through Run's override_params +// channel (NOT pipeline inputs) and is honoured by the deepdoc/pdf parser. +// +// It parses a multi-page PDF twice through a real debug pipeline (real DSL, +// real pdfium parser, in-memory sqlite + storage): +// +// - Uncapped baseline: an explicit cpnID+family page cap of [[1, 1000000]] +// is supplied in ParserConfig. injectDebugPageCap must RESPECT it, so the +// parser reads every page. +// - Capped: no page cap is supplied, so injectDebugPageCap injects the +// debug default [[1, debugPageCapPages]], and the parser reads only the +// leading pages. +// +// The capped run must therefore yield strictly fewer chunk characters than +// the uncapped run. This exercises the full path the production code uses +// (Run's 3rd-arg override_params → applyOverrideParams → c.Setups → +// ConfigureFromSetup → deepdoc pdf Config.Pages). +// +// This is gated by //go:build integration because it depends on the native +// pdfium library, the 03_multipage.pdf fixture, and (optionally) the +// tokenizer resource dict. +func TestExecute_DebugViaEntry_HonorsPagesCap_Integration(t *testing.T) { + requireTokenizerPool(t) + mustLoadTaskTestConfig(t) + + origDB := dao.DB + realDB := mustOpenTaskTestDB(t) + dao.DB = realDB + t.Cleanup(func() { dao.DB = origDB }) + + realStorage := storage.NewMemoryStorage() + origStorage := storage.GetStorageFactory().GetStorage() + storage.GetStorageFactory().SetStorage(realStorage) + t.Cleanup(func() { storage.GetStorageFactory().SetStorage(origStorage) }) + + templatePath := filepath.Join(taskRepoRoot(t), "internal", "ingestion", "pipeline", "template", "ingestion_pipeline_general.json") + templateBytes, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("read template: %v", err) + } + templateBytes = disableTokenizerEmbeddingForTaskTemplate(t, templateBytes) + + // envelopeDSL is stored verbatim on the canvas (this is what production + // persists), so loadDSLFromCanvas marshals the envelope and Run receives + // it. injectDebugPageCap must unwrap it to find the Parser cpnID. + var envelope struct { + DSL json.RawMessage `json:"dsl"` + } + if err := json.Unmarshal(templateBytes, &envelope); err != nil { + t.Fatalf("unmarshal template envelope: %v", err) + } + var canvasDSL entity.JSONMap + if err := json.Unmarshal(templateBytes, &canvasDSL); err != nil { + t.Fatalf("unmarshal template into canvas dsl: %v", err) + } + + // Discover the Parser cpnID from the inner DSL. + schemas, err := pipeline.ExtractAllComponentParams(envelope.DSL) + if err != nil { + t.Fatalf("ExtractAllComponentParams: %v", err) + } + var parserCpnID string + for _, s := range schemas { + if s.ComponentName == component.ComponentNameParser { + parserCpnID = s.CpnID + } + } + if parserCpnID == "" { + t.Fatal("template has no Parser component") + } + + pdfPath := filepath.Join(taskRepoRoot(t), "internal", "deepdoc", "parser", "pdf", "testdata", "pdfs", "03_multipage.pdf") + pdfBytes, err := os.ReadFile(pdfPath) + if err != nil { + t.Skipf("read pdf fixture %s: %v", pdfPath, err) + return + } + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + tenantID := taskLimit32("it_tenant_" + suffix) + canvasID := taskLimit32("it_canvas_" + suffix) + + if err := realDB.Create(&entity.UserCanvas{ + ID: canvasID, + UserID: tenantID, + Permission: "me", + CanvasCategory: "agent_canvas", + DSL: canvasDSL, + }).Error; err != nil { + t.Fatalf("create user canvas: %v", err) + } + t.Cleanup(func() { _ = realDB.Where("id = ?", canvasID).Delete(&entity.UserCanvas{}).Error }) + + // Explicit "parse all pages" cap (JSON-decoded []any form, the shape the + // parser actually consumes). injectDebugPageCap must respect it and leave + // it untouched, so the parser reads every page of the PDF. + allPages := map[string]any{ + parserCpnID: map[string]any{ + "pdf": map[string]any{ + "pages": []any{[]any{1, 1000000}}, + }, + }, + } + + newDebugCtx := func(parserConfig map[string]any) *TaskContext { + // A canvas-debug (dry-run) context carries no KB: KB.ID == "" is the + // single debug signal. The executor then skips the persist stage and + // injects the debug page cap (see injectDebugPageCap, gated on + // KB.ID == ""). + return &TaskContext{ + Doc: entity.Document{ + ID: "debug-run-1", + KbID: "", + Name: taskStrPtr("03_multipage.pdf"), + Type: "pdf", + ParserConfig: parserConfig, + }, + KB: entity.Knowledgebase{ + ID: "", + TenantID: tenantID, + EmbdID: "embd-1", + }, + Tenant: entity.Tenant{ID: tenantID}, + File: pdfBytes, + PipelineID: canvasID, + } + } + + // Uncapped baseline: explicit pages=all → executor respects override. + uncappedExec, err := NewPipelineExecutor(newDebugCtx(allPages), canvasID, 0) + if err != nil { + t.Fatalf("NewPipelineExecutor (uncapped): %v", err) + } + uncapped, err := uncappedExec.Execute(context.Background()) + if err != nil { + t.Fatalf("Execute (uncapped): %v", err) + } + + // Capped: no pages override → executor injects [[1, debugPageCapPages]]. + cappedExec, err := NewPipelineExecutor(newDebugCtx(nil), canvasID, 0) + if err != nil { + t.Fatalf("NewPipelineExecutor (capped): %v", err) + } + capped, err := cappedExec.Execute(context.Background()) + if err != nil { + t.Fatalf("Execute (capped): %v", err) + } + + uncappedLen := len(joinedChunks(uncapped.Chunks)) + cappedLen := len(joinedChunks(capped.Chunks)) + if uncappedLen == 0 { + t.Fatal("uncapped baseline produced no chunks; fixture/pipeline misconfigured") + } + if cappedLen == 0 { + t.Fatal("pages cap produced no chunks; pages wiring broken") + } + if cappedLen >= uncappedLen { + t.Errorf("pages cap had no effect: capped text len %d >= uncapped %d (pages not honoured via override_params)", cappedLen, uncappedLen) + } +} + +// joinedChunks concatenates the text of every chunk into a single string so +// the page-cap assertion does not depend on how the chunker splits pages. +func joinedChunks(chunks []map[string]any) string { + var b strings.Builder + for _, ck := range chunks { + for _, key := range []string{"content_with_weight", "content", "text"} { + if v, ok := ck[key].(string); ok && v != "" { + b.WriteString(v) + b.WriteString("\n") + break + } + } + } + return b.String() +} diff --git a/internal/ingestion/task/debug_result_dsl.go b/internal/ingestion/task/debug_result_dsl.go new file mode 100644 index 0000000000..284bec3f34 --- /dev/null +++ b/internal/ingestion/task/debug_result_dsl.go @@ -0,0 +1,253 @@ +// +// 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. +// +// This file is the Go analogue of Python's `Graph.__str__` +// (agent/canvas.py:119) used by the dataflow debug "View result" page. +// Python attaches `dsl = json.loads(str(self))` to the END debug-log marker +// (rag/flow/pipeline.py:98); the front-end reads `dsl.components[].obj` +// to render each component's parsed output. Go has no per-component `__str__` +// serialization, so BuildDebugResultDSL combines the STATIC DSL structure +// (component_name / downstream / params / graph.nodes) with the RUN output map +// (state.go:284 `out[] = `) to emit the identical JSON +// shape. Result is a strict subset (UI-relevant fields only) of Python's, so +// the front-end renders chunks with parity and a smaller payload. + +package task + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ResultSink is an OPTIONAL capability a ProgressSink may implement to receive +// the debug-run result DSL. The pipeline executor probes for it via a type +// assertion, so the ProgressSink contract stays unchanged and non-debug +// (DB-backed) sinks simply ignore it — keeping the coupling one-directional. +type ResultSink interface { + SetResult(dsl map[string]any) +} + +// outputFormats is the priority order used to pick a component's payload key, +// mirroring NormalizeChunks (chunk_utils.go:27). +var outputFormats = []string{"chunks", "json", "text", "html", "markdown"} + +// vectorKeys are dropped from payloads while copying. The front-end never +// renders raw vectors and Python's serialized component obj excludes them; +// stripping keeps the Redis-stored debug log at Python-scale size. +var vectorKeys = map[string]struct{}{ + "vector": {}, + "embedding": {}, + "q_vec": {}, + "feature": {}, +} + +// isVectorKey reports whether k is a raw embedding-vector key that must be +// stripped from debug payloads. It matches the fixed legacy keys +// (vector/embedding/feature) AND the dimension-scoped pattern q__vec that +// the tokenizer actually emits (see hasEmbeddingVector, tokenizer.go:828, e.g. +// q_4_vec, q_1024_vec). The literal "q_vec" entry never matches those, so a +// bare map lookup would let real vectors leak into the Redis log. +func isVectorKey(k string) bool { + if _, ok := vectorKeys[k]; ok { + return true + } + return strings.HasPrefix(k, "q_") && strings.HasSuffix(k, "_vec") +} + +// BuildDebugResultDSL builds the `dsl` object the debug-log END marker carries +// so the front-end "View result" page can render each component's output. +// +// dsl is the raw canvas DSL JSON (optionally wrapped as {"dsl": {...}}). output +// is the pipeline run output keyed by component id (output[] is that +// component's outputs map, which may carry chunks/json/text/html/markdown). +func BuildDebugResultDSL(dsl string, output map[string]any) (map[string]any, error) { + var tpl map[string]any + if err := json.Unmarshal([]byte(dsl), &tpl); err != nil { + return nil, fmt.Errorf("BuildDebugResultDSL: unmarshal dsl: %w", err) + } + root := tpl + if nested, ok := tpl["dsl"].(map[string]any); ok { + root = nested + } + + components, ok := root["components"].(map[string]any) + if !ok { + return nil, fmt.Errorf("BuildDebugResultDSL: dsl missing components map") + } + + built := make(map[string]any, len(components)) + for id, raw := range components { + comp, _ := raw.(map[string]any) + if comp == nil { + comp = map[string]any{} + } + + // component_name: prefer the nested obj.component_name (Python shape), + // fall back to a top-level component_name. + name := "" + var staticParams map[string]any + if obj, _ := comp["obj"].(map[string]any); obj != nil { + name, _ = obj["component_name"].(string) + if p, _ := obj["params"].(map[string]any); p != nil { + staticParams = p + } + } + if name == "" { + name, _ = comp["component_name"].(string) + } + + down := comp["downstream"] // preserve as-is (string or []any) + + // Build obj.params: start from a deep copy of the static DSL params + // (setups/field_name/...), then inject the runtime outputs wrapper. + mergedParams := map[string]any{} + for k, v := range staticParams { + mergedParams[k] = deepCopy(v) + } + if format, payload := detectFormat(lookupComponentOutput(output, id)); format != "" { + mergedParams["outputs"] = map[string]any{ + format: map[string]any{ + "value": deepCopyStrip(payload), + "type": "", + }, + "output_format": map[string]any{"value": format}, + } + } + + built[id] = map[string]any{ + "obj": map[string]any{ + "component_name": name, + "params": mergedParams, + }, + "downstream": down, + "component_name": name, + } + } + + result := map[string]any{ + "components": built, + "graph": deepCopy(root["graph"]), + } + return result, nil +} + +// lookupComponentOutput resolves a single component's runtime output map from +// the pipeline run result. +// +// The production `pipe.Run` return value nests every component's outputs under +// output["state"][] — finalizeResult (pipeline.go:636) attaches +// runState.Snapshot() (state.go:296, map[string]map[string]any keyed by cpn +// id), and statePost (scheduler.go:229) writes each component's top-level +// output keys there via SetVar(cpnID, k, v). So the canonical lookup is +// output["state"][id]. +// +// A flat keyed-by-id shape (output[id] directly) is accepted as a fallback so +// this builder stays usable for hand-built outputs in tests and any +// non-Snapshot callers; it is never produced by the real pipeline. +func lookupComponentOutput(output map[string]any, id string) any { + // The production run output nests each component under + // output["state"][] (finalizeResult → runState.Snapshot(), state.go:296). + // Snapshot returns map[string]map[string]any, but some callers build a + // map[string]any-shaped state, so accept BOTH concrete types — a + // single-type assertion would silently fail the real shape and fall through + // to the (usually empty) top-level lookup. + switch state := output["state"].(type) { + case map[string]map[string]any: + if v, ok := state[id]; ok { + return v + } + case map[string]any: + if v, ok := state[id]; ok { + return v + } + } + // Fallback: flat shape (tests / non-Snapshot producers). + return output[id] +} + +// detectFormat returns the output key (chunks/json/text/html/markdown) present +// in a component's output map, by priority, plus the raw payload under it. +// Returns ("", nil) when the component produced no recognized output — the +// front-end then renders that step empty (matching Python's empty obj). +func detectFormat(out any) (string, any) { + m, ok := out.(map[string]any) + if !ok || m == nil { + return "", nil + } + for _, f := range outputFormats { + if v, exists := m[f]; exists && v != nil { + return f, v + } + } + return "", nil +} + +// deepCopy returns a JSON-compatible deep copy of v (maps/slices/primitives), +// preserving structure but sharing nothing mutable with the source. +func deepCopy(v any) any { + switch val := v.(type) { + case map[string]any: + cp := make(map[string]any, len(val)) + for k, vv := range val { + cp[k] = deepCopy(vv) + } + return cp + case []map[string]any: + cp := make([]any, len(val)) + for i, vv := range val { + cp[i] = deepCopy(vv) + } + return cp + case []any: + cp := make([]any, len(val)) + for i, vv := range val { + cp[i] = deepCopy(vv) + } + return cp + default: + return v + } +} + +// deepCopyStrip is deepCopy plus dropping vectorKeys from every map it visits. +// Used for component payloads so raw embedding vectors never reach the log. +func deepCopyStrip(v any) any { + switch val := v.(type) { + case map[string]any: + cp := make(map[string]any, len(val)) + for k, vv := range val { + if isVectorKey(k) { + continue + } + cp[k] = deepCopyStrip(vv) + } + return cp + case []map[string]any: + cp := make([]any, len(val)) + for i, vv := range val { + cp[i] = deepCopyStrip(vv) + } + return cp + case []any: + cp := make([]any, len(val)) + for i, vv := range val { + cp[i] = deepCopyStrip(vv) + } + return cp + default: + return v + } +} diff --git a/internal/ingestion/task/debug_result_dsl_test.go b/internal/ingestion/task/debug_result_dsl_test.go new file mode 100644 index 0000000000..f443fba339 --- /dev/null +++ b/internal/ingestion/task/debug_result_dsl_test.go @@ -0,0 +1,430 @@ +// +// 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 task + +import ( + "testing" +) + +// TestBuildDebugResultDSL locks the contract that the Go debug log's END-marker +// `dsl` must satisfy so the front-end "View result" page renders parsed chunks. +// +// The shape is the faithful Go analogue of Python's `Graph.__str__` +// (agent/canvas.py:119) + the END-marker `dsl` attachment (rag/flow/pipeline.py:98): +// for every component in the DSL we emit `dsl.components[].obj` carrying +// `component_name` and `params.outputs[].value`, plus `downstream` and +// `graph.nodes[].data.name`. The front-end reads exactly these keys +// (web/src/pages/dataflow-result/parser.tsx:41, hooks.ts:215). +func TestBuildDebugResultDSL(t *testing.T) { + const ( + compA = "my.Parser" + compB = "my.Tokenizer" + ) + dsl := `{ + "dsl": { + "components": { + "begin": {"obj": {"component_name": "Begin", "params": {}}, "downstream": ["a"]}, + "a": {"obj": {"component_name": "` + compA + `", "params": {"setups": {"pdf": {"parse_method": "general"}}}}, "upstream": ["begin"], "downstream": ["b"]}, + "b": {"obj": {"component_name": "` + compB + `", "params": {"field_name": "content"}}, "upstream": ["a"]} + }, + "graph": {"nodes": [ + {"id": "begin", "data": {"name": "开始"}}, + {"id": "a", "data": {"name": "解析"}}, + {"id": "b", "data": {"name": "分词"}} + ]} + } + }` + + // Run output: keyed by component id (state.go:284 `out[ref]=v`). `a` emits + // chunks, `b` emits plain text, `begin` has no recognized output key. + output := map[string]any{ + "a": map[string]any{ + "chunks": []any{ + map[string]any{"text": "hello", "vector": []float64{0.1, 0.2}}, + }, + }, + "b": map[string]any{"text": "plain"}, + } + + result, err := BuildDebugResultDSL(dsl, output) + if err != nil { + t.Fatalf("BuildDebugResultDSL: %v", err) + } + + components, ok := result["components"].(map[string]any) + if !ok { + t.Fatalf("result.components missing or wrong type: %#v", result["components"]) + } + if len(components) != 3 { + t.Fatalf("want 3 components (begin,a,b), got %d: %#v", len(components), components) + } + + // --- component "a": must carry chunks under params.outputs --- + a, ok := components["a"].(map[string]any) + if !ok { + t.Fatalf("components.a wrong type: %#v", components["a"]) + } + aObj, ok := a["obj"].(map[string]any) + if !ok { + t.Fatalf("components.a.obj wrong type: %#v", a["obj"]) + } + if name, _ := aObj["component_name"].(string); name != compA { + t.Errorf("components.a.obj.component_name=%v want %q", name, compA) + } + // downstream preserved + if down, _ := a["downstream"].(string); down != "b" { + // accept []any form too + if dl, _ := a["downstream"].([]any); len(dl) != 1 || dl[0] != "b" { + t.Errorf("components.a.downstream=%v want [b]", a["downstream"]) + } + } + aParams, ok := aObj["params"].(map[string]any) + if !ok { + t.Fatalf("components.a.obj.params wrong type: %#v", aObj["params"]) + } + aOutputs, ok := aParams["outputs"].(map[string]any) + if !ok { + t.Fatalf("components.a.obj.params.outputs wrong type: %#v", aParams["outputs"]) + } + // output_format selects "chunks" + of, ok := aOutputs["output_format"].(map[string]any) + if !ok || of["value"] != "chunks" { + t.Errorf("components.a output_format=%#v want {value:\"chunks\"}", aOutputs["output_format"]) + } + chunksOut, ok := aOutputs["chunks"].(map[string]any) + if !ok { + t.Fatalf("components.a outputs.chunks wrong type: %#v", aOutputs["chunks"]) + } + chunksVal, _ := chunksOut["value"].([]any) + if len(chunksVal) != 1 { + t.Fatalf("components.a outputs.chunks.value len=%d want 1", len(chunksVal)) + } + chunk0, _ := chunksVal[0].(map[string]any) + if chunk0["text"] != "hello" { + t.Errorf("chunk text=%v want hello", chunk0["text"]) + } + // VECTOR STRIPPING: Python's serialized obj excludes raw vectors; verify gone. + if _, exists := chunk0["vector"]; exists { + t.Errorf("vector key must be stripped from chunk payload, but it is present: %#v", chunk0) + } + // static params (setups) carried through, matching Python's cpn["params"] copy. + if setups, _ := aParams["setups"].(map[string]any); setups == nil { + t.Errorf("static params.setups must be carried from DSL, got %#v", aParams) + } + + // --- component "b": plain text format --- + b, _ := components["b"].(map[string]any) + bObj, _ := b["obj"].(map[string]any) + bParams, _ := bObj["params"].(map[string]any) + bOutputs, _ := bParams["outputs"].(map[string]any) + if bOutputs["output_format"].(map[string]any)["value"] != "text" { + t.Errorf("components.b output_format=%#v want {value:\"text\"}", bOutputs["output_format"]) + } + // static params.field_name carried (used by front-end chunk text key). + if fn, _ := bParams["field_name"].(string); fn != "content" { + t.Errorf("components.b params.field_name=%v want content", fn) + } + + // --- component "begin": no recognized output -> outputs empty, safe --- + begin, _ := components["begin"].(map[string]any) + beginObj, _ := begin["obj"].(map[string]any) + beginParams, _ := beginObj["params"].(map[string]any) + if _, exists := beginParams["outputs"]; exists { + t.Errorf("begin has no run output, outputs must be absent: %#v", beginParams) + } + + // --- graph.nodes copied through --- + graph, ok := result["graph"].(map[string]any) + if !ok { + t.Fatalf("result.graph missing: %#v", result["graph"]) + } + nodes, _ := graph["nodes"].([]any) + if len(nodes) != 3 { + t.Fatalf("graph.nodes len=%d want 3", len(nodes)) + } +} + +// TestBuildDebugResultDSL_NestedState locks the REAL pipeline output shape. +// +// Unlike TestBuildDebugResultDSL (which uses a flat keyed-by-id map for +// readability), the production `pipe.Run` return value nests each component's +// outputs under output["state"][] — see finalizeResult +// (pipeline.go:636, `merged["state"] = runState.Snapshot()`) and +// CanvasState.Snapshot() (state.go:296), which returns +// map[string]map[string]any keyed by cpn id. statePost (scheduler.go:229) +// writes each component's top-level output keys there via SetVar(cpnID, k, v). +// +// A prior bug read output[id] at the TOP level, which is always nil for the +// real run, so every component's params.outputs stayed empty and the front-end +// "View result" rendered blank tabs. This test pins the nested lookup so that +// regression cannot recur. +func TestBuildDebugResultDSL_NestedState(t *testing.T) { + const ( + compA = "my.Parser" + compB = "my.Tokenizer" + ) + dsl := `{ + "dsl": { + "components": { + "begin": {"obj": {"component_name": "Begin", "params": {}}, "downstream": ["a"]}, + "a": {"obj": {"component_name": "` + compA + `", "params": {"setups": {"pdf": {"parse_method": "general"}}}}, "upstream": ["begin"], "downstream": ["b"]}, + "b": {"obj": {"component_name": "` + compB + `", "params": {"field_name": "content"}}, "upstream": ["a"]} + }, + "graph": {"nodes": [ + {"id": "begin", "data": {"name": "开始"}}, + {"id": "a", "data": {"name": "解析"}}, + {"id": "b", "data": {"name": "分词"}} + ]} + } + }` + + // REAL shape: Snapshot() returns map[string]map[string]any (state.go:296), + // so state is typed map[string]map[string]any at runtime — NOT + // map[string]any. Using the real type here is what makes this test a true + // regression guard: a single-type assertion in lookupComponentOutput would + // pass a map[string]any-shaped test yet fail the production output. + output := map[string]any{ + "state": map[string]map[string]any{ + "a": { + "chunks": []any{ + map[string]any{"text": "hello", "vector": []float64{0.1, 0.2}}, + }, + }, + "b": {"text": "plain"}, + }, + } + + result, err := BuildDebugResultDSL(dsl, output) + if err != nil { + t.Fatalf("BuildDebugResultDSL: %v", err) + } + components, ok := result["components"].(map[string]any) + if !ok { + t.Fatalf("result.components missing: %#v", result["components"]) + } + + // "a" must carry chunks under params.outputs even though the run output is + // nested under output["state"]["a"]. + aObj := components["a"].(map[string]any)["obj"].(map[string]any) + aParams := aObj["params"].(map[string]any) + aOutputs, ok := aParams["outputs"].(map[string]any) + if !ok { + t.Fatalf("NESTED-SHAPE REGRESSION: components.a params.outputs missing; "+ + "BuildDebugResultDSL must read output[\"state\"][id], got params=%#v", aParams) + } + if of, _ := aOutputs["output_format"].(map[string]any); of["value"] != "chunks" { + t.Errorf("components.a output_format=%#v want {value:\"chunks\"}", aOutputs["output_format"]) + } + chunksVal := aOutputs["chunks"].(map[string]any)["value"].([]any) + if len(chunksVal) != 1 { + t.Fatalf("components.a outputs.chunks.value len=%d want 1", len(chunksVal)) + } + chunk0 := chunksVal[0].(map[string]any) + if chunk0["text"] != "hello" { + t.Errorf("chunk text=%v want hello", chunk0["text"]) + } + if _, exists := chunk0["vector"]; exists { + t.Errorf("vector key must be stripped, but present: %#v", chunk0) + } + + // "b": plain text nested under output["state"]["b"]. + bParams := components["b"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any) + bOutputs, ok := bParams["outputs"].(map[string]any) + if !ok { + t.Fatalf("NESTED-SHAPE REGRESSION: components.b params.outputs missing; "+ + "got params=%#v", bParams) + } + if of, _ := bOutputs["output_format"].(map[string]any); of["value"] != "text" { + t.Errorf("components.b output_format=%#v want {value:\"text\"}", bOutputs["output_format"]) + } +} + +// TestLookupComponentOutput locks the resolution rules of +// lookupComponentOutput: the production run output nests each component under +// output["state"][] (finalizeResult + Snapshot), so that is the primary +// lookup; a flat keyed-by-id shape at the top level is the documented fallback +// for tests and any non-Snapshot caller. These branches are defensive and must +// keep behaving if the run-output envelope changes. +func TestLookupComponentOutput(t *testing.T) { + const id = "a" + + // 1. nested present -> returns the nested value (primary path). + out1 := map[string]any{"state": map[string]any{id: map[string]any{"chunks": "x"}}} + if got := lookupComponentOutput(out1, id); got == nil { + t.Errorf("case1: nested present, want non-nil, got nil") + } + + // 2. nested present but NOT a map -> must fall back to top-level. + out2 := map[string]any{"state": "not-a-map", id: map[string]any{"text": "y"}} + if got := lookupComponentOutput(out2, id); got == nil { + t.Errorf("case2: state wrong type, must fall back to top-level, got nil") + } else if m, _ := got.(map[string]any); m["text"] != "y" { + t.Errorf("case2: fallback got %#v want {text:\"y\"}", got) + } + + // 3. nested present, id missing in state but present at top-level -> fallback. + out3 := map[string]any{"state": map[string]any{"other": map[string]any{}}, id: map[string]any{"text": "z"}} + if got := lookupComponentOutput(out3, id); got == nil { + t.Errorf("case3: id missing in state, must fall back to top-level, got nil") + } else if m, _ := got.(map[string]any); m["text"] != "z" { + t.Errorf("case3: fallback got %#v want {text:\"z\"}", got) + } + + // 4. nested present, id missing everywhere -> nil (safe empty). + out4 := map[string]any{"state": map[string]any{"other": map[string]any{}}} + if got := lookupComponentOutput(out4, id); got != nil { + t.Errorf("case4: id missing everywhere, want nil, got %#v", got) + } + + // 5. no "state" key at all -> flat top-level lookup. + out5 := map[string]any{id: map[string]any{"json": "j"}} + if got := lookupComponentOutput(out5, id); got == nil { + t.Errorf("case5: flat-only, want non-nil, got nil") + } +} + +// TestDeepCopyStrip_StripsVectorsFromMapSlice locks the regression for review +// comment #5: real component chunk output is []map[string]any (as produced by +// ChunkDocsToMaps), which the type switch MUST recurse into AND strip vectorKeys +// from. Previously []map[string]any fell into the default branch — no recursion, +// no stripping — so embedding vectors leaked into the Redis debug log and the +// copy shared mutable state with the source. +func TestDeepCopyStrip_StripsVectorsFromMapSlice(t *testing.T) { + src := []map[string]any{ + { + "text": "real chunk", + "vector": []float64{0.1, 0.2}, + "embedding": []float64{0.3}, + "q_1024_vec": []float64{0.7}, + "nested": map[string]any{"q_vec": []float64{0.9}, "q_4_vec": []float64{0.8}, "keep": "x"}, + }, + {"text": "second", "feature": []float64{0.4}}, + } + + got := deepCopyStrip(src) + + // Returned slice must be a deep copy (new []any holding new maps), not the + // original slice/map identity. + cp, ok := got.([]any) + if !ok { + t.Fatalf("deepCopyStrip returned %T, want []any", got) + } + if len(cp) != 2 { + t.Fatalf("len=%d want 2", len(cp)) + } + + // First chunk: vector & embedding dropped, text kept, nested vector dropped. + c0, ok := cp[0].(map[string]any) + if !ok { + t.Fatalf("element 0 type %T, want map[string]any", cp[0]) + } + if _, exists := c0["vector"]; exists { + t.Errorf("vector must be stripped, but present: %#v", c0) + } + if _, exists := c0["embedding"]; exists { + t.Errorf("embedding must be stripped, but present: %#v", c0) + } + if _, exists := c0["q_1024_vec"]; exists { + t.Errorf("q_1024_vec (dimension-scoped vector key) must be stripped, but present: %#v", c0) + } + if c0["text"] != "real chunk" { + t.Errorf("text=%v want 'real chunk'", c0["text"]) + } + nested, _ := c0["nested"].(map[string]any) + if _, exists := nested["q_vec"]; exists { + t.Errorf("nested q_vec must be stripped, but present: %#v", nested) + } + if _, exists := nested["q_4_vec"]; exists { + t.Errorf("nested q_4_vec (dimension-scoped vector key) must be stripped, but present: %#v", nested) + } + if nested["keep"] != "x" { + t.Errorf("nested.keep=%v want x", nested["keep"]) + } + + // Second chunk: feature (a vectorKey) stripped, text kept. + c1, ok := cp[1].(map[string]any) + if !ok { + t.Fatalf("element 1 type %T, want map[string]any", cp[1]) + } + if _, exists := c1["feature"]; exists { + t.Errorf("feature must be stripped, but present: %#v", c1) + } + if c1["text"] != "second" { + t.Errorf("text=%v want 'second'", c1["text"]) + } + + // Mutation isolation: mutating the copy must not touch the source. + c0["text"] = "mutated" + if src[0]["text"] != "real chunk" { + t.Errorf("copy mutation leaked into source: %#v", src[0]) + } +} + +// TestDeepCopy_MapSliceIsDeep locks that deepCopy also recurses []map[string]any +// (structure preservation, no vector-strip concern for plain deepCopy). +func TestDeepCopy_MapSliceIsDeep(t *testing.T) { + src := []map[string]any{{"text": "a", "n": map[string]any{"v": 1}}} + got := deepCopy(src) + cp, ok := got.([]any) + if !ok { + t.Fatalf("deepCopy returned %T, want []any", got) + } + m, ok := cp[0].(map[string]any) + if !ok { + t.Fatalf("element type %T, want map[string]any", cp[0]) + } + if m["text"] != "a" { + t.Errorf("text=%v want a", m["text"]) + } + if n, _ := m["n"].(map[string]any); n["v"] != 1 { + t.Errorf("nested not copied: %#v", m["n"]) + } + // isolate + m["text"] = "mut" + if src[0]["text"] != "a" { + t.Errorf("deepCopy shares mutable state: %#v", src[0]) + } +} + +// TestDetectFormat_Priority locks the format selection order +// (chunks > json > text > html > markdown) used to pick a component's payload +// key. A component that emits multiple recognized keys must surface the +// highest-priority one, matching NormalizeChunks and the front-end tab render. +func TestDetectFormat_Priority(t *testing.T) { + cases := []struct { + name string + in map[string]any + want string + }{ + {"chunks beats text", map[string]any{"text": "t", "chunks": "c"}, "chunks"}, + {"json beats text", map[string]any{"text": "t", "json": "j"}, "json"}, + {"text beats html", map[string]any{"html": "h", "text": "t"}, "text"}, + {"html beats markdown", map[string]any{"markdown": "m", "html": "h"}, "html"}, + {"markdown alone", map[string]any{"markdown": "m"}, "markdown"}, + {"only unrecognized -> empty", map[string]any{"ok": true}, ""}, + {"empty map -> empty", map[string]any{}, ""}, + {"nil -> empty", nil, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, _ := detectFormat(c.in) + if got != c.want { + t.Errorf("detectFormat(%#v) = %q, want %q", c.in, got, c.want) + } + }) + } +} diff --git a/internal/ingestion/task/debug_test.go b/internal/ingestion/task/debug_test.go new file mode 100644 index 0000000000..d39681f097 --- /dev/null +++ b/internal/ingestion/task/debug_test.go @@ -0,0 +1,254 @@ +// +// 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 task + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "gorm.io/gorm" + "ragflow/internal/entity" + "ragflow/internal/ingestion/component" + "ragflow/internal/ingestion/pipeline" +) + +// TestNewDebugTaskContext_InjectsDebugID asserts the debug context carries the +// side-effect-free markers: a fresh non-empty Doc.ID (a throwaway uuid, not a +// persisted row) and an empty KB (debug has no knowledgebase, so kb_id == "" is +// the debug signal used across the pipeline). The parser page cap is no longer +// stored as a flat ParserConfig key here — flat keys are dropped by the +// override_params merge and never reach the parser. It is injected at run time +// by injectDebugPageCap via Run's override_params channel (see +// TestInjectDebugPageCap). +func TestNewDebugTaskContext_InjectsDebugID(t *testing.T) { + taskCtx := NewDebugTaskContext("t1", "canvas-1", "doc.pdf", []byte("page one\fpage two\fpage three")) + + if taskCtx.Doc.ID == "" { + t.Errorf("Doc.ID = %q, want non-empty (uuid)", taskCtx.Doc.ID) + } + if taskCtx.Doc.ParserConfig != nil { + t.Errorf("Doc.ParserConfig = %v, want nil (debug page cap is injected via override_params, not a flat ParserConfig key)", taskCtx.Doc.ParserConfig) + } + if taskCtx.Doc.KbID != "" { + t.Errorf("Doc.KbID = %q, want empty (debug has no KB)", taskCtx.Doc.KbID) + } + if taskCtx.KB.ID != "" { + t.Errorf("KB.ID = %q, want empty (debug has no KB)", taskCtx.KB.ID) + } + if taskCtx.Tenant.ID != "t1" { + t.Errorf("Tenant.ID = %q, want t1", taskCtx.Tenant.ID) + } + if taskCtx.PipelineID != "canvas-1" { + t.Errorf("PipelineID = %q, want canvas-1", taskCtx.PipelineID) + } +} + +// TestExecute_DebugViaEntry proves the entry-point constructor produces a +// valid debug TaskContext that routes through PipelineExecutor.Execute and +// returns the pipeline's chunks WITHOUT persisting (no index insert, no +// pipeline log). +func TestExecute_DebugViaEntry(t *testing.T) { + taskCtx := NewDebugTaskContext("t1", "canvas-1", "doc.pdf", []byte("page one\fpage two\fpage three")) + + logCalled := false + insertCalled := false + + exec, err := NewPipelineExecutor(taskCtx, "canvas-1", 0) + if err != nil { + t.Fatalf("NewPipelineExecutor: %v", err) + } + exec. + WithLoadDSLFunc(func(ctx context.Context, canvasID string) (string, string, error) { + return "dsl", "canvas-1", nil + }). + WithRunPipelineFunc(func(ctx context.Context, dsl string) (map[string]any, string, error) { + return map[string]any{ + "chunks": []map[string]any{ + {"text": "c1"}, + {"text": "c2"}, + {"text": "c3"}, + {"text": "c4"}, + }, + }, dsl, nil + }). + WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { + logCalled = true + return nil + }). + WithInsertFunc(func(ctx context.Context, chunks []map[string]any, baseName, datasetID string) ([]string, error) { + insertCalled = true + return nil, nil + }) + + result, err := exec.Execute(context.Background()) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result == nil { + t.Fatal("result is nil") + } + if len(result.Chunks) != 4 { + t.Errorf("len(result.Chunks) = %d, want 4", len(result.Chunks)) + } + if logCalled { + t.Error("pipeline log should NOT be created in a debug (kb_id == \"\") run") + } + if insertCalled { + t.Error("chunk insert should NOT be called in a debug (kb_id == \"\") run") + } +} + +// TestInjectDebugPageCap verifies the canvas-debug page cap is delivered +// through Run's override_params channel (the existing ParserConfig shape), +// NOT through pipeline inputs. The cap must land at +// ParserConfig[cpnID][family]["pages"], expressed as the JSON-decoded +// []any{[]any{1, N}} form (a list of [from,to] pairs) — cpnID is the Parser +// component's instance id from the DSL and family is the document's filetype +// family. This is the exact shape NormalizeParserConfigPages produces after a +// storage JSON round-trip and the shape the deepdoc pdf parser consumes +// (NormalizePDFPages requires []any, not a Go [][]int). +// +// It also pins the regression: a flat top-level "pages" key would be dropped +// by the override_params merge and never reach the parser, so the cap must be +// nested under the cpnID. +func TestInjectDebugPageCap(t *testing.T) { + templatePath := filepath.Join(taskRepoRoot(t), "internal", "ingestion", "pipeline", "template", "ingestion_pipeline_general.json") + raw, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("read template: %v", err) + } + var envelope struct { + DSL json.RawMessage `json:"dsl"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("unmarshal template envelope: %v", err) + } + dsl := string(envelope.DSL) + + // Discover the Parser cpnID the same way the executor does. + schemas, err := pipeline.ExtractAllComponentParams(envelope.DSL) + if err != nil { + t.Fatalf("ExtractAllComponentParams: %v", err) + } + var parserCpnID string + for _, s := range schemas { + if s.ComponentName == component.ComponentNameParser { + parserCpnID = s.CpnID + } + } + if parserCpnID == "" { + t.Fatal("template has no Parser component") + } + + t.Run("pdf injects [1,2] under cpnID+family", func(t *testing.T) { + parserConfig := map[string]any{} + injectDebugPageCap(dsl, parserConfig, "pdf") + famEntry, ok := parserConfig[parserCpnID].(map[string]any) + if !ok { + t.Fatalf("parserConfig[%q] = %T, want map[string]any", parserCpnID, parserConfig[parserCpnID]) + } + pdf, ok := famEntry["pdf"].(map[string]any) + if !ok { + t.Fatalf("parserConfig[%q][\"pdf\"] = %T, want map[string]any", parserCpnID, famEntry["pdf"]) + } + if !reflect.DeepEqual(pdf["pages"], []any{[]any{1, debugPageCapPages}}) { + t.Errorf("parserConfig[%q][\"pdf\"][\"pages\"] = %v, want [[1, %d]]", parserCpnID, pdf["pages"], debugPageCapPages) + } + }) + + t.Run("docx injects under docx family", func(t *testing.T) { + parserConfig := map[string]any{} + injectDebugPageCap(dsl, parserConfig, "docx") + famEntry, ok := parserConfig[parserCpnID].(map[string]any) + if !ok { + t.Fatalf("parserConfig[%q] = %T, want map[string]any", parserCpnID, parserConfig[parserCpnID]) + } + docx, ok := famEntry["docx"].(map[string]any) + if !ok { + t.Fatalf("parserConfig[%q][\"docx\"] = %T, want map[string]any", parserCpnID, famEntry["docx"]) + } + if !reflect.DeepEqual(docx["pages"], []any{[]any{1, debugPageCapPages}}) { + t.Errorf("parserConfig[%q][\"docx\"][\"pages\"] = %v, want [[1, %d]]", parserCpnID, docx["pages"], debugPageCapPages) + } + }) + + t.Run("empty docType is a no-op", func(t *testing.T) { + parserConfig := map[string]any{} + injectDebugPageCap(dsl, parserConfig, "") + if len(parserConfig) != 0 { + t.Errorf("parserConfig = %v, want empty (no family derivable from empty docType)", parserConfig) + } + }) + + t.Run("does not clobber existing parser params", func(t *testing.T) { + parserConfig := map[string]any{ + parserCpnID: map[string]any{ + "pdf": map[string]any{"parse_method": "deepdoc"}, + }, + } + injectDebugPageCap(dsl, parserConfig, "pdf") + famEntry := parserConfig[parserCpnID].(map[string]any) + pdf := famEntry["pdf"].(map[string]any) + if pdf["parse_method"] != "deepdoc" { + t.Errorf("parserConfig[%q][\"pdf\"][\"parse_method\"] = %v, want deepdoc (existing params must be preserved)", parserCpnID, pdf["parse_method"]) + } + if !reflect.DeepEqual(pdf["pages"], []any{[]any{1, debugPageCapPages}}) { + t.Errorf("parserConfig[%q][\"pdf\"][\"pages\"] = %v, want [[1, %d]]", parserCpnID, pdf["pages"], debugPageCapPages) + } + }) + + t.Run("envelope dsl form (production shape)", func(t *testing.T) { + // In production dsl is the canvas envelope {"dsl": {"components": ...}}, + // not the bare components map. injectDebugPageCap must still find the + // Parser cpnID after unwrapping. + wrapped := fmt.Sprintf(`{"dsl":%s}`, string(envelope.DSL)) + parserConfig := map[string]any{} + injectDebugPageCap(wrapped, parserConfig, "pdf") + famEntry, ok := parserConfig[parserCpnID].(map[string]any) + if !ok { + t.Fatalf("parserConfig[%q] = %T, want map[string]any (envelope dsl must unwrap)", parserCpnID, parserConfig[parserCpnID]) + } + pdf, ok := famEntry["pdf"].(map[string]any) + if !ok { + t.Fatalf("parserConfig[%q][\"pdf\"] = %T, want map[string]any", parserCpnID, famEntry["pdf"]) + } + if !reflect.DeepEqual(pdf["pages"], []any{[]any{1, debugPageCapPages}}) { + t.Errorf("parserConfig[%q][\"pdf\"][\"pages\"] = %v, want [[1, %d]] (envelope dsl not unwrapped?)", parserCpnID, pdf["pages"], debugPageCapPages) + } + }) + + t.Run("respects explicit caller-supplied cap", func(t *testing.T) { + // When the document already carries an explicit cpnID+family page cap, + // the debug default must NOT override it (so a wider/narrower cap wins). + parserConfig := map[string]any{ + parserCpnID: map[string]any{ + "pdf": map[string]any{"pages": []any{[]any{1, 1000000}}}, + }, + } + injectDebugPageCap(dsl, parserConfig, "pdf") + famEntry := parserConfig[parserCpnID].(map[string]any) + pdf := famEntry["pdf"].(map[string]any) + if !reflect.DeepEqual(pdf["pages"], []any{[]any{1, 1000000}}) { + t.Errorf("parserConfig[%q][\"pdf\"][\"pages\"] = %v, want [[1, 1000000]] (explicit cap must be respected, not overridden by debug default)", parserCpnID, pdf["pages"]) + } + }) +} diff --git a/internal/ingestion/task/pipeline_executor.go b/internal/ingestion/task/pipeline_executor.go index dfd187a550..f8a3fbd5b5 100644 --- a/internal/ingestion/task/pipeline_executor.go +++ b/internal/ingestion/task/pipeline_executor.go @@ -28,6 +28,7 @@ import ( "ragflow/internal/dao" "ragflow/internal/engine" "ragflow/internal/entity" + "ragflow/internal/ingestion/component" "ragflow/internal/ingestion/knowledge_compile" pipelinepkg "ragflow/internal/ingestion/pipeline" @@ -41,9 +42,14 @@ type PipelineResult struct { DocID string KbID string Metadata map[string]any + Chunks []map[string]any // populated only in debug (dry-run) mode ChunkCount int TokenConsumption int Duration float64 // pipeline wall-clock seconds + // MessageID is the polling key for the debug-run log. The front-end reads + // it from the run response and polls GET /agents/:id/logs/:message_id to + // render progress; it is empty for non-debug (persist) runs. + MessageID string } type PipelineExecutor struct { @@ -66,15 +72,14 @@ func validateTaskContext(taskCtx *TaskContext) error { if taskCtx.Doc.ID == "" { return fmt.Errorf("pipeline executor: empty document id") } - if taskCtx.Doc.KbID == "" { + // A debug (dry-run) context carries no knowledgebase (see + // TaskContext.IsDebug), so it must not be required to supply one. + if !taskCtx.IsDebug() && taskCtx.Doc.KbID == "" { return fmt.Errorf("pipeline executor: empty document knowledgebase id") } if taskCtx.Doc.Name == nil || *taskCtx.Doc.Name == "" { return fmt.Errorf("pipeline executor: empty document name") } - if taskCtx.KB.ID == "" { - return fmt.Errorf("pipeline executor: empty knowledgebase id") - } if taskCtx.Tenant.ID == "" { return fmt.Errorf("pipeline executor: empty tenant id") } @@ -170,9 +175,10 @@ func (s *PipelineExecutor) Execute(ctx context.Context) (*PipelineResult, error) return nil, err } - if s.taskCtx.Doc.ID == CANVAS_DEBUG_DOC_ID { - s.recordPipelineLog(ctx, dao.DB, s.taskCtx.Doc.ID, pipelineDSL, "done") - return nil, nil + // A debug (dry-run) run produces no persistent side effect (no MinIO + // image upload, no index insert, no pipeline log); see TaskContext.IsDebug. + if s.taskCtx.IsDebug() { + return s.collectDebugOutput(ctx, pipelineOutput, start) } result, err := s.processOutput(ctx, pipelineOutput, start) @@ -187,6 +193,22 @@ func (s *PipelineExecutor) Execute(ctx context.Context) (*PipelineResult, error) return result, nil } +// collectDebugOutput builds a PipelineResult for a debug (dry-run) run. +// It surfaces the pipeline's chunks so a debug endpoint can render them, but +// performs no DB/index writes — the embedding vectors already computed by the +// pipeline run are left on the chunks. This keeps debug runs side-effect free. +func (s *PipelineExecutor) collectDebugOutput(ctx context.Context, pipelineOutput map[string]any, start time.Time) (*PipelineResult, error) { + chunks := NormalizeChunks(pipelineOutput) + return &PipelineResult{ + DocID: s.taskCtx.Doc.ID, + KbID: s.taskCtx.Doc.KbID, + Chunks: chunks, + ChunkCount: countDistinctChunkIDs(chunks), + TokenConsumption: GetEmbeddingTokenConsumption(pipelineOutput), + Duration: time.Since(start).Seconds(), + }, nil +} + func (s *PipelineExecutor) processOutput(ctx context.Context, pipelineOutput map[string]any, start time.Time) (*PipelineResult, error) { if pipelineOutput == nil { return nil, nil @@ -375,6 +397,12 @@ func (s *PipelineExecutor) runPipelineWithDSL(ctx context.Context, dsl string) ( } parserConfig := map[string]interface{}(s.taskCtx.Doc.ParserConfig) + if parserConfig == nil { + // Debug (dataflow dry-run) contexts intentionally carry no + // ParserConfig; start from an empty map so the debug page cap can be + // injected in place below without a nil-map assignment panic. + parserConfig = map[string]interface{}{} + } common.InjectExtractorLLMID(parserConfig, s.taskCtx.Tenant.LLMID) // When the dataset enables auto-metadata, ensure the Extractor node(s) // carry the enable_metadata mode + field schema so the LLM extraction fires @@ -404,15 +432,51 @@ func (s *PipelineExecutor) runPipelineWithDSL(ctx context.Context, dsl string) ( if s.taskCtx.Doc.ID != "" { inputs["doc_id"] = s.taskCtx.Doc.ID } - if s.taskCtx.File != nil { - inputs["file"] = s.taskCtx.File - } + // Run-level metadata shared by both persist and debug (dataflow + // dry-run) runs. In debug the KB is absent (NewDebugTaskContext forces + // KB.ID == ""); in persist it is the document's own KB. Either way the + // Tokenizer reads kb_id from CanvasState.Globals. inputs["tenant_id"] = s.taskCtx.Tenant.ID inputs["kb_id"] = s.taskCtx.KB.ID if s.taskCtx.KB.Language != nil { inputs["lang"] = *s.taskCtx.KB.Language } + // File delivery and doc metadata differ between the two run modes. + debug := s.taskCtx.IsDebug() + if debug { + // A debug (dry-run) run has no DB document row, so the parser + // cannot resolve its bytes via doc_id → storage. Deliver the + // uploaded bytes directly as `binary` (what the parser actually + // reads) and surface the doc name/type so family detection works. + if s.taskCtx.File != nil { + inputs["file"] = s.taskCtx.File + inputs["binary"] = s.taskCtx.File + } + if s.taskCtx.Doc.Name != nil && *s.taskCtx.Doc.Name != "" { + inputs["name"] = *s.taskCtx.Doc.Name + } + if s.taskCtx.Doc.Type != "" { + inputs["file_type"] = s.taskCtx.Doc.Type + } + } else { + if s.taskCtx.File != nil { + inputs["file"] = s.taskCtx.File + } + } + + // A canvas-debug (dataflow dry-run) must return a fast preview, so it + // caps the parser to the first few pages. The cap is delivered through + // override_params (Run's 3rd argument) — the SAME channel the + // production ParserConfig uses — keyed by the Parser component's cpnID + // and the document's filetype family. It is NOT passed through pipeline + // inputs: the parser selects pages from ParserConfig[cpnID][family] + // ["pages"] (a list of 1-indexed inclusive ranges), exactly mirroring + // NormalizeParserConfigPages / pdf_pages_test.go. See injectDebugPageCap. + if debug { + injectDebugPageCap(dsl, parserConfig, s.taskCtx.Doc.Type) + } + // Component params from Doc.ParserConfig — including the tenant LLM id // injected into Extractor components above — are passed to Run as // override_params, keyed by cpnID with override-wins. The DSL itself is @@ -421,9 +485,105 @@ func (s *PipelineExecutor) runPipelineWithDSL(ctx context.Context, dsl string) ( if err != nil { return nil, dsl, err } + + // Surface the debug-run result DSL to any sink that implements ResultSink + // (the DebugLogSink used by canvas-debug runs). This mirrors Python's + // END-marker `dsl` attachment (rag/flow/pipeline.py:98) so the front-end + // "View result" page can render parsed chunks. The probe is an optional + // capability: non-debug (DB-backed) sinks ignore it and the ProgressSink + // contract is unchanged, keeping the coupling one-directional. + if rs, ok := s.progressSink.(ResultSink); ok { + if resultDSL, e := BuildDebugResultDSL(dsl, output); e == nil { + rs.SetResult(resultDSL) + } + } + payload, err := pipelinepkg.ExtractPayload(dsl, output) if err != nil { return nil, dsl, err } return payload, dsl, nil } + +// debugPageCapPages is the number of leading pages a canvas-debug +// (dataflow dry-run) parses. The debug preview must return fast, so we cap +// the parser to the first few pages. The cap is expressed as the 1-indexed +// inclusive range [1, debugPageCapPages], matching the production +// ParserConfig[cpnID][filetype]["pages"] shape (see NormalizeParserConfigPages). +const debugPageCapPages = 2 + +// injectDebugPageCap wires the canvas-debug page cap into parserConfig using +// the SAME channel the production ParserConfig travels through: Run's +// override_params (the 3rd argument), keyed by the Parser component's cpnID +// and the document's filetype family. It must NOT be passed as a pipeline +// input — the parser selects pages from ParserConfig[cpnID][family]["pages"], +// which the deepdoc/pdf parser consumes as a list of page ranges. +// +// dsl is the raw pipeline DSL (used only to discover the Parser component's +// cpnID); docType is the uploaded file's extension (e.g. "pdf"). When no +// Parser component can be found, or the docType yields no known family, the +// call is a no-op (the run parses everything, which is safe). +// +// dsl arrives as the canvas ENVELOPE ({ "dsl": { "components": ... } }) in +// production (loadDSLFromCanvas marshals canvas.DSL), so it is unwrapped the +// same way NewPipelineFromDSL does before ExtractAllComponentParams runs. +// An explicit page cap already present in parserConfig (keyed by cpnID + +// family) is respected and left untouched — the debug default is only a +// fallback, so a debug run can honour a narrower or wider caller-supplied cap. +func injectDebugPageCap(dsl string, parserConfig map[string]any, docType string) { + // Unwrap the canvas envelope to the inner components map. + var raw map[string]any + if err := json.Unmarshal([]byte(dsl), &raw); err != nil { + return + } + if env, ok := raw["dsl"].(map[string]any); ok && len(env) > 0 { + raw = env + } + inner, err := json.Marshal(raw) + if err != nil { + return + } + schemas, err := pipelinepkg.ExtractAllComponentParams(inner) + if err != nil { + return + } + var parserCpnID string + for _, s := range schemas { + if s.ComponentName == component.ComponentNameParser { + parserCpnID = s.CpnID + break + } + } + if parserCpnID == "" { + return + } + family := component.ParserFileFamily(docType) + if family == "" { + return + } + // Respect an explicit page cap already present under cpnID + family. + if cpnEntry, ok := parserConfig[parserCpnID].(map[string]any); ok { + if famEntry, ok := cpnEntry[family].(map[string]any); ok { + if _, has := famEntry["pages"]; has { + return + } + } + } + cpnEntry, ok := parserConfig[parserCpnID].(map[string]any) + if !ok { + cpnEntry = map[string]any{} + parserConfig[parserCpnID] = cpnEntry + } + famEntry, ok := cpnEntry[family].(map[string]any) + if !ok { + famEntry = map[string]any{} + cpnEntry[family] = famEntry + } + // pages is delivered as a JSON-decoded map — the very shape a ParserConfig + // arrives in from the API/storage JSON round-trip: a []any of [from,to] + // pairs. The deepdoc/pdf parser's NormalizePDFPages requires this + // []any-of-[]any form (not a Go [][]int), so it is built explicitly here. + // The shallow override_params merge in applyOverrideParams preserves this + // shape all the way to ConfigureFromSetup, so the cap is honoured. + famEntry["pages"] = []any{[]any{1, debugPageCapPages}} +} diff --git a/internal/ingestion/task/pipeline_executor_persist_test.go b/internal/ingestion/task/pipeline_executor_persist_test.go new file mode 100644 index 0000000000..aeca8a05e4 --- /dev/null +++ b/internal/ingestion/task/pipeline_executor_persist_test.go @@ -0,0 +1,151 @@ +// +// 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 task + +import ( + "context" + "testing" + + "gorm.io/gorm" + "ragflow/internal/entity" +) + +func strptr(s string) *string { return &s } + +// TestExecute_DebugSkipsPipelineLog asserts that a debug run +// (KB.ID == "") returns before recording the pipeline operation +// log and before inserting chunks into the index. A debug run +// must produce no persistent side effects: no pipeline_log and no +// index insert. +func TestExecute_DebugSkipsPipelineLog(t *testing.T) { + taskCtx := &TaskContext{ + Ctx: context.Background(), + Doc: entity.Document{ + ID: "debug-run-1", + KbID: "", + Name: strptr("doc.pdf"), + ParserID: "parser-1", + Suffix: "pdf", + Type: "pdf", + SourceType: "local", + }, + KB: entity.Knowledgebase{ID: ""}, + Tenant: entity.Tenant{ID: "tenant-1"}, + } + + exec, err := NewPipelineExecutor(taskCtx, "canvas-1", 10) + if err != nil { + t.Fatalf("NewPipelineExecutor: %v", err) + } + exec.WithLoadDSLFunc(func(ctx context.Context, canvasID string) (string, string, error) { + return `"dsl"`, "", nil + }) + exec.WithRunPipelineFunc(func(ctx context.Context, dsl string) (map[string]any, string, error) { + return map[string]any{"chunks": []map[string]any{}}, "dsl", nil + }) + + var logCalled bool + exec.WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { + logCalled = true + return nil + }) + var insertCalled bool + exec.WithInsertFunc(func(ctx context.Context, chunks []map[string]any, _, _ string) ([]string, error) { + insertCalled = true + return nil, nil + }) + + if _, err := exec.Execute(context.Background()); err != nil { + t.Fatalf("Execute: %v", err) + } + if logCalled { + t.Errorf("recordPipelineLog must not be called in a debug (kb_id == \"\") run") + } + if insertCalled { + t.Errorf("index insert must not be called in a debug (kb_id == \"\") run") + } +} + +// TestExecute_DebugReturnsChunks asserts that a debug (kb_id == "") run +// returns the pipeline's chunks in the result instead of discarding them. The +// chunks are surfaced for a debug HTTP endpoint to render; no DB/index writes +// happen and the pipeline operation log is never recorded. +func TestExecute_DebugReturnsChunks(t *testing.T) { + taskCtx := &TaskContext{ + Ctx: context.Background(), + Doc: entity.Document{ + ID: "debug-run-1", + KbID: "", + Name: strptr("doc.pdf"), + ParserID: "parser-1", + Suffix: "pdf", + Type: "pdf", + SourceType: "local", + }, + KB: entity.Knowledgebase{ID: ""}, + Tenant: entity.Tenant{ID: "tenant-1"}, + } + + exec, err := NewPipelineExecutor(taskCtx, "canvas-1", 10) + if err != nil { + t.Fatalf("NewPipelineExecutor: %v", err) + } + exec.WithLoadDSLFunc(func(ctx context.Context, canvasID string) (string, string, error) { + return "dsl", "canvas-1", nil + }) + exec.WithRunPipelineFunc(func(ctx context.Context, dsl string) (map[string]any, string, error) { + return map[string]any{ + "chunks": []map[string]any{ + {"id": "c1", "content": "hello", "text": "hello"}, + {"id": "c2", "content": "world", "text": "world"}, + {"id": "c3", "content": "debug", "text": "debug"}, + }, + "embedding_token_consumption": 12, + }, "dsl", nil + }) + + var logCalled bool + exec.WithLogCreateFunc(func(ctx context.Context, db *gorm.DB, log *entity.PipelineOperationLog) error { + logCalled = true + return nil + }) + var insertCalled bool + exec.WithInsertFunc(func(ctx context.Context, chunks []map[string]any, _, _ string) ([]string, error) { + insertCalled = true + return nil, nil + }) + + result, err := exec.Execute(context.Background()) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result == nil { + t.Fatalf("Execute returned nil result in debug (kb_id == \"\") mode; expected chunks") + } + if len(result.Chunks) != 3 { + t.Errorf("expected 3 chunks in result, got %d", len(result.Chunks)) + } + if result.ChunkCount != 3 { + t.Errorf("expected ChunkCount 3, got %d", result.ChunkCount) + } + if logCalled { + t.Errorf("recordPipelineLog must not be called in a debug (kb_id == \"\") run") + } + if insertCalled { + t.Errorf("index insert must not be called in a debug (kb_id == \"\") run") + } +} diff --git a/internal/ingestion/task/pipeline_executor_test.go b/internal/ingestion/task/pipeline_executor_test.go index 296063fc01..1fce515841 100644 --- a/internal/ingestion/task/pipeline_executor_test.go +++ b/internal/ingestion/task/pipeline_executor_test.go @@ -139,7 +139,6 @@ func TestNewPipelineExecutor_RejectsIncompleteTaskContext(t *testing.T) { {name: "missing doc id", mutate: func(ctx *TaskContext) { ctx.Doc.ID = "" }}, {name: "missing kb id", mutate: func(ctx *TaskContext) { ctx.Doc.KbID = "" }}, {name: "missing doc name", mutate: func(ctx *TaskContext) { ctx.Doc.Name = nil }}, - {name: "missing knowledgebase id", mutate: func(ctx *TaskContext) { ctx.KB.ID = "" }}, {name: "missing tenant id", mutate: func(ctx *TaskContext) { ctx.Tenant.ID = "" }}, } @@ -155,6 +154,19 @@ func TestNewPipelineExecutor_RejectsIncompleteTaskContext(t *testing.T) { } } +// TestNewPipelineExecutor_AcceptsDebugTaskContext verifies the canvas-debug +// (dry-run) contract: a TaskContext with an empty KB.ID is valid because debug +// mode carries no knowledgebase. KB.ID == "" never occurs in production +// ingestion, which always supplies a KB. +func TestNewPipelineExecutor_AcceptsDebugTaskContext(t *testing.T) { + ctx := makeTaskCtx() + ctx.KB = entity.Knowledgebase{ID: ""} + ctx.Doc.KbID = "" + if _, err := NewPipelineExecutor(ctx, "flow-1", 0); err != nil { + t.Fatalf("debug TaskContext rejected: %v", err) + } +} + func TestNewPipelineExecutor_DocBulkSize(t *testing.T) { svc := mustNewPipelineExecutor(t, makeTaskCtx(), "flow-1", 128) if svc.docBulkSize != 128 { diff --git a/web/src/pages/agent/hooks/use-run-dataflow.ts b/web/src/pages/agent/hooks/use-run-dataflow.ts index 6dac58acb9..da74fc8865 100644 --- a/web/src/pages/agent/hooks/use-run-dataflow.ts +++ b/web/src/pages/agent/hooks/use-run-dataflow.ts @@ -44,6 +44,13 @@ export function useRunDataflow({ return msgId; } else { + // Even on a run failure the debug log (with the [ERROR] END marker) is + // already written; surface its polling key so the log sheet can render + // the failure timeline instead of leaving it stuck on an empty state. + const msgId = get(res, 'data.data.message_id'); + if (msgId) { + setMessageId(msgId); + } message.error(get(res, 'data.message', '')); } },