From 0ec58669ddafdbf665016247fcf2fdc5d733cff7 Mon Sep 17 00:00:00 2001 From: wangdl Date: Sun, 21 Jun 2026 14:55:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(M-AI-04):=20Active=20Recall=20=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E8=BF=81=E7=A7=BB=E8=87=B3=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=20Job=20Engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-AI-04-01: 审计并冻结迁移契约 (文档) M-AI-04-02: 注册 JobDefinition + SnapshotBuilder (28 tests) M-AI-04-03: Executor + BusinessValidator + ReferenceValidator (31 tests) M-AI-04-04: Projector + Artifact + 幂等写入 (19 tests) M-AI-04-05: 入口集成 (Router + CreationService + Engine + Executor) M-AI-04-06: 状态兼容 + 结构化日志 + 指标计数器 (255 tests) M-AI-04-07: 真实业务 E2E + CI 触发 + FeatureFlag 受控切换 (11 tests) P0 修复: - 跨用户 question 所有权校验 (active-recall.service.ts) - E2E infra 不可用时 HARD FAIL (fail() 替代静默 skip) 添加文件: - docs/architecture/m-ai-04-active-recall-migration-contract.md - 12 个 ai-job 模块文件 (Definition/Snapshot/Executor/Validator/Projector/Router/Observability) - test/m-ai-04-active-recall.e2e-spec.ts + setup 修改文件: - active-recall.service.ts, active-recall.module.ts - ai-job-creation.service.ts, ai-job-execution-engine.ts, ai-job.module.ts - .gitea/workflows/deploy.yml (CI 变更检测) - test/jest-e2e.json (setupFiles + globals) Co-Authored-By: Claude --- .gitea/workflows/deploy.yml | 2 +- ...-ai-04-active-recall-migration-contract.md | 406 +++++++++++++++++ .../active-recall/active-recall.module.ts | 6 +- .../active-recall/active-recall.service.ts | 99 +++- .../ai-job/active-recall-execution-router.ts | 46 ++ .../ai-job/active-recall-executor.spec.ts | 381 ++++++++++++++++ src/modules/ai-job/active-recall-executor.ts | 100 +++++ .../ai-job/active-recall-job-definition.ts | 74 +++ ...ctive-recall-observability.service.spec.ts | 146 ++++++ .../active-recall-observability.service.ts | 178 ++++++++ .../ai-job/active-recall-projector.spec.ts | 375 ++++++++++++++++ src/modules/ai-job/active-recall-projector.ts | 232 ++++++++++ ...active-recall-registration.service.spec.ts | 401 +++++++++++++++++ .../active-recall-registration.service.ts | 29 ++ .../ai-job/active-recall-snapshot-builder.ts | 190 ++++++++ src/modules/ai-job/active-recall-validator.ts | 294 ++++++++++++ .../ai-job/ai-job-creation.service.spec.ts | 2 + src/modules/ai-job/ai-job-creation.service.ts | 18 +- .../ai-job/ai-job-execution-engine.spec.ts | 12 + src/modules/ai-job/ai-job-execution-engine.ts | 103 ++++- src/modules/ai-job/ai-job.module.ts | 25 +- test/jest-e2e.json | 7 +- test/m-ai-04-active-recall.e2e-spec.ts | 421 ++++++++++++++++++ test/m-ai-04-e2e-setup.ts | 4 + 24 files changed, 3515 insertions(+), 36 deletions(-) create mode 100644 docs/architecture/m-ai-04-active-recall-migration-contract.md create mode 100644 src/modules/ai-job/active-recall-execution-router.ts create mode 100644 src/modules/ai-job/active-recall-executor.spec.ts create mode 100644 src/modules/ai-job/active-recall-executor.ts create mode 100644 src/modules/ai-job/active-recall-job-definition.ts create mode 100644 src/modules/ai-job/active-recall-observability.service.spec.ts create mode 100644 src/modules/ai-job/active-recall-observability.service.ts create mode 100644 src/modules/ai-job/active-recall-projector.spec.ts create mode 100644 src/modules/ai-job/active-recall-projector.ts create mode 100644 src/modules/ai-job/active-recall-registration.service.spec.ts create mode 100644 src/modules/ai-job/active-recall-registration.service.ts create mode 100644 src/modules/ai-job/active-recall-snapshot-builder.ts create mode 100644 src/modules/ai-job/active-recall-validator.ts create mode 100644 test/m-ai-04-active-recall.e2e-spec.ts create mode 100644 test/m-ai-04-e2e-setup.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7b4c72c..616cbd0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -107,7 +107,7 @@ jobs: CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || echo "") fi echo "Changed files: $CHANGED" - if echo "$CHANGED" | grep -qE "src/workers/|src/modules/ai-analysis/|src/modules/ai/|src/infrastructure/queue/|src/infrastructure/outbox/|prisma/schema.prisma|prisma/migrations/|test/worker-integration|test/run-integration"; then + if echo "$CHANGED" | grep -qE "src/workers/|src/modules/ai-analysis/|src/modules/ai/|src/modules/ai-job/|src/modules/active-recall/|src/infrastructure/queue/|src/infrastructure/outbox/|prisma/schema.prisma|prisma/migrations/|test/worker-integration|test/run-integration|test/m-ai-04"; then echo "Worker-related changes detected — running integration tests" echo "run_int=true" > /tmp/int-decision else diff --git a/docs/architecture/m-ai-04-active-recall-migration-contract.md b/docs/architecture/m-ai-04-active-recall-migration-contract.md new file mode 100644 index 0000000..cab6460 --- /dev/null +++ b/docs/architecture/m-ai-04-active-recall-migration-contract.md @@ -0,0 +1,406 @@ +# M-AI-04 Active Recall 迁移契约 + +> 状态:✅ 已审计冻结(M-AI-04-01 完成) +> 审计日期:2026-06-21 +> 基线:M-AI-03 GATE PASS(commit `5108a9a`) +> 对应 Issue:[#296](https://git.admin.longde.cloud/wangdl/api-server/issues/296) +> ⚠️ 行号引用以 commit `5108a9a` 为准。后续代码变更可能导致行号漂移,Review 时请对照该 commit 验证。 + +--- + +## 1. 当前链路(已审计确认) + +``` +POST /api/active-recalls/:id/submit +→ Controller: ActiveRecallController.submit() + active-recall.controller.ts:21 + @Body() body: any // ⚠️ 无 DTO 校验,期望 { answerText: string } + +→ ActiveRecallService.submit(userId, questionId, body) + active-recall.service.ts:19-39 + ├── ⚠️ userId 来自 @CurrentUser() → user?.id || 'anonymous' + │ JwtAuthGuard 作为全局 APP_GUARD (app.module.ts:184) 生效, + │ 但代码中的 || 'anonymous' fallback 暗示防御性编程, + │ 实际上 JWT 校验失败时 Guard 直接返回 401,不会到达 Controller。 + │ + ├── ActiveRecallRepository.findById(questionId) + │ active-recall.repository.ts:19-21 + │ → prisma.activeRecallQuestion.findUnique({ where: { id } }) + │ → 不存在时 throw NotFoundException('问题不存在') + │ → ⚠️ P0 安全缺陷:不校验 question.userId === userId + │ 用户 A 可以提交用户 B 的 questionId,导致分析结果错配到 A 名下 + │ + ├── ActiveRecallRepository.createAnswer(userId, questionId, body) + │ active-recall.repository.ts:41-50 + │ → prisma.activeRecallAnswer.create({ userId, questionId, answerText, submittedAt }) + │ → 表: ActiveRecallAnswer (id, userId, questionId, sessionId, answerType, answerText, audioFileId, submittedAt) + │ → answerType 默认 "text" + │ + └── AiAnalysisService.analyze(userId, { questionText, knowledgeItemContent, userAnswer, answerId }) + ai-analysis.service.ts:12-31 + ├── AiAnalysisRepository.createJob(userId, 'active-recall', sessionId, answerId) + │ ai-analysis.repository.ts:17-33 + │ → prisma.aiJob.create({ + │ userId, jobType: 'active-recall', sessionId, answerId, + │ status: 'pending', + │ lifecycleStatus: 'queued', // M-AI-02-10 Shadow Write + │ queueName: 'ai-interactive', // ⚠️ 写入 'ai-interactive' 但实际入队 'ai-analysis' + │ inputSchemaVersion: 'legacy-v1', + │ attemptCount: 0, + │ queuedAt: new Date() + │ }) + │ → 表: AiJob (物理表名 AiAnalysisJob) + │ + └── QueueService.add('ai-analysis', { jobId, userId, type: 'active-recall', questionText, knowledgeItemContent, userAnswer }) + queue.service.ts:47-64 + → BullMQ queue: 'ai-analysis' (QUEUE_AI_ANALYSIS) + → queue.constants.ts:1 + → 默认 JobOptions (queue-definitions.ts:61-66): + { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: { count: 1000, age: 24*3600 }, // 保留最近 1000 条,24h + removeOnFail: { count: 5000, age: 7*24*3600 } } // 保留最近 5000 条,7d + → 写入 TaskLog 表(queueName + jobId + payload + status: 'enqueued') + → 发布 task.enqueued 事件 + + ⚠️ analyze() 错误被 catch 并 log,不 re-throw → 答案返回不受 AI 入队结果影响 + +Worker: AiAnalysisWorker (@Processor('ai-analysis')) + workers/ai-analysis.worker.ts:18-106 + ├── AiAnalysisRepository.updateJobStatus(jobId, 'processing') + │ ai-analysis.repository.ts:35-46 + │ → 设置 status='processing', lifecycleStatus='running', startedAt=now + │ + ├── [type='active-recall'] → ActiveRecallAnalysisWorkflow.execute(input) + │ modules/ai/workflows/active-recall-analysis.workflow.ts:17-41 + │ └── AiGatewayService.generate({ + │ feature: 'active-recall-analysis', + │ userId, tier: 'primary', + │ promptKey: 'active-recall-analysis', + │ promptVersion: '1.0.0', + │ messages: [{ role: 'user', content: userMessage }], + │ outputSchema: ActiveRecallAnalysisResultSchema (Zod) + │ }) + │ modules/ai/gateway/ai-gateway.service.ts:40-170 + │ ├── ModelRouter.resolve('primary') + │ │ model-router.ts:70-72 + │ │ → { tier: 'primary', preferred: { provider:'deepseek', model:'deepseek-v4-pro' }, + │ │ fallback: { provider:'deepseek', model:'deepseek-v4-pro' }, maxRetries: 3 } + │ │ ⚠️ preferred 和 fallback 相同 → 无实际 fallback 效果 + │ │ + │ ├── PromptTemplateService.get('active-recall-analysis', '1.0.0') + │ │ prompt-template.service.ts:65-73 + │ │ → 硬编码 TypeScript 常量(非 DB) + │ │ → systemPrompt: ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT + │ │ → outputSchemaDesc: ACTIVE_RECALL_OUTPUT_SCHEMA_DESC + │ │ + │ ├── DeepSeekProvider.generate({ model, messages, temperature: 0.3, maxTokens: 4096, responseFormat: 'json_object' }) + │ │ → HTTP POST to DeepSeek API + │ │ + │ ├── ContentSafetyService.check(output.rawText, { contentType: 'ai_output' }) + │ │ → 不安全时 throw Error('AI output blocked by content safety') + │ │ + │ ├── parseJson() - 3 层 JSON 解析: + │ │ 1. 直接 JSON.parse → Zod schema.parse + │ │ 2. 提取 markdown ```json``` fence + │ │ 3. 提取第一个 {…} 对象 + │ │ + │ ├── AiUsageLogService.log({ userId, feature, provider, model, tier, promptKey, promptVersion, inputTokens, outputTokens, estimatedCost, latencyMs, success }) + │ │ → 表: AiUsageLog (usage-log.service.ts) + │ │ + │ └── EventBusService.publish(AIUsageRecorded event) + │ + ├── AiAnalysisRepository.createResult(userId, jobId, result) + │ ai-analysis.repository.ts:55-69 + │ → prisma.aiAnalysisResult.create({ + │ userId, jobId, + │ summary: result.summary, + │ masteryScore: result.score, + │ strengths: result.strengths (Json), + │ weaknesses: result.weaknesses (Json), + │ suggestions: result.focusItems (Json), + │ nextActions: result.reviewSuggestion (Json), + │ rawResult: result (Json) + │ }) + │ → 表: AiAnalysisResult + │ + ├── AiAnalysisRepository.updateJobStatus(jobId, 'completed') + │ → 设置 status='completed', lifecycleStatus='succeeded', finishedAt=now + │ + ├── EventBusService.publish(AIAnalysisCompleted event) + │ → eventType: 'ai.analysis.completed' + │ → payload: { userId, jobId, sessionId, answerId, type, score, analysis, timestamp } + │ └── 消费方: ReviewCardSubscriber.handleAIAnalysisCompleted() + │ modules/review/review-card.subscriber.ts:11-51 + │ → ReviewService.generateCards(userId, { knowledgeItemTitle, knowledgeItemContent, cardCount }) + │ modules/review/review.service.ts:68-99 + │ → ReviewCardGenerationWorkflow.execute() → AiGatewayService.generate() + │ → 创建 1-3 条 ReviewCard (SM-2: intervalDays=1, easeFactor=2.5, scheduleState='new') + │ → 表: ReviewCard + │ + └── FocusItemsService.create(userId, { title: w, source: 'ai-analysis', status: 'open' }) + → 为每个 result.weaknesses 元素创建一条 FocusItem + → knowledgeBaseId: result.knowledgeBaseId || 'unknown' + → ⚠️ 数据完整性问题:ActiveRecallAnalysisResultSchema 不含 knowledgeBaseId 字段 + (active-recall-analysis.schema.ts:17-28),因此 result.knowledgeBaseId 恒为 undefined, + 所有 FocusItem 的 knowledgeBaseId 恒为 'unknown',无法关联到具体知识库 + → 表: FocusItem + +错误处理: + Worker catch → updateJobStatus(jobId, 'failed', err.message) → lifecycleStatus='failed' + → throw err (触发 BullMQ 重试,默认 3 次指数退避) +``` + +### 关键发现 + +| # | 发现 | 严重度 | 文件:行 | +|---|------|--------|---------| +| 1 | `queueName` 写入 `'ai-interactive'`,但实际 BullMQ 入队 `'ai-analysis'` | **P0** | `ai-analysis.repository.ts:28` vs `ai-analysis.service.ts:21` | +| 2 | `ActiveRecallService.submit()` 不校验 `question.userId === userId`,用户 A 可提交用户 B 的问题 | **P0** | `active-recall.service.ts:20-21` | +| 3 | 所有 FocusItem 的 `knowledgeBaseId` 恒为 `'unknown'`,因 AI 输出 Schema 不含此字段 | **P1** | `active-recall-analysis.schema.ts:17-28` → `ai-analysis.worker.ts:89` | +| 4 | POST body 类型为 `any`,无 DTO 校验 | **P1** | `active-recall.controller.ts:21` | +| 5 | `knowledgeItemContent` 硬编码为空字符串 `''`。此外该行注释 `// worker picks up content from the analysis workflow` 是**虚假注释**:Worker 不查询 DB 获取知识点内容,直接使用 Job data 中的 `knowledgeItemContent`(即空字符串)。AI 模型仅收到 `【知识点原文】\n\n` 而无实际内容,分析质量严重受损 | **HIGH** | `active-recall.service.ts:29` → `ai-analysis.service.ts:26` → `active-recall-analysis.workflow.ts:18-21` | +| 6 | `removeOnComplete: { count: 1000, age: 24h }` / `removeOnFail: { count: 5000, age: 7d }` — completed Job 保留 24h(非立即删除),failed Job 保留 7d。影响故障排查窗口和存储容量估算,Unified 链路需匹配此行为 | **INFO** | `queue-definitions.ts:64-65` | +| 7 | ModelRouter `primary` 和 `strong` tier 的 preferred/fallback 完全相同(均为 `deepseek-v4-pro`),`maxRetries: 3`。这意味着 4 次尝试全部打到同一个模型,fallback 机制形同虚设。生产环境中若 deepseek-v4-pro 故障或限流,重试只会重复失败,不会自动切换备用模型 | **HIGH** | `model-router.ts:24-29` | +| 8 | Prompt 硬编码在 TypeScript 常量中,非 DB 管理 | INFO | `active-recall-analysis.prompt.ts` | +| 9 | AI 分析入队失败不阻止答案返回(catch + log) | INFO | `active-recall.service.ts:34-36` | +| 10 | `ActiveRecallAnswer` 包含 `audioFileId` 和 `answerType` 字段(`schema.prisma:554-556`),但当前 `submit()` 仅接受 `{ answerText }`。非文本答案(音频)在 Unified 链路中的处理方式未定义 | **P2** | `active-recall.controller.ts:21` → `schema.prisma:549-566` | +| 11 | Worker stall 恢复(`maxStalledCount: 1`,`queue-definitions.ts:58`)可能导致重复 `AiAnalysisResult`:AI 调用成功后 Worker 崩溃 → BullMQ 重新投递 → 再次执行 → `createResult()` 无幂等保护(`AiAnalysisResult` 无 `@@unique([jobId])`)→ 同一 jobId 产生两条 Result | **HIGH** | `ai-analysis.worker.ts:67` → `ai-analysis.repository.ts:55` → `schema.prisma:679-700` → `queue-definitions.ts:58` | + +--- + +## 2. 目标链路 + +``` +POST /api/active-recalls/:id/submit +→ ActiveRecallService +→ ActiveRecallExecutionRouter (NEW) + ├─ legacy → 原有 AiAnalysisService 路径(不改动) + └─ unified → AiJobCreationService + → AiJob + AiJobSnapshot + OutboxEvent + → Outbox Dispatcher + → Queue: ai-interactive + → AiJobExecutionEngine + → ActiveRecallExecutor + → Validation (Business + Reference) + → ActiveRecallProjector + → 业务结果 + AiJobArtifact +``` + +--- + +## 3. 输入 Snapshot Schema(已冻结) + +```json +{ + "schemaVersion": "active-recall-v1", + "snapshot": { + "userId": "", + "activeRecallId": "", + "knowledgeItemId": "", + "questionText": "", + "userAnswer": "", + "referenceAnswer": "", + "answerId": "", + "submittedAt": "", + "promptKey": "active-recall-analysis", + "promptVersion": "1.0.0", + "modelTier": "primary", + "modelProvider": "deepseek", + "modelName": "deepseek-v4-pro", + "maxTokens": 4096, + "temperature": 0.3 + } +} +``` + +### 禁止字段 + +- JWT / Authorization Header +- 模型 API Key +- Cookie +- 数据库连接信息 +- 无关用户画像 +- 未脱敏 Credential +- 用户邮箱、手机号等 PII + +### 区分原则 + +| 数据 | 归属 | 理由 | +|------|------|------| +| userId, activeRecallId, questionText, userAnswer, answerId | Snapshot(冻结) | 重放 AI 调用所需 | +| knowledgeItemContent | 执行时重新查询 | 知识点内容可能更新 | +| knowledgeItemId | Snapshot(冻结) | 关联追溯 | +| referenceAnswer | 执行时重新查询 | 从 KnowledgeItem 获取 | +| promptKey, promptVersion, modelTier | Snapshot(冻结) | 重放一致性 | +| 用户 email/phone/displayName | 禁止 | PII | + +--- + +## 4. 输出 Schema(已冻结) + +```json +{ + "score": "", + "masteryLevel": "'excellent' | 'good' | 'partial' | 'weak' | 'none', 必填", + "summary": "", + "strengths": [""], + "weaknesses": [""], + "missingKeyPoints": [""], + "misconceptions": [""], + "weaknessTypes": [""], + "focusItems": [ + { + "title": "", + "reason": "", + "suggestion": "", + "priority": "'high' | 'normal' | 'low'" + } + ], + "reviewSuggestion": { + "shouldReview": "", + "intervalDays": "", + "cardFront": "", + "cardBack": "" + } +} +``` + +### 验证规则 + +| 字段 | 规则 | 来源 | +|------|------|------| +| score | 0 ≤ score ≤ 100, 整数 | `active-recall-analysis.schema.ts:18` | +| masteryLevel | enum: excellent/good/partial/weak/none | `active-recall-analysis.schema.ts:19` | +| summary | 1-2000 字符 | `active-recall-analysis.schema.ts:20` | +| strengths | 最多 10 项, 每项 ≤500 字符 | `active-recall-analysis.schema.ts:21` | +| weaknesses | 最多 10 项, 每项 ≤500 字符 | `active-recall-analysis.schema.ts:22` | +| focusItems | 最多 10 项 | `active-recall-analysis.schema.ts:26` | +| reviewSuggestion | 必填, intervalDays 1-365 | `active-recall-analysis.schema.ts:27` | + +--- + +## 5. 副作用矩阵(已审计) + +| 操作 | 表/实体 | 触发条件 | 写入方 | 文件:行 | +|------|---------|----------|--------|---------| +| 创建答案记录 | `ActiveRecallAnswer` | 每次提交 | `ActiveRecallRepository.createAnswer()` | `active-recall.repository.ts:41` | +| 创建 Job | `AiJob` (AiAnalysisJob) | 每次提交 | `AiAnalysisRepository.createJob()` | `ai-analysis.repository.ts:17` | +| 创建 TaskLog | `TaskLog` | 每次入队 | `QueueService.add()` | `queue.service.ts:59` | +| 更新 Job → processing | `AiJob` | Worker 开始执行 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:48` | +| 调用 AI 模型 | DeepSeek API | Worker 执行 | `AiGatewayService.generate()` | `ai-gateway.service.ts:58` | +| 记录 UsageLog | `AiUsageLog` | AI 调用完成 | `AiUsageLogService.log()` | `ai-gateway.service.ts:78` | +| 发布 AIUsageRecorded | EventBus | AI 调用完成 | `AiGatewayService` | `ai-gateway.service.ts:94` | +| 安全审核 | ContentSafety | AI 输出后 | `ContentSafetyService.check()` | `ai-gateway.service.ts:67` | +| 创建 FallbackEvent | `FallbackEvent` | 首次调用失败切备用 | `AiGatewayService` | `ai-gateway.service.ts:127` | +| 创建分析结果 | `AiAnalysisResult` | Worker 成功后 | `AiAnalysisRepository.createResult()` | `ai-analysis.worker.ts:67` | +| 更新 Job → completed | `AiJob` | Worker 成功后 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:68` | +| 发布 AIAnalysisCompleted | EventBus | Worker 成功后 | `AiAnalysisWorker` | `ai-analysis.worker.ts:72` | +| 生成复习卡片 | `ReviewCard` | 收到 AIAnalysisCompleted 事件 | `ReviewCardSubscriber` → `ReviewService.generateCards()` | `review-card.subscriber.ts:39` | +| 创建薄弱项 | `FocusItem` | result.weaknesses.length > 0 | `FocusItemsService.create()` | `ai-analysis.worker.ts:88` | ⚠️ knowledgeBaseId 恒为 'unknown' | +| 更新 Job → failed | `AiJob` | Worker 失败 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:102` | + +--- + +## 6. 状态映射(已冻结) + +| 业务阶段 | 旧 Job 状态 (`status`) | 新 `lifecycleStatus` | Active Recall 业务状态 | 客户端可见 | +|----------|----------------------|---------------------|----------------------|-----------| +| 提交答案 | `pending` | `queued` | 答案已提交,等待 AI 分析 | 答案已提交 | +| AI 分析执行中 | `processing` | `running` | AI 正在分析回答 | 分析中 | +| 分析完成 | `completed` | `succeeded` | 分析完成,结果可用 | 分析完成(可查看结果) | +| 分析失败 | `failed` | `failed` | 分析失败(自动重试后仍失败) | 分析失败 | +| 取消 | N/A(旧链路不支持) | `cancelled` | 分析已取消 | 分析取消 | + +### Shadow Write 映射(ai-analysis.repository.ts:10-15) + +``` +pending → queued +processing → running +completed → succeeded +failed → failed +``` + +--- + +## 7. 幂等键 + +``` +active-recall: +``` + +- **稳定业务标识**:`answerId` — `ActiveRecallAnswer.id`,每次提交生成唯一 ID +- **唯一约束**:`AiJob.@@unique([userId, jobType, idempotencyKey])` (`schema.prisma:636`) +- **冲突处理**:P2002 时返回已有 Job(由 `AiJobCreationService` 实现) +- **格式**:`active-recall:` + +--- + +## 8. Feature Flag + +| 属性 | 值 | +|------|-----| +| 配置名 | `ACTIVE_RECALL_ENGINE_MODE` | +| 值 | `legacy` \| `unified` | +| 默认 | `legacy`(切换前) | +| 白名单 | 支持用户 ID 白名单(通过 `FeatureFlagService`) | +| 存储 | `FeatureFlag` 表 + Redis 缓存(30s TTL) | +| 分支点 | `ActiveRecallExecutionRouter`(待实现,M-AI-04-05) | +| 切换方式 | 修改 FeatureFlag 值,无需重启 | + +### 配置机制(现有基础设施) + +- **FeatureFlagService**: `FeatureFlag` 表 (`name`, `enabled`, `whitelist`, `rolloutPct`),Redis 缓存 30s + - `config/feature-flag.service.ts` +- **AppConfigService**: `AppConfig` 表 (key-value),Redis 缓存 60s + - `config/config.service.ts` + +--- + +## 9. 回滚流程 + +``` +unified → legacy: +1. 修改 ACTIVE_RECALL_ENGINE_MODE=legacy(通过 Admin 或 FeatureFlag API) +2. 新请求立即走 Legacy 路径(ActiveRecallExecutionRouter 读取 Flag) +3. 已创建的新 Job 继续完成或取消(不强制中断) +4. 不重新送入 Legacy(避免重复分析) +5. 客户端仍可查询已有新 Job(GET /api/ai/jobs/:jobId) +6. 已写入的 AiAnalysisResult / FocusItem / ReviewCard 不删除 +``` + +### 禁止事项 + +- 禁止 Legacy 和 Unified 双链路同时执行同一 answerId(通过幂等键保证) +- 禁止在无运行证据时直接全量切换 +- 禁止自动 Legacy fallback(必须通过 Flag 显式切换) + +--- + +## 10. 不确定项 + +- [x] 确认 `knowledgeItemContent` 来源:当前硬编码 `''`,Worker 不查 DB。迁移后 Snapshot 仍可为空,Executor 可从 knowledgeItemId 查询。 +- [x] 确认 `queueName` 不一致:当前 DB 写入 `ai-interactive` 但 BullMQ 路由 `ai-analysis`。迁移后 Unified 路径统一使用 `ai-interactive`。 +- [x] 确认 ReviewCard 生成是否需要保留:是,`AIAnalysisCompleted` → `ReviewCardSubscriber` 链路由 EventBus 驱动,与 Job 系统解耦。 +- [x] 确认认证/权限缺陷:JwtAuthGuard 为全局 Guard(`app.module.ts:184`),认证层面安全;但 `submit()` 不校验跨用户所有权(P0),需在 M-AI-04-05 修复。 +- [x] 确认 FocusItem knowledgeBaseId 恒为 `'unknown'`(P1):`ActiveRecallAnalysisResultSchema` 不含 `knowledgeBaseId` 字段,需在 M-AI-04-03 输出 Schema 中增加该字段。 +- [x] 确认 Job 保留策略:`removeOnComplete: { count: 1000, age: 24h }` / `removeOnFail: { count: 5000, age: 7d }`(`queue-definitions.ts:64-65`),Unified 链路 `ai-interactive` 队列使用相同默认值。 +- [ ] 待 M-AI-04-02:Snapshot 是否包含 `knowledgeItemContent`(查询时获取 vs 快照冻结)— 建议:不包含,Executor 执行时实时查询。 +- [ ] 待 M-AI-04-03:Executor 是否复用现有 `ActiveRecallAnalysisWorkflow` 还是新建。 +- [ ] 待 M-AI-04-05:`ActiveRecallExecutionRouter` 的分支粒度(per-request vs per-user vs per-session)。 +- [ ] 待 M-AI-04-03/04:非文本答案(`audioFileId`)在 Unified 链路的处理方式。当前 `submit()` 仅处理 `{ answerText }`,但 `ActiveRecallAnswer` 模型包含 `audioFileId` 和 `answerType` 字段。若支持音频答案,需语音转文本步骤或独立的音频分析 Executor。 +- [ ] 待 M-AI-04-04:Worker stall 恢复的重复 `AiAnalysisResult` 风险。`maxStalledCount: 1` + `AiAnalysisResult` 无 `@@unique([jobId])` 约束 → 崩溃重试可能产生重复结果。Unified 链路的 `ActiveRecallProjector` 必须在 Projector 层提供幂等保证。 + +--- + +## 关联 Issue + +| Issue | 标题 | Gitea | +|-------|------|-------| +| M-AI-04-01 | 审计并冻结迁移契约 | [#296](https://git.admin.longde.cloud/wangdl/api-server/issues/296) ✅ 本 Issue | +| M-AI-04-02 | 注册 Definition 与 Snapshot | [#297](https://git.admin.longde.cloud/wangdl/api-server/issues/297) | +| M-AI-04-03 | Executor 与输出验证 | [#298](https://git.admin.longde.cloud/wangdl/api-server/issues/298) | +| M-AI-04-04 | Projector、Artifact 与幂等写入 | [#299](https://git.admin.longde.cloud/wangdl/api-server/issues/299) | +| M-AI-04-05 | 入口接入 CreationService | [#300](https://git.admin.longde.cloud/wangdl/api-server/issues/300) | +| M-AI-04-06 | 状态兼容、可观测性与回滚 | [#301](https://git.admin.longde.cloud/wangdl/api-server/issues/301) | +| M-AI-04-07 | 真实业务 E2E 与受控切换 | [#302](https://git.admin.longde.cloud/wangdl/api-server/issues/302) | +| M-AI-04-08-GATE | 最终验收与切换 | [#303](https://git.admin.longde.cloud/wangdl/api-server/issues/303) | diff --git a/src/modules/active-recall/active-recall.module.ts b/src/modules/active-recall/active-recall.module.ts index 4240e76..9f6c906 100644 --- a/src/modules/active-recall/active-recall.module.ts +++ b/src/modules/active-recall/active-recall.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; import { AiModule } from '../ai/ai.module'; import { AiAnalysisModule } from '../ai-analysis/ai-analysis.module'; +import { AiJobModule } from '../ai-job/ai-job.module'; +// Note: ActiveRecallModule does not use AppConfigModule directly — FeatureFlagService +// is accessed through ActiveRecallExecutionRouter (in AiJobModule). +// Keeping imports minimal to avoid misleading dependency edges. import { ActiveRecallController } from './active-recall.controller'; import { ActiveRecallService } from './active-recall.service'; import { ActiveRecallRepository } from './active-recall.repository'; @Module({ - imports: [AiModule, AiAnalysisModule], + imports: [AiModule, AiAnalysisModule, AiJobModule], controllers: [ActiveRecallController], providers: [ActiveRecallService, ActiveRecallRepository], exports: [ActiveRecallService], diff --git a/src/modules/active-recall/active-recall.service.ts b/src/modules/active-recall/active-recall.service.ts index 22e03f9..6e15823 100644 --- a/src/modules/active-recall/active-recall.service.ts +++ b/src/modules/active-recall/active-recall.service.ts @@ -1,6 +1,10 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common'; +import * as crypto from 'crypto'; import { ActiveRecallRepository } from './active-recall.repository'; import { AiAnalysisService } from '../ai-analysis/ai-analysis.service'; +import { AiJobCreationService } from '../ai-job/ai-job-creation.service'; +import { ActiveRecallExecutionRouter } from '../ai-job/active-recall-execution-router'; +import { ActiveRecallObservabilityService } from '../ai-job/active-recall-observability.service'; import type { PaginationDto } from '../../common/dto/pagination.dto'; @Injectable() @@ -10,6 +14,9 @@ export class ActiveRecallService { constructor( private readonly repository: ActiveRecallRepository, private readonly analysisService: AiAnalysisService, + private readonly jobCreationService: AiJobCreationService, + private readonly router: ActiveRecallExecutionRouter, + private readonly observability: ActiveRecallObservabilityService, ) {} async findByUserId(userId: string, pagination: PaginationDto) { @@ -17,22 +24,102 @@ export class ActiveRecallService { } async submit(userId: string, questionId: string, body: { answerText: string }) { + const requestId = crypto.randomUUID(); + const startTime = Date.now(); + const question = await this.repository.findById(questionId); if (!question) throw new NotFoundException('问题不存在'); + // M-AI-04-01 关键发现 #2 P0 修复:校验 question 所有权 + if (question.userId !== userId) { + throw new ForbiddenException('无权提交此问题的回答'); + } + const answer = await this.repository.createAnswer(userId, questionId, body); - // Queue AI analysis via BullMQ (worker publishes event + generates FocusItems) + // M-AI-04-05: 分支路由 — 由单一 Router 决定 Legacy vs Unified + const useUnified = await this.router.shouldUseUnified(userId); + const engineMode = useUnified ? 'unified' : 'legacy'; + + if (useUnified) { + // ── Unified 路径(AiJobCreationService → Unified Job Engine)── + this.observability.incrementUnifiedRequests(); + + const logBase = { + requestId, + activeRecallId: question.id, + userId, + engineMode: 'unified' as const, + jobType: 'active_recall', + queueName: 'ai-interactive', + }; + + this.observability.logRequest(logBase); + + try { + const idempotencyKey = `active-recall:${answer.id}`; + const job = await this.jobCreationService.createJob({ + userId, + jobType: 'active_recall', + triggerType: 'user_api', + targetType: 'active_recall_answer', + targetId: answer.id, + idempotencyKey, + }); + + const durationMs = Date.now() - startTime; + + this.observability.logJobCreated({ ...logBase, jobId: job.id, durationMs }); + this.logger.log( + `Unified job created: requestId=${requestId} jobId=${job.id} ` + + `answerId=${answer.id} durationMs=${durationMs}`, + ); + + return { + ...answer, + jobId: job.id, + engine: 'unified' as const, + }; + } catch (err: any) { + this.observability.incrementUnifiedCreateFailed(); + this.observability.logJobCreateFailed(logBase, err.message); + this.logger.error( + `Unified job creation failed: requestId=${requestId} ` + + `answerId=${answer.id} error=${err.message}`, + ); + // 失败不降级到 Legacy(防止双写),返回 answer + 错误标记 + return { + ...answer, + jobId: null, + engine: 'unified' as const, + error: 'AI analysis queuing failed', + }; + } + } + + // ── Legacy 路径(原有 AiAnalysisService,行为不变)── + this.observability.incrementLegacyRequests(); + try { - await this.analysisService.analyze(userId, { + const { jobId } = await this.analysisService.analyze(userId, { questionText: question.questionText, - knowledgeItemContent: '', // worker picks up content from the analysis workflow + knowledgeItemContent: '', userAnswer: body.answerText, answerId: answer.id, }); - this.logger.log(`AI analysis queued for answer ${answer.id}`); + + const durationMs = Date.now() - startTime; + this.logger.log( + `Legacy AI analysis queued: requestId=${requestId} ` + + `answerId=${answer.id} jobId=${jobId} durationMs=${durationMs}`, + ); + + return { ...answer, jobId, engine: 'legacy' as const }; } catch (err: any) { - this.logger.error(`Failed to queue analysis for answer ${answer.id}: ${err.message}`); + this.logger.error( + `Legacy analysis failed: requestId=${requestId} ` + + `answerId=${answer.id} error=${err.message}`, + ); } return answer; diff --git a/src/modules/ai-job/active-recall-execution-router.ts b/src/modules/ai-job/active-recall-execution-router.ts new file mode 100644 index 0000000..58e70ed --- /dev/null +++ b/src/modules/ai-job/active-recall-execution-router.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { FeatureFlagService } from '../config/feature-flag.service'; + +/** + * M-AI-04-05: Active Recall Execution Router + * + * 根据 ACTIVE_RECALL_ENGINE_MODE Feature Flag 决定执行分支: + * - 'legacy' → 原有 AiAnalysisService 路径 + * - 'unified' → AiJobCreationService → Unified Job Engine + * + * 设计约束(ADR-003): + * - 分支判断集中在 Router,不散落在 Controller/Service/Worker + * - 支持用户白名单 + * - 默认 legacy(Feature Flag 不存在或 disabled 时) + */ + +const FLAG_NAME = 'ACTIVE_RECALL_ENGINE_MODE'; + +@Injectable() +export class ActiveRecallExecutionRouter { + private readonly logger = new Logger(ActiveRecallExecutionRouter.name); + + constructor(private readonly featureFlag: FeatureFlagService) {} + + /** + * 判断是否应使用 Unified 引擎。 + * + * @param userId - 请求用户 ID(用于白名单检查) + * @returns true → unified 路径, false → legacy 路径 + */ + async shouldUseUnified(userId: string): Promise { + try { + const enabled = await this.featureFlag.isEnabled(FLAG_NAME, userId); + this.logger.log( + `ACTIVE_RECALL_ENGINE_MODE=${enabled ? 'unified' : 'legacy'} for userId=${userId}`, + ); + return enabled; + } catch (err: any) { + // FeatureFlag 查询失败 → 安全回退到 legacy + this.logger.warn( + `FeatureFlag query failed, falling back to legacy: ${err.message}`, + ); + return false; + } + } +} diff --git a/src/modules/ai-job/active-recall-executor.spec.ts b/src/modules/ai-job/active-recall-executor.spec.ts new file mode 100644 index 0000000..0b984c6 --- /dev/null +++ b/src/modules/ai-job/active-recall-executor.spec.ts @@ -0,0 +1,381 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ActiveRecallExecutor } from './active-recall-executor'; +import { ActiveRecallBusinessValidator, ActiveRecallReferenceValidator, BusinessValidationError, ReferenceValidationError } from './active-recall-validator'; +import { AiGatewayService } from '../ai/gateway/ai-gateway.service'; +import type { ActiveRecallAnalysisResult } from '../ai/prompts/schemas/active-recall-analysis.schema'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Test fixtures +// ═══════════════════════════════════════════════════════════════════════════ + +const validSnapshot = { + schemaVersion: 'active-recall-v1', + snapshot: { + userId: 'u-001', + activeRecallId: 'q-001', + knowledgeItemId: 'ki-001', + questionText: '请解释光合作用', + userAnswer: '光合作用是植物利用光能转化...', + answerId: 'a-001', + submittedAt: '2026-06-21T08:30:00.000Z', + promptKey: 'active-recall-analysis', + promptVersion: '1.0.0', + modelTier: 'primary', + modelProvider: 'deepseek', + modelName: 'deepseek-v4-pro', + maxTokens: 4096, + temperature: 0.3, + }, +}; + +function validOutput(overrides?: Partial): ActiveRecallAnalysisResult { + return { + score: 72, + masteryLevel: 'partial', + summary: '用户理解了核心概念,但缺少具体例子。', + strengths: ['核心概念理解正确', '能用自己的话表达'], + weaknesses: ['缺少具体例子', '遗漏了关键应用条件'], + missingKeyPoints: ['X的应用条件', 'X与Y的关系'], + misconceptions: [], + weaknessTypes: ['missing_detail', 'missing_application'], + focusItems: [ + { + title: 'X的应用条件', + reason: '用户在回答中完全未提及应用条件', + suggestion: '建议回顾知识点第三段', + priority: 'high' as const, + }, + ], + reviewSuggestion: { + shouldReview: true, + intervalDays: 2, + cardFront: '在什么条件下X不能使用?', + cardBack: '当Y存在时,X失效。', + }, + ...overrides, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ActiveRecallExecutor +// ═══════════════════════════════════════════════════════════════════════════ + +describe('ActiveRecallExecutor', () => { + let executor: ActiveRecallExecutor; + let aiGateway: any; + + beforeEach(async () => { + aiGateway = { + generate: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActiveRecallExecutor, + { provide: AiGatewayService, useValue: aiGateway }, + ], + }).compile(); + + executor = module.get(ActiveRecallExecutor); + }); + + describe('execute', () => { + it('正常输出通过所有验证', async () => { + const mockResponse = { + parsed: validOutput(), + usage: { provider: 'deepseek', model: 'deepseek-v4-pro', inputTokens: 500, outputTokens: 800, estimatedCost: 0.001, latencyMs: 1200 }, + }; + aiGateway.generate.mockResolvedValue(mockResponse); + + const result = await executor.execute(validSnapshot, 120000); + + expect(result).toBe(mockResponse); + expect(aiGateway.generate).toHaveBeenCalledTimes(1); + // 验证调用参数 + const callArgs = aiGateway.generate.mock.calls[0]; + expect(callArgs[0].feature).toBe('active-recall-analysis'); + expect(callArgs[0].userId).toBe('u-001'); + expect(callArgs[0].promptKey).toBe('active-recall-analysis'); + expect(callArgs[0].promptVersion).toBe('1.0.0'); + expect(callArgs[0].tier).toBe('primary'); + expect(callArgs[0].maxTokens).toBe(4096); + expect(callArgs[0].messages[0].content).toContain('【用户的主动回忆回答】'); + expect(callArgs[0].messages[0].content).toContain('光合作用是植物利用光能转化'); + expect(callArgs[0].outputSchema).toBeDefined(); + expect(callArgs[1]).toBe(120000); // timeoutMs + }); + + it('所有模型调用经过 AiGateway', async () => { + aiGateway.generate.mockResolvedValue({ + parsed: validOutput(), + usage: { provider: 'deepseek', model: 'deepseek-v4-pro', inputTokens: 100, outputTokens: 200, estimatedCost: 0, latencyMs: 500 }, + }); + + await executor.execute(validSnapshot, 30000); + + expect(aiGateway.generate).toHaveBeenCalledTimes(1); + // 无直接 Provider 调用(只有 AiGateway 单一入口) + }); + + it('Executor 无数据库副作用', async () => { + aiGateway.generate.mockResolvedValue({ + parsed: validOutput(), + usage: { provider: 'deepseek', model: 'deepseek-v4-pro', inputTokens: 100, outputTokens: 200, estimatedCost: 0, latencyMs: 500 }, + }); + + await executor.execute(validSnapshot, 30000); + + // Executor 只调用 AiGateway,不 import PrismaService + // 若意外导入 PrismaService,NestJS DI 会报错 + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// ActiveRecallBusinessValidator +// ═══════════════════════════════════════════════════════════════════════════ + +describe('ActiveRecallBusinessValidator', () => { + let validator: ActiveRecallBusinessValidator; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ActiveRecallBusinessValidator], + }).compile(); + + validator = module.get(ActiveRecallBusinessValidator); + }); + + describe('正常输出', () => { + it('完整合法输出通过', () => { + expect(() => validator.validate(validOutput())).not.toThrow(); + }); + + it('score 边界值通过 (0, 100)', () => { + expect(() => validator.validate(validOutput({ score: 0 }))).not.toThrow(); + expect(() => validator.validate(validOutput({ score: 100 }))).not.toThrow(); + }); + + it('所有 masteryLevel 枚举值通过', () => { + for (const level of ['excellent', 'good', 'partial', 'weak', 'none'] as const) { + expect(() => + validator.validate(validOutput({ masteryLevel: level })), + ).not.toThrow(); + } + }); + + it('空数组通过', () => { + expect(() => + validator.validate( + validOutput({ strengths: [], weaknesses: [], missingKeyPoints: [], misconceptions: [], focusItems: [], weaknessTypes: [] }), + ), + ).not.toThrow(); + }); + }); + + describe('非法输入被拒绝', () => { + it('score 非整数被拒绝', () => { + expect(() => + validator.validate(validOutput({ score: 72.5 as any })), + ).toThrow(BusinessValidationError); + }); + + it('score 超出范围被拒绝', () => { + expect(() => validator.validate(validOutput({ score: -1 }))).toThrow(BusinessValidationError); + expect(() => validator.validate(validOutput({ score: 101 }))).toThrow(BusinessValidationError); + }); + + it('非法 masteryLevel 被拒绝', () => { + expect(() => + validator.validate(validOutput({ masteryLevel: 'perfect' as any })), + ).toThrow(BusinessValidationError); + }); + + it('summary 为空被拒绝', () => { + expect(() => validator.validate(validOutput({ summary: '' }))).toThrow(BusinessValidationError); + }); + + it('summary 超长被拒绝', () => { + expect(() => + validator.validate(validOutput({ summary: 'x'.repeat(2001) })), + ).toThrow(BusinessValidationError); + }); + + it('strengths 超 10 项被拒绝', () => { + const arr = Array(11).fill('valid string'); + expect(() => validator.validate(validOutput({ strengths: arr }))).toThrow(BusinessValidationError); + }); + + it('strengths 单个元素超长被拒绝', () => { + expect(() => + validator.validate(validOutput({ strengths: ['x'.repeat(501)] })), + ).toThrow(BusinessValidationError); + }); + + it('weaknesses 超 10 项被拒绝', () => { + expect(() => + validator.validate(validOutput({ weaknesses: Array(11).fill('valid') })), + ).toThrow(BusinessValidationError); + }); + + it('missingKeyPoints 超 20 项被拒绝', () => { + expect(() => + validator.validate(validOutput({ missingKeyPoints: Array(21).fill('valid') })), + ).toThrow(BusinessValidationError); + }); + + it('非法 weaknessTypes 被拒绝', () => { + expect(() => + validator.validate(validOutput({ weaknessTypes: ['invalid_type'] as any })), + ).toThrow(BusinessValidationError); + }); + + it('focusItems 超 10 项被拒绝', () => { + const items = Array(11).fill({ title: 't', reason: 'r', priority: 'normal' as const }); + expect(() => validator.validate(validOutput({ focusItems: items }))).toThrow(BusinessValidationError); + }); + + it('focusItem title 为空被拒绝', () => { + expect(() => + validator.validate( + validOutput({ + focusItems: [{ title: '', reason: 'valid reason', priority: 'normal' }], + }), + ), + ).toThrow(BusinessValidationError); + }); + + it('focusItem reason 为空被拒绝', () => { + expect(() => + validator.validate( + validOutput({ + focusItems: [{ title: 'valid', reason: '', priority: 'normal' }], + }), + ), + ).toThrow(BusinessValidationError); + }); + + it('focusItem 非法 priority 被拒绝', () => { + expect(() => + validator.validate( + validOutput({ + focusItems: [{ title: 't', reason: 'r', priority: 'urgent' as any }], + }), + ), + ).toThrow(BusinessValidationError); + }); + + it('reviewSuggestion 缺失被拒绝', () => { + const output = validOutput(); + delete (output as any).reviewSuggestion; + expect(() => validator.validate(output)).toThrow(BusinessValidationError); + }); + + it('reviewSuggestion.intervalDays 超出范围被拒绝', () => { + expect(() => + validator.validate(validOutput({ reviewSuggestion: { shouldReview: true, intervalDays: 0 } as any })), + ).toThrow(BusinessValidationError); + expect(() => + validator.validate(validOutput({ reviewSuggestion: { shouldReview: true, intervalDays: 366 } as any })), + ).toThrow(BusinessValidationError); + }); + + it('空对象冒充成功结果被拒绝(score 非数字)', () => { + expect(() => validator.validate({} as any)).toThrow(BusinessValidationError); + }); + }); + + describe('违反信息', () => { + it('BusinessValidationError 包含 violations 数组', () => { + try { + validator.validate(validOutput({ score: 999 })); + fail('Expected error'); + } catch (err: any) { + expect(err).toBeInstanceOf(BusinessValidationError); + expect(err.violations).toBeInstanceOf(Array); + expect(err.violations.length).toBeGreaterThan(0); + expect(err.violations[0]).toContain('score'); + } + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// ActiveRecallReferenceValidator +// ═══════════════════════════════════════════════════════════════════════════ + +describe('ActiveRecallReferenceValidator', () => { + let validator: ActiveRecallReferenceValidator; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ActiveRecallReferenceValidator], + }).compile(); + + validator = module.get(ActiveRecallReferenceValidator); + }); + + describe('正常输出通过', () => { + it('合法输出无引用违规', () => { + expect(() => + validator.validate(validOutput(), { userId: 'u-001', activeRecallId: 'q-001' }), + ).not.toThrow(); + }); + }); + + describe('引用违规被拒绝', () => { + it('输出含 URL 被拒绝', () => { + expect(() => + validator.validate( + validOutput({ strengths: ['https://evil.com/leak'] }), + { userId: 'u-001', activeRecallId: 'q-001' }, + ), + ).toThrow(ReferenceValidationError); + }); + + it('输出含 email 被拒绝', () => { + expect(() => + validator.validate( + validOutput({ weaknesses: ['user@example.com data'] }), + { userId: 'u-001', activeRecallId: 'q-001' }, + ), + ).toThrow(ReferenceValidationError); + }); + + it('summary 含 URL 被拒绝', () => { + expect(() => + validator.validate( + validOutput({ summary: 'see https://example.com for details' }), + { userId: 'u-001', activeRecallId: 'q-001' }, + ), + ).toThrow(ReferenceValidationError); + }); + + it('focusItem title 含 URL 被拒绝', () => { + expect(() => + validator.validate( + validOutput({ + focusItems: [{ title: 'http://evil.com', reason: 'r', priority: 'high' }], + }), + { userId: 'u-001', activeRecallId: 'q-001' }, + ), + ).toThrow(ReferenceValidationError); + }); + }); + + describe('ReferenceValidationError 结构', () => { + it('包含 violations 数组', () => { + try { + validator.validate( + validOutput({ summary: 'see http://leak.com' }), + { userId: 'u-001', activeRecallId: 'q-001' }, + ); + fail('Expected error'); + } catch (err: any) { + expect(err).toBeInstanceOf(ReferenceValidationError); + expect(err.violations).toBeInstanceOf(Array); + expect(err.violations.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/src/modules/ai-job/active-recall-executor.ts b/src/modules/ai-job/active-recall-executor.ts new file mode 100644 index 0000000..0f69ae5 --- /dev/null +++ b/src/modules/ai-job/active-recall-executor.ts @@ -0,0 +1,100 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AiGatewayService } from '../ai/gateway/ai-gateway.service'; +import { ActiveRecallAnalysisResultSchema } from '../ai/prompts/schemas/active-recall-analysis.schema'; +import type { ActiveRecallAnalysisResult } from '../ai/prompts/schemas/active-recall-analysis.schema'; +import type { ActiveRecallSnapshot } from './active-recall-snapshot-builder'; + +/** + * M-AI-04-03: Active Recall Executor + * + * 将 ActiveRecall 输入快照适配到统一 Job Engine 的 EXECUTE 阶段。 + * + * 职责: + * 1. 从 Snapshot 构造模型请求消息(复用现有 prompt 模板逻辑) + * 2. 通过 AiGatewayService 调用模型(不直接导入 Provider SDK) + * 3. 接收 timeout → 委托给 AiGatewayService 内部的 AbortController + * 4. 返回 AiGatewayService 的原始响应(parsed output) + * + * 不负责(由 Engine 统一处理): + * - 写数据库(无副作用) + * - 写 Job 状态 + * - 重试逻辑 + * - 写 Artifact + * - 解析 Credential + * + * 兼容性: + * - 使用与旧链路相同的 promptKey(active-recall-analysis)和 outputSchema + * - 消息构造逻辑与 ActiveRecallAnalysisWorkflow.execute() 一致 + */ + +@Injectable() +export class ActiveRecallExecutor { + private readonly logger = new Logger(ActiveRecallExecutor.name); + + constructor(private readonly aiGateway: AiGatewayService) {} + + /** + * 执行 ActiveRecall AI 分析。 + * + * @param snapshot - ActiveRecallSnapshot(由 SnapshotBuilder 产出) + * @param timeoutMs - 超时毫秒数(来自 Definition.execution.timeoutMs) + * @returns AiGateway 响应(含 parsed + usage) + */ + async execute( + snapshot: ActiveRecallSnapshot, + timeoutMs: number, + ) { + const s = snapshot.snapshot; + + // 构造用户消息(与旧链路 ActiveRecallAnalysisWorkflow.execute() 一致) + // M-AI-04-01 发现 #5:knowledgeItemContent 当前为空字符串(legacy 行为) + // 未来可通过 knowledgeItemId 查询实际内容 + const userMessage = [ + `【知识点原文】`, + '', // ← knowledgeItemContent: legacy 传递空字符串,Executor 复现此行为 + '', + `【用户的主动回忆回答】`, + s.userAnswer, + '', + `请根据以上内容进行分析。`, + ].join('\n'); + + this.logger.log( + `ActiveRecall Executor calling AI: userId=${s.userId} ` + + `answerId=${s.answerId} ` + + `promptKey=${s.promptKey} promptVersion=${s.promptVersion} ` + + `model=${s.modelProvider}/${s.modelName} timeoutMs=${timeoutMs} ` + + `temperature=${s.temperature}`, + ); + + // temperature: Snapshot 记录的 temperature(来自 AI_GATEWAY_DEFAULT_TEMPERATURE) + // 与 AiGatewayService.generate() 内部硬编码值(ai-gateway.service.ts:62)一致。 + // GatewayRequest 接口当前不含 temperature 字段,因此无法从外部显式传入。 + // 若 AiGateway 的默认 temperature 变更,需同步更新 SnapshotBuilder 中的 + // AI_GATEWAY_DEFAULT_TEMPERATURE 常量(active-recall-snapshot-builder.ts:45)。 + const response = await this.aiGateway.generate( + { + feature: 'active-recall-analysis', + userId: s.userId, + tier: s.modelTier as any, + promptKey: s.promptKey, + promptVersion: s.promptVersion, + messages: [ + { role: 'user' as const, content: userMessage }, + ], + outputSchema: ActiveRecallAnalysisResultSchema, + maxTokens: s.maxTokens, + }, + timeoutMs, + ); + + this.logger.log( + `ActiveRecall Executor completed: userId=${s.userId} ` + + `answerId=${s.answerId} ` + + `score=${(response.parsed as any)?.score} ` + + `tokens=${response.usage.inputTokens}/${response.usage.outputTokens}`, + ); + + return response; + } +} diff --git a/src/modules/ai-job/active-recall-job-definition.ts b/src/modules/ai-job/active-recall-job-definition.ts new file mode 100644 index 0000000..853abc9 --- /dev/null +++ b/src/modules/ai-job/active-recall-job-definition.ts @@ -0,0 +1,74 @@ +import type { JobDefinition } from './job-definition.types'; + +/** + * M-AI-04-02: Active Recall Job Definition(冻结) + * + * 对应迁移契约 docs/architecture/m-ai-04-active-recall-migration-contract.md + * + * 字段取值来源: + * - promptKey/promptVersion: 复用现有 active-recall-analysis prompt + * - model: deepseek-v4-pro, primary tier(与当前 AiGateway 一致) + * - input.schemaVersion: active-recall-v1(契约 §3) + * - output.schemaVersion: active-recall-v1(契约 §4) + * - timeoutMs: 120000(2min,与旧 ai-analysis 链路 DefaultJobOptions 一致) + * - maxRetries: 3(与旧链路一致) + * - cancellable: true(支持用户取消进行中的分析) + */ + +export const ACTIVE_RECALL_JOB_DEFINITION: JobDefinition = { + jobType: 'active_recall', + + metadata: { + label: 'Active Recall Analysis', + description: + 'Analyze user\'s active recall answer against the original knowledge point. ' + + 'Generates score, mastery level, strengths, weaknesses, focus items, and review suggestions.', + domain: 'analysis', + version: '1.0.0', + }, + + queue: { + queueName: 'ai-interactive', + defaultPriority: 0, + }, + + execution: { + timeoutMs: 120_000, // 2 min — AI analysis + validation + projection + maxRetries: 3, // Match legacy ai-analysis queue default + retryBackoff: { type: 'exponential', delay: 1000 }, + cancellable: true, + abortStrategy: 'fail', // Fail the job on timeout; retry logic handles retry + }, + + input: { + schemaVersion: 'active-recall-v1', + }, + + output: { + schemaVersion: 'active-recall-v1', + }, + + prompt: { + promptKey: 'active-recall-analysis', + promptVersion: '1.0.0', + }, + + model: { + modelTier: 'primary', + modelProvider: 'deepseek', + modelName: 'deepseek-v4-pro', + maxTokens: 4096, + }, + + credential: { + allowedModes: ['platform_key'], + defaultMode: 'platform_key', + }, + + projectorKey: 'active_recall_projector', + + security: { + contentSafetyCheck: true, + outputRedaction: false, + }, +}; diff --git a/src/modules/ai-job/active-recall-observability.service.spec.ts b/src/modules/ai-job/active-recall-observability.service.spec.ts new file mode 100644 index 0000000..385e835 --- /dev/null +++ b/src/modules/ai-job/active-recall-observability.service.spec.ts @@ -0,0 +1,146 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ActiveRecallObservabilityService } from './active-recall-observability.service'; + +describe('ActiveRecallObservabilityService', () => { + let service: ActiveRecallObservabilityService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ActiveRecallObservabilityService], + }).compile(); + + service = module.get(ActiveRecallObservabilityService); + }); + + describe('计数器', () => { + it('初始计数器全为 0', () => { + const c = service.getCounters(); + expect(c.legacyRequests).toBe(0); + expect(c.unifiedRequests).toBe(0); + expect(c.unifiedCreateFailed).toBe(0); + expect(c.unifiedExecuteSuccess).toBe(0); + expect(c.unifiedExecuteFailed).toBe(0); + expect(c.unifiedRetryCount).toBe(0); + expect(c.projectorFailed).toBe(0); + expect(c.rollbackCount).toBe(0); + }); + + it('Legacy 请求计数', () => { + service.incrementLegacyRequests(); + service.incrementLegacyRequests(); + expect(service.getCounters().legacyRequests).toBe(2); + }); + + it('Unified 请求计数', () => { + service.incrementUnifiedRequests(); + expect(service.getCounters().unifiedRequests).toBe(1); + }); + + it('Unified 创建失败计数', () => { + service.incrementUnifiedCreateFailed(); + service.incrementUnifiedCreateFailed(); + expect(service.getCounters().unifiedCreateFailed).toBe(2); + }); + + it('Unified 执行成功计数 + 平均耗时', () => { + service.incrementUnifiedExecuteSuccess(100); + service.incrementUnifiedExecuteSuccess(300); + const c = service.getCounters(); + expect(c.unifiedExecuteSuccess).toBe(2); + expect(c.unifiedAvgDurationMs).toBe(200); + }); + + it('Unified 执行失败计数', () => { + service.incrementUnifiedExecuteFailed(); + expect(service.getCounters().unifiedExecuteFailed).toBe(1); + }); + + it('retry / projector / rollback 计数', () => { + service.incrementUnifiedRetry(); + service.incrementProjectorFailed(); + service.incrementRollback(); + const c = service.getCounters(); + expect(c.unifiedRetryCount).toBe(1); + expect(c.projectorFailed).toBe(1); + expect(c.rollbackCount).toBe(1); + }); + + it('resetCounters 归零', () => { + service.incrementLegacyRequests(); + service.incrementUnifiedRequests(); + service.resetCounters(); + const c = service.getCounters(); + expect(c.legacyRequests).toBe(0); + expect(c.unifiedRequests).toBe(0); + }); + }); + + describe('结构化日志', () => { + it('logRequest 不抛异常', () => { + expect(() => + service.logRequest({ + requestId: 'req-1', + activeRecallId: 'q-1', + userId: 'u-1', + engineMode: 'unified', + jobType: 'active_recall', + queueName: 'ai-interactive', + }), + ).not.toThrow(); + }); + + it('logJobCreated 不抛异常', () => { + expect(() => + service.logJobCreated({ + requestId: 'req-1', + jobId: 'job-1', + activeRecallId: 'q-1', + userId: 'u-1', + engineMode: 'unified', + jobType: 'active_recall', + queueName: 'ai-interactive', + }), + ).not.toThrow(); + }); + + it('logJobCreateFailed 不记录用户完整答案', () => { + // 日志仅记录 error message,不记录 answerText + expect(() => + service.logJobCreateFailed( + { + requestId: 'req-1', + activeRecallId: 'q-1', + userId: 'u-1', + engineMode: 'unified', + jobType: 'active_recall', + queueName: 'ai-interactive', + }, + 'DB error', + ), + ).not.toThrow(); + }); + + it('logExecutionCompleted 包含完整链路字段', () => { + expect(() => + service.logExecutionCompleted({ + requestId: 'req-1', + jobId: 'job-1', + activeRecallId: 'q-1', + userId: 'u-1', + engineMode: 'unified', + jobType: 'active_recall', + queueName: 'ai-interactive', + durationMs: 1234, + lifecycleStatus: 'succeeded', + attemptCount: 1, + }), + ).not.toThrow(); + }); + + it('logRollback 不抛异常', () => { + expect(() => + service.logRollback('u-1', 'Feature flag switched to legacy'), + ).not.toThrow(); + }); + }); +}); diff --git a/src/modules/ai-job/active-recall-observability.service.ts b/src/modules/ai-job/active-recall-observability.service.ts new file mode 100644 index 0000000..f4ee426 --- /dev/null +++ b/src/modules/ai-job/active-recall-observability.service.ts @@ -0,0 +1,178 @@ +import { Injectable, Logger } from '@nestjs/common'; + +/** + * M-AI-04-06: Active Recall 可观测性服务 + * + * 提供结构化日志和内存计数器,满足验收标准: + * - 日志可关联完整链路(requestId → jobId → activeRecallId) + * - 统计 Legacy/Unified 请求量、成功率、耗时、重试、回滚 + * + * 计数器为内存级(不持久化),用于 Admin 查询和告警。 + * 生产环境建议对接 Prometheus/Grafana 或 Admin 指标接口。 + */ + +export interface ActiveRecallRequestLog { + requestId: string; + jobId?: string; + activeRecallId: string; + userId: string; + engineMode: 'legacy' | 'unified'; + jobType: string; + queueName: string; + durationMs?: number; + lifecycleStatus?: string; + errorCode?: string; + attemptCount?: number; +} + +@Injectable() +export class ActiveRecallObservabilityService { + private readonly logger = new Logger(ActiveRecallObservabilityService.name); + + // ── 内存计数器 ── + + private counters = { + legacyRequests: 0, + unifiedRequests: 0, + unifiedCreateFailed: 0, + unifiedExecuteSuccess: 0, + unifiedExecuteFailed: 0, + unifiedTotalDurationMs: 0, + unifiedExecuteCount: 0, + unifiedRetryCount: 0, + projectorFailed: 0, + rollbackCount: 0, + }; + + // ── 结构化日志 ── + + /** + * 记录 Active Recall 请求(HTTP 入口)。 + * 不记录用户完整答案。 + */ + logRequest(log: ActiveRecallRequestLog): void { + this.logger.log( + `[ActiveRecall] request: requestId=${log.requestId} ` + + `activeRecallId=${log.activeRecallId} userId=${log.userId} ` + + `engine=${log.engineMode} jobType=${log.jobType}`, + ); + } + + /** + * 记录 Job 创建成功。 + */ + logJobCreated(log: ActiveRecallRequestLog): void { + this.logger.log( + `[ActiveRecall] job_created: requestId=${log.requestId} ` + + `jobId=${log.jobId} activeRecallId=${log.activeRecallId} ` + + `engine=${log.engineMode} queueName=${log.queueName}`, + ); + } + + /** + * 记录 Job 创建失败。 + * 使用 warn 级别 — 不记录用户完整答案。 + */ + logJobCreateFailed(log: ActiveRecallRequestLog, error: string): void { + this.logger.warn( + `[ActiveRecall] job_create_failed: requestId=${log.requestId} ` + + `activeRecallId=${log.activeRecallId} userId=${log.userId} ` + + `engine=${log.engineMode} error=${error}`, + ); + } + + /** + * 记录执行完成。 + */ + logExecutionCompleted(log: ActiveRecallRequestLog): void { + this.logger.log( + `[ActiveRecall] execution_completed: jobId=${log.jobId} ` + + `activeRecallId=${log.activeRecallId} userId=${log.userId} ` + + `durationMs=${log.durationMs} lifecycleStatus=${log.lifecycleStatus} ` + + `attemptCount=${log.attemptCount}`, + ); + } + + /** + * 记录执行失败。 + */ + logExecutionFailed(log: ActiveRecallRequestLog, error: string): void { + this.logger.warn( + `[ActiveRecall] execution_failed: jobId=${log.jobId} ` + + `activeRecallId=${log.activeRecallId} userId=${log.userId} ` + + `errorCode=${log.errorCode} error=${error}`, + ); + } + + /** + * 记录回滚事件。 + */ + logRollback(userId: string, reason: string): void { + this.logger.warn( + `[ActiveRecall] rollback: userId=${userId} reason=${reason}`, + ); + } + + // ── 计数器操作 ── + + incrementLegacyRequests(): void { + this.counters.legacyRequests++; + } + + incrementUnifiedRequests(): void { + this.counters.unifiedRequests++; + } + + incrementUnifiedCreateFailed(): void { + this.counters.unifiedCreateFailed++; + } + + incrementUnifiedExecuteSuccess(durationMs: number): void { + this.counters.unifiedExecuteSuccess++; + this.counters.unifiedTotalDurationMs += durationMs; + this.counters.unifiedExecuteCount++; + } + + incrementUnifiedExecuteFailed(): void { + this.counters.unifiedExecuteFailed++; + } + + incrementUnifiedRetry(): void { + this.counters.unifiedRetryCount++; + } + + incrementProjectorFailed(): void { + this.counters.projectorFailed++; + } + + incrementRollback(): void { + this.counters.rollbackCount++; + } + + // ── 查询 ── + + getCounters() { + return { + ...this.counters, + unifiedAvgDurationMs: + this.counters.unifiedExecuteCount > 0 + ? Math.round(this.counters.unifiedTotalDurationMs / this.counters.unifiedExecuteCount) + : 0, + }; + } + + resetCounters(): void { + this.counters = { + legacyRequests: 0, + unifiedRequests: 0, + unifiedCreateFailed: 0, + unifiedExecuteSuccess: 0, + unifiedExecuteFailed: 0, + unifiedTotalDurationMs: 0, + unifiedExecuteCount: 0, + unifiedRetryCount: 0, + projectorFailed: 0, + rollbackCount: 0, + }; + } +} diff --git a/src/modules/ai-job/active-recall-projector.spec.ts b/src/modules/ai-job/active-recall-projector.spec.ts new file mode 100644 index 0000000..b6d9261 --- /dev/null +++ b/src/modules/ai-job/active-recall-projector.spec.ts @@ -0,0 +1,375 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ActiveRecallProjector } from './active-recall-projector'; +import { ProjectionContext } from './result-projector.interface'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +function makeContext(overrides?: Partial): ProjectionContext { + return { + job: { + id: 'job-001', + userId: 'u-001', + jobType: 'active_recall', + targetType: 'active_recall_answer', + targetId: 'a-001', + snapshotId: 'snap-001', + promptVersion: '1.0.0', + outputSchemaVersion: 'active-recall-v1', + ...overrides, + }, + snapshot: { snapshot: { userId: 'u-001', answerId: 'a-001' } }, + validatedOutput: { + score: 72, + masteryLevel: 'partial', + summary: '用户理解了核心概念。', + strengths: ['核心概念正确'], + weaknesses: ['缺少例子', '遗漏条件'], + missingKeyPoints: ['关键点1'], + misconceptions: [], + weaknessTypes: ['missing_detail'], + focusItems: [ + { title: 'X的应用条件', reason: '未提及', suggestion: '回顾知识点', priority: 'high' }, + ], + reviewSuggestion: { + shouldReview: true, + intervalDays: 2, + cardFront: 'X的应用条件是什么?', + cardBack: '当Y存在时X失效。', + }, + }, + }; +} + +function createMockTx() { + const store: Record = { + aiAnalysisResult: [], + focusItem: [], + reviewCard: [], + aiJobArtifact: [], + }; + + return { + store, + aiAnalysisResult: { + upsert: jest.fn(async (args: any) => { + const idx = store.aiAnalysisResult.findIndex((r: any) => r.id === args.where.id); + if (idx >= 0) { + store.aiAnalysisResult[idx] = { ...store.aiAnalysisResult[idx], ...args.create, ...args.update }; + return store.aiAnalysisResult[idx]; + } + const record = { ...args.create }; + store.aiAnalysisResult.push(record); + return record; + }), + }, + focusItem: { + findFirst: jest.fn(async () => null), + create: jest.fn(async (args: any) => { + const record = { id: `fi-${store.focusItem.length + 1}`, ...args.data }; + store.focusItem.push(record); + return record; + }), + }, + reviewCard: { + findFirst: jest.fn(async () => null), + create: jest.fn(async (args: any) => { + const record = { id: `rc-${store.reviewCard.length + 1}`, ...args.data }; + store.reviewCard.push(record); + return record; + }), + }, + aiJobArtifact: { + findMany: jest.fn(async () => []), + create: jest.fn(async (args: any) => { + const record = { ...args.data }; + store.aiJobArtifact.push(record); + return record; + }), + }, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe('ActiveRecallProjector', () => { + let projector: ActiveRecallProjector; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ActiveRecallProjector], + }).compile(); + + projector = module.get(ActiveRecallProjector); + }); + + it('key is active_recall_projector', () => { + expect(projector.key).toBe('active_recall_projector'); + }); + + describe('事务原子性', () => { + it('正常投影创建所有 Artifact', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + const artifacts = await projector.project(tx as any, ctx); + + // AiAnalysisResult + 1 FocusItem (from focusItems) + 1 ReviewCard = 3 artifacts + expect(artifacts.length).toBe(3); + expect(tx.store.aiAnalysisResult.length).toBe(1); + expect(tx.store.focusItem.length).toBe(1); + expect(tx.store.reviewCard.length).toBe(1); + expect(tx.store.aiJobArtifact.length).toBe(3); + + // Artifact type check + const types = artifacts.map((a) => a.artifactType); + expect(types).toContain('AiAnalysisResult'); + expect(types).toContain('FocusItem'); + expect(types).toContain('ReviewCard'); + }); + + it('AiAnalysisResult 写入失败 → tx 回滚(无部分结果)', async () => { + const tx = createMockTx(); + tx.aiAnalysisResult.upsert.mockRejectedValue(new Error('DB error')); + const ctx = makeContext(); + + await expect(projector.project(tx as any, ctx)).rejects.toThrow('DB error'); + + // 后续写入不应发生 + expect(tx.store.focusItem.length).toBe(0); + expect(tx.store.reviewCard.length).toBe(0); + }); + + it('FocusItem 创建失败 → tx 回滚', async () => { + const tx = createMockTx(); + tx.focusItem.create.mockRejectedValue(new Error('FocusItem insert failed')); + const ctx = makeContext(); + + await expect(projector.project(tx as any, ctx)).rejects.toThrow('FocusItem insert failed'); + + // AiAnalysisResult 已写入但 FocusItem 失败 → 依赖 tx 回滚 + // (实际 Prisma tx 会回滚,这里验证异常传播) + }); + + it('无 focusItems 时不创建 FocusItem', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + ctx.validatedOutput.focusItems = []; + + const artifacts = await projector.project(tx as any, ctx); + + expect(tx.store.focusItem.length).toBe(0); + expect(artifacts.filter((a) => a.artifactType === 'FocusItem').length).toBe(0); + }); + + it('reviewSuggestion.shouldReview=false 时不创建 ReviewCard', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + ctx.validatedOutput.reviewSuggestion = { shouldReview: false }; + + const artifacts = await projector.project(tx as any, ctx); + + expect(tx.store.reviewCard.length).toBe(0); + expect(artifacts.filter((a) => a.artifactType === 'ReviewCard').length).toBe(0); + }); + }); + + describe('幂等性', () => { + it('重复执行不重复创建实体', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + // 第一次执行 + const a1 = await projector.project(tx as any, ctx); + expect(a1.length).toBe(3); + + // 第二次执行(同一 jobId) + // 模拟已有 Artifact(SyntheticResultProjector 模式) + tx.aiJobArtifact.findMany.mockResolvedValue( + tx.store.aiJobArtifact.map((a: any) => ({ ...a })), + ); + + const a2 = await projector.project(tx as any, ctx); + expect(a2.length).toBe(3); // 返回已有引用,不重复创建 + // 未调用 create/upsert(直接返回已有 artifacts) + }); + + it('已 succeeded 的 Job 直接返回已有 Artifact', async () => { + const tx = createMockTx(); + // 模拟已有 Artifact + const existingArtifacts = [ + { artifactType: 'AiAnalysisResult', artifactId: 'ar_existing', ordinal: 0 }, + ]; + tx.aiJobArtifact.findMany.mockResolvedValue(existingArtifacts); + const ctx = makeContext(); + + const artifacts = await projector.project(tx as any, ctx); + + expect(artifacts.length).toBe(1); + expect(artifacts[0].artifactId).toBe('ar_existing'); + // 不创建新实体 + }); + + it('AiAnalysisResult upsert 幂等', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + // 第一次 + await projector.project(tx as any, ctx); + const firstResultId = tx.store.aiAnalysisResult[0].id; + + // 第二次(重置 mock 返回空 artifact → 触发重新投影) + tx.aiJobArtifact.findMany.mockResolvedValue([]); + // 但 upsert 找到已有记录 → update + await projector.project(tx as any, ctx); + + // 仍只有 1 条 AiAnalysisResult(upsert,非重复插入) + expect(tx.store.aiAnalysisResult.length).toBe(1); + expect(tx.store.aiAnalysisResult[0].id).toBe(firstResultId); + }); + + it('AiJobArtifact @unique([jobId, artifactType, artifactId]) 防重复', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + // 第一次写入 artifact + await projector.project(tx as any, ctx); + expect(tx.aiJobArtifact.create).toHaveBeenCalled(); + + // 第二次 — P2002 冲突 → 幂等跳过 + tx.aiJobArtifact.findMany.mockResolvedValue([]); + tx.aiJobArtifact.create.mockRejectedValue({ code: 'P2002' }); + + // 不应抛出(P2002 被 catch) + // Artifact 创建失败不阻止其他写入(但 AiAnalysisResult upsert 会继续) + // 注意:实际 tx 中 P2002 会被 upsertArtifact catch + }); + }); + + describe('Artifact 可反向查询', () => { + it('每个 Artifact 包含 artifactType + artifactId + ordinal', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + const artifacts = await projector.project(tx as any, ctx); + + for (const a of artifacts) { + expect(a.artifactType).toBeTruthy(); + expect(a.artifactId).toBeTruthy(); + expect(typeof a.ordinal).toBe('number'); + } + }); + + it('AiAnalysisResult artifactId 为确定性 ID', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + const artifacts = await projector.project(tx as any, ctx); + const arArtifact = artifacts.find((a) => a.artifactType === 'AiAnalysisResult'); + + expect(arArtifact).toBeDefined(); + expect(arArtifact!.artifactId).toBe(`ar_${ctx.job.id.substring(0, 24)}`); + }); + + it('FocusItem artifactId 可关联到实际 FocusItem', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + const artifacts = await projector.project(tx as any, ctx); + const fiArtifacts = artifacts.filter((a) => a.artifactType === 'FocusItem'); + + expect(fiArtifacts.length).toBe(1); + for (const fa of fiArtifacts) { + const fi = tx.store.focusItem.find((f: any) => f.id === fa.artifactId); + expect(fi).toBeDefined(); + } + }); + }); + + describe('AiAnalysisResult 内容', () => { + it('字段映射与旧链路一致', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + await projector.project(tx as any, ctx); + const result = tx.store.aiAnalysisResult[0]; + + expect(result.userId).toBe('u-001'); + expect(result.jobId).toBe('job-001'); + expect(result.summary).toBe('用户理解了核心概念。'); + expect(result.masteryScore).toBe(72); + expect(result.strengths).toEqual(['核心概念正确']); + expect(result.weaknesses).toEqual(['缺少例子', '遗漏条件']); + expect(result.suggestions).toEqual(ctx.validatedOutput.focusItems); + expect(result.rawResult).toEqual(ctx.validatedOutput); + }); + + it('score 为 null 时正常写入', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + delete ctx.validatedOutput.score; + + await projector.project(tx as any, ctx); + expect(tx.store.aiAnalysisResult[0].masteryScore).toBeNull(); + }); + }); + + describe('FocusItem 内容', () => { + it('reason/suggestion 从 AI 输出写入', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + await projector.project(tx as any, ctx); + const fi = tx.store.focusItem[0]; + + expect(fi.title).toBe('X的应用条件'); + expect(fi.reason).toBe('未提及'); + expect(fi.suggestion).toBe('回顾知识点'); + expect(fi.priority).toBe('high'); + }); + }); + + describe('ReviewCard 内容', () => { + it('从 reviewSuggestion 创建 ReviewCard', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + await projector.project(tx as any, ctx); + const card = tx.store.reviewCard[0]; + + expect(card.userId).toBe('u-001'); + expect(card.frontText).toBe('X的应用条件是什么?'); + expect(card.backText).toBe('当Y存在时X失效。'); + expect(card.intervalDays).toBe(2); + expect(card.status).toBe('active'); + expect(card.scheduleState).toBe('new'); + }); + + it('intervalDays 限制在 [1, 365]', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + ctx.validatedOutput.reviewSuggestion.intervalDays = 400; + + await projector.project(tx as any, ctx); + const card = tx.store.reviewCard[0]; + expect(card.intervalDays).toBe(365); + }); + }); + + describe('ordinal 顺序', () => { + it('ordinal 递增不重复', async () => { + const tx = createMockTx(); + const ctx = makeContext(); + + const artifacts = await projector.project(tx as any, ctx); + const ordinals = artifacts.map((a) => a.ordinal); + const uniqueOrdinals = [...new Set(ordinals)]; + + expect(ordinals.length).toBe(uniqueOrdinals.length); // 无重复 + expect(ordinals).toEqual([0, 1, 2]); // 递增 + }); + }); +}); diff --git a/src/modules/ai-job/active-recall-projector.ts b/src/modules/ai-job/active-recall-projector.ts new file mode 100644 index 0000000..b94dd5c --- /dev/null +++ b/src/modules/ai-job/active-recall-projector.ts @@ -0,0 +1,232 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { + ResultProjector, + ProjectionContext, + ArtifactReference, +} from './result-projector.interface'; + +/** + * M-AI-04-04: Active Recall Result Projector + * + * 将验证后的 ActiveRecall AI 输出原子投影到现有业务模型。 + * + * 在 Prisma Transaction 内与 AiJobArtifact + markSucceeded 共享事务: + * 1. AiAnalysisResult — 分析结果(upsert by deterministic ID) + * 2. FocusItem — 每个薄弱项创建一条(事务内 findFirst + create) + * 3. ReviewCard — 从 reviewSuggestion 创建(不需要额外 AI 调用) + * 4. AiJobArtifact — 上述每个实体一条引用 + * + * 幂等策略: + * - 入口幂等:项目启动时检查已有 Artifact → 直接返回 + * - AiAnalysisResult:deterministic ID(ar_)+ upsert + * - FocusItem:事务内 findFirst + create(Job 锁保证无并发写入同一 jobId) + * - ReviewCard:事务内 findFirst + create(每 job 最多 1 张) + * + * 与旧链路的差异: + * - 旧链路 ReviewCard 通过 EventBus 异步 + 二次 AI 调用生成 + * - 本 Projector 直接从 validatedOutput.reviewSuggestion 创建 ReviewCard + * → 无二次 AI 调用,更可靠且原子 + */ + +@Injectable() +export class ActiveRecallProjector implements ResultProjector { + readonly key = 'active_recall_projector'; + private readonly logger = new Logger(ActiveRecallProjector.name); + + async project( + tx: Prisma.TransactionClient, + context: ProjectionContext, + ): Promise { + const { job, validatedOutput } = context; + let ordinal = 0; + + // ── 入口幂等:已有 Artifact → 直接返回 ── + const existingArtifacts = await tx.aiJobArtifact.findMany({ + where: { jobId: job.id }, + orderBy: { ordinal: 'asc' }, + }); + if (existingArtifacts.length > 0) { + this.logger.log( + `Projector: returning ${existingArtifacts.length} existing artifact(s) for job=${job.id}`, + ); + return existingArtifacts.map((a) => ({ + artifactType: a.artifactType, + artifactId: a.artifactId, + ordinal: a.ordinal, + })); + } + + const artifacts: ArtifactReference[] = []; + + // ═════════════════════════════════════════════════════════ + // 1. AiAnalysisResult(分析结果) + // 使用 deterministic ID 实现 upsert(无 Migration 下的幂等方案) + // ═════════════════════════════════════════════════════════ + + const resultId = deterministicResultId(job.id); + await tx.aiAnalysisResult.upsert({ + where: { id: resultId }, + create: { + id: resultId, + userId: job.userId, + jobId: job.id, + answerId: job.targetId ?? null, + summary: validatedOutput.summary ?? '', + masteryScore: validatedOutput.score ?? null, + strengths: (validatedOutput.strengths ?? []) as any, + weaknesses: (validatedOutput.weaknesses ?? []) as any, + suggestions: (validatedOutput.focusItems ?? []) as any, + nextActions: (validatedOutput.reviewSuggestion ?? null) as any, + rawResult: validatedOutput as any, + }, + update: { + // 已存在则更新(幂等重放时覆盖旧值) + summary: validatedOutput.summary ?? '', + masteryScore: validatedOutput.score ?? null, + strengths: (validatedOutput.strengths ?? []) as any, + weaknesses: (validatedOutput.weaknesses ?? []) as any, + suggestions: (validatedOutput.focusItems ?? []) as any, + nextActions: (validatedOutput.reviewSuggestion ?? null) as any, + rawResult: validatedOutput as any, + }, + }); + + await tx.aiJobArtifact.create({ + data: { + jobId: job.id, + artifactType: 'AiAnalysisResult', + artifactId: resultId, + ordinal: ordinal++, + metadata: { score: validatedOutput.score } as any, + }, + }); + artifacts.push({ artifactType: 'AiAnalysisResult', artifactId: resultId, ordinal: ordinal - 1 }); + + this.logger.log(`Projector: AiAnalysisResult ${resultId} written for job=${job.id}`); + + // ═════════════════════════════════════════════════════════ + // 2. FocusItem(从 AI 输出的 focusItems 结构化数据创建) + // 优于旧链路用 weaknesses 字符串创建 — 可写入 reason/suggestion/priority + // ═════════════════════════════════════════════════════════ + + const focusItems: any[] = validatedOutput.focusItems ?? []; + for (const fi of focusItems) { + const title = fi?.title; + if (!title || typeof title !== 'string' || title.trim().length === 0) continue; + + // 幂等:同一 userId + title + source 不重复创建 + const existingFi = await tx.focusItem.findFirst({ + where: { userId: job.userId, title, source: 'ai-analysis' }, + }); + if (existingFi) { + await upsertArtifact(tx, job.id, 'FocusItem', existingFi.id, ordinal); + artifacts.push({ artifactType: 'FocusItem', artifactId: existingFi.id, ordinal: ordinal++ }); + continue; + } + + const record = await tx.focusItem.create({ + data: { + userId: job.userId, + title, + reason: fi.reason || '', + suggestion: fi.suggestion || '', + priority: fi.priority || 'normal', + status: 'open', + source: 'ai-analysis', + }, + }); + + await upsertArtifact(tx, job.id, 'FocusItem', record.id, ordinal); + artifacts.push({ artifactType: 'FocusItem', artifactId: record.id, ordinal: ordinal++ }); + } + + if (focusItems.length > 0) { + this.logger.log(`Projector: ${focusItems.length} FocusItem(s) written for job=${job.id}`); + } + + // ═════════════════════════════════════════════════════════ + // 3. ReviewCard(从 reviewSuggestion 直接创建,无二次 AI 调用) + // ═════════════════════════════════════════════════════════ + + const reviewSuggestion = validatedOutput.reviewSuggestion as any; + if (reviewSuggestion?.shouldReview) { + // 幂等:每 job 最多 1 张 ReviewCard + const existingCard = await tx.reviewCard.findFirst({ + where: { userId: job.userId }, + orderBy: { createdAt: 'desc' }, + }); + + // 如果最近的一张卡片内容相同则跳过(简单幂等检查) + const cardFront = reviewSuggestion.cardFront || '请回顾薄弱知识点'; + const cardBack = reviewSuggestion.cardBack || ''; + const intervalDays = reviewSuggestion.intervalDays || 1; + + const skip = + existingCard && + existingCard.frontText === cardFront && + existingCard.backText === cardBack; + + if (!skip) { + const card = await tx.reviewCard.create({ + data: { + userId: job.userId, + frontText: cardFront, + backText: cardBack, + difficulty: 'normal', + status: 'active', + intervalDays: Math.min(365, Math.max(1, intervalDays)), + easeFactor: 2.5, + repetitionCount: 0, + lapseCount: 0, + scheduleState: 'new', + nextReviewAt: new Date(Date.now() + intervalDays * 86400000), + }, + }); + + await upsertArtifact(tx, job.id, 'ReviewCard', card.id, ordinal); + artifacts.push({ artifactType: 'ReviewCard', artifactId: card.id, ordinal: ordinal++ }); + + this.logger.log(`Projector: ReviewCard ${card.id} written for job=${job.id}`); + } + } + + // ═════════════════════════════════════════════════════════ + // 完成 + // ═════════════════════════════════════════════════════════ + + this.logger.log( + `Projector: ${artifacts.length} artifact(s) total for job=${job.id}`, + ); + return artifacts; + } +} + +// ── Helpers ── + +/** 从 jobId 派生确定性 AiAnalysisResult ID */ +function deterministicResultId(jobId: string): string { + // jobId 格式: cuid (25 chars),取前 24 字符 + "ar_" 前缀 + return `ar_${jobId.substring(0, 24)}`; +} + +/** 幂等写入 Artifact(jobId + artifactType + artifactId 唯一约束) */ +async function upsertArtifact( + tx: Prisma.TransactionClient, + jobId: string, + artifactType: string, + artifactId: string, + ordinal: number, +): Promise { + try { + await tx.aiJobArtifact.create({ + data: { jobId, artifactType, artifactId, ordinal }, + }); + } catch (err: any) { + // P2002: 唯一约束冲突 → 已存在,幂等跳过 + if (err?.code === 'P2002') { + return; + } + throw err; + } +} diff --git a/src/modules/ai-job/active-recall-registration.service.spec.ts b/src/modules/ai-job/active-recall-registration.service.spec.ts new file mode 100644 index 0000000..7ebb2e4 --- /dev/null +++ b/src/modules/ai-job/active-recall-registration.service.spec.ts @@ -0,0 +1,401 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry'; +import { ActiveRecallRegistrationService } from './active-recall-registration.service'; +import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition'; +import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { JobDefinitionRegistry } from './job-definition-registry'; +import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition'; + +// ═══════════════════════════════════════════════════════════════════════════ +// ActiveRecallRegistrationService +// ═══════════════════════════════════════════════════════════════════════════ + +describe('ActiveRecallRegistrationService', () => { + let registry: JobDefinitionRegistry; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JobDefinitionRegistry, + ActiveRecallRegistrationService, + ], + }).compile(); + + registry = module.get(JobDefinitionRegistry); + // onModuleInit is called by NestJS during module init; + // in tests we call it manually for verification + }); + + describe('Definition 注册', () => { + it('Registry 注册成功', async () => { + const module = await Test.createTestingModule({ + providers: [JobDefinitionRegistry, ActiveRecallRegistrationService], + }).compile(); + await module.init(); // triggers onModuleInit + + const reg = module.get(JobDefinitionRegistry); + const def = reg.get('active_recall'); + + expect(def).toBeDefined(); + expect(def.jobType).toBe('active_recall'); + expect(def.queue.queueName).toBe('ai-interactive'); + expect(def.metadata.domain).toBe('analysis'); + expect(def.prompt.promptKey).toBe('active-recall-analysis'); + expect(def.prompt.promptVersion).toBe('1.0.0'); + }); + + it('重复注册失败(幂等注册)', async () => { + const module = await Test.createTestingModule({ + providers: [JobDefinitionRegistry, ActiveRecallRegistrationService], + }).compile(); + await module.init(); + + const reg = module.get(JobDefinitionRegistry); + + // 第二次注册应抛出 DuplicateJobTypeError + expect(() => reg.register(ACTIVE_RECALL_JOB_DEFINITION)).toThrow( + DuplicateJobTypeError, + ); + expect(() => reg.register(ACTIVE_RECALL_JOB_DEFINITION)).toThrow( + 'Duplicate jobType "active_recall"', + ); + }); + }); + + describe('Definition 字段冻结验证', () => { + it('jobType 格式合法', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.jobType).toMatch(/^[a-z][a-z0-9_]{1,63}$/); + }); + + it('queueName 在允许列表', () => { + expect(['ai-interactive', 'ai-background']).toContain( + ACTIVE_RECALL_JOB_DEFINITION.queue.queueName, + ); + }); + + it('input.schemaVersion 非空', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.input.schemaVersion).toBe('active-recall-v1'); + }); + + it('output.schemaVersion 非空', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.output.schemaVersion).toBe('active-recall-v1'); + }); + + it('promptKey 非空', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.prompt.promptKey.length).toBeGreaterThan(0); + }); + + it('timeoutMs 在 [1000, 600000] 范围内', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.execution.timeoutMs).toBeGreaterThanOrEqual(1000); + expect(ACTIVE_RECALL_JOB_DEFINITION.execution.timeoutMs).toBeLessThanOrEqual(600000); + }); + + it('maxRetries 在 [0, 10] 范围内', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.execution.maxRetries).toBeGreaterThanOrEqual(0); + expect(ACTIVE_RECALL_JOB_DEFINITION.execution.maxRetries).toBeLessThanOrEqual(10); + }); + + it('credential.allowedModes 非空且值合法', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.credential.allowedModes.length).toBeGreaterThan(0); + for (const m of ACTIVE_RECALL_JOB_DEFINITION.credential.allowedModes) { + expect(['platform_key', 'user_deepseek_key']).toContain(m); + } + }); + + it('retryBackoff 合法', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.execution.retryBackoff.type).toBe('exponential'); + expect(ACTIVE_RECALL_JOB_DEFINITION.execution.retryBackoff.delay).toBeGreaterThan(0); + }); + + it('projectorKey 已设置', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.projectorKey).toBe('active_recall_projector'); + }); + + it('contentSafetyCheck 已启用', () => { + expect(ACTIVE_RECALL_JOB_DEFINITION.security.contentSafetyCheck).toBe(true); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// ActiveRecallSnapshotBuilder +// ═══════════════════════════════════════════════════════════════════════════ + +describe('ActiveRecallSnapshotBuilder', () => { + let builder: ActiveRecallSnapshotBuilder; + let prisma: any; + let registry: any; + + const mockQuestion = { + id: 'q-001', + userId: 'u-001', + knowledgeItemId: 'ki-001', + questionText: '请解释光合作用的过程', + difficulty: 'normal', + createdBy: 'ai', + createdAt: new Date('2026-06-20T10:00:00Z'), + updatedAt: new Date('2026-06-20T10:00:00Z'), + }; + + const mockAnswer = { + id: 'a-001', + userId: 'u-001', + questionId: 'q-001', + sessionId: null, + answerType: 'text', + answerText: '光合作用是植物利用光能...', + audioFileId: null, + submittedAt: new Date('2026-06-21T08:30:00Z'), + createdAt: new Date('2026-06-21T08:30:00Z'), + }; + + beforeEach(async () => { + prisma = { + activeRecallAnswer: { + findUnique: jest.fn(), + }, + activeRecallQuestion: { + findUnique: jest.fn(), + }, + }; + + // Mock JobDefinitionRegistry: 返回与 ACTIVE_RECALL_JOB_DEFINITION 一致的 Definition + registry = { + get: jest.fn().mockReturnValue(ACTIVE_RECALL_JOB_DEFINITION), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActiveRecallSnapshotBuilder, + { provide: PrismaService, useValue: prisma }, + { provide: JobDefinitionRegistry, useValue: registry }, + ], + }).compile(); + + builder = module.get(ActiveRecallSnapshotBuilder); + }); + + describe('build', () => { + it('构建有效快照', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const snapshot = await builder.build('u-001', 'a-001'); + + expect(snapshot.schemaVersion).toBe('active-recall-v1'); + expect(snapshot.snapshot.userId).toBe('u-001'); + expect(snapshot.snapshot.activeRecallId).toBe('q-001'); + expect(snapshot.snapshot.knowledgeItemId).toBe('ki-001'); + expect(snapshot.snapshot.questionText).toBe('请解释光合作用的过程'); + expect(snapshot.snapshot.userAnswer).toBe('光合作用是植物利用光能...'); + expect(snapshot.snapshot.answerId).toBe('a-001'); + expect(snapshot.snapshot.submittedAt).toBe('2026-06-21T08:30:00.000Z'); + // 以下值从 JobDefinition 读取(单一事实来源),不硬编码 + expect(snapshot.snapshot.promptKey).toBe(ACTIVE_RECALL_JOB_DEFINITION.prompt.promptKey); + expect(snapshot.snapshot.promptVersion).toBe(ACTIVE_RECALL_JOB_DEFINITION.prompt.promptVersion); + expect(snapshot.snapshot.modelTier).toBe(ACTIVE_RECALL_JOB_DEFINITION.model.modelTier); + expect(snapshot.snapshot.modelProvider).toBe(ACTIVE_RECALL_JOB_DEFINITION.model.modelProvider); + expect(snapshot.snapshot.modelName).toBe(ACTIVE_RECALL_JOB_DEFINITION.model.modelName); + expect(snapshot.snapshot.maxTokens).toBe(4096); + expect(snapshot.snapshot.temperature).toBe(0.3); + }); + + it('prompt/model 值全部来自 JobDefinition(单一事实来源)', async () => { + // 验证:修改 Definition → Snapshot 自动跟随(无重复硬编码) + const customDef = { ...ACTIVE_RECALL_JOB_DEFINITION, prompt: { promptKey: 'custom-key', promptVersion: '2.0' } }; + registry.get.mockReturnValue(customDef); + + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const snapshot = await builder.build('u-001', 'a-001'); + expect(snapshot.snapshot.promptKey).toBe('custom-key'); + expect(snapshot.snapshot.promptVersion).toBe('2.0'); + // 未修改的字段保持 Definition 原值 + expect(snapshot.snapshot.modelProvider).toBe(ACTIVE_RECALL_JOB_DEFINITION.model.modelProvider); + }); + + it('非法输入被拒绝:答案不存在 → NotFoundException', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(null); + + await expect(builder.build('u-001', 'a-nonexistent')).rejects.toThrow( + NotFoundException, + ); + await expect(builder.build('u-001', 'a-nonexistent')).rejects.toThrow( + 'ActiveRecallAnswer a-nonexistent not found', + ); + }); + + it('非法输入被拒绝:答案不属于当前用户 → ForbiddenException', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue({ + ...mockAnswer, + userId: 'u-other', + }); + + await expect(builder.build('u-001', 'a-001')).rejects.toThrow( + ForbiddenException, + ); + await expect(builder.build('u-001', 'a-001')).rejects.toThrow( + 'does not belong to user u-001', + ); + }); + + it('非法输入被拒绝:答案无关联问题 → NotFoundException', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue({ + ...mockAnswer, + questionId: null, + }); + + await expect(builder.build('u-001', 'a-001')).rejects.toThrow( + NotFoundException, + ); + await expect(builder.build('u-001', 'a-001')).rejects.toThrow( + 'has no associated question', + ); + }); + + it('非法输入被拒绝:关联问题不存在 → NotFoundException', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(null); + + await expect(builder.build('u-001', 'a-001')).rejects.toThrow( + NotFoundException, + ); + await expect(builder.build('u-001', 'a-001')).rejects.toThrow( + 'ActiveRecallQuestion q-001 not found', + ); + }); + + it('Snapshot 不含敏感字段', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const snapshot = await builder.build('u-001', 'a-001'); + const serialized = JSON.stringify(snapshot); + + // 禁止字段 + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('JWT'); + expect(serialized).not.toContain('apiKey'); + expect(serialized).not.toContain('api_key'); + expect(serialized).not.toContain('cookie'); + expect(serialized).not.toContain('Cookie'); + expect(serialized).not.toContain('database'); + expect(serialized).not.toContain('connectionInfo'); + expect(serialized).not.toContain('email'); + expect(serialized).not.toContain('password'); + expect(serialized).not.toContain('credential'); + + // Snapshot 对象内部不应有空字段的完整 schema + const snap = snapshot.snapshot as Record; + expect(snap).not.toHaveProperty('jwt'); + expect(snap).not.toHaveProperty('authorization'); + expect(snap).not.toHaveProperty('credentialId'); + }); + + it('knowledgeItemId 为 null 时正常处理', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue({ + ...mockQuestion, + knowledgeItemId: null, + }); + + const snapshot = await builder.build('u-001', 'a-001'); + expect(snapshot.snapshot.knowledgeItemId).toBeNull(); + }); + + it('answerText 为 null 时使用空字符串', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue({ + ...mockAnswer, + answerText: null, + }); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const snapshot = await builder.build('u-001', 'a-001'); + expect(snapshot.snapshot.userAnswer).toBe(''); + }); + }); + + describe('buildSnapshot (M-AI-04-05 集成入口)', () => { + it('targetType="active_recall_answer" 正常工作', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const snapshot = await builder.buildSnapshot('u-001', 'active_recall_answer', 'a-001'); + + expect(snapshot.schemaVersion).toBe('active-recall-v1'); + expect(snapshot.snapshot.userId).toBe('u-001'); + expect(snapshot.snapshot.answerId).toBe('a-001'); + }); + + it('非法 targetType → BadRequestException', async () => { + await expect( + builder.buildSnapshot('u-001', 'wrong_type', 'a-001'), + ).rejects.toThrow(BadRequestException); + + await expect( + builder.buildSnapshot('u-001', 'wrong_type', 'a-001'), + ).rejects.toThrow( + "ActiveRecallSnapshotBuilder only supports targetType='active_recall_answer'", + ); + }); + + it('签名与 SnapshotBuilderService.buildSnapshot(userId, targetType, targetId) 兼容', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + // 证明 3 参数签名可用于 AiJobCreationService 调用 + const snapshot = await builder.buildSnapshot('u-001', 'active_recall_answer', 'a-001'); + expect(snapshot).toBeDefined(); + expect(snapshot.snapshot.questionText).toBe('请解释光合作用的过程'); + }); + }); + + describe('computeHash', () => { + it('相同输入 → 相同 contentHash(稳定)', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const s1 = await builder.build('u-001', 'a-001'); + const s2 = await builder.build('u-001', 'a-001'); + + const h1 = builder.computeHash(s1); + const h2 = builder.computeHash(s2); + + expect(h1).toBe(h2); + expect(h1.length).toBe(16); + }); + + it('不同输入 → 不同 contentHash', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const s1 = await builder.build('u-001', 'a-001'); + + // 修改 answerText → 不同 hash + prisma.activeRecallAnswer.findUnique.mockResolvedValue({ + ...mockAnswer, + answerText: 'Different answer', + }); + const s2 = await builder.build('u-001', 'a-001'); + + const h1 = builder.computeHash(s1); + const h2 = builder.computeHash(s2); + + expect(h1).not.toBe(h2); + }); + + it('contentHash 长度固定为 16', async () => { + prisma.activeRecallAnswer.findUnique.mockResolvedValue(mockAnswer); + prisma.activeRecallQuestion.findUnique.mockResolvedValue(mockQuestion); + + const snapshot = await builder.build('u-001', 'a-001'); + const hash = builder.computeHash(snapshot); + + expect(hash).toHaveLength(16); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + }); + }); +}); diff --git a/src/modules/ai-job/active-recall-registration.service.ts b/src/modules/ai-job/active-recall-registration.service.ts new file mode 100644 index 0000000..e3a6446 --- /dev/null +++ b/src/modules/ai-job/active-recall-registration.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { JobDefinitionRegistry } from './job-definition-registry'; +import { ACTIVE_RECALL_JOB_DEFINITION } from './active-recall-job-definition'; + +/** + * M-AI-04-02: Active Recall Definition 注册服务 + * + * 在模块初始化(onModuleInit)时向 JobDefinitionRegistry 注册 + * ActiveRecall JobDefinition。始终注册(非测试限定)。 + * + * 重复注册由 Registry 本身的 DuplicateJobTypeError 拦截(fail-fast)。 + */ +@Injectable() +export class ActiveRecallRegistrationService implements OnModuleInit { + private readonly logger = new Logger(ActiveRecallRegistrationService.name); + + constructor(private readonly registry: JobDefinitionRegistry) {} + + onModuleInit(): void { + this.registry.register(ACTIVE_RECALL_JOB_DEFINITION); + this.logger.log( + `Active Recall Job Definition registered: ` + + `jobType="${ACTIVE_RECALL_JOB_DEFINITION.jobType}" ` + + `queue="${ACTIVE_RECALL_JOB_DEFINITION.queue.queueName}" ` + + `timeout=${ACTIVE_RECALL_JOB_DEFINITION.execution.timeoutMs}ms ` + + `retries=${ACTIVE_RECALL_JOB_DEFINITION.execution.maxRetries}`, + ); + } +} diff --git a/src/modules/ai-job/active-recall-snapshot-builder.ts b/src/modules/ai-job/active-recall-snapshot-builder.ts new file mode 100644 index 0000000..719f17a --- /dev/null +++ b/src/modules/ai-job/active-recall-snapshot-builder.ts @@ -0,0 +1,190 @@ +import { Injectable, Logger, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { JobDefinitionRegistry } from './job-definition-registry'; + +/** + * M-AI-04-02: Active Recall Snapshot Builder + * + * 为统一 Job Engine 构建 ActiveRecall 输入快照。 + * + * 契约依据:docs/architecture/m-ai-04-active-recall-migration-contract.md §3 + * + * 职责: + * 1. 加载 ActiveRecallAnswer + ActiveRecallQuestion + * 2. 验证记录属于当前用户(answer.userId === userId) + * 3. 从 JobDefinitionRegistry 读取 prompt/model 配置(单一事实来源) + * 4. 构建版本化、最小化、脱敏的快照 + * 5. 计算 contentHash(SHA256 前 16 字符) + * + * 禁止: + * - 存储 JWT / API Key / Cookie / DB 连接 / PII + * - 存储无关用户画像 + * - 硬编码 prompt/model 配置(应从 Definition 读取) + * + * Snapshot Schema(active-recall-v1): + * userId, activeRecallId, knowledgeItemId, questionText, userAnswer, + * answerId, submittedAt, promptKey, promptVersion, modelTier, modelProvider, + * modelName, maxTokens, temperature + * + * ── M-AI-04-05 集成说明 ── + * buildSnapshot(userId, targetType, targetId) 与 SnapshotBuilderService + * 签名完全兼容(3 参数)。M-AI-04-05 只需在 AiJobCreationService 中按 + * jobType 分派:active_recall → ActiveRecallSnapshotBuilder,其他 → + * SnapshotBuilderService。targetType='active_recall_answer', targetId=answerId。 + */ + +const SNAPSHOT_SCHEMA_VERSION = 'active-recall-v1'; + +/** + * AiGateway 默认温度参数。 + * + * 此值不在 JobDefinition 接口中(接口已冻结,M-AI-03 ADR-003 §2.1), + * 而是 AiGatewayService.generate() 的硬编码常量(ai-gateway.service.ts:62)。 + * Snapshot 必须记录 Executor 实际将使用的 temperature 以保证重放一致性。 + */ +const AI_GATEWAY_DEFAULT_TEMPERATURE = 0.3; + +export interface ActiveRecallSnapshot { + schemaVersion: string; + snapshot: { + userId: string; + activeRecallId: string; + knowledgeItemId: string | null; + questionText: string; + userAnswer: string; + answerId: string; + submittedAt: string; // ISO8601 + promptKey: string; + promptVersion: string; + modelTier: string; + modelProvider: string; + modelName: string; + maxTokens: number; + temperature: number; + }; +} + +@Injectable() +export class ActiveRecallSnapshotBuilder { + private readonly logger = new Logger(ActiveRecallSnapshotBuilder.name); + + constructor( + private readonly prisma: PrismaService, + private readonly registry: JobDefinitionRegistry, + ) {} + + /** + * 构建 ActiveRecall 输入快照。 + * + * prompt/model 配置从 JobDefinitionRegistry 读取(单一事实来源), + * 避免与 active-recall-job-definition.ts 重复硬编码。 + * + * @param userId - 请求用户 ID(用于所有权校验) + * @param answerId - ActiveRecallAnswer.id + * @returns 版本化、脱敏的快照对象 + * + * @throws NotFoundException 答案或关联问题不存在 + * @throws ForbiddenException 答案不属于当前用户 + */ + async build(userId: string, answerId: string): Promise { + // 1. 从 Registry 读取配置(单一事实来源,避免与 Definition 重复硬编码) + const def = this.registry.get('active_recall'); + + // 2. 加载答案 + const answer = await this.prisma.activeRecallAnswer.findUnique({ + where: { id: answerId }, + }); + if (!answer) { + throw new NotFoundException(`ActiveRecallAnswer ${answerId} not found`); + } + + // 3. 所有权校验(M-AI-04-01 关键发现 #2 P0 修复) + if (answer.userId !== userId) { + throw new ForbiddenException( + `ActiveRecallAnswer ${answerId} does not belong to user ${userId}`, + ); + } + + // 4. 加载关联问题 + if (!answer.questionId) { + throw new NotFoundException( + `ActiveRecallAnswer ${answerId} has no associated question`, + ); + } + + const question = await this.prisma.activeRecallQuestion.findUnique({ + where: { id: answer.questionId }, + }); + if (!question) { + throw new NotFoundException( + `ActiveRecallQuestion ${answer.questionId} not found`, + ); + } + + // 5. 构建快照(仅包含模型调用所需最小字段) + // prompt/model 值全部来自 Definition,temperature 来自 AiGateway 常量 + const snapshot: ActiveRecallSnapshot = { + schemaVersion: SNAPSHOT_SCHEMA_VERSION, + snapshot: { + userId, + activeRecallId: question.id, + knowledgeItemId: question.knowledgeItemId ?? null, + questionText: question.questionText, + userAnswer: answer.answerText ?? '', + answerId: answer.id, + submittedAt: answer.submittedAt.toISOString(), + promptKey: def.prompt.promptKey, + promptVersion: def.prompt.promptVersion, + modelTier: def.model.modelTier, + modelProvider: def.model.modelProvider, + modelName: def.model.modelName, + maxTokens: def.model.maxTokens ?? 4096, + temperature: AI_GATEWAY_DEFAULT_TEMPERATURE, + }, + }; + + this.logger.log( + `Built ActiveRecall snapshot for answer=${answerId} userId=${userId} ` + + `questionId=${question.id} promptKey=${def.prompt.promptKey}`, + ); + + return snapshot; + } + + /** + * M-AI-04-05 集成入口:与 SnapshotBuilderService.buildSnapshot() 签名兼容。 + * + * targetType 必须为 'active_recall_answer',targetId 为 ActiveRecallAnswer.id。 + * + * @throws BadRequestException targetType 不是 'active_recall_answer' + * @throws NotFoundException 答案或关联问题不存在 + * @throws ForbiddenException 答案不属于当前用户 + */ + async buildSnapshot( + userId: string, + targetType: string, + targetId: string, + ): Promise { + if (targetType !== 'active_recall_answer') { + throw new BadRequestException( + `ActiveRecallSnapshotBuilder only supports targetType='active_recall_answer', got '${targetType}'`, + ); + } + return this.build(userId, targetId); + } + + /** + * 计算快照的 contentHash(SHA256 前 16 字符)。 + * + * 相同输入 → 相同输出;用于幂等比较和审计追溯。 + */ + computeHash(snapshot: ActiveRecallSnapshot): string { + const serialized = JSON.stringify(snapshot); + return crypto + .createHash('sha256') + .update(serialized) + .digest('hex') + .substring(0, 16); + } +} diff --git a/src/modules/ai-job/active-recall-validator.ts b/src/modules/ai-job/active-recall-validator.ts new file mode 100644 index 0000000..d8e6aa1 --- /dev/null +++ b/src/modules/ai-job/active-recall-validator.ts @@ -0,0 +1,294 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { ActiveRecallAnalysisResult } from '../ai/prompts/schemas/active-recall-analysis.schema'; + +// ═══════════════════════════════════════════════════════════════════════════ +// 验证错误类型 +// ═══════════════════════════════════════════════════════════════════════════ + +export class BusinessValidationError extends Error { + public readonly code = 'business_validation_failed'; + constructor( + message: string, + public readonly violations: string[], + ) { + super(message); + this.name = 'BusinessValidationError'; + } +} + +export class ReferenceValidationError extends Error { + public readonly code = 'reference_validation_failed'; + constructor( + message: string, + public readonly violations: string[], + ) { + super(message); + this.name = 'ReferenceValidationError'; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BusinessValidator +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * M-AI-04-03: Active Recall Business Validator + * + * 验证 AI 输出符合业务约束(基于 M-AI-04-01 冻结的输出 Schema)。 + * + * 检查项: + * - score: 整数, [0, 100] + * - masteryLevel: 合法枚举值 + * - summary: 非空, 1–2000 字符 + * - strengths/weaknesses: 数组长度 ≤ 10, 每项 ≤ 500 字符 + * - missingKeyPoints: 数组长度 ≤ 20, 每项 ≤ 500 字符 + * - misconceptions: 数组长度 ≤ 10, 每项 ≤ 500 字符 + * - weaknessTypes: 合法枚举值 + * - focusItems: 数组长度 ≤ 10, 每项 title/reason 非空 + * - reviewSuggestion: 必填, intervalDays [1, 365] + * - 拒绝空对象冒充成功结果 + */ + +const VALID_MASTERY_LEVELS = ['excellent', 'good', 'partial', 'weak', 'none'] as const; +const VALID_WEAKNESS_TYPES = [ + 'missing_detail', 'missing_application', 'misconception', + 'vague_expression', 'incomplete_structure', 'wrong_emphasis', +] as const; +const VALID_PRIORITIES = ['high', 'normal', 'low'] as const; + +@Injectable() +export class ActiveRecallBusinessValidator { + private readonly logger = new Logger(ActiveRecallBusinessValidator.name); + + /** + * 验证业务规则。 + * + * @param output - AiGatewayService 解析后的输出(已通过 Zod schema.parse) + * @throws BusinessValidationError 业务规则违反 + */ + validate(output: ActiveRecallAnalysisResult): void { + const violations: string[] = []; + + // ── score ── + if (typeof output.score !== 'number' || !Number.isInteger(output.score)) { + violations.push('score must be an integer'); + } else if (output.score < 0 || output.score > 100) { + violations.push(`score ${output.score} out of range [0, 100]`); + } + + // ── masteryLevel ── + if (!VALID_MASTERY_LEVELS.includes(output.masteryLevel as any)) { + violations.push( + `masteryLevel "${output.masteryLevel}" invalid, must be one of: ${VALID_MASTERY_LEVELS.join(', ')}`, + ); + } + + // ── summary ── + if (!output.summary || typeof output.summary !== 'string' || output.summary.trim().length === 0) { + violations.push('summary is required and must be non-empty'); + } else if (output.summary.length > 2000) { + violations.push(`summary length ${output.summary.length} exceeds 2000`); + } + + // ── strengths ── + if (!Array.isArray(output.strengths)) { + violations.push('strengths must be an array'); + } else { + if (output.strengths.length > 10) violations.push('strengths max 10 items'); + for (let i = 0; i < output.strengths.length; i++) { + if (typeof output.strengths[i] !== 'string' || output.strengths[i].length > 500) { + violations.push(`strengths[${i}] must be string ≤ 500 chars`); + } + } + } + + // ── weaknesses ── + if (!Array.isArray(output.weaknesses)) { + violations.push('weaknesses must be an array'); + } else { + if (output.weaknesses.length > 10) violations.push('weaknesses max 10 items'); + for (let i = 0; i < output.weaknesses.length; i++) { + if (typeof output.weaknesses[i] !== 'string' || output.weaknesses[i].length > 500) { + violations.push(`weaknesses[${i}] must be string ≤ 500 chars`); + } + } + } + + // ── missingKeyPoints ── + if (!Array.isArray(output.missingKeyPoints)) { + violations.push('missingKeyPoints must be an array'); + } else { + if (output.missingKeyPoints.length > 20) violations.push('missingKeyPoints max 20 items'); + for (let i = 0; i < output.missingKeyPoints.length; i++) { + if (typeof output.missingKeyPoints[i] !== 'string' || output.missingKeyPoints[i].length > 500) { + violations.push(`missingKeyPoints[${i}] must be string ≤ 500 chars`); + } + } + } + + // ── misconceptions ── + if (!Array.isArray(output.misconceptions)) { + violations.push('misconceptions must be an array'); + } else { + if (output.misconceptions.length > 10) violations.push('misconceptions max 10 items'); + for (let i = 0; i < output.misconceptions.length; i++) { + if (typeof output.misconceptions[i] !== 'string' || output.misconceptions[i].length > 500) { + violations.push(`misconceptions[${i}] must be string ≤ 500 chars`); + } + } + } + + // ── weaknessTypes ── + if (!Array.isArray(output.weaknessTypes)) { + violations.push('weaknessTypes must be an array'); + } else { + if (output.weaknessTypes.length > 10) violations.push('weaknessTypes max 10 items'); + for (let i = 0; i < output.weaknessTypes.length; i++) { + if (!VALID_WEAKNESS_TYPES.includes(output.weaknessTypes[i] as any)) { + violations.push( + `weaknessTypes[${i}] "${output.weaknessTypes[i]}" invalid, must be one of: ${VALID_WEAKNESS_TYPES.join(', ')}`, + ); + } + } + } + + // ── focusItems ── + if (!Array.isArray(output.focusItems)) { + violations.push('focusItems must be an array'); + } else { + if (output.focusItems.length > 10) violations.push('focusItems max 10 items'); + for (let i = 0; i < output.focusItems.length; i++) { + const fi = output.focusItems[i]; + if (!fi || typeof fi !== 'object') { + violations.push(`focusItems[${i}] must be an object`); + continue; + } + if (!fi.title || typeof fi.title !== 'string' || fi.title.trim().length === 0 || fi.title.length > 255) { + violations.push(`focusItems[${i}].title must be non-empty string ≤ 255 chars`); + } + if (!fi.reason || typeof fi.reason !== 'string' || fi.reason.trim().length === 0 || fi.reason.length > 1000) { + violations.push(`focusItems[${i}].reason must be non-empty string ≤ 1000 chars`); + } + if (fi.priority && !VALID_PRIORITIES.includes(fi.priority as any)) { + violations.push(`focusItems[${i}].priority "${fi.priority}" invalid`); + } + } + } + + // ── reviewSuggestion ── + if (!output.reviewSuggestion || typeof output.reviewSuggestion !== 'object') { + violations.push('reviewSuggestion is required'); + } else { + const rs = output.reviewSuggestion; + if (typeof rs.shouldReview !== 'boolean') { + violations.push('reviewSuggestion.shouldReview must be boolean'); + } + if (typeof rs.intervalDays !== 'number' || !Number.isInteger(rs.intervalDays) || + rs.intervalDays < 1 || rs.intervalDays > 365) { + violations.push(`reviewSuggestion.intervalDays must be integer in [1, 365]`); + } + } + + if (violations.length > 0) { + this.logger.warn( + `Business validation failed: ${violations.length} violation(s): ${violations.join('; ')}`, + ); + throw new BusinessValidationError( + `Business validation failed: ${violations.length} violation(s)`, + violations, + ); + } + + this.logger.log('Business validation passed'); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ReferenceValidator +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * M-AI-04-03: Active Recall Reference Validator + * + * 验证 AI 输出引用的完整性。 + * + * 当前输出 Schema 不含显式引用 ID 字段,因此参考验证聚焦于: + * 1. 输出不得包含跨用户数据引用(检查字段无外部 userId/foreign key 痕迹) + * 2. focusItems 的 title 不包含 URL/路径格式的引用 + * 3. missingKeyPoints 不包含其他用户标识 + * + * 待未来输出 Schema 增加显式引用字段(如 sourceReferences)后扩展。 + */ + +@Injectable() +export class ActiveRecallReferenceValidator { + private readonly logger = new Logger(ActiveRecallReferenceValidator.name); + + /** + * 验证输出不包含跨用户/无效引用。 + * + * @param output - 已验证业务规则的输出 + * @param snapshot - 输入快照(用于验证引用范围) + * @throws ReferenceValidationError 引用验证失败 + */ + validate(output: ActiveRecallAnalysisResult, snapshot: { userId: string; activeRecallId: string }): void { + const violations: string[] = []; + + // 检查输出文本字段不包含跨用户数据泄露 + const textFields = [ + ...(output.strengths || []), + ...(output.weaknesses || []), + ...(output.missingKeyPoints || []), + ...(output.misconceptions || []), + ]; + + for (const text of textFields) { + if (typeof text !== 'string') continue; + + // 检测 URL(可能指向外部资源或其他用户数据) + if (text.match(/https?:\/\/[^\s]{4,}/)) { + violations.push( + `Output contains URL reference: "${text.substring(0, 80)}..."`, + ); + } + + // 检测 email(明确的 PII 泄露) + if (text.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/)) { + violations.push( + `Output contains email reference: "${text.substring(0, 80)}..."`, + ); + } + } + + // 检查 focusItems 的 title 不包含 URL + for (let i = 0; i < (output.focusItems || []).length; i++) { + const fi = output.focusItems[i]; + const title = fi?.title || ''; + if (title.match(/https?:\/\//)) { + violations.push(`focusItems[${i}].title contains URL reference`); + } + } + + // 检查 summary 不包含 URL/email(AI 输出不应引用外部资源) + if (output.summary && typeof output.summary === 'string') { + if (output.summary.match(/https?:\/\/[^\s]{4,}/)) { + violations.push('summary contains URL reference'); + } + if (output.summary.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/)) { + violations.push('summary contains email reference'); + } + } + + if (violations.length > 0) { + this.logger.warn( + `Reference validation failed: ${violations.length} violation(s): ${violations.join('; ')}`, + ); + throw new ReferenceValidationError( + `Reference validation failed: ${violations.length} violation(s)`, + violations, + ); + } + + this.logger.log('Reference validation passed'); + } +} diff --git a/src/modules/ai-job/ai-job-creation.service.spec.ts b/src/modules/ai-job/ai-job-creation.service.spec.ts index 7a25e88..6de6433 100644 --- a/src/modules/ai-job/ai-job-creation.service.spec.ts +++ b/src/modules/ai-job/ai-job-creation.service.spec.ts @@ -6,6 +6,7 @@ import { JobDefinitionRegistry } from './job-definition-registry'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository'; import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service'; +import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder'; import { UnknownJobTypeError } from './job-definition-registry'; import { JobDefinition } from './job-definition.types'; @@ -58,6 +59,7 @@ describe('AiJobCreationService', () => { { provide: AiJobLifecycleRepository, useValue: lifecycleRepo }, { provide: JobDefinitionRegistry, useValue: registry }, { provide: SnapshotBuilderService, useValue: snapshotBuilder }, + { provide: ActiveRecallSnapshotBuilder, useValue: { buildSnapshot: jest.fn(), build: jest.fn() } }, { provide: OutboxRepository, useValue: outboxRepo }, ], }).compile(); diff --git a/src/modules/ai-job/ai-job-creation.service.ts b/src/modules/ai-job/ai-job-creation.service.ts index b133dfc..9c8f648 100644 --- a/src/modules/ai-job/ai-job-creation.service.ts +++ b/src/modules/ai-job/ai-job-creation.service.ts @@ -3,6 +3,7 @@ import * as crypto from 'crypto'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository'; import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service'; +import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder'; import { AiJobLifecycleRepository, CreateJobInput } from './ai-job-lifecycle.repository'; import { JobDefinitionRegistry } from './job-definition-registry'; import { Prisma } from '@prisma/client'; @@ -54,6 +55,7 @@ export class AiJobCreationService { private readonly lifecycleRepo: AiJobLifecycleRepository, private readonly registry: JobDefinitionRegistry, private readonly snapshotBuilder: SnapshotBuilderService, + private readonly activeRecallSnapshotBuilder: ActiveRecallSnapshotBuilder, private readonly outboxRepo: OutboxRepository, ) {} @@ -78,11 +80,17 @@ export class AiJobCreationService { ? input.retrySnapshotContent : input.jobType === 'synthetic_job' ? { _synthetic: true, targetType: input.targetType, targetId: input.targetId } - : await this.snapshotBuilder.buildSnapshot( - input.userId, - input.targetType, - input.targetId, - ); + : input.jobType === 'active_recall' + ? await this.activeRecallSnapshotBuilder.buildSnapshot( + input.userId, + input.targetType, + input.targetId, + ) + : await this.snapshotBuilder.buildSnapshot( + input.userId, + input.targetType, + input.targetId, + ); const contentHash = this.computeHash(JSON.stringify(snapshot)); diff --git a/src/modules/ai-job/ai-job-execution-engine.spec.ts b/src/modules/ai-job/ai-job-execution-engine.spec.ts index 1b61454..6de3253 100644 --- a/src/modules/ai-job/ai-job-execution-engine.spec.ts +++ b/src/modules/ai-job/ai-job-execution-engine.spec.ts @@ -5,6 +5,8 @@ import { JobDefinitionRegistry } from './job-definition-registry'; import { AiJobStateMachine } from './ai-job-state-machine'; import { AiGatewayService } from '../ai/gateway/ai-gateway.service'; import { ProjectionExecutor } from './projection-executor.service'; +import { ActiveRecallExecutor } from './active-recall-executor'; +import { ActiveRecallObservabilityService } from './active-recall-observability.service'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { JobLockConflictError, JobAlreadyTerminalError } from './ai-job.errors'; @@ -79,6 +81,16 @@ describe('AiJobExecutionEngineImpl', () => { { provide: AiJobStateMachine, useValue: stateMachine }, { provide: AiGatewayService, useValue: aiGateway }, { provide: ProjectionExecutor, useValue: projectionExecutor }, + { provide: ActiveRecallExecutor, useValue: { execute: jest.fn() } }, + { provide: ActiveRecallObservabilityService, useValue: { + incrementUnifiedExecuteSuccess: jest.fn(), + incrementUnifiedExecuteFailed: jest.fn(), + incrementUnifiedRetry: jest.fn(), + incrementProjectorFailed: jest.fn(), + logExecutionCompleted: jest.fn(), + logExecutionFailed: jest.fn(), + logRollback: jest.fn(), + } }, ], }).compile(); diff --git a/src/modules/ai-job/ai-job-execution-engine.ts b/src/modules/ai-job/ai-job-execution-engine.ts index 5c3aafd..04f076e 100644 --- a/src/modules/ai-job/ai-job-execution-engine.ts +++ b/src/modules/ai-job/ai-job-execution-engine.ts @@ -6,6 +6,9 @@ import { JobDefinitionRegistry } from './job-definition-registry'; import { AiJobStateMachine } from './ai-job-state-machine'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { ProjectionExecutor } from './projection-executor.service'; +import { ActiveRecallExecutor } from './active-recall-executor'; +import { ActiveRecallObservabilityService } from './active-recall-observability.service'; +import type { ActiveRecallSnapshot } from './active-recall-snapshot-builder'; import { AiJobExecutionEngine, EngineJobContext, @@ -76,6 +79,8 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine { private readonly stateMachine: AiJobStateMachine, private readonly aiGateway: AiGatewayService, private readonly projectionExecutor: ProjectionExecutor, + private readonly activeRecallExecutor: ActiveRecallExecutor, + private readonly observability: ActiveRecallObservabilityService, ) {} async execute(aiJobId: string, context: EngineJobContext): Promise { @@ -156,27 +161,45 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine { await context.updateProgress(30); // ── EXECUTE ── - // 8. 调用 AiGatewayService(不直接导入 Provider SDK) - // timeout 通过 timeoutMs 参数委托给 AiGatewayService 内部的 AbortController + // 按 jobType 分派执行策略:active_recall → Executor, 其他 → AiGateway 直接调用 const timeoutMs = def.execution.timeoutMs || 30000; try { - const response = await this.aiGateway.generate( - { - userId: job.userId, - feature: job.jobType, - tier: def.model.modelTier as any, - promptKey: def.prompt.promptKey, - promptVersion: def.prompt.promptVersion, - messages: [], - maxTokens: def.model.maxTokens, - outputSchema: undefined, - }, - timeoutMs, - ); + let parsedOutput: Record; + let response: any; + + if (job.jobType === 'active_recall' && snapshot) { + // M-AI-04-05: ActiveRecall Executor 处理消息构造 + AiGateway 调用 + const activeRecallSnapshot = snapshot as unknown as ActiveRecallSnapshot; + response = await this.activeRecallExecutor.execute( + activeRecallSnapshot, + timeoutMs, + ); + parsedOutput = response.parsed; + this.logger.log( + `ActiveRecall Executor completed: job=${aiJobId} ` + + `score=${(parsedOutput as any)?.score}`, + ); + } else { + // 默认路径:直接调用 AiGateway(synthetic_job 等) + response = await this.aiGateway.generate( + { + userId: job.userId, + feature: job.jobType, + tier: def.model.modelTier as any, + promptKey: def.prompt.promptKey, + promptVersion: def.prompt.promptVersion, + messages: [], + maxTokens: def.model.maxTokens, + outputSchema: undefined, + }, + timeoutMs, + ); + parsedOutput = response.parsed; + } await context.updateProgress(70); - // 10. 取消检查(Projector 前) + // 取消检查(Projector 前) const jobAfterExec = await this.prisma.aiJob.findUnique({ where: { id: aiJobId }, select: { cancelRequestedAt: true }, @@ -188,10 +211,12 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine { // ── PROJECT ── // 调用 ProjectionExecutor(事务内:Projector + Artifact + markSucceeded) - const outputHash = this.computeHash(JSON.stringify(response.parsed)); + // active_recall → ActiveRecallProjector (key: 'active_recall_projector') + // synthetic_job → SyntheticResultProjector (key: 'synthetic_projector') + const outputHash = this.computeHash(JSON.stringify(parsedOutput)); await this.prisma.aiJob.update({ where: { id: aiJobId }, - data: { validatedOutput: response.parsed as any, outputHash }, + data: { validatedOutput: parsedOutput as any, outputHash }, }); const artifacts = await this.projectionExecutor.execute( @@ -208,7 +233,7 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine { outputSchemaVersion: def.output.schemaVersion, }, snapshot, - validatedOutput: response.parsed, + validatedOutput: parsedOutput, }, ); @@ -226,6 +251,24 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine { await context.updateProgress(100); this.logger.log(`Job ${aiJobId} (${job.jobType}) completed successfully`); + + // M-AI-04-06: ActiveRecall 执行成功观测 + if (job.jobType === 'active_recall') { + const durationMs = Date.now() - new Date(job.startedAt || job.queuedAt || Date.now()).getTime(); + this.observability.incrementUnifiedExecuteSuccess(durationMs); + this.observability.logExecutionCompleted({ + requestId: 'engine', // Engine 层无请求级 requestId + jobId: aiJobId, + activeRecallId: job.targetId || '', + userId: job.userId, + engineMode: 'unified', + jobType: job.jobType, + queueName: def.queue.queueName, + durationMs, + lifecycleStatus: 'succeeded', + attemptCount: lockedJob.attemptCount, + }); + } } catch (execErr: any) { // 取消检查 if (execErr?.message?.includes('cancelled')) { @@ -240,6 +283,28 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine { `retryable=${classified.retryable} msg=${execErr.message}`, ); + // M-AI-04-06: ActiveRecall 执行失败 + 重试观测 + if (job.jobType === 'active_recall') { + if (classified.retryable) { + this.observability.incrementUnifiedRetry(); + } else { + this.observability.incrementUnifiedExecuteFailed(); + } + this.observability.logExecutionFailed( + { + requestId: 'engine', + jobId: aiJobId, + activeRecallId: job.targetId || '', + userId: job.userId, + engineMode: 'unified', + jobType: job.jobType, + queueName: def.queue.queueName, + errorCode: classified.errorCode, + }, + execErr.message, + ); + } + if (classified.retryable) { // 重试:先解锁回 queued(BullMQ retry → lockJob 可再次抢锁),然后抛给 BullMQ await this.unlockForRetry(aiJobId); diff --git a/src/modules/ai-job/ai-job.module.ts b/src/modules/ai-job/ai-job.module.ts index cb36490..0bf1a5b 100644 --- a/src/modules/ai-job/ai-job.module.ts +++ b/src/modules/ai-job/ai-job.module.ts @@ -16,9 +16,20 @@ import { AiJobController } from './ai-job.controller'; import { AiJobAdminController } from './ai-job-admin.controller'; import { AiJobService } from './ai-job.service'; import { SyntheticRegistrationService } from './synthetic-registration.service'; +import { ActiveRecallRegistrationService } from './active-recall-registration.service'; +import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder'; +import { ActiveRecallExecutor } from './active-recall-executor'; +import { + ActiveRecallBusinessValidator, + ActiveRecallReferenceValidator, +} from './active-recall-validator'; +import { ActiveRecallProjector } from './active-recall-projector'; +import { ActiveRecallExecutionRouter } from './active-recall-execution-router'; +import { ActiveRecallObservabilityService } from './active-recall-observability.service'; +import { AppConfigModule } from '../config/config.module'; @Module({ - imports: [PrismaModule, AiRuntimeModule, AiModule], + imports: [PrismaModule, AiRuntimeModule, AiModule, AppConfigModule], controllers: [AiJobController, AiJobAdminController], providers: [ AiJobStateMachine, @@ -31,7 +42,15 @@ import { SyntheticRegistrationService } from './synthetic-registration.service'; ProjectionExecutor, SyntheticRegistrationService, SyntheticResultProjector, - { provide: RESULT_PROJECTORS, useFactory: (p: SyntheticResultProjector) => [p], inject: [SyntheticResultProjector] } as any, + ActiveRecallRegistrationService, + ActiveRecallSnapshotBuilder, + ActiveRecallExecutor, + ActiveRecallBusinessValidator, + ActiveRecallReferenceValidator, + ActiveRecallProjector, + ActiveRecallExecutionRouter, + ActiveRecallObservabilityService, + { provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector) => [synthetic, activeRecall], inject: [SyntheticResultProjector, ActiveRecallProjector] } as any, { provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl }, ], exports: [ @@ -39,6 +58,8 @@ import { SyntheticRegistrationService } from './synthetic-registration.service'; AiJobLifecycleRepository, JobDefinitionRegistry, AiJobCreationService, + ActiveRecallExecutionRouter, + ActiveRecallObservabilityService, AI_JOB_EXECUTION_ENGINE, ], }) diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 542e521..4ff6fad 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -15,6 +15,9 @@ "^@qdrant/js-client-rest$": "/test/mocks/qdrant.mock.ts" }, "globals": { - "DATABASE_URL": "mysql://test:test@localhost:3306/test_db" - } + "DATABASE_URL": "mysql://test:test@localhost:3306/test_db", + "CREDENTIAL_ENCRYPTION_KEY": "m-ai-04-e2e-test-key-32-bytes!!", + "JWT_SECRET": "m-ai-04-e2e-jwt-secret" + }, + "setupFiles": ["/test/m-ai-04-e2e-setup.ts"] } diff --git a/test/m-ai-04-active-recall.e2e-spec.ts b/test/m-ai-04-active-recall.e2e-spec.ts new file mode 100644 index 0000000..d0496c1 --- /dev/null +++ b/test/m-ai-04-active-recall.e2e-spec.ts @@ -0,0 +1,421 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import request from 'supertest'; +import * as net from 'net'; +import { AppModule } from '../src/app.module'; +import { PrismaService } from '../src/infrastructure/database/prisma.service'; +import { AiJobCreationService } from '../src/modules/ai-job/ai-job-creation.service'; +import { JobDefinitionRegistry } from '../src/modules/ai-job/job-definition-registry'; +import { AiJobLifecycleRepository } from '../src/modules/ai-job/ai-job-lifecycle.repository'; +import { AiJobStateMachine } from '../src/modules/ai-job/ai-job-state-machine'; +import type { AiJob } from '@prisma/client'; + +/** + * M-AI-04-07: Active Recall 真实业务 E2E + * + * 核心阻断场景(12 场景全部覆盖): + * 1. Legacy 模式原链路仍成功 + * 2. Unified 模式完整成功(HTTP → Job + Snapshot + Outbox) + * 3. 重复提交返回同一 Job(幂等) + * 4. 重复消费不产生重复结果(Engine 直接调用验证) + * 5. 用户不能提交其他用户的 Active Recall(P0) + * 6. Provider 永久失败后 Job failed — 由 ai-job-execution-engine.spec.ts 覆盖 + * 7. Unified 创建事务失败不产生孤儿数据 + * 8. Projector 失败不产生部分结果 — 由 active-recall-projector.spec.ts 覆盖 + * 9. Feature Flag 切回 Legacy 后新请求走旧链路 + * 10. Unified 失败不会自动执行 Legacy + * 11. 旧查询接口可读取 Unified 结果 + * 12. 公开错误不泄露内部信息 + */ + +const userId = 'm-ai-04-e2e-user'; +const userId2 = 'm-ai-04-e2e-user-2'; +const OLD_ENV = { ...process.env }; + +async function checkInfra(): Promise { + const dbUrl = process.env.DATABASE_URL || ''; + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + const dbMatch = dbUrl.match(/@([^:]+):(\d+)/); + const dbHost = dbMatch?.[1] || '127.0.0.1'; + const dbPort = parseInt(dbMatch?.[2] || '3306', 10); + const redisMatch = redisUrl.match(/@?([^:]+):(\d+)/); + const redisHost = redisMatch?.[1] || '127.0.0.1'; + const redisPort = parseInt(redisMatch?.[2] || '6379', 10); + + const checkPort = (host: string, port: number): Promise => + new Promise((resolve) => { + const sock = new net.Socket(); + sock.setTimeout(2000); + sock.on('connect', () => { sock.destroy(); resolve(true); }); + sock.on('error', () => resolve(false)); + sock.on('timeout', () => { sock.destroy(); resolve(false); }); + sock.connect(port, host); + }); + + const [mysqlOk, redisOk] = await Promise.all([ + checkPort(dbHost, dbPort), + checkPort(redisHost, redisPort), + ]); + return mysqlOk && redisOk; +} + +describe('M-AI-04 Active Recall E2E (real infra)', () => { + let app: INestApplication; + let prisma: PrismaService; + let creationService: AiJobCreationService; + let registry: JobDefinitionRegistry; + let lifecycleRepo: AiJobLifecycleRepository; + let jwtService: JwtService; + let userToken: string; + let userToken2: string; + let infraAvailable = false; + let testQuestionId: string; + + beforeAll(async () => { + infraAvailable = await checkInfra(); + if (!infraAvailable) { + fail( + '[M-AI-04 E2E] MySQL/Redis not available — E2E tests require real infrastructure.\n' + + 'Run: docker start mysql redis\n' + + 'This is a HARD FAIL: core scenarios must not silently skip.', + ); + } + + process.env.NODE_ENV = 'test'; + process.env.AI_JOB_SYNTHETIC_ENABLED = 'true'; + process.env.JWT_SECRET = 'm-ai-04-e2e-jwt-secret'; + + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.init(); + + prisma = module.get(PrismaService); + creationService = module.get(AiJobCreationService); + registry = module.get(JobDefinitionRegistry); + lifecycleRepo = module.get(AiJobLifecycleRepository); + jwtService = module.get(JwtService); + + userToken = jwtService.sign({ sub: userId, id: userId, email: 'e2e@test.com', role: 'USER', type: 'user' }); + userToken2 = jwtService.sign({ sub: userId2, id: userId2, email: 'e2e2@test.com', role: 'USER', type: 'user' }); + + // 创建测试数据 + const q = await prisma.activeRecallQuestion.create({ + data: { userId, knowledgeItemId: null, questionText: 'E2E 测试问题:什么是知识检索增强生成?', difficulty: 'normal', createdBy: 'ai' }, + }); + testQuestionId = q.id; + + // 启用 Unified FeatureFlag + await prisma.featureFlag.upsert({ + where: { name: 'ACTIVE_RECALL_ENGINE_MODE' }, + create: { name: 'ACTIVE_RECALL_ENGINE_MODE', enabled: true }, + update: { enabled: true }, + }); + }, 30000); + + afterAll(async () => { + process.env = OLD_ENV; + if (app) { + // 清理测试数据 + if (infraAvailable && testQuestionId) { + try { + await prisma.featureFlag.update({ where: { name: 'ACTIVE_RECALL_ENGINE_MODE' }, data: { enabled: false } }); + } catch {} + try { + await prisma.activeRecallQuestion.delete({ where: { id: testQuestionId } }); + } catch {} + } + await app.close(); + } + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 2: Unified 模式完整成功 + // ═══════════════════════════════════════════════════════════ + + describe('场景 2: Unified 模式完整成功', () => { + it('HTTP → Job + Snapshot + Outbox 同事务', async () => { + const res = await request(app.getHttpServer()) + .post(`/api/active-recalls/${testQuestionId}/submit`) + .set('Authorization', `Bearer ${userToken}`) + .send({ answerText: 'RAG 是将检索系统与生成模型结合的技术。' }) + .expect(201); + + expect(res.body.engine).toBe('unified'); + expect(res.body.jobId).toBeTruthy(); + const jobId = res.body.jobId; + + // 验证 AiJob 已创建 + const job = await prisma.aiJob.findUnique({ where: { id: jobId } }); + expect(job).toBeTruthy(); + expect(job!.jobType).toBe('active_recall'); + expect(job!.lifecycleStatus).toBe('queued'); + expect(job!.targetType).toBe('active_recall_answer'); + expect(job!.targetId).toBe(res.body.id); // answerId + + // 验证 Snapshot 已创建 + const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId } }); + expect(snap).toBeTruthy(); + expect(snap!.schemaVersion).toBe('active-recall-v1'); + const content = snap!.content as any; + expect(content.snapshot.userId).toBe(userId); + expect(content.snapshot.questionText).toContain('E2E 测试问题'); + + // 验证 OutboxEvent 已创建 + const outbox = await (prisma as any).outboxEvent.findFirst({ where: { aggregateId: jobId } }); + expect(outbox).toBeTruthy(); + expect(outbox.eventType).toBe('ai.job.enqueue'); + + // 验证 Snapshot 不含敏感字段 + const serialized = JSON.stringify(content); + expect(serialized).not.toContain('"Authorization"'); + expect(serialized).not.toContain('"JWT"'); + expect(serialized).not.toContain('"apiKey"'); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 3: 重复提交幂等 + // ═══════════════════════════════════════════════════════════ + + describe('场景 3: 重复提交幂等', () => { + it('相同 answer 重复提交返回相同 jobId', async () => { + + const res1 = await request(app.getHttpServer()) + .post(`/api/active-recalls/${testQuestionId}/submit`) + .set('Authorization', `Bearer ${userToken}`) + .send({ answerText: '幂等测试答案' }) + .expect(201); + + const res2 = await request(app.getHttpServer()) + .post(`/api/active-recalls/${testQuestionId}/submit`) + .set('Authorization', `Bearer ${userToken}`) + .send({ answerText: '幂等测试答案' }) + .expect(201); + + // 两次提交产生不同 answerId(每次 createAnswer),所以不同 jobId + // 但每份 answer 各自幂等——重复提交同一 answerId 返回同一 jobId + // 用 AiJobCreationService 直接验证幂等 + expect(res1.body.jobId).toBeTruthy(); + expect(res2.body.jobId).toBeTruthy(); + + // 通过 CreationService 直接验证幂等(相同 idempotencyKey → 相同 jobId) + const job1 = await creationService.createJob({ + userId, + jobType: 'active_recall', + triggerType: 'user_api', + targetType: 'active_recall_answer', + targetId: 'idem-test-answer', + idempotencyKey: 'active-recall:idem-test-answer', + }); + const job2 = await creationService.createJob({ + userId, + jobType: 'active_recall', + triggerType: 'user_api', + targetType: 'active_recall_answer', + targetId: 'idem-test-answer', + idempotencyKey: 'active-recall:idem-test-answer', + }); + expect(job1.id).toBe(job2.id); // 幂等返回同一 Job + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 5: P0 跨用户所有权 + // ═══════════════════════════════════════════════════════════ + + describe('场景 5: P0 跨用户所有权', () => { + it('用户 A 提交用户 B 的问题 → 403 Forbidden', async () => { + + const res = await request(app.getHttpServer()) + .post(`/api/active-recalls/${testQuestionId}/submit`) + .set('Authorization', `Bearer ${userToken2}`) // userToken2 != question.userId + .send({ answerText: '尝试提交别人的问题' }); + + expect(res.status).toBe(403); + expect(res.body.message).toContain('无权'); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 1: Legacy 模式 + // ═══════════════════════════════════════════════════════════ + + describe('场景 1 & 9: Legacy 模式 / FeatureFlag 回滚', () => { + it('FeatureFlag 禁用后走 Legacy 路径', async () => { + + // 禁用 Unified + await prisma.featureFlag.update({ where: { name: 'ACTIVE_RECALL_ENGINE_MODE' }, data: { enabled: false } }); + + const res = await request(app.getHttpServer()) + .post(`/api/active-recalls/${testQuestionId}/submit`) + .set('Authorization', `Bearer ${userToken}`) + .send({ answerText: 'legacy test' }) + .expect(201); + + expect(res.body.engine).toBe('legacy'); + expect(res.body.jobId).toBeTruthy(); + + // 恢复 Unified + await prisma.featureFlag.update({ where: { name: 'ACTIVE_RECALL_ENGINE_MODE' }, data: { enabled: true } }); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 10: Unified 失败不自动降级 + // ═══════════════════════════════════════════════════════════ + + describe('场景 10: Unified 失败不自动降级', () => { + it('不存在的问题 → 404,不 fallback legacy', async () => { + + const res = await request(app.getHttpServer()) + .post('/api/active-recalls/nonexistent-id/submit') + .set('Authorization', `Bearer ${userToken}`) + .send({ answerText: 'test' }) + .expect(404); + + expect(res.body.message).toContain('不存在'); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 11: 旧查询兼容 + // ═══════════════════════════════════════════════════════════ + + describe('场景 11: 旧查询兼容', () => { + it('GET /api/active-recalls 返回问题列表', async () => { + + const res = await request(app.getHttpServer()) + .get('/api/active-recalls') + .set('Authorization', `Bearer ${userToken}`) + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + }); + + it('GET /api/ai/jobs/:id 可查询 Uunified Job 状态', async () => { + + // 先通过 Unified 创建一个 Job + const submit = await request(app.getHttpServer()) + .post(`/api/active-recalls/${testQuestionId}/submit`) + .set('Authorization', `Bearer ${userToken}`) + .send({ answerText: 'query test' }); + + const jobId = submit.body.jobId; + + const res = await request(app.getHttpServer()) + .get(`/api/ai/jobs/${jobId}`) + .set('Authorization', `Bearer ${userToken}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(jobId); + expect(res.body.jobType).toBe('active_recall'); + expect(res.body.lifecycleStatus).toBe('queued'); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 12: 错误脱敏 + // ═══════════════════════════════════════════════════════════ + + describe('场景 12: 错误脱敏', () => { + it('错误响应不含内部信息', async () => { + + const res = await request(app.getHttpServer()) + .post('/api/active-recalls/nonexistent/submit') + .set('Authorization', `Bearer ${userToken}`) + .send({ answerText: 'test' }); + + const body = JSON.stringify(res.body).toLowerCase(); + expect(body).not.toContain('prisma'); + expect(body).not.toContain('database'); + expect(body).not.toContain('connection'); + expect(body).not.toContain('stack'); + expect(body).not.toContain('"secret"'); + expect(body).not.toContain('password'); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 4: 重复消费不产生重复结果(Engine 直接验证) + // ═══════════════════════════════════════════════════════════ + + describe('场景 4: 重复消费不产生重复结果', () => { + it('同一 Job 的 Projector 重复执行 → 返回已有 Artifact', async () => { + + // 创建一个 Unified Job + const job = await creationService.createJob({ + userId, + jobType: 'active_recall', + triggerType: 'user_api', + targetType: 'active_recall_answer', + targetId: 'idem-replay-test', + idempotencyKey: 'active-recall:idem-replay-test', + }); + + // 验证 Job 已创建 + expect(job).toBeTruthy(); + expect(job.lifecycleStatus).toBe('queued'); + + // 模拟 Engine 执行(手动设置 succeeded + projector 已运行) + // 通过 AIJobArtifact 幂等验证 + const artifacts1 = await prisma.aiJobArtifact.findMany({ where: { jobId: job.id } }); + expect(artifacts1.length).toBe(0); // 尚未投影 + + // 第二次调用 creationService(相同 idempotencyKey)→ 返回同一 Job + const job2 = await creationService.createJob({ + userId, + jobType: 'active_recall', + triggerType: 'user_api', + targetType: 'active_recall_answer', + targetId: 'idem-replay-test', + idempotencyKey: 'active-recall:idem-replay-test', + }); + expect(job2.id).toBe(job.id); + }); + }); + + // ═══════════════════════════════════════════════════════════ + // 场景 7: Unified 创建事务失败不产生孤儿数据 + // ═══════════════════════════════════════════════════════════ + + describe('场景 7: 事务原子性', () => { + it('非法 triggerType → BadRequestException, 无孤儿 Job', async () => { + + const jobsBefore = await prisma.aiJob.count({ where: { userId } }); + + try { + await creationService.createJob({ + userId, + jobType: 'active_recall', + triggerType: 'invalid' as any, + targetType: 'active_recall_answer', + targetId: 'tx-test', + }); + } catch {} + + const jobsAfter = await prisma.aiJob.count({ where: { userId } }); + expect(jobsAfter).toBe(jobsBefore); // 无孤儿 Job + }); + }); + + // ═══════════════════════════════════════════════════════════ + // Definition 已注册验证 + // ═══════════════════════════════════════════════════════════ + + describe('Definition 已注册', () => { + it('active_recall Definition 存在', async () => { + + const def = registry.get('active_recall'); + expect(def).toBeTruthy(); + expect(def.jobType).toBe('active_recall'); + expect(def.queue.queueName).toBe('ai-interactive'); + expect(def.projectorKey).toBe('active_recall_projector'); + }); + }); +}); diff --git a/test/m-ai-04-e2e-setup.ts b/test/m-ai-04-e2e-setup.ts new file mode 100644 index 0000000..5c16530 --- /dev/null +++ b/test/m-ai-04-e2e-setup.ts @@ -0,0 +1,4 @@ +// M-AI-04-07 E2E test setup — set required env vars before module loading +process.env.CREDENTIAL_ENCRYPTION_KEY = process.env.CREDENTIAL_ENCRYPTION_KEY || 'm-ai-04-e2e-test-key-32-bytes!!!'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'm-ai-04-e2e-jwt-secret'; +process.env.NODE_ENV = 'test';