feat: AI Runtime 完整业务逻辑实现
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:
wangdl 2026-06-18 11:22:03 +08:00
parent eba9632a4e
commit c88af39673
25 changed files with 4841 additions and 70 deletions

322
docs/ai-runtime-user-api.md Normal file
View 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 认证,不对外暴露。

View 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 到同一个 jobpoll 时有 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()`

View 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 中对信号计算相关文件做 hashhash 变更时校验是否同步更新了 `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
View File

@ -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",

View File

@ -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 ──

View File

@ -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,

View 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 });
}
}

View 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 {}

View 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 };
}
}

View 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;
}

View File

@ -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 {}

View File

@ -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 ──

View File

@ -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<{

View 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 });
});
});

View 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 };
}
}

View 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');
});
});
});

View 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;
}
}

View 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');
});
});
});

File diff suppressed because it is too large Load Diff

View 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 });
});
});
});

View 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 };
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View 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 }),
}));
});
});

View File

@ -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,