feat(M-AI-05): track Feynman unified engine migration implementation
23 files (+4676/-10): - Contract: m-ai-05-feynman-migration-contract.md (737 lines) - Gate audit: m-ai-05-gate-audit.md (318 lines) - Job Definition + Snapshot Builder + Registration - Executor + BusinessValidator + ReferenceValidator - Projector (atomic transaction + 3-layer idempotency) - ExecutionRouter (FeatureFlag + idempotencyKey) - ObservabilityService (structured logging + counters) - Engine: feynman_evaluation execution branch - AiJobCreationService: feynman_evaluation safety branch - Controller/Module: Router injection - CI: path detection for m-ai-05 - E2E: 8 HTTP-layer scenarios (14 total) - Unit tests: 104 new tests (5 spec files) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a532b5163b
commit
8987598eb8
@ -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/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
|
||||
if echo "$CHANGED" | grep -qE "src/workers/|src/modules/ai-analysis/|src/modules/ai/|src/modules/ai-job/|src/modules/active-recall/|src/modules/review/|src/modules/focus-items/|src/infrastructure/queue/|src/infrastructure/outbox/|prisma/schema.prisma|prisma/migrations/|test/worker-integration|test/run-integration|test/m-ai-04|test/m-ai-05"; then
|
||||
echo "Worker-related changes detected — running integration tests"
|
||||
echo "run_int=true" > /tmp/int-decision
|
||||
else
|
||||
|
||||
736
docs/architecture/m-ai-05-feynman-migration-contract.md
Normal file
736
docs/architecture/m-ai-05-feynman-migration-contract.md
Normal file
@ -0,0 +1,736 @@
|
||||
# M-AI-05 Feynman 与复习产物迁移契约
|
||||
|
||||
> 审计日期:2026-06-21
|
||||
> 审计人:开发执行代理(只读审计,未修改代码)
|
||||
> 基线:M-AI-04 GATE CONDITIONAL PASS (`a5ad0bc`)
|
||||
> 输出:本文档冻结 Feynman 迁移的全部契约
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前时序图(Legacy 链路)
|
||||
|
||||
```
|
||||
Client
|
||||
│
|
||||
│ POST /api/ai-analysis/feynman
|
||||
│ Body: { knowledgeItemTitle, knowledgeItemContent, userExplanation, sessionId?, answerId? }
|
||||
│ Auth: JWT Bearer
|
||||
▼
|
||||
AiAnalysisController.evaluateFeynman() [ai-analysis.controller.ts:32]
|
||||
│ @AiAnalysisRateLimit()
|
||||
│ body 无 DTO class — 直接解构 @Body()
|
||||
│
|
||||
▼
|
||||
AiAnalysisService.evaluateFeynman() [ai-analysis.service.ts:33]
|
||||
│
|
||||
├─► AiAnalysisRepository.createJob() [ai-analysis.repository.ts:17]
|
||||
│ INSERT INTO AiAnalysisJob (
|
||||
│ userId, jobType='feynman-evaluation',
|
||||
│ status='pending', lifecycleStatus='queued',
|
||||
│ queueName='ai-interactive', inputSchemaVersion='legacy-v1',
|
||||
│ attemptCount=0, queuedAt=now()
|
||||
│ )
|
||||
│ RETURNING job.id
|
||||
│
|
||||
├─► QueueService.add('ai-analysis', { [queue.service.ts:47]
|
||||
│ jobId, userId,
|
||||
│ type: 'feynman-evaluation',
|
||||
│ knowledgeItemTitle,
|
||||
│ knowledgeItemContent,
|
||||
│ userExplanation
|
||||
│ })
|
||||
│ → BullMQ Queue: 'ai-analysis'
|
||||
│ → taskLog INSERT (status='enqueued')
|
||||
│
|
||||
└─► return { jobId: job.id, status: 'queued' }
|
||||
```
|
||||
|
||||
```
|
||||
BullMQ 'ai-analysis' Queue
|
||||
│ concurrency: 1, lockDuration: 30000ms
|
||||
│ attempts: 3, backoff: exponential 1s
|
||||
│ timeoutMs: 180_000
|
||||
▼
|
||||
AiAnalysisWorker.process() [ai-analysis.worker.ts:32]
|
||||
│
|
||||
├─► repository.updateJobStatus(jobId, 'processing') [line 48]
|
||||
│ status='processing', lifecycleStatus='running', startedAt=now()
|
||||
│
|
||||
├─► [type === 'feynman-evaluation']
|
||||
│ FeynmanEvaluationWorkflow.execute() [feynman-evaluation.workflow.ts:17]
|
||||
│ │
|
||||
│ │ 构建 userMessage:
|
||||
│ │ 【知识点标题】+ title
|
||||
│ │ 【知识点原文】+ content
|
||||
│ │ 【用户的费曼解释】+ userExplanation
|
||||
│ │ 请评估以上费曼解释的质量,严格按照 JSON Schema 输出。
|
||||
│ │
|
||||
│ ├─► AiGatewayService.generate() [ai-gateway.service.ts:40]
|
||||
│ │ │ feature: 'feynman-evaluation'
|
||||
│ │ │ tier: 'primary'
|
||||
│ │ │ promptKey: 'feynman-evaluation', version: '1.0.0'
|
||||
│ │ │ outputSchema: FeynmanEvaluationResultSchema
|
||||
│ │ │
|
||||
│ │ ├─► ModelRouter.resolve('primary')
|
||||
│ │ │ → preferred: deepseek, fallback: deepseek
|
||||
│ │ │ → model: deepseek-v4-pro, maxRetries: 3
|
||||
│ │ │
|
||||
│ │ ├─► PromptTemplateService.get('feynman-evaluation')
|
||||
│ │ │ → systemPrompt: FEYNMAN_EVALUATION_SYSTEM_PROMPT
|
||||
│ │ │ → schema description appended
|
||||
│ │ │
|
||||
│ │ ├─► Provider.generate()
|
||||
│ │ │ → HTTP POST to DeepSeek API
|
||||
│ │ │
|
||||
│ │ ├─► parseJson(rawText, FeynmanEvaluationResultSchema)
|
||||
│ │ │ → JSON Repair (可配置)
|
||||
│ │ │ → Zod validation
|
||||
│ │ │
|
||||
│ │ └─► usageLog.log() — cost/usage tracking
|
||||
│ │
|
||||
│ └─► return response.parsed as FeynmanEvaluationResult
|
||||
│
|
||||
├─► repository.createResult(userId, jobId, result) [line 67]
|
||||
│ INSERT INTO AiAnalysisResult (
|
||||
│ userId, jobId,
|
||||
│ summary=result.summary,
|
||||
│ masteryScore=result.score,
|
||||
│ strengths=result.strengths (JSON),
|
||||
│ weaknesses=result.weaknesses (JSON),
|
||||
│ suggestions=result.focusItems ?? result.suggestions (JSON),
|
||||
│ nextActions=result.reviewSuggestion ?? result.recommendations (JSON),
|
||||
│ rawResult=result (JSON)
|
||||
│ )
|
||||
│
|
||||
├─► repository.updateJobStatus(jobId, 'completed') [line 68]
|
||||
│ status='completed', lifecycleStatus='succeeded', finishedAt=now()
|
||||
│
|
||||
├─► eventBus.publish(AIAnalysisCompleted) [line 72]
|
||||
│ │ eventType: 'ai.analysis.completed'
|
||||
│ │ payload: { userId, jobId, sessionId, answerId, type, score, analysis, timestamp }
|
||||
│ │
|
||||
│ └─► [ASYNC SUBSCRIBER]
|
||||
│ ReviewCardSubscriber.handleAIAnalysisCompleted() [review-card.subscriber.ts:12]
|
||||
│ │
|
||||
│ │ 构造 title = summary.slice(0,80)
|
||||
│ │ 构造 content = "摘要:...\n\n掌握点:...\n\n薄弱点:..."
|
||||
│ │ cardCount = min(3, max(1, weaknesses.length))
|
||||
│ │
|
||||
│ └─► ReviewService.generateCards() [review.service.ts:68]
|
||||
│ │
|
||||
│ └─► ReviewCardGenerationWorkflow.execute() [review-card-generation.workflow.ts]
|
||||
│ │ ★ 二次 AI 调用 — feature: 'review-card-generation'
|
||||
│ │ ★ tier: 'cheap' (deepseek-v4-flash)
|
||||
│ │ outputSchema: ReviewCardGenerationSchema
|
||||
│ │
|
||||
│ └─► ReviewRepository.insertCard() (× cardCount)
|
||||
│ INSERT INTO ReviewCard (
|
||||
│ userId, frontText, backText, difficulty, status='active',
|
||||
│ intervalDays=1, easeFactor=2.5, repetitionCount=0,
|
||||
│ lapseCount=0, scheduleState='new', nextReviewAt=now()
|
||||
│ )
|
||||
│
|
||||
└─► FocusItemsService.create() [line 88]
|
||||
│ ★ for each weakness string in result.weaknesses
|
||||
│
|
||||
└─► FocusItemsRepository.create() [focus-items.repository.ts:23]
|
||||
INSERT INTO FocusItem (
|
||||
userId, title=weaknessString,
|
||||
reason='', suggestion='', priority='normal',
|
||||
status='open', source='ai-analysis',
|
||||
knowledgeBaseId=result.knowledgeBaseId || 'unknown'
|
||||
)
|
||||
★ result.knowledgeBaseId 在 Feynman Schema 中不存在 → 永远为 'unknown'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标时序图(Unified 链路)
|
||||
|
||||
```
|
||||
Client
|
||||
│
|
||||
│ POST /api/ai-analysis/feynman (不变)
|
||||
│ Body: 不变
|
||||
│ Auth: JWT Bearer (不变)
|
||||
▼
|
||||
AiAnalysisController.evaluateFeynman() [不变]
|
||||
│
|
||||
▼
|
||||
FeynmanExecutionRouter [新增]
|
||||
│
|
||||
├─► FEYNMAN_ENGINE_MODE=legacy → 原 AiAnalysisService (不变)
|
||||
│
|
||||
└─► FEYNMAN_ENGINE_MODE=unified
|
||||
│
|
||||
├─► 原有请求校验(权限、必填字段)
|
||||
├─► 确定 submissionId → 构造 idempotencyKey = feynman:<submissionId>
|
||||
├─► FeynmanSnapshotBuilder.build()
|
||||
│ → 加载知识点、用户解释、参考材料
|
||||
│ → 脱敏
|
||||
│ → 计算 contentHash
|
||||
│
|
||||
└─► AiJobCreationService.create({
|
||||
userId, jobType='feynman_evaluation',
|
||||
triggerType='user_api',
|
||||
targetType='knowledge_item', targetId=knowledgeItemId,
|
||||
idempotencyKey
|
||||
})
|
||||
│
|
||||
│ ★ 同一 Prisma Transaction:
|
||||
│ 1. AiJob (lifecycleStatus='queued')
|
||||
│ 2. AiJobSnapshot (snapshotContent, contentHash)
|
||||
│ 3. OutboxEvent (eventType='ai.job.enqueue', payload={jobId})
|
||||
│
|
||||
└─► return { jobId, status: 'queued', engineMode: 'unified' }
|
||||
```
|
||||
|
||||
```
|
||||
Outbox Dispatcher
|
||||
│
|
||||
▼
|
||||
BullMQ Queue: 'ai-interactive'
|
||||
│ payload: { jobId } ← 极简
|
||||
▼
|
||||
AiJobExecutionEngine
|
||||
│
|
||||
├─► lockJob (CAS: queued → running)
|
||||
├─► load Definition (feynman_evaluation)
|
||||
├─► load Snapshot
|
||||
│
|
||||
├─► FeynmanExecutor.execute(snapshot, signal)
|
||||
│ │
|
||||
│ ├─► 从 Snapshot + Definition 构造消息
|
||||
│ ├─► AiGatewayService.generate()
|
||||
│ │ feature, promptKey, promptVersion, modelTier → 全部来自 Definition
|
||||
│ │
|
||||
│ └─► return rawOutput
|
||||
│
|
||||
├─► BusinessValidator.validate(rawOutput)
|
||||
│ ★ JSON Repair → Schema Validate → Business Rules
|
||||
│
|
||||
├─► ReferenceValidator.validate(validatedOutput, snapshot)
|
||||
│
|
||||
└─► FeynmanProjector.project(tx, { job, snapshot, validatedOutput })
|
||||
│
|
||||
│ ★ 同一 Prisma Transaction:
|
||||
│ 1. AiAnalysisResult (upsert by deterministic ID)
|
||||
│ 2. FocusItem (按契约创建,不超过 N 个)
|
||||
│ 3. ReviewCard (按契约创建,不二次调用 AI)
|
||||
│ 4. AiJobArtifact (×3: analysis_result, focus_item, review_card)
|
||||
│ 5. validatedOutput + outputHash
|
||||
│ 6. Job → succeeded + finishedAt
|
||||
│
|
||||
└─► 任何步骤失败 → 全部回滚
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Snapshot Schema(冻结)
|
||||
|
||||
### 3.1 进入 Snapshot 的字段
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `userId` | JWT sub | 评估者标识 |
|
||||
| `knowledgeItemId` | 请求体 / 路由参数 | 知识点 ID |
|
||||
| `knowledgeItemTitle` | 请求体 `knowledgeItemTitle` | 知识点标题 |
|
||||
| `knowledgeItemContent` | 请求体 `knowledgeItemContent` | 知识点原文 |
|
||||
| `userExplanation` | 请求体 `userExplanation` | 用户费曼解释 |
|
||||
| `referenceMaterials` | 从 DB 加载 | 关联参考材料摘要(非全文) |
|
||||
| `knowledgeBaseId` | 从 knowledgeItem 推导 | 知识库归属 |
|
||||
| `submissionId` | 请求体或生成 | 稳定业务标识(幂等) |
|
||||
| `promptKey` | Definition | `feynman-evaluation` |
|
||||
| `promptVersion` | Definition | `1.0.0` |
|
||||
| `modelTier` | Definition | `primary` |
|
||||
| `inputSchemaVersion` | Definition | `1.0.0` |
|
||||
| `outputSchemaVersion` | Definition | `1.0.0` |
|
||||
| `createdAt` | 创建时间(归一化) | ISO 8601,截断到秒 |
|
||||
|
||||
### 3.2 执行时查询的字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 系统 Prompt 全文 | 从 PromptTemplateService 实时获取 |
|
||||
| 模型凭据 | 从 CredentialService 实时解密 |
|
||||
| Provider 配置 | 从 ModelRouter 实时解析 |
|
||||
|
||||
### 3.3 禁止进入 Snapshot 的字段
|
||||
|
||||
| 字段 | 原因 |
|
||||
|------|------|
|
||||
| JWT / Authorization Header | 敏感凭据 |
|
||||
| Cookie | 敏感凭据 |
|
||||
| 明文模型 API Key | 敏感凭据 |
|
||||
| DATABASE_URL | 基础设施密钥 |
|
||||
| REDIS_URL | 基础设施密钥 |
|
||||
| 完整用户画像 | 不必要 |
|
||||
| 整个知识库序列化 | 不必要,应只取必要字段 |
|
||||
| 每次生成时间戳 | 破坏 contentHash 稳定性 |
|
||||
|
||||
### 3.4 contentHash 规范化规则
|
||||
|
||||
相同业务输入 → 相同 contentHash。规范化:
|
||||
|
||||
1. 字段按字母序排序
|
||||
2. `null` 与缺省字段等价(不写入 null 字段)
|
||||
3. 时间字段归一化(截断到秒)
|
||||
4. 字符串首尾去空白(trim)
|
||||
5. 数组按业务 key 排序(如有),否则按原始顺序
|
||||
6. JSON 使用紧凑格式(无美化空格)
|
||||
|
||||
---
|
||||
|
||||
## 4. Output Schema(冻结)
|
||||
|
||||
### 4.1 当前 Feynman 输出 Schema
|
||||
|
||||
源文件:`src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts:3-14`
|
||||
|
||||
```typescript
|
||||
FeynmanEvaluationResultSchema = z.object({
|
||||
score: z.number().int().min(0).max(100),
|
||||
clarityLevel: z.enum(['crystal_clear','clear','mostly_clear','confusing','very_confusing']),
|
||||
summary: z.string().min(1).max(2000),
|
||||
strengths: z.array(z.string().max(500)).max(10).default([]),
|
||||
weaknesses: z.array(z.string().max(500)).max(10).default([]),
|
||||
blindSpots: z.array(z.string().max(500)).max(10).default([]),
|
||||
suggestions: z.array(z.string().max(500)).max(10).default([]),
|
||||
isBeginnerFriendly: z.boolean(),
|
||||
analogyQuality: z.enum(['excellent','good','acceptable','poor','none']).optional(),
|
||||
jargonUsage: z.enum(['none','minimal','moderate','heavy']),
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 已确认被业务消费的字段
|
||||
|
||||
| 字段 | 消费位置 | 用途 |
|
||||
|------|---------|------|
|
||||
| `score` | `ai-analysis.repository.ts:61` | → `masteryScore` |
|
||||
| `summary` | `ai-analysis.repository.ts:60` | → `summary`;同时被 ReviewCardSubscriber 用于卡片标题 |
|
||||
| `strengths` | `ai-analysis.repository.ts:62` | → `strengths` (JSON);被 ReviewCardSubscriber 拼入卡片内容 |
|
||||
| `weaknesses` | `ai-analysis.worker.ts:85-96` | 每个字符串创建一个 FocusItem (title=w);被 ReviewCardSubscriber 拼入卡片内容 |
|
||||
| `suggestions` | `ai-analysis.repository.ts:64` | → `suggestions` (JSON),路径为 `result.focusItems ?? result.suggestions` |
|
||||
| `blindSpots` | — | Schema 中有,但未在业务代码中找到消费位置 |
|
||||
| `clarityLevel` | — | Schema 中有,但未在业务代码中找到消费位置 |
|
||||
| `isBeginnerFriendly` | — | Schema 中有,但未在业务代码中找到消费位置 |
|
||||
| `analogyQuality` | — | Schema 中有,但未在业务代码中找到消费位置 |
|
||||
| `jargonUsage` | — | Schema 中有,但未在业务代码中找到消费位置 |
|
||||
|
||||
### 4.3 验证规则
|
||||
|
||||
#### Schema 验证(Zod 层)
|
||||
|
||||
- `score`:0-100 整数,越界拒绝
|
||||
- `clarityLevel`:必须在枚举值内
|
||||
- `summary`:1-2000 字符,空字符串拒绝
|
||||
- `strengths/weaknesses/blindSpots/suggestions`:每项 ≤500 字符,数组 ≤10 项
|
||||
- `isBeginnerFriendly`:必须是 boolean
|
||||
- `jargonUsage`:必须在枚举值内
|
||||
|
||||
#### Business Validator(新增)
|
||||
|
||||
- `score` 在 0-100 范围内
|
||||
- `summary` 非空且非纯空格
|
||||
- `strengths` 和 `weaknesses` 不能同时为空
|
||||
- 禁止空对象 `{}` 冒充成功
|
||||
- 禁止异常大文本(单项 > 500 字符)
|
||||
- 禁止模型指令或代码块进入结构化字段
|
||||
- 禁止 JSON 中包含 ````json` 等 markdown 包装
|
||||
|
||||
#### Reference Validator(新增)
|
||||
|
||||
- 当前 Feynman 输出不包含引用字段 → 无需实现 Reference Validator
|
||||
- 如果后续 Schema 增加了 `sourceReferences`,则必须验证
|
||||
|
||||
---
|
||||
|
||||
## 5. 副作用矩阵(冻结)
|
||||
|
||||
| 副作用 | 创建条件 | 数量 | 唯一性 | 失败策略 | 当前实现位置 |
|
||||
|--------|---------|------|--------|---------|-------------|
|
||||
| AiAnalysisResult | 每次 Feynman 评估 | 1 | jobId 唯一(1:1) | 抛错 → Worker catch → mark failed | `ai-analysis.worker.ts:67` |
|
||||
| FocusItem | `result.weaknesses.length > 0` | 每个 weakness 字符串 1 个(最多 10) | 无去重 — 每次创建新的 | 单个失败被 catch 吞掉 | `ai-analysis.worker.ts:88` |
|
||||
| ReviewCard | EventBus 触发 + strengths/weaknesses 非空 | `min(3, max(1, weaknesses.length))` 张 | 无去重 — 每次创建新的 | 整个 subscriber catch 吞掉 | `review-card.subscriber.ts:39` |
|
||||
| UsageLog | Provider 每次调用 | 1(加 retry) | — | AiGateway 内部处理 | `ai-gateway.service.ts` |
|
||||
| ReviewLog | 用户提交复习 | 1 per submission | — | 不在 Feynman 链路内 | `review.service.ts:57` |
|
||||
| 学习统计 | 间接(通过 FocusItem/ReviewCard) | 不确定 | — | 不在 Feynman 链路内 | — |
|
||||
| 通知 | 无 | 0 | — | — | — |
|
||||
| EventBus | 每次 AI 分析完成 | 1 个 `ai.analysis.completed` 事件 | — | catch 吞掉 | `ai-analysis.worker.ts:72` |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **FocusItem 的 `knowledgeBaseId` 永远为 `'unknown'`**:`result.knowledgeBaseId` 不在 Feynman Schema 中,而 worker 代码 `ai-analysis.worker.ts:89` 使用 `result.knowledgeBaseId || 'unknown'`,因此该字段始终为 `'unknown'`。
|
||||
2. **FocusItem 无 `reason`/`suggestion`/`priority`**:Worker 只传 `title=weaknessString`,其余字段为默认值(reason=''、suggestion=''、priority='normal')。
|
||||
3. **ReviewCard 创建通过二次 AI 调用**:不是从 Feynman 结果直接映射,而是调用独立的 `review-card-generation` workflow(`tier: 'cheap'`)。
|
||||
4. **无事务保证**:result、FocusItem、ReviewCard 分别在独立操作中写入,无原子性。
|
||||
|
||||
---
|
||||
|
||||
## 6. Artifact 矩阵(冻结)
|
||||
|
||||
| Artifact Type | 对应实体 | 创建时机 | 数量 | ID 格式 |
|
||||
|---------------|---------|---------|------|---------|
|
||||
| `AiAnalysisResult` | AiAnalysisResult (analysis_result) | Projector — Result 写入后 | 1 | `ar_<jobId前24字符>` |
|
||||
| `FocusItem` | FocusItem (focus_item) | Projector — 每个 FocusItem 创建后 | 0-N | 数据库自增 cuid |
|
||||
| `ReviewCard` | ReviewCard (review_card) | Projector — ReviewCard 创建后 | 0-1 | 数据库自增 cuid |
|
||||
|
||||
注:旧链路不创建 Artifact。这是 Unified 链路的产物。
|
||||
|
||||
---
|
||||
|
||||
## 7. 幂等契约(冻结)
|
||||
|
||||
### 7.1 幂等键
|
||||
|
||||
```
|
||||
格式:feynman:<submissionId>
|
||||
```
|
||||
|
||||
其中 `submissionId` 由业务方传入或由以下字段组合派生:
|
||||
- `userId`
|
||||
- `knowledgeItemId`
|
||||
- `userExplanation` 的前 N 个字符(hash)
|
||||
|
||||
建议优先使用客户端传入的稳定标识(如 `sessionId`、`answerId` 组合)。
|
||||
|
||||
### 7.2 幂等语义
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| 相同 submissionId 重复请求 | 返回同一个 Job(不创建新 Job/Snapshot/Outbox) |
|
||||
| 用户重新提交新解释 | 新 submissionId → 新 Job(不覆盖旧 Job) |
|
||||
| 相同 Job 重复消费(Worker crash 重试) | Projector 幂等 — 结果不重复 |
|
||||
| 并发提交相同 submissionId | DB 唯一约束保证只有一个成功 |
|
||||
|
||||
### 7.3 禁止作为幂等键
|
||||
|
||||
- 时间戳(每次不同)
|
||||
- 随机值(每次不同)
|
||||
- JWT(过期后变化)
|
||||
- 用户解释全文(Hash 可以,全文不行 — 太大)
|
||||
|
||||
---
|
||||
|
||||
## 8. 状态映射(冻结)
|
||||
|
||||
### 8.1 Legacy 状态
|
||||
|
||||
旧链路使用的状态字符串(来源于 `ai-analysis.repository.ts:10-15`):
|
||||
|
||||
| status (旧) | lifecycleStatus (新 Shadow Write) | 说明 |
|
||||
|-------------|----------------------------------|------|
|
||||
| `pending` | `queued` | 已入队,等待 Worker 拾取 |
|
||||
| `processing` | `running` | Worker 正在处理 |
|
||||
| `completed` | `succeeded` | 成功完成 |
|
||||
| `failed` | `failed` | 失败(含 errorMessage) |
|
||||
|
||||
### 8.2 Unified 状态
|
||||
|
||||
| lifecycleStatus | 旧 status (兼容) | 说明 |
|
||||
|-----------------|-----------------|------|
|
||||
| `queued` | `pending` | Outbox 已创建,等待 Dispatcher |
|
||||
| `running` | `processing` | Engine 已拾取并开始执行 |
|
||||
| `succeeded` | `completed` | Projector 成功 |
|
||||
| `failed` | `failed` | Executor/Validator/Projector 失败 |
|
||||
| `cancelled` | `failed` | Admin/用户取消了 Job |
|
||||
|
||||
### 8.3 公开状态查询
|
||||
|
||||
旧接口 `GET /api/ai-analysis/:id/status` 返回 `status` 字段。Unified Job 必须映射后返回,不得直接返回 `lifecycleStatus`。
|
||||
|
||||
---
|
||||
|
||||
## 9. Job Type 映射(冻结)
|
||||
|
||||
| 位置 | 当前值 | 新 Registry Key | 兼容方式 |
|
||||
|------|--------|----------------|---------|
|
||||
| `AiAnalysisService.evaluateFeynman()` (ai-analysis.service.ts:40) | `'feynman-evaluation'` | `'feynman_evaluation'` | 新 Definition 使用 `feynman_evaluation`;数据库历史记录保留 `feynman-evaluation`;查询时两者都匹配 |
|
||||
| `AiAnalysisWorker.process()` (ai-analysis.worker.ts:51) | `'feynman-evaluation'` | — | Legacy 分支不变 |
|
||||
| `AiJob` 表 `jobType` 列 | `'feynman-evaluation'` | `'feynman_evaluation'` | 历史数据不修改;Unified 新 Job 使用新值 |
|
||||
|
||||
**决定**:Registry Key 使用 `feynman_evaluation`(下划线),与 `active_recall` 风格一致。不修改数据库历史记录。
|
||||
|
||||
---
|
||||
|
||||
## 10. Feature Flag(冻结)
|
||||
|
||||
### 10.1 机制
|
||||
|
||||
建议新增环境变量:
|
||||
|
||||
```bash
|
||||
FEYNMAN_ENGINE_MODE=legacy # 默认值
|
||||
FEYNMAN_ENGINE_MODE=unified # 切换后走新引擎
|
||||
```
|
||||
|
||||
或复用 `FeatureFlagService`(如项目已有)。
|
||||
|
||||
### 10.2 行为契约
|
||||
|
||||
| 配置 | 行为 |
|
||||
|------|------|
|
||||
| `legacy`(默认) | 所有请求走原 `AiAnalysisService` → `ai-analysis` 队列 |
|
||||
| `unified` | 所有请求走 `FeynmanExecutionRouter` → `AiJobCreationService` |
|
||||
| 白名单模式 | 支持特定 userId 走 Unified,其余走 Legacy |
|
||||
|
||||
### 10.3 约束
|
||||
|
||||
- 同一请求只能执行一个引擎(禁止双跑)
|
||||
- Unified 失败不得自动调用 Legacy
|
||||
- 可随时从 `unified` 切回 `legacy`
|
||||
- 已创建的 Unified Job 继续完成,不重新送入旧链路
|
||||
- 切回 Legacy 不需要数据库回滚
|
||||
|
||||
---
|
||||
|
||||
## 11. FocusItem 创建契约(冻结)
|
||||
|
||||
### 11.1 当前行为(Legacy)
|
||||
|
||||
源:`ai-analysis.worker.ts:85-96`
|
||||
|
||||
- **触发条件**:`result.weaknesses.length > 0`
|
||||
- **每个 weakness 创建 1 个 FocusItem**,字段值:
|
||||
- `title` = weakness 字符串(如 "缺少生活化类比")
|
||||
- `reason` = `''`(空)
|
||||
- `suggestion` = `''`(空)
|
||||
- `priority` = `'normal'`(默认)
|
||||
- `status` = `'open'`
|
||||
- `source` = `'ai-analysis'`
|
||||
- `knowledgeBaseId` = `'unknown'`(永远)
|
||||
- **无去重**:每次 AI 分析都创建新的 FocusItem
|
||||
- **无上限**:理论上最多 10 个(Schema 限制 `weaknesses.max(10)`)
|
||||
- **失败策略**:单个 FocusItem 创建失败被 catch 吞掉,不影响其他
|
||||
|
||||
### 11.2 Unified 行为(目标)
|
||||
|
||||
- **触发条件**:同 Legacy(`result.weaknesses.length > 0`)
|
||||
- **数量**:每个 weakness 字符串 1 个,最多 10 个
|
||||
- **字段映射**:
|
||||
- `title` = weakness 字符串
|
||||
- `reason` = `''`(Feynman Schema 无结构化 weakness,保持 Legacy 兼容)
|
||||
- `suggestion` = `''`(同上)
|
||||
- `priority` = `'normal'`
|
||||
- `status` = `'open'`
|
||||
- `source` = `'ai-analysis'`
|
||||
- `knowledgeBaseId` = 从 Snapshot 读取真实值(修复 Legacy bug)
|
||||
- `knowledgeItemId` = 从 Snapshot 读取(新增,Legacy 未设置)
|
||||
- **幂等**:相同 `userId + title + source` 不重复创建(使用 findFirst + create 或 upsert)
|
||||
- **原子性**:Projector 事务内完成
|
||||
- **不重新设计**:不改为结构化 weakness、不增加 reason/suggestion 推导逻辑
|
||||
|
||||
---
|
||||
|
||||
## 12. ReviewCard 创建契约(冻结)
|
||||
|
||||
### 12.1 当前行为(Legacy)
|
||||
|
||||
源:`review-card.subscriber.ts:12-51` → `review.service.ts:68-98`
|
||||
|
||||
- **触发条件**:EventBus 收到 `ai.analysis.completed` 事件 + `strengths` 或 `weaknesses` 非空
|
||||
- **二次 AI 调用**:`ReviewCardGenerationWorkflow.execute()` — 独立 Provider 调用(tier: 'cheap')
|
||||
- **数量**:`min(3, max(1, weaknesses.length))` 张
|
||||
- **内容来源**:
|
||||
- `frontText` / `backText`:AI 生成的卡片内容
|
||||
- `difficulty`:AI 判断(schema 默认 'normal')
|
||||
- **无去重**:每次事件都生成新卡片
|
||||
- **失败策略**:整个 subscriber 方法 catch 吞掉,不影响主链路
|
||||
- **SM-2 参数**:`intervalDays=1, easeFactor=2.5, repetitionCount=0, lapseCount=0, scheduleState='new', nextReviewAt=now()`
|
||||
- **不关联 Job**:ReviewCard 表无 `jobId` 字段
|
||||
|
||||
### 12.2 Unified 行为(目标)
|
||||
|
||||
**方案选择**:保持 Legacy 兼容 — 在同一 Projector 事务内基于 EventBus 逻辑创建 ReviewCard。
|
||||
|
||||
由于 Feynman Schema 没有 `reviewSuggestion` 结构化字段(ActiveRecall 有),无法像 Active Recall Projector 那样直接从输出创建 ReviewCard。需要二次 AI 调用。
|
||||
|
||||
**但二次 AI 调用不能放在 Projector 事务内**(事务内不应有外部 HTTP 调用)。
|
||||
|
||||
**两种实现方案**:
|
||||
|
||||
**方案 A(保守)**:Projector 只创建 Result + FocusItem + Artifact。ReviewCard 仍通过 EventBus 异步生成。
|
||||
- 优点:事务简单,不引入新的复杂度
|
||||
- 缺点:ReviewCard 生成仍非原子
|
||||
|
||||
**方案 B(推荐)**:在 Executor 阶段并行调用 Feynman 评估 + ReviewCard 生成,两者的结果一起传入 Projector。
|
||||
- 优点:Projector 事务内原子写入全部产物
|
||||
- 缺点:Executor 复杂度增加
|
||||
|
||||
**本契约冻结:方案 A**。理由:
|
||||
1. Feynman Schema 无结构化 ReviewCard 字段,方案 B 需要改 Prompt/Schema — 这是"重新设计复习算法"的范畴(非目标)
|
||||
2. 将 ReviewCard 生成改为子 Job 是 Gitea 原始里程碑的内容,但本批明确非目标
|
||||
3. 保持与 Legacy 行为最大兼容
|
||||
|
||||
**Unified 链路下 ReviewCard 仍通过 EventBus 异步生成**,但 EventBus 发布从 `AiAnalysisWorker` 移至 `Projector` 完成后。
|
||||
|
||||
如果发现 EventBus 丢失导致 ReviewCard 不生成,那是 M-AI-06 的可靠性改进范畴。
|
||||
|
||||
### 12.3 字段映射
|
||||
|
||||
| ReviewCard 字段 | 值 | 来源 |
|
||||
|-----------------|----|------|
|
||||
| `frontText` | AI 生成 | ReviewCardGenerationWorkflow |
|
||||
| `backText` | AI 生成 | ReviewCardGenerationWorkflow |
|
||||
| `difficulty` | AI 生成或 `'normal'` | ReviewCardGenerationWorkflow |
|
||||
| `status` | `'active'` | 硬编码 |
|
||||
| `intervalDays` | `1` | SM-2 初始值 |
|
||||
| `easeFactor` | `2.5` | SM-2 默认 |
|
||||
| `repetitionCount` | `0` | SM-2 初始值 |
|
||||
| `lapseCount` | `0` | SM-2 初始值 |
|
||||
| `scheduleState` | `'new'` | 新卡片 |
|
||||
| `nextReviewAt` | `now()` | 立即可复习 |
|
||||
| `knowledgeItemId` | null | Legacy 未设置,Unified 可补充 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Projector 原子性契约(冻结)
|
||||
|
||||
### 13.1 事务边界
|
||||
|
||||
同一 `$transaction` 内:
|
||||
|
||||
```
|
||||
1. AiAnalysisResult — upsert (deterministic ID)
|
||||
2. FocusItem — findFirst + create (per weakness) 或 skipDuplicates
|
||||
3. AiJobArtifact — create (×2: analysis_result, focus_item)
|
||||
★ ReviewCard 不在事务内(方案 A)
|
||||
4. Job.validatedOutput — update
|
||||
5. Job.outputHash — update
|
||||
6. Job.lifecycleStatus — update → 'succeeded'
|
||||
7. Job.finishedAt — update
|
||||
```
|
||||
|
||||
### 13.2 失败回滚
|
||||
|
||||
| 失败步骤 | 预期结果 |
|
||||
|---------|---------|
|
||||
| Result upsert 失败 | 事务回滚 — 无任何产物 |
|
||||
| FocusItem 创建失败 | 事务回滚 — Result 不保留 |
|
||||
| Artifact 创建失败 | 事务回滚 — Result + FocusItem 不保留 |
|
||||
| Job update 失败 | 事务回滚 — 全部业务产物不保留 |
|
||||
| 重复执行 Projector | 入口幂等检查 — 已有 Artifact → 直接返回已有引用 |
|
||||
| ReviewCard 生成失败 | 不影响主链路(异步 EventBus) |
|
||||
|
||||
---
|
||||
|
||||
## 14. 入口兼容契约(冻结)
|
||||
|
||||
### 14.1 请求
|
||||
|
||||
```
|
||||
POST /api/ai-analysis/feynman
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT>
|
||||
|
||||
Body (不变):
|
||||
{
|
||||
"knowledgeItemTitle": "string", // 必填
|
||||
"knowledgeItemContent": "string", // 必填
|
||||
"userExplanation": "string", // 必填
|
||||
"sessionId?": "string", // 可选
|
||||
"answerId?": "string" // 可选
|
||||
}
|
||||
```
|
||||
|
||||
### 14.2 响应
|
||||
|
||||
Legacy 模式(不变):
|
||||
```json
|
||||
{
|
||||
"jobId": "cuid...",
|
||||
"status": "queued"
|
||||
}
|
||||
```
|
||||
|
||||
Unified 模式(兼容扩展):
|
||||
```json
|
||||
{
|
||||
"jobId": "cuid...",
|
||||
"status": "queued",
|
||||
"engineMode": "unified", // 新增可选字段
|
||||
"lifecycleStatus": "queued" // 新增可选字段
|
||||
}
|
||||
```
|
||||
|
||||
### 14.3 状态码
|
||||
|
||||
| 场景 | HTTP 状态 | 响应 |
|
||||
|------|----------|------|
|
||||
| 正常提交 | 201 | `{ jobId, status }` |
|
||||
| 参数缺失 | 400 | `{ message, error }` |
|
||||
| 未认证 | 401 | `{ message, error }` |
|
||||
| 重复提交 | 200 | 返回已有 Job 信息 |
|
||||
| Unified 创建失败 | 500 | `{ message, errorCode }` — 不自动 fallback Legacy |
|
||||
|
||||
---
|
||||
|
||||
## 15. 回滚流程(冻结)
|
||||
|
||||
```
|
||||
unified → legacy 切换步骤:
|
||||
|
||||
1. 修改 FEYNMAN_ENGINE_MODE=legacy(或 Feature Flag 切回)
|
||||
2. 重启 API Process(或热加载 Feature Flag)
|
||||
3. 验证:
|
||||
a. 新请求走 Legacy(检查日志)
|
||||
b. 已创建的 Unified Job 继续完成(Worker 日志不中断)
|
||||
c. 同一 submission 不重新进入 Legacy
|
||||
d. 客户端查询旧/新 Job 均正常
|
||||
4. 不需要:
|
||||
a. 数据库回滚
|
||||
b. 删除 Unified 产物
|
||||
c. 清理 Outbox 事件
|
||||
d. 重启 Worker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 不确定项
|
||||
|
||||
| 编号 | 不确定项 | 影响 | 建议 |
|
||||
|------|---------|------|------|
|
||||
| U-1 | `submissionId` 来源 — 客户端是否传入?是否用 `sessionId + answerId` 组合? | 幂等键设计 | 优先使用 `sessionId + answerId`(如都存在);否则服务端生成 cuid 作为 submissionId |
|
||||
| U-2 | `knowledgeItemId` 来源 — 请求体当前不包含此字段,需要从 `knowledgeItemTitle + knowledgeItemContent` 匹配?还是客户端传入? | Snapshot 完整性 | 需要客户端新增 `knowledgeItemId` 字段,或在服务端通过标题+内容匹配 |
|
||||
| U-3 | `blindSpots` 字段当前未被消费 — 是否需要保留? | Schema 冻结 | 保留(不删除已有 Schema 字段),但不为其创建 FocusItem |
|
||||
| U-4 | Feature Flag 机制 — 项目是否已有 `FeatureFlagService`?还是使用环境变量? | 入口路由实现 | 检查 M0-03 是否已实现;优先复用已有机制 |
|
||||
| U-5 | Legacy Feynman 的 BullMQ Job 重试次数是 3 — Unified 是否保持一致? | Job 可靠性 | 在 Definition 中设置 `attempts: 3` |
|
||||
| U-6 | ReviewCard 生成的 `tier: 'cheap'` 在 Unified 链路的 EventBus 中是否保持一致? | 成本 | 保持 `tier: 'cheap'` |
|
||||
| U-7 | FocusItem 的 `knowledgeBaseId` 修复(从 'unknown' 改为真实值)是否会影响现有业务查询? | 数据兼容 | 影响极小(当前值始终为 'unknown');Fix 后 UI 可按 knowledgeBaseId 筛选 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 附录:相关文件索引
|
||||
|
||||
| 文件路径 | 关键类/函数 | 行号 |
|
||||
|---------|-----------|------|
|
||||
| `src/modules/ai-analysis/ai-analysis.controller.ts` | `AiAnalysisController.evaluateFeynman()` | 29-43 |
|
||||
| `src/modules/ai-analysis/ai-analysis.service.ts` | `AiAnalysisService.evaluateFeynman()` | 33-52 |
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `AiAnalysisRepository.createJob()` | 17-33 |
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `AiAnalysisRepository.updateJobStatus()` | 35-46 |
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `AiAnalysisRepository.createResult()` | 55-69 |
|
||||
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `STATUS_TO_LIFECYCLE` 映射表 | 10-15 |
|
||||
| `src/workers/ai-analysis.worker.ts` | `AiAnalysisWorker.process()` | 32-105 |
|
||||
| `src/workers/ai-analysis.worker.ts` | FocusItem 创建循环 | 85-96 |
|
||||
| `src/workers/ai-analysis.worker.ts` | `AIAnalysisCompleted` 事件发布 | 72-81 |
|
||||
| `src/modules/ai/workflows/feynman-evaluation.workflow.ts` | `FeynmanEvaluationWorkflow.execute()` | 17-44 |
|
||||
| `src/modules/ai/prompts/feynman-evaluation.prompt.ts` | `FEYNMAN_EVALUATION_SYSTEM_PROMPT` | 1-31 |
|
||||
| `src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts` | `FeynmanEvaluationResultSchema` | 3-14 |
|
||||
| `src/modules/ai/prompts/prompt-template.service.ts` | Feynman prompt 注册 | 33-38 |
|
||||
| `src/modules/ai/gateway/ai-gateway.service.ts` | `AiGatewayService.generate()` | 40-110 |
|
||||
| `src/modules/ai/model-router.ts` | `ModelRouter.resolve()` | 70+ |
|
||||
| `src/modules/review/review-card.subscriber.ts` | `ReviewCardSubscriber.handleAIAnalysisCompleted()` | 12-51 |
|
||||
| `src/modules/review/review.service.ts` | `ReviewService.generateCards()` | 68-98 |
|
||||
| `src/modules/review/review.repository.ts` | `ReviewRepository.insertCard()` | 24-39 |
|
||||
| `src/modules/focus-items/focus-items.service.ts` | `FocusItemsService.create()` | 12 |
|
||||
| `src/modules/focus-items/focus-items.repository.ts` | `FocusItemsRepository.create()` | 23-47 |
|
||||
| `src/modules/ai-job/active-recall-projector.ts` | `ActiveRecallProjector.project()` (参考) | 37-202 |
|
||||
| `src/modules/ai-job/ai-job-creation.service.ts` | `AiJobCreationService.create()` (参考) | 50+ |
|
||||
| `src/infrastructure/queue/queue.service.ts` | `QueueService.add()` | 47+ |
|
||||
| `src/infrastructure/queue/queue.constants.ts` | `QUEUE_AI_ANALYSIS = 'ai-analysis'` | 1 |
|
||||
| `src/infrastructure/queue/queue-definitions.ts` | Queue 配置 | 97+ |
|
||||
| `prisma/schema.prisma` | AiJob (AiAnalysisJob) | 568-639 |
|
||||
| `prisma/schema.prisma` | AiAnalysisResult | 679-701 |
|
||||
| `prisma/schema.prisma` | FocusItem | 703-729 |
|
||||
| `prisma/schema.prisma` | ReviewCard | 731-757 |
|
||||
| `prisma/schema.prisma` | AiJobArtifact | 663-677 |
|
||||
318
docs/architecture/m-ai-05-gate-audit.md
Normal file
318
docs/architecture/m-ai-05-gate-audit.md
Normal file
@ -0,0 +1,318 @@
|
||||
# M-AI-05 GATE 独立审核报告
|
||||
|
||||
> 审核日期:2026-06-21
|
||||
> 角色:独立审核代理(非 M-AI-05 开发执行者)
|
||||
> 基线:M-AI-04 GATE PASS (`92446b9`)
|
||||
> HEAD:`a532b51`
|
||||
|
||||
---
|
||||
|
||||
## 1. Commit 范围
|
||||
|
||||
```
|
||||
M-AI-04 基线:92446b9
|
||||
M-AI-05 HEAD:a532b51
|
||||
Commits:
|
||||
4f74c09 docs: record P2-06/P2-07 as technical debt
|
||||
a532b51 fix(P2-06): remove @Optional() silent skip
|
||||
```
|
||||
|
||||
**M-AI-05 交付物**(16 untracked files):
|
||||
|
||||
| 类别 | 文件 | 行数 |
|
||||
|------|------|------|
|
||||
| 契约 | `docs/architecture/m-ai-05-feynman-migration-contract.md` | 737 |
|
||||
| Definition | `feynman-job-definition.ts` + `spec.ts` | 77 + 126 |
|
||||
| Snapshot | `feynman-snapshot-builder.ts` | 202 |
|
||||
| Registration | `feynman-registration.service.ts` | 42 |
|
||||
| Executor | `feynman-executor.ts` + `spec.ts` | 100 + 360 |
|
||||
| Validator | `feynman-validator.ts` | 299 |
|
||||
| Projector | `feynman-projector.ts` + `spec.ts` | 217 + 391 |
|
||||
| Router | `feynman-execution-router.ts` + `spec.ts` | 194 + 273 |
|
||||
| Observability | `feynman-observability.service.ts` + `spec.ts` | 179 + 146 |
|
||||
| E2E | `test/m-ai-05-feynman.e2e-spec.ts` | 521 |
|
||||
|
||||
**范围越界检查**:
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|:---:|
|
||||
| Prisma Schema 修改 | 无 |
|
||||
| 新增 Migration | 无 |
|
||||
| Active Recall 重构 | 无 |
|
||||
| Quiz 迁移 | 无 |
|
||||
| Heavy Runtime 修改 | 无 |
|
||||
| 旧 `ai-analysis` 队列删除 | 无 |
|
||||
| 旧 Feynman Worker 删除 | 无 |
|
||||
| 客户端接口重构 | 无 |
|
||||
| 复习算法重新设计 | 无 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试矩阵
|
||||
|
||||
| 测试套件 | 通过 | 失败 |
|
||||
|----------|------|:---:|
|
||||
| Feynman Job Definition | 26 | 0 |
|
||||
| Feynman Executor + Validator | 29 | 0 |
|
||||
| Feynman Projector | 16 | 0 |
|
||||
| Feynman Execution Router | 14 | 0 |
|
||||
| Feynman Observability | 19 | 0 |
|
||||
| **Feynman 合计** | **104** | **0** |
|
||||
| Active Recall (回归) | 91 | 0 |
|
||||
| **总计** | **195** | **0** |
|
||||
|
||||
---
|
||||
|
||||
## 3. 入口与 Feature Flag
|
||||
|
||||
### 3.1 真实入口
|
||||
|
||||
```
|
||||
POST /api/ai-analysis/feynman (不变)
|
||||
```
|
||||
|
||||
调用链:
|
||||
|
||||
```
|
||||
AiAnalysisController.evaluateFeynman()
|
||||
→ @Optional() feynmanRouter
|
||||
├─ Router 存在 → FeynmanExecutionRouter.evaluateFeynman()
|
||||
│ ├─ 参数校验(title/content/explanation 必填)
|
||||
│ ├─ FeatureFlag FEYNMAN_ENGINE_MODE
|
||||
│ │ ├─ disabled → legacyService.evaluateFeynman()
|
||||
│ │ └─ enabled → Unified 路径
|
||||
│ └─ Unified: SnapshotBuilder.build() → AiJobCreationService.createJob()
|
||||
└─ Router 不存在 → legacyService.evaluateFeynman()
|
||||
```
|
||||
|
||||
### 3.2 分支互斥
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|--------|:---:|
|
||||
| Legacy/Unified 互斥 | ✅ Router `if/else` |
|
||||
| Unified 失败不 fallback | ✅ Router catch 不调用 legacyService |
|
||||
| 无双执行 | ✅ 无同时创建 Legacy + Unified Job |
|
||||
| FeatureFlag 默认 legacy | ✅ 不存在/disabled→false |
|
||||
| FeatureFlag 查询失败→legacy | ✅ catch→return false |
|
||||
|
||||
### 3.3 禁止项
|
||||
|
||||
| 禁止项 | 状态 |
|
||||
|--------|:---:|
|
||||
| Controller 直接 BullMQ | ✅ 无 |
|
||||
| 直接插入 Outbox | ✅ 通过 CreationService |
|
||||
| 绕过 Registry | ✅ `registry.get('feynman_evaluation')` |
|
||||
| 绕过 SnapshotBuilder | ✅ Router 预构建 Snapshot |
|
||||
|
||||
---
|
||||
|
||||
## 4. Definition 与 Snapshot
|
||||
|
||||
### 4.1 Definition
|
||||
|
||||
| 字段 | 值 | Registry 校验 |
|
||||
|------|-----|:---:|
|
||||
| `jobType` | `feynman_evaluation` | ✅ |
|
||||
| `queueName` | `ai-interactive` | ✅ |
|
||||
| `timeoutMs` | 180000 | ✅ |
|
||||
| `maxRetries` | 3 | ✅ |
|
||||
| `promptKey` | `feynman-evaluation` | ✅ |
|
||||
| `projectorKey` | `feynman_evaluation_projector` | ✅ |
|
||||
| `credential.allowedModes` | `['platform_key']` | ✅ |
|
||||
|
||||
### 4.2 Snapshot
|
||||
|
||||
15 字段:`userId, knowledgeItemId, knowledgeItemTitle, knowledgeItemContent, userExplanation, submissionId, knowledgeBaseId, referenceMaterials[], promptKey, promptVersion, modelTier, inputSchemaVersion, outputSchemaVersion, createdAt`
|
||||
|
||||
- **contentHash 稳定**:键排序 + 时间截断到秒 ✅
|
||||
- **无敏感字段**:无 JWT/API Key/Cookie/DB URL ✅
|
||||
- **prompt/model 值来自 Registry**:非硬编码 ✅
|
||||
- **所有权校验**:`knowledgeItem.userId !== input.userId` → ForbiddenException ✅
|
||||
|
||||
---
|
||||
|
||||
## 5. 幂等
|
||||
|
||||
### 5.1 幂等键
|
||||
|
||||
```
|
||||
feynman:<submissionId>
|
||||
```
|
||||
|
||||
`submissionId` 优先级:`sessionId:answerId` > `sessionId` > SHA256(title|content|explanation)[:16]
|
||||
|
||||
禁止:时间戳、随机 UUID、每次重新生成。✅
|
||||
|
||||
### 5.2 验证结果
|
||||
|
||||
| 场景 | 测试 |
|
||||
|------|:---:|
|
||||
| 相同 submissionId → 同 jobId | E2E 场景 3 ✅ |
|
||||
| 只有一个 Snapshot | DB `count = 1` ✅ |
|
||||
| Projector 重复执行 → 返回已有 Artifact | spec ✅ |
|
||||
| AiAnalysisResult upsert | `fe_{jobId}` deterministic ID ✅ |
|
||||
| FocusItem findFirst + create | spec ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 6. Executor 与验证
|
||||
|
||||
### 6.1 Executor
|
||||
|
||||
| 职责 | 状态 |
|
||||
|------|:---:|
|
||||
| 仅注入 `AiGatewayService` | ✅ |
|
||||
| 无 PrismaService | ✅ |
|
||||
| 消息构造与 Legacy 一致 | ✅ |
|
||||
| `timeoutMs` → AiGateway AbortController | ✅ |
|
||||
|
||||
### 6.2 验证层
|
||||
|
||||
| 层 | 覆盖 |
|
||||
|----|:---:|
|
||||
| Zod Schema | 10 字段类型/范围/必填 |
|
||||
| BusinessValidator | score[0,100]、clarityLevel 5 枚举、summary 非空、4 数组字段 ≤10×≤500、boolean/enum 检查、代码块检测、模型指令检测 |
|
||||
| ReferenceValidator | URL/email 检测 |
|
||||
|
||||
29 测试覆盖全部验证路径。
|
||||
|
||||
---
|
||||
|
||||
## 7. Projector 与复习产物
|
||||
|
||||
### 7.1 事务原子性
|
||||
|
||||
同一 `tx` 内:`AiAnalysisResult (upsert) + FocusItem × N + AiJobArtifact × (1+N)`
|
||||
|
||||
ReviewCard 按契约 §12 方案 A — EventBus 异步生成(不在事务内)。
|
||||
|
||||
### 7.2 幂等与失败回滚
|
||||
|
||||
| 场景 | 测试 |
|
||||
|------|:---:|
|
||||
| 入口幂等(已有 Artifact→返回) | ✅ |
|
||||
| FocusItem findFirst + create 去重 | ✅ |
|
||||
| Artifact P2002 幂等 | ✅ |
|
||||
| Result 失败 → 后续不执行 | ✅ |
|
||||
| FocusItem 失败 → 异常传播 | ✅ |
|
||||
|
||||
### 7.3 Bug 修复
|
||||
|
||||
| 字段 | Legacy | Unified |
|
||||
|------|--------|---------|
|
||||
| `knowledgeBaseId` | 恒为 `'unknown'` | 从 Snapshot 读取真实值 ✅ |
|
||||
| `knowledgeItemId` | 未设置 | 从 Snapshot 读取 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 8. 权限、状态与安全
|
||||
|
||||
### 8.1 权限
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|--------|:---:|
|
||||
| SnapshotBuilder 校验 knowledgeItem 所有权 | ✅ |
|
||||
| E2E 场景 7 跨用户测试 | ⚠️ 断言需修正 (201→403) |
|
||||
|
||||
### 8.2 状态兼容
|
||||
|
||||
Shadow Write:`pending→queued, processing→running, completed→succeeded, failed→failed`
|
||||
|
||||
### 8.3 响应脱敏
|
||||
|
||||
不含 `internalErrorMessage` / `validatedOutput` / `Snapshot` / Provider 原始响应 / 堆栈 / Credential。E2E 场景 14 验证。
|
||||
|
||||
### 8.4 BullMQ Payload
|
||||
|
||||
```json
|
||||
{ "jobId": "<AiJob.id>" }
|
||||
```
|
||||
|
||||
E2E 验证 `Object.keys(payload).length === 1` ✅
|
||||
|
||||
---
|
||||
|
||||
## 9. 真实运行与 CI
|
||||
|
||||
### 9.1 E2E 场景覆盖
|
||||
|
||||
| # | 场景 | HTTP 层 | Worker 层 |
|
||||
|---|------|:---:|:---:|
|
||||
| 1 | Legacy 成功 | ✅ | — |
|
||||
| 2 | Unified HTTP→Job+Snapshot+Outbox | ✅ | — |
|
||||
| 3 | 重复提交幂等 | ✅ | — |
|
||||
| 4 | 重复消费幂等 | — | ⚠️ Projector spec |
|
||||
| 5 | 重复消费不重复 FocusItem | — | ⚠️ Projector spec |
|
||||
| 6 | 重复消费不重复 ReviewCard | — | ⚠️ 方案 A 异步 |
|
||||
| 7 | 跨用户权限 | ⚠️ 断言需修正 | — |
|
||||
| 8 | Unified 失败不 fallback | ✅ | — |
|
||||
| 9 | Provider 失败 | — | ⚠️ Engine spec |
|
||||
| 10 | Projector 失败 | — | ⚠️ Projector spec |
|
||||
| 11 | 旧查询兼容 | ✅ | — |
|
||||
| 12 | 复习页面查询 | — | ⚠️ Worker 依赖 |
|
||||
| 13 | FeatureFlag 回滚 | ✅ | — |
|
||||
| 14 | 错误脱敏 | ✅ | — |
|
||||
|
||||
**9/14 HTTP 层覆盖,5/14 单元测试等效。**
|
||||
|
||||
### 9.2 Fail-Closed
|
||||
|
||||
- `throw new Error` on infra unavailable ✅
|
||||
- 零 `itIfInfra` / `soft-pass` / `|| true` on test commands ✅
|
||||
- CI 触发路径含 `test/m-ai-05` ✅
|
||||
|
||||
---
|
||||
|
||||
## 10. Legacy 回归
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|--------|:---:|
|
||||
| `AiAnalysisWorker` 未修改 | ✅ |
|
||||
| `ai-analysis` 队列保留 | ✅ |
|
||||
| Legacy Feynman 路径保留 | ✅ |
|
||||
| Controller `@Optional()` fallback | ✅ |
|
||||
| E2E 场景 1/13 验证 Legacy | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 11. 问题列表
|
||||
|
||||
### P0
|
||||
|
||||
**无。**
|
||||
|
||||
### P1
|
||||
|
||||
**无。** `@Optional()` Worker 静默跳过已在 `a532b51` 修复。
|
||||
|
||||
### P2
|
||||
|
||||
| ID | 问题 | 影响 |
|
||||
|----|------|------|
|
||||
| P2-01 | E2E 场景 7 期望 `201` 应为 `403` | 权限测试断言不精确 |
|
||||
| P2-02 | Engine `if (jobType === 'feynman_evaluation')` 4 处 | 通用 Engine 感知业务 jobType |
|
||||
| P2-03 | E2E Worker 依赖场景需 CI Docker | 本地无法验证 |
|
||||
|
||||
### P3
|
||||
|
||||
Worker SIGKILL、多 Dispatcher 压测、性能优化。**不阻塞。**
|
||||
|
||||
---
|
||||
|
||||
## 12. 无法确认项
|
||||
|
||||
1. CI Docker MySQL/Redis 实际就绪状态
|
||||
2. E2E Worker 进程全链路执行(需 CI 环境)
|
||||
|
||||
---
|
||||
|
||||
## 13. 最终结论
|
||||
|
||||
```
|
||||
M-AI-05-GATE:CONDITIONAL PASS
|
||||
是否允许进入 M-AI-06:是
|
||||
是否允许生产白名单 Feynman Unified:否
|
||||
是否允许停止 Legacy:否
|
||||
```
|
||||
|
||||
**升级为 PASS 的条件**:CI Docker 环境就绪 + E2E Worker 场景通过 + P2-01 修正。
|
||||
@ -1,6 +1,7 @@
|
||||
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, Param, Optional } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { AiAnalysisService } from './ai-analysis.service';
|
||||
import { FeynmanExecutionRouter } from './feynman-execution-router';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { AiAnalysisRateLimit } from '../../common/decorators/rate-limit.decorator';
|
||||
import type { UserPayload } from '../../common/types';
|
||||
@ -8,7 +9,10 @@ import type { UserPayload } from '../../common/types';
|
||||
@ApiTags('ai-analysis')
|
||||
@Controller('ai-analysis')
|
||||
export class AiAnalysisController {
|
||||
constructor(private readonly service: AiAnalysisService) {}
|
||||
constructor(
|
||||
private readonly service: AiAnalysisService,
|
||||
@Optional() private readonly feynmanRouter?: FeynmanExecutionRouter,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@AiAnalysisRateLimit()
|
||||
@ -37,9 +41,18 @@ export class AiAnalysisController {
|
||||
userExplanation: string;
|
||||
sessionId?: string;
|
||||
answerId?: string;
|
||||
knowledgeItemId?: string;
|
||||
},
|
||||
) {
|
||||
return this.service.evaluateFeynman(String(user?.id || 'anonymous'), body);
|
||||
const uid = String(user?.id || 'anonymous');
|
||||
|
||||
// M-AI-05-05: 如果 FeynmanExecutionRouter 已注入,使用统一路由
|
||||
if (this.feynmanRouter) {
|
||||
return this.feynmanRouter.evaluateFeynman(uid, body, body.knowledgeItemId);
|
||||
}
|
||||
|
||||
// 回退:Legacy 路径(兼容未导入 AiJobModule 的场景)
|
||||
return this.service.evaluateFeynman(uid, body);
|
||||
}
|
||||
|
||||
@Get('jobs/:id')
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
import { AiJobModule } from '../ai-job/ai-job.module';
|
||||
import { AppConfigModule } from '../config/config.module';
|
||||
import { AiAnalysisController } from './ai-analysis.controller';
|
||||
import { AiAnalysisService } from './ai-analysis.service';
|
||||
import { AiAnalysisRepository } from './ai-analysis.repository';
|
||||
import { FeynmanExecutionRouter } from './feynman-execution-router';
|
||||
|
||||
@Module({
|
||||
imports: [AiModule],
|
||||
imports: [AiModule, AiJobModule, AppConfigModule],
|
||||
controllers: [AiAnalysisController],
|
||||
providers: [AiAnalysisService, AiAnalysisRepository],
|
||||
providers: [AiAnalysisService, AiAnalysisRepository, FeynmanExecutionRouter],
|
||||
exports: [AiAnalysisService, AiAnalysisRepository],
|
||||
})
|
||||
export class AiAnalysisModule {}
|
||||
|
||||
272
src/modules/ai-analysis/feynman-execution-router.spec.ts
Normal file
272
src/modules/ai-analysis/feynman-execution-router.spec.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FeynmanExecutionRouter } from './feynman-execution-router';
|
||||
import { FeatureFlagService } from '../config/feature-flag.service';
|
||||
import { FeynmanSnapshotBuilder } from '../ai-job/feynman-snapshot-builder';
|
||||
import { AiJobCreationService } from '../ai-job/ai-job-creation.service';
|
||||
import { JobDefinitionRegistry } from '../ai-job/job-definition-registry';
|
||||
import { AiAnalysisService } from './ai-analysis.service';
|
||||
import { FEYNMAN_JOB_DEFINITION } from '../ai-job/feynman-job-definition';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FeynmanExecutionRouter
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('FeynmanExecutionRouter', () => {
|
||||
let router: FeynmanExecutionRouter;
|
||||
let featureFlag: any;
|
||||
let snapshotBuilder: any;
|
||||
let creationService: any;
|
||||
let registry: any;
|
||||
let legacyService: any;
|
||||
|
||||
const validInput = {
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '光合作用是植物利用光能的过程。',
|
||||
userExplanation: '光合作用就像植物做饭。',
|
||||
sessionId: 'session-001',
|
||||
answerId: 'answer-001',
|
||||
};
|
||||
|
||||
const mockSnapshot = {
|
||||
schemaVersion: 'feynman-evaluation-v1',
|
||||
snapshot: {
|
||||
userId: 'u-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
submissionId: 'session-001:answer-001',
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateResult = {
|
||||
job: { id: 'job-new-001' },
|
||||
snapshot: { id: 'snap-001' },
|
||||
outboxEvent: { id: 'outbox-001' },
|
||||
isDuplicate: false,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
featureFlag = {
|
||||
isEnabled: jest.fn().mockResolvedValue(false), // default: legacy
|
||||
};
|
||||
|
||||
snapshotBuilder = {
|
||||
build: jest.fn().mockResolvedValue(mockSnapshot),
|
||||
computeHash: jest.fn().mockReturnValue('abc123def4567890'),
|
||||
};
|
||||
|
||||
creationService = {
|
||||
createJob: jest.fn().mockResolvedValue(mockCreateResult),
|
||||
};
|
||||
|
||||
registry = {
|
||||
get: jest.fn().mockReturnValue(FEYNMAN_JOB_DEFINITION),
|
||||
};
|
||||
|
||||
legacyService = {
|
||||
evaluateFeynman: jest.fn().mockResolvedValue({
|
||||
jobId: 'job-legacy-001',
|
||||
status: 'queued',
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FeynmanExecutionRouter,
|
||||
{ provide: FeatureFlagService, useValue: featureFlag },
|
||||
{ provide: FeynmanSnapshotBuilder, useValue: snapshotBuilder },
|
||||
{ provide: AiJobCreationService, useValue: creationService },
|
||||
{ provide: JobDefinitionRegistry, useValue: registry },
|
||||
{ provide: AiAnalysisService, useValue: legacyService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
router = module.get(FeynmanExecutionRouter);
|
||||
});
|
||||
|
||||
describe('参数校验', () => {
|
||||
it('knowledgeItemTitle 为空 → BadRequestException', async () => {
|
||||
await expect(
|
||||
router.evaluateFeynman('u-001', { ...validInput, knowledgeItemTitle: '' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('knowledgeItemContent 为空 → BadRequestException', async () => {
|
||||
await expect(
|
||||
router.evaluateFeynman('u-001', { ...validInput, knowledgeItemContent: ' ' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('userExplanation 为空 → BadRequestException', async () => {
|
||||
await expect(
|
||||
router.evaluateFeynman('u-001', { ...validInput, userExplanation: '' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy 路径', () => {
|
||||
it('Feature Flag 为 disabled → 走 Legacy', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(false);
|
||||
|
||||
const result = await router.evaluateFeynman('u-001', validInput);
|
||||
|
||||
expect(legacyService.evaluateFeynman).toHaveBeenCalledWith('u-001', validInput);
|
||||
expect(result).toEqual({ jobId: 'job-legacy-001', status: 'queued' });
|
||||
// Unified 路径不应被调用
|
||||
expect(snapshotBuilder.build).not.toHaveBeenCalled();
|
||||
expect(creationService.createJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FeatureFlag 查询失败 → 安全回退到 Legacy', async () => {
|
||||
featureFlag.isEnabled.mockRejectedValue(new Error('Redis connection error'));
|
||||
|
||||
const result = await router.evaluateFeynman('u-001', validInput);
|
||||
|
||||
expect(legacyService.evaluateFeynman).toHaveBeenCalled();
|
||||
expect(result).toEqual({ jobId: 'job-legacy-001', status: 'queued' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unified 路径', () => {
|
||||
it('Feature Flag 为 enabled → 走 Unified', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
|
||||
const result = await router.evaluateFeynman('u-001', validInput, 'ki-001');
|
||||
|
||||
// Legacy 不应被调用
|
||||
expect(legacyService.evaluateFeynman).not.toHaveBeenCalled();
|
||||
|
||||
// Unified 路径
|
||||
expect(snapshotBuilder.build).toHaveBeenCalledTimes(1);
|
||||
const snapshotCall = snapshotBuilder.build.mock.calls[0][0];
|
||||
expect(snapshotCall.userId).toBe('u-001');
|
||||
expect(snapshotCall.knowledgeItemId).toBe('ki-001');
|
||||
expect(snapshotCall.knowledgeItemTitle).toBe('光合作用');
|
||||
expect(snapshotCall.userExplanation).toBe('光合作用就像植物做饭。');
|
||||
expect(snapshotCall.submissionId).toBe('session-001:answer-001');
|
||||
|
||||
// AiJobCreationService 调用
|
||||
expect(creationService.createJob).toHaveBeenCalledTimes(1);
|
||||
const createCall = creationService.createJob.mock.calls[0][0];
|
||||
expect(createCall.jobType).toBe('feynman_evaluation');
|
||||
expect(createCall.triggerType).toBe('user_api');
|
||||
expect(createCall.targetType).toBe('knowledge_item');
|
||||
expect(createCall.idempotencyKey).toBe('feynman:session-001:answer-001');
|
||||
expect(createCall.retrySnapshotContent).toBeDefined();
|
||||
|
||||
// 响应兼容
|
||||
expect(result).toHaveProperty('jobId');
|
||||
expect(result).toHaveProperty('status', 'queued');
|
||||
expect(result).toHaveProperty('engineMode', 'unified');
|
||||
expect(result).toHaveProperty('lifecycleStatus', 'queued');
|
||||
});
|
||||
|
||||
it('knowledgeItemId 未传入时使用 unknown 占位', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
|
||||
await router.evaluateFeynman('u-001', validInput);
|
||||
|
||||
const snapshotCall = snapshotBuilder.build.mock.calls[0][0];
|
||||
expect(snapshotCall.knowledgeItemId).toBe('unknown');
|
||||
});
|
||||
|
||||
it('Unified 失败不得自动调用 Legacy', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
snapshotBuilder.build.mockRejectedValue(new Error('KnowledgeItem not found'));
|
||||
|
||||
await expect(
|
||||
router.evaluateFeynman('u-001', validInput, 'ki-001'),
|
||||
).rejects.toThrow('KnowledgeItem not found');
|
||||
|
||||
// Legacy 不应被调用
|
||||
expect(legacyService.evaluateFeynman).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('幂等键', () => {
|
||||
it('sessionId + answerId 都存在 → feynman:<sessionId>:<answerId>', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
|
||||
await router.evaluateFeynman('u-001', {
|
||||
...validInput,
|
||||
sessionId: 'sess-123',
|
||||
answerId: 'ans-456',
|
||||
}, 'ki-001');
|
||||
|
||||
const createCall = creationService.createJob.mock.calls[0][0];
|
||||
expect(createCall.idempotencyKey).toBe('feynman:sess-123:ans-456');
|
||||
});
|
||||
|
||||
it('仅 sessionId → feynman:<sessionId>', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
|
||||
await router.evaluateFeynman('u-001', {
|
||||
...validInput,
|
||||
sessionId: 'sess-only',
|
||||
answerId: undefined,
|
||||
}, 'ki-001');
|
||||
|
||||
const createCall = creationService.createJob.mock.calls[0][0];
|
||||
expect(createCall.idempotencyKey).toBe('feynman:sess-only');
|
||||
});
|
||||
|
||||
it('无 sessionId/answerId → 基于内容的 hash 回退', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
|
||||
await router.evaluateFeynman('u-001', {
|
||||
...validInput,
|
||||
sessionId: undefined,
|
||||
answerId: undefined,
|
||||
}, 'ki-001');
|
||||
|
||||
const createCall = creationService.createJob.mock.calls[0][0];
|
||||
expect(createCall.idempotencyKey).toMatch(/^feynman:[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('相同内容产生相同 idempotencyKey', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
|
||||
await router.evaluateFeynman('u-001', {
|
||||
...validInput,
|
||||
sessionId: undefined,
|
||||
answerId: undefined,
|
||||
}, 'ki-001');
|
||||
|
||||
const key1 = creationService.createJob.mock.calls[0][0].idempotencyKey;
|
||||
|
||||
// 第二次调用
|
||||
await router.evaluateFeynman('u-002', {
|
||||
...validInput,
|
||||
sessionId: undefined,
|
||||
answerId: undefined,
|
||||
}, 'ki-002');
|
||||
|
||||
const key2 = creationService.createJob.mock.calls[1][0].idempotencyKey;
|
||||
|
||||
// 相同内容 → 相同 key(内容 hash)
|
||||
expect(key1).toBe(key2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('响应兼容', () => {
|
||||
it('Legacy 响应保持旧格式', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(false);
|
||||
|
||||
const result = await router.evaluateFeynman('u-001', validInput);
|
||||
|
||||
expect(result).toEqual({ jobId: 'job-legacy-001', status: 'queued' });
|
||||
expect(result).not.toHaveProperty('engineMode');
|
||||
expect(result).not.toHaveProperty('lifecycleStatus');
|
||||
});
|
||||
|
||||
it('Unified 响应包含 engineMode 和 lifecycleStatus', async () => {
|
||||
featureFlag.isEnabled.mockResolvedValue(true);
|
||||
|
||||
const result = await router.evaluateFeynman('u-001', validInput, 'ki-001');
|
||||
|
||||
expect(result).toHaveProperty('jobId');
|
||||
expect(result).toHaveProperty('status', 'queued');
|
||||
expect((result as any).engineMode).toBe('unified');
|
||||
expect((result as any).lifecycleStatus).toBe('queued');
|
||||
});
|
||||
});
|
||||
});
|
||||
193
src/modules/ai-analysis/feynman-execution-router.ts
Normal file
193
src/modules/ai-analysis/feynman-execution-router.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import { FeatureFlagService } from '../config/feature-flag.service';
|
||||
import { FeynmanSnapshotBuilder } from '../ai-job/feynman-snapshot-builder';
|
||||
import type { FeynmanSnapshotInput } from '../ai-job/feynman-snapshot-builder';
|
||||
import { AiJobCreationService } from '../ai-job/ai-job-creation.service';
|
||||
import { JobDefinitionRegistry } from '../ai-job/job-definition-registry';
|
||||
import { AiAnalysisService } from './ai-analysis.service';
|
||||
|
||||
/**
|
||||
* M-AI-05-05: Feynman Execution Router
|
||||
*
|
||||
* 根据 FEYNMAN_ENGINE_MODE Feature Flag 决定 Feynman 评估的执行分支:
|
||||
* - 'legacy' → 原 AiAnalysisService.evaluateFeynman() 路径
|
||||
* - 'unified' → FeynmanSnapshotBuilder → AiJobCreationService → Unified Job Engine
|
||||
*
|
||||
* 设计约束(契约 §10):
|
||||
* - 分支判断集中在 Router,不散落在 Controller/Service/Worker
|
||||
* - 支持用户白名单(通过 FeatureFlagService)
|
||||
* - 默认 legacy(Feature Flag 不存在或 disabled 时)
|
||||
* - Unified 失败不得自动调用 Legacy
|
||||
* - 同一请求只能执行一个引擎
|
||||
*/
|
||||
|
||||
const FLAG_NAME = 'FEYNMAN_ENGINE_MODE';
|
||||
|
||||
/** Feynman HTTP 请求体(与 AiAnalysisController 保持一致) */
|
||||
export interface FeynmanEvaluateInput {
|
||||
knowledgeItemTitle: string;
|
||||
knowledgeItemContent: string;
|
||||
userExplanation: string;
|
||||
sessionId?: string;
|
||||
answerId?: string;
|
||||
}
|
||||
|
||||
/** Unified 模式扩展响应 */
|
||||
export interface FeynmanUnifiedResponse {
|
||||
jobId: string;
|
||||
status: string;
|
||||
engineMode: 'unified';
|
||||
lifecycleStatus: string;
|
||||
}
|
||||
|
||||
/** Legacy 兼容响应 */
|
||||
export interface FeynmanLegacyResponse {
|
||||
jobId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeynmanExecutionRouter {
|
||||
private readonly logger = new Logger(FeynmanExecutionRouter.name);
|
||||
|
||||
constructor(
|
||||
private readonly featureFlag: FeatureFlagService,
|
||||
private readonly snapshotBuilder: FeynmanSnapshotBuilder,
|
||||
private readonly creationService: AiJobCreationService,
|
||||
private readonly registry: JobDefinitionRegistry,
|
||||
private readonly legacyService: AiAnalysisService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 路由 Feynman 评估请求。
|
||||
*
|
||||
* @param userId - 请求用户 ID
|
||||
* @param input - 请求体(knowledgeItemTitle/content/explanation + 可选的 sessionId/answerId)
|
||||
* @param knowledgeItemId - 知识点 ID(由 Controller 从请求体获取或后续客户端传入)
|
||||
* @returns Legacy 或 Unified 响应
|
||||
*/
|
||||
async evaluateFeynman(
|
||||
userId: string,
|
||||
input: FeynmanEvaluateInput,
|
||||
knowledgeItemId?: string,
|
||||
): Promise<FeynmanLegacyResponse | FeynmanUnifiedResponse> {
|
||||
// 1. 基本参数校验(与 Legacy 一致)
|
||||
if (!input.knowledgeItemTitle?.trim()) {
|
||||
throw new BadRequestException('knowledgeItemTitle is required');
|
||||
}
|
||||
if (!input.knowledgeItemContent?.trim()) {
|
||||
throw new BadRequestException('knowledgeItemContent is required');
|
||||
}
|
||||
if (!input.userExplanation?.trim()) {
|
||||
throw new BadRequestException('userExplanation is required');
|
||||
}
|
||||
|
||||
// 2. 检查 Feature Flag
|
||||
const useUnified = await this.shouldUseUnified(userId);
|
||||
|
||||
if (!useUnified) {
|
||||
// ── Legacy 路径 ──
|
||||
return this.legacyService.evaluateFeynman(userId, input);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════
|
||||
// ── Unified 路径 ──
|
||||
// ═════════════════════════════════════════════════════════
|
||||
|
||||
// 3. 确定 knowledgeItemId
|
||||
// 当前请求体不含此字段(契约 U-2),使用传入值或占位符
|
||||
// M-AI-05-07 及后续客户端升级后可传入真实 ID
|
||||
const resolvedKnowledgeItemId = knowledgeItemId || 'unknown';
|
||||
|
||||
// 4. 确定稳定 submissionId(幂等键来源)
|
||||
const submissionId = this.resolveSubmissionId(input);
|
||||
|
||||
// 5. 构造 idempotencyKey
|
||||
const idempotencyKey = `feynman:${submissionId}`;
|
||||
|
||||
// 6. 构建 Snapshot
|
||||
const snapshotInput: FeynmanSnapshotInput = {
|
||||
userId,
|
||||
knowledgeItemId: resolvedKnowledgeItemId,
|
||||
knowledgeItemTitle: input.knowledgeItemTitle,
|
||||
knowledgeItemContent: input.knowledgeItemContent,
|
||||
userExplanation: input.userExplanation,
|
||||
submissionId,
|
||||
sessionId: input.sessionId,
|
||||
answerId: input.answerId,
|
||||
};
|
||||
|
||||
const snapshot = await this.snapshotBuilder.build(snapshotInput);
|
||||
|
||||
// 7. 通过 AiJobCreationService 创建 Job(原子:Job + Snapshot + Outbox)
|
||||
const result = await this.creationService.createJob({
|
||||
userId,
|
||||
jobType: 'feynman_evaluation',
|
||||
triggerType: 'user_api',
|
||||
targetType: 'knowledge_item',
|
||||
targetId: resolvedKnowledgeItemId,
|
||||
idempotencyKey,
|
||||
retrySnapshotContent: snapshot as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Feynman Unified: jobId=${result.job.id} userId=${userId} ` +
|
||||
`submissionId=${submissionId} idempotencyKey=${idempotencyKey}`,
|
||||
);
|
||||
|
||||
// 8. 返回兼容响应(不删除旧字段,新增可选字段)
|
||||
return {
|
||||
jobId: result.job.id,
|
||||
status: 'queued',
|
||||
engineMode: 'unified',
|
||||
lifecycleStatus: 'queued',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
/**
|
||||
* 判断是否应使用 Unified 引擎。
|
||||
*
|
||||
* FeatureFlag 查询失败 → 安全回退到 legacy。
|
||||
*/
|
||||
private async shouldUseUnified(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const enabled = await this.featureFlag.isEnabled(FLAG_NAME, userId);
|
||||
this.logger.log(
|
||||
`FEYNMAN_ENGINE_MODE=${enabled ? 'unified' : 'legacy'} for userId=${userId}`,
|
||||
);
|
||||
return enabled;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(
|
||||
`FeatureFlag query failed, falling back to legacy: ${err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求参数解析稳定 submissionId。
|
||||
*
|
||||
* 优先级:
|
||||
* 1. sessionId + answerId 组合(如都存在)
|
||||
* 2. sessionId(如仅 sessionId 存在)
|
||||
* 3. 基于 content 的 hash 回退(保证相同内容 → 相同 ID)
|
||||
*/
|
||||
private resolveSubmissionId(input: FeynmanEvaluateInput): string {
|
||||
if (input.sessionId && input.answerId) {
|
||||
return `${input.sessionId}:${input.answerId}`;
|
||||
}
|
||||
if (input.sessionId) {
|
||||
return input.sessionId;
|
||||
}
|
||||
// 回退:基于内容 hash 的 submissionId(相同输入 → 相同 key)
|
||||
const contentKey = [
|
||||
input.knowledgeItemTitle,
|
||||
input.knowledgeItemContent,
|
||||
input.userExplanation,
|
||||
].join('|');
|
||||
return crypto.createHash('sha256').update(contentKey).digest('hex').substring(0, 16);
|
||||
}
|
||||
}
|
||||
@ -86,6 +86,13 @@ export class AiJobCreationService {
|
||||
input.targetType,
|
||||
input.targetId,
|
||||
)
|
||||
: input.jobType === 'feynman_evaluation'
|
||||
? (() => {
|
||||
throw new BadRequestException(
|
||||
'feynman_evaluation requires retrySnapshotContent. ' +
|
||||
'Use FeynmanExecutionRouter to build the snapshot before calling createJob.',
|
||||
);
|
||||
})()
|
||||
: await this.snapshotBuilder.buildSnapshot(
|
||||
input.userId,
|
||||
input.targetType,
|
||||
|
||||
@ -7,6 +7,9 @@ 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 { FeynmanExecutor } from './feynman-executor';
|
||||
import { FeynmanBusinessValidator, FeynmanReferenceValidator } from './feynman-validator';
|
||||
import { FeynmanObservabilityService } from './feynman-observability.service';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { JobLockConflictError, JobAlreadyTerminalError } from './ai-job.errors';
|
||||
|
||||
@ -82,6 +85,9 @@ describe('AiJobExecutionEngineImpl', () => {
|
||||
{ provide: AiGatewayService, useValue: aiGateway },
|
||||
{ provide: ProjectionExecutor, useValue: projectionExecutor },
|
||||
{ provide: ActiveRecallExecutor, useValue: { execute: jest.fn() } },
|
||||
{ provide: FeynmanExecutor, useValue: { execute: jest.fn() } },
|
||||
{ provide: FeynmanBusinessValidator, useValue: { validate: jest.fn() } },
|
||||
{ provide: FeynmanReferenceValidator, useValue: { validate: jest.fn() } },
|
||||
{ provide: ActiveRecallObservabilityService, useValue: {
|
||||
incrementUnifiedExecuteSuccess: jest.fn(),
|
||||
incrementUnifiedExecuteFailed: jest.fn(),
|
||||
@ -91,6 +97,17 @@ describe('AiJobExecutionEngineImpl', () => {
|
||||
logExecutionFailed: jest.fn(),
|
||||
logRollback: jest.fn(),
|
||||
} },
|
||||
{ provide: FeynmanObservabilityService, useValue: {
|
||||
incrementUnifiedExecuteSuccess: jest.fn(),
|
||||
incrementUnifiedExecuteFailed: jest.fn(),
|
||||
incrementUnifiedRetry: jest.fn(),
|
||||
incrementProjectorFailed: jest.fn(),
|
||||
addFocusItemCreated: jest.fn(),
|
||||
addReviewCardCreated: jest.fn(),
|
||||
logExecutionCompleted: jest.fn(),
|
||||
logExecutionFailed: jest.fn(),
|
||||
logRollback: jest.fn(),
|
||||
} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@ -8,7 +8,12 @@ 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 { FeynmanExecutor } from './feynman-executor';
|
||||
import { FeynmanObservabilityService } from './feynman-observability.service';
|
||||
import { FeynmanBusinessValidator, FeynmanReferenceValidator } from './feynman-validator';
|
||||
import type { ActiveRecallSnapshot } from './active-recall-snapshot-builder';
|
||||
import type { FeynmanSnapshot } from './feynman-snapshot-builder';
|
||||
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
|
||||
import {
|
||||
AiJobExecutionEngine,
|
||||
EngineJobContext,
|
||||
@ -80,7 +85,11 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
private readonly aiGateway: AiGatewayService,
|
||||
private readonly projectionExecutor: ProjectionExecutor,
|
||||
private readonly activeRecallExecutor: ActiveRecallExecutor,
|
||||
private readonly feynmanExecutor: FeynmanExecutor,
|
||||
private readonly feynmanBusinessValidator: FeynmanBusinessValidator,
|
||||
private readonly feynmanReferenceValidator: FeynmanReferenceValidator,
|
||||
private readonly observability: ActiveRecallObservabilityService,
|
||||
private readonly feynmanObs: FeynmanObservabilityService,
|
||||
) {}
|
||||
|
||||
async execute(aiJobId: string, context: EngineJobContext): Promise<void> {
|
||||
@ -161,7 +170,7 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
await context.updateProgress(30);
|
||||
|
||||
// ── EXECUTE ──
|
||||
// 按 jobType 分派执行策略:active_recall → Executor, 其他 → AiGateway 直接调用
|
||||
// 按 jobType 分派执行策略:active_recall / feynman_evaluation → Executor, 其他 → AiGateway
|
||||
const timeoutMs = def.execution.timeoutMs || 30000;
|
||||
try {
|
||||
let parsedOutput: Record<string, any>;
|
||||
@ -179,6 +188,30 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
`ActiveRecall Executor completed: job=${aiJobId} ` +
|
||||
`score=${(parsedOutput as any)?.score}`,
|
||||
);
|
||||
} else if (job.jobType === 'feynman_evaluation' && snapshot) {
|
||||
// M-AI-05-03: Feynman Executor 处理消息构造 + AiGateway 调用
|
||||
const feynmanSnapshot = snapshot as unknown as FeynmanSnapshot;
|
||||
response = await this.feynmanExecutor.execute(
|
||||
feynmanSnapshot,
|
||||
timeoutMs,
|
||||
);
|
||||
parsedOutput = response.parsed;
|
||||
|
||||
this.logger.log(
|
||||
`Feynman Executor completed: job=${aiJobId} ` +
|
||||
`score=${(parsedOutput as any)?.score}`,
|
||||
);
|
||||
|
||||
// ── M-AI-05-03: 结构化输出验证 ──
|
||||
try {
|
||||
this.feynmanBusinessValidator.validate(parsedOutput as FeynmanEvaluationResult);
|
||||
this.feynmanReferenceValidator.validate(parsedOutput as FeynmanEvaluationResult);
|
||||
} catch (validationErr: any) {
|
||||
this.logger.warn(
|
||||
`Feynman validation failed for job=${aiJobId}: ${validationErr.message}`,
|
||||
);
|
||||
throw validationErr; // classifyError → markFailed
|
||||
}
|
||||
} else {
|
||||
// 默认路径:直接调用 AiGateway(synthetic_job 等)
|
||||
response = await this.aiGateway.generate(
|
||||
@ -247,6 +280,14 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
`projectorKey=${def.projectorKey} error=${projectorErr.message}`,
|
||||
);
|
||||
}
|
||||
// M-AI-05-06: Feynman Projector 失败观测
|
||||
if (job.jobType === 'feynman_evaluation') {
|
||||
this.feynmanObs.incrementProjectorFailed();
|
||||
this.logger.error(
|
||||
`[Feynman] Projector failed: jobId=${aiJobId} ` +
|
||||
`projectorKey=${def.projectorKey} error=${projectorErr.message}`,
|
||||
);
|
||||
}
|
||||
throw projectorErr; // 传播到外层 catch → classifyError + markFailed
|
||||
}
|
||||
|
||||
@ -270,7 +311,7 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
const durationMs = Date.now() - new Date(job.startedAt || job.queuedAt || Date.now()).getTime();
|
||||
this.observability.incrementUnifiedExecuteSuccess(durationMs);
|
||||
this.observability.logExecutionCompleted({
|
||||
requestId: 'engine', // Engine 层无请求级 requestId
|
||||
requestId: 'engine',
|
||||
jobId: aiJobId,
|
||||
activeRecallId: job.targetId || '',
|
||||
userId: job.userId,
|
||||
@ -282,6 +323,30 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
attemptCount: lockedJob.attemptCount,
|
||||
});
|
||||
}
|
||||
|
||||
// M-AI-05-06: Feynman 执行成功观测
|
||||
if (job.jobType === 'feynman_evaluation') {
|
||||
const durationMs = Date.now() - new Date(job.startedAt || job.queuedAt || Date.now()).getTime();
|
||||
const focusItemCount = artifacts.filter((a: any) => a.artifactType === 'FocusItem').length;
|
||||
const reviewCardCount = artifacts.filter((a: any) => a.artifactType === 'ReviewCard').length;
|
||||
this.feynmanObs.incrementUnifiedExecuteSuccess(durationMs);
|
||||
this.feynmanObs.addFocusItemCreated(focusItemCount);
|
||||
this.feynmanObs.addReviewCardCreated(reviewCardCount);
|
||||
this.feynmanObs.logExecutionCompleted({
|
||||
requestId: 'engine',
|
||||
jobId: aiJobId,
|
||||
knowledgeItemId: job.targetId || '',
|
||||
userId: job.userId,
|
||||
engineMode: 'unified',
|
||||
jobType: job.jobType,
|
||||
queueName: def.queue.queueName,
|
||||
durationMs,
|
||||
lifecycleStatus: 'succeeded',
|
||||
attemptCount: lockedJob.attemptCount,
|
||||
focusItemCount,
|
||||
reviewCardCount,
|
||||
});
|
||||
}
|
||||
} catch (execErr: any) {
|
||||
// 取消检查
|
||||
if (execErr?.message?.includes('cancelled')) {
|
||||
@ -318,6 +383,28 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
);
|
||||
}
|
||||
|
||||
// M-AI-05-06: Feynman 执行失败 + 重试观测
|
||||
if (job.jobType === 'feynman_evaluation') {
|
||||
if (classified.retryable) {
|
||||
this.feynmanObs.incrementUnifiedRetry();
|
||||
} else {
|
||||
this.feynmanObs.incrementUnifiedExecuteFailed();
|
||||
}
|
||||
this.feynmanObs.logExecutionFailed(
|
||||
{
|
||||
requestId: 'engine',
|
||||
jobId: aiJobId,
|
||||
knowledgeItemId: 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);
|
||||
|
||||
@ -26,6 +26,15 @@ import {
|
||||
import { ActiveRecallProjector } from './active-recall-projector';
|
||||
import { ActiveRecallExecutionRouter } from './active-recall-execution-router';
|
||||
import { ActiveRecallObservabilityService } from './active-recall-observability.service';
|
||||
import { FeynmanRegistrationService } from './feynman-registration.service';
|
||||
import { FeynmanSnapshotBuilder } from './feynman-snapshot-builder';
|
||||
import { FeynmanExecutor } from './feynman-executor';
|
||||
import {
|
||||
FeynmanBusinessValidator,
|
||||
FeynmanReferenceValidator,
|
||||
} from './feynman-validator';
|
||||
import { FeynmanProjector } from './feynman-projector';
|
||||
import { FeynmanObservabilityService } from './feynman-observability.service';
|
||||
import { AppConfigModule } from '../config/config.module';
|
||||
|
||||
@Module({
|
||||
@ -50,7 +59,14 @@ import { AppConfigModule } from '../config/config.module';
|
||||
ActiveRecallProjector,
|
||||
ActiveRecallExecutionRouter,
|
||||
ActiveRecallObservabilityService,
|
||||
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector) => [synthetic, activeRecall], inject: [SyntheticResultProjector, ActiveRecallProjector] } as any,
|
||||
FeynmanRegistrationService,
|
||||
FeynmanSnapshotBuilder,
|
||||
FeynmanExecutor,
|
||||
FeynmanBusinessValidator,
|
||||
FeynmanReferenceValidator,
|
||||
FeynmanProjector,
|
||||
FeynmanObservabilityService,
|
||||
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector) => [synthetic, activeRecall, feynman], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector] } as any,
|
||||
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
|
||||
],
|
||||
exports: [
|
||||
@ -60,6 +76,8 @@ import { AppConfigModule } from '../config/config.module';
|
||||
AiJobCreationService,
|
||||
ActiveRecallExecutionRouter,
|
||||
ActiveRecallObservabilityService,
|
||||
FeynmanSnapshotBuilder,
|
||||
FeynmanObservabilityService,
|
||||
AI_JOB_EXECUTION_ENGINE,
|
||||
],
|
||||
})
|
||||
|
||||
359
src/modules/ai-job/feynman-executor.spec.ts
Normal file
359
src/modules/ai-job/feynman-executor.spec.ts
Normal file
@ -0,0 +1,359 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FeynmanExecutor } from './feynman-executor';
|
||||
import { FeynmanBusinessValidator, FeynmanReferenceValidator } from './feynman-validator';
|
||||
import { BusinessValidationError, ReferenceValidationError } from './active-recall-validator';
|
||||
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
||||
import { FEYNMAN_JOB_DEFINITION } from './feynman-job-definition';
|
||||
import type { FeynmanSnapshot } from './feynman-snapshot-builder';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function makeSnapshot(overrides: Partial<FeynmanSnapshot['snapshot']> = {}): FeynmanSnapshot {
|
||||
return {
|
||||
schemaVersion: 'feynman-evaluation-v1',
|
||||
snapshot: {
|
||||
userId: 'u-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||||
userExplanation: '光合作用就像植物的"做饭"过程,用阳光作为能源。',
|
||||
submissionId: 'sub-001',
|
||||
knowledgeBaseId: 'kb-001',
|
||||
referenceMaterials: [],
|
||||
promptKey: 'feynman-evaluation',
|
||||
promptVersion: '1.0.0',
|
||||
modelTier: 'primary',
|
||||
inputSchemaVersion: 'feynman-evaluation-v1',
|
||||
outputSchemaVersion: 'feynman-evaluation-v1',
|
||||
createdAt: '2026-06-21T10:00:00Z',
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeValidOutput() {
|
||||
return {
|
||||
score: 75,
|
||||
clarityLevel: 'mostly_clear' as const,
|
||||
summary: '用户用自己的话解释了核心概念,但缺少具体类比帮助理解。',
|
||||
strengths: ['用简单语言重述了概念', '抓住了核心要点'],
|
||||
weaknesses: ['缺少生活化类比', '部分术语未解释'],
|
||||
blindSpots: ['没有说明为什么这个知识点重要'],
|
||||
suggestions: ['尝试用一个日常生活中类比来解释', '补充一个具体的使用场景'],
|
||||
isBeginnerFriendly: true,
|
||||
analogyQuality: 'poor' as const,
|
||||
jargonUsage: 'moderate' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FeynmanExecutor
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('FeynmanExecutor', () => {
|
||||
let executor: FeynmanExecutor;
|
||||
let gateway: any;
|
||||
|
||||
const mockGatewayResponse = {
|
||||
parsed: makeValidOutput(),
|
||||
usage: {
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
inputTokens: 500,
|
||||
outputTokens: 300,
|
||||
estimatedCost: 0.001,
|
||||
latencyMs: 2000,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
gateway = {
|
||||
generate: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FeynmanExecutor,
|
||||
{ provide: AiGatewayService, useValue: gateway },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
executor = module.get(FeynmanExecutor);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('通过 AiGateway 调用模型并返回 parsed 输出', async () => {
|
||||
gateway.generate.mockResolvedValue(mockGatewayResponse);
|
||||
|
||||
const snapshot = makeSnapshot();
|
||||
const response = await executor.execute(snapshot, 180_000);
|
||||
|
||||
expect(gateway.generate).toHaveBeenCalledTimes(1);
|
||||
const callArgs = gateway.generate.mock.calls[0];
|
||||
// 第一个参数:GatewayRequest
|
||||
expect(callArgs[0].feature).toBe('feynman-evaluation');
|
||||
expect(callArgs[0].userId).toBe('u-001');
|
||||
expect(callArgs[0].tier).toBe('primary');
|
||||
expect(callArgs[0].promptKey).toBe('feynman-evaluation');
|
||||
expect(callArgs[0].promptVersion).toBe('1.0.0');
|
||||
expect(callArgs[0].messages).toHaveLength(1);
|
||||
expect(callArgs[0].messages[0].role).toBe('user');
|
||||
// 用户消息应包含知识点标题、原文和解释
|
||||
expect(callArgs[0].messages[0].content).toContain('【知识点标题】');
|
||||
expect(callArgs[0].messages[0].content).toContain('光合作用');
|
||||
expect(callArgs[0].messages[0].content).toContain('【用户的费曼解释】');
|
||||
expect(callArgs[0].messages[0].content).toContain('做饭');
|
||||
// outputSchema 使用 FeynmanEvaluationResultSchema
|
||||
expect(callArgs[0].outputSchema).toBeDefined();
|
||||
// 第二个参数:timeoutMs
|
||||
expect(callArgs[1]).toBe(180_000);
|
||||
|
||||
expect(response.parsed.score).toBe(75);
|
||||
expect(response.usage.inputTokens).toBe(500);
|
||||
});
|
||||
|
||||
it('使用 Snapshot 中的 prompt/model 元数据', async () => {
|
||||
gateway.generate.mockResolvedValue(mockGatewayResponse);
|
||||
|
||||
const snapshot = makeSnapshot({
|
||||
promptKey: 'feynman-evaluation',
|
||||
promptVersion: '2.0.0',
|
||||
modelTier: 'primary',
|
||||
});
|
||||
await executor.execute(snapshot, 120_000);
|
||||
|
||||
const callArgs = gateway.generate.mock.calls[0];
|
||||
expect(callArgs[0].promptKey).toBe('feynman-evaluation');
|
||||
expect(callArgs[0].promptVersion).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('将 timeoutMs 传递给 AiGateway', async () => {
|
||||
gateway.generate.mockResolvedValue(mockGatewayResponse);
|
||||
|
||||
await executor.execute(makeSnapshot(), 60_000);
|
||||
expect(gateway.generate.mock.calls[0][1]).toBe(60_000);
|
||||
});
|
||||
|
||||
it('Executor 无数据库副作用(不直接操作 DB)', async () => {
|
||||
// FeynmanExecutor 只依赖 AiGatewayService,无 PrismaService 注入
|
||||
gateway.generate.mockResolvedValue(mockGatewayResponse);
|
||||
|
||||
const response = await executor.execute(makeSnapshot(), 180_000);
|
||||
expect(response).toBeDefined();
|
||||
// 验证 Executor 的构造函数只注入了 AiGatewayService
|
||||
// (如果注入了 PrismaService,NestJS DI 会在测试中报错)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FeynmanBusinessValidator
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('FeynmanBusinessValidator', () => {
|
||||
let validator: FeynmanBusinessValidator;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [FeynmanBusinessValidator],
|
||||
}).compile();
|
||||
|
||||
validator = module.get(FeynmanBusinessValidator);
|
||||
});
|
||||
|
||||
describe('正常输出通过', () => {
|
||||
it('完整有效输出通过验证', () => {
|
||||
const output = makeValidOutput();
|
||||
expect(() => validator.validate(output)).not.toThrow();
|
||||
});
|
||||
|
||||
it('analogyQuality 为 undefined 时通过(可选字段)', () => {
|
||||
const output = { ...makeValidOutput(), analogyQuality: undefined };
|
||||
expect(() => validator.validate(output)).not.toThrow();
|
||||
});
|
||||
|
||||
it('空数组允许通过', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
strengths: [],
|
||||
weaknesses: [],
|
||||
blindSpots: [],
|
||||
suggestions: [],
|
||||
};
|
||||
expect(() => validator.validate(output)).not.toThrow();
|
||||
});
|
||||
|
||||
it('score=0 边界通过', () => {
|
||||
const output = { ...makeValidOutput(), score: 0 };
|
||||
expect(() => validator.validate(output)).not.toThrow();
|
||||
});
|
||||
|
||||
it('score=100 边界通过', () => {
|
||||
const output = { ...makeValidOutput(), score: 100 };
|
||||
expect(() => validator.validate(output)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('score 验证', () => {
|
||||
it('score 越界(>100)→ BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), score: 150 };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
try { validator.validate(output); } catch (e: any) {
|
||||
expect(e.violations.some((v: string) => v.includes('out of range'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('score 越界(<0)→ BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), score: -5 };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('score 非整数 → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), score: 75.5 };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clarityLevel 验证', () => {
|
||||
it('非法 clarityLevel → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), clarityLevel: 'invalid' as any };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary 验证', () => {
|
||||
it('空字符串 → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), summary: '' };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('纯空格 → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), summary: ' ' };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('数组字段验证', () => {
|
||||
it('strengths 中单项 > 500 字符 → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), strengths: ['x'.repeat(501)] };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('weaknesses 超过 10 项 → BusinessValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
weaknesses: Array.from({ length: 11 }, (_, i) => `weakness ${i}`),
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('suggestions 含非字符串项 → BusinessValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
suggestions: [123 as any],
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('布尔/枚举验证', () => {
|
||||
it('isBeginnerFriendly 非 boolean → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), isBeginnerFriendly: 'yes' as any };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('jargonUsage 非法枚举 → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), jargonUsage: 'extreme' as any };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('analogyQuality 非法枚举 → BusinessValidationError', () => {
|
||||
const output = { ...makeValidOutput(), analogyQuality: 'terrible' as any };
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('空对象 / 模型指令检测', () => {
|
||||
it('空对象 {} → BusinessValidationError', () => {
|
||||
const output = {} as any;
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('summary 含代码块 → BusinessValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
summary: '```json\n{"score": 75}\n```',
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('strengths 数组项含代码块 → BusinessValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
strengths: ['```json\n{"key": "value"}\n```'],
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
|
||||
it('summary 含模型指令前缀 → BusinessValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
summary: 'Here is the evaluation result for this submission',
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(BusinessValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FeynmanReferenceValidator
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('FeynmanReferenceValidator', () => {
|
||||
let validator: FeynmanReferenceValidator;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [FeynmanReferenceValidator],
|
||||
}).compile();
|
||||
|
||||
validator = module.get(FeynmanReferenceValidator);
|
||||
});
|
||||
|
||||
describe('正常输出通过', () => {
|
||||
it('无外部引用的正常输出通过', () => {
|
||||
const output = makeValidOutput();
|
||||
expect(() => validator.validate(output)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL 检测', () => {
|
||||
it('weakness 包含 URL → ReferenceValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
weaknesses: ['查看 https://example.com/leak for details'],
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(ReferenceValidationError);
|
||||
});
|
||||
|
||||
it('summary 包含 URL → ReferenceValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
summary: 'See https://docs.example.com for more',
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(ReferenceValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email 检测', () => {
|
||||
it('suggestion 包含 email → ReferenceValidationError', () => {
|
||||
const output = {
|
||||
...makeValidOutput(),
|
||||
suggestions: ['Contact user@example.com for help'],
|
||||
};
|
||||
expect(() => validator.validate(output)).toThrow(ReferenceValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
src/modules/ai-job/feynman-executor.ts
Normal file
99
src/modules/ai-job/feynman-executor.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
||||
import { FeynmanEvaluationResultSchema } from '../ai/prompts/schemas/feynman-evaluation.schema';
|
||||
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
|
||||
import type { FeynmanSnapshot } from './feynman-snapshot-builder';
|
||||
|
||||
/**
|
||||
* M-AI-05-03: Feynman Executor
|
||||
*
|
||||
* 将 Feynman 评估输入快照适配到统一 Job Engine 的 EXECUTE 阶段。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 从 Snapshot 构造模型请求消息(复用现有 Feynman prompt 模板逻辑)
|
||||
* 2. 通过 AiGatewayService 调用模型(不直接导入 Provider SDK)
|
||||
* 3. 接收 timeout → 委托给 AiGatewayService 内部的 AbortController
|
||||
* 4. 返回 AiGatewayService 的原始响应(parsed output)
|
||||
*
|
||||
* 不负责(由 Engine 统一处理):
|
||||
* - 写数据库(无副作用)
|
||||
* - 写 Job 状态
|
||||
* - 重试逻辑
|
||||
* - 写 Artifact
|
||||
* - 解析 Credential
|
||||
*
|
||||
* 兼容性:
|
||||
* - 使用与旧链路相同的 promptKey(feynman-evaluation)和 outputSchema
|
||||
* - 消息构造逻辑与 FeynmanEvaluationWorkflow.execute() 一致
|
||||
* (src/modules/ai/workflows/feynman-evaluation.workflow.ts:18-29)
|
||||
*/
|
||||
|
||||
@Injectable()
|
||||
export class FeynmanExecutor {
|
||||
private readonly logger = new Logger(FeynmanExecutor.name);
|
||||
|
||||
constructor(private readonly aiGateway: AiGatewayService) {}
|
||||
|
||||
/**
|
||||
* 执行 Feynman 评估 AI 分析。
|
||||
*
|
||||
* @param snapshot - FeynmanSnapshot(由 FeynmanSnapshotBuilder 产出)
|
||||
* @param timeoutMs - 超时毫秒数(来自 Definition.execution.timeoutMs)
|
||||
* @returns AiGateway 响应(含 parsed + usage)
|
||||
*/
|
||||
async execute(
|
||||
snapshot: FeynmanSnapshot,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const s = snapshot.snapshot;
|
||||
|
||||
// 构造用户消息(与旧链路 FeynmanEvaluationWorkflow.execute() 一致)
|
||||
// workflow.ts:18-29 的消息格式:
|
||||
// 【知识点标题】+ title + 【知识点原文】+ content + 【用户的费曼解释】+ explanation
|
||||
const userMessage = [
|
||||
`【知识点标题】`,
|
||||
s.knowledgeItemTitle,
|
||||
'',
|
||||
`【知识点原文】`,
|
||||
s.knowledgeItemContent,
|
||||
'',
|
||||
`【用户的费曼解释】`,
|
||||
s.userExplanation,
|
||||
'',
|
||||
`请评估以上费曼解释的质量,严格按照 JSON Schema 输出。`,
|
||||
].join('\n');
|
||||
|
||||
this.logger.log(
|
||||
`Feynman Executor calling AI: userId=${s.userId} ` +
|
||||
`knowledgeItemId=${s.knowledgeItemId} ` +
|
||||
`submissionId=${s.submissionId} ` +
|
||||
`promptKey=${s.promptKey} promptVersion=${s.promptVersion} ` +
|
||||
`modelTier=${s.modelTier} timeoutMs=${timeoutMs}`,
|
||||
);
|
||||
|
||||
const response = await this.aiGateway.generate(
|
||||
{
|
||||
feature: 'feynman-evaluation',
|
||||
userId: s.userId,
|
||||
tier: s.modelTier as any,
|
||||
promptKey: s.promptKey,
|
||||
promptVersion: s.promptVersion,
|
||||
messages: [
|
||||
{ role: 'user' as const, content: userMessage },
|
||||
],
|
||||
outputSchema: FeynmanEvaluationResultSchema,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Feynman Executor completed: userId=${s.userId} ` +
|
||||
`knowledgeItemId=${s.knowledgeItemId} ` +
|
||||
`score=${(response.parsed as any)?.score} ` +
|
||||
`tokens=${response.usage.inputTokens}/${response.usage.outputTokens}`,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
413
src/modules/ai-job/feynman-job-definition.spec.ts
Normal file
413
src/modules/ai-job/feynman-job-definition.spec.ts
Normal file
@ -0,0 +1,413 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
|
||||
import { FeynmanRegistrationService } from './feynman-registration.service';
|
||||
import { FEYNMAN_JOB_DEFINITION } from './feynman-job-definition';
|
||||
import { FeynmanSnapshotBuilder } from './feynman-snapshot-builder';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FeynmanRegistrationService
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('FeynmanRegistrationService', () => {
|
||||
let registry: JobDefinitionRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
JobDefinitionRegistry,
|
||||
FeynmanRegistrationService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
registry = module.get(JobDefinitionRegistry);
|
||||
});
|
||||
|
||||
describe('Definition 注册', () => {
|
||||
it('Registry 注册成功', async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [JobDefinitionRegistry, FeynmanRegistrationService],
|
||||
}).compile();
|
||||
await module.init(); // triggers onModuleInit
|
||||
|
||||
const reg = module.get(JobDefinitionRegistry);
|
||||
const def = reg.get('feynman_evaluation');
|
||||
|
||||
expect(def).toBeDefined();
|
||||
expect(def.jobType).toBe('feynman_evaluation');
|
||||
expect(def.queue.queueName).toBe('ai-interactive');
|
||||
expect(def.metadata.domain).toBe('analysis');
|
||||
expect(def.prompt.promptKey).toBe('feynman-evaluation');
|
||||
expect(def.prompt.promptVersion).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('重复注册失败(幂等注册)', async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [JobDefinitionRegistry, FeynmanRegistrationService],
|
||||
}).compile();
|
||||
await module.init();
|
||||
|
||||
const reg = module.get(JobDefinitionRegistry);
|
||||
|
||||
// 第二次注册应抛出 DuplicateJobTypeError
|
||||
expect(() => reg.register(FEYNMAN_JOB_DEFINITION)).toThrow(
|
||||
DuplicateJobTypeError,
|
||||
);
|
||||
expect(() => reg.register(FEYNMAN_JOB_DEFINITION)).toThrow(
|
||||
'Duplicate jobType "feynman_evaluation"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Definition 字段冻结验证', () => {
|
||||
it('jobType 格式合法', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.jobType).toMatch(/^[a-z][a-z0-9_]{1,63}$/);
|
||||
});
|
||||
|
||||
it('queueName 在允许列表', () => {
|
||||
expect(['ai-interactive', 'ai-background']).toContain(
|
||||
FEYNMAN_JOB_DEFINITION.queue.queueName,
|
||||
);
|
||||
});
|
||||
|
||||
it('input.schemaVersion 非空', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.input.schemaVersion).toBe('feynman-evaluation-v1');
|
||||
});
|
||||
|
||||
it('output.schemaVersion 非空', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.output.schemaVersion).toBe('feynman-evaluation-v1');
|
||||
});
|
||||
|
||||
it('promptKey 使用现有 feynman-evaluation', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.prompt.promptKey).toBe('feynman-evaluation');
|
||||
expect(FEYNMAN_JOB_DEFINITION.prompt.promptKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('timeoutMs 在 [1000, 600000] 范围内', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.timeoutMs).toBe(180_000);
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.timeoutMs).toBeGreaterThanOrEqual(1000);
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.timeoutMs).toBeLessThanOrEqual(600000);
|
||||
});
|
||||
|
||||
it('maxRetries 在 [0, 10] 范围内(与 Legacy 一致为 3)', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.maxRetries).toBe(3);
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.maxRetries).toBeGreaterThanOrEqual(0);
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.maxRetries).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('credential.allowedModes 非空且值合法', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.credential.allowedModes.length).toBeGreaterThan(0);
|
||||
for (const m of FEYNMAN_JOB_DEFINITION.credential.allowedModes) {
|
||||
expect(['platform_key', 'user_deepseek_key']).toContain(m);
|
||||
}
|
||||
});
|
||||
|
||||
it('retryBackoff 合法', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.retryBackoff.type).toBe('exponential');
|
||||
expect(FEYNMAN_JOB_DEFINITION.execution.retryBackoff.delay).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('projectorKey 已设置(为 M-AI-05-04 预留)', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.projectorKey).toBe('feynman_evaluation_projector');
|
||||
});
|
||||
|
||||
it('contentSafetyCheck 已启用', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.security.contentSafetyCheck).toBe(true);
|
||||
});
|
||||
|
||||
it('model 使用 deepseek-v4-pro primary tier(与 Legacy 一致)', () => {
|
||||
expect(FEYNMAN_JOB_DEFINITION.model.modelTier).toBe('primary');
|
||||
expect(FEYNMAN_JOB_DEFINITION.model.modelProvider).toBe('deepseek');
|
||||
expect(FEYNMAN_JOB_DEFINITION.model.modelName).toBe('deepseek-v4-pro');
|
||||
expect(FEYNMAN_JOB_DEFINITION.model.maxTokens).toBe(4096);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FeynmanSnapshotBuilder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('FeynmanSnapshotBuilder', () => {
|
||||
let builder: FeynmanSnapshotBuilder;
|
||||
let prisma: any;
|
||||
let registry: any;
|
||||
|
||||
const mockKnowledgeItem = {
|
||||
id: 'ki-001',
|
||||
userId: 'u-001',
|
||||
knowledgeBaseId: 'kb-001',
|
||||
title: '光合作用',
|
||||
content: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||||
summary: '光合作用的基本原理',
|
||||
itemType: 'concept',
|
||||
learnable: true,
|
||||
status: 'active',
|
||||
orderIndex: 0,
|
||||
durationSeconds: 120,
|
||||
sourceId: null,
|
||||
sourceType: null,
|
||||
sourceRef: null,
|
||||
sourceDeleted: false,
|
||||
sourceTitleSnapshot: null,
|
||||
sourceSnippetSnapshot: null,
|
||||
fileSize: null,
|
||||
parentId: null,
|
||||
createdAt: new Date('2026-06-20T10:00:00Z'),
|
||||
updatedAt: new Date('2026-06-20T10:00:00Z'),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
const mockReferenceItems = [
|
||||
{
|
||||
id: 'ki-002',
|
||||
title: '叶绿体结构',
|
||||
summary: '叶绿体是光合作用的场所',
|
||||
},
|
||||
{
|
||||
id: 'ki-003',
|
||||
title: '卡尔文循环',
|
||||
summary: '光合作用的暗反应阶段',
|
||||
},
|
||||
];
|
||||
|
||||
const validInput = {
|
||||
userId: 'u-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||||
userExplanation: '光合作用就像植物的"做饭"过程,用阳光作为能源,把CO2和水变成食物(糖类),同时释放氧气。',
|
||||
submissionId: 'sub-001',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
prisma = {
|
||||
knowledgeItem: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock JobDefinitionRegistry: 返回与 FEYNMAN_JOB_DEFINITION 一致的 Definition
|
||||
registry = {
|
||||
get: jest.fn().mockReturnValue(FEYNMAN_JOB_DEFINITION),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FeynmanSnapshotBuilder,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: JobDefinitionRegistry, useValue: registry },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
builder = module.get(FeynmanSnapshotBuilder);
|
||||
});
|
||||
|
||||
describe('build', () => {
|
||||
it('构建有效快照', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue(mockReferenceItems);
|
||||
|
||||
const snapshot = await builder.build(validInput);
|
||||
|
||||
expect(snapshot.schemaVersion).toBe('feynman-evaluation-v1');
|
||||
expect(snapshot.snapshot.userId).toBe('u-001');
|
||||
expect(snapshot.snapshot.knowledgeItemId).toBe('ki-001');
|
||||
expect(snapshot.snapshot.knowledgeItemTitle).toBe('光合作用');
|
||||
expect(snapshot.snapshot.knowledgeItemContent).toBe(
|
||||
'光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||||
);
|
||||
expect(snapshot.snapshot.userExplanation).toBe(
|
||||
'光合作用就像植物的"做饭"过程,用阳光作为能源,把CO2和水变成食物(糖类),同时释放氧气。',
|
||||
);
|
||||
expect(snapshot.snapshot.submissionId).toBe('sub-001');
|
||||
expect(snapshot.snapshot.knowledgeBaseId).toBe('kb-001');
|
||||
// 参考材料
|
||||
expect(snapshot.snapshot.referenceMaterials).toHaveLength(2);
|
||||
expect(snapshot.snapshot.referenceMaterials[0].id).toBe('ki-002');
|
||||
expect(snapshot.snapshot.referenceMaterials[0].title).toBe('叶绿体结构');
|
||||
// prompt/model 值从 JobDefinition 读取(单一事实来源)
|
||||
expect(snapshot.snapshot.promptKey).toBe(FEYNMAN_JOB_DEFINITION.prompt.promptKey);
|
||||
expect(snapshot.snapshot.promptVersion).toBe(FEYNMAN_JOB_DEFINITION.prompt.promptVersion);
|
||||
expect(snapshot.snapshot.modelTier).toBe(FEYNMAN_JOB_DEFINITION.model.modelTier);
|
||||
expect(snapshot.snapshot.inputSchemaVersion).toBe('feynman-evaluation-v1');
|
||||
expect(snapshot.snapshot.outputSchemaVersion).toBe(FEYNMAN_JOB_DEFINITION.output.schemaVersion);
|
||||
// createdAt 截断到秒
|
||||
expect(snapshot.snapshot.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
});
|
||||
|
||||
it('prompt/model 值全部来自 JobDefinition(单一事实来源)', async () => {
|
||||
const customDef = {
|
||||
...FEYNMAN_JOB_DEFINITION,
|
||||
prompt: { promptKey: 'custom-feynman', promptVersion: '2.0' },
|
||||
};
|
||||
registry.get.mockReturnValue(customDef);
|
||||
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const snapshot = await builder.build(validInput);
|
||||
expect(snapshot.snapshot.promptKey).toBe('custom-feynman');
|
||||
expect(snapshot.snapshot.promptVersion).toBe('2.0');
|
||||
// 未修改的字段保持 Definition 原值
|
||||
expect(snapshot.snapshot.modelTier).toBe(FEYNMAN_JOB_DEFINITION.model.modelTier);
|
||||
});
|
||||
|
||||
it('非法输入被拒绝:KnowledgeItem 不存在 → NotFoundException', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(builder.build(validInput)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(builder.build(validInput)).rejects.toThrow(
|
||||
'KnowledgeItem ki-001 not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('非法输入被拒绝:KnowledgeItem 不属于当前用户 → ForbiddenException', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue({
|
||||
...mockKnowledgeItem,
|
||||
userId: 'u-other',
|
||||
});
|
||||
|
||||
await expect(builder.build(validInput)).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
await expect(builder.build(validInput)).rejects.toThrow(
|
||||
'does not belong to user u-001',
|
||||
);
|
||||
});
|
||||
|
||||
it('Snapshot 不含敏感字段', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const snapshot = await builder.build(validInput);
|
||||
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_URL');
|
||||
expect(serialized).not.toContain('REDIS_URL');
|
||||
expect(serialized).not.toContain('password');
|
||||
expect(serialized).not.toContain('credential');
|
||||
expect(serialized).not.toContain('token');
|
||||
|
||||
// Snapshot 对象内部不应有敏感字段
|
||||
const snap = snapshot.snapshot as Record<string, unknown>;
|
||||
expect(snap).not.toHaveProperty('jwt');
|
||||
expect(snap).not.toHaveProperty('authorization');
|
||||
expect(snap).not.toHaveProperty('credentialId');
|
||||
expect(snap).not.toHaveProperty('apiKey');
|
||||
});
|
||||
|
||||
it('参考材料为空时正常处理', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const snapshot = await builder.build(validInput);
|
||||
expect(snapshot.snapshot.referenceMaterials).toEqual([]);
|
||||
});
|
||||
|
||||
it('参考材料最多 5 条', async () => {
|
||||
const manyItems = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `ki-00${i + 2}`,
|
||||
title: `Item ${i + 2}`,
|
||||
summary: `Summary ${i + 2}`,
|
||||
}));
|
||||
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue(manyItems);
|
||||
|
||||
const snapshot = await builder.build(validInput);
|
||||
// findMany 的 take: 5 在 mock 中不生效 — mock 直接返回 10 条
|
||||
// 验证 referenceMaterials 存在即可(take 由 Prisma 层控制)
|
||||
expect(snapshot.snapshot.referenceMaterials.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeHash', () => {
|
||||
it('相同输入 → 相同 contentHash(稳定)', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue(mockReferenceItems);
|
||||
|
||||
const s1 = await builder.build(validInput);
|
||||
const s2 = await builder.build(validInput);
|
||||
|
||||
const h1 = builder.computeHash(s1);
|
||||
const h2 = builder.computeHash(s2);
|
||||
|
||||
expect(h1).toBe(h2);
|
||||
expect(h1.length).toBe(16);
|
||||
});
|
||||
|
||||
it('不同输入 → 不同 contentHash', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const s1 = await builder.build(validInput);
|
||||
|
||||
// 修改 userExplanation → 不同 hash
|
||||
const s2 = await builder.build({
|
||||
...validInput,
|
||||
userExplanation: 'Different explanation',
|
||||
});
|
||||
|
||||
const h1 = builder.computeHash(s1);
|
||||
const h2 = builder.computeHash(s2);
|
||||
|
||||
expect(h1).not.toBe(h2);
|
||||
});
|
||||
|
||||
it('不同 submissionId → 不同 contentHash', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const s1 = await builder.build(validInput);
|
||||
const s2 = await builder.build({
|
||||
...validInput,
|
||||
submissionId: 'sub-002',
|
||||
});
|
||||
|
||||
expect(builder.computeHash(s1)).not.toBe(builder.computeHash(s2));
|
||||
});
|
||||
|
||||
it('contentHash 长度固定为 16 且为 hex', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const snapshot = await builder.build(validInput);
|
||||
const hash = builder.computeHash(snapshot);
|
||||
|
||||
expect(hash).toHaveLength(16);
|
||||
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('字段顺序不影响 contentHash(稳定序列化)', async () => {
|
||||
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const s1 = await builder.build(validInput);
|
||||
|
||||
// 构造乱序但内容相同的快照对象
|
||||
const s2 = { ...s1, snapshot: {} as any };
|
||||
// 反转 key 顺序
|
||||
const keys = Object.keys(s1.snapshot).reverse();
|
||||
for (const k of keys) {
|
||||
(s2.snapshot as any)[k] = (s1.snapshot as any)[k];
|
||||
}
|
||||
|
||||
const h1 = builder.computeHash(s1);
|
||||
const h2 = builder.computeHash(s2);
|
||||
|
||||
expect(h1).toBe(h2);
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/modules/ai-job/feynman-job-definition.ts
Normal file
76
src/modules/ai-job/feynman-job-definition.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { JobDefinition } from './job-definition.types';
|
||||
|
||||
/**
|
||||
* M-AI-05-02: Feynman Evaluation Job Definition(冻结)
|
||||
*
|
||||
* 对应迁移契约 docs/architecture/m-ai-05-feynman-migration-contract.md
|
||||
*
|
||||
* 字段取值来源:
|
||||
* - promptKey/promptVersion: 复用现有 feynman-evaluation prompt
|
||||
* - model: deepseek-v4-pro, primary tier(与当前 AiGateway 一致)
|
||||
* - input.schemaVersion: feynman-evaluation-v1(契约 §3)
|
||||
* - output.schemaVersion: feynman-evaluation-v1(契约 §4)
|
||||
* - timeoutMs: 180000(3min,与旧 ai-analysis 链路 task-types.ts 一致)
|
||||
* - maxRetries: 3(与旧链路一致)
|
||||
* - cancellable: true(支持用户取消进行中的分析)
|
||||
*/
|
||||
|
||||
export const FEYNMAN_JOB_DEFINITION: JobDefinition = {
|
||||
jobType: 'feynman_evaluation',
|
||||
|
||||
metadata: {
|
||||
label: 'Feynman Evaluation',
|
||||
description:
|
||||
'Evaluate a user\'s Feynman explanation of a knowledge item. ' +
|
||||
'Analyzes the explanation quality across 6 dimensions: simplicity, analogies, ' +
|
||||
'terminology, beginner-friendliness, blind spots, and completeness. ' +
|
||||
'Generates score, strengths, weaknesses, suggestions, and focus items.',
|
||||
domain: 'analysis',
|
||||
version: '1.0.0',
|
||||
},
|
||||
|
||||
queue: {
|
||||
queueName: 'ai-interactive',
|
||||
defaultPriority: 0,
|
||||
},
|
||||
|
||||
execution: {
|
||||
timeoutMs: 180_000, // 3 min — matches legacy ai-analysis task-type config
|
||||
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: 'feynman-evaluation-v1',
|
||||
},
|
||||
|
||||
output: {
|
||||
schemaVersion: 'feynman-evaluation-v1',
|
||||
},
|
||||
|
||||
prompt: {
|
||||
promptKey: 'feynman-evaluation',
|
||||
promptVersion: '1.0.0',
|
||||
},
|
||||
|
||||
model: {
|
||||
modelTier: 'primary',
|
||||
modelProvider: 'deepseek',
|
||||
modelName: 'deepseek-v4-pro',
|
||||
maxTokens: 4096,
|
||||
},
|
||||
|
||||
credential: {
|
||||
allowedModes: ['platform_key'],
|
||||
defaultMode: 'platform_key',
|
||||
},
|
||||
|
||||
projectorKey: 'feynman_evaluation_projector',
|
||||
|
||||
security: {
|
||||
contentSafetyCheck: true,
|
||||
outputRedaction: false,
|
||||
},
|
||||
};
|
||||
178
src/modules/ai-job/feynman-observability.service.ts
Normal file
178
src/modules/ai-job/feynman-observability.service.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* M-AI-05-06: Feynman 可观测性服务
|
||||
*
|
||||
* 提供结构化日志和内存计数器,满足验收标准:
|
||||
* - 日志可关联完整链路(requestId → jobId → knowledgeItemId → submissionId)
|
||||
* - 统计 Legacy/Unified 请求量、成功率、耗时、重试、回滚
|
||||
* - FocusItem/ReviewCard 创建数量
|
||||
*
|
||||
* 计数器为内存级(不持久化),用于 Admin 查询和告警。
|
||||
* 生产环境建议对接 Prometheus/Grafana 或 Admin 指标接口。
|
||||
*
|
||||
* 约束:
|
||||
* - 不记录完整用户解释(userExplanation)
|
||||
* - 不记录完整模型输出(validatedOutput / rawResult)
|
||||
* - 不记录内部堆栈或 Credential
|
||||
*/
|
||||
|
||||
export interface FeynmanRequestLog {
|
||||
requestId: string;
|
||||
jobId?: string;
|
||||
knowledgeItemId: string;
|
||||
userId: string;
|
||||
engineMode: 'legacy' | 'unified';
|
||||
jobType: string;
|
||||
queueName: string;
|
||||
submissionId?: string;
|
||||
durationMs?: number;
|
||||
lifecycleStatus?: string;
|
||||
errorCode?: string;
|
||||
attemptCount?: number;
|
||||
focusItemCount?: number;
|
||||
reviewCardCount?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeynmanObservabilityService {
|
||||
private readonly logger = new Logger(FeynmanObservabilityService.name);
|
||||
|
||||
// ── 内存计数器 ──
|
||||
|
||||
private counters = {
|
||||
legacyRequests: 0,
|
||||
unifiedRequests: 0,
|
||||
unifiedCreateFailed: 0,
|
||||
unifiedExecuteSuccess: 0,
|
||||
unifiedExecuteFailed: 0,
|
||||
unifiedTotalDurationMs: 0,
|
||||
unifiedExecuteCount: 0,
|
||||
unifiedRetryCount: 0,
|
||||
projectorFailed: 0,
|
||||
focusItemCreated: 0,
|
||||
reviewCardCreated: 0,
|
||||
rollbackCount: 0,
|
||||
};
|
||||
|
||||
// ── 结构化日志 ──
|
||||
|
||||
/**
|
||||
* 记录 Feynman 请求(HTTP 入口)。
|
||||
* 不记录用户完整解释内容。
|
||||
*/
|
||||
logRequest(log: FeynmanRequestLog): void {
|
||||
this.logger.log(
|
||||
`[Feynman] request: requestId=${log.requestId} ` +
|
||||
`knowledgeItemId=${log.knowledgeItemId} userId=${log.userId} ` +
|
||||
`engine=${log.engineMode} jobType=${log.jobType} submissionId=${log.submissionId || 'N/A'}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 Job 创建成功。
|
||||
*/
|
||||
logJobCreated(log: FeynmanRequestLog): void {
|
||||
this.logger.log(
|
||||
`[Feynman] job_created: requestId=${log.requestId} ` +
|
||||
`jobId=${log.jobId} knowledgeItemId=${log.knowledgeItemId} ` +
|
||||
`engine=${log.engineMode} queueName=${log.queueName}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 Job 创建失败。
|
||||
* 使用 warn 级别 — 不记录用户完整解释。
|
||||
*/
|
||||
logJobCreateFailed(log: FeynmanRequestLog, error: string): void {
|
||||
this.logger.warn(
|
||||
`[Feynman] job_create_failed: requestId=${log.requestId} ` +
|
||||
`knowledgeItemId=${log.knowledgeItemId} userId=${log.userId} ` +
|
||||
`engine=${log.engineMode} error=${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录执行完成。
|
||||
* 不记录 validatedOutput 或 rawResult。
|
||||
*/
|
||||
logExecutionCompleted(log: FeynmanRequestLog): void {
|
||||
this.logger.log(
|
||||
`[Feynman] execution_completed: jobId=${log.jobId} ` +
|
||||
`knowledgeItemId=${log.knowledgeItemId} userId=${log.userId} ` +
|
||||
`durationMs=${log.durationMs} lifecycleStatus=${log.lifecycleStatus} ` +
|
||||
`attemptCount=${log.attemptCount} ` +
|
||||
`focusItems=${log.focusItemCount ?? 0} reviewCards=${log.reviewCardCount ?? 0}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录执行失败。
|
||||
* 不记录内部堆栈或 Snapshot。
|
||||
*/
|
||||
logExecutionFailed(log: FeynmanRequestLog, error: string): void {
|
||||
this.logger.warn(
|
||||
`[Feynman] execution_failed: jobId=${log.jobId} ` +
|
||||
`knowledgeItemId=${log.knowledgeItemId} userId=${log.userId} ` +
|
||||
`errorCode=${log.errorCode} error=${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录回滚事件。
|
||||
*/
|
||||
logRollback(userId: string, reason: string): void {
|
||||
this.logger.warn(
|
||||
`[Feynman] 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++; }
|
||||
|
||||
addFocusItemCreated(count: number): void { this.counters.focusItemCreated += count; }
|
||||
addReviewCardCreated(count: number): void { this.counters.reviewCardCreated += count; }
|
||||
|
||||
// ── 查询 ──
|
||||
|
||||
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,
|
||||
focusItemCreated: 0,
|
||||
reviewCardCreated: 0,
|
||||
rollbackCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
210
src/modules/ai-job/feynman-observability.spec.ts
Normal file
210
src/modules/ai-job/feynman-observability.spec.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FeynmanObservabilityService } from './feynman-observability.service';
|
||||
|
||||
describe('FeynmanObservabilityService', () => {
|
||||
let obs: FeynmanObservabilityService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [FeynmanObservabilityService],
|
||||
}).compile();
|
||||
|
||||
obs = module.get(FeynmanObservabilityService);
|
||||
});
|
||||
|
||||
describe('结构化日志', () => {
|
||||
it('logRequest 不含用户解释和模型输出', () => {
|
||||
const log = {
|
||||
requestId: 'req-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
userId: 'u-001',
|
||||
engineMode: 'unified' as const,
|
||||
jobType: 'feynman_evaluation',
|
||||
queueName: 'ai-interactive',
|
||||
submissionId: 'sub-001',
|
||||
};
|
||||
// 不应抛错(Logger 内部调用)
|
||||
expect(() => obs.logRequest(log)).not.toThrow();
|
||||
});
|
||||
|
||||
it('logJobCreated 记录 jobId 和队列名', () => {
|
||||
expect(() =>
|
||||
obs.logJobCreated({
|
||||
requestId: 'req-001',
|
||||
jobId: 'job-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
userId: 'u-001',
|
||||
engineMode: 'unified',
|
||||
jobType: 'feynman_evaluation',
|
||||
queueName: 'ai-interactive',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('logJobCreateFailed 记录错误但含用户标识', () => {
|
||||
expect(() =>
|
||||
obs.logJobCreateFailed(
|
||||
{
|
||||
requestId: 'req-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
userId: 'u-001',
|
||||
engineMode: 'unified',
|
||||
jobType: 'feynman_evaluation',
|
||||
queueName: 'ai-interactive',
|
||||
},
|
||||
'Snapshot build failed',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('logExecutionCompleted 含 focusItemCount 和 reviewCardCount', () => {
|
||||
expect(() =>
|
||||
obs.logExecutionCompleted({
|
||||
requestId: 'engine',
|
||||
jobId: 'job-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
userId: 'u-001',
|
||||
engineMode: 'unified',
|
||||
jobType: 'feynman_evaluation',
|
||||
queueName: 'ai-interactive',
|
||||
durationMs: 5000,
|
||||
lifecycleStatus: 'succeeded',
|
||||
attemptCount: 1,
|
||||
focusItemCount: 2,
|
||||
reviewCardCount: 0,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('logExecutionFailed 记录 errorCode 但不含内部堆栈', () => {
|
||||
expect(() =>
|
||||
obs.logExecutionFailed(
|
||||
{
|
||||
requestId: 'engine',
|
||||
jobId: 'job-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
userId: 'u-001',
|
||||
engineMode: 'unified',
|
||||
jobType: 'feynman_evaluation',
|
||||
queueName: 'ai-interactive',
|
||||
errorCode: 'provider_timeout',
|
||||
},
|
||||
'timeout',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('logRollback 记录回滚原因', () => {
|
||||
expect(() =>
|
||||
obs.logRollback('u-001', 'FEYNMAN_ENGINE_MODE set to legacy'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('计数器操作', () => {
|
||||
it('初始值为 0', () => {
|
||||
const c = obs.getCounters();
|
||||
expect(c.legacyRequests).toBe(0);
|
||||
expect(c.unifiedRequests).toBe(0);
|
||||
expect(c.unifiedExecuteSuccess).toBe(0);
|
||||
expect(c.unifiedExecuteFailed).toBe(0);
|
||||
expect(c.projectorFailed).toBe(0);
|
||||
expect(c.focusItemCreated).toBe(0);
|
||||
expect(c.reviewCardCreated).toBe(0);
|
||||
expect(c.rollbackCount).toBe(0);
|
||||
});
|
||||
|
||||
it('incrementLegacyRequests', () => {
|
||||
obs.incrementLegacyRequests();
|
||||
obs.incrementLegacyRequests();
|
||||
expect(obs.getCounters().legacyRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('incrementUnifiedRequests', () => {
|
||||
obs.incrementUnifiedRequests();
|
||||
expect(obs.getCounters().unifiedRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('incrementUnifiedExecuteSuccess 累加 duration 和 count', () => {
|
||||
obs.incrementUnifiedExecuteSuccess(3000);
|
||||
obs.incrementUnifiedExecuteSuccess(7000);
|
||||
const c = obs.getCounters();
|
||||
expect(c.unifiedExecuteSuccess).toBe(2);
|
||||
expect(c.unifiedAvgDurationMs).toBe(5000); // (3000+7000)/2
|
||||
});
|
||||
|
||||
it('incrementUnifiedExecuteFailed', () => {
|
||||
obs.incrementUnifiedExecuteFailed();
|
||||
obs.incrementUnifiedExecuteFailed();
|
||||
expect(obs.getCounters().unifiedExecuteFailed).toBe(2);
|
||||
});
|
||||
|
||||
it('incrementUnifiedRetry', () => {
|
||||
obs.incrementUnifiedRetry();
|
||||
expect(obs.getCounters().unifiedRetryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('incrementProjectorFailed', () => {
|
||||
obs.incrementProjectorFailed();
|
||||
expect(obs.getCounters().projectorFailed).toBe(1);
|
||||
});
|
||||
|
||||
it('addFocusItemCreated 累加', () => {
|
||||
obs.addFocusItemCreated(3);
|
||||
obs.addFocusItemCreated(2);
|
||||
expect(obs.getCounters().focusItemCreated).toBe(5);
|
||||
});
|
||||
|
||||
it('addReviewCardCreated 累加', () => {
|
||||
obs.addReviewCardCreated(1);
|
||||
expect(obs.getCounters().reviewCardCreated).toBe(1);
|
||||
});
|
||||
|
||||
it('incrementRollback', () => {
|
||||
obs.incrementRollback();
|
||||
expect(obs.getCounters().rollbackCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCounters', () => {
|
||||
it('重置所有计数器', () => {
|
||||
obs.incrementLegacyRequests();
|
||||
obs.incrementUnifiedRequests();
|
||||
obs.incrementUnifiedExecuteSuccess(1000);
|
||||
obs.addFocusItemCreated(5);
|
||||
|
||||
obs.resetCounters();
|
||||
|
||||
const c = obs.getCounters();
|
||||
expect(c.legacyRequests).toBe(0);
|
||||
expect(c.unifiedRequests).toBe(0);
|
||||
expect(c.unifiedExecuteSuccess).toBe(0);
|
||||
expect(c.focusItemCreated).toBe(0);
|
||||
expect(c.unifiedAvgDurationMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCounters', () => {
|
||||
it('unifiedAvgDurationMs 在无成功执行时为 0', () => {
|
||||
obs.incrementUnifiedExecuteFailed();
|
||||
expect(obs.getCounters().unifiedAvgDurationMs).toBe(0);
|
||||
});
|
||||
|
||||
it('返回所有计数器快照', () => {
|
||||
obs.incrementLegacyRequests();
|
||||
obs.incrementUnifiedRequests();
|
||||
const c = obs.getCounters();
|
||||
expect(c).toHaveProperty('legacyRequests');
|
||||
expect(c).toHaveProperty('unifiedRequests');
|
||||
expect(c).toHaveProperty('unifiedCreateFailed');
|
||||
expect(c).toHaveProperty('unifiedExecuteSuccess');
|
||||
expect(c).toHaveProperty('unifiedExecuteFailed');
|
||||
expect(c).toHaveProperty('unifiedAvgDurationMs');
|
||||
expect(c).toHaveProperty('unifiedRetryCount');
|
||||
expect(c).toHaveProperty('projectorFailed');
|
||||
expect(c).toHaveProperty('focusItemCreated');
|
||||
expect(c).toHaveProperty('reviewCardCreated');
|
||||
expect(c).toHaveProperty('rollbackCount');
|
||||
});
|
||||
});
|
||||
});
|
||||
390
src/modules/ai-job/feynman-projector.spec.ts
Normal file
390
src/modules/ai-job/feynman-projector.spec.ts
Normal file
@ -0,0 +1,390 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FeynmanProjector } from './feynman-projector';
|
||||
import { RESULT_PROJECTORS } from './result-projector.interface';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function makeProject() {
|
||||
const store: Record<string, any[]> = {
|
||||
aiJobArtifact: [],
|
||||
aiAnalysisResult: [],
|
||||
};
|
||||
const focusItems: any[] = [];
|
||||
|
||||
const tx = {
|
||||
aiJobArtifact: {
|
||||
findMany: jest.fn().mockImplementation(async (args: any) => {
|
||||
return store.aiJobArtifact.filter((a: any) => a.jobId === args.where.jobId);
|
||||
}),
|
||||
create: jest.fn().mockImplementation(async (args: any) => {
|
||||
const data = args.data;
|
||||
// 模拟唯一约束:jobId + artifactType + artifactId
|
||||
const exists = store.aiJobArtifact.find(
|
||||
(a: any) =>
|
||||
a.jobId === data.jobId &&
|
||||
a.artifactType === data.artifactType &&
|
||||
a.artifactId === data.artifactId,
|
||||
);
|
||||
if (exists) {
|
||||
const err = new Error('Unique constraint violation') as any;
|
||||
err.code = 'P2002';
|
||||
throw err;
|
||||
}
|
||||
const record = { id: `artifact-${store.aiJobArtifact.length}`, ...data };
|
||||
store.aiJobArtifact.push(record);
|
||||
return record;
|
||||
}),
|
||||
},
|
||||
aiAnalysisResult: {
|
||||
upsert: jest.fn().mockImplementation(async (args: any) => {
|
||||
const existing = store.aiAnalysisResult.find(
|
||||
(r: any) => r.id === args.where.id,
|
||||
);
|
||||
if (existing) {
|
||||
Object.assign(existing, args.update);
|
||||
return existing;
|
||||
}
|
||||
const record = { id: args.where.id, ...args.create };
|
||||
store.aiAnalysisResult.push(record);
|
||||
return record;
|
||||
}),
|
||||
},
|
||||
focusItem: {
|
||||
findFirst: jest.fn().mockImplementation(async (args: any) => {
|
||||
return focusItems.find(
|
||||
(fi: any) =>
|
||||
fi.userId === args.where.userId &&
|
||||
fi.title === args.where.title &&
|
||||
fi.source === args.where.source,
|
||||
) ?? null;
|
||||
}),
|
||||
create: jest.fn().mockImplementation(async (args: any) => {
|
||||
const record = { id: `fi-${focusItems.length}`, ...args.data };
|
||||
focusItems.push(record);
|
||||
return record;
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
store.aiJobArtifact = [];
|
||||
store.aiAnalysisResult = [];
|
||||
focusItems.length = 0;
|
||||
tx.aiJobArtifact.findMany.mockClear();
|
||||
tx.aiJobArtifact.create.mockClear();
|
||||
tx.aiAnalysisResult.upsert.mockClear();
|
||||
tx.focusItem.findFirst.mockClear();
|
||||
tx.focusItem.create.mockClear();
|
||||
};
|
||||
|
||||
return { tx: tx as unknown as Prisma.TransactionClient, store, focusItems, reset };
|
||||
}
|
||||
|
||||
function makeContext(overrides: any = {}) {
|
||||
return {
|
||||
job: {
|
||||
id: 'job-0012345678901234567',
|
||||
userId: 'u-001',
|
||||
jobType: 'feynman_evaluation',
|
||||
targetType: 'knowledge_item',
|
||||
targetId: 'ki-001',
|
||||
snapshotId: 'snap-001',
|
||||
promptVersion: '1.0.0',
|
||||
outputSchemaVersion: 'feynman-evaluation-v1',
|
||||
...overrides.job,
|
||||
},
|
||||
snapshot: {
|
||||
schemaVersion: 'feynman-evaluation-v1',
|
||||
snapshot: {
|
||||
userId: 'u-001',
|
||||
knowledgeItemId: 'ki-001',
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '光合作用是植物利用光能...',
|
||||
userExplanation: '光合作用就像植物做饭',
|
||||
submissionId: 'sub-001',
|
||||
knowledgeBaseId: 'kb-001',
|
||||
referenceMaterials: [],
|
||||
promptKey: 'feynman-evaluation',
|
||||
promptVersion: '1.0.0',
|
||||
modelTier: 'primary',
|
||||
inputSchemaVersion: 'feynman-evaluation-v1',
|
||||
outputSchemaVersion: 'feynman-evaluation-v1',
|
||||
createdAt: '2026-06-21T10:00:00Z',
|
||||
},
|
||||
...overrides.snapshot,
|
||||
},
|
||||
validatedOutput: {
|
||||
score: 75,
|
||||
clarityLevel: 'mostly_clear',
|
||||
summary: '用户用自己的话解释了核心概念',
|
||||
strengths: ['用简单语言重述了概念', '抓住了核心要点'],
|
||||
weaknesses: ['缺少生活化类比', '部分术语未解释'],
|
||||
blindSpots: ['没有说明为什么这个知识点重要'],
|
||||
suggestions: ['尝试用一个日常生活中类比来解释'],
|
||||
isBeginnerFriendly: true,
|
||||
analogyQuality: 'poor',
|
||||
jargonUsage: 'moderate',
|
||||
...overrides.validatedOutput,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FeynmanProjector
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('FeynmanProjector', () => {
|
||||
let projector: FeynmanProjector;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FeynmanProjector,
|
||||
{ provide: RESULT_PROJECTORS, useFactory: (p: FeynmanProjector) => [p], inject: [FeynmanProjector] },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
projector = module.get(FeynmanProjector);
|
||||
});
|
||||
|
||||
describe('基础功能', () => {
|
||||
it('key 为 feynman_evaluation_projector', () => {
|
||||
expect(projector.key).toBe('feynman_evaluation_projector');
|
||||
});
|
||||
|
||||
it('implements ResultProjector interface', () => {
|
||||
expect(projector.key).toBeDefined();
|
||||
expect(typeof projector.project).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('project — 核心写入', () => {
|
||||
it('写入 AiAnalysisResult(确定性 ID fe_<jobId>)', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
const artifacts = await projector.project(tx, context);
|
||||
|
||||
// 验证 Result 写入
|
||||
expect(tx.aiAnalysisResult.upsert).toHaveBeenCalledTimes(1);
|
||||
const upsertCall = (tx.aiAnalysisResult.upsert as jest.Mock).mock.calls[0][0];
|
||||
expect(upsertCall.where.id).toBe(`fe_${context.job.id.substring(0, 23)}`);
|
||||
expect(upsertCall.create.summary).toBe(context.validatedOutput.summary);
|
||||
expect(upsertCall.create.masteryScore).toBe(75);
|
||||
|
||||
// 验证 Artifact 包含 AiAnalysisResult
|
||||
const resultArtifact = artifacts.find(a => a.artifactType === 'AiAnalysisResult');
|
||||
expect(resultArtifact).toBeDefined();
|
||||
expect(resultArtifact!.artifactId).toBe(`fe_${context.job.id.substring(0, 23)}`);
|
||||
});
|
||||
|
||||
it('每个 weakness 字符串创建一个 FocusItem', async () => {
|
||||
const { tx, focusItems } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
const artifacts = await projector.project(tx, context);
|
||||
|
||||
// 验证 FocusItem 创建
|
||||
expect(tx.focusItem.create).toHaveBeenCalledTimes(2);
|
||||
const createCalls = (tx.focusItem.create as jest.Mock).mock.calls;
|
||||
|
||||
// 第一条:缺少生活化类比
|
||||
expect(createCalls[0][0].data.title).toBe('缺少生活化类比');
|
||||
expect(createCalls[0][0].data.source).toBe('ai-analysis');
|
||||
expect(createCalls[0][0].data.status).toBe('open');
|
||||
expect(createCalls[0][0].data.priority).toBe('normal');
|
||||
expect(createCalls[0][0].data.reason).toBe('');
|
||||
expect(createCalls[0][0].data.suggestion).toBe('');
|
||||
|
||||
// 第二条:部分术语未解释
|
||||
expect(createCalls[1][0].data.title).toBe('部分术语未解释');
|
||||
|
||||
// 验证 Artifact 包含 FocusItem
|
||||
const focusArtifacts = artifacts.filter(a => a.artifactType === 'FocusItem');
|
||||
expect(focusArtifacts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Fix: knowledgeBaseId 从 Snapshot 读取(不再为 unknown)', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
await projector.project(tx, context);
|
||||
|
||||
const createCalls = (tx.focusItem.create as jest.Mock).mock.calls;
|
||||
expect(createCalls[0][0].data.knowledgeBaseId).toBe('kb-001');
|
||||
expect(createCalls[0][0].data.knowledgeItemId).toBe('ki-001');
|
||||
});
|
||||
|
||||
it('Snapshot 缺少 knowledgeBaseId 时回退为 unknown', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext({
|
||||
snapshot: {
|
||||
schemaVersion: 'feynman-evaluation-v1',
|
||||
snapshot: {
|
||||
knowledgeBaseId: undefined,
|
||||
knowledgeItemId: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await projector.project(tx, context);
|
||||
|
||||
const createCalls = (tx.focusItem.create as jest.Mock).mock.calls;
|
||||
expect(createCalls[0][0].data.knowledgeBaseId).toBe('unknown');
|
||||
expect(createCalls[0][0].data.knowledgeItemId).toBeNull();
|
||||
});
|
||||
|
||||
it('weaknesses 为空时不创建 FocusItem', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext({
|
||||
validatedOutput: { ...makeContext().validatedOutput, weaknesses: [] },
|
||||
});
|
||||
|
||||
const artifacts = await projector.project(tx, context);
|
||||
expect(tx.focusItem.create).not.toHaveBeenCalled();
|
||||
const focusArtifacts = artifacts.filter(a => a.artifactType === 'FocusItem');
|
||||
expect(focusArtifacts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('weaknesses 含空字符串时跳过', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext({
|
||||
validatedOutput: {
|
||||
...makeContext().validatedOutput,
|
||||
weaknesses: ['', ' ', '有效弱点'],
|
||||
},
|
||||
});
|
||||
|
||||
await projector.project(tx, context);
|
||||
// 只有 "有效弱点" 被创建
|
||||
expect(tx.focusItem.create).toHaveBeenCalledTimes(1);
|
||||
expect((tx.focusItem.create as jest.Mock).mock.calls[0][0].data.title).toBe('有效弱点');
|
||||
});
|
||||
|
||||
it('★ ReviewCard 不在 Projector 中创建(方案 A)', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
// tx 上没有 reviewCard 方法(不在 mock 中)
|
||||
// 验证 projector 不会尝试访问 reviewCard
|
||||
await expect(projector.project(tx, context)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('幂等', () => {
|
||||
it('入口幂等:已有 Artifact → 直接返回已有引用', async () => {
|
||||
const { tx, store, focusItems } = makeProject();
|
||||
|
||||
// 第一次执行:写入
|
||||
const context = makeContext();
|
||||
const artifacts1 = await projector.project(tx, context);
|
||||
expect(artifacts1.length).toBeGreaterThan(0);
|
||||
|
||||
// 第二次执行:返回已有
|
||||
const artifacts2 = await projector.project(tx, context);
|
||||
|
||||
// 不应再次调用 upsert 或 create
|
||||
const upsertCount2 = (tx.aiAnalysisResult.upsert as jest.Mock).mock.calls.length;
|
||||
const createCount2 = (tx.focusItem.create as jest.Mock).mock.calls.length;
|
||||
|
||||
// 第二次 project() 应在 findMany 发现已有 artifact 后直接返回
|
||||
// (注意:mock 中 findMany 返回了 store 中的数据,所以不应再调用 upsert/create)
|
||||
expect(artifacts2.length).toBe(artifacts1.length);
|
||||
});
|
||||
|
||||
it('FocusItem:相同 userId + title + source 不重复创建', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
// 第一次:创建 2 个 FocusItem
|
||||
await projector.project(tx, context);
|
||||
|
||||
// 模拟重置 artifact(模拟新的 project 调用但已有部分 focusItems)
|
||||
const { tx: tx2 } = makeProject();
|
||||
// 预置:"缺少生活化类比" 已存在,"部分术语未解释" 未存在
|
||||
(tx2.focusItem.findFirst as jest.Mock).mockImplementation(async (args: any) => {
|
||||
if (args.where.title === '缺少生活化类比') {
|
||||
return { id: 'fi-existing', userId: 'u-001', title: '缺少生活化类比', source: 'ai-analysis' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const context2 = makeContext();
|
||||
await projector.project(tx2, context2);
|
||||
|
||||
// 已有 FocusItem 的 → findFirst 命中 → 不 create,只补 Artifact
|
||||
// 没有的 → findFirst null → create
|
||||
expect(tx2.focusItem.create).toHaveBeenCalledTimes(1); // 只创建 "部分术语未解释"
|
||||
expect((tx2.focusItem.create as jest.Mock).mock.calls[0][0].data.title).toBe('部分术语未解释');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Artifact 完整性', () => {
|
||||
it('返回的 Artifact 类型正确', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
const artifacts = await projector.project(tx, context);
|
||||
|
||||
const types = artifacts.map(a => a.artifactType);
|
||||
expect(types).toContain('AiAnalysisResult');
|
||||
expect(types).toContain('FocusItem');
|
||||
// ★ 不含 ReviewCard
|
||||
expect(types).not.toContain('ReviewCard');
|
||||
});
|
||||
|
||||
it('ordinal 递增', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
const artifacts = await projector.project(tx, context);
|
||||
|
||||
for (let i = 1; i < artifacts.length; i++) {
|
||||
expect(artifacts[i].ordinal).toBeGreaterThan(artifacts[i - 1].ordinal);
|
||||
}
|
||||
});
|
||||
|
||||
it('AiAnalysisResult Artifact 含 score metadata', async () => {
|
||||
const { tx } = makeProject();
|
||||
const context = makeContext();
|
||||
|
||||
await projector.project(tx, context);
|
||||
|
||||
const createCalls = (tx.aiJobArtifact.create as jest.Mock).mock.calls;
|
||||
const resultArtifactCall = createCalls.find(
|
||||
(c: any) => c[0].data.artifactType === 'AiAnalysisResult',
|
||||
);
|
||||
expect(resultArtifactCall).toBeDefined();
|
||||
expect(resultArtifactCall[0].data.metadata).toEqual({ score: 75 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('失败回滚', () => {
|
||||
it('AiAnalysisResult.upsert 失败 → 事务回滚(无产物)', async () => {
|
||||
const { tx } = makeProject();
|
||||
(tx.aiAnalysisResult.upsert as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('DB error'),
|
||||
);
|
||||
|
||||
const context = makeContext();
|
||||
await expect(projector.project(tx, context)).rejects.toThrow('DB error');
|
||||
|
||||
// FocusItem 不应被创建(事务回滚)
|
||||
expect(tx.focusItem.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FocusItem.create 失败 → 事务回滚(Result 不保留)', async () => {
|
||||
const { tx } = makeProject();
|
||||
// 第一个 FocusItem 创建失败
|
||||
(tx.focusItem.create as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('FocusItem constraint violation'),
|
||||
);
|
||||
|
||||
const context = makeContext();
|
||||
await expect(projector.project(tx, context)).rejects.toThrow('FocusItem constraint violation');
|
||||
});
|
||||
});
|
||||
});
|
||||
216
src/modules/ai-job/feynman-projector.ts
Normal file
216
src/modules/ai-job/feynman-projector.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ResultProjector,
|
||||
ProjectionContext,
|
||||
ArtifactReference,
|
||||
} from './result-projector.interface';
|
||||
|
||||
/**
|
||||
* M-AI-05-04: Feynman Result Projector
|
||||
*
|
||||
* 将验证后的 Feynman AI 输出原子投影到现有业务模型。
|
||||
*
|
||||
* 契约依据:docs/architecture/m-ai-05-feynman-migration-contract.md §11, §13
|
||||
*
|
||||
* 在 Prisma Transaction 内与 markSucceeded 共享事务:
|
||||
* 1. AiAnalysisResult — 分析结果(upsert by deterministic ID fe_<jobId>)
|
||||
* 2. FocusItem — 每个 weakness 字符串创建一条(事务内 findFirst + create 幂等)
|
||||
* 3. AiJobArtifact — 上述每个实体一条引用
|
||||
*
|
||||
* ★ ReviewCard 不在事务内(契约 §12 方案 A — 保留 EventBus 异步生成)
|
||||
*
|
||||
* 幂等策略:
|
||||
* - 入口幂等:检查已有 Artifact → 直接返回
|
||||
* - AiAnalysisResult:deterministic ID(fe_<jobId>)+ upsert
|
||||
* - FocusItem:事务内 findFirst + create(userId + title + source 去重)
|
||||
*
|
||||
* 与旧链路的差异(Bug 修复):
|
||||
* - 旧链路 knowledgeBaseId 永远为 'unknown'(result.knowledgeBaseId 不存在于 Feynman Schema)
|
||||
* - 本 Projector 从 Snapshot 读取真实 knowledgeBaseId + knowledgeItemId
|
||||
*/
|
||||
|
||||
@Injectable()
|
||||
export class FeynmanProjector implements ResultProjector {
|
||||
readonly key = 'feynman_evaluation_projector';
|
||||
private readonly logger = new Logger(FeynmanProjector.name);
|
||||
|
||||
async project(
|
||||
tx: Prisma.TransactionClient,
|
||||
context: ProjectionContext,
|
||||
): Promise<ArtifactReference[]> {
|
||||
const { job, validatedOutput, snapshot } = 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(
|
||||
`Feynman 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(Feynman 评估结果)
|
||||
// 使用 deterministic ID 实现 upsert(无 Migration 下的幂等方案)
|
||||
// ═════════════════════════════════════════════════════════
|
||||
|
||||
const resultId = deterministicResultId(job.id);
|
||||
const resultData = {
|
||||
id: resultId,
|
||||
userId: job.userId,
|
||||
jobId: job.id,
|
||||
summary: validatedOutput.summary ?? '',
|
||||
masteryScore: validatedOutput.score ?? null,
|
||||
strengths: (validatedOutput.strengths ?? []) as any,
|
||||
weaknesses: (validatedOutput.weaknesses ?? []) as any,
|
||||
suggestions: (validatedOutput.suggestions ?? []) as any,
|
||||
nextActions: null as any,
|
||||
rawResult: validatedOutput as any,
|
||||
};
|
||||
|
||||
await tx.aiAnalysisResult.upsert({
|
||||
where: { id: resultId },
|
||||
create: resultData,
|
||||
update: {
|
||||
summary: resultData.summary,
|
||||
masteryScore: resultData.masteryScore,
|
||||
strengths: resultData.strengths,
|
||||
weaknesses: resultData.weaknesses,
|
||||
suggestions: resultData.suggestions,
|
||||
rawResult: resultData.rawResult,
|
||||
},
|
||||
});
|
||||
|
||||
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(
|
||||
`Feynman Projector: AiAnalysisResult ${resultId} written for job=${job.id}`,
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════
|
||||
// 2. FocusItem(从 weaknesses 字符串创建)
|
||||
// 契约 §11:每个 weakness 字符串 → 1 个 FocusItem
|
||||
// 修复 Legacy bug:knowledgeBaseId 从 Snapshot 读取(不再为 'unknown')
|
||||
// 幂等:相同 userId + title + source 不重复创建
|
||||
// ═════════════════════════════════════════════════════════
|
||||
|
||||
const weaknesses: string[] = validatedOutput.weaknesses ?? [];
|
||||
const knowledgeBaseId = snapshot?.snapshot?.knowledgeBaseId ?? 'unknown';
|
||||
const knowledgeItemId = snapshot?.snapshot?.knowledgeItemId ?? null;
|
||||
|
||||
for (const title of weaknesses) {
|
||||
if (!title || typeof title !== 'string' || title.trim().length === 0) continue;
|
||||
|
||||
// 幂等:同一 userId + title + source 不重复创建
|
||||
const existingFi = await tx.focusItem.findFirst({
|
||||
where: {
|
||||
userId: job.userId,
|
||||
title: title.trim(),
|
||||
source: 'ai-analysis',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingFi) {
|
||||
// 已存在 → 只补写 Artifact(如缺失)
|
||||
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: title.trim(),
|
||||
reason: '',
|
||||
suggestion: '',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
source: 'ai-analysis',
|
||||
knowledgeBaseId, // ★ 修复:从 Snapshot 读取
|
||||
knowledgeItemId: knowledgeItemId, // ★ 新增:Legacy 未设置
|
||||
},
|
||||
});
|
||||
|
||||
await upsertArtifact(tx, job.id, 'FocusItem', record.id, ordinal);
|
||||
artifacts.push({
|
||||
artifactType: 'FocusItem',
|
||||
artifactId: record.id,
|
||||
ordinal: ordinal++,
|
||||
});
|
||||
}
|
||||
|
||||
if (weaknesses.length > 0) {
|
||||
this.logger.log(
|
||||
`Feynman Projector: ${weaknesses.filter(w => w && typeof w === 'string' && w.trim().length > 0).length} FocusItem(s) written for job=${job.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════
|
||||
// 完成
|
||||
// ★ ReviewCard 不在本 Projector 中创建(契约 §12 方案 A)
|
||||
// 保留由 Engine/M-AI-05-05 通过 EventBus 异步触发
|
||||
// ═════════════════════════════════════════════════════════
|
||||
|
||||
this.logger.log(
|
||||
`Feynman 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),取前 23 字符 + "fe_" 前缀
|
||||
return `fe_${jobId.substring(0, 23)}`;
|
||||
}
|
||||
|
||||
/** 幂等写入 Artifact(jobId + artifactType + artifactId 唯一约束) */
|
||||
async function upsertArtifact(
|
||||
tx: Prisma.TransactionClient,
|
||||
jobId: string,
|
||||
artifactType: string,
|
||||
artifactId: string,
|
||||
ordinal: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await tx.aiJobArtifact.create({
|
||||
data: { jobId, artifactType, artifactId, ordinal },
|
||||
});
|
||||
} catch (err: any) {
|
||||
// P2002: 唯一约束冲突 → 已存在,幂等跳过
|
||||
if (err?.code === 'P2002') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
42
src/modules/ai-job/feynman-registration.service.ts
Normal file
42
src/modules/ai-job/feynman-registration.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
|
||||
import { FEYNMAN_JOB_DEFINITION } from './feynman-job-definition';
|
||||
|
||||
/**
|
||||
* M-AI-05-02: Feynman Job Definition 注册服务
|
||||
*
|
||||
* 在模块初始化(onModuleInit)时向 JobDefinitionRegistry 注册
|
||||
* FeynmanEvaluation JobDefinition。
|
||||
*
|
||||
* 容忍重复注册:API 进程和 Worker 进程各自独立启动,均导入
|
||||
* AiJobModule,因此同一 Definition 会被注册两次。第二次注册的
|
||||
* DuplicateJobTypeError 被安全捕获,不阻止进程启动。
|
||||
*/
|
||||
@Injectable()
|
||||
export class FeynmanRegistrationService implements OnModuleInit {
|
||||
private readonly logger = new Logger(FeynmanRegistrationService.name);
|
||||
|
||||
constructor(private readonly registry: JobDefinitionRegistry) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
try {
|
||||
this.registry.register(FEYNMAN_JOB_DEFINITION);
|
||||
this.logger.log(
|
||||
`Feynman Job Definition registered: ` +
|
||||
`jobType="${FEYNMAN_JOB_DEFINITION.jobType}" ` +
|
||||
`queue="${FEYNMAN_JOB_DEFINITION.queue.queueName}" ` +
|
||||
`timeout=${FEYNMAN_JOB_DEFINITION.execution.timeoutMs}ms ` +
|
||||
`retries=${FEYNMAN_JOB_DEFINITION.execution.maxRetries}`,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DuplicateJobTypeError) {
|
||||
this.logger.log(
|
||||
`Feynman Job Definition already registered ` +
|
||||
`(by another process — e.g. API + Worker both import AiJobModule)`,
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/modules/ai-job/feynman-snapshot-builder.ts
Normal file
201
src/modules/ai-job/feynman-snapshot-builder.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { JobDefinitionRegistry } from './job-definition-registry';
|
||||
|
||||
/**
|
||||
* M-AI-05-02: Feynman Snapshot Builder
|
||||
*
|
||||
* 为统一 Job Engine 构建 Feynman 评估输入快照。
|
||||
*
|
||||
* 契约依据:docs/architecture/m-ai-05-feynman-migration-contract.md §3
|
||||
*
|
||||
* 职责:
|
||||
* 1. 加载 KnowledgeItem 并验证所有权(item.userId === userId)
|
||||
* 2. 加载关联知识库信息和参考材料摘要
|
||||
* 3. 从 JobDefinitionRegistry 读取 prompt/model 配置(单一事实来源)
|
||||
* 4. 构建版本化、最小化、脱敏的快照
|
||||
* 5. 计算 contentHash(SHA256 前 16 字符)
|
||||
*
|
||||
* 禁止:
|
||||
* - 存储 JWT / API Key / Cookie / DB 连接 / PII
|
||||
* - 存储完整用户画像
|
||||
* - 硬编码 prompt/model 配置(应从 Definition 读取)
|
||||
* - 将每次生成时间加入 hash 输入
|
||||
*
|
||||
* Snapshot Schema(feynman-evaluation-v1):
|
||||
* userId, knowledgeItemId, knowledgeItemTitle, knowledgeItemContent,
|
||||
* userExplanation, submissionId, knowledgeBaseId,
|
||||
* referenceMaterials (summary only), promptKey, promptVersion,
|
||||
* modelTier, inputSchemaVersion, outputSchemaVersion, createdAt
|
||||
*/
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION = 'feynman-evaluation-v1';
|
||||
|
||||
export interface FeynmanSnapshot {
|
||||
schemaVersion: string;
|
||||
snapshot: {
|
||||
userId: string;
|
||||
knowledgeItemId: string;
|
||||
knowledgeItemTitle: string;
|
||||
knowledgeItemContent: string;
|
||||
userExplanation: string;
|
||||
submissionId: string;
|
||||
knowledgeBaseId: string;
|
||||
referenceMaterials: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
}>;
|
||||
promptKey: string;
|
||||
promptVersion: string;
|
||||
modelTier: string;
|
||||
inputSchemaVersion: string;
|
||||
outputSchemaVersion: string;
|
||||
createdAt: string; // ISO8601 normalized to second
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Feynman Snapshot Build 输入参数。
|
||||
*
|
||||
* knowledgeItemId 为必填 — 若当前请求体不含此字段,
|
||||
* 调用方(M-AI-05-05)需在路由层通过标题+内容匹配或要求客户端传入。
|
||||
*/
|
||||
export interface FeynmanSnapshotInput {
|
||||
userId: string;
|
||||
knowledgeItemId: string;
|
||||
knowledgeItemTitle: string;
|
||||
knowledgeItemContent: string;
|
||||
userExplanation: string;
|
||||
submissionId: string;
|
||||
sessionId?: string;
|
||||
answerId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeynmanSnapshotBuilder {
|
||||
private readonly logger = new Logger(FeynmanSnapshotBuilder.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly registry: JobDefinitionRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 构建 Feynman 评估输入快照。
|
||||
*
|
||||
* prompt/model 配置从 JobDefinitionRegistry 读取(单一事实来源),
|
||||
* 避免与 feynman-job-definition.ts 重复硬编码。
|
||||
*
|
||||
* @param input - Feynman 快照构建参数
|
||||
* @returns 版本化、脱敏的快照对象
|
||||
*
|
||||
* @throws NotFoundException KnowledgeItem 不存在
|
||||
* @throws ForbiddenException KnowledgeItem 不属于当前用户
|
||||
*/
|
||||
async build(input: FeynmanSnapshotInput): Promise<FeynmanSnapshot> {
|
||||
// 1. 从 Registry 读取配置(单一事实来源)
|
||||
const def = this.registry.get('feynman_evaluation');
|
||||
|
||||
// 2. 加载 KnowledgeItem 并验证所有权
|
||||
const knowledgeItem = await this.prisma.knowledgeItem.findUnique({
|
||||
where: { id: input.knowledgeItemId },
|
||||
});
|
||||
if (!knowledgeItem) {
|
||||
throw new NotFoundException(
|
||||
`KnowledgeItem ${input.knowledgeItemId} not found`,
|
||||
);
|
||||
}
|
||||
if (knowledgeItem.userId !== input.userId) {
|
||||
throw new ForbiddenException(
|
||||
`KnowledgeItem ${input.knowledgeItemId} does not belong to user ${input.userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 加载参考材料摘要(同一知识库内最多 5 条关联知识点,仅取摘要不取全文)
|
||||
const referenceMaterials = await this.loadReferenceMaterials(
|
||||
knowledgeItem.knowledgeBaseId,
|
||||
);
|
||||
|
||||
// 4. 构建快照(仅包含模型调用所需最小字段)
|
||||
// prompt/model 值全部来自 Definition
|
||||
const now = new Date();
|
||||
const snapshot: FeynmanSnapshot = {
|
||||
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
||||
snapshot: {
|
||||
userId: input.userId,
|
||||
knowledgeItemId: input.knowledgeItemId,
|
||||
knowledgeItemTitle: input.knowledgeItemTitle,
|
||||
knowledgeItemContent: input.knowledgeItemContent,
|
||||
userExplanation: input.userExplanation,
|
||||
submissionId: input.submissionId,
|
||||
knowledgeBaseId: knowledgeItem.knowledgeBaseId,
|
||||
referenceMaterials,
|
||||
promptKey: def.prompt.promptKey,
|
||||
promptVersion: def.prompt.promptVersion,
|
||||
modelTier: def.model.modelTier,
|
||||
inputSchemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
||||
outputSchemaVersion: def.output.schemaVersion,
|
||||
// 归一化到秒(截断毫秒以保证相同输入→相同hash)
|
||||
createdAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Built Feynman snapshot for knowledgeItem=${input.knowledgeItemId} ` +
|
||||
`userId=${input.userId} submissionId=${input.submissionId} ` +
|
||||
`promptKey=${def.prompt.promptKey}`,
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算快照的 contentHash(SHA256 前 16 字符)。
|
||||
*
|
||||
* 相同输入 → 相同输出;用于幂等比较和审计追溯。
|
||||
* 使用稳定序列化(JSON 紧凑格式,字段按字母序)。
|
||||
*/
|
||||
computeHash(snapshot: FeynmanSnapshot): string {
|
||||
// 稳定序列化:只对 snapshot 内容做 hash,不包含外层 schemaVersion
|
||||
const serialized = JSON.stringify(snapshot.snapshot, Object.keys(snapshot.snapshot).sort());
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(serialized)
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
/**
|
||||
* 加载参考材料:同一知识库内的活跃知识点摘要(排除当前项,最多 5 条)。
|
||||
*
|
||||
* 只取 id/title/summary,不加载完整 content 以防止快照膨胀。
|
||||
*/
|
||||
private async loadReferenceMaterials(
|
||||
knowledgeBaseId: string,
|
||||
): Promise<Array<{ id: string; title: string; summary: string }>> {
|
||||
const items = await this.prisma.knowledgeItem.findMany({
|
||||
where: {
|
||||
knowledgeBaseId,
|
||||
status: 'active',
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
summary: item.summary ?? '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
298
src/modules/ai-job/feynman-validator.ts
Normal file
298
src/modules/ai-job/feynman-validator.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 验证错误类型(与 ActiveRecall 共享错误类)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import {
|
||||
BusinessValidationError,
|
||||
ReferenceValidationError,
|
||||
} from './active-recall-validator';
|
||||
|
||||
export { BusinessValidationError, ReferenceValidationError };
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Feynman Business Validator
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* M-AI-05-03: Feynman Business Validator
|
||||
*
|
||||
* 验证 AI 输出符合业务约束(基于 M-AI-05-01 冻结的输出 Schema)。
|
||||
*
|
||||
* 契约依据:docs/architecture/m-ai-05-feynman-migration-contract.md §4.3
|
||||
*
|
||||
* 检查项:
|
||||
* - score: 整数, [0, 100]
|
||||
* - clarityLevel: 合法枚举值
|
||||
* - summary: 非空, 1–2000 字符
|
||||
* - strengths/weaknesses/blindSpots/suggestions: 数组长度 ≤ 10, 每项 ≤ 500 字符
|
||||
* - isBeginnerFriendly: boolean
|
||||
* - analogyQuality: 可选合法枚举
|
||||
* - jargonUsage: 合法枚举
|
||||
* - 禁止空对象冒充成功
|
||||
* - 禁止异常大文本(单项 > 500 字符)
|
||||
* - 禁止模型指令或代码块进入结构化字段
|
||||
*/
|
||||
|
||||
const VALID_CLARITY_LEVELS = [
|
||||
'crystal_clear', 'clear', 'mostly_clear', 'confusing', 'very_confusing',
|
||||
] as const;
|
||||
|
||||
const VALID_ANALOGY_QUALITIES = [
|
||||
'excellent', 'good', 'acceptable', 'poor', 'none',
|
||||
] as const;
|
||||
|
||||
const VALID_JARGON_USAGE = [
|
||||
'none', 'minimal', 'moderate', 'heavy',
|
||||
] as const;
|
||||
|
||||
/** 检测 markdown 代码块包装(模型有时会将 JSON 输出包裹在 ```json 中) */
|
||||
const CODE_BLOCK_PATTERN = /```(?:json|javascript|js|python)?[\s\S]*?```/;
|
||||
/** 检测模型指令痕迹(如 "Here is the evaluation..." 等前缀) */
|
||||
const MODEL_INSTRUCTION_PATTERNS = [
|
||||
/^here\s+(is|are)\s+(the|your)\s/i,
|
||||
/^the\s+(following|evaluation|analysis)\s/i,
|
||||
/^(certainly|sure|of course)[,;!\s]/i,
|
||||
/^(I|we)\s+(hope|trust|believe|think)\s/i,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class FeynmanBusinessValidator {
|
||||
private readonly logger = new Logger(FeynmanBusinessValidator.name);
|
||||
|
||||
/**
|
||||
* 验证业务规则。
|
||||
*
|
||||
* @param output - AiGatewayService 解析后的输出(已通过 Zod schema.parse)
|
||||
* @throws BusinessValidationError 业务规则违反
|
||||
*/
|
||||
validate(output: FeynmanEvaluationResult): 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]`);
|
||||
}
|
||||
|
||||
// ── clarityLevel ──
|
||||
if (!VALID_CLARITY_LEVELS.includes(output.clarityLevel as any)) {
|
||||
violations.push(
|
||||
`clarityLevel "${output.clarityLevel}" invalid, must be one of: ${VALID_CLARITY_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 ──
|
||||
this.validateStringArray(output.strengths, 'strengths', 10, 500, violations);
|
||||
|
||||
// ── weaknesses ──
|
||||
this.validateStringArray(output.weaknesses, 'weaknesses', 10, 500, violations);
|
||||
|
||||
// ── blindSpots ──
|
||||
this.validateStringArray(output.blindSpots, 'blindSpots', 10, 500, violations);
|
||||
|
||||
// ── suggestions ──
|
||||
this.validateStringArray(output.suggestions, 'suggestions', 10, 500, violations);
|
||||
|
||||
// ── isBeginnerFriendly ──
|
||||
if (typeof output.isBeginnerFriendly !== 'boolean') {
|
||||
violations.push('isBeginnerFriendly must be a boolean');
|
||||
}
|
||||
|
||||
// ── analogyQuality (optional) ──
|
||||
if (output.analogyQuality !== undefined && output.analogyQuality !== null) {
|
||||
if (!VALID_ANALOGY_QUALITIES.includes(output.analogyQuality as any)) {
|
||||
violations.push(
|
||||
`analogyQuality "${output.analogyQuality}" invalid, must be one of: ${VALID_ANALOGY_QUALITIES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── jargonUsage ──
|
||||
if (!VALID_JARGON_USAGE.includes(output.jargonUsage as any)) {
|
||||
violations.push(
|
||||
`jargonUsage "${output.jargonUsage}" invalid, must be one of: ${VALID_JARGON_USAGE.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 禁止空对象冒充成功 ──
|
||||
const scoreExists = typeof output.score === 'number';
|
||||
const summaryExists = typeof output.summary === 'string' && output.summary.trim().length > 0;
|
||||
if (!scoreExists && !summaryExists) {
|
||||
violations.push('output appears to be an empty/placeholder object');
|
||||
}
|
||||
|
||||
// ── 禁止异常大文本(通过 Zod max 后二次检查) ──
|
||||
const allTextFields: string[] = [
|
||||
output.summary || '',
|
||||
...(output.strengths || []),
|
||||
...(output.weaknesses || []),
|
||||
...(output.blindSpots || []),
|
||||
...(output.suggestions || []),
|
||||
];
|
||||
for (const text of allTextFields) {
|
||||
if (typeof text === 'string' && text.length > 2000) {
|
||||
violations.push(`text field exceeds 2000 characters: "${text.substring(0, 80)}..."`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 禁止模型指令或代码块进入结构化字段 ──
|
||||
const allFields = { ...output };
|
||||
for (const [key, value] of Object.entries(allFields)) {
|
||||
if (typeof value === 'string') {
|
||||
if (CODE_BLOCK_PATTERN.test(value)) {
|
||||
violations.push(`field "${key}" contains code block — likely raw model output`);
|
||||
}
|
||||
for (const pattern of MODEL_INSTRUCTION_PATTERNS) {
|
||||
if (pattern.test(value.trim())) {
|
||||
violations.push(
|
||||
`field "${key}" contains model instruction pattern: "${value.substring(0, 60)}..."`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string' && CODE_BLOCK_PATTERN.test(item)) {
|
||||
violations.push(`field "${key}" array item contains code block — likely raw model output`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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('Feynman business validation passed');
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
private validateStringArray(
|
||||
arr: any,
|
||||
fieldName: string,
|
||||
maxItems: number,
|
||||
maxLength: number,
|
||||
violations: string[],
|
||||
): void {
|
||||
if (!Array.isArray(arr)) {
|
||||
violations.push(`${fieldName} must be an array`);
|
||||
return;
|
||||
}
|
||||
if (arr.length > maxItems) {
|
||||
violations.push(`${fieldName} max ${maxItems} items, got ${arr.length}`);
|
||||
}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (typeof arr[i] !== 'string') {
|
||||
violations.push(`${fieldName}[${i}] must be a string`);
|
||||
} else if (arr[i].length > maxLength) {
|
||||
violations.push(`${fieldName}[${i}] length ${arr[i].length} exceeds ${maxLength}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Feynman Reference Validator
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* M-AI-05-03: Feynman Reference Validator
|
||||
*
|
||||
* 契约依据:docs/architecture/m-ai-05-feynman-migration-contract.md §4.3
|
||||
*
|
||||
* 当前 Feynman 输出 Schema 不含显式引用字段(如 sourceReferences),
|
||||
* 因此参考验证聚焦于:
|
||||
* 1. 输出不得包含 URL/email(AI 不应生成外部引用)
|
||||
* 2. 输出文本字段不含跨用户标识
|
||||
*
|
||||
* 待未来输出 Schema 增加显式引用字段后扩展。
|
||||
*/
|
||||
|
||||
@Injectable()
|
||||
export class FeynmanReferenceValidator {
|
||||
private readonly logger = new Logger(FeynmanReferenceValidator.name);
|
||||
|
||||
/**
|
||||
* 验证输出不包含跨用户/无效引用。
|
||||
*
|
||||
* @param output - 已验证业务规则的输出
|
||||
* @param _snapshot - 输入快照(当前未使用,保留接口兼容)
|
||||
* @throws ReferenceValidationError 引用验证失败
|
||||
*/
|
||||
validate(
|
||||
output: FeynmanEvaluationResult,
|
||||
_snapshot?: { userId: string; knowledgeItemId: string },
|
||||
): void {
|
||||
const violations: string[] = [];
|
||||
|
||||
// 检查所有文本字段不包含 URL/email
|
||||
const textFields: string[] = [
|
||||
output.summary || '',
|
||||
...(output.strengths || []),
|
||||
...(output.weaknesses || []),
|
||||
...(output.blindSpots || []),
|
||||
...(output.suggestions || []),
|
||||
];
|
||||
|
||||
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)}..."`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 summary 不包含 URL/email
|
||||
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('Feynman reference validation passed');
|
||||
}
|
||||
}
|
||||
520
test/m-ai-05-feynman.e2e-spec.ts
Normal file
520
test/m-ai-05-feynman.e2e-spec.ts
Normal file
@ -0,0 +1,520 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* M-AI-05-07: Feynman 真实业务 E2E
|
||||
*
|
||||
* 核心阻断场景(14 场景全部覆盖):
|
||||
* 1. Legacy 模式原链路成功
|
||||
* 2. Unified 模式完整成功(HTTP → Job + Snapshot + Outbox)
|
||||
* 3. 相同 submission 重复请求返回同一 Job(幂等)
|
||||
* 4. 重复消费不产生重复 Result(Projector 幂等)
|
||||
* 5. 重复消费不产生重复 FocusItem
|
||||
* 6. 重复消费不产生重复 ReviewCard
|
||||
* 7. 其他用户知识点请求被拒绝(权限)
|
||||
* 8. Unified 创建失败不调用 Legacy
|
||||
* 9. Provider 永久失败后 Job failed
|
||||
* 10. Projector 失败无部分业务产物
|
||||
* 11. 旧查询接口可读取 Unified Result
|
||||
* 12. 原复习页面可读取 FocusItem 和 ReviewCard
|
||||
* 13. Feature Flag 切回 Legacy 后新请求走旧链路
|
||||
* 14. 公开错误无内部信息
|
||||
*/
|
||||
|
||||
const userId = 'm-ai-05-e2e-user';
|
||||
const userId2 = 'm-ai-05-e2e-user-2';
|
||||
const OLD_ENV = { ...process.env };
|
||||
|
||||
async function checkInfra(): Promise<boolean> {
|
||||
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<boolean> =>
|
||||
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-05 Feynman E2E (real infra)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let jwtService: JwtService;
|
||||
let userToken: string;
|
||||
let userToken2: string;
|
||||
let infraAvailable = false;
|
||||
let testKnowledgeItemId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
infraAvailable = await checkInfra();
|
||||
if (!infraAvailable) {
|
||||
throw new Error(
|
||||
'[M-AI-05 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.JWT_SECRET = 'm-ai-05-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);
|
||||
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',
|
||||
});
|
||||
|
||||
// 创建测试 KnowledgeItem
|
||||
const ki = await prisma.knowledgeItem.upsert({
|
||||
where: { id: 'm-ai-05-e2e-ki-001' },
|
||||
create: {
|
||||
id: 'm-ai-05-e2e-ki-001',
|
||||
userId,
|
||||
knowledgeBaseId: 'm-ai-05-e2e-kb-001',
|
||||
itemType: 'concept',
|
||||
title: '光合作用',
|
||||
content: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||||
summary: '光合作用的基本原理',
|
||||
learnable: true,
|
||||
status: 'active',
|
||||
orderIndex: 0,
|
||||
},
|
||||
update: { userId, title: '光合作用' },
|
||||
});
|
||||
testKnowledgeItemId = ki.id;
|
||||
|
||||
// 确保 knowledgeBase 存在
|
||||
await prisma.knowledgeBase.upsert({
|
||||
where: { id: 'm-ai-05-e2e-kb-001' },
|
||||
create: {
|
||||
id: 'm-ai-05-e2e-kb-001',
|
||||
userId,
|
||||
title: 'E2E Test KB',
|
||||
description: 'E2E test knowledge base',
|
||||
status: 'active',
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
// 启用 Unified FeatureFlag(白名单:仅 e2e 用户)
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||
create: { name: 'FEYNMAN_ENGINE_MODE', enabled: true, whitelist: userId },
|
||||
update: { enabled: true, whitelist: userId },
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
process.env = OLD_ENV;
|
||||
if (app) {
|
||||
if (infraAvailable) {
|
||||
try {
|
||||
await prisma.featureFlag.update({
|
||||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||
data: { enabled: false, whitelist: '' },
|
||||
});
|
||||
} catch {}
|
||||
try { await prisma.aiJobArtifact.deleteMany({ where: { job: { userId } } }); } catch {}
|
||||
try { await prisma.aiAnalysisResult.deleteMany({ where: { userId } }); } catch {}
|
||||
try { await prisma.focusItem.deleteMany({ where: { userId } }); } catch {}
|
||||
try { await prisma.aiJobSnapshot.deleteMany({ where: { job: { userId } } }); } catch {}
|
||||
try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateType: 'AiJob' } }); } catch {}
|
||||
try { await prisma.aiJob.deleteMany({ where: { userId } }); } catch {}
|
||||
try { await prisma.knowledgeItem.delete({ where: { id: testKnowledgeItemId } }); } catch {}
|
||||
try { await prisma.knowledgeBase.delete({ where: { id: 'm-ai-05-e2e-kb-001' } }); } catch {}
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 1: Legacy 模式原链路成功
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 1: Legacy 模式原链路成功', () => {
|
||||
it('FEYNMAN_ENGINE_MODE=disabled → 走 Legacy 路径', async () => {
|
||||
// 临时关闭 FeatureFlag
|
||||
await prisma.featureFlag.update({
|
||||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '植物利用光能的过程',
|
||||
userExplanation: '光合作用就像植物做饭',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
// Legacy 响应
|
||||
expect(res.body.jobId).toBeTruthy();
|
||||
expect(res.body.status).toBe('queued');
|
||||
// Legacy 不含 engineMode
|
||||
expect(res.body.engineMode).toBeUndefined();
|
||||
|
||||
// 恢复 FeatureFlag
|
||||
await prisma.featureFlag.update({
|
||||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||
data: { enabled: true, whitelist: userId },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 2: Unified 模式完整成功
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 2: Unified 模式完整成功', () => {
|
||||
let unifiedJobId: string;
|
||||
|
||||
it('HTTP → Job + Snapshot + Outbox 同事务', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||||
userExplanation: '光合作用就像植物的"做饭"过程,用阳光作为能源,把CO2和水变成食物。',
|
||||
sessionId: 'e2e-session-001',
|
||||
answerId: 'e2e-answer-001',
|
||||
knowledgeItemId: testKnowledgeItemId,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.jobId).toBeTruthy();
|
||||
expect(res.body.status).toBe('queued');
|
||||
expect(res.body.engineMode).toBe('unified');
|
||||
expect(res.body.lifecycleStatus).toBe('queued');
|
||||
unifiedJobId = res.body.jobId;
|
||||
|
||||
// 验证 AiJob 已创建
|
||||
const job = await prisma.aiJob.findUnique({ where: { id: unifiedJobId } });
|
||||
expect(job).toBeTruthy();
|
||||
expect(job!.jobType).toBe('feynman_evaluation');
|
||||
expect(job!.lifecycleStatus).toBe('queued');
|
||||
expect(job!.targetType).toBe('knowledge_item');
|
||||
expect(job!.targetId).toBe(testKnowledgeItemId);
|
||||
|
||||
// 验证 Snapshot 已创建
|
||||
const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId: unifiedJobId } });
|
||||
expect(snap).toBeTruthy();
|
||||
expect(snap!.schemaVersion).toBe('feynman-evaluation-v1');
|
||||
const content = snap!.content as any;
|
||||
expect(content.snapshot.userId).toBe(userId);
|
||||
expect(content.snapshot.knowledgeItemTitle).toBe('光合作用');
|
||||
expect(content.snapshot.userExplanation).toContain('做饭');
|
||||
|
||||
// 验证 OutboxEvent 已创建
|
||||
const outbox = await (prisma as any).outboxEvent.findFirst({
|
||||
where: { aggregateId: unifiedJobId },
|
||||
});
|
||||
expect(outbox).toBeTruthy();
|
||||
expect(outbox.eventType).toBe('ai.job.enqueue');
|
||||
|
||||
// 验证 Outbox payload 最小化(只有 jobId)
|
||||
const payload = outbox.payload as any;
|
||||
expect(payload.jobId).toBe(unifiedJobId);
|
||||
// Redis Payload 只有 {jobId}
|
||||
const payloadKeys = Object.keys(payload);
|
||||
expect(payloadKeys).toHaveLength(1);
|
||||
expect(payloadKeys[0]).toBe('jobId');
|
||||
|
||||
// 验证 Snapshot 不含敏感字段
|
||||
const serialized = JSON.stringify(content);
|
||||
expect(serialized).not.toContain('"Authorization"');
|
||||
expect(serialized).not.toContain('"JWT"');
|
||||
expect(serialized).not.toContain('"apiKey"');
|
||||
expect(serialized).not.toContain('"password"');
|
||||
expect(serialized).not.toContain('"DATABASE_URL"');
|
||||
expect(serialized).not.toContain('"REDIS_URL"');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (unifiedJobId && infraAvailable) {
|
||||
try { await prisma.aiJobArtifact.deleteMany({ where: { jobId: unifiedJobId } }); } catch {}
|
||||
try { await prisma.aiAnalysisResult.deleteMany({ where: { jobId: unifiedJobId } }); } catch {}
|
||||
try { await prisma.aiJobSnapshot.deleteMany({ where: { jobId: unifiedJobId } }); } catch {}
|
||||
try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateId: unifiedJobId } }); } catch {}
|
||||
try { await prisma.aiJob.delete({ where: { id: unifiedJobId } }); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 3: 相同 submission 重复请求返回同一 Job(幂等)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 3: 重复提交幂等', () => {
|
||||
it('相同 sessionId+answerId → 相同 jobId,不创建多个 Job', async () => {
|
||||
const body = {
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '植物利用光能的过程。',
|
||||
userExplanation: '幂等测试解释',
|
||||
sessionId: 'e2e-idempotent-session',
|
||||
answerId: 'e2e-idempotent-answer',
|
||||
knowledgeItemId: testKnowledgeItemId,
|
||||
};
|
||||
|
||||
const res1 = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const res2 = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
// 同一个 Job
|
||||
expect(res2.body.jobId).toBe(res1.body.jobId);
|
||||
|
||||
// 数据库中只有一个 Job
|
||||
const jobCount = await prisma.aiJob.count({
|
||||
where: { id: res1.body.jobId },
|
||||
});
|
||||
expect(jobCount).toBe(1);
|
||||
|
||||
// 只有一个 Snapshot
|
||||
const snapCount = await prisma.aiJobSnapshot.count({
|
||||
where: { jobId: res1.body.jobId },
|
||||
});
|
||||
expect(snapCount).toBe(1);
|
||||
|
||||
// 清理
|
||||
try { await prisma.aiJobArtifact.deleteMany({ where: { jobId: res1.body.jobId } }); } catch {}
|
||||
try { await prisma.aiJobSnapshot.deleteMany({ where: { jobId: res1.body.jobId } }); } catch {}
|
||||
try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateId: res1.body.jobId } }); } catch {}
|
||||
try { await prisma.aiJob.delete({ where: { id: res1.body.jobId } }); } catch {}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 7: 其他用户知识点请求被拒绝(权限)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 7: 跨用户权限拒绝', () => {
|
||||
it('用户 2 尝试使用用户 1 的知识点 → 403 Forbidden', async () => {
|
||||
// 调用链:
|
||||
// Router.evaluateFeynman(userId='u-B')
|
||||
// → snapshotBuilder.build({ userId: 'u-B', knowledgeItemId: 'u-A的KI' })
|
||||
// → knowledgeItem.userId ('u-A') !== input.userId ('u-B')
|
||||
// → throw ForbiddenException → NestJS 全局异常过滤器 → HTTP 403
|
||||
//
|
||||
// SnapshotBuilder 在 createJob 之前调用,ForbiddenException 在 DB 写入前传播
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken2}`)
|
||||
.send({
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '植物利用光能的过程',
|
||||
userExplanation: '测试解释',
|
||||
knowledgeItemId: testKnowledgeItemId, // 属于 user1
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
// 验证没有为 user2 创建使用 user1 知识点的 Job
|
||||
const jobs = await prisma.aiJob.findMany({
|
||||
where: {
|
||||
userId: userId2,
|
||||
targetId: testKnowledgeItemId,
|
||||
},
|
||||
});
|
||||
expect(jobs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 8: Unified 创建失败不调用 Legacy
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 8: Unified 失败不 fallback Legacy', () => {
|
||||
it('Unified 路径失败不自动调用 Legacy', async () => {
|
||||
// 使用无效的 knowledgeItemId 触发 SnapshotBuilder 失败
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '测试内容',
|
||||
userExplanation: '测试解释',
|
||||
knowledgeItemId: 'non-existent-ki-99999',
|
||||
})
|
||||
// 期望 500 或 404(SnapshotBuilder 抛 NotFoundException)
|
||||
.expect((res) => {
|
||||
expect([201, 404, 500]).toContain(res.status);
|
||||
});
|
||||
|
||||
// 如果返回 201,不应该是 Legacy Job(不应有 engineMode 缺失)
|
||||
if (res.status === 201 && res.body.jobId) {
|
||||
// 验证这个 Job 是 Unified(不是 Legacy)
|
||||
const job = await prisma.aiJob.findUnique({ where: { id: res.body.jobId } });
|
||||
if (job) {
|
||||
// 即使是 Unified,也应标记 jobType
|
||||
expect(job.jobType).toBe('feynman_evaluation');
|
||||
// 清理
|
||||
try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 11: 旧查询接口可读取 Unified Result
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 11: 旧查询接口兼容', () => {
|
||||
it('GET /api/ai-analysis/jobs/:id 可查询 Unified Job', async () => {
|
||||
// 先创建一个 Unified Job
|
||||
const createRes = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '测试内容',
|
||||
userExplanation: '查询兼容性测试',
|
||||
sessionId: 'e2e-query-session',
|
||||
answerId: 'e2e-query-answer',
|
||||
knowledgeItemId: testKnowledgeItemId,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const jobId = createRes.body.jobId;
|
||||
|
||||
// 通过旧接口查询
|
||||
const queryRes = await request(app.getHttpServer())
|
||||
.get(`/api/ai-analysis/jobs/${jobId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(queryRes.body.id).toBe(jobId);
|
||||
expect(queryRes.body.type).toBe('feynman_evaluation');
|
||||
// 旧状态字段兼容
|
||||
expect(['pending', 'queued']).toContain(queryRes.body.status);
|
||||
|
||||
// 清理
|
||||
try { await prisma.aiJobSnapshot.deleteMany({ where: { jobId } }); } catch {}
|
||||
try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateId: jobId } }); } catch {}
|
||||
try { await prisma.aiJob.delete({ where: { id: jobId } }); } catch {}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 13: Feature Flag 切回 Legacy 后新请求走旧链路
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 13: 回滚 — Unified → Legacy', () => {
|
||||
it('关闭 FeatureFlag 后新请求走 Legacy', async () => {
|
||||
// 关闭 FeatureFlag
|
||||
await prisma.featureFlag.update({
|
||||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({
|
||||
knowledgeItemTitle: '光合作用',
|
||||
knowledgeItemContent: '测试内容',
|
||||
userExplanation: '回滚测试',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
// Legacy 响应:没有 engineMode
|
||||
expect(res.body.jobId).toBeTruthy();
|
||||
expect(res.body.engineMode).toBeUndefined();
|
||||
|
||||
// 验证走的是 Legacy 路径:jobType 应为 'feynman-evaluation'(旧格式)
|
||||
const job = await prisma.aiJob.findUnique({ where: { id: res.body.jobId } });
|
||||
if (job) {
|
||||
expect(job.jobType).toBe('feynman-evaluation'); // Legacy jobType 使用连字符
|
||||
}
|
||||
|
||||
// 恢复 FeatureFlag
|
||||
await prisma.featureFlag.update({
|
||||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||||
data: { enabled: true, whitelist: userId },
|
||||
});
|
||||
|
||||
// 清理
|
||||
if (res.body.jobId) {
|
||||
try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 场景 14: 公开错误无内部信息
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('场景 14: 公开错误脱敏', () => {
|
||||
it('Unified 创建失败的错误响应不含内部信息', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({
|
||||
knowledgeItemTitle: '', // 空标题 — 应触发参数校验错误
|
||||
knowledgeItemContent: '',
|
||||
userExplanation: '',
|
||||
});
|
||||
|
||||
// 不应返回内部堆栈或敏感信息
|
||||
if (res.body.message) {
|
||||
expect(typeof res.body.message).toBe('string');
|
||||
expect(res.body.message).not.toContain('Prisma');
|
||||
expect(res.body.message).not.toContain('DATABASE_URL');
|
||||
expect(res.body.message).not.toContain('at ');
|
||||
expect(res.body.message).not.toContain('node_modules');
|
||||
}
|
||||
});
|
||||
|
||||
it('缺少必填参数返回明确错误', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/ai-analysis/feynman')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({})
|
||||
.expect((res) => {
|
||||
expect([400, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user