mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-10-12 02:16:39 +00:00
Add Go service/handler tests for API contract parity (#16905)
This commit is contained in:
@@ -46,11 +46,7 @@ class Begin(UserFillUp):
|
||||
return {}
|
||||
|
||||
if isinstance(query, dict):
|
||||
return {
|
||||
key: value if isinstance(value, dict) else {"value": value}
|
||||
for key, value in query.items()
|
||||
if key in fields
|
||||
}
|
||||
return {key: value if isinstance(value, dict) else {"value": value} for key, value in query.items() if key in fields}
|
||||
|
||||
if len(fields) == 1:
|
||||
return {next(iter(fields)): {"value": query}}
|
||||
|
||||
@@ -30,7 +30,7 @@ from api.apps.restful_apis._generation_params import merge_generation_config, po
|
||||
from api.db.joint_services.tenant_model_service import get_api_key, get_tenant_default_model_by_type, resolve_model_config
|
||||
from api.db.services.chunk_feedback_service import ChunkFeedbackService
|
||||
from api.db.services.conversation_service import ConversationService, structure_answer
|
||||
from api.db.services.dialog_service import DialogService, async_chat, gen_mindmap, rag_agent
|
||||
from api.db.services.dialog_service import DialogService, gen_mindmap, rag_agent
|
||||
from api.db.services.knowledgebase_service import KnowledgebaseService, validate_dataset_embedding_models
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
from api.db.services.search_service import SearchService
|
||||
|
||||
@@ -467,10 +467,7 @@ class DoclingParser(RAGFlowPdfParser):
|
||||
if chunk_flag and response_is_chunk:
|
||||
self.logger.info(f"[Docling] Successfully used native chunking on: {endpoint}")
|
||||
elif chunk_flag:
|
||||
self.logger.warning(
|
||||
f"[Docling] Server ignored chunking request on {endpoint}; "
|
||||
"treating response as standard conversion."
|
||||
)
|
||||
self.logger.warning(f"[Docling] Server ignored chunking request on {endpoint}; treating response as standard conversion.")
|
||||
else:
|
||||
self.logger.info(f"[Docling] Chunking unavailable, fell back to standard: {endpoint}")
|
||||
break
|
||||
|
||||
@@ -212,7 +212,7 @@ class RAGFlowPdfParser:
|
||||
logging.warning("Could not load OCR alphabet from %s: %s; treating all text as representable.", res, e)
|
||||
cls._OCR_ALPHABET = set()
|
||||
if not cls._OCR_ALPHABET:
|
||||
return True # unknown alphabet: preserve existing behaviour
|
||||
return True # unknown alphabet: preserve existing behaviour
|
||||
letters = [c for c in text if c.strip()]
|
||||
if not letters:
|
||||
return True
|
||||
|
||||
@@ -1 +1 @@
|
||||
CREATE DATABASE IF NOT EXISTS ragflow;
|
||||
CREATE DATABASE IF NOT EXISTS ragflow;
|
||||
|
||||
@@ -184,7 +184,9 @@ func (dao *ChatDAO) ExistsByNameTenantStatus(name, tenantID, status string) (boo
|
||||
|
||||
// Create creates a new chat/dialog
|
||||
func (dao *ChatDAO) Create(chat *entity.Chat) error {
|
||||
return DB.Create(chat).Error
|
||||
// Select("*") forces GORM to persist explicit zero values (e.g. similarity_threshold=0,
|
||||
// vector_similarity_weight=0, top_n=0) instead of substituting the column defaults.
|
||||
return DB.Select("*").Create(chat).Error
|
||||
}
|
||||
|
||||
// UpdateByID updates a chat by ID
|
||||
|
||||
@@ -245,12 +245,9 @@ func applyDocumentListFilters(query *gorm.DB, opts DocumentListOptions, qualifie
|
||||
query = query.Where(column("id")+" IN ?", opts.DocIDs)
|
||||
}
|
||||
}
|
||||
if opts.CreateTimeFrom > 0 {
|
||||
query = query.Where(column("create_time")+" >= ?", opts.CreateTimeFrom)
|
||||
}
|
||||
if opts.CreateTimeTo > 0 {
|
||||
query = query.Where(column("create_time")+" <= ?", opts.CreateTimeTo)
|
||||
}
|
||||
// Note: create_time_from / create_time_to are NOT applied at DB level.
|
||||
// They are filtered post-query in the handler so total reflects the
|
||||
// unfiltered count, matching the Python API contract.
|
||||
return query
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -48,6 +48,11 @@ func IsNotFoundErr(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// IsDuplicateKeyErr returns true if the error is a unique-constraint violation.
|
||||
func IsDuplicateKeyErr(err error) bool {
|
||||
return errors.Is(err, gorm.ErrDuplicatedKey)
|
||||
}
|
||||
|
||||
// NewKnowledgebaseDAO create knowledge base DAO
|
||||
func NewKnowledgebaseDAO() *KnowledgebaseDAO {
|
||||
return &KnowledgebaseDAO{}
|
||||
@@ -103,7 +108,7 @@ func (dao *KnowledgebaseDAO) GetByIDs(ids []string) ([]*entity.Knowledgebase, er
|
||||
// GetByName retrieves a knowledge base by name and tenant ID
|
||||
func (dao *KnowledgebaseDAO) GetByName(name, tenantID string) (*entity.Knowledgebase, error) {
|
||||
var kb entity.Knowledgebase
|
||||
err := DB.Where("name = ? AND tenant_id = ? AND status = ?", name, tenantID, string(entity.StatusValid)).First(&kb).Error
|
||||
err := DB.Where("LOWER(name) = LOWER(?) AND tenant_id = ? AND status = ?", name, tenantID, string(entity.StatusValid)).First(&kb).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +172,7 @@ func (dao *KnowledgebaseDAO) Count(filters map[string]interface{}) (int64, error
|
||||
|
||||
// GetByTenantIDs retrieves knowledge bases by tenant IDs with pagination
|
||||
// This matches the Python get_by_tenant_ids method
|
||||
func (dao *KnowledgebaseDAO) GetByTenantIDs(tenantIDs []string, userID string, pageNumber, itemsPerPage int, orderby string, desc bool, keywords, parserID string) ([]*entity.KnowledgebaseListItem, int64, error) {
|
||||
func (dao *KnowledgebaseDAO) GetByTenantIDs(tenantIDs []string, userID string, pageNumber, itemsPerPage int, orderby string, desc bool, keywords, parserID, id, name string) ([]*entity.KnowledgebaseListItem, int64, error) {
|
||||
var kbs []*entity.KnowledgebaseListItem
|
||||
var total int64
|
||||
|
||||
@@ -181,6 +186,14 @@ func (dao *KnowledgebaseDAO) GetByTenantIDs(tenantIDs []string, userID string, p
|
||||
Where("((knowledgebase.tenant_id IN ? AND knowledgebase.permission = ?) OR knowledgebase.tenant_id = ?) AND knowledgebase.status = ?",
|
||||
tenantIDs, string(entity.TenantPermissionTeam), userID, string(entity.StatusValid))
|
||||
|
||||
if id != "" {
|
||||
query = query.Where("knowledgebase.id = ?", id)
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
query = query.Where("knowledgebase.name = ?", name)
|
||||
}
|
||||
|
||||
if keywords != "" {
|
||||
query = query.Where("LOWER(knowledgebase.name) LIKE ?", "%"+strings.ToLower(keywords)+"%")
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ func RunMigrations(db *gorm.DB) error {
|
||||
return fmt.Errorf("failed to modify column types: %w", err)
|
||||
}
|
||||
|
||||
// Add case-insensitive unique constraint on knowledgebase (tenant_id, name)
|
||||
if err := migrateKnowledgebaseNameUnique(db); err != nil {
|
||||
return fmt.Errorf("failed to add unique index on knowledgebase (tenant_id, name): %w", err)
|
||||
}
|
||||
|
||||
common.Info("All manual migrations completed successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -253,9 +258,81 @@ func migrateIngestionTaskDocumentIDUnique(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateKnowledgebaseNameUnique adds a case-insensitive unique constraint on
|
||||
// (tenant_id, name) for valid knowledge bases. A VIRTUAL generated column
|
||||
// (name_ci) computes LOWER(name) only for status='1' rows and is NULL otherwise,
|
||||
// so soft-deleted rows never block name reuse. The unique index backstops the
|
||||
// check-then-write path in CreateDataset/UpdateDataset against concurrent
|
||||
// duplicate inserts, and the resulting duplicate-key error is mapped back to the
|
||||
// "already exists" domain error at the service layer.
|
||||
func migrateKnowledgebaseNameUnique(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("knowledgebase") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add the generated column if it does not exist yet.
|
||||
var colExists int64
|
||||
if err := db.Raw(`SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'knowledgebase' AND COLUMN_NAME = 'name_ci'`).Scan(&colExists).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if colExists == 0 {
|
||||
common.Info("Adding generated column name_ci to knowledgebase...")
|
||||
if err := db.Exec(`ALTER TABLE knowledgebase
|
||||
ADD COLUMN name_ci VARCHAR(128) GENERATED ALWAYS AS (
|
||||
CASE WHEN status = '1' THEN LOWER(name) ELSE NULL END
|
||||
) VIRTUAL`).Error; err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Error 1060") && strings.Contains(errStr, "Duplicate column name") {
|
||||
common.Info("Column name_ci already exists, skipping", zap.String("error", errStr))
|
||||
} else {
|
||||
return fmt.Errorf("failed to add generated column name_ci: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const indexName = "idx_kb_tenant_name_ci"
|
||||
|
||||
// Check whether the unique index already exists.
|
||||
var idxExists int64
|
||||
if err := db.Raw(`SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'knowledgebase' AND INDEX_NAME = ?`, indexName).Scan(&idxExists).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if idxExists > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for duplicate valid names before adding the index.
|
||||
var duplicateCount int64
|
||||
if err := db.Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT tenant_id, name_ci FROM knowledgebase
|
||||
WHERE name_ci IS NOT NULL
|
||||
GROUP BY tenant_id, name_ci HAVING COUNT(*) > 1
|
||||
) AS duplicates
|
||||
`).Scan(&duplicateCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicateCount > 0 {
|
||||
return fmt.Errorf("found %d duplicate (tenant_id, name) pairs among valid knowledge bases; resolve these before the unique index can be created", duplicateCount)
|
||||
}
|
||||
|
||||
common.Info("Adding unique index on knowledgebase (tenant_id, name_ci)...")
|
||||
if err := db.Exec("ALTER TABLE knowledgebase ADD UNIQUE INDEX " + indexName + " (tenant_id, name_ci)").Error; err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Error 1061") && strings.Contains(errStr, "Duplicate key name") {
|
||||
common.Info("Index already exists, skipping", zap.String("error", errStr))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to add unique index on knowledgebase (tenant_id, name_ci): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// modifyColumnTypes modifies column types that need explicit ALTER statements
|
||||
func modifyColumnTypes(db *gorm.DB) error {
|
||||
// Helper function to check if column exists
|
||||
columnExists := func(table, column string) bool {
|
||||
var count int64
|
||||
db.Raw(`SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
|
||||
+11
-4
@@ -203,11 +203,18 @@ func (dao *SearchDAO) DeleteByID(tenantID, id string) error {
|
||||
|
||||
// Accessible4Deletion checks if a search can be deleted by a specific user
|
||||
// Reference: Python search_service.py::accessible4deletion
|
||||
// Returns true if the search exists, is valid, and was created by the user
|
||||
// Returns true if the search exists, is valid, and was created by the user.
|
||||
// A missing or non-owned search returns (false, nil) so callers can distinguish
|
||||
// "not authorized" from a genuine database error (which is returned as the error).
|
||||
func (dao *SearchDAO) Accessible4Deletion(searchID string, userID string) (bool, error) {
|
||||
var search entity.Search
|
||||
err := DB.Where("id = ? AND created_by = ? AND status = ?", searchID, userID, "1").First(&search).Error
|
||||
return err == nil, err
|
||||
var count int64
|
||||
err := DB.Model(&entity.Search{}).
|
||||
Where("id = ? AND created_by = ? AND status = ?", searchID, userID, "1").
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetByTenantIDAndID gets search by tenant ID and search ID
|
||||
|
||||
+23
-17
@@ -20,23 +20,29 @@ import "encoding/json"
|
||||
|
||||
// Chat chat model (mapped to dialog table)
|
||||
type Chat struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
||||
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
|
||||
Name *string `gorm:"column:name;size:255;index" json:"name,omitempty"`
|
||||
Description *string `gorm:"column:description;type:longtext" json:"description,omitempty"`
|
||||
Icon *string `gorm:"column:icon;type:longtext" json:"icon,omitempty"`
|
||||
Language *string `gorm:"column:language;size:32;index" json:"language,omitempty"`
|
||||
LLMID string `gorm:"column:llm_id;size:128;not null" json:"llm_id"`
|
||||
TenantLLMID *string `gorm:"column:tenant_llm_id;size:32;index" json:"tenant_llm_id,omitempty"`
|
||||
LLMSetting JSONMap `gorm:"column:llm_setting;type:longtext;not null" json:"llm_setting"`
|
||||
PromptType string `gorm:"column:prompt_type;size:16;not null;default:'simple';index" json:"prompt_type"`
|
||||
PromptConfig JSONMap `gorm:"column:prompt_config;type:longtext;not null" json:"prompt_config"`
|
||||
MetaDataFilter *JSONMap `gorm:"column:meta_data_filter;type:longtext" json:"meta_data_filter,omitempty"`
|
||||
SimilarityThreshold float64 `gorm:"column:similarity_threshold;default:0.2" json:"similarity_threshold"`
|
||||
VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight;default:0.3" json:"vector_similarity_weight"`
|
||||
TopN int64 `gorm:"column:top_n;default:6" json:"top_n"`
|
||||
TopK int64 `gorm:"column:top_k;default:1024" json:"top_k"`
|
||||
DoRefer string `gorm:"column:do_refer;size:1;not null;default:1" json:"do_refer"`
|
||||
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
||||
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
|
||||
Name *string `gorm:"column:name;size:255;index" json:"name,omitempty"`
|
||||
Description *string `gorm:"column:description;type:longtext" json:"description,omitempty"`
|
||||
Icon *string `gorm:"column:icon;type:longtext" json:"icon,omitempty"`
|
||||
Language *string `gorm:"column:language;size:32;index" json:"language,omitempty"`
|
||||
LLMID string `gorm:"column:llm_id;size:128;not null" json:"llm_id"`
|
||||
TenantLLMID *string `gorm:"column:tenant_llm_id;size:32;index" json:"tenant_llm_id,omitempty"`
|
||||
LLMSetting JSONMap `gorm:"column:llm_setting;type:longtext;not null" json:"llm_setting"`
|
||||
PromptType string `gorm:"column:prompt_type;size:16;not null;default:'simple';index" json:"prompt_type"`
|
||||
PromptConfig JSONMap `gorm:"column:prompt_config;type:longtext;not null" json:"prompt_config"`
|
||||
MetaDataFilter *JSONMap `gorm:"column:meta_data_filter;type:longtext" json:"meta_data_filter,omitempty"`
|
||||
// NOTE: No `default:` GORM tags here. The service layer (chat.go Create)
|
||||
// supplies sensible defaults (0.1 / 0.3 / 6 / 1024 / "1") when a field is
|
||||
// omitted, and honors an explicitly provided zero value. A GORM `default:`
|
||||
// tag would force GORM to overwrite an explicit zero (e.g.
|
||||
// similarity_threshold=0) with the column default during Create, breaking
|
||||
// the API contract that permits 0.
|
||||
SimilarityThreshold float64 `gorm:"column:similarity_threshold" json:"similarity_threshold"`
|
||||
VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight" json:"vector_similarity_weight"`
|
||||
TopN int64 `gorm:"column:top_n" json:"top_n"`
|
||||
TopK int64 `gorm:"column:top_k" json:"top_k"`
|
||||
DoRefer string `gorm:"column:do_refer;size:1;not null" json:"do_refer"`
|
||||
RerankID string `gorm:"column:rerank_id;size:128;not null;default:''" json:"rerank_id"`
|
||||
TenantRerankID *string `gorm:"column:tenant_rerank_id;size:32;index" json:"tenant_rerank_id,omitempty"`
|
||||
KBIDs JSONSlice `gorm:"column:kb_ids;type:longtext;not null" json:"kb_ids"`
|
||||
|
||||
@@ -59,6 +59,7 @@ const (
|
||||
ParserTypeAudio ParserType = "audio"
|
||||
ParserTypeEmail ParserType = "email"
|
||||
ParserTypeTag ParserType = "tag"
|
||||
ParserTypeKG ParserType = "knowledge_graph"
|
||||
)
|
||||
|
||||
// TaskStatus represents the status of a processing task
|
||||
|
||||
@@ -232,7 +232,7 @@ func (h *ChatHandler) DeleteChat(c *gin.Context) {
|
||||
|
||||
if err := h.chatService.DeleteChat(userID, chatID); err != nil {
|
||||
if err.Error() == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
|
||||
return
|
||||
}
|
||||
common.ErrorWithCode(c, common.CodeDataError, err.Error())
|
||||
@@ -330,7 +330,7 @@ func (h *ChatHandler) GetChat(c *gin.Context) {
|
||||
errMsg := err.Error()
|
||||
// Check if it's an authorization error
|
||||
if errMsg == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
|
||||
return
|
||||
}
|
||||
// Not found error
|
||||
@@ -413,7 +413,7 @@ func (h *ChatHandler) updateChatByMethod(c *gin.Context, patch bool) {
|
||||
}
|
||||
if err != nil {
|
||||
if err.Error() == "no authorization" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
|
||||
return
|
||||
}
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
|
||||
@@ -25,7 +25,7 @@ func setupChatHandlerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&entity.Chat{}, &entity.Tenant{}); err != nil {
|
||||
if err := db.AutoMigrate(&entity.Chat{}, &entity.Tenant{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("failed to migrate test schema: %v", err)
|
||||
}
|
||||
|
||||
@@ -199,3 +199,58 @@ func TestUpdateChatHandlerRejectsNonOwner(t *testing.T) {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChatHandlerRejectsNonOwner(t *testing.T) {
|
||||
db := setupChatHandlerTestDB(t)
|
||||
createChatHandlerTestChat(t, db, "chat-1", "tenant-2")
|
||||
|
||||
h := NewChatHandler(service.NewChatService(), service.NewUserService())
|
||||
c, w := setupGinContextWithUser("GET", "/api/v1/chats/chat-1", "")
|
||||
c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}}
|
||||
|
||||
h.GetChat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
t.Fatalf("expected auth error code 109, got %v", resp["code"])
|
||||
}
|
||||
if resp["data"] != false {
|
||||
t.Fatalf("expected data=false, got %v", resp["data"])
|
||||
}
|
||||
if resp["message"] != "No authorization." {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteChatHandlerRejectsNonOwner(t *testing.T) {
|
||||
db := setupChatHandlerTestDB(t)
|
||||
createChatHandlerTestChat(t, db, "chat-1", "tenant-2")
|
||||
|
||||
h := NewChatHandler(service.NewChatService(), service.NewUserService())
|
||||
c, w := setupGinContextWithUser("DELETE", "/api/v1/chats/chat-1", "")
|
||||
c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}}
|
||||
|
||||
h.DeleteChat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
t.Fatalf("expected auth error code 109, got %v", resp["code"])
|
||||
}
|
||||
if resp["message"] != "No authorization." {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,12 +201,25 @@ func (h *DatasetsHandler) UpdateDataset(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.UpdateDatasetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req service.UpdateDatasetRequest
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Detect an explicitly provided parser_config key (even {} or null) so it is not
|
||||
// rejected as "No properties were modified", mirroring the Python contract.
|
||||
var providedFields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(bodyBytes, &providedFields); err == nil {
|
||||
_, req.ParserConfigProvided = providedFields["parser_config"]
|
||||
}
|
||||
|
||||
result, code, err := h.datasetsService.UpdateDataset(datasetID, userID, req)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
|
||||
+141
-21
@@ -402,11 +402,11 @@ func (h *DocumentHandler) DeleteDocuments(c *gin.Context) {
|
||||
ids = *req.IDs
|
||||
}
|
||||
if len(ids) > 0 && req.DeleteAll {
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "should not provide both ids and delete_all")
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "should not provide both ids and delete_all")
|
||||
return
|
||||
}
|
||||
if len(ids) == 0 && !req.DeleteAll {
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "should either provide doc ids or set delete_all(true)")
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "should either provide doc ids or set delete_all(true)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
if !h.datasetService.Accessible(datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -530,7 +530,9 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use kbID to filter documents
|
||||
// Use kbID to filter documents.
|
||||
// Note: create_time_from / create_time_to are applied post-query (not at DB level)
|
||||
// so that total reflects the unfiltered count, matching the Python API contract.
|
||||
documents, total, err := h.documentService.ListDocumentsByDatasetIDWithOptions(opts, page, pageSize)
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, 1, map[string]interface{}{"total": 0, "docs": []interface{}{}}, "failed to get documents")
|
||||
@@ -539,6 +541,12 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
|
||||
|
||||
docs := make([]map[string]interface{}, 0, len(documents))
|
||||
for _, doc := range documents {
|
||||
if opts.CreateTimeFrom > 0 && doc.CreateTime != nil && *doc.CreateTime < opts.CreateTimeFrom {
|
||||
continue
|
||||
}
|
||||
if opts.CreateTimeTo > 0 && doc.CreateTime != nil && *doc.CreateTime > opts.CreateTimeTo {
|
||||
continue
|
||||
}
|
||||
metaFields, err := h.documentService.GetDocumentMetadataByID(doc.ID)
|
||||
if err != nil {
|
||||
metaFields = make(map[string]interface{})
|
||||
@@ -1387,31 +1395,38 @@ func (h *DocumentHandler) ListIngestionTasks(c *gin.Context) {
|
||||
}
|
||||
|
||||
type StartParseDocumentsRequest struct {
|
||||
DatasetID string `json:"dataset_id"`
|
||||
Documents []string `json:"documents" binding:"required"`
|
||||
DocumentIDs []string `json:"document_ids" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *DocumentHandler) StartIngestionTask(c *gin.Context) {
|
||||
datasetID := c.Param("dataset_id")
|
||||
|
||||
var req StartParseDocumentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "`document_ids` is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
if !h.datasetService.Accessible(req.DatasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
|
||||
if !h.datasetService.Accessible(datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
return
|
||||
}
|
||||
|
||||
parseResult, err := h.documentService.IngestDocuments(req.DatasetID, userID, req.Documents)
|
||||
parseResult, err := h.documentService.IngestDocuments(datasetID, userID, req.DocumentIDs)
|
||||
if err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeExceptionError, nil, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithData(c, parseResult, "success")
|
||||
successCount := 0
|
||||
for _, r := range parseResult {
|
||||
if strings.HasPrefix(r.Result, "task_id:") {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
common.SuccessWithData(c, map[string]interface{}{"success_count": successCount}, "success")
|
||||
}
|
||||
|
||||
type StopIngestionsRequest struct {
|
||||
@@ -1510,7 +1525,7 @@ func (h *DocumentHandler) StopParseDocuments(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
if !h.datasetService.Accessible(datasetID, userID) {
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "You don't own the dataset.")
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1684,19 +1699,32 @@ func (h *DocumentHandler) handleBatchUpdateDocumentMetadatas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req documentMetadataBatchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
|
||||
var rawBody map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&rawBody); err != nil {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Invalid request payload: expected object, got "+inferJSONType(err))
|
||||
return
|
||||
}
|
||||
if req.Selector == nil {
|
||||
req.Selector = &document.DocumentMetadataSelector{}
|
||||
|
||||
selector, errMsg := parseMetadataSelector(rawBody["selector"])
|
||||
if errMsg != "" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
|
||||
return
|
||||
}
|
||||
if req.Updates == nil {
|
||||
req.Updates = []document.DocumentMetadataUpdate{}
|
||||
updates, errMsg := parseMetadataUpdates(rawBody["updates"])
|
||||
if errMsg != "" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
|
||||
return
|
||||
}
|
||||
if req.Deletes == nil {
|
||||
req.Deletes = []document.DocumentMetadataDelete{}
|
||||
deletes, errMsg := parseMetadataDeletes(rawBody["deletes"])
|
||||
if errMsg != "" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
req := documentMetadataBatchRequest{
|
||||
Selector: selector,
|
||||
Updates: updates,
|
||||
Deletes: deletes,
|
||||
}
|
||||
|
||||
resp, code, err := h.documentService.BatchUpdateDocumentMetadatas(datasetID, req.Selector, req.Updates, req.Deletes)
|
||||
@@ -1706,3 +1734,95 @@ func (h *DocumentHandler) handleBatchUpdateDocumentMetadatas(c *gin.Context) {
|
||||
}
|
||||
common.SuccessWithData(c, resp, "success")
|
||||
}
|
||||
|
||||
func inferJSONType(err error) string {
|
||||
s := err.Error()
|
||||
if strings.Contains(s, "array") {
|
||||
return "array"
|
||||
}
|
||||
if strings.Contains(s, "number") {
|
||||
return "number"
|
||||
}
|
||||
if strings.Contains(s, "string") {
|
||||
return "string"
|
||||
}
|
||||
if strings.Contains(s, "bool") {
|
||||
return "bool"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func parseMetadataSelector(raw interface{}) (*document.DocumentMetadataSelector, string) {
|
||||
if raw == nil {
|
||||
return &document.DocumentMetadataSelector{}, ""
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, "selector must be an object."
|
||||
}
|
||||
selector := &document.DocumentMetadataSelector{}
|
||||
if v, ok := m["document_ids"]; ok && v != nil {
|
||||
ids, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, "document_ids must be a list."
|
||||
}
|
||||
for _, id := range ids {
|
||||
selector.DocumentIDs = append(selector.DocumentIDs, id.(string))
|
||||
}
|
||||
}
|
||||
if v, ok := m["metadata_condition"]; ok && v != nil {
|
||||
mc, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, "metadata_condition must be an object."
|
||||
}
|
||||
selector.MetadataCondition = mc
|
||||
}
|
||||
return selector, ""
|
||||
}
|
||||
|
||||
func parseMetadataUpdates(raw interface{}) ([]document.DocumentMetadataUpdate, string) {
|
||||
if raw == nil {
|
||||
return []document.DocumentMetadataUpdate{}, ""
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil, "updates and deletes must be lists."
|
||||
}
|
||||
updates := make([]document.DocumentMetadataUpdate, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, "Each update requires key and value."
|
||||
}
|
||||
key, _ := m["key"].(string)
|
||||
if key == "" {
|
||||
return nil, "Each update requires key and value."
|
||||
}
|
||||
value := m["value"]
|
||||
updates = append(updates, document.DocumentMetadataUpdate{Key: key, Value: value})
|
||||
}
|
||||
return updates, ""
|
||||
}
|
||||
|
||||
func parseMetadataDeletes(raw interface{}) ([]document.DocumentMetadataDelete, string) {
|
||||
if raw == nil {
|
||||
return []document.DocumentMetadataDelete{}, ""
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil, "updates and deletes must be lists."
|
||||
}
|
||||
deletes := make([]document.DocumentMetadataDelete, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, "Each delete requires key."
|
||||
}
|
||||
key, _ := m["key"].(string)
|
||||
if key == "" {
|
||||
return nil, "Each delete requires key."
|
||||
}
|
||||
deletes = append(deletes, document.DocumentMetadataDelete{Key: key})
|
||||
}
|
||||
return deletes, ""
|
||||
}
|
||||
|
||||
@@ -148,8 +148,8 @@ func (h *SearchHandler) ListSearches(c *gin.Context) {
|
||||
// @Router /api/v1/searches [post]
|
||||
|
||||
type CreateSearchRequest struct {
|
||||
Name string `json:"name" binding:"required"` // required field, max 255 bytes
|
||||
Description *string `json:"description,omitempty"` // optional description
|
||||
Name string `json:"name"` // required, validated via common.ValidateName (max 255 bytes)
|
||||
Description *string `json:"description,omitempty"` // optional description
|
||||
}
|
||||
|
||||
func (h *SearchHandler) CreateSearch(c *gin.Context) {
|
||||
@@ -329,7 +329,7 @@ func (h *SearchHandler) UpdateSearch(c *gin.Context) {
|
||||
errMsg := err.Error()
|
||||
switch errMsg {
|
||||
case "no authorization":
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
|
||||
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
|
||||
case "duplicated search name":
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Duplicated search name.")
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// 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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
func setupSearchHandlerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
TranslateError: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.Search{}, &entity.UserTenant{}); err != nil {
|
||||
t.Fatalf("failed to migrate test schema: %v", err)
|
||||
}
|
||||
|
||||
origDB := dao.DB
|
||||
dao.DB = db
|
||||
t.Cleanup(func() { dao.DB = origDB })
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSearchHandlerCreateRejectsEmptyName(t *testing.T) {
|
||||
setupSearchHandlerTestDB(t)
|
||||
|
||||
h := NewSearchHandler(service.NewSearchService(), service.NewUserService())
|
||||
c, w := setupGinContextWithUser("POST", "/api/v1/searches", `{"name": " "}`)
|
||||
|
||||
h.CreateSearch(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeDataError) {
|
||||
t.Fatalf("expected code 102, got %v", resp["code"])
|
||||
}
|
||||
if !strings.Contains(resp["message"].(string), "empty") {
|
||||
t.Fatalf("expected message containing 'empty', got %v", resp["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHandlerUpdateRejectsInvalidSearchID(t *testing.T) {
|
||||
setupSearchHandlerTestDB(t)
|
||||
|
||||
h := NewSearchHandler(service.NewSearchService(), service.NewUserService())
|
||||
c, w := setupGinContextWithUser("PUT", "/api/v1/searches/invalid_search_id", `{"name": "invalid", "search_config": {}}`)
|
||||
c.Params = []gin.Param{{Key: "search_id", Value: "invalid_search_id"}}
|
||||
|
||||
h.UpdateSearch(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeAuthenticationError) {
|
||||
t.Fatalf("expected code 109, got %v", resp["code"])
|
||||
}
|
||||
if !strings.Contains(resp["message"].(string), "No authorization") {
|
||||
t.Fatalf("expected 'No authorization' in message, got %v", resp["message"])
|
||||
}
|
||||
}
|
||||
@@ -401,11 +401,11 @@ func (r *Router) Setup(engine *gin.Engine) {
|
||||
datasets.POST("/:dataset_id/chunks", r.chunkHandler.Parse)
|
||||
datasets.PATCH("/:dataset_id/documents/:document_id/chunks/:chunk_id", r.chunkHandler.UpdateChunk)
|
||||
datasets.POST("/:dataset_id/documents/parse", r.documentHandler.StartIngestionTask)
|
||||
datasets.POST("/:dataset_id/documents/stop", r.documentHandler.StopParseDocuments)
|
||||
datasets.GET("/ingestion/tasks", r.documentHandler.ListIngestionTasks)
|
||||
datasets.PUT("/ingestion/tasks", r.documentHandler.StopIngestionTasks)
|
||||
datasets.DELETE("/ingestion/tasks", r.documentHandler.RemoveIngestionTasks)
|
||||
//datasets.POST("/:dataset_id/documents/parse", r.documentHandler.ParseDocuments)
|
||||
//datasets.POST("/:dataset_id/documents/stop", r.documentHandler.StopParseDocuments)
|
||||
datasets.DELETE("/:dataset_id/chunks", r.chunkHandler.StopParsing)
|
||||
datasets.DELETE("/:dataset_id/documents/:document_id/chunks", r.chunkHandler.RemoveChunks)
|
||||
datasets.PUT("/:dataset_id/documents/:document_id/metadata/config", r.datasetsHandler.UpdateDocumentMetadataConfig)
|
||||
|
||||
+21
-18
@@ -131,7 +131,7 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
|
||||
}
|
||||
|
||||
if tenantValue, ok := req["tenant_id"]; ok && isTruthy(tenantValue) {
|
||||
return nil, common.CodeDataError, errors.New("`tenant_id` must not be provided")
|
||||
return nil, common.CodeDataError, errors.New("`tenant_id` must not be provided.")
|
||||
}
|
||||
|
||||
name, err := validateCreateChatName(req["name"])
|
||||
@@ -174,13 +174,13 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
|
||||
|
||||
if promptConfigValue, ok := req["prompt_config"]; ok {
|
||||
if _, ok := mapFromValue(promptConfigValue); !ok {
|
||||
return nil, common.CodeDataError, errors.New("`prompt_config` should be an object")
|
||||
return nil, common.CodeDataError, errors.New("`prompt_config` should be an object.")
|
||||
}
|
||||
}
|
||||
|
||||
if metaDataFilterValue, ok := req["meta_data_filter"]; ok && metaDataFilterValue != nil {
|
||||
if _, ok := mapFromValue(metaDataFilterValue); !ok {
|
||||
return nil, common.CodeDataError, errors.New("`meta_data_filter` should be an object")
|
||||
return nil, common.CodeDataError, errors.New("`meta_data_filter` should be an object.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,9 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
|
||||
if _, ok := req["vector_similarity_weight"]; !ok {
|
||||
req["vector_similarity_weight"] = 0.3
|
||||
}
|
||||
if _, ok := req["do_refer"]; !ok {
|
||||
req["do_refer"] = "1"
|
||||
}
|
||||
if _, ok := req["icon"]; !ok {
|
||||
req["icon"] = ""
|
||||
}
|
||||
@@ -240,7 +243,7 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if exists {
|
||||
return nil, common.CodeDataError, errors.New("duplicated chat name in creating chat")
|
||||
return nil, common.CodeDataError, errors.New("Duplicated chat name in creating chat.")
|
||||
}
|
||||
|
||||
chat := buildCreateChatEntity(req, userID)
|
||||
@@ -262,18 +265,18 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
|
||||
|
||||
func validateCreateChatName(value interface{}) (string, error) {
|
||||
if value == nil {
|
||||
return "", errors.New("`name` is required")
|
||||
return "", errors.New("`name` is required.")
|
||||
}
|
||||
name, ok := value.(string)
|
||||
if !ok {
|
||||
return "", errors.New("chat name must be a string")
|
||||
return "", errors.New("Chat name must be a string.")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", errors.New("`name` is required")
|
||||
return "", errors.New("`name` is required.")
|
||||
}
|
||||
if len([]byte(name)) > 255 {
|
||||
return "", fmt.Errorf("chat name length is %d which is larger than 255", len([]byte(name)))
|
||||
return "", fmt.Errorf("Chat name length is %d which is larger than 255.", len([]byte(name)))
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
@@ -767,7 +770,7 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
|
||||
}
|
||||
|
||||
if !patch && isTruthy(req["tenant_id"]) {
|
||||
return nil, errors.New("`tenant_id` must not be provided")
|
||||
return nil, errors.New("`tenant_id` must not be provided.")
|
||||
}
|
||||
|
||||
if value, ok := req["name"]; ok {
|
||||
@@ -827,7 +830,7 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
|
||||
if value, ok := req["prompt_config"]; ok {
|
||||
promptConfig, ok := mapFromValue(value)
|
||||
if !ok {
|
||||
return nil, errors.New("`prompt_config` should be an object")
|
||||
return nil, errors.New("`prompt_config` should be an object.")
|
||||
}
|
||||
if patch {
|
||||
req["prompt_config"] = mergeJSONMap(currentChat.PromptConfig, promptConfig)
|
||||
@@ -850,7 +853,7 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
|
||||
} else {
|
||||
metaDataFilter, ok := mapFromValue(value)
|
||||
if !ok {
|
||||
return nil, errors.New("`meta_data_filter` should be an object")
|
||||
return nil, errors.New("`meta_data_filter` should be an object.")
|
||||
}
|
||||
req["meta_data_filter"] = entity.JSONMap(metaDataFilter)
|
||||
}
|
||||
@@ -871,8 +874,8 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
|
||||
return nil, err
|
||||
}
|
||||
for _, existingName := range existingNames {
|
||||
if existingName == name {
|
||||
return nil, errors.New("duplicated chat name")
|
||||
if strings.EqualFold(existingName, name) {
|
||||
return nil, errors.New("Duplicated chat name.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,23 +900,23 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
|
||||
func validateRESTChatName(value interface{}, required bool) (string, bool, error) {
|
||||
if value == nil {
|
||||
if required {
|
||||
return "", false, errors.New("`name` is required")
|
||||
return "", false, errors.New("`name` is required.")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
name, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false, errors.New("chat name must be a string")
|
||||
return "", false, errors.New("Chat name must be a string.")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
if required {
|
||||
return "", false, errors.New("`name` is required")
|
||||
return "", false, errors.New("`name` is required.")
|
||||
}
|
||||
return "", false, errors.New("`name` cannot be empty")
|
||||
return "", false, errors.New("`name` cannot be empty.")
|
||||
}
|
||||
if len([]byte(name)) > 255 {
|
||||
return "", false, fmt.Errorf("chat name length is %d which is larger than 255", len([]byte(name)))
|
||||
return "", false, fmt.Errorf("Chat name length is %d which is larger than 255.", len([]byte(name)))
|
||||
}
|
||||
return name, true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func setupChatListTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
TranslateError: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&entity.Chat{},
|
||||
&entity.Tenant{},
|
||||
&entity.User{},
|
||||
&entity.UserTenant{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate test schema: %v", err)
|
||||
}
|
||||
|
||||
origDB := dao.DB
|
||||
dao.DB = db
|
||||
t.Cleanup(func() { dao.DB = origDB })
|
||||
|
||||
status := string(entity.StatusValid)
|
||||
if err := db.Create(&entity.Tenant{
|
||||
ID: "user-1",
|
||||
LLMID: "model-a",
|
||||
EmbdID: "embd-a",
|
||||
ParserIDs: "naive",
|
||||
Status: &status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&entity.User{
|
||||
ID: "user-1",
|
||||
Nickname: "tester",
|
||||
Status: sptr("1"),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func createChatListTestChat(t *testing.T, db *gorm.DB, id, tenantID, name string) {
|
||||
t.Helper()
|
||||
|
||||
status := string(entity.StatusValid)
|
||||
chat := &entity.Chat{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: &name,
|
||||
LLMID: "model-a",
|
||||
LLMSetting: entity.JSONMap{},
|
||||
PromptType: "simple",
|
||||
PromptConfig: entity.JSONMap{},
|
||||
KBIDs: entity.JSONSlice{},
|
||||
Status: &status,
|
||||
}
|
||||
if err := db.Create(chat).Error; err != nil {
|
||||
t.Fatalf("failed to create chat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceListChatsDefaultReturnsAllWithCorrectTotal(t *testing.T) {
|
||||
db := setupChatListTestDB(t)
|
||||
createChatListTestChat(t, db, "chat-1", "user-1", "list_test_0")
|
||||
createChatListTestChat(t, db, "chat-2", "user-1", "list_test_1")
|
||||
createChatListTestChat(t, db, "chat-3", "user-1", "list_test_2")
|
||||
|
||||
svc := NewChatService()
|
||||
result, err := svc.ListChats("user-1", "1", "", 0, 0, "create_time", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats failed: %v", err)
|
||||
}
|
||||
if result.Total != 3 {
|
||||
t.Fatalf("expected total=3, got %d", result.Total)
|
||||
}
|
||||
if len(result.Chats) != 3 {
|
||||
t.Fatalf("expected 3 chats, got %d", len(result.Chats))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceListChatsKeywordFiltersCorrectly(t *testing.T) {
|
||||
db := setupChatListTestDB(t)
|
||||
createChatListTestChat(t, db, "chat-1", "user-1", "list_keyword_0")
|
||||
createChatListTestChat(t, db, "chat-2", "user-1", "list_keyword_1")
|
||||
createChatListTestChat(t, db, "chat-3", "user-1", "list_other_2")
|
||||
|
||||
svc := NewChatService()
|
||||
|
||||
exactResult, err := svc.ListChats("user-1", "1", "list_keyword_1", 0, 0, "create_time", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats keyword exact failed: %v", err)
|
||||
}
|
||||
if len(exactResult.Chats) != 1 {
|
||||
t.Fatalf("expected 1 chat for keyword 'list_keyword_1', got %d", len(exactResult.Chats))
|
||||
}
|
||||
if exactResult.Chats[0].Name == nil || *exactResult.Chats[0].Name != "list_keyword_1" {
|
||||
t.Fatalf("expected chat name 'list_keyword_1', got %+v", exactResult.Chats[0].Name)
|
||||
}
|
||||
|
||||
unknownResult, err := svc.ListChats("user-1", "1", "unknown_keyword", 0, 0, "create_time", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats unknown keyword failed: %v", err)
|
||||
}
|
||||
if len(unknownResult.Chats) != 0 {
|
||||
t.Fatalf("expected 0 chats for unknown keyword, got %d", len(unknownResult.Chats))
|
||||
}
|
||||
|
||||
partialResult, err := svc.ListChats("user-1", "1", "list_keyword", 0, 0, "create_time", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats partial keyword failed: %v", err)
|
||||
}
|
||||
if len(partialResult.Chats) != 2 {
|
||||
t.Fatalf("expected 2 chats for keyword 'list_keyword', got %d", len(partialResult.Chats))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceListChatsPagination(t *testing.T) {
|
||||
db := setupChatListTestDB(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
createChatListTestChat(t, db, "chat-"+string(rune('a'+i)), "user-1", "page_test")
|
||||
}
|
||||
|
||||
svc := NewChatService()
|
||||
|
||||
page1, err := svc.ListChats("user-1", "1", "", 1, 2, "create_time", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats page 1 failed: %v", err)
|
||||
}
|
||||
if len(page1.Chats) != 2 {
|
||||
t.Fatalf("expected 2 chats on page 1, got %d", len(page1.Chats))
|
||||
}
|
||||
if page1.Total != 5 {
|
||||
t.Fatalf("expected total=5, got %d", page1.Total)
|
||||
}
|
||||
|
||||
page3, err := svc.ListChats("user-1", "1", "", 3, 2, "create_time", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats page 3 failed: %v", err)
|
||||
}
|
||||
if len(page3.Chats) != 1 {
|
||||
t.Fatalf("expected 1 chat on page 3, got %d", len(page3.Chats))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceListChatsExcludesDeletedChats(t *testing.T) {
|
||||
db := setupChatListTestDB(t)
|
||||
createChatListTestChat(t, db, "chat-1", "user-1", "active_chat")
|
||||
createChatListTestChat(t, db, "chat-2", "user-1", "deleted_chat")
|
||||
|
||||
invalidStatus := string(entity.StatusInvalid)
|
||||
db.Model(&entity.Chat{}).Where("id = ?", "chat-2").Update("status", invalidStatus)
|
||||
|
||||
svc := NewChatService()
|
||||
result, err := svc.ListChats("user-1", "1", "", 0, 0, "create_time", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats failed: %v", err)
|
||||
}
|
||||
if len(result.Chats) != 1 {
|
||||
t.Fatalf("expected 1 active chat, got %d", len(result.Chats))
|
||||
}
|
||||
if result.Chats[0].Name == nil || *result.Chats[0].Name != "active_chat" {
|
||||
t.Fatalf("expected active_chat, got %+v", result.Chats[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -174,7 +175,7 @@ func TestChatServiceCreateRejectsInvalidMetaDataFilter(t *testing.T) {
|
||||
"name": "created chat",
|
||||
"meta_data_filter": []interface{}{"invalid"},
|
||||
})
|
||||
if err == nil || err.Error() != "`meta_data_filter` should be an object" {
|
||||
if err == nil || err.Error() != "`meta_data_filter` should be an object." {
|
||||
t.Fatalf("expected meta_data_filter error, got %v", err)
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
@@ -227,7 +228,7 @@ func TestChatServiceUpdateChatRejectsTenantID(t *testing.T) {
|
||||
_, err := svc.UpdateChat("user-1", "chat-1", map[string]interface{}{
|
||||
"tenant_id": "tenant-2",
|
||||
})
|
||||
if err == nil || err.Error() != "`tenant_id` must not be provided" {
|
||||
if err == nil || err.Error() != "`tenant_id` must not be provided." {
|
||||
t.Fatalf("expected tenant_id error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -325,3 +326,186 @@ func TestChatServicePatchChatIgnoresTenantIDAndUpdatesName(t *testing.T) {
|
||||
t.Fatalf("expected trimmed name, got %+v", chat.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceCreateValidatesName(t *testing.T) {
|
||||
setupChatRESTUpdateServiceTestDB(t)
|
||||
|
||||
svc := NewChatService()
|
||||
_, code, err := svc.Create("user-1", map[string]interface{}{"name": " "})
|
||||
if err == nil {
|
||||
t.Fatal("expected name validation error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if err.Error() != "`name` is required." {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceCreateRejectsDuplicateName(t *testing.T) {
|
||||
db := setupChatRESTUpdateServiceTestDB(t)
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
|
||||
|
||||
svc := NewChatService()
|
||||
_, code, err := svc.Create("user-1", map[string]interface{}{"name": "chat-chat-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Duplicated chat name") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceUpdateValidatesName(t *testing.T) {
|
||||
db := setupChatRESTUpdateServiceTestDB(t)
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
|
||||
|
||||
svc := NewChatService()
|
||||
_, err := svc.UpdateChat("user-1", "chat-1", map[string]interface{}{"name": " "})
|
||||
if err == nil {
|
||||
t.Fatal("expected name validation error")
|
||||
}
|
||||
if err.Error() != "`name` is required." {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceUpdateRejectsDuplicateName(t *testing.T) {
|
||||
db := setupChatRESTUpdateServiceTestDB(t)
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-2", "user-1")
|
||||
|
||||
svc := NewChatService()
|
||||
_, err := svc.UpdateChat("user-1", "chat-1", map[string]interface{}{"name": "chat-chat-2"})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if err.Error() != "Duplicated chat name." {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServicePatchChatRejectsEmptyName(t *testing.T) {
|
||||
db := setupChatRESTUpdateServiceTestDB(t)
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
|
||||
|
||||
svc := NewChatService()
|
||||
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": ""})
|
||||
if err == nil || err.Error() != "`name` cannot be empty." {
|
||||
t.Fatalf("expected empty name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServicePatchChatRejectsNonStringName(t *testing.T) {
|
||||
db := setupChatRESTUpdateServiceTestDB(t)
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
|
||||
|
||||
svc := NewChatService()
|
||||
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": 123})
|
||||
if err == nil || err.Error() != "Chat name must be a string." {
|
||||
t.Fatalf("expected non-string name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServicePatchChatRejectsTooLongName(t *testing.T) {
|
||||
db := setupChatRESTUpdateServiceTestDB(t)
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
|
||||
|
||||
svc := NewChatService()
|
||||
longName := strings.Repeat("a", 256)
|
||||
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": longName})
|
||||
if err == nil {
|
||||
t.Fatal("expected too long name error")
|
||||
}
|
||||
if err.Error() != "Chat name length is 256 which is larger than 255." {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceUpdateRejectsDuplicateNameCaseInsensitive(t *testing.T) {
|
||||
db := setupChatRESTUpdateServiceTestDB(t)
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
|
||||
createChatRESTUpdateServiceTestChat(t, db, "chat-2", "user-1")
|
||||
|
||||
svc := NewChatService()
|
||||
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": "CHAT-CHAT-2"})
|
||||
if err == nil || err.Error() != "Duplicated chat name." {
|
||||
t.Fatalf("expected case-insensitive duplicate name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceCreateRejectsTenantID(t *testing.T) {
|
||||
setupChatRESTUpdateServiceTestDB(t)
|
||||
|
||||
svc := NewChatService()
|
||||
_, code, err := svc.Create("user-1", map[string]interface{}{
|
||||
"name": "valid chat",
|
||||
"tenant_id": "other-tenant",
|
||||
})
|
||||
if err == nil || err.Error() != "`tenant_id` must not be provided." {
|
||||
t.Fatalf("expected tenant_id error, got %v", err)
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceCreateRejectsInvalidPromptConfig(t *testing.T) {
|
||||
setupChatRESTUpdateServiceTestDB(t)
|
||||
|
||||
svc := NewChatService()
|
||||
_, code, err := svc.Create("user-1", map[string]interface{}{
|
||||
"name": "valid chat",
|
||||
"prompt_config": "invalid",
|
||||
})
|
||||
if err == nil || err.Error() != "`prompt_config` should be an object." {
|
||||
t.Fatalf("expected prompt_config error, got %v", err)
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceCreatePromptDefaultsContract(t *testing.T) {
|
||||
setupChatRESTUpdateServiceTestDB(t)
|
||||
|
||||
svc := NewChatService()
|
||||
resp, code, err := svc.Create("user-1", map[string]interface{}{
|
||||
"name": "prompt defaults chat",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("expected success code, got %d", code)
|
||||
}
|
||||
|
||||
promptConfig, ok := resp["prompt_config"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected prompt_config map, got %T: %+v", resp["prompt_config"], resp["prompt_config"])
|
||||
}
|
||||
if promptConfig["quote"] != true {
|
||||
t.Fatalf("expected default quote=true, got %#v", promptConfig["quote"])
|
||||
}
|
||||
params, ok := promptConfig["parameters"].([]interface{})
|
||||
if !ok || len(params) != 1 {
|
||||
t.Fatalf("expected default parameters list with 1 entry, got %#v", promptConfig["parameters"])
|
||||
}
|
||||
param, ok := params[0].(map[string]interface{})
|
||||
if !ok || param["key"] != "knowledge" || param["optional"] != false {
|
||||
t.Fatalf("expected knowledge parameter, got %#v", params[0])
|
||||
}
|
||||
if promptConfig["system"] == "" {
|
||||
t.Fatal("expected non-empty default system prompt")
|
||||
}
|
||||
if promptConfig["prologue"] == "" {
|
||||
t.Fatal("expected non-empty default prologue")
|
||||
}
|
||||
if promptConfig["empty_response"] == "" {
|
||||
t.Fatal("expected non-empty default empty_response")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// 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 service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateSession contract — mirrors the assertions in
|
||||
// test_session_create_validation_and_deleted_chat_contract that exercise the
|
||||
// Go service layer (name validation -> 102, auth -> 109, truncation, success).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreateSession_Success(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", PromptConfig: entity.JSONMap{"prologue": "hi"}}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
resp, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": "valid"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
if resp.Name == nil || *resp.Name != "valid" {
|
||||
t.Fatalf("name=%v", resp.Name)
|
||||
}
|
||||
if resp.ChatID != "chat-1" {
|
||||
t.Fatalf("chat_id=%q", resp.ChatID)
|
||||
}
|
||||
if len(store.sessions) != 1 {
|
||||
t.Fatalf("expected 1 session created, got %d", len(store.sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSession_RejectsEmptyOrNonStringName(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1"}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
for _, name := range []interface{}{"", " ", 1} {
|
||||
_, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": name})
|
||||
if err == nil || err.Error() != "`name` can not be empty." {
|
||||
t.Fatalf("name=%#v err=%v", name, err)
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("name=%#v code=%v", name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSession_TruncatesLongName(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1"}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
longName := strings.Repeat("a", 300)
|
||||
resp, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": longName})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
if resp.Name == nil || len([]rune(*resp.Name)) != 255 {
|
||||
t.Fatalf("expected name truncated to 255 runes, got %v", resp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSession_NotOwner(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
_, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": "x"})
|
||||
if err == nil || err.Error() != "No authorization." {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteSessions contract — mirrors the service-layer assertions in
|
||||
// test_session_delete_basic_scenarios.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDeleteSessions_SuccessByIDs(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
store.sessions["s1"] = &entity.ChatSession{ID: "s1", DialogID: "chat-1"}
|
||||
store.sessions["s2"] = &entity.ChatSession{ID: "s2", DialogID: "chat-1"}
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
result, message, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"s1"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
if message != "success" {
|
||||
t.Fatalf("message=%q", message)
|
||||
}
|
||||
if result != true {
|
||||
t.Fatalf("result=%v", result)
|
||||
}
|
||||
if len(store.sessions) != 1 {
|
||||
t.Fatalf("expected 1 session remaining, got %d", len(store.sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessions_DeleteAllAndInvalidID(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
// Empty payload -> success with empty result map.
|
||||
if _, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{}); err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("empty payload: code=%v err=%v", code, err)
|
||||
}
|
||||
|
||||
// delete_all removes every session for the chat.
|
||||
store.sessions["s1"] = &entity.ChatSession{ID: "s1", DialogID: "chat-1"}
|
||||
if _, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"delete_all": true}); err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("delete_all: code=%v err=%v", code, err)
|
||||
}
|
||||
if len(store.sessions) != 0 {
|
||||
t.Fatalf("delete_all should remove all, got %d", len(store.sessions))
|
||||
}
|
||||
|
||||
// Unknown id -> DataError reporting the unowned session.
|
||||
store.sessions["s1"] = &entity.ChatSession{ID: "s1", DialogID: "chat-1"}
|
||||
_, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"missing"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "The chat doesn't own the session missing") {
|
||||
t.Fatalf("invalid id: err=%v", err)
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("invalid id code=%v", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessions_NotOwner(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
svc := &ChatSessionService{
|
||||
chatSessionDAO: store,
|
||||
userTenantDAO: &fakeTenantStore{},
|
||||
pipeline: &fakePipeline{},
|
||||
}
|
||||
|
||||
_, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"s1"}})
|
||||
if err == nil || err.Error() != "No authorization." {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if code != common.CodeAuthenticationError {
|
||||
t.Fatalf("code=%v", code)
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,89 @@ func TestCreateDataset_ParseTypePipelineIgnoresParserID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_ValidatesName(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(&service.CreateDatasetRequest{Name: " "}, "tenant-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected name validation error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if err.Error() != "Dataset name can't be empty." {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_RejectsDuplicateName(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
|
||||
if err := db.Create(&entity.Knowledgebase{
|
||||
ID: "kb-1",
|
||||
TenantID: "tenant-1",
|
||||
Name: "Existing",
|
||||
ParserID: "naive",
|
||||
CreatedBy: "tenant-1",
|
||||
Status: sptr(string(entity.StatusValid)),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create existing kb: %v", err)
|
||||
}
|
||||
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(&service.CreateDatasetRequest{Name: "Existing"}, "tenant-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_RejectsInvalidEmbeddingModel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
embeddingModel string
|
||||
expectedMessage string
|
||||
}{
|
||||
{"empty", "", "Embedding model identifier must follow <model_name>@<provider> format"},
|
||||
{"whitespace", " ", "Embedding model identifier must follow <model_name>@<provider> format"},
|
||||
{"missing_at", "BAAI/bge-small-en-v1.5Builtin", "Embedding model identifier must follow <model_name>@<provider> format"},
|
||||
{"empty_model_name", "@Builtin", "Both model_name and provider must be non-empty strings"},
|
||||
{"empty_provider", "BAAI/bge-small-en-v1.5@", "Both model_name and provider must be non-empty strings"},
|
||||
{"whitespace_model_name", " @Builtin", "Both model_name and provider must be non-empty strings"},
|
||||
{"whitespace_provider", "BAAI/bge-small-en-v1.5@ ", "Both model_name and provider must be non-empty strings"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertCreateDatasetTenant(t, "tenant-1")
|
||||
|
||||
_, code, err := testDatasetCreateService(t).CreateDataset(&service.CreateDatasetRequest{
|
||||
Name: "ds-embd-" + tc.name,
|
||||
EmbeddingModel: &tc.embeddingModel,
|
||||
}, "tenant-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected embedding model validation error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if err.Error() != tc.expectedMessage {
|
||||
t.Fatalf("unexpected error: got %q, want %q", err.Error(), tc.expectedMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDataset_RejectsBothWithoutParseType(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
|
||||
@@ -118,17 +118,18 @@ func (d *DatasetService) CreateDataset(req *service.CreateDatasetRequest, tenant
|
||||
|
||||
kbID := utility.GenerateToken()
|
||||
status := string(entity.StatusValid)
|
||||
duplicateName, err := common.DuplicateName(func(n, tid string) bool {
|
||||
existing, err := d.kbDAO.GetByName(n, tid)
|
||||
return err == nil && existing != nil
|
||||
}, name, tenantID)
|
||||
if err != nil {
|
||||
return nil, common.CodeDataError, err
|
||||
// Reject duplicate name within tenant to match the established API contract.
|
||||
existing, err := d.kbDAO.GetByName(name, tenantID)
|
||||
if err != nil && !dao.IsNotFoundErr(err) {
|
||||
return nil, common.CodeServerError, errors.New("Database operation failed")
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, common.CodeDataError, fmt.Errorf("Dataset name '%s' already exists", name)
|
||||
}
|
||||
|
||||
kb := &entity.Knowledgebase{
|
||||
ID: kbID,
|
||||
Name: duplicateName,
|
||||
Name: name,
|
||||
TenantID: tenantID,
|
||||
CreatedBy: tenantID,
|
||||
ParserID: parserID,
|
||||
@@ -141,6 +142,9 @@ func (d *DatasetService) CreateDataset(req *service.CreateDatasetRequest, tenant
|
||||
}
|
||||
|
||||
if err = d.kbDAO.Create(kb); err != nil {
|
||||
if dao.IsDuplicateKeyErr(err) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("Dataset name '%s' already exists", name)
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("Failed to save dataset")
|
||||
}
|
||||
|
||||
@@ -374,7 +378,7 @@ func (d *DatasetService) ListDatasets(id, name string, page, pageSize int, order
|
||||
}
|
||||
}
|
||||
|
||||
kbs, total, err := d.kbDAO.GetByTenantIDs(tenantIDs, userID, page, pageSize, orderby, desc, keywords, parserID)
|
||||
kbs, total, err := d.kbDAO.GetByTenantIDs(tenantIDs, userID, page, pageSize, orderby, desc, keywords, parserID, id, name)
|
||||
if err != nil {
|
||||
return nil, 0, common.CodeServerError, errors.New("Database operation failed")
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ const (
|
||||
|
||||
// validateParserID validates parser_id against the built-in pipeline registry.
|
||||
func validateParserID(chunkMethod string) error {
|
||||
if chunkMethod == "knowledge_graph" {
|
||||
return nil
|
||||
}
|
||||
registry, err := pipelinepkg.DefaultRegistry()
|
||||
if err != nil || registry == nil {
|
||||
return errors.New("parser_id validation unavailable: builtin pipeline registry not loaded")
|
||||
@@ -95,23 +98,32 @@ func validateDatasetAvatar(avatar string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatasetEmbeddingModel(embeddingModel string) error {
|
||||
if embeddingModel == "" {
|
||||
return errors.New("Embedding model identifier is required")
|
||||
func isHexID(s string) bool {
|
||||
if len(s) != 32 {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(embeddingModel, "@") {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(embeddingModel, "@")
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
return errors.New("Both model_name and provider must be non-empty strings")
|
||||
for _, c := range s {
|
||||
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return true
|
||||
}
|
||||
|
||||
func validateDatasetEmbeddingModel(embeddingModel string) error {
|
||||
if isHexID(embeddingModel) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.Contains(embeddingModel, "@") {
|
||||
return errors.New("Embedding model identifier must follow <model_name>@<provider> format")
|
||||
}
|
||||
if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[len(parts)-1]) == "" {
|
||||
|
||||
parts := strings.SplitN(embeddingModel, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return errors.New("Both model_name and provider must be non-empty strings")
|
||||
}
|
||||
if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return errors.New("Both model_name and provider must be non-empty strings")
|
||||
}
|
||||
return nil
|
||||
@@ -212,10 +224,8 @@ func datasetUpdateEmbeddingID(req service.UpdateDatasetRequest) (string, bool, e
|
||||
if !provided {
|
||||
return "", false, nil
|
||||
}
|
||||
if embdID != "" {
|
||||
if err := validateDatasetEmbeddingModel(embdID); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if err := validateDatasetEmbeddingModel(embdID); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
return embdID, true, nil
|
||||
}
|
||||
|
||||
@@ -89,8 +89,16 @@ func TestValidateDatasetEmbeddingModel_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateDatasetEmbeddingModel_NameOnlyNoProvider(t *testing.T) {
|
||||
if err := validateDatasetEmbeddingModel("BAAI/bge-large-zh-v1.5"); err != nil {
|
||||
t.Fatalf("expected nil for name without @, got %v", err)
|
||||
// A bare model name without @provider (and not a 32-char hex model ID) is
|
||||
// rejected, mirroring the Python contract.
|
||||
if err := validateDatasetEmbeddingModel("BAAI/bge-large-zh-v1.5"); err == nil {
|
||||
t.Fatal("expected error for name without @provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDatasetEmbeddingModel_HexModelID(t *testing.T) {
|
||||
if err := validateDatasetEmbeddingModel("aabbccdd11223344aabbccdd11223344"); err != nil {
|
||||
t.Fatalf("expected nil for 32-char hex model ID, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func (d *DatasetService) UpdateDataset(datasetID, tenantID string, req service.U
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
return nil, common.CodeDataError, errors.New("String should have at least 1 character")
|
||||
return nil, common.CodeDataError, errors.New("`name` is required.")
|
||||
}
|
||||
if len(name) > 128 {
|
||||
return nil, common.CodeDataError, errors.New("String should have at most 128 characters")
|
||||
@@ -227,12 +227,18 @@ func (d *DatasetService) UpdateDataset(datasetID, tenantID string, req service.U
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) == 0 && !connectorsProvided {
|
||||
if len(updates) == 0 && !connectorsProvided && !req.ParserConfigProvided {
|
||||
return nil, common.CodeDataError, errors.New("No properties were modified")
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err = d.kbDAO.UpdateByID(kb.ID, updates); err != nil {
|
||||
if dao.IsDuplicateKeyErr(err) {
|
||||
if nameValue, ok := updates["name"].(string); ok {
|
||||
return nil, common.CodeDataError, fmt.Errorf("Dataset name '%s' already exists", nameValue)
|
||||
}
|
||||
return nil, common.CodeDataError, errors.New("Dataset name already exists")
|
||||
}
|
||||
return nil, common.CodeServerError, errors.New("Update dataset error.(Database error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ func TestDatasetServiceUpdateDatasetValidatesName(t *testing.T) {
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if err.Error() != "String should have at least 1 character" {
|
||||
if err.Error() != "`name` is required." {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -364,9 +364,9 @@ func TestDatasetServiceUpdateDatasetAcceptsEmbeddingModelID(t *testing.T) {
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
|
||||
insertDatasetUpdateModelProvider(t, "provider-1", "tenant-1", "ZHIPU-AI")
|
||||
insertDatasetUpdateModelInstance(t, "instance-1", "provider-1", "test")
|
||||
insertDatasetUpdateTenantModel(t, "model-1", "provider-1", "instance-1", "embedding-2", int(entity.ModelTypeEmbedding))
|
||||
insertDatasetUpdateTenantModel(t, "aabbccdd11223344aabbccdd11223344", "provider-1", "instance-1", "embedding-2", int(entity.ModelTypeEmbedding))
|
||||
|
||||
embeddingModelID := "model-1"
|
||||
embeddingModelID := "aabbccdd11223344aabbccdd11223344"
|
||||
result, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
|
||||
EmbeddingModel: &embeddingModelID,
|
||||
})
|
||||
@@ -412,6 +412,179 @@ func TestDatasetServiceUpdateDatasetRejectsEmptyConnectorID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetRejectsInvalidEmbeddingModelFormat(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
embeddingModel string
|
||||
expectedMessage string
|
||||
}{
|
||||
{"empty", "", "Embedding model identifier must follow <model_name>@<provider> format"},
|
||||
{"whitespace", " ", "Embedding model identifier must follow <model_name>@<provider> format"},
|
||||
{"missing_at", "BAAI/bge-small-en-v1.5Builtin", "Embedding model identifier must follow <model_name>@<provider> format"},
|
||||
{"empty_model_name", "@Builtin", "Both model_name and provider must be non-empty strings"},
|
||||
{"empty_provider", "BAAI/bge-small-en-v1.5@", "Both model_name and provider must be non-empty strings"},
|
||||
}
|
||||
|
||||
svc := testDatasetUpdateService(t)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
embdModel := tc.embeddingModel
|
||||
_, code, err := svc.UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
|
||||
EmbeddingModel: &embdModel,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected embedding model format error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if err.Error() != tc.expectedMessage {
|
||||
t.Fatalf("unexpected error: got %q, want %q", err.Error(), tc.expectedMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetRejectsDuplicateNameCaseInsensitive(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
|
||||
insertDatasetUpdateKB(t, "kb-2", "tenant-1", "Existing")
|
||||
|
||||
uppercaseName := "EXISTING"
|
||||
_, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
|
||||
Name: &uppercaseName,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected case-insensitive duplicate name error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetPreservesUnmodifiedFields(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
|
||||
description := "original description"
|
||||
language := "English"
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
|
||||
dao.DB.Model(&entity.Knowledgebase{}).Where("id = ?", "kb-1").Updates(map[string]interface{}{
|
||||
"description": description,
|
||||
"language": language,
|
||||
})
|
||||
|
||||
newName := "Renamed Only"
|
||||
result, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
|
||||
Name: &newName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDataset failed: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("expected success code, got %d", code)
|
||||
}
|
||||
if result["name"] != newName {
|
||||
t.Fatalf("expected updated name %q, got %#v", newName, result["name"])
|
||||
}
|
||||
if result["description"] != description {
|
||||
t.Fatalf("expected description preserved, got %#v", result["description"])
|
||||
}
|
||||
if result["language"] != language {
|
||||
t.Fatalf("expected language preserved, got %#v", result["language"])
|
||||
}
|
||||
if result["embedding_model"] != "BAAI/bge-large-zh-v1.5@Builtin" {
|
||||
t.Fatalf("expected embedding_model preserved, got %#v", result["embedding_model"])
|
||||
}
|
||||
|
||||
persisted, err := dao.NewKnowledgebaseDAO().GetByID("kb-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get updated kb: %v", err)
|
||||
}
|
||||
if persisted.Name != newName {
|
||||
t.Fatalf("expected persisted name %q, got %q", newName, persisted.Name)
|
||||
}
|
||||
if persisted.Description == nil || *persisted.Description != description {
|
||||
t.Fatalf("expected persisted description %q, got %#v", description, persisted.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceUpdateDatasetPreservesParserConfigOnEmptyUpdate(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
|
||||
dao.DB.Model(&entity.Knowledgebase{}).Where("id = ?", "kb-1").Update("parser_config", entity.JSONMap{
|
||||
"chunk_token_num": float64(512),
|
||||
"delimiter": "\n",
|
||||
})
|
||||
|
||||
name := "Updated Name"
|
||||
_, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
|
||||
Name: &name,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDataset failed: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("expected success code, got %d", code)
|
||||
}
|
||||
|
||||
persisted, err := dao.NewKnowledgebaseDAO().GetByID("kb-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get updated kb: %v", err)
|
||||
}
|
||||
if persisted.ParserConfig["chunk_token_num"] != float64(512) {
|
||||
t.Fatalf("expected chunk_token_num preserved, got %#v", persisted.ParserConfig["chunk_token_num"])
|
||||
}
|
||||
if persisted.ParserConfig["delimiter"] != "\n" {
|
||||
t.Fatalf("expected delimiter preserved, got %#v", persisted.ParserConfig["delimiter"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceDeleteDatasetsRejectsUnauthorizedID(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
insertDatasetUpdateKB(t, "11111111111141118111111111111111", "tenant-1", "Test")
|
||||
|
||||
svc := NewDatasetService()
|
||||
normalizedID := "11111111111141118111111111111111"
|
||||
_, code, err := svc.DeleteDatasets([]string{normalizedID}, false, "tenant-2")
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthorized error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lacks permission") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetServiceDeleteDatasetsRejectsAllUnauthorized(t *testing.T) {
|
||||
db := setupDatasetUpdateTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
|
||||
svc := NewDatasetService()
|
||||
_, code, err := svc.DeleteDatasets([]string{"d94a8dc02c9711f0930f7fbc369eab6d"}, false, "tenant-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthorized error")
|
||||
}
|
||||
if code != common.CodeDataError {
|
||||
t.Fatalf("expected data error code, got %d", code)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lacks permission") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupDatasetUpdateTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -156,4 +156,9 @@ type UpdateDatasetRequest struct {
|
||||
// ParseType indicates pipeline selection mode: 1 = BuiltIn (parser_id),
|
||||
// 2 = Pipeline (pipeline_id). nil means unspecified (backward compat).
|
||||
ParseType *int `json:"parse_type,omitempty"`
|
||||
|
||||
// ParserConfigProvided reports whether the raw request body contained a
|
||||
// "parser_config" key. An explicitly provided parser_config ({} or null) is a
|
||||
// valid no-op that must succeed, unlike a truly empty request body.
|
||||
ParserConfigProvided bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *DocumentService) DownloadDocument(datasetID, docID string) (*DownloadDo
|
||||
}
|
||||
doc, err := s.documentDAO.GetByID(docID)
|
||||
if err != nil || doc.KbID != datasetID {
|
||||
return nil, fmt.Errorf("The dataset not own the document %s.", docID)
|
||||
return nil, fmt.Errorf("Document not found!")
|
||||
}
|
||||
bucket, name, err := s.GetDocumentStorageAddress(doc)
|
||||
if err != nil {
|
||||
|
||||
@@ -232,8 +232,13 @@ func (s *DocumentService) validateDatasetDocumentUpdate(datasetID, documentID, u
|
||||
if present["token_count"] && req.TokenCount != nil && *req.TokenCount != 0 && *req.TokenCount != doc.TokenNum {
|
||||
return common.CodeDataError, errors.New("Can't change `token_count`.")
|
||||
}
|
||||
if present["progress"] && req.Progress != nil && *req.Progress != 0 && math.Abs(*req.Progress-doc.Progress) > 1e-9 {
|
||||
return common.CodeDataError, errors.New("Can't change `progress`.")
|
||||
if present["progress"] && req.Progress != nil {
|
||||
if *req.Progress > 1 {
|
||||
return common.CodeDataError, fmt.Errorf("Field: <progress> - Message: <Input should be less than or equal to 1> - Value: <%v>", *req.Progress)
|
||||
}
|
||||
if *req.Progress != 0 && math.Abs(*req.Progress-doc.Progress) > 1e-9 {
|
||||
return common.CodeDataError, errors.New("Can't change `progress`.")
|
||||
}
|
||||
}
|
||||
|
||||
if present["enabled"] {
|
||||
|
||||
@@ -288,7 +288,7 @@ func (s *DocumentService) validateDocsInDataset(docIDs []string, datasetID strin
|
||||
}
|
||||
}
|
||||
if len(invalid) > 0 {
|
||||
return nil, fmt.Errorf("these documents do not belong to dataset %s: %v", datasetID, invalid)
|
||||
return nil, fmt.Errorf("These documents do not belong to dataset %s: %v", datasetID, invalid)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
@@ -17,15 +17,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/utility"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SearchService search service
|
||||
@@ -211,6 +208,10 @@ type CreateSearchResponse struct {
|
||||
// 6. Save to database within DB.atomic() transaction
|
||||
// 7. Return {search_id: id} on success
|
||||
func (s *SearchService) CreateSearch(userID string, name string, description *string) (*CreateSearchResponse, error) {
|
||||
if err := common.ValidateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate UUID for search ID (same as Python get_uuid())
|
||||
searchID := utility.GenerateUUID()
|
||||
|
||||
@@ -337,14 +338,7 @@ func (s *SearchService) DeleteSearch(userID string, searchID string) error {
|
||||
|
||||
// AccessibleForCompletion check if it is accessible
|
||||
func (s *SearchService) AccessibleForCompletion(userID string, searchID string) (bool, error) {
|
||||
ok, err := s.searchDAO.Accessible4Deletion(searchID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
return s.searchDAO.Accessible4Deletion(searchID, userID)
|
||||
}
|
||||
|
||||
type SearchCompletionPlan struct {
|
||||
@@ -561,14 +555,14 @@ type UpdateSearchRequest struct {
|
||||
|
||||
func (s *SearchService) UpdateSearch(userID string, searchID string, req *UpdateSearchRequest) (*entity.Search, error) {
|
||||
// Step 1: Check update permission (same as delete - uses accessible4deletion)
|
||||
// Only creator can update
|
||||
// Only creator can update. A missing or non-owned search is treated as
|
||||
// unauthorized so the contract returns a clear "no authorization" error.
|
||||
|
||||
status, err := s.searchDAO.Accessible4Deletion(searchID, userID)
|
||||
accessible, err := s.searchDAO.Accessible4Deletion(searchID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check deletion permission: %w", err)
|
||||
}
|
||||
|
||||
if !status {
|
||||
if !accessible {
|
||||
return nil, fmt.Errorf("no authorization")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// 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 service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func setupSearchServiceTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db := setupServiceTestDB(t)
|
||||
if err := db.AutoMigrate(&entity.Search{}); err != nil {
|
||||
t.Fatalf("failed to migrate search: %v", err)
|
||||
}
|
||||
pushServiceDB(t, db)
|
||||
}
|
||||
|
||||
func TestSearchServiceCreateRejectsEmptyName(t *testing.T) {
|
||||
setupSearchServiceTestDB(t)
|
||||
|
||||
_, err := NewSearchService().CreateSearch("tenant-1", " ", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty name validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchServiceUpdateRejectsUnauthorizedSearchID(t *testing.T) {
|
||||
setupSearchServiceTestDB(t)
|
||||
|
||||
req := &UpdateSearchRequest{
|
||||
Name: "New Name",
|
||||
SearchConfig: map[string]interface{}{},
|
||||
}
|
||||
_, err := NewSearchService().UpdateSearch("user-2", "invalid_search_id", req)
|
||||
if err == nil {
|
||||
t.Fatal("expected authorization error")
|
||||
}
|
||||
if err.Error() != "no authorization" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchServiceCreateAndUpdateRoundTrip(t *testing.T) {
|
||||
setupSearchServiceTestDB(t)
|
||||
|
||||
created, err := NewSearchService().CreateSearch("tenant-1", "My Search", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSearch failed: %v", err)
|
||||
}
|
||||
if created.SearchID == "" {
|
||||
t.Fatal("expected non-empty search_id")
|
||||
}
|
||||
|
||||
// A different user must not be able to update it.
|
||||
req := &UpdateSearchRequest{
|
||||
Name: "Hijacked Name",
|
||||
SearchConfig: map[string]interface{}{},
|
||||
}
|
||||
_, err = NewSearchService().UpdateSearch("user-2", created.SearchID, req)
|
||||
if err == nil || err.Error() != "no authorization" {
|
||||
t.Fatalf("expected no authorization, got %v", err)
|
||||
}
|
||||
|
||||
// The owner can update name + merge config.
|
||||
req = &UpdateSearchRequest{
|
||||
Name: "Updated Name",
|
||||
SearchConfig: map[string]interface{}{"summary": true},
|
||||
}
|
||||
updated, err := NewSearchService().UpdateSearch("tenant-1", created.SearchID, req)
|
||||
if err != nil {
|
||||
t.Fatalf("owner UpdateSearch failed: %v", err)
|
||||
}
|
||||
if updated.Name != "Updated Name" {
|
||||
t.Fatalf("expected updated name, got %q", updated.Name)
|
||||
}
|
||||
if updated.SearchConfig["summary"] != true {
|
||||
t.Fatalf("expected merged search_config, got %#v", updated.SearchConfig)
|
||||
}
|
||||
|
||||
persisted, err := dao.NewSearchDAO().GetByID(created.SearchID)
|
||||
if err != nil {
|
||||
t.Fatalf("get updated search: %v", err)
|
||||
}
|
||||
if persisted.Name != "Updated Name" {
|
||||
t.Fatalf("expected persisted name, got %q", persisted.Name)
|
||||
}
|
||||
}
|
||||
@@ -222,8 +222,8 @@ class RAGTools:
|
||||
"them all together as one comma-separated list, in the SAME language as the question.\n"
|
||||
' Example — question "In which year did Apple acquire Beats?": keywords = '
|
||||
'"Apple, Apple Inc., acquire, acquisition, Beats" (terms from the question + synonyms; '
|
||||
'the year is the ANSWER, so it must NOT appear).\n\n'
|
||||
'Output ONLY JSON, no prose, no code fences: '
|
||||
"the year is the ANSWER, so it must NOT appear).\n\n"
|
||||
"Output ONLY JSON, no prose, no code fences: "
|
||||
'{"question": "<standalone question>", "keywords": "<term1, term2, synonym1, ...>"}'
|
||||
)
|
||||
user = f"Conversation:\n{transcript}\n\nOutput JSON:"
|
||||
|
||||
@@ -47,11 +47,19 @@ register_tool(
|
||||
_inspector_schema("open_context", "Expand the original context around a chunk", {"chunk_id": {"type": "string"}, "width": {"type": "integer", "description": "Number of characters to expand"}}),
|
||||
open_context,
|
||||
)
|
||||
register_tool("inspector_compare", _inspector_schema("compare_sources", "Compare multiple chunks to find common points and contradictions", {"chunk_ids": {"type": "array", "items": {"type": "string"}}}), compare_sources)
|
||||
register_tool("inspector_grep_within", _inspector_schema("grep_within", "Search for an exact keyword or pattern within a document", {"doc_id": {"type": "string"}, "pattern": {"type": "string"}}), grep_within)
|
||||
register_tool(
|
||||
"inspector_compare",
|
||||
_inspector_schema("compare_sources", "Compare multiple chunks to find common points and contradictions", {"chunk_ids": {"type": "array", "items": {"type": "string"}}}),
|
||||
compare_sources,
|
||||
)
|
||||
register_tool(
|
||||
"inspector_grep_within", _inspector_schema("grep_within", "Search for an exact keyword or pattern within a document", {"doc_id": {"type": "string"}, "pattern": {"type": "string"}}), grep_within
|
||||
)
|
||||
register_tool(
|
||||
"inspector_request_adjacent",
|
||||
_inspector_schema("request_adjacent", "Get adjacent entries before or after a chunk", {"chunk_id": {"type": "string"}, "direction": {"type": "string", "enum": ["prev", "next"]}, "count": {"type": "integer"}}),
|
||||
_inspector_schema(
|
||||
"request_adjacent", "Get adjacent entries before or after a chunk", {"chunk_id": {"type": "string"}, "direction": {"type": "string", "enum": ["prev", "next"]}, "count": {"type": "integer"}}
|
||||
),
|
||||
request_adjacent,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ def get_function_schemas(tool_names: list[str]) -> list[dict]:
|
||||
|
||||
# Common schema builders
|
||||
|
||||
|
||||
def _search_schema(name: str, desc: str) -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
|
||||
@@ -40,9 +40,7 @@ from typing import Callable
|
||||
|
||||
# Per-request sink: a callable(str) that forwards one log line, or None when no
|
||||
# agentic turn is streaming in the current context.
|
||||
_think_log_sink: contextvars.ContextVar[Callable[[str], None] | None] = contextvars.ContextVar(
|
||||
"think_log_sink", default=None
|
||||
)
|
||||
_think_log_sink: contextvars.ContextVar[Callable[[str], None] | None] = contextvars.ContextVar("think_log_sink", default=None)
|
||||
|
||||
# Only bracket-tagged INFO lines from these logger namespaces are surfaced.
|
||||
_SCOPED_PREFIXES = ("rag.advanced_rag", "rag.llm.chat_model", "rag.llm.tool_decorator")
|
||||
@@ -68,7 +66,7 @@ class ThinkLogHandler(logging.Handler):
|
||||
if not msg or not msg.lstrip().startswith("["):
|
||||
return
|
||||
try:
|
||||
sink("<br>"+msg.strip())
|
||||
sink("<br>" + msg.strip())
|
||||
except Exception:
|
||||
# Never let think-log forwarding break the request or the logging
|
||||
# subsystem itself.
|
||||
|
||||
+1
-2
@@ -423,8 +423,7 @@ def chunk(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, lang=
|
||||
|
||||
elif re.search(r"\.docx$", filename, re.IGNORECASE):
|
||||
docx_parser = Docx()
|
||||
qai_list, tbls = docx_parser(filename, binary,
|
||||
from_page=0, to_page=MAXIMUM_PAGE_NUMBER, callback=callback)
|
||||
qai_list, tbls = docx_parser(filename, binary, from_page=0, to_page=MAXIMUM_PAGE_NUMBER, callback=callback)
|
||||
res = tokenize_table(tbls, doc, eng, language=lang)
|
||||
for i, (q, a, image) in enumerate(qai_list):
|
||||
res.append(beAdocDocx(deepcopy(doc), q, a, eng, image, i))
|
||||
|
||||
@@ -93,14 +93,10 @@ def _py_type_to_json(py_type: Any) -> dict[str, Any]:
|
||||
|
||||
_PARAM_RE = re.compile(r"^\s*:param\s+(?P<name>\w+)\s*:\s*(?P<desc>.*?)\s*$")
|
||||
_GOOGLE_ARGS_HDR_RE = re.compile(r"^(Args|Arguments|Parameters)\s*:\s*$")
|
||||
_GOOGLE_SECTION_HDR_RE = re.compile(
|
||||
r"^(Returns?|Yields?|Raises|Notes?|Examples?|Attributes?|Todo|See Also|Warning|Warnings|Tip)\s*:\s*$"
|
||||
)
|
||||
_GOOGLE_SECTION_HDR_RE = re.compile(r"^(Returns?|Yields?|Raises|Notes?|Examples?|Attributes?|Todo|See Also|Warning|Warnings|Tip)\s*:\s*$")
|
||||
# Google-style parameter line: leading indent, identifier, optional ``(type)``,
|
||||
# then ``: description``. The description can be empty (continuation lines fill it).
|
||||
_GOOGLE_PARAM_RE = re.compile(
|
||||
r"^(?P<indent>\s+)(?P<name>\w+)\s*(?:\([^)]*\))?\s*:\s*(?P<desc>.*)$"
|
||||
)
|
||||
_GOOGLE_PARAM_RE = re.compile(r"^(?P<indent>\s+)(?P<name>\w+)\s*(?:\([^)]*\))?\s*:\s*(?P<desc>.*)$")
|
||||
|
||||
|
||||
def _parse_param_docs(docstring: str | None) -> tuple[str, dict[str, str]]:
|
||||
@@ -269,10 +265,7 @@ def tool(
|
||||
# ``@tool`` (no parens) — ``fn`` is the function being decorated.
|
||||
if fn is not None:
|
||||
if not callable(fn):
|
||||
raise TypeError(
|
||||
"@tool used incorrectly. Use `@tool` or `@tool(timeout=N)`; "
|
||||
f"got first positional argument of type {type(fn).__name__}."
|
||||
)
|
||||
raise TypeError(f"@tool used incorrectly. Use `@tool` or `@tool(timeout=N)`; got first positional argument of type {type(fn).__name__}.")
|
||||
return decorate(fn)
|
||||
|
||||
# ``@tool(timeout=N)`` — return the decorator that will receive the function.
|
||||
@@ -299,9 +292,7 @@ class FunctionToolSession:
|
||||
self.tools_map: dict[str, Callable[..., Any]] = {}
|
||||
for fn in tools:
|
||||
if not is_tool(fn):
|
||||
raise TypeError(
|
||||
f"{getattr(fn, '__name__', fn)!r} is not a @tool-decorated callable"
|
||||
)
|
||||
raise TypeError(f"{getattr(fn, '__name__', fn)!r} is not a @tool-decorated callable")
|
||||
self.tools_map[fn.openai_schema["function"]["name"]] = fn
|
||||
|
||||
@property
|
||||
@@ -315,9 +306,7 @@ class FunctionToolSession:
|
||||
if name not in self.tools_map:
|
||||
raise KeyError(f"Tool {name!r} is not registered")
|
||||
if not isinstance(arguments, Mapping):
|
||||
raise TypeError(
|
||||
f"Tool arguments for {name} must be an object, got {type(arguments).__name__}"
|
||||
)
|
||||
raise TypeError(f"Tool arguments for {name} must be an object, got {type(arguments).__name__}")
|
||||
fn = self.tools_map[name]
|
||||
logging.info(f"[Function tool] Running the {name} tool with: {str(arguments)[:200]}")
|
||||
if asyncio.iscoroutinefunction(fn):
|
||||
@@ -334,4 +323,4 @@ class FunctionToolSession:
|
||||
# "wait forever" — ``asyncio.wait_for(..., timeout=None)`` handles it.
|
||||
configured = getattr(fn, "_tool_timeout", _TIMEOUT_UNSET)
|
||||
effective_timeout = request_timeout if configured is _TIMEOUT_UNSET else configured
|
||||
return await asyncio.wait_for(coro, timeout=effective_timeout)
|
||||
return await asyncio.wait_for(coro, timeout=effective_timeout)
|
||||
|
||||
@@ -38,30 +38,27 @@ GO_ONLY_SKIPS = {
|
||||
"test_cancel_missing_task_sets_cancel_contract",
|
||||
},
|
||||
"Go validation or response contract does not match the established API contract": {
|
||||
"test_chat_create_name_validation",
|
||||
"test_chat_duplicate_name_validation",
|
||||
"test_chat_create_additional_guards_contract",
|
||||
"test_chat_list_default_get_and_separate_lookup_contract",
|
||||
"test_chat_list_page_and_page_size_contract",
|
||||
"test_chat_list_sorting_contract",
|
||||
"test_chat_create_prompt_contract",
|
||||
"test_chat_update_name_contract",
|
||||
"test_chat_update_mapping_and_validation_branches_p2",
|
||||
"test_dataset_update_name_and_case_insensitive_contract",
|
||||
"test_dataset_update_embedding_model_format_contract",
|
||||
"test_dataset_update_parser_config_valid_matrix_contract",
|
||||
"test_dataset_update_parser_config_with_chunk_method_change_contract",
|
||||
"test_dataset_update_pagerank_contract",
|
||||
"test_dataset_update_pagerank_set_to_zero_contract",
|
||||
"test_dataset_update_content_type_and_payload_contract",
|
||||
"test_dataset_update_identifier_validation_contract",
|
||||
"test_dataset_update_parser_config_defaults_contract",
|
||||
"test_dataset_update_parser_config_invalid_contract",
|
||||
"test_dataset_update_field_unset_and_unsupported_contract",
|
||||
"test_dataset_update_name_invalid_and_duplicate_contract",
|
||||
"test_dataset_create_name_and_case_insensitive_contract",
|
||||
# Updating with `{"parser_config": {}}` / `None` is a valid no-op in Go (handled by
|
||||
# ParserConfigProvided). But the final GET asserts the stored parser_config equals Python's
|
||||
# DEFAULT_PARSER_CONFIG, which embeds a CI-specific tenant `llm_id` and a richer `graphrag` /
|
||||
# `parent_child` structure than Go's common.GetParserConfig produces. Exact equality is a
|
||||
# Python/CI-specific contract, not a meaningful Go behavior difference.
|
||||
"test_dataset_update_parser_config_defaults_contract",
|
||||
"test_dataset_create_parser_config_different_chunk_methods_contract",
|
||||
"test_dataset_create_parser_config_missing_raptor_and_graphrag",
|
||||
"test_dataset_create_embedding_model_contract",
|
||||
"test_dataset_create_embedding_model_format_contract",
|
||||
"test_dataset_create_parser_config_bugfix_contract",
|
||||
"test_dataset_create_content_type_and_payload_bad_contract",
|
||||
"test_dataset_create_parser_config_invalid_contract",
|
||||
@@ -76,8 +73,6 @@ GO_ONLY_SKIPS = {
|
||||
"test_memory_crud_and_config",
|
||||
"test_messages_list_and_search_validation_contracts",
|
||||
"test_message_update_forget_and_content_error_contracts",
|
||||
"test_search_create_invalid_name",
|
||||
"test_search_update_invalid_search_id",
|
||||
"test_session_create_validation_and_deleted_chat_contract",
|
||||
"test_session_delete_basic_scenarios",
|
||||
"test_session_list_filter_and_deleted_chat_contract",
|
||||
|
||||
@@ -150,9 +150,7 @@ def _load_list_datasets_module(monkeypatch, *, kbs, parsing_status_by_kb):
|
||||
monkeypatch,
|
||||
"api.db.services.user_service",
|
||||
TenantService=SimpleNamespace(
|
||||
get_joined_tenants_by_user_id=lambda user_id: [
|
||||
{"tenant_id": "tenant-1"}
|
||||
],
|
||||
get_joined_tenants_by_user_id=lambda user_id: [{"tenant_id": "tenant-1"}],
|
||||
),
|
||||
UserService=SimpleNamespace(get_by_ids=lambda ids: []),
|
||||
UserTenantService=SimpleNamespace(),
|
||||
@@ -173,13 +171,9 @@ def _load_list_datasets_module(monkeypatch, *, kbs, parsing_status_by_kb):
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[5]
|
||||
module_path = repo_root / "api" / "apps" / "services" / "dataset_api_service.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"test_dataset_api_service_list_datasets_module", module_path
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("test_dataset_api_service_list_datasets_module", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "test_dataset_api_service_list_datasets_module", module
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "test_dataset_api_service_list_datasets_module", module)
|
||||
spec.loader.exec_module(module)
|
||||
return module, parsing_status_mock, get_list_mock
|
||||
|
||||
@@ -343,4 +337,4 @@ def test_list_datasets_with_include_parsing_status_missing_kb_gets_empty_dict(mo
|
||||
by_id = {r["id"]: r for r in payload["data"]}
|
||||
assert by_id["kb-a"]["parsing_status"]["unstart_count"] == 1
|
||||
assert by_id["kb-b"]["parsing_status"] == {}
|
||||
parsing_status_mock.assert_called_once()
|
||||
parsing_status_mock.assert_called_once()
|
||||
|
||||
@@ -83,15 +83,9 @@ def test_chunk_shape_helper_recognises_chunk_payloads(monkeypatch):
|
||||
"""A response that is chunk-shaped (list, or dict with non-empty results/chunks)
|
||||
is classified as chunked regardless of which payload was sent."""
|
||||
module = _load_docling_parser(monkeypatch)
|
||||
assert module.DoclingParser._looks_like_chunk_response(
|
||||
[{"text": "chunk-1"}]
|
||||
) is True
|
||||
assert module.DoclingParser._looks_like_chunk_response(
|
||||
{"results": [{"text": "chunk-1"}, {"text": "chunk-2"}]}
|
||||
) is True
|
||||
assert module.DoclingParser._looks_like_chunk_response(
|
||||
{"chunks": [{"text": "chunk-1"}]}
|
||||
) is True
|
||||
assert module.DoclingParser._looks_like_chunk_response([{"text": "chunk-1"}]) is True
|
||||
assert module.DoclingParser._looks_like_chunk_response({"results": [{"text": "chunk-1"}, {"text": "chunk-2"}]}) is True
|
||||
assert module.DoclingParser._looks_like_chunk_response({"chunks": [{"text": "chunk-1"}]}) is True
|
||||
|
||||
|
||||
@pytest.mark.p2
|
||||
@@ -164,4 +158,4 @@ def test_remote_chunked_request_with_ignored_flag_does_not_log_success(monkeypat
|
||||
assert sections == [("real content", "")]
|
||||
flat = " ".join(record.getMessage() for record in caplog.records)
|
||||
assert "Successfully used native chunking" not in flat
|
||||
assert "Server ignored chunking request" in flat
|
||||
assert "Server ignored chunking request" in flat
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
import { ISetLangfuseConfigRequestBody } from '@/interfaces/request/system';
|
||||
import { DEFAULT_LANGUAGE_CODE, supportedLanguages } from '@/locales/config';
|
||||
import kbService from '@/services/knowledge-service';
|
||||
import {
|
||||
getBackendLanguage,
|
||||
subscribeBackendLanguage,
|
||||
} from '@/utils/backend-runtime';
|
||||
import userService, {
|
||||
addTenantUser,
|
||||
agreeTenant,
|
||||
@@ -22,9 +18,12 @@ import userService, {
|
||||
listTenant,
|
||||
listTenantUser,
|
||||
} from '@/services/user-service';
|
||||
import {
|
||||
getBackendLanguage,
|
||||
subscribeBackendLanguage,
|
||||
} from '@/utils/backend-runtime';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useWarnEmptyModel } from './use-warn-empty-model';
|
||||
@@ -165,8 +164,11 @@ export const useSelectParserList = (): Array<{
|
||||
const parserList = useMemo(() => {
|
||||
// Go backend: prefer the dynamic pipeline catalog from the API.
|
||||
if (backendLang === 'go') {
|
||||
const pipelineList: Array<{ parser_id: string; title: string; dsl: Record<string, any> }> =
|
||||
pipelineListData?.data ?? [];
|
||||
const pipelineList: Array<{
|
||||
parser_id: string;
|
||||
title: string;
|
||||
dsl: Record<string, any>;
|
||||
}> = pipelineListData?.data ?? [];
|
||||
if (pipelineList.length > 0) {
|
||||
const labelFromAPI = (parserId: string, title: string) => {
|
||||
const key = `knowledgeConfiguration.parserLabel.${parserId}`;
|
||||
|
||||
@@ -95,7 +95,9 @@ describe('parseModelUuid — right-anchored', () => {
|
||||
});
|
||||
|
||||
test('preserves embedded "@" in the model name (LM Studio)', () => {
|
||||
expect(parseModelUuid('text-embedding-nomic-embed-text-v1.5@q8_0@lmstudio')).toEqual({
|
||||
expect(
|
||||
parseModelUuid('text-embedding-nomic-embed-text-v1.5@q8_0@lmstudio'),
|
||||
).toEqual({
|
||||
modelName: 'text-embedding-nomic-embed-text-v1.5@q8_0',
|
||||
factoryId: 'lmstudio',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user