Compare commits
37 Commits
main
..
6e569ff478
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e569ff478 | |||
| a83de861dd | |||
| 0b0da09d4b | |||
| d7cd5641d7 | |||
| c53f26a07e | |||
| b15e821252 | |||
| 990b8c7b83 | |||
| f8df92c36b | |||
| 51f2a41cc3 | |||
| 0a3a8a2e32 | |||
| 9303d7ac64 | |||
| 02f4ab23f7 | |||
| 7fd2a4cda2 | |||
| 7b1103903f | |||
| 3cc3b28471 | |||
| 5c82c75a09 | |||
| 24ffc028e2 | |||
| 734c0129d8 | |||
| 1224a74e63 | |||
| c015ea3697 | |||
| 240aea24aa | |||
| 54762ca299 | |||
| eba30517a6 | |||
| 35b1c6c37d | |||
| 3993099907 | |||
| 57898f939c | |||
| e782d180d7 | |||
| 17ddfa83bf | |||
| 83483d8117 | |||
| 29bac74b58 | |||
| 5b5f14674d | |||
| 82a9e75842 | |||
| 7f8e7214b3 | |||
| eb0798de5b | |||
| 33e48f6d4e | |||
| b139ae18b7 | |||
| 68371922ca |
@@ -1,10 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { ModelConfig } from '../types';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiService {
|
||||
private readonly logger = new Logger(ApiService.name);
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
// Simple health check method
|
||||
@@ -23,7 +25,7 @@ export class ApiService {
|
||||
const response = await llm.invoke(prompt);
|
||||
return response.content.toString();
|
||||
} catch (error) {
|
||||
console.error('LangChain call failed:', error);
|
||||
this.logger.error('LangChain call failed:', error);
|
||||
if (error.message?.includes('401')) {
|
||||
throw new Error(this.i18nService.getMessage('invalidApiKey'));
|
||||
}
|
||||
|
||||
@@ -31,34 +31,11 @@ import { ImportTaskModule } from './import-task/import-task.module';
|
||||
import { AssessmentModule } from './assessment/assessment.module';
|
||||
import { I18nMiddleware } from './i18n/i18n.middleware';
|
||||
import { TenantMiddleware } from './tenant/tenant.middleware';
|
||||
import { User } from './user/user.entity';
|
||||
import { UserSetting } from './user/user-setting.entity';
|
||||
import { ModelConfig } from './model-config/model-config.entity';
|
||||
import { KnowledgeBase } from './knowledge-base/knowledge-base.entity';
|
||||
import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
|
||||
import { SearchHistory } from './search-history/search-history.entity';
|
||||
import { ChatMessage } from './search-history/chat-message.entity';
|
||||
import { Note } from './note/note.entity';
|
||||
import { NoteCategory } from './note/note-category.entity';
|
||||
import { PodcastEpisode } from './podcasts/entities/podcast-episode.entity';
|
||||
import { ImportTask } from './import-task/import-task.entity';
|
||||
import { AssessmentSession } from './assessment/entities/assessment-session.entity';
|
||||
import { AssessmentQuestion } from './assessment/entities/assessment-question.entity';
|
||||
import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity';
|
||||
import { AssessmentTemplate } from './assessment/entities/assessment-template.entity';
|
||||
import { QuestionBank } from './assessment/entities/question-bank.entity';
|
||||
import { QuestionBankItem } from './assessment/entities/question-bank-item.entity';
|
||||
import { Tenant } from './tenant/tenant.entity';
|
||||
import { TenantSetting } from './tenant/tenant-setting.entity';
|
||||
import { ApiKey } from './auth/entities/api-key.entity';
|
||||
import { TenantMember } from './tenant/tenant-member.entity';
|
||||
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { SuperAdminModule } from './super-admin/super-admin.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { FeishuModule } from './feishu/feishu.module';
|
||||
import { FeishuBot } from './feishu/entities/feishu-bot.entity';
|
||||
import { FeishuAssessmentSession } from './feishu/entities/feishu-assessment-session.entity';
|
||||
import { AssessmentCertificate } from './assessment/entities/assessment-certificate.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -77,33 +54,8 @@ import { AssessmentCertificate } from './assessment/entities/assessment-certific
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'better-sqlite3',
|
||||
database: configService.get<string>('DATABASE_PATH'),
|
||||
entities: [
|
||||
User,
|
||||
UserSetting,
|
||||
ModelConfig,
|
||||
KnowledgeBase,
|
||||
KnowledgeGroup,
|
||||
SearchHistory,
|
||||
ChatMessage,
|
||||
Note,
|
||||
NoteCategory,
|
||||
PodcastEpisode,
|
||||
ImportTask,
|
||||
AssessmentSession,
|
||||
AssessmentQuestion,
|
||||
AssessmentAnswer,
|
||||
AssessmentTemplate,
|
||||
QuestionBank,
|
||||
QuestionBankItem,
|
||||
Tenant,
|
||||
TenantSetting,
|
||||
TenantMember,
|
||||
ApiKey,
|
||||
FeishuBot,
|
||||
FeishuAssessmentSession,
|
||||
AssessmentCertificate,
|
||||
],
|
||||
synchronize: true, // Auto-create database schema. Disable in production.
|
||||
autoLoadEntities: true,
|
||||
synchronize: true,
|
||||
}),
|
||||
}),
|
||||
AuthModule,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AssessmentService } from './assessment.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { ExportService } from './services/export.service';
|
||||
import { AuditLogService } from './services/audit-log.service';
|
||||
|
||||
describe('AssessmentController', () => {
|
||||
let controller: AssessmentController;
|
||||
@@ -23,8 +25,10 @@ describe('AssessmentController', () => {
|
||||
controllers: [AssessmentController],
|
||||
providers: [
|
||||
{ provide: AssessmentService, useFactory: mockService },
|
||||
{ provide: 'UserService', useFactory: mockService },
|
||||
{ provide: UserService, useFactory: mockService },
|
||||
{ provide: TenantService, useFactory: mockService },
|
||||
{ provide: ExportService, useFactory: mockService },
|
||||
{ provide: AuditLogService, useFactory: () => ({ log: jest.fn() }) },
|
||||
{ provide: Reflector, useFactory: mockReflector },
|
||||
{ provide: CombinedAuthGuard, useFactory: mockGuard },
|
||||
],
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
Delete,
|
||||
Put,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { AssessmentService } from './assessment.service';
|
||||
import { ExportService } from './services/export.service';
|
||||
import { AuditLogService } from './services/audit-log.service';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
@@ -25,9 +27,12 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
@Controller('assessment')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
export class AssessmentController {
|
||||
private readonly logger = new Logger(AssessmentController.name);
|
||||
|
||||
constructor(
|
||||
private readonly assessmentService: AssessmentService,
|
||||
private readonly exportService: ExportService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
@Post('start')
|
||||
@@ -38,16 +43,18 @@ export class AssessmentController {
|
||||
body: { knowledgeBaseId?: string; language?: string; templateId?: string },
|
||||
) {
|
||||
const { id: userId, tenantId } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
|
||||
this.logger.log(
|
||||
`startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
|
||||
);
|
||||
return this.assessmentService.startSession(
|
||||
const session = await this.assessmentService.startSession(
|
||||
userId,
|
||||
body.knowledgeBaseId,
|
||||
tenantId,
|
||||
body.language,
|
||||
body.templateId,
|
||||
);
|
||||
this.auditLog.log({ userId, tenantId, action: 'session.start', resourceType: 'assessment_session', resourceId: session.id });
|
||||
return session;
|
||||
}
|
||||
|
||||
@Post(':id/answer')
|
||||
@@ -57,24 +64,26 @@ export class AssessmentController {
|
||||
@Param('id') sessionId: string,
|
||||
@Body() body: { answer: string; language?: string },
|
||||
) {
|
||||
const { id: userId } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
|
||||
const { id: userId, tenantId } = req.user;
|
||||
this.logger.log(
|
||||
`submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
|
||||
);
|
||||
return this.assessmentService.submitAnswer(
|
||||
const result = await this.assessmentService.submitAnswer(
|
||||
sessionId,
|
||||
userId,
|
||||
body.answer,
|
||||
body.language,
|
||||
);
|
||||
this.auditLog.log({ userId, tenantId, action: 'session.answer', resourceType: 'assessment_session', resourceId: sessionId, details: { answerLength: body.answer?.length } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Sse(':id/start-stream')
|
||||
@ApiOperation({ summary: 'Stream initial session generation' })
|
||||
startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
|
||||
const { id: userId } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`,
|
||||
this.logger.log(
|
||||
`startSessionStream: user=${userId}, session=${sessionId}`,
|
||||
);
|
||||
return this.assessmentService
|
||||
.startSessionStream(sessionId, userId)
|
||||
@@ -92,8 +101,8 @@ export class AssessmentController {
|
||||
@Query('language') language?: string,
|
||||
) {
|
||||
const { id: userId } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] >>> submitAnswerStream CALLED: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
|
||||
this.logger.log(
|
||||
`submitAnswerStream: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
|
||||
);
|
||||
return this.assessmentService
|
||||
.submitAnswerStream(sessionId, userId, answer, language)
|
||||
@@ -104,8 +113,8 @@ export class AssessmentController {
|
||||
@ApiOperation({ summary: 'Get the current state of an assessment session' })
|
||||
async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
|
||||
const { id: userId } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`,
|
||||
this.logger.log(
|
||||
`getSessionState: user=${userId}, session=${sessionId}`,
|
||||
);
|
||||
return this.assessmentService.getSessionState(sessionId, userId);
|
||||
}
|
||||
@@ -114,10 +123,12 @@ export class AssessmentController {
|
||||
@ApiOperation({ summary: 'Delete an assessment session' })
|
||||
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
|
||||
const user = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
|
||||
this.logger.log(
|
||||
`deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
|
||||
);
|
||||
return this.assessmentService.deleteSession(sessionId, user);
|
||||
await this.assessmentService.deleteSession(sessionId, user);
|
||||
this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.delete', resourceType: 'assessment_session', resourceId: sessionId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get(':id/certificate')
|
||||
@@ -127,8 +138,8 @@ export class AssessmentController {
|
||||
@Param('id') sessionId: string,
|
||||
) {
|
||||
const { id: userId, tenantId } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] getCertificate: user=${userId}, session=${sessionId}`,
|
||||
this.logger.log(
|
||||
`getCertificate: user=${userId}, session=${sessionId}`,
|
||||
);
|
||||
return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
|
||||
}
|
||||
@@ -170,8 +181,8 @@ export class AssessmentController {
|
||||
@Query('knowledgeGroupId') knowledgeGroupId?: string,
|
||||
) {
|
||||
const { id: userId, tenantId, role } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
|
||||
this.logger.log(
|
||||
`getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
|
||||
);
|
||||
return this.assessmentService.getStats(
|
||||
userId,
|
||||
@@ -216,6 +227,26 @@ export class AssessmentController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@ApiOperation({ summary: 'Batch delete assessment sessions (admin only)' })
|
||||
async batchDelete(@Request() req: any, @Body() body: { ids: string[] }) {
|
||||
const user = req.user;
|
||||
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('Only admin can batch delete');
|
||||
}
|
||||
const count = await this.assessmentService.batchDeleteSessions(body.ids, user);
|
||||
this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.batch_delete', resourceType: 'assessment_session', details: { count, ids: body.ids } });
|
||||
return { deleted: count };
|
||||
}
|
||||
|
||||
@Post('batch-export')
|
||||
@ApiOperation({ summary: 'Batch export assessments as JSON array' })
|
||||
async batchExport(@Request() req: any, @Body() body: { ids: string[] }) {
|
||||
const { id: userId } = req.user;
|
||||
return this.assessmentService.batchExportSessions(body.ids, userId);
|
||||
}
|
||||
|
||||
@Put(':id/review')
|
||||
@ApiOperation({ summary: 'Review assessment - adjust final score' })
|
||||
async review(
|
||||
@@ -224,13 +255,15 @@ export class AssessmentController {
|
||||
@Req() req: any,
|
||||
) {
|
||||
const { id: userId, tenantId } = req.user;
|
||||
return this.assessmentService.reviewAssessment(
|
||||
const result = await this.assessmentService.reviewAssessment(
|
||||
sessionId,
|
||||
body.newScore,
|
||||
body.comment,
|
||||
userId,
|
||||
tenantId,
|
||||
);
|
||||
this.auditLog.log({ userId, tenantId, action: 'session.review', resourceType: 'assessment_session', resourceId: sessionId, details: { newScore: body.newScore, comment: body.comment } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id/time-check')
|
||||
@@ -252,12 +285,14 @@ export class AssessmentController {
|
||||
@Param('id') sessionId: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { role } = req.user;
|
||||
const { id: userId, tenantId, role } = req.user;
|
||||
const isAdmin = role === 'super_admin' || role === 'admin';
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('Only admin can force end assessment');
|
||||
}
|
||||
return this.assessmentService.forceEndAssessment(sessionId);
|
||||
const result = await this.assessmentService.forceEndAssessment(sessionId);
|
||||
this.auditLog.log({ userId, tenantId, action: 'session.force_end', resourceType: 'assessment_session', resourceId: sessionId });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id/export/excel')
|
||||
@@ -271,12 +306,12 @@ export class AssessmentController {
|
||||
}
|
||||
|
||||
@Get(':id/export/pdf')
|
||||
@ApiOperation({ summary: 'Export assessment to PDF (text format)' })
|
||||
@ApiOperation({ summary: 'Export assessment to HTML report' })
|
||||
async exportPdf(@Param('id') sessionId: string) {
|
||||
const buffer = await this.exportService.exportToPdf(sessionId);
|
||||
return {
|
||||
filename: `assessment-${sessionId}.txt`,
|
||||
content: buffer.toString('utf-8'),
|
||||
filename: `assessment-${sessionId}.html`,
|
||||
buffer: buffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { ContentFilterService } from './services/content-filter.service';
|
||||
import { QuestionOutlineService } from './services/question-outline.service';
|
||||
import { QuestionBankService } from './services/question-bank.service';
|
||||
import { ExportService } from './services/export.service';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogService } from './services/audit-log.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +36,7 @@ import { ExportService } from './services/export.service';
|
||||
AssessmentCertificate,
|
||||
QuestionBank,
|
||||
QuestionBankItem,
|
||||
AuditLog,
|
||||
]),
|
||||
forwardRef(() => KnowledgeBaseModule),
|
||||
forwardRef(() => KnowledgeGroupModule),
|
||||
@@ -51,6 +54,7 @@ import { ExportService } from './services/export.service';
|
||||
QuestionOutlineService,
|
||||
QuestionBankService,
|
||||
ExportService,
|
||||
AuditLogService,
|
||||
],
|
||||
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AssessmentService } from './assessment.service';
|
||||
import { AssessmentSession } from './entities/assessment-session.entity';
|
||||
import { AssessmentSession, AssessmentStatus } from './entities/assessment-session.entity';
|
||||
import { AssessmentQuestion } from './entities/assessment-question.entity';
|
||||
import { AssessmentAnswer } from './entities/assessment-answer.entity';
|
||||
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
||||
import { QuestionBank } from './entities/question-bank.entity';
|
||||
import { QuestionBankItem } from './entities/question-bank-item.entity';
|
||||
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
||||
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
||||
import { ModelConfigService } from '../model-config/model-config.service';
|
||||
@@ -22,16 +25,35 @@ import { NotFoundException } from '@nestjs/common';
|
||||
describe('AssessmentService', () => {
|
||||
let service: AssessmentService;
|
||||
let sessionRepository: any;
|
||||
let certificateRepository: any;
|
||||
let dataSource: any;
|
||||
|
||||
const mockRepository = () => ({
|
||||
delete: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
});
|
||||
|
||||
const mockService = () => ({});
|
||||
|
||||
const regularUser = { id: 'user-1', role: 'user' };
|
||||
const adminUser = { id: 'admin-1', role: 'admin' };
|
||||
|
||||
const mockManager = (overrides?: any) => ({
|
||||
findOne: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
save: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockDataSource = (manager?: any) => ({
|
||||
transaction: jest.fn(async (cb: any) => {
|
||||
return cb(manager || mockManager());
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -40,6 +62,8 @@ describe('AssessmentService', () => {
|
||||
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
|
||||
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
|
||||
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
|
||||
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
|
||||
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
|
||||
{ provide: KnowledgeBaseService, useFactory: mockService },
|
||||
{ provide: KnowledgeGroupService, useFactory: mockService },
|
||||
{ provide: ModelConfigService, useFactory: mockService },
|
||||
@@ -52,11 +76,14 @@ describe('AssessmentService', () => {
|
||||
{ provide: ChatService, useFactory: mockService },
|
||||
{ provide: I18nService, useFactory: mockService },
|
||||
{ provide: TenantService, useFactory: mockService },
|
||||
{ provide: DataSource, useFactory: () => mockDataSource(mockManager()) },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AssessmentService>(AssessmentService);
|
||||
sessionRepository = module.get(getRepositoryToken(AssessmentSession));
|
||||
certificateRepository = module.get(getRepositoryToken(AssessmentCertificate));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -64,15 +91,110 @@ describe('AssessmentService', () => {
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete a session if it exists and belongs to the user', async () => {
|
||||
sessionRepository.delete.mockResolvedValue({ affected: 1 });
|
||||
await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow();
|
||||
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' });
|
||||
it('should delete a session when non-admin user owns it', async () => {
|
||||
const manager = mockManager({
|
||||
findOne: jest.fn().mockResolvedValue({ id: 'session-id', userId: 'user-1' }),
|
||||
});
|
||||
dataSource.transaction.mockImplementation(async (cb: any) => cb(manager));
|
||||
|
||||
await expect(service.deleteSession('session-id', regularUser)).resolves.not.toThrow();
|
||||
expect(manager.findOne).toHaveBeenCalledWith(AssessmentSession, { where: { id: 'session-id', userId: 'user-1' } });
|
||||
expect(manager.delete).toHaveBeenCalledWith(AssessmentCertificate, { sessionId: 'session-id' });
|
||||
expect(manager.delete).toHaveBeenCalledWith(AssessmentSession, { id: 'session-id' });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if no session was affected', async () => {
|
||||
sessionRepository.delete.mockResolvedValue({ affected: 0 });
|
||||
await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException);
|
||||
it('should delete any session when admin user', async () => {
|
||||
const manager = mockManager({
|
||||
findOne: jest.fn().mockResolvedValue({ id: 'other-session', userId: 'user-2' }),
|
||||
});
|
||||
dataSource.transaction.mockImplementation(async (cb: any) => cb(manager));
|
||||
|
||||
await expect(service.deleteSession('other-session', adminUser)).resolves.not.toThrow();
|
||||
expect(manager.findOne).toHaveBeenCalledWith(AssessmentSession, { where: { id: 'other-session' } });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if session not found', async () => {
|
||||
const manager = mockManager({
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
dataSource.transaction.mockImplementation(async (cb: any) => cb(manager));
|
||||
|
||||
await expect(service.deleteSession('non-existent', regularUser)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCertificate', () => {
|
||||
const completedSession = {
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
status: AssessmentStatus.COMPLETED,
|
||||
finalScore: 85,
|
||||
templateId: 'template-1',
|
||||
};
|
||||
|
||||
it('should throw NotFoundException when session does not exist', async () => {
|
||||
sessionRepository.findOne.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.generateCertificate('no-session', 'user-1', 'tenant-1'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw Error when session is not completed', async () => {
|
||||
sessionRepository.findOne.mockResolvedValue({
|
||||
...completedSession,
|
||||
status: AssessmentStatus.IN_PROGRESS,
|
||||
});
|
||||
await expect(
|
||||
service.generateCertificate('session-1', 'user-1', 'tenant-1'),
|
||||
).rejects.toThrow('Session not completed');
|
||||
});
|
||||
|
||||
it('should return existing certificate if already generated (idempotent)', async () => {
|
||||
const existingCert = { id: 'cert-1', sessionId: 'session-1' };
|
||||
sessionRepository.findOne.mockResolvedValue(completedSession);
|
||||
certificateRepository.findOne.mockResolvedValue(existingCert);
|
||||
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||
expect(result).toEqual(existingCert);
|
||||
expect(certificateRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a new certificate with correct level for score >= 90 (Expert)', async () => {
|
||||
sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 95 });
|
||||
certificateRepository.findOne.mockResolvedValue(null);
|
||||
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Expert' });
|
||||
|
||||
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: 'Expert', totalScore: 95 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new certificate with Advanced level for score 75-89', async () => {
|
||||
sessionRepository.findOne.mockResolvedValue(completedSession);
|
||||
certificateRepository.findOne.mockResolvedValue(null);
|
||||
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Advanced' });
|
||||
|
||||
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: 'Advanced', totalScore: 85 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new certificate with Novice level for score < 60', async () => {
|
||||
sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 45 });
|
||||
certificateRepository.findOne.mockResolvedValue(null);
|
||||
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Novice' });
|
||||
|
||||
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: 'Novice', totalScore: 45 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DeepPartial, In } from 'typeorm';
|
||||
import { Repository, DeepPartial, In, DataSource } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ import { AssessmentAnswer } from './entities/assessment-answer.entity';
|
||||
import { AssessmentTemplate } from './entities/assessment-template.entity';
|
||||
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
||||
import { QuestionBank, QuestionBankStatus } from './entities/question-bank.entity';
|
||||
import { QuestionBankItem } from './entities/question-bank-item.entity';
|
||||
import { QuestionBankItem, QuestionBankItemStatus } from './entities/question-bank-item.entity';
|
||||
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
||||
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
||||
import { ModelConfigService } from '../model-config/model-config.service';
|
||||
@@ -78,6 +78,7 @@ export class AssessmentService {
|
||||
private chatService: ChatService,
|
||||
private i18nService: I18nService,
|
||||
private tenantService: TenantService,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
@@ -136,12 +137,19 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
return result;
|
||||
}
|
||||
|
||||
private normalizeDimension(dim: string): string {
|
||||
const lower = dim.toLowerCase();
|
||||
if (lower === 'dev_pattern') return 'devPattern';
|
||||
if (lower === 'work_capability') return 'workCapability';
|
||||
return lower;
|
||||
}
|
||||
|
||||
private calculateScores(
|
||||
questions: any[],
|
||||
scores: Record<string, number>,
|
||||
weightConfig: { prompt: number; other: number },
|
||||
): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } {
|
||||
console.log('[calculateScores] Input:', {
|
||||
this.logger.debug('[calculateScores] Input:', {
|
||||
questionsCount: questions.length,
|
||||
scores,
|
||||
weightConfig,
|
||||
@@ -156,7 +164,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
};
|
||||
|
||||
questions.forEach((q: any, idx: number) => {
|
||||
const dimension = q.dimension || 'workCapability';
|
||||
const dimension = this.normalizeDimension(q.dimension || 'workCapability');
|
||||
const score = scores[q.id || idx.toString()] || 0;
|
||||
if (dimensionScoresMap[dimension]) {
|
||||
dimensionScoresMap[dimension].push(score);
|
||||
@@ -179,16 +187,24 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
? otherDimsWithScores.reduce((sum, dim) => sum + (dimensionAverages[dim] || 0), 0) / otherDimsWithScores.length
|
||||
: 0;
|
||||
|
||||
console.log('[calculateScores] Scoring debug:', { promptAvg, otherDimsWithScores, otherAvg, workCapability: dimensionAverages.workCapability });
|
||||
this.logger.debug('[calculateScores] Scoring debug:', { promptAvg, otherDimsWithScores, otherAvg, workCapability: dimensionAverages.workCapability });
|
||||
|
||||
const finalScore = promptAvg * (weightConfig.prompt / 100) + otherAvg * (weightConfig.other / 100);
|
||||
const allScores: number[] = [];
|
||||
questions.forEach((q: any) => {
|
||||
const score = scores[q.id || questions.indexOf(q).toString()] || 0;
|
||||
allScores.push(score);
|
||||
});
|
||||
|
||||
const finalScore = allScores.length > 0
|
||||
? allScores.reduce((a, b) => a + b, 0) / allScores.length
|
||||
: 0;
|
||||
|
||||
const radarData: Record<string, number> = {};
|
||||
Object.keys(dimensionAverages).forEach(dim => {
|
||||
radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10;
|
||||
});
|
||||
|
||||
console.log('[calculateScores] Result:', {
|
||||
this.logger.debug('[calculateScores] Result:', {
|
||||
finalScore: Math.round(finalScore * 10) / 10,
|
||||
dimensionScores: dimensionAverages,
|
||||
promptAvg,
|
||||
@@ -445,7 +461,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
}
|
||||
this.logger.debug(`[startSession] isKb: ${isKb}`);
|
||||
|
||||
const templateData = template
|
||||
const templateData: any = template
|
||||
? {
|
||||
name: template.name,
|
||||
keywords: template.keywords,
|
||||
@@ -457,6 +473,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
weightConfig: template.weightConfig,
|
||||
passingScore: template.passingScore,
|
||||
style: template.style,
|
||||
dimensions: template.dimensions,
|
||||
linkedGroupIds: template.linkedGroupIds,
|
||||
}
|
||||
: undefined;
|
||||
@@ -467,36 +484,70 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
if (templateId) {
|
||||
try {
|
||||
const targetCount = template?.questionCount || 5;
|
||||
const publishedBanks = await this.questionBankRepository.find({
|
||||
where: { templateId, status: QuestionBankStatus.PUBLISHED },
|
||||
const linkedBanks = await this.questionBankRepository.find({
|
||||
where: { templateId },
|
||||
});
|
||||
|
||||
if (publishedBanks.length > 0) {
|
||||
const bankIds = publishedBanks.map(b => b.id);
|
||||
if (linkedBanks.length > 0) {
|
||||
const bankIds = linkedBanks.map(b => b.id);
|
||||
const questionCount = await this.questionBankItemRepository.count({
|
||||
where: { bankId: In(bankIds) },
|
||||
where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[startSession] Found ${publishedBanks.length} published banks with ${questionCount} questions, target: ${targetCount}`,
|
||||
`[startSession] Found ${linkedBanks.length} banks with ${questionCount} published questions, target: ${targetCount}`,
|
||||
);
|
||||
|
||||
if (questionCount >= targetCount) {
|
||||
const bankId = publishedBanks[0].id;
|
||||
const bankId = linkedBanks[0].id;
|
||||
const selectedItems = await this.questionBankService.selectQuestions(
|
||||
bankId,
|
||||
targetCount,
|
||||
);
|
||||
|
||||
questionsFromBank = selectedItems.map(item => ({
|
||||
id: item.id,
|
||||
questionText: item.questionText,
|
||||
questionType: item.questionType,
|
||||
keyPoints: item.keyPoints,
|
||||
difficulty: item.difficulty,
|
||||
dimension: item.dimension,
|
||||
basis: item.basis,
|
||||
}));
|
||||
questionsFromBank = selectedItems.map(item => {
|
||||
let options = item.options;
|
||||
let correctAnswer = item.correctAnswer;
|
||||
if (item.questionType === 'MULTIPLE_CHOICE' && options && options.length > 0 && correctAnswer) {
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
const optTexts = options.map((o: string) => o.replace(/^[A-D][.)、]\s*/, ''));
|
||||
const correctIdx = correctAnswer.charCodeAt(0) - 65;
|
||||
const correctText = correctIdx >= 0 && correctIdx < optTexts.length ? optTexts[correctIdx] : null;
|
||||
const indices = optTexts.map((_: any, i: number) => i);
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
options = indices.map((origIdx: number, newPos: number) => `${labels[newPos]}${optTexts[origIdx]}`);
|
||||
correctAnswer = correctText ? labels[indices.indexOf(correctIdx)] : correctAnswer;
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
questionText: item.questionText,
|
||||
questionType: item.questionType,
|
||||
options,
|
||||
correctAnswer,
|
||||
judgment: item.judgment,
|
||||
keyPoints: item.keyPoints,
|
||||
difficulty: item.difficulty,
|
||||
dimension: item.dimension,
|
||||
basis: item.basis,
|
||||
maxFollowUps: item.followupHints?.length || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const answerKey: Record<string, { correctAnswer?: string | null; judgment?: string | null }> = {};
|
||||
selectedItems.forEach(item => {
|
||||
if (item.correctAnswer || item.judgment) {
|
||||
answerKey[item.id] = {
|
||||
correctAnswer: item.correctAnswer,
|
||||
judgment: item.judgment,
|
||||
};
|
||||
}
|
||||
});
|
||||
if (Object.keys(answerKey).length > 0 && templateData) {
|
||||
templateData.questionAnswerKey = answerKey;
|
||||
}
|
||||
|
||||
questionSource = 'bank';
|
||||
this.logger.log(
|
||||
@@ -534,15 +585,20 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
perQuestionTimeLimit: template?.perQuestionTimeLimit || 300,
|
||||
};
|
||||
|
||||
const content = await this.getSessionContent(sessionData);
|
||||
// Skip content check if questions are loaded from the question bank
|
||||
const hasBankQuestions = questionsFromBank.length > 0;
|
||||
|
||||
if (!content || content.trim().length < 10) {
|
||||
this.logger.error(
|
||||
`[startSession] Insufficient content length: ${content?.length || 0}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'Selected knowledge source has no sufficient content for evaluation.',
|
||||
);
|
||||
if (!hasBankQuestions) {
|
||||
const content = await this.getSessionContent(sessionData);
|
||||
|
||||
if (!content || content.trim().length < 10) {
|
||||
this.logger.error(
|
||||
`[startSession] Insufficient content length: ${content?.length || 0}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'Selected knowledge source has no sufficient content for evaluation.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const session = this.sessionRepository.create(
|
||||
@@ -560,7 +616,9 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
`[startSession] Session ${savedSession.id} created and saved`,
|
||||
);
|
||||
|
||||
this.cleanupOldSessions(userId);
|
||||
// cleanupOldSessions permanently destroys data - disabled to preserve history.
|
||||
// Admins can use batch-delete endpoint for manual cleanup.
|
||||
// this.cleanupOldSessions(userId);
|
||||
|
||||
return savedSession;
|
||||
}
|
||||
@@ -581,12 +639,14 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
}
|
||||
|
||||
const model = await this.getModel(session.tenantId);
|
||||
const content = await this.getSessionContent(session);
|
||||
|
||||
// Check if questions already exist in session (from question bank)
|
||||
const existingQuestions = session.questions_json || [];
|
||||
const hasExistingQuestions = existingQuestions.length > 0;
|
||||
|
||||
// Skip content retrieval when bank questions exist (prevents generator errors)
|
||||
const content = hasExistingQuestions ? '' : await this.getSessionContent(session);
|
||||
|
||||
// Check if we already have state
|
||||
const existingState = await this.graph.getState({
|
||||
configurable: { thread_id: sessionId },
|
||||
@@ -599,7 +659,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
this.logger.log(
|
||||
`Session ${sessionId} already has state, skipping generation.`,
|
||||
);
|
||||
const mappedData = { ...existingState.values };
|
||||
const mappedData = this.sanitizeStateForClient({ ...existingState.values });
|
||||
mappedData.messages = this.mapMessages(mappedData.messages || []);
|
||||
mappedData.feedbackHistory = this.mapMessages(
|
||||
mappedData.feedbackHistory || [],
|
||||
@@ -621,6 +681,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
|
||||
style: session.templateJson?.style,
|
||||
keywords: session.templateJson?.keywords,
|
||||
questionAnswerKey: session.templateJson?.questionAnswerKey,
|
||||
currentQuestionIndex: 0,
|
||||
};
|
||||
|
||||
@@ -708,7 +769,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
const finalData = fullState.values as EvaluationState;
|
||||
|
||||
if (finalData && finalData.messages) {
|
||||
console.log(
|
||||
this.logger.debug(
|
||||
`[AssessmentService] startSessionStream Final Authoritative State messages:`,
|
||||
finalData.messages.length,
|
||||
);
|
||||
@@ -726,7 +787,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
const scores = finalData.scores;
|
||||
const questions = finalData.questions || [];
|
||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||
const passingScore = session.templateJson?.passingScore || 90;
|
||||
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
|
||||
|
||||
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
||||
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
||||
@@ -742,7 +803,10 @@ const initialState: Partial<EvaluationState> = {
|
||||
}
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
const mappedData: any = { ...finalData };
|
||||
const mappedData: any = this.sanitizeStateForClient(
|
||||
{ ...finalData },
|
||||
session.status !== AssessmentStatus.COMPLETED,
|
||||
);
|
||||
mappedData.messages = this.mapMessages(finalData.messages);
|
||||
mappedData.feedbackHistory = this.mapMessages(
|
||||
finalData.feedbackHistory || [],
|
||||
@@ -750,6 +814,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
mappedData.status = session.status;
|
||||
mappedData.report = session.finalReport;
|
||||
mappedData.finalScore = session.finalScore;
|
||||
mappedData.passed = (session as any).passed;
|
||||
observer.next({ type: 'final', data: mappedData });
|
||||
}
|
||||
|
||||
@@ -776,6 +841,33 @@ const initialState: Partial<EvaluationState> = {
|
||||
});
|
||||
if (!session) throw new NotFoundException('Session not found');
|
||||
|
||||
if (session.status === AssessmentStatus.IN_PROGRESS) {
|
||||
const now = new Date();
|
||||
const startTime = session.startedAt ? new Date(session.startedAt) : now;
|
||||
const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now;
|
||||
const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000);
|
||||
const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000);
|
||||
|
||||
if (totalElapsed >= session.totalTimeLimit || questionElapsed >= session.perQuestionTimeLimit) {
|
||||
session.status = AssessmentStatus.COMPLETED;
|
||||
session.finalReport = totalElapsed >= session.totalTimeLimit
|
||||
? '评测总时间已用尽,评估已自动结束'
|
||||
: '单题答题时间已用尽,评估已自动结束';
|
||||
if (session.finalScore === null || session.finalScore === undefined) {
|
||||
session.finalScore = 0;
|
||||
}
|
||||
await this.sessionRepository.save(session);
|
||||
this.logger.log(`[submitAnswer] Session ${sessionId} auto-ended due to timeout`);
|
||||
return {
|
||||
assessmentSessionId: sessionId,
|
||||
status: 'COMPLETED',
|
||||
timeout: true,
|
||||
finalScore: session.finalScore,
|
||||
finalReport: session.finalReport,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const model = await this.getModel(session.tenantId);
|
||||
await this.ensureGraphState(sessionId, session);
|
||||
const content = await this.getSessionContent(session);
|
||||
@@ -790,7 +882,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
|
||||
let finalResult: any = null;
|
||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||
const passingScore = session.templateJson?.passingScore || 90;
|
||||
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
|
||||
|
||||
// Resume from the last interrupt (typically after interviewer)
|
||||
const stream = await this.graph.stream(null, {
|
||||
@@ -843,18 +935,18 @@ const initialState: Partial<EvaluationState> = {
|
||||
const scores = finalResult.scores as Record<string, number>;
|
||||
const questions = finalResult.questions || [];
|
||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||
const passingScore = session.templateJson?.passingScore || 90;
|
||||
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
|
||||
|
||||
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
||||
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
||||
questions,
|
||||
scores,
|
||||
weightConfig,
|
||||
);
|
||||
session.finalScore = finalScore;
|
||||
(session as any).dimensionScores = dimensionScores;
|
||||
(session as any).radarData = radarData;
|
||||
(session as any).passed = finalScore >= passingScore;
|
||||
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
||||
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
||||
questions,
|
||||
scores,
|
||||
weightConfig,
|
||||
);
|
||||
session.finalScore = finalScore;
|
||||
(session as any).dimensionScores = dimensionScores;
|
||||
(session as any).radarData = radarData;
|
||||
(session as any).passed = finalScore >= passingScore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,13 +994,13 @@ const initialState: Partial<EvaluationState> = {
|
||||
answer: string,
|
||||
language: string = 'en',
|
||||
): Observable<any> {
|
||||
console.log('[submitAnswerStream] START - sessionId:', sessionId, 'answer length:', answer?.length);
|
||||
this.logger.debug('[submitAnswerStream] START - sessionId:', sessionId, 'answer length:', answer?.length);
|
||||
let emittedNextQuestion = false;
|
||||
let hasEmittedNodes = false;
|
||||
return new Observable((observer) => {
|
||||
(async () => {
|
||||
try {
|
||||
console.log('[submitAnswerStream] After Observable - sessionId:', sessionId);
|
||||
this.logger.debug('[submitAnswerStream] After Observable - sessionId:', sessionId);
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId, userId },
|
||||
});
|
||||
@@ -917,6 +1009,36 @@ const initialState: Partial<EvaluationState> = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === AssessmentStatus.IN_PROGRESS) {
|
||||
const now = new Date();
|
||||
const startTime = session.startedAt ? new Date(session.startedAt) : now;
|
||||
const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now;
|
||||
const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000);
|
||||
const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000);
|
||||
|
||||
if (totalElapsed >= session.totalTimeLimit || questionElapsed >= session.perQuestionTimeLimit) {
|
||||
session.status = AssessmentStatus.COMPLETED;
|
||||
session.finalReport = totalElapsed >= session.totalTimeLimit
|
||||
? '评测总时间已用尽,评估已自动结束'
|
||||
: '单题答题时间已用尽,评估已自动结束';
|
||||
if (session.finalScore === null || session.finalScore === undefined) {
|
||||
session.finalScore = 0;
|
||||
}
|
||||
await this.sessionRepository.save(session);
|
||||
this.logger.log(`[submitAnswerStream] Session ${sessionId} auto-ended due to timeout`);
|
||||
observer.next({
|
||||
type: 'final',
|
||||
assessmentSessionId: sessionId,
|
||||
status: 'COMPLETED',
|
||||
timeout: true,
|
||||
finalScore: session.finalScore,
|
||||
finalReport: session.finalReport,
|
||||
});
|
||||
observer.complete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const model = await this.getModel(session.tenantId);
|
||||
const content = await this.getSessionContent(session);
|
||||
await this.ensureGraphState(sessionId, session);
|
||||
@@ -927,7 +1049,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
graphState &&
|
||||
graphState.values &&
|
||||
Object.keys(graphState.values).length > 0;
|
||||
console.log(
|
||||
this.logger.debug(
|
||||
`[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`,
|
||||
);
|
||||
|
||||
@@ -953,8 +1075,8 @@ const initialState: Partial<EvaluationState> = {
|
||||
let hasEmittedNodes = false;
|
||||
for await (const [mode, data] of stream) {
|
||||
streamCount++;
|
||||
console.log('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
|
||||
console.log('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500));
|
||||
this.logger.debug('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
|
||||
this.logger.debug('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500));
|
||||
if (mode === 'updates') {
|
||||
hasEmittedNodes = true;
|
||||
const node = Object.keys(data)[0];
|
||||
@@ -962,17 +1084,17 @@ const initialState: Partial<EvaluationState> = {
|
||||
|
||||
// Skip interrupt nodes - they have no useful data
|
||||
if (node === '__interrupt__' || !updateData || Object.keys(updateData).length === 0) {
|
||||
console.log('[submitAnswerStream] Skipping empty interrupt node');
|
||||
this.logger.debug('[submitAnswerStream] Skipping empty interrupt node');
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('[submitAnswerStream] Node update:', node, {
|
||||
this.logger.debug('[submitAnswerStream] Node update:', node, {
|
||||
hasMessages: !!updateData.messages,
|
||||
messageCount: updateData.messages?.length,
|
||||
currentIndex: updateData.currentQuestionIndex,
|
||||
dataKeys: Object.keys(updateData).join(',')
|
||||
});
|
||||
console.log('[submitAnswerStream] Sending to frontend:', JSON.stringify(updateData).substring(0, 500));
|
||||
this.logger.debug('[submitAnswerStream] Sending to frontend:', JSON.stringify(updateData).substring(0, 500));
|
||||
if (updateData.messages) {
|
||||
updateData.messages = this.mapMessages(updateData.messages);
|
||||
}
|
||||
@@ -983,7 +1105,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
}
|
||||
observer.next({ type: 'node', node, data: updateData });
|
||||
} else if (mode === 'values') {
|
||||
console.log('[submitAnswerStream] Values update - keys:', Object.keys(data || {}));
|
||||
this.logger.debug('[submitAnswerStream] Values update - keys:', Object.keys(data || {}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,13 +1116,13 @@ const initialState: Partial<EvaluationState> = {
|
||||
const finalData = fullState.values as EvaluationState;
|
||||
|
||||
// Force emit the next question if stream didn't emit updates (hasEmittedNodes is false)
|
||||
console.log('[submitAnswerStream] Force check:', { hasEmittedNodes, hasFinalData: !!finalData, hasQuestions: !!finalData?.questions, qLen: finalData?.questions?.length, emittedNextQuestion });
|
||||
this.logger.debug('[submitAnswerStream] Force check:', { hasEmittedNodes, hasFinalData: !!finalData, hasQuestions: !!finalData?.questions, qLen: finalData?.questions?.length, emittedNextQuestion });
|
||||
if (!hasEmittedNodes && finalData && finalData.questions && finalData.questions.length > 0 && !emittedNextQuestion) {
|
||||
const currentIndex = finalData.currentQuestionIndex || 0;
|
||||
const nextQuestion = finalData.questions[currentIndex];
|
||||
if (nextQuestion) {
|
||||
const questionText = nextQuestion.questionText || '';
|
||||
console.log('[submitAnswerStream] Forcing emit next question:', {
|
||||
this.logger.debug('[submitAnswerStream] Forcing emit next question:', {
|
||||
currentIndex,
|
||||
questionPreview: questionText.substring(0, 50)
|
||||
});
|
||||
@@ -1020,7 +1142,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
}
|
||||
|
||||
if (finalData && finalData.messages) {
|
||||
console.log(
|
||||
this.logger.debug(
|
||||
`[AssessmentService] submitAnswerStream Final Authoritative State messages:`,
|
||||
finalData.messages.length,
|
||||
);
|
||||
@@ -1036,7 +1158,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
const scores = finalData.scores;
|
||||
const questions = finalData.questions || [];
|
||||
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 };
|
||||
const passingScore = session.templateJson?.passingScore || 90;
|
||||
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
|
||||
|
||||
if (questions.length > 0 && Object.keys(scores).length > 0) {
|
||||
const { finalScore, dimensionScores, radarData } = this.calculateScores(
|
||||
@@ -1048,6 +1170,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
(session as any).dimensionScores = dimensionScores;
|
||||
(session as any).radarData = radarData;
|
||||
(session as any).passed = finalScore >= passingScore;
|
||||
|
||||
this.logger.log(
|
||||
`[DimensionScoring] Session ${sessionId} Final Score: ${finalScore}, Passed: ${finalScore >= passingScore}`,
|
||||
);
|
||||
@@ -1055,13 +1178,18 @@ const initialState: Partial<EvaluationState> = {
|
||||
}
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
const mappedData: any = { ...finalData };
|
||||
const mappedData: any = this.sanitizeStateForClient(
|
||||
{ ...finalData },
|
||||
session.status !== AssessmentStatus.COMPLETED,
|
||||
);
|
||||
mappedData.messages = this.mapMessages(finalData.messages);
|
||||
mappedData.feedbackHistory = this.mapMessages(
|
||||
finalData.feedbackHistory || [],
|
||||
);
|
||||
mappedData.status = session.status;
|
||||
mappedData.report = session.finalReport;
|
||||
mappedData.finalScore = session.finalScore;
|
||||
mappedData.passed = (session as any).passed;
|
||||
observer.next({ type: 'final', data: mappedData });
|
||||
}
|
||||
|
||||
@@ -1101,7 +1229,10 @@ const initialState: Partial<EvaluationState> = {
|
||||
values.feedbackHistory = this.mapMessages(values.feedbackHistory);
|
||||
}
|
||||
|
||||
return values;
|
||||
return this.sanitizeStateForClient(
|
||||
values,
|
||||
session.status !== AssessmentStatus.COMPLETED,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1138,16 +1269,25 @@ const initialState: Partial<EvaluationState> = {
|
||||
const userId = user.id;
|
||||
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
||||
|
||||
const deleteCondition: any = { id: sessionId };
|
||||
if (!isAdmin) {
|
||||
deleteCondition.userId = userId;
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const deleteCondition: any = { id: sessionId };
|
||||
if (!isAdmin) {
|
||||
deleteCondition.userId = userId;
|
||||
}
|
||||
|
||||
const result = await this.sessionRepository.delete(deleteCondition);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(
|
||||
'Session not found or you do not have permission to delete it',
|
||||
);
|
||||
const session = await manager.findOne(AssessmentSession, { where: deleteCondition });
|
||||
if (!session) {
|
||||
throw new NotFoundException('Session not found or you do not have permission to delete it');
|
||||
}
|
||||
|
||||
await manager.delete(AssessmentCertificate, { sessionId });
|
||||
await manager.delete(AssessmentSession, { id: sessionId });
|
||||
});
|
||||
|
||||
try {
|
||||
await this.graph.getState({ configurable: { thread_id: sessionId } });
|
||||
} catch {
|
||||
this.logger.debug(`[deleteSession] No graph state to clean up for ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1178,55 +1318,47 @@ const initialState: Partial<EvaluationState> = {
|
||||
const historicalMessages = this.hydrateMessages(session.messages);
|
||||
const existingQuestions = session.questions_json || [];
|
||||
const hasQuestionsFromBank = existingQuestions.length > 0;
|
||||
const scoresRecord: Record<string, number> = {};
|
||||
if (session.feedbackHistory) {
|
||||
for (const fh of session.feedbackHistory) {
|
||||
if (fh.score && fh.questionId) scoresRecord[fh.questionId] = fh.score;
|
||||
}
|
||||
}
|
||||
|
||||
const recoveredState: any = {
|
||||
assessmentSessionId: sessionId,
|
||||
knowledgeBaseId:
|
||||
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
||||
messages: historicalMessages,
|
||||
feedbackHistory: this.hydrateMessages(
|
||||
session.feedbackHistory || [],
|
||||
),
|
||||
questions: existingQuestions,
|
||||
currentQuestionIndex: session.currentQuestionIndex || 0,
|
||||
followUpCount: session.followUpCount || 0,
|
||||
shouldFollowUp: false,
|
||||
scores: scoresRecord,
|
||||
questionCount: session.templateJson?.questionCount || 5,
|
||||
difficultyDistribution:
|
||||
session.templateJson?.difficultyDistribution,
|
||||
style: session.templateJson?.style,
|
||||
keywords: session.templateJson?.keywords,
|
||||
questionAnswerKey: session.templateJson?.questionAnswerKey,
|
||||
language: session.language || 'zh',
|
||||
report: session.finalReport || undefined,
|
||||
};
|
||||
|
||||
if (hasQuestionsFromBank) {
|
||||
this.logger.log(
|
||||
`[ensureGraphState] Using ${existingQuestions.length} questions from question bank`,
|
||||
);
|
||||
await this.graph.updateState(
|
||||
{ configurable: { thread_id: sessionId } },
|
||||
{
|
||||
assessmentSessionId: sessionId,
|
||||
knowledgeBaseId:
|
||||
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
||||
messages: historicalMessages,
|
||||
feedbackHistory: this.hydrateMessages(
|
||||
session.feedbackHistory || [],
|
||||
),
|
||||
questions: existingQuestions,
|
||||
currentQuestionIndex: session.currentQuestionIndex || 0,
|
||||
followUpCount: session.followUpCount || 0,
|
||||
questionCount: session.templateJson?.questionCount || 5,
|
||||
difficultyDistribution:
|
||||
session.templateJson?.difficultyDistribution,
|
||||
style: session.templateJson?.style,
|
||||
keywords: session.templateJson?.keywords,
|
||||
},
|
||||
'grader',
|
||||
);
|
||||
} else {
|
||||
await this.graph.updateState(
|
||||
{ configurable: { thread_id: sessionId } },
|
||||
{
|
||||
assessmentSessionId: sessionId,
|
||||
knowledgeBaseId:
|
||||
session.knowledgeBaseId || session.knowledgeGroupId || '',
|
||||
messages: historicalMessages,
|
||||
feedbackHistory: this.hydrateMessages(
|
||||
session.feedbackHistory || [],
|
||||
),
|
||||
questions: session.questions_json || [],
|
||||
currentQuestionIndex: session.currentQuestionIndex || 0,
|
||||
followUpCount: session.followUpCount || 0,
|
||||
questionCount: session.templateJson?.questionCount || 5,
|
||||
difficultyDistribution:
|
||||
session.templateJson?.difficultyDistribution,
|
||||
style: session.templateJson?.style,
|
||||
keywords: session.templateJson?.keywords,
|
||||
},
|
||||
'grader',
|
||||
);
|
||||
}
|
||||
|
||||
await this.graph.updateState(
|
||||
{ configurable: { thread_id: sessionId } },
|
||||
recoveredState,
|
||||
'interviewer',
|
||||
);
|
||||
} else {
|
||||
this.logger.log(`Initializing new state for session ${sessionId}`);
|
||||
const content = await this.getSessionContent(session);
|
||||
@@ -1241,6 +1373,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
difficultyDistribution: session.templateJson?.difficultyDistribution,
|
||||
style: session.templateJson?.style,
|
||||
keywords: session.templateJson?.keywords,
|
||||
questionAnswerKey: session.templateJson?.questionAnswerKey,
|
||||
language: session.language || 'en',
|
||||
};
|
||||
|
||||
@@ -1305,6 +1438,27 @@ const initialState: Partial<EvaluationState> = {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips sensitive fields before sending state to frontend.
|
||||
*/
|
||||
private sanitizeStateForClient(data: any, stripAnswers = true): any {
|
||||
if (!data) return data;
|
||||
const sanitized = { ...data };
|
||||
if (stripAnswers) {
|
||||
delete sanitized.questionAnswerKey;
|
||||
}
|
||||
if (Array.isArray(sanitized.questions)) {
|
||||
sanitized.questions = sanitized.questions.map((q: any) => {
|
||||
if (stripAnswers) {
|
||||
const { correctAnswer, judgment, followupHints, ...rest } = q;
|
||||
return rest;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps LangChain messages to a simple format for the frontend and storage.
|
||||
*/
|
||||
@@ -1340,7 +1494,7 @@ const initialState: Partial<EvaluationState> = {
|
||||
}
|
||||
|
||||
if (session.status !== AssessmentStatus.COMPLETED) {
|
||||
throw new Error('Session not completed');
|
||||
throw new BadRequestException('Session not completed yet');
|
||||
}
|
||||
|
||||
const existing = await this.certificateRepository.findOne({
|
||||
@@ -1353,6 +1507,13 @@ const initialState: Partial<EvaluationState> = {
|
||||
const level = this.determineLevel(session.finalScore || 0);
|
||||
const qrCode = `cert://${sessionId}-${Date.now()}`;
|
||||
|
||||
const questionDetails = (session.questions_json || []).map((q: any, i: number) => ({
|
||||
index: i + 1,
|
||||
questionText: q.questionText?.substring(0, 100) || '',
|
||||
questionType: q.questionType || 'SHORT_ANSWER',
|
||||
dimension: q.dimension || '',
|
||||
}));
|
||||
|
||||
const certificate = this.certificateRepository.create({
|
||||
userId,
|
||||
sessionId,
|
||||
@@ -1365,13 +1526,19 @@ const initialState: Partial<EvaluationState> = {
|
||||
passed: (session as any).passed || false,
|
||||
});
|
||||
|
||||
return this.certificateRepository.save(certificate);
|
||||
const saved = await this.certificateRepository.save(certificate);
|
||||
return {
|
||||
...saved,
|
||||
templateName: session.template?.name || session.templateJson?.name || '-',
|
||||
userName: session.user?.displayName || session.user?.username || '',
|
||||
questionDetails,
|
||||
} as any;
|
||||
}
|
||||
|
||||
private determineLevel(score: number): string {
|
||||
if (score >= 90) return 'Expert';
|
||||
if (score >= 75) return 'Advanced';
|
||||
if (score >= 60) return 'Proficient';
|
||||
if (score >= 9) return 'Expert';
|
||||
if (score >= 7.5) return 'Advanced';
|
||||
if (score >= 6) return 'Proficient';
|
||||
return 'Novice';
|
||||
}
|
||||
|
||||
@@ -1464,19 +1631,15 @@ const initialState: Partial<EvaluationState> = {
|
||||
|
||||
const sessions = await qb.take(100).getMany();
|
||||
|
||||
const dimensionScores: Record<string, number[]> = {
|
||||
PROMPT: [],
|
||||
LLM: [],
|
||||
IDE: [],
|
||||
DEV_PATTERN: [],
|
||||
WORK_CAPABILITY: [],
|
||||
};
|
||||
const dimensionScores: Record<string, number[]> = {};
|
||||
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages || [];
|
||||
for (const msg of messages) {
|
||||
if (msg.dimension && msg.score !== undefined) {
|
||||
dimensionScores[msg.dimension]?.push(msg.score);
|
||||
const scores = (session as any).dimensionScores || {};
|
||||
for (const [dim, score] of Object.entries(scores)) {
|
||||
if (dimensionScores[dim]) {
|
||||
dimensionScores[dim].push(score as number);
|
||||
} else {
|
||||
dimensionScores[dim] = [score as number];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1531,48 +1694,50 @@ const initialState: Partial<EvaluationState> = {
|
||||
reviewerId: string,
|
||||
tenantId: string,
|
||||
): Promise<AssessmentSession> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const session = await manager.findOne(AssessmentSession, {
|
||||
where: { id: sessionId },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new NotFoundException('Assessment session not found');
|
||||
}
|
||||
|
||||
if (session.status !== AssessmentStatus.COMPLETED) {
|
||||
throw new ForbiddenException('Can only review completed assessments');
|
||||
}
|
||||
|
||||
const reviewRecord = {
|
||||
reviewedBy: reviewerId,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
originalScore: session.finalScore,
|
||||
newScore: newScore,
|
||||
comment: comment || '',
|
||||
};
|
||||
|
||||
const reviewHistory = session.reviewHistory || [];
|
||||
reviewHistory.push(reviewRecord);
|
||||
|
||||
if (!session.originalScore) {
|
||||
session.originalScore = session.finalScore;
|
||||
}
|
||||
|
||||
session.finalScore = newScore;
|
||||
const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
|
||||
(session as any).passed = newScore >= passingScore;
|
||||
session.reviewedBy = reviewerId;
|
||||
session.reviewedAt = new Date();
|
||||
session.reviewComment = comment || null;
|
||||
session.reviewHistory = reviewHistory;
|
||||
|
||||
await manager.save(session);
|
||||
|
||||
this.logger.log(
|
||||
`[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`,
|
||||
);
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new NotFoundException('Assessment session not found');
|
||||
}
|
||||
|
||||
if (session.status !== AssessmentStatus.COMPLETED) {
|
||||
throw new ForbiddenException('Can only review completed assessments');
|
||||
}
|
||||
|
||||
const reviewRecord = {
|
||||
reviewedBy: reviewerId,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
originalScore: session.finalScore,
|
||||
newScore: newScore,
|
||||
comment: comment || '',
|
||||
};
|
||||
|
||||
const reviewHistory = session.reviewHistory || [];
|
||||
reviewHistory.push(reviewRecord);
|
||||
|
||||
if (!session.originalScore) {
|
||||
session.originalScore = session.finalScore;
|
||||
}
|
||||
|
||||
session.finalScore = newScore;
|
||||
const passingScore = session.templateJson?.passingScore || 90;
|
||||
(session as any).passed = newScore >= passingScore;
|
||||
session.reviewedBy = reviewerId;
|
||||
session.reviewedAt = new Date();
|
||||
session.reviewComment = comment || null;
|
||||
session.reviewHistory = reviewHistory;
|
||||
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
this.logger.log(
|
||||
`[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`,
|
||||
);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async getUserHistory(userId: string): Promise<AssessmentSession[]> {
|
||||
@@ -1655,7 +1820,6 @@ const initialState: Partial<EvaluationState> = {
|
||||
totalScore: number;
|
||||
passed: boolean;
|
||||
issuedAt: Date;
|
||||
userId: string;
|
||||
};
|
||||
message?: string;
|
||||
}> {
|
||||
@@ -1676,7 +1840,6 @@ const initialState: Partial<EvaluationState> = {
|
||||
totalScore: certificate.totalScore,
|
||||
passed: certificate.passed,
|
||||
issuedAt: certificate.issuedAt,
|
||||
userId: certificate.userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1712,6 +1875,45 @@ const initialState: Partial<EvaluationState> = {
|
||||
};
|
||||
}
|
||||
|
||||
async batchDeleteSessions(ids: string[], user: any): Promise<number> {
|
||||
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const query: any = { id: In(ids) };
|
||||
if (!isAdmin) {
|
||||
query.userId = user.id;
|
||||
}
|
||||
|
||||
const sessions = await manager.find(AssessmentSession, { where: query });
|
||||
const sessionIds = sessions.map((s) => s.id);
|
||||
|
||||
if (sessionIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await manager.delete(AssessmentCertificate, { sessionId: In(sessionIds) });
|
||||
const result = await manager.delete(AssessmentSession, { id: In(sessionIds) });
|
||||
this.logger.log(`[batchDeleteSessions] Deleted ${sessionIds.length} sessions`);
|
||||
return result.affected || 0;
|
||||
});
|
||||
}
|
||||
|
||||
async batchExportSessions(ids: string[], userId: string): Promise<any[]> {
|
||||
const sessions = await this.sessionRepository.find({
|
||||
where: { id: In(ids), userId },
|
||||
relations: ['questions'],
|
||||
});
|
||||
return sessions.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
finalScore: s.finalScore,
|
||||
startedAt: s.startedAt,
|
||||
createdAt: s.createdAt,
|
||||
totalTimeLimit: s.totalTimeLimit,
|
||||
questionCount: s.questions?.length || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async forceEndAssessment(sessionId: string): Promise<AssessmentSession> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ReviewDto,
|
||||
} from '../services/question-bank.service';
|
||||
import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
|
||||
import { KnowledgeGroupService } from '../../knowledge-group/knowledge-group.service';
|
||||
|
||||
@Controller('question-banks')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
@@ -29,12 +30,20 @@ import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
|
||||
export class QuestionBankController {
|
||||
private readonly logger = new Logger(QuestionBankController.name);
|
||||
|
||||
constructor(private readonly questionBankService: QuestionBankService) {}
|
||||
constructor(
|
||||
private readonly questionBankService: QuestionBankService,
|
||||
private readonly groupService: KnowledgeGroupService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
|
||||
this.logger.log(`Creating question bank: ${createDto.name}`);
|
||||
return this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
|
||||
async create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
|
||||
try {
|
||||
this.logger.log(`Creating question bank: ${createDto.name}, user: ${req.user?.id}, tenant: ${req.user?.tenantId}`);
|
||||
return await this.questionBankService.create(createDto, req.user.id, req.user.tenantId);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[create] Failed: ${err.message}`, err.stack);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -125,11 +134,32 @@ export class QuestionBankController {
|
||||
@Body() body: { count: number; knowledgeBaseContent?: string },
|
||||
@Req() req: any,
|
||||
) {
|
||||
this.logger.log(`[generate] Generating ${body.count} questions for bank ${bankId}`);
|
||||
let content = body.knowledgeBaseContent || '';
|
||||
if (!content || content.trim().length < 10) {
|
||||
try {
|
||||
const bank = await this.questionBankService.findOne(bankId);
|
||||
if (bank?.template?.knowledgeGroupId) {
|
||||
const files = await this.groupService.getGroupFiles(
|
||||
bank.template.knowledgeGroupId,
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
);
|
||||
content = files
|
||||
.filter((f: any) => f.content && f.content.trim().length > 0)
|
||||
.map((f: any) => `--- ${f.title || f.originalName || 'Document'} ---\n${f.content}`)
|
||||
.join('\n\n');
|
||||
this.logger.log(`[generate] Auto-loaded ${files.length} files, content length: ${content.length}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[generate] Auto-load failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[generate] Generating ${body.count} questions for bank ${bankId}, content length: ${content.length}`);
|
||||
return this.questionBankService.generateQuestions(
|
||||
bankId,
|
||||
body.count,
|
||||
body.knowledgeBaseContent || '',
|
||||
content,
|
||||
req.user.tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Max,
|
||||
IsObject,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTemplateDto {
|
||||
@@ -59,6 +60,11 @@ export class CreateTemplateDto {
|
||||
@IsOptional()
|
||||
linkedGroupIds?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsObject({ each: true })
|
||||
@IsOptional()
|
||||
dimensions?: Array<{ name: string; label: string; weight: number }>;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
weightConfig?: {
|
||||
@@ -91,4 +97,16 @@ export class CreateTemplateDto {
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
passingScore?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(60)
|
||||
@Max(7200)
|
||||
@IsOptional()
|
||||
totalTimeLimit?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(30)
|
||||
@Max(1800)
|
||||
@IsOptional()
|
||||
perQuestionTimeLimit?: number;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@ export class AssessmentSession {
|
||||
@Column({ type: 'float', name: 'original_score', nullable: true })
|
||||
originalScore: number;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, name: 'dimension_scores' })
|
||||
dimensionScores: Record<string, number>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, name: 'radar_data' })
|
||||
radarData: any;
|
||||
|
||||
@Column({ nullable: true })
|
||||
passed: boolean;
|
||||
|
||||
@Column({ type: 'text', name: 'final_report', nullable: true })
|
||||
finalReport: string;
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ export class AssessmentTemplate {
|
||||
@JoinColumn({ name: 'knowledge_group_id' })
|
||||
knowledgeGroup: KnowledgeGroup;
|
||||
|
||||
@Column({ type: 'simple-json', name: 'dimensions', nullable: true })
|
||||
dimensions: Array<{ name: string; label: string; weight: number }>;
|
||||
|
||||
@Column({ type: 'boolean', name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('audit_logs')
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'text' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'tenant_id', nullable: true, type: 'text' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
action: string;
|
||||
|
||||
@Column({ name: 'resource_type', type: 'varchar', length: 50 })
|
||||
resourceType: string;
|
||||
|
||||
@Column({ name: 'resource_id', nullable: true, type: 'text' })
|
||||
resourceId: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
details: any;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { QuestionBankItem, QuestionType, QuestionDifficulty, QuestionDimension, QuestionBankItemStatus } from './question-bank-item.entity';
|
||||
|
||||
describe('QuestionBankItem entity', () => {
|
||||
describe('existing fields', () => {
|
||||
it('should create an instance with default questionType', () => {
|
||||
const item = new QuestionBankItem();
|
||||
expect(item.questionType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set and get basic fields', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.questionText = '【场景】你在编写代码... 【问题】请描述你会如何处理';
|
||||
item.questionType = QuestionType.SHORT_ANSWER;
|
||||
item.options = null;
|
||||
item.correctAnswer = null;
|
||||
item.keyPoints = ['规范文档化', '源头统一'];
|
||||
item.difficulty = QuestionDifficulty.STANDARD;
|
||||
item.dimension = QuestionDimension.PROMPT;
|
||||
item.basis = '知识库原文依据';
|
||||
item.status = QuestionBankItemStatus.PENDING_REVIEW;
|
||||
|
||||
expect(item.questionText).toBe('【场景】你在编写代码... 【问题】请描述你会如何处理');
|
||||
expect(item.questionType).toBe(QuestionType.SHORT_ANSWER);
|
||||
expect(item.options).toBeNull();
|
||||
expect(item.correctAnswer).toBeNull();
|
||||
expect(item.keyPoints).toEqual(['规范文档化', '源头统一']);
|
||||
expect(item.difficulty).toBe(QuestionDifficulty.STANDARD);
|
||||
expect(item.dimension).toBe(QuestionDimension.PROMPT);
|
||||
expect(item.basis).toBe('知识库原文依据');
|
||||
expect(item.status).toBe(QuestionBankItemStatus.PENDING_REVIEW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('judgment field', () => {
|
||||
it('should accept judgment text for choice question', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.judgment = 'B正确,因为提供了具体约束和角色设定。A错误在于过于笼统。C错误在于过度细节但缺乏核心约束。D错误在于错误建议。';
|
||||
expect(item.judgment).toBe('B正确,因为提供了具体约束和角色设定。A错误在于过于笼统。C错误在于过度细节但缺乏核心约束。D错误在于错误建议。');
|
||||
});
|
||||
|
||||
it('should accept judgment text for open question', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.judgment = '关键考点:会话管理——长对话导致上下文窗口膨胀 通过标准:说出"让AI总结之前内容+开新窗口"即通过';
|
||||
expect(item.judgment).toContain('通过标准');
|
||||
expect(item.judgment).toContain('会话管理');
|
||||
});
|
||||
|
||||
it('should allow null judgment', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.judgment = null;
|
||||
expect(item.judgment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('followupHints field', () => {
|
||||
it('should accept array of followup hints', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.followupHints = [
|
||||
'如果只回答"开新窗口"没说怎么带上前情:追问"开新窗口后之前讨论的结论不就丢了吗?怎么把有用信息带过去?"',
|
||||
'如果内容不完整:追问"还有没有更好的办法?"',
|
||||
];
|
||||
expect(item.followupHints).toHaveLength(2);
|
||||
expect(item.followupHints[0]).toContain('开新窗口');
|
||||
expect(item.followupHints[1]).toContain('更好的办法');
|
||||
});
|
||||
|
||||
it('should accept single followup hint', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.followupHints = ['追问如何保留之前结论'];
|
||||
expect(item.followupHints).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should accept empty array', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.followupHints = [];
|
||||
expect(item.followupHints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should allow null followupHints', () => {
|
||||
const item = new QuestionBankItem();
|
||||
item.followupHints = null;
|
||||
expect(item.followupHints).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ export class QuestionBankItem {
|
||||
@Column({
|
||||
type: 'simple-enum',
|
||||
enum: QuestionType,
|
||||
default: QuestionType.SHORT_ANSWER,
|
||||
})
|
||||
questionType: QuestionType;
|
||||
|
||||
@@ -71,24 +72,33 @@ export class QuestionBankItem {
|
||||
@Column({
|
||||
type: 'simple-enum',
|
||||
enum: QuestionDifficulty,
|
||||
default: QuestionDifficulty.STANDARD,
|
||||
})
|
||||
difficulty: QuestionDifficulty;
|
||||
|
||||
@Column({
|
||||
type: 'simple-enum',
|
||||
enum: QuestionDimension,
|
||||
default: QuestionDimension.PROMPT,
|
||||
})
|
||||
dimension: QuestionDimension;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
basis: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
judgment: string | null;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true, name: 'followup_hints' })
|
||||
followupHints: string[] | null;
|
||||
|
||||
@Column({ name: 'created_by', nullable: true, type: 'text' })
|
||||
createdBy: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'simple-enum',
|
||||
enum: QuestionBankItemStatus,
|
||||
default: QuestionBankItemStatus.PENDING_REVIEW,
|
||||
})
|
||||
status: QuestionBankItemStatus;
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export class QuestionBank {
|
||||
@Column({ name: 'template_id', nullable: true })
|
||||
templateId: string | null;
|
||||
|
||||
@OneToOne(() => AssessmentTemplate, { nullable: true })
|
||||
@OneToOne(() => AssessmentTemplate, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'template_id' })
|
||||
template: AssessmentTemplate;
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { routeAfterGrading } from './builder';
|
||||
|
||||
describe('routeAfterGrading', () => {
|
||||
it('should route to interviewer when shouldFollowUp is true (overrides all other logic)', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: true,
|
||||
currentQuestionIndex: 0,
|
||||
questionCount: 5,
|
||||
questions: [],
|
||||
} as any);
|
||||
expect(result).toBe('interviewer');
|
||||
});
|
||||
|
||||
it('should route to generator when currentIndex >= questionsLen and currentIndex < targetCount', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: 3,
|
||||
questionCount: 5,
|
||||
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }],
|
||||
} as any);
|
||||
expect(result).toBe('generator');
|
||||
});
|
||||
|
||||
it('should route to interviewer when currentIndex < questionsLen and currentIndex < targetCount', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: 2,
|
||||
questionCount: 5,
|
||||
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||
} as any);
|
||||
expect(result).toBe('interviewer');
|
||||
});
|
||||
|
||||
it('should route to analyzer when currentIndex >= targetCount', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: 5,
|
||||
questionCount: 5,
|
||||
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||
} as any);
|
||||
expect(result).toBe('analyzer');
|
||||
});
|
||||
|
||||
it('should use default targetCount of 5 when questionCount is undefined', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: 4,
|
||||
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }],
|
||||
} as any);
|
||||
expect(result).toBe('generator');
|
||||
});
|
||||
|
||||
it('should use default targetCount of 5 when questionCount is undefined and index 5 routes to analyzer', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: 5,
|
||||
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||
} as any);
|
||||
expect(result).toBe('analyzer');
|
||||
});
|
||||
|
||||
it('should handle undefined questions gracefully (defaults to empty array)', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: 0,
|
||||
questionCount: 5,
|
||||
} as any);
|
||||
expect(result).toBe('generator');
|
||||
});
|
||||
|
||||
it('should prevent negative currentQuestionIndex via Math.max(0)', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: -1,
|
||||
questionCount: 5,
|
||||
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||
} as any);
|
||||
expect(result).toBe('interviewer');
|
||||
});
|
||||
|
||||
it('should handle completely empty state (no fields provided)', () => {
|
||||
const result = routeAfterGrading({} as any);
|
||||
expect(result).toBe('generator');
|
||||
});
|
||||
|
||||
it('should route to interviewer at last index before targetCount boundary', () => {
|
||||
const result = routeAfterGrading({
|
||||
shouldFollowUp: false,
|
||||
currentQuestionIndex: 4,
|
||||
questionCount: 5,
|
||||
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||
} as any);
|
||||
expect(result).toBe('interviewer');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { reportAnalyzerNode } from './nodes/analyzer.node';
|
||||
/**
|
||||
* Conditional routing logic for the Grader node.
|
||||
*/
|
||||
const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
|
||||
export const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
|
||||
const targetCount = state.questionCount || 5;
|
||||
const questionsLen = state.questions?.length || 0;
|
||||
const currentIndex = Math.max(0, state.currentQuestionIndex || 0);
|
||||
|
||||
@@ -22,6 +22,14 @@ export const questionGeneratorNode = async (
|
||||
targetCount: limitCount,
|
||||
});
|
||||
|
||||
const existingQuestions = state.questions || [];
|
||||
|
||||
// Early return if enough questions from bank (no LLM call needed)
|
||||
if (existingQuestions.length >= limitCount) {
|
||||
console.log('[GeneratorNode] Skipping generation - enough questions from bank:', existingQuestions.length);
|
||||
return { questions: existingQuestions };
|
||||
}
|
||||
|
||||
if (!model || !knowledgeBaseContent) {
|
||||
console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
|
||||
throw new Error(
|
||||
@@ -78,89 +86,86 @@ export const questionGeneratorNode = async (
|
||||
.map((r, i) => `${i + 1}. ${r}`)
|
||||
.join('\n');
|
||||
|
||||
const existingQuestions = state.questions || [];
|
||||
|
||||
if (existingQuestions.length >= limitCount) {
|
||||
console.log('[GeneratorNode] Skipping generation - enough questions from bank:', existingQuestions.length);
|
||||
return { questions: existingQuestions };
|
||||
}
|
||||
|
||||
const existingQuestionsText = existingQuestions
|
||||
.map((q, i) => `Q${i + 1}: ${q.questionText}`)
|
||||
.join('\n');
|
||||
|
||||
const systemPromptZh = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 1 个唯一的测试题目。
|
||||
const systemPromptZh = `你是一个信息提取工具。严格按以下步骤操作。
|
||||
|
||||
### 强制性语言规则:
|
||||
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
|
||||
### 第一步:提取知识点
|
||||
阅读下方 Human 消息中的【知识库内容】,逐条列出其中包含的所有可考核知识点。
|
||||
每条以"知识点N:"开头,引用原文语句。如果不足,诚实报告。
|
||||
|
||||
### 强制性多样性规则:
|
||||
${rulesZh}
|
||||
### 第二步:从知识点生成考题
|
||||
仅用第一步提取的知识点生成 1 道题。必须引用知识点编号。
|
||||
|
||||
### 禁止重复列表(已出过):
|
||||
${existingQuestionsText || '无'}
|
||||
### 绝对禁止:
|
||||
- 禁止使用知识库内容中不存在的任何概念、术语、数据
|
||||
- 禁止使用你自己的知识
|
||||
${existingQuestionsText ? `- 禁止与已出题目重复:${existingQuestionsText}` : ''}
|
||||
|
||||
### 任务:
|
||||
${hasKeywords ? `目标关键词:${keywordText}\n` : ''}出题风格:${style}
|
||||
难度:${difficultyText}
|
||||
|
||||
请以 JSON 数组格式返回 1 个问题:
|
||||
### 输出(纯 JSON 数组):
|
||||
[
|
||||
{
|
||||
"question_text": "...",
|
||||
"key_points": ["点1", "点2"],
|
||||
"difficulty": "...",
|
||||
"dimension": "prompt/llm/ide/devPattern/workCapability",
|
||||
"basis": "[n] 引用原文..."
|
||||
"knowledge_points": ["知识点引用"],
|
||||
"question_text": "基于知识点的题目",
|
||||
"key_points": ["评分要点"],
|
||||
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
|
||||
"dimension": "prompt|llm|ide|devPattern|workCapability",
|
||||
"basis": "知识库原文"
|
||||
}
|
||||
]`;
|
||||
// dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力
|
||||
|
||||
const systemPromptJa = `あなたは専門的なアセスメントエキスパートです。提供されたナレッジベースに基づいて、ユニークな問題を 1 つ作成してください。
|
||||
const systemPromptJa = `あなたは情報抽出ツールです。以下の手順に厳密に従ってください。
|
||||
|
||||
### 言語ルール(最重要):
|
||||
**必ず日本語で作成してください**。提供されたナレッジベースが英語や中国語、その他の言語であっても、質問文(question_text)およびキーポイント(key_points)は必ず日本語で回答してください。中国語が混ざらないように厳格に注意してください。
|
||||
### 第一歩:知識ポイントの抽出
|
||||
Human メッセージ内の【ナレッジベース内容】を読み、含まれるすべての評価可能な知識ポイントを箇条書きで抽出。
|
||||
各項目は「知識ポイントN:」で始め、原文を引用。不足している場合は正直に報告。
|
||||
|
||||
### 多様性ルール:
|
||||
${rulesJa}
|
||||
### 第二歩:知識ポイントから問題を作成
|
||||
第一歩で抽出した知識ポイントのみを使用して 1 問作成。知識ポイント番号を引用すること。
|
||||
|
||||
### 作成済み問題リスト:
|
||||
${existingQuestionsText || 'なし'}
|
||||
### 絶対禁止:
|
||||
- ナレッジベースに存在しない概念、用語、データの使用
|
||||
- 自身の知識の使用
|
||||
${existingQuestionsText ? `- 作成済み問題との重複禁止:${existingQuestionsText}` : ''}
|
||||
|
||||
### 任務:
|
||||
${hasKeywords ? `目標キーワード:${keywordText}\n` : ''}出題スタイル:${style}
|
||||
難易度:${difficultyText}
|
||||
|
||||
以下のJSON配列形式で問題を1つ返してください:
|
||||
### 出力(純粋な JSON 配列):
|
||||
[
|
||||
{
|
||||
"question_text": "...",
|
||||
"key_points": ["ポイント1", "ポイント2"],
|
||||
"difficulty": "...",
|
||||
"dimension": "prompt/llm/ide/devPattern/workCapability",
|
||||
"basis": "[n] 引用箇所..."
|
||||
"knowledge_points": ["知識ポイント参照"],
|
||||
"question_text": "知識ポイントに基づく問題",
|
||||
"key_points": ["採点ポイント"],
|
||||
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
|
||||
"dimension": "prompt|llm|ide|devPattern|workCapability",
|
||||
"basis": "ナレッジベースの原文"
|
||||
}
|
||||
]`;
|
||||
]`;
|
||||
|
||||
const systemPromptEn = `You are an expert examiner. Generate 1 UNIQUE question based on the provided context.
|
||||
const systemPromptEn = `You are an information extraction tool. Follow these steps exactly.
|
||||
|
||||
### Language Rule:
|
||||
**You MUST generate the question and key points in English.**
|
||||
### Step 1: Extract Knowledge Points
|
||||
Read the knowledge base content in the Human message. List ALL assessable knowledge points found.
|
||||
Each point must start with "KP N:" and quote the source text. If insufficient, honestly report.
|
||||
|
||||
### Diversity Rules:
|
||||
${rulesEn}
|
||||
### Step 2: Generate Question from Points
|
||||
Use ONLY the knowledge points from Step 1 to generate 1 question. Must reference KP numbers.
|
||||
|
||||
### Previous Questions (DO NOT REPEAT):
|
||||
${existingQuestionsText || 'None'}
|
||||
### Absolutely Forbidden:
|
||||
- Using any concept, term, or data NOT present in the knowledge base content
|
||||
- Using your own knowledge
|
||||
${existingQuestionsText ? `- Repeating previous questions: ${existingQuestionsText}` : ''}
|
||||
|
||||
Return 1 question as a JSON array with format:
|
||||
### Output (pure JSON array only):
|
||||
[
|
||||
{
|
||||
"question_text": "...",
|
||||
"key_points": ["point1", "point2"],
|
||||
"difficulty": "...",
|
||||
"dimension": "prompt/llm/ide/devPattern/workCapability",
|
||||
"basis": "[n] citation..."
|
||||
"knowledge_points": ["KP reference"],
|
||||
"question_text": "Question based on the knowledge points",
|
||||
"key_points": ["scoring points"],
|
||||
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
|
||||
"dimension": "prompt|llm|ide|devPattern|workCapability",
|
||||
"basis": "Source text from knowledge base"
|
||||
}
|
||||
]`;
|
||||
|
||||
@@ -172,10 +177,10 @@ Return 1 question as a JSON array with format:
|
||||
? systemPromptJa
|
||||
: systemPromptEn;
|
||||
const humanMsg = isZh
|
||||
? `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`
|
||||
? `【知识库内容 - 以下是你出题的唯一依据】\n\n--- 知识库开始 ---\n${knowledgeBaseContent}\n--- 知识库结束 ---\n\n请严格基于以上内容生成题目。`
|
||||
: isJa
|
||||
? `以下の内容に基づいて、必ず日本語でアセスメント問題を作成してください:\n\n${knowledgeBaseContent}`
|
||||
: `Generate evaluation question in English based on:\n\n${knowledgeBaseContent}`;
|
||||
? `【ナレッジベース内容 - 以下は出題の唯一の根拠です】\n\n--- ナレッジベース開始 ---\n${knowledgeBaseContent}\n--- ナレッジベース終了 ---\n\n上記の内容のみに基づいて問題を作成してください。`
|
||||
: `【Knowledge Base Content - Your ONLY source for questions】\n\n--- KB START ---\n${knowledgeBaseContent}\n--- KB END ---\n\nGenerate questions strictly from the above content only.`;
|
||||
|
||||
try {
|
||||
const response = await model.invoke([
|
||||
@@ -226,6 +231,7 @@ Return 1 question as a JSON array with format:
|
||||
return {
|
||||
id: (existingQuestions.length + 1).toString(),
|
||||
questionText: q.question_text,
|
||||
questionType: 'SHORT_ANSWER',
|
||||
keyPoints: q.key_points,
|
||||
difficulty: q.difficulty,
|
||||
basis: q.basis,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { graderNode } from './grader.node';
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
||||
|
||||
function mockModel(response: any) {
|
||||
return {
|
||||
invoke: jest.fn().mockResolvedValue({
|
||||
content: JSON.stringify(response),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function baseState(overrides: any = {}) {
|
||||
return {
|
||||
messages: [new HumanMessage('test answer')],
|
||||
questions: [{ id: 'q1', questionText: 'What is JS?', keyPoints: ['point1'], dimension: 'llm' }],
|
||||
currentQuestionIndex: 0,
|
||||
scores: {},
|
||||
feedbackHistory: [],
|
||||
followUpCount: 0,
|
||||
shouldFollowUp: false,
|
||||
questionCount: 5,
|
||||
language: 'en',
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('graderNode', () => {
|
||||
describe('validation guards', () => {
|
||||
it('should throw when model is missing', async () => {
|
||||
await expect(graderNode(baseState(), { configurable: {} } as any)).rejects.toThrow('Missing model');
|
||||
});
|
||||
|
||||
it('should return empty object when last message is not HumanMessage', async () => {
|
||||
const state = baseState({ messages: [new AIMessage('I am AI')] });
|
||||
const result = await graderNode(state, { configurable: { model: mockModel({}) } } as any);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should skip question and advance index when current question not found', async () => {
|
||||
const state = baseState({ currentQuestionIndex: 99, questions: [{ id: 'q1', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||
const result = await graderNode(state, { configurable: { model: mockModel({}) } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('breakout logic (shouldFollowUp overrides)', () => {
|
||||
it('should NOT follow up when followUpCount >= 2 even if LLM says follow up', async () => {
|
||||
const model = mockModel({ score: 5, feedback: 'needs work', should_follow_up: true, follow_up_question: 'More?' });
|
||||
const state = baseState({ followUpCount: 2 });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT follow up when score >= 8 even if LLM says follow up', async () => {
|
||||
const model = mockModel({ score: 9, feedback: 'good', should_follow_up: true });
|
||||
const state = baseState();
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT follow up when user says "I don\'t know"', async () => {
|
||||
const model = mockModel({ score: 2, feedback: 'no answer', should_follow_up: true });
|
||||
const state = baseState({ messages: [new HumanMessage("no idea")] });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow follow up when conditions are met', async () => {
|
||||
const model = mockModel({ score: 5, feedback: 'incomplete', should_follow_up: true, follow_up_question: 'Can you elaborate?' });
|
||||
const state = baseState({ followUpCount: 0 });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(true);
|
||||
expect(result.followUpCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle LLM returning invalid JSON gracefully', async () => {
|
||||
const model = { invoke: jest.fn().mockResolvedValue({ content: 'NOT JSON' }) };
|
||||
const result = await graderNode(baseState(), { configurable: { model } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(1);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoring and indexing', () => {
|
||||
it('should advance currentQuestionIndex when not following up', async () => {
|
||||
const model = mockModel({ score: 6, feedback: 'ok', should_follow_up: false });
|
||||
const result = await graderNode(baseState(), { configurable: { model } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(1);
|
||||
expect(result.scores).toBeDefined();
|
||||
});
|
||||
|
||||
it('should keep currentQuestionIndex when following up', async () => {
|
||||
const model = mockModel({ score: 5, feedback: 'needs work', should_follow_up: true, follow_up_question: 'Can you clarify?' });
|
||||
const state = baseState({ followUpCount: 0 });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should record score under question id in scores map', async () => {
|
||||
const model = mockModel({ score: 7, feedback: 'good', should_follow_up: false });
|
||||
const state = baseState({ questions: [{ id: 'q-test', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect((result.scores as any)['q-test']).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('language support', () => {
|
||||
it('should handle Chinese language', async () => {
|
||||
const model = mockModel({ score: 8, feedback: '很好', should_follow_up: false });
|
||||
const state = baseState({ language: 'zh' });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle Japanese language', async () => {
|
||||
const model = mockModel({ score: 8, feedback: '良い', should_follow_up: false });
|
||||
const state = baseState({ language: 'ja' });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -67,106 +67,151 @@ export const graderNode = async (
|
||||
return { currentQuestionIndex: currentQuestionIndex + 1 };
|
||||
}
|
||||
|
||||
const systemPromptZh = `你是一位专业的考官。
|
||||
请根据以下问题和关键点对用户的回答进行评分。
|
||||
const isChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE';
|
||||
const expectedAnswer = currentQuestion.correctAnswer;
|
||||
|
||||
重要提示:
|
||||
1. **你必须使用以下语言提供反馈:中文 (Simplified Chinese)**。
|
||||
2. 即使用户的回答或知识库内容涉及其他语言,请确保你的反馈和解释依然严格使用中文。不要夹杂日文。
|
||||
if (isChoice && expectedAnswer) {
|
||||
const userAnswer = (lastUserMessage.content as string).trim();
|
||||
const isCorrect = userAnswer.toUpperCase() === expectedAnswer?.toUpperCase();
|
||||
|
||||
console.log('[GraderNode] Choice grading:', { userAnswer, expectedAnswer, isCorrect });
|
||||
|
||||
const feedback = isCorrect ? '✅ 正确' : `❌ 错误,正确答案是 ${expectedAnswer}`;
|
||||
const feedbackMessage = new AIMessage(
|
||||
{ content: `Score: ${isCorrect ? 10 : 0}\nFeedback: ${feedback}` } as any,
|
||||
);
|
||||
|
||||
return {
|
||||
messages: [feedbackMessage],
|
||||
feedbackHistory: [feedbackMessage],
|
||||
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: isCorrect ? 10 : 0 },
|
||||
shouldFollowUp: false,
|
||||
followUpCount: 0,
|
||||
currentQuestionIndex: currentQuestionIndex + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const systemPromptZh = `你是一位考官。请评分并给出反馈。
|
||||
|
||||
规则:
|
||||
1. 只用中文。
|
||||
2. 多轮追问时,用户回答含所有轮次(第N轮回答:标记),综合判断已覆盖内容。
|
||||
|
||||
问题:${currentQuestion.questionText}
|
||||
预期的关键点:${currentQuestion.keyPoints.join(', ')}
|
||||
关键点:${currentQuestion.keyPoints.join(', ')}
|
||||
|
||||
评估标准:
|
||||
1. 准确性:他们是否正确覆盖了关键点?
|
||||
2. 完整性:他们是否遗漏了任何重要内容?
|
||||
3. 深度:解释是否充分?
|
||||
评分标准:准确性、完整性、深度。
|
||||
部分正确也给分(5-7分),完全不沾边才0-2分。
|
||||
|
||||
请提供:
|
||||
1. 0 到 10 的评分。
|
||||
2. 建设性的反馈。
|
||||
3. 如果回答不完整或不清晰,需要进一步解释,请将 'should_follow_up' 标志设为 true。
|
||||
返回JSON:
|
||||
- score: 0-10
|
||||
- feedback: 评语
|
||||
- should_follow_up: true/false
|
||||
- follow_up_question: 追问(仅true时需要,针对未覆盖的关键点,false时null)
|
||||
|
||||
请以 JSON 格式返回响应:
|
||||
{
|
||||
"score": 8,
|
||||
"feedback": "...",
|
||||
"should_follow_up": false
|
||||
}`;
|
||||
{"score":0到10,"feedback":"评语","should_follow_up":true或false,"follow_up_question":"追问或null"}
|
||||
|
||||
const systemPromptJa = `あなたは専門的な試験官です。
|
||||
以下の質問とキーポイントに基づいて、ユーザーの回答を採点してください。
|
||||
示例(需要追问):
|
||||
{"score":6,"feedback":"提到了安全性和性能,未说明依赖关系。","should_follow_up":true,"follow_up_question":"你如何让AI在计划中明确任务依赖关系?"}
|
||||
|
||||
重要事項:
|
||||
1. **フィードバックは必ず次の言語で提供してください:日本語**。
|
||||
2. ユーザーの回答やナレッジベースの内容に他の言語(中国語や英語など)が含まれている場合でも、フィードバックと説明は必ず日本語のみで行ってください。中国語が混ざらないよう厳格に注意してください。
|
||||
示例(不需追问):
|
||||
{"score":8,"feedback":"回答完整。","should_follow_up":false,"follow_up_question":null}`;
|
||||
|
||||
const systemPromptJa = `あなたは試験官です。採点とフィードバックを提供してください。
|
||||
|
||||
ルール:
|
||||
1. 日本語のみ使用。
|
||||
2. 複数ラウンドの回答は「第N輪回答:」でマークされ、全ラウンドを総合判断。
|
||||
|
||||
質問:${currentQuestion.questionText}
|
||||
期待されるキーポイント:${currentQuestion.keyPoints.join(', ')}
|
||||
キーポイント:${currentQuestion.keyPoints.join(', ')}
|
||||
|
||||
評価基準:
|
||||
1. 正確性:キーポイントを正確に網羅していますか?
|
||||
2. 網羅性:重要な内容が欠落していませんか?
|
||||
3. 深さ:説明は十分ですか?
|
||||
評価基準:正確性、網羅性、深さ。
|
||||
部分点可(5〜7点)、見当違いのみ0〜2点。
|
||||
|
||||
以下を提供してください:
|
||||
1. 0 から 10 までのスコア。
|
||||
2. 建設的なフィードバック。
|
||||
3. 回答が不完全または不明確で、さらなる説明が必要な場合は、'should_follow_up' フラグを true に設定してください。
|
||||
JSON形式:
|
||||
- score: 0〜10
|
||||
- feedback: 評価
|
||||
- should_follow_up: true/false
|
||||
- follow_up_question: 追質問(true時のみ、未カバーのポイントに焦点、false時null)
|
||||
|
||||
JSON 形式で回答してください:
|
||||
{
|
||||
"score": 8,
|
||||
"feedback": "...",
|
||||
"should_follow_up": false
|
||||
}`;
|
||||
{"score":0から10,"feedback":"評価","should_follow_up":trueかfalse,"follow_up_question":"追質問かnull"}
|
||||
|
||||
const systemPromptEn = `You are an expert examiner.
|
||||
Grade the user's answer based on the following question and key points.
|
||||
例(追質問が必要):
|
||||
{"score":6,"feedback":"安全性と性能に言及したが、依存関係が不明。","should_follow_up":true,"follow_up_question":"AIに計画内のタスク依存関係を明示させる方法は?"}
|
||||
|
||||
IMPORTANT:
|
||||
1. **You MUST provide the feedback in English.**
|
||||
2. If the user's answer or knowledge base content references other languages, ensure your feedback and explanation remain strictly in English.
|
||||
例(不要):
|
||||
{"score":8,"feedback":"回答は完全。","should_follow_up":false,"follow_up_question":null}`;
|
||||
|
||||
QUESTION: ${currentQuestion.questionText}
|
||||
EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(', ')}
|
||||
const systemPromptEn = `You are an examiner. Grade and give feedback.
|
||||
|
||||
Evaluate:
|
||||
1. Accuracy: Did they cover the key points correctly?
|
||||
2. Completeness: Did they miss anything important?
|
||||
3. Depth: Is the explanation sufficient?
|
||||
Rules:
|
||||
1. English only.
|
||||
2. Multi-round answers are tagged "第N轮回答:". Consider all rounds.
|
||||
|
||||
Provide:
|
||||
1. A score from 0 to 10.
|
||||
2. Constructive feedback.
|
||||
3. A boolean flag 'should_follow_up' if the answer is incomplete or unclear and needs further clarification.
|
||||
Question: ${currentQuestion.questionText}
|
||||
Key points: ${currentQuestion.keyPoints.join(', ')}
|
||||
|
||||
Format your response as JSON:
|
||||
{
|
||||
"score": 8,
|
||||
"feedback": "...",
|
||||
"should_follow_up": false
|
||||
}`;
|
||||
Criteria: accuracy, completeness, depth.
|
||||
Give partial credit (5-7 for partial), 0-2 only for off-target.
|
||||
|
||||
const systemPrompt = isZh
|
||||
Return JSON:
|
||||
- score: 0-10
|
||||
- feedback: text
|
||||
- should_follow_up: true/false
|
||||
- follow_up_question: question (only when true, target uncovered points, null when false)
|
||||
|
||||
Format as JSON:
|
||||
{"score":0-10,"feedback":"...","should_follow_up":true|false,"follow_up_question":"question or null"}
|
||||
|
||||
Example (follow-up needed):
|
||||
{"score":6,"feedback":"Covered security and performance, missed dependencies.","should_follow_up":true,"follow_up_question":"How would you make the AI clarify task dependencies?"}
|
||||
|
||||
Example (no follow-up):
|
||||
{"score":8,"feedback":"Complete answer.","should_follow_up":false,"follow_up_question":null}`;
|
||||
|
||||
let systemPrompt = isZh
|
||||
? systemPromptZh
|
||||
: isJa
|
||||
? systemPromptJa
|
||||
: systemPromptEn;
|
||||
|
||||
if (currentQuestion.judgment) {
|
||||
const anchorText = isZh
|
||||
? `\n\n【判定依据(通过标准)】${currentQuestion.judgment}`
|
||||
: isJa
|
||||
? `\n\n【判定基準(合格基準)】${currentQuestion.judgment}`
|
||||
: `\n\n【Judgment Criteria (Pass Standard)】${currentQuestion.judgment}`;
|
||||
systemPrompt += anchorText;
|
||||
}
|
||||
|
||||
const maxFollowUps = (currentQuestion as any).maxFollowUps ?? 2;
|
||||
|
||||
const userContentText =
|
||||
typeof lastUserMessage.content === 'string'
|
||||
? lastUserMessage.content
|
||||
: JSON.stringify(lastUserMessage.content);
|
||||
|
||||
let allAnswers = userContentText;
|
||||
if (currentFollowUpCount > 0) {
|
||||
const prevAnswers = state.messages
|
||||
.filter(m => m instanceof HumanMessage)
|
||||
.slice(-(currentFollowUpCount + 1))
|
||||
.map((m, i) => `第${i + 1}轮回答:${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`);
|
||||
allAnswers = prevAnswers.join('\n\n');
|
||||
}
|
||||
|
||||
console.log('[GraderNode] === START GRADING ===');
|
||||
console.log('[GraderNode] User answer length:', userContentText.length);
|
||||
console.log('[GraderNode] Question:', currentQuestion?.questionText?.substring(0, 100));
|
||||
console.log('[GraderNode] Target dimension:', currentQuestion?.dimension);
|
||||
|
||||
try {
|
||||
const response = await model.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userContentText),
|
||||
new HumanMessage(allAnswers),
|
||||
]);
|
||||
|
||||
console.log('[GraderNode] LLM invoke completed');
|
||||
@@ -187,10 +232,7 @@ Format your response as JSON:
|
||||
|
||||
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
|
||||
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
|
||||
|
||||
const feedbackMessage = new AIMessage(
|
||||
`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${result.feedback}`,
|
||||
);
|
||||
let enhancedFeedback: string = result.feedback;
|
||||
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
@@ -199,10 +241,6 @@ Format your response as JSON:
|
||||
|
||||
let shouldFollowUp = result.should_follow_up === true;
|
||||
|
||||
// Breakout logic:
|
||||
// 1. Max 1 follow-up per question
|
||||
// 2. If score is decent (>= 8), don't follow up
|
||||
// 3. If answer is short "don't know", don't follow up
|
||||
const normalizedContent = userContentText.trim().toLowerCase();
|
||||
const saysIDontKnow =
|
||||
normalizedContent.length < 10 &&
|
||||
@@ -217,10 +255,21 @@ Format your response as JSON:
|
||||
normalizedContent.includes('不明') ||
|
||||
normalizedContent.includes('わからない'));
|
||||
|
||||
if (currentFollowUpCount >= 2 || result.score >= 8 || saysIDontKnow) {
|
||||
if (currentFollowUpCount >= maxFollowUps || result.score >= 8 || saysIDontKnow) {
|
||||
shouldFollowUp = false;
|
||||
}
|
||||
|
||||
let followupHintMsg: AIMessage | null = null;
|
||||
if (shouldFollowUp && result.follow_up_question && result.follow_up_question.trim()) {
|
||||
followupHintMsg = new AIMessage(result.follow_up_question.trim());
|
||||
} else if (shouldFollowUp) {
|
||||
shouldFollowUp = false;
|
||||
}
|
||||
|
||||
const feedbackMessage = new AIMessage(
|
||||
`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${enhancedFeedback}`,
|
||||
);
|
||||
|
||||
console.log('[GraderNode] Final State decision:', {
|
||||
shouldFollowUp,
|
||||
nextIndex: shouldFollowUp
|
||||
@@ -230,8 +279,12 @@ Format your response as JSON:
|
||||
saysIDontKnow,
|
||||
});
|
||||
|
||||
const feedbackHistoryMessages = followupHintMsg
|
||||
? [feedbackMessage, followupHintMsg]
|
||||
: [feedbackMessage];
|
||||
|
||||
return {
|
||||
feedbackHistory: [feedbackMessage],
|
||||
feedbackHistory: feedbackHistoryMessages,
|
||||
scores: newScores,
|
||||
shouldFollowUp: shouldFollowUp,
|
||||
followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
|
||||
@@ -239,14 +292,29 @@ Format your response as JSON:
|
||||
? currentQuestionIndex
|
||||
: currentQuestionIndex + 1,
|
||||
} as any;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse grade from AI response:', error);
|
||||
} catch (parseError) {
|
||||
console.error('[GraderNode] Failed to parse grade:', parseError);
|
||||
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
|
||||
const fallbackMsg = new AIMessage(`${scoreLabel}: 5/10\n\n评分解析失败,默认给5分。`);
|
||||
return {
|
||||
feedbackHistory: [
|
||||
new AIMessage("I had some trouble grading that, but let's move on."),
|
||||
],
|
||||
currentQuestionIndex: currentQuestionIndex + 1,
|
||||
feedbackHistory: [fallbackMsg],
|
||||
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: 5 },
|
||||
shouldFollowUp: false,
|
||||
followUpCount: 0,
|
||||
currentQuestionIndex: currentQuestionIndex + 1,
|
||||
} as any;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[GraderNode] LLM grading failed:', error);
|
||||
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
|
||||
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
|
||||
const fallbackMsg = new AIMessage(`${scoreLabel}: 5/10\n\n${feedbackLabel}: 评分服务暂时不可用,默认给5分。`);
|
||||
return {
|
||||
feedbackHistory: [fallbackMsg],
|
||||
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: 5 },
|
||||
shouldFollowUp: false,
|
||||
followUpCount: 0,
|
||||
currentQuestionIndex: currentQuestionIndex + 1,
|
||||
} as any;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { interviewerNode } from './interviewer.node';
|
||||
import { AIMessage } from '@langchain/core/messages';
|
||||
|
||||
function baseState(overrides: any = {}) {
|
||||
return {
|
||||
questions: [{ id: 'q1', questionText: 'What is JS?', keyPoints: ['point1'], dimension: 'llm' }],
|
||||
currentQuestionIndex: 0,
|
||||
shouldFollowUp: false,
|
||||
language: 'en',
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('interviewerNode', () => {
|
||||
describe('empty questions handling', () => {
|
||||
it('should return apology message when questions array is empty', async () => {
|
||||
const state = baseState({ questions: [] });
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as any)[0].content).toContain("sorry");
|
||||
});
|
||||
|
||||
it('should return apology message when questions is undefined', async () => {
|
||||
const state = baseState({ questions: undefined });
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as any)[0].content).toContain("sorry");
|
||||
});
|
||||
|
||||
it('should return Chinese apology when language is zh', async () => {
|
||||
const state = baseState({ questions: [], language: 'zh' });
|
||||
const result = await interviewerNode(state);
|
||||
expect((result.messages as any)[0].content).toContain('抱歉');
|
||||
});
|
||||
|
||||
it('should return Japanese apology when language is ja', async () => {
|
||||
const state = baseState({ questions: [], language: 'ja' });
|
||||
const result = await interviewerNode(state);
|
||||
expect((result.messages as any)[0].content).toContain('申し訳');
|
||||
});
|
||||
});
|
||||
|
||||
describe('question index range checks', () => {
|
||||
it('should return shouldFollowUp: false when currentQuestionIndex >= questions.length', async () => {
|
||||
const state = baseState({ currentQuestionIndex: 5, questions: [{ id: 'q1', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('standard question presentation', () => {
|
||||
it('should present the current question', async () => {
|
||||
const result = await interviewerNode(baseState());
|
||||
expect(result.messages).toBeDefined();
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('Question 1');
|
||||
expect(msg).toContain('What is JS?');
|
||||
});
|
||||
|
||||
it('should include answer instruction', async () => {
|
||||
const result = await interviewerNode(baseState());
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('answer');
|
||||
});
|
||||
|
||||
it('should use Chinese labels when language is zh', async () => {
|
||||
const state = baseState({ language: 'zh' });
|
||||
const result = await interviewerNode(state);
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('问题');
|
||||
expect(msg).toContain('回答');
|
||||
});
|
||||
|
||||
it('should use Japanese labels when language is ja', async () => {
|
||||
const state = baseState({ language: 'ja' });
|
||||
const result = await interviewerNode(state);
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('質問');
|
||||
expect(msg).toContain('回答');
|
||||
});
|
||||
});
|
||||
|
||||
describe('follow-up mode', () => {
|
||||
it('should use last feedbackHistory message content as follow-up prompt', async () => {
|
||||
const state = baseState({
|
||||
shouldFollowUp: true,
|
||||
feedbackHistory: [new AIMessage('You need more details')],
|
||||
});
|
||||
const result = await interviewerNode(state);
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('You need more details');
|
||||
});
|
||||
|
||||
it('should reset shouldFollowUp to false after processing', async () => {
|
||||
const state = baseState({
|
||||
shouldFollowUp: true,
|
||||
feedbackHistory: [new AIMessage('Feedback: More info needed')],
|
||||
});
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,8 +33,6 @@ export const interviewerNode = async (
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
|
||||
// If it's a follow-up, we add a prefix to the label later.
|
||||
// If we've run out of questions and no follow-up requested, we shouldn't be here, but let's be safe.
|
||||
if (currentQuestionIndex >= questions.length) {
|
||||
return { shouldFollowUp: false };
|
||||
}
|
||||
@@ -49,33 +47,24 @@ export const interviewerNode = async (
|
||||
state.feedbackHistory &&
|
||||
state.feedbackHistory.length > 0
|
||||
) {
|
||||
// Construct a follow-up prompt based on last feedback
|
||||
const lastFeedbackMsg =
|
||||
state.feedbackHistory[state.feedbackHistory.length - 1];
|
||||
const feedbackText = lastFeedbackMsg.content.toString();
|
||||
|
||||
// Extract the "Feedback: ..." part if possible, otherwise use whole text
|
||||
const feedbackMatch = feedbackText.match(
|
||||
/(?:Feedback|反馈|フィードバック): ([\s\S]*)/i,
|
||||
);
|
||||
const specificFeedback = feedbackMatch
|
||||
? feedbackMatch[1].trim()
|
||||
: feedbackText;
|
||||
|
||||
const followUpLabel = isZh
|
||||
? '补充追问'
|
||||
prompt = lastFeedbackMsg.content.toString();
|
||||
} else if (currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0) {
|
||||
const label = isZh
|
||||
? `问题 ${currentQuestionIndex + 1}`
|
||||
: isJa
|
||||
? '追加の質問'
|
||||
: 'Follow-up Clarification';
|
||||
const followUpInstruction = isZh
|
||||
? '根据以上反馈,请补充更具体的信息:'
|
||||
: isJa
|
||||
? '上記のフィードバックに基づき、より具体的な情報を追加してください:'
|
||||
: 'Based on the feedback above, please provide more specific details:';
|
||||
? `質問 ${currentQuestionIndex + 1}`
|
||||
: `Question ${currentQuestionIndex + 1}`;
|
||||
|
||||
prompt = `${followUpLabel}\n\n${specificFeedback}\n\n${followUpInstruction}`;
|
||||
const instruction = isZh
|
||||
? '请选择一个选项'
|
||||
: isJa
|
||||
? '選択肢から1つ選んでください'
|
||||
: 'Please select one option';
|
||||
|
||||
prompt = `${label}: ${currentQuestion.questionText}\n\n${instruction}`;
|
||||
} else {
|
||||
// Standard question presentation
|
||||
const label = isZh
|
||||
? `问题 ${currentQuestionIndex + 1}`
|
||||
: isJa
|
||||
|
||||
@@ -119,6 +119,15 @@ export const EvaluationAnnotation = Annotation.Root({
|
||||
keywords: Annotation<string[] | undefined>({
|
||||
reducer: (prev, next) => next ?? prev,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Answer key for bank questions: id → { correctAnswer, judgment, followupHints }.
|
||||
* Used by grader for instant choice scoring and open-question anchoring.
|
||||
* NOT sent to frontend.
|
||||
*/
|
||||
questionAnswerKey: Annotation<Record<string, any> | undefined>({
|
||||
reducer: (prev, next) => next ?? prev,
|
||||
}),
|
||||
});
|
||||
|
||||
export type EvaluationState = typeof EvaluationAnnotation.State;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditLog } from '../entities/audit-log.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogService {
|
||||
private readonly logger = new Logger(AuditLogService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AuditLog)
|
||||
private auditLogRepository: Repository<AuditLog>,
|
||||
) {}
|
||||
|
||||
async log(params: {
|
||||
userId: string;
|
||||
tenantId?: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
details?: any;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const entry = this.auditLogRepository.create({
|
||||
userId: params.userId,
|
||||
tenantId: params.tenantId,
|
||||
action: params.action,
|
||||
resourceType: params.resourceType,
|
||||
resourceId: params.resourceId,
|
||||
details: params.details,
|
||||
});
|
||||
await this.auditLogRepository.insert(entry);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write audit log: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AssessmentSession } from '../entities/assessment-session.entity';
|
||||
import { AssessmentQuestion } from '../entities/assessment-question.entity';
|
||||
import { AssessmentAnswer } from '../entities/assessment-answer.entity';
|
||||
import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
|
||||
import { generateAssessmentPdf } from './pdf-generator';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
@@ -95,7 +96,7 @@ export class ExportService {
|
||||
}
|
||||
|
||||
private extractDimensionScores(session: AssessmentSession): any[][] {
|
||||
const scores = session.templateJson?.dimensionScores || session.finalReport;
|
||||
const scores = (session as any).dimensionScores;
|
||||
if (!scores) return [['未找到维度分数']];
|
||||
|
||||
if (typeof scores === 'string') {
|
||||
@@ -142,86 +143,47 @@ export class ExportService {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const certificate = await this.certificateRepository.findOne({
|
||||
const cert = await this.certificateRepository.findOne({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
const questions = await this.questionRepository.find({
|
||||
where: { sessionId },
|
||||
order: { createdAt: 'ASC' },
|
||||
const questions = (session.questions_json || []) as any[];
|
||||
|
||||
const userName = session.user?.displayName || session.user?.username || session.userId;
|
||||
const templateName = session.template?.name || session.templateJson?.name || '-';
|
||||
const dimensionScores = (session as any).dimensionScores || {};
|
||||
|
||||
let dimRows = '';
|
||||
for (const [dim, score] of Object.entries(dimensionScores)) {
|
||||
dimRows += `<tr><td>${dim}</td><td>${score}/10</td></tr>`;
|
||||
}
|
||||
|
||||
let qRows = '';
|
||||
questions.forEach((q: any, i: number) => {
|
||||
qRows += `<tr><td>${i + 1}</td><td>${(q.questionText || '').substring(0, 80)}</td><td>${q.questionType || '-'}</td><td>${q.dimension || '-'}</td></tr>`;
|
||||
});
|
||||
|
||||
const answers = await this.answerRepository.find({
|
||||
where: { questionId: In(questions.map((q) => q.id)) },
|
||||
});
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Assessment Report</title>
|
||||
<style>body{font-family:'Microsoft YaHei',sans-serif;max-width:800px;margin:40px auto;color:#333}
|
||||
h1{font-size:24px}h2{font-size:18px;border-bottom:2px solid #4F46E5;padding-bottom:8px}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0}
|
||||
td,th{border:1px solid #ddd;padding:8px;text-align:left}
|
||||
th{background:#F3F4F6}.score{font-size:36px;font-weight:bold;color:#4F46E5}
|
||||
.pass{color:#059669}.fail{color:#DC2626}</style></head><body>
|
||||
<h1>Assessment Report</h1>
|
||||
<p>${userName} — ${new Date(session.createdAt).toLocaleDateString()}</p>
|
||||
<p>Template: ${templateName}</p>
|
||||
<h2>Result</h2>
|
||||
<p class="score">${session.finalScore ?? '-'}/10</p>
|
||||
<p class="${(session as any).passed ? 'pass' : 'fail'}">${(session as any).passed ? 'PASSED' : 'FAILED'}</p>
|
||||
${cert ? `<p>Level: ${cert.level}</p>` : ''}
|
||||
<h2>Dimension Scores</h2>
|
||||
<table>${dimRows}</table>
|
||||
<h2>Questions</h2>
|
||||
<table><tr><th>#</th><th>Question</th><th>Type</th><th>Dimension</th></tr>${qRows}</table>
|
||||
${session.finalReport ? `<h2>Mastery Report</h2><pre>${session.finalReport}</pre>` : ''}
|
||||
</body></html>`;
|
||||
|
||||
const content = this.generatePdfContent(session, questions, answers, certificate);
|
||||
return Buffer.from(content, 'utf-8');
|
||||
}
|
||||
|
||||
private generatePdfContent(
|
||||
session: AssessmentSession,
|
||||
questions: AssessmentQuestion[],
|
||||
answers: AssessmentAnswer[],
|
||||
certificate: AssessmentCertificate | null,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('='.repeat(60));
|
||||
lines.push(' 人才评估报告');
|
||||
lines.push('='.repeat(60));
|
||||
lines.push('');
|
||||
|
||||
lines.push(`评估ID: ${session.id}`);
|
||||
lines.push(`用户: ${session.user?.displayName || session.user?.username || session.userId}`);
|
||||
lines.push(`状态: ${session.status === 'COMPLETED' ? '已完成' : '进行中'}`);
|
||||
lines.push(`最终分数: ${session.finalScore || '-'}`);
|
||||
lines.push(`评估模板: ${session.template?.name || session.templateJson?.name || '-'}`);
|
||||
lines.push(`评估时间: ${session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'}`);
|
||||
lines.push('');
|
||||
|
||||
if (certificate) {
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push('证书信息');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push(`等级: ${certificate.level}`);
|
||||
lines.push(`总分: ${certificate.totalScore}`);
|
||||
lines.push(`是否通过: ${certificate.passed ? '是' : '否'}`);
|
||||
lines.push(`颁发时间: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push('题目详情');
|
||||
lines.push('-'.repeat(60));
|
||||
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a]));
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const a = answerMap.get(q.id);
|
||||
lines.push('');
|
||||
lines.push(`第${i + 1}题:`);
|
||||
lines.push(` 题目: ${q.questionText || '-'}`);
|
||||
lines.push(` 用户回答: ${a?.userAnswer || '-'}`);
|
||||
lines.push(` 得分: ${a?.score ?? '-'}`);
|
||||
lines.push(` 反馈: ${a?.feedback || '-'}`);
|
||||
lines.push(` 追问: ${a?.isFollowUp ? '是' : '否'}`);
|
||||
}
|
||||
|
||||
if (session.finalReport) {
|
||||
lines.push('');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push('综合评估报告');
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push(session.finalReport);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('='.repeat(60));
|
||||
lines.push(' 报告结束');
|
||||
lines.push('='.repeat(60));
|
||||
|
||||
return lines.join('\n');
|
||||
return Buffer.from(html, 'utf-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PDFDocument, rgb, StandardFonts, PageSizes } from 'pdf-lib';
|
||||
|
||||
const FONT_SEARCH_PATHS = [
|
||||
'C:/Windows/Fonts/NotoSansSC-VF.ttf',
|
||||
'C:/Windows/Fonts/NotoSansJP-VF.ttf',
|
||||
path.join(__dirname, '..', '..', '..', 'assets', 'fonts', 'NotoSansSC-VF.ttf'),
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||
];
|
||||
|
||||
let cachedFontBytes: Buffer | null = null;
|
||||
|
||||
function findFont(): Buffer {
|
||||
if (cachedFontBytes) return cachedFontBytes;
|
||||
for (const p of FONT_SEARCH_PATHS) {
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
cachedFontBytes = fs.readFileSync(p);
|
||||
return cachedFontBytes;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
interface PdfReportOptions {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sections: PdfSection[];
|
||||
}
|
||||
|
||||
interface PdfSection {
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export async function generateAssessmentPdf(options: PdfReportOptions): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
|
||||
let font: any;
|
||||
const fontBytes = findFont();
|
||||
if (fontBytes.length > 0) {
|
||||
try {
|
||||
font = await doc.embedFont(fontBytes, { subset: true });
|
||||
} catch {
|
||||
font = undefined;
|
||||
}
|
||||
}
|
||||
if (!font) {
|
||||
font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
}
|
||||
|
||||
const pageWidth = PageSizes.A4[0];
|
||||
const pageHeight = PageSizes.A4[1];
|
||||
const margin = 50;
|
||||
const fontSize = 10;
|
||||
const titleSize = 20;
|
||||
const sectionSize = 13;
|
||||
const lineHeight = fontSize * 1.6;
|
||||
|
||||
let page = doc.addPage([pageWidth, pageHeight]);
|
||||
let y = pageHeight - margin;
|
||||
|
||||
function newPage() {
|
||||
page = doc.addPage([pageWidth, pageHeight]);
|
||||
y = pageHeight - margin;
|
||||
}
|
||||
|
||||
function drawText(text: string, size: number, color: any, offsetY: number) {
|
||||
if (y < margin + offsetY) newPage();
|
||||
page.drawText(text, { x: margin, y, size, font, color });
|
||||
y -= offsetY;
|
||||
}
|
||||
|
||||
drawText(options.title, titleSize, rgb(0, 0, 0), titleSize * 1.8);
|
||||
|
||||
if (options.subtitle) {
|
||||
drawText(options.subtitle, 9, rgb(0.4, 0.4, 0.4), 16);
|
||||
}
|
||||
|
||||
for (const section of options.sections) {
|
||||
y -= 8;
|
||||
drawText(section.title, sectionSize, rgb(0.1, 0.1, 0.1), sectionSize * 1.8);
|
||||
|
||||
for (const line of section.lines) {
|
||||
if (!line) continue;
|
||||
for (const chunk of line.split('\n')) {
|
||||
drawText(chunk || ' ', fontSize, rgb(0.2, 0.2, 0.2), lineHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawText('--- End of Report ---', 8, rgb(0.6, 0.6, 0.6), 20);
|
||||
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
GENERATE_QUESTIONS_SYSTEM_PROMPT,
|
||||
parseGeneratedQuestion,
|
||||
QuestionBankService,
|
||||
} from './question-bank.service';
|
||||
import {
|
||||
QuestionBankItem,
|
||||
QuestionType,
|
||||
QuestionDifficulty,
|
||||
QuestionDimension,
|
||||
QuestionBankItemStatus,
|
||||
} from '../entities/question-bank-item.entity';
|
||||
import { QuestionBank, QuestionBankStatus } from '../entities/question-bank.entity';
|
||||
import { ModelConfigService } from '../../model-config/model-config.service';
|
||||
|
||||
const BANK_ID = 'test-bank-id';
|
||||
const TEMPLATE_ID = 'test-template-id';
|
||||
const USER_ID = 'user-1';
|
||||
const TENANT_ID = 'default';
|
||||
|
||||
describe('GENERATE_QUESTIONS_SYSTEM_PROMPT', () => {
|
||||
it('should require both choice and open question types', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('choice');
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('open');
|
||||
});
|
||||
|
||||
it('should specify choice:open ratio', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/3.*7|choice.*open|选择题.*简答题/);
|
||||
});
|
||||
|
||||
it('should require judgment field for every question', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('judgment');
|
||||
});
|
||||
|
||||
it('should require followupHints for open questions', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('followupHints');
|
||||
});
|
||||
|
||||
it('should include a few-shot example for choice questions', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/代码规范|AGENTS\.md|Prettier/);
|
||||
});
|
||||
|
||||
it('should include a few-shot example for open questions', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/会话管理|上下文窗口|开新窗口/);
|
||||
});
|
||||
|
||||
it('should prohibit concept-definition questions', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/禁止.*概念|不要.*定义|不能.*什么是/);
|
||||
});
|
||||
|
||||
it('should require similar option lengths', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/字符差|选项.*长度|长度.*相近/);
|
||||
});
|
||||
|
||||
it('should prohibit "以上都对" and "以上都不对"', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/禁止.*以上都对|以上都对.*禁止|禁止.*以上都不对/);
|
||||
});
|
||||
|
||||
it('should require keyPoints from knowledge base', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/key_points.*知识库|知识库.*key_points|知识库.*原文/);
|
||||
});
|
||||
|
||||
it('should prohibit markdown wrapping in JSON output', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/不要.*[Mm]arkdown|禁止.*[Mm]arkdown|不允许.*[Mm]arkdown|只输出.*JSON|纯JSON/);
|
||||
});
|
||||
|
||||
it('should allow difficulty STANDARD, ADVANCED, SPECIALIST', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toContain('STANDARD');
|
||||
});
|
||||
|
||||
it('should allow five dimensions: prompt, llm, ide, devPattern, workCapability', () => {
|
||||
expect(GENERATE_QUESTIONS_SYSTEM_PROMPT).toMatch(/prompt|llm|ide|devPattern|workCapability/);
|
||||
});
|
||||
|
||||
it('should have reasonable prompt length', () => {
|
||||
const len = GENERATE_QUESTIONS_SYSTEM_PROMPT.length;
|
||||
expect(len).toBeGreaterThan(1500);
|
||||
expect(len).toBeLessThan(8000);
|
||||
});
|
||||
});
|
||||
|
||||
const mockChoiceQuestion = {
|
||||
type: 'choice',
|
||||
scenario: '你在编写代码,AI生成的代码风格不一致',
|
||||
questionText: '【场景】你在编写一段复杂代码... 【问题】以下哪种做法最有效?',
|
||||
options: ['A. 每次手动调整', 'B. 写入AGENTS.md', 'C. 用通用指令', 'D. Prettier格式化'],
|
||||
correctAnswer: 'B',
|
||||
judgment: 'B正确,因为规范文档化能从源头统一。A效率低。C模糊。D只解决表面问题。',
|
||||
keyPoints: ['规范文档化', '源头统一'],
|
||||
difficulty: 'STANDARD',
|
||||
dimension: 'prompt',
|
||||
basis: '知识库原文',
|
||||
};
|
||||
|
||||
const mockOpenQuestion = {
|
||||
type: 'open',
|
||||
scenario: '你与AI反复修改文档30轮后AI开始遗忘关键约束',
|
||||
questionText: '【场景】你与AI反复修改... 【问题】这种情况怎么造成的?应该怎么做?',
|
||||
judgment: '关键考点:会话管理 通过标准:说出让AI总结+开新窗口即通过',
|
||||
followupHints: ['追问如何保留之前结论'],
|
||||
keyPoints: ['上下文窗口膨胀', '信息蒸馏'],
|
||||
difficulty: 'STANDARD',
|
||||
dimension: 'prompt',
|
||||
basis: '知识库原文',
|
||||
};
|
||||
|
||||
describe('parseGeneratedQuestion', () => {
|
||||
describe('choice type', () => {
|
||||
it('should parse choice question with MULTIPLE_CHOICE type', () => {
|
||||
const item = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||
|
||||
expect(item.questionType).toBe(QuestionType.MULTIPLE_CHOICE);
|
||||
expect(item.options).toEqual([
|
||||
'A. 每次手动调整',
|
||||
'B. 写入AGENTS.md',
|
||||
'C. 用通用指令',
|
||||
'D. Prettier格式化',
|
||||
]);
|
||||
expect(item.options).toHaveLength(4);
|
||||
expect(item.correctAnswer).toBe('B');
|
||||
expect(item.judgment).toContain('B正确');
|
||||
expect(item.followupHints).toBeNull();
|
||||
});
|
||||
|
||||
it('should store judgment for choice question', () => {
|
||||
const item = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||
|
||||
expect(item.judgment).toBe(
|
||||
'B正确,因为规范文档化能从源头统一。A效率低。C模糊。D只解决表面问题。',
|
||||
);
|
||||
});
|
||||
|
||||
it('should store keyPoints with technique tag', () => {
|
||||
const q = {
|
||||
...mockChoiceQuestion,
|
||||
technique: '代码风格注入',
|
||||
};
|
||||
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||
|
||||
expect(item.keyPoints[0]).toBe('【考查技巧】代码风格注入');
|
||||
expect(item.keyPoints).toContain('规范文档化');
|
||||
expect(item.keyPoints).toContain('源头统一');
|
||||
});
|
||||
});
|
||||
|
||||
describe('open type', () => {
|
||||
it('should parse open question with SHORT_ANSWER type', () => {
|
||||
const item = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||
|
||||
expect(item.questionType).toBe(QuestionType.SHORT_ANSWER);
|
||||
expect(item.options).toBeNull();
|
||||
expect(item.correctAnswer).toBeNull();
|
||||
expect(item.judgment).toContain('通过标准');
|
||||
expect(item.judgment).toContain('会话管理');
|
||||
});
|
||||
|
||||
it('should store followupHints array', () => {
|
||||
const item = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||
|
||||
expect(item.followupHints).toEqual(['追问如何保留之前结论']);
|
||||
expect(item.followupHints).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle open question with no followupHints', () => {
|
||||
const q = { ...mockOpenQuestion, followupHints: [] };
|
||||
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||
|
||||
expect(item.followupHints).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle open question with 2 followupHints', () => {
|
||||
const q = {
|
||||
...mockOpenQuestion,
|
||||
followupHints: ['追问1', '追问2'],
|
||||
};
|
||||
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||
|
||||
expect(item.followupHints).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('common fields', () => {
|
||||
it('should store keyPoints on both types', () => {
|
||||
const choice = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||
const open = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||
|
||||
expect(choice.keyPoints.length).toBeGreaterThan(0);
|
||||
expect(open.keyPoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle missing keyPoints gracefully', () => {
|
||||
const q = { ...mockOpenQuestion, keyPoints: undefined };
|
||||
const item = parseGeneratedQuestion(q, BANK_ID);
|
||||
|
||||
expect(item.keyPoints).toEqual([]);
|
||||
});
|
||||
|
||||
it('should normalize dimension case-insensitively', () => {
|
||||
const q1 = parseGeneratedQuestion(
|
||||
{ ...mockOpenQuestion, dimension: 'LLM' },
|
||||
BANK_ID,
|
||||
);
|
||||
const q2 = parseGeneratedQuestion(
|
||||
{ ...mockOpenQuestion, dimension: 'llm' },
|
||||
BANK_ID,
|
||||
);
|
||||
const q3 = parseGeneratedQuestion(
|
||||
{ ...mockOpenQuestion, dimension: 'Llm' },
|
||||
BANK_ID,
|
||||
);
|
||||
|
||||
expect(q1.dimension).toBe(QuestionDimension.LLM);
|
||||
expect(q2.dimension).toBe(QuestionDimension.LLM);
|
||||
expect(q3.dimension).toBe(QuestionDimension.LLM);
|
||||
});
|
||||
|
||||
it('should default dimension to WORK_CAPABILITY for unknown values', () => {
|
||||
const q = parseGeneratedQuestion(
|
||||
{ ...mockOpenQuestion, dimension: 'unknown' },
|
||||
BANK_ID,
|
||||
);
|
||||
|
||||
expect(q.dimension).toBe(QuestionDimension.WORK_CAPABILITY);
|
||||
});
|
||||
|
||||
it('should map all five dimensions correctly', () => {
|
||||
const dims = ['prompt', 'llm', 'ide', 'devPattern', 'workCapability'];
|
||||
const expected = [
|
||||
QuestionDimension.PROMPT,
|
||||
QuestionDimension.LLM,
|
||||
QuestionDimension.IDE,
|
||||
QuestionDimension.DEV_PATTERN,
|
||||
QuestionDimension.WORK_CAPABILITY,
|
||||
];
|
||||
|
||||
dims.forEach((dim, i) => {
|
||||
const q = parseGeneratedQuestion(
|
||||
{ ...mockOpenQuestion, dimension: dim },
|
||||
BANK_ID,
|
||||
);
|
||||
expect(q.dimension).toBe(expected[i]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should store difficulty correctly', () => {
|
||||
const q = parseGeneratedQuestion(
|
||||
{ ...mockOpenQuestion, difficulty: 'ADVANCED' },
|
||||
BANK_ID,
|
||||
);
|
||||
|
||||
expect(q.difficulty).toBe(QuestionDifficulty.ADVANCED);
|
||||
});
|
||||
|
||||
it('should set bankId and status on all items', () => {
|
||||
const item = parseGeneratedQuestion(mockOpenQuestion, BANK_ID);
|
||||
|
||||
expect(item.bankId).toBe(BANK_ID);
|
||||
expect(item.status).toBe(QuestionBankItemStatus.PENDING_REVIEW);
|
||||
});
|
||||
|
||||
it('should store basis text', () => {
|
||||
const item = parseGeneratedQuestion(mockChoiceQuestion, BANK_ID);
|
||||
|
||||
expect(item.basis).toBe('知识库原文');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestionBankService - status guards', () => {
|
||||
let service: QuestionBankService;
|
||||
let bankRepo: any;
|
||||
let itemRepo: any;
|
||||
|
||||
const mockRepository = () => ({
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
save: jest.fn().mockImplementation((entity: any) => Promise.resolve(entity)),
|
||||
create: jest.fn((dto: any) => dto as any),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
createQueryBuilder: jest.fn().mockReturnValue({
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
});
|
||||
|
||||
const mockModelConfig = () => ({
|
||||
findDefaultByType: jest.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'deepseek-chat',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
}),
|
||||
});
|
||||
|
||||
const makeBank = (overrides?: Partial<QuestionBank>): QuestionBank =>
|
||||
({
|
||||
id: 'bank-1',
|
||||
name: 'Test Bank',
|
||||
status: QuestionBankStatus.DRAFT,
|
||||
templateId: TEMPLATE_ID,
|
||||
tenantId: TENANT_ID,
|
||||
...overrides,
|
||||
}) as QuestionBank;
|
||||
|
||||
const makeItem = (overrides?: Partial<QuestionBankItem>): QuestionBankItem =>
|
||||
({
|
||||
id: 'item-1',
|
||||
bankId: BANK_ID,
|
||||
questionText: 'Question?',
|
||||
questionType: QuestionType.SHORT_ANSWER,
|
||||
keyPoints: ['kp1'],
|
||||
difficulty: QuestionDifficulty.STANDARD,
|
||||
dimension: QuestionDimension.PROMPT,
|
||||
status: QuestionBankItemStatus.PENDING_REVIEW,
|
||||
...overrides,
|
||||
}) as QuestionBankItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
QuestionBankService,
|
||||
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
|
||||
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
|
||||
{ provide: ModelConfigService, useFactory: mockModelConfig },
|
||||
{ provide: ConfigService, useFactory: () => ({}) },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<QuestionBankService>(QuestionBankService);
|
||||
bankRepo = module.get(getRepositoryToken(QuestionBank));
|
||||
itemRepo = module.get(getRepositoryToken(QuestionBankItem));
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createDto = { name: 'New Bank', templateId: TEMPLATE_ID };
|
||||
|
||||
it('create: should allow cross-tenant when DRAFT exists for another tenant', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.create(createDto, USER_ID, TENANT_ID);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('create: DRAFT exists same tenant → BadRequestException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT }));
|
||||
|
||||
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('create: REJECTED exists same tenant → BadRequestException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED }));
|
||||
|
||||
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('create: PUBLISHED exists same tenant → BadRequestException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.create(createDto, USER_ID, TENANT_ID)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('create: no existing bank → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.create(createDto, USER_ID, TENANT_ID);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('remove: DRAFT → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.DRAFT }));
|
||||
|
||||
await expect(service.remove('bank-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('remove: REJECTED → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.REJECTED }));
|
||||
|
||||
await expect(service.remove('bank-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('remove: PUBLISHED → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.remove('bank-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('removeItem: PENDING_REVIEW item → success', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank());
|
||||
itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PENDING_REVIEW }));
|
||||
|
||||
await expect(service.removeItem(BANK_ID, 'item-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('removeItem: PUBLISHED item → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank());
|
||||
itemRepo.findOne.mockResolvedValue(makeItem({ status: QuestionBankItemStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.removeItem(BANK_ID, 'item-1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateQuestions', () => {
|
||||
it('generateQuestions: PUBLISHED bank → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PUBLISHED }));
|
||||
|
||||
await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID))
|
||||
.rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('generateQuestions: PENDING_REVIEW bank → ForbiddenException', async () => {
|
||||
bankRepo.findOne.mockResolvedValue(makeBank({ status: QuestionBankStatus.PENDING_REVIEW }));
|
||||
|
||||
await expect(service.generateQuestions('bank-1', 1, 'some content', TENANT_ID))
|
||||
.rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,180 @@ const DIMENSIONS = [
|
||||
QuestionDimension.WORK_CAPABILITY,
|
||||
];
|
||||
|
||||
export const GENERATE_QUESTIONS_SYSTEM_PROMPT = `你是 AI 人才考核的出题专家。你需要从知识库内容中生成考核题目。
|
||||
|
||||
## 一、内部步骤(在脑中完成,不要输出)
|
||||
1. 从知识库提取可考核的实战知识点
|
||||
2. 确定该知识点对应的具体技巧或方法
|
||||
3. 围绕该技巧设计一个真实工作场景
|
||||
|
||||
## 二、题型比例
|
||||
本题库同时生成两种题型,按 **choice:open = 3:7** 分配。
|
||||
- choice = 选择题(4选1)
|
||||
- open = 简答题(开放式 + 追问)
|
||||
|
||||
## 三、选择题规则(choice 型)
|
||||
### 3.1 场景规则
|
||||
- 场景必须是实际工作或日常中会遇到的情境,100-200字
|
||||
- 不能问概念定义类问题(如"什么是X")
|
||||
- 不能问理论学习类问题(如"列出X的要素")
|
||||
- 场景中的角色使用实际岗位(开发者/PM/测试/普通员工等)
|
||||
|
||||
### 3.2 决策点规则
|
||||
- 每道题必须有明确的决策点——学习者要做选择或决定怎么做
|
||||
- 不能只是"请解释"
|
||||
|
||||
### 3.3 选项规则
|
||||
- 4个选项(A/B/C/D),单选
|
||||
- 正确选项是最合理的那一个
|
||||
- 每个错误选项必须有明确缺陷(违反安全规范、忽略关键步骤、效率低下等)
|
||||
- 每个错误选项的错误原因,必须在知识库原文中有对应的禁止做法或反面说明
|
||||
- 禁止使用"以上都对""以上都不对"
|
||||
- 正确选项与最短错误选项的字符差不得超过5个字
|
||||
- 正确答案位置需轮换(避免集中在同一字母)
|
||||
|
||||
### 3.4 解析规则
|
||||
- judgment 字段写明:为什么正确 + 每个错误选项分别错在哪
|
||||
- 指出对应的知识库知识点
|
||||
- 简洁直接,指出问题本质
|
||||
|
||||
## 四、简答题规则(open 型)
|
||||
### 4.1 场景规则
|
||||
- 同选择题 3.1
|
||||
- 场景中暗示需要什么能力,但不要说破
|
||||
|
||||
### 4.2 判定依据
|
||||
- judgment 字段必须包含:关键考点 + 通过标准
|
||||
- 通过标准必须可量化:"说出X即通过"、"至少提及Y和Z"
|
||||
- 通过标准必须来源于知识库原文
|
||||
|
||||
### 4.3 追问方向
|
||||
- followupHints 数组:0-2条追问方向
|
||||
- 追问用于引导学习者补充遗漏的关键点
|
||||
- 追问应具体、可回答
|
||||
- 示例:"如果只回答开新窗口没说怎么带上前情:追问怎么把有用信息带过去?"
|
||||
|
||||
## 五、禁止项(适用于所有题型)
|
||||
- 禁止问概念定义(如"什么是提示词工程")
|
||||
- 禁止问理论列举(如"六要素有哪些")
|
||||
- 禁止选择题出现"以上都对""以上都不对"
|
||||
- 禁止正确选项明显比其他选项长或短
|
||||
- 禁止场景脱离实际(如"如果你是CEO"不适合L1)
|
||||
- 禁止虚构知识库中不存在的方法、工具、术语
|
||||
- key_points 必须从知识库原文中提取,不得自行编造
|
||||
- 相邻题目的场景背景不得重复或相似
|
||||
|
||||
## 六、出题维度(自动判断)
|
||||
根据题目内容,从以下五个维度中选择最匹配的一个:
|
||||
- prompt(提示词工程)
|
||||
- llm(LLM理解)
|
||||
- ide(IDE协作开发)
|
||||
- devPattern(开发范式)
|
||||
- workCapability(工作能力)
|
||||
|
||||
## 七、难度说明
|
||||
默认 STANDARD。如果场景特别复杂或涉及多步推理,可标记 ADVANCED 或 SPECIALIST。
|
||||
|
||||
## 八、参考示例
|
||||
|
||||
### 选择题示例
|
||||
【场景】你在编写一段复杂的业务逻辑代码,让 AI 帮忙生成。AI 第一次生成的代码功能没问题,但代码风格和你项目现有的不太一样(缩进方式、命名规范不同)。为了提高后续生成的代码一致性,以下哪种做法最有效?
|
||||
|
||||
A. 每次生成后手动调整格式,下次再让 AI 生成时重新说明一遍风格要求。
|
||||
B. 将项目的代码规范写入 AGENTS.md 或项目配置文件中,让 AI 在生成时自动参考。
|
||||
C. 给 AI 发送一条"请遵循团队规范"的通用指令,下一条代码就会自动匹配风格。
|
||||
D. 等全部代码生成完后,统一用 Prettier 或 ESLint 格式化工具修正所有风格问题。
|
||||
|
||||
**正确答案:B**
|
||||
|
||||
**解析:** B正确,将规范文档化并注入上下文,能从源头统一AI的输出风格。A效率低且容易遗漏。C"团队规范"是模糊描述,AI无法知道具体指什么。D格式化工具只能解决缩进等表面问题,无法修复命名规范等逻辑性规范。
|
||||
|
||||
### 简答题示例
|
||||
【场景】你正在同一个 AI 对话窗口里和 AI 反复修改一份技术方案文档。改了大概30轮之后,你发现 AI 开始"忘记"一开始定下的某些关键约束条件。比如你最早说过"目标读者是业务部门,不要写太多技术细节",但 AI 新生成的内容又开始出现大量技术术语。
|
||||
|
||||
【问题】这种情况是怎么造成的?你应该怎么做才能让 AI 重新聚焦?
|
||||
|
||||
**判定依据:**
|
||||
- 关键考点:会话管理——长对话导致上下文窗口膨胀,AI注意力分散
|
||||
- 通过标准:说出"让AI总结之前内容+开新窗口"即通过
|
||||
|
||||
**追问方向:**
|
||||
- 如果只回答"开新窗口"没说怎么带上前情:追问"开新窗口后之前讨论的结论不就丢了吗?怎么把有用信息带过去?"
|
||||
- 如果内容不完整:追问"还有没有更好的办法?"
|
||||
|
||||
## 九、输出格式(仅输出纯JSON,不要带Markdown标记)
|
||||
|
||||
选择题输出:
|
||||
{
|
||||
"type": "choice",
|
||||
"scenario": "场景描述(100-200字实际工作场景)",
|
||||
"questionText": "【场景】... 【问题】以下哪种做法最有效?",
|
||||
"options": ["A. 选项A描述", "B. 选项B描述", "C. 选项C描述", "D. 选项D描述"],
|
||||
"correctAnswer": "B",
|
||||
"judgment": "B正确,因为... A错误在于... C错误在于... D错误在于...",
|
||||
"keyPoints": ["知识库中的评分要素1", "知识库中的评分要素2"],
|
||||
"difficulty": "STANDARD",
|
||||
"dimension": "prompt",
|
||||
"basis": "知识库原文依据"
|
||||
}
|
||||
|
||||
简答题输出:
|
||||
{
|
||||
"type": "open",
|
||||
"scenario": "场景描述(100-200字实际工作场景)",
|
||||
"questionText": "【场景】... 【问题】请描述你会如何处理",
|
||||
"judgment": "关键考点:XXX 通过标准:说出XXX即通过",
|
||||
"followupHints": ["追问方向1", "追问方向2"],
|
||||
"keyPoints": ["知识库中的评分要素1"],
|
||||
"difficulty": "STANDARD",
|
||||
"dimension": "prompt",
|
||||
"basis": "知识库原文依据"
|
||||
}
|
||||
|
||||
输出为JSON数组:`;
|
||||
|
||||
const DIMENSION_MAP: Record<string, QuestionDimension> = {
|
||||
'prompt': QuestionDimension.PROMPT,
|
||||
'llm': QuestionDimension.LLM,
|
||||
'ide': QuestionDimension.IDE,
|
||||
'devpattern': QuestionDimension.DEV_PATTERN,
|
||||
'workcapability': QuestionDimension.WORK_CAPABILITY,
|
||||
};
|
||||
|
||||
const DIFFICULTY_MAP: Record<string, QuestionDifficulty> = {
|
||||
'STANDARD': QuestionDifficulty.STANDARD,
|
||||
'ADVANCED': QuestionDifficulty.ADVANCED,
|
||||
'SPECIALIST': QuestionDifficulty.SPECIALIST,
|
||||
};
|
||||
|
||||
export function parseGeneratedQuestion(
|
||||
q: any,
|
||||
bankId: string,
|
||||
): QuestionBankItem {
|
||||
const isChoice = q.type === 'choice';
|
||||
const dimension = DIMENSION_MAP[q.dimension?.toLowerCase()] ?? QuestionDimension.WORK_CAPABILITY;
|
||||
const difficulty = DIFFICULTY_MAP[q.difficulty?.toUpperCase()] ?? QuestionDifficulty.STANDARD;
|
||||
const techniqueTag = q.technique ? `【考查技巧】${q.technique}` : null;
|
||||
const keyPoints = techniqueTag
|
||||
? [techniqueTag, ...(q.keyPoints ?? q.key_points ?? [])]
|
||||
: (q.keyPoints ?? q.key_points ?? []);
|
||||
|
||||
return {
|
||||
bankId,
|
||||
questionText: q.questionText ?? q.question_text ?? '',
|
||||
questionType: isChoice ? QuestionType.MULTIPLE_CHOICE : QuestionType.SHORT_ANSWER,
|
||||
options: isChoice ? (q.options ?? null) : null,
|
||||
correctAnswer: isChoice ? (q.correctAnswer ?? null) : null,
|
||||
judgment: q.judgment ?? null,
|
||||
followupHints: isChoice ? null : (q.followupHints ?? null),
|
||||
keyPoints,
|
||||
difficulty,
|
||||
dimension,
|
||||
basis: q.basis ?? null,
|
||||
status: QuestionBankItemStatus.PENDING_REVIEW,
|
||||
} as QuestionBankItem;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuestionBankService {
|
||||
private readonly logger = new Logger(QuestionBankService.name);
|
||||
@@ -92,13 +266,11 @@ export class QuestionBankService {
|
||||
}
|
||||
if (createDto.templateId) {
|
||||
const existing = await this.bankRepository.findOne({
|
||||
where: { templateId: createDto.templateId, tenantId: tenantId as any },
|
||||
where: { templateId: createDto.templateId, tenantId: tenantId || undefined as any },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) {
|
||||
await this.bankRepository.remove(existing);
|
||||
} else {
|
||||
throw new BadRequestException('该模板已关联有效题库,请编辑已有题库');
|
||||
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED || existing.status === QuestionBankStatus.PUBLISHED) {
|
||||
throw new BadRequestException('该模板已关联题库,请编辑已有题库或删除后重建');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +294,7 @@ export class QuestionBankService {
|
||||
page?: number,
|
||||
limit?: number,
|
||||
): Promise<{ data: QuestionBank[]; total: number } | QuestionBank[]> {
|
||||
console.log('[QuestionBank findAll] userId:', userId, 'tenantId:', tenantId);
|
||||
this.logger.log('[QuestionBank findAll] userId: ' + userId + ', tenantId: ' + tenantId);
|
||||
const queryBuilder = this.bankRepository
|
||||
.createQueryBuilder('bank')
|
||||
.leftJoinAndSelect('bank.template', 'template');
|
||||
@@ -175,6 +347,9 @@ export class QuestionBankService {
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const bank = await this.findOne(id);
|
||||
if (bank.status === QuestionBankStatus.PUBLISHED) {
|
||||
throw new ForbiddenException('已发布的题库不可删除');
|
||||
}
|
||||
await this.bankRepository.remove(bank);
|
||||
}
|
||||
|
||||
@@ -267,6 +442,9 @@ export class QuestionBankService {
|
||||
if (!item) {
|
||||
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
|
||||
}
|
||||
if (item.status === QuestionBankItemStatus.PUBLISHED) {
|
||||
throw new ForbiddenException('已发布的题目不可删除');
|
||||
}
|
||||
await this.itemRepository.remove(item);
|
||||
}
|
||||
|
||||
@@ -278,6 +456,10 @@ export class QuestionBankService {
|
||||
): Promise<QuestionBankItem[]> {
|
||||
const bank = await this.findOne(bankId);
|
||||
|
||||
if (bank.status !== QuestionBankStatus.DRAFT) {
|
||||
throw new ForbiddenException('仅草稿状态的题库可生成题目');
|
||||
}
|
||||
|
||||
if (count <= 0 || count > 50) {
|
||||
throw new BadRequestException('生成数量必须在 1-50 之间');
|
||||
}
|
||||
@@ -295,35 +477,14 @@ export class QuestionBankService {
|
||||
const model = new ChatOpenAI({
|
||||
apiKey: modelConfig.apiKey || 'ollama',
|
||||
modelName: modelConfig.modelId,
|
||||
temperature: 0.7,
|
||||
temperature: 0.1,
|
||||
configuration: {
|
||||
baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1',
|
||||
},
|
||||
});
|
||||
|
||||
const systemPrompt = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 ${count} 个唯一的测试题目。
|
||||
|
||||
### 强制性语言规则:
|
||||
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文。
|
||||
|
||||
### 多样性规则:
|
||||
1. 禁止重复:绝对禁止生成相似的题目
|
||||
2. 深度挖掘:从不同的角度出题,如流程、限制、优缺点、具体参数等
|
||||
3. 随机扰动:从不同的逻辑链条出发
|
||||
|
||||
### 任务:
|
||||
请以 JSON 数组格式返回 ${count} 个问题:
|
||||
[
|
||||
{
|
||||
"question_text": "问题内容",
|
||||
"key_points": ["要点1", "要点2"],
|
||||
"difficulty": "STANDARD|ADVANCED|SPECIALIST",
|
||||
"dimension": "prompt|llm|ide|devPattern|workCapability",
|
||||
"basis": "[n] 引用原文..."
|
||||
}
|
||||
]`;
|
||||
|
||||
const humanMsg = `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}`;
|
||||
const systemPrompt = GENERATE_QUESTIONS_SYSTEM_PROMPT;
|
||||
const humanMsg = `【知识库内容 - 唯一来源】\n\n--- 开始 ---\n${knowledgeBaseContent}\n--- 结束 ---\n\n请按上述规则生成 ${count} 道题,choice:open 比例约 3:7。难度以 STANDARD 为主。`;
|
||||
|
||||
try {
|
||||
const response = await model.invoke([
|
||||
@@ -341,35 +502,11 @@ export class QuestionBankService {
|
||||
parsedQuestions = [parsedQuestions];
|
||||
}
|
||||
|
||||
const dimensionMap: Record<string, string> = {
|
||||
'prompt': 'PROMPT',
|
||||
'llm': 'LLM',
|
||||
'ide': 'IDE',
|
||||
'devPattern': 'DEV_PATTERN',
|
||||
'workCapability': 'WORK_CAPABILITY',
|
||||
};
|
||||
|
||||
const difficultyMap: Record<string, string> = {
|
||||
'STANDARD': 'STANDARD',
|
||||
'ADVANCED': 'ADVANCED',
|
||||
'SPECIALIST': 'SPECIALIST',
|
||||
};
|
||||
|
||||
const items: QuestionBankItem[] = [];
|
||||
for (const q of parsedQuestions) {
|
||||
const dimension = dimensionMap[q.dimension?.toLowerCase()] || 'WORK_CAPABILITY';
|
||||
const difficulty = difficultyMap[q.difficulty?.toUpperCase()] || 'STANDARD';
|
||||
|
||||
const item = this.itemRepository.create({
|
||||
bankId,
|
||||
questionText: q.question_text,
|
||||
questionType: QuestionType.SHORT_ANSWER,
|
||||
keyPoints: q.key_points || [],
|
||||
difficulty: difficulty as QuestionDifficulty,
|
||||
dimension: dimension as QuestionDimension,
|
||||
basis: q.basis,
|
||||
status: QuestionBankItemStatus.PENDING_REVIEW,
|
||||
});
|
||||
const item = this.itemRepository.create(
|
||||
parseGeneratedQuestion(q, bankId),
|
||||
);
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
@@ -388,14 +525,9 @@ export class QuestionBankService {
|
||||
count: number,
|
||||
): Promise<QuestionBankItem[]> {
|
||||
const bank = await this.findOne(bankId);
|
||||
if (bank.status !== QuestionBankStatus.PUBLISHED) {
|
||||
throw new ForbiddenException(
|
||||
'Only PUBLISHED banks can be used for selection',
|
||||
);
|
||||
}
|
||||
|
||||
const allItems = await this.itemRepository.find({
|
||||
where: { bankId },
|
||||
where: { bankId, status: QuestionBankItemStatus.PUBLISHED },
|
||||
});
|
||||
|
||||
if (allItems.length === 0) {
|
||||
@@ -456,7 +588,7 @@ export class QuestionBankService {
|
||||
this.logger.log(
|
||||
`[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`,
|
||||
);
|
||||
return selected;
|
||||
return this.shuffleArray(selected);
|
||||
}
|
||||
|
||||
async batchReviewItems(
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { TemplateService } from './template.service';
|
||||
import { AssessmentTemplate } from '../entities/assessment-template.entity';
|
||||
import { TenantService } from '../../tenant/tenant.service';
|
||||
|
||||
describe('TemplateService', () => {
|
||||
let service: TemplateService;
|
||||
let repo: any;
|
||||
|
||||
const mockTenantService = () => ({
|
||||
canAccessTenant: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TemplateService,
|
||||
{
|
||||
provide: getRepositoryToken(AssessmentTemplate),
|
||||
useFactory: () => ({
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{ provide: TenantService, useFactory: mockTenantService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TemplateService>(TemplateService);
|
||||
repo = module.get(getRepositoryToken(AssessmentTemplate));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should throw BadRequestException when linkedGroupIds is empty', async () => {
|
||||
const dto = { name: 'Test', linkedGroupIds: [] };
|
||||
await expect(
|
||||
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when dimensions is empty array', async () => {
|
||||
const dto = { name: 'Test', linkedGroupIds: ['g-1'], dimensions: [] };
|
||||
await expect(
|
||||
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create template with valid data', async () => {
|
||||
const dto = {
|
||||
name: 'Valid Template',
|
||||
linkedGroupIds: ['g-1'],
|
||||
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 0.5 }],
|
||||
};
|
||||
repo.create.mockReturnValue({ id: 'new-id', ...dto });
|
||||
repo.save.mockResolvedValue({ id: 'new-id', ...dto });
|
||||
|
||||
const result = await service.create(dto as any, 'user-id', 'tenant-id');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('new-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw BadRequestException when clearing linkedGroupIds', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
id: 'tpl-1', name: 'Existing',
|
||||
linkedGroupIds: ['g-1'],
|
||||
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('tpl-1', { linkedGroupIds: [] } as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when clearing dimensions', async () => {
|
||||
repo.findOne.mockResolvedValue({
|
||||
id: 'tpl-1', name: 'Existing',
|
||||
linkedGroupIds: ['g-1'],
|
||||
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('tpl-1', { dimensions: [] } as any, 'user-id', 'tenant-id'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -18,11 +19,33 @@ export class TemplateService {
|
||||
private readonly tenantService: TenantService,
|
||||
) {}
|
||||
|
||||
private validateRequiredFields(data: {
|
||||
linkedGroupIds?: string[] | null;
|
||||
dimensions?: Array<{ name: string; label?: string; weight?: number }> | null;
|
||||
}) {
|
||||
if (data.linkedGroupIds != null && Array.isArray(data.linkedGroupIds)) {
|
||||
if (data.linkedGroupIds.length === 0) {
|
||||
throw new BadRequestException('At least one knowledge group must be linked');
|
||||
}
|
||||
}
|
||||
if (data.dimensions != null && Array.isArray(data.dimensions)) {
|
||||
if (data.dimensions.length === 0) {
|
||||
throw new BadRequestException('At least one dimension must be defined');
|
||||
}
|
||||
for (const d of data.dimensions) {
|
||||
if (!d.name) {
|
||||
throw new BadRequestException('Each dimension must have a name');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
createDto: CreateTemplateDto,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<AssessmentTemplate> {
|
||||
this.validateRequiredFields(createDto);
|
||||
const { ...data } = createDto;
|
||||
const template = this.templateRepository.create({
|
||||
...data,
|
||||
@@ -76,6 +99,8 @@ export class TemplateService {
|
||||
tenantId: string,
|
||||
): Promise<AssessmentTemplate> {
|
||||
const template = await this.findOne(id, userId, tenantId);
|
||||
const merged = { ...template, ...updateDto };
|
||||
this.validateRequiredFields(merged);
|
||||
Object.assign(template, updateDto);
|
||||
return this.templateRepository.save(template);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@@ -25,6 +26,8 @@ import * as path from 'path';
|
||||
*/
|
||||
@Injectable()
|
||||
export class CombinedAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(CombinedAuthGuard.name);
|
||||
|
||||
// We extend AuthGuard('jwt') functionality by composition
|
||||
private jwtGuard: ReturnType<typeof AuthGuard>;
|
||||
|
||||
@@ -55,7 +58,7 @@ export class CombinedAuthGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
this.logger.log(
|
||||
`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
|
||||
);
|
||||
|
||||
@@ -160,7 +163,7 @@ export class CombinedAuthGuard implements CanActivate {
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
|
||||
this.logger.error('[CombinedAuthGuard] JWT Auth Error: ' + e);
|
||||
throw e instanceof UnauthorizedException
|
||||
? e
|
||||
: new UnauthorizedException('Authentication required');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Logger,
|
||||
Post,
|
||||
Request,
|
||||
Res,
|
||||
@@ -36,6 +37,8 @@ class StreamChatDto {
|
||||
@Controller('chat')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
private modelConfigService: ModelConfigService,
|
||||
@@ -49,7 +52,7 @@ export class ChatController {
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
console.log('Full Request Body:', JSON.stringify(body, null, 2));
|
||||
this.logger.log('Full Request Body:', JSON.stringify(body, null, 2));
|
||||
const {
|
||||
message,
|
||||
history = [],
|
||||
@@ -71,22 +74,22 @@ export class ChatController {
|
||||
} = body;
|
||||
const userId = req.user.id;
|
||||
|
||||
console.log('=== Chat Debug Info ===');
|
||||
console.log('User ID:', userId);
|
||||
console.log('Message:', message);
|
||||
console.log('User Language:', userLanguage);
|
||||
console.log('Selected Embedding ID:', selectedEmbeddingId);
|
||||
console.log('Selected LLM ID:', selectedLLMId);
|
||||
console.log('Selected Groups:', selectedGroups);
|
||||
console.log('Selected Files:', selectedFiles);
|
||||
console.log('History ID:', historyId);
|
||||
console.log('Temperature:', temperature);
|
||||
console.log('Max Tokens:', maxTokens);
|
||||
console.log('Top K:', topK);
|
||||
console.log('Similarity Threshold:', similarityThreshold);
|
||||
console.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
|
||||
console.log('Query Expansion:', enableQueryExpansion);
|
||||
console.log('HyDE:', enableHyDE);
|
||||
this.logger.log('=== Chat Debug Info ===');
|
||||
this.logger.log('User ID:', userId);
|
||||
this.logger.log('Message:', message);
|
||||
this.logger.log('User Language:', userLanguage);
|
||||
this.logger.log('Selected Embedding ID:', selectedEmbeddingId);
|
||||
this.logger.log('Selected LLM ID:', selectedLLMId);
|
||||
this.logger.log('Selected Groups:', selectedGroups);
|
||||
this.logger.log('Selected Files:', selectedFiles);
|
||||
this.logger.log('History ID:', historyId);
|
||||
this.logger.log('Temperature:', temperature);
|
||||
this.logger.log('Max Tokens:', maxTokens);
|
||||
this.logger.log('Top K:', topK);
|
||||
this.logger.log('Similarity Threshold:', similarityThreshold);
|
||||
this.logger.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
|
||||
this.logger.log('Query Expansion:', enableQueryExpansion);
|
||||
this.logger.log('HyDE:', enableHyDE);
|
||||
|
||||
const role = req.user.role;
|
||||
const tenantId = req.user.tenantId;
|
||||
@@ -105,14 +108,14 @@ export class ChatController {
|
||||
if (selectedLLMId) {
|
||||
// Find specifically selected model
|
||||
llmModel = await this.modelConfigService.findOne(selectedLLMId);
|
||||
console.log('使用选中的LLM模型:', llmModel.name);
|
||||
this.logger.log('使用选中的LLM模型:', llmModel.name);
|
||||
} else {
|
||||
// Use organization's default LLM from Index Chat Config (strict)
|
||||
llmModel = await this.modelConfigService.findDefaultByType(
|
||||
tenantId,
|
||||
ModelType.LLM,
|
||||
);
|
||||
console.log(
|
||||
this.logger.log(
|
||||
'最终使用的LLM模型 (默认):',
|
||||
llmModel ? llmModel.name : '无',
|
||||
);
|
||||
@@ -162,7 +165,7 @@ export class ChatController {
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (error) {
|
||||
console.error('Stream chat error:', error);
|
||||
this.logger.error('Stream chat error:', error);
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
|
||||
@@ -170,7 +173,7 @@ export class ChatController {
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (writeError) {
|
||||
console.error('Failed to write error response:', writeError);
|
||||
this.logger.error('Failed to write error response:', writeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +223,7 @@ export class ChatController {
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (error) {
|
||||
console.error('Stream assist error:', error);
|
||||
this.logger.error('Stream assist error:', error);
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
|
||||
);
|
||||
|
||||
@@ -71,30 +71,30 @@ export class ChatService {
|
||||
enableHyDE?: boolean, // New
|
||||
tenantId?: string, // New: tenant isolation
|
||||
): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
|
||||
console.log('=== ChatService.streamChat ===');
|
||||
console.log('User ID:', userId);
|
||||
console.log('User language:', userLanguage);
|
||||
console.log('Selected embedding model ID:', selectedEmbeddingId);
|
||||
console.log('Selected groups:', selectedGroups);
|
||||
console.log('Selected files:', selectedFiles);
|
||||
console.log('History ID:', historyId);
|
||||
console.log('Temperature:', temperature);
|
||||
console.log('Max Tokens:', maxTokens);
|
||||
console.log('Top K:', topK);
|
||||
console.log('Similarity threshold:', similarityThreshold);
|
||||
console.log('Rerank threshold:', rerankSimilarityThreshold);
|
||||
console.log('Query expansion:', enableQueryExpansion);
|
||||
console.log('HyDE:', enableHyDE);
|
||||
console.log('Model configuration:', {
|
||||
this.logger.log('=== ChatService.streamChat ===');
|
||||
this.logger.log('User ID:', userId);
|
||||
this.logger.log('User language:', userLanguage);
|
||||
this.logger.log('Selected embedding model ID:', selectedEmbeddingId);
|
||||
this.logger.log('Selected groups:', selectedGroups);
|
||||
this.logger.log('Selected files:', selectedFiles);
|
||||
this.logger.log('History ID:', historyId);
|
||||
this.logger.log('Temperature:', temperature);
|
||||
this.logger.log('Max Tokens:', maxTokens);
|
||||
this.logger.log('Top K:', topK);
|
||||
this.logger.log('Similarity threshold:', similarityThreshold);
|
||||
this.logger.log('Rerank threshold:', rerankSimilarityThreshold);
|
||||
this.logger.log('Query expansion:', enableQueryExpansion);
|
||||
this.logger.log('HyDE:', enableHyDE);
|
||||
this.logger.log('Model configuration:', {
|
||||
name: modelConfig.name,
|
||||
modelId: modelConfig.modelId,
|
||||
baseUrl: modelConfig.baseUrl,
|
||||
});
|
||||
console.log(
|
||||
this.logger.log(
|
||||
'API Key prefix:',
|
||||
modelConfig.apiKey?.substring(0, 10) + '...',
|
||||
);
|
||||
console.log('API Key length:', modelConfig.apiKey?.length);
|
||||
this.logger.log('API Key length:', modelConfig.apiKey?.length);
|
||||
|
||||
// Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service)
|
||||
// Use actual language based on user settings
|
||||
@@ -113,7 +113,7 @@ export class ChatService {
|
||||
selectedGroups,
|
||||
);
|
||||
currentHistoryId = searchHistory.id;
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage(
|
||||
'creatingHistory',
|
||||
effectiveUserLanguage,
|
||||
@@ -143,7 +143,7 @@ export class ChatService {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage(
|
||||
'usingEmbeddingModel',
|
||||
effectiveUserLanguage,
|
||||
@@ -156,7 +156,7 @@ export class ChatService {
|
||||
);
|
||||
|
||||
// 2. Search using user's query directly
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage('startingSearch', effectiveUserLanguage),
|
||||
);
|
||||
yield {
|
||||
@@ -204,7 +204,7 @@ export class ChatService {
|
||||
// HybridSearch returns ES hit structure, but RagSearchResult is normalized
|
||||
// BuildContext expects {fileName, content}. RagSearchResult has these
|
||||
searchResults = ragResults;
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage(
|
||||
'searchResultsCount',
|
||||
effectiveUserLanguage,
|
||||
@@ -274,7 +274,7 @@ export class ChatService {
|
||||
};
|
||||
}
|
||||
} catch (searchError) {
|
||||
console.error(
|
||||
this.logger.error(
|
||||
this.i18nService.getMessage(
|
||||
'searchFailedLog',
|
||||
effectiveUserLanguage,
|
||||
@@ -461,14 +461,14 @@ ${instruction}`;
|
||||
try {
|
||||
// Join keywords into search string
|
||||
const combinedQuery = keywords.join(' ');
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage('searchString', userLanguage) +
|
||||
combinedQuery,
|
||||
);
|
||||
|
||||
// Check if embedding model ID is provided
|
||||
if (!embeddingModelId) {
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage(
|
||||
'embeddingModelIdNotProvided',
|
||||
userLanguage,
|
||||
@@ -478,7 +478,7 @@ ${instruction}`;
|
||||
}
|
||||
|
||||
// Use actual embedding vector
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage('generatingEmbeddings', userLanguage),
|
||||
);
|
||||
const queryEmbedding = await this.embeddingService.getEmbeddings(
|
||||
@@ -486,7 +486,7 @@ ${instruction}`;
|
||||
embeddingModelId,
|
||||
);
|
||||
const queryVector = queryEmbedding[0];
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
|
||||
this.i18nService.getMessage('dimensions', userLanguage) +
|
||||
':',
|
||||
@@ -494,7 +494,7 @@ ${instruction}`;
|
||||
);
|
||||
|
||||
// Hybrid search
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage('performingHybridSearch', userLanguage),
|
||||
);
|
||||
const results = await this.elasticsearchService.hybridSearch(
|
||||
@@ -507,7 +507,7 @@ ${instruction}`;
|
||||
explicitFileIds, // Pass explicit file IDs
|
||||
tenantId, // Pass tenant ID
|
||||
);
|
||||
console.log(
|
||||
this.logger.log(
|
||||
this.i18nService.getMessage('esSearchCompleted', userLanguage) +
|
||||
this.i18nService.getMessage('resultsCount', userLanguage) +
|
||||
':',
|
||||
@@ -516,7 +516,7 @@ ${instruction}`;
|
||||
|
||||
return results.slice(0, 10);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.logger.error(
|
||||
this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':',
|
||||
error,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
const logger = new Logger('JsonUtils');
|
||||
|
||||
/**
|
||||
* Safely parses JSON from a string, handling markdown code blocks and leading/trailing text.
|
||||
*/
|
||||
@@ -40,9 +44,9 @@ export function safeParseJson<T = any>(text: string): T | null {
|
||||
try {
|
||||
return JSON.parse(jsonStr) as T;
|
||||
} catch (error) {
|
||||
console.error('[safeParseJson] Failed to parse JSON:', error);
|
||||
console.error('[safeParseJson] Original text:', text);
|
||||
console.error('[safeParseJson] Extracted string:', jsonStr);
|
||||
logger.error('[safeParseJson] Failed to parse JSON:', error);
|
||||
logger.error('[safeParseJson] Original text:', text);
|
||||
logger.error('[safeParseJson] Extracted string:', jsonStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Query,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -24,6 +25,8 @@ import { I18nService } from '../i18n/i18n.service';
|
||||
@Controller('knowledge-groups')
|
||||
@UseGuards(CombinedAuthGuard, RolesGuard)
|
||||
export class KnowledgeGroupController {
|
||||
private readonly logger = new Logger(KnowledgeGroupController.name);
|
||||
|
||||
constructor(
|
||||
private readonly groupService: KnowledgeGroupService,
|
||||
private readonly i18nService: I18nService,
|
||||
@@ -43,7 +46,7 @@ export class KnowledgeGroupController {
|
||||
@Post()
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
|
||||
console.log('[KnowledgeGroup] create called, user:', req.user);
|
||||
this.logger.log('[KnowledgeGroup] create called, user: ' + JSON.stringify(req.user));
|
||||
return await this.groupService.create(
|
||||
req.user.id,
|
||||
req.user.tenantId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
@@ -47,6 +48,8 @@ export interface PaginatedGroups {
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeGroupService {
|
||||
private readonly logger = new Logger(KnowledgeGroupService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(KnowledgeGroup)
|
||||
private groupRepository: Repository<KnowledgeGroup>,
|
||||
@@ -62,7 +65,7 @@ export class KnowledgeGroupService {
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<GroupWithFileCount[]> {
|
||||
console.log('[KnowledgeGroup findAll] userId:', userId, 'tenantId:', tenantId);
|
||||
this.logger.log('[KnowledgeGroup findAll] userId: ' + userId + ', tenantId: ' + tenantId);
|
||||
// Return all groups for the tenant with file counts
|
||||
const queryBuilder = this.groupRepository
|
||||
.createQueryBuilder('group')
|
||||
@@ -147,7 +150,7 @@ export class KnowledgeGroupService {
|
||||
tenantId: string,
|
||||
createGroupDto: CreateGroupDto,
|
||||
): Promise<KnowledgeGroup> {
|
||||
console.log('[KnowledgeGroup create] userId:', userId, 'tenantId:', tenantId);
|
||||
this.logger.log('[KnowledgeGroup create] userId: ' + userId + ', tenantId: ' + tenantId);
|
||||
const group = this.groupRepository.create({
|
||||
...createGroupDto,
|
||||
parentId: createGroupDto.parentId ?? null,
|
||||
@@ -155,7 +158,7 @@ export class KnowledgeGroupService {
|
||||
});
|
||||
|
||||
const saved = await this.groupRepository.save(group);
|
||||
console.log('[KnowledgeGroup create] saved group tenantId:', saved.tenantId);
|
||||
this.logger.log('[KnowledgeGroup create] saved group tenantId: ' + saved.tenantId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -229,7 +232,7 @@ export class KnowledgeGroupService {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.logger.error(
|
||||
`Failed to delete file ${file.id} when deleting group ${id}`,
|
||||
error,
|
||||
);
|
||||
@@ -257,7 +260,6 @@ export class KnowledgeGroupService {
|
||||
throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
|
||||
}
|
||||
|
||||
// Check permission using TenantService
|
||||
const hasAccess = await this.tenantService.canAccessTenant(
|
||||
userId,
|
||||
group.tenantId,
|
||||
@@ -269,7 +271,31 @@ export class KnowledgeGroupService {
|
||||
);
|
||||
}
|
||||
|
||||
return group.knowledgeBases;
|
||||
const allGroups = await this.groupRepository.find({
|
||||
where: tenantId === null ? {} : { tenantId },
|
||||
relations: ['knowledgeBases'],
|
||||
});
|
||||
|
||||
const childIds = new Set<string>();
|
||||
const collectDescendantIds = (parentId: string) => {
|
||||
for (const g of allGroups) {
|
||||
if (g.parentId === parentId) {
|
||||
childIds.add(g.id);
|
||||
collectDescendantIds(g.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectDescendantIds(groupId);
|
||||
|
||||
const result = [...(group.knowledgeBases || [])];
|
||||
for (const childId of childIds) {
|
||||
const childGroup = allGroups.find(g => g.id === childId);
|
||||
if (childGroup?.knowledgeBases) {
|
||||
result.push(...childGroup.knowledgeBases);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async addFilesToGroup(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Note } from './note.entity';
|
||||
@@ -11,6 +11,8 @@ import { I18nService } from '../i18n/i18n.service';
|
||||
|
||||
@Injectable()
|
||||
export class NoteService {
|
||||
private readonly logger = new Logger(NoteService.name);
|
||||
|
||||
// Directory will be created dynamically per user
|
||||
private getScreenshotsDir(userId: string) {
|
||||
return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId);
|
||||
@@ -153,7 +155,7 @@ export class NoteService {
|
||||
}
|
||||
|
||||
// Optional: Add logging to help debug permission issues
|
||||
console.log(`User ${userId} attempting to add note to group ${groupId}`);
|
||||
this.logger.log('User ' + userId + ' attempting to add note to group ' + groupId);
|
||||
}
|
||||
|
||||
if (categoryId === '') {
|
||||
@@ -176,7 +178,7 @@ export class NoteService {
|
||||
screenshot.buffer,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('OCR extraction failed:', error);
|
||||
this.logger.error('OCR extraction failed:', error);
|
||||
// Continue without OCR text if extraction fails
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Controller,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@@ -14,6 +15,8 @@ import { I18nService } from '../i18n/i18n.service';
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
export class OcrController {
|
||||
private readonly logger = new Logger(OcrController.name);
|
||||
|
||||
constructor(
|
||||
private readonly ocrService: OcrService,
|
||||
private readonly i18n: I18nService,
|
||||
@@ -22,14 +25,14 @@ export class OcrController {
|
||||
@Post('recognize')
|
||||
@UseInterceptors(FileInterceptor('image'))
|
||||
async recognizeText(@UploadedFile() image: Express.Multer.File) {
|
||||
console.log('OCR recognition endpoint called');
|
||||
this.logger.log('OCR recognition endpoint called');
|
||||
if (!image) {
|
||||
console.error('No image uploaded');
|
||||
this.logger.error('No image uploaded');
|
||||
throw new Error(this.i18n.getMessage('noImageUploaded'));
|
||||
}
|
||||
console.log(`Received image. Size: ${image.size} bytes`);
|
||||
this.logger.log('Received image. Size: ' + image.size + ' bytes');
|
||||
const text = await this.ocrService.extractTextFromImage(image.buffer);
|
||||
console.log(`OCR extraction completed. Text length: ${text.length}`);
|
||||
this.logger.log('OCR extraction completed. Text length: ' + text.length);
|
||||
return { text };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export class UserService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
console.log(
|
||||
this.logger.log(
|
||||
`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`,
|
||||
);
|
||||
const user = await this.usersRepository.save({
|
||||
@@ -403,10 +403,7 @@ export class UserService implements OnModuleInit {
|
||||
role: UserRole.SUPER_ADMIN,
|
||||
});
|
||||
|
||||
console.log('\n=== Admin account created ===');
|
||||
console.log('Username: admin');
|
||||
console.log('Password:', randomPassword);
|
||||
console.log('========================================\n');
|
||||
this.logger.log('Admin account created (username: admin, password: ' + randomPassword + ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CostControlService } from './cost-control.service';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
providers: [CostControlService],
|
||||
exports: [CostControlService],
|
||||
})
|
||||
export class CostControlModule {}
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Cost control and quota management service
|
||||
* Used to manage API call costs for Vision Pipeline
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
export interface UserQuota {
|
||||
userId: string;
|
||||
monthlyCost: number; // Current month used cost
|
||||
maxCost: number; // Monthly max cost
|
||||
remaining: number; // Remaining cost
|
||||
lastReset: Date; // Last reset time
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
estimatedCost: number; // Estimated cost
|
||||
estimatedTime: number; // Estimated time(seconds)
|
||||
pageBreakdown: {
|
||||
// Per-page breakdown
|
||||
pageIndex: number;
|
||||
cost: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CostControlService {
|
||||
private readonly logger = new Logger(CostControlService.name);
|
||||
private readonly COST_PER_PAGE = 0.01; // Cost per page(USD)
|
||||
private readonly DEFAULT_MONTHLY_LIMIT = 100; // Default monthly limit(USD)
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Estimate processing cost
|
||||
*/
|
||||
estimateCost(
|
||||
pageCount: number,
|
||||
quality: 'low' | 'medium' | 'high' = 'medium',
|
||||
): CostEstimate {
|
||||
// Adjust cost coefficient based on quality
|
||||
const qualityMultiplier = {
|
||||
low: 0.5,
|
||||
medium: 1.0,
|
||||
high: 1.5,
|
||||
};
|
||||
|
||||
const baseCost =
|
||||
pageCount * this.COST_PER_PAGE * qualityMultiplier[quality];
|
||||
const estimatedTime = pageCount * 3; // // Approximately 3 seconds
|
||||
|
||||
const pageBreakdown = Array.from({ length: pageCount }, (_, i) => ({
|
||||
pageIndex: i + 1,
|
||||
cost: this.COST_PER_PAGE * qualityMultiplier[quality],
|
||||
}));
|
||||
|
||||
return {
|
||||
estimatedCost: baseCost,
|
||||
estimatedTime,
|
||||
pageBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check user quota
|
||||
*/
|
||||
async checkQuota(
|
||||
userId: string,
|
||||
estimatedCost: number,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
quota: UserQuota;
|
||||
reason?: string;
|
||||
}> {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
|
||||
// Check monthly reset
|
||||
this.checkAndResetMonthlyQuota(quota);
|
||||
|
||||
if (quota.remaining < estimatedCost) {
|
||||
this.logger.warn(
|
||||
`User ${userId} quota insufficient: remaining $${quota.remaining.toFixed(2)}, required $${estimatedCost.toFixed(2)}`,
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
quota,
|
||||
reason: `Insufficient quota: remaining $${quota.remaining.toFixed(2)}, required $${estimatedCost.toFixed(2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
quota,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct from quota
|
||||
*/
|
||||
async deductQuota(userId: string, actualCost: number): Promise<void> {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
quota.monthlyCost += actualCost;
|
||||
quota.remaining = quota.maxCost - quota.monthlyCost;
|
||||
|
||||
await this.userRepository.update(userId, {
|
||||
monthlyCost: quota.monthlyCost,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Deducted $${actualCost.toFixed(2)} from user ${userId} quota. Remaining: $${quota.remaining.toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user quota
|
||||
*/
|
||||
async getUserQuota(userId: string): Promise<UserQuota> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${userId} does not exist`);
|
||||
}
|
||||
|
||||
// Use default if user has no quota info
|
||||
const monthlyCost = user.monthlyCost || 0;
|
||||
const maxCost = user.maxCost || this.DEFAULT_MONTHLY_LIMIT;
|
||||
const lastReset = user.lastQuotaReset || new Date();
|
||||
|
||||
return {
|
||||
userId,
|
||||
monthlyCost,
|
||||
maxCost,
|
||||
remaining: maxCost - monthlyCost,
|
||||
lastReset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and reset monthly quota
|
||||
*/
|
||||
private checkAndResetMonthlyQuota(quota: UserQuota): void {
|
||||
const now = new Date();
|
||||
const lastReset = quota.lastReset;
|
||||
|
||||
// Check if crossed month
|
||||
if (
|
||||
now.getMonth() !== lastReset.getMonth() ||
|
||||
now.getFullYear() !== lastReset.getFullYear()
|
||||
) {
|
||||
this.logger.log(`Reset monthly quota for user ${quota.userId}`);
|
||||
|
||||
// Reset quota
|
||||
quota.monthlyCost = 0;
|
||||
quota.remaining = quota.maxCost;
|
||||
quota.lastReset = now;
|
||||
|
||||
// Update database
|
||||
this.userRepository.update(quota.userId, {
|
||||
monthlyCost: 0,
|
||||
lastQuotaReset: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user quota limit
|
||||
*/
|
||||
async setQuotaLimit(userId: string, maxCost: number): Promise<void> {
|
||||
await this.userRepository.update(userId, { maxCost });
|
||||
this.logger.log(`Set quota limit to $${maxCost} for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost report
|
||||
*/
|
||||
async getCostReport(
|
||||
userId: string,
|
||||
days: number = 30,
|
||||
): Promise<{
|
||||
totalCost: number;
|
||||
dailyAverage: number;
|
||||
pageStats: {
|
||||
totalPages: number;
|
||||
avgCostPerPage: number;
|
||||
};
|
||||
quotaUsage: number; // Percentage
|
||||
}> {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
const usagePercent = (quota.monthlyCost / quota.maxCost) * 100;
|
||||
|
||||
// Query history records here(if implemented)
|
||||
// Return current quota info temporarily
|
||||
|
||||
return {
|
||||
totalCost: quota.monthlyCost,
|
||||
dailyAverage: quota.monthlyCost / Math.max(days, 1),
|
||||
pageStats: {
|
||||
totalPages: Math.floor(quota.monthlyCost / this.COST_PER_PAGE),
|
||||
avgCostPerPage: this.COST_PER_PAGE,
|
||||
},
|
||||
quotaUsage: usagePercent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check cost warning threshold
|
||||
*/
|
||||
async checkWarningThreshold(userId: string): Promise<{
|
||||
shouldWarn: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
const usagePercent = (quota.monthlyCost / quota.maxCost) * 100;
|
||||
|
||||
if (usagePercent >= 90) {
|
||||
return {
|
||||
shouldWarn: true,
|
||||
message: `⚠️ Quota usage reached ${usagePercent.toFixed(1)}%. Remaining: $${quota.remaining.toFixed(2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (usagePercent >= 75) {
|
||||
return {
|
||||
shouldWarn: true,
|
||||
message: `💡 Quota usage at ${usagePercent.toFixed(1)}%. Please monitor your costs carefully`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldWarn: false,
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cost display
|
||||
*/
|
||||
formatCost(cost: number): string {
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time display
|
||||
*/
|
||||
formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(0)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
/**
|
||||
* Vision Pipeline Service (with cost control)
|
||||
* This is an extended version of vision-pipeline.service.ts with integrated cost control
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { LibreOfficeService } from '../libreoffice/libreoffice.service';
|
||||
import { Pdf2ImageService } from '../pdf2image/pdf2image.service';
|
||||
import { VisionService } from '../vision/vision.service';
|
||||
import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
|
||||
import { ModelConfigService } from '../model-config/model-config.service';
|
||||
import {
|
||||
PreciseModeOptions,
|
||||
PipelineResult,
|
||||
ProcessingStatus,
|
||||
ModeRecommendation,
|
||||
} from './vision-pipeline.interface';
|
||||
import {
|
||||
VisionModelConfig,
|
||||
VisionAnalysisResult,
|
||||
} from '../vision/vision.interface';
|
||||
import { CostControlService } from './cost-control.service';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
|
||||
@Injectable()
|
||||
export class VisionPipelineCostAwareService {
|
||||
private readonly logger = new Logger(VisionPipelineCostAwareService.name);
|
||||
|
||||
constructor(
|
||||
private libreOffice: LibreOfficeService,
|
||||
private pdf2Image: Pdf2ImageService,
|
||||
private vision: VisionService,
|
||||
private elasticsearch: ElasticsearchService,
|
||||
private modelConfigService: ModelConfigService,
|
||||
private configService: ConfigService,
|
||||
private costControl: CostControlService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Main processing flow: Precise mode (with cost control)
|
||||
*/
|
||||
async processPreciseMode(
|
||||
filePath: string,
|
||||
options: PreciseModeOptions,
|
||||
): Promise<PipelineResult> {
|
||||
const startTime = Date.now();
|
||||
const results: VisionAnalysisResult[] = [];
|
||||
let processedPages = 0;
|
||||
let failedPages = 0;
|
||||
let totalCost = 0;
|
||||
let pdfPath = filePath;
|
||||
let imagesToProcess: any[] = [];
|
||||
|
||||
this.logger.log(
|
||||
`Starting precise mode processing for ${options.fileName} (user: ${options.userId})`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Step 1: Convert format
|
||||
this.updateStatus('converting', 10, 'Converting document format...');
|
||||
pdfPath = await this.convertToPDF(filePath);
|
||||
|
||||
// Step 2: Convert PDF to images
|
||||
this.updateStatus('splitting', 30, 'Converting PDF to images...');
|
||||
const conversionResult = await this.pdf2Image.convertToImages(pdfPath, {
|
||||
density: 300,
|
||||
quality: 85,
|
||||
format: 'jpeg',
|
||||
});
|
||||
|
||||
if (conversionResult.images.length === 0) {
|
||||
throw new Error(
|
||||
this.i18nService.getMessage('pdfToImageConversionFailed'),
|
||||
);
|
||||
}
|
||||
|
||||
// Limit processing pages
|
||||
imagesToProcess = options.maxPages
|
||||
? conversionResult.images.slice(0, options.maxPages)
|
||||
: conversionResult.images;
|
||||
|
||||
const pageCount = imagesToProcess.length;
|
||||
|
||||
// Step 3: Cost estimation and quota check
|
||||
this.updateStatus(
|
||||
'checking',
|
||||
40,
|
||||
'Checking quota and estimating cost...',
|
||||
);
|
||||
const costEstimate = this.costControl.estimateCost(pageCount);
|
||||
this.logger.log(
|
||||
`Estimated cost: $${costEstimate.estimatedCost.toFixed(2)}, Estimated time: ${this.costControl.formatTime(costEstimate.estimatedTime)}`,
|
||||
);
|
||||
|
||||
// Quota check
|
||||
const quotaCheck = await this.costControl.checkQuota(
|
||||
options.userId,
|
||||
costEstimate.estimatedCost,
|
||||
);
|
||||
|
||||
if (!quotaCheck.allowed) {
|
||||
throw new Error(quotaCheck.reason);
|
||||
}
|
||||
|
||||
// Cost warning check
|
||||
const warning = await this.costControl.checkWarningThreshold(
|
||||
options.userId,
|
||||
);
|
||||
if (warning.shouldWarn) {
|
||||
this.logger.warn(warning.message);
|
||||
}
|
||||
|
||||
// Step 4: Get Vision model config
|
||||
const modelConfig = await this.getVisionModelConfig(
|
||||
options.userId,
|
||||
options.modelId,
|
||||
options.tenantId,
|
||||
);
|
||||
|
||||
// Step 5: VL model analysis
|
||||
this.updateStatus(
|
||||
'analyzing',
|
||||
50,
|
||||
'Analyzing pages with Vision model...',
|
||||
);
|
||||
const batchResult = await this.vision.batchAnalyze(
|
||||
imagesToProcess.map((img) => img.path),
|
||||
modelConfig,
|
||||
{
|
||||
startIndex: 1,
|
||||
skipQualityCheck: options.skipQualityCheck,
|
||||
},
|
||||
);
|
||||
|
||||
totalCost = batchResult.estimatedCost;
|
||||
processedPages = batchResult.successCount;
|
||||
failedPages = batchResult.failedCount;
|
||||
results.push(...batchResult.results);
|
||||
|
||||
// Step 6: Subtract actual cost
|
||||
if (totalCost > 0) {
|
||||
await this.costControl.deductQuota(options.userId, totalCost);
|
||||
this.logger.log(`Actual cost deducted: $${totalCost.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// Step 7: Cleanup temp files
|
||||
this.updateStatus(
|
||||
'completed',
|
||||
100,
|
||||
'Processing completed. Cleaning up temp files...',
|
||||
);
|
||||
await this.pdf2Image.cleanupImages(imagesToProcess);
|
||||
|
||||
// Cleanup converted PDF file if converted
|
||||
if (pdfPath !== filePath) {
|
||||
try {
|
||||
await fs.unlink(pdfPath);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to cleanup converted PDF: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
|
||||
this.logger.log(
|
||||
`Precise mode completed: ${processedPages} pages processed, ` +
|
||||
`cost: $${totalCost.toFixed(2)}, duration: ${duration.toFixed(1)}s`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileId: options.fileId,
|
||||
fileName: options.fileName,
|
||||
totalPages: conversionResult.totalPages,
|
||||
processedPages,
|
||||
failedPages,
|
||||
results,
|
||||
cost: totalCost,
|
||||
duration,
|
||||
mode: 'precise',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Precise mode failed: ${error.message}`);
|
||||
|
||||
// Try to clean up temp files
|
||||
try {
|
||||
if (pdfPath !== filePath && pdfPath !== filePath) {
|
||||
await fs.unlink(pdfPath);
|
||||
}
|
||||
if (imagesToProcess.length > 0) {
|
||||
await this.pdf2Image.cleanupImages(imagesToProcess);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
fileId: options.fileId,
|
||||
fileName: options.fileName,
|
||||
totalPages: 0,
|
||||
processedPages,
|
||||
failedPages,
|
||||
results: [],
|
||||
cost: totalCost,
|
||||
duration: (Date.now() - startTime) / 1000,
|
||||
mode: 'precise',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Vision model configuration
|
||||
*/
|
||||
private async getVisionModelConfig(
|
||||
userId: string,
|
||||
modelId: string,
|
||||
tenantId?: string,
|
||||
): Promise<VisionModelConfig> {
|
||||
const config = await this.modelConfigService.findOne(modelId);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`Model config not found: ${modelId}`);
|
||||
}
|
||||
|
||||
// API key is optional - allows local models
|
||||
|
||||
return {
|
||||
baseUrl: config.baseUrl || '',
|
||||
apiKey: config.apiKey || '',
|
||||
modelId: config.modelId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to PDF
|
||||
*/
|
||||
private async convertToPDF(filePath: string): Promise<string> {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Return as-is if already PDF
|
||||
if (ext === '.pdf') {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// Call LibreOffice to convert
|
||||
return await this.libreOffice.convertToPDF(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format detection and mode recommendation (with cost estimation)
|
||||
*/
|
||||
async recommendMode(filePath: string): Promise<ModeRecommendation> {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const stats = await fs.stat(filePath);
|
||||
const sizeMB = stats.size / (1024 * 1024);
|
||||
|
||||
const supportedFormats = [
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
];
|
||||
const preciseFormats = ['.pdf', '.doc', '.docx', '.ppt', '.pptx'];
|
||||
|
||||
if (!supportedFormats.includes(ext)) {
|
||||
return {
|
||||
recommendedMode: 'fast',
|
||||
reason: `Unsupported file format: ${ext}`,
|
||||
warnings: ['Using fast mode (text extraction only)'],
|
||||
};
|
||||
}
|
||||
|
||||
if (!preciseFormats.includes(ext)) {
|
||||
return {
|
||||
recommendedMode: 'fast',
|
||||
reason: `Format ${ext} does not support precise mode`,
|
||||
warnings: ['Using fast mode (text extraction only)'],
|
||||
};
|
||||
}
|
||||
|
||||
// Estimate page count(based on file size)
|
||||
const estimatedPages = Math.max(1, Math.ceil(sizeMB * 2));
|
||||
const costEstimate = this.costControl.estimateCost(estimatedPages);
|
||||
|
||||
// Recommend precise mode for large files
|
||||
if (sizeMB > 50) {
|
||||
return {
|
||||
recommendedMode: 'precise',
|
||||
reason:
|
||||
'File is large, recommend precise mode to preserve full content',
|
||||
estimatedCost: costEstimate.estimatedCost,
|
||||
estimatedTime: costEstimate.estimatedTime,
|
||||
warnings: [
|
||||
'Processing time may be longer',
|
||||
'API costs will be incurred',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Recommend precise mode
|
||||
return {
|
||||
recommendedMode: 'precise',
|
||||
reason:
|
||||
'Precise mode available. Can preserve mixed text and image content',
|
||||
estimatedCost: costEstimate.estimatedCost,
|
||||
estimatedTime: costEstimate.estimatedTime,
|
||||
warnings: ['API costs will be incurred'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user quota information
|
||||
*/
|
||||
async getUserQuotaInfo(userId: string) {
|
||||
const quota = await this.costControl.getUserQuota(userId);
|
||||
const report = await this.costControl.getCostReport(userId);
|
||||
|
||||
return {
|
||||
...quota,
|
||||
report,
|
||||
warnings: await this.costControl.checkWarningThreshold(userId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update processing status (for real-time feedback)
|
||||
*/
|
||||
private updateStatus(
|
||||
status: ProcessingStatus['status'],
|
||||
progress: number,
|
||||
message: string,
|
||||
): void {
|
||||
this.logger.log(`[${status}] ${progress}% - ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AssessmentService } from '../src/assessment/assessment.service';
|
||||
import { AssessmentSession } from '../src/assessment/entities/assessment-session.entity';
|
||||
import { AssessmentQuestion } from '../src/assessment/entities/assessment-question.entity';
|
||||
import { AssessmentAnswer } from '../src/assessment/entities/assessment-answer.entity';
|
||||
import { AssessmentCertificate } from '../src/assessment/entities/assessment-certificate.entity';
|
||||
import { QuestionBank } from '../src/assessment/entities/question-bank.entity';
|
||||
import { QuestionBankItem } from '../src/assessment/entities/question-bank-item.entity';
|
||||
import { KnowledgeBaseService } from '../src/knowledge-base/knowledge-base.service';
|
||||
import { KnowledgeGroupService } from '../src/knowledge-group/knowledge-group.service';
|
||||
import { ModelConfigService } from '../src/model-config/model-config.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TemplateService } from '../src/assessment/services/template.service';
|
||||
import { ContentFilterService } from '../src/assessment/services/content-filter.service';
|
||||
import { QuestionOutlineService } from '../src/assessment/services/question-outline.service';
|
||||
import { QuestionBankService } from '../src/assessment/services/question-bank.service';
|
||||
import { RagService } from '../src/rag/rag.service';
|
||||
import { ChatService } from '../src/chat/chat.service';
|
||||
import { I18nService } from '../src/i18n/i18n.service';
|
||||
import { TenantService } from '../src/tenant/tenant.service';
|
||||
|
||||
const mockManager = () => ({
|
||||
findOne: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
save: jest.fn(),
|
||||
});
|
||||
|
||||
const mockDataSource = () => ({
|
||||
transaction: jest.fn(async (cb: any) => cb(mockManager())),
|
||||
});
|
||||
|
||||
/**
|
||||
* Certificate integration tests — verify the full certificate lifecycle
|
||||
* through the AssessmentService with mocked repositories.
|
||||
*/
|
||||
describe('Certificate (integration)', () => {
|
||||
let service: AssessmentService;
|
||||
let sessionRepo: any;
|
||||
let certificateRepo: any;
|
||||
|
||||
const mockRepo = () => ({
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
});
|
||||
|
||||
const mockSvc = () => ({});
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssessmentService,
|
||||
{ provide: getRepositoryToken(AssessmentSession), useFactory: mockRepo },
|
||||
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepo },
|
||||
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepo },
|
||||
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepo },
|
||||
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepo },
|
||||
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepo },
|
||||
{ provide: KnowledgeBaseService, useFactory: mockSvc },
|
||||
{ provide: KnowledgeGroupService, useFactory: mockSvc },
|
||||
{ provide: ModelConfigService, useFactory: mockSvc },
|
||||
{ provide: ConfigService, useFactory: mockSvc },
|
||||
{ provide: TemplateService, useFactory: mockSvc },
|
||||
{ provide: ContentFilterService, useFactory: mockSvc },
|
||||
{ provide: QuestionOutlineService, useFactory: mockSvc },
|
||||
{ provide: QuestionBankService, useFactory: mockSvc },
|
||||
{ provide: RagService, useFactory: mockSvc },
|
||||
{ provide: ChatService, useFactory: mockSvc },
|
||||
{ provide: I18nService, useFactory: mockSvc },
|
||||
{ provide: TenantService, useFactory: mockSvc },
|
||||
{ provide: DataSource, useFactory: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AssessmentService>(AssessmentService);
|
||||
sessionRepo = module.get(getRepositoryToken(AssessmentSession));
|
||||
certificateRepo = module.get(getRepositoryToken(AssessmentCertificate));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('verifyCertificate (public endpoint logic)', () => {
|
||||
it('should return { valid: false } for unknown certificate ID', async () => {
|
||||
certificateRepo.findOne.mockResolvedValue(null);
|
||||
const result = await service.verifyCertificate('no-cert');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return { valid: true } with certificate data for known ID', async () => {
|
||||
certificateRepo.findOne.mockResolvedValue({
|
||||
id: 'cert-1',
|
||||
level: 'Expert',
|
||||
totalScore: 95,
|
||||
passed: true,
|
||||
issuedAt: new Date('2026-01-01'),
|
||||
userId: 'user-1',
|
||||
});
|
||||
const result = await service.verifyCertificate('cert-1');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.certificate!.level).toBe('Expert');
|
||||
expect(result.certificate!.userId).toBe('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicCertificateInfo (public endpoint logic)', () => {
|
||||
it('should return { exists: false } for session without certificate', async () => {
|
||||
certificateRepo.findOne.mockResolvedValue(null);
|
||||
const result = await service.getPublicCertificateInfo('no-session');
|
||||
expect(result.exists).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return certificate info for session with certificate', async () => {
|
||||
certificateRepo.findOne.mockResolvedValue({
|
||||
id: 'cert-1',
|
||||
sessionId: 'session-1',
|
||||
level: 'Advanced',
|
||||
totalScore: 85,
|
||||
passed: true,
|
||||
issuedAt: new Date('2026-01-01'),
|
||||
dimensionScores: { prompt: 80, llm: 90 },
|
||||
});
|
||||
const result = await service.getPublicCertificateInfo('session-1');
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.certificate!.level).toBe('Advanced');
|
||||
expect(result.certificate!.totalScore).toBe(85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate lifecycle', () => {
|
||||
it('should generate certificate then verify it', async () => {
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 'session-lc',
|
||||
userId: 'user-1',
|
||||
status: 'COMPLETED',
|
||||
finalScore: 88,
|
||||
templateId: 'template-1',
|
||||
});
|
||||
certificateRepo.findOne.mockResolvedValueOnce(null);
|
||||
certificateRepo.create.mockReturnValue({ id: 'cert-lc' });
|
||||
certificateRepo.save.mockResolvedValue({
|
||||
id: 'cert-lc',
|
||||
level: 'Advanced',
|
||||
totalScore: 88,
|
||||
passed: true,
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-lc',
|
||||
});
|
||||
|
||||
const cert = await service.generateCertificate('session-lc', 'user-1', 'tenant-1');
|
||||
expect(cert.level).toBe('Advanced');
|
||||
|
||||
certificateRepo.findOne.mockResolvedValueOnce({
|
||||
id: 'cert-lc',
|
||||
level: 'Advanced',
|
||||
totalScore: 88,
|
||||
passed: true,
|
||||
issuedAt: new Date(),
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
const verified = await service.verifyCertificate('cert-lc');
|
||||
expect(verified.valid).toBe(true);
|
||||
expect(verified.certificate!.totalScore).toBe(88);
|
||||
});
|
||||
|
||||
it('should be idempotent — returning existing certificate on re-generation', async () => {
|
||||
const existing = { id: 'cert-dup', sessionId: 'session-dup', level: 'Proficient' };
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 'session-dup',
|
||||
userId: 'user-1',
|
||||
status: 'COMPLETED',
|
||||
finalScore: 65,
|
||||
});
|
||||
certificateRepo.findOne.mockResolvedValue(existing);
|
||||
|
||||
const result = await service.generateCertificate('session-dup', 'user-1', 'tenant-1');
|
||||
expect(result).toBe(existing);
|
||||
expect(certificateRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should determine correct level for different scores', async () => {
|
||||
const testCases = [
|
||||
{ score: 95, expectedLevel: 'Expert' },
|
||||
{ score: 80, expectedLevel: 'Advanced' },
|
||||
{ score: 65, expectedLevel: 'Proficient' },
|
||||
{ score: 45, expectedLevel: 'Novice' },
|
||||
];
|
||||
|
||||
for (const { score, expectedLevel } of testCases) {
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: `session-${score}`,
|
||||
userId: 'user-1',
|
||||
status: 'COMPLETED',
|
||||
finalScore: score,
|
||||
templateId: 't1',
|
||||
});
|
||||
certificateRepo.findOne.mockResolvedValue(null);
|
||||
certificateRepo.create.mockReturnValue({ id: `cert-${score}` });
|
||||
certificateRepo.save.mockResolvedValue({ id: `cert-${score}`, level: expectedLevel });
|
||||
|
||||
const cert = await service.generateCertificate(`session-${score}`, 'user-1', 'tenant-1');
|
||||
expect(cert.level).toBe(expectedLevel);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
cd /d D:\AuraK\server
|
||||
node --enable-source-maps dist/main
|
||||
pause
|
||||
@@ -0,0 +1,3 @@
|
||||
cd /d D:\AuraK\web
|
||||
npx vite --port 13001
|
||||
pause
|
||||
@@ -34,7 +34,7 @@ export const WorkspaceLayout: React.FC<WorkspaceLayoutProps> = ({
|
||||
appMode={appMode}
|
||||
onSwitchMode={onSwitchMode}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||
import { templateService } from '../../services/templateService';
|
||||
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
|
||||
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types';
|
||||
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup, AssessmentDimension } from '../../types';
|
||||
|
||||
export const AssessmentTemplateManager: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
@@ -29,8 +29,12 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
|
||||
style: 'Professional',
|
||||
knowledgeGroupId: '',
|
||||
passingScore: 6,
|
||||
totalTimeLimit: 1800,
|
||||
perQuestionTimeLimit: 300,
|
||||
});
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -72,7 +76,11 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
: (template.difficultyDistribution || ''),
|
||||
style: template.style || 'Professional',
|
||||
knowledgeGroupId: template.knowledgeGroupId || '',
|
||||
passingScore: template.passingScore ? template.passingScore / 10 : 6,
|
||||
totalTimeLimit: template.totalTimeLimit ?? 1800,
|
||||
perQuestionTimeLimit: template.perQuestionTimeLimit ?? 300,
|
||||
});
|
||||
setDimensions(template.dimensions || []);
|
||||
} else {
|
||||
setEditingTemplate(null);
|
||||
setFormData({
|
||||
@@ -83,7 +91,11 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
|
||||
style: 'Professional',
|
||||
knowledgeGroupId: '',
|
||||
passingScore: 6,
|
||||
totalTimeLimit: 1800,
|
||||
perQuestionTimeLimit: 300,
|
||||
});
|
||||
setDimensions([]);
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -95,13 +107,10 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
// Convert UI strings back to required types
|
||||
const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
|
||||
let diffDist: any = formData.difficultyDistribution;
|
||||
try {
|
||||
if (formData.difficultyDistribution.startsWith('{')) {
|
||||
diffDist = JSON.parse(formData.difficultyDistribution);
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep as string if parsing fails
|
||||
if (typeof diffDist === 'string' && diffDist.trim().startsWith('{')) {
|
||||
try { diffDist = JSON.parse(diffDist); } catch (e) { diffDist = undefined; }
|
||||
}
|
||||
if (typeof diffDist !== 'object' || diffDist === null) diffDist = undefined;
|
||||
|
||||
const payload: CreateTemplateData = {
|
||||
name: formData.name,
|
||||
@@ -111,6 +120,10 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
difficultyDistribution: diffDist,
|
||||
style: formData.style,
|
||||
knowledgeGroupId: formData.knowledgeGroupId || undefined,
|
||||
dimensions: dimensions.length > 0 ? dimensions : undefined,
|
||||
passingScore: formData.passingScore * 10,
|
||||
totalTimeLimit: formData.totalTimeLimit,
|
||||
perQuestionTimeLimit: formData.perQuestionTimeLimit,
|
||||
};
|
||||
|
||||
if (editingTemplate) {
|
||||
@@ -122,9 +135,10 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
}
|
||||
setShowModal(false);
|
||||
fetchTemplates();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Save failed:', error);
|
||||
showError(t('actionFailed'));
|
||||
const msg = error?.message;
|
||||
showError(msg && msg !== 'Request failed' ? msg : t('actionFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -141,6 +155,20 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionChange = (index: number, field: 'name' | 'label' | 'weight', value: string | number) => {
|
||||
const updated = [...dimensions];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setDimensions(updated);
|
||||
};
|
||||
|
||||
const handleAddDimension = () => {
|
||||
setDimensions([...dimensions, { name: '', label: '', weight: 1 }]);
|
||||
};
|
||||
|
||||
const handleRemoveDimension = (index: number) => {
|
||||
setDimensions(dimensions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!(await confirm(t('confirmTitle')))) return;
|
||||
try {
|
||||
@@ -255,6 +283,16 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{Array.isArray(template.dimensions) && template.dimensions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{template.dimensions.map((dim, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-full border border-amber-100/50">
|
||||
{dim.label} ({dim.weight}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
|
||||
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
|
||||
@@ -372,16 +410,99 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<Sliders size={12} className="text-indigo-500" />
|
||||
{t('style')}
|
||||
</label>
|
||||
<input
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||
value={formData.style}
|
||||
onChange={e => setFormData({ ...formData, style: e.target.value })}
|
||||
/>
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<Sliders size={12} className="text-indigo-500" />
|
||||
{t('style')}
|
||||
</label>
|
||||
<input
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||
value={formData.style}
|
||||
onChange={e => setFormData({ ...formData, style: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<Hash size={12} className="text-indigo-500" />通过分 (0-10)
|
||||
</label>
|
||||
<input type="number" min="0" max="10" step="0.5"
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||
value={formData.passingScore}
|
||||
onChange={e => setFormData({ ...formData, passingScore: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<Hash size={12} className="text-indigo-500" />总时间限制 (秒)
|
||||
</label>
|
||||
<input type="number" min="60" max="7200" step="60"
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||
value={formData.totalTimeLimit}
|
||||
onChange={e => setFormData({ ...formData, totalTimeLimit: parseInt(e.target.value) || 1800 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<Hash size={12} className="text-indigo-500" />单题时间限制 (秒)
|
||||
</label>
|
||||
<input type="number" min="30" max="1800" step="30"
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||
value={formData.perQuestionTimeLimit}
|
||||
onChange={e => setFormData({ ...formData, perQuestionTimeLimit: parseInt(e.target.value) || 300 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<Sliders size={12} className="text-indigo-500" />
|
||||
{t('templateDimensions')} *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{dimensions.length === 0 && (
|
||||
<p className="text-xs text-slate-400 italic px-3">{t('mmEmpty')}</p>
|
||||
)}
|
||||
{dimensions.map((dim, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<input
|
||||
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||
value={dim.name}
|
||||
onChange={e => handleDimensionChange(index, 'name', e.target.value)}
|
||||
placeholder={t('dimensionName')}
|
||||
/>
|
||||
<input
|
||||
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||
value={dim.label}
|
||||
onChange={e => handleDimensionChange(index, 'label', e.target.value)}
|
||||
placeholder={t('dimensionLabel')}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="w-20 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||
value={dim.weight}
|
||||
onChange={e => handleDimensionChange(index, 'weight', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
placeholder={t('dimensionWeight')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveDimension(index)}
|
||||
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all flex-shrink-0"
|
||||
title={t('removeDimension')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddDimension}
|
||||
className="text-xs font-bold text-indigo-600 hover:text-indigo-800 transition-colors px-1"
|
||||
>
|
||||
+ {t('addDimension')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Brain,
|
||||
Send,
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
Star,
|
||||
Award,
|
||||
Trophy,
|
||||
Trash2
|
||||
Trash2,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
@@ -51,6 +53,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | null>(null);
|
||||
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
|
||||
const [autoSubmitted, setAutoSubmitted] = useState(false);
|
||||
const [showCertModal, setShowCertModal] = useState(false);
|
||||
const [certData, setCertData] = useState<any>(null);
|
||||
const isTimedOut = timeCheck?.isTotalTimeout || timeCheck?.isQuestionTimeout;
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -103,6 +110,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
setTimeCheck(data);
|
||||
if (data.isTotalTimeout || data.isQuestionTimeout) {
|
||||
setError(t('timeLimitExceeded'));
|
||||
if (!autoSubmitted && !isLoading) {
|
||||
setAutoSubmitted(true);
|
||||
await handleSubmitAnswer(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check time:', err);
|
||||
@@ -137,7 +148,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
setState(histState);
|
||||
setSession(histSession);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load historical assessment');
|
||||
if (histSession.status === 'IN_PROGRESS') {
|
||||
setError(t('cannotResumeInProgress'));
|
||||
} else {
|
||||
setError(err.message || 'Failed to load historical assessment');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingHistoryId(null);
|
||||
@@ -184,7 +199,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
...prev,
|
||||
...event.data,
|
||||
messages: event.data.messages
|
||||
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
|
||||
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => (m.id && pm.id === m.id) || (pm.content === m.content && pm.role === m.role)))]
|
||||
: prevMessages,
|
||||
feedbackHistory: event.data.feedbackHistory
|
||||
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
|
||||
@@ -226,11 +241,21 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitAnswer = async () => {
|
||||
if (!session || !inputValue.trim() || isLoading) return;
|
||||
const handleSubmitAnswer = async (forced = false) => {
|
||||
const currentQuestion = state?.questions?.[state.currentQuestionIndex || 0] as any;
|
||||
const isChoice = currentQuestion?.questionType === 'MULTIPLE_CHOICE' && currentQuestion?.options?.length > 0;
|
||||
|
||||
const answer = inputValue.trim();
|
||||
if (!forced) {
|
||||
if (isChoice) {
|
||||
if (!selectedChoice || isLoading || isTimedOut) return;
|
||||
} else {
|
||||
if (!inputValue.trim() || isLoading || isTimedOut) return;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = isChoice ? (selectedChoice || '') : inputValue.trim();
|
||||
setInputValue('');
|
||||
setSelectedChoice(null);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
|
||||
@@ -252,7 +277,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
if (!prev) return event.data;
|
||||
const prevMessages = prev.messages || [];
|
||||
const mergedMessages = event.data.messages
|
||||
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
|
||||
? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => (m.id && pm.id === m.id) || (pm.content === m.content && pm.role === m.role)))]
|
||||
: prevMessages;
|
||||
|
||||
return {
|
||||
@@ -428,7 +453,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
|
||||
{/* Assessment History Sidebar */}
|
||||
{history.length > 0 && (
|
||||
<div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-200/60 shadow-[4px_0_24px_rgba(0,0,0,0.02)]">
|
||||
<div className="w-80 flex-none bg-white p-6 overflow-y-auto flex flex-col border-l border-slate-200/60 shadow-[4px_0_24px_rgba(0,0,0,0.02)]">
|
||||
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
|
||||
<History size={18} className="text-indigo-600" />
|
||||
{t('recentAssessments')}
|
||||
@@ -502,6 +527,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
!(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:')))
|
||||
);
|
||||
|
||||
const currentQuestion = (state?.questions?.[state.currentQuestionIndex || 0] || {}) as any;
|
||||
const isCurrentChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0;
|
||||
const optionLabels = ['A', 'B', 'C', 'D'];
|
||||
|
||||
const feedbackHistory = state?.feedbackHistory || [];
|
||||
const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
|
||||
|
||||
@@ -576,26 +605,79 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white border-t border-slate-200/60 shadow-[0_-4px_20px_-10px_rgba(0,0,0,0.05)]">
|
||||
{isTimedOut && (
|
||||
<div className="max-w-3xl mx-auto mb-3 px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-bold rounded-xl text-center">
|
||||
{t('timeLimitExceeded')}
|
||||
</div>
|
||||
)}
|
||||
{isCurrentChoice ? (
|
||||
<div className="max-w-3xl mx-auto space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">
|
||||
<span className="w-1 h-1 bg-indigo-400 rounded-full" />
|
||||
请选择一个选项
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{currentQuestion.options.map((opt: string, i: number) => {
|
||||
const letter = optionLabels[i];
|
||||
const isSelected = selectedChoice === letter;
|
||||
const displayText = opt.slice(1);
|
||||
return (
|
||||
<button
|
||||
key={letter}
|
||||
onClick={() => !isTimedOut && setSelectedChoice(letter)}
|
||||
disabled={isTimedOut}
|
||||
className={cn(
|
||||
"w-full text-left px-5 py-4 rounded-2xl border-2 transition-all text-sm font-medium",
|
||||
isSelected
|
||||
? "border-indigo-500 bg-indigo-50 text-indigo-700 shadow-md"
|
||||
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50",
|
||||
isTimedOut && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-xl text-xs font-black mr-3 shrink-0 border-2 border-current">
|
||||
{letter}
|
||||
</span>
|
||||
{displayText}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSubmitAnswer()}
|
||||
disabled={!selectedChoice || isLoading || isTimedOut}
|
||||
className={cn(
|
||||
"w-full mt-3 h-14 flex items-center justify-center gap-2 rounded-2xl transition-all shadow-lg text-white font-bold",
|
||||
!selectedChoice || isLoading || isTimedOut
|
||||
? "bg-slate-300 cursor-not-allowed"
|
||||
: "bg-indigo-600 hover:bg-indigo-700 active:scale-[0.97]"
|
||||
)}
|
||||
>
|
||||
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
|
||||
<span className="text-sm">确认答案</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto flex items-end gap-3">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isTimedOut) {
|
||||
e.preventDefault();
|
||||
handleSubmitAnswer();
|
||||
}
|
||||
}}
|
||||
placeholder={t('typeAnswerPlaceholder')}
|
||||
className="flex-1 max-h-32 p-4 bg-slate-50 border-none rounded-2xl focus:bg-white focus:ring-2 focus:ring-indigo-500/20 text-sm font-medium resize-none transition-all placeholder:text-slate-400 outline-none shadow-inner"
|
||||
placeholder={isTimedOut ? t('timeLimitExceeded') : t('typeAnswerPlaceholder')}
|
||||
disabled={isTimedOut}
|
||||
className="flex-1 max-h-32 p-4 bg-slate-50 border-none rounded-2xl focus:bg-white focus:ring-2 focus:ring-indigo-500/20 text-sm font-medium resize-none transition-all placeholder:text-slate-400 outline-none shadow-inner disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmitAnswer}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
disabled={!inputValue.trim() || isLoading || isTimedOut}
|
||||
className={cn(
|
||||
"w-14 h-14 flex items-center justify-center rounded-2xl transition-all shadow-lg",
|
||||
!inputValue.trim() || isLoading
|
||||
!inputValue.trim() || isLoading || isTimedOut
|
||||
? "bg-slate-100 text-slate-400 shadow-none"
|
||||
: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200 active:scale-95"
|
||||
)}
|
||||
@@ -603,11 +685,12 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
<Send size={22} className={isLoading ? "animate-pulse" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Feedback Panel */}
|
||||
<div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-100">
|
||||
<div className="w-80 flex-none bg-white p-6 overflow-y-auto flex flex-col border-l border-slate-100">
|
||||
<h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
|
||||
<ClipboardCheck size={18} className="text-indigo-600" />
|
||||
{t('liveFeedback')}
|
||||
@@ -744,14 +827,74 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('status')}</span>
|
||||
<span className={cn(
|
||||
"text-2xl font-black uppercase tracking-tighter",
|
||||
(state?.finalScore || 0) >= 6 ? "text-emerald-600" : "text-rose-600"
|
||||
state?.passed ? "text-emerald-600" : "text-rose-600"
|
||||
)}>
|
||||
{(state?.finalScore || 0) >= 6 ? t('verified') : t('fail')}
|
||||
{state?.passed ? t('verified') : t('fail')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{state?.questions && state.questions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
|
||||
<CheckCircle size={20} className="text-indigo-600" />
|
||||
每题详情
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{state.questions.map((q: any, i: number) => {
|
||||
const score = state.scores?.[q.id || (i + 1).toString()];
|
||||
const isChoice = q.questionType === 'MULTIPLE_CHOICE';
|
||||
const isCorrect = isChoice && q.correctAnswer && score >= 10;
|
||||
return (
|
||||
<div key={q.id || i} className="bg-white border border-slate-200 rounded-2xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0",
|
||||
isChoice
|
||||
? (isCorrect ? "bg-emerald-100 text-emerald-600" : "bg-red-100 text-red-600")
|
||||
: score !== undefined ? "bg-indigo-100 text-indigo-600" : "bg-slate-100 text-slate-400"
|
||||
)}>
|
||||
{isChoice
|
||||
? (isCorrect ? <CheckCircle size={20} /> : <XCircle size={20} />)
|
||||
: <span className="text-sm font-black">{score !== undefined ? score : '?'}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-800 text-sm leading-relaxed">{q.questionText}</p>
|
||||
{isChoice && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
{q.options?.map((opt: string, oi: number) => {
|
||||
const letter = String.fromCharCode(65 + oi);
|
||||
const isAnswer = letter === q.correctAnswer;
|
||||
const displayText = opt.slice(1);
|
||||
return (
|
||||
<span key={oi} className={cn(
|
||||
"px-3 py-1 rounded-lg font-medium",
|
||||
isAnswer ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-slate-50 text-slate-500"
|
||||
)}>
|
||||
{letter}. {displayText}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{q.judgment && (
|
||||
<div className="mt-3 bg-blue-50/50 border border-blue-100 rounded-xl p-3">
|
||||
<p className="text-xs text-slate-600 leading-relaxed">{q.judgment}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isChoice && score !== undefined && (
|
||||
<span className="inline-block mt-2 text-xs text-slate-400">得分: {score}/10</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
|
||||
<FileText size={20} className="text-indigo-600" />
|
||||
@@ -777,15 +920,14 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
if (!session) return;
|
||||
try {
|
||||
const result = await assessmentService.exportPdf(session.id);
|
||||
const blob = new Blob([result.content], { type: 'text/plain;charset=utf-8' });
|
||||
const binary = atob(result.buffer);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const blob = new Blob([bytes], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
window.open(url, '_blank');
|
||||
} catch (err) {
|
||||
console.error('Failed to export PDF:', err);
|
||||
setError(t('exportAssessmentFailed'));
|
||||
}
|
||||
}}
|
||||
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
|
||||
@@ -810,7 +952,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Failed to export Excel:', err);
|
||||
setError(t('exportAssessmentFailed'));
|
||||
}
|
||||
}}
|
||||
className="px-6 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
|
||||
@@ -822,7 +964,8 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
if (!session) return;
|
||||
try {
|
||||
const cert = await assessmentService.getCertificate(session.id);
|
||||
alert(`${t('certificate')}: ${cert.level}\n${t('totalScore')}: ${cert.totalScore}\n${t('passed')}: ${cert.passed ? t('yes') : t('no')}`);
|
||||
setCertData(cert);
|
||||
setShowCertModal(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to get certificate:', err);
|
||||
}
|
||||
@@ -843,6 +986,58 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
<div className="flex flex-col h-full bg-white animate-in flex-1">
|
||||
{renderHeader()}
|
||||
|
||||
{showCertModal && certData && createPortal(
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={() => setShowCertModal(false)} />
|
||||
<div className="relative bg-white rounded-3xl shadow-2xl max-w-lg w-full p-8 max-h-[80vh] overflow-y-auto">
|
||||
<button onClick={() => setShowCertModal(false)} className="absolute top-4 right-4 p-2 text-slate-400 hover:text-slate-600 rounded-xl hover:bg-slate-100">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 5L15 15M15 5L5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
<Award size={40} className="text-indigo-600 mb-3" />
|
||||
<h3 className="text-2xl font-black text-slate-900">{certData.level}</h3>
|
||||
<p className="text-sm text-slate-500 font-medium mt-1">{certData.templateName || '-'}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-slate-50 rounded-2xl p-4 text-center">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">总分</span>
|
||||
<p className="text-xl font-black text-slate-900 mt-1">{certData.totalScore}/10</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-2xl p-4 text-center">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">结果</span>
|
||||
<p className={`text-xl font-black mt-1 ${certData.passed ? 'text-emerald-600' : 'text-rose-600'}`}>{certData.passed ? '合格' : '不合格'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{certData.dimensionScores && (
|
||||
<div className="mb-6">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">维度得分</span>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{Object.entries(certData.dimensionScores).map(([dim, score]: [string, any]) => (
|
||||
<div key={dim} className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-600">{dim}</span>
|
||||
<span className="font-black text-slate-900">{score}/10</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{certData.questionDetails && (
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">题目列表</span>
|
||||
<div className="mt-2 space-y-1">
|
||||
{certData.questionDetails.map((qd: any) => (
|
||||
<div key={qd.index} className="text-xs text-slate-600 truncate">
|
||||
<span className="font-bold text-slate-400">#{qd.index}</span> {qd.questionText}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{error && (
|
||||
<motion.div
|
||||
|
||||
@@ -3,35 +3,31 @@ import { createPortal } from 'react-dom';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ChevronLeft, Plus, Sparkles, Send, Check, X,
|
||||
ChevronLeft, Plus, Sparkles, Send, Check, X, XCircle, Clock,
|
||||
Trash2, Edit2, FileText, Loader2, BookOpen, Brain,
|
||||
AlertCircle, Hash, Layers
|
||||
} from 'lucide-react';
|
||||
import { questionBankService, QuestionBank, QuestionBankItem, CreateQuestionBankItemDto } from '../../services/questionBankService';
|
||||
import { templateService } from '../../services/templateService';
|
||||
import { AssessmentTemplate } from '../../types';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: 'SHORT_ANSWER', label: '简答题' },
|
||||
{ value: 'MULTIPLE_CHOICE', label: '选择题' },
|
||||
{ value: 'TRUE_FALSE', label: '判断题' },
|
||||
{ value: 'SHORT_ANSWER', labelKey: 'shortAnswer' as const },
|
||||
{ value: 'MULTIPLE_CHOICE', labelKey: 'multipleChoice' as const },
|
||||
{ value: 'TRUE_FALSE', labelKey: 'trueFalse' as const },
|
||||
];
|
||||
|
||||
const DIFFICULTIES = [
|
||||
{ value: 'STANDARD', label: '标准' },
|
||||
{ value: 'ADVANCED', label: '高级' },
|
||||
{ value: 'SPECIALIST', label: '专家' },
|
||||
{ value: 'STANDARD', labelKey: 'standard' as const },
|
||||
{ value: 'ADVANCED', labelKey: 'advanced' as const },
|
||||
{ value: 'SPECIALIST', labelKey: 'specialist' as const },
|
||||
];
|
||||
|
||||
const DIMENSIONS = [
|
||||
{ value: 'PROMPT', label: 'Prompt' },
|
||||
{ value: 'LLM', label: 'LLM' },
|
||||
{ value: 'IDE', label: 'IDE' },
|
||||
{ value: 'DEV_PATTERN', label: '开发模式' },
|
||||
{ value: 'WORK_CAPABILITY', label: '工作能力' },
|
||||
];
|
||||
|
||||
const typeIcons: Record<string, React.ReactNode> = {
|
||||
type TypeIcon = { [key: string]: React.ReactNode };
|
||||
const typeIcons: TypeIcon = {
|
||||
SHORT_ANSWER: <FileText size={12} />,
|
||||
MULTIPLE_CHOICE: <Layers size={12} />,
|
||||
TRUE_FALSE: <Check size={12} />,
|
||||
@@ -40,14 +36,19 @@ const typeIcons: Record<string, React.ReactNode> = {
|
||||
export default function QuestionBankDetailView() {
|
||||
const { id: bankId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
if (!bankId) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4">
|
||||
<ChevronLeft size={20} /> 返回
|
||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-4">
|
||||
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
|
||||
</button>
|
||||
<div className="text-red-500">无效的题库ID</div>
|
||||
<div className="flex items-center gap-2 text-red-500 bg-red-50 rounded-2xl p-4 border border-red-100">
|
||||
<AlertCircle size={18} /><span className="text-sm font-bold">{t('invalidBankId')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +56,7 @@ export default function QuestionBankDetailView() {
|
||||
const [bank, setBank] = useState<QuestionBank | null>(null);
|
||||
const [items, setItems] = useState<QuestionBankItem[]>([]);
|
||||
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
||||
const [template, setTemplate] = useState<AssessmentTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -72,30 +74,96 @@ export default function QuestionBankDetailView() {
|
||||
});
|
||||
const [keyPointsInput, setKeyPointsInput] = useState('');
|
||||
|
||||
const [generateForm, setGenerateForm] = useState({
|
||||
count: 5,
|
||||
knowledgeBaseContent: '',
|
||||
});
|
||||
const [generateForm, setGenerateForm] = useState({ count: 5, knowledgeBaseContent: '' });
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const selectableItems = items.filter(i => i.status === 'PENDING_REVIEW');
|
||||
const allSelected = selectableItems.length > 0 && selectableItems.every(i => selectedItemIds.has(i.id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedItemIds(new Set());
|
||||
} else {
|
||||
setSelectedItemIds(new Set(selectableItems.map(i => i.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectItem = (itemId: string) => {
|
||||
setSelectedItemIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemId)) next.delete(itemId); else next.add(itemId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchApprove = async () => {
|
||||
const ids = Array.from(selectedItemIds);
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await questionBankService.batchReviewItems(bankId, ids, true);
|
||||
showSuccess(`已通过 ${ids.length} 道题目`);
|
||||
setSelectedItemIds(new Set());
|
||||
fetchData();
|
||||
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||
};
|
||||
|
||||
const handleBatchReject = async () => {
|
||||
const ids = Array.from(selectedItemIds);
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await questionBankService.batchReviewItems(bankId, ids, false);
|
||||
showSuccess(`已驳回 ${ids.length} 道题目`);
|
||||
setSelectedItemIds(new Set());
|
||||
fetchData();
|
||||
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try { setLoading(true);
|
||||
try {
|
||||
setLoading(true);
|
||||
const bankData = await questionBankService.getBank(bankId);
|
||||
setBank(bankData);
|
||||
const itemsData = await questionBankService.getBankItems(bankId);
|
||||
setItems(itemsData);
|
||||
} catch (err: any) { setError(err.message || '加载失败');
|
||||
} finally { setLoading(false); }
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('actionFailed'));
|
||||
showError(err.message || t('actionFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try { const data = await templateService.getAll(); setTemplates(data);
|
||||
} catch (err) { console.error('加载模板失败:', err); }
|
||||
try {
|
||||
const data = await templateService.getAll();
|
||||
setTemplates(data);
|
||||
const bankData = await questionBankService.getBank(bankId);
|
||||
if (bankData.templateId) {
|
||||
const tpl = data.find(tpl => tpl.id === bankData.templateId);
|
||||
setTemplate(tpl || null);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
const openGenerateModal = () => {
|
||||
setShowGenerate(true);
|
||||
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
|
||||
};
|
||||
|
||||
const dimensionOptions = template?.dimensions?.map(d => ({ value: d.name || d.label, label: d.label || d.name }))
|
||||
|| [
|
||||
{ value: 'PROMPT', label: 'Prompt' },
|
||||
{ value: 'LLM', label: 'LLM' },
|
||||
{ value: 'IDE', label: 'IDE' },
|
||||
{ value: 'DEV_PATTERN', label: 'Dev Pattern' },
|
||||
{ value: 'WORK_CAPABILITY', label: 'Work Capability' },
|
||||
];
|
||||
|
||||
const handleCreateItem = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!itemForm.questionText.trim()) return;
|
||||
@@ -103,8 +171,9 @@ export default function QuestionBankDetailView() {
|
||||
try {
|
||||
await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
|
||||
closeItemForm();
|
||||
showSuccess(t('questionAdded'));
|
||||
fetchData();
|
||||
} catch (err: any) { alert('创建失败: ' + (err.message || '未知错误'));
|
||||
} catch (err: any) { showError(err.message || t('actionFailed'));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
@@ -115,15 +184,17 @@ export default function QuestionBankDetailView() {
|
||||
try {
|
||||
await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
|
||||
closeItemForm();
|
||||
showSuccess(t('questionUpdated'));
|
||||
fetchData();
|
||||
} catch (err: any) { alert('更新失败: ' + (err.message || '未知错误'));
|
||||
} catch (err: any) { showError(err.message || t('actionFailed'));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (itemId: string) => {
|
||||
if (!confirm('确定要删除这道题目吗?')) return;
|
||||
try { await questionBankService.deleteItem(bankId, itemId); fetchData();
|
||||
} catch (err: any) { alert('删除失败: ' + (err.message || '未知错误')); }
|
||||
const ok = await confirm({ message: t('confirmDeleteQuestion'), confirmLabel: t('delete'), cancelLabel: t('cancel') });
|
||||
if (!ok) return;
|
||||
try { await questionBankService.deleteItem(bankId, itemId); showSuccess(t('questionDeleted')); fetchData();
|
||||
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
@@ -132,26 +203,41 @@ export default function QuestionBankDetailView() {
|
||||
await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
|
||||
setShowGenerate(false);
|
||||
setGenerateForm({ count: 5, knowledgeBaseContent: '' });
|
||||
showSuccess(t('generatedQuestions').replace('$1', String(generateForm.count)));
|
||||
fetchData();
|
||||
} catch (err: any) { alert('生成失败: ' + (err.message || '未知错误'));
|
||||
} catch (err: any) { showError(err.message || t('actionFailed'));
|
||||
} finally { setGenerating(false); }
|
||||
};
|
||||
|
||||
const handleSubmitForReview = async () => {
|
||||
if (!confirm('确定要提交审核吗?')) return;
|
||||
try { await questionBankService.submitForReview(bankId); fetchData();
|
||||
} catch (err: any) { alert('提交失败: ' + (err.message || '未知错误')); }
|
||||
const ok = await confirm({ message: t('confirmSubmitReview'), confirmLabel: t('submitForReview'), cancelLabel: t('cancel') });
|
||||
if (!ok) return;
|
||||
try { await questionBankService.submitForReview(bankId); showSuccess(t('bankSubmittedForReview')); fetchData();
|
||||
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!confirm('确定要发布题库吗?')) return;
|
||||
try { await questionBankService.publishBank(bankId); fetchData();
|
||||
} catch (err: any) { alert('发布失败: ' + (err.message || '未知错误')); }
|
||||
const isPendingReview = bank?.status === 'PENDING_REVIEW';
|
||||
const label = isPendingReview ? t('approve') : t('republish');
|
||||
const msg = isPendingReview ? t('confirmApproveBank') : t('confirmRepublishBank');
|
||||
const ok = await confirm({ message: msg, confirmLabel: label, cancelLabel: t('cancel') });
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (isPendingReview) await questionBankService.approveBank(bankId);
|
||||
else await questionBankService.publishBank(bankId);
|
||||
showSuccess(isPendingReview ? t('bankApproved') : t('bankRepublished'));
|
||||
fetchData();
|
||||
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||
};
|
||||
|
||||
const handleApproveItem = async (itemId: string) => {
|
||||
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); fetchData();
|
||||
} catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); }
|
||||
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); showSuccess(t('questionApproved')); fetchData();
|
||||
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||
};
|
||||
|
||||
const handleRejectItem = async (itemId: string) => {
|
||||
try { await questionBankService.batchReviewItems(bankId, [itemId], false); showSuccess(t('questionReturned')); fetchData();
|
||||
} catch (err: any) { showError(err.message || t('actionFailed')); }
|
||||
};
|
||||
|
||||
const openEditItem = (item: QuestionBankItem) => {
|
||||
@@ -163,27 +249,24 @@ export default function QuestionBankDetailView() {
|
||||
|
||||
const closeItemForm = () => { setShowAddItem(false); setEditingItem(null); };
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PUBLISHED': return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200/50">已发布</span>;
|
||||
case 'PENDING_REVIEW': return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-amber-50 text-amber-600 border border-amber-200/50">待审核</span>;
|
||||
default: return <span className="px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full bg-slate-50 text-slate-500 border border-slate-200/50">草稿</span>;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-30" />
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"><ChevronLeft size={20} /> 返回</button>
|
||||
<div className="flex items-center gap-2 text-red-500 bg-red-50 rounded-2xl p-4 border border-red-100"><AlertCircle size={18} /><span className="text-sm font-bold">{error}</span></div>
|
||||
<div className="space-y-4">
|
||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-3 text-red-500 bg-red-50 rounded-2xl p-6 border border-red-100">
|
||||
<AlertCircle size={20} /><span className="text-sm font-bold">{error}</span>
|
||||
<button onClick={fetchData} className="ml-auto text-xs font-black text-red-600 hover:text-red-700 uppercase tracking-widest">{t('retry')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,100 +274,182 @@ export default function QuestionBankDetailView() {
|
||||
const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW');
|
||||
const publishedItems = items.filter(i => i.status === 'PUBLISHED');
|
||||
|
||||
const statusColors: Record<string, { bg: string; text: string; border: string; label: string; blur: string; icon: React.ReactNode }> = {
|
||||
PUBLISHED: { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-200/50', label: t('published'), blur: 'bg-emerald-500/5', icon: <Check size={12} /> },
|
||||
PENDING_REVIEW: { bg: 'bg-amber-50', text: 'text-amber-600', border: 'border-amber-200/50', label: t('pendingReview'), blur: 'bg-amber-500/5', icon: <Clock size={12} /> },
|
||||
DRAFT: { bg: 'bg-slate-50', text: 'text-slate-500', border: 'border-slate-200/50', label: t('draft'), blur: 'bg-blue-500/5', icon: <FileText size={12} /> },
|
||||
REJECTED: { bg: 'bg-red-50', text: 'text-red-500', border: 'border-red-200/50', label: t('rejected'), blur: 'bg-red-500/5', icon: <XCircle size={12} /> },
|
||||
};
|
||||
|
||||
const bankStatus = statusColors[bank?.status || 'DRAFT'] || statusColors.DRAFT;
|
||||
|
||||
const statCards = [
|
||||
{ label: t('questionList'), value: items.length, icon: <FileText size={18} />, classes: 'bg-slate-50 border-slate-200/50 text-slate-700' },
|
||||
{ label: t('published'), value: publishedItems.length, icon: <Check size={18} />, classes: 'bg-emerald-50 border-emerald-200/50 text-emerald-700' },
|
||||
{ label: t('pendingReview'), value: pendingItems.length, icon: <Clock size={18} />, classes: 'bg-amber-50 border-amber-200/50 text-amber-700' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-2">
|
||||
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">返回题库列表</span>
|
||||
<div className="space-y-6 overflow-y-auto h-full">
|
||||
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
|
||||
</button>
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center shadow-sm"><BookOpen size={28} /></div>
|
||||
<div className="w-14 h-14 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center shadow-sm shrink-0"><BookOpen size={28} /></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-slate-900">{bank?.name}</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">{bank?.description || '暂无描述'}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center gap-1.5"><Brain size={12} className="text-blue-500" />{templates.find(t => t.id === bank?.templateId)?.name || '未关联模板'}</span>
|
||||
{getStatusBadge(bank?.status || 'DRAFT')}
|
||||
<p className="text-sm text-slate-500 mt-1">{bank?.description || t('noDescription')}</p>
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
||||
{template && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100/50">
|
||||
<Brain size={12} />{template.name}
|
||||
</span>
|
||||
)}
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${bankStatus.bg} ${bankStatus.text} ${bankStatus.border}`}>
|
||||
{bankStatus.icon}{bankStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{bank?.status === 'DRAFT' && (
|
||||
<button onClick={handleSubmitForReview} className="px-5 py-3 bg-amber-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-amber-100 hover:bg-amber-600 transition-all active:scale-95">
|
||||
<Send size={16} /> 提交审核
|
||||
<Send size={16} /> {t('submitForReview')}
|
||||
</button>
|
||||
)}
|
||||
{bank?.status === 'PENDING_REVIEW' && (
|
||||
{(bank?.status === 'PENDING_REVIEW' || bank?.status === 'REJECTED') && (
|
||||
<button onClick={handlePublish} className="px-5 py-3 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-emerald-100 hover:bg-emerald-700 transition-all active:scale-95">
|
||||
<Check size={16} /> 发布
|
||||
<Check size={16} /> {bank?.status === 'PENDING_REVIEW' ? t('approve') : t('republish')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowGenerate(true)} className="px-5 py-3 bg-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95">
|
||||
<Sparkles size={16} /> AI生成
|
||||
<button onClick={openGenerateModal} className="px-5 py-3 bg-purple-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95">
|
||||
<Sparkles size={16} /> {t('aiGenerate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: '总题目数', value: items.length, color: 'blue', icon: <FileText size={16} /> },
|
||||
{ label: '待审核', value: pendingItems.length, color: 'amber', icon: <Send size={16} /> },
|
||||
{ label: '已发布', value: publishedItems.length, color: 'emerald', icon: <Check size={16} /> },
|
||||
].map((stat, i) => (
|
||||
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}
|
||||
className={`bg-${stat.color}-50/50 border border-${stat.color}-100/50 rounded-2xl p-4`}>
|
||||
{statCards.map((stat, i) => (
|
||||
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08 }}
|
||||
className={`rounded-2xl border p-4 ${stat.classes}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`text-[10px] font-black uppercase tracking-widest text-${stat.color}-500`}>{stat.label}</span>
|
||||
<span className={`text-${stat.color}-500`}>{stat.icon}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<div className={`text-3xl font-black text-${stat.color}-700`}>{stat.value}</div>
|
||||
<div className="text-3xl font-black">{stat.value}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-black text-slate-900">题目列表</h2>
|
||||
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: 'WORK_CAPABILITY' }); }}
|
||||
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95">
|
||||
<Plus size={16} /> 添加题目
|
||||
</button>
|
||||
<h2 className="text-lg font-black text-slate-900">{t('questionList')}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedItemIds.size > 0 && (
|
||||
<>
|
||||
<button onClick={handleBatchApprove}
|
||||
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-emerald-100 hover:bg-emerald-700 transition-all active:scale-95">
|
||||
<Check size={14} /> 通过所选 ({selectedItemIds.size})
|
||||
</button>
|
||||
<button onClick={handleBatchReject}
|
||||
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-red-100 hover:bg-red-600 transition-all active:scale-95">
|
||||
<X size={14} /> 驳回所选
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={toggleSelectAll}
|
||||
className="px-4 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 hover:bg-slate-200 transition-all">
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: (dimensionOptions[0]?.value as any) || 'WORK_CAPABILITY' }); }}
|
||||
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95">
|
||||
<Plus size={16} /> {t('addQuestion')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
|
||||
<FileText className="w-14 h-14 text-slate-200 mx-auto mb-4" />
|
||||
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">暂无题目</p>
|
||||
<p className="text-slate-300 text-xs mt-2">点击上方按钮添加或使用AI生成</p>
|
||||
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4"><FileText size={28} className="text-slate-300" /></div>
|
||||
<p className="text-slate-400 font-black uppercase tracking-widest text-xs mb-1">{t('noQuestions')}</p>
|
||||
<p className="text-slate-300 text-xs">{t('noQuestionsDesc')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items.map((item, idx) => (
|
||||
<motion.div key={item.id} layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ delay: idx * 0.03 }}
|
||||
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/5 rounded-full blur-3xl -mr-16 -mt-16" />
|
||||
<div className="flex items-start justify-between relative z-10">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{QUESTION_TYPES.find(t => t.value === item.questionType)?.label}</span>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100"><Hash size={10} />{DIFFICULTIES.find(d => d.value === item.difficulty)?.label}</span>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100"><Brain size={10} />{DIMENSIONS.find(d => d.value === item.dimension)?.label}</span>
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p>
|
||||
{item.keyPoints.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">评分要点:</span>
|
||||
{item.keyPoints.map((kp, i) => <span key={i} className="px-2.5 py-1 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-lg border border-amber-100/50">{kp}</span>)}
|
||||
</div>
|
||||
{items.map((item, idx) => {
|
||||
const itemStat = item.status === 'PUBLISHED' ? statusColors.PUBLISHED : statusColors.PENDING_REVIEW;
|
||||
return (
|
||||
<motion.div key={item.id} layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ delay: Math.min(idx * 0.03, 0.3) }}
|
||||
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
|
||||
<div className={`absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl -mr-20 -mt-20 ${itemStat.blur}`} />
|
||||
<div className="relative z-10 flex items-start justify-between">
|
||||
{item.status === 'PENDING_REVIEW' && (
|
||||
<input type="checkbox" checked={selectedItemIds.has(item.id)}
|
||||
onChange={() => toggleSelectItem(item.id)}
|
||||
className="mt-1.5 mr-3 w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 shrink-0 cursor-pointer" />
|
||||
)}
|
||||
{item.basis && <div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-400"><FileText size={10} /><span className="font-medium">依据:</span><span>{item.basis}</span></div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2.5 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{t(QUESTION_TYPES.find(qt => qt.value === item.questionType)?.labelKey || 'shortAnswer')}</span>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100"><Hash size={10} />{t(DIFFICULTIES.find(d => d.value === item.difficulty)?.labelKey || 'standard')}</span>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-bold rounded-lg border border-purple-100"><Brain size={10} />{dimensionOptions.find(d => d.value === item.dimension)?.label || item.dimension}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${itemStat.bg} ${itemStat.text} ${itemStat.border}`}>{itemStat.icon}{itemStat.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p>
|
||||
{item.questionType === 'MULTIPLE_CHOICE' && item.options && item.options.length > 0 && (
|
||||
<div className="mt-3 space-y-1.5 pl-1 border-l-2 border-blue-200">
|
||||
{item.options.map((opt, i) => {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
const isCorrect = item.correctAnswer === letter;
|
||||
const displayText = opt.slice(1);
|
||||
return (
|
||||
<div key={i} className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm ${isCorrect ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50'}`}>
|
||||
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-lg text-[10px] font-black shrink-0 ${isCorrect ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-500'}`}>{letter}</span>
|
||||
<span className={`font-medium ${isCorrect ? 'text-emerald-700' : 'text-slate-600'}`}>{displayText}</span>
|
||||
{isCorrect && <Check size={14} className="text-emerald-500 shrink-0 ml-auto" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{item.judgment && (
|
||||
<div className="mt-3 bg-blue-50/50 border border-blue-100 rounded-xl p-3">
|
||||
<span className="text-[10px] font-black text-blue-400 uppercase tracking-widest">{item.questionType === 'MULTIPLE_CHOICE' ? '解析' : '判定依据'}</span>
|
||||
<p className="text-xs text-slate-600 mt-1 leading-relaxed">{item.judgment}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.questionType === 'SHORT_ANSWER' && item.followupHints && item.followupHints.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-[10px] font-black text-purple-400 uppercase tracking-widest">追问方向</span>
|
||||
{item.followupHints.map((hint, i) => <span key={i} className="px-2.5 py-1 bg-purple-50 text-purple-600 text-[10px] font-medium rounded-lg border border-purple-100/50">#{i + 1} {hint}</span>)}
|
||||
</div>
|
||||
)}
|
||||
{item.keyPoints.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">{t('gradingPoints')}</span>
|
||||
{item.keyPoints.map((kp, i) => <span key={i} className="px-2.5 py-1 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-lg border border-amber-100/50">{kp}</span>)}
|
||||
</div>
|
||||
)}
|
||||
{item.basis && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-400"><FileText size={10} /><span className="font-medium">{t('basis')}</span><span>{item.basis}</span></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{item.status === 'PENDING_REVIEW' && (<>
|
||||
<button onClick={() => handleApproveItem(item.id)} className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all" title={t('approve')}><Check size={15} /></button>
|
||||
<button onClick={() => handleRejectItem(item.id)} className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-all" title={t('rejected')}><X size={15} /></button>
|
||||
</>)}
|
||||
<button onClick={() => openEditItem(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title={t('edit')}><Edit2 size={15} /></button>
|
||||
<button onClick={() => handleDeleteItem(item.id)} className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-all" title={t('delete')}><Trash2 size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-4 shrink-0">
|
||||
{item.status === 'PENDING_REVIEW' && <button onClick={() => handleApproveItem(item.id)} className="p-2 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-all" title="通过"><Check size={15} /></button>}
|
||||
<button onClick={() => openEditItem(item)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title="编辑"><Edit2 size={15} /></button>
|
||||
<button onClick={() => handleDeleteItem(item.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-xl transition-all" title="删除"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
@@ -297,60 +462,37 @@ export default function QuestionBankDetailView() {
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
|
||||
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">{editingItem ? <Edit2 size={24} /> : <Plus size={24} />}</div>
|
||||
<h3 className="text-xl font-black text-slate-900">{editingItem ? '编辑题目' : '添加题目'}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3"><div className="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">{editingItem ? <Edit2 size={24} /> : <Plus size={24} />}</div>
|
||||
<h3 className="text-xl font-black text-slate-900">{editingItem ? t('editQuestion') : t('addQuestionTitle')}</h3></div>
|
||||
<button onClick={closeItemForm} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
|
||||
</div>
|
||||
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-blue-500" /> 题目内容 *</label>
|
||||
<textarea value={itemForm.questionText} onChange={(e) => setItemForm({...itemForm, questionText: e.target.value})}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="输入题目内容" rows={3} required />
|
||||
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-blue-500" /> {t('questionContent')} <span className="text-red-500">*</span></label>
|
||||
<textarea value={itemForm.questionText} onChange={(e) => setItemForm({...itemForm, questionText: e.target.value})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder={t('questionContent')} rows={3} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Layers size={12} className="text-blue-500" /> 题型</label>
|
||||
<select value={itemForm.questionType} onChange={(e) => setItemForm({...itemForm, questionType: e.target.value as any})}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
|
||||
{QUESTION_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Layers size={12} className="text-blue-500" /> {t('questionType')}</label>
|
||||
<select value={itemForm.questionType} onChange={(e) => setItemForm({...itemForm, questionType: e.target.value as any})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ QUESTION_TYPES.map(qt => <option key={qt.value} value={qt.value}>{t(qt.labelKey)}</option>) }</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-blue-500" /> 难度</label>
|
||||
<select value={itemForm.difficulty} onChange={(e) => setItemForm({...itemForm, difficulty: e.target.value as any})}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
|
||||
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||
</select>
|
||||
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-blue-500" /> {t('difficultyDistribution')}</label>
|
||||
<select value={itemForm.difficulty} onChange={(e) => setItemForm({...itemForm, difficulty: e.target.value as any})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>) }</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Brain size={12} className="text-blue-500" /> 维度</label>
|
||||
<select value={itemForm.dimension} onChange={(e) => setItemForm({...itemForm, dimension: e.target.value as any})}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all appearance-none cursor-pointer">
|
||||
{DIMENSIONS.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||
</select>
|
||||
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Brain size={12} className="text-blue-500" /> {t('dimension')}</label>
|
||||
<select value={itemForm.dimension} onChange={(e) => setItemForm({...itemForm, dimension: e.target.value as any})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer">{ dimensionOptions.map(d => <option key={d.value} value={d.value}>{d.label}</option>) }</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><AlertCircle size={12} className="text-blue-500" /> 评分要点(每行一个)</label>
|
||||
<textarea value={keyPointsInput} onChange={(e) => setKeyPointsInput(e.target.value)}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="要点1
|
||||
要点2
|
||||
要点3" rows={4} />
|
||||
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><AlertCircle size={12} className="text-blue-500" /> {t('gradingPoints')}</label>
|
||||
<textarea value={keyPointsInput} onChange={(e) => setKeyPointsInput(e.target.value)} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300" placeholder={'1\n2\n3'} rows={4} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button type="button" onClick={closeItemForm} className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">取消</button>
|
||||
<button type="submit" form="item-form" disabled={saving}
|
||||
className="px-10 py-4 bg-blue-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95 flex items-center gap-2">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />}{saving ? '保存中...' : (editingItem ? '更新' : '添加')}</button>
|
||||
<button type="button" onClick={closeItemForm} className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">{t('cancel')}</button>
|
||||
<button type="submit" form="item-form" disabled={saving} className="px-10 py-4 bg-blue-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95 flex items-center gap-2">{saving && <Loader2 size={16} className="animate-spin" />}{saving ? t('saving') : (editingItem ? t('save') : t('addQuestion'))}</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
</AnimatePresence>, document.body
|
||||
)}
|
||||
|
||||
{createPortal(
|
||||
@@ -361,36 +503,26 @@ export default function QuestionBankDetailView() {
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden">
|
||||
<div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center"><Sparkles size={24} /></div>
|
||||
<h3 className="text-xl font-black text-slate-900">AI生成题目</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3"><div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center"><Sparkles size={24} /></div>
|
||||
<h3 className="text-xl font-black text-slate-900">{t('aiGenerateTitle')}</h3></div>
|
||||
<button onClick={() => setShowGenerate(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><X size={20} /></button>
|
||||
</div>
|
||||
<div className="p-8 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-purple-500" /> 生成数量</label>
|
||||
<input type="number" value={generateForm.count} onChange={(e) => setGenerateForm({...generateForm, count: parseInt(e.target.value) || 5})}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500/50 outline-none transition-all" min={1} max={20} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><FileText size={12} className="text-purple-500" /> 知识库内容(可选)</label>
|
||||
<textarea value={generateForm.knowledgeBaseContent} onChange={(e) => setGenerateForm({...generateForm, knowledgeBaseContent: e.target.value})}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500/50 outline-none transition-all placeholder:text-slate-300" placeholder="输入知识库内容作为生成依据..." rows={4} />
|
||||
<div className="space-y-1.5"><label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2"><Hash size={12} className="text-purple-500" /> {t('generateCount')}</label>
|
||||
<input type="number" value={generateForm.count} onChange={(e) => setGenerateForm({...generateForm, count: parseInt(e.target.value) || 5})} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500/50 outline-none transition-all" min={1} max={20} />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 px-1">知识库内容已自动加载</p>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button onClick={() => setShowGenerate(false)} className="flex-1 px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">取消</button>
|
||||
<button onClick={handleGenerate} disabled={generating}
|
||||
className="flex-1 px-6 py-4 bg-purple-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95 flex items-center justify-center gap-2">
|
||||
{generating ? <><Loader2 size={16} className="animate-spin" /> 生成中...</> : <><Sparkles size={16} /> 生成</>}</button>
|
||||
<button onClick={() => setShowGenerate(false)} className="flex-1 px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors">{t('cancel')}</button>
|
||||
<button onClick={handleGenerate} disabled={generating} className="flex-1 px-6 py-4 bg-purple-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-purple-100 hover:bg-purple-700 transition-all active:scale-95 flex items-center justify-center gap-2">
|
||||
{generating ? <><Loader2 size={16} className="animate-spin" /> {t('generating')}</> : <><Sparkles size={16} /> {t('generate')}</>}</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
</AnimatePresence>, document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, BookOpen, ChevronRight, Trash2, Edit2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BookOpen, FileText, Layers, Loader2, Plus, Search, Trash2, Edit2, AlertCircle, Check, Clock, XCircle } from 'lucide-react';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { templateService } from '../../services/templateService';
|
||||
import { questionBankService } from '../../services/questionBankService';
|
||||
import { AssessmentTemplate } from '../../types';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface QuestionBankViewProps {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface QuestionBank {
|
||||
interface QuestionBankItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -19,25 +22,27 @@ interface QuestionBank {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
type StatusFilter = 'ALL' | 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED';
|
||||
|
||||
export default function QuestionBankView({ isAdmin: _isAdmin }: QuestionBankViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [banks, setBanks] = useState<QuestionBank[]>([]);
|
||||
const { t } = useLanguage();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const [banks, setBanks] = useState<QuestionBankItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
templateId: ''
|
||||
});
|
||||
const [formData, setFormData] = useState({ name: '', description: '', templateId: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -47,7 +52,8 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
const data = await res.json();
|
||||
setBanks(Array.isArray(data) ? data : (data.data || []));
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载失败');
|
||||
setError(err.message || t('actionFailed'));
|
||||
showError(err.message || t('actionFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,7 +64,7 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
setLoadingTemplates(true);
|
||||
templateService.getAll()
|
||||
.then(data => setTemplates(data))
|
||||
.catch(err => console.error('加载模板失败:', err))
|
||||
.catch(() => showError(t('actionFailed')))
|
||||
.finally(() => setLoadingTemplates(false));
|
||||
setShowDrawer(true);
|
||||
};
|
||||
@@ -66,17 +72,10 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim()) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
};
|
||||
if (formData.templateId) {
|
||||
payload.templateId = formData.templateId;
|
||||
}
|
||||
|
||||
const payload: any = { name: formData.name, description: formData.description };
|
||||
if (formData.templateId) payload.templateId = formData.templateId;
|
||||
const res = await apiClient.request('/question-banks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -88,12 +87,11 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
setShowDrawer(false);
|
||||
showSuccess(t('questionBankCreated'));
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error('创建失败:', err);
|
||||
alert('创建失败: ' + (err.message || '未知错误'));
|
||||
showError(err.message || t('actionFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -101,182 +99,280 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`确定要删除题库"${bankName}"吗?此操作不可恢复。`)) return;
|
||||
|
||||
const ok = await confirm({ message: t('confirmDeleteBank').replace('$1', bankName), confirmLabel: t('delete'), cancelLabel: t('cancel') });
|
||||
if (!ok) return;
|
||||
setDeletingId(bankId);
|
||||
try {
|
||||
await questionBankService.deleteBank(bankId);
|
||||
const res = await apiClient.request(`/question-banks/${bankId}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
let msg = res.status.toString();
|
||||
try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
showSuccess(t('confirm'));
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error('删除失败:', err);
|
||||
alert('删除失败: ' + (err.message || '未知错误'));
|
||||
showError(err.message || t('questionBankDeleteFailed'));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = (bank: QuestionBank) => {
|
||||
const handleCardClick = (bank: QuestionBankItem) => {
|
||||
navigate(`/question-banks/${bank.id}`);
|
||||
};
|
||||
|
||||
const filteredBanks = useMemo(() => {
|
||||
let result = banks;
|
||||
if (statusFilter !== 'ALL') result = result.filter(b => b.status === statusFilter);
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(b => b.name.toLowerCase().includes(q) || (b.description || '').toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [banks, statusFilter, searchQuery]);
|
||||
|
||||
const STATUS_TABS: { key: StatusFilter; label: string; icon: React.ReactNode; count: (b: QuestionBankItem[]) => number }[] = [
|
||||
{ key: 'ALL', label: t('all'), icon: <Layers size={14} />, count: (b) => b.length },
|
||||
{ key: 'PUBLISHED', label: t('published'), icon: <Check size={14} />, count: (b) => b.filter(i => i.status === 'PUBLISHED').length },
|
||||
{ key: 'DRAFT', label: t('draft'), icon: <FileText size={14} />, count: (b) => b.filter(i => i.status === 'DRAFT').length },
|
||||
{ key: 'PENDING_REVIEW', label: t('pendingReview'), icon: <Clock size={14} />, count: (b) => b.filter(i => i.status === 'PENDING_REVIEW').length },
|
||||
];
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: banks.length,
|
||||
published: banks.filter(b => b.status === 'PUBLISHED').length,
|
||||
draft: banks.filter(b => b.status === 'DRAFT').length,
|
||||
pending: banks.filter(b => b.status === 'PENDING_REVIEW').length,
|
||||
}), [banks]);
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PUBLISHED: t('published'),
|
||||
PENDING_REVIEW: t('pendingReview'),
|
||||
REJECTED: t('rejected'),
|
||||
DRAFT: t('draft'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white min-h-screen">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">题库管理</h1>
|
||||
<button
|
||||
<div className="space-y-6 overflow-y-auto h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-slate-900">{t('questionBankManagement')}</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">{t('questionBankManagementDesc')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openDrawer}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
className="px-5 py-3 bg-blue-600 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-600/20 hover:bg-blue-700 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>创建题库</span>
|
||||
{t('createQuestionBank')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500">错误: {error}</div>
|
||||
) : banks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<BookOpen size={48} className="mx-auto mb-4 text-gray-300" />
|
||||
<p>暂无题库,点击上方按钮创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{banks.map((bank) => (
|
||||
<div
|
||||
key={bank.id}
|
||||
className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer group relative"
|
||||
onClick={() => handleCardClick(bank)}
|
||||
>
|
||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCardClick(bank); }}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 rounded-md bg-white border shadow-sm"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, bank.id, bank.name)}
|
||||
disabled={deletingId === bank.id}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded-md bg-white border shadow-sm disabled:opacity-50"
|
||||
title="删除"
|
||||
>
|
||||
{deletingId === bank.id ? (
|
||||
<span className="w-3.5 h-3.5 border-2 border-red-500 border-t-transparent rounded-full animate-spin block"></span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
{!loading && !error && banks.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: t('totalBanks'), value: stats.total, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <Layers size={16} className="text-slate-500" /> },
|
||||
{ label: t('published'), value: stats.published, color: 'bg-emerald-50 border-emerald-200/50 text-emerald-700', icon: <Check size={16} className="text-emerald-500" /> },
|
||||
{ label: t('draft'), value: stats.draft, color: 'bg-slate-50 border-slate-200 text-slate-700', icon: <FileText size={16} className="text-slate-500" /> },
|
||||
{ label: t('pendingReview'), value: stats.pending, color: 'bg-amber-50 border-amber-200/50 text-amber-700', icon: <Clock size={16} className="text-amber-500" /> },
|
||||
].map((stat, i) => (
|
||||
<motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
|
||||
className={`${stat.color} rounded-2xl border p-4`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<h3 className="font-semibold pr-16">{bank.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{bank.description || '暂无描述'}</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
bank.status === 'PUBLISHED' ? 'bg-green-100 text-green-700' :
|
||||
bank.status === 'PENDING_REVIEW' ? 'bg-yellow-100 text-yellow-700' :
|
||||
bank.status === 'REJECTED' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{bank.status === 'PUBLISHED' ? '已发布' :
|
||||
bank.status === 'PENDING_REVIEW' ? '待审核' :
|
||||
bank.status === 'REJECTED' ? '已否决' : '草稿'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(bank.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-black">{stat.value}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<>
|
||||
{showDrawer && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300"
|
||||
onClick={() => setShowDrawer(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-out ${showDrawer ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50">
|
||||
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2">
|
||||
<Plus className="w-6 h-6 text-blue-600" />
|
||||
创建题库
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowDrawer(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full transition-colors"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form id="create-form" onSubmit={handleCreate} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
|
||||
placeholder="输入题库名称"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
|
||||
placeholder="输入描述"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
关联模板
|
||||
</label>
|
||||
<select
|
||||
value={formData.templateId}
|
||||
onChange={(e) => setFormData({...formData, templateId: e.target.value})}
|
||||
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50"
|
||||
disabled={loadingTemplates}
|
||||
>
|
||||
<option value="">不选择模板</option>
|
||||
{templates.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingTemplates && <span className="text-xs text-slate-500">加载中...</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 border-t bg-slate-50">
|
||||
<button
|
||||
type="submit"
|
||||
form="create-form"
|
||||
disabled={saving || !formData.name.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
{!loading && !error && banks.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchQuestionBanksPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-slate-50 rounded-2xl p-1 border border-slate-200">
|
||||
{STATUS_TABS.map((tab) => {
|
||||
const active = statusFilter === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
active
|
||||
? 'bg-white text-slate-900 shadow-sm border border-slate-200/50'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
<span className={`${active ? 'bg-slate-100 text-slate-600' : 'bg-white/50 text-slate-400'} px-1.5 py-0.5 rounded-lg text-[10px] font-black`}>
|
||||
{tab.count(banks)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-3 text-red-500 bg-red-50 rounded-2xl p-6 border border-red-100">
|
||||
<AlertCircle size={20} />
|
||||
<span className="text-sm font-bold">{t('actionFailed')}</span>
|
||||
<button onClick={fetchData} className="ml-auto text-xs font-black text-red-600 hover:text-red-700 uppercase tracking-widest">{t('retry')}</button>
|
||||
</div>
|
||||
) : banks.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-20 text-center">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
||||
<BookOpen size={32} className="text-slate-300" />
|
||||
</div>
|
||||
<p className="text-slate-400 font-black uppercase tracking-widest text-xs mb-2">{t('noQuestionBanks')}</p>
|
||||
<p className="text-slate-300 text-xs mb-6">{t('createFirstBank')}</p>
|
||||
<button onClick={openDrawer} className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-2xl text-sm font-black uppercase tracking-widest hover:bg-blue-700 transition-all active:scale-[0.98] shadow-lg shadow-blue-600/20">
|
||||
<Plus size={18} /> {t('createQuestionBank')}
|
||||
</button>
|
||||
</div>
|
||||
) : filteredBanks.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-20 text-center">
|
||||
<Search size={32} className="text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-400 font-bold text-xs uppercase tracking-widest">{t('noMatchingQuestionBanks')}</p>
|
||||
<p className="text-slate-300 text-xs mt-2">{t('tryChangingFilter')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredBanks.map((bank) => (
|
||||
<motion.div
|
||||
key={bank.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
onClick={() => handleCardClick(bank)}
|
||||
className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className={`absolute top-0 right-0 w-32 h-32 rounded-full blur-3xl -mr-16 -mt-16 ${
|
||||
bank.status === 'PUBLISHED' ? 'bg-emerald-500/5' :
|
||||
bank.status === 'PENDING_REVIEW' ? 'bg-amber-500/5' :
|
||||
bank.status === 'REJECTED' ? 'bg-red-500/5' : 'bg-blue-500/5'
|
||||
}`} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-black text-base text-slate-900 pr-8 line-clamp-1">{bank.name}</h3>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity absolute top-0 right-0">
|
||||
<button onClick={(e) => { e.stopPropagation(); handleCardClick(bank); }}
|
||||
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all" title={t('edit')}>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
<button onClick={(e) => handleDelete(e, bank.id, bank.name)} disabled={deletingId === bank.id}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all disabled:opacity-50" title={t('delete')}>
|
||||
{deletingId === bank.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{bank.description || t('noDescription')}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-50">
|
||||
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${
|
||||
bank.status === 'PUBLISHED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200/50' :
|
||||
bank.status === 'PENDING_REVIEW' ? 'bg-amber-50 text-amber-600 border-amber-200/50' :
|
||||
bank.status === 'REJECTED' ? 'bg-red-50 text-red-500 border-red-200/50' :
|
||||
'bg-slate-50 text-slate-500 border-slate-200/50'
|
||||
}`}>
|
||||
{statusLabels[bank.status] || bank.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium">
|
||||
{new Date(bank.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showDrawer && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={() => setShowDrawer(false)} className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-40" />
|
||||
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center"><Plus size={22} /></div>
|
||||
<h2 className="text-lg font-black text-slate-900">{t('createQuestionBank')}</h2>
|
||||
</div>
|
||||
<button onClick={() => setShowDrawer(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all"><XCircle size={22} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form id="create-form" onSubmit={handleCreate} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<BookOpen size={12} className="text-blue-500" /> {t('name')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||
placeholder={t('name')} required autoFocus />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<FileText size={12} className="text-blue-500" /> {t('description')}
|
||||
</label>
|
||||
<input type="text" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||
placeholder={t('description')} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||
<Layers size={12} className="text-blue-500" /> {t('linkTemplate')}
|
||||
</label>
|
||||
<select value={formData.templateId} onChange={(e) => setFormData({ ...formData, templateId: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500/50 outline-none transition-all cursor-pointer"
|
||||
disabled={loadingTemplates}>
|
||||
<option value="">{t('noTemplate')}</option>
|
||||
{templates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{loadingTemplates && (
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Loader2 size={12} className="animate-spin text-slate-400" />
|
||||
<span className="text-[10px] text-slate-400 font-medium">{t('loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 border-t border-slate-100">
|
||||
<button type="submit" form="create-form" disabled={saving || !formData.name.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-blue-600 text-white font-black uppercase tracking-widest text-xs rounded-[1.25rem] hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-blue-100">
|
||||
{saving ? <Loader2 size={18} className="animate-spin" /> : <Plus size={18} />}
|
||||
{saving ? t('creating') : t('createQuestionBank')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class ApiClient {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null') {
|
||||
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null' && activeTenantId !== 'default') {
|
||||
headers['x-tenant-id'] = activeTenantId;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface AssessmentState {
|
||||
status?: 'IN_PROGRESS' | 'COMPLETED';
|
||||
report?: string;
|
||||
finalScore?: number;
|
||||
passed?: boolean;
|
||||
dimensionScores?: Record<string, number>;
|
||||
radarData?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
@@ -139,8 +142,8 @@ export class AssessmentService {
|
||||
return data;
|
||||
}
|
||||
|
||||
async exportPdf(sessionId: string): Promise<{ filename: string; content: string }> {
|
||||
const { data } = await apiClient.get<{ filename: string; content: string }>(`/assessment/${sessionId}/export/pdf`);
|
||||
async exportPdf(sessionId: string): Promise<{ filename: string; buffer: string }> {
|
||||
const { data } = await apiClient.get<{ filename: string; buffer: string }>(`/assessment/${sessionId}/export/pdf`);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface QuestionBankItem {
|
||||
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
|
||||
options?: string[] | null;
|
||||
correctAnswer?: string | null;
|
||||
judgment?: string | null;
|
||||
followupHints?: string[] | null;
|
||||
keyPoints: string[];
|
||||
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
|
||||
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
|
||||
|
||||
@@ -332,6 +332,12 @@ export interface TenantMember {
|
||||
}
|
||||
|
||||
// Assessment Template Types
|
||||
export interface AssessmentDimension {
|
||||
name: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface AssessmentTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -343,6 +349,10 @@ export interface AssessmentTemplate {
|
||||
knowledgeBaseId?: string;
|
||||
knowledgeGroupId?: string;
|
||||
knowledgeGroup?: KnowledgeGroup;
|
||||
dimensions?: AssessmentDimension[];
|
||||
passingScore?: number;
|
||||
totalTimeLimit?: number;
|
||||
perQuestionTimeLimit?: number;
|
||||
isActive: boolean;
|
||||
version: number;
|
||||
creatorId: string;
|
||||
@@ -359,6 +369,10 @@ export interface CreateTemplateData {
|
||||
style?: string;
|
||||
knowledgeBaseId?: string;
|
||||
knowledgeGroupId?: string;
|
||||
dimensions?: AssessmentDimension[];
|
||||
passingScore?: number;
|
||||
totalTimeLimit?: number;
|
||||
perQuestionTimeLimit?: number;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
|
||||
|
||||
+232
-3
@@ -636,6 +636,12 @@ export const translations = {
|
||||
style: "风格要求",
|
||||
createTemplate: "创建模板",
|
||||
editTemplate: "编辑模板",
|
||||
templateDimensions: "评估维度",
|
||||
dimensionName: "维度名称",
|
||||
dimensionLabel: "维度标签",
|
||||
dimensionWeight: "权重",
|
||||
addDimension: "添加维度",
|
||||
removeDimension: "删除",
|
||||
|
||||
allNotes: "所有笔记",
|
||||
filterNotesPlaceholder: "筛选笔记...",
|
||||
@@ -813,7 +819,7 @@ export const translations = {
|
||||
questionBasis: "出题依据",
|
||||
viewBasis: "查看依据",
|
||||
hideBasis: "隐藏依据",
|
||||
verified: "已验证",
|
||||
verified: "合格",
|
||||
fail: "失败",
|
||||
comprehensiveMasteryReport: "综合能力报告",
|
||||
newAssessmentSession: "新评测会话",
|
||||
@@ -828,6 +834,8 @@ export const translations = {
|
||||
deleteAssessmentSuccess: "评测记录已成功删除",
|
||||
deleteAssessmentFailed: '删除评估记录失败',
|
||||
view: '查看',
|
||||
exportAssessmentFailed: '导出评估报告失败',
|
||||
cannotResumeInProgress: '此评估进行中,无法恢复查看',
|
||||
|
||||
// Plugins
|
||||
pluginTitle: "插件中心",
|
||||
@@ -933,6 +941,74 @@ export const translations = {
|
||||
allFormats: "所有格式支持",
|
||||
visualVision: "视觉识别",
|
||||
releaseToIngest: "释放以注入",
|
||||
|
||||
// Question Bank Management
|
||||
questionBankManagement: "题库管理",
|
||||
questionBankManagementDesc: "管理和创建评测题库",
|
||||
createQuestionBank: "创建题库",
|
||||
searchQuestionBanksPlaceholder: "搜索题库名称或描述...",
|
||||
noQuestionBanks: "暂无题库",
|
||||
noMatchingQuestionBanks: "未找到匹配的题库",
|
||||
createFirstBank: "点击上方按钮创建第一个题库",
|
||||
totalBanks: "总题库",
|
||||
pendingReview: "待审核",
|
||||
rejected: "已否决",
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
description: "描述",
|
||||
linkTemplate: "关联模板",
|
||||
noTemplate: "不选择模板",
|
||||
tryChangingFilter: "尝试修改筛选条件",
|
||||
|
||||
// Question Bank Detail
|
||||
backToBankList: "返回题库列表",
|
||||
invalidBankId: "无效的题库ID",
|
||||
questionList: "题目列表",
|
||||
addQuestion: "添加题目",
|
||||
noQuestions: "暂无题目",
|
||||
noQuestionsDesc: "点击上方按钮添加或使用 AI 生成",
|
||||
editQuestion: "编辑题目",
|
||||
addQuestionTitle: "添加题目",
|
||||
gradingPoints: "评分要点",
|
||||
questionContent: "题目内容",
|
||||
questionType: "题型",
|
||||
shortAnswer: "简答题",
|
||||
multipleChoice: "选择题",
|
||||
trueFalse: "判断题",
|
||||
advanced: "进阶",
|
||||
specialist: "专家",
|
||||
standard: "标准",
|
||||
dimension: "维度",
|
||||
basis: "依据:",
|
||||
submitForReview: "提交审核",
|
||||
approve: "审核通过",
|
||||
republish: "重新发布",
|
||||
aiGenerate: "AI生成",
|
||||
aiGenerateTitle: "AI 生成题目",
|
||||
generateCount: "生成数量",
|
||||
knowledgeBaseContentOptional: "知识库内容(可选)",
|
||||
generate: "生成",
|
||||
generating: "生成中...",
|
||||
|
||||
// Question Bank Toasts
|
||||
questionBankCreated: "题库已创建",
|
||||
questionBankDeleteFailed: "删除失败",
|
||||
questionAdded: "题目已添加",
|
||||
questionUpdated: "题目已更新",
|
||||
questionDeleted: "题目已删除",
|
||||
bankSubmittedForReview: "题库已提交审核",
|
||||
bankApproved: "题库已审核通过",
|
||||
bankRepublished: "题库已重新发布",
|
||||
questionApproved: "题目已通过审核",
|
||||
questionReturned: "题目已退回",
|
||||
generatedQuestions: "成功生成 $1 道题目",
|
||||
|
||||
// Question Bank Confirm
|
||||
confirmDeleteBank: "确定要删除题库「$1」吗?此操作不可恢复。",
|
||||
confirmDeleteQuestion: "确定要删除这道题目吗?",
|
||||
confirmSubmitReview: "确定要提交审核吗?提交后将进入待审核状态。",
|
||||
confirmApproveBank: "确定要审核通过此题库吗?",
|
||||
confirmRepublishBank: "确定要重新发布此题库吗?",
|
||||
},
|
||||
en: {
|
||||
aiCommandsError: "An error occurred",
|
||||
@@ -1573,6 +1649,12 @@ export const translations = {
|
||||
style: "Style Requirements",
|
||||
createTemplate: "Create Template",
|
||||
editTemplate: "Edit Template",
|
||||
templateDimensions: "Evaluation Dimensions",
|
||||
dimensionName: "Dimension Name",
|
||||
dimensionLabel: "Label",
|
||||
dimensionWeight: "Weight",
|
||||
addDimension: "Add Dimension",
|
||||
removeDimension: "Remove",
|
||||
|
||||
allNotes: "All Notes",
|
||||
filterNotesPlaceholder: "Filter notes...",
|
||||
@@ -1750,7 +1832,7 @@ export const translations = {
|
||||
questionBasis: "Question Basis",
|
||||
viewBasis: "View Basis",
|
||||
hideBasis: "Hide Basis",
|
||||
verified: "Verified",
|
||||
verified: "Qualified",
|
||||
fail: "Fail",
|
||||
comprehensiveMasteryReport: "Comprehensive Mastery Report",
|
||||
newAssessmentSession: "New Assessment Session",
|
||||
@@ -1765,6 +1847,8 @@ export const translations = {
|
||||
deleteAssessmentSuccess: "Assessment record deleted successfully",
|
||||
deleteAssessmentFailed: 'Failed to delete assessment record',
|
||||
view: 'View',
|
||||
exportAssessmentFailed: 'Failed to export assessment report',
|
||||
cannotResumeInProgress: 'Assessment in progress, cannot view',
|
||||
|
||||
// Plugins
|
||||
pluginTitle: "Plugin Store",
|
||||
@@ -1877,6 +1961,74 @@ export const translations = {
|
||||
allFormats: "All Formats Supported",
|
||||
visualVision: "Visual Recognition",
|
||||
releaseToIngest: "Release to Ingest",
|
||||
|
||||
// Question Bank Management
|
||||
questionBankManagement: "Question Bank Management",
|
||||
questionBankManagementDesc: "Manage and create assessment question banks",
|
||||
createQuestionBank: "Create Question Bank",
|
||||
searchQuestionBanksPlaceholder: "Search bank name or description...",
|
||||
noQuestionBanks: "No question banks",
|
||||
noMatchingQuestionBanks: "No matching question banks found",
|
||||
createFirstBank: "Click the button above to create your first bank",
|
||||
totalBanks: "Total Banks",
|
||||
pendingReview: "Pending Review",
|
||||
rejected: "Rejected",
|
||||
draft: "Draft",
|
||||
published: "Published",
|
||||
description: "Description",
|
||||
linkTemplate: "Linked Template",
|
||||
noTemplate: "No template",
|
||||
tryChangingFilter: "Try changing the filter criteria",
|
||||
|
||||
// Question Bank Detail
|
||||
backToBankList: "Back to Bank List",
|
||||
invalidBankId: "Invalid question bank ID",
|
||||
questionList: "Question List",
|
||||
addQuestion: "Add Question",
|
||||
noQuestions: "No questions",
|
||||
noQuestionsDesc: "Click the button above to add or use AI to generate",
|
||||
editQuestion: "Edit Question",
|
||||
addQuestionTitle: "Add Question",
|
||||
gradingPoints: "Scoring Points",
|
||||
questionContent: "Question Content",
|
||||
questionType: "Question Type",
|
||||
shortAnswer: "Short Answer",
|
||||
multipleChoice: "Multiple Choice",
|
||||
trueFalse: "True/False",
|
||||
advanced: "Advanced",
|
||||
specialist: "Specialist",
|
||||
standard: "Standard",
|
||||
dimension: "Dimension",
|
||||
basis: "Basis:",
|
||||
submitForReview: "Submit for Review",
|
||||
approve: "Approve",
|
||||
republish: "Republish",
|
||||
aiGenerate: "AI Generate",
|
||||
aiGenerateTitle: "AI Generate Questions",
|
||||
generateCount: "Generation Count",
|
||||
knowledgeBaseContentOptional: "Knowledge Base Content (optional)",
|
||||
generate: "Generate",
|
||||
generating: "Generating...",
|
||||
|
||||
// Question Bank Toasts
|
||||
questionBankCreated: "Question bank created",
|
||||
questionBankDeleteFailed: "Delete failed",
|
||||
questionAdded: "Question added",
|
||||
questionUpdated: "Question updated",
|
||||
questionDeleted: "Question deleted",
|
||||
bankSubmittedForReview: "Bank submitted for review",
|
||||
bankApproved: "Bank approved",
|
||||
bankRepublished: "Bank republished",
|
||||
questionApproved: "Question approved",
|
||||
questionReturned: "Question returned",
|
||||
generatedQuestions: "Successfully generated $1 questions",
|
||||
|
||||
// Question Bank Confirm
|
||||
confirmDeleteBank: "Are you sure you want to delete \"$1\"? This cannot be undone.",
|
||||
confirmDeleteQuestion: "Are you sure you want to delete this question?",
|
||||
confirmSubmitReview: "Submit this bank for review? It will enter pending review status.",
|
||||
confirmApproveBank: "Approve this question bank?",
|
||||
confirmRepublishBank: "Republish this question bank?",
|
||||
},
|
||||
ja: {
|
||||
aiCommandsError: "エラーが発生しました",
|
||||
@@ -2610,6 +2762,13 @@ export const translations = {
|
||||
style: "スタイル要件",
|
||||
createTemplate: "テンプレートを作成",
|
||||
editTemplate: "テンプレートを編集",
|
||||
templateDimensions: "評価ディメンション",
|
||||
dimensionName: "ディメンション名",
|
||||
dimensionLabel: "ラベル",
|
||||
dimensionWeight: "重み",
|
||||
addDimension: "ディメンションを追加",
|
||||
removeDimension: "削除",
|
||||
|
||||
allNotes: "すべてのノート",
|
||||
filterNotesPlaceholder: "ノートをフィルタリング...",
|
||||
startWritingPlaceholder: "書き始める...",
|
||||
@@ -2688,7 +2847,7 @@ export const translations = {
|
||||
questionBasis: "出題の根拠",
|
||||
viewBasis: "根拠を表示",
|
||||
hideBasis: "根拠を非表示",
|
||||
verified: "検証済み",
|
||||
verified: "合格",
|
||||
fail: "失敗",
|
||||
comprehensiveMasteryReport: "包括的習熟度レポート",
|
||||
newAssessmentSession: "新しいアセスメントセッション",
|
||||
@@ -2703,6 +2862,8 @@ export const translations = {
|
||||
deleteAssessmentSuccess: "評価記録が正常に削除されました",
|
||||
deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました',
|
||||
view: '表示',
|
||||
exportAssessmentFailed: '評価レポートのエクスポートに失敗しました',
|
||||
cannotResumeInProgress: '評価進行中、表示できません',
|
||||
|
||||
// Plugins
|
||||
pluginTitle: "プラグインストア",
|
||||
@@ -2817,5 +2978,73 @@ export const translations = {
|
||||
allFormats: "すべてのフォーマット対応",
|
||||
visualVision: "視覚認識",
|
||||
releaseToIngest: "離して取り込む",
|
||||
|
||||
// Question Bank Management
|
||||
questionBankManagement: "問題バンク管理",
|
||||
questionBankManagementDesc: "評価問題バンクの管理と作成",
|
||||
createQuestionBank: "問題バンクを作成",
|
||||
searchQuestionBanksPlaceholder: "バンク名または説明を検索...",
|
||||
noQuestionBanks: "問題バンクがありません",
|
||||
noMatchingQuestionBanks: "一致する問題バンクが見つかりません",
|
||||
createFirstBank: "上のボタンをクリックして最初の問題バンクを作成",
|
||||
totalBanks: "総バンク数",
|
||||
pendingReview: "審査待ち",
|
||||
rejected: "却下",
|
||||
draft: "下書き",
|
||||
published: "公開済み",
|
||||
description: "説明",
|
||||
linkTemplate: "関連テンプレート",
|
||||
noTemplate: "テンプレートなし",
|
||||
tryChangingFilter: "フィルター条件を変更してみてください",
|
||||
|
||||
// Question Bank Detail
|
||||
backToBankList: "問題バンクリストに戻る",
|
||||
invalidBankId: "無効な問題バンクID",
|
||||
questionList: "問題リスト",
|
||||
addQuestion: "問題を追加",
|
||||
noQuestions: "問題がありません",
|
||||
noQuestionsDesc: "上のボタンをクリックして追加するか、AIで生成してください",
|
||||
editQuestion: "問題を編集",
|
||||
addQuestionTitle: "問題を追加",
|
||||
gradingPoints: "採点ポイント",
|
||||
questionContent: "問題内容",
|
||||
questionType: "問題タイプ",
|
||||
shortAnswer: "記述式",
|
||||
multipleChoice: "選択式",
|
||||
trueFalse: "正誤式",
|
||||
advanced: "上級",
|
||||
specialist: "専門家",
|
||||
standard: "標準",
|
||||
dimension: "ディメンション",
|
||||
basis: "根拠:",
|
||||
submitForReview: "審査を依頼",
|
||||
approve: "承認",
|
||||
republish: "再公開",
|
||||
aiGenerate: "AI生成",
|
||||
aiGenerateTitle: "AI問題生成",
|
||||
generateCount: "生成数",
|
||||
knowledgeBaseContentOptional: "ナレッジベース内容(任意)",
|
||||
generate: "生成",
|
||||
generating: "生成中...",
|
||||
|
||||
// Question Bank Toasts
|
||||
questionBankCreated: "問題バンクが作成されました",
|
||||
questionBankDeleteFailed: "削除に失敗しました",
|
||||
questionAdded: "問題が追加されました",
|
||||
questionUpdated: "問題が更新されました",
|
||||
questionDeleted: "問題が削除されました",
|
||||
bankSubmittedForReview: "バンクが審査に提出されました",
|
||||
bankApproved: "バンクが承認されました",
|
||||
bankRepublished: "バンクが再公開されました",
|
||||
questionApproved: "問題が承認されました",
|
||||
questionReturned: "問題が差し戻されました",
|
||||
generatedQuestions: "$1問の問題を生成しました",
|
||||
|
||||
// Question Bank Confirm
|
||||
confirmDeleteBank: "「$1」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
confirmDeleteQuestion: "この問題を削除してもよろしいですか?",
|
||||
confirmSubmitReview: "審査に提出しますか?審査待ち状態になります。",
|
||||
confirmApproveBank: "この問題バンクを承認しますか?",
|
||||
confirmRepublishBank: "この問題バンクを再公開しますか?",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user