// // 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 ( "context" "fmt" "strings" "time" "ragflow/internal/dao" "ragflow/internal/entity" "ragflow/internal/utility" "gorm.io/gorm" ) // timeStr formats a Unix-millisecond timestamp for the read-side JSON payloads. func timeStr(ms *int64) string { if ms == nil { return "" } return time.UnixMilli(*ms).UTC().Format("2006-01-02 15:04:05") } const ( groupScopeFile = "file" groupScopeDataset = "dataset" // maxGroupNameLen is the DB column size for compilation_template_group.name. maxGroupNameLen = 128 ) // GroupTemplate is a child-template entry in a group create/update payload. type GroupTemplate struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Kind string `json:"kind,omitempty"` Config entity.JSONMap `json:"config,omitempty"` } // GroupRequest is the create/update payload for a compilation template group. type GroupRequest struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Templates []*GroupTemplate `json:"templates,omitempty"` } // CompilationTemplateGroupService implements the knowledge-compilation template // group REST operations, mirroring the Python CompilationTemplateGroupService. type CompilationTemplateGroupService struct { groupDAO *dao.CompilationTemplateGroupDAO templateDAO *dao.CompilationTemplateDAO tenantDAO *dao.TenantDAO } // NewCompilationTemplateGroupService creates a CompilationTemplateGroupService. func NewCompilationTemplateGroupService() *CompilationTemplateGroupService { return &CompilationTemplateGroupService{ groupDAO: dao.NewCompilationTemplateGroupDAO(), templateDAO: dao.NewCompilationTemplateDAO(), tenantDAO: dao.NewTenantDAO(), } } // GroupListItem is the read-side representation of a group with its nested // templates, mirroring Python _group_to_dict. type GroupListItem struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Scope string `json:"scope"` CreateTime string `json:"create_time,omitempty"` UpdateTime string `json:"update_time,omitempty"` Templates []*TemplateListItem `json:"templates"` } // ListSaved returns the tenant's groups with nested templates. Mirrors Python // list_saved(). total is derived from the full (unpaginated) result set. func (s *CompilationTemplateGroupService) ListSaved(ctx context.Context, tenantID, keywords, scope, orderby string, desc bool) ([]*GroupListItem, error) { groups, err := s.groupDAO.ListSaved(ctx, tenantID, keywords, scope, orderby, desc) if err != nil { return nil, err } return s.buildGroupItems(ctx, tenantID, groups) } // GetSaved returns a single group with its nested templates, or nil. Mirrors // Python get_saved(). func (s *CompilationTemplateGroupService) GetSaved(ctx context.Context, tenantID, groupID string) (*GroupListItem, error) { group, err := s.groupDAO.GetSaved(ctx, tenantID, groupID) if err != nil || group == nil { return nil, err } return s.buildGroupItem(ctx, tenantID, group) } // CreateGroup creates a group plus its child templates. Mirrors Python // create_group(). It returns a GroupValidationError for payload/scope problems. // The group and all child writes are committed atomically so a mid-way failure // cannot leave a partially-populated group behind. func (s *CompilationTemplateGroupService) CreateGroup(ctx context.Context, tenantID string, req *GroupRequest) (*GroupListItem, error) { if err := validateGroupPayload(req, true); err != nil { return nil, err } scope, err := s.deriveScope(req.Templates) if err != nil { return nil, err } groupID := utility.GenerateUUID() if err := dao.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { group := &entity.CompilationTemplateGroup{ ID: groupID, TenantID: tenantID, Name: strings.TrimSpace(req.Name), Description: strptr(req.Description), Scope: scope, Status: strptr(string(entity.StatusValid)), } if cerr := s.groupDAO.Create(ctx, tx, group); cerr != nil { return cerr } return s.insertChildren(ctx, tx, tenantID, groupID, req.Templates, nil) }); err != nil { return nil, err } return s.GetSaved(ctx, tenantID, groupID) } // UpdateGroup applies a partial update to a group and reconciles its child // templates. Mirrors Python update_group(). The field update and child // reconciliation are committed atomically. func (s *CompilationTemplateGroupService) UpdateGroup(ctx context.Context, tenantID, groupID string, req *GroupRequest) (*GroupListItem, error) { existing, err := s.groupDAO.GetSaved(ctx, tenantID, groupID) if err != nil { return nil, err } if existing == nil { return nil, nil } if err := validateGroupPayload(req, false); err != nil { return nil, err } if err := dao.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { updates := map[string]interface{}{} if strings.TrimSpace(req.Name) != "" { updates["name"] = strings.TrimSpace(req.Name) } if req.Description != "" { updates["description"] = req.Description } if req.Templates != nil { scope, serr := s.deriveScope(req.Templates) if serr != nil { return serr } updates["scope"] = scope } if len(updates) > 0 { if uerr := s.groupDAO.UpdateFields(ctx, tx, groupID, updates); uerr != nil { return uerr } } if req.Templates != nil { return s.reconcileChildren(ctx, tx, tenantID, groupID, req.Templates) } return nil }); err != nil { return nil, err } return s.GetSaved(ctx, tenantID, groupID) } // DeleteGroup soft-deletes a group and its valid child templates. Mirrors // Python delete_group(). Returns (false, nil) when the group is missing. func (s *CompilationTemplateGroupService) DeleteGroup(ctx context.Context, tenantID, groupID string) (bool, error) { existing, err := s.groupDAO.GetSaved(ctx, tenantID, groupID) if err != nil { return false, err } if existing == nil { return false, nil } if err := s.templateDAO.UpdateStatusByGroup(ctx, dao.DB, groupID, string(entity.StatusInvalid)); err != nil { return false, err } if err := s.groupDAO.Delete(ctx, dao.DB, tenantID, groupID); err != nil { return false, err } return true, nil } // GroupValidationError is returned for group payload/scope problems so handlers // can map it to a 400 without an HTTP 500. type GroupValidationError struct{ msg string } func (e *GroupValidationError) Error() string { return e.msg } func groupValidationErrorf(format string, a ...interface{}) error { return &GroupValidationError{msg: fmt.Sprintf(format, a...)} } // NameExists reports whether a valid group with the given name exists for the // tenant (optionally excluding excludeID), mirroring Python name_exists(). func (s *CompilationTemplateGroupService) NameExists(ctx context.Context, tenantID, name string, excludeIDs ...string) (bool, error) { excludeID := "" if len(excludeIDs) > 0 { excludeID = excludeIDs[0] } return s.groupDAO.NameExists(ctx, tenantID, name, excludeID) } // ValidateGroupRequest validates the fields present in a group create/update // payload before any DB write, mirroring the Python blueprint // _validate_group_payload. Required-field enforcement for create happens inside // CreateGroup (require_all=True). func ValidateGroupRequest(req *GroupRequest) error { return validateGroupPayload(req, false) } // buildGroupItems loads child templates for many groups at once. func (s *CompilationTemplateGroupService) buildGroupItems(ctx context.Context, tenantID string, groups []*entity.CompilationTemplateGroup) ([]*GroupListItem, error) { items := make([]*GroupListItem, 0, len(groups)) for _, g := range groups { item, err := s.buildGroupItem(ctx, tenantID, g) if err != nil { return nil, err } items = append(items, item) } return items, nil } func (s *CompilationTemplateGroupService) buildGroupItem(ctx context.Context, tenantID string, group *entity.CompilationTemplateGroup) (*GroupListItem, error) { children, err := s.templateDAO.ListByGroup(ctx, dao.DB, group.ID) if err != nil { return nil, err } items := make([]*TemplateListItem, 0, len(children)) for _, c := range children { items = append(items, &TemplateListItem{ ID: c.ID, Name: c.Name, Description: derefString(c.Description), Kind: c.Kind, Config: fillConfigDefaultLLM(ctx, s.tenantDAO, c.Config, c.TenantID), CreateTime: timeStr(c.CreateTime), UpdateTime: timeStr(c.UpdateTime), }) } return &GroupListItem{ ID: group.ID, Name: group.Name, Description: derefString(group.Description), Scope: group.Scope, CreateTime: timeStr(group.CreateTime), UpdateTime: timeStr(group.UpdateTime), Templates: items, }, nil } // deriveScope mirrors Python _derive_scope: one wiki child => dataset scope, // otherwise file scope (with a wiki-combination guard and re-chunk tree guard). func (s *CompilationTemplateGroupService) deriveScope(templates []*GroupTemplate) (string, error) { if len(templates) == 0 { return "", groupValidationErrorf("a template group must contain at least one template.") } artifactCount := 0 for _, t := range templates { if strings.TrimSpace(t.Kind) == "wiki" { artifactCount++ } } if artifactCount > 0 { if artifactCount != 1 || len(templates) != 1 { return "", groupValidationErrorf("a wiki template cannot be combined with other templates in the same group.") } return groupScopeDataset, nil } if err := s.enforceSingleRechunkTree(templates); err != nil { return "", err } return groupScopeFile, nil } // enforceSingleRechunkTree mirrors Python _enforce_single_rechunk_tree. func (s *CompilationTemplateGroupService) enforceSingleRechunkTree(templates []*GroupTemplate) error { rechunkTrees := 0 for _, t := range templates { if strings.TrimSpace(t.Kind) != "tree" { continue } raptor, _ := t.Config["raptor"].(map[string]interface{}) if b, ok := raptor["rechunk"].(bool); ok && b { rechunkTrees++ } } if rechunkTrees > 1 { return groupValidationErrorf("only one tree template in a group may enable re-chunking.") } return nil } // validateGroupPayload mirrors Python _validate_group_payload in the group // blueprint. func validateGroupPayload(req *GroupRequest, requireAll bool) error { if requireAll { if strings.TrimSpace(req.Name) == "" { return groupValidationErrorf("missing required field: name.") } if len(req.Templates) == 0 { return groupValidationErrorf("missing required field: templates.") } } if strings.TrimSpace(req.Name) != "" { if len([]byte(req.Name)) > maxGroupNameLen { return groupValidationErrorf("template group name is too long.") } } if len(req.Description) > 1024 { return groupValidationErrorf("invalid template group description.") } if req.Templates != nil { if len(req.Templates) == 0 { return groupValidationErrorf("a template group must contain at least one template.") } seen := map[string]struct{}{} for _, child := range req.Templates { if child == nil { return groupValidationErrorf("invalid template entry in group.") } payload := map[string]interface{}{ "name": child.Name, "description": child.Description, "kind": child.Kind, "config": child.Config, } if err := ValidateTemplatePayload(payload, true); err != nil { return err } name := strings.TrimSpace(child.Name) if _, dup := seen[name]; dup { return groupValidationErrorf("template name '%s' is duplicated in this group.", name) } seen[name] = struct{}{} } } return nil } // insertChildren inserts new child templates. usedForReconcile controls whether // the duplicate-name guard applies (create always; reconcile passes seen set). func (s *CompilationTemplateGroupService) insertChildren(ctx context.Context, db *gorm.DB, tenantID, groupID string, templates []*GroupTemplate, seen map[string]struct{}) error { for _, child := range templates { name := strings.TrimSpace(child.Name) if exists, err := s.templateDAO.NameExistsInGroup(ctx, tenantID, groupID, name, ""); err != nil { return err } else if exists { return groupValidationErrorf("template name '%s' already exists in this group.", name) } desc := child.Description config := fillConfigDefaultLLM(ctx, s.tenantDAO, child.Config, &tenantID) if config == nil { config = entity.JSONMap{} } valid := string(entity.StatusValid) tmpl := &entity.CompilationTemplate{ ID: utility.GenerateUUID(), TenantID: &tenantID, GroupID: &groupID, Name: name, Kind: strings.TrimSpace(child.Kind), Config: config, Status: &valid, } if desc != "" { tmpl.Description = &desc } if err := s.templateDAO.Save(ctx, db, tmpl); err != nil { return err } } return nil } // reconcileChildren mirrors the Python update_group child reconciliation: // existing children (matched by id, or by submitted order for legacy clients) // are updated in place, new ones inserted, and removed ones soft-deleted. func (s *CompilationTemplateGroupService) reconcileChildren(ctx context.Context, db *gorm.DB, tenantID, groupID string, templates []*GroupTemplate) error { current, err := s.templateDAO.ListByGroup(ctx, db, groupID) if err != nil { return err } currentByID := map[string]*entity.CompilationTemplate{} for _, c := range current { currentByID[c.ID] = c } seenNames := map[string]struct{}{} retained := map[string]struct{}{} for index, child := range templates { name := strings.TrimSpace(child.Name) if _, dup := seenNames[name]; dup { return groupValidationErrorf("template name '%s' is duplicated in this group.", name) } seenNames[name] = struct{}{} var target *entity.CompilationTemplate if child.ID != "" { target = currentByID[child.ID] if target == nil { return groupValidationErrorf("template %s does not belong to this group.", child.ID) } } else if index < len(current) { // Positional fallback for legacy clients that omit IDs. Skip rows // already retained by an explicit ID above so a mixed payload cannot // bind two submitted entries to one existing row. if _, taken := retained[current[index].ID]; !taken { target = current[index] } } config := fillConfigDefaultLLM(ctx, s.tenantDAO, child.Config, &tenantID) if config == nil { config = entity.JSONMap{} } desc := child.Description if target != nil { updates := map[string]interface{}{ "name": name, "kind": strings.TrimSpace(child.Kind), "config": config, } if desc != "" { updates["description"] = desc } if err := s.templateDAO.UpdateFields(ctx, db, target.ID, updates); err != nil { return err } retained[target.ID] = struct{}{} continue } newID := utility.GenerateUUID() valid := string(entity.StatusValid) tmpl := &entity.CompilationTemplate{ ID: newID, TenantID: &tenantID, GroupID: &groupID, Name: name, Kind: strings.TrimSpace(child.Kind), Config: config, Status: &valid, } if desc != "" { tmpl.Description = &desc } if err := s.templateDAO.Save(ctx, db, tmpl); err != nil { return err } retained[newID] = struct{}{} } for _, c := range current { if _, keep := retained[c.ID]; !keep { if err := s.templateDAO.UpdateStatusByID(ctx, db, c.ID, string(entity.StatusInvalid)); err != nil { return err } // Mirror Python _purge_stale_invalid_children: permanently drop // any stale invalid non-builtin template of the same name, scoped to // this group so a same-named template in another group is untouched. if c.Name != "" && !c.IsBuiltin { if err := s.templateDAO.HardDeleteOrphansByName(ctx, db, tenantID, groupID, c.Name); err != nil { return err } } } } return nil } func strptr(s string) *string { return &s }