hydro-ai-helper 1.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/.eslintrc.json +31 -0
  2. package/.github/workflows/sync-releases-to-gitee.yml +119 -0
  3. package/.github/workflows/sync-to-gitee.yml +53 -0
  4. package/.vercelignore +31 -0
  5. package/CONTRIBUTING.md +165 -0
  6. package/README.md +273 -0
  7. package/TROUBLESHOOTING.md +355 -0
  8. package/api/badge-active.ts +88 -0
  9. package/api/badge-installs.ts +75 -0
  10. package/api/report.ts +242 -0
  11. package/assets/screenshots/1.png +0 -0
  12. package/assets/screenshots/2.png +0 -0
  13. package/assets/screenshots/3.png +0 -0
  14. package/assets/screenshots/4.png +0 -0
  15. package/assets/screenshots/5.png +0 -0
  16. package/assets/screenshots/6.png +0 -0
  17. package/assets/screenshots/7.png +0 -0
  18. package/dist/__tests__/__mocks__/hydrooj.js +29 -0
  19. package/dist/__tests__/__mocks__/hydrooj.js.map +1 -0
  20. package/dist/constants/jailbreakRules.js +29 -0
  21. package/dist/constants/jailbreakRules.js.map +1 -0
  22. package/dist/constants/permissions.js +42 -0
  23. package/dist/constants/permissions.js.map +1 -0
  24. package/dist/handlers/adminConfigHandler.js +347 -0
  25. package/dist/handlers/adminConfigHandler.js.map +1 -0
  26. package/dist/handlers/adminHandler.js +378 -0
  27. package/dist/handlers/adminHandler.js.map +1 -0
  28. package/dist/handlers/analyticsHandler.js +433 -0
  29. package/dist/handlers/analyticsHandler.js.map +1 -0
  30. package/dist/handlers/dashboardHandler.js +22 -0
  31. package/dist/handlers/dashboardHandler.js.map +1 -0
  32. package/dist/handlers/exportHandler.js +118 -0
  33. package/dist/handlers/exportHandler.js.map +1 -0
  34. package/dist/handlers/studentHandler.js +430 -0
  35. package/dist/handlers/studentHandler.js.map +1 -0
  36. package/dist/handlers/teacherHandler.js +246 -0
  37. package/dist/handlers/teacherHandler.js.map +1 -0
  38. package/dist/handlers/testHandler.js +27 -0
  39. package/dist/handlers/testHandler.js.map +1 -0
  40. package/dist/handlers/updateHandler.js +84 -0
  41. package/dist/handlers/updateHandler.js.map +1 -0
  42. package/dist/handlers/versionHandler.js +61 -0
  43. package/dist/handlers/versionHandler.js.map +1 -0
  44. package/dist/index.d.ts +22 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +151 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/lib/crypto.js +110 -0
  49. package/dist/lib/crypto.js.map +1 -0
  50. package/dist/lib/httpHelpers.js +61 -0
  51. package/dist/lib/httpHelpers.js.map +1 -0
  52. package/dist/lib/queryHelpers.js +81 -0
  53. package/dist/lib/queryHelpers.js.map +1 -0
  54. package/dist/models/aiConfig.js +255 -0
  55. package/dist/models/aiConfig.js.map +1 -0
  56. package/dist/models/conversation.js +209 -0
  57. package/dist/models/conversation.js.map +1 -0
  58. package/dist/models/jailbreakLog.js +68 -0
  59. package/dist/models/jailbreakLog.js.map +1 -0
  60. package/dist/models/message.js +174 -0
  61. package/dist/models/message.js.map +1 -0
  62. package/dist/models/pluginInstall.js +95 -0
  63. package/dist/models/pluginInstall.js.map +1 -0
  64. package/dist/models/rateLimitRecord.js +54 -0
  65. package/dist/models/rateLimitRecord.js.map +1 -0
  66. package/dist/models/versionCache.js +89 -0
  67. package/dist/models/versionCache.js.map +1 -0
  68. package/dist/services/effectivenessService.js +131 -0
  69. package/dist/services/effectivenessService.js.map +1 -0
  70. package/dist/services/exportService.js +152 -0
  71. package/dist/services/exportService.js.map +1 -0
  72. package/dist/services/migrationService.js +90 -0
  73. package/dist/services/migrationService.js.map +1 -0
  74. package/dist/services/openaiClient.js +412 -0
  75. package/dist/services/openaiClient.js.map +1 -0
  76. package/dist/services/promptService.js +467 -0
  77. package/dist/services/promptService.js.map +1 -0
  78. package/dist/services/rateLimitService.js +108 -0
  79. package/dist/services/rateLimitService.js.map +1 -0
  80. package/dist/services/telemetryService.js +173 -0
  81. package/dist/services/telemetryService.js.map +1 -0
  82. package/dist/services/updateService.js +388 -0
  83. package/dist/services/updateService.js.map +1 -0
  84. package/dist/services/versionService.js +289 -0
  85. package/dist/services/versionService.js.map +1 -0
  86. package/dist/types/hydrooj.js +7 -0
  87. package/dist/types/hydrooj.js.map +1 -0
  88. package/dist/types/mongo.js +7 -0
  89. package/dist/types/mongo.js.map +1 -0
  90. package/dist/utils/domainHelper.js +16 -0
  91. package/dist/utils/domainHelper.js.map +1 -0
  92. package/dist/utils/mongo.js +16 -0
  93. package/dist/utils/mongo.js.map +1 -0
  94. package/frontend/admin/ConfigPanel.tsx +1379 -0
  95. package/frontend/admin/VersionBadge.tsx +655 -0
  96. package/frontend/ai_helper.page.tsx +52 -0
  97. package/frontend/ai_helper_admin_config.page.tsx +47 -0
  98. package/frontend/ai_helper_analytics.page.tsx +52 -0
  99. package/frontend/ai_helper_conversation_detail.page.tsx +55 -0
  100. package/frontend/ai_helper_conversations.page.tsx +56 -0
  101. package/frontend/components/AIHelperDashboard.tsx +125 -0
  102. package/frontend/problem_detail.page.tsx +395 -0
  103. package/frontend/student/AIAssistantPanel.tsx +1839 -0
  104. package/frontend/student/AIChatPanel.tsx +944 -0
  105. package/frontend/student/ThreeColumnLayout.tsx +240 -0
  106. package/frontend/teacher/AnalyticsPage.tsx +614 -0
  107. package/frontend/teacher/ConversationDetail.tsx +504 -0
  108. package/frontend/teacher/ConversationList.tsx +568 -0
  109. package/frontend/teacher/ExportDialog.tsx +291 -0
  110. package/frontend/test.page.ts +88 -0
  111. package/frontend/utils/domainUtils.ts +37 -0
  112. package/index.js +6 -0
  113. package/jest.config.js +17 -0
  114. package/lib/mongodb.ts +60 -0
  115. package/locales/en.yaml +4 -0
  116. package/locales/zh.yaml +77 -0
  117. package/package.json +48 -0
  118. package/public/.gitkeep +0 -0
  119. package/public/README.md +1 -0
  120. package/src/__tests__/__mocks__/hydrooj.ts +27 -0
  121. package/src/__tests__/services/exportService.test.ts +156 -0
  122. package/src/__tests__/services/promptService.test.ts +141 -0
  123. package/src/__tests__/services/rateLimitService.test.ts +145 -0
  124. package/src/__tests__/utils/domainHelper.test.ts +47 -0
  125. package/src/constants/jailbreakRules.ts +28 -0
  126. package/src/constants/permissions.ts +43 -0
  127. package/src/handlers/adminConfigHandler.ts +363 -0
  128. package/src/handlers/adminHandler.ts +427 -0
  129. package/src/handlers/analyticsHandler.ts +517 -0
  130. package/src/handlers/dashboardHandler.ts +20 -0
  131. package/src/handlers/exportHandler.ts +134 -0
  132. package/src/handlers/studentHandler.ts +483 -0
  133. package/src/handlers/teacherHandler.ts +317 -0
  134. package/src/handlers/testHandler.ts +25 -0
  135. package/src/handlers/updateHandler.ts +85 -0
  136. package/src/handlers/versionHandler.ts +62 -0
  137. package/src/index.ts +198 -0
  138. package/src/lib/crypto.ts +125 -0
  139. package/src/lib/httpHelpers.ts +75 -0
  140. package/src/lib/queryHelpers.ts +108 -0
  141. package/src/models/aiConfig.ts +375 -0
  142. package/src/models/conversation.ts +328 -0
  143. package/src/models/jailbreakLog.ts +108 -0
  144. package/src/models/message.ts +246 -0
  145. package/src/models/pluginInstall.ts +138 -0
  146. package/src/models/rateLimitRecord.ts +81 -0
  147. package/src/models/versionCache.ts +127 -0
  148. package/src/services/effectivenessService.ts +161 -0
  149. package/src/services/exportService.ts +191 -0
  150. package/src/services/migrationService.ts +111 -0
  151. package/src/services/openaiClient.ts +548 -0
  152. package/src/services/promptService.ts +573 -0
  153. package/src/services/rateLimitService.ts +122 -0
  154. package/src/services/telemetryService.ts +218 -0
  155. package/src/services/updateService.ts +441 -0
  156. package/src/services/versionService.ts +351 -0
  157. package/src/types/hydrooj-extensions.d.ts +55 -0
  158. package/src/types/hydrooj.ts +38 -0
  159. package/src/types/mongo.ts +23 -0
  160. package/src/utils/domainHelper.ts +16 -0
  161. package/src/utils/mongo.ts +15 -0
  162. package/templates/ai-helper/admin_config.html +8 -0
  163. package/templates/ai-helper/analytics.html +8 -0
  164. package/templates/ai-helper/dashboard.html +8 -0
  165. package/templates/ai-helper/teacher_conversation_detail.html +8 -0
  166. package/templates/ai-helper/teacher_conversations.html +8 -0
  167. package/tests/integration/README.md +86 -0
  168. package/tests/integration/curl/01-admin-config-set.sh +56 -0
  169. package/tests/integration/curl/02-student-chat.sh +67 -0
  170. package/tests/integration/curl/03-teacher-list.sh +52 -0
  171. package/tests/integration/curl/04-teacher-detail.sh +37 -0
  172. package/tests/integration/curl/05-export.sh +49 -0
  173. package/tests/integration/curl/06-analytics.sh +43 -0
  174. package/tests/performance/README.md +85 -0
  175. package/tests/performance/data/prepare-sample-data.md +57 -0
  176. package/tests/performance/scripts/export-csv-k6.js +41 -0
  177. package/tests/performance/scripts/student-chat-k6.js +44 -0
  178. package/tests/performance/scripts/teacher-conversations-k6.js +33 -0
  179. package/tsconfig.json +42 -0
  180. package/tsconfig.vercel.json +27 -0
  181. package/types/hydrooj/index.d.ts +73 -0
  182. package/vercel.json +3 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * PluginInstall Model - 插件安装与遥测数据模型
3
+ *
4
+ * 用于记录插件安装信息和遥测配置
5
+ */
6
+
7
+ import type { Db, Collection } from 'mongodb';
8
+ import { randomUUID } from 'crypto';
9
+
10
+ /**
11
+ * 插件安装记录接口
12
+ */
13
+ export interface PluginInstall {
14
+ _id: string; // 固定为 'install'
15
+ instanceId: string; // 随机 UUID,用于去重
16
+ installedAt: Date; // 首次启动时间
17
+ firstUsedAt?: Date; // 首次 AI 对话时间
18
+ lastUsedAt?: Date; // 最近使用时间
19
+ installedVersion: string; // 安装时的版本号
20
+ lastVersion: string; // 当前运行版本
21
+ domainsSeen: string[]; // 见过的 domainId 列表
22
+ telemetryEnabled: boolean;// 是否允许远程上报(默认 true)
23
+ lastReportAt?: Date; // 最后一次上报时间
24
+ }
25
+
26
+ /**
27
+ * PluginInstall Model 操作类
28
+ * 封装插件安装记录的 CRUD 操作
29
+ */
30
+ export class PluginInstallModel {
31
+ private collection: Collection<PluginInstall>;
32
+ private readonly FIXED_ID = 'install'; // 固定记录 ID
33
+
34
+ constructor(db: Db) {
35
+ this.collection = db.collection<PluginInstall>('ai_plugin_install');
36
+ }
37
+
38
+ /**
39
+ * 确保索引已创建
40
+ * (单条记录无需复杂索引,仅用于一致性)
41
+ */
42
+ async ensureIndexes(): Promise<void> {
43
+ console.log('[PluginInstallModel] Collection initialized');
44
+ }
45
+
46
+ /**
47
+ * 获取安装记录
48
+ * @returns 安装记录或 null
49
+ */
50
+ async getInstall(): Promise<PluginInstall | null> {
51
+ return this.collection.findOne({ _id: this.FIXED_ID });
52
+ }
53
+
54
+ /**
55
+ * 创建安装记录(如果不存在)
56
+ * @param version 当前版本号
57
+ */
58
+ async createIfMissing(version: string): Promise<void> {
59
+ const existing = await this.getInstall();
60
+
61
+ if (!existing) {
62
+ const now = new Date();
63
+ await this.collection.insertOne({
64
+ _id: this.FIXED_ID,
65
+ instanceId: randomUUID(),
66
+ installedAt: now,
67
+ installedVersion: version,
68
+ lastVersion: version,
69
+ domainsSeen: [],
70
+ telemetryEnabled: true
71
+ } as PluginInstall);
72
+
73
+ console.log('[PluginInstallModel] Install record created');
74
+ } else {
75
+ // 更新版本号
76
+ await this.collection.updateOne(
77
+ { _id: this.FIXED_ID },
78
+ { $set: { lastVersion: version } }
79
+ );
80
+ console.log('[PluginInstallModel] Install record already exists, version updated');
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 标记首次使用时间
86
+ */
87
+ async markFirstUse(): Promise<void> {
88
+ const existing = await this.getInstall();
89
+ if (existing && !existing.firstUsedAt) {
90
+ await this.collection.updateOne(
91
+ { _id: this.FIXED_ID },
92
+ { $set: { firstUsedAt: new Date() } }
93
+ );
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 更新最近使用时间
99
+ */
100
+ async markLastUse(): Promise<void> {
101
+ await this.collection.updateOne(
102
+ { _id: this.FIXED_ID },
103
+ { $set: { lastUsedAt: new Date() } }
104
+ );
105
+ }
106
+
107
+ /**
108
+ * 添加 domainId(如果不存在)
109
+ * @param domainId 域 ID
110
+ */
111
+ async addDomain(domainId: string): Promise<void> {
112
+ await this.collection.updateOne(
113
+ { _id: this.FIXED_ID },
114
+ { $addToSet: { domainsSeen: domainId } }
115
+ );
116
+ }
117
+
118
+ /**
119
+ * 更新最后上报时间
120
+ */
121
+ async updateLastReportTime(): Promise<void> {
122
+ await this.collection.updateOne(
123
+ { _id: this.FIXED_ID },
124
+ { $set: { lastReportAt: new Date() } }
125
+ );
126
+ }
127
+
128
+ /**
129
+ * 更新遥测开关
130
+ * @param enabled 是否启用
131
+ */
132
+ async updateTelemetryEnabled(enabled: boolean): Promise<void> {
133
+ await this.collection.updateOne(
134
+ { _id: this.FIXED_ID },
135
+ { $set: { telemetryEnabled: enabled } }
136
+ );
137
+ }
138
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * RateLimitRecord Model - 频率限制记录数据模型
3
+ *
4
+ * 用于记录每个用户每分钟的请求次数,并通过 TTL 自动清理
5
+ */
6
+
7
+ import type { Db, Collection } from 'mongodb';
8
+ import { type ObjectIdType } from '../utils/mongo';
9
+
10
+ /**
11
+ * 频率限制记录接口
12
+ */
13
+ export interface RateLimitRecord {
14
+ _id?: ObjectIdType; // MongoDB ObjectId
15
+ domainId: string; // 域 ID (用于多租户隔离)
16
+ userId: number; // 用户 ID(与 HydroOJ 用户系统保持一致)
17
+ minuteKey: string; // 形如 "2025-11-18T09:32" 的分钟粒度时间键
18
+ count: number; // 当前分钟内已请求次数
19
+ expireAt: Date; // TTL 过期时间(通常为当前分钟结束后略多一些)
20
+ }
21
+
22
+ /**
23
+ * RateLimitRecord Model 操作类
24
+ * 封装频率限制记录的操作
25
+ */
26
+ export class RateLimitRecordModel {
27
+ private collection: Collection<RateLimitRecord>;
28
+
29
+ constructor(db: Db) {
30
+ this.collection = db.collection<RateLimitRecord>('ai_rate_limit_records');
31
+ }
32
+
33
+ /**
34
+ * 确保索引已创建
35
+ * 创建 TTL 索引,自动清理过期记录
36
+ */
37
+ async ensureIndexes(): Promise<void> {
38
+ // 创建 TTL 索引:expireAt 字段,过期后自动删除
39
+ await this.collection.createIndex(
40
+ { expireAt: 1 },
41
+ {
42
+ name: 'idx_expireAt_ttl',
43
+ expireAfterSeconds: 0 // 到达 expireAt 时间后立即删除
44
+ }
45
+ );
46
+
47
+ // 创建复合唯一索引:domainId + userId + minuteKey(确保每个域内每个用户每分钟只有一条记录)
48
+ await this.collection.createIndex(
49
+ { domainId: 1, userId: 1, minuteKey: 1 },
50
+ {
51
+ name: 'idx_domainId_userId_minuteKey',
52
+ unique: true
53
+ }
54
+ );
55
+
56
+ // 创建索引: 域 ID (用于域隔离查询)
57
+ await this.collection.createIndex(
58
+ { domainId: 1 },
59
+ { name: 'idx_domainId' }
60
+ );
61
+
62
+ console.log('[RateLimitRecordModel] Indexes created successfully');
63
+ }
64
+
65
+ /**
66
+ * 获取集合对象(供 RateLimitService 使用)
67
+ * @returns MongoDB Collection
68
+ */
69
+ getCollection(): Collection<RateLimitRecord> {
70
+ return this.collection;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 工厂函数:获取频率限制记录集合
76
+ * @param db MongoDB Database 对象
77
+ * @returns Collection<RateLimitRecord>
78
+ */
79
+ export function getRateLimitCollection(db: Db): Collection<RateLimitRecord> {
80
+ return db.collection<RateLimitRecord>('ai_rate_limit_records');
81
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * VersionCache Model - 版本缓存数据模型
3
+ *
4
+ * 用于缓存从 Gitee 获取的最新版本信息,避免频繁请求外部 API
5
+ * 缓存有效期为 24 小时
6
+ */
7
+
8
+ import type { Db, Collection } from 'mongodb';
9
+ import { type ObjectIdType } from '../utils/mongo';
10
+
11
+ /**
12
+ * 版本缓存记录接口
13
+ */
14
+ export interface VersionCache {
15
+ _id?: ObjectIdType; // MongoDB ObjectId
16
+ key: string; // 缓存键(如 'latest_version')
17
+ currentVersion: string; // 当前安装版本
18
+ latestVersion: string; // 远程最新版本
19
+ releaseUrl?: string; // 发布页面 URL
20
+ releaseNotes?: string; // 版本更新说明
21
+ hasUpdate: boolean; // 是否有可用更新
22
+ checkedAt: Date; // 检查时间
23
+ expireAt: Date; // 缓存过期时间(用于 TTL)
24
+ }
25
+
26
+ /**
27
+ * 缓存有效期:24 小时(毫秒)
28
+ */
29
+ export const VERSION_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
30
+
31
+ /**
32
+ * VersionCache Model 操作类
33
+ * 封装版本缓存的 CRUD 操作
34
+ */
35
+ export class VersionCacheModel {
36
+ private collection: Collection<VersionCache>;
37
+
38
+ constructor(db: Db) {
39
+ this.collection = db.collection<VersionCache>('ai_version_cache');
40
+ }
41
+
42
+ /**
43
+ * 确保索引已创建
44
+ * 创建 TTL 索引,自动清理过期记录
45
+ */
46
+ async ensureIndexes(): Promise<void> {
47
+ // 创建 TTL 索引:expireAt 字段,过期后自动删除
48
+ await this.collection.createIndex(
49
+ { expireAt: 1 },
50
+ {
51
+ name: 'idx_expireAt_ttl',
52
+ expireAfterSeconds: 0 // 到达 expireAt 时间后立即删除
53
+ }
54
+ );
55
+
56
+ // 创建唯一索引:key 字段
57
+ await this.collection.createIndex(
58
+ { key: 1 },
59
+ {
60
+ name: 'idx_key',
61
+ unique: true
62
+ }
63
+ );
64
+
65
+ console.log('[VersionCacheModel] Indexes created successfully');
66
+ }
67
+
68
+ /**
69
+ * 获取缓存的版本信息
70
+ * @param key 缓存键
71
+ * @returns 版本缓存记录或 null(不存在或已过期)
72
+ */
73
+ async get(key: string): Promise<VersionCache | null> {
74
+ const cache = await this.collection.findOne({ key });
75
+
76
+ // 如果缓存存在但已过期,返回 null
77
+ if (cache && cache.expireAt < new Date()) {
78
+ return null;
79
+ }
80
+
81
+ return cache;
82
+ }
83
+
84
+ /**
85
+ * 设置版本缓存
86
+ * @param data 版本缓存数据
87
+ */
88
+ async set(data: Omit<VersionCache, '_id' | 'expireAt'>): Promise<void> {
89
+ const now = new Date();
90
+ const expireAt = new Date(now.getTime() + VERSION_CACHE_TTL_MS);
91
+
92
+ await this.collection.updateOne(
93
+ { key: data.key },
94
+ {
95
+ $set: {
96
+ ...data,
97
+ checkedAt: now,
98
+ expireAt
99
+ }
100
+ },
101
+ { upsert: true }
102
+ );
103
+ }
104
+
105
+ /**
106
+ * 删除指定缓存
107
+ * @param key 缓存键
108
+ */
109
+ async delete(key: string): Promise<void> {
110
+ await this.collection.deleteOne({ key });
111
+ }
112
+
113
+ /**
114
+ * 清除所有缓存
115
+ */
116
+ async clear(): Promise<void> {
117
+ await this.collection.deleteMany({});
118
+ }
119
+
120
+ /**
121
+ * 获取集合对象
122
+ * @returns MongoDB Collection
123
+ */
124
+ getCollection(): Collection<VersionCache> {
125
+ return this.collection;
126
+ }
127
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * EffectivenessService - 有效对话判定服务
3
+ *
4
+ * 根据规则判定会话是否为「有效对话」,并更新数据库
5
+ */
6
+
7
+ import type { Context } from 'hydrooj';
8
+ import { ObjectId, type ObjectIdType } from '../utils/mongo';
9
+ import type { MessageModel } from '../models/message';
10
+ import type { ConversationModel } from '../models/conversation';
11
+ import type { JailbreakLogModel } from '../models/jailbreakLog';
12
+ import type { QuestionType } from './promptService';
13
+
14
+ /**
15
+ * 有效对话判定规则
16
+ */
17
+ const EFFECTIVENESS_RULES = {
18
+ // 最小消息数量要求
19
+ MIN_STUDENT_MESSAGES: 2,
20
+ MIN_AI_MESSAGES: 2,
21
+
22
+ // 学生消息平均长度要求(字符数)
23
+ MIN_STUDENT_AVG_LENGTH: 20,
24
+
25
+ // 学习相关关键词(至少匹配一个)
26
+ LEARNING_KEYWORDS: [
27
+ '理解', '思路', '算法', '复杂度', '优化', '错误', '调试'
28
+ ]
29
+ };
30
+
31
+ /**
32
+ * EffectivenessService 类
33
+ * 负责分析会话并判定是否为有效对话
34
+ */
35
+ export class EffectivenessService {
36
+ private ctx: Context;
37
+
38
+ constructor(ctx: Context) {
39
+ this.ctx = ctx;
40
+ }
41
+
42
+ /**
43
+ * 分析会话并判定是否为有效对话
44
+ *
45
+ * @param conversationId 会话 ID(字符串或 ObjectId)
46
+ * @returns boolean - 是否为有效对话
47
+ */
48
+ async analyzeConversation(conversationId: string | ObjectIdType): Promise<boolean> {
49
+ try {
50
+ // 获取模型实例
51
+ const messageModel: MessageModel = this.ctx.get('messageModel');
52
+ const conversationModel: ConversationModel = this.ctx.get('conversationModel');
53
+
54
+ // 转换为 ObjectId
55
+ const convObjectId = typeof conversationId === 'string'
56
+ ? new ObjectId(conversationId)
57
+ : conversationId;
58
+
59
+ // 从数据库读取所有消息(按时间升序)
60
+ const messages = await messageModel.findByConversationId(convObjectId);
61
+
62
+ if (!messages.length) {
63
+ // 没有消息,标记为无效
64
+ await conversationModel.updateEffectiveness(convObjectId, false);
65
+ return false;
66
+ }
67
+
68
+ // 1. 按 role 划分学生/AI 消息
69
+ const studentMessages = messages.filter(msg => msg.role === 'student');
70
+ const aiMessages = messages.filter(msg => msg.role === 'ai');
71
+
72
+ // 2. 检查消息数量要求
73
+ const hasEnoughStudentMessages = studentMessages.length >= EFFECTIVENESS_RULES.MIN_STUDENT_MESSAGES;
74
+ const hasEnoughAiMessages = aiMessages.length >= EFFECTIVENESS_RULES.MIN_AI_MESSAGES;
75
+
76
+ if (!hasEnoughStudentMessages || !hasEnoughAiMessages) {
77
+ // 消息数量不足,标记为无效
78
+ await conversationModel.updateEffectiveness(convObjectId, false);
79
+ return false;
80
+ }
81
+
82
+ // 3. 计算学生消息平均长度
83
+ const totalStudentLength = studentMessages.reduce((sum, msg) => sum + msg.content.length, 0);
84
+ const avgStudentLength = totalStudentLength / studentMessages.length;
85
+
86
+ if (avgStudentLength <= EFFECTIVENESS_RULES.MIN_STUDENT_AVG_LENGTH) {
87
+ // 平均长度不足,标记为无效
88
+ await conversationModel.updateEffectiveness(convObjectId, false);
89
+ return false;
90
+ }
91
+
92
+ // 4. 检查关键词命中情况
93
+ const hasLearningKeyword = studentMessages.some(msg => {
94
+ const content = msg.content;
95
+ return EFFECTIVENESS_RULES.LEARNING_KEYWORDS.some(keyword => content.includes(keyword));
96
+ });
97
+
98
+ if (!hasLearningKeyword) {
99
+ // 未命中任何学习关键词,标记为无效
100
+ await conversationModel.updateEffectiveness(convObjectId, false);
101
+ return false;
102
+ }
103
+
104
+ // 所有条件都满足,标记为有效
105
+ await conversationModel.updateEffectiveness(convObjectId, true);
106
+ return true;
107
+
108
+ } catch (err) {
109
+ // 记录错误日志,但不抛出异常(保守处理为 false)
110
+ this.ctx.logger.error('EffectivenessService analyzeConversation error', err);
111
+
112
+ // 出现错误时,尝试将会话标记为无效(如果可能)
113
+ try {
114
+ const conversationModel: ConversationModel = this.ctx.get('conversationModel');
115
+ const convObjectId = typeof conversationId === 'string'
116
+ ? new ObjectId(conversationId)
117
+ : conversationId;
118
+ await conversationModel.updateEffectiveness(convObjectId, false);
119
+ } catch (updateErr) {
120
+ this.ctx.logger.error('EffectivenessService updateEffectiveness error', updateErr);
121
+ }
122
+
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 记录越狱尝试日志,供管理员分析
129
+ */
130
+ async logJailbreakAttempt(payload: {
131
+ userId?: number;
132
+ problemId?: string;
133
+ conversationId?: string | ObjectIdType;
134
+ questionType?: QuestionType;
135
+ matchedPattern: string;
136
+ matchedText: string;
137
+ createdAt?: Date;
138
+ }): Promise<void> {
139
+ try {
140
+ const jailbreakLogModel: JailbreakLogModel = this.ctx.get('jailbreakLogModel');
141
+ const formattedConversationId =
142
+ payload.conversationId === undefined
143
+ ? undefined
144
+ : typeof payload.conversationId === 'string'
145
+ ? new ObjectId(payload.conversationId)
146
+ : payload.conversationId;
147
+
148
+ await jailbreakLogModel.create({
149
+ userId: payload.userId,
150
+ problemId: payload.problemId,
151
+ conversationId: formattedConversationId,
152
+ questionType: payload.questionType,
153
+ matchedPattern: payload.matchedPattern,
154
+ matchedText: payload.matchedText,
155
+ createdAt: payload.createdAt ?? new Date()
156
+ });
157
+ } catch (err) {
158
+ this.ctx.logger.error('EffectivenessService logJailbreakAttempt error', err);
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Export Service - 数据导出服务
3
+ *
4
+ * 支持按筛选条件导出会话数据为 CSV 格式
5
+ */
6
+
7
+ import type { Context } from 'hydrooj';
8
+ import type { Collection, Filter } from 'mongodb';
9
+ import { ConversationModel, Conversation } from '../models/conversation';
10
+
11
+ /**
12
+ * 导出筛选条件接口
13
+ */
14
+ export interface ConversationExportFilters {
15
+ domainId?: string; // 域 ID (用于域隔离)
16
+ startDate?: Date; // 开始日期 (会话 startTime >= startDate)
17
+ endDate?: Date; // 结束日期 (会话 startTime <= endDate)
18
+ classId?: string; // 班级 ID
19
+ problemId?: string; // 题目 ID
20
+ userId?: string; // 学生用户 ID (字符串类型,用于传递)
21
+ }
22
+
23
+ /**
24
+ * 导出选项接口
25
+ */
26
+ export interface ConversationExportOptions {
27
+ includeSensitive?: boolean; // 是否包含敏感信息(真实 userId),默认 false
28
+ }
29
+
30
+ /**
31
+ * ExportService 类
32
+ * 负责导出会话级数据为 CSV 字符串
33
+ */
34
+ export class ExportService {
35
+ private conversationModel: ConversationModel;
36
+
37
+ constructor(private ctx: Context) {
38
+ this.conversationModel = ctx.get('conversationModel');
39
+ }
40
+
41
+ /**
42
+ * 导出会话列表为 CSV 字符串
43
+ *
44
+ * @param filters 筛选条件
45
+ * @param options 导出选项
46
+ * @returns CSV 字符串
47
+ */
48
+ async exportConversations(
49
+ filters: ConversationExportFilters,
50
+ options: ConversationExportOptions = {}
51
+ ): Promise<string> {
52
+ // 1. 构造查询条件
53
+ const query: Filter<Conversation> = {};
54
+ const projection: Partial<Record<keyof Conversation, 1 | 0>> = {
55
+ _id: 1,
56
+ userId: 1,
57
+ classId: 1,
58
+ problemId: 1,
59
+ startTime: 1,
60
+ endTime: 1,
61
+ messageCount: 1,
62
+ isEffective: 1,
63
+ teacherNote: 1,
64
+ tags: 1,
65
+ metadata: 1
66
+ };
67
+
68
+ // 域隔离:只导出当前域的数据
69
+ if (filters.domainId) {
70
+ query.domainId = filters.domainId;
71
+ }
72
+
73
+ // 时间范围筛选
74
+ if (filters.startDate || filters.endDate) {
75
+ query.startTime = {};
76
+ if (filters.startDate) {
77
+ query.startTime.$gte = filters.startDate;
78
+ }
79
+ if (filters.endDate) {
80
+ query.startTime.$lte = filters.endDate;
81
+ }
82
+ }
83
+
84
+ // 班级/题目/用户筛选
85
+ if (filters.classId) {
86
+ query.classId = filters.classId;
87
+ }
88
+ if (filters.problemId) {
89
+ query.problemId = filters.problemId;
90
+ }
91
+ if (filters.userId) {
92
+ query.userId = parseInt(filters.userId, 10); // 转换为 number
93
+ }
94
+
95
+ // 2. 查询所有符合条件的会话记录(按开始时间升序排序)
96
+ const collection = this.ctx.db.collection('ai_conversations') as unknown as Collection<Conversation>;
97
+ const cursor = collection
98
+ .find(query, { projection })
99
+ .sort({ startTime: 1 });
100
+
101
+ // 3. 脱敏逻辑(如果 includeSensitive=false)
102
+ const userIdMap = new Map<number, string>();
103
+ let userCounter = 1;
104
+
105
+ const getAnonymousId = (realUserId: number): string => {
106
+ if (options.includeSensitive) {
107
+ return realUserId.toString();
108
+ }
109
+ if (!userIdMap.has(realUserId)) {
110
+ const anonymousId = `user_${String(userCounter).padStart(3, '0')}`;
111
+ userIdMap.set(realUserId, anonymousId);
112
+ userCounter++;
113
+ }
114
+ // 安全断言: 上面的 if 语句确保了 userIdMap 中一定存在 realUserId
115
+ const anonymousId = userIdMap.get(realUserId);
116
+ return anonymousId as string;
117
+ };
118
+
119
+ // 4. 构建 CSV 数据
120
+ const rows: string[][] = [];
121
+
122
+ // 表头
123
+ const headers = [
124
+ 'conversationId',
125
+ options.includeSensitive ? 'userId' : 'anonymousId',
126
+ 'classId',
127
+ 'problemId',
128
+ 'problemTitle',
129
+ 'startTime',
130
+ 'endTime',
131
+ 'messageCount',
132
+ 'isEffective',
133
+ 'teacherNote',
134
+ 'tags'
135
+ ];
136
+ rows.push(headers);
137
+
138
+ // 数据行
139
+ for await (const conv of cursor) {
140
+ const row = [
141
+ conv._id.toString(),
142
+ getAnonymousId(conv.userId),
143
+ conv.classId || '',
144
+ conv.problemId || '',
145
+ conv.metadata?.problemTitle || '',
146
+ conv.startTime.toISOString(),
147
+ conv.endTime.toISOString(),
148
+ conv.messageCount.toString(),
149
+ conv.isEffective ? 'true' : 'false',
150
+ conv.teacherNote || '',
151
+ Array.isArray(conv.tags) ? conv.tags.join(';') : ''
152
+ ];
153
+ rows.push(row);
154
+ }
155
+
156
+ // 5. 转换为 CSV 字符串
157
+ return this.convertToCsv(rows);
158
+ }
159
+
160
+ /**
161
+ * 将二维数组转换为 CSV 字符串
162
+ *
163
+ * @param rows 二维数组,第一行为表头,后续为数据行
164
+ * @returns CSV 字符串
165
+ */
166
+ private convertToCsv(rows: string[][]): string {
167
+ return rows.map(row => row.map(this.escapeCsvField).join(',')).join('\n');
168
+ }
169
+
170
+ /**
171
+ * 转义 CSV 字段
172
+ * 如果字段包含逗号、双引号或换行,则用双引号包裹,并转义内部双引号
173
+ *
174
+ * @param value 字段值
175
+ * @returns 转义后的字符串
176
+ */
177
+ private escapeCsvField(value: unknown): string {
178
+ if (value === null || value === undefined) {
179
+ return '';
180
+ }
181
+
182
+ const str = String(value);
183
+
184
+ // 如果包含逗号、双引号或换行,则用双引号包裹
185
+ if (/[",\n]/.test(str)) {
186
+ return `"${str.replace(/"/g, '""')}"`;
187
+ }
188
+
189
+ return str;
190
+ }
191
+ }