diff --git a/src/workers/ai-analysis.worker.ts b/src/workers/ai-analysis.worker.ts index b23df34..05885e0 100644 --- a/src/workers/ai-analysis.worker.ts +++ b/src/workers/ai-analysis.worker.ts @@ -1,10 +1,18 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { Logger } from '@nestjs/common'; +import { Logger, Optional } from '@nestjs/common'; import { Job } from 'bullmq'; import { QUEUE_AI_ANALYSIS } from '../infrastructure/queue/queue.service'; +import { EventBusService } from '../common/event-bus/event-bus.service'; +import { BaseDomainEvent } from '../common/events/base-domain.event'; import { ActiveRecallAnalysisWorkflow } from '../modules/ai/workflows/active-recall-analysis.workflow'; import { FeynmanEvaluationWorkflow } from '../modules/ai/workflows/feynman-evaluation.workflow'; import { AiAnalysisRepository } from '../modules/ai-analysis/ai-analysis.repository'; +import { FocusItemsService } from '../modules/focus-items/focus-items.service'; + +class AIAnalysisCompleted extends BaseDomainEvent { + eventType = 'ai.analysis.completed'; + constructor(public readonly payload: Record) { super(); } +} @Processor(QUEUE_AI_ANALYSIS) export class AiAnalysisWorker extends WorkerHost { @@ -14,6 +22,8 @@ export class AiAnalysisWorker extends WorkerHost { private readonly recallWorkflow: ActiveRecallAnalysisWorkflow, private readonly feynmanWorkflow: FeynmanEvaluationWorkflow, private readonly repository: AiAnalysisRepository, + @Optional() private readonly eventBus?: EventBusService, + @Optional() private readonly focusItems?: FocusItemsService, ) { super(); } @@ -22,43 +32,69 @@ export class AiAnalysisWorker extends WorkerHost { jobId: string; userId: string; type: 'active-recall' | 'feynman-evaluation'; - // active-recall fields questionText?: string; knowledgeItemContent?: string; userAnswer?: string; - // feynman fields knowledgeItemTitle?: string; userExplanation?: string; + sessionId?: string; + answerId?: string; }>) { - const { jobId, userId, type, knowledgeItemContent } = job.data; + const { jobId, userId, type, knowledgeItemContent, sessionId, answerId } = job.data; this.logger.log(`Processing AI analysis job ${job.id}, dbJobId=${jobId}, type=${type}`); try { await this.repository.updateJobStatus(jobId, 'processing'); + let result: any; if (type === 'feynman-evaluation') { - const result = await this.feynmanWorkflow.execute({ + result = await this.feynmanWorkflow.execute({ userId, knowledgeItemTitle: job.data.knowledgeItemTitle || '', knowledgeItemContent: knowledgeItemContent || '', userExplanation: job.data.userExplanation || '', }); - await this.repository.createResult(userId, jobId, result); - await this.repository.updateJobStatus(jobId, 'completed'); - this.logger.log(`AI analysis job ${job.id} completed (feynman), score=${result.score}`); - return result; + } else { + result = await this.recallWorkflow.execute({ + userId, + questionText: job.data.questionText || '', + knowledgeItemContent: knowledgeItemContent || '', + userAnswer: job.data.userAnswer || '', + }); } - // active-recall (default) - const result = await this.recallWorkflow.execute({ - userId, - questionText: job.data.questionText || '', - knowledgeItemContent: knowledgeItemContent || '', - userAnswer: job.data.userAnswer || '', - }); await this.repository.createResult(userId, jobId, result); await this.repository.updateJobStatus(jobId, 'completed'); - this.logger.log(`AI analysis job ${job.id} completed (recall), score=${result.score}`); + + // Publish AIAnalysisCompleted event for Review Engine + try { + this.eventBus?.publish(new AIAnalysisCompleted({ + userId, + jobId, + sessionId: sessionId || null, + answerId: answerId || null, + type, + score: result.score, + analysis: result, + timestamp: new Date().toISOString(), + })); + } catch {} + + // Generate FocusItems for weaknesses + if (result.weaknesses?.length > 0) { + for (const w of result.weaknesses) { + try { + await this.focusItems?.create(userId, { + knowledgeBaseId: result.knowledgeBaseId || 'unknown', + title: w, + source: 'ai-analysis', + status: 'pending', + }); + } catch {} + } + } + + this.logger.log(`AI analysis job ${job.id} completed, type=${type}, score=${result.score}`); return result; } catch (err: any) { this.logger.error(`AI analysis job ${job.id} failed: ${err.message}`); diff --git a/test/m3.e2e-spec.ts b/test/m3.e2e-spec.ts new file mode 100644 index 0000000..24fd19c --- /dev/null +++ b/test/m3.e2e-spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('M3 E2E Tests', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); + await app.init(); + }); + + afterAll(async () => { await app.close(); }); + + async function loginAdmin(): Promise { + const res = await request(app.getHttpServer()) + .post('/admin-api/auth/login') + .send({ email: 'admin@zhixi.app', password: 'admin123' }); + return res.body?.data?.accessToken || ''; + } + + // ══════════════════════════════════════════════ + // M3-01: Learning Engine + // ══════════════════════════════════════════════ + describe('M3-01 Learning Engine', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('POST /api/learning-sessions → 201 create session', async () => { + const res = await request(app.getHttpServer()) + .post('/api/learning-sessions') + .send({ knowledgeItemId: 'ki-1', title: 'Test Session' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('id'); + }); + + it('GET /api/learning-sessions → 200 list sessions', async () => { + const res = await request(app.getHttpServer()) + .get('/api/learning-sessions') + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('POST /api/ai-analysis → 201 queue analysis', async () => { + const res = await request(app.getHttpServer()) + .post('/api/ai-analysis') + .send({ questionText: 'What is this?', knowledgeItemContent: 'Test content', userAnswer: 'Test answer', sessionId: 's1' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('jobId'); + }); + + it('POST /api/ai-analysis/feynman → 201 queue feynman eval', async () => { + const res = await request(app.getHttpServer()) + .post('/api/ai-analysis/feynman') + .send({ knowledgeItemTitle: 'Test', knowledgeItemContent: 'Content', userExplanation: 'Explanation', sessionId: 's1' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('jobId'); + }); + + it('GET /api/focus-items → 200 list focus items', async () => { + const res = await request(app.getHttpServer()) + .get('/api/focus-items') + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('GET /api/activity/summary → 200 learning summary', async () => { + const res = await request(app.getHttpServer()) + .get('/api/activity/summary') + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + }); +});