// Package service - 话术服务 // 功能:话术的增删改查、绑定/解绑客服账号、同步到RAGFlow、重试消费者 package service import ( "context" "customer-server/dao" "customer-server/model/dto" "customer-server/model/entity" "customer-server/util" "fmt" "gitea.com/red-future/common/db/mongo" "gitea.com/red-future/common/jaeger" "gitea.com/red-future/common/rabbitmq" "gitea.com/red-future/common/ragflow" "gitea.com/red-future/common/redis" "gitea.com/red-future/common/utils" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/glog" "github.com/gogf/gf/v2/os/grpool" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/text/gstr" "github.com/gogf/gf/v2/util/gconv" "go.mongodb.org/mongo-driver/v2/bson" ) var ( Speechcraft = new(speechcraft) speechcraftGrpool = grpool.New(50) // 文档解析协程池,最大50并发 ) type speechcraft struct{} // Add 添加话术 // 参数: ctx - 上下文,req - 添加话术请求(包含标题、内容、方向、标签等) // 返回: res - 添加成功后的话术ID和RAGFlow文档ID,err - 错误信息 // 功能: 创建话术记录并自动上传到RAGFlow租户知识库,支持去重检查 func (s *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) { // 去重检查:同一租户下tag唯一 if req.Tag != "" { existing, err := dao.Speechcraft.FindByTag(ctx, req.Tag) if err != nil { return nil, gerror.Wrap(err, "检查话术重复失败") } if existing != nil { return nil, gerror.Newf("话术tag已存在:tag=%s, id=%s", req.Tag, existing.Id.Hex()) } } // 先从token或请求获取租户信息(在Insert之前) var tenantId string if req.TenantId != nil { tenantId = gconv.String(req.TenantId) g.Log().Debugf(ctx, "使用请求中的TenantId: %v", req.TenantId) } else { user, err := util.GetTenantInfo(ctx) if err != nil { return nil, gerror.Wrap(err, "获取租户信息失败") } tenantId = gconv.String(user.TenantId) if tenantId == "" { return nil, gerror.New("租户ID为空") } } // 查询租户知识库ID datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) if err != nil || datasetId == "" { return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId) } data := &entity.Speechcraft{} if err = utils.Struct(req, data); err != nil { return } // 设置基础字段 now := gtime.Now().Time data.CreatedAt = &now // 取地址赋值给指针类型 data.UpdatedAt = &now // 取地址赋值给指针类型 data.IsDeleted = false // 统一使用string类型存储tenantId到MongoDB data.TenantId = tenantId // 使用DAO封装的Insert方法 id, err := dao.Speechcraft.Insert(ctx, data) if err != nil { return nil, gerror.Wrap(err, "插入话术失败") } data.Id = &id // 取地址赋值给指针类型 // 同步上传到RAGFlow ragflowClient := ragflow.GetGlobalClient() if ragflowClient == nil { // 回滚:删除刚插入的话术 dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection) return nil, gerror.New("RAGFlow客户端未初始化,请检查配置") } g.Log().Infof(ctx, "准备上传话术到RAGFlow: speechcraft_id=%s, dataset_id=%s, direction=%s, tag=%s", data.Id.Hex(), datasetId, data.Direction, data.Tag) filename := fmt.Sprintf("%s_%s.txt", data.Direction, data.Tag) documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Content, filename) if err != nil { // 回滚:删除刚插入的话术 dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection) g.Log().Errorf(ctx, "话术上传RAGFlow失败: speechcraft_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err) jaeger.RecordError(ctx, err, "话术上传RAGFlow失败") return nil, gerror.Wrap(err, "文档上传到知识库失败") } // 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消) speechcraftGrpool.Add(ctx, func(ctx context.Context) { parseCtx := context.WithoutCancel(ctx) if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil { g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err) } else { g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId) } }) // 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档) syncTime := gtime.Now().Format("Y-m-d H:i:s") record := entity.RagSyncRecord{ AccountName: "", // 空表示租户级文档 RagDocumentId: documentId, RagSyncStatus: "synced", SyncTime: syncTime, RetryCount: 0, } filter := bson.M{"_id": data.Id} update := bson.M{ "$set": bson.M{ "ragSyncRecords": []entity.RagSyncRecord{record}, "ragLastSyncTime": syncTime, "updatedAt": gtime.Now().Time, }, } if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.SpeechcraftCollection); err != nil { g.Log().Errorf(ctx, "更新话术RagSyncRecords失败: %v", err) // 不回滚,文档已上传成功 } g.Log().Infof(ctx, "话术添加成功并上传到知识库: speechcraft_id=%s, document_id=%s", data.Id.Hex(), documentId) res = &dto.AddSpeechcraftRes{Id: data.Id.Hex()} return } // Update 更新话术 // 参数: ctx - 上下文,req - 更新话术请求(包含话术ID和待更新字段) // 返回: err - 错误信息 // 功能: 更新话术内容并同步到RAGFlow,支持文档删除重建 func (s *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) { return dao.Speechcraft.Update(ctx, req) } // Delete 删除话术 // 参数: ctx - 上下文,req - 删除话术请求(包含话术ID) // 返回: err - 错误信息 // 功能: 逻辑删除话术记录并从RAGFlow移除对应文档 func (s *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) { g.Log().Infof(ctx, "[Delete] 开始删除话术 - speechcraftId: %s", req.Id) // 1. 查询话术,获取RAGFlow同步记录(使用原生查询,避免租户过滤) objectId, err := bson.ObjectIDFromHex(req.Id) if err != nil { return gerror.Wrap(err, "无效的话术ID") } var speechcraft entity.Speechcraft filter := bson.M{"_id": objectId, "isDeleted": false} err = dao.MongoDAO.FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection) if err != nil { if err.Error() == "mongo: no documents in result" { return gerror.New("话术不存在") } return gerror.Wrap(err, "查询话术失败") } g.Log().Infof(ctx, "[Delete] 查询到话术 - tag: %s, ragSyncRecords数量: %d", speechcraft.Tag, len(speechcraft.RagSyncRecords)) // 2. 删除RAGFlow中的文档 if len(speechcraft.RagSyncRecords) > 0 { ragflowClient := ragflow.GetGlobalClient() if ragflowClient != nil { tenantId := gconv.String(speechcraft.TenantId) // 查询租户的dataset_id datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) if err != nil { g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err) } else if datasetId != "" { // 收集所有需要删除的document_id var documentIds []string for _, record := range speechcraft.RagSyncRecords { if record.RagDocumentId != "" { documentIds = append(documentIds, record.RagDocumentId) } } // 批量删除RAGFlow文档 if len(documentIds) > 0 { if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil { g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds) // 不阻断删除流程,记录错误后继续 } else { g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds)) } } } } } // 3. 软删除MongoDB记录 return dao.Speechcraft.Delete(ctx, req) } // List 获取话术列表 // 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索、平台筛选) // 返回: res - 话术列表及分页信息,err - 错误信息 // 功能: 分页查询话术记录,支持按标题、内容模糊搜索和平台筛选 func (s *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) { list, total, err := dao.Speechcraft.List(ctx, req) if err != nil { return } res = &dto.ListSpeechcraftRes{ List: list, Total: int(total), } return } // Match 话术匹配(核心方法) // 根据用户当前阶段、行为、输入内容匹配话术 // // func (s *speechcraft) Match(ctx context.Context, userId, platform, content, status string) (answer string, nextStage int, err error) { // // 1. 获取用户当前阶段 // state, err := dao.UserStage.GetOrCreate(ctx, userId, platform) func (s *speechcraft) Match(ctx context.Context, userId, platform, tenantId, content, status string) (answer string, nextStage int, err error) { // 1. 获取用户当前状态(Redis,5分钟过期) userState, err := redis.GetUserState(ctx, userId, platform) if err != nil { jaeger.RecordError(ctx, err, "获取用户状态失败") return } glog.Infof(ctx, "话术匹配 - 用户: %s, 当前阶段: %d, 行为: %s, 内容: %s", userId, userState.Stage, status, content) // 2. 状态3(发卡片状态):持续提示用户添加联系方式 if userState.Stage == 3 { answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容 nextStage = 3 // 保持状态3 glog.Infof(ctx, "用户处于发卡片状态 - 用户: %s", userId) return } // 4. 检测用户是否想要联系方式(5次内立即发卡) if s.isRequestingContact(content) { glog.Infof(ctx, "检测到联系方式请求关键词 - 用户: %s, 内容: %s", userId, content) return s.handleCardRequest(ctx, userId, platform) } // 5. 所有其他消息直接走RAGFlow(话术已上传到知识库) // 后端只负责:开场白(WebSocket连接时发送)+ 发卡片(上面的逻辑) glog.Infof(ctx, "无话术匹配,转发到RAGFlow - 用户: %s, 内容: %s", userId, content) nextStage = 0 if updateErr := redis.SetUserStage(ctx, userId, platform, 0); updateErr != nil { jaeger.RecordError(ctx, updateErr, "更新用户阶段为0失败") } // answer为空,调用方会走RAGFlow return } // ProcessAndPublish 处理用户消息并推送到Redis Stream // 参数: ctx - 上下文,userId - 用户ID,platform - 平台,tenantId - 租户ID,content - 消息内容,status - 用户行为状态,accountName - 客服账号名 // 返回: isPushed - 是否成功推送话术匹配结果,err - 错误信息 // 功能: 尝试匹配话术,匹配成功则直接推送话术内容,失败则转发到Redis Stream由RAGFlow处理 func (s *speechcraft) ProcessAndPublish(ctx context.Context, userId, platform, tenantId, content, status, accountName string) (isPushed bool, err error) { // 1. 话术匹配 answer, _, err := s.Match(ctx, userId, platform, tenantId, content, status) if err != nil { return } // 2. 未匹配到话术,直接推送到Redis Stream if answer == "" { glog.Infof(ctx, "话术未匹配,转发到 AI 模型 - 用户: %s, 客服账号: %s", userId, accountName) // 获取当前实例的动态响应队列名(自动生成,支持多实例部署) baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") replyQueue := rabbitmq.GetInstanceQueueName(baseQueue) messageId := userId + "_" + gconv.String(gtime.Now().TimestampNano()) // 构造Stream消息 msg := &redis.SendStreamMessage{ UserId: userId, Platform: platform, TenantId: tenantId, AccountName: accountName, Content: content, Timestamp: gtime.Now().Timestamp(), MessageId: messageId, ReplyQueue: replyQueue, } // 检查是否有session缓存,无缓存说明已归档,需要读取历史 if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" { if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 { msg.History = history glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId) } } // 写入Redis Stream var streamMsgId string streamMsgId, err = redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg) if err != nil { jaeger.RecordError(ctx, err, "写入Stream失败") return } glog.Infof(ctx, "消息已写入Stream - StreamID: %s, 用户: %s", streamMsgId, userId) isPushed = false // 未匹配话术,消息转发到AI处理 return } // 3. 匹配到话术,直接推送 WebSocket(无需经过 RabbitMQ) if err = WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, answer); err != nil { jaeger.RecordError(ctx, err, "推送话术响应失败") return } glog.Infof(ctx, "话术响应已推送 - 用户: %s, 话术长度: %d", userId, len(answer)) isPushed = true // 已直接推送响应 return } // ResetUserStage 重置用户阶段 func (s *speechcraft) ResetUserStage(ctx context.Context, userId, platform string) (err error) { return dao.UserStage.Reset(ctx, userId, platform) } // isRequestingContact 检测用户是否想要联系方式(触发立即发卡) func (s *speechcraft) isRequestingContact(content string) bool { // 联系方式相关关键词 contactKeywords := []string{ "联系方式", "联系你", "联系", "微信", "VX", "vx", "wx", "WX", "电话", "手机号", "电话号码", "怎么找你", "如何联系", "加你", "私信", "私聊", } // 清理内容(去除空格、标点) cleanContent := gstr.Trim(content) cleanContent = gstr.ToLower(cleanContent) for _, keyword := range contactKeywords { if gstr.Contains(cleanContent, gstr.ToLower(keyword)) { return true } } return false } // handleCardRequest 处理用户请求联系方式(立即发卡片) func (s *speechcraft) handleCardRequest(ctx context.Context, userId, platform string) (answer string, nextStage int, err error) { // 更新用户状态为3(发卡片状态) if err = redis.SetUserStage(ctx, userId, platform, 3); err != nil { jaeger.RecordError(ctx, err, "更新用户状态为3失败") return } // 返回卡片话术 answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容 nextStage = 3 glog.Infof(ctx, "用户请求联系方式,立即发送卡片 - 用户: %s", userId) return } // BindToCustomerServices 绑定话术到客服账号 // 参数: ctx - 上下文,req - 绑定请求(包含话术ID和客服账号名列表) // 返回: res - 绑定结果(成功和失败的账号列表),err - 错误信息 // 功能: 将话术同步到指定客服账号的RAGFlow知识库,批量处理并返回每个账号的结果 func (s *speechcraft) BindToCustomerServices(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) { res = &dto.BindSpeechcraftRes{} // 0. 参数验证 if len(req.AccountNames) == 0 { return nil, gerror.New("客服账号ID列表不能为空") } // 1. 查询话术(验证存在性和获取租户信息) r := g.RequestFromCtx(ctx) if r != nil { r.SetParam("accountName", req.AccountNames[0]) } speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId) if err != nil { return nil, gerror.Wrapf(err, "查询话术失败") } if speechcraft == nil { return nil, gerror.New("话术不存在") } speechcraftTenantId := gconv.String(speechcraft.TenantId) // 2. 遍历客服账号,更新每个账号的speechcraft_ids var newBindings []string var alreadyBound []string var notFound []string for _, csId := range req.AccountNames { // 查询客服账号 csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) if err != nil || csAccount == nil { notFound = append(notFound, csId) g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId) continue } // 租户隔离校验 accountTenantId := gconv.String(csAccount.TenantId) if speechcraftTenantId != accountTenantId { g.Log().Warningf(ctx, "话术和客服账号不属于同一租户,跳过: speechcraft_tenant=%s, account_tenant=%s", speechcraftTenantId, accountTenantId) notFound = append(notFound, csId) continue } // 检查是否已绑定 alreadyExists := false for _, existingId := range csAccount.SpeechcraftIds { if existingId == req.SpeechcraftId { alreadyExists = true break } } if alreadyExists { alreadyBound = append(alreadyBound, csId) g.Log().Warningf(ctx, "客服账号 %s 已绑定该话术,跳过", csId) continue } // 添加到speechcraft_ids列表 csAccount.SpeechcraftIds = append(csAccount.SpeechcraftIds, req.SpeechcraftId) // 更新数据库 filter := bson.M{"_id": csAccount.Id, "isDeleted": false} update := bson.M{"$set": bson.M{"speechcraftIds": csAccount.SpeechcraftIds}} if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil { g.Log().Errorf(ctx, "更新客服账号绑定失败: %s, error=%v", csId, err) notFound = append(notFound, csId) continue } newBindings = append(newBindings, csId) } // 3. 如果没有新的绑定,直接返回 if len(newBindings) == 0 { res.SuccessCount = 0 res.AlreadyBound = alreadyBound res.NotFound = notFound if len(alreadyBound) > 0 && len(notFound) > 0 { res.Message = "部分客服账号已绑定,部分不存在" } else if len(alreadyBound) > 0 { res.Message = "所有客服账号已绑定,无需重复绑定" } else if len(notFound) > 0 { res.Message = "所有客服账号都不存在或租户不匹配" } return res, nil } // 6. 同步到RAGFlow(自动创建知识库) for _, csId := range newBindings { // 获取客服账号信息以获取tenant_id csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) if err != nil || csAccount == nil { g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId) continue } // 同步到RAGFlow(会自动创建知识库) tenantId := gconv.String(csAccount.TenantId) g.Log().Infof(ctx, "客服账号租户信息: cs_id=%s, tenant_id=%v, tenant_id_type=%T", csId, csAccount.TenantId, csAccount.TenantId) _, err = s.SyncToRAGFlow(ctx, req.SpeechcraftId, csId, tenantId) if err != nil { g.Log().Errorf(ctx, "同步到RAGFlow失败: speechcraft_id=%s, cs_id=%s, error=%v", req.SpeechcraftId, csId, err) // 不阻断绑定流程,失败会进入重试队列 } } res.SuccessCount = len(newBindings) res.AlreadyBound = alreadyBound res.NotFound = notFound // 生成详细的响应消息 if len(alreadyBound) > 0 || len(notFound) > 0 { res.Message = fmt.Sprintf("成功绑定%d个", len(newBindings)) if len(alreadyBound) > 0 { res.Message += fmt.Sprintf(",%d个已绑定", len(alreadyBound)) } if len(notFound) > 0 { res.Message += fmt.Sprintf(",%d个不存在", len(notFound)) } } else { res.Message = fmt.Sprintf("成功绑定%d个客服账号", len(newBindings)) } return } // UnbindFromCustomerService 从客服账号解绑话术 // 参数: ctx - 上下文,req - 解绑请求(包含话术ID和客服账号名) // 返回: res - 解绑结果信息,err - 错误信息 // 功能: 从客服账号的RAGFlow知识库中删除话术文档 func (s *speechcraft) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) { res = &dto.UnbindSpeechcraftRes{} // 1. 验证话术存在 speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId) if err != nil { return nil, gerror.Wrapf(err, "查询话术失败") } if speechcraft == nil { return nil, gerror.New("话术不存在") } // 2. 查询客服账号 csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) if err != nil || csAccount == nil { res.Success = false res.Message = "客服账号不存在" return res, nil } // 3. 从 speechcraft_ids 中移除话术ID var newSpeechcraftIds []string found := false for _, scId := range csAccount.SpeechcraftIds { if scId == req.SpeechcraftId { found = true continue } newSpeechcraftIds = append(newSpeechcraftIds, scId) } if !found { res.Success = false res.Message = "未找到该绑定关系" return res, nil } // 4. 更新数据库 filter := bson.M{"_id": csAccount.Id, "isDeleted": false} update := bson.M{"$set": bson.M{"speechcraftIds": newSpeechcraftIds}} if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil { return nil, gerror.Wrapf(err, "解绑失败") } res.Success = true res.Message = "解绑成功" return } // SyncToRAGFlow 同步话术到RAGFlow // 参数: ctx - 上下文,speechcraftId - 话术ID,accountName - 客服账号名,tenantId - 租户ID // 返回: documentId - RAGFlow文档ID,err - 错误信息 // 功能: 将话术上传到指定客服账号的RAGFlow知识库,失败时自动加入重试队列 func (s *speechcraft) SyncToRAGFlow(ctx context.Context, speechcraftId, accountName, tenantId string) (documentId string, err error) { // 1. 查询话术 speechcraft, err := dao.Speechcraft.GetById(ctx, speechcraftId) if err != nil { return "", gerror.Wrapf(err, "查询话术失败") } if speechcraft == nil { return "", gerror.New("话术不存在") } // 2. 确保知识库存在,获取真实的datasetId datasetId, err := s.ensureDatasetExists(ctx, tenantId, "话术") if err != nil { return "", gerror.Wrapf(err, "确保知识库存在失败") } // 3. 调用RAGFlow上传文档 ragflowClient := ragflow.GetGlobalClient() filename := fmt.Sprintf("%s_%s_%s.txt", speechcraft.Direction, speechcraft.Tag, accountName) documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, speechcraft.Content, filename) if err != nil { jaeger.RecordError(ctx, err, "话术上传RAGFlow失败") return "", gerror.Wrap(err, "话术上传RAGFlow失败") } // 3.1 上传成功后立即调用解析接口 g.Log().Infof(ctx, "文档上传成功,开始解析: document_id=%s", documentId) if err = ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); err != nil { // 解析失败只记录日志,不影响绑定流程(文档已上传,可以手动重试解析) g.Log().Errorf(ctx, "文档解析失败: document_id=%s, error=%v", documentId, err) jaeger.RecordError(ctx, err, "文档解析失败") } else { g.Log().Infof(ctx, "文档解析请求已发送: document_id=%s", documentId) } // 4. 更新MongoDB的RagSyncRecord now := gtime.Now().Format("Y-m-d H:i:s") updated := false for i := range speechcraft.RagSyncRecords { record := &speechcraft.RagSyncRecords[i] if record.AccountName == accountName { record.RagDocumentId = documentId record.RagSyncStatus = "synced" record.SyncTime = now record.RetryCount = 0 updated = true break } } // 如果没有找到记录,新增 if !updated { speechcraft.RagSyncRecords = append(speechcraft.RagSyncRecords, entity.RagSyncRecord{ AccountName: accountName, RagDocumentId: documentId, RagSyncStatus: "synced", SyncTime: now, RetryCount: 0, }) } if err = dao.Speechcraft.UpdateEntity(ctx, speechcraft); err != nil { return "", gerror.Wrapf(err, "更新话术同步状态失败") } // 注意:不再更新Chat的datasetIds,因为创建Chat时已经绑定了知识库 // 话术文档上传到知识库后,Chat会自动使用该知识库的内容 glog.Infof(ctx, "话术同步成功: speechcraft_id=%s, account_name=%s, document_id=%s", speechcraftId, accountName, documentId) return documentId, nil } // ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset // 保留此方法仅为兼容性,直接调用公共方法 func (s *speechcraft) ensureDatasetExists(ctx context.Context, tenantId, datasetType string) (datasetId string, err error) { return EnsureTenantDataset(ctx, tenantId) }