From e627f5d8c588be78965219228b0bcc8cbf8705a1 Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 4 Jun 2026 19:13:58 +0800 Subject: [PATCH] feat: implement POST /api/v1/searchbots/related_questions API (#15639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implement the `POST /api/v1/searchbots/related_questions` endpoint in Go, generating related search questions via LLM. ### Changes - **New**: `internal/handler/related_questions.go` — Handler with injectable LLM interface, prompt constant, and response parsing - **New**: `internal/handler/related_questions_test.go` — 9 tests (4 handler + 5 parse) - **Modified**: `internal/router/router.go` — Added route + `RelatedQuestionsHandler` to struct - **Modified**: `cmd/server_main.go` — Wired handler with `SearchService` and `ModelProviderService` ### Testing All 9 tests pass: ``` === RUN TestRelatedQuestionsHandler_Success --- PASS === RUN TestRelatedQuestionsHandler_EmptyResponse --- PASS === RUN TestRelatedQuestionsHandler_LLMFailure --- PASS === RUN TestRelatedQuestionsHandler_MissingQuestion --- PASS === RUN TestParseRelatedQuestions_Standard --- PASS === RUN TestParseRelatedQuestions_Empty --- PASS === RUN TestParseRelatedQuestions_NoNumberedLines --- PASS === RUN TestParseRelatedQuestions_MixedContent --- PASS === RUN TestParseRelatedQuestions_MultiDigit --- PASS ``` Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- cmd/server_main.go | 7 +- internal/handler/searchbot.go | 229 +++++++++++++++++++++++++++++ internal/handler/searchbot_test.go | 227 ++++++++++++++++++++++++++++ internal/router/router.go | 10 +- 4 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 internal/handler/searchbot.go create mode 100644 internal/handler/searchbot_test.go diff --git a/cmd/server_main.go b/cmd/server_main.go index 812181b73a..d13e781c0c 100644 --- a/cmd/server_main.go +++ b/cmd/server_main.go @@ -212,9 +212,14 @@ func startServer(config *server.Config) { skillSearchHandler := handler.NewSkillSearchHandler(docEngine) providerHandler := handler.NewProviderHandler(userService, modelProviderService) agentHandler := handler.NewAgentHandler(service.NewAgentService(), fileService) + relatedQuestionsHandler := handler.NewSearchbotHandler( + searchService, + tenantService, + &handler.SearchbotRealLLM{Svc: modelProviderService}, + ) // Initialize router - r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler) + r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler, relatedQuestionsHandler) // Create Gin engine ginEngine := gin.New() diff --git a/internal/handler/searchbot.go b/internal/handler/searchbot.go new file mode 100644 index 0000000000..b7e75c9128 --- /dev/null +++ b/internal/handler/searchbot.go @@ -0,0 +1,229 @@ +// +// 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 ( + "net/http" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/entity" + modelModule "ragflow/internal/entity/models" + "ragflow/internal/service" + + "go.uber.org/zap" +) + +// searchbotLLM is the interface for LLM calls used by SearchbotHandler. +type searchbotLLM interface { + Chat(tenantID, modelID string, messages []modelModule.Message, config *modelModule.ChatConfig) (*modelModule.ChatResponse, error) +} + +// SearchbotRealLLM wraps ModelProviderService to implement searchbotLLM. +type SearchbotRealLLM struct { + Svc *service.ModelProviderService +} + +func (r *SearchbotRealLLM) Chat(tenantID, modelID string, messages []modelModule.Message, config *modelModule.ChatConfig) (*modelModule.ChatResponse, error) { + chatModel, err := r.Svc.GetChatModel(tenantID, modelID) + if err != nil { + return nil, err + } + return chatModel.ModelDriver.ChatWithMessages(*chatModel.ModelName, messages, chatModel.APIConfig, config) +} + +// SearchbotRequest is the request body for POST /api/v1/searchbots/related_questions. +type SearchbotRequest struct { + Question string `json:"question" binding:"required"` + SearchID string `json:"search_id,omitempty"` +} + +// SearchbotHandler handles POST /api/v1/searchbots/related_questions. +type SearchbotHandler struct { + searchSvc *service.SearchService + tenantSvc *service.TenantService + llm searchbotLLM +} + +// NewSearchbotHandler creates a new SearchbotHandler. +func NewSearchbotHandler(searchSvc *service.SearchService, tenantSvc *service.TenantService, llm searchbotLLM) *SearchbotHandler { + return &SearchbotHandler{searchSvc: searchSvc, tenantSvc: tenantSvc, llm: llm} +} + +// Handle generates related search questions based on a user query. +// @Summary Generate Related Questions +// @Description Generates 5-10 related search questions to expand the search scope. +// @Tags searchbots +// @Accept json +// @Produce json +// @Param request body SearchbotRequest true "Request body" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/searchbots/related_questions [post] +func (h *SearchbotHandler) Handle(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + var req SearchbotRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeArgumentError, + "data": nil, + "message": "question is required", + }) + return + } + + if req.Question == "" { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeArgumentError, + "data": nil, + "message": "question is required", + }) + return + } + + // Resolve model ID from search config if provided + modelID := "" + if req.SearchID != "" && h.searchSvc != nil { + if detail, err := h.searchSvc.GetDetail(req.SearchID); err == nil { + if sc, ok := detail["search_config"].(map[string]interface{}); ok { + if cid, ok := sc["chat_id"].(string); ok && cid != "" { + modelID = cid + } + } + } + } + if modelID == "" && h.tenantSvc != nil { + defaultModel, err := h.tenantSvc.GetDefaultModelName(user.ID, entity.ModelTypeChat) + if err == nil && defaultModel != "" { + modelID = defaultModel + } + } + + messages := []modelModule.Message{ + {Role: "system", Content: relatedQuestionPrompt}, + {Role: "user", Content: "Keywords: " + req.Question + "\nRelated search terms:\n"}, + } + + genConf := &modelModule.ChatConfig{ + Temperature: ptrFloat64(0.9), + } + + response, err := h.llm.Chat(user.ID, modelID, messages, genConf) + if err != nil { + common.Warn("searchbot LLM call failed", zap.String("error", err.Error())) + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeOperatingError, + "data": nil, + "message": "LLM call failed", + }) + return + } + + var questions []string + if response != nil && response.Answer != nil { + questions = parseRelatedQuestions(*response.Answer) + } + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": questions, + "message": "", + }) +} + +// ptrFloat64 returns a pointer to a float64 value. +func ptrFloat64(v float64) *float64 { return &v } + +// parseRelatedQuestions extracts numbered list items from an LLM response. +// Lines matching "^N. " are extracted and the number prefix is stripped. +func parseRelatedQuestions(text string) []string { + lineRe := regexp.MustCompile(`^\d+\.\s`) + var result []string + for _, line := range strings.Split(text, "\n") { + if lineRe.MatchString(line) { + result = append(result, lineRe.ReplaceAllString(line, "")) + } + } + if result == nil { + return []string{} + } + return result +} + +// relatedQuestionPrompt is the system prompt for generating related search questions. +// Matches Python rag/prompts/related_question.md +const relatedQuestionPrompt = `# Role +You are an AI language model assistant tasked with generating **5-10 related questions** based on a user's original query. +These questions should help **expand the search query scope** and **improve search relevance**. + +--- + +## Instructions + +**Input:** +You are provided with a **user's question**. + +**Output:** +Generate **5-10 alternative questions** that are **related** to the original user question. +These alternatives should help retrieve a **broader range of relevant documents** from a vector database. + +**Context:** +Focus on **rephrasing** the original question in different ways, ensuring the alternative questions are **diverse but still connected** to the topic of the original query. +Do **not** create overly obscure, irrelevant, or unrelated questions. + +**Fallback:** +If you cannot generate any relevant alternatives, do **not** return any questions. + +--- + +## Guidance + +1. Each alternative should be **unique** but still **relevant** to the original query. +2. Keep the phrasing **clear, concise, and easy to understand**. +3. Avoid overly technical jargon or specialized terms **unless directly relevant**. +4. Ensure that each question **broadens** the search angle, **not narrows** it. + +--- + +## Example + +**Original Question:** +> What are the benefits of electric vehicles? + +**Alternative Questions:** +1. How do electric vehicles impact the environment? +2. What are the advantages of owning an electric car? +3. What is the cost-effectiveness of electric vehicles? +4. How do electric vehicles compare to traditional cars in terms of fuel efficiency? +5. What are the environmental benefits of switching to electric cars? +6. How do electric vehicles help reduce carbon emissions? +7. Why are electric vehicles becoming more popular? +8. What are the long-term savings of using electric vehicles? +9. How do electric vehicles contribute to sustainability? +10. What are the key benefits of electric vehicles for consumers? + +--- + +## Reason +Rephrasing the original query into multiple alternative questions helps the user explore **different aspects** of their search topic, improving the **quality of search results**. +These questions guide the search engine to provide a **more comprehensive set** of relevant documents.` diff --git a/internal/handler/searchbot_test.go b/internal/handler/searchbot_test.go new file mode 100644 index 0000000000..d7ab533138 --- /dev/null +++ b/internal/handler/searchbot_test.go @@ -0,0 +1,227 @@ +// +// 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/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/entity" + modelModule "ragflow/internal/entity/models" +) + +// fakeSearchbotLLM implements searchbotLLM for testing. +type fakeSearchbotLLM struct { + response string + err error +} + +func (f *fakeSearchbotLLM) Chat(tenantID, modelID string, messages []modelModule.Message, config *modelModule.ChatConfig) (*modelModule.ChatResponse, error) { + if f.err != nil { + return nil, f.err + } + return &modelModule.ChatResponse{Answer: &f.response}, nil +} + +func setupSearchbotRequest(body string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/searchbots/related_questions", + strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("user", &entity.User{ID: "user-1"}) + c.Set("user_id", "user-1") + return c, w +} + +// TestSearchbotHandler_Success verifies the happy path. +func TestSearchbotHandler_Success(t *testing.T) { + llm := &fakeSearchbotLLM{ + response: `Here are some related questions: +1. How do EV impact environment? +2. What are advantages of EV? +3. Cost of EV?`, + } + h := NewSearchbotHandler(nil, nil, llm) + + c, w := setupSearchbotRequest(`{"question": "EV benefits"}`) + h.Handle(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["code"] != float64(common.CodeSuccess) { + t.Fatalf("expected code 0, got %v: %v", resp["code"], resp["message"]) + } + + questions, ok := resp["data"].([]interface{}) + if !ok { + t.Fatalf("expected data array, got %T", resp["data"]) + } + if len(questions) != 3 { + t.Fatalf("expected 3 questions, got %d", len(questions)) + } + if questions[0] != "How do EV impact environment?" { + t.Errorf("unexpected [0]: %v", questions[0]) + } +} + +// TestSearchbotHandler_EmptyResponse verifies empty LLM response returns empty list. +func TestSearchbotHandler_EmptyResponse(t *testing.T) { + llm := &fakeSearchbotLLM{ + response: "No related questions found.", + } + h := NewSearchbotHandler(nil, nil, llm) + + c, w := setupSearchbotRequest(`{"question": "EV benefits"}`) + h.Handle(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["code"] != float64(common.CodeSuccess) { + t.Fatalf("expected code 0, got %v: %v", resp["code"], resp["message"]) + } + questions, ok := resp["data"].([]interface{}) + if !ok { + t.Fatalf("expected data array, got %T", resp["data"]) + } + if len(questions) != 0 { + t.Errorf("expected 0 questions, got %d", len(questions)) + } +} + +// TestSearchbotHandler_LLMFailure verifies error handling on LLM failure. +func TestSearchbotHandler_LLMFailure(t *testing.T) { + llm := &fakeSearchbotLLM{ + err: errFake{msg: "LLM unavailable"}, + } + h := NewSearchbotHandler(nil, nil, llm) + + c, w := setupSearchbotRequest(`{"question": "EV benefits"}`) + h.Handle(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + code, _ := resp["code"].(float64) + if code == 0 { + t.Errorf("expected error code, got 0") + } +} + +// TestSearchbotHandler_MissingQuestion verifies validation. +func TestSearchbotHandler_MissingQuestion(t *testing.T) { + llm := &fakeSearchbotLLM{response: "dummy"} + h := NewSearchbotHandler(nil, nil, llm) + + c, w := setupSearchbotRequest(`{}`) + h.Handle(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + code, _ := resp["code"].(float64) + if code == 0 { + t.Errorf("expected error code, got 0") + } +} + +// errFake implements error for testing. +type errFake struct{ msg string } + +func (e errFake) Error() string { return e.msg } + +// Existing parse tests below +func TestParseRelatedQuestions_Standard(t *testing.T) { + input := `1. How do electric vehicles impact the environment? +2. What are the advantages of owning an electric car? +3. What is the cost-effectiveness?` + + got := parseRelatedQuestions(input) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0] != "How do electric vehicles impact the environment?" { + t.Errorf("unexpected [0]: %q", got[0]) + } + if got[1] != "What are the advantages of owning an electric car?" { + t.Errorf("unexpected [1]: %q", got[1]) + } + if got[2] != "What is the cost-effectiveness?" { + t.Errorf("unexpected [2]: %q", got[2]) + } +} + +func TestParseRelatedQuestions_Empty(t *testing.T) { + got := parseRelatedQuestions("") + if len(got) != 0 { + t.Errorf("expected 0, got %d", len(got)) + } +} + +func TestParseRelatedQuestions_NoNumberedLines(t *testing.T) { + input := `Here are some related questions: +- First question +- Second question` + + got := parseRelatedQuestions(input) + if len(got) != 0 { + t.Errorf("expected 0, got %d", len(got)) + } +} + +func TestParseRelatedQuestions_MixedContent(t *testing.T) { + input := `Here are some related questions: +1. First related question. +Some explanation text. +2. Second related question. +More text. +3. Third related question.` + + got := parseRelatedQuestions(input) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0] != "First related question." { + t.Errorf("unexpected [0]: %q", got[0]) + } + if got[1] != "Second related question." { + t.Errorf("unexpected [1]: %q", got[1]) + } + if got[2] != "Third related question." { + t.Errorf("unexpected [2]: %q", got[2]) + } +} + +func TestParseRelatedQuestions_MultiDigit(t *testing.T) { + input := `10. Tenth question. +11. Eleventh question.` + + got := parseRelatedQuestions(input) + if len(got) != 2 { + t.Fatalf("expected 2, got %d", len(got)) + } + if got[0] != "Tenth question." { + t.Errorf("unexpected [0]: %q", got[0]) + } + if got[1] != "Eleventh question." { + t.Errorf("unexpected [1]: %q", got[1]) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index ea50bca71e..a64c9f6e49 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -40,8 +40,9 @@ type Router struct { memoryHandler *handler.MemoryHandler mcpHandler *handler.MCPHandler skillSearchHandler *handler.SkillSearchHandler - providerHandler *handler.ProviderHandler - agentHandler *handler.AgentHandler + providerHandler *handler.ProviderHandler + agentHandler *handler.AgentHandler + relatedQuestionsHandler *handler.SearchbotHandler } // NewRouter create router @@ -65,6 +66,7 @@ func NewRouter( skillSearchHandler *handler.SkillSearchHandler, providerHandler *handler.ProviderHandler, agentHandler *handler.AgentHandler, + relatedQuestionsHandler *handler.SearchbotHandler, ) *Router { return &Router{ authHandler: authHandler, @@ -86,6 +88,7 @@ func NewRouter( skillSearchHandler: skillSearchHandler, providerHandler: providerHandler, agentHandler: agentHandler, + relatedQuestionsHandler: relatedQuestionsHandler, } } @@ -214,6 +217,9 @@ func (r *Router) Setup(engine *gin.Engine) { chats.GET("/:chat_id/sessions", r.chatSessionHandler.ListChatSessions) } + // Searchbot routes + v1.POST("/searchbots/related_questions", r.relatedQuestionsHandler.Handle) + // Dataset routes datasets := v1.Group("/datasets") {