liuchengsen před 3 týdny
rodič
revize
83d2651abd

+ 13 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/AnalyticsController.java

@@ -115,6 +115,19 @@ public class AnalyticsController {
                 analyticsService.queryDimension(dimension, system, startDate, endDate, page, pageSize)));
     }
 
+    /** 浏览每次 AI 问答的耗时(按 request_id 关联前端 chat_complete 与后端 chat_stream_server 分段) */
+    @GetMapping("/qa-sessions")
+    public ResponseEntity<ApiResponse<Map<String, Object>>> queryQaSessions(
+            @RequestParam(required = false) String system,
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate,
+            @RequestParam(required = false) Long minTotalMs,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return ResponseEntity.ok(ApiResponse.success(
+                analyticsService.queryQaSessions(system, startDate, endDate, page, pageSize, minTotalMs)));
+    }
+
     private String getCurrentUserKey() {
         var auth = org.springframework.security.core.context.SecurityContextHolder
                 .getContext().getAuthentication();

+ 76 - 1
backend-java/src/main/java/com/pharmacopoeia/service/AnalyticsService.java

@@ -9,6 +9,7 @@ import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.domain.Specification;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
@@ -28,9 +29,11 @@ public class AnalyticsService {
     private static final Instant FAR_FUTURE = Instant.parse("2099-12-31T23:59:59Z");
 
     private final UserEventRepository repo;
+    private final JdbcTemplate jdbc;
 
-    public AnalyticsService(UserEventRepository repo) {
+    public AnalyticsService(UserEventRepository repo, JdbcTemplate jdbc) {
         this.repo = repo;
+        this.jdbc = jdbc;
     }
 
     /** 默认来源系统:当前业务系统 */
@@ -307,6 +310,78 @@ public class AnalyticsService {
         return systems;
     }
 
+    // ==================== 问答会话(按 request_id 汇总耗时) ====================
+
+    /**
+     * 浏览每次 AI 问答的耗时:以 chat_complete 事件为主,LEFT JOIN chat(取 query)和
+     * chat_stream_server(取服务端分段),按 request_id 关联,时间倒序分页。
+     * systemSource: null/空 → AI药典;"all" → 跨系统;其它 → 按系统过滤。
+     * minTotalMs: 只看耗时 ≥ 此值的(找慢请求),null/0 不过滤。
+     */
+    public Map<String, Object> queryQaSessions(String systemSource, String startDate, String endDate,
+                                               int page, int pageSize, Long minTotalMs) {
+        Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
+        Instant endTime = endDate != null && !endDate.isBlank()
+                ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
+                : FAR_FUTURE;
+        String systemFilter = normalizeSystemFilter(systemSource);
+
+        StringBuilder where = new StringBuilder(
+                "WHERE c.event_type = 'chat_complete' AND c.created_at >= ? AND c.created_at < ?");
+        List<Object> params = new ArrayList<>();
+        params.add(startTime);
+        params.add(endTime);
+        if (systemFilter != null) {
+            where.append(" AND c.system_source = ?");
+            params.add(systemFilter);
+        }
+        if (minTotalMs != null && minTotalMs > 0) {
+            where.append(" AND (c.event_data->>'total_ms')::bigint >= ?");
+            params.add(minTotalMs);
+        }
+
+        Long total = jdbc.queryForObject(
+                "SELECT COUNT(*) FROM user_events c " + where, Long.class, params.toArray());
+
+        int offset = (page - 1) * pageSize;
+        List<Object> dataParams = new ArrayList<>(params);
+        dataParams.add(pageSize);
+        dataParams.add(offset);
+        String dataSql = "SELECT c.id, c.created_at, c.user_key, c.ip, "
+                + "c.event_data->>'request_id' AS request_id, "
+                + "q.event_data->>'query' AS query, "
+                + "c.event_data->>'intent' AS intent, "
+                + "c.event_data->>'status' AS status, "
+                + "(c.event_data->>'total_ms')::bigint AS total_ms, "
+                + "(c.event_data->>'ttft_ms')::bigint AS ttft_ms, "
+                + "(c.event_data->>'msg_len')::int AS msg_len, "
+                + "(c.event_data->>'has_media')::boolean AS has_media, "
+                + "c.event_data->>'conversation_id' AS conversation_id, "
+                + "s.event_data->>'endpoint' AS endpoint, "
+                + "(s.event_data->>'total_ms')::bigint AS server_total_ms, "
+                + "(s.event_data->>'search_rerank_ms')::bigint AS search_rerank_ms, "
+                + "(s.event_data->>'prompt_ms')::bigint AS prompt_ms, "
+                + "(s.event_data->>'llm_ms')::bigint AS llm_ms "
+                + "FROM user_events c "
+                + "LEFT JOIN user_events q ON q.event_type = 'chat' "
+                + "  AND q.event_data->>'request_id' = c.event_data->>'request_id' "
+                + "LEFT JOIN user_events s ON s.event_type = 'chat_stream_server' "
+                + "  AND s.event_data->>'request_id' = c.event_data->>'request_id' "
+                + where
+                + " ORDER BY c.created_at DESC LIMIT ? OFFSET ?";
+
+        List<Map<String, Object>> items = jdbc.queryForList(dataSql, dataParams.toArray());
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("dimension", "qa_sessions");
+        response.put("system", systemFilter != null ? systemFilter : "all");
+        response.put("items", items);
+        response.put("total", total);
+        response.put("page", page);
+        response.put("page_size", pageSize);
+        return response;
+    }
+
     // ==================== Helpers ====================
 
     private Specification<UserEvent> buildTimeSpec(String eventType, String systemSource,