feat: AI Runtime 完整业务逻辑实现
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 45s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 45s
- runtime-internal.service: resolveSnapshot 自动重建、persistResult 5种jobType持久化、validateOutput 校验、convertQuizCandidates/convertFlashcardCandidates 候选转换、notifyJobComplete 通知、JOB_CANCELLED处理、heartbeat 双阶段更新+取消检测 - user-ai.service: createAnalysisJob 11步流程、cancelJob、publishQuiz/publishFlashcard、getAnalysis/listAnalyses等 - user-ai.controller: 20+ 用户API端点 - 新增服务: SnapshotBuilderService、PriorityRulesService、SnapshotCleanupService、JobReaperService - 新增模块: admin-learning (CRUD管理) - Prisma schema: cancelRequestedAt/cancelledAt/sourceBlockIds 字段、expiresAt 索引 - 文档: ai-runtime-user-api.md、Issue 记录 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
eba9632a4e
commit
c88af39673
322
docs/ai-runtime-user-api.md
Normal file
322
docs/ai-runtime-user-api.md
Normal file
@ -0,0 +1,322 @@
|
||||
# AI Runtime 用户 API 接入文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述 AI Runtime 对外暴露的用户面 REST API。所有端点均需 Bearer Token 认证(`Authorization: Bearer <token>`),用户只能操作自己的资源。
|
||||
|
||||
Base URL: `/ai`
|
||||
|
||||
---
|
||||
|
||||
## 1. Job 管理
|
||||
|
||||
### 1.1 创建分析 Job
|
||||
|
||||
```
|
||||
POST /ai/jobs
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"jobType": "learning_state_analysis | weak_point_analysis | next_action_planning | quiz_generation | flashcard_generation",
|
||||
"targetType": "user | material | knowledge_base",
|
||||
"targetId": "string",
|
||||
"idempotencyKey": "string (optional)",
|
||||
"apiKeyMode": "platform_key | user_deepseek_key (optional, default from settings)",
|
||||
"credentialId": "string (optional, required for user_deepseek_key)",
|
||||
"questionCount": 5,
|
||||
"difficultyLevel": "easy | medium | hard",
|
||||
"questionTypes": ["choice", "judge"],
|
||||
"cardCount": 5,
|
||||
"knowledgePointIds": ["kp1", "kp2"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201:**
|
||||
```json
|
||||
{ "jobId": "clx...", "status": "pending", "createdAt": "2026-...", "planId": "clx... (quiz/flashcard only)" }
|
||||
```
|
||||
|
||||
**Error Codes:** `AI_ANALYSIS_DISABLED`, `INVALID_JOB_TYPE`, `INVALID_TARGET_TYPE`, `CREDENTIAL_REQUIRED`, `CREDENTIAL_NOT_FOUND`
|
||||
|
||||
### 1.2 查询 Job 列表
|
||||
|
||||
```
|
||||
GET /ai/jobs?status=pending&take=20
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[
|
||||
{ "id": "clx...", "jobType": "learning_state_analysis", "targetType": "material", "targetId": "m1",
|
||||
"status": "pending", "priority": 50, "errorCode": null, "cancelRequestedAt": null,
|
||||
"startedAt": null, "finishedAt": null, "createdAt": "2026-..." }
|
||||
]
|
||||
```
|
||||
|
||||
### 1.3 查询单个 Job
|
||||
|
||||
```
|
||||
GET /ai/jobs/:jobId
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "clx...", "jobType": "...", "targetType": "...", "targetId": "...",
|
||||
"status": "succeeded", "priority": 50, "snapshotId": "snap-...",
|
||||
"attemptNo": 0, "retryCount": 0, "maxRetryCount": 3,
|
||||
"errorCode": null, "errorMessage": null,
|
||||
"cancelRequestedAt": null, "cancelledAt": null,
|
||||
"startedAt": "2026-...", "finishedAt": "2026-...",
|
||||
"createdAt": "2026-...", "updatedAt": "2026-..."
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 取消 Job
|
||||
|
||||
```
|
||||
POST /ai/jobs/:jobId/cancel
|
||||
```
|
||||
|
||||
**Response 200:** `{ "jobId": "clx...", "status": "cancelled | cancel_requested" }`
|
||||
|
||||
**Error Codes:** `JOB_NOT_FOUND`, `JOB_CANNOT_CANCEL`
|
||||
|
||||
---
|
||||
|
||||
## 2. 分析结果查询
|
||||
|
||||
### 2.1 查询分析列表
|
||||
|
||||
```
|
||||
GET /ai/analyses?targetType=material&targetId=m1&take=20
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[
|
||||
{ "id": "clx...", "targetType": "material", "targetId": "m1",
|
||||
"learningState": "mastered", "riskLevel": "low", "confidence": 0.85,
|
||||
"summary": "...", "createdAt": "2026-..." }
|
||||
]
|
||||
```
|
||||
|
||||
### 2.2 查询单个分析
|
||||
|
||||
```
|
||||
GET /ai/analyses/:id
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "clx...", "userId": "...", "jobId": "...", "snapshotId": "...",
|
||||
"targetType": "material", "targetId": "m1",
|
||||
"learningState": "mastered", "summary": "...", "riskLevel": "low",
|
||||
"confidence": 0.85, "evidence": ["fact1", "fact2"],
|
||||
"nextActionIds": ["..."],
|
||||
"promptVersion": "learning_state_v1", "schemaVersion": "analysis_output_v1",
|
||||
"createdAt": "2026-...", "updatedAt": "2026-..."
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 触发重新分析
|
||||
|
||||
```
|
||||
POST /ai/reanalyze
|
||||
```
|
||||
|
||||
**Request Body:** `{ "targetType": "material", "targetId": "m1" }`
|
||||
|
||||
**Response 201:** `{ "jobId": "clx...", "status": "pending", "createdAt": "2026-..." }`
|
||||
|
||||
---
|
||||
|
||||
## 3. 建议 / 弱项查询
|
||||
|
||||
### 3.1 查询建议列表
|
||||
|
||||
```
|
||||
GET /ai/recommendations?targetType=material&targetId=m1&status=active&take=20
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[
|
||||
{ "id": "clx...", "actionType": "review", "targetType": "material", "targetId": "m1",
|
||||
"title": "...", "reason": "...", "priority": 10, "estimatedMinutes": 15,
|
||||
"deviceSuitability": "phone", "status": "active", "createdAt": "2026-..." }
|
||||
]
|
||||
```
|
||||
|
||||
### 3.2 查询弱项列表
|
||||
|
||||
```
|
||||
GET /ai/weak-points?targetType=material&targetId=m1&status=active&take=20
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[
|
||||
{ "id": "clx...", "knowledgePointId": "kp1", "title": "Grammar: Tense",
|
||||
"reason": "...", "confidence": 0.9, "evidence": ["..."],
|
||||
"status": "active", "targetType": "material", "targetId": "m1", "createdAt": "2026-..." }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 题目查询
|
||||
|
||||
### 4.1 查询题目列表
|
||||
|
||||
```
|
||||
GET /ai/quizzes?knowledgeBaseId=kb1&status=active&take=20
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[
|
||||
{ "id": "clx...", "knowledgeBaseId": "kb1", "title": "...", "questionCount": 10,
|
||||
"sourceType": "ai", "status": "active", "createdAt": "2026-..." }
|
||||
]
|
||||
```
|
||||
|
||||
### 4.2 查询单个 Quiz
|
||||
|
||||
```
|
||||
GET /ai/quizzes/:quizId
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "clx...", "knowledgeBaseId": "kb1", "title": "...", "description": "...",
|
||||
"questionCount": 10, "sourceType": "ai", "sourceId": "job-...",
|
||||
"status": "active", "createdAt": "2026-...", "updatedAt": "2026-..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 查询 Quiz 题目详情
|
||||
|
||||
```
|
||||
GET /ai/quizzes/:quizId/questions
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[
|
||||
{ "id": "clx...", "type": "choice", "stem": "...", "options": ["A", "B", "C", "D"],
|
||||
"answer": "A", "explanation": "...", "sourceBlockIds": ["..."], "orderIndex": 0 }
|
||||
]
|
||||
```
|
||||
|
||||
### 4.4 发布 Quiz (draft → active)
|
||||
|
||||
```
|
||||
POST /ai/quizzes/:quizId/publish
|
||||
```
|
||||
|
||||
**Response 200:** `{ "quizId": "clx...", "status": "active" }`
|
||||
|
||||
**Error Codes:** `QUIZ_NOT_FOUND`, `QUIZ_NOT_READY`
|
||||
|
||||
---
|
||||
|
||||
## 5. 卡片查询
|
||||
|
||||
### 5.1 查询卡片列表
|
||||
|
||||
```
|
||||
GET /ai/flashcards?knowledgePointId=kp1&status=active&take=20
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[
|
||||
{ "id": "clx...", "front": "...", "back": "...", "hint": "...",
|
||||
"difficultyLevel": "medium", "knowledgePointId": "kp1",
|
||||
"sourceType": "ai", "status": "active", "createdAt": "2026-..." }
|
||||
]
|
||||
```
|
||||
|
||||
### 5.2 查询单个卡片
|
||||
|
||||
```
|
||||
GET /ai/flashcards/:cardId
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "clx...", "front": "...", "back": "...", "hint": "...",
|
||||
"difficultyLevel": "medium", "knowledgePointId": "kp1",
|
||||
"sourceBlockIds": ["..."],
|
||||
"sourceType": "ai", "sourceId": "job-...", "generatedByJobId": "job-...",
|
||||
"status": "active", "createdAt": "2026-...", "updatedAt": "2026-..."
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 发布卡片 (draft → active)
|
||||
|
||||
```
|
||||
POST /ai/flashcards/:cardId/publish
|
||||
```
|
||||
|
||||
**Response 200:** `{ "cardId": "clx...", "status": "active" }`
|
||||
|
||||
**Error Codes:** `FLASHCARD_NOT_FOUND`, `FLASHCARD_NOT_DRAFT`
|
||||
|
||||
---
|
||||
|
||||
## 6. 反馈
|
||||
|
||||
### 6.1 通用反馈
|
||||
|
||||
```
|
||||
POST /ai/feedback
|
||||
```
|
||||
|
||||
**Request Body:** `{ "category": "bug|feature|ux|other", "content": "...", "email": "optional", "deviceInfo": {} }`
|
||||
|
||||
**Response 201:** `{ "id": "clx...", "status": "open", "createdAt": "2026-..." }`
|
||||
|
||||
### 6.2 AI 产出物反馈
|
||||
|
||||
```
|
||||
POST /ai/artifacts/:type/:id/feedback
|
||||
```
|
||||
|
||||
**Path Params:** `type` = `analysis | quiz | flashcard`
|
||||
|
||||
**Request Body:** `{ "feedbackType": "correct|incorrect|helpful|not_helpful|...", "reason": "optional" }`
|
||||
|
||||
**Response 201:** `{ "id": "clx...", "feedbackType": "correct", "createdAt": "2026-..." }`
|
||||
|
||||
**Error Codes:** `ARTIFACT_NOT_FOUND`
|
||||
|
||||
---
|
||||
|
||||
## 错误码汇总
|
||||
|
||||
| 错误码 | HTTP Status | 说明 |
|
||||
|--------|------------|------|
|
||||
| `AI_ANALYSIS_DISABLED` | 400 | 用户关闭了 AI 分析 |
|
||||
| `INVALID_JOB_TYPE` | 400 | 不支持的 jobType |
|
||||
| `INVALID_TARGET_TYPE` | 400 | targetType 不匹配 jobType 要求 |
|
||||
| `CREDENTIAL_REQUIRED` | 400 | user_deepseek_key 模式需提供 credentialId |
|
||||
| `CREDENTIAL_NOT_FOUND` | 404 | 凭证不存在或未激活 |
|
||||
| `JOB_NOT_FOUND` | 404 | Job 不存在或不属于用户 |
|
||||
| `JOB_CANNOT_CANCEL` | 400 | Job 已处于终态,无法取消 |
|
||||
| `QUIZ_NOT_FOUND` | 404 | Quiz 不存在或不属于用户 |
|
||||
| `QUIZ_NOT_READY` | 400 | Quiz 非 ready 状态,无法发布 |
|
||||
| `FLASHCARD_NOT_FOUND` | 404 | 卡片不存在或不属于用户 |
|
||||
| `FLASHCARD_NOT_DRAFT` | 400 | 卡片非 draft 状态,无法发布 |
|
||||
| `ANALYSIS_NOT_FOUND` | 404 | 分析结果不存在 |
|
||||
| `ARTIFACT_NOT_FOUND` | 404 | 产出物不存在或不属于用户 |
|
||||
|
||||
## 认证
|
||||
|
||||
所有端点使用 `Authorization: Bearer <JWT>` 认证,用户身份从 JWT payload 提取。内部端点(`/internal/runtime/*`)使用 Service Token 认证,不对外暴露。
|
||||
88
docs/issues/API-AI-R01-resolveSnapshot-race.md
Normal file
88
docs/issues/API-AI-R01-resolveSnapshot-race.md
Normal file
@ -0,0 +1,88 @@
|
||||
# API-AI-R01: resolveSnapshot 并发竞争
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| Issue ID | API-AI-R01 |
|
||||
| 类型 | Non-blocking / 优化 |
|
||||
| 仓库 | api-server |
|
||||
| 关联 Issue | API-AI-016 (Snapshot Builder) |
|
||||
| 发现日期 | 2026-06-17 |
|
||||
| 优先级 | P2 |
|
||||
|
||||
## 问题描述
|
||||
|
||||
`runtime-internal.service.ts` 中的 `resolveSnapshot()` 存在并发竞态窗口。
|
||||
|
||||
### 场景
|
||||
|
||||
两个 Runtime 实例同时对同一个 job 调用 `getSnapshot()`,且 job 当前没有有效 snapshot(未生成或已过期):
|
||||
|
||||
```
|
||||
时间线 →
|
||||
|
||||
实例 A 实例 B
|
||||
│ │
|
||||
├─ resolveSnapshot(job) │
|
||||
│ snapshotId=null → 进 else │
|
||||
│ ├─ resolveSnapshot(job)
|
||||
│ │ snapshotId=null → 进 else
|
||||
│ │
|
||||
├─ buildSnapshot() → snap-A │
|
||||
│ ├─ buildSnapshot() → snap-B
|
||||
│ │
|
||||
├─ job.update(snapshotId=A) │
|
||||
│ ├─ job.update(snapshotId=B) ← 覆盖 A
|
||||
```
|
||||
|
||||
### 后果
|
||||
|
||||
1. 数据库产生 snap-A 孤儿行(无 job 引用)
|
||||
2. 浪费一次全量聚合查询(buildSnapshot)
|
||||
3. snap-A 在 24h TTL 后自动过期清理
|
||||
|
||||
### 为什么当前影响可接受
|
||||
|
||||
- 不会丢数据或返回错误
|
||||
- snapshot 构建是幂等的,两份结果一致性高
|
||||
- 触发条件苛刻:两个 Runtime 实例需同时 poll 到同一个 job(poll 时有 lock 机制大幅降低概率)
|
||||
- 即使发生,额外开销仅为一次聚合查询
|
||||
|
||||
## 建议修复方案
|
||||
|
||||
方案:对 job 行加悲观锁后再判断 snapshot 状态。
|
||||
|
||||
```typescript
|
||||
// resolveSnapshot 改为:
|
||||
private async resolveSnapshot(job) {
|
||||
// SELECT ... FOR UPDATE 锁住 job 行
|
||||
const locked = await this.prisma.aiRuntimeJob.findUnique({
|
||||
where: { id: job.id },
|
||||
// Prisma 不直接支持 FOR UPDATE,需用 $queryRaw
|
||||
});
|
||||
|
||||
if (locked.snapshotId) {
|
||||
const existing = await this.prisma.learningAnalysisSnapshot.findUnique({
|
||||
where: { id: locked.snapshotId },
|
||||
});
|
||||
if (existing && (!existing.expiresAt || new Date(existing.expiresAt) >= new Date())) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await this.snapshotBuilder.buildSnapshot(...);
|
||||
await this.prisma.aiRuntimeJob.update({
|
||||
where: { id: job.id },
|
||||
data: { snapshotId: snapshot.id },
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
```
|
||||
|
||||
或者使用 `$transaction` 包裹读-判断-写逻辑,依赖数据库隔离级别保护。
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `src/modules/ai-runtime/internal/runtime-internal.service.ts:resolveSnapshot()`
|
||||
- `src/modules/ai-runtime/snapshot-builder.service.ts:buildSnapshot()`
|
||||
43
docs/issues/API-AI-R02-sourceDataVersion-enhancement.md
Normal file
43
docs/issues/API-AI-R02-sourceDataVersion-enhancement.md
Normal file
@ -0,0 +1,43 @@
|
||||
# API-AI-R02: sourceDataVersion 增强
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| Issue ID | API-AI-R02 |
|
||||
| 类型 | Non-blocking / 增强 |
|
||||
| 仓库 | api-server |
|
||||
| 关联 Issue | API-AI-021 (Snapshot 版本化与过期) |
|
||||
| 发现日期 | 2026-06-17 |
|
||||
| 优先级 | P2 |
|
||||
|
||||
## 问题描述
|
||||
|
||||
`LearningAnalysisSnapshot.sourceDataVersion` 字段当前写入固定值 `'1.0'`(`snapshot-builder.service.ts:122`),但缺乏自动递增/校验机制。
|
||||
|
||||
### 当前状态
|
||||
|
||||
- 字段已写入:`sourceDataVersion: SOURCE_DATA_VERSION`(常量 `'1.0'`)
|
||||
- `resolveSnapshot` 已做版本匹配检查:`existing.sourceDataVersion === SOURCE_DATA_VERSION`
|
||||
- 缺少:当聚合逻辑变更时自动检测并递增版本号的能力
|
||||
|
||||
### 期望增强
|
||||
|
||||
当以下任一变更发生时,`SOURCE_DATA_VERSION` 需要手动递增,但目前依赖开发者记忆:
|
||||
|
||||
1. `computeSignals` 信号计算公式变更
|
||||
2. `getScoreWeights` 权重调整
|
||||
3. `classifyMasteryLevel` 分类阈值变更
|
||||
4. `BEHAVIOR_WINDOW_DAYS` / `SCORE_WINDOW_DAYS` 等窗口常量变更
|
||||
5. 聚合查询字段增减
|
||||
|
||||
### 建议方案
|
||||
|
||||
- 方案 A:在 CI 中对信号计算相关文件做 hash,hash 变更时校验是否同步更新了 `SOURCE_DATA_VERSION`
|
||||
- 方案 B:将 `SOURCE_DATA_VERSION` 改为从信号逻辑的语义版本自动推导
|
||||
- 方案 C:在开发流程中增加 checklist,变更信号逻辑时必须更新版本号
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `src/modules/ai-runtime/snapshot-builder.service.ts`
|
||||
- `src/modules/ai-runtime/internal/runtime-internal.service.ts:resolveSnapshot()`
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@ -241,7 +241,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -816,7 +815,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
@ -829,7 +827,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@ -2235,7 +2232,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz",
|
||||
"integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
@ -2310,7 +2306,6 @@
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@ -2458,7 +2453,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/common/-/common-11.1.23.tgz",
|
||||
"integrity": "sha512-qKgEqwQXHIVu8TwiISmgbTrGHAFBsseP86KNolBZwAiHQryinJ5FPiDpp0ZJBBryY+WEMnsqaCa4TSxVuQEWug==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"file-type": "21.3.4",
|
||||
"iterare": "1.2.1",
|
||||
@ -2506,7 +2500,6 @@
|
||||
"integrity": "sha512-Yd+mVFUilw4A6PzV7tyfiW+zrG2wmRXnFZVmNQA+fl1N0k6km4bhhNboxjLu//dzl+XiZI5AsOHHOTegzvOgNQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nuxt/opencollective": "0.4.1",
|
||||
"fast-safe-stringify": "2.1.1",
|
||||
@ -2603,7 +2596,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/platform-express/-/platform-express-11.1.23.tgz",
|
||||
"integrity": "sha512-7TQ9v2Z9lej8btTYK1VUVB3nZr0lXFNFiyI/iOc0jAOg31dHdpAWtnMMm9iUvyAfq/ssw7dsBhjCHOmEJJKr0A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
@ -3183,7 +3175,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.4.tgz",
|
||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@ -3354,7 +3345,6 @@
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
@ -4131,7 +4121,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -4180,7 +4169,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@ -4652,7 +4640,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@ -4732,7 +4719,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/bullmq/-/bullmq-5.77.1.tgz",
|
||||
"integrity": "sha512-n25H1jW3PI0vfVQ0ge6f8mP9hHPwDM2Ivqm0rAOfFFy4t6d3mE7JtX1gyWNuZKIVmlS41w7DDzv3giGgM6qo+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cron-parser": "4.9.0",
|
||||
"ioredis": "5.10.1",
|
||||
@ -4936,15 +4922,13 @@
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmmirror.com/class-validator/-/class-validator-0.15.1.tgz",
|
||||
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
@ -5801,7 +5785,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -5862,7 +5845,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@ -6101,7 +6083,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@ -6522,7 +6503,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@ -7301,7 +7281,6 @@
|
||||
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.4.2",
|
||||
"@jest/types": "30.4.1",
|
||||
@ -9053,7 +9032,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
@ -9371,7 +9349,6 @@
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@ -9431,7 +9408,6 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/engines": "5.22.0"
|
||||
},
|
||||
@ -9593,6 +9569,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/redis-info/-/redis-info-3.1.0.tgz",
|
||||
"integrity": "sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.11"
|
||||
}
|
||||
@ -9613,8 +9590,7 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/request": {
|
||||
"version": "2.88.2",
|
||||
@ -9765,7 +9741,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@ -10471,7 +10446,6 @@
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@ -10799,7 +10773,6 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@ -11006,7 +10979,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -11394,6 +11366,7 @@
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
@ -11412,6 +11385,7 @@
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
@ -11425,6 +11399,7 @@
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
@ -11439,6 +11414,7 @@
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
@ -11448,7 +11424,8 @@
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/webpack/node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
@ -11456,6 +11433,7 @@
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@ -11466,6 +11444,7 @@
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
|
||||
@ -1728,6 +1728,7 @@ model QuizQuestion {
|
||||
options Json?
|
||||
answer String @db.VarChar(500)
|
||||
explanation String? @db.Text
|
||||
sourceBlockIds Json?
|
||||
orderIndex Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@ -1878,6 +1879,8 @@ model AiRuntimeJob {
|
||||
|
||||
errorCode String? @db.VarChar(100)
|
||||
errorMessage String? @db.Text
|
||||
cancelRequestedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@ -1944,6 +1947,7 @@ model LearningAnalysisSnapshot {
|
||||
|
||||
@@index([userId])
|
||||
@@index([scopeType, scopeId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
// ── API-AI-009: ModelInvocationLog ──
|
||||
|
||||
@ -26,6 +26,7 @@ import { AdminEventsModule } from './modules/admin-events/admin-events.module';
|
||||
import { AdminKnowledgeModule } from './modules/admin-knowledge/admin-knowledge.module';
|
||||
import { AdminCostsModule } from './modules/admin-costs/admin-costs.module';
|
||||
import { AdminBillingModule } from './modules/admin-billing/admin-billing.module';
|
||||
import { AdminLearningModule } from './modules/admin-learning/admin-learning.module';
|
||||
import { AdminServersModule } from './modules/admin-servers/admin-servers.module';
|
||||
import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module';
|
||||
import { AdminAiChatModule } from './modules/admin-ai-chat/admin-ai-chat.module';
|
||||
@ -138,6 +139,7 @@ import appleConfig from './config/apple.config';
|
||||
AdminCostsModule,
|
||||
AdminBillingModule,
|
||||
AdminServersModule,
|
||||
AdminLearningModule,
|
||||
AdminConversationModule,
|
||||
AdminAiChatModule,
|
||||
AdminAuditLogModule,
|
||||
|
||||
78
src/modules/admin-learning/admin-learning.controller.ts
Normal file
78
src/modules/admin-learning/admin-learning.controller.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { Controller, Get, Post, Param, Query, Body, UseGuards } from '@nestjs/common';
|
||||
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
|
||||
import { AdminLearningService } from './admin-learning.service';
|
||||
import { EventFilterQuery, SessionFilterQuery, ProgressFilterQuery, RecordFilterQuery, PaginationQuery, BatchReprocessDto, RecalculateDto } from './dto/admin-learning.dto';
|
||||
|
||||
@Controller('admin/learning')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminLearningController {
|
||||
constructor(private readonly service: AdminLearningService) {}
|
||||
|
||||
// ── Dashboard ──
|
||||
@Get('dashboard')
|
||||
async getDashboard() { return this.service.getDashboard(); }
|
||||
|
||||
// ── ReadingEvents ──
|
||||
@Get('reading-events')
|
||||
async getReadingEvents(@Query() query: EventFilterQuery) { return this.service.getReadingEvents(query); }
|
||||
@Get('reading-events/failed')
|
||||
async getFailedEvents(@Query() query: PaginationQuery) { return this.service.getFailedEvents(query); }
|
||||
@Get('reading-events/:id')
|
||||
async getReadingEvent(@Param('id') id: string) { return this.service.getReadingEvent(id); }
|
||||
@Post('reading-events/:id/reprocess')
|
||||
async reprocessEvent(@Param('id') id: string) { return this.service.reprocessEvent(id); }
|
||||
@Post('reading-events/reprocess-batch')
|
||||
async batchReprocess(@Body() dto: BatchReprocessDto) { return this.service.batchReprocess(dto.eventIds); }
|
||||
|
||||
// ── Sessions ──
|
||||
@Get('sessions')
|
||||
async getSessions(@Query() query: SessionFilterQuery) { return this.service.getSessions(query); }
|
||||
@Get('sessions/interrupted')
|
||||
async getInterruptedSessions(@Query() query: PaginationQuery) { return this.service.getInterruptedSessions(query); }
|
||||
@Get('sessions/:id')
|
||||
async getSession(@Param('id') id: string) { return this.service.getSession(id); }
|
||||
|
||||
// ── Progress ──
|
||||
@Get('progress')
|
||||
async getProgress(@Query() query: ProgressFilterQuery) { return this.service.getProgress(query); }
|
||||
@Get('progress/:id')
|
||||
async getProgressDetail(@Param('id') id: string) { return this.service.getProgressDetail(id); }
|
||||
|
||||
// ── DailyActivities ──
|
||||
@Get('daily-activities')
|
||||
async getDailyActivities(@Query() query: PaginationQuery & { userId?: string; startDate?: string; endDate?: string }) { return this.service.getDailyActivities(query); }
|
||||
|
||||
// ── Records ──
|
||||
@Get('records')
|
||||
async getRecords(@Query() query: RecordFilterQuery) { return this.service.getRecords(query); }
|
||||
@Get('records/:id')
|
||||
async getRecord(@Param('id') id: string) { return this.service.getRecord(id); }
|
||||
|
||||
// ── Timeline ──
|
||||
@Get('user-timeline')
|
||||
async getUserTimeline(@Query('userId') userId: string, @Query('limit') limit?: string) {
|
||||
return this.service.getUserTimeline(userId, limit ? parseInt(limit) : undefined);
|
||||
}
|
||||
|
||||
// ── Diagnose ──
|
||||
@Get('user-diagnose')
|
||||
async diagnoseUser(@Query('userId') userId: string) { return this.service.diagnoseUser(userId); }
|
||||
@Get('material-diagnose')
|
||||
async diagnoseMaterial(@Query('materialId') materialId: string) { return this.service.diagnoseMaterial(materialId); }
|
||||
|
||||
// ── Anomalies ──
|
||||
@Get('anomalies')
|
||||
async getAnomalies(@Query() query: PaginationQuery) { return this.service.getAnomalies(query); }
|
||||
|
||||
// ── Temporary Materials ──
|
||||
@Get('temporary-materials')
|
||||
async getTemporaryMaterials(@Query() query: PaginationQuery) { return this.service.getTemporaryMaterials(query); }
|
||||
|
||||
// ── Operations ──
|
||||
@Post('recalculate')
|
||||
async recalculateLearningData(@Body() dto: RecalculateDto) { return this.service.recalculateLearningData(dto.userId); }
|
||||
@Get('export')
|
||||
async export(@Query('startDate') startDate?: string, @Query('endDate') endDate?: string, @Query('type') type?: string) {
|
||||
return this.service.export({ startDate, endDate, type });
|
||||
}
|
||||
}
|
||||
12
src/modules/admin-learning/admin-learning.module.ts
Normal file
12
src/modules/admin-learning/admin-learning.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AiRuntimeModule } from '../ai-runtime/ai-runtime.module';
|
||||
import { AdminLearningController } from './admin-learning.controller';
|
||||
import { AdminLearningService } from './admin-learning.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AiRuntimeModule],
|
||||
controllers: [AdminLearningController],
|
||||
providers: [AdminLearningService],
|
||||
})
|
||||
export class AdminLearningModule {}
|
||||
310
src/modules/admin-learning/admin-learning.service.ts
Normal file
310
src/modules/admin-learning/admin-learning.service.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminLearningService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly snapshotBuilder: SnapshotBuilderService,
|
||||
) {}
|
||||
|
||||
// ══ Dashboard ══
|
||||
|
||||
async getDashboard() {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
const [totalEvents, todayEvents, failedEvents, duplicateEvents,
|
||||
activeSessions, interruptedSessions, completedSessions, totalSessions,
|
||||
activeToday, totalWithEvents, totalRead, totalMarkedRead,
|
||||
] = await Promise.all([
|
||||
this.prisma.readingEvent.count(),
|
||||
this.prisma.readingEvent.count({ where: { serverReceivedAt: { gte: today } } }),
|
||||
this.prisma.readingEvent.count({ where: { status: 'failed' } }),
|
||||
this.prisma.readingEvent.count({ where: { status: 'duplicate' } }),
|
||||
this.prisma.learningSession.count({ where: { status: 'active' } }),
|
||||
this.prisma.learningSession.count({ where: { status: 'interrupted' } }),
|
||||
this.prisma.learningSession.count({ where: { status: 'completed' } }),
|
||||
this.prisma.learningSession.count(),
|
||||
this.prisma.dailyLearningActivity.count({ where: { activityDate: today } }),
|
||||
this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], _count: true }),
|
||||
this.prisma.materialReadingProgress.count({ where: { status: { not: 'not_started' } } }),
|
||||
this.prisma.materialReadingProgress.count({ where: { isMarkedRead: true } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
overview: { totalEvents, todayEvents, failedEvents, duplicateEvents },
|
||||
sessions: { active: activeSessions, interrupted: interruptedSessions, completed: completedSessions, total: totalSessions },
|
||||
users: { activeToday, totalWithEvents: totalWithEvents.length },
|
||||
materials: { totalRead, totalMarkedRead },
|
||||
};
|
||||
}
|
||||
|
||||
// ══ ReadingEvents ══
|
||||
|
||||
async getReadingEvents(query: {
|
||||
userId?: string; materialId?: string; readingTargetType?: string;
|
||||
eventType?: string; status?: string; startDate?: string; endDate?: string;
|
||||
page?: number; limit?: number; sortBy?: string; order?: 'asc' | 'desc';
|
||||
}) {
|
||||
const where: any = {};
|
||||
if (query.userId) where.userId = query.userId;
|
||||
if (query.materialId) where.materialId = query.materialId;
|
||||
if (query.readingTargetType) where.readingTargetType = query.readingTargetType;
|
||||
if (query.eventType) where.eventType = query.eventType;
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.startDate || query.endDate) {
|
||||
where.serverReceivedAt = {};
|
||||
if (query.startDate) where.serverReceivedAt.gte = new Date(query.startDate);
|
||||
if (query.endDate) where.serverReceivedAt.lte = new Date(query.endDate);
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const orderBy: any = {};
|
||||
orderBy[query.sortBy ?? 'serverReceivedAt'] = query.order ?? 'desc';
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.readingEvent.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.readingEvent.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
async getReadingEvent(id: string) {
|
||||
const event = await this.prisma.readingEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new BadRequestException('Event not found');
|
||||
return event;
|
||||
}
|
||||
|
||||
async getFailedEvents(query: { page?: number; limit?: number }) {
|
||||
const where = { status: { in: ['failed', 'warning', 'duplicate'] } };
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.readingEvent.findMany({ where, orderBy: { serverReceivedAt: 'desc' }, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.readingEvent.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
// ══ Sessions ══
|
||||
|
||||
async getSessions(query: { userId?: string; materialId?: string; status?: string; clientSessionId?: string; startDate?: string; endDate?: string; page?: number; limit?: number }) {
|
||||
const where: any = {};
|
||||
if (query.userId) where.userId = query.userId;
|
||||
if (query.materialId) where.materialId = query.materialId;
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.clientSessionId) where.clientSessionId = query.clientSessionId;
|
||||
if (query.startDate) where.startedAt = { ...(where.startedAt ?? {}), gte: new Date(query.startDate) };
|
||||
if (query.endDate) where.startedAt = { ...(where.startedAt ?? {}), lte: new Date(query.endDate) };
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.learningSession.findMany({ where, orderBy: { startedAt: 'desc' }, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.learningSession.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
async getSession(id: string) {
|
||||
const session = await this.prisma.learningSession.findUnique({ where: { id } });
|
||||
if (!session) throw new BadRequestException('Session not found');
|
||||
return session;
|
||||
}
|
||||
|
||||
async getInterruptedSessions(query: { page?: number; limit?: number }) {
|
||||
const where = { status: 'interrupted' };
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.learningSession.findMany({ where, orderBy: { startedAt: 'desc' }, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.learningSession.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
// ══ Progress ══
|
||||
|
||||
async getProgress(query: { userId?: string; materialId?: string; status?: string; page?: number; limit?: number }) {
|
||||
const where: any = {};
|
||||
if (query.userId) where.userId = query.userId;
|
||||
if (query.materialId) where.materialId = query.materialId;
|
||||
if (query.status) where.status = query.status;
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.materialReadingProgress.findMany({ where, orderBy: { lastReadAt: 'desc' }, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.materialReadingProgress.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
async getProgressDetail(id: string) {
|
||||
const p = await this.prisma.materialReadingProgress.findUnique({ where: { id } });
|
||||
if (!p) throw new BadRequestException('Progress not found');
|
||||
return p;
|
||||
}
|
||||
|
||||
// ══ DailyActivities ══
|
||||
|
||||
async getDailyActivities(query: { userId?: string; startDate?: string; endDate?: string; page?: number; limit?: number }) {
|
||||
const where: any = {};
|
||||
if (query.userId) where.userId = query.userId;
|
||||
if (query.startDate) where.activityDate = { ...(where.activityDate ?? {}), gte: new Date(query.startDate) };
|
||||
if (query.endDate) where.activityDate = { ...(where.activityDate ?? {}), lte: new Date(query.endDate) };
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.dailyLearningActivity.findMany({ where, orderBy: { activityDate: 'desc' }, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.dailyLearningActivity.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
// ══ Records ══
|
||||
|
||||
async getRecords(query: { userId?: string; recordType?: string; startDate?: string; endDate?: string; page?: number; limit?: number }) {
|
||||
const where: any = {};
|
||||
if (query.userId) where.userId = query.userId;
|
||||
if (query.recordType) where.recordType = query.recordType;
|
||||
if (query.startDate) where.occurredAt = { ...(where.occurredAt ?? {}), gte: new Date(query.startDate) };
|
||||
if (query.endDate) where.occurredAt = { ...(where.occurredAt ?? {}), lte: new Date(query.endDate) };
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.learningRecord.findMany({ where, orderBy: { occurredAt: 'desc' }, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.learningRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
async getRecord(id: string) {
|
||||
const r = await this.prisma.learningRecord.findUnique({ where: { id } });
|
||||
if (!r) throw new BadRequestException('Record not found');
|
||||
return r;
|
||||
}
|
||||
|
||||
// ══ Timeline ══
|
||||
|
||||
async getUserTimeline(userId: string, limit: number = 50) {
|
||||
const events = await this.prisma.readingEvent.findMany({
|
||||
where: { userId },
|
||||
orderBy: { clientTimestampMs: 'desc' },
|
||||
take: Math.min(limit, 200),
|
||||
select: { eventId: true, eventType: true, materialId: true, clientTimestampMs: true, status: true },
|
||||
});
|
||||
|
||||
const sessions = await this.prisma.learningSession.findMany({
|
||||
where: { userId },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: Math.min(limit, 50),
|
||||
select: { id: true, mode: true, status: true, startedAt: true, endedAt: true, totalActiveSeconds: true },
|
||||
});
|
||||
|
||||
return { events, sessions };
|
||||
}
|
||||
|
||||
// ══ Anomalies ══
|
||||
|
||||
async getAnomalies(query: { page?: number; limit?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
|
||||
// Broad anomaly detection: failed + duplicate + events with unusually large activeSecondsDelta
|
||||
const [failed, duplicates, outliers, stuckSessions] = await Promise.all([
|
||||
this.prisma.readingEvent.findMany({ where: { status: 'failed' }, orderBy: { serverReceivedAt: 'desc' }, take: limit }),
|
||||
this.prisma.readingEvent.findMany({ where: { status: 'duplicate' }, orderBy: { serverReceivedAt: 'desc' }, take: limit }),
|
||||
this.prisma.readingEvent.findMany({ where: { activeSecondsDelta: { gte: 3600 } }, orderBy: { activeSecondsDelta: 'desc' }, take: 10 }),
|
||||
this.prisma.learningSession.findMany({ where: { status: 'interrupted' }, orderBy: { startedAt: 'desc' }, take: 10 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
failed: { items: failed, total: await this.prisma.readingEvent.count({ where: { status: 'failed' } }) },
|
||||
duplicates: { items: duplicates, total: await this.prisma.readingEvent.count({ where: { status: 'duplicate' } }) },
|
||||
outliers: { items: outliers, note: 'activeSecondsDelta >= 3600' },
|
||||
stuckSessions,
|
||||
page, limit,
|
||||
};
|
||||
}
|
||||
|
||||
// ══ Diagnose ══
|
||||
|
||||
async diagnoseUser(userId: string) {
|
||||
const [events, sessions, progress, dailyActivity] = await Promise.all([
|
||||
this.prisma.readingEvent.count({ where: { userId } }),
|
||||
this.prisma.learningSession.findMany({ where: { userId }, orderBy: { startedAt: 'desc' }, take: 10, select: { id: true, status: true, startedAt: true, totalActiveSeconds: true } }),
|
||||
this.prisma.materialReadingProgress.findMany({ where: { userId }, orderBy: { lastReadAt: 'desc' }, take: 10, select: { materialId: true, status: true, totalActiveSeconds: true, isMarkedRead: true } }),
|
||||
this.prisma.dailyLearningActivity.findMany({ where: { userId }, orderBy: { activityDate: 'desc' }, take: 7 }),
|
||||
]);
|
||||
return { userId, totalEvents: events, recentSessions: sessions, progress, dailyActivity };
|
||||
}
|
||||
|
||||
async diagnoseMaterial(materialId: string) {
|
||||
const [events, sessions, progress] = await Promise.all([
|
||||
this.prisma.readingEvent.findMany({ where: { materialId }, orderBy: { clientTimestampMs: 'desc' }, take: 20 }),
|
||||
this.prisma.learningSession.findMany({ where: { materialId }, orderBy: { startedAt: 'desc' }, take: 10 }),
|
||||
this.prisma.materialReadingProgress.findFirst({ where: { materialId } }),
|
||||
]);
|
||||
return { materialId, events, sessions, progress };
|
||||
}
|
||||
|
||||
// ══ Temporary Materials ══
|
||||
|
||||
async getTemporaryMaterials(query: { page?: number; limit?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.temporaryReadingMaterial.findMany({ orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit }),
|
||||
this.prisma.temporaryReadingMaterial.count(),
|
||||
]);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
// ══ Operations ══
|
||||
|
||||
async reprocessEvent(id: string) {
|
||||
const event = await this.prisma.readingEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new BadRequestException('Event not found');
|
||||
await this.prisma.readingEvent.update({ where: { id }, data: { status: 'pending', processedAt: null, errorCode: null } });
|
||||
return { id, status: 'pending' };
|
||||
}
|
||||
|
||||
async batchReprocess(eventIds: string[]) {
|
||||
const result = await this.prisma.readingEvent.updateMany({
|
||||
where: { id: { in: eventIds }, status: { in: ['failed', 'duplicate'] } },
|
||||
data: { status: 'pending', processedAt: null, errorCode: null },
|
||||
});
|
||||
return { reprocessed: result.count };
|
||||
}
|
||||
|
||||
async recalculateLearningData(userId?: string) {
|
||||
if (userId) {
|
||||
const snapshot = await this.snapshotBuilder.buildSnapshot(userId, 'user', userId);
|
||||
return { status: 'recalculated', userId, snapshotId: snapshot.id };
|
||||
}
|
||||
// Recalculate for all active users (limit to 100)
|
||||
const users = await this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], _count: true, take: 100, orderBy: { _count: { userId: 'desc' } } });
|
||||
const snapshots = await Promise.all(users.map(u =>
|
||||
this.snapshotBuilder.buildSnapshot(u.userId, 'user', u.userId).catch(() => null)
|
||||
));
|
||||
return { status: 'recalculated', count: snapshots.filter(Boolean).length };
|
||||
}
|
||||
|
||||
async export(query: { startDate?: string; endDate?: string; type?: string }) {
|
||||
const where: any = {};
|
||||
if (query.startDate) where.serverReceivedAt = { ...(where.serverReceivedAt ?? {}), gte: new Date(query.startDate) };
|
||||
if (query.endDate) where.serverReceivedAt = { ...(where.serverReceivedAt ?? {}), lte: new Date(query.endDate) };
|
||||
const total = await this.prisma.readingEvent.count({ where });
|
||||
const events = await this.prisma.readingEvent.findMany({ where, take: 10000 });
|
||||
return { exported: events.length, total, truncated: total > 10000, format: 'json', data: events };
|
||||
}
|
||||
}
|
||||
54
src/modules/admin-learning/dto/admin-learning.dto.ts
Normal file
54
src/modules/admin-learning/dto/admin-learning.dto.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { IsOptional, IsString, IsInt, Min, Max, IsIn } from 'class-validator';
|
||||
|
||||
export class PaginationQuery {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(100) limit?: number;
|
||||
@IsOptional() @IsString() sortBy?: string;
|
||||
@IsOptional() @IsIn(['asc', 'desc']) order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export class EventFilterQuery extends PaginationQuery {
|
||||
@IsOptional() @IsString() userId?: string;
|
||||
@IsOptional() @IsString() materialId?: string;
|
||||
@IsOptional() @IsString() readingTargetType?: string;
|
||||
@IsOptional() @IsString() eventType?: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
@IsOptional() @IsString() startDate?: string;
|
||||
@IsOptional() @IsString() endDate?: string;
|
||||
}
|
||||
|
||||
export class SessionFilterQuery extends PaginationQuery {
|
||||
@IsOptional() @IsString() userId?: string;
|
||||
@IsOptional() @IsString() materialId?: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
@IsOptional() @IsString() clientSessionId?: string;
|
||||
@IsOptional() @IsString() startDate?: string;
|
||||
@IsOptional() @IsString() endDate?: string;
|
||||
}
|
||||
|
||||
export class ProgressFilterQuery extends PaginationQuery {
|
||||
@IsOptional() @IsString() userId?: string;
|
||||
@IsOptional() @IsString() materialId?: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
}
|
||||
|
||||
export class RecordFilterQuery extends PaginationQuery {
|
||||
@IsOptional() @IsString() userId?: string;
|
||||
@IsOptional() @IsString() recordType?: string;
|
||||
@IsOptional() @IsString() startDate?: string;
|
||||
@IsOptional() @IsString() endDate?: string;
|
||||
}
|
||||
|
||||
export class BatchReprocessDto {
|
||||
@IsString({ each: true }) eventIds!: string[];
|
||||
}
|
||||
|
||||
export class RecalculateDto {
|
||||
@IsOptional() @IsString() userId?: string;
|
||||
}
|
||||
|
||||
export class EventConfigDto {
|
||||
@IsOptional() @IsInt() dedupWindowMinutes?: number;
|
||||
@IsOptional() @IsInt() batchSize?: number;
|
||||
@IsOptional() @IsInt() retryMaxAttempts?: number;
|
||||
}
|
||||
@ -8,11 +8,15 @@ import { RuntimeInternalController } from './internal/runtime-internal.controlle
|
||||
import { RuntimeInternalService } from './internal/runtime-internal.service';
|
||||
import { UserAiQuotaService } from './user-ai-quota.service';
|
||||
import { PlatformBudgetService } from './platform-budget.service';
|
||||
import { SnapshotBuilderService } from './snapshot-builder.service';
|
||||
import { PriorityRulesService } from './priority-rules.service';
|
||||
import { SnapshotCleanupService } from './snapshot-cleanup.service';
|
||||
import { JobReaperService } from './job-reaper.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, PrismaModule],
|
||||
controllers: [UserAiController, RuntimeInternalController],
|
||||
providers: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService],
|
||||
exports: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService],
|
||||
providers: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService],
|
||||
exports: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService],
|
||||
})
|
||||
export class AiRuntimeModule {}
|
||||
|
||||
@ -33,9 +33,8 @@ export class RuntimeInternalController {
|
||||
// ── Heartbeat ──
|
||||
|
||||
@Post('jobs/:jobId/heartbeat')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async heartbeatJob(@Req() req: any, @Param('jobId') jobId: string, @Body() dto: RuntimeHeartbeatRequestDto) {
|
||||
await this.service.heartbeatJob(jobId, dto.runtimeInstanceId || this.instanceId(req));
|
||||
return this.service.heartbeatJob(jobId, dto.runtimeInstanceId || this.instanceId(req));
|
||||
}
|
||||
|
||||
// ── Snapshot ──
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../infrastructure/database/prisma.service';
|
||||
import { UserAiService } from '../user-ai.service';
|
||||
import { SnapshotBuilderService, SOURCE_DATA_VERSION } from '../snapshot-builder.service';
|
||||
|
||||
@Injectable()
|
||||
export class RuntimeInternalService {
|
||||
private readonly logger = new Logger(RuntimeInternalService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly userAi: UserAiService,
|
||||
private readonly snapshotBuilder: SnapshotBuilderService,
|
||||
) {}
|
||||
|
||||
// ── Poll ──
|
||||
@ -32,6 +36,7 @@ export class RuntimeInternalService {
|
||||
select: {
|
||||
id: true, jobType: true, targetType: true, targetId: true,
|
||||
priority: true, snapshotId: true, promptVersion: true, outputSchemaVersion: true,
|
||||
apiKeyMode: true, credentialId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -110,19 +115,33 @@ export class RuntimeInternalService {
|
||||
const now = new Date();
|
||||
const lockUntil = new Date(now.getTime() + 60_000);
|
||||
|
||||
// First heartbeat: locked → running + set startedAt; subsequent: extend lockUntil
|
||||
const result = await this.prisma.aiRuntimeJob.updateMany({
|
||||
where: {
|
||||
id: jobId,
|
||||
lockedBy: runtimeInstanceId,
|
||||
status: { in: ['locked', 'running'] },
|
||||
},
|
||||
data: { lockUntil, status: 'running', startedAt: new Date() },
|
||||
// locked → running transition: set startedAt once
|
||||
const lockedResult = await this.prisma.aiRuntimeJob.updateMany({
|
||||
where: { id: jobId, lockedBy: runtimeInstanceId, status: 'locked' },
|
||||
data: { lockUntil, status: 'running', startedAt: now },
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
// running → running: only extend lockUntil, preserve original startedAt
|
||||
const runningResult = await this.prisma.aiRuntimeJob.updateMany({
|
||||
where: { id: jobId, lockedBy: runtimeInstanceId, status: 'running' },
|
||||
data: { lockUntil },
|
||||
});
|
||||
|
||||
if (lockedResult.count === 0 && runningResult.count === 0) {
|
||||
throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found or not locked by this runtime' });
|
||||
}
|
||||
|
||||
// Check if cancellation was requested
|
||||
const job = await this.prisma.aiRuntimeJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { cancelRequestedAt: true },
|
||||
});
|
||||
|
||||
return {
|
||||
jobId,
|
||||
lockUntil: lockUntil.getTime(),
|
||||
cancelRequested: job?.cancelRequestedAt != null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Snapshot ──
|
||||
@ -130,20 +149,11 @@ export class RuntimeInternalService {
|
||||
async getSnapshot(jobId: string) {
|
||||
const job = await this.prisma.aiRuntimeJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { id: true, snapshotId: true },
|
||||
select: { id: true, userId: true, targetType: true, targetId: true, snapshotId: true },
|
||||
});
|
||||
if (!job) throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found' });
|
||||
if (!job.snapshotId) throw new NotFoundException({ errorCode: 'SNAPSHOT_NOT_FOUND', message: 'No snapshot bound to this job' });
|
||||
|
||||
const snapshot = await this.prisma.learningAnalysisSnapshot.findUnique({
|
||||
where: { id: job.snapshotId },
|
||||
});
|
||||
if (!snapshot) throw new NotFoundException({ errorCode: 'SNAPSHOT_NOT_FOUND', message: 'Snapshot not found' });
|
||||
|
||||
if (snapshot.expiresAt && new Date(snapshot.expiresAt) < new Date()) {
|
||||
throw new NotFoundException({ errorCode: 'SNAPSHOT_EXPIRED', message: 'Snapshot has expired for this job' });
|
||||
}
|
||||
|
||||
const snapshot = await this.resolveSnapshot(job);
|
||||
return {
|
||||
jobId: job.id,
|
||||
snapshotId: snapshot.id,
|
||||
@ -162,6 +172,26 @@ export class RuntimeInternalService {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveSnapshot(job: { id: string; userId: string; targetType: string; targetId: string; snapshotId: string | null }) {
|
||||
if (job.snapshotId) {
|
||||
const existing = await this.prisma.learningAnalysisSnapshot.findUnique({
|
||||
where: { id: job.snapshotId },
|
||||
});
|
||||
if (existing
|
||||
&& existing.sourceDataVersion === SOURCE_DATA_VERSION
|
||||
&& (!existing.expiresAt || new Date(existing.expiresAt) >= new Date())) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await this.snapshotBuilder.buildSnapshot(job.userId, job.targetType, job.targetId);
|
||||
await this.prisma.aiRuntimeJob.update({
|
||||
where: { id: job.id },
|
||||
data: { snapshotId: snapshot.id },
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// ── Credential Resolve ──
|
||||
|
||||
async resolveCredential(jobId: string, apiKeyMode: string, provider: string, credentialId?: string) {
|
||||
@ -236,9 +266,291 @@ export class RuntimeInternalService {
|
||||
data: { status: 'succeeded', finishedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.persistResult(job, dto).catch(err => {
|
||||
this.logger.error(`Result persistence failed for job=${jobId}: ${err.message}`, err.stack);
|
||||
});
|
||||
|
||||
this.notifyJobComplete(job.userId, jobId, job.jobType, 'succeeded').catch(() => {});
|
||||
|
||||
return { status: 'ok', duplicate: false };
|
||||
}
|
||||
|
||||
private async persistResult(job: { id: string; userId: string; jobType: string; targetType: string; targetId: string; snapshotId: string | null; promptVersion: string | null; outputSchemaVersion: string | null }, dto: { validatedOutput?: any }) {
|
||||
const output = dto.validatedOutput;
|
||||
if (!output) return;
|
||||
|
||||
const errors = this.validateOutput(job.jobType, output);
|
||||
if (errors.length > 0) {
|
||||
this.logger.warn(`Output validation warnings for job=${job.id} type=${job.jobType}: ${errors.join('; ')}`);
|
||||
}
|
||||
|
||||
if (job.jobType === 'learning_state_analysis') {
|
||||
await this.prisma.aiLearningAnalysis.create({
|
||||
data: {
|
||||
userId: job.userId, jobId: job.id,
|
||||
snapshotId: job.snapshotId,
|
||||
targetType: job.targetType, targetId: job.targetId,
|
||||
learningState: output.learningState ?? null,
|
||||
summary: output.summary ?? null,
|
||||
riskLevel: output.riskLevel ?? null,
|
||||
confidence: output.confidence ?? null,
|
||||
evidence: output.evidence ?? undefined,
|
||||
nextActionIds: output.nextActionIds ?? undefined,
|
||||
promptVersion: job.promptVersion,
|
||||
schemaVersion: job.outputSchemaVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (job.jobType === 'weak_point_analysis') {
|
||||
// Resolve previous active weak points for same target to avoid duplicate accumulation
|
||||
if (job.targetId) {
|
||||
await this.prisma.weakPointCandidate.updateMany({
|
||||
where: { userId: job.userId, targetType: job.targetType, targetId: job.targetId, status: 'active' },
|
||||
data: { status: 'resolved' },
|
||||
});
|
||||
}
|
||||
const candidates = Array.isArray(output.candidates) ? output.candidates : (output.candidates ? [output.candidates] : []);
|
||||
for (const wp of candidates) {
|
||||
await this.prisma.weakPointCandidate.create({
|
||||
data: {
|
||||
userId: job.userId, jobId: job.id,
|
||||
snapshotId: job.snapshotId,
|
||||
targetType: job.targetType, targetId: job.targetId,
|
||||
knowledgePointId: wp.knowledgePointId ?? null,
|
||||
title: wp.title ?? 'Untitled Weak Point',
|
||||
reason: wp.reason ?? null,
|
||||
confidence: wp.confidence ?? null,
|
||||
evidence: wp.evidence ?? undefined,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (job.jobType === 'next_action_planning') {
|
||||
// Resolve previous active recommendations for same target
|
||||
if (job.targetId) {
|
||||
await this.prisma.nextActionRecommendation.updateMany({
|
||||
where: { userId: job.userId, targetType: job.targetType ?? undefined, targetId: job.targetId ?? undefined, status: 'active' },
|
||||
data: { status: 'resolved' },
|
||||
});
|
||||
}
|
||||
const actions = Array.isArray(output.actions) ? output.actions : (output.actions ? [output.actions] : []);
|
||||
for (const action of actions) {
|
||||
await this.prisma.nextActionRecommendation.create({
|
||||
data: {
|
||||
userId: job.userId, jobId: job.id,
|
||||
snapshotId: job.snapshotId,
|
||||
actionType: action.actionType ?? 'general',
|
||||
targetType: action.targetType ?? null,
|
||||
targetId: action.targetId ?? null,
|
||||
title: action.title ?? 'Untitled Action',
|
||||
reason: action.reason ?? null,
|
||||
priority: action.priority ?? 0,
|
||||
estimatedMinutes: action.estimatedMinutes ?? null,
|
||||
deviceSuitability: action.deviceSuitability ?? null,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (job.jobType === 'quiz_generation') {
|
||||
const questions = Array.isArray(output.questions) ? output.questions : (output.questions ? [output.questions] : []);
|
||||
const questionCount = questions.length || output.count || 0;
|
||||
|
||||
await this.prisma.questionGenerationPlan.updateMany({
|
||||
where: { jobId: job.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
reason: output.summary ?? `Generated ${questionCount} question(s)`,
|
||||
},
|
||||
});
|
||||
|
||||
if (questions.length > 0 && job.targetType === 'knowledge_base') {
|
||||
await this.convertQuizCandidates(job, output, questions);
|
||||
}
|
||||
}
|
||||
|
||||
if (job.jobType === 'flashcard_generation') {
|
||||
const cards = Array.isArray(output.cards) ? output.cards : (output.cards ? [output.cards] : []);
|
||||
const cardCount = cards.length || output.count || 0;
|
||||
|
||||
await this.prisma.flashcardGenerationPlan.updateMany({
|
||||
where: { jobId: job.id },
|
||||
data: {
|
||||
status: 'completed',
|
||||
reason: output.summary ?? `Generated ${cardCount} card(s)`,
|
||||
},
|
||||
});
|
||||
|
||||
if (cards.length > 0) {
|
||||
await this.convertFlashcardCandidates(job, cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output Validation ──
|
||||
|
||||
private validateOutput(jobType: string, output: any): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (jobType === 'learning_state_analysis') {
|
||||
if (!output.learningState) warnings.push('missing learningState');
|
||||
if (output.confidence != null && (output.confidence < 0 || output.confidence > 1)) {
|
||||
warnings.push('confidence out of range [0,1]');
|
||||
}
|
||||
}
|
||||
|
||||
if (jobType === 'weak_point_analysis') {
|
||||
const candidates = Array.isArray(output.candidates) ? output.candidates : (output.candidates ? [output.candidates] : []);
|
||||
if (candidates.length === 0) warnings.push('no candidates in output');
|
||||
for (const wp of candidates) {
|
||||
if (!wp.title && !wp.knowledgePointId) warnings.push('candidate missing title and knowledgePointId');
|
||||
}
|
||||
}
|
||||
|
||||
if (jobType === 'next_action_planning') {
|
||||
const actions = Array.isArray(output.actions) ? output.actions : (output.actions ? [output.actions] : []);
|
||||
if (actions.length === 0) warnings.push('no actions in output');
|
||||
for (const a of actions) {
|
||||
if (!a.title) warnings.push('action missing title');
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// ── Quiz Candidate Conversion ──
|
||||
|
||||
private async convertQuizCandidates(
|
||||
job: { userId: string; id: string; targetId: string },
|
||||
output: { quizTitle?: string; quizDescription?: string },
|
||||
questions: any[],
|
||||
) {
|
||||
const valid = questions.filter(q => (q.stem || q.question) && (q.answer || q.correctAnswer));
|
||||
const skipped = questions.length - valid.length;
|
||||
if (skipped > 0) {
|
||||
this.logger.warn(`convertQuizCandidates: skipped ${skipped} question(s) missing stem or answer for job=${job.id}`);
|
||||
}
|
||||
if (valid.length === 0) return;
|
||||
|
||||
const quiz = await this.prisma.quiz.create({
|
||||
data: {
|
||||
userId: job.userId,
|
||||
knowledgeBaseId: job.targetId,
|
||||
title: output.quizTitle ?? 'AI Generated Quiz',
|
||||
description: output.quizDescription ?? null,
|
||||
questionCount: valid.length,
|
||||
sourceType: 'ai',
|
||||
sourceId: job.id,
|
||||
status: 'ready',
|
||||
},
|
||||
});
|
||||
|
||||
let created = 0;
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
const q = valid[i];
|
||||
const stem = q.stem ?? q.question;
|
||||
|
||||
// Dedup: skip if same stem already exists for this user in any quiz
|
||||
const existing = await this.prisma.quizQuestion.findFirst({
|
||||
where: {
|
||||
stem,
|
||||
quiz: { userId: job.userId },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
this.logger.warn(`convertQuizCandidates: skipping duplicate question stem="${stem.substring(0, 50)}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.quizQuestion.create({
|
||||
data: {
|
||||
quizId: quiz.id,
|
||||
type: q.type ?? 'choice',
|
||||
stem,
|
||||
options: q.options ?? undefined,
|
||||
answer: q.answer ?? q.correctAnswer,
|
||||
explanation: q.explanation ?? null,
|
||||
sourceBlockIds: Array.isArray(q.sourceBlockIds) ? q.sourceBlockIds : undefined,
|
||||
orderIndex: created,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
if (created < valid.length) {
|
||||
await this.prisma.quiz.update({
|
||||
where: { id: quiz.id },
|
||||
data: { questionCount: created },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flashcard Candidate Conversion ──
|
||||
|
||||
private async convertFlashcardCandidates(
|
||||
job: { userId: string; id: string; targetId: string; promptVersion: string | null; outputSchemaVersion: string | null },
|
||||
cards: any[],
|
||||
) {
|
||||
const valid = cards.filter(c => (c.front || c.question) && (c.back || c.answer));
|
||||
const skipped = cards.length - valid.length;
|
||||
if (skipped > 0) {
|
||||
this.logger.warn(`convertFlashcardCandidates: skipped ${skipped} card(s) missing front/back for job=${job.id}`);
|
||||
}
|
||||
|
||||
// Validate sourceBlockIds: resolve referenced knowledge items exist
|
||||
const allBlockIds = [...new Set(valid.flatMap(c => (Array.isArray(c.sourceBlockIds) ? c.sourceBlockIds : [])))];
|
||||
const existingBlockIds = allBlockIds.length > 0
|
||||
? new Set(
|
||||
(await this.prisma.knowledgeItem.findMany({
|
||||
where: { id: { in: allBlockIds }, userId: job.userId },
|
||||
select: { id: true },
|
||||
})).map(k => k.id),
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
for (const card of valid) {
|
||||
const rawIds: string[] = Array.isArray(card.sourceBlockIds) ? card.sourceBlockIds : [];
|
||||
const validIds = rawIds.filter(id => existingBlockIds.has(id));
|
||||
if (rawIds.length > 0 && validIds.length < rawIds.length) {
|
||||
this.logger.warn(`convertFlashcardCandidates: filtered ${rawIds.length - validIds.length} invalid sourceBlockIds`);
|
||||
}
|
||||
|
||||
|
||||
// Dedup: skip if same front already exists for this user
|
||||
const front = card.front ?? card.question;
|
||||
const dupCheck = await this.prisma.flashcard.findFirst({
|
||||
where: { userId: job.userId, front, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (dupCheck) {
|
||||
this.logger.warn(`convertFlashcardCandidates: skipping duplicate front="${front.substring(0, 50)}"`);
|
||||
continue;
|
||||
}
|
||||
await this.prisma.flashcard.create({
|
||||
data: {
|
||||
userId: job.userId,
|
||||
sourceType: 'ai',
|
||||
sourceId: job.id,
|
||||
knowledgePointId: card.knowledgePointId ?? null,
|
||||
front: card.front ?? card.question,
|
||||
back: card.back ?? card.answer,
|
||||
hint: card.hint ?? null,
|
||||
difficultyLevel: card.difficultyLevel ?? null,
|
||||
sourceBlockIds: validIds.length > 0 ? validIds : undefined,
|
||||
generatedByJobId: job.id,
|
||||
promptVersion: job.promptVersion,
|
||||
schemaVersion: job.outputSchemaVersion,
|
||||
status: 'draft',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fail ──
|
||||
|
||||
async submitFailure(jobId: string, dto: {
|
||||
@ -251,6 +563,14 @@ export class RuntimeInternalService {
|
||||
throw new ConflictException({ errorCode: 'JOB_NOT_ACTIVE', message: `Job is ${job.status}, cannot accept failure` });
|
||||
}
|
||||
|
||||
if (dto.errorCode === 'JOB_CANCELLED') {
|
||||
await this.prisma.aiRuntimeJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: 'cancelled', cancelledAt: new Date(), finishedAt: new Date() },
|
||||
});
|
||||
return { status: 'cancelled' };
|
||||
}
|
||||
|
||||
const newRetryCount = job.retryCount + 1;
|
||||
const exceeded = newRetryCount > job.maxRetryCount;
|
||||
|
||||
@ -278,9 +598,34 @@ export class RuntimeInternalService {
|
||||
});
|
||||
}
|
||||
|
||||
if (exceeded) {
|
||||
this.notifyJobComplete(job.userId, jobId, job.jobType, 'failed').catch(() => {});
|
||||
}
|
||||
|
||||
return { status: exceeded ? 'failed' : 'pending', retryCount: newRetryCount };
|
||||
}
|
||||
|
||||
// ── Job Completion Notification ──
|
||||
|
||||
private async notifyJobComplete(userId: string, jobId: string, jobType: string, status: string) {
|
||||
const title = status === 'succeeded'
|
||||
? `AI analysis complete`
|
||||
: `AI analysis failed`;
|
||||
const content = status === 'succeeded'
|
||||
? `Your ${jobType} job has completed successfully.`
|
||||
: `Your ${jobType} job has failed.`;
|
||||
|
||||
await this.prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type: status === 'succeeded' ? 'ai_job_succeeded' : 'ai_job_failed',
|
||||
title,
|
||||
content,
|
||||
data: { jobId, jobType, status },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Invocation Logs ──
|
||||
|
||||
async submitInvocationLogs(logs: Array<{
|
||||
|
||||
77
src/modules/ai-runtime/job-reaper.service.spec.ts
Normal file
77
src/modules/ai-runtime/job-reaper.service.spec.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { JobReaperService } from './job-reaper.service';
|
||||
|
||||
describe('JobReaperService', () => {
|
||||
let service: JobReaperService;
|
||||
let mockUpdateMany: jest.Mock;
|
||||
let mockFindMany: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateMany = jest.fn();
|
||||
mockFindMany = jest.fn();
|
||||
const mockPrisma = {
|
||||
aiRuntimeJob: { updateMany: mockUpdateMany, findMany: mockFindMany },
|
||||
} as any;
|
||||
service = new JobReaperService(mockPrisma);
|
||||
jest.spyOn(global, 'setInterval').mockReturnValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('expires locked jobs past lockUntil', async () => {
|
||||
mockUpdateMany.mockResolvedValue({ count: 2 });
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.reap();
|
||||
|
||||
expect(mockUpdateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { status: 'locked', lockUntil: { lt: expect.any(Date) } },
|
||||
data: { status: 'expired' },
|
||||
}));
|
||||
expect(result.expired).toBe(2);
|
||||
});
|
||||
|
||||
it('expires running jobs past timeout', async () => {
|
||||
const now = Date.now();
|
||||
mockUpdateMany.mockResolvedValueOnce({ count: 0 }); // locked
|
||||
mockFindMany.mockResolvedValueOnce([
|
||||
{ id: 'j1', startedAt: new Date(now - 180_000), timeoutSeconds: 120, retryCount: 0, maxRetryCount: 3 },
|
||||
]);
|
||||
mockUpdateMany.mockResolvedValueOnce({ count: 1 }); // running expired
|
||||
mockFindMany.mockResolvedValueOnce([]); // expired jobs
|
||||
|
||||
const result = await service.reap();
|
||||
|
||||
expect(mockUpdateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: { in: ['j1'] }, status: 'running' },
|
||||
data: { status: 'expired' },
|
||||
}));
|
||||
expect(result.expired).toBe(1);
|
||||
});
|
||||
|
||||
it('retries expired jobs with remaining retries', async () => {
|
||||
mockUpdateMany.mockResolvedValueOnce({ count: 0 }); // locked
|
||||
mockFindMany.mockResolvedValueOnce([]); // running
|
||||
mockFindMany.mockResolvedValueOnce([
|
||||
{ id: 'j1', retryCount: 0, maxRetryCount: 3 },
|
||||
{ id: 'j2', retryCount: 3, maxRetryCount: 3 },
|
||||
]);
|
||||
mockUpdateMany.mockResolvedValueOnce({ count: 1 }); // retry j1
|
||||
mockUpdateMany.mockResolvedValueOnce({ count: 1 }); // fail j2
|
||||
|
||||
const result = await service.reap();
|
||||
|
||||
expect(result.retried).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
});
|
||||
|
||||
it('handles no stuck jobs gracefully', async () => {
|
||||
mockUpdateMany.mockResolvedValue({ count: 0 });
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.reap();
|
||||
|
||||
expect(result).toEqual({ expired: 0, retried: 0, failed: 0 });
|
||||
});
|
||||
});
|
||||
98
src/modules/ai-runtime/job-reaper.service.ts
Normal file
98
src/modules/ai-runtime/job-reaper.service.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
const REAP_INTERVAL_MS = 30_000; // every 30 seconds
|
||||
|
||||
@Injectable()
|
||||
export class JobReaperService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(JobReaperService.name);
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.reap().catch(() => {});
|
||||
this.timer = setInterval(() => this.reap().catch(() => {}), REAP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
/** Recover jobs stuck in locked or running state past their timeout. */
|
||||
async reap(): Promise<{ expired: number; retried: number; failed: number }> {
|
||||
const now = new Date();
|
||||
|
||||
// 1. Expire stuck locked jobs (lockUntil passed)
|
||||
const expiredLocks = await this.prisma.aiRuntimeJob.updateMany({
|
||||
where: { status: 'locked', lockUntil: { lt: now } },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
|
||||
// 2. Expire stuck running jobs (startedAt + timeoutSeconds < now)
|
||||
// We can't do arithmetic in Prisma where, so fetch IDs and update
|
||||
const stuckRunning = await this.prisma.aiRuntimeJob.findMany({
|
||||
where: { status: 'running' },
|
||||
select: { id: true, startedAt: true, timeoutSeconds: true, retryCount: true, maxRetryCount: true },
|
||||
take: 500,
|
||||
});
|
||||
const stuckIds = stuckRunning
|
||||
.filter(j => j.startedAt && (now.getTime() - j.startedAt.getTime()) > j.timeoutSeconds * 1000)
|
||||
.map(j => j.id);
|
||||
|
||||
let expiredRunning = 0;
|
||||
if (stuckIds.length > 0) {
|
||||
const result = await this.prisma.aiRuntimeJob.updateMany({
|
||||
where: { id: { in: stuckIds }, status: 'running' },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
expiredRunning = result.count;
|
||||
}
|
||||
|
||||
// 3. Retry expired jobs where retryCount < maxRetryCount
|
||||
// Prisma doesn't support comparing two columns in where, so fetch and batch
|
||||
const expiredJobs = await this.prisma.aiRuntimeJob.findMany({
|
||||
where: { status: 'expired' },
|
||||
select: { id: true, retryCount: true, maxRetryCount: true },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
const retryIds = expiredJobs.filter(j => j.retryCount < j.maxRetryCount).map(j => j.id);
|
||||
const failIds = expiredJobs.filter(j => j.retryCount >= j.maxRetryCount).map(j => j.id);
|
||||
|
||||
let retried = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (retryIds.length > 0) {
|
||||
const result = await this.prisma.aiRuntimeJob.updateMany({
|
||||
where: { id: { in: retryIds }, status: 'expired' },
|
||||
data: {
|
||||
status: 'pending',
|
||||
lockedBy: null,
|
||||
lockedAt: null,
|
||||
lockUntil: null,
|
||||
retryCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
retried = result.count;
|
||||
}
|
||||
|
||||
if (failIds.length > 0) {
|
||||
const result = await this.prisma.aiRuntimeJob.updateMany({
|
||||
where: { id: { in: failIds }, status: 'expired' },
|
||||
data: { status: 'failed', finishedAt: new Date() },
|
||||
});
|
||||
failed = result.count;
|
||||
}
|
||||
|
||||
const total = expiredLocks.count + expiredRunning + retried + failed;
|
||||
if (total > 0) {
|
||||
this.logger.log(
|
||||
`Reaped: ${expiredLocks.count} locked expired, ${expiredRunning} running expired, ` +
|
||||
`${retried} retried → pending, ${failed} failed`,
|
||||
);
|
||||
}
|
||||
|
||||
return { expired: expiredLocks.count + expiredRunning, retried, failed };
|
||||
}
|
||||
}
|
||||
240
src/modules/ai-runtime/priority-rules.service.spec.ts
Normal file
240
src/modules/ai-runtime/priority-rules.service.spec.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import { PriorityRulesService } from './priority-rules.service';
|
||||
|
||||
describe('PriorityRulesService', () => {
|
||||
let service: PriorityRulesService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PriorityRulesService();
|
||||
});
|
||||
|
||||
// ── computePriorityRules ──
|
||||
|
||||
describe('computePriorityRules', () => {
|
||||
it('null profile → all defaults', () => {
|
||||
const rules = service.computePriorityRules(null, null, 'material', 'm1');
|
||||
expect(rules.depthPreference).toBe('standard');
|
||||
expect(rules.learningGoal).toBeNull();
|
||||
expect(rules.hasExamTarget).toBe(false);
|
||||
expect(rules.daysUntilDeadline).toBeNull();
|
||||
expect(rules.isTimeConstrained).toBe(false);
|
||||
expect(rules.dailyAvailableMinutes).toBeNull();
|
||||
expect(rules.isBudgetConstrained).toBe(false);
|
||||
});
|
||||
|
||||
it('null settings → isBudgetConstrained=false', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: 'deep' },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.isBudgetConstrained).toBe(false);
|
||||
});
|
||||
|
||||
it('exam mode with deadline → hasExamTarget + daysUntilDeadline computed', () => {
|
||||
const future = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000);
|
||||
const rules = service.computePriorityRules(
|
||||
{
|
||||
qualityPreference: 'exam',
|
||||
examTarget: 'AWS Solutions Architect',
|
||||
learningDeadline: future,
|
||||
},
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.depthPreference).toBe('exam');
|
||||
expect(rules.hasExamTarget).toBe(true);
|
||||
expect(rules.daysUntilDeadline).toBeGreaterThanOrEqual(9);
|
||||
expect(rules.daysUntilDeadline).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('deadline as ISO string → daysUntilDeadline computed correctly', () => {
|
||||
const futureStr = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const rules = service.computePriorityRules(
|
||||
{ examTarget: 'test', learningDeadline: futureStr },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.daysUntilDeadline).toBeGreaterThanOrEqual(9);
|
||||
expect(rules.daysUntilDeadline).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('deadline already expired → daysUntilDeadline=0', () => {
|
||||
const past = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||
const rules = service.computePriorityRules(
|
||||
{ examTarget: 'test', learningDeadline: past },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.daysUntilDeadline).toBe(0);
|
||||
});
|
||||
|
||||
it('dailyAvailableMinutes=10 → isTimeConstrained=true', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ dailyAvailableMinutes: 10 },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.isTimeConstrained).toBe(true);
|
||||
});
|
||||
|
||||
it('dailyAvailableMinutes=20 → isTimeConstrained=false', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ dailyAvailableMinutes: 20 },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.isTimeConstrained).toBe(false);
|
||||
});
|
||||
|
||||
it('qualityPreference=light → taskSuitability only lightReview=true, rest restricted', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: 'light' },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.taskSuitability.lightReview).toBe(true);
|
||||
expect(rules.taskSuitability.deepAnalysis).toBe(false);
|
||||
expect(rules.taskSuitability.quizGeneration).toBe(false);
|
||||
expect(rules.taskSuitability.flashcardGeneration).toBe(false);
|
||||
expect(rules.taskSuitability.contentSummarization).toBe(false);
|
||||
});
|
||||
|
||||
it('qualityPreference=light with examTarget → quiz/flashcard enabled despite light mode', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: 'light', examTarget: 'Exam' },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.taskSuitability.quizGeneration).toBe(true);
|
||||
expect(rules.taskSuitability.flashcardGeneration).toBe(true);
|
||||
expect(rules.taskSuitability.deepAnalysis).toBe(false);
|
||||
});
|
||||
|
||||
it('deadline within 14 days + examTarget → urgentExamMode=true', () => {
|
||||
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const rules = service.computePriorityRules(
|
||||
{ examTarget: 'Exam', learningDeadline: future },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.taskSuitability.urgentExamMode).toBe(true);
|
||||
});
|
||||
|
||||
it('deadline 30 days away → urgentExamMode=false', () => {
|
||||
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
const rules = service.computePriorityRules(
|
||||
{ examTarget: 'Exam', learningDeadline: future },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.taskSuitability.urgentExamMode).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown qualityPreference → falls back to standard with warning', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: 'fast' },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.depthPreference).toBe('standard');
|
||||
});
|
||||
|
||||
it('maxDailyAiJobs=3 → isBudgetConstrained=true', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
null,
|
||||
{ maxDailyAiJobs: 3 },
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.isBudgetConstrained).toBe(true);
|
||||
});
|
||||
|
||||
it('maxDailyTokenBudget=5000 → isBudgetConstrained=true', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
null,
|
||||
{ maxDailyTokenBudget: 5000 },
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.isBudgetConstrained).toBe(true);
|
||||
});
|
||||
|
||||
it('knowledge_base scope enables contentSummarization for deep mode', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: 'deep' },
|
||||
null,
|
||||
'knowledge_base',
|
||||
'kb1',
|
||||
);
|
||||
expect(rules.taskSuitability.contentSummarization).toBe(true);
|
||||
});
|
||||
|
||||
it('material scope disables contentSummarization even for deep mode', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: 'deep' },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.taskSuitability.contentSummarization).toBe(false);
|
||||
});
|
||||
|
||||
it('learningGoal is passed through', () => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ learningGoal: 'Pass AWS exam' },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
expect(rules.learningGoal).toBe('Pass AWS exam');
|
||||
});
|
||||
});
|
||||
|
||||
// ── computeTaskSuitability matrix ──
|
||||
|
||||
describe('taskSuitability matrix', () => {
|
||||
const qualities = ['light', 'standard', 'deep', 'exam'] as const;
|
||||
|
||||
it.each(qualities)('%s without time constraint → correct suitability', (qp) => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: qp, dailyAvailableMinutes: 30 },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
|
||||
expect(rules.taskSuitability.lightReview).toBe(true);
|
||||
expect(rules.taskSuitability.deepAnalysis).toBe(qp !== 'light');
|
||||
expect(rules.taskSuitability.quizGeneration).toBe(qp !== 'light');
|
||||
expect(rules.taskSuitability.flashcardGeneration).toBe(qp !== 'light');
|
||||
expect(rules.taskSuitability.contentSummarization).toBe(false); // material scope
|
||||
expect(rules.taskSuitability.urgentExamMode).toBe(false); // no exam
|
||||
});
|
||||
|
||||
it.each(qualities)('%s with time constraint (10 min) → correct suitability', (qp) => {
|
||||
const rules = service.computePriorityRules(
|
||||
{ qualityPreference: qp, dailyAvailableMinutes: 10 },
|
||||
null,
|
||||
'material',
|
||||
'm1',
|
||||
);
|
||||
|
||||
expect(rules.isTimeConstrained).toBe(true);
|
||||
expect(rules.taskSuitability.lightReview).toBe(true);
|
||||
expect(rules.taskSuitability.deepAnalysis).toBe(false); // time-constrained → no deep
|
||||
expect(rules.taskSuitability.quizGeneration).toBe(qp !== 'light');
|
||||
expect(rules.taskSuitability.flashcardGeneration).toBe(qp !== 'light');
|
||||
});
|
||||
});
|
||||
});
|
||||
157
src/modules/ai-runtime/priority-rules.service.ts
Normal file
157
src/modules/ai-runtime/priority-rules.service.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
const VALID_DEPTH_PREFERENCES = ['light', 'standard', 'deep', 'exam'] as const;
|
||||
type DepthPreference = (typeof VALID_DEPTH_PREFERENCES)[number];
|
||||
|
||||
const TIME_CONSTRAINED_THRESHOLD_MINUTES = 15;
|
||||
const EXAM_URGENT_DAYS = 14;
|
||||
|
||||
interface UserProfileSlice {
|
||||
qualityPreference?: string | null;
|
||||
dailyAvailableMinutes?: number | null;
|
||||
learningGoal?: string | null;
|
||||
examTarget?: string | null;
|
||||
learningDeadline?: Date | string | null;
|
||||
}
|
||||
|
||||
interface UserSettingsSlice {
|
||||
maxDailyAiJobs?: number | null;
|
||||
maxDailyTokenBudget?: number | null;
|
||||
}
|
||||
|
||||
export interface PriorityRules {
|
||||
version: string;
|
||||
depthPreference: DepthPreference;
|
||||
learningGoal: string | null;
|
||||
hasExamTarget: boolean;
|
||||
daysUntilDeadline: number | null;
|
||||
isTimeConstrained: boolean;
|
||||
dailyAvailableMinutes: number | null;
|
||||
isBudgetConstrained: boolean;
|
||||
taskSuitability: {
|
||||
lightReview: boolean;
|
||||
deepAnalysis: boolean;
|
||||
quizGeneration: boolean;
|
||||
flashcardGeneration: boolean;
|
||||
contentSummarization: boolean;
|
||||
urgentExamMode: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PriorityRulesService {
|
||||
private readonly logger = new Logger(PriorityRulesService.name);
|
||||
|
||||
computePriorityRules(
|
||||
profile: UserProfileSlice | null,
|
||||
settings: UserSettingsSlice | null,
|
||||
targetType: string,
|
||||
targetId: string, // reserved: future per-resource priority overrides (API-AI-017 follow-up)
|
||||
): PriorityRules {
|
||||
const qualityPreference = this.normalizeDepthPreference(profile?.qualityPreference);
|
||||
const dailyAvailableMinutes = profile?.dailyAvailableMinutes ?? null;
|
||||
const learningGoal = profile?.learningGoal ?? null;
|
||||
const examTarget = profile?.examTarget ?? null;
|
||||
const learningDeadline = profile?.learningDeadline ?? null;
|
||||
|
||||
const hasExamTarget = !!examTarget;
|
||||
const daysUntilDeadline = learningDeadline
|
||||
? Math.max(0, Math.ceil((new Date(learningDeadline).getTime() - Date.now()) / (24 * 60 * 60 * 1000)))
|
||||
: null;
|
||||
const isTimeConstrained = dailyAvailableMinutes !== null
|
||||
&& dailyAvailableMinutes <= TIME_CONSTRAINED_THRESHOLD_MINUTES;
|
||||
|
||||
const maxDailyAiJobs = settings?.maxDailyAiJobs ?? null;
|
||||
const maxDailyTokenBudget = settings?.maxDailyTokenBudget ?? null;
|
||||
const isBudgetConstrained = (maxDailyAiJobs !== null && maxDailyAiJobs <= 3)
|
||||
|| (maxDailyTokenBudget !== null && maxDailyTokenBudget <= 10000);
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
depthPreference: qualityPreference,
|
||||
learningGoal,
|
||||
hasExamTarget,
|
||||
daysUntilDeadline,
|
||||
isTimeConstrained,
|
||||
dailyAvailableMinutes,
|
||||
isBudgetConstrained,
|
||||
taskSuitability: this.computeTaskSuitability(
|
||||
qualityPreference,
|
||||
isTimeConstrained,
|
||||
isBudgetConstrained,
|
||||
hasExamTarget,
|
||||
daysUntilDeadline,
|
||||
targetType,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Depth Preference Normalization ──
|
||||
|
||||
private normalizeDepthPreference(raw: string | null | undefined): DepthPreference {
|
||||
if (!raw) return 'standard';
|
||||
const lower = raw.toLowerCase();
|
||||
if ((VALID_DEPTH_PREFERENCES as readonly string[]).includes(lower)) {
|
||||
return lower as DepthPreference;
|
||||
}
|
||||
this.logger.warn(`Unknown qualityPreference="${raw}", falling back to "standard"`);
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
// ── Task Suitability ──
|
||||
|
||||
private computeTaskSuitability(
|
||||
qualityPreference: DepthPreference,
|
||||
isTimeConstrained: boolean,
|
||||
isBudgetConstrained: boolean,
|
||||
hasExamTarget: boolean,
|
||||
daysUntilDeadline: number | null,
|
||||
targetType: string,
|
||||
) {
|
||||
const isUrgent = hasExamTarget && daysUntilDeadline !== null && daysUntilDeadline <= EXAM_URGENT_DAYS;
|
||||
|
||||
// Scope-based adjustments: knowledge_base scope favors content summarization;
|
||||
// material scope favors light review
|
||||
const isKnowledgeBaseScope = targetType === 'knowledge_base';
|
||||
|
||||
return {
|
||||
lightReview: true,
|
||||
|
||||
deepAnalysis: qualityPreference !== 'light'
|
||||
&& !isTimeConstrained
|
||||
&& !isBudgetConstrained,
|
||||
|
||||
quizGeneration: qualityPreference !== 'light' || hasExamTarget,
|
||||
|
||||
flashcardGeneration: qualityPreference !== 'light' || hasExamTarget,
|
||||
|
||||
contentSummarization: (qualityPreference === 'deep' || qualityPreference === 'exam')
|
||||
&& isKnowledgeBaseScope,
|
||||
|
||||
urgentExamMode: isUrgent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute numeric job priority for poll ordering (lower = higher priority).
|
||||
* Must be called after computePriorityRules to get taskSuitability.
|
||||
*/
|
||||
computeJobPriority(
|
||||
profile: UserProfileSlice | null,
|
||||
settings: UserSettingsSlice | null,
|
||||
targetType: string,
|
||||
): number {
|
||||
const rules = this.computePriorityRules(profile, settings, targetType, '');
|
||||
const { taskSuitability, hasExamTarget, depthPreference, isTimeConstrained } = rules;
|
||||
|
||||
if (taskSuitability.urgentExamMode) return 0;
|
||||
if (hasExamTarget) return 10;
|
||||
if (depthPreference === 'deep' || depthPreference === 'exam') return 20;
|
||||
|
||||
let priority = 50;
|
||||
if (isTimeConstrained && priority > 10) priority -= 5;
|
||||
if (targetType === 'knowledge_base' && priority > 5) priority -= 5;
|
||||
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
901
src/modules/ai-runtime/snapshot-builder.service.spec.ts
Normal file
901
src/modules/ai-runtime/snapshot-builder.service.spec.ts
Normal file
@ -0,0 +1,901 @@
|
||||
import { SnapshotBuilderService } from './snapshot-builder.service';
|
||||
import { PriorityRulesService } from './priority-rules.service';
|
||||
|
||||
function mockPrisma() {
|
||||
return {
|
||||
userAiSettings: { findUnique: jest.fn() },
|
||||
userLearningProfile: { findUnique: jest.fn() },
|
||||
learningAnalysisSnapshot: { create: jest.fn(), findUnique: jest.fn(), findMany: jest.fn() },
|
||||
learningSession: { aggregate: jest.fn(), count: jest.fn(), findMany: jest.fn() },
|
||||
dailyLearningActivity: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn() },
|
||||
learningRecord: { findMany: jest.fn() },
|
||||
knowledgeItem: { findMany: jest.fn() },
|
||||
quizAttempt: { aggregate: jest.fn(), findMany: jest.fn() },
|
||||
reviewLog: { findMany: jest.fn() },
|
||||
aiLearningAnalysis: { findMany: jest.fn() },
|
||||
readingEvent: { findFirst: jest.fn(), findMany: jest.fn() },
|
||||
userDevice: { findMany: jest.fn() },
|
||||
aiRuntimeJob: { findUnique: jest.fn(), update: jest.fn() },
|
||||
streakRecord: { findMany: jest.fn() },
|
||||
weakPointCandidate: { findMany: jest.fn() },
|
||||
materialReadingProgress: { aggregate: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockPriorityRules() {
|
||||
return { computePriorityRules: jest.fn().mockReturnValue({ version: '1.0', depthPreference: 'standard' }) } as any;
|
||||
}
|
||||
|
||||
function seedDefaults(prisma: ReturnType<typeof mockPrisma>) {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({
|
||||
id: 's1', userId: 'u1',
|
||||
allowUseDocumentContent: false,
|
||||
allowUseLearningBehavior: true,
|
||||
allowUseUserProfile: true,
|
||||
allowAiAnalysis: true,
|
||||
allowStoreAiAnalysisHistory: true,
|
||||
apiKeyMode: 'platform_key',
|
||||
defaultCredentialId: null,
|
||||
fallbackToPlatformKey: true,
|
||||
maxDailyAiJobs: 20,
|
||||
maxDailyTokenBudget: 100000,
|
||||
});
|
||||
prisma.userLearningProfile.findUnique.mockResolvedValue({
|
||||
id: 'p1', userId: 'u1',
|
||||
learningGoal: 'Learn TypeScript',
|
||||
currentLevel: 'intermediate',
|
||||
dailyAvailableMinutes: 30,
|
||||
qualityPreference: 'standard',
|
||||
ageRange: '25-34',
|
||||
preferredLanguage: 'zh',
|
||||
learningStyle: 'visual',
|
||||
examTarget: null,
|
||||
preferredQuestionTypes: ['single_choice'],
|
||||
occupation: null,
|
||||
learningDeadline: null,
|
||||
aiAcceptanceLevel: null,
|
||||
digitalSkillLevel: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
prisma.learningSession.aggregate.mockResolvedValue({ _count: 5, _sum: { totalActiveSeconds: 3600 } });
|
||||
prisma.learningSession.count.mockResolvedValue(2);
|
||||
prisma.learningSession.findMany.mockResolvedValue([]);
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([]);
|
||||
prisma.dailyLearningActivity.count.mockResolvedValue(0);
|
||||
prisma.dailyLearningActivity.findUnique.mockResolvedValue(null);
|
||||
prisma.learningRecord.findMany.mockResolvedValue([]);
|
||||
prisma.materialReadingProgress.findMany.mockResolvedValue([]);
|
||||
prisma.materialReadingProgress.findFirst.mockResolvedValue(null);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
prisma.quizAttempt.aggregate.mockResolvedValue({ _count: 0, _avg: { score: null, correctCount: null, totalQuestions: null } });
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([]);
|
||||
prisma.reviewLog.findMany.mockResolvedValue([]);
|
||||
prisma.aiLearningAnalysis.findMany.mockResolvedValue([]);
|
||||
prisma.readingEvent.findFirst.mockResolvedValue(null);
|
||||
prisma.readingEvent.findMany.mockResolvedValue([]);
|
||||
prisma.userDevice.findMany.mockResolvedValue([]);
|
||||
prisma.streakRecord.findMany.mockResolvedValue([]);
|
||||
prisma.weakPointCandidate.findMany.mockResolvedValue([]);
|
||||
prisma.materialReadingProgress.aggregate.mockResolvedValue({ _sum: { totalActiveSeconds: 0, sessionCount: 0 }, _count: 0 });
|
||||
prisma.learningAnalysisSnapshot.create.mockImplementation((args: any) =>
|
||||
Promise.resolve({ id: 'snap-1', ...args.data, createdAt: new Date() }));
|
||||
}
|
||||
|
||||
describe('SnapshotBuilderService', () => {
|
||||
let service: SnapshotBuilderService;
|
||||
let prisma: ReturnType<typeof mockPrisma>;
|
||||
let priorityRules: ReturnType<typeof mockPriorityRules>;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = mockPrisma();
|
||||
priorityRules = mockPriorityRules();
|
||||
service = new SnapshotBuilderService(prisma, priorityRules);
|
||||
seedDefaults(prisma);
|
||||
});
|
||||
|
||||
// ── buildSnapshot ──
|
||||
|
||||
describe('buildSnapshot', () => {
|
||||
it('creates snapshot with all required fields', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
|
||||
expect(snap.id).toBe('snap-1');
|
||||
expect(snap.userId).toBe('u1');
|
||||
expect(snap.scopeType).toBe('material');
|
||||
expect(snap.scopeId).toBe('m1');
|
||||
expect(snap.snapshotVersion).toBe('ai_snapshot_v1');
|
||||
expect(snap.sourceDataVersion).toBe('1.0');
|
||||
expect(snap.constraints).toMatchObject({
|
||||
dailyAvailableMinutes: 30,
|
||||
qualityPreference: 'standard',
|
||||
learningGoal: 'Learn TypeScript',
|
||||
priorityRules: { version: '1.0', depthPreference: 'standard' },
|
||||
});
|
||||
expect(snap.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('sets userProfile when allowUseUserProfile=true', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.userProfile).toEqual({
|
||||
learningGoal: 'Learn TypeScript',
|
||||
currentLevel: 'intermediate',
|
||||
qualityPreference: 'standard',
|
||||
ageRange: '25-34',
|
||||
preferredLanguage: 'zh',
|
||||
learningStyle: 'visual',
|
||||
examTarget: null,
|
||||
preferredQuestionTypes: ['single_choice'],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits userProfile when allowUseUserProfile=false', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({
|
||||
...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })),
|
||||
allowUseUserProfile: false,
|
||||
});
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.userProfile).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits behavior fields when allowUseLearningBehavior=false', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({
|
||||
...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })),
|
||||
allowUseLearningBehavior: false,
|
||||
});
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.learningBehaviorSummary).toBeUndefined();
|
||||
expect(snap.behaviorSignals).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes content structure when allowUseDocumentContent=true', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({
|
||||
...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })),
|
||||
allowUseDocumentContent: true,
|
||||
});
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([
|
||||
{ id: 'ki1', itemType: 'note', title: 'Chapter 1', summary: 'Intro', learnable: true, orderIndex: 0, durationSeconds: 300 },
|
||||
]);
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'knowledge_base', 'kb1');
|
||||
expect(snap.contentStructureSummary).toEqual({
|
||||
itemCount: 1,
|
||||
items: [{ id: 'ki1', itemType: 'note', title: 'Chapter 1', summary: 'Intro', learnable: true, orderIndex: 0, durationSeconds: 300 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits content structure when allowUseDocumentContent=false (default)', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'knowledge_base', 'kb1');
|
||||
expect(snap.contentStructureSummary).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes priority rules into constraints', async () => {
|
||||
priorityRules.computePriorityRules.mockReturnValue({
|
||||
version: '1.0', depthPreference: 'deep', learningGoal: 'Test', hasExamTarget: false,
|
||||
daysUntilDeadline: null, isTimeConstrained: false, dailyAvailableMinutes: 30,
|
||||
isBudgetConstrained: false, taskSuitability: { lightReview: true, deepAnalysis: true, quizGeneration: true, flashcardGeneration: true, contentSummarization: false, urgentExamMode: false },
|
||||
});
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.constraints.priorityRules.depthPreference).toBe('deep');
|
||||
});
|
||||
|
||||
it('computes scores from quiz + review + analysis data', async () => {
|
||||
prisma.quizAttempt.aggregate.mockResolvedValue({ _count: 3, _avg: { score: 80, correctCount: 8, totalQuestions: 10 } });
|
||||
prisma.reviewLog.findMany.mockResolvedValue([
|
||||
{ rating: 'good', reviewedAt: new Date() },
|
||||
{ rating: 'good', reviewedAt: new Date() },
|
||||
{ rating: 'hard', reviewedAt: new Date() },
|
||||
]);
|
||||
prisma.aiLearningAnalysis.findMany.mockResolvedValue([
|
||||
{ id: 'a1', targetType: 'material', targetId: 'm1', learningState: 'mastering', riskLevel: 'low', confidence: 0.9, createdAt: new Date() },
|
||||
]);
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.scoreSignals.quiz.attempts).toBe(3);
|
||||
expect(snap.scoreSignals.quiz.avgScore).toBe(80);
|
||||
expect(snap.scoreSignals.review.ratingDistribution).toEqual({ good: 2, hard: 1 });
|
||||
expect(snap.scoreSignals.recentAnalyses).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Derived scores (API-AI-020) ──
|
||||
|
||||
it('weightedQuizScore uses recency-weighted average', async () => {
|
||||
const now = Date.now();
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([
|
||||
{ score: 90, startedAt: new Date(now - 1 * 24 * 60 * 60 * 1000) },
|
||||
{ score: 50, startedAt: new Date(now - 28 * 24 * 60 * 60 * 1000) },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// Recent 90% heavily outweighs old 50% → > 80
|
||||
expect(snap.scoreSignals.derived.weightedQuizScore).toBeGreaterThan(0.85);
|
||||
});
|
||||
|
||||
it('weightedQuizScore is null when no attempts', async () => {
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.scoreSignals.derived.weightedQuizScore).toBeNull();
|
||||
});
|
||||
|
||||
it('weightedReviewScore maps ratings to numeric values', async () => {
|
||||
prisma.reviewLog.findMany.mockResolvedValue([
|
||||
{ rating: 'good', reviewedAt: new Date() },
|
||||
{ rating: 'easy', reviewedAt: new Date() },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// (0.66 + 1.0) / 2 = 0.83
|
||||
expect(snap.scoreSignals.derived.weightedReviewScore).toBeCloseTo(0.83, 1);
|
||||
});
|
||||
|
||||
it('aiConfidenceScore weights by analysis confidence', async () => {
|
||||
prisma.aiLearningAnalysis.findMany.mockResolvedValue([
|
||||
{ id: 'a1', targetType: 'material', targetId: 'm1', learningState: 'mastered', riskLevel: 'low', confidence: 0.9, createdAt: new Date() },
|
||||
{ id: 'a2', targetType: 'material', targetId: 'm1', learningState: 'in_progress', riskLevel: 'medium', confidence: 0.5, createdAt: new Date() },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// (0.90 * 0.9 + 0.50 * 0.5) / (0.9 + 0.5) = 0.76
|
||||
expect(snap.scoreSignals.derived.aiConfidenceScore).toBeCloseTo(0.76, 1);
|
||||
});
|
||||
|
||||
it('weakPointSeverity accumulates active weak points', async () => {
|
||||
prisma.weakPointCandidate.findMany.mockResolvedValue([
|
||||
{ confidence: 0.8, title: 'Grammar' },
|
||||
{ confidence: 0.6, title: 'Vocabulary' },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// (0.8 + 0.6) / 5 = 0.28
|
||||
expect(snap.scoreSignals.derived.weakPointSeverity).toBeCloseTo(0.28, 1);
|
||||
});
|
||||
|
||||
it('quizTrend requires at least 4 attempts', async () => {
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([
|
||||
{ score: 80, startedAt: new Date('2026-06-10') },
|
||||
{ score: 90, startedAt: new Date('2026-06-15') },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.scoreSignals.derived.quizTrend.direction).toBe('insufficient_data');
|
||||
});
|
||||
|
||||
it('quizTrend detects increasing scores', async () => {
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([
|
||||
{ score: 50, startedAt: new Date('2026-06-01') },
|
||||
{ score: 55, startedAt: new Date('2026-06-05') },
|
||||
{ score: 80, startedAt: new Date('2026-06-10') },
|
||||
{ score: 90, startedAt: new Date('2026-06-15') },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// recent avg (80+90)/2=85 vs prior (50+55)/2=52.5 → +62% → increasing
|
||||
expect(snap.scoreSignals.derived.quizTrend.direction).toBe('increasing');
|
||||
expect(snap.scoreSignals.derived.quizTrend.percentChange).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('compositeMastery blends sub-scores with default weights', async () => {
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([
|
||||
{ score: 80, startedAt: new Date() },
|
||||
]);
|
||||
prisma.reviewLog.findMany.mockResolvedValue([
|
||||
{ rating: 'good', reviewedAt: new Date() },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.scoreSignals.derived.compositeMastery).not.toBeNull();
|
||||
expect(snap.scoreSignals.derived.compositeMastery).toBeGreaterThan(0);
|
||||
expect(snap.scoreSignals.derived.compositeMastery).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('masteryLevel classifies composite correctly', async () => {
|
||||
// No quiz/review/ai data → composite = null → unknown
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([]);
|
||||
prisma.reviewLog.findMany.mockResolvedValue([]);
|
||||
prisma.aiLearningAnalysis.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.scoreSignals.derived.masteryLevel).toBe('unknown');
|
||||
});
|
||||
|
||||
it('weight configuration varies by qualityPreference', async () => {
|
||||
// Set profile to 'exam' mode
|
||||
prisma.userLearningProfile.findUnique.mockResolvedValue({
|
||||
id: 'p1', userId: 'u1',
|
||||
learningGoal: 'Pass exam',
|
||||
currentLevel: 'intermediate',
|
||||
dailyAvailableMinutes: 30,
|
||||
qualityPreference: 'exam',
|
||||
ageRange: '25-34',
|
||||
preferredLanguage: 'zh',
|
||||
learningStyle: 'visual',
|
||||
examTarget: 'AWS',
|
||||
preferredQuestionTypes: null,
|
||||
occupation: null,
|
||||
learningDeadline: null,
|
||||
aiAcceptanceLevel: null,
|
||||
digitalSkillLevel: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
prisma.quizAttempt.findMany.mockResolvedValue([]);
|
||||
prisma.reviewLog.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.scoreSignals.derived.weightConfiguration).toEqual({
|
||||
w_quiz: 0.50, w_review: 0.25, w_ai: 0.10, w_weak: 0.15,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds device context from reading events and devices', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.1.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([]);
|
||||
prisma.userDevice.findMany.mockResolvedValue([
|
||||
{ deviceId: 'd1', deviceName: 'iPhone', osVersion: '18.0', lastSeenAt: new Date() },
|
||||
]);
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.latestPlatform).toBe('ios');
|
||||
expect(snap.deviceContext.deviceCount).toBe(1);
|
||||
});
|
||||
|
||||
// ── Device scene signals (API-AI-019) ──
|
||||
|
||||
it('maps ios platform to phone category', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.1.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.platformCategory).toBe('phone');
|
||||
});
|
||||
|
||||
it('maps web platform to web category', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'web', appVersion: null, clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.platformCategory).toBe('web');
|
||||
});
|
||||
|
||||
it('computes platform distribution from recent events', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'web', appVersion: null, clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'web', appVersion: null, clientTimezoneOffsetMinutes: 480 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.platformDistribution.phone).toBeCloseTo(0.67, 1);
|
||||
expect(snap.deviceContext.platformDistribution.web).toBeCloseTo(0.33, 1);
|
||||
expect(snap.deviceContext.hasPrimaryDevice).toBe(false); // 67% < 70% threshold
|
||||
});
|
||||
|
||||
it('detects primary device when >70% from one category', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'android', appVersion: '1.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.hasPrimaryDevice).toBe(true);
|
||||
expect(snap.deviceContext.primaryPlatformCategory).toBe('phone');
|
||||
});
|
||||
|
||||
it('computes device switch frequency from ordered events', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
// Chronologically ordered: ios → ios → android → ios → web (3 switches in 5 events = 60% → daily)
|
||||
prisma.readingEvent.findMany.mockResolvedValue([
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'android', appVersion: '1.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'web', appVersion: null, clientTimezoneOffsetMinutes: 480 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.deviceSwitchFrequency).toBe('daily');
|
||||
});
|
||||
|
||||
it('returns unknown switch frequency for single event', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.deviceSwitchFrequency).toBe('unknown');
|
||||
});
|
||||
|
||||
it('detects timezone variance', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 540 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.primaryTimezoneOffsetMinutes).toBe(480);
|
||||
expect(snap.deviceContext.hasTimezoneVariance).toBe(true);
|
||||
});
|
||||
|
||||
it('phone category has appropriate task suitability', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.0.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.deviceTaskSuitability.suitableTaskTypes).toContain('flashcard');
|
||||
expect(snap.deviceContext.deviceTaskSuitability.unsuitableTaskTypes).toContain('deep_analysis');
|
||||
});
|
||||
|
||||
it('desktop category supports deep analysis', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'macos', appVersion: '3.0.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.deviceContext.deviceTaskSuitability.suitableTaskTypes).toContain('deep_analysis');
|
||||
expect(snap.deviceContext.deviceTaskSuitability.unsuitableTaskTypes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('appVersion freshness detects outdated versions', async () => {
|
||||
prisma.readingEvent.findFirst.mockResolvedValue({ platform: 'ios', appVersion: '2.1.0', clientTimestampMs: BigInt(Date.now()) });
|
||||
prisma.readingEvent.findMany.mockResolvedValue([
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.0.0', clientTimezoneOffsetMinutes: 480 },
|
||||
{ platform: 'ios', appVersion: '2.1.0', clientTimezoneOffsetMinutes: 480 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// 2.1.0 appears only 1/4 → <50% → outdated
|
||||
expect(snap.deviceContext.appVersion.latest).toBe('2.1.0');
|
||||
expect(snap.deviceContext.appVersion.isOutdated).toBe(true);
|
||||
});
|
||||
|
||||
it('sets aiSettings from UserAiSettings', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.aiSettings).toEqual({
|
||||
apiKeyMode: 'platform_key',
|
||||
defaultCredentialId: null,
|
||||
fallbackToPlatformKey: true,
|
||||
maxDailyAiJobs: 20,
|
||||
maxDailyTokenBudget: 100000,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null profile gracefully', async () => {
|
||||
prisma.userLearningProfile.findUnique.mockResolvedValue(null);
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.userProfile).toBeUndefined();
|
||||
expect(snap.constraints.learningGoal).toBeNull();
|
||||
expect(snap.constraints.dailyAvailableMinutes).toBeNull();
|
||||
expect(snap.constraints.qualityPreference).toBe('standard');
|
||||
});
|
||||
|
||||
it('handles null settings gracefully', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue(null);
|
||||
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// defaults: allowUserProfile=true, allowLearningBehavior=true, allowDocumentContent=false
|
||||
expect(snap.userProfile).toBeDefined();
|
||||
expect(snap.learningBehaviorSummary).toBeDefined();
|
||||
expect(snap.contentStructureSummary).toBeUndefined();
|
||||
expect(snap.aiSettings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws and logs on prisma error', async () => {
|
||||
prisma.learningAnalysisSnapshot.create.mockRejectedValue(new Error('DB down'));
|
||||
|
||||
await expect(service.buildSnapshot('u1', 'material', 'm1')).rejects.toThrow('DB down');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Behavior signal value correctness ──
|
||||
|
||||
describe('behavior signal values', () => {
|
||||
function seedBaseSignals() {
|
||||
prisma.learningSession.aggregate.mockResolvedValue({
|
||||
_count: 10,
|
||||
_sum: { totalActiveSeconds: 7200 },
|
||||
});
|
||||
prisma.learningSession.count.mockResolvedValue(5); // default: 5 global, scoped
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1200, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 3 },
|
||||
{ activityDate: new Date('2026-06-16'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 },
|
||||
{ activityDate: new Date('2026-06-15'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 },
|
||||
]);
|
||||
prisma.dailyLearningActivity.findUnique.mockResolvedValue({
|
||||
durationSeconds: 1800, sessionsCount: 2, activityLevel: 3,
|
||||
});
|
||||
prisma.learningRecord.findMany.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seedBaseSignals();
|
||||
});
|
||||
|
||||
it('learningBehaviorSummary.totalSessions matches session aggregate', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.learningBehaviorSummary.totalSessions).toBe(10);
|
||||
});
|
||||
|
||||
it('learningBehaviorSummary.totalActiveSeconds matches session aggregate', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.learningBehaviorSummary.totalActiveSeconds).toBe(7200);
|
||||
});
|
||||
|
||||
it('learningBehaviorSummary.weeklySessions and behaviorSignals.totalWeeklySessions use count', async () => {
|
||||
// Both scoped and global count return 5 by default
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.learningBehaviorSummary.weeklySessions).toBe(5);
|
||||
expect(snap.behaviorSignals.totalWeeklySessions).toBe(5);
|
||||
});
|
||||
|
||||
it('behaviorSignals.activeDays counts days with durationSeconds > 0', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.activeDays).toBe(2); // 06-17=1800 and 06-16=600 have >0; 06-15=0
|
||||
});
|
||||
|
||||
it('behaviorSignals.avgSecondsPerActiveDay computes correct average', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// (1800 + 600 + 0) / 3 = 800
|
||||
expect(snap.behaviorSignals.avgSecondsPerActiveDay).toBe(800);
|
||||
});
|
||||
|
||||
it('behaviorSignals.today reflects today activity', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.today).toEqual({
|
||||
durationSeconds: 1800,
|
||||
sessionsCount: 2,
|
||||
activityLevel: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('behaviorSignals.today is null when no activity today', async () => {
|
||||
prisma.dailyLearningActivity.findUnique.mockResolvedValue(null);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.today).toBeNull();
|
||||
});
|
||||
|
||||
it('behaviorSignals.recentActivityLevels maps correctly', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.recentActivityLevels).toEqual([
|
||||
{ date: new Date('2026-06-17'), level: 3, seconds: 1800 },
|
||||
{ date: new Date('2026-06-16'), level: 2, seconds: 600 },
|
||||
{ date: new Date('2026-06-15'), level: 0, seconds: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('dailyActivities in learningBehaviorSummary includes full detail', async () => {
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.learningBehaviorSummary.dailyActivities).toHaveLength(3);
|
||||
expect(snap.learningBehaviorSummary.dailyActivities[0].readingSeconds).toBe(1200);
|
||||
expect(snap.learningBehaviorSummary.dailyActivities[0].materialsReadCount).toBe(3);
|
||||
});
|
||||
|
||||
// ── New signals (API-AI-018) ──
|
||||
|
||||
it('engagementSignal=medium with moderate metrics', async () => {
|
||||
// 5 sessions (1pt), 4 active days (0.5pt), avg=800s (0pt) = 1.5 → "medium"
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1200, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 3 },
|
||||
{ activityDate: new Date('2026-06-16'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 },
|
||||
{ activityDate: new Date('2026-06-15'), durationSeconds: 300, sessionsCount: 1, readingSeconds: 200, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 1 },
|
||||
{ activityDate: new Date('2026-06-14'), durationSeconds: 500, sessionsCount: 1, readingSeconds: 300, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 0, activityLevel: 2 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.engagementSignal).toBe('medium');
|
||||
expect(snap.behaviorSignals.engagement.score).toBe(1.5);
|
||||
});
|
||||
|
||||
it('engagementSignal=high when all metrics are strong', async () => {
|
||||
prisma.learningSession.count.mockResolvedValue(5);
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 3600, sessionsCount: 3, readingSeconds: 3000, materialsReadCount: 5, activeRecallCount: 3, reviewCount: 5, aiAnalysisCount: 1, activityLevel: 5 },
|
||||
{ activityDate: new Date('2026-06-16'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 },
|
||||
{ activityDate: new Date('2026-06-15'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 },
|
||||
{ activityDate: new Date('2026-06-14'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 },
|
||||
{ activityDate: new Date('2026-06-13'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 },
|
||||
{ activityDate: new Date('2026-06-12'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 },
|
||||
{ activityDate: new Date('2026-06-11'), durationSeconds: 3600, sessionsCount: 2, readingSeconds: 3000, materialsReadCount: 3, activeRecallCount: 2, reviewCount: 3, aiAnalysisCount: 1, activityLevel: 5 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.engagementSignal).toBe('high');
|
||||
expect(snap.behaviorSignals.engagement.score).toBeGreaterThanOrEqual(2.5);
|
||||
});
|
||||
|
||||
it('consistency computes coefficient of variation', async () => {
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1200, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 3 },
|
||||
{ activityDate: new Date('2026-06-16'), durationSeconds: 1500, sessionsCount: 1, readingSeconds: 1000, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 },
|
||||
{ activityDate: new Date('2026-06-15'), durationSeconds: 1600, sessionsCount: 2, readingSeconds: 1100, materialsReadCount: 2, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// Mean ≈ 1633, stddev ≈ 125, CV ≈ 0.077 → consistent
|
||||
expect(snap.behaviorSignals.consistency.label).toBe('consistent');
|
||||
expect(snap.behaviorSignals.consistency.coefficientOfVariation).toBeLessThan(0.5);
|
||||
expect(snap.behaviorSignals.consistency.zeroDaysCount).toBe(0);
|
||||
});
|
||||
|
||||
it('streak metrics reflect streak records', async () => {
|
||||
prisma.streakRecord.findMany.mockResolvedValue([
|
||||
{ length: 7, startDate: new Date('2026-06-10'), endDate: new Date('2026-06-17') },
|
||||
{ length: 15, startDate: new Date('2026-05-01'), endDate: new Date('2026-05-16') },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.streak.currentStreak).toBe(7);
|
||||
expect(snap.behaviorSignals.streak.longestStreak).toBe(15);
|
||||
});
|
||||
|
||||
it('streakAtRisk when no activity today and last day was 0', async () => {
|
||||
prisma.dailyLearningActivity.findUnique.mockResolvedValue(null);
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 },
|
||||
{ activityDate: new Date('2026-06-16'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.streak.streakAtRisk).toBe(true);
|
||||
});
|
||||
|
||||
it('sessionPattern computes duration and completion metrics', async () => {
|
||||
prisma.learningSession.findMany.mockResolvedValue([
|
||||
{ id: 's1', totalActiveSeconds: 600, status: 'completed', startedAt: new Date('2026-06-17T10:00:00Z') },
|
||||
{ id: 's2', totalActiveSeconds: 1200, status: 'completed', startedAt: new Date('2026-06-17T14:00:00Z') },
|
||||
{ id: 's3', totalActiveSeconds: 300, status: 'interrupted', startedAt: new Date('2026-06-17T16:00:00Z') },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.sessionPattern.avgSessionDuration).toBe(700);
|
||||
expect(snap.behaviorSignals.sessionPattern.medianSessionDuration).toBe(600);
|
||||
expect(snap.behaviorSignals.sessionPattern.sessionCompletionRate).toBe(2 / 3);
|
||||
});
|
||||
|
||||
it('readingVelocity uses progress aggregate', async () => {
|
||||
prisma.materialReadingProgress.aggregate.mockResolvedValue({
|
||||
_sum: { totalActiveSeconds: 3600, sessionCount: 10 },
|
||||
_count: 3,
|
||||
});
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.readingVelocity.totalMaterialsTracked).toBe(3);
|
||||
expect(snap.behaviorSignals.readingVelocity.avgProgressPerSession).toBe(360);
|
||||
});
|
||||
|
||||
it('timeDistribution bins reading events by local hour', async () => {
|
||||
// One event at 8:00 UTC+8 = 8am local → morning
|
||||
// One event at 22:00 UTC+8 = 10pm local → evening
|
||||
prisma.readingEvent.findMany.mockResolvedValue([
|
||||
{ clientTimestampMs: BigInt(new Date('2026-06-17T00:00:00Z').getTime()), clientTimezoneOffsetMinutes: 480 },
|
||||
{ clientTimestampMs: BigInt(new Date('2026-06-17T14:00:00Z').getTime()), clientTimezoneOffsetMinutes: 480 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// 0:00 UTC + 480min(UTC+8) = 8:00 local → morning
|
||||
// 14:00 UTC + 480min = 22:00 local → evening
|
||||
expect(snap.behaviorSignals.timeDistribution.morning).toBe(0.5);
|
||||
expect(snap.behaviorSignals.timeDistribution.evening).toBe(0.5);
|
||||
expect(snap.behaviorSignals.timeDistribution.afternoon).toBe(0);
|
||||
expect(snap.behaviorSignals.timeDistribution.night).toBe(0);
|
||||
});
|
||||
|
||||
it('activityBalance computes activity type proportions', async () => {
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 120, sessionsCount: 1, readingSeconds: 120, materialsReadCount: 1, activeRecallCount: 1, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 1 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
// reading: 120, recall: 1*120=120, review: 0, analysis: 0 → total = 240
|
||||
expect(snap.behaviorSignals.activityBalance.readingPct).toBe(0.5);
|
||||
expect(snap.behaviorSignals.activityBalance.activeRecallPct).toBe(0.5);
|
||||
});
|
||||
|
||||
it('weeklyTrend detects increasing trend', async () => {
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 2000, sessionsCount: 2, readingSeconds: 1500, materialsReadCount: 3, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 4 },
|
||||
{ activityDate: new Date('2026-06-16'), durationSeconds: 1800, sessionsCount: 2, readingSeconds: 1400, materialsReadCount: 2, activeRecallCount: 1, reviewCount: 2, aiAnalysisCount: 0, activityLevel: 4 },
|
||||
{ activityDate: new Date('2026-06-15'), durationSeconds: 500, sessionsCount: 1, readingSeconds: 300, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 0, activityLevel: 2 },
|
||||
{ activityDate: new Date('2026-06-14'), durationSeconds: 400, sessionsCount: 1, readingSeconds: 250, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 1, activityLevel: 2 },
|
||||
]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.weeklyTrend.trendDirection).toBe('increasing');
|
||||
expect(snap.behaviorSignals.weeklyTrend.percentChange).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('fatigue risk detects consecutive inactive days', async () => {
|
||||
prisma.dailyLearningActivity.findMany.mockResolvedValue([
|
||||
{ activityDate: new Date('2026-06-17'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 },
|
||||
{ activityDate: new Date('2026-06-16'), durationSeconds: 0, sessionsCount: 0, readingSeconds: 0, materialsReadCount: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0, activityLevel: 0 },
|
||||
{ activityDate: new Date('2026-06-15'), durationSeconds: 600, sessionsCount: 1, readingSeconds: 400, materialsReadCount: 1, activeRecallCount: 0, reviewCount: 1, aiAnalysisCount: 0, activityLevel: 2 },
|
||||
]);
|
||||
prisma.learningSession.findMany.mockResolvedValue([]);
|
||||
const snap = await service.buildSnapshot('u1', 'material', 'm1');
|
||||
expect(snap.behaviorSignals.fatigue.fatigueRisk).toBe(true);
|
||||
expect(snap.behaviorSignals.fatigue.riskFactors).toContain('consecutive_inactive_days');
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildAllowedFields ──
|
||||
|
||||
describe('buildAllowedFields', () => {
|
||||
const callBuildAllowedFields = (settings: any, profile?: any) =>
|
||||
(service as any).buildAllowedFields(settings, profile ?? null);
|
||||
|
||||
it('always includes base fields', () => {
|
||||
const fields = callBuildAllowedFields({
|
||||
allowUseUserProfile: false,
|
||||
allowUseLearningBehavior: false,
|
||||
allowUseDocumentContent: false,
|
||||
});
|
||||
expect(fields).toEqual(expect.arrayContaining([
|
||||
'constraints', 'materialProgressSummary', 'scoreSignals', 'deviceContext',
|
||||
]));
|
||||
expect(fields).not.toContain('userProfile');
|
||||
expect(fields).not.toContain('learningBehaviorSummary');
|
||||
expect(fields).not.toContain('contentStructureSummary');
|
||||
});
|
||||
|
||||
it('adds userProfile+aiSettings when allowUseUserProfile=true', () => {
|
||||
const fields = callBuildAllowedFields({ allowUseUserProfile: true, allowUseLearningBehavior: false, allowUseDocumentContent: false });
|
||||
expect(fields).toContain('userProfile');
|
||||
expect(fields).toContain('aiSettings');
|
||||
});
|
||||
|
||||
it('adds behavior fields when allowUseLearningBehavior=true', () => {
|
||||
const fields = callBuildAllowedFields({ allowUseUserProfile: false, allowUseLearningBehavior: true, allowUseDocumentContent: false });
|
||||
expect(fields).toContain('learningBehaviorSummary');
|
||||
expect(fields).toContain('behaviorSignals');
|
||||
});
|
||||
|
||||
it('adds contentStructureSummary only when allowUseDocumentContent=true', () => {
|
||||
const fields = callBuildAllowedFields({ allowUseUserProfile: false, allowUseLearningBehavior: false, allowUseDocumentContent: true });
|
||||
expect(fields).toContain('contentStructureSummary');
|
||||
});
|
||||
|
||||
it('handles null settings with defaults', () => {
|
||||
const fields = callBuildAllowedFields(null);
|
||||
// null → allowUseUserProfile !== false → true (default), allowUseLearningBehavior !== false → true
|
||||
expect(fields).toContain('userProfile');
|
||||
expect(fields).toContain('learningBehaviorSummary');
|
||||
expect(fields).not.toContain('contentStructureSummary');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pickSafeFields ──
|
||||
|
||||
describe('pickSafeFields', () => {
|
||||
const call = (profile: any) => (service as any).pickSafeFields(profile);
|
||||
|
||||
it('returns undefined for null profile', () => {
|
||||
expect(call(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters to safe fields only', () => {
|
||||
const result = call({
|
||||
learningGoal: 'goal',
|
||||
currentLevel: 'beginner',
|
||||
qualityPreference: 'deep',
|
||||
ageRange: '18-24',
|
||||
preferredLanguage: 'en',
|
||||
learningStyle: 'reading',
|
||||
examTarget: 'exam',
|
||||
preferredQuestionTypes: ['mc'],
|
||||
occupation: 'engineer', // excluded
|
||||
dailyAvailableMinutes: 30, // excluded → goes to constraints
|
||||
aiAcceptanceLevel: 'high', // excluded
|
||||
digitalSkillLevel: 'high', // excluded
|
||||
id: 'x', userId: 'x', createdAt: 'x', updatedAt: 'x',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
learningGoal: 'goal',
|
||||
currentLevel: 'beginner',
|
||||
qualityPreference: 'deep',
|
||||
ageRange: '18-24',
|
||||
preferredLanguage: 'en',
|
||||
learningStyle: 'reading',
|
||||
examTarget: 'exam',
|
||||
preferredQuestionTypes: ['mc'],
|
||||
});
|
||||
expect(result).not.toHaveProperty('occupation');
|
||||
expect(result).not.toHaveProperty('dailyAvailableMinutes');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pickAiSettingsFields ──
|
||||
|
||||
describe('pickAiSettingsFields', () => {
|
||||
const call = (settings: any) => (service as any).pickAiSettingsFields(settings);
|
||||
|
||||
it('returns undefined for null settings', () => {
|
||||
expect(call(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('picks only relevant settings fields', () => {
|
||||
const result = call({
|
||||
apiKeyMode: 'user_deepseek_key',
|
||||
defaultCredentialId: 'c1',
|
||||
fallbackToPlatformKey: false,
|
||||
maxDailyAiJobs: 10,
|
||||
maxDailyTokenBudget: 50000,
|
||||
allowAiAnalysis: true,
|
||||
allowUseDocumentContent: true,
|
||||
id: 'x', userId: 'x', createdAt: 'x', updatedAt: 'x',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
apiKeyMode: 'user_deepseek_key',
|
||||
defaultCredentialId: 'c1',
|
||||
fallbackToPlatformKey: false,
|
||||
maxDailyAiJobs: 10,
|
||||
maxDailyTokenBudget: 50000,
|
||||
});
|
||||
expect(result).not.toHaveProperty('allowAiAnalysis');
|
||||
});
|
||||
});
|
||||
|
||||
// ── countBy ──
|
||||
|
||||
describe('countBy', () => {
|
||||
const call = (items: any[], fn: (item: any) => string) =>
|
||||
(service as any).countBy(items, fn);
|
||||
|
||||
it('counts items by key', () => {
|
||||
const result = call(['a', 'a', 'b', 'c', 'b', 'a'], (s: string) => s);
|
||||
expect(result).toEqual({ a: 3, b: 2, c: 1 });
|
||||
});
|
||||
|
||||
it('returns empty object for empty array', () => {
|
||||
expect(call([], (s: string) => s)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ── aggregateBehavior scope support ──
|
||||
|
||||
describe('aggregateBehavior scope', () => {
|
||||
it('filters sessions by materialId for material scope', async () => {
|
||||
await service.buildSnapshot('u1', 'material', 'm1');
|
||||
|
||||
const callArg = prisma.learningSession.aggregate.mock.calls[0][0];
|
||||
expect(callArg.where.materialId).toBe('m1');
|
||||
});
|
||||
|
||||
it('filters sessions by knowledgeBaseId for knowledge_base scope', async () => {
|
||||
await service.buildSnapshot('u1', 'knowledge_base', 'kb1');
|
||||
|
||||
const callArg = prisma.learningSession.aggregate.mock.calls[0][0];
|
||||
expect(callArg.where.knowledgeBaseId).toBe('kb1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── aggregateProgress scope support ──
|
||||
|
||||
describe('aggregateProgress scope', () => {
|
||||
it('filters by knowledgeBaseId for knowledge_base scope', async () => {
|
||||
await service.buildSnapshot('u1', 'knowledge_base', 'kb1');
|
||||
|
||||
const callArg = prisma.materialReadingProgress.findMany.mock.calls[0][0];
|
||||
expect(callArg.where.knowledgeBaseId).toBe('kb1');
|
||||
expect(callArg.where.readingTargetType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters by materialId for material scope', async () => {
|
||||
await service.buildSnapshot('u1', 'material', 'm1');
|
||||
|
||||
const callArg = prisma.materialReadingProgress.findMany.mock.calls[0][0];
|
||||
expect(callArg.where.materialId).toBe('m1');
|
||||
expect(callArg.where.readingTargetType).toBe('material');
|
||||
});
|
||||
});
|
||||
|
||||
// ── aggregateContent userId isolation ──
|
||||
|
||||
describe('aggregateContent userId isolation', () => {
|
||||
it('knowledge_base scope includes userId in query', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({
|
||||
...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })),
|
||||
allowUseDocumentContent: true,
|
||||
});
|
||||
|
||||
await service.buildSnapshot('u1', 'knowledge_base', 'kb1');
|
||||
|
||||
const callArg = prisma.knowledgeItem.findMany.mock.calls[0][0];
|
||||
expect(callArg.where.userId).toBe('u1');
|
||||
expect(callArg.where.knowledgeBaseId).toBe('kb1');
|
||||
});
|
||||
|
||||
it('material scope includes userId in query', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({
|
||||
...(await prisma.userAiSettings.findUnique({ where: { userId: 'u1' } })),
|
||||
allowUseDocumentContent: true,
|
||||
});
|
||||
|
||||
await service.buildSnapshot('u1', 'material', 'm1');
|
||||
|
||||
const callArg = prisma.materialReadingProgress.findFirst.mock.calls[0][0];
|
||||
expect(callArg.where.userId).toBe('u1');
|
||||
expect(callArg.where.materialId).toBe('m1');
|
||||
});
|
||||
});
|
||||
});
|
||||
1055
src/modules/ai-runtime/snapshot-builder.service.ts
Normal file
1055
src/modules/ai-runtime/snapshot-builder.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
src/modules/ai-runtime/snapshot-cleanup.service.spec.ts
Normal file
40
src/modules/ai-runtime/snapshot-cleanup.service.spec.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { SnapshotCleanupService } from './snapshot-cleanup.service';
|
||||
|
||||
describe('SnapshotCleanupService', () => {
|
||||
let service: SnapshotCleanupService;
|
||||
let mockDeleteMany: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeleteMany = jest.fn().mockResolvedValue({ count: 0 });
|
||||
const mockPrisma = {
|
||||
learningAnalysisSnapshot: { deleteMany: mockDeleteMany },
|
||||
} as any;
|
||||
service = new SnapshotCleanupService(mockPrisma);
|
||||
// suppress interval side effects from onModuleInit in test
|
||||
jest.spyOn(global, 'setInterval').mockReturnValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('cleanupExpired', () => {
|
||||
it('calls deleteMany with lt: now', async () => {
|
||||
await service.cleanupExpired();
|
||||
expect(mockDeleteMany).toHaveBeenCalledTimes(1);
|
||||
const where = mockDeleteMany.mock.calls[0][0].where;
|
||||
expect(where.expiresAt.lt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns deleted count', async () => {
|
||||
mockDeleteMany.mockResolvedValue({ count: 3 });
|
||||
const result = await service.cleanupExpired();
|
||||
expect(result).toEqual({ deleted: 3 });
|
||||
});
|
||||
|
||||
it('returns zero when nothing to delete', async () => {
|
||||
const result = await service.cleanupExpired();
|
||||
expect(result).toEqual({ deleted: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
32
src/modules/ai-runtime/snapshot-cleanup.service.ts
Normal file
32
src/modules/ai-runtime/snapshot-cleanup.service.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
|
||||
@Injectable()
|
||||
export class SnapshotCleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SnapshotCleanupService.name);
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.cleanupExpired().catch(() => {});
|
||||
this.timer = setInterval(() => this.cleanupExpired().catch(() => {}), CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
/** Delete expired snapshots. Safe to hard-delete: snapshots are cache artifacts with no FK constraints. */
|
||||
async cleanupExpired(): Promise<{ deleted: number }> {
|
||||
const result = await this.prisma.learningAnalysisSnapshot.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
if (result.count > 0) {
|
||||
this.logger.log(`Cleaned up ${result.count} expired snapshot(s)`);
|
||||
}
|
||||
return { deleted: result.count };
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Controller, Get, Put, Post, Delete, Param, Body, Req } from '@nestjs/common';
|
||||
import { Controller, Get, Put, Post, Delete, Param, Body, Req, Query } from '@nestjs/common';
|
||||
import { UserAiService } from './user-ai.service';
|
||||
import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto } from './user-ai.dto';
|
||||
import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CreateAnalysisJobDto } from './user-ai.dto';
|
||||
|
||||
@Controller('ai')
|
||||
export class UserAiController {
|
||||
@ -57,4 +57,141 @@ export class UserAiController {
|
||||
async testCredential(@Req() req: any, @Param('id') id: string) {
|
||||
return this.service.testCredential(req.user.id, id);
|
||||
}
|
||||
|
||||
// ── Analysis Jobs ──
|
||||
|
||||
@Post('jobs')
|
||||
async createAnalysisJob(@Req() req: any, @Body() dto: CreateAnalysisJobDto) {
|
||||
return this.service.createAnalysisJob(req.user.id, dto);
|
||||
}
|
||||
|
||||
@Post('jobs/:jobId/cancel')
|
||||
async cancelJob(@Req() req: any, @Param('jobId') jobId: string) {
|
||||
return this.service.cancelJob(req.user.id, jobId);
|
||||
}
|
||||
|
||||
@Get('jobs/:jobId')
|
||||
async getJob(@Req() req: any, @Param('jobId') jobId: string) {
|
||||
return this.service.getJob(req.user.id, jobId);
|
||||
}
|
||||
|
||||
@Get('jobs')
|
||||
async listJobs(@Req() req: any, @Query('status') status?: string, @Query('take') take?: string) {
|
||||
return this.service.listJobs(req.user.id, status, take ? parseInt(take) : undefined);
|
||||
}
|
||||
|
||||
// ── Quiz Publish ──
|
||||
|
||||
@Post('quizzes/:quizId/publish')
|
||||
async publishQuiz(@Req() req: any, @Param('quizId') quizId: string) {
|
||||
return this.service.publishQuiz(req.user.id, quizId);
|
||||
}
|
||||
|
||||
@Post('flashcards/:cardId/publish')
|
||||
async publishFlashcard(@Req() req: any, @Param('cardId') cardId: string) {
|
||||
return this.service.publishFlashcard(req.user.id, cardId);
|
||||
}
|
||||
|
||||
// ── Analysis Results ──
|
||||
|
||||
@Get('analyses/:id')
|
||||
async getAnalysis(@Req() req: any, @Param('id') id: string) {
|
||||
return this.service.getAnalysis(req.user.id, id);
|
||||
}
|
||||
|
||||
@Get('analyses')
|
||||
async listAnalyses(
|
||||
@Req() req: any,
|
||||
@Query('targetType') targetType?: string,
|
||||
@Query('targetId') targetId?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listAnalyses(req.user.id, targetType, targetId, take ? parseInt(take) : undefined);
|
||||
}
|
||||
|
||||
@Get('recommendations')
|
||||
async listRecommendations(
|
||||
@Req() req: any,
|
||||
@Query('targetType') targetType?: string,
|
||||
@Query('targetId') targetId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listRecommendations(req.user.id, targetType, targetId, status, take ? parseInt(take) : undefined);
|
||||
}
|
||||
|
||||
@Get('weak-points')
|
||||
async listWeakPoints(
|
||||
@Req() req: any,
|
||||
@Query('targetType') targetType?: string,
|
||||
@Query('targetId') targetId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listWeakPoints(req.user.id, targetType, targetId, status, take ? parseInt(take) : undefined);
|
||||
}
|
||||
|
||||
@Get('quizzes/:quizId')
|
||||
async getQuiz(@Req() req: any, @Param('quizId') quizId: string) {
|
||||
return this.service.getQuiz(req.user.id, quizId);
|
||||
}
|
||||
|
||||
@Get('quizzes/:quizId/questions')
|
||||
async getQuizQuestions(@Req() req: any, @Param('quizId') quizId: string) {
|
||||
return this.service.getQuizQuestions(req.user.id, quizId);
|
||||
}
|
||||
|
||||
@Get('quizzes')
|
||||
async listQuizzes(
|
||||
@Req() req: any,
|
||||
@Query('knowledgeBaseId') knowledgeBaseId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listQuizzes(req.user.id, knowledgeBaseId, status, take ? parseInt(take) : undefined);
|
||||
}
|
||||
|
||||
@Post('reanalyze')
|
||||
async triggerReanalysis(
|
||||
@Req() req: any,
|
||||
@Body('targetType') targetType: string,
|
||||
@Body('targetId') targetId: string,
|
||||
) {
|
||||
return this.service.triggerReanalysis(req.user.id, targetType, targetId);
|
||||
}
|
||||
|
||||
@Get('notifications')
|
||||
async listNotifications(@Req() req: any, @Query('take') take?: string) {
|
||||
return this.service.listNotifications(req.user.id, take ? parseInt(take) : undefined);
|
||||
}
|
||||
|
||||
@Post('artifacts/:type/:id/feedback')
|
||||
async submitArtifactFeedback(
|
||||
@Req() req: any,
|
||||
@Param('type') artifactType: string,
|
||||
@Param('id') artifactId: string,
|
||||
@Body() dto: { feedbackType: string; reason?: string },
|
||||
) {
|
||||
return this.service.submitArtifactFeedback(req.user.id, artifactType, artifactId, dto);
|
||||
}
|
||||
|
||||
@Post('feedback')
|
||||
async submitFeedback(@Req() req: any, @Body() dto: { category: string; content: string; email?: string; deviceInfo?: any }) {
|
||||
return this.service.submitFeedback(req.user.id, dto);
|
||||
}
|
||||
|
||||
@Get('flashcards/:cardId')
|
||||
async getFlashcard(@Req() req: any, @Param('cardId') cardId: string) {
|
||||
return this.service.getFlashcard(req.user.id, cardId);
|
||||
}
|
||||
|
||||
@Get('flashcards')
|
||||
async listFlashcards(
|
||||
@Req() req: any,
|
||||
@Query('knowledgePointId') knowledgePointId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listFlashcards(req.user.id, knowledgePointId, status, take ? parseInt(take) : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsEnum, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
// ── LearningProfile ──
|
||||
|
||||
@ -57,3 +57,45 @@ export class CredentialResponseDto {
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
// ── Analysis Job ──
|
||||
|
||||
export const VALID_JOB_TYPES = [
|
||||
'learning_state_analysis',
|
||||
'weak_point_analysis',
|
||||
'next_action_planning',
|
||||
'quiz_generation',
|
||||
'flashcard_generation',
|
||||
] as const;
|
||||
|
||||
export const VALID_TARGET_TYPES = ['user', 'material', 'knowledge_base'] as const;
|
||||
const VALID_DIFFICULTY_LEVELS = ['easy', 'medium', 'hard'] as const;
|
||||
|
||||
export class CreateAnalysisJobDto {
|
||||
@IsIn(VALID_JOB_TYPES) jobType!: string;
|
||||
@IsIn(VALID_TARGET_TYPES) targetType!: string;
|
||||
@IsString() targetId!: string;
|
||||
@IsOptional() @IsString() idempotencyKey?: string;
|
||||
@IsOptional() @IsString() apiKeyMode?: string;
|
||||
@IsOptional() @IsString() credentialId?: string;
|
||||
@IsOptional() @IsString() promptVersion?: string;
|
||||
@IsOptional() @IsString() outputSchemaVersion?: string;
|
||||
|
||||
// Quiz-specific (API-AI-023)
|
||||
@IsOptional() @IsInt() @Min(1) @Max(50) questionCount?: number;
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) questionTypes?: string[];
|
||||
|
||||
// Flashcard-specific (API-AI-024)
|
||||
@IsOptional() @IsInt() @Min(1) @Max(100) cardCount?: number;
|
||||
|
||||
// Shared by quiz & flashcard
|
||||
@IsOptional() @IsIn(VALID_DIFFICULTY_LEVELS) difficultyLevel?: string;
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) knowledgePointIds?: string[];
|
||||
}
|
||||
|
||||
export class CreateAnalysisJobResponseDto {
|
||||
jobId!: string;
|
||||
status!: string;
|
||||
createdAt!: string;
|
||||
@IsOptional() @IsString() planId?: string;
|
||||
}
|
||||
|
||||
266
src/modules/ai-runtime/user-ai.service.spec.ts
Normal file
266
src/modules/ai-runtime/user-ai.service.spec.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { UserAiService } from './user-ai.service';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { CredentialEncryptionService } from './credential-encryption.service';
|
||||
import { SnapshotBuilderService } from './snapshot-builder.service';
|
||||
import { PriorityRulesService } from './priority-rules.service';
|
||||
import { UserAiQuotaService } from './user-ai-quota.service';
|
||||
import { PlatformBudgetService } from './platform-budget.service';
|
||||
|
||||
describe('UserAiService.createAnalysisJob', () => {
|
||||
let service: UserAiService;
|
||||
let prisma: any;
|
||||
let snapshotBuilder: any;
|
||||
let priorityRules: any;
|
||||
let quota: any;
|
||||
let budget: any;
|
||||
|
||||
const mockSnapshot = { id: 'snap-1', snapshotVersion: 'ai_snapshot_v1' };
|
||||
|
||||
beforeEach(async () => {
|
||||
prisma = {
|
||||
userAiSettings: { findUnique: jest.fn(), create: jest.fn() },
|
||||
userModelCredential: { findFirst: jest.fn() },
|
||||
aiRuntimeJob: { findUnique: jest.fn(), create: jest.fn() },
|
||||
questionGenerationPlan: { create: jest.fn() },
|
||||
flashcardGenerationPlan: { create: jest.fn() },
|
||||
userLearningProfile: { findUnique: jest.fn() },
|
||||
};
|
||||
snapshotBuilder = { buildSnapshot: jest.fn().mockResolvedValue(mockSnapshot) };
|
||||
priorityRules = { computeJobPriority: jest.fn().mockReturnValue(50) };
|
||||
quota = { checkAndReserve: jest.fn().mockResolvedValue(undefined), incrementJobCount: jest.fn().mockResolvedValue(undefined) };
|
||||
budget = { checkPlatformBudget: jest.fn().mockResolvedValue(undefined) };
|
||||
const crypto = { encrypt: jest.fn(), decrypt: jest.fn(), hash: jest.fn(), mask: jest.fn() } as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserAiService,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: CredentialEncryptionService, useValue: crypto },
|
||||
{ provide: SnapshotBuilderService, useValue: snapshotBuilder },
|
||||
{ provide: PriorityRulesService, useValue: priorityRules },
|
||||
{ provide: UserAiQuotaService, useValue: quota },
|
||||
{ provide: PlatformBudgetService, useValue: budget },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(UserAiService);
|
||||
});
|
||||
|
||||
const validDto = {
|
||||
jobType: 'learning_state_analysis',
|
||||
targetType: 'material',
|
||||
targetId: 'm1',
|
||||
};
|
||||
|
||||
it('creates a job with snapshot and correct fields', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date('2026-06-17') });
|
||||
|
||||
const result = await service.createAnalysisJob('u1', validDto);
|
||||
|
||||
expect(result.jobId).toBe('job-1');
|
||||
expect(result.status).toBe('pending');
|
||||
expect(snapshotBuilder.buildSnapshot).toHaveBeenCalledWith('u1', 'material', 'm1');
|
||||
expect(prisma.aiRuntimeJob.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'u1',
|
||||
jobType: 'learning_state_analysis',
|
||||
snapshotId: 'snap-1',
|
||||
priority: 50,
|
||||
promptVersion: 'learning_state_v1',
|
||||
outputSchemaVersion: 'analysis_output_v1',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('auto-creates settings for new user', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue(null);
|
||||
prisma.userAiSettings.create.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
|
||||
await service.createAnalysisJob('u1', validDto);
|
||||
|
||||
expect(prisma.userAiSettings.create).toHaveBeenCalledWith({ data: { userId: 'u1' } });
|
||||
});
|
||||
|
||||
it('throws AI_ANALYSIS_DISABLED when user opted out', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: false, apiKeyMode: 'platform_key' });
|
||||
|
||||
await expect(service.createAnalysisJob('u1', validDto)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.createAnalysisJob('u1', validDto)).rejects.toMatchObject({
|
||||
response: { errorCode: 'AI_ANALYSIS_DISABLED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns existing job on idempotent retry', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.findUnique.mockResolvedValue({ id: 'existing-job', status: 'pending', createdAt: new Date('2026-06-01') });
|
||||
|
||||
const result = await service.createAnalysisJob('u1', { ...validDto, idempotencyKey: 'ik-1' });
|
||||
|
||||
expect(result.jobId).toBe('existing-job');
|
||||
expect(snapshotBuilder.buildSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws INVALID_JOB_TYPE for unknown job type', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' });
|
||||
|
||||
await expect(service.createAnalysisJob('u1', { ...validDto, jobType: 'invalid_type' }))
|
||||
.rejects.toMatchObject({ response: { errorCode: 'INVALID_JOB_TYPE' } });
|
||||
});
|
||||
|
||||
it('throws CREDENTIAL_REQUIRED for user key mode without credential', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
|
||||
await expect(service.createAnalysisJob('u1', { ...validDto, apiKeyMode: 'user_deepseek_key' }))
|
||||
.rejects.toMatchObject({ response: { errorCode: 'CREDENTIAL_REQUIRED' } });
|
||||
});
|
||||
|
||||
it('throws CREDENTIAL_NOT_FOUND for invalid credential', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.userModelCredential.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.createAnalysisJob('u1', { ...validDto, apiKeyMode: 'user_deepseek_key', credentialId: 'bad-cred' }))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('calls quota check and budget for platform_key', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
|
||||
await service.createAnalysisJob('u1', validDto);
|
||||
|
||||
expect(quota.checkAndReserve).toHaveBeenCalledWith('u1', 'platform_key');
|
||||
expect(budget.checkPlatformBudget).toHaveBeenCalledWith('deepseek', 'deepseek-chat');
|
||||
expect(quota.incrementJobCount).toHaveBeenCalledWith('u1', 'platform_key');
|
||||
});
|
||||
|
||||
it('skips budget check for user_deepseek_key mode', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: 'c1', fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.userModelCredential.findFirst.mockResolvedValue({ id: 'c1', userId: 'u1', status: 'active' });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
|
||||
await service.createAnalysisJob('u1', { ...validDto });
|
||||
|
||||
expect(budget.checkPlatformBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes priority from profile and settings', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.userLearningProfile.findUnique.mockResolvedValue({ qualityPreference: 'exam', examTarget: 'AWS' });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
priorityRules.computeJobPriority.mockReturnValue(0);
|
||||
|
||||
await service.createAnalysisJob('u1', validDto);
|
||||
|
||||
expect(priorityRules.computeJobPriority).toHaveBeenCalled();
|
||||
expect(prisma.aiRuntimeJob.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ priority: 0 }),
|
||||
}));
|
||||
});
|
||||
|
||||
// ── quiz_generation (API-AI-023) ──
|
||||
|
||||
const quizDto = {
|
||||
jobType: 'quiz_generation',
|
||||
targetType: 'knowledge_base',
|
||||
targetId: 'kb1',
|
||||
questionCount: 10,
|
||||
difficultyLevel: 'medium',
|
||||
questionTypes: ['choice', 'judge'],
|
||||
knowledgePointIds: ['kp1', 'kp2'],
|
||||
};
|
||||
|
||||
it('creates QuestionGenerationPlan for quiz_generation', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
prisma.questionGenerationPlan.create.mockResolvedValue({ id: 'plan-1' });
|
||||
|
||||
const result = await service.createAnalysisJob('u1', quizDto);
|
||||
|
||||
expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
jobId: 'job-1',
|
||||
count: 10,
|
||||
difficultyLevel: 'medium',
|
||||
questionTypes: ['choice', 'judge'],
|
||||
knowledgePointIds: ['kp1', 'kp2'],
|
||||
status: 'pending',
|
||||
}),
|
||||
}));
|
||||
expect(result).toMatchObject({ jobId: 'job-1', planId: 'plan-1' });
|
||||
});
|
||||
|
||||
it('rejects quiz_generation with non-knowledge_base target', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' });
|
||||
|
||||
await expect(service.createAnalysisJob('u1', { ...quizDto, targetType: 'material' }))
|
||||
.rejects.toMatchObject({ response: { errorCode: 'INVALID_TARGET_TYPE' } });
|
||||
});
|
||||
|
||||
it('uses default questionCount=5 when not provided', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
prisma.questionGenerationPlan.create.mockResolvedValue({ id: 'plan-1' });
|
||||
|
||||
await service.createAnalysisJob('u1', { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' });
|
||||
|
||||
expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ count: 5, questionTypes: [], knowledgePointIds: [] }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not create questionGenerationPlan for non-quiz job types', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
|
||||
await service.createAnalysisJob('u1', validDto);
|
||||
|
||||
expect(prisma.questionGenerationPlan.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── flashcard_generation (API-AI-024) ──
|
||||
|
||||
const flashcardDto = {
|
||||
jobType: 'flashcard_generation',
|
||||
targetType: 'knowledge_base',
|
||||
targetId: 'kb1',
|
||||
cardCount: 20,
|
||||
difficultyLevel: 'hard',
|
||||
knowledgePointIds: ['kp1'],
|
||||
};
|
||||
|
||||
it('creates FlashcardGenerationPlan for flashcard_generation', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
prisma.flashcardGenerationPlan.create.mockResolvedValue({ id: 'fplan-1' });
|
||||
|
||||
const result = await service.createAnalysisJob('u1', flashcardDto);
|
||||
|
||||
expect(prisma.flashcardGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ jobId: 'job-1', count: 20, difficultyLevel: 'hard', knowledgePointIds: ['kp1'], status: 'pending' }),
|
||||
}));
|
||||
expect(result).toMatchObject({ jobId: 'job-1', planId: 'fplan-1' });
|
||||
});
|
||||
|
||||
it('rejects flashcard_generation with non-knowledge_base target', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' });
|
||||
|
||||
await expect(service.createAnalysisJob('u1', { ...flashcardDto, targetType: 'material' }))
|
||||
.rejects.toMatchObject({ response: { errorCode: 'INVALID_TARGET_TYPE' } });
|
||||
});
|
||||
|
||||
it('defaults cardCount to 5', async () => {
|
||||
prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
|
||||
prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
|
||||
prisma.flashcardGenerationPlan.create.mockResolvedValue({ id: 'fplan-1' });
|
||||
|
||||
await service.createAnalysisJob('u1', { jobType: 'flashcard_generation', targetType: 'knowledge_base', targetId: 'kb1' });
|
||||
|
||||
expect(prisma.flashcardGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ count: 5 }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -1,13 +1,29 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { CredentialEncryptionService } from './credential-encryption.service';
|
||||
import type { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CredentialResponseDto } from './user-ai.dto';
|
||||
import { SnapshotBuilderService } from './snapshot-builder.service';
|
||||
import { PriorityRulesService } from './priority-rules.service';
|
||||
import { UserAiQuotaService } from './user-ai-quota.service';
|
||||
import { PlatformBudgetService } from './platform-budget.service';
|
||||
import type { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CredentialResponseDto, CreateAnalysisJobDto, CreateAnalysisJobResponseDto } from './user-ai.dto';
|
||||
|
||||
const JOB_TYPE_CONFIG: Record<string, { promptVersion: string; outputSchemaVersion: string }> = {
|
||||
learning_state_analysis: { promptVersion: 'learning_state_v1', outputSchemaVersion: 'analysis_output_v1' },
|
||||
weak_point_analysis: { promptVersion: 'weak_point_v1', outputSchemaVersion: 'weak_point_output_v1' },
|
||||
next_action_planning: { promptVersion: 'next_action_v1', outputSchemaVersion: 'next_action_output_v1' },
|
||||
quiz_generation: { promptVersion: 'quiz_gen_v1', outputSchemaVersion: 'quiz_output_v1' },
|
||||
flashcard_generation: { promptVersion: 'flashcard_gen_v1', outputSchemaVersion: 'flashcard_output_v1' },
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UserAiService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CredentialEncryptionService,
|
||||
private readonly snapshotBuilder: SnapshotBuilderService,
|
||||
private readonly priorityRules: PriorityRulesService,
|
||||
private readonly quota: UserAiQuotaService,
|
||||
private readonly budget: PlatformBudgetService,
|
||||
) {}
|
||||
|
||||
// ══ LearningProfile ══
|
||||
@ -175,6 +191,476 @@ export class UserAiService {
|
||||
return { provider: cred.provider, apiKey };
|
||||
}
|
||||
|
||||
// ══ Analysis Job Creation ══
|
||||
|
||||
async createAnalysisJob(userId: string, dto: CreateAnalysisJobDto): Promise<CreateAnalysisJobResponseDto> {
|
||||
// 1. Settings check (auto-create with defaults if new user)
|
||||
let settings = await this.prisma.userAiSettings.findUnique({ where: { userId } });
|
||||
if (!settings) {
|
||||
settings = await this.prisma.userAiSettings.create({ data: { userId } });
|
||||
}
|
||||
if (!settings.allowAiAnalysis) {
|
||||
throw new BadRequestException({ errorCode: 'AI_ANALYSIS_DISABLED', message: 'AI analysis is disabled. Enable it in settings.' });
|
||||
}
|
||||
|
||||
// 2. Validate jobType + per-type constraints
|
||||
if (!Object.keys(JOB_TYPE_CONFIG).includes(dto.jobType)) {
|
||||
throw new BadRequestException({
|
||||
errorCode: 'INVALID_JOB_TYPE',
|
||||
message: `Invalid jobType "${dto.jobType}". Must be one of: ${Object.keys(JOB_TYPE_CONFIG).join(', ')}`,
|
||||
});
|
||||
}
|
||||
const requiresKnowledgeBase = ['quiz_generation', 'flashcard_generation'];
|
||||
if (requiresKnowledgeBase.includes(dto.jobType) && dto.targetType !== 'knowledge_base') {
|
||||
throw new BadRequestException({
|
||||
errorCode: 'INVALID_TARGET_TYPE',
|
||||
message: `${dto.jobType} requires targetType="knowledge_base"`,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Idempotency check (cheapest fast-return, before quota/snapshot)
|
||||
if (dto.idempotencyKey) {
|
||||
const existing = await this.prisma.aiRuntimeJob.findUnique({
|
||||
where: { idempotencyKey: dto.idempotencyKey },
|
||||
select: { id: true, status: true, createdAt: true },
|
||||
});
|
||||
if (existing) {
|
||||
return { jobId: existing.id, status: existing.status, createdAt: existing.createdAt.toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve apiKeyMode / credentialId
|
||||
let apiKeyMode = dto.apiKeyMode ?? settings.apiKeyMode;
|
||||
let credentialId: string | undefined = dto.credentialId ?? settings.defaultCredentialId ?? undefined;
|
||||
if (apiKeyMode === 'user_deepseek_key' && !credentialId) {
|
||||
throw new BadRequestException({ errorCode: 'CREDENTIAL_REQUIRED', message: 'User key mode requires a credential.' });
|
||||
}
|
||||
if (credentialId) {
|
||||
const cred = await this.prisma.userModelCredential.findFirst({
|
||||
where: { id: credentialId, userId, deletedAt: null, status: 'active' },
|
||||
});
|
||||
if (!cred) {
|
||||
throw new NotFoundException({ errorCode: 'CREDENTIAL_NOT_FOUND', message: 'Credential not found or not active.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Quota check
|
||||
await this.quota.checkAndReserve(userId, apiKeyMode);
|
||||
|
||||
// 6. Platform budget check (platform_key only) — with fallback to user key
|
||||
if (apiKeyMode === 'platform_key') {
|
||||
try {
|
||||
await this.budget.checkPlatformBudget('deepseek', 'deepseek-chat');
|
||||
} catch (err: any) {
|
||||
if (err?.response?.errorCode === 'PLATFORM_CIRCUIT_OPEN' && settings.fallbackToPlatformKey && settings.defaultCredentialId) {
|
||||
// Fallback: switch to user key mode
|
||||
apiKeyMode = 'user_deepseek_key';
|
||||
credentialId = settings.defaultCredentialId;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Build snapshot
|
||||
const snapshot = await this.snapshotBuilder.buildSnapshot(userId, dto.targetType, dto.targetId);
|
||||
|
||||
// 8. Resolve prompt/output schema versions
|
||||
const config = JOB_TYPE_CONFIG[dto.jobType];
|
||||
const promptVersion = dto.promptVersion ?? config.promptVersion;
|
||||
const outputSchemaVersion = dto.outputSchemaVersion ?? config.outputSchemaVersion;
|
||||
|
||||
// 9. Compute priority
|
||||
const profile = await this.prisma.userLearningProfile.findUnique({ where: { userId } });
|
||||
const priority = this.priorityRules.computeJobPriority(profile, settings, dto.targetType);
|
||||
|
||||
// 10. Create job + per-type plan records
|
||||
const job = await this.prisma.aiRuntimeJob.create({
|
||||
data: {
|
||||
userId,
|
||||
jobType: dto.jobType,
|
||||
targetType: dto.targetType,
|
||||
targetId: dto.targetId,
|
||||
snapshotId: snapshot.id,
|
||||
status: 'pending',
|
||||
priority,
|
||||
idempotencyKey: dto.idempotencyKey ?? null,
|
||||
apiKeyMode,
|
||||
credentialId: credentialId ?? null,
|
||||
modelProvider: 'deepseek',
|
||||
modelName: 'deepseek-chat',
|
||||
promptVersion,
|
||||
outputSchemaVersion,
|
||||
},
|
||||
});
|
||||
|
||||
// 10. Create per-type plan record
|
||||
let planId: string | undefined;
|
||||
if (dto.jobType === 'quiz_generation') {
|
||||
const plan = await this.prisma.questionGenerationPlan.create({
|
||||
data: {
|
||||
userId,
|
||||
jobId: job.id,
|
||||
snapshotId: snapshot.id,
|
||||
targetType: dto.targetType,
|
||||
targetId: dto.targetId,
|
||||
knowledgePointIds: (dto.knowledgePointIds ?? []) as any,
|
||||
questionTypes: (dto.questionTypes ?? []) as any,
|
||||
difficultyLevel: dto.difficultyLevel ?? null,
|
||||
count: dto.questionCount ?? 5,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
planId = plan.id;
|
||||
}
|
||||
if (dto.jobType === 'flashcard_generation') {
|
||||
const plan = await this.prisma.flashcardGenerationPlan.create({
|
||||
data: {
|
||||
userId,
|
||||
jobId: job.id,
|
||||
snapshotId: snapshot.id,
|
||||
targetType: dto.targetType,
|
||||
targetId: dto.targetId,
|
||||
knowledgePointIds: (dto.knowledgePointIds ?? []) as any,
|
||||
difficultyLevel: dto.difficultyLevel ?? null,
|
||||
count: dto.cardCount ?? 5,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
planId = plan.id;
|
||||
}
|
||||
|
||||
// 11. Increment quota
|
||||
await this.quota.incrementJobCount(userId, apiKeyMode).catch(() => {});
|
||||
|
||||
return { jobId: job.id, status: job.status, createdAt: job.createdAt.toISOString(), ...(planId ? { planId } : {}) };
|
||||
}
|
||||
|
||||
async cancelJob(userId: string, jobId: string) {
|
||||
const job = await this.prisma.aiRuntimeJob.findFirst({
|
||||
where: { id: jobId, userId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!job) throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found' });
|
||||
|
||||
if (job.status === 'pending') {
|
||||
await this.prisma.aiRuntimeJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: 'cancelled', cancelledAt: new Date() },
|
||||
});
|
||||
return { jobId, status: 'cancelled' };
|
||||
}
|
||||
|
||||
if (job.status === 'locked' || job.status === 'running') {
|
||||
await this.prisma.aiRuntimeJob.update({
|
||||
where: { id: jobId },
|
||||
data: { cancelRequestedAt: new Date() },
|
||||
});
|
||||
return { jobId, status: 'cancel_requested' };
|
||||
}
|
||||
|
||||
throw new BadRequestException({
|
||||
errorCode: 'JOB_CANNOT_CANCEL',
|
||||
message: `Job is in terminal state "${job.status}"`,
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Quiz Publish ══
|
||||
|
||||
async publishQuiz(userId: string, quizId: string) {
|
||||
const quiz = await this.prisma.quiz.findFirst({
|
||||
where: { id: quizId, userId },
|
||||
select: { id: true, status: true, knowledgeBaseId: true },
|
||||
});
|
||||
if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' });
|
||||
if (quiz.status !== 'ready') {
|
||||
throw new BadRequestException({
|
||||
errorCode: 'QUIZ_NOT_READY',
|
||||
message: `Quiz is "${quiz.status}", only "ready" quizzes can be published`,
|
||||
});
|
||||
}
|
||||
|
||||
// Archive old active quizzes for same knowledge base
|
||||
if (quiz.knowledgeBaseId) {
|
||||
await this.prisma.quiz.updateMany({
|
||||
where: { userId, knowledgeBaseId: quiz.knowledgeBaseId, status: 'active', id: { not: quizId } },
|
||||
data: { status: 'archived' },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.quiz.update({
|
||||
where: { id: quizId },
|
||||
data: { status: 'active' },
|
||||
});
|
||||
|
||||
return { quizId, status: 'active' };
|
||||
}
|
||||
|
||||
// ══ Flashcard Publish ══
|
||||
|
||||
async publishFlashcard(userId: string, cardId: string) {
|
||||
const card = await this.prisma.flashcard.findFirst({
|
||||
where: { id: cardId, userId, deletedAt: null },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!card) throw new NotFoundException({ errorCode: 'FLASHCARD_NOT_FOUND', message: 'Flashcard not found' });
|
||||
if (card.status !== 'draft') {
|
||||
throw new BadRequestException({
|
||||
errorCode: 'FLASHCARD_NOT_DRAFT',
|
||||
message: `Flashcard is "${card.status}", only "draft" cards can be published`,
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.flashcard.update({
|
||||
where: { id: cardId },
|
||||
data: { status: 'active' },
|
||||
});
|
||||
|
||||
return { cardId, status: 'active' };
|
||||
}
|
||||
|
||||
// ══ Job Status Query ══
|
||||
|
||||
async getJob(userId: string, jobId: string) {
|
||||
const job = await this.prisma.aiRuntimeJob.findFirst({
|
||||
where: { id: jobId, userId },
|
||||
select: {
|
||||
id: true, jobType: true, targetType: true, targetId: true,
|
||||
status: true, priority: true, snapshotId: true,
|
||||
attemptNo: true, retryCount: true, maxRetryCount: true,
|
||||
errorCode: true, errorMessage: true,
|
||||
cancelRequestedAt: true, cancelledAt: true,
|
||||
startedAt: true, finishedAt: true,
|
||||
createdAt: true, updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!job) throw new NotFoundException({ errorCode: 'JOB_NOT_FOUND', message: 'Job not found' });
|
||||
return job;
|
||||
}
|
||||
|
||||
async listJobs(userId: string, status?: string, take: number = 20) {
|
||||
const where: any = { userId };
|
||||
if (status) where.status = status;
|
||||
return this.prisma.aiRuntimeJob.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, jobType: true, targetType: true, targetId: true,
|
||||
status: true, priority: true,
|
||||
errorCode: true, cancelRequestedAt: true,
|
||||
startedAt: true, finishedAt: true, createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(take, 50),
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Analysis Results Query ══
|
||||
|
||||
async getAnalysis(userId: string, analysisId: string) {
|
||||
const a = await this.prisma.aiLearningAnalysis.findFirst({
|
||||
where: { id: analysisId, userId },
|
||||
select: {
|
||||
id: true, userId: true, jobId: true, snapshotId: true,
|
||||
targetType: true, targetId: true,
|
||||
learningState: true, summary: true, riskLevel: true, confidence: true,
|
||||
evidence: true, nextActionIds: true,
|
||||
promptVersion: true, schemaVersion: true,
|
||||
createdAt: true, updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!a) throw new NotFoundException({ errorCode: 'ANALYSIS_NOT_FOUND', message: 'Analysis not found' });
|
||||
return a;
|
||||
}
|
||||
|
||||
async listAnalyses(userId: string, targetType?: string, targetId?: string, take: number = 20) {
|
||||
const where: any = { userId };
|
||||
if (targetType) where.targetType = targetType;
|
||||
if (targetId) where.targetId = targetId;
|
||||
return this.prisma.aiLearningAnalysis.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, targetType: true, targetId: true,
|
||||
learningState: true, riskLevel: true, confidence: true,
|
||||
summary: true, createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(take, 50),
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Suggestions / Weak Points Query ══
|
||||
|
||||
async listRecommendations(userId: string, targetType?: string, targetId?: string, status?: string, take: number = 20) {
|
||||
const where: any = { userId };
|
||||
if (targetType) where.targetType = targetType;
|
||||
if (targetId) where.targetId = targetId;
|
||||
if (status) where.status = status;
|
||||
return this.prisma.nextActionRecommendation.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, actionType: true, targetType: true, targetId: true,
|
||||
title: true, reason: true, priority: true, estimatedMinutes: true,
|
||||
deviceSuitability: true, status: true, createdAt: true,
|
||||
},
|
||||
orderBy: { priority: 'asc' },
|
||||
take: Math.min(take, 50),
|
||||
});
|
||||
}
|
||||
|
||||
async listWeakPoints(userId: string, targetType?: string, targetId?: string, status?: string, take: number = 20) {
|
||||
const where: any = { userId };
|
||||
if (targetType) where.targetType = targetType;
|
||||
if (targetId) where.targetId = targetId;
|
||||
if (status) where.status = status;
|
||||
return this.prisma.weakPointCandidate.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, knowledgePointId: true, title: true, reason: true,
|
||||
confidence: true, evidence: true, status: true,
|
||||
targetType: true, targetId: true, createdAt: true,
|
||||
},
|
||||
orderBy: { confidence: 'desc' },
|
||||
take: Math.min(take, 50),
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Quiz Query ══
|
||||
|
||||
async getQuiz(userId: string, quizId: string) {
|
||||
const quiz = await this.prisma.quiz.findFirst({
|
||||
where: { id: quizId, userId },
|
||||
select: {
|
||||
id: true, knowledgeBaseId: true, title: true, description: true,
|
||||
questionCount: true, sourceType: true, sourceId: true,
|
||||
status: true, createdAt: true, updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' });
|
||||
return quiz;
|
||||
}
|
||||
|
||||
async getQuizQuestions(userId: string, quizId: string) {
|
||||
const quiz = await this.prisma.quiz.findFirst({ where: { id: quizId, userId }, select: { id: true } });
|
||||
if (!quiz) throw new NotFoundException({ errorCode: 'QUIZ_NOT_FOUND', message: 'Quiz not found' });
|
||||
return this.prisma.quizQuestion.findMany({
|
||||
where: { quizId },
|
||||
select: {
|
||||
id: true, type: true, stem: true, options: true,
|
||||
answer: true, explanation: true, sourceBlockIds: true, orderIndex: true,
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listQuizzes(userId: string, knowledgeBaseId?: string, status?: string, take: number = 20) {
|
||||
const where: any = { userId };
|
||||
if (knowledgeBaseId) where.knowledgeBaseId = knowledgeBaseId;
|
||||
if (status) where.status = status;
|
||||
return this.prisma.quiz.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, knowledgeBaseId: true, title: true, questionCount: true,
|
||||
sourceType: true, status: true, createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(take, 50),
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Flashcard Query ══
|
||||
|
||||
async getFlashcard(userId: string, cardId: string) {
|
||||
const card = await this.prisma.flashcard.findFirst({
|
||||
where: { id: cardId, userId, deletedAt: null },
|
||||
select: {
|
||||
id: true, front: true, back: true, hint: true,
|
||||
difficultyLevel: true, knowledgePointId: true, sourceBlockIds: true,
|
||||
sourceType: true, sourceId: true, generatedByJobId: true,
|
||||
status: true, createdAt: true, updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!card) throw new NotFoundException({ errorCode: 'FLASHCARD_NOT_FOUND', message: 'Flashcard not found' });
|
||||
return card;
|
||||
}
|
||||
|
||||
async listFlashcards(userId: string, knowledgePointId?: string, status?: string, take: number = 20) {
|
||||
const where: any = { userId, deletedAt: null };
|
||||
if (knowledgePointId) where.knowledgePointId = knowledgePointId;
|
||||
if (status) where.status = status;
|
||||
return this.prisma.flashcard.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, front: true, difficultyLevel: true,
|
||||
knowledgePointId: true, sourceType: true, status: true, createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(take, 50),
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Re-analysis Trigger ══
|
||||
|
||||
async triggerReanalysis(userId: string, targetType: string, targetId: string) {
|
||||
return this.createAnalysisJob(userId, {
|
||||
jobType: 'learning_state_analysis',
|
||||
targetType,
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Artifact Feedback ══
|
||||
|
||||
async submitArtifactFeedback(
|
||||
userId: string, artifactType: string, artifactId: string,
|
||||
dto: { feedbackType: string; reason?: string },
|
||||
) {
|
||||
const exists = await this.checkArtifactExists(userId, artifactType, artifactId);
|
||||
if (!exists) throw new NotFoundException({ errorCode: 'ARTIFACT_NOT_FOUND', message: `${artifactType} not found` });
|
||||
return this.prisma.aiArtifactFeedback.create({
|
||||
data: { userId, artifactType, artifactId, feedbackType: dto.feedbackType, reason: dto.reason ?? null },
|
||||
select: { id: true, feedbackType: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async checkArtifactExists(userId: string, artifactType: string, artifactId: string): Promise<boolean> {
|
||||
if (artifactType === 'analysis') {
|
||||
const a = await this.prisma.aiLearningAnalysis.findFirst({ where: { id: artifactId, userId }, select: { id: true } });
|
||||
return !!a;
|
||||
}
|
||||
if (artifactType === 'quiz') {
|
||||
const q = await this.prisma.quiz.findFirst({ where: { id: artifactId, userId }, select: { id: true } });
|
||||
return !!q;
|
||||
}
|
||||
if (artifactType === 'flashcard') {
|
||||
const f = await this.prisma.flashcard.findFirst({ where: { id: artifactId, userId, deletedAt: null }, select: { id: true } });
|
||||
return !!f;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ══ General Feedback ══
|
||||
|
||||
async submitFeedback(userId: string, dto: { category: string; content: string; email?: string; deviceInfo?: any }) {
|
||||
return this.prisma.feedback.create({
|
||||
data: {
|
||||
userId, category: dto.category, content: dto.content,
|
||||
email: dto.email ?? null, deviceInfo: dto.deviceInfo ?? undefined, status: 'open',
|
||||
},
|
||||
select: { id: true, status: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ══ Notifications ══
|
||||
|
||||
async listNotifications(userId: string, take: number = 20) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, type: true, title: true, content: true, data: true, readAt: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(take, 50),
|
||||
});
|
||||
}
|
||||
|
||||
private toResponse(c: any): CredentialResponseDto {
|
||||
return {
|
||||
id: c.id,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user