analytics_service.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. package service
  2. import (
  3. "fmt"
  4. "strings"
  5. "time"
  6. "github.com/2930134478/AI-CS/backend/models"
  7. "github.com/2930134478/AI-CS/backend/repository"
  8. "gorm.io/gorm"
  9. )
  10. // AnalyticsSummaryResponse 报表汇总(按日 + 区间总计)
  11. type AnalyticsSummaryResponse struct {
  12. From string `json:"from"`
  13. To string `json:"to"`
  14. Totals AnalyticsTotals `json:"totals"`
  15. Daily []AnalyticsDailyRow `json:"daily"`
  16. Note string `json:"note"`
  17. }
  18. // AnalyticsTotals 区间内汇总指标
  19. type AnalyticsTotals struct {
  20. WidgetOpens int64 `json:"widget_opens"`
  21. Sessions int64 `json:"sessions"`
  22. Messages int64 `json:"messages"`
  23. AIReplies int64 `json:"ai_replies"`
  24. AIFailed int64 `json:"ai_failed"`
  25. AIFailureRatePercent float64 `json:"ai_failure_rate_percent"`
  26. KBHits int64 `json:"kb_hits"`
  27. KBHitRatePercent float64 `json:"kb_hit_rate_percent"`
  28. MaxAIRounds int `json:"max_ai_rounds"`
  29. // SessionsWithAI 区间内新建的访客会话中,至少使用过 AI(访客 AI 发言或收到 AI 回复)的会话数
  30. SessionsWithAI int64 `json:"sessions_with_ai"`
  31. AIParticipationRatePercent float64 `json:"ai_participation_rate_percent"`
  32. AIToHumanSessions int64 `json:"ai_to_human_sessions"`
  33. AIToHumanRatePercent float64 `json:"ai_to_human_rate_percent"`
  34. HumanToAISessions int64 `json:"human_to_ai_sessions"`
  35. HumanToAIRatePercent float64 `json:"human_to_ai_rate_percent"`
  36. // 以下为转人工率分母说明用(区间内有活动的会话中统计)
  37. SessionsWithAIUserMsg int64 `json:"sessions_with_ai_user_msg"`
  38. SessionsWithHumanUserMsg int64 `json:"sessions_with_human_user_msg"`
  39. }
  40. // AnalyticsDailyRow 单日指标(用于折线/柱状图)
  41. type AnalyticsDailyRow struct {
  42. Date string `json:"date"`
  43. WidgetOpens int64 `json:"widget_opens"`
  44. Sessions int64 `json:"sessions"`
  45. Messages int64 `json:"messages"`
  46. AIReplies int64 `json:"ai_replies"`
  47. }
  48. // AnalyticsService 数据分析报表(访客会话,不含内部知识库测试)
  49. type AnalyticsService struct {
  50. db *gorm.DB
  51. widgetOpens *repository.WidgetOpenRepository
  52. analyticsLoc *time.Location
  53. }
  54. // NewAnalyticsService 创建报表服务;loc 用于按自然日切分,默认上海时区
  55. func NewAnalyticsService(db *gorm.DB, widgetOpens *repository.WidgetOpenRepository) *AnalyticsService {
  56. loc, err := time.LoadLocation("Asia/Shanghai")
  57. if err != nil {
  58. loc = time.Local
  59. }
  60. return &AnalyticsService{db: db, widgetOpens: widgetOpens, analyticsLoc: loc}
  61. }
  62. // RecordWidgetOpen 记录一次访客打开客服小窗
  63. func (s *AnalyticsService) RecordWidgetOpen(visitorID uint) error {
  64. if visitorID == 0 {
  65. return fmt.Errorf("visitor_id 无效")
  66. }
  67. return s.widgetOpens.Create(&models.WidgetOpenEvent{VisitorID: visitorID})
  68. }
  69. // GetSummary 查询 [fromDate, toDate] 闭区间内的统计(按上海时区日历日)
  70. func (s *AnalyticsService) GetSummary(fromDate, toDate string) (*AnalyticsSummaryResponse, error) {
  71. start, endExclusive, err := parseInclusiveDateRange(fromDate, toDate, s.analyticsLoc)
  72. if err != nil {
  73. return nil, err
  74. }
  75. if !endExclusive.After(start) {
  76. return nil, fmt.Errorf("结束日期须不早于开始日期")
  77. }
  78. totals := s.computeTotals(start, endExclusive)
  79. daily := s.computeDailySeries(start, endExclusive)
  80. return &AnalyticsSummaryResponse{
  81. From: fromDate,
  82. To: toDate,
  83. Totals: totals,
  84. Daily: daily,
  85. Note: "访客会话统计;时区按 Asia/Shanghai 切日。知识库命中率分母为「非失败的 AI 回复数」。转人工率分母为「有过 AI 模式访客发言的会话数」。",
  86. }, nil
  87. }
  88. func parseInclusiveDateRange(fromStr, toStr string, loc *time.Location) (start, endExclusive time.Time, err error) {
  89. fromStr = strings.TrimSpace(fromStr)
  90. toStr = strings.TrimSpace(toStr)
  91. if fromStr == "" || toStr == "" {
  92. return time.Time{}, time.Time{}, fmt.Errorf("请提供 from 与 to,格式 YYYY-MM-DD")
  93. }
  94. d0, e1 := time.ParseInLocation("2006-01-02", fromStr, loc)
  95. d1, e2 := time.ParseInLocation("2006-01-02", toStr, loc)
  96. if e1 != nil || e2 != nil {
  97. return time.Time{}, time.Time{}, fmt.Errorf("日期格式应为 YYYY-MM-DD")
  98. }
  99. start = time.Date(d0.Year(), d0.Month(), d0.Day(), 0, 0, 0, 0, loc)
  100. endDay := time.Date(d1.Year(), d1.Month(), d1.Day(), 0, 0, 0, 0, loc)
  101. endExclusive = endDay.AddDate(0, 0, 1)
  102. return start, endExclusive, nil
  103. }
  104. func (s *AnalyticsService) computeTotals(start, endExclusive time.Time) AnalyticsTotals {
  105. var out AnalyticsTotals
  106. // 小窗打开次数
  107. s.db.Model(&models.WidgetOpenEvent{}).
  108. Where("created_at >= ? AND created_at < ?", start, endExclusive).
  109. Count(&out.WidgetOpens)
  110. // 新建访客会话(区间内创建的)
  111. s.db.Model(&models.Conversation{}).
  112. Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive).
  113. Count(&out.Sessions)
  114. // 区间内产生的消息(仅访客会话)
  115. s.db.Model(&models.Message{}).
  116. Joins("JOIN conversations ON conversations.id = messages.conversation_id").
  117. Where("conversations.conversation_type = ?", "visitor").
  118. Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
  119. Count(&out.Messages)
  120. // AI 回复:客服侧且 sender_id=0
  121. s.db.Model(&models.Message{}).
  122. Joins("JOIN conversations ON conversations.id = messages.conversation_id").
  123. Where("conversations.conversation_type = ?", "visitor").
  124. Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
  125. Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
  126. Count(&out.AIReplies)
  127. s.db.Model(&models.Message{}).
  128. Joins("JOIN conversations ON conversations.id = messages.conversation_id").
  129. Where("conversations.conversation_type = ?", "visitor").
  130. Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
  131. Where("messages.is_ai_generation_failed = ?", true).
  132. Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
  133. Count(&out.AIFailed)
  134. aiOK := out.AIReplies - out.AIFailed
  135. if out.AIReplies > 0 {
  136. out.AIFailureRatePercent = round2(float64(out.AIFailed) * 100 / float64(out.AIReplies))
  137. }
  138. s.db.Model(&models.Message{}).
  139. Joins("JOIN conversations ON conversations.id = messages.conversation_id").
  140. Where("conversations.conversation_type = ?", "visitor").
  141. Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
  142. Where("messages.is_ai_generation_failed = ?", false).
  143. Where("messages.sources_used LIKE ?", "%knowledge_base%").
  144. Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
  145. Count(&out.KBHits)
  146. if aiOK > 0 {
  147. out.KBHitRatePercent = round2(float64(out.KBHits) * 100 / float64(aiOK))
  148. }
  149. // 需要全量消息的会话:区间内新建或有消息活动的访客会话
  150. convIDs := s.visitorConversationIDsTouchingRange(start, endExclusive)
  151. if len(convIDs) > 0 {
  152. var all []models.Message
  153. s.db.Where("conversation_id IN ?", convIDs).
  154. Order("conversation_id ASC, created_at ASC").
  155. Find(&all)
  156. byConv := groupMessagesByConversation(all)
  157. maxRounds := 0
  158. seenAI := make(map[uint]struct{})
  159. seenHuman := make(map[uint]struct{})
  160. seenATH := make(map[uint]struct{})
  161. seenHTA := make(map[uint]struct{})
  162. for cid, msgs := range byConv {
  163. r := countAIRounds(msgs)
  164. if r > maxRounds {
  165. maxRounds = r
  166. }
  167. ath, hta, hasAIUser, hasHumanUser := detectModeTransitions(msgs)
  168. if hasAIUser {
  169. seenAI[cid] = struct{}{}
  170. }
  171. if hasHumanUser {
  172. seenHuman[cid] = struct{}{}
  173. }
  174. if ath {
  175. seenATH[cid] = struct{}{}
  176. }
  177. if hta {
  178. seenHTA[cid] = struct{}{}
  179. }
  180. }
  181. out.MaxAIRounds = maxRounds
  182. out.SessionsWithAIUserMsg = int64(len(seenAI))
  183. out.SessionsWithHumanUserMsg = int64(len(seenHuman))
  184. var createdInRange []uint
  185. s.db.Model(&models.Conversation{}).
  186. Select("id").
  187. Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive).
  188. Pluck("id", &createdInRange)
  189. var sessionsWithAI int64
  190. for _, cid := range createdInRange {
  191. if conversationUsedAI(byConv[cid]) {
  192. sessionsWithAI++
  193. }
  194. }
  195. out.SessionsWithAI = sessionsWithAI
  196. if out.Sessions > 0 {
  197. out.AIParticipationRatePercent = round2(float64(sessionsWithAI) * 100 / float64(out.Sessions))
  198. }
  199. if len(seenAI) > 0 {
  200. out.AIToHumanSessions = int64(len(seenATH))
  201. out.AIToHumanRatePercent = round2(float64(len(seenATH)) * 100 / float64(len(seenAI)))
  202. }
  203. if len(seenHuman) > 0 {
  204. out.HumanToAISessions = int64(len(seenHTA))
  205. out.HumanToAIRatePercent = round2(float64(len(seenHTA)) * 100 / float64(len(seenHuman)))
  206. }
  207. }
  208. return out
  209. }
  210. func (s *AnalyticsService) visitorConversationIDsTouchingRange(start, endExclusive time.Time) []uint {
  211. var created []uint
  212. s.db.Model(&models.Conversation{}).
  213. Select("id").
  214. Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive).
  215. Pluck("id", &created)
  216. var fromMessages []uint
  217. s.db.Model(&models.Message{}).
  218. Joins("JOIN conversations ON conversations.id = messages.conversation_id").
  219. Where("conversations.conversation_type = ?", "visitor").
  220. Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
  221. Pluck("messages.conversation_id", &fromMessages)
  222. uniq := make(map[uint]struct{})
  223. for _, id := range created {
  224. uniq[id] = struct{}{}
  225. }
  226. for _, id := range fromMessages {
  227. uniq[id] = struct{}{}
  228. }
  229. out := make([]uint, 0, len(uniq))
  230. for id := range uniq {
  231. out = append(out, id)
  232. }
  233. return out
  234. }
  235. func groupMessagesByConversation(msgs []models.Message) map[uint][]models.Message {
  236. m := make(map[uint][]models.Message)
  237. for _, msg := range msgs {
  238. m[msg.ConversationID] = append(m[msg.ConversationID], msg)
  239. }
  240. return m
  241. }
  242. // countAIRounds 同一会话内:访客在 AI 模式下一条消息 + 紧随其后的 AI 回复算一轮
  243. func countAIRounds(msgs []models.Message) int {
  244. n := 0
  245. for i := 0; i < len(msgs)-1; i++ {
  246. a, b := msgs[i], msgs[i+1]
  247. if !a.SenderIsAgent && a.ChatMode == "ai" && b.SenderIsAgent && b.SenderID == 0 {
  248. n++
  249. }
  250. }
  251. return n
  252. }
  253. // detectModeTransitions 仅看访客用户消息(非客服)的 chat_mode 变化
  254. func conversationUsedAI(msgs []models.Message) bool {
  255. for _, m := range msgs {
  256. if m.SenderIsAgent && m.SenderID == 0 {
  257. return true
  258. }
  259. if !m.SenderIsAgent && m.ChatMode == "ai" {
  260. return true
  261. }
  262. }
  263. return false
  264. }
  265. func detectModeTransitions(msgs []models.Message) (aiToHuman, humanToAI, hasAIUser, hasHumanUser bool) {
  266. var prev string
  267. for _, m := range msgs {
  268. if m.SenderIsAgent {
  269. continue
  270. }
  271. mode := m.ChatMode
  272. if mode != "ai" && mode != "human" {
  273. continue
  274. }
  275. if mode == "ai" {
  276. hasAIUser = true
  277. }
  278. if mode == "human" {
  279. hasHumanUser = true
  280. }
  281. if prev == "ai" && mode == "human" {
  282. aiToHuman = true
  283. }
  284. if prev == "human" && mode == "ai" {
  285. humanToAI = true
  286. }
  287. prev = mode
  288. }
  289. return
  290. }
  291. func (s *AnalyticsService) computeDailySeries(start, endExclusive time.Time) []AnalyticsDailyRow {
  292. var rows []AnalyticsDailyRow
  293. for d := start; d.Before(endExclusive); d = d.AddDate(0, 0, 1) {
  294. dayEnd := d.AddDate(0, 0, 1)
  295. dateStr := d.Format("2006-01-02")
  296. var w, sess, msg, ai int64
  297. s.db.Model(&models.WidgetOpenEvent{}).
  298. Where("created_at >= ? AND created_at < ?", d, dayEnd).Count(&w)
  299. s.db.Model(&models.Conversation{}).
  300. Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", d, dayEnd).Count(&sess)
  301. s.db.Model(&models.Message{}).
  302. Joins("JOIN conversations ON conversations.id = messages.conversation_id").
  303. Where("conversations.conversation_type = ?", "visitor").
  304. Where("messages.created_at >= ? AND messages.created_at < ?", d, dayEnd).
  305. Count(&msg)
  306. s.db.Model(&models.Message{}).
  307. Joins("JOIN conversations ON conversations.id = messages.conversation_id").
  308. Where("conversations.conversation_type = ?", "visitor").
  309. Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
  310. Where("messages.created_at >= ? AND messages.created_at < ?", d, dayEnd).
  311. Count(&ai)
  312. rows = append(rows, AnalyticsDailyRow{
  313. Date: dateStr,
  314. WidgetOpens: w,
  315. Sessions: sess,
  316. Messages: msg,
  317. AIReplies: ai,
  318. })
  319. }
  320. return rows
  321. }
  322. func round2(x float64) float64 {
  323. return float64(int64(x*100+0.5)) / 100
  324. }