mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-13 16:38:26 +08:00
feat: implement POST /api/v1/searchbots/related_questions API (#15639)
## 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
229
internal/handler/searchbot.go
Normal file
229
internal/handler/searchbot.go
Normal file
@@ -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.`
|
||||
227
internal/handler/searchbot_test.go
Normal file
227
internal/handler/searchbot_test.go
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user