fix: 代码整合修复 - Entity类型、题库生成、评估流程等14项修复

This commit is contained in:
Developer
2026-05-14 09:55:07 +08:00
parent 122ab5e96f
commit 368eddfd75
17 changed files with 1666 additions and 115 deletions
+104 -11
View File
@@ -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'),
};
}
}
+3 -1
View File
@@ -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 {}
+218
View File
@@ -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;
+4 -3
View File
@@ -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;
}
}