From c88af39673266ab3594ca188788751ba9058c43d Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 18 Jun 2026 11:22:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20Runtime=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/ai-runtime-user-api.md | 322 +++++ .../issues/API-AI-R01-resolveSnapshot-race.md | 88 ++ ...PI-AI-R02-sourceDataVersion-enhancement.md | 43 + package-lock.json | 43 +- prisma/schema.prisma | 22 +- src/app.module.ts | 2 + .../admin-learning.controller.ts | 78 ++ .../admin-learning/admin-learning.module.ts | 12 + .../admin-learning/admin-learning.service.ts | 310 +++++ .../admin-learning/dto/admin-learning.dto.ts | 54 + src/modules/ai-runtime/ai-runtime.module.ts | 8 +- .../internal/runtime-internal.controller.ts | 3 +- .../internal/runtime-internal.service.ts | 387 +++++- .../ai-runtime/job-reaper.service.spec.ts | 77 ++ src/modules/ai-runtime/job-reaper.service.ts | 98 ++ .../ai-runtime/priority-rules.service.spec.ts | 240 ++++ .../ai-runtime/priority-rules.service.ts | 157 +++ .../snapshot-builder.service.spec.ts | 901 ++++++++++++++ .../ai-runtime/snapshot-builder.service.ts | 1055 +++++++++++++++++ .../snapshot-cleanup.service.spec.ts | 40 + .../ai-runtime/snapshot-cleanup.service.ts | 32 + src/modules/ai-runtime/user-ai.controller.ts | 141 ++- src/modules/ai-runtime/user-ai.dto.ts | 44 +- .../ai-runtime/user-ai.service.spec.ts | 266 +++++ src/modules/ai-runtime/user-ai.service.ts | 488 +++++++- 25 files changed, 4841 insertions(+), 70 deletions(-) create mode 100644 docs/ai-runtime-user-api.md create mode 100644 docs/issues/API-AI-R01-resolveSnapshot-race.md create mode 100644 docs/issues/API-AI-R02-sourceDataVersion-enhancement.md create mode 100644 src/modules/admin-learning/admin-learning.controller.ts create mode 100644 src/modules/admin-learning/admin-learning.module.ts create mode 100644 src/modules/admin-learning/admin-learning.service.ts create mode 100644 src/modules/admin-learning/dto/admin-learning.dto.ts create mode 100644 src/modules/ai-runtime/job-reaper.service.spec.ts create mode 100644 src/modules/ai-runtime/job-reaper.service.ts create mode 100644 src/modules/ai-runtime/priority-rules.service.spec.ts create mode 100644 src/modules/ai-runtime/priority-rules.service.ts create mode 100644 src/modules/ai-runtime/snapshot-builder.service.spec.ts create mode 100644 src/modules/ai-runtime/snapshot-builder.service.ts create mode 100644 src/modules/ai-runtime/snapshot-cleanup.service.spec.ts create mode 100644 src/modules/ai-runtime/snapshot-cleanup.service.ts create mode 100644 src/modules/ai-runtime/user-ai.service.spec.ts diff --git a/docs/ai-runtime-user-api.md b/docs/ai-runtime-user-api.md new file mode 100644 index 0000000..37257b4 --- /dev/null +++ b/docs/ai-runtime-user-api.md @@ -0,0 +1,322 @@ +# AI Runtime 用户 API 接入文档 + +## 概述 + +本文档描述 AI Runtime 对外暴露的用户面 REST API。所有端点均需 Bearer Token 认证(`Authorization: Bearer `),用户只能操作自己的资源。 + +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 payload 提取。内部端点(`/internal/runtime/*`)使用 Service Token 认证,不对外暴露。 diff --git a/docs/issues/API-AI-R01-resolveSnapshot-race.md b/docs/issues/API-AI-R01-resolveSnapshot-race.md new file mode 100644 index 0000000..47f5d4f --- /dev/null +++ b/docs/issues/API-AI-R01-resolveSnapshot-race.md @@ -0,0 +1,88 @@ +# API-AI-R01: resolveSnapshot 并发竞争 + +## 基本信息 + +| 字段 | 值 | +|------|-----| +| Issue ID | API-AI-R01 | +| 类型 | Non-blocking / 优化 | +| 仓库 | api-server | +| 关联 Issue | API-AI-016 (Snapshot Builder) | +| 发现日期 | 2026-06-17 | +| 优先级 | P2 | + +## 问题描述 + +`runtime-internal.service.ts` 中的 `resolveSnapshot()` 存在并发竞态窗口。 + +### 场景 + +两个 Runtime 实例同时对同一个 job 调用 `getSnapshot()`,且 job 当前没有有效 snapshot(未生成或已过期): + +``` +时间线 → + +实例 A 实例 B + │ │ + ├─ resolveSnapshot(job) │ + │ snapshotId=null → 进 else │ + │ ├─ resolveSnapshot(job) + │ │ snapshotId=null → 进 else + │ │ + ├─ buildSnapshot() → snap-A │ + │ ├─ buildSnapshot() → snap-B + │ │ + ├─ job.update(snapshotId=A) │ + │ ├─ job.update(snapshotId=B) ← 覆盖 A +``` + +### 后果 + +1. 数据库产生 snap-A 孤儿行(无 job 引用) +2. 浪费一次全量聚合查询(buildSnapshot) +3. snap-A 在 24h TTL 后自动过期清理 + +### 为什么当前影响可接受 + +- 不会丢数据或返回错误 +- snapshot 构建是幂等的,两份结果一致性高 +- 触发条件苛刻:两个 Runtime 实例需同时 poll 到同一个 job(poll 时有 lock 机制大幅降低概率) +- 即使发生,额外开销仅为一次聚合查询 + +## 建议修复方案 + +方案:对 job 行加悲观锁后再判断 snapshot 状态。 + +```typescript +// resolveSnapshot 改为: +private async resolveSnapshot(job) { + // SELECT ... FOR UPDATE 锁住 job 行 + const locked = await this.prisma.aiRuntimeJob.findUnique({ + where: { id: job.id }, + // Prisma 不直接支持 FOR UPDATE,需用 $queryRaw + }); + + if (locked.snapshotId) { + const existing = await this.prisma.learningAnalysisSnapshot.findUnique({ + where: { id: locked.snapshotId }, + }); + if (existing && (!existing.expiresAt || new Date(existing.expiresAt) >= new Date())) { + return existing; + } + } + + const snapshot = await this.snapshotBuilder.buildSnapshot(...); + await this.prisma.aiRuntimeJob.update({ + where: { id: job.id }, + data: { snapshotId: snapshot.id }, + }); + return snapshot; +} +``` + +或者使用 `$transaction` 包裹读-判断-写逻辑,依赖数据库隔离级别保护。 + +## 相关文件 + +- `src/modules/ai-runtime/internal/runtime-internal.service.ts:resolveSnapshot()` +- `src/modules/ai-runtime/snapshot-builder.service.ts:buildSnapshot()` diff --git a/docs/issues/API-AI-R02-sourceDataVersion-enhancement.md b/docs/issues/API-AI-R02-sourceDataVersion-enhancement.md new file mode 100644 index 0000000..950bf4d --- /dev/null +++ b/docs/issues/API-AI-R02-sourceDataVersion-enhancement.md @@ -0,0 +1,43 @@ +# API-AI-R02: sourceDataVersion 增强 + +## 基本信息 + +| 字段 | 值 | +|------|-----| +| Issue ID | API-AI-R02 | +| 类型 | Non-blocking / 增强 | +| 仓库 | api-server | +| 关联 Issue | API-AI-021 (Snapshot 版本化与过期) | +| 发现日期 | 2026-06-17 | +| 优先级 | P2 | + +## 问题描述 + +`LearningAnalysisSnapshot.sourceDataVersion` 字段当前写入固定值 `'1.0'`(`snapshot-builder.service.ts:122`),但缺乏自动递增/校验机制。 + +### 当前状态 + +- 字段已写入:`sourceDataVersion: SOURCE_DATA_VERSION`(常量 `'1.0'`) +- `resolveSnapshot` 已做版本匹配检查:`existing.sourceDataVersion === SOURCE_DATA_VERSION` +- 缺少:当聚合逻辑变更时自动检测并递增版本号的能力 + +### 期望增强 + +当以下任一变更发生时,`SOURCE_DATA_VERSION` 需要手动递增,但目前依赖开发者记忆: + +1. `computeSignals` 信号计算公式变更 +2. `getScoreWeights` 权重调整 +3. `classifyMasteryLevel` 分类阈值变更 +4. `BEHAVIOR_WINDOW_DAYS` / `SCORE_WINDOW_DAYS` 等窗口常量变更 +5. 聚合查询字段增减 + +### 建议方案 + +- 方案 A:在 CI 中对信号计算相关文件做 hash,hash 变更时校验是否同步更新了 `SOURCE_DATA_VERSION` +- 方案 B:将 `SOURCE_DATA_VERSION` 改为从信号逻辑的语义版本自动推导 +- 方案 C:在开发流程中增加 checklist,变更信号逻辑时必须更新版本号 + +## 相关文件 + +- `src/modules/ai-runtime/snapshot-builder.service.ts` +- `src/modules/ai-runtime/internal/runtime-internal.service.ts:resolveSnapshot()` diff --git a/package-lock.json b/package-lock.json index a76d4d1..0f24ae9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c10c122..97acfc1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1721,15 +1721,16 @@ model Quiz { } model QuizQuestion { - id String @id @default(cuid()) - quizId String - type String @db.VarChar(16) - stem String @db.Text - options Json? - answer String @db.VarChar(500) - explanation String? @db.Text - orderIndex Int @default(0) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + quizId String + type String @db.VarChar(16) + stem String @db.Text + options Json? + answer String @db.VarChar(500) + explanation String? @db.Text + sourceBlockIds Json? + orderIndex Int @default(0) + createdAt DateTime @default(now()) quiz Quiz @relation(fields: [quizId], references: [id]) answers QuizAnswer[] @@ -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 ── diff --git a/src/app.module.ts b/src/app.module.ts index b0647d8..800cea5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/modules/admin-learning/admin-learning.controller.ts b/src/modules/admin-learning/admin-learning.controller.ts new file mode 100644 index 0000000..64daf7d --- /dev/null +++ b/src/modules/admin-learning/admin-learning.controller.ts @@ -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 }); + } +} diff --git a/src/modules/admin-learning/admin-learning.module.ts b/src/modules/admin-learning/admin-learning.module.ts new file mode 100644 index 0000000..f6901b3 --- /dev/null +++ b/src/modules/admin-learning/admin-learning.module.ts @@ -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 {} diff --git a/src/modules/admin-learning/admin-learning.service.ts b/src/modules/admin-learning/admin-learning.service.ts new file mode 100644 index 0000000..8346b93 --- /dev/null +++ b/src/modules/admin-learning/admin-learning.service.ts @@ -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 }; + } +} diff --git a/src/modules/admin-learning/dto/admin-learning.dto.ts b/src/modules/admin-learning/dto/admin-learning.dto.ts new file mode 100644 index 0000000..7a36526 --- /dev/null +++ b/src/modules/admin-learning/dto/admin-learning.dto.ts @@ -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; +} diff --git a/src/modules/ai-runtime/ai-runtime.module.ts b/src/modules/ai-runtime/ai-runtime.module.ts index aec489b..efd80a7 100644 --- a/src/modules/ai-runtime/ai-runtime.module.ts +++ b/src/modules/ai-runtime/ai-runtime.module.ts @@ -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 {} diff --git a/src/modules/ai-runtime/internal/runtime-internal.controller.ts b/src/modules/ai-runtime/internal/runtime-internal.controller.ts index c79030a..563a768 100644 --- a/src/modules/ai-runtime/internal/runtime-internal.controller.ts +++ b/src/modules/ai-runtime/internal/runtime-internal.controller.ts @@ -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 ── diff --git a/src/modules/ai-runtime/internal/runtime-internal.service.ts b/src/modules/ai-runtime/internal/runtime-internal.service.ts index 759dec9..4d13256 100644 --- a/src/modules/ai-runtime/internal/runtime-internal.service.ts +++ b/src/modules/ai-runtime/internal/runtime-internal.service.ts @@ -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(); + + 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<{ diff --git a/src/modules/ai-runtime/job-reaper.service.spec.ts b/src/modules/ai-runtime/job-reaper.service.spec.ts new file mode 100644 index 0000000..9deb65a --- /dev/null +++ b/src/modules/ai-runtime/job-reaper.service.spec.ts @@ -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 }); + }); +}); diff --git a/src/modules/ai-runtime/job-reaper.service.ts b/src/modules/ai-runtime/job-reaper.service.ts new file mode 100644 index 0000000..7418863 --- /dev/null +++ b/src/modules/ai-runtime/job-reaper.service.ts @@ -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 | 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 }; + } +} diff --git a/src/modules/ai-runtime/priority-rules.service.spec.ts b/src/modules/ai-runtime/priority-rules.service.spec.ts new file mode 100644 index 0000000..ea8bfa5 --- /dev/null +++ b/src/modules/ai-runtime/priority-rules.service.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/modules/ai-runtime/priority-rules.service.ts b/src/modules/ai-runtime/priority-rules.service.ts new file mode 100644 index 0000000..77c5643 --- /dev/null +++ b/src/modules/ai-runtime/priority-rules.service.ts @@ -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; + } +} diff --git a/src/modules/ai-runtime/snapshot-builder.service.spec.ts b/src/modules/ai-runtime/snapshot-builder.service.spec.ts new file mode 100644 index 0000000..46209bd --- /dev/null +++ b/src/modules/ai-runtime/snapshot-builder.service.spec.ts @@ -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) { + 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; + let priorityRules: ReturnType; + + 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'); + }); + }); +}); diff --git a/src/modules/ai-runtime/snapshot-builder.service.ts b/src/modules/ai-runtime/snapshot-builder.service.ts new file mode 100644 index 0000000..fdc3fa1 --- /dev/null +++ b/src/modules/ai-runtime/snapshot-builder.service.ts @@ -0,0 +1,1055 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { PriorityRulesService } from './priority-rules.service'; + +/** Protocol version negotiated with Runtime via capabilities.supportedSnapshotVersions. Bump on schema changes. */ +const SNAPSHOT_VERSION = 'ai_snapshot_v1'; + +/** Tracks aggregation logic version. Bump when computeSignals, getScoreWeights, classifyMasteryLevel, or window constants change. */ +export const SOURCE_DATA_VERSION = '1.0'; + +/** Aggregation windows */ +const BEHAVIOR_WINDOW_DAYS = 7; +const SCORE_WINDOW_DAYS = 30; + +/** Result limits */ +const MAX_DAILY_ACTIVITIES = 7; +const MAX_RECENT_RECORDS = 20; +const MAX_MATERIALS = 50; +const MAX_KNOWLEDGE_ITEMS = 200; +const MAX_RECENT_ANALYSES = 5; + +/** Snapshot expires after 24 hours by default */ +const SNAPSHOT_TTL_HOURS = 24; + +interface DailyActivitySlice { + activityDate: Date; + durationSeconds: number; + sessionsCount: number; + readingSeconds: number; + materialsReadCount: number; + activeRecallCount: number; + reviewCount: number; + aiAnalysisCount: number; + activityLevel: number; +} + +interface TodayActivitySlice { + durationSeconds: number; + sessionsCount: number; + activityLevel: number; +} + +interface StreakRecordSlice { + length: number; + startDate: Date; + endDate: Date; +} + +interface ReadingEventSlice { + clientTimestampMs: bigint; + clientTimezoneOffsetMinutes: number | null; +} + +interface SessionSlice { + id: string; + totalActiveSeconds: number; + status: string; + startedAt: Date; +} + +interface ProgressAggregateSlice { + _sum: { totalActiveSeconds: number | null; sessionCount: number | null }; + _count: number; +} + +@Injectable() +export class SnapshotBuilderService { + private readonly logger = new Logger(SnapshotBuilderService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly priorityRules: PriorityRulesService, + ) {} + + async buildSnapshot(userId: string, targetType: string, targetId: string) { + try { + const [settings, profile] = await Promise.all([ + this.prisma.userAiSettings.findUnique({ where: { userId } }), + this.prisma.userLearningProfile.findUnique({ where: { userId } }), + ]); + + const privacyScope = { + allowDocumentContent: settings?.allowUseDocumentContent ?? false, + allowLearningBehavior: settings?.allowUseLearningBehavior ?? true, + allowUserProfile: settings?.allowUseUserProfile ?? true, + }; + + const behaviorData = privacyScope.allowLearningBehavior + ? await this.fetchBehaviorData(userId, targetType, targetId) + : null; + + const [ + learningBehaviorSummary, + materialProgressSummary, + contentStructureSummary, + behaviorSignals, + scoreSignals, + deviceContext, + ] = await Promise.all([ + behaviorData + ? this.aggregateBehavior(userId, targetType, targetId, behaviorData) + : undefined, + this.aggregateProgress(userId, targetType, targetId), + privacyScope.allowDocumentContent + ? this.aggregateContent(userId, targetType, targetId) + : undefined, + behaviorData + ? this.calculateBehaviorSignals(userId, behaviorData) + : undefined, + this.calculateScores(userId, profile?.qualityPreference ?? 'standard'), + this.buildDeviceContext(userId), + ]); + + const expiresAt = new Date(Date.now() + SNAPSHOT_TTL_HOURS * 60 * 60 * 1000); + + const snapshot = await this.prisma.learningAnalysisSnapshot.create({ + data: { + userId, + scopeType: targetType, + scopeId: targetId, + snapshotVersion: SNAPSHOT_VERSION, + sourceDataVersion: SOURCE_DATA_VERSION, + privacyScope: privacyScope as any, + userProfile: privacyScope.allowUserProfile ? this.pickSafeFields(profile) : undefined, + aiSettings: settings ? this.pickAiSettingsFields(settings) : undefined, + deviceContext: deviceContext as any, + learningBehaviorSummary: learningBehaviorSummary as any, + materialProgressSummary: materialProgressSummary as any, + contentStructureSummary: contentStructureSummary as any, + behaviorSignals: behaviorSignals as any, + scoreSignals: scoreSignals as any, + constraints: { + dailyAvailableMinutes: profile?.dailyAvailableMinutes ?? null, + qualityPreference: profile?.qualityPreference ?? 'standard', + learningGoal: profile?.learningGoal ?? null, + priorityRules: this.priorityRules.computePriorityRules(profile, settings, targetType, targetId) as any, + }, + allowedModelFields: this.buildAllowedFields(settings, profile), + expiresAt, + }, + }); + + return snapshot; + } catch (error: any) { + this.logger.error( + `Snapshot build failed for user=${userId} scope=${targetType}/${targetId}: ${error.message}`, + error.stack, + ); + throw error; + } + } + + // ── Field Pickers ── + + private pickSafeFields(profile: any) { + if (!profile) return undefined; + return { + learningGoal: profile.learningGoal, + currentLevel: profile.currentLevel, + qualityPreference: profile.qualityPreference, + ageRange: profile.ageRange, + preferredLanguage: profile.preferredLanguage, + learningStyle: profile.learningStyle, + examTarget: profile.examTarget, + preferredQuestionTypes: profile.preferredQuestionTypes, + }; + } + + private pickAiSettingsFields(settings: any) { + if (!settings) return undefined; + return { + apiKeyMode: settings.apiKeyMode, + defaultCredentialId: settings.defaultCredentialId, + fallbackToPlatformKey: settings.fallbackToPlatformKey, + maxDailyAiJobs: settings.maxDailyAiJobs, + maxDailyTokenBudget: settings.maxDailyTokenBudget, + }; + } + + // ── Shared Behavior Data ── + + private async fetchBehaviorData(userId: string, targetType: string, targetId: string) { + const now = new Date(); + const weekAgo = new Date(now.getTime() - BEHAVIOR_WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const sessionWhere: any = { userId }; + if (targetType && targetId) { + if (targetType === 'material') { + sessionWhere.materialId = targetId; + } else if (targetType === 'knowledge_base') { + sessionWhere.knowledgeBaseId = targetId; + } + } + + const [ + sessionAgg, + weeklyScopedSessions, + weeklyGlobalSessions, + dailyActivities, + todayActivity, + ] = await Promise.all([ + this.prisma.learningSession.aggregate({ + where: sessionWhere, + _sum: { totalActiveSeconds: true }, + _count: true, + }), + this.prisma.learningSession.count({ + where: { ...sessionWhere, startedAt: { gte: weekAgo } }, + }), + this.prisma.learningSession.count({ + where: { userId, startedAt: { gte: weekAgo } }, + }), + this.prisma.dailyLearningActivity.findMany({ + where: { userId, activityDate: { gte: weekAgo } }, + orderBy: { activityDate: 'desc' }, + take: MAX_DAILY_ACTIVITIES, + select: { + activityDate: true, + durationSeconds: true, + sessionsCount: true, + readingSeconds: true, + materialsReadCount: true, + activeRecallCount: true, + reviewCount: true, + aiAnalysisCount: true, + activityLevel: true, + }, + }), + this.prisma.dailyLearningActivity.findUnique({ + where: { + userId_activityDate: { userId, activityDate: new Date(now.toISOString().split('T')[0]) }, + }, + select: { durationSeconds: true, sessionsCount: true, activityLevel: true }, + }), + ]); + + const activeDaysCount = dailyActivities.filter(d => d.durationSeconds > 0).length; + + return { + sessionAgg, + weeklyScopedSessions, + weeklyGlobalSessions, + dailyActivities, + activeDaysCount, + todayActivity, + }; + } + + // ── Aggregators ── + + /** + * Aggregate learning behavior data. + * + * Scope support: + * - LearningSession: scoped by targetType/targetId (materialId or knowledgeBaseId) + * - DailyLearningActivity: user-global (model lacks materialId/knowledgeBaseId fields) + * - LearningRecord: user-global (model lacks materialId/knowledgeBaseId fields) + */ + private async aggregateBehavior( + userId: string, + targetType: string, + targetId: string, + shared: Awaited>, + ) { + const recentRecords = await this.prisma.learningRecord.findMany({ + where: { userId }, + orderBy: { occurredAt: 'desc' }, + take: MAX_RECENT_RECORDS, + select: { + id: true, + recordType: true, + title: true, + durationSeconds: true, + occurredAt: true, + }, + }); + + return { + totalSessions: shared.sessionAgg._count, + totalActiveSeconds: shared.sessionAgg._sum.totalActiveSeconds ?? 0, + weeklySessions: shared.weeklyScopedSessions, + dailyActivities: shared.dailyActivities, + recentRecords, + }; + } + + private async aggregateProgress(userId: string, targetType: string, targetId: string) { + const where: any = { userId }; + if (targetType && targetId) { + if (targetType === 'knowledge_base') { + where.knowledgeBaseId = targetId; + } else { + where.readingTargetType = targetType; + where.materialId = targetId; + } + } + + const progress = await this.prisma.materialReadingProgress.findMany({ + where, + select: { + materialId: true, + readingTargetType: true, + knowledgeBaseId: true, + status: true, + lastProgress: true, + totalActiveSeconds: true, + sessionCount: true, + lastReadAt: true, + isMarkedRead: true, + }, + orderBy: { lastReadAt: 'desc' }, + take: MAX_MATERIALS, + }); + + return { + totalMaterials: progress.length, + completedCount: progress.filter(p => p.status === 'completed').length, + inProgressCount: progress.filter(p => p.status === 'in_progress').length, + totalActiveSeconds: progress.reduce((sum, p) => sum + p.totalActiveSeconds, 0), + materials: progress, + }; + } + + private async aggregateContent(userId: string, targetType: string, targetId: string) { + if (!targetId) return undefined; + + if (targetType === 'knowledge_base') { + const items = await this.prisma.knowledgeItem.findMany({ + where: { userId, knowledgeBaseId: targetId, status: 'active', deletedAt: null }, + select: { + id: true, + itemType: true, + title: true, + summary: true, + learnable: true, + orderIndex: true, + durationSeconds: true, + }, + orderBy: { orderIndex: 'asc' }, + take: MAX_KNOWLEDGE_ITEMS, + }); + + return { + itemCount: items.length, + items, + }; + } + + if (targetType === 'material') { + const progress = await this.prisma.materialReadingProgress.findFirst({ + where: { userId, materialId: targetId }, + select: { + materialId: true, + readingTargetType: true, + lastPosition: true, + lastProgress: true, + }, + }); + + return { + materialId: targetId, + lastPosition: progress?.lastPosition, + lastProgress: progress?.lastProgress, + }; + } + + this.logger.warn(`aggregateContent: unsupported targetType="${targetType}", returning undefined`); + return undefined; + } + + // ── Score Calculator ── + + private async calculateScores(userId: string, qualityPreference: string) { + const normalizedPref = this.normalizeQualityPreference(qualityPreference); + + const now = new Date(); + const monthAgo = new Date(now.getTime() - SCORE_WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const [quizAgg, quizAttempts, reviewLogs, recentAnalyses, activeWeakPoints] = await Promise.all([ + this.prisma.quizAttempt.aggregate({ + where: { userId, startedAt: { gte: monthAgo } }, + _avg: { score: true, correctCount: true, totalQuestions: true }, + _count: true, + }), + this.prisma.quizAttempt.findMany({ + where: { userId, startedAt: { gte: monthAgo } }, + select: { score: true, startedAt: true }, + orderBy: { startedAt: 'asc' }, + take: 100, + }), + this.prisma.reviewLog.findMany({ + where: { userId, reviewedAt: { gte: monthAgo } }, + select: { rating: true, reviewedAt: true }, + orderBy: { reviewedAt: 'asc' }, + take: 200, + }), + this.prisma.aiLearningAnalysis.findMany({ + where: { userId, createdAt: { gte: monthAgo } }, + orderBy: { createdAt: 'desc' }, + take: MAX_RECENT_ANALYSES, + select: { + id: true, targetType: true, targetId: true, + learningState: true, riskLevel: true, confidence: true, createdAt: true, + }, + }), + this.prisma.weakPointCandidate.findMany({ + where: { userId, status: 'active' }, + select: { confidence: true, title: true }, + take: 20, + }), + ]); + + // Base scores (existing) + const base = { + quiz: { + attempts: quizAgg._count, + avgScore: quizAgg._avg.score ?? null, + avgCorrectCount: quizAgg._avg.correctCount ?? null, + avgTotalQuestions: quizAgg._avg.totalQuestions ?? null, + }, + review: { + totalLogs: reviewLogs.length, + ratingDistribution: this.countBy(reviewLogs, r => r.rating), + }, + recentAnalyses, + }; + + // Derived scores (API-AI-020) + const weightedQuizScore = this.computeWeightedQuizScore(quizAttempts, now); + const weightedReviewScore = this.computeWeightedReviewScore(reviewLogs, now); + const aiConfidenceScore = this.computeAIScore(recentAnalyses); + const weakPointSeverity = this.computeWeakPointSeverity(activeWeakPoints); + const quizTrend = this.computeQuizTrend(quizAttempts); + + const weights = this.getScoreWeights(normalizedPref); + const compositeMastery = this.computeCompositeMastery( + weightedQuizScore, weightedReviewScore, aiConfidenceScore, weakPointSeverity, weights, + ); + const masteryLevel = this.classifyMasteryLevel(compositeMastery); + + return { + ...base, + derived: { + weightedQuizScore, + weightedReviewScore, + aiConfidenceScore, + weakPointSeverity, + quizTrend, + compositeMastery, + masteryLevel, + qualityPreference: normalizedPref, + weightConfiguration: weights, + }, + }; + } + + // ── Recency Weighting ── + + private expDecayWeight(daysAgo: number, halfLifeDays: number = 7): number { + return Math.exp(-Math.LN2 * daysAgo / halfLifeDays); + } + + // ── Weighted Quiz Score ── + + private computeWeightedQuizScore( + attempts: { score: number; startedAt: Date }[], + now: Date, + ): number | null { + if (attempts.length === 0) return null; + let weightedSum = 0; + let weightSum = 0; + for (const a of attempts) { + const daysAgo = (now.getTime() - new Date(a.startedAt).getTime()) / (24 * 60 * 60 * 1000); + const w = this.expDecayWeight(daysAgo); + weightedSum += (a.score / 100) * w; + weightSum += w; + } + return weightSum > 0 ? Math.round((weightedSum / weightSum) * 100) / 100 : null; + } + + // ── Weighted Review Score ── + + private computeWeightedReviewScore( + logs: { rating: string; reviewedAt: Date }[], + now: Date, + ): number | null { + if (logs.length === 0) return null; + const ratingMap: Record = { again: 0, hard: 0.33, good: 0.66, easy: 1.0 }; + let weightedSum = 0; + let weightSum = 0; + for (const l of logs) { + const num = ratingMap[l.rating] ?? 0.5; + const daysAgo = (now.getTime() - new Date(l.reviewedAt).getTime()) / (24 * 60 * 60 * 1000); + const w = this.expDecayWeight(daysAgo); + weightedSum += num * w; + weightSum += w; + } + return weightSum > 0 ? Math.round((weightedSum / weightSum) * 100) / 100 : null; + } + + // ── AI Confidence Score ── + + private computeAIScore( + analyses: { learningState: string | null; confidence: number | null }[], + ): number | null { + const stateMap: Record = { + struggling: 0.25, in_progress: 0.50, advanced: 0.75, mastered: 0.90, + }; + const valid = analyses.filter(a => a.learningState && stateMap[a.learningState] !== undefined); + if (valid.length === 0) return null; + let weightedSum = 0; + let weightSum = 0; + for (const a of valid) { + const stateNum = stateMap[a.learningState!]; + const conf = a.confidence ?? 0.5; + weightedSum += stateNum * conf; + weightSum += conf; + } + return weightSum > 0 ? Math.round((weightedSum / weightSum) * 100) / 100 : null; + } + + // ── Weak Point Severity ── + + private computeWeakPointSeverity( + weakPoints: { confidence: number | null }[], + ): number | null { + if (weakPoints.length === 0) return null; + const totalConfidence = weakPoints.reduce((sum, wp) => sum + (wp.confidence ?? 0.5), 0); + return Math.round(Math.min(1.0, totalConfidence / 5.0) * 100) / 100; + } + + // ── Quiz Trend ── + + private computeQuizTrend( + attempts: { score: number; startedAt: Date }[], + ) { + if (attempts.length < 4) { + return { direction: 'insufficient_data', percentChange: null, recentAvg: null, priorAvg: null }; + } + const midPoint = Math.floor(attempts.length / 2); + const recentHalf = attempts.slice(midPoint); + const priorHalf = attempts.slice(0, midPoint); + const recentAvg = recentHalf.reduce((s, a) => s + a.score, 0) / recentHalf.length; + const priorAvg = priorHalf.reduce((s, a) => s + a.score, 0) / priorHalf.length; + const percentChange = priorAvg > 0 ? ((recentAvg - priorAvg) / priorAvg) * 100 : null; + let direction = 'stable'; + if (percentChange !== null && percentChange >= 10) direction = 'increasing'; + else if (percentChange !== null && percentChange <= -10) direction = 'decreasing'; + return { direction, percentChange: percentChange !== null ? Math.round(percentChange * 100) / 100 : null, recentAvg, priorAvg }; + } + + // ── Composite Mastery ── + + private computeCompositeMastery( + quizScore: number | null, + reviewScore: number | null, + aiScore: number | null, + weakSeverity: number | null, + weights: ReturnType, + ): number | null { + if (quizScore === null && reviewScore === null && aiScore === null) return null; + const q = (quizScore ?? 0) * weights.w_quiz; + const r = (reviewScore ?? 0) * weights.w_review; + const a = (aiScore ?? 0) * weights.w_ai; + const penalty = (weakSeverity ?? 0) * weights.w_weak; + const composites = [quizScore, reviewScore, aiScore].filter(s => s !== null); + const usedWeights = [ + quizScore !== null ? weights.w_quiz : 0, + reviewScore !== null ? weights.w_review : 0, + aiScore !== null ? weights.w_ai : 0, + ]; + const totalWeight = usedWeights.reduce((s, w) => s + w, 0); + if (totalWeight === 0) return null; + const score = (q + r + a) / totalWeight - penalty; + return Math.round(Math.max(0, Math.min(1, score)) * 100) / 100; + } + + // ── Mastery Level Classification ── + + private classifyMasteryLevel(composite: number | null): 'struggling' | 'developing' | 'progressing' | 'proficient' | 'mastered' | 'unknown' { + if (composite === null) return 'unknown'; + if (composite >= 0.8) return 'mastered'; + if (composite >= 0.6) return 'proficient'; + if (composite >= 0.4) return 'progressing'; + if (composite >= 0.2) return 'developing'; + return 'struggling'; + } + + // ── Score Weights by Quality Preference ── + + private normalizeQualityPreference(raw: string): 'light' | 'standard' | 'deep' | 'exam' { + const valid = ['light', 'standard', 'deep', 'exam']; + const lower = raw?.toLowerCase(); + if (valid.includes(lower)) return lower as 'light' | 'standard' | 'deep' | 'exam'; + return 'standard'; + } + + private getScoreWeights(pref: string) { + const presets: Record = { + light: { w_quiz: 0.35, w_review: 0.45, w_ai: 0.10, w_weak: 0.10 }, + standard: { w_quiz: 0.35, w_review: 0.30, w_ai: 0.25, w_weak: 0.10 }, + deep: { w_quiz: 0.25, w_review: 0.20, w_ai: 0.40, w_weak: 0.15 }, + exam: { w_quiz: 0.50, w_review: 0.25, w_ai: 0.10, w_weak: 0.15 }, + }; + return presets[pref] ?? presets.standard; + } + + // ── Behavior Signals ── + + private async calculateBehaviorSignals( + userId: string, + shared: Awaited>, + ) { + const { dailyActivities, weeklyGlobalSessions, activeDaysCount, todayActivity } = shared; + const now = new Date(); + const weekAgo = new Date(now.getTime() - BEHAVIOR_WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const [ + streakRecords, + readingEvents, + recentSessions, + progressSummary, + ] = await Promise.all([ + this.prisma.streakRecord.findMany({ + where: { userId, streakType: 'reading' }, + orderBy: { endDate: 'desc' }, + take: 2, + select: { length: true, startDate: true, endDate: true }, + }), + this.prisma.readingEvent.findMany({ + where: { userId, clientTimestampMs: { gte: BigInt(weekAgo.getTime()) } }, + select: { clientTimestampMs: true, clientTimezoneOffsetMinutes: true }, + take: 500, + }), + this.prisma.learningSession.findMany({ + where: { userId, startedAt: { gte: weekAgo } }, + select: { id: true, totalActiveSeconds: true, status: true, startedAt: true }, + orderBy: { startedAt: 'asc' }, + take: 50, + }), + this.prisma.materialReadingProgress.aggregate({ + where: { userId, status: { in: ['completed', 'in_progress'] } }, + _sum: { totalActiveSeconds: true, sessionCount: true }, + _count: true, + }), + ]); + + return this.computeSignals( + dailyActivities, + weeklyGlobalSessions, + activeDaysCount, + todayActivity, + now, + streakRecords, + readingEvents, + recentSessions, + progressSummary, + ); + } + + private computeSignals( + dailyActivities: DailyActivitySlice[], + weeklyGlobalSessions: number, + activeDaysCount: number, + todayActivity: TodayActivitySlice | null, + now: Date, + streakRecords: StreakRecordSlice[], + readingEvents: ReadingEventSlice[], + recentSessions: SessionSlice[], + progressSummary: ProgressAggregateSlice, + ) { + // ── 1. Composite Engagement ── + const avgDailySeconds = dailyActivities.length > 0 + ? dailyActivities.reduce((sum: number, d: any) => sum + d.durationSeconds, 0) / dailyActivities.length + : 0; + let engagementScore = 0; + if (weeklyGlobalSessions >= 4) engagementScore += 1; + else if (weeklyGlobalSessions >= 2) engagementScore += 0.5; + if (activeDaysCount >= 5) engagementScore += 1; + else if (activeDaysCount >= 3) engagementScore += 0.5; + if (avgDailySeconds >= 1800) engagementScore += 1; + else if (avgDailySeconds >= 900) engagementScore += 0.5; + const engagementSignal = engagementScore >= 2.5 ? 'high' : engagementScore >= 1.5 ? 'medium' : 'low'; + + // ── 2. Learning Consistency ── + const durations = dailyActivities.map((d: any) => d.durationSeconds); + const mean = durations.length > 0 ? durations.reduce((a: number, b: number) => a + b, 0) / durations.length : 0; + const variance = durations.length > 1 + ? durations.reduce((sum: number, v: number) => sum + (v - mean) ** 2, 0) / durations.length + : 0; + const stddev = Math.sqrt(variance); + const cv = mean > 0 ? stddev / mean : null; + let consistencyLabel = 'irregular'; + if (cv !== null && cv < 0.5) consistencyLabel = 'consistent'; + else if (cv !== null && cv <= 1.0) consistencyLabel = 'moderate'; + const zeroDaysCount = durations.filter((d: number) => d === 0).length; + + // ── 3. Streak Metrics ── + const currentStreak = streakRecords.length > 0 ? streakRecords[0].length : 0; + const longestStreak = streakRecords.reduce((max: number, s: any) => Math.max(max, s.length), 0); + const streakAtRisk = todayActivity === null && dailyActivities.length > 0 && dailyActivities[0].durationSeconds === 0; + + // ── 4. Session Pattern Metrics ── + const completedSessions = recentSessions.filter((s: any) => s.status !== 'active' && s.status !== 'interrupted'); + const totalSessions = recentSessions.length; + const sessionDurations = recentSessions.map((s: any) => s.totalActiveSeconds).sort((a: number, b: number) => a - b); + const avgSessionDuration = sessionDurations.length > 0 + ? sessionDurations.reduce((a: number, b: number) => a + b, 0) / sessionDurations.length + : 0; + const medianSessionDuration = sessionDurations.length > 0 + ? sessionDurations[Math.floor(sessionDurations.length / 2)] + : 0; + const sessionsPerDay = activeDaysCount > 0 ? totalSessions / activeDaysCount : 0; + const sessionCompletionRate = totalSessions > 0 ? completedSessions.length / totalSessions : 0; + let avgTimeBetweenSessions: number | null = null; + if (recentSessions.length >= 2) { + const gaps: number[] = []; + for (let i = 1; i < recentSessions.length; i++) { + gaps.push((new Date(recentSessions[i].startedAt).getTime() - new Date(recentSessions[i - 1].startedAt).getTime()) / 1000); + } + avgTimeBetweenSessions = gaps.reduce((a: number, b: number) => a + b, 0) / gaps.length; + } + + // ── 5. Reading Velocity ── + const totalActiveSeconds = progressSummary._sum.totalActiveSeconds ?? 0; + const totalSessionCount = progressSummary._sum.sessionCount ?? 0; + const totalMaterialsTracked = progressSummary._count ?? 0; + const avgProgressPerSession = totalSessionCount > 0 ? totalActiveSeconds / totalSessionCount : 0; + const progressRate = totalActiveSeconds > 0 ? (totalMaterialsTracked / totalActiveSeconds) * 3600 : 0; + + // ── 6. Time-of-Day Distribution ── + const bins = { morning: 0, afternoon: 0, evening: 0, night: 0 }; + const totalEvents = readingEvents.length; + for (const e of readingEvents) { + const ms = Number(e.clientTimestampMs); + const tzOffset = (e.clientTimezoneOffsetMinutes ?? 0) as number; + const localHour = new Date(ms + tzOffset * 60 * 1000).getUTCHours(); + if (localHour >= 6 && localHour < 12) bins.morning++; + else if (localHour >= 12 && localHour < 18) bins.afternoon++; + else if (localHour >= 18 && localHour < 24) bins.evening++; + else bins.night++; + } + const timeDistribution = totalEvents > 0 ? { + morning: Math.round((bins.morning / totalEvents) * 100) / 100, + afternoon: Math.round((bins.afternoon / totalEvents) * 100) / 100, + evening: Math.round((bins.evening / totalEvents) * 100) / 100, + night: Math.round((bins.night / totalEvents) * 100) / 100, + } : { morning: 0, afternoon: 0, evening: 0, night: 0 }; + + // ── 7. Activity Balance ── + const totals = dailyActivities.reduce((acc: any, d: any) => ({ + readingSeconds: acc.readingSeconds + (d.readingSeconds ?? 0), + activeRecallCount: acc.activeRecallCount + (d.activeRecallCount ?? 0), + reviewCount: acc.reviewCount + (d.reviewCount ?? 0), + aiAnalysisCount: acc.aiAnalysisCount + (d.aiAnalysisCount ?? 0), + }), { readingSeconds: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0 }); + const activityUnits = totals.readingSeconds + totals.activeRecallCount * 120 + totals.reviewCount * 60 + totals.aiAnalysisCount * 120; + const activityBalance = activityUnits > 0 ? { + readingPct: Math.round((totals.readingSeconds / activityUnits) * 100) / 100, + activeRecallPct: Math.round((totals.activeRecallCount * 120 / activityUnits) * 100) / 100, + reviewPct: Math.round((totals.reviewCount * 60 / activityUnits) * 100) / 100, + aiAnalysisPct: Math.round((totals.aiAnalysisCount * 120 / activityUnits) * 100) / 100, + } : { readingPct: 0, activeRecallPct: 0, reviewPct: 0, aiAnalysisPct: 0 }; + const maxPct = Math.max(activityBalance.readingPct, activityBalance.activeRecallPct, activityBalance.reviewPct, activityBalance.aiAnalysisPct); + let primaryActivity = 'reading'; + if (maxPct === activityBalance.activeRecallPct) primaryActivity = 'activeRecall'; + else if (maxPct === activityBalance.reviewPct) primaryActivity = 'review'; + else if (maxPct === activityBalance.aiAnalysisPct) primaryActivity = 'aiAnalysis'; + + // ── 8. Weekly Trend ── + let trendDirection = 'insufficient_data'; + let percentChange: number | null = null; + let recentAvg = 0; + let priorAvg = 0; + if (dailyActivities.length >= 3) { + const midPoint = Math.floor(dailyActivities.length / 2); + const recentHalf = dailyActivities.slice(0, midPoint); + const priorHalf = dailyActivities.slice(midPoint); + recentAvg = recentHalf.length > 0 ? recentHalf.reduce((s: number, d: any) => s + d.durationSeconds, 0) / recentHalf.length : 0; + priorAvg = priorHalf.length > 0 ? priorHalf.reduce((s: number, d: any) => s + d.durationSeconds, 0) / priorHalf.length : 0; + percentChange = priorAvg > 0 ? ((recentAvg - priorAvg) / priorAvg) * 100 : null; + trendDirection = 'stable'; + if (percentChange !== null && percentChange > 20) trendDirection = 'increasing'; + else if (percentChange !== null && percentChange < -20) trendDirection = 'decreasing'; + } + + // ── 9. Fatigue / Decline Risk ── + const riskFactors: string[] = []; + let consecutiveZeros = 0; + for (const d of dailyActivities) { + if (d.durationSeconds === 0) consecutiveZeros++; + else break; + } + if (consecutiveZeros >= 2) riskFactors.push('consecutive_inactive_days'); + if (trendDirection === 'decreasing') riskFactors.push('declining_trend'); + const interruptedSessions = recentSessions.filter((s: any) => s.status === 'interrupted').length; + if (interruptedSessions > 0) riskFactors.push('interrupted_sessions'); + if (recentSessions.length > 0) { + const lastSession = recentSessions[recentSessions.length - 1]; + const hoursSinceLastSession = (now.getTime() - new Date(lastSession.startedAt).getTime()) / (1000 * 60 * 60); + if (hoursSinceLastSession > 48) riskFactors.push('long_gap_since_last_session'); + } + const fatigueRisk = riskFactors.length > 0; + + return { + // Base signals — user-global (unlike learningBehaviorSummary which may be scoped) + totalWeeklySessions: weeklyGlobalSessions, + activeDays: activeDaysCount, + avgSecondsPerActiveDay: Math.round(avgDailySeconds), + today: todayActivity ? { + durationSeconds: todayActivity.durationSeconds, + sessionsCount: todayActivity.sessionsCount, + activityLevel: todayActivity.activityLevel, + } : null, + recentActivityLevels: dailyActivities.map((d: any) => ({ + date: d.activityDate, + level: d.activityLevel, + seconds: d.durationSeconds, + })), + // New signals (API-AI-018) + engagementSignal, + engagement: { score: engagementScore, label: engagementSignal }, + consistency: { coefficientOfVariation: cv, label: consistencyLabel, activeDaysCount, zeroDaysCount }, + streak: { currentStreak, longestStreak, streakAtRisk }, + sessionPattern: { avgSessionDuration, medianSessionDuration, sessionCompletionRate, sessionsPerDay, avgTimeBetweenSessions }, + readingVelocity: { avgProgressPerSession, progressRate, totalMaterialsTracked }, + timeDistribution, + activityBalance: { ...activityBalance, primaryActivity }, + weeklyTrend: { trendDirection, percentChange, recentAvg, priorAvg }, + fatigue: { fatigueRisk, riskFactors }, + }; + } + + // ── Device Context ── + + private async buildDeviceContext(userId: string) { + const now = new Date(); + const weekAgo = new Date(now.getTime() - BEHAVIOR_WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const [latestEvent, devices, recentPlatformEvents] = await Promise.all([ + this.prisma.readingEvent.findFirst({ + where: { userId, platform: { not: null } }, + orderBy: { clientTimestampMs: 'desc' }, + select: { platform: true, appVersion: true, clientTimestampMs: true }, + }), + this.prisma.userDevice.findMany({ + where: { userId }, + select: { deviceId: true, deviceName: true, osVersion: true, lastSeenAt: true }, + orderBy: { lastSeenAt: 'desc' }, + }), + this.prisma.readingEvent.findMany({ + where: { userId, clientTimestampMs: { gte: BigInt(weekAgo.getTime()) } }, + select: { platform: true, appVersion: true, clientTimezoneOffsetMinutes: true }, + orderBy: { clientTimestampMs: 'asc' }, + take: 200, + }), + ]); + + const platformCategory = this.mapPlatformCategory(latestEvent?.platform ?? null); + + // Platform distribution in last 7 days + const platformCounts: Record = { phone: 0, tablet: 0, desktop: 0, web: 0 }; + for (const e of recentPlatformEvents) { + const cat = this.mapPlatformCategory(e.platform); + if (cat) platformCounts[cat]++; + } + const totalPlatformEvents = Object.values(platformCounts).reduce((a, b) => a + b, 0); + const platformDistribution = totalPlatformEvents > 0 + ? { + phone: Math.round((platformCounts.phone / totalPlatformEvents) * 100) / 100, + tablet: Math.round((platformCounts.tablet / totalPlatformEvents) * 100) / 100, + desktop: Math.round((platformCounts.desktop / totalPlatformEvents) * 100) / 100, + web: Math.round((platformCounts.web / totalPlatformEvents) * 100) / 100, + } + : { phone: 0, tablet: 0, desktop: 0, web: 0 }; + + // Primary device detection (>70% of events from one category) + const maxCount = Math.max(...Object.values(platformCounts)); + const hasPrimaryDevice = totalPlatformEvents > 0 && maxCount / totalPlatformEvents > 0.7; + const primaryPlatformCategory = hasPrimaryDevice + ? (Object.entries(platformCounts).find(([, c]) => c === maxCount)?.[0] as typeof platformCategory) ?? null + : null; + + // Device switch frequency + const deviceSwitchFrequency = this.computeSwitchFrequency(recentPlatformEvents); + + // Timezone analysis + const timezones = recentPlatformEvents + .map(e => e.clientTimezoneOffsetMinutes) + .filter((t): t is number => t !== null); + const primaryTimezoneOffsetMinutes = timezones.length > 0 + ? this.modeOf(timezones) + : null; + const hasTimezoneVariance = new Set(timezones).size > 1; + const lastSeenTimezoneOffsetMinutes = timezones.length > 0 ? timezones[timezones.length - 1] : null; + + // App version freshness + const versions = recentPlatformEvents + .map(e => e.appVersion) + .filter((v): v is string => v !== null); + const latestAppVersion = latestEvent?.appVersion ?? null; + const versionChanges = new Set(versions).size; + const isOutdated = versionChanges > 1 && latestAppVersion + ? versions.filter(v => v === latestAppVersion).length < versions.length * 0.5 + : false; + + // Device task suitability per privacy doc §4.4 + const deviceTaskSuitability = this.mapDeviceTaskSuitability(platformCategory); + + return { + latestPlatform: latestEvent?.platform ?? null, + latestAppVersion, + deviceCount: devices.length, + devices, + + // Signal 1: Platform categorization + platformCategory, + + // Signal 2: Device task suitability + deviceTaskSuitability, + + // Signal 3: Multi-device pattern + hasPrimaryDevice, + primaryPlatformCategory, + deviceSwitchFrequency, + platformDistribution, + + // Signal 4: Timezone / scene + primaryTimezoneOffsetMinutes, + hasTimezoneVariance, + lastSeenTimezoneOffsetMinutes, + + // Signal 5: App version freshness + appVersion: { + latest: latestAppVersion, + isOutdated, + versionCount: new Set(versions).size, + }, + }; + } + + // ── Platform Category Mapping ── + + /** + * Map raw platform string to allowed device categories per privacy doc §4.4. + * + * Limitation: Android does not distinguish phone vs tablet at the platform + * string level. All 'android' platforms are mapped to 'phone'. A future + * enhancement could use screen-size hints or UserDevice metadata to detect + * Android tablets. + */ + private mapPlatformCategory(platform: string | null): 'phone' | 'tablet' | 'desktop' | 'web' | null { + if (!platform) return null; + const p = platform.toLowerCase(); + if (p === 'ios' || p === 'android') return 'phone'; + if (p === 'ipados') return 'tablet'; + if (p === 'macos' || p === 'windows' || p === 'linux') return 'desktop'; + if (p === 'web' || p === 'browser') return 'web'; + this.logger.warn(`mapPlatformCategory: unknown platform="${platform}", returning null`); + return null; + } + + // ── Device Switch Frequency ── + + private computeSwitchFrequency( + events: { platform: string | null; clientTimezoneOffsetMinutes?: number | null }[], + ): 'daily' | 'weekly' | 'rarely' | 'unknown' { + const platforms = events.map(e => e.platform).filter((p): p is string => p !== null); + if (platforms.length < 2) return 'unknown'; + + let switches = 0; + for (let i = 1; i < platforms.length; i++) { + if (platforms[i] !== platforms[i - 1]) switches++; + } + + if (switches >= platforms.length * 0.3) return 'daily'; + if (switches >= 1) return 'weekly'; + return 'rarely'; + } + + // ── Device Task Suitability ── + + private mapDeviceTaskSuitability(category: 'phone' | 'tablet' | 'desktop' | 'web' | null) { + if (!category) { + return { suitableTaskTypes: [], unsuitableTaskTypes: [] }; + } + + const mapping: Record = { + phone: { + suitableTaskTypes: ['flashcard', 'single_choice', 'short_review', 'light_quiz'], + unsuitableTaskTypes: ['deep_analysis', 'long_reading', 'complex_quiz'], + }, + tablet: { + suitableTaskTypes: ['reading', 'annotation', 'light_quiz', 'flashcard'], + unsuitableTaskTypes: ['complex_quiz'], + }, + desktop: { + suitableTaskTypes: ['deep_analysis', 'complex_quiz', 'long_reading', 'content_organization'], + unsuitableTaskTypes: [], + }, + web: { + suitableTaskTypes: ['deep_analysis', 'complex_quiz', 'long_reading', 'content_organization'], + unsuitableTaskTypes: [], + }, + }; + + return mapping[category] ?? { suitableTaskTypes: [], unsuitableTaskTypes: [] }; + } + + // ── Math Helper ── + + private modeOf(numbers: number[]): number | null { + if (numbers.length === 0) return null; + const freq: Record = {}; + let maxCount = 0; + let mode = numbers[0]; + for (const n of numbers) { + freq[n] = (freq[n] ?? 0) + 1; + if (freq[n] > maxCount) { + maxCount = freq[n]; + mode = n; + } + } + return mode; + } + + // ── Allowed Fields Builder ── + + private buildAllowedFields(settings: any, profile: any): string[] { + const fields: string[] = ['constraints', 'materialProgressSummary', 'scoreSignals', 'deviceContext']; + + if (settings?.allowUseUserProfile !== false) { + fields.push('userProfile', 'aiSettings'); + } + if (settings?.allowUseLearningBehavior !== false) { + fields.push('learningBehaviorSummary', 'behaviorSignals'); + } + if (settings?.allowUseDocumentContent === true) { + fields.push('contentStructureSummary'); + } + + return fields; + } + + // ── Helpers ── + + private countBy(items: T[], fn: (item: T) => string): Record { + const result: Record = {}; + for (const item of items) { + const key = fn(item); + result[key] = (result[key] ?? 0) + 1; + } + return result; + } +} diff --git a/src/modules/ai-runtime/snapshot-cleanup.service.spec.ts b/src/modules/ai-runtime/snapshot-cleanup.service.spec.ts new file mode 100644 index 0000000..9aa59df --- /dev/null +++ b/src/modules/ai-runtime/snapshot-cleanup.service.spec.ts @@ -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 }); + }); + }); +}); diff --git a/src/modules/ai-runtime/snapshot-cleanup.service.ts b/src/modules/ai-runtime/snapshot-cleanup.service.ts new file mode 100644 index 0000000..e50f1f5 --- /dev/null +++ b/src/modules/ai-runtime/snapshot-cleanup.service.ts @@ -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 | 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 }; + } +} diff --git a/src/modules/ai-runtime/user-ai.controller.ts b/src/modules/ai-runtime/user-ai.controller.ts index e1ee313..1e51e2f 100644 --- a/src/modules/ai-runtime/user-ai.controller.ts +++ b/src/modules/ai-runtime/user-ai.controller.ts @@ -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); + } } diff --git a/src/modules/ai-runtime/user-ai.dto.ts b/src/modules/ai-runtime/user-ai.dto.ts index 90ab7be..c0581be 100644 --- a/src/modules/ai-runtime/user-ai.dto.ts +++ b/src/modules/ai-runtime/user-ai.dto.ts @@ -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; +} diff --git a/src/modules/ai-runtime/user-ai.service.spec.ts b/src/modules/ai-runtime/user-ai.service.spec.ts new file mode 100644 index 0000000..effb3eb --- /dev/null +++ b/src/modules/ai-runtime/user-ai.service.spec.ts @@ -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 }), + })); + }); +}); diff --git a/src/modules/ai-runtime/user-ai.service.ts b/src/modules/ai-runtime/user-ai.service.ts index f10a7d2..d101a99 100644 --- a/src/modules/ai-runtime/user-ai.service.ts +++ b/src/modules/ai-runtime/user-ai.service.ts @@ -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 = { + 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 { + // 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 { + 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,