forked from hangshuo652/aurak
fix: 代码整合修复 - Entity类型、题库生成、评估流程等14项修复
This commit is contained in:
@@ -6,13 +6,17 @@ import {
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
Req,
|
||||
Sse,
|
||||
MessageEvent,
|
||||
Query,
|
||||
Delete,
|
||||
Put,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { AssessmentService } from './assessment.service';
|
||||
import { ExportService } from './services/export.service';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
|
||||
@@ -20,7 +24,10 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
@Controller('assessment')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
export class AssessmentController {
|
||||
constructor(private readonly assessmentService: AssessmentService) {}
|
||||
constructor(
|
||||
private readonly assessmentService: AssessmentService,
|
||||
private readonly exportService: ExportService,
|
||||
) {}
|
||||
|
||||
@Post('start')
|
||||
@ApiOperation({ summary: 'Start a new assessment session' })
|
||||
@@ -102,16 +109,6 @@ export class AssessmentController {
|
||||
return this.assessmentService.getSessionState(sessionId, userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get assessment session history' })
|
||||
async getHistory(@Request() req: any) {
|
||||
const { id: userId, tenantId } = req.user;
|
||||
console.log(
|
||||
`[AssessmentController] getHistory: user=${userId}, tenant=${tenantId}`,
|
||||
);
|
||||
return this.assessmentService.getHistory(userId, tenantId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete an assessment session' })
|
||||
async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
|
||||
@@ -135,6 +132,23 @@ export class AssessmentController {
|
||||
return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
|
||||
}
|
||||
|
||||
@Get('certificate/verify/:certificateId')
|
||||
@ApiOperation({ summary: 'Verify certificate by ID (public)' })
|
||||
@UseGuards()
|
||||
async verifyCertificate(
|
||||
@Param('certificateId') certificateId: string,
|
||||
) {
|
||||
return this.assessmentService.verifyCertificate(certificateId);
|
||||
}
|
||||
|
||||
@Get('certificate/public/:sessionId')
|
||||
@ApiOperation({ summary: 'Get public certificate info for verification' })
|
||||
async getPublicCertificate(
|
||||
@Param('sessionId') sessionId: string,
|
||||
) {
|
||||
return this.assessmentService.getPublicCertificateInfo(sessionId);
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
@ApiOperation({ summary: 'Get current user assessment history (keep latest 3)' })
|
||||
async getHistory(
|
||||
@@ -168,6 +182,38 @@ export class AssessmentController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('stats/radar')
|
||||
@ApiOperation({ summary: 'Get radar chart data for dimension scores' })
|
||||
async getRadarStats(
|
||||
@Request() req: any,
|
||||
@Query('templateId') templateId?: string,
|
||||
) {
|
||||
const { id: userId, tenantId, role } = req.user;
|
||||
return this.assessmentService.getRadarStats(
|
||||
userId,
|
||||
tenantId,
|
||||
role,
|
||||
templateId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('stats/trend')
|
||||
@ApiOperation({ summary: 'Get trend data for scores over time' })
|
||||
async getTrendStats(
|
||||
@Request() req: any,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
) {
|
||||
const { id: userId, tenantId, role } = req.user;
|
||||
return this.assessmentService.getTrendStats(
|
||||
userId,
|
||||
tenantId,
|
||||
role,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id/review')
|
||||
@ApiOperation({ summary: 'Review assessment - adjust final score' })
|
||||
async review(
|
||||
@@ -184,4 +230,51 @@ export class AssessmentController {
|
||||
tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/time-check')
|
||||
@ApiOperation({ summary: 'Check assessment time limits' })
|
||||
async checkTimeLimits(@Param('id') sessionId: string) {
|
||||
return this.assessmentService.checkTimeLimits(sessionId);
|
||||
}
|
||||
|
||||
@Post(':id/next-question')
|
||||
@ApiOperation({ summary: 'Start timing for next question' })
|
||||
async nextQuestion(@Param('id') sessionId: string) {
|
||||
await this.assessmentService.updateQuestionStartTime(sessionId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/force-end')
|
||||
@ApiOperation({ summary: 'Force end assessment (admin only)' })
|
||||
async forceEnd(
|
||||
@Param('id') sessionId: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { 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);
|
||||
}
|
||||
|
||||
@Get(':id/export/excel')
|
||||
@ApiOperation({ summary: 'Export assessment to Excel' })
|
||||
async exportExcel(@Param('id') sessionId: string) {
|
||||
const buffer = await this.exportService.exportToExcel(sessionId);
|
||||
return {
|
||||
filename: `assessment-${sessionId}.xlsx`,
|
||||
buffer: buffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id/export/pdf')
|
||||
@ApiOperation({ summary: 'Export assessment to PDF (text format)' })
|
||||
async exportPdf(@Param('id') sessionId: string) {
|
||||
const buffer = await this.exportService.exportToPdf(sessionId);
|
||||
return {
|
||||
filename: `assessment-${sessionId}.txt`,
|
||||
content: buffer.toString('utf-8'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { QuestionBankController } from './controllers/question-bank.controller';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -49,7 +50,8 @@ import { QuestionBankService } from './services/question-bank.service';
|
||||
ContentFilterService,
|
||||
QuestionOutlineService,
|
||||
QuestionBankService,
|
||||
ExportService,
|
||||
],
|
||||
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService],
|
||||
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
|
||||
})
|
||||
export class AssessmentModule {}
|
||||
|
||||
@@ -526,6 +526,10 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||
language,
|
||||
questions_json: questionsFromBank.length > 0 ? questionsFromBank : [],
|
||||
questionSource,
|
||||
startedAt: new Date(),
|
||||
currentQuestionStartedAt: new Date(),
|
||||
totalTimeLimit: template?.totalTimeLimit || 1800,
|
||||
perQuestionTimeLimit: template?.perQuestionTimeLimit || 300,
|
||||
};
|
||||
|
||||
const content = await this.getSessionContent(sessionData);
|
||||
@@ -1442,6 +1446,82 @@ const initialState: Partial<EvaluationState> = {
|
||||
};
|
||||
}
|
||||
|
||||
async getRadarStats(userId: string, tenantId: string, role: string, templateId?: string): Promise<any> {
|
||||
const isAdmin = role === 'super_admin' || role === 'admin';
|
||||
|
||||
const qb = this.sessionRepository.createQueryBuilder('session');
|
||||
qb.where('session.tenantId = :tenantId', { tenantId });
|
||||
qb.andWhere('session.status = :status', { status: AssessmentStatus.COMPLETED });
|
||||
|
||||
if (!isAdmin) {
|
||||
qb.andWhere('session.userId = :userId', { userId });
|
||||
}
|
||||
if (templateId) {
|
||||
qb.andWhere('session.templateId = :templateId', { templateId });
|
||||
}
|
||||
|
||||
const sessions = await qb.take(100).getMany();
|
||||
|
||||
const dimensionScores: Record<string, number[]> = {
|
||||
PROMPT: [],
|
||||
LLM: [],
|
||||
IDE: [],
|
||||
DEV_PATTERN: [],
|
||||
WORK_CAPABILITY: [],
|
||||
};
|
||||
|
||||
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 radarData: Record<string, number> = {};
|
||||
for (const [dim, scores] of Object.entries(dimensionScores)) {
|
||||
if (scores.length > 0) {
|
||||
radarData[dim] = Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10;
|
||||
} else {
|
||||
radarData[dim] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { radarData, sampleCount: sessions.length };
|
||||
}
|
||||
|
||||
async getTrendStats(userId: string, tenantId: string, role: string, startDate?: string, endDate?: string): Promise<any> {
|
||||
const isAdmin = role === 'super_admin' || role === 'admin';
|
||||
|
||||
const qb = this.sessionRepository.createQueryBuilder('session');
|
||||
qb.where('session.tenantId = :tenantId', { tenantId });
|
||||
qb.andWhere('session.status = :status', { status: AssessmentStatus.COMPLETED });
|
||||
|
||||
if (!isAdmin) {
|
||||
qb.andWhere('session.userId = :userId', { userId });
|
||||
}
|
||||
if (startDate) {
|
||||
qb.andWhere('session.createdAt >= :startDate', { startDate: new Date(startDate) });
|
||||
}
|
||||
if (endDate) {
|
||||
qb.andWhere('session.createdAt <= :endDate', { endDate: new Date(endDate) });
|
||||
}
|
||||
|
||||
const sessions = await qb
|
||||
.orderBy('session.createdAt', 'ASC')
|
||||
.take(50)
|
||||
.getMany();
|
||||
|
||||
const trendData = sessions.map(session => ({
|
||||
date: session.createdAt,
|
||||
score: session.finalScore || 0,
|
||||
template: session.template?.name || '-',
|
||||
}));
|
||||
|
||||
return { trendData, count: sessions.length };
|
||||
}
|
||||
|
||||
async reviewAssessment(
|
||||
sessionId: string,
|
||||
newScore: number,
|
||||
@@ -1477,6 +1557,8 @@ const initialState: Partial<EvaluationState> = {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1515,4 +1597,140 @@ const initialState: Partial<EvaluationState> = {
|
||||
this.logger.log(`[cleanupOldSessions] Deleted ${toDelete.length} old sessions for user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async checkTimeLimits(sessionId: string): Promise<{
|
||||
totalTimeRemaining: number;
|
||||
questionTimeRemaining: number;
|
||||
isTotalTimeout: boolean;
|
||||
isQuestionTimeout: boolean;
|
||||
}> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
|
||||
if (!session || session.status === AssessmentStatus.COMPLETED) {
|
||||
return {
|
||||
totalTimeRemaining: 0,
|
||||
questionTimeRemaining: 0,
|
||||
isTotalTimeout: true,
|
||||
isQuestionTimeout: true,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const totalTimeRemaining = Math.max(0, session.totalTimeLimit - totalElapsed);
|
||||
const questionTimeRemaining = Math.max(0, session.perQuestionTimeLimit - questionElapsed);
|
||||
|
||||
return {
|
||||
totalTimeRemaining,
|
||||
questionTimeRemaining,
|
||||
isTotalTimeout: totalElapsed >= session.totalTimeLimit,
|
||||
isQuestionTimeout: questionElapsed >= session.perQuestionTimeLimit,
|
||||
};
|
||||
}
|
||||
|
||||
async updateQuestionStartTime(sessionId: string): Promise<void> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
if (session) {
|
||||
session.currentQuestionStartedAt = new Date();
|
||||
await this.sessionRepository.save(session);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCertificate(certificateId: string): Promise<{
|
||||
valid: boolean;
|
||||
certificate?: {
|
||||
id: string;
|
||||
level: string;
|
||||
totalScore: number;
|
||||
passed: boolean;
|
||||
issuedAt: Date;
|
||||
userId: string;
|
||||
};
|
||||
message?: string;
|
||||
}> {
|
||||
const certificate = await this.certificateRepository.findOne({
|
||||
where: { id: certificateId },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!certificate) {
|
||||
return { valid: false, message: 'Certificate not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
certificate: {
|
||||
id: certificate.id,
|
||||
level: certificate.level,
|
||||
totalScore: certificate.totalScore,
|
||||
passed: certificate.passed,
|
||||
issuedAt: certificate.issuedAt,
|
||||
userId: certificate.userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getPublicCertificateInfo(sessionId: string): Promise<{
|
||||
exists: boolean;
|
||||
certificate?: {
|
||||
level: string;
|
||||
totalScore: number;
|
||||
passed: boolean;
|
||||
issuedAt: Date;
|
||||
dimensionScores: Record<string, number>;
|
||||
};
|
||||
message?: string;
|
||||
}> {
|
||||
const certificate = await this.certificateRepository.findOne({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (!certificate) {
|
||||
return { exists: false, message: 'Certificate not found for this session' };
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
certificate: {
|
||||
level: certificate.level,
|
||||
totalScore: certificate.totalScore,
|
||||
passed: certificate.passed,
|
||||
issuedAt: certificate.issuedAt,
|
||||
dimensionScores: certificate.dimensionScores || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async forceEndAssessment(sessionId: string): Promise<AssessmentSession> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new NotFoundException('Assessment session not found');
|
||||
}
|
||||
|
||||
if (session.status === AssessmentStatus.COMPLETED) {
|
||||
return session;
|
||||
}
|
||||
|
||||
session.status = AssessmentStatus.COMPLETED;
|
||||
session.finalReport = '评估已被管理员强制结束';
|
||||
session.finalScore = 0;
|
||||
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
this.logger.log(`[forceEndAssessment] Session ${sessionId} force ended by admin`);
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class AssessmentAnswer {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'question_id' })
|
||||
@Column({ name: 'question_id', type: 'text' })
|
||||
questionId: string;
|
||||
|
||||
@ManyToOne(
|
||||
|
||||
@@ -13,26 +13,26 @@ export class AssessmentCertificate {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
@Column({ name: 'user_id', type: 'text' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@Column({ name: 'session_id' })
|
||||
@Column({ name: 'session_id', type: 'text' })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ name: 'template_id' })
|
||||
@Column({ name: 'template_id', type: 'text' })
|
||||
templateId: string;
|
||||
|
||||
@Column()
|
||||
@Column({ type: 'text' })
|
||||
level: string;
|
||||
|
||||
@Column({ type: 'float', name: 'total_score' })
|
||||
totalScore: number;
|
||||
|
||||
@Column({ name: 'qr_code', nullable: true })
|
||||
@Column({ name: 'qr_code', nullable: true, type: 'text' })
|
||||
qrCode: string;
|
||||
|
||||
@Column({ name: 'dimension_scores', type: 'simple-json', nullable: true })
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AssessmentQuestion {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'session_id' })
|
||||
@Column({ name: 'session_id', type: 'text' })
|
||||
sessionId: string;
|
||||
|
||||
@ManyToOne(
|
||||
|
||||
@@ -24,31 +24,31 @@ export class AssessmentSession {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
@Column({ name: 'user_id', type: 'text' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@Column({ name: 'tenant_id', nullable: true })
|
||||
@Column({ name: 'tenant_id', nullable: true, type: 'text' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'knowledge_base_id', nullable: true })
|
||||
@Column({ name: 'knowledge_base_id', nullable: true, type: 'text' })
|
||||
knowledgeBaseId: string | null;
|
||||
|
||||
@ManyToOne(() => KnowledgeBase, { nullable: true })
|
||||
@JoinColumn({ name: 'knowledge_base_id' })
|
||||
knowledgeBase: KnowledgeBase;
|
||||
|
||||
@Column({ name: 'knowledge_group_id', nullable: true })
|
||||
@Column({ name: 'knowledge_group_id', nullable: true, type: 'text' })
|
||||
knowledgeGroupId: string | null;
|
||||
|
||||
@ManyToOne(() => KnowledgeGroup, { nullable: true })
|
||||
@JoinColumn({ name: 'knowledge_group_id' })
|
||||
knowledgeGroup: KnowledgeGroup;
|
||||
|
||||
@Column({ name: 'thread_id', nullable: true })
|
||||
@Column({ name: 'thread_id', nullable: true, type: 'text' })
|
||||
threadId: string;
|
||||
|
||||
@Column({
|
||||
@@ -85,6 +85,18 @@ export class AssessmentSession {
|
||||
@Column({ type: 'simple-json', name: 'review_history', nullable: true })
|
||||
reviewHistory: any[];
|
||||
|
||||
@Column({ name: 'started_at', nullable: true, type: 'datetime' })
|
||||
startedAt: Date | null;
|
||||
|
||||
@Column({ name: 'current_question_started_at', nullable: true, type: 'datetime' })
|
||||
currentQuestionStartedAt: Date | null;
|
||||
|
||||
@Column({ type: 'int', name: 'total_time_limit', default: 1800 })
|
||||
totalTimeLimit: number;
|
||||
|
||||
@Column({ type: 'int', name: 'per_question_time_limit', default: 300 })
|
||||
perQuestionTimeLimit: number;
|
||||
|
||||
@Column({ type: 'int', name: 'current_question_index', default: 0 })
|
||||
currentQuestionIndex: number;
|
||||
|
||||
@@ -97,7 +109,7 @@ export class AssessmentSession {
|
||||
@Column({ type: 'varchar', length: 10, default: 'zh' })
|
||||
language: string;
|
||||
|
||||
@Column({ name: 'template_id', nullable: true })
|
||||
@Column({ name: 'template_id', nullable: true, type: 'text' })
|
||||
templateId: string;
|
||||
|
||||
@ManyToOne(() => AssessmentTemplate, { nullable: true })
|
||||
|
||||
@@ -97,6 +97,12 @@ export class AssessmentTemplate {
|
||||
@Column({ type: 'int', name: 'passing_score', default: 90 })
|
||||
passingScore: number;
|
||||
|
||||
@Column({ type: 'int', name: 'total_time_limit', default: 1800 })
|
||||
totalTimeLimit: number;
|
||||
|
||||
@Column({ type: 'int', name: 'per_question_time_limit', default: 300 })
|
||||
perQuestionTimeLimit: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ import { reportAnalyzerNode } from './nodes/analyzer.node';
|
||||
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);
|
||||
|
||||
console.log('[Router] Evaluation Result:', {
|
||||
currentIndex: state.currentQuestionIndex,
|
||||
currentIndex,
|
||||
shouldFollowUp: state.shouldFollowUp,
|
||||
numQuestions: questionsLen,
|
||||
targetCount,
|
||||
@@ -24,9 +25,9 @@ const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
|
||||
return 'interviewer';
|
||||
}
|
||||
|
||||
if (state.currentQuestionIndex < targetCount) {
|
||||
if (currentIndex < targetCount) {
|
||||
// If the next question isn't generated yet, go back to generator
|
||||
if (state.currentQuestionIndex >= questionsLen) {
|
||||
if (currentIndex >= questionsLen) {
|
||||
console.log('[Router] Index >= Questions, routing to generator');
|
||||
return 'generator';
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ export const questionGeneratorNode = async (
|
||||
.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');
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import * as XLSX from 'xlsx';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssessmentSession)
|
||||
private sessionRepository: Repository<AssessmentSession>,
|
||||
@InjectRepository(AssessmentQuestion)
|
||||
private questionRepository: Repository<AssessmentQuestion>,
|
||||
@InjectRepository(AssessmentAnswer)
|
||||
private answerRepository: Repository<AssessmentAnswer>,
|
||||
@InjectRepository(AssessmentCertificate)
|
||||
private certificateRepository: Repository<AssessmentCertificate>,
|
||||
) {}
|
||||
|
||||
async exportToExcel(sessionId: string): Promise<Buffer> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
relations: ['template', 'user'],
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const questions = await this.questionRepository.find({
|
||||
where: { sessionId },
|
||||
order: { order: 'ASC' },
|
||||
});
|
||||
|
||||
const answers = await this.answerRepository.find({
|
||||
where: { questionId: In(questions.map((q) => q.id)) },
|
||||
});
|
||||
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a]));
|
||||
|
||||
const summarySheet = [
|
||||
['评估报告'],
|
||||
['评估ID', session.id],
|
||||
['用户', session.user?.name || session.userId],
|
||||
['状态', session.status],
|
||||
['最终分数', session.finalScore || '-'],
|
||||
['原始分数', session.originalScore || '-'],
|
||||
['评估模板', session.template?.name || session.templateJson?.name || '-'],
|
||||
['开始时间', session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'],
|
||||
['完成时间', session.updatedAt ? new Date(session.updatedAt).toLocaleString() : '-'],
|
||||
['总用时(秒)', session.totalTimeLimit],
|
||||
[''],
|
||||
['维度分数'],
|
||||
...this.extractDimensionScores(session),
|
||||
];
|
||||
|
||||
const questionRows = [
|
||||
['题号', '题目内容', '用户回答', '分数', '反馈', '是否追问'],
|
||||
];
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const a = answerMap.get(q.id);
|
||||
questionRows.push([
|
||||
(i + 1).toString(),
|
||||
q.content || q.questionText || '',
|
||||
a?.userAnswer || '',
|
||||
a?.score?.toString() || '',
|
||||
a?.feedback || '',
|
||||
a?.isFollowUp ? '是' : '否',
|
||||
]);
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
const summaryWs = XLSX.utils.aoa_to_sheet(summarySheet);
|
||||
XLSX.utils.book_append_sheet(wb, summaryWs, '摘要');
|
||||
|
||||
const questionWs = XLSX.utils.aoa_to_sheet(questionRows);
|
||||
XLSX.utils.book_append_sheet(wb, questionWs, '题目详情');
|
||||
|
||||
if (session.finalReport) {
|
||||
const reportSheet = this.wrapTextToLines(session.finalReport, 80);
|
||||
const reportWs = XLSX.utils.aoa_to_sheet(reportSheet);
|
||||
XLSX.utils.book_append_sheet(wb, reportWs, '评估报告');
|
||||
}
|
||||
|
||||
const buffer = XLSX.write(wb, { bookType: 'xlsx', type: 'buffer' });
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private extractDimensionScores(session: AssessmentSession): any[][] {
|
||||
const scores = session.templateJson?.dimensionScores || session.finalReport;
|
||||
if (!scores) return [['未找到维度分数']];
|
||||
|
||||
if (typeof scores === 'string') {
|
||||
return [['维度分数', scores]];
|
||||
}
|
||||
|
||||
return Object.entries(scores).map(([key, value]) => [key, value]);
|
||||
}
|
||||
|
||||
private wrapTextToLines(text: string, maxWidth: number): string[][] {
|
||||
const lines: string[] = [];
|
||||
const paragraphs = text.split('\n');
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.trim() === '') {
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const words = paragraph.split(' ');
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
if ((currentLine + ' ' + word).trim().length <= maxWidth) {
|
||||
currentLine = (currentLine + ' ' + word).trim();
|
||||
} else {
|
||||
if (currentLine) lines.push([currentLine]);
|
||||
currentLine = word;
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push([currentLine]);
|
||||
}
|
||||
|
||||
return lines.map((l) => [l]);
|
||||
}
|
||||
|
||||
async exportToPdf(sessionId: string): Promise<Buffer> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
relations: ['template', 'user'],
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
const certificate = await this.certificateRepository.findOne({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
const questions = await this.questionRepository.find({
|
||||
where: { sessionId },
|
||||
order: { order: 'ASC' },
|
||||
});
|
||||
|
||||
const answers = await this.answerRepository.find({
|
||||
where: { questionId: In(questions.map((q) => q.id)) },
|
||||
});
|
||||
|
||||
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?.name || 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.content || 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');
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,11 @@ export class QuestionBankService {
|
||||
if (bank.status === QuestionBankStatus.PUBLISHED) {
|
||||
return bank;
|
||||
}
|
||||
if (bank.status !== QuestionBankStatus.PUBLISHED && bank.status !== QuestionBankStatus.REJECTED) {
|
||||
throw new ForbiddenException(
|
||||
'Only PUBLISHED or REJECTED status can be re-published',
|
||||
);
|
||||
}
|
||||
bank.status = QuestionBankStatus.PUBLISHED;
|
||||
this.logger.log(`QuestionBank ${id} published`);
|
||||
return this.bankRepository.save(bank);
|
||||
@@ -254,6 +259,15 @@ export class QuestionBankService {
|
||||
tenantId: string,
|
||||
): Promise<QuestionBankItem[]> {
|
||||
const bank = await this.findOne(bankId);
|
||||
|
||||
if (count <= 0 || count > 50) {
|
||||
throw new Error('生成数量必须在 1-50 之间');
|
||||
}
|
||||
|
||||
if (!knowledgeBaseContent || knowledgeBaseContent.trim().length < 10) {
|
||||
throw new Error('知识库内容太短,无法生成有效题目');
|
||||
}
|
||||
|
||||
this.logger.log(`[generateQuestions] Starting AI generation for bank ${bankId}, count: ${count}`);
|
||||
|
||||
const modelConfig = await this.modelConfigService.findDefaultByType(
|
||||
@@ -338,11 +352,13 @@ export class QuestionBankService {
|
||||
basis: q.basis,
|
||||
status: QuestionBankItemStatus.PENDING_REVIEW,
|
||||
});
|
||||
items.push(await this.itemRepository.save(item));
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
this.logger.log(`[generateQuestions] Generated ${items.length} questions for bank ${bankId}`);
|
||||
return items;
|
||||
const savedItems = await this.itemRepository.save(items);
|
||||
|
||||
this.logger.log(`[generateQuestions] Generated ${savedItems.length} questions for bank ${bankId}`);
|
||||
return savedItems;
|
||||
} catch (error) {
|
||||
this.logger.error('[generateQuestions] Error generating questions:', error);
|
||||
throw error;
|
||||
@@ -371,15 +387,14 @@ export class QuestionBankService {
|
||||
|
||||
const usedIds = new Set<string>();
|
||||
const selected: QuestionBankItem[] = [];
|
||||
const availableItems = [...allItems];
|
||||
|
||||
let dimIdx = 0;
|
||||
while (selected.length < count && usedIds.size < allItems.length) {
|
||||
while (selected.length < count && availableItems.length > 0) {
|
||||
const dim = DIMENSIONS[dimIdx % DIMENSIONS.length];
|
||||
dimIdx++;
|
||||
|
||||
if (selected.length >= count) break;
|
||||
|
||||
const available = allItems.filter(
|
||||
const available = availableItems.filter(
|
||||
(i) => i.dimension === dim && !usedIds.has(i.id),
|
||||
);
|
||||
|
||||
@@ -388,7 +403,27 @@ export class QuestionBankService {
|
||||
const item = available[idx];
|
||||
selected.push(item);
|
||||
usedIds.add(item.id);
|
||||
const actualIdx = availableItems.findIndex(i => i.id === item.id);
|
||||
if (actualIdx > -1) {
|
||||
availableItems.splice(actualIdx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (dimIdx >= DIMENSIONS.length * 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length < count && availableItems.length > 0) {
|
||||
const shuffled = this.shuffleArray([...availableItems]);
|
||||
for (const item of shuffled) {
|
||||
if (selected.length >= count) break;
|
||||
if (!usedIds.has(item.id)) {
|
||||
selected.push(item);
|
||||
usedIds.add(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length < count) {
|
||||
@@ -440,4 +475,13 @@ export class QuestionBankService {
|
||||
this.logger.log(`[batchReview] ${items.length} items ${approved ? 'approved' : 'rejected'}`);
|
||||
return items;
|
||||
}
|
||||
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const result = [...array];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user