ai_service.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/base64"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "log"
  10. "strings"
  11. "time"
  12. "github.com/2930134478/AI-CS/backend/infra"
  13. "github.com/2930134478/AI-CS/backend/infra/search"
  14. "github.com/2930134478/AI-CS/backend/models"
  15. "github.com/2930134478/AI-CS/backend/repository"
  16. "github.com/2930134478/AI-CS/backend/service/rag"
  17. "github.com/2930134478/AI-CS/backend/utils"
  18. "gorm.io/gorm"
  19. )
  20. // AIService AI 服务(负责调用 AI 生成回复)
  21. type AIService struct {
  22. aiConfigRepo *repository.AIConfigRepository
  23. messageRepo *repository.MessageRepository
  24. conversationRepo *repository.ConversationRepository
  25. retrievalService *rag.RetrievalService
  26. providerFactory *AIProviderFactory
  27. webSearchProvider search.WebSearchProvider // 可选,自建联网时用
  28. embeddingConfigSvc *EmbeddingConfigService // 读取联网方式:厂商内置 / 自建
  29. promptConfigSvc *PromptConfigService // 可选,提示词配置(为空则用代码内默认)
  30. storageService infra.StorageService // 可选,用于多模态识图时读取消息附件
  31. systemLogSvc *SystemLogService // 可选,结构化日志服务
  32. }
  33. // NewAIService 创建 AI 服务实例。webSearchProvider、storageService 可为 nil。
  34. func NewAIService(
  35. aiConfigRepo *repository.AIConfigRepository,
  36. messageRepo *repository.MessageRepository,
  37. conversationRepo *repository.ConversationRepository,
  38. retrievalService *rag.RetrievalService,
  39. webSearchProvider search.WebSearchProvider,
  40. embeddingConfigSvc *EmbeddingConfigService,
  41. promptConfigSvc *PromptConfigService,
  42. storageService infra.StorageService,
  43. systemLogSvc *SystemLogService,
  44. ) *AIService {
  45. return &AIService{
  46. aiConfigRepo: aiConfigRepo,
  47. messageRepo: messageRepo,
  48. conversationRepo: conversationRepo,
  49. retrievalService: retrievalService,
  50. providerFactory: NewAIProviderFactory(),
  51. webSearchProvider: webSearchProvider,
  52. embeddingConfigSvc: embeddingConfigSvc,
  53. promptConfigSvc: promptConfigSvc,
  54. storageService: storageService,
  55. systemLogSvc: systemLogSvc,
  56. }
  57. }
  58. // GenerateAIResponse 为对话生成 AI 回复(兼容旧调用,使用默认数据源选项)。
  59. // 返回: AI 回复内容,若失败返回错误。
  60. func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, userID uint) (string, error) {
  61. res, err := s.GenerateAIResponseWithOptions(conversationID, userMessage, userID, nil)
  62. if err != nil {
  63. return "", err
  64. }
  65. return res.Content, nil
  66. }
  67. // GenerateAIResponseWithOptions 根据数据源开关生成一条合成回复,并返回使用的来源标记。
  68. // opts 为 nil 时使用默认:知识库+大模型开,联网关。
  69. func (s *AIService) GenerateAIResponseWithOptions(conversationID uint, userMessage string, userID uint, opts *GenerateAIResponseInput) (*GenerateAIResponseResult, error) {
  70. useKB := true
  71. useLLM := true
  72. useWeb := false
  73. needWeb := false
  74. if opts != nil {
  75. if opts.UseKnowledgeBase != nil {
  76. useKB = *opts.UseKnowledgeBase
  77. }
  78. if opts.UseLLM != nil {
  79. useLLM = *opts.UseLLM
  80. }
  81. if opts.UseWebSearch != nil {
  82. useWeb = *opts.UseWebSearch
  83. }
  84. needWeb = opts.NeedWebSearch
  85. }
  86. conversation, err := s.conversationRepo.GetByID(conversationID)
  87. if err != nil {
  88. return nil, fmt.Errorf("获取对话失败: %v", err)
  89. }
  90. // 以下 config 为「AI 配置」:对话/联网均使用此接口;与「知识库向量配置」(embedding,如 nekoai)无关。
  91. var config *models.AIConfig
  92. if conversation.AIConfigID != nil {
  93. config, err = s.aiConfigRepo.GetByID(*conversation.AIConfigID)
  94. if err != nil {
  95. return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
  96. }
  97. if !config.IsActive {
  98. return nil, errors.New("该模型配置已禁用")
  99. }
  100. } else {
  101. config, err = s.aiConfigRepo.GetActiveByUserID(userID, "text")
  102. if err != nil {
  103. if errors.Is(err, gorm.ErrRecordNotFound) {
  104. return nil, errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
  105. }
  106. return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
  107. }
  108. }
  109. apiKey, err := utils.DecryptAPIKey(config.APIKey)
  110. if err != nil {
  111. return nil, fmt.Errorf("解密 API Key 失败: %v", err)
  112. }
  113. // 若当前 AI 配置为生图模型(model_type=image),则直接走生图逻辑,
  114. // 不参与 RAG/联网与文本对话流程。前端仍显示在「AI 客服」渠道下。
  115. if config.ModelType == "image" {
  116. log.Printf("[生图] 对话ID=%d 使用 model_type=image 配置 id=%d,走 GenerateImageReply", conversationID, config.ID)
  117. return s.GenerateImageReply(conversationID, userMessage, userID)
  118. }
  119. // 调试:确认本条对话实际使用的 AI 配置(便于排查联网/厂商内置是否走对接口)
  120. if needWeb || useWeb {
  121. convAIConfigID := "nil"
  122. if conversation.AIConfigID != nil {
  123. convAIConfigID = fmt.Sprintf("%d", *conversation.AIConfigID)
  124. }
  125. apiURLMask := config.APIURL
  126. if len(apiURLMask) > 50 {
  127. apiURLMask = apiURLMask[:50] + "..."
  128. }
  129. log.Printf("[联网] 对话ID=%d 使用的AI配置: conversation.ai_config_id=%s, config.id=%d, provider=%s, api_url=%s",
  130. conversationID, convAIConfigID, config.ID, config.Provider, apiURLMask)
  131. }
  132. history, err := s.buildConversationHistory(conversationID)
  133. if err != nil {
  134. log.Printf("⚠️ 获取对话历史失败: %v", err)
  135. history = []MessageHistory{}
  136. }
  137. // 多模态识图:当前条带图时读取文件并转 base64 供 provider 使用
  138. var imageBase64, imageMimeType string
  139. if opts != nil && opts.Attachment != nil && opts.Attachment.FileType == "image" && opts.Attachment.FileURL != "" && s.storageService != nil {
  140. data, err := s.storageService.ReadMessageFile(opts.Attachment.FileURL)
  141. if err != nil {
  142. log.Printf("⚠️ 读取消息图片失败: %v", err)
  143. } else {
  144. imageBase64 = base64.StdEncoding.EncodeToString(data)
  145. imageMimeType = opts.Attachment.MimeType
  146. if imageMimeType == "" {
  147. imageMimeType = "image/jpeg"
  148. }
  149. }
  150. }
  151. var ragContext string
  152. ragStartedAt := time.Now()
  153. if useKB && s.retrievalService != nil {
  154. ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation)
  155. if err != nil {
  156. log.Printf("⚠️ RAG 检索失败: %v", err)
  157. }
  158. if s.systemLogSvc != nil {
  159. hit := strings.TrimSpace(ragContext) != ""
  160. convID := conversationID
  161. uID := userID
  162. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  163. Level: "info",
  164. Category: "rag",
  165. Event: "rag_context_result",
  166. Source: "backend",
  167. ConversationID: &convID,
  168. UserID: &uID,
  169. Message: "RAG 检索完成",
  170. Meta: map[string]interface{}{
  171. "hit": hit,
  172. "context_len": len(ragContext),
  173. "elapsed_ms": time.Since(ragStartedAt).Milliseconds(),
  174. "use_kb": useKB,
  175. "need_web": needWeb,
  176. "use_web": useWeb,
  177. },
  178. })
  179. }
  180. }
  181. var adapterConfig *AdapterConfig
  182. if config.AdapterConfig != "" {
  183. _ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig)
  184. }
  185. aiConfig := AIConfig{
  186. APIURL: config.APIURL,
  187. APIKey: apiKey,
  188. Model: config.Model,
  189. ModelType: config.ModelType,
  190. Provider: config.Provider,
  191. AdapterConfig: adapterConfig,
  192. }
  193. provider, err := s.providerFactory.CreateProvider(aiConfig)
  194. if err != nil {
  195. return nil, fmt.Errorf("创建 AI 提供商失败: %v", err)
  196. }
  197. var sources []string
  198. enhancedMessage := userMessage
  199. // 1) 有知识库匹配:以知识库为主生成;若本回合允许联网,则用增强 prompt + 联网工具,由模型在无关/不足时用自身知识或联网
  200. if ragContext != "" {
  201. sources = append(sources, "knowledge_base")
  202. if needWeb && useWeb {
  203. webSource := "custom"
  204. if s.embeddingConfigSvc != nil {
  205. webSource, _ = s.embeddingConfigSvc.GetWebSearchSource()
  206. }
  207. enhancedMessage = s.buildRAGPromptWithWebOptional(userMessage, ragContext)
  208. content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, enhancedMessage, webSource, imageBase64, imageMimeType)
  209. if err != nil {
  210. log.Printf("⚠️ RAG+联网(function calling)失败: %v,回退到仅 RAG", err)
  211. if s.systemLogSvc != nil {
  212. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  213. Level: "warn",
  214. Category: "ai",
  215. Event: "rag_web_fallback",
  216. Source: "backend",
  217. ConversationID: &conversationID,
  218. UserID: &userID,
  219. Message: "RAG+联网失败,回退到仅RAG",
  220. Meta: map[string]interface{}{
  221. "error": err.Error(),
  222. "web_source": webSource,
  223. "ai_config": config.ID,
  224. },
  225. })
  226. }
  227. if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) {
  228. log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。")
  229. }
  230. enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
  231. } else if content != "" {
  232. sources = append(sources, "llm")
  233. if usedWeb {
  234. sources = append(sources, "web")
  235. }
  236. if s.systemLogSvc != nil {
  237. convID := conversationID
  238. uID := userID
  239. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  240. Level: "info",
  241. Category: "ai",
  242. Event: "ai_web_success",
  243. Source: "backend",
  244. ConversationID: &convID,
  245. UserID: &uID,
  246. Message: "RAG+联网生成成功",
  247. Meta: map[string]interface{}{
  248. "sources": strings.Join(sources, ","),
  249. },
  250. })
  251. }
  252. return &GenerateAIResponseResult{
  253. Content: content,
  254. SourcesUsed: strings.Join(sources, ","),
  255. }, nil
  256. } else {
  257. enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
  258. }
  259. } else {
  260. enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
  261. }
  262. } else {
  263. // 2) 无知识库匹配:本回合允许联网时走「模型决定搜」function calling;否则仅用大模型知识
  264. if needWeb && useWeb {
  265. webSource := "custom"
  266. if s.embeddingConfigSvc != nil {
  267. webSource, _ = s.embeddingConfigSvc.GetWebSearchSource()
  268. }
  269. content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, userMessage, webSource, imageBase64, imageMimeType)
  270. if err != nil {
  271. log.Printf("⚠️ 联网(function calling)失败: %v,回退到仅大模型", err)
  272. if s.systemLogSvc != nil {
  273. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  274. Level: "warn",
  275. Category: "ai",
  276. Event: "web_fallback_to_llm",
  277. Source: "backend",
  278. ConversationID: &conversationID,
  279. UserID: &userID,
  280. Message: "联网失败,回退到仅大模型",
  281. Meta: map[string]interface{}{
  282. "error": err.Error(),
  283. "web_source": webSource,
  284. "ai_config": config.ID,
  285. },
  286. })
  287. }
  288. if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) {
  289. log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。")
  290. }
  291. } else if content != "" {
  292. sources = append(sources, "llm")
  293. if usedWeb {
  294. sources = append(sources, "web")
  295. }
  296. if s.systemLogSvc != nil {
  297. convID := conversationID
  298. uID := userID
  299. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  300. Level: "info",
  301. Category: "ai",
  302. Event: "ai_web_success",
  303. Source: "backend",
  304. ConversationID: &convID,
  305. UserID: &uID,
  306. Message: "联网生成成功",
  307. Meta: map[string]interface{}{
  308. "sources": strings.Join(sources, ","),
  309. },
  310. })
  311. }
  312. return &GenerateAIResponseResult{
  313. Content: content,
  314. SourcesUsed: strings.Join(sources, ","),
  315. }, nil
  316. }
  317. }
  318. if useLLM && len(sources) == 0 {
  319. enhancedMessage = s.buildNoKBPrompt(userMessage)
  320. sources = append(sources, "llm")
  321. } else if useLLM && len(sources) > 0 {
  322. sources = append(sources, "llm")
  323. }
  324. }
  325. // 无任何来源时(例如 useKB 且无匹配,useLLM 关):使用可配置回复语
  326. if len(sources) == 0 {
  327. reply := s.getNoSourceReply()
  328. return &GenerateAIResponseResult{
  329. Content: reply,
  330. SourcesUsed: "",
  331. }, nil
  332. }
  333. response, err := provider.GenerateResponse(history, enhancedMessage, imageBase64, imageMimeType)
  334. if err != nil {
  335. log.Printf("❌ AI 调用失败: %v", err)
  336. if s.systemLogSvc != nil {
  337. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  338. Level: "error",
  339. Category: "ai",
  340. Event: "ai_generate_failed",
  341. Source: "backend",
  342. ConversationID: &conversationID,
  343. UserID: &userID,
  344. Message: "AI 调用失败,返回兜底回复",
  345. Meta: map[string]interface{}{
  346. "error": err.Error(),
  347. "ai_config": config.ID,
  348. },
  349. })
  350. }
  351. return &GenerateAIResponseResult{
  352. Content: s.getAIFailReply(),
  353. SourcesUsed: strings.Join(sources, ","),
  354. GenerationFailed: true,
  355. }, nil
  356. }
  357. if s.systemLogSvc != nil {
  358. convID := conversationID
  359. uID := userID
  360. event := "ai_llm_success"
  361. if strings.Contains(strings.Join(sources, ","), "knowledge_base") {
  362. event = "ai_rag_success"
  363. }
  364. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  365. Level: "info",
  366. Category: "ai",
  367. Event: event,
  368. Source: "backend",
  369. ConversationID: &convID,
  370. UserID: &uID,
  371. Message: "AI 生成成功",
  372. Meta: map[string]interface{}{
  373. "sources": strings.Join(sources, ","),
  374. },
  375. })
  376. }
  377. return &GenerateAIResponseResult{
  378. Content: response,
  379. SourcesUsed: strings.Join(sources, ","),
  380. }, nil
  381. }
  382. // GenerateImageReply 生图渠道专用:根据用户描述生成图片并保存到存储,返回说明文案与图片 URL。
  383. func (s *AIService) GenerateImageReply(conversationID uint, prompt string, userID uint) (*GenerateAIResponseResult, error) {
  384. conversation, err := s.conversationRepo.GetByID(conversationID)
  385. if err != nil {
  386. return nil, fmt.Errorf("获取对话失败: %v", err)
  387. }
  388. if conversation.AIConfigID == nil {
  389. return nil, errors.New("生图渠道需要选择生图模型,请先在渠道中选择「生图绘画」并选择模型")
  390. }
  391. config, err := s.aiConfigRepo.GetByID(*conversation.AIConfigID)
  392. if err != nil {
  393. return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
  394. }
  395. if !config.IsActive {
  396. return nil, errors.New("该生图模型已禁用")
  397. }
  398. if config.ModelType != "image" {
  399. return nil, fmt.Errorf("当前选择的不是生图模型,model_type=%s", config.ModelType)
  400. }
  401. apiKey, err := utils.DecryptAPIKey(config.APIKey)
  402. if err != nil {
  403. return nil, fmt.Errorf("解密 API Key 失败: %v", err)
  404. }
  405. var adapterConfig *AdapterConfig
  406. if config.AdapterConfig != "" {
  407. _ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig)
  408. }
  409. aiConfig := AIConfig{
  410. APIURL: config.APIURL,
  411. APIKey: apiKey,
  412. Model: config.Model,
  413. ModelType: config.ModelType,
  414. Provider: config.Provider,
  415. AdapterConfig: adapterConfig,
  416. }
  417. provider, err := s.providerFactory.CreateProvider(aiConfig)
  418. if err != nil {
  419. return nil, err
  420. }
  421. imgProvider, ok := provider.(ImageGenerationProvider)
  422. if !ok {
  423. return nil, errors.New("当前提供商不支持生图")
  424. }
  425. imageData, mimeType, err := imgProvider.GenerateImage(prompt)
  426. if err != nil {
  427. return nil, err
  428. }
  429. if s.storageService == nil {
  430. return nil, errors.New("存储服务未配置,无法保存生成图片")
  431. }
  432. ext := ".png"
  433. if strings.Contains(mimeType, "jpeg") || strings.Contains(mimeType, "jpg") {
  434. ext = ".jpg"
  435. }
  436. fileURL, err := s.storageService.SaveMessageFile(conversationID, bytes.NewReader(imageData), "generated"+ext)
  437. if err != nil {
  438. return nil, fmt.Errorf("保存生成图片失败: %v", err)
  439. }
  440. content := "已根据您的描述生成图片。"
  441. return &GenerateAIResponseResult{
  442. Content: content,
  443. SourcesUsed: "",
  444. GeneratedFileURL: &fileURL,
  445. }, nil
  446. }
  447. func (s *AIService) buildNoKBPrompt(userMessage string) string {
  448. if s.promptConfigSvc != nil {
  449. tpl, err := s.promptConfigSvc.GetNoKBPromptTemplate()
  450. if err == nil && tpl != "" {
  451. return replaceUserMessageOnly(tpl, userMessage)
  452. }
  453. }
  454. return fmt.Sprintf(`你是一个智能客服助手。当前未使用知识库,请仅基于你的知识回答用户问题。
  455. 用户问题:%s
  456. 请简洁、友好地回答。若无法回答,可建议用户联系人工客服。`, userMessage)
  457. }
  458. func (s *AIService) buildWebSearchPrompt(userMessage string, webContext string) string {
  459. if s.promptConfigSvc != nil {
  460. tpl, err := s.promptConfigSvc.GetWebSearchResultPromptTemplate()
  461. if err == nil && tpl != "" {
  462. return replaceWebSearchPlaceholders(tpl, webContext, userMessage)
  463. }
  464. }
  465. return fmt.Sprintf(`你是一个智能客服助手。请结合以下联网搜索结果回答用户问题。
  466. 联网搜索结果:
  467. %s
  468. 用户问题:%s
  469. 请基于以上内容给出简洁、准确的回答。`, webContext, userMessage)
  470. }
  471. // replaceUserMessageOnly 仅替换 {{user_message}}
  472. func replaceUserMessageOnly(template, userMessage string) string {
  473. return strings.ReplaceAll(template, "{{user_message}}", userMessage)
  474. }
  475. // replaceWebSearchPlaceholders 替换 {{web_context}}、{{user_message}}
  476. func replaceWebSearchPlaceholders(template, webContext, userMessage string) string {
  477. template = strings.ReplaceAll(template, "{{web_context}}", webContext)
  478. template = strings.ReplaceAll(template, "{{user_message}}", userMessage)
  479. return template
  480. }
  481. // getNoSourceReply 无任何来源时返回给用户的一句话(可配置)
  482. func (s *AIService) getNoSourceReply() string {
  483. if s.promptConfigSvc != nil {
  484. reply, err := s.promptConfigSvc.GetNoSourceReply()
  485. if err == nil && strings.TrimSpace(reply) != "" {
  486. return strings.TrimSpace(reply)
  487. }
  488. }
  489. return "当前知识库暂无与此问题相关的内容,您可以尝试联系人工客服。"
  490. }
  491. // getAIFailReply AI 调用失败时返回给用户的一句话(可配置)
  492. func (s *AIService) getAIFailReply() string {
  493. if s.promptConfigSvc != nil {
  494. reply, err := s.promptConfigSvc.GetAIFailReply()
  495. if err == nil && strings.TrimSpace(reply) != "" {
  496. return strings.TrimSpace(reply)
  497. }
  498. }
  499. return "AI客服好像出了点差错,请联系人工客服解决"
  500. }
  501. // buildConversationHistory 构建对话历史(用于 AI 上下文)。
  502. func (s *AIService) buildConversationHistory(conversationID uint) ([]MessageHistory, error) {
  503. // 获取最近的对话消息(最多 10 条,避免上下文过长)
  504. messages, err := s.messageRepo.ListByConversationID(conversationID)
  505. if err != nil {
  506. return nil, err
  507. }
  508. // 只取最近 10 条消息
  509. startIdx := 0
  510. if len(messages) > 10 {
  511. startIdx = len(messages) - 10
  512. }
  513. history := make([]MessageHistory, 0)
  514. for i := startIdx; i < len(messages); i++ {
  515. msg := messages[i]
  516. // 跳过系统消息
  517. if msg.MessageType == "system_message" {
  518. continue
  519. }
  520. role := "user"
  521. if msg.SenderIsAgent {
  522. role = "assistant"
  523. }
  524. history = append(history, MessageHistory{
  525. Role: role,
  526. Content: msg.Content,
  527. })
  528. }
  529. return history, nil
  530. }
  531. // retrieveRAGContext 从知识库中检索相关文档内容
  532. // query: 用户查询文本
  533. // conversation: 对话信息(可能包含知识库 ID)
  534. // 返回: 检索到的文档内容(格式化后的字符串)
  535. func (s *AIService) retrieveRAGContext(ctx context.Context, query string, conversation *models.Conversation) (string, error) {
  536. // 确定知识库 ID(可以从对话中获取,或为空表示搜索所有知识库)
  537. // TODO: 后续在 Conversation 模型增加 KnowledgeBaseID 字段
  538. var knowledgeBaseID *uint
  539. // knowledgeBaseID = conversation.KnowledgeBaseID // 暂时注释,等模型字段添加后启用
  540. // 执行 RAG 检索(Top-K = 5,返回最相关的 5 个文档片段)
  541. // 使用重排序优化检索结果
  542. topK := 5
  543. results, err := s.retrievalService.RetrieveWithRerank(ctx, query, topK, knowledgeBaseID)
  544. if err != nil {
  545. return "", fmt.Errorf("RAG 检索失败: %w", err)
  546. }
  547. if len(results) == 0 {
  548. // 没有检索到相关文档
  549. return "", nil
  550. }
  551. // 格式化检索结果
  552. var contextParts []string
  553. for i, result := range results {
  554. // 只使用相似度较高的结果(Score 越小表示相似度越高)
  555. // 如果使用余弦相似度,通常阈值在 0.7-0.9 之间
  556. // 这里我们暂时不过滤,让所有结果都参与
  557. contextParts = append(contextParts, fmt.Sprintf("文档片段 %d:\n%s", i+1, result.Content))
  558. }
  559. return strings.Join(contextParts, "\n\n"), nil
  560. }
  561. // buildRAGPrompt 构建包含 RAG 上下文的 Prompt
  562. // userMessage: 用户原始消息
  563. // ragContext: RAG 检索到的文档内容
  564. // 返回: 增强后的用户消息(包含知识库上下文)。若已配置提示词服务则使用可配置模板(占位符 {{rag_context}}、{{user_message}}),否则使用代码内默认。
  565. func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string {
  566. if s.promptConfigSvc != nil {
  567. tpl, err := s.promptConfigSvc.GetRAGPromptTemplate()
  568. if err == nil && tpl != "" {
  569. return replacePromptPlaceholders(tpl, ragContext, userMessage)
  570. }
  571. }
  572. return s.buildRAGPromptFallback(userMessage, ragContext)
  573. }
  574. // buildRAGPromptFallback 代码内默认 RAG 提示词(与 prompt_config_service 默认一致,用于 promptConfigSvc 为空或出错时)
  575. func (s *AIService) buildRAGPromptFallback(userMessage string, ragContext string) string {
  576. return fmt.Sprintf(`你是一个智能客服助手,请基于以下知识库内容回答用户的问题。
  577. 知识库内容:
  578. %s
  579. 用户问题:%s
  580. 请根据知识库内容回答用户的问题。如果知识库中没有相关信息,请礼貌地告知用户,并建议联系人工客服。
  581. 回答要求:
  582. 1. 基于知识库内容,提供准确、有用的回答
  583. 2. 如果知识库中有相关信息,请直接引用并解释
  584. 3. 如果知识库中没有相关信息,请诚实告知
  585. 4. 保持友好、专业的语气
  586. 5. 回答要简洁明了,避免冗长`, ragContext, userMessage)
  587. }
  588. // replacePromptPlaceholders 将模板中的 {{rag_context}}、{{user_message}} 替换为实际值
  589. func replacePromptPlaceholders(template, ragContext, userMessage string) string {
  590. template = strings.ReplaceAll(template, "{{rag_context}}", ragContext)
  591. template = strings.ReplaceAll(template, "{{user_message}}", userMessage)
  592. return template
  593. }
  594. // buildRAGPromptWithWebOptional 构建 RAG prompt,并允许在知识库无关或不足时用自身知识或联网。
  595. // 与 buildRAGPrompt 区别:明确说明可先基于知识库,若无关/弱相关可基于自身知识,若仍不足可由模型决定是否联网(需配合传入 web_search 工具使用)。
  596. func (s *AIService) buildRAGPromptWithWebOptional(userMessage string, ragContext string) string {
  597. if s.promptConfigSvc != nil {
  598. tpl, err := s.promptConfigSvc.GetRAGPromptWithWebOptionalTemplate()
  599. if err == nil && tpl != "" {
  600. return replacePromptPlaceholders(tpl, ragContext, userMessage)
  601. }
  602. }
  603. return s.buildRAGPromptWithWebOptionalFallback(userMessage, ragContext)
  604. }
  605. // buildRAGPromptWithWebOptionalFallback 代码内默认(RAG+联网可选)
  606. func (s *AIService) buildRAGPromptWithWebOptionalFallback(userMessage string, ragContext string) string {
  607. return fmt.Sprintf(`你是一个智能客服助手。请优先基于以下知识库内容回答用户的问题。
  608. 知识库内容:
  609. %s
  610. 用户问题:%s
  611. 回答要求:
  612. 1. 若知识库内容与问题明确相关,请基于知识库给出准确、简洁的回答。
  613. 2. 若知识库内容与问题无关或仅弱相关,可先基于你自身的知识回答,不必拘泥于知识库。
  614. 3. 若你自身知识仍不足以回答(例如需要最新资讯、实时数据),你可决定是否使用联网搜索获取信息后再回答。
  615. 4. 保持友好、专业,回答简洁明了。`, ragContext, userMessage)
  616. }
  617. const maxWebToolRounds = 5
  618. // webSearchToolDefinition 返回 type: "function" 的 web_search 工具定义,仅用于「自建」联网(Serper 执行)。
  619. func (s *AIService) webSearchToolDefinition() []map[string]interface{} {
  620. return []map[string]interface{}{
  621. {
  622. "type": "function",
  623. "function": map[string]interface{}{
  624. "name": "web_search",
  625. "description": "Search the web for current information. Use when you need up-to-date or external information to answer the user.",
  626. "parameters": map[string]interface{}{
  627. "type": "object",
  628. "properties": map[string]interface{}{
  629. "query": map[string]string{"type": "string", "description": "Search query"},
  630. },
  631. "required": []string{"query"},
  632. },
  633. },
  634. },
  635. }
  636. }
  637. // generateWithWebTools 使用 function calling 做联网(模型决定是否搜)。webSource: vendor / custom。
  638. // 联网请求始终发往当前对话的「AI 配置」对话接口(与知识库向量配置/embedding 无关)。
  639. // - vendor(模式一:厂商内置):在 tools 里传 type "web_search",由厂商在自家 API 内封装并执行搜索,无需自建。
  640. // - custom(模式二:自建):在 tools 里传 type "function" 的自定义函数(如 web_search),由本服务调用 Serper 等执行并回填。
  641. func (s *AIService) generateWithWebTools(ctx context.Context, provider AIProvider, history []MessageHistory, userMessage string, webSource string, imageBase64 string, imageMimeType string) (content string, usedWeb bool, err error) {
  642. messages := s.historyToOpenAIMessages(history, userMessage, imageBase64, imageMimeType)
  643. var tools []map[string]interface{}
  644. useFunctionFormat := false
  645. switch webSource {
  646. case "vendor":
  647. // 模式一:厂商内置,仅传 web_search,由厂商执行
  648. tools = []map[string]interface{}{
  649. {"type": "web_search"},
  650. }
  651. case "custom":
  652. if s.webSearchProvider == nil {
  653. return "", false, nil
  654. }
  655. useFunctionFormat = true
  656. tools = s.webSearchToolDefinition()
  657. default:
  658. tools = nil
  659. }
  660. if len(tools) == 0 {
  661. return "", false, nil
  662. }
  663. rounds := 0
  664. for rounds < maxWebToolRounds {
  665. rounds++
  666. respContent, toolCalls, callErr := provider.GenerateResponseWithTools(messages, tools)
  667. if callErr != nil {
  668. return "", usedWeb, callErr
  669. }
  670. if len(toolCalls) == 0 {
  671. return respContent, usedWeb, nil
  672. }
  673. if useFunctionFormat {
  674. usedWeb = true
  675. }
  676. // 追加 assistant 消息(含 tool_calls)
  677. assistantMsg := map[string]interface{}{"role": "assistant", "content": respContent}
  678. tcList := make([]map[string]interface{}, 0, len(toolCalls))
  679. for _, tc := range toolCalls {
  680. tcList = append(tcList, map[string]interface{}{
  681. "id": tc.ID,
  682. "type": "function",
  683. "function": map[string]interface{}{"name": tc.Name, "arguments": tc.Arguments},
  684. })
  685. }
  686. assistantMsg["tool_calls"] = tcList
  687. messages = append(messages, assistantMsg)
  688. for _, tc := range toolCalls {
  689. toolResult := ""
  690. if useFunctionFormat && tc.Name == "web_search" && s.webSearchProvider != nil {
  691. var args struct {
  692. Query string `json:"query"`
  693. }
  694. _ = json.Unmarshal([]byte(tc.Arguments), &args)
  695. query := args.Query
  696. if query == "" {
  697. query = userMessage
  698. }
  699. toolResult, _ = s.webSearchProvider.Search(ctx, query)
  700. }
  701. messages = append(messages, map[string]interface{}{
  702. "role": "tool",
  703. "tool_call_id": tc.ID,
  704. "content": toolResult,
  705. })
  706. }
  707. }
  708. return "", usedWeb, fmt.Errorf("联网工具调用超过 %d 轮", maxWebToolRounds)
  709. }
  710. func (s *AIService) historyToOpenAIMessages(history []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) []map[string]interface{} {
  711. out := make([]map[string]interface{}, 0, len(history)+1)
  712. for _, h := range history {
  713. out = append(out, map[string]interface{}{"role": h.Role, "content": h.Content})
  714. }
  715. var lastContent interface{} = userMessage
  716. if imageBase64 != "" {
  717. dataURL := "data:" + imageMimeType + ";base64," + imageBase64
  718. if imageMimeType == "" {
  719. dataURL = "data:image/jpeg;base64," + imageBase64
  720. }
  721. lastContent = []map[string]interface{}{
  722. {"type": "text", "text": userMessage},
  723. {"type": "image_url", "image_url": map[string]string{"url": dataURL}},
  724. }
  725. }
  726. out = append(out, map[string]interface{}{"role": "user", "content": lastContent})
  727. return out
  728. }