| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- package service
- import (
- "fmt"
- "strings"
- "time"
- "github.com/2930134478/AI-CS/backend/models"
- "github.com/2930134478/AI-CS/backend/repository"
- "gorm.io/gorm"
- )
- // AnalyticsSummaryResponse 报表汇总(按日 + 区间总计)
- type AnalyticsSummaryResponse struct {
- From string `json:"from"`
- To string `json:"to"`
- Totals AnalyticsTotals `json:"totals"`
- Daily []AnalyticsDailyRow `json:"daily"`
- Note string `json:"note"`
- }
- // AnalyticsTotals 区间内汇总指标
- type AnalyticsTotals struct {
- WidgetOpens int64 `json:"widget_opens"`
- Sessions int64 `json:"sessions"`
- Messages int64 `json:"messages"`
- AIReplies int64 `json:"ai_replies"`
- AIFailed int64 `json:"ai_failed"`
- AIFailureRatePercent float64 `json:"ai_failure_rate_percent"`
- KBHits int64 `json:"kb_hits"`
- KBHitRatePercent float64 `json:"kb_hit_rate_percent"`
- MaxAIRounds int `json:"max_ai_rounds"`
- // SessionsWithAI 区间内新建的访客会话中,至少使用过 AI(访客 AI 发言或收到 AI 回复)的会话数
- SessionsWithAI int64 `json:"sessions_with_ai"`
- AIParticipationRatePercent float64 `json:"ai_participation_rate_percent"`
- AIToHumanSessions int64 `json:"ai_to_human_sessions"`
- AIToHumanRatePercent float64 `json:"ai_to_human_rate_percent"`
- HumanToAISessions int64 `json:"human_to_ai_sessions"`
- HumanToAIRatePercent float64 `json:"human_to_ai_rate_percent"`
- // 以下为转人工率分母说明用(区间内有活动的会话中统计)
- SessionsWithAIUserMsg int64 `json:"sessions_with_ai_user_msg"`
- SessionsWithHumanUserMsg int64 `json:"sessions_with_human_user_msg"`
- }
- // AnalyticsDailyRow 单日指标(用于折线/柱状图)
- type AnalyticsDailyRow struct {
- Date string `json:"date"`
- WidgetOpens int64 `json:"widget_opens"`
- Sessions int64 `json:"sessions"`
- Messages int64 `json:"messages"`
- AIReplies int64 `json:"ai_replies"`
- }
- // AnalyticsService 数据分析报表(访客会话,不含内部知识库测试)
- type AnalyticsService struct {
- db *gorm.DB
- widgetOpens *repository.WidgetOpenRepository
- analyticsLoc *time.Location
- }
- // NewAnalyticsService 创建报表服务;loc 用于按自然日切分,默认上海时区
- func NewAnalyticsService(db *gorm.DB, widgetOpens *repository.WidgetOpenRepository) *AnalyticsService {
- loc, err := time.LoadLocation("Asia/Shanghai")
- if err != nil {
- loc = time.Local
- }
- return &AnalyticsService{db: db, widgetOpens: widgetOpens, analyticsLoc: loc}
- }
- // RecordWidgetOpen 记录一次访客打开客服小窗
- func (s *AnalyticsService) RecordWidgetOpen(visitorID uint) error {
- if visitorID == 0 {
- return fmt.Errorf("visitor_id 无效")
- }
- return s.widgetOpens.Create(&models.WidgetOpenEvent{VisitorID: visitorID})
- }
- // GetSummary 查询 [fromDate, toDate] 闭区间内的统计(按上海时区日历日)
- func (s *AnalyticsService) GetSummary(fromDate, toDate string) (*AnalyticsSummaryResponse, error) {
- start, endExclusive, err := parseInclusiveDateRange(fromDate, toDate, s.analyticsLoc)
- if err != nil {
- return nil, err
- }
- if !endExclusive.After(start) {
- return nil, fmt.Errorf("结束日期须不早于开始日期")
- }
- totals := s.computeTotals(start, endExclusive)
- daily := s.computeDailySeries(start, endExclusive)
- return &AnalyticsSummaryResponse{
- From: fromDate,
- To: toDate,
- Totals: totals,
- Daily: daily,
- Note: "访客会话统计;时区按 Asia/Shanghai 切日。知识库命中率分母为「非失败的 AI 回复数」。转人工率分母为「有过 AI 模式访客发言的会话数」。",
- }, nil
- }
- func parseInclusiveDateRange(fromStr, toStr string, loc *time.Location) (start, endExclusive time.Time, err error) {
- fromStr = strings.TrimSpace(fromStr)
- toStr = strings.TrimSpace(toStr)
- if fromStr == "" || toStr == "" {
- return time.Time{}, time.Time{}, fmt.Errorf("请提供 from 与 to,格式 YYYY-MM-DD")
- }
- d0, e1 := time.ParseInLocation("2006-01-02", fromStr, loc)
- d1, e2 := time.ParseInLocation("2006-01-02", toStr, loc)
- if e1 != nil || e2 != nil {
- return time.Time{}, time.Time{}, fmt.Errorf("日期格式应为 YYYY-MM-DD")
- }
- start = time.Date(d0.Year(), d0.Month(), d0.Day(), 0, 0, 0, 0, loc)
- endDay := time.Date(d1.Year(), d1.Month(), d1.Day(), 0, 0, 0, 0, loc)
- endExclusive = endDay.AddDate(0, 0, 1)
- return start, endExclusive, nil
- }
- func (s *AnalyticsService) computeTotals(start, endExclusive time.Time) AnalyticsTotals {
- var out AnalyticsTotals
- // 小窗打开次数
- s.db.Model(&models.WidgetOpenEvent{}).
- Where("created_at >= ? AND created_at < ?", start, endExclusive).
- Count(&out.WidgetOpens)
- // 新建访客会话(区间内创建的)
- s.db.Model(&models.Conversation{}).
- Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive).
- Count(&out.Sessions)
- // 区间内产生的消息(仅访客会话)
- s.db.Model(&models.Message{}).
- Joins("JOIN conversations ON conversations.id = messages.conversation_id").
- Where("conversations.conversation_type = ?", "visitor").
- Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
- Count(&out.Messages)
- // AI 回复:客服侧且 sender_id=0
- s.db.Model(&models.Message{}).
- Joins("JOIN conversations ON conversations.id = messages.conversation_id").
- Where("conversations.conversation_type = ?", "visitor").
- Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
- Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
- Count(&out.AIReplies)
- s.db.Model(&models.Message{}).
- Joins("JOIN conversations ON conversations.id = messages.conversation_id").
- Where("conversations.conversation_type = ?", "visitor").
- Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
- Where("messages.is_ai_generation_failed = ?", true).
- Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
- Count(&out.AIFailed)
- aiOK := out.AIReplies - out.AIFailed
- if out.AIReplies > 0 {
- out.AIFailureRatePercent = round2(float64(out.AIFailed) * 100 / float64(out.AIReplies))
- }
- s.db.Model(&models.Message{}).
- Joins("JOIN conversations ON conversations.id = messages.conversation_id").
- Where("conversations.conversation_type = ?", "visitor").
- Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
- Where("messages.is_ai_generation_failed = ?", false).
- Where("messages.sources_used LIKE ?", "%knowledge_base%").
- Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
- Count(&out.KBHits)
- if aiOK > 0 {
- out.KBHitRatePercent = round2(float64(out.KBHits) * 100 / float64(aiOK))
- }
- // 需要全量消息的会话:区间内新建或有消息活动的访客会话
- convIDs := s.visitorConversationIDsTouchingRange(start, endExclusive)
- if len(convIDs) > 0 {
- var all []models.Message
- s.db.Where("conversation_id IN ?", convIDs).
- Order("conversation_id ASC, created_at ASC").
- Find(&all)
- byConv := groupMessagesByConversation(all)
- maxRounds := 0
- seenAI := make(map[uint]struct{})
- seenHuman := make(map[uint]struct{})
- seenATH := make(map[uint]struct{})
- seenHTA := make(map[uint]struct{})
- for cid, msgs := range byConv {
- r := countAIRounds(msgs)
- if r > maxRounds {
- maxRounds = r
- }
- ath, hta, hasAIUser, hasHumanUser := detectModeTransitions(msgs)
- if hasAIUser {
- seenAI[cid] = struct{}{}
- }
- if hasHumanUser {
- seenHuman[cid] = struct{}{}
- }
- if ath {
- seenATH[cid] = struct{}{}
- }
- if hta {
- seenHTA[cid] = struct{}{}
- }
- }
- out.MaxAIRounds = maxRounds
- out.SessionsWithAIUserMsg = int64(len(seenAI))
- out.SessionsWithHumanUserMsg = int64(len(seenHuman))
- var createdInRange []uint
- s.db.Model(&models.Conversation{}).
- Select("id").
- Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive).
- Pluck("id", &createdInRange)
- var sessionsWithAI int64
- for _, cid := range createdInRange {
- if conversationUsedAI(byConv[cid]) {
- sessionsWithAI++
- }
- }
- out.SessionsWithAI = sessionsWithAI
- if out.Sessions > 0 {
- out.AIParticipationRatePercent = round2(float64(sessionsWithAI) * 100 / float64(out.Sessions))
- }
- if len(seenAI) > 0 {
- out.AIToHumanSessions = int64(len(seenATH))
- out.AIToHumanRatePercent = round2(float64(len(seenATH)) * 100 / float64(len(seenAI)))
- }
- if len(seenHuman) > 0 {
- out.HumanToAISessions = int64(len(seenHTA))
- out.HumanToAIRatePercent = round2(float64(len(seenHTA)) * 100 / float64(len(seenHuman)))
- }
- }
- return out
- }
- func (s *AnalyticsService) visitorConversationIDsTouchingRange(start, endExclusive time.Time) []uint {
- var created []uint
- s.db.Model(&models.Conversation{}).
- Select("id").
- Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive).
- Pluck("id", &created)
- var fromMessages []uint
- s.db.Model(&models.Message{}).
- Joins("JOIN conversations ON conversations.id = messages.conversation_id").
- Where("conversations.conversation_type = ?", "visitor").
- Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive).
- Pluck("messages.conversation_id", &fromMessages)
- uniq := make(map[uint]struct{})
- for _, id := range created {
- uniq[id] = struct{}{}
- }
- for _, id := range fromMessages {
- uniq[id] = struct{}{}
- }
- out := make([]uint, 0, len(uniq))
- for id := range uniq {
- out = append(out, id)
- }
- return out
- }
- func groupMessagesByConversation(msgs []models.Message) map[uint][]models.Message {
- m := make(map[uint][]models.Message)
- for _, msg := range msgs {
- m[msg.ConversationID] = append(m[msg.ConversationID], msg)
- }
- return m
- }
- // countAIRounds 同一会话内:访客在 AI 模式下一条消息 + 紧随其后的 AI 回复算一轮
- func countAIRounds(msgs []models.Message) int {
- n := 0
- for i := 0; i < len(msgs)-1; i++ {
- a, b := msgs[i], msgs[i+1]
- if !a.SenderIsAgent && a.ChatMode == "ai" && b.SenderIsAgent && b.SenderID == 0 {
- n++
- }
- }
- return n
- }
- // detectModeTransitions 仅看访客用户消息(非客服)的 chat_mode 变化
- func conversationUsedAI(msgs []models.Message) bool {
- for _, m := range msgs {
- if m.SenderIsAgent && m.SenderID == 0 {
- return true
- }
- if !m.SenderIsAgent && m.ChatMode == "ai" {
- return true
- }
- }
- return false
- }
- func detectModeTransitions(msgs []models.Message) (aiToHuman, humanToAI, hasAIUser, hasHumanUser bool) {
- var prev string
- for _, m := range msgs {
- if m.SenderIsAgent {
- continue
- }
- mode := m.ChatMode
- if mode != "ai" && mode != "human" {
- continue
- }
- if mode == "ai" {
- hasAIUser = true
- }
- if mode == "human" {
- hasHumanUser = true
- }
- if prev == "ai" && mode == "human" {
- aiToHuman = true
- }
- if prev == "human" && mode == "ai" {
- humanToAI = true
- }
- prev = mode
- }
- return
- }
- func (s *AnalyticsService) computeDailySeries(start, endExclusive time.Time) []AnalyticsDailyRow {
- var rows []AnalyticsDailyRow
- for d := start; d.Before(endExclusive); d = d.AddDate(0, 0, 1) {
- dayEnd := d.AddDate(0, 0, 1)
- dateStr := d.Format("2006-01-02")
- var w, sess, msg, ai int64
- s.db.Model(&models.WidgetOpenEvent{}).
- Where("created_at >= ? AND created_at < ?", d, dayEnd).Count(&w)
- s.db.Model(&models.Conversation{}).
- Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", d, dayEnd).Count(&sess)
- s.db.Model(&models.Message{}).
- Joins("JOIN conversations ON conversations.id = messages.conversation_id").
- Where("conversations.conversation_type = ?", "visitor").
- Where("messages.created_at >= ? AND messages.created_at < ?", d, dayEnd).
- Count(&msg)
- s.db.Model(&models.Message{}).
- Joins("JOIN conversations ON conversations.id = messages.conversation_id").
- Where("conversations.conversation_type = ?", "visitor").
- Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0).
- Where("messages.created_at >= ? AND messages.created_at < ?", d, dayEnd).
- Count(&ai)
- rows = append(rows, AnalyticsDailyRow{
- Date: dateStr,
- WidgetOpens: w,
- Sessions: sess,
- Messages: msg,
- AIReplies: ai,
- })
- }
- return rows
- }
- func round2(x float64) float64 {
- return float64(int64(x*100+0.5)) / 100
- }
|