ai_service.go 30 KB

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