Compare commits

...

37 Commits

Author SHA1 Message Date
Developer 6e569ff478 fix: skip content check when bank questions available, early generator return 2026-05-23 22:32:51 +08:00
Developer a83de861dd fix: replace PDF with HTML report (fontkit unavailable) 2026-05-21 16:30:36 +08:00
Developer 0b0da09d4b fix: use pdf-lib embedFont with proper pagination for CJK PDF 2026-05-21 16:12:38 +08:00
Developer d7cd5641d7 fix: rewrite PDF generator using pdf-lib native embedFont for CJK support 2026-05-21 15:59:01 +08:00
Developer c53f26a07e perf: trim grader prompts ~40% to reduce LLM latency 2026-05-21 15:52:33 +08:00
Developer b15e821252 feat: enriched certificate with template name, dimension scores, question details + Modal UI
- generateCertificate: return templateName, questionDetails, dimensionScores
- Frontend: replace alert() with certificate Modal showing level, scores, dimensions, questions
- Status label: change from '已验证' to '合格'
2026-05-21 15:42:59 +08:00
Developer 990b8c7b83 fix: forward passed flag in SSE final events 2026-05-21 15:24:36 +08:00
Developer f8df92c36b fix: forward finalScore in submitAnswerStream final event 2026-05-21 15:19:10 +08:00
Developer 51f2a41cc3 fix: determineLevel uses 0-10 scale thresholds instead of 0-100 2026-05-21 15:07:23 +08:00
Developer 0a3a8a2e32 fix: send accumulated answers to LLM grader for follow-up context
- Grader now passes all rounds of user answers to LLM (tagged 第N轮回答)
- LLM can see what was already answered and avoid redundant follow-ups
- Updated all three language prompts with multi-round guidance
2026-05-21 14:41:57 +08:00
Developer 9303d7ac64 fix: auto-submit answer on timeout instead of blocking
- Timeout triggers forced submission of current answer (or empty)
- Prevents assessment from hanging when time expires
- autoSubmitted flag prevents duplicate submissions
2026-05-21 14:32:01 +08:00
Developer 02f4ab23f7 feat: LLM-generated adaptive follow-up questions
- Grader: LLM outputs follow_up_question targeting uncovered keyPoints
- Remove static followupHints usage in grading flow
- maxFollowUps sourced from question.maxFollowUps (hints.length)
- Clean answerKey: remove followupHints field
- Three-language prompt update with examples and bad examples
- Grader spec: add follow_up_question to mock responses
2026-05-21 14:18:14 +08:00
Developer 7fd2a4cda2 fix: option display + partial credit grading
- Option display: use slice(1) instead of regex to strip letter prefix
- Grader prompts: add explicit partial credit guidance (5-7 for partial, 0-2 only for off-target)
2026-05-21 13:13:21 +08:00
Developer 7b1103903f fix: remove prefix from followup hint, use raw text 2026-05-21 13:00:03 +08:00
Developer 3cc3b28471 fix: broader regex to strip conditional prefix from followup hints 2026-05-21 12:57:04 +08:00
Developer 5c82c75a09 fix: strip option letter prefix in QuestionBankDetailView
Consistent with AssessmentView, now strips A./B./C./D. prefix
from option text before displaying alongside letter badge.
2026-05-21 12:48:35 +08:00
Developer 24ffc028e2 fix: shuffle choice options per session + clean followup hints
- Options shuffled with correctAnswer remapped at session creation
- Followup hints strip conditional prefix (如果只说了XX,追问:)
2026-05-21 12:42:52 +08:00
Developer 734c0129d8 chore: remove test artifact 2026-05-21 11:53:47 +08:00
Developer 1224a74e63 fix: natural follow-up conversation flow
- Grader: separate followup hint from scoring feedback
- Interviewer: use followup hint directly without prefix/suffix
- Restored standard and choice question presentation paths
2026-05-21 11:53:24 +08:00
Developer c015ea3697 fix: shuffle bank questions + grader LLM error resilience
- selectQuestions: shuffle final result for random question order
- grader: wrap LLM invoke in try-catch, default score 5 on failure
- grader: inner try-catch for JSON parse errors, graceful fallback
2026-05-21 11:33:17 +08:00
Developer 240aea24aa fix: linkedGroupIds null check in validateRequiredFields
null !== undefined was true, causing false validation failure on templates
without linked groups. Changed to != null check.
2026-05-21 11:17:45 +08:00
Developer 54762ca299 fix: passingScore scaling and dimensions propagation
- Frontend: divide by 10 on load, multiply by 10 on send (UI:0-10, DB:0-100)
- Backend: include template dimensions in session templateData snapshot
2026-05-21 11:07:07 +08:00
Developer eba30517a6 fix: remove bank PUBLISHED guard from selectQuestions
selectQuestions now only checks item-level PUBLISHED status.
startSession already handles bank detection by counting published items.
This fixes assessment always falling back to LLM generation.
2026-05-21 10:26:19 +08:00
Developer 35b1c6c37d feat: judgment-anchored grading and per-question results
- Grader: inject judgment as pass criteria anchor in LLM prompt
- Grader: use followupHints for follow-up direction (not generic text)
- Grader: follow-up limit from followupHints.length instead of hardcoded 2
- Session: correctAnswer/judgment stored in questions, stripped during assessment
- Frontend: per-question results panel with choice / + judgment display
2026-05-21 10:18:15 +08:00
Developer 3993099907 feat: end-to-end choice question support in assessment pipeline
- Data pathway: flow options through questions, answerKey in graph state
- Interviewer: format MULTIPLE_CHOICE with A/B/C/D options
- Grader: instant choice scoring (zero LLM), compare correctAnswer
- AssessmentView: render choice buttons vs textarea based on questionType
- Security: sanitizeStateForClient strips correctAnswer/judgment/answerKey
- Bank detection: check PUBLISHED items (not PUBLISHED bank status)
- Batch UI: select all / batch approve / batch reject on detail view
2026-05-21 10:06:33 +08:00
Developer 57898f939c fix: add status guards to prevent data loss
- create: auto-delete REJECTED→throw error; add tenantId filter
- remove: forbid PUBLISHED bank deletion
- removeItem: forbid PUBLISHED item deletion
- generateQuestions: restrict to DRAFT status only
- frontend: render MULTIPLE_CHOICE options/judgment/followupHints
- frontend: add judgment and followupHints to QuestionBankItem type
- add 12 service guard tests (109 total)
2026-05-21 08:55:35 +08:00
Developer e782d180d7 feat: support choice+open dual question generation with judgment anchors
- Add judgment and followupHints fields to QuestionBankItem entity
- Rewrite generateQuestions prompt for 3:7 choice:open ratio
- Extract parseGeneratedQuestion function with type-aware parsing
- Add 29 unit tests: 14 prompt content + 15 parse logic
- Total: 97 tests passing (59 baseline + 38 new)
2026-05-21 01:04:08 +08:00
Developer 17ddfa83bf Question generation: scenario-based 3-step prompt with technique labeling, key_points constrained to KB source, temperature 0.1. Generator node: two-step extraction prompt for assessment flow. 2026-05-20 17:33:28 +08:00
Developer 83483d8117 F1-F10: audit fixes (dimension normalize, passingScore scale, DB defaults, onDelete, item status filter, timeout event type, userId privacy) + generator.node.ts strict prompt rules (anti-hallucination) 2026-05-20 11:13:37 +08:00
Developer 29bac74b58 M3: console.log -> Logger + UI redesign (QuestionBank) + S7/A9/A10/A11/U11 bug fixes + #1/#2/#3/#4 enhancements + i18n for QuestionBank pages 2026-05-19 16:57:45 +08:00
Developer 5b5f14674d fix: minor issues from code review
(M1) DTO: @IsObject({ each: true }) on dimensions array
(M2) audit log: add missing tenantId in submitAnswer
(M3) console.log -> this.logger in controller + service
2026-05-19 10:22:18 +08:00
Developer 82a9e75842 fix: code review — 7 issues resolved
(C1) Add dimensionScores/radarData/passed columns to AssessmentSession
(C2) Mock DataSource in service.spec.ts + app.e2e-spec.ts
(C3) Mock AuditLogService in controller.spec.ts
(C4) Rewrite deleteSession tests for dataSource.transaction
(I1) batchDeleteSessions uses transaction with certificate cleanup
(I2) extractDimensionScores reads from session property
(I3/I5) PDF generator supports multi-page + newline splitting
(I4) findOne inside transaction uses deleteCondition
2026-05-19 10:06:30 +08:00
Developer 7f8e7214b3 P3-02-03-04: audit log, batch ops, transactions
P3-02: audit-log.entity + service, manual logging in controller
  (startSession, submitAnswer, deleteSession, review, forceEnd)
P3-03: POST batch-delete, POST batch-export endpoints + service methods
P3-04: DataSource.transaction for deleteSession + reviewAssessment,
  graph state cleanup on session delete
2026-05-19 09:52:31 +08:00
Developer eb0798de5b P2-1: remove dead cost-control module (3 files)
P2-2: switch TypeORM to autoLoadEntities: true

Remove unused vision-pipeline-cost-aware.service.ts,
cost-control.service.ts and its orphan module.
Switch explicit entities[] list to autoLoadEntities.
2026-05-19 09:39:41 +08:00
Developer 33e48f6d4e P1-3: grader/interviewer node unit tests (24 passing)
grader.node.spec.ts — 13 tests: LLM mock validation, breakout logic
(shorts/IDontKnow), error handling, scoring/indexing, zh/ja language support

interviewer.node.spec.ts — 11 tests: empty questions, index bounds,
standard presentation, follow-up mode, zh/ja/en localization
2026-05-19 09:30:19 +08:00
Developer b139ae18b7 P1-2: certificate E2E integration tests + API verification
- Certificate lifecycle tests: create/verify/idempotency/level
- Public endpoint integration tests for verifyCertificate and getPublicCertificateInfo
- API verified: /public returns 200, /verify returns 200, auth endpoint returns 404 for missing
2026-05-19 09:26:34 +08:00
Developer 68371922ca P0-1/P0-2/P1-1: dimensions form + E2E tests + PDF export
P0-1 Backend: dimensions column on template entity + validation
P0-1 Frontend: dimensions edit UI in TemplateManager
P0-2: routeAfterGrading unit tests (10 cases), service spec fix + certificate tests, jest-e2e.json
P1-1: proper PDF generation with embedded CJK font via pdf-lib low-level API
2026-05-19 08:42:03 +08:00
57 changed files with 3792 additions and 1664 deletions
+4 -2
View File
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai'; import { ChatOpenAI } from '@langchain/openai';
import { ModelConfig } from '../types'; import { ModelConfig } from '../types';
import { I18nService } from '../i18n/i18n.service'; import { I18nService } from '../i18n/i18n.service';
@Injectable() @Injectable()
export class ApiService { export class ApiService {
private readonly logger = new Logger(ApiService.name);
constructor(private i18nService: I18nService) {} constructor(private i18nService: I18nService) {}
// Simple health check method // Simple health check method
@@ -23,7 +25,7 @@ export class ApiService {
const response = await llm.invoke(prompt); const response = await llm.invoke(prompt);
return response.content.toString(); return response.content.toString();
} catch (error) { } catch (error) {
console.error('LangChain call failed:', error); this.logger.error('LangChain call failed:', error);
if (error.message?.includes('401')) { if (error.message?.includes('401')) {
throw new Error(this.i18nService.getMessage('invalidApiKey')); throw new Error(this.i18nService.getMessage('invalidApiKey'));
} }
+3 -51
View File
@@ -31,34 +31,11 @@ import { ImportTaskModule } from './import-task/import-task.module';
import { AssessmentModule } from './assessment/assessment.module'; import { AssessmentModule } from './assessment/assessment.module';
import { I18nMiddleware } from './i18n/i18n.middleware'; import { I18nMiddleware } from './i18n/i18n.middleware';
import { TenantMiddleware } from './tenant/tenant.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 { TenantModule } from './tenant/tenant.module';
import { SuperAdminModule } from './super-admin/super-admin.module'; import { SuperAdminModule } from './super-admin/super-admin.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { FeishuModule } from './feishu/feishu.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({ @Module({
imports: [ imports: [
@@ -77,33 +54,8 @@ import { AssessmentCertificate } from './assessment/entities/assessment-certific
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
type: 'better-sqlite3', type: 'better-sqlite3',
database: configService.get<string>('DATABASE_PATH'), database: configService.get<string>('DATABASE_PATH'),
entities: [ autoLoadEntities: true,
User, synchronize: true,
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.
}), }),
}), }),
AuthModule, AuthModule,
@@ -5,6 +5,8 @@ import { AssessmentService } from './assessment.service';
import { TenantService } from '../tenant/tenant.service'; import { TenantService } from '../tenant/tenant.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard'; import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { ExportService } from './services/export.service';
import { AuditLogService } from './services/audit-log.service';
describe('AssessmentController', () => { describe('AssessmentController', () => {
let controller: AssessmentController; let controller: AssessmentController;
@@ -23,8 +25,10 @@ describe('AssessmentController', () => {
controllers: [AssessmentController], controllers: [AssessmentController],
providers: [ providers: [
{ provide: AssessmentService, useFactory: mockService }, { provide: AssessmentService, useFactory: mockService },
{ provide: 'UserService', useFactory: mockService }, { provide: UserService, useFactory: mockService },
{ provide: TenantService, useFactory: mockService }, { provide: TenantService, useFactory: mockService },
{ provide: ExportService, useFactory: mockService },
{ provide: AuditLogService, useFactory: () => ({ log: jest.fn() }) },
{ provide: Reflector, useFactory: mockReflector }, { provide: Reflector, useFactory: mockReflector },
{ provide: CombinedAuthGuard, useFactory: mockGuard }, { provide: CombinedAuthGuard, useFactory: mockGuard },
], ],
+61 -26
View File
@@ -13,10 +13,12 @@ import {
Delete, Delete,
Put, Put,
ForbiddenException, ForbiddenException,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AssessmentService } from './assessment.service'; import { AssessmentService } from './assessment.service';
import { ExportService } from './services/export.service'; import { ExportService } from './services/export.service';
import { AuditLogService } from './services/audit-log.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard'; import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { Public } from '../auth/public.decorator'; import { Public } from '../auth/public.decorator';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@@ -25,9 +27,12 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@Controller('assessment') @Controller('assessment')
@UseGuards(CombinedAuthGuard) @UseGuards(CombinedAuthGuard)
export class AssessmentController { export class AssessmentController {
private readonly logger = new Logger(AssessmentController.name);
constructor( constructor(
private readonly assessmentService: AssessmentService, private readonly assessmentService: AssessmentService,
private readonly exportService: ExportService, private readonly exportService: ExportService,
private readonly auditLog: AuditLogService,
) {} ) {}
@Post('start') @Post('start')
@@ -38,16 +43,18 @@ export class AssessmentController {
body: { knowledgeBaseId?: string; language?: string; templateId?: string }, body: { knowledgeBaseId?: string; language?: string; templateId?: string },
) { ) {
const { id: userId, tenantId } = req.user; const { id: userId, tenantId } = req.user;
console.log( this.logger.log(
`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`, `startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
); );
return this.assessmentService.startSession( const session = await this.assessmentService.startSession(
userId, userId,
body.knowledgeBaseId, body.knowledgeBaseId,
tenantId, tenantId,
body.language, body.language,
body.templateId, body.templateId,
); );
this.auditLog.log({ userId, tenantId, action: 'session.start', resourceType: 'assessment_session', resourceId: session.id });
return session;
} }
@Post(':id/answer') @Post(':id/answer')
@@ -57,24 +64,26 @@ export class AssessmentController {
@Param('id') sessionId: string, @Param('id') sessionId: string,
@Body() body: { answer: string; language?: string }, @Body() body: { answer: string; language?: string },
) { ) {
const { id: userId } = req.user; const { id: userId, tenantId } = req.user;
console.log( this.logger.log(
`[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`, `submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
); );
return this.assessmentService.submitAnswer( const result = await this.assessmentService.submitAnswer(
sessionId, sessionId,
userId, userId,
body.answer, body.answer,
body.language, 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') @Sse(':id/start-stream')
@ApiOperation({ summary: 'Stream initial session generation' }) @ApiOperation({ summary: 'Stream initial session generation' })
startSessionStream(@Request() req: any, @Param('id') sessionId: string) { startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
const { id: userId } = req.user; const { id: userId } = req.user;
console.log( this.logger.log(
`[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`, `startSessionStream: user=${userId}, session=${sessionId}`,
); );
return this.assessmentService return this.assessmentService
.startSessionStream(sessionId, userId) .startSessionStream(sessionId, userId)
@@ -92,8 +101,8 @@ export class AssessmentController {
@Query('language') language?: string, @Query('language') language?: string,
) { ) {
const { id: userId } = req.user; const { id: userId } = req.user;
console.log( this.logger.log(
`[AssessmentController] >>> submitAnswerStream CALLED: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`, `submitAnswerStream: user=${userId}, session=${sessionId}, answerLen=${answer?.length}, lang=${language}`,
); );
return this.assessmentService return this.assessmentService
.submitAnswerStream(sessionId, userId, answer, language) .submitAnswerStream(sessionId, userId, answer, language)
@@ -104,8 +113,8 @@ export class AssessmentController {
@ApiOperation({ summary: 'Get the current state of an assessment session' }) @ApiOperation({ summary: 'Get the current state of an assessment session' })
async getSessionState(@Request() req: any, @Param('id') sessionId: string) { async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
const { id: userId } = req.user; const { id: userId } = req.user;
console.log( this.logger.log(
`[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`, `getSessionState: user=${userId}, session=${sessionId}`,
); );
return this.assessmentService.getSessionState(sessionId, userId); return this.assessmentService.getSessionState(sessionId, userId);
} }
@@ -114,10 +123,12 @@ export class AssessmentController {
@ApiOperation({ summary: 'Delete an assessment session' }) @ApiOperation({ summary: 'Delete an assessment session' })
async deleteSession(@Request() req: any, @Param('id') sessionId: string) { async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
const user = req.user; const user = req.user;
console.log( this.logger.log(
`[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`, `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') @Get(':id/certificate')
@@ -127,8 +138,8 @@ export class AssessmentController {
@Param('id') sessionId: string, @Param('id') sessionId: string,
) { ) {
const { id: userId, tenantId } = req.user; const { id: userId, tenantId } = req.user;
console.log( this.logger.log(
`[AssessmentController] getCertificate: user=${userId}, session=${sessionId}`, `getCertificate: user=${userId}, session=${sessionId}`,
); );
return this.assessmentService.generateCertificate(sessionId, userId, tenantId); return this.assessmentService.generateCertificate(sessionId, userId, tenantId);
} }
@@ -170,8 +181,8 @@ export class AssessmentController {
@Query('knowledgeGroupId') knowledgeGroupId?: string, @Query('knowledgeGroupId') knowledgeGroupId?: string,
) { ) {
const { id: userId, tenantId, role } = req.user; const { id: userId, tenantId, role } = req.user;
console.log( this.logger.log(
`[AssessmentController] getStats: user=${userId}, role=${role}, tenant=${tenantId}`, `getStats: user=${userId}, role=${role}, tenant=${tenantId}`,
); );
return this.assessmentService.getStats( return this.assessmentService.getStats(
userId, 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') @Put(':id/review')
@ApiOperation({ summary: 'Review assessment - adjust final score' }) @ApiOperation({ summary: 'Review assessment - adjust final score' })
async review( async review(
@@ -224,13 +255,15 @@ export class AssessmentController {
@Req() req: any, @Req() req: any,
) { ) {
const { id: userId, tenantId } = req.user; const { id: userId, tenantId } = req.user;
return this.assessmentService.reviewAssessment( const result = await this.assessmentService.reviewAssessment(
sessionId, sessionId,
body.newScore, body.newScore,
body.comment, body.comment,
userId, userId,
tenantId, 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') @Get(':id/time-check')
@@ -252,12 +285,14 @@ export class AssessmentController {
@Param('id') sessionId: string, @Param('id') sessionId: string,
@Request() req: any, @Request() req: any,
) { ) {
const { role } = req.user; const { id: userId, tenantId, role } = req.user;
const isAdmin = role === 'super_admin' || role === 'admin'; const isAdmin = role === 'super_admin' || role === 'admin';
if (!isAdmin) { if (!isAdmin) {
throw new ForbiddenException('Only admin can force end assessment'); 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') @Get(':id/export/excel')
@@ -271,12 +306,12 @@ export class AssessmentController {
} }
@Get(':id/export/pdf') @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) { async exportPdf(@Param('id') sessionId: string) {
const buffer = await this.exportService.exportToPdf(sessionId); const buffer = await this.exportService.exportToPdf(sessionId);
return { return {
filename: `assessment-${sessionId}.txt`, filename: `assessment-${sessionId}.html`,
content: buffer.toString('utf-8'), buffer: buffer.toString('base64'),
}; };
} }
} }
@@ -23,6 +23,8 @@ import { ContentFilterService } from './services/content-filter.service';
import { QuestionOutlineService } from './services/question-outline.service'; import { QuestionOutlineService } from './services/question-outline.service';
import { QuestionBankService } from './services/question-bank.service'; import { QuestionBankService } from './services/question-bank.service';
import { ExportService } from './services/export.service'; import { ExportService } from './services/export.service';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogService } from './services/audit-log.service';
@Module({ @Module({
imports: [ imports: [
@@ -34,6 +36,7 @@ import { ExportService } from './services/export.service';
AssessmentCertificate, AssessmentCertificate,
QuestionBank, QuestionBank,
QuestionBankItem, QuestionBankItem,
AuditLog,
]), ]),
forwardRef(() => KnowledgeBaseModule), forwardRef(() => KnowledgeBaseModule),
forwardRef(() => KnowledgeGroupModule), forwardRef(() => KnowledgeGroupModule),
@@ -51,6 +54,7 @@ import { ExportService } from './services/export.service';
QuestionOutlineService, QuestionOutlineService,
QuestionBankService, QuestionBankService,
ExportService, ExportService,
AuditLogService,
], ],
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService], exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
}) })
@@ -1,10 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { AssessmentService } from './assessment.service'; 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 { AssessmentQuestion } from './entities/assessment-question.entity';
import { AssessmentAnswer } from './entities/assessment-answer.entity'; import { AssessmentAnswer } from './entities/assessment-answer.entity';
import { AssessmentCertificate } from './entities/assessment-certificate.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 { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service'; import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
import { ModelConfigService } from '../model-config/model-config.service'; import { ModelConfigService } from '../model-config/model-config.service';
@@ -22,16 +25,35 @@ import { NotFoundException } from '@nestjs/common';
describe('AssessmentService', () => { describe('AssessmentService', () => {
let service: AssessmentService; let service: AssessmentService;
let sessionRepository: any; let sessionRepository: any;
let certificateRepository: any;
let dataSource: any;
const mockRepository = () => ({ const mockRepository = () => ({
delete: jest.fn(), delete: jest.fn(),
find: jest.fn(), find: jest.fn(),
findOne: jest.fn(), findOne: jest.fn(),
save: jest.fn(), save: jest.fn(),
create: jest.fn(),
}); });
const mockService = () => ({}); 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
@@ -40,6 +62,8 @@ describe('AssessmentService', () => {
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository }, { provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository }, { provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository }, { provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
{ provide: KnowledgeBaseService, useFactory: mockService }, { provide: KnowledgeBaseService, useFactory: mockService },
{ provide: KnowledgeGroupService, useFactory: mockService }, { provide: KnowledgeGroupService, useFactory: mockService },
{ provide: ModelConfigService, useFactory: mockService }, { provide: ModelConfigService, useFactory: mockService },
@@ -52,11 +76,14 @@ describe('AssessmentService', () => {
{ provide: ChatService, useFactory: mockService }, { provide: ChatService, useFactory: mockService },
{ provide: I18nService, useFactory: mockService }, { provide: I18nService, useFactory: mockService },
{ provide: TenantService, useFactory: mockService }, { provide: TenantService, useFactory: mockService },
{ provide: DataSource, useFactory: () => mockDataSource(mockManager()) },
], ],
}).compile(); }).compile();
service = module.get<AssessmentService>(AssessmentService); service = module.get<AssessmentService>(AssessmentService);
sessionRepository = module.get(getRepositoryToken(AssessmentSession)); sessionRepository = module.get(getRepositoryToken(AssessmentSession));
certificateRepository = module.get(getRepositoryToken(AssessmentCertificate));
dataSource = module.get(DataSource);
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -64,15 +91,110 @@ describe('AssessmentService', () => {
}); });
describe('deleteSession', () => { describe('deleteSession', () => {
it('should delete a session if it exists and belongs to the user', async () => { it('should delete a session when non-admin user owns it', async () => {
sessionRepository.delete.mockResolvedValue({ affected: 1 }); const manager = mockManager({
await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow(); findOne: jest.fn().mockResolvedValue({ id: 'session-id', userId: 'user-1' }),
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' }); });
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 () => { it('should delete any session when admin user', async () => {
sessionRepository.delete.mockResolvedValue({ affected: 0 }); const manager = mockManager({
await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException); 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 }),
);
}); });
}); });
}); });
+299 -97
View File
@@ -8,7 +8,7 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DeepPartial, In } from 'typeorm'; import { Repository, DeepPartial, In, DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai'; import { ChatOpenAI } from '@langchain/openai';
import { import {
@@ -27,7 +27,7 @@ import { AssessmentAnswer } from './entities/assessment-answer.entity';
import { AssessmentTemplate } from './entities/assessment-template.entity'; import { AssessmentTemplate } from './entities/assessment-template.entity';
import { AssessmentCertificate } from './entities/assessment-certificate.entity'; import { AssessmentCertificate } from './entities/assessment-certificate.entity';
import { QuestionBank, QuestionBankStatus } from './entities/question-bank.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 { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service'; import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
import { ModelConfigService } from '../model-config/model-config.service'; import { ModelConfigService } from '../model-config/model-config.service';
@@ -78,6 +78,7 @@ export class AssessmentService {
private chatService: ChatService, private chatService: ChatService,
private i18nService: I18nService, private i18nService: I18nService,
private tenantService: TenantService, private tenantService: TenantService,
private dataSource: DataSource,
) {} ) {}
private async getModel(tenantId: string): Promise<ChatOpenAI> { private async getModel(tenantId: string): Promise<ChatOpenAI> {
@@ -136,12 +137,19 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
return result; 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( private calculateScores(
questions: any[], questions: any[],
scores: Record<string, number>, scores: Record<string, number>,
weightConfig: { prompt: number; other: number }, weightConfig: { prompt: number; other: number },
): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } { ): { finalScore: number; dimensionScores: Record<string, number>; radarData: Record<string, number> } {
console.log('[calculateScores] Input:', { this.logger.debug('[calculateScores] Input:', {
questionsCount: questions.length, questionsCount: questions.length,
scores, scores,
weightConfig, weightConfig,
@@ -156,7 +164,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
}; };
questions.forEach((q: any, idx: number) => { 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; const score = scores[q.id || idx.toString()] || 0;
if (dimensionScoresMap[dimension]) { if (dimensionScoresMap[dimension]) {
dimensionScoresMap[dimension].push(score); 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 ? otherDimsWithScores.reduce((sum, dim) => sum + (dimensionAverages[dim] || 0), 0) / otherDimsWithScores.length
: 0; : 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> = {}; const radarData: Record<string, number> = {};
Object.keys(dimensionAverages).forEach(dim => { Object.keys(dimensionAverages).forEach(dim => {
radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10; radarData[dim] = Math.round(dimensionAverages[dim] * 10) / 10;
}); });
console.log('[calculateScores] Result:', { this.logger.debug('[calculateScores] Result:', {
finalScore: Math.round(finalScore * 10) / 10, finalScore: Math.round(finalScore * 10) / 10,
dimensionScores: dimensionAverages, dimensionScores: dimensionAverages,
promptAvg, promptAvg,
@@ -445,7 +461,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
} }
this.logger.debug(`[startSession] isKb: ${isKb}`); this.logger.debug(`[startSession] isKb: ${isKb}`);
const templateData = template const templateData: any = template
? { ? {
name: template.name, name: template.name,
keywords: template.keywords, keywords: template.keywords,
@@ -457,6 +473,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
weightConfig: template.weightConfig, weightConfig: template.weightConfig,
passingScore: template.passingScore, passingScore: template.passingScore,
style: template.style, style: template.style,
dimensions: template.dimensions,
linkedGroupIds: template.linkedGroupIds, linkedGroupIds: template.linkedGroupIds,
} }
: undefined; : undefined;
@@ -467,36 +484,70 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
if (templateId) { if (templateId) {
try { try {
const targetCount = template?.questionCount || 5; const targetCount = template?.questionCount || 5;
const publishedBanks = await this.questionBankRepository.find({ const linkedBanks = await this.questionBankRepository.find({
where: { templateId, status: QuestionBankStatus.PUBLISHED }, where: { templateId },
}); });
if (publishedBanks.length > 0) { if (linkedBanks.length > 0) {
const bankIds = publishedBanks.map(b => b.id); const bankIds = linkedBanks.map(b => b.id);
const questionCount = await this.questionBankItemRepository.count({ const questionCount = await this.questionBankItemRepository.count({
where: { bankId: In(bankIds) }, where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED },
}); });
this.logger.log( 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) { if (questionCount >= targetCount) {
const bankId = publishedBanks[0].id; const bankId = linkedBanks[0].id;
const selectedItems = await this.questionBankService.selectQuestions( const selectedItems = await this.questionBankService.selectQuestions(
bankId, bankId,
targetCount, targetCount,
); );
questionsFromBank = selectedItems.map(item => ({ 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, id: item.id,
questionText: item.questionText, questionText: item.questionText,
questionType: item.questionType, questionType: item.questionType,
options,
correctAnswer,
judgment: item.judgment,
keyPoints: item.keyPoints, keyPoints: item.keyPoints,
difficulty: item.difficulty, difficulty: item.difficulty,
dimension: item.dimension, dimension: item.dimension,
basis: item.basis, 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'; questionSource = 'bank';
this.logger.log( this.logger.log(
@@ -534,6 +585,10 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
perQuestionTimeLimit: template?.perQuestionTimeLimit || 300, perQuestionTimeLimit: template?.perQuestionTimeLimit || 300,
}; };
// Skip content check if questions are loaded from the question bank
const hasBankQuestions = questionsFromBank.length > 0;
if (!hasBankQuestions) {
const content = await this.getSessionContent(sessionData); const content = await this.getSessionContent(sessionData);
if (!content || content.trim().length < 10) { if (!content || content.trim().length < 10) {
@@ -544,6 +599,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
'Selected knowledge source has no sufficient content for evaluation.', 'Selected knowledge source has no sufficient content for evaluation.',
); );
} }
}
const session = this.sessionRepository.create( const session = this.sessionRepository.create(
sessionData as DeepPartial<AssessmentSession>, sessionData as DeepPartial<AssessmentSession>,
@@ -560,7 +616,9 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
`[startSession] Session ${savedSession.id} created and saved`, `[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; return savedSession;
} }
@@ -581,12 +639,14 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
} }
const model = await this.getModel(session.tenantId); const model = await this.getModel(session.tenantId);
const content = await this.getSessionContent(session);
// Check if questions already exist in session (from question bank) // Check if questions already exist in session (from question bank)
const existingQuestions = session.questions_json || []; const existingQuestions = session.questions_json || [];
const hasExistingQuestions = existingQuestions.length > 0; 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 // Check if we already have state
const existingState = await this.graph.getState({ const existingState = await this.graph.getState({
configurable: { thread_id: sessionId }, configurable: { thread_id: sessionId },
@@ -599,7 +659,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
this.logger.log( this.logger.log(
`Session ${sessionId} already has state, skipping generation.`, `Session ${sessionId} already has state, skipping generation.`,
); );
const mappedData = { ...existingState.values }; const mappedData = this.sanitizeStateForClient({ ...existingState.values });
mappedData.messages = this.mapMessages(mappedData.messages || []); mappedData.messages = this.mapMessages(mappedData.messages || []);
mappedData.feedbackHistory = this.mapMessages( mappedData.feedbackHistory = this.mapMessages(
mappedData.feedbackHistory || [], mappedData.feedbackHistory || [],
@@ -621,6 +681,7 @@ const initialState: Partial<EvaluationState> = {
style: session.templateJson?.style, style: session.templateJson?.style,
keywords: session.templateJson?.keywords, keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
currentQuestionIndex: 0, currentQuestionIndex: 0,
}; };
@@ -708,7 +769,7 @@ const initialState: Partial<EvaluationState> = {
const finalData = fullState.values as EvaluationState; const finalData = fullState.values as EvaluationState;
if (finalData && finalData.messages) { if (finalData && finalData.messages) {
console.log( this.logger.debug(
`[AssessmentService] startSessionStream Final Authoritative State messages:`, `[AssessmentService] startSessionStream Final Authoritative State messages:`,
finalData.messages.length, finalData.messages.length,
); );
@@ -726,7 +787,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalData.scores; const scores = finalData.scores;
const questions = finalData.questions || []; const questions = finalData.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; 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) { if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores( const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -742,7 +803,10 @@ const initialState: Partial<EvaluationState> = {
} }
await this.sessionRepository.save(session); 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.messages = this.mapMessages(finalData.messages);
mappedData.feedbackHistory = this.mapMessages( mappedData.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [], finalData.feedbackHistory || [],
@@ -750,6 +814,7 @@ const initialState: Partial<EvaluationState> = {
mappedData.status = session.status; mappedData.status = session.status;
mappedData.report = session.finalReport; mappedData.report = session.finalReport;
mappedData.finalScore = session.finalScore; mappedData.finalScore = session.finalScore;
mappedData.passed = (session as any).passed;
observer.next({ type: 'final', data: mappedData }); observer.next({ type: 'final', data: mappedData });
} }
@@ -776,6 +841,33 @@ const initialState: Partial<EvaluationState> = {
}); });
if (!session) throw new NotFoundException('Session not found'); 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); const model = await this.getModel(session.tenantId);
await this.ensureGraphState(sessionId, session); await this.ensureGraphState(sessionId, session);
const content = await this.getSessionContent(session); const content = await this.getSessionContent(session);
@@ -790,7 +882,7 @@ const initialState: Partial<EvaluationState> = {
let finalResult: any = null; let finalResult: any = null;
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; 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) // Resume from the last interrupt (typically after interviewer)
const stream = await this.graph.stream(null, { const stream = await this.graph.stream(null, {
@@ -843,7 +935,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalResult.scores as Record<string, number>; const scores = finalResult.scores as Record<string, number>;
const questions = finalResult.questions || []; const questions = finalResult.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; 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) { if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores( const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -902,13 +994,13 @@ const initialState: Partial<EvaluationState> = {
answer: string, answer: string,
language: string = 'en', language: string = 'en',
): Observable<any> { ): 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 emittedNextQuestion = false;
let hasEmittedNodes = false; let hasEmittedNodes = false;
return new Observable((observer) => { return new Observable((observer) => {
(async () => { (async () => {
try { try {
console.log('[submitAnswerStream] After Observable - sessionId:', sessionId); this.logger.debug('[submitAnswerStream] After Observable - sessionId:', sessionId);
const session = await this.sessionRepository.findOne({ const session = await this.sessionRepository.findOne({
where: { id: sessionId, userId }, where: { id: sessionId, userId },
}); });
@@ -917,6 +1009,36 @@ const initialState: Partial<EvaluationState> = {
return; 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 model = await this.getModel(session.tenantId);
const content = await this.getSessionContent(session); const content = await this.getSessionContent(session);
await this.ensureGraphState(sessionId, session); await this.ensureGraphState(sessionId, session);
@@ -927,7 +1049,7 @@ const initialState: Partial<EvaluationState> = {
graphState && graphState &&
graphState.values && graphState.values &&
Object.keys(graphState.values).length > 0; Object.keys(graphState.values).length > 0;
console.log( this.logger.debug(
`[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`, `[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`,
); );
@@ -953,8 +1075,8 @@ const initialState: Partial<EvaluationState> = {
let hasEmittedNodes = false; let hasEmittedNodes = false;
for await (const [mode, data] of stream) { for await (const [mode, data] of stream) {
streamCount++; streamCount++;
console.log('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {})); this.logger.debug('[submitAnswerStream] Stream event:', streamCount, mode, Object.keys(data || {}));
console.log('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500)); this.logger.debug('[submitAnswerStream] Data detail:', JSON.stringify(data).substring(0, 500));
if (mode === 'updates') { if (mode === 'updates') {
hasEmittedNodes = true; hasEmittedNodes = true;
const node = Object.keys(data)[0]; const node = Object.keys(data)[0];
@@ -962,17 +1084,17 @@ const initialState: Partial<EvaluationState> = {
// Skip interrupt nodes - they have no useful data // Skip interrupt nodes - they have no useful data
if (node === '__interrupt__' || !updateData || Object.keys(updateData).length === 0) { 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; continue;
} }
console.log('[submitAnswerStream] Node update:', node, { this.logger.debug('[submitAnswerStream] Node update:', node, {
hasMessages: !!updateData.messages, hasMessages: !!updateData.messages,
messageCount: updateData.messages?.length, messageCount: updateData.messages?.length,
currentIndex: updateData.currentQuestionIndex, currentIndex: updateData.currentQuestionIndex,
dataKeys: Object.keys(updateData).join(',') 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) { if (updateData.messages) {
updateData.messages = this.mapMessages(updateData.messages); updateData.messages = this.mapMessages(updateData.messages);
} }
@@ -983,7 +1105,7 @@ const initialState: Partial<EvaluationState> = {
} }
observer.next({ type: 'node', node, data: updateData }); observer.next({ type: 'node', node, data: updateData });
} else if (mode === 'values') { } 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; const finalData = fullState.values as EvaluationState;
// Force emit the next question if stream didn't emit updates (hasEmittedNodes is false) // 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) { if (!hasEmittedNodes && finalData && finalData.questions && finalData.questions.length > 0 && !emittedNextQuestion) {
const currentIndex = finalData.currentQuestionIndex || 0; const currentIndex = finalData.currentQuestionIndex || 0;
const nextQuestion = finalData.questions[currentIndex]; const nextQuestion = finalData.questions[currentIndex];
if (nextQuestion) { if (nextQuestion) {
const questionText = nextQuestion.questionText || ''; const questionText = nextQuestion.questionText || '';
console.log('[submitAnswerStream] Forcing emit next question:', { this.logger.debug('[submitAnswerStream] Forcing emit next question:', {
currentIndex, currentIndex,
questionPreview: questionText.substring(0, 50) questionPreview: questionText.substring(0, 50)
}); });
@@ -1020,7 +1142,7 @@ const initialState: Partial<EvaluationState> = {
} }
if (finalData && finalData.messages) { if (finalData && finalData.messages) {
console.log( this.logger.debug(
`[AssessmentService] submitAnswerStream Final Authoritative State messages:`, `[AssessmentService] submitAnswerStream Final Authoritative State messages:`,
finalData.messages.length, finalData.messages.length,
); );
@@ -1036,7 +1158,7 @@ const initialState: Partial<EvaluationState> = {
const scores = finalData.scores; const scores = finalData.scores;
const questions = finalData.questions || []; const questions = finalData.questions || [];
const weightConfig = session.templateJson?.weightConfig || { prompt: 50, other: 50 }; 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) { if (questions.length > 0 && Object.keys(scores).length > 0) {
const { finalScore, dimensionScores, radarData } = this.calculateScores( const { finalScore, dimensionScores, radarData } = this.calculateScores(
@@ -1048,6 +1170,7 @@ const initialState: Partial<EvaluationState> = {
(session as any).dimensionScores = dimensionScores; (session as any).dimensionScores = dimensionScores;
(session as any).radarData = radarData; (session as any).radarData = radarData;
(session as any).passed = finalScore >= passingScore; (session as any).passed = finalScore >= passingScore;
this.logger.log( this.logger.log(
`[DimensionScoring] Session ${sessionId} Final Score: ${finalScore}, Passed: ${finalScore >= passingScore}`, `[DimensionScoring] Session ${sessionId} Final Score: ${finalScore}, Passed: ${finalScore >= passingScore}`,
); );
@@ -1055,13 +1178,18 @@ const initialState: Partial<EvaluationState> = {
} }
await this.sessionRepository.save(session); 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.messages = this.mapMessages(finalData.messages);
mappedData.feedbackHistory = this.mapMessages( mappedData.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [], finalData.feedbackHistory || [],
); );
mappedData.status = session.status; mappedData.status = session.status;
mappedData.report = session.finalReport; mappedData.report = session.finalReport;
mappedData.finalScore = session.finalScore;
mappedData.passed = (session as any).passed;
observer.next({ type: 'final', data: mappedData }); observer.next({ type: 'final', data: mappedData });
} }
@@ -1101,7 +1229,10 @@ const initialState: Partial<EvaluationState> = {
values.feedbackHistory = this.mapMessages(values.feedbackHistory); 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 userId = user.id;
const isAdmin = user.role === 'super_admin' || user.role === 'admin'; const isAdmin = user.role === 'super_admin' || user.role === 'admin';
await this.dataSource.transaction(async (manager) => {
const deleteCondition: any = { id: sessionId }; const deleteCondition: any = { id: sessionId };
if (!isAdmin) { if (!isAdmin) {
deleteCondition.userId = userId; deleteCondition.userId = userId;
} }
const result = await this.sessionRepository.delete(deleteCondition); const session = await manager.findOne(AssessmentSession, { where: deleteCondition });
if (result.affected === 0) { if (!session) {
throw new NotFoundException( throw new NotFoundException('Session not found or you do not have permission to delete it');
'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,14 +1318,14 @@ const initialState: Partial<EvaluationState> = {
const historicalMessages = this.hydrateMessages(session.messages); const historicalMessages = this.hydrateMessages(session.messages);
const existingQuestions = session.questions_json || []; const existingQuestions = session.questions_json || [];
const hasQuestionsFromBank = existingQuestions.length > 0; 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;
}
}
if (hasQuestionsFromBank) { const recoveredState: any = {
this.logger.log(
`[ensureGraphState] Using ${existingQuestions.length} questions from question bank`,
);
await this.graph.updateState(
{ configurable: { thread_id: sessionId } },
{
assessmentSessionId: sessionId, assessmentSessionId: sessionId,
knowledgeBaseId: knowledgeBaseId:
session.knowledgeBaseId || session.knowledgeGroupId || '', session.knowledgeBaseId || session.knowledgeGroupId || '',
@@ -1196,37 +1336,29 @@ const initialState: Partial<EvaluationState> = {
questions: existingQuestions, questions: existingQuestions,
currentQuestionIndex: session.currentQuestionIndex || 0, currentQuestionIndex: session.currentQuestionIndex || 0,
followUpCount: session.followUpCount || 0, followUpCount: session.followUpCount || 0,
shouldFollowUp: false,
scores: scoresRecord,
questionCount: session.templateJson?.questionCount || 5, questionCount: session.templateJson?.questionCount || 5,
difficultyDistribution: difficultyDistribution:
session.templateJson?.difficultyDistribution, session.templateJson?.difficultyDistribution,
style: session.templateJson?.style, style: session.templateJson?.style,
keywords: session.templateJson?.keywords, keywords: session.templateJson?.keywords,
}, questionAnswerKey: session.templateJson?.questionAnswerKey,
'grader', language: session.language || 'zh',
); report: session.finalReport || undefined,
} else { };
await this.graph.updateState(
{ configurable: { thread_id: sessionId } }, if (hasQuestionsFromBank) {
{ this.logger.log(
assessmentSessionId: sessionId, `[ensureGraphState] Using ${existingQuestions.length} questions from question bank`,
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 { } else {
this.logger.log(`Initializing new state for session ${sessionId}`); this.logger.log(`Initializing new state for session ${sessionId}`);
const content = await this.getSessionContent(session); const content = await this.getSessionContent(session);
@@ -1241,6 +1373,7 @@ const initialState: Partial<EvaluationState> = {
difficultyDistribution: session.templateJson?.difficultyDistribution, difficultyDistribution: session.templateJson?.difficultyDistribution,
style: session.templateJson?.style, style: session.templateJson?.style,
keywords: session.templateJson?.keywords, keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
language: session.language || 'en', 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. * 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) { if (session.status !== AssessmentStatus.COMPLETED) {
throw new Error('Session not completed'); throw new BadRequestException('Session not completed yet');
} }
const existing = await this.certificateRepository.findOne({ const existing = await this.certificateRepository.findOne({
@@ -1353,6 +1507,13 @@ const initialState: Partial<EvaluationState> = {
const level = this.determineLevel(session.finalScore || 0); const level = this.determineLevel(session.finalScore || 0);
const qrCode = `cert://${sessionId}-${Date.now()}`; 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({ const certificate = this.certificateRepository.create({
userId, userId,
sessionId, sessionId,
@@ -1365,13 +1526,19 @@ const initialState: Partial<EvaluationState> = {
passed: (session as any).passed || false, 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 { private determineLevel(score: number): string {
if (score >= 90) return 'Expert'; if (score >= 9) return 'Expert';
if (score >= 75) return 'Advanced'; if (score >= 7.5) return 'Advanced';
if (score >= 60) return 'Proficient'; if (score >= 6) return 'Proficient';
return 'Novice'; return 'Novice';
} }
@@ -1464,19 +1631,15 @@ const initialState: Partial<EvaluationState> = {
const sessions = await qb.take(100).getMany(); const sessions = await qb.take(100).getMany();
const dimensionScores: Record<string, number[]> = { const dimensionScores: Record<string, number[]> = {};
PROMPT: [],
LLM: [],
IDE: [],
DEV_PATTERN: [],
WORK_CAPABILITY: [],
};
for (const session of sessions) { for (const session of sessions) {
const messages = session.messages || []; const scores = (session as any).dimensionScores || {};
for (const msg of messages) { for (const [dim, score] of Object.entries(scores)) {
if (msg.dimension && msg.score !== undefined) { if (dimensionScores[dim]) {
dimensionScores[msg.dimension]?.push(msg.score); dimensionScores[dim].push(score as number);
} else {
dimensionScores[dim] = [score as number];
} }
} }
} }
@@ -1531,7 +1694,8 @@ const initialState: Partial<EvaluationState> = {
reviewerId: string, reviewerId: string,
tenantId: string, tenantId: string,
): Promise<AssessmentSession> { ): Promise<AssessmentSession> {
const session = await this.sessionRepository.findOne({ return this.dataSource.transaction(async (manager) => {
const session = await manager.findOne(AssessmentSession, {
where: { id: sessionId }, where: { id: sessionId },
}); });
@@ -1559,20 +1723,21 @@ const initialState: Partial<EvaluationState> = {
} }
session.finalScore = newScore; session.finalScore = newScore;
const passingScore = session.templateJson?.passingScore || 90; const passingScore = (session.templateJson?.passingScore ?? 90) / 10;
(session as any).passed = newScore >= passingScore; (session as any).passed = newScore >= passingScore;
session.reviewedBy = reviewerId; session.reviewedBy = reviewerId;
session.reviewedAt = new Date(); session.reviewedAt = new Date();
session.reviewComment = comment || null; session.reviewComment = comment || null;
session.reviewHistory = reviewHistory; session.reviewHistory = reviewHistory;
await this.sessionRepository.save(session); await manager.save(session);
this.logger.log( this.logger.log(
`[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`, `[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`,
); );
return session; return session;
});
} }
async getUserHistory(userId: string): Promise<AssessmentSession[]> { async getUserHistory(userId: string): Promise<AssessmentSession[]> {
@@ -1655,7 +1820,6 @@ const initialState: Partial<EvaluationState> = {
totalScore: number; totalScore: number;
passed: boolean; passed: boolean;
issuedAt: Date; issuedAt: Date;
userId: string;
}; };
message?: string; message?: string;
}> { }> {
@@ -1676,7 +1840,6 @@ const initialState: Partial<EvaluationState> = {
totalScore: certificate.totalScore, totalScore: certificate.totalScore,
passed: certificate.passed, passed: certificate.passed,
issuedAt: certificate.issuedAt, 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> { async forceEndAssessment(sessionId: string): Promise<AssessmentSession> {
const session = await this.sessionRepository.findOne({ const session = await this.sessionRepository.findOne({
where: { id: sessionId }, where: { id: sessionId },
@@ -22,6 +22,7 @@ import {
ReviewDto, ReviewDto,
} from '../services/question-bank.service'; } from '../services/question-bank.service';
import { CombinedAuthGuard } from '../../auth/combined-auth.guard'; import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
import { KnowledgeGroupService } from '../../knowledge-group/knowledge-group.service';
@Controller('question-banks') @Controller('question-banks')
@UseGuards(CombinedAuthGuard) @UseGuards(CombinedAuthGuard)
@@ -29,12 +30,20 @@ import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
export class QuestionBankController { export class QuestionBankController {
private readonly logger = new Logger(QuestionBankController.name); private readonly logger = new Logger(QuestionBankController.name);
constructor(private readonly questionBankService: QuestionBankService) {} constructor(
private readonly questionBankService: QuestionBankService,
private readonly groupService: KnowledgeGroupService,
) {}
@Post() @Post()
create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) { async create(@Body() createDto: CreateQuestionBankDto, @Req() req: any) {
this.logger.log(`Creating question bank: ${createDto.name}`); try {
return this.questionBankService.create(createDto, req.user.id, req.user.tenantId); 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() @Get()
@@ -125,11 +134,32 @@ export class QuestionBankController {
@Body() body: { count: number; knowledgeBaseContent?: string }, @Body() body: { count: number; knowledgeBaseContent?: string },
@Req() req: any, @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( return this.questionBankService.generateQuestions(
bankId, bankId,
body.count, body.count,
body.knowledgeBaseContent || '', content,
req.user.tenantId, req.user.tenantId,
); );
} }
@@ -8,6 +8,7 @@ import {
Max, Max,
IsObject, IsObject,
IsBoolean, IsBoolean,
IsNumber,
} from 'class-validator'; } from 'class-validator';
export class CreateTemplateDto { export class CreateTemplateDto {
@@ -59,6 +60,11 @@ export class CreateTemplateDto {
@IsOptional() @IsOptional()
linkedGroupIds?: string[]; linkedGroupIds?: string[];
@IsArray()
@IsObject({ each: true })
@IsOptional()
dimensions?: Array<{ name: string; label: string; weight: number }>;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
weightConfig?: { weightConfig?: {
@@ -91,4 +97,16 @@ export class CreateTemplateDto {
@Max(100) @Max(100)
@IsOptional() @IsOptional()
passingScore?: number; 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 }) @Column({ type: 'float', name: 'original_score', nullable: true })
originalScore: number; 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 }) @Column({ type: 'text', name: 'final_report', nullable: true })
finalReport: string; finalReport: string;
@@ -63,6 +63,9 @@ export class AssessmentTemplate {
@JoinColumn({ name: 'knowledge_group_id' }) @JoinColumn({ name: 'knowledge_group_id' })
knowledgeGroup: KnowledgeGroup; 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 }) @Column({ type: 'boolean', name: 'is_active', default: true })
isActive: boolean; 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({ @Column({
type: 'simple-enum', type: 'simple-enum',
enum: QuestionType, enum: QuestionType,
default: QuestionType.SHORT_ANSWER,
}) })
questionType: QuestionType; questionType: QuestionType;
@@ -71,24 +72,33 @@ export class QuestionBankItem {
@Column({ @Column({
type: 'simple-enum', type: 'simple-enum',
enum: QuestionDifficulty, enum: QuestionDifficulty,
default: QuestionDifficulty.STANDARD,
}) })
difficulty: QuestionDifficulty; difficulty: QuestionDifficulty;
@Column({ @Column({
type: 'simple-enum', type: 'simple-enum',
enum: QuestionDimension, enum: QuestionDimension,
default: QuestionDimension.PROMPT,
}) })
dimension: QuestionDimension; dimension: QuestionDimension;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
basis: string | null; 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' }) @Column({ name: 'created_by', nullable: true, type: 'text' })
createdBy: string | null; createdBy: string | null;
@Column({ @Column({
type: 'simple-enum', type: 'simple-enum',
enum: QuestionBankItemStatus, enum: QuestionBankItemStatus,
default: QuestionBankItemStatus.PENDING_REVIEW,
}) })
status: QuestionBankItemStatus; status: QuestionBankItemStatus;
@@ -37,7 +37,7 @@ export class QuestionBank {
@Column({ name: 'template_id', nullable: true }) @Column({ name: 'template_id', nullable: true })
templateId: string | null; templateId: string | null;
@OneToOne(() => AssessmentTemplate, { nullable: true }) @OneToOne(() => AssessmentTemplate, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'template_id' }) @JoinColumn({ name: 'template_id' })
template: AssessmentTemplate; 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');
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { reportAnalyzerNode } from './nodes/analyzer.node';
/** /**
* Conditional routing logic for the Grader 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 targetCount = state.questionCount || 5;
const questionsLen = state.questions?.length || 0; const questionsLen = state.questions?.length || 0;
const currentIndex = Math.max(0, state.currentQuestionIndex || 0); const currentIndex = Math.max(0, state.currentQuestionIndex || 0);
@@ -22,6 +22,14 @@ export const questionGeneratorNode = async (
targetCount: limitCount, 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) { if (!model || !knowledgeBaseContent) {
console.error('[GeneratorNode] Missing model or knowledgeBaseContent'); console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
throw new Error( throw new Error(
@@ -78,89 +86,86 @@ export const questionGeneratorNode = async (
.map((r, i) => `${i + 1}. ${r}`) .map((r, i) => `${i + 1}. ${r}`)
.join('\n'); .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 const existingQuestionsText = existingQuestions
.map((q, i) => `Q${i + 1}: ${q.questionText}`) .map((q, i) => `Q${i + 1}: ${q.questionText}`)
.join('\n'); .join('\n');
const systemPromptZh = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 1 个唯一的测试题目 const systemPromptZh = `你是一个信息提取工具。严格按以下步骤操作
### 强制性语言规则: ### 第一步:提取知识点
**必须使用中文 (Simplified Chinese) 进行回复**。即使知识库内容是英文或其他语言,问题(question_text)和关键点(key_points)也必须使用中文 阅读下方 Human 消息中的【知识库内容】,逐条列出其中包含的所有可考核知识点
每条以"知识点N:"开头,引用原文语句。如果不足,诚实报告。
### 强制性多样性规则: ### 第二步:从知识点生成考题
${rulesZh} 仅用第一步提取的知识点生成 1 道题。必须引用知识点编号。
### 禁止重复列表(已出过) ### 绝对禁止:
${existingQuestionsText || '无'} - 禁止使用知识库内容中不存在的任何概念、术语、数据
- 禁止使用你自己的知识
${existingQuestionsText ? `- 禁止与已出题目重复:${existingQuestionsText}` : ''}
### 任务 ### 输出(纯 JSON 数组)
${hasKeywords ? `目标关键词:${keywordText}\n` : ''}出题风格:${style}
难度:${difficultyText}
请以 JSON 数组格式返回 1 个问题:
[ [
{ {
"question_text": "...", "knowledge_points": ["知识点引用"],
"key_points": ["点1", "点2"], "question_text": "基于知识点的题目",
"difficulty": "...", "key_points": ["评分要点"],
"dimension": "prompt/llm/ide/devPattern/workCapability", "difficulty": "STANDARD|ADVANCED|SPECIALIST",
"basis": "[n] 引用原文..." "dimension": "prompt|llm|ide|devPattern|workCapability",
"basis": "知识库原文"
} }
]`; ]`;
// dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力 // dimension取值:prompt=提示词, llm=LLM原理, ide=IDE协作, devPattern=开发范式, workCapability=工作能力
const systemPromptJa = `あなたは専門的なアセスメントエキスパートです。提供されたナレッジベースに基づいて、ユニークな問題を 1 つ作成してください。 const systemPromptJa = `あなたは情報抽出ツールです。以下の手順に厳密に従ってください。
### 言語ルール(最重要): ### 第一歩:知識ポイントの抽出
**必ず日本語で作成してください**。提供されたナレッジベースが英語や中国語、その他の言語であっても、質問文(question_text)およびキーポイント(key_points)は必ず日本語で回答してください。中国語が混ざらないように厳格に注意してください Human メッセージ内の【ナレッジベース内容】を読み、含まれるすべての評価可能な知識ポイントを箇条書きで抽出
各項目は「知識ポイントN:」で始め、原文を引用。不足している場合は正直に報告。
### 多様性ルール: ### 第二歩:知識ポイントから問題を作成
${rulesJa} 第一歩で抽出した知識ポイントのみを使用して 1 問作成。知識ポイント番号を引用すること。
### 作成済み問題リスト ### 絶対禁止
${existingQuestionsText || 'なし'} - ナレッジベースに存在しない概念、用語、データの使用
- 自身の知識の使用
${existingQuestionsText ? `- 作成済み問題との重複禁止:${existingQuestionsText}` : ''}
### 任務 ### 出力(純粋な JSON 配列)
${hasKeywords ? `目標キーワード:${keywordText}\n` : ''}出題スタイル:${style}
難易度:${difficultyText}
以下のJSON配列形式で問題を1つ返してください:
[ [
{ {
"question_text": "...", "knowledge_points": ["知識ポイント参照"],
"key_points": ["ポイント1", "ポイント2"], "question_text": "知識ポイントに基づく問題",
"difficulty": "...", "key_points": ["採点ポイント"],
"dimension": "prompt/llm/ide/devPattern/workCapability", "difficulty": "STANDARD|ADVANCED|SPECIALIST",
"basis": "[n] 引用箇所..." "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: ### Step 1: Extract Knowledge Points
**You MUST generate the question and key points in English.** 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: ### Step 2: Generate Question from Points
${rulesEn} Use ONLY the knowledge points from Step 1 to generate 1 question. Must reference KP numbers.
### Previous Questions (DO NOT REPEAT): ### Absolutely Forbidden:
${existingQuestionsText || 'None'} - 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": "...", "knowledge_points": ["KP reference"],
"key_points": ["point1", "point2"], "question_text": "Question based on the knowledge points",
"difficulty": "...", "key_points": ["scoring points"],
"dimension": "prompt/llm/ide/devPattern/workCapability", "difficulty": "STANDARD|ADVANCED|SPECIALIST",
"basis": "[n] citation..." "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 ? systemPromptJa
: systemPromptEn; : systemPromptEn;
const humanMsg = isZh const humanMsg = isZh
? `请使用中文基于以下内容生成题目:\n\n${knowledgeBaseContent}` ? `【知识库内容 - 以下是你出题的唯一依据】\n\n--- 知识库开始 ---\n${knowledgeBaseContent}\n--- 知识库结束 ---\n\n请严格基于以上内容生成题目。`
: isJa : isJa
? `以下の内容に基づいて、必ず日本語でアセスメント問題を作成してください:\n\n${knowledgeBaseContent}` ? `【ナレッジベース内容 - 以下は出題の唯一の根拠です】\n\n--- ナレッジベース開始 ---\n${knowledgeBaseContent}\n--- ナレッジベース終了 ---\n\n上記の内容のみに基づいて問題を作成してください。`
: `Generate evaluation question in English based on:\n\n${knowledgeBaseContent}`; : `【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 { try {
const response = await model.invoke([ const response = await model.invoke([
@@ -226,6 +231,7 @@ Return 1 question as a JSON array with format:
return { return {
id: (existingQuestions.length + 1).toString(), id: (existingQuestions.length + 1).toString(),
questionText: q.question_text, questionText: q.question_text,
questionType: 'SHORT_ANSWER',
keyPoints: q.key_points, keyPoints: q.key_points,
difficulty: q.difficulty, difficulty: q.difficulty,
basis: q.basis, 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();
});
});
});
+145 -77
View File
@@ -67,106 +67,151 @@ export const graderNode = async (
return { currentQuestionIndex: currentQuestionIndex + 1 }; return { currentQuestionIndex: currentQuestionIndex + 1 };
} }
const systemPromptZh = `你是一位专业的考官。 const isChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE';
请根据以下问题和关键点对用户的回答进行评分。 const expectedAnswer = currentQuestion.correctAnswer;
重要提示: if (isChoice && expectedAnswer) {
1. **你必须使用以下语言提供反馈:中文 (Simplified Chinese)**。 const userAnswer = (lastUserMessage.content as string).trim();
2. 即使用户的回答或知识库内容涉及其他语言,请确保你的反馈和解释依然严格使用中文。不要夹杂日文。 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.questionText}
预期的关键点:${currentQuestion.keyPoints.join(', ')} 关键点:${currentQuestion.keyPoints.join(', ')}
标准: 标准:准确性、完整性、深度。
1. 准确性:他们是否正确覆盖了关键点? 部分正确也给分(5-7分),完全不沾边才0-2分。
2. 完整性:他们是否遗漏了任何重要内容?
3. 深度:解释是否充分?
请提供 返回JSON
1. 0 到 10 的评分。 - score: 0-10
2. 建设性的反馈。 - feedback: 评语
3. 如果回答不完整或不清晰,需要进一步解释,请将 'should_follow_up' 标志设为 true。 - should_follow_up: true/false
- follow_up_question: 追问(仅true时需要,针对未覆盖的关键点,false时null)
请以 JSON 格式返回响应: 请以 JSON 格式返回响应:
{ {"score":0到10,"feedback":"评语","should_follow_up":true或false,"follow_up_question":"追问或null"}
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
const systemPromptJa = `あなたは専門的な試験官です。 示例(需要追问):
以下の質問とキーポイントに基づいて、ユーザーの回答を採点してください。 {"score":6,"feedback":"提到了安全性和性能,未说明依赖关系。","should_follow_up":true,"follow_up_question":"你如何让AI在计划中明确任务依赖关系?"}
重要事項 示例(不需追问)
1. **フィードバックは必ず次の言語で提供してください:日本語**。 {"score":8,"feedback":"回答完整。","should_follow_up":false,"follow_up_question":null}`;
2. ユーザーの回答やナレッジベースの内容に他の言語(中国語や英語など)が含まれている場合でも、フィードバックと説明は必ず日本語のみで行ってください。中国語が混ざらないよう厳格に注意してください。
const systemPromptJa = `あなたは試験官です。採点とフィードバックを提供してください。
ルール:
1. 日本語のみ使用。
2. 複数ラウンドの回答は「第N輪回答:」でマークされ、全ラウンドを総合判断。
質問:${currentQuestion.questionText} 質問:${currentQuestion.questionText}
期待されるキーポイント:${currentQuestion.keyPoints.join(', ')} キーポイント:${currentQuestion.keyPoints.join(', ')}
評価基準: 評価基準:正確性、網羅性、深さ。
1. 正確性:キーポイントを正確に網羅していますか? 部分点可(5〜7点)、見当違いのみ0〜2点。
2. 網羅性:重要な内容が欠落していませんか?
3. 深さ:説明は十分ですか?
以下を提供してください JSON形式
1. 0 から 10 までのスコア。 - score: 0〜10
2. 建設的なフィードバック。 - feedback: 評価
3. 回答が不完全または不明確で、さらなる説明が必要な場合は、'should_follow_up' フラグを true に設定してください。 - should_follow_up: true/false
- follow_up_question: 追質問(true時のみ、未カバーのポイントに焦点、false時null)
JSON 形式で回答してください: JSON 形式で回答してください:
{ {"score":0から10,"feedback":"評価","should_follow_up":trueかfalse,"follow_up_question":"追質問かnull"}
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
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.** {"score":8,"feedback":"回答は完全。","should_follow_up":false,"follow_up_question":null}`;
2. If the user's answer or knowledge base content references other languages, ensure your feedback and explanation remain strictly in English.
QUESTION: ${currentQuestion.questionText} const systemPromptEn = `You are an examiner. Grade and give feedback.
EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(', ')}
Evaluate: Rules:
1. Accuracy: Did they cover the key points correctly? 1. English only.
2. Completeness: Did they miss anything important? 2. Multi-round answers are tagged "第N轮回答:". Consider all rounds.
3. Depth: Is the explanation sufficient?
Provide: Question: ${currentQuestion.questionText}
1. A score from 0 to 10. Key points: ${currentQuestion.keyPoints.join(', ')}
2. Constructive feedback.
3. A boolean flag 'should_follow_up' if the answer is incomplete or unclear and needs further clarification.
Format your response as JSON: Criteria: accuracy, completeness, depth.
{ Give partial credit (5-7 for partial), 0-2 only for off-target.
"score": 8,
"feedback": "...",
"should_follow_up": false
}`;
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 ? systemPromptZh
: isJa : isJa
? systemPromptJa ? systemPromptJa
: systemPromptEn; : 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 = const userContentText =
typeof lastUserMessage.content === 'string' typeof lastUserMessage.content === 'string'
? lastUserMessage.content ? lastUserMessage.content
: JSON.stringify(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] === START GRADING ===');
console.log('[GraderNode] User answer length:', userContentText.length); console.log('[GraderNode] User answer length:', userContentText.length);
console.log('[GraderNode] Question:', currentQuestion?.questionText?.substring(0, 100)); console.log('[GraderNode] Question:', currentQuestion?.questionText?.substring(0, 100));
console.log('[GraderNode] Target dimension:', currentQuestion?.dimension); console.log('[GraderNode] Target dimension:', currentQuestion?.dimension);
try {
const response = await model.invoke([ const response = await model.invoke([
new SystemMessage(systemPrompt), new SystemMessage(systemPrompt),
new HumanMessage(userContentText), new HumanMessage(allAnswers),
]); ]);
console.log('[GraderNode] LLM invoke completed'); console.log('[GraderNode] LLM invoke completed');
@@ -187,10 +232,7 @@ Format your response as JSON:
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score'; const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback'; const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
let enhancedFeedback: string = result.feedback;
const feedbackMessage = new AIMessage(
`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${result.feedback}`,
);
const newScores = { const newScores = {
...state.scores, ...state.scores,
@@ -199,10 +241,6 @@ Format your response as JSON:
let shouldFollowUp = result.should_follow_up === true; 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 normalizedContent = userContentText.trim().toLowerCase();
const saysIDontKnow = const saysIDontKnow =
normalizedContent.length < 10 && normalizedContent.length < 10 &&
@@ -217,10 +255,21 @@ Format your response as JSON:
normalizedContent.includes('不明') || normalizedContent.includes('不明') ||
normalizedContent.includes('わからない')); normalizedContent.includes('わからない'));
if (currentFollowUpCount >= 2 || result.score >= 8 || saysIDontKnow) { if (currentFollowUpCount >= maxFollowUps || result.score >= 8 || saysIDontKnow) {
shouldFollowUp = false; 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:', { console.log('[GraderNode] Final State decision:', {
shouldFollowUp, shouldFollowUp,
nextIndex: shouldFollowUp nextIndex: shouldFollowUp
@@ -230,8 +279,12 @@ Format your response as JSON:
saysIDontKnow, saysIDontKnow,
}); });
const feedbackHistoryMessages = followupHintMsg
? [feedbackMessage, followupHintMsg]
: [feedbackMessage];
return { return {
feedbackHistory: [feedbackMessage], feedbackHistory: feedbackHistoryMessages,
scores: newScores, scores: newScores,
shouldFollowUp: shouldFollowUp, shouldFollowUp: shouldFollowUp,
followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0, followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
@@ -239,14 +292,29 @@ Format your response as JSON:
? currentQuestionIndex ? currentQuestionIndex
: currentQuestionIndex + 1, : currentQuestionIndex + 1,
} as any; } as any;
} catch (error) { } catch (parseError) {
console.error('Failed to parse grade from AI response:', error); console.error('[GraderNode] Failed to parse grade:', parseError);
const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
const fallbackMsg = new AIMessage(`${scoreLabel}: 5/10\n\n评分解析失败,默认给5分。`);
return { return {
feedbackHistory: [ feedbackHistory: [fallbackMsg],
new AIMessage("I had some trouble grading that, but let's move on."), scores: { [currentQuestion.id || currentQuestionIndex.toString()]: 5 },
],
currentQuestionIndex: currentQuestionIndex + 1,
shouldFollowUp: false, 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; } 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]; 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) { if (currentQuestionIndex >= questions.length) {
return { shouldFollowUp: false }; return { shouldFollowUp: false };
} }
@@ -49,33 +47,24 @@ export const interviewerNode = async (
state.feedbackHistory && state.feedbackHistory &&
state.feedbackHistory.length > 0 state.feedbackHistory.length > 0
) { ) {
// Construct a follow-up prompt based on last feedback
const lastFeedbackMsg = const lastFeedbackMsg =
state.feedbackHistory[state.feedbackHistory.length - 1]; state.feedbackHistory[state.feedbackHistory.length - 1];
const feedbackText = lastFeedbackMsg.content.toString(); prompt = lastFeedbackMsg.content.toString();
} else if (currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0) {
// Extract the "Feedback: ..." part if possible, otherwise use whole text const label = isZh
const feedbackMatch = feedbackText.match( ? `问题 ${currentQuestionIndex + 1}`
/(?:Feedback|反馈|フィードバック): ([\s\S]*)/i,
);
const specificFeedback = feedbackMatch
? feedbackMatch[1].trim()
: feedbackText;
const followUpLabel = isZh
? '补充追问'
: isJa : isJa
? '追加の質問' ? `質問 ${currentQuestionIndex + 1}`
: 'Follow-up Clarification'; : `Question ${currentQuestionIndex + 1}`;
const followUpInstruction = isZh
? '根据以上反馈,请补充更具体的信息:'
: isJa
? '上記のフィードバックに基づき、より具体的な情報を追加してください:'
: 'Based on the feedback above, please provide more specific details:';
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 { } else {
// Standard question presentation
const label = isZh const label = isZh
? `问题 ${currentQuestionIndex + 1}` ? `问题 ${currentQuestionIndex + 1}`
: isJa : isJa
+9
View File
@@ -119,6 +119,15 @@ export const EvaluationAnnotation = Annotation.Root({
keywords: Annotation<string[] | undefined>({ keywords: Annotation<string[] | undefined>({
reducer: (prev, next) => next ?? prev, 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; 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 { AssessmentQuestion } from '../entities/assessment-question.entity';
import { AssessmentAnswer } from '../entities/assessment-answer.entity'; import { AssessmentAnswer } from '../entities/assessment-answer.entity';
import { AssessmentCertificate } from '../entities/assessment-certificate.entity'; import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
import { generateAssessmentPdf } from './pdf-generator';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
@@ -95,7 +96,7 @@ export class ExportService {
} }
private extractDimensionScores(session: AssessmentSession): any[][] { private extractDimensionScores(session: AssessmentSession): any[][] {
const scores = session.templateJson?.dimensionScores || session.finalReport; const scores = (session as any).dimensionScores;
if (!scores) return [['未找到维度分数']]; if (!scores) return [['未找到维度分数']];
if (typeof scores === 'string') { if (typeof scores === 'string') {
@@ -142,86 +143,47 @@ export class ExportService {
throw new Error('Session not found'); throw new Error('Session not found');
} }
const certificate = await this.certificateRepository.findOne({ const cert = await this.certificateRepository.findOne({
where: { sessionId }, where: { sessionId },
}); });
const questions = await this.questionRepository.find({ const questions = (session.questions_json || []) as any[];
where: { sessionId },
order: { createdAt: 'ASC' }, 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({ const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Assessment Report</title>
where: { questionId: In(questions.map((q) => q.id)) }, <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(html, 'utf-8');
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');
} }
} }
@@ -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, 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(提示词工程)
- llmLLM理解)
- ideIDE协作开发)
- 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() @Injectable()
export class QuestionBankService { export class QuestionBankService {
private readonly logger = new Logger(QuestionBankService.name); private readonly logger = new Logger(QuestionBankService.name);
@@ -92,13 +266,11 @@ export class QuestionBankService {
} }
if (createDto.templateId) { if (createDto.templateId) {
const existing = await this.bankRepository.findOne({ 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) {
if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED) { if (existing.status === QuestionBankStatus.DRAFT || existing.status === QuestionBankStatus.REJECTED || existing.status === QuestionBankStatus.PUBLISHED) {
await this.bankRepository.remove(existing); throw new BadRequestException('该模板已关联题库,请编辑已有题库或删除后重建');
} else {
throw new BadRequestException('该模板已关联有效题库,请编辑已有题库');
} }
} }
} }
@@ -122,7 +294,7 @@ export class QuestionBankService {
page?: number, page?: number,
limit?: number, limit?: number,
): Promise<{ data: QuestionBank[]; total: number } | QuestionBank[]> { ): 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 const queryBuilder = this.bankRepository
.createQueryBuilder('bank') .createQueryBuilder('bank')
.leftJoinAndSelect('bank.template', 'template'); .leftJoinAndSelect('bank.template', 'template');
@@ -175,6 +347,9 @@ export class QuestionBankService {
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const bank = await this.findOne(id); const bank = await this.findOne(id);
if (bank.status === QuestionBankStatus.PUBLISHED) {
throw new ForbiddenException('已发布的题库不可删除');
}
await this.bankRepository.remove(bank); await this.bankRepository.remove(bank);
} }
@@ -267,6 +442,9 @@ export class QuestionBankService {
if (!item) { if (!item) {
throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`); throw new NotFoundException(`QuestionBankItem with ID "${itemId}" not found`);
} }
if (item.status === QuestionBankItemStatus.PUBLISHED) {
throw new ForbiddenException('已发布的题目不可删除');
}
await this.itemRepository.remove(item); await this.itemRepository.remove(item);
} }
@@ -278,6 +456,10 @@ export class QuestionBankService {
): Promise<QuestionBankItem[]> { ): Promise<QuestionBankItem[]> {
const bank = await this.findOne(bankId); const bank = await this.findOne(bankId);
if (bank.status !== QuestionBankStatus.DRAFT) {
throw new ForbiddenException('仅草稿状态的题库可生成题目');
}
if (count <= 0 || count > 50) { if (count <= 0 || count > 50) {
throw new BadRequestException('生成数量必须在 1-50 之间'); throw new BadRequestException('生成数量必须在 1-50 之间');
} }
@@ -295,35 +477,14 @@ export class QuestionBankService {
const model = new ChatOpenAI({ const model = new ChatOpenAI({
apiKey: modelConfig.apiKey || 'ollama', apiKey: modelConfig.apiKey || 'ollama',
modelName: modelConfig.modelId, modelName: modelConfig.modelId,
temperature: 0.7, temperature: 0.1,
configuration: { configuration: {
baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1', baseURL: modelConfig.baseUrl || 'https://api.deepseek.com/v1',
}, },
}); });
const systemPrompt = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 ${count} 个唯一的测试题目。 const systemPrompt = GENERATE_QUESTIONS_SYSTEM_PROMPT;
const humanMsg = `【知识库内容 - 唯一来源】\n\n--- 开始 ---\n${knowledgeBaseContent}\n--- 结束 ---\n\n请按上述规则生成 ${count} 道题,choice:open 比例约 3:7。难度以 STANDARD 为主。`;
### 强制性语言规则:
**必须使用中文 (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}`;
try { try {
const response = await model.invoke([ const response = await model.invoke([
@@ -341,35 +502,11 @@ export class QuestionBankService {
parsedQuestions = [parsedQuestions]; 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[] = []; const items: QuestionBankItem[] = [];
for (const q of parsedQuestions) { for (const q of parsedQuestions) {
const dimension = dimensionMap[q.dimension?.toLowerCase()] || 'WORK_CAPABILITY'; const item = this.itemRepository.create(
const difficulty = difficultyMap[q.difficulty?.toUpperCase()] || 'STANDARD'; parseGeneratedQuestion(q, bankId),
);
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,
});
items.push(item); items.push(item);
} }
@@ -388,14 +525,9 @@ export class QuestionBankService {
count: number, count: number,
): Promise<QuestionBankItem[]> { ): Promise<QuestionBankItem[]> {
const bank = await this.findOne(bankId); 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({ const allItems = await this.itemRepository.find({
where: { bankId }, where: { bankId, status: QuestionBankItemStatus.PUBLISHED },
}); });
if (allItems.length === 0) { if (allItems.length === 0) {
@@ -456,7 +588,7 @@ export class QuestionBankService {
this.logger.log( this.logger.log(
`[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`, `[selectQuestions] Selected ${selected.length} questions from bank ${bankId}`,
); );
return selected; return this.shuffleArray(selected);
} }
async batchReviewItems( 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, Injectable,
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -18,11 +19,33 @@ export class TemplateService {
private readonly tenantService: TenantService, 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( async create(
createDto: CreateTemplateDto, createDto: CreateTemplateDto,
userId: string, userId: string,
tenantId: string, tenantId: string,
): Promise<AssessmentTemplate> { ): Promise<AssessmentTemplate> {
this.validateRequiredFields(createDto);
const { ...data } = createDto; const { ...data } = createDto;
const template = this.templateRepository.create({ const template = this.templateRepository.create({
...data, ...data,
@@ -76,6 +99,8 @@ export class TemplateService {
tenantId: string, tenantId: string,
): Promise<AssessmentTemplate> { ): Promise<AssessmentTemplate> {
const template = await this.findOne(id, userId, tenantId); const template = await this.findOne(id, userId, tenantId);
const merged = { ...template, ...updateDto };
this.validateRequiredFields(merged);
Object.assign(template, updateDto); Object.assign(template, updateDto);
return this.templateRepository.save(template); return this.templateRepository.save(template);
} }
+5 -2
View File
@@ -3,6 +3,7 @@ import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
UnauthorizedException, UnauthorizedException,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@@ -25,6 +26,8 @@ import * as path from 'path';
*/ */
@Injectable() @Injectable()
export class CombinedAuthGuard implements CanActivate { export class CombinedAuthGuard implements CanActivate {
private readonly logger = new Logger(CombinedAuthGuard.name);
// We extend AuthGuard('jwt') functionality by composition // We extend AuthGuard('jwt') functionality by composition
private jwtGuard: ReturnType<typeof AuthGuard>; private jwtGuard: ReturnType<typeof AuthGuard>;
@@ -55,7 +58,7 @@ export class CombinedAuthGuard implements CanActivate {
return true; return true;
} }
console.log( this.logger.log(
`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`, `[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
); );
@@ -160,7 +163,7 @@ export class CombinedAuthGuard implements CanActivate {
} }
return false; return false;
} catch (e) { } catch (e) {
console.error(`[CombinedAuthGuard] JWT Auth Error:`, e); this.logger.error('[CombinedAuthGuard] JWT Auth Error: ' + e);
throw e instanceof UnauthorizedException throw e instanceof UnauthorizedException
? e ? e
: new UnauthorizedException('Authentication required'); : new UnauthorizedException('Authentication required');
+25 -22
View File
@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Logger,
Post, Post,
Request, Request,
Res, Res,
@@ -36,6 +37,8 @@ class StreamChatDto {
@Controller('chat') @Controller('chat')
@UseGuards(CombinedAuthGuard) @UseGuards(CombinedAuthGuard)
export class ChatController { export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor( constructor(
private chatService: ChatService, private chatService: ChatService,
private modelConfigService: ModelConfigService, private modelConfigService: ModelConfigService,
@@ -49,7 +52,7 @@ export class ChatController {
@Res() res: Response, @Res() res: Response,
) { ) {
try { try {
console.log('Full Request Body:', JSON.stringify(body, null, 2)); this.logger.log('Full Request Body:', JSON.stringify(body, null, 2));
const { const {
message, message,
history = [], history = [],
@@ -71,22 +74,22 @@ export class ChatController {
} = body; } = body;
const userId = req.user.id; const userId = req.user.id;
console.log('=== Chat Debug Info ==='); this.logger.log('=== Chat Debug Info ===');
console.log('User ID:', userId); this.logger.log('User ID:', userId);
console.log('Message:', message); this.logger.log('Message:', message);
console.log('User Language:', userLanguage); this.logger.log('User Language:', userLanguage);
console.log('Selected Embedding ID:', selectedEmbeddingId); this.logger.log('Selected Embedding ID:', selectedEmbeddingId);
console.log('Selected LLM ID:', selectedLLMId); this.logger.log('Selected LLM ID:', selectedLLMId);
console.log('Selected Groups:', selectedGroups); this.logger.log('Selected Groups:', selectedGroups);
console.log('Selected Files:', selectedFiles); this.logger.log('Selected Files:', selectedFiles);
console.log('History ID:', historyId); this.logger.log('History ID:', historyId);
console.log('Temperature:', temperature); this.logger.log('Temperature:', temperature);
console.log('Max Tokens:', maxTokens); this.logger.log('Max Tokens:', maxTokens);
console.log('Top K:', topK); this.logger.log('Top K:', topK);
console.log('Similarity Threshold:', similarityThreshold); this.logger.log('Similarity Threshold:', similarityThreshold);
console.log('Rerank Similarity Threshold:', rerankSimilarityThreshold); this.logger.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
console.log('Query Expansion:', enableQueryExpansion); this.logger.log('Query Expansion:', enableQueryExpansion);
console.log('HyDE:', enableHyDE); this.logger.log('HyDE:', enableHyDE);
const role = req.user.role; const role = req.user.role;
const tenantId = req.user.tenantId; const tenantId = req.user.tenantId;
@@ -105,14 +108,14 @@ export class ChatController {
if (selectedLLMId) { if (selectedLLMId) {
// Find specifically selected model // Find specifically selected model
llmModel = await this.modelConfigService.findOne(selectedLLMId); llmModel = await this.modelConfigService.findOne(selectedLLMId);
console.log('使用选中的LLM模型:', llmModel.name); this.logger.log('使用选中的LLM模型:', llmModel.name);
} else { } else {
// Use organization's default LLM from Index Chat Config (strict) // Use organization's default LLM from Index Chat Config (strict)
llmModel = await this.modelConfigService.findDefaultByType( llmModel = await this.modelConfigService.findDefaultByType(
tenantId, tenantId,
ModelType.LLM, ModelType.LLM,
); );
console.log( this.logger.log(
'最终使用的LLM模型 (默认):', '最终使用的LLM模型 (默认):',
llmModel ? llmModel.name : '无', llmModel ? llmModel.name : '无',
); );
@@ -162,7 +165,7 @@ export class ChatController {
res.write('data: [DONE]\n\n'); res.write('data: [DONE]\n\n');
res.end(); res.end();
} catch (error) { } catch (error) {
console.error('Stream chat error:', error); this.logger.error('Stream chat error:', error);
try { try {
res.write( res.write(
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`, `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.write('data: [DONE]\n\n');
res.end(); res.end();
} catch (writeError) { } 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.write('data: [DONE]\n\n');
res.end(); res.end();
} catch (error) { } catch (error) {
console.error('Stream assist error:', error); this.logger.error('Stream assist error:', error);
res.write( res.write(
`data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`, `data: ${JSON.stringify({ type: 'error', data: error.message || 'Server Error' })}\n\n`,
); );
+29 -29
View File
@@ -71,30 +71,30 @@ export class ChatService {
enableHyDE?: boolean, // New enableHyDE?: boolean, // New
tenantId?: string, // New: tenant isolation tenantId?: string, // New: tenant isolation
): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> { ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
console.log('=== ChatService.streamChat ==='); this.logger.log('=== ChatService.streamChat ===');
console.log('User ID:', userId); this.logger.log('User ID:', userId);
console.log('User language:', userLanguage); this.logger.log('User language:', userLanguage);
console.log('Selected embedding model ID:', selectedEmbeddingId); this.logger.log('Selected embedding model ID:', selectedEmbeddingId);
console.log('Selected groups:', selectedGroups); this.logger.log('Selected groups:', selectedGroups);
console.log('Selected files:', selectedFiles); this.logger.log('Selected files:', selectedFiles);
console.log('History ID:', historyId); this.logger.log('History ID:', historyId);
console.log('Temperature:', temperature); this.logger.log('Temperature:', temperature);
console.log('Max Tokens:', maxTokens); this.logger.log('Max Tokens:', maxTokens);
console.log('Top K:', topK); this.logger.log('Top K:', topK);
console.log('Similarity threshold:', similarityThreshold); this.logger.log('Similarity threshold:', similarityThreshold);
console.log('Rerank threshold:', rerankSimilarityThreshold); this.logger.log('Rerank threshold:', rerankSimilarityThreshold);
console.log('Query expansion:', enableQueryExpansion); this.logger.log('Query expansion:', enableQueryExpansion);
console.log('HyDE:', enableHyDE); this.logger.log('HyDE:', enableHyDE);
console.log('Model configuration:', { this.logger.log('Model configuration:', {
name: modelConfig.name, name: modelConfig.name,
modelId: modelConfig.modelId, modelId: modelConfig.modelId,
baseUrl: modelConfig.baseUrl, baseUrl: modelConfig.baseUrl,
}); });
console.log( this.logger.log(
'API Key prefix:', 'API Key prefix:',
modelConfig.apiKey?.substring(0, 10) + '...', 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) // Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service)
// Use actual language based on user settings // Use actual language based on user settings
@@ -113,7 +113,7 @@ export class ChatService {
selectedGroups, selectedGroups,
); );
currentHistoryId = searchHistory.id; currentHistoryId = searchHistory.id;
console.log( this.logger.log(
this.i18nService.getMessage( this.i18nService.getMessage(
'creatingHistory', 'creatingHistory',
effectiveUserLanguage, effectiveUserLanguage,
@@ -143,7 +143,7 @@ export class ChatService {
); );
} }
console.log( this.logger.log(
this.i18nService.getMessage( this.i18nService.getMessage(
'usingEmbeddingModel', 'usingEmbeddingModel',
effectiveUserLanguage, effectiveUserLanguage,
@@ -156,7 +156,7 @@ export class ChatService {
); );
// 2. Search using user's query directly // 2. Search using user's query directly
console.log( this.logger.log(
this.i18nService.getMessage('startingSearch', effectiveUserLanguage), this.i18nService.getMessage('startingSearch', effectiveUserLanguage),
); );
yield { yield {
@@ -204,7 +204,7 @@ export class ChatService {
// HybridSearch returns ES hit structure, but RagSearchResult is normalized // HybridSearch returns ES hit structure, but RagSearchResult is normalized
// BuildContext expects {fileName, content}. RagSearchResult has these // BuildContext expects {fileName, content}. RagSearchResult has these
searchResults = ragResults; searchResults = ragResults;
console.log( this.logger.log(
this.i18nService.getMessage( this.i18nService.getMessage(
'searchResultsCount', 'searchResultsCount',
effectiveUserLanguage, effectiveUserLanguage,
@@ -274,7 +274,7 @@ export class ChatService {
}; };
} }
} catch (searchError) { } catch (searchError) {
console.error( this.logger.error(
this.i18nService.getMessage( this.i18nService.getMessage(
'searchFailedLog', 'searchFailedLog',
effectiveUserLanguage, effectiveUserLanguage,
@@ -461,14 +461,14 @@ ${instruction}`;
try { try {
// Join keywords into search string // Join keywords into search string
const combinedQuery = keywords.join(' '); const combinedQuery = keywords.join(' ');
console.log( this.logger.log(
this.i18nService.getMessage('searchString', userLanguage) + this.i18nService.getMessage('searchString', userLanguage) +
combinedQuery, combinedQuery,
); );
// Check if embedding model ID is provided // Check if embedding model ID is provided
if (!embeddingModelId) { if (!embeddingModelId) {
console.log( this.logger.log(
this.i18nService.getMessage( this.i18nService.getMessage(
'embeddingModelIdNotProvided', 'embeddingModelIdNotProvided',
userLanguage, userLanguage,
@@ -478,7 +478,7 @@ ${instruction}`;
} }
// Use actual embedding vector // Use actual embedding vector
console.log( this.logger.log(
this.i18nService.getMessage('generatingEmbeddings', userLanguage), this.i18nService.getMessage('generatingEmbeddings', userLanguage),
); );
const queryEmbedding = await this.embeddingService.getEmbeddings( const queryEmbedding = await this.embeddingService.getEmbeddings(
@@ -486,7 +486,7 @@ ${instruction}`;
embeddingModelId, embeddingModelId,
); );
const queryVector = queryEmbedding[0]; const queryVector = queryEmbedding[0];
console.log( this.logger.log(
this.i18nService.getMessage('embeddingsGenerated', userLanguage) + this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
this.i18nService.getMessage('dimensions', userLanguage) + this.i18nService.getMessage('dimensions', userLanguage) +
':', ':',
@@ -494,7 +494,7 @@ ${instruction}`;
); );
// Hybrid search // Hybrid search
console.log( this.logger.log(
this.i18nService.getMessage('performingHybridSearch', userLanguage), this.i18nService.getMessage('performingHybridSearch', userLanguage),
); );
const results = await this.elasticsearchService.hybridSearch( const results = await this.elasticsearchService.hybridSearch(
@@ -507,7 +507,7 @@ ${instruction}`;
explicitFileIds, // Pass explicit file IDs explicitFileIds, // Pass explicit file IDs
tenantId, // Pass tenant ID tenantId, // Pass tenant ID
); );
console.log( this.logger.log(
this.i18nService.getMessage('esSearchCompleted', userLanguage) + this.i18nService.getMessage('esSearchCompleted', userLanguage) +
this.i18nService.getMessage('resultsCount', userLanguage) + this.i18nService.getMessage('resultsCount', userLanguage) +
':', ':',
@@ -516,7 +516,7 @@ ${instruction}`;
return results.slice(0, 10); return results.slice(0, 10);
} catch (error) { } catch (error) {
console.error( this.logger.error(
this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':', this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':',
error, error,
); );
+7 -3
View File
@@ -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. * 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 { try {
return JSON.parse(jsonStr) as T; return JSON.parse(jsonStr) as T;
} catch (error) { } catch (error) {
console.error('[safeParseJson] Failed to parse JSON:', error); logger.error('[safeParseJson] Failed to parse JSON:', error);
console.error('[safeParseJson] Original text:', text); logger.error('[safeParseJson] Original text:', text);
console.error('[safeParseJson] Extracted string:', jsonStr); logger.error('[safeParseJson] Extracted string:', jsonStr);
return null; return null;
} }
} }
@@ -9,6 +9,7 @@ import {
UseGuards, UseGuards,
Request, Request,
Query, Query,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { CombinedAuthGuard } from '../auth/combined-auth.guard'; import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -24,6 +25,8 @@ import { I18nService } from '../i18n/i18n.service';
@Controller('knowledge-groups') @Controller('knowledge-groups')
@UseGuards(CombinedAuthGuard, RolesGuard) @UseGuards(CombinedAuthGuard, RolesGuard)
export class KnowledgeGroupController { export class KnowledgeGroupController {
private readonly logger = new Logger(KnowledgeGroupController.name);
constructor( constructor(
private readonly groupService: KnowledgeGroupService, private readonly groupService: KnowledgeGroupService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
@@ -43,7 +46,7 @@ export class KnowledgeGroupController {
@Post() @Post()
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
async create(@Body() createGroupDto: CreateGroupDto, @Request() req) { 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( return await this.groupService.create(
req.user.id, req.user.id,
req.user.tenantId, req.user.tenantId,
@@ -1,5 +1,6 @@
import { import {
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
ForbiddenException, ForbiddenException,
Inject, Inject,
@@ -47,6 +48,8 @@ export interface PaginatedGroups {
@Injectable() @Injectable()
export class KnowledgeGroupService { export class KnowledgeGroupService {
private readonly logger = new Logger(KnowledgeGroupService.name);
constructor( constructor(
@InjectRepository(KnowledgeGroup) @InjectRepository(KnowledgeGroup)
private groupRepository: Repository<KnowledgeGroup>, private groupRepository: Repository<KnowledgeGroup>,
@@ -62,7 +65,7 @@ export class KnowledgeGroupService {
userId: string, userId: string,
tenantId: string, tenantId: string,
): Promise<GroupWithFileCount[]> { ): 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 // Return all groups for the tenant with file counts
const queryBuilder = this.groupRepository const queryBuilder = this.groupRepository
.createQueryBuilder('group') .createQueryBuilder('group')
@@ -147,7 +150,7 @@ export class KnowledgeGroupService {
tenantId: string, tenantId: string,
createGroupDto: CreateGroupDto, createGroupDto: CreateGroupDto,
): Promise<KnowledgeGroup> { ): Promise<KnowledgeGroup> {
console.log('[KnowledgeGroup create] userId:', userId, 'tenantId:', tenantId); this.logger.log('[KnowledgeGroup create] userId: ' + userId + ', tenantId: ' + tenantId);
const group = this.groupRepository.create({ const group = this.groupRepository.create({
...createGroupDto, ...createGroupDto,
parentId: createGroupDto.parentId ?? null, parentId: createGroupDto.parentId ?? null,
@@ -155,7 +158,7 @@ export class KnowledgeGroupService {
}); });
const saved = await this.groupRepository.save(group); 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; return saved;
} }
@@ -229,7 +232,7 @@ export class KnowledgeGroupService {
); );
} }
} catch (error) { } catch (error) {
console.error( this.logger.error(
`Failed to delete file ${file.id} when deleting group ${id}`, `Failed to delete file ${file.id} when deleting group ${id}`,
error, error,
); );
@@ -257,7 +260,6 @@ export class KnowledgeGroupService {
throw new NotFoundException(this.i18nService.getMessage('groupNotFound')); throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
} }
// Check permission using TenantService
const hasAccess = await this.tenantService.canAccessTenant( const hasAccess = await this.tenantService.canAccessTenant(
userId, userId,
group.tenantId, 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( async addFilesToGroup(
+5 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Note } from './note.entity'; import { Note } from './note.entity';
@@ -11,6 +11,8 @@ import { I18nService } from '../i18n/i18n.service';
@Injectable() @Injectable()
export class NoteService { export class NoteService {
private readonly logger = new Logger(NoteService.name);
// Directory will be created dynamically per user // Directory will be created dynamically per user
private getScreenshotsDir(userId: string) { private getScreenshotsDir(userId: string) {
return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId); return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId);
@@ -153,7 +155,7 @@ export class NoteService {
} }
// Optional: Add logging to help debug permission issues // 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 === '') { if (categoryId === '') {
@@ -176,7 +178,7 @@ export class NoteService {
screenshot.buffer, screenshot.buffer,
); );
} catch (error) { } catch (error) {
console.error('OCR extraction failed:', error); this.logger.error('OCR extraction failed:', error);
// Continue without OCR text if extraction fails // Continue without OCR text if extraction fails
} }
+7 -4
View File
@@ -1,5 +1,6 @@
import { import {
Controller, Controller,
Logger,
Post, Post,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -14,6 +15,8 @@ import { I18nService } from '../i18n/i18n.service';
@UseGuards(CombinedAuthGuard) @UseGuards(CombinedAuthGuard)
@UseGuards(CombinedAuthGuard) @UseGuards(CombinedAuthGuard)
export class OcrController { export class OcrController {
private readonly logger = new Logger(OcrController.name);
constructor( constructor(
private readonly ocrService: OcrService, private readonly ocrService: OcrService,
private readonly i18n: I18nService, private readonly i18n: I18nService,
@@ -22,14 +25,14 @@ export class OcrController {
@Post('recognize') @Post('recognize')
@UseInterceptors(FileInterceptor('image')) @UseInterceptors(FileInterceptor('image'))
async recognizeText(@UploadedFile() image: Express.Multer.File) { async recognizeText(@UploadedFile() image: Express.Multer.File) {
console.log('OCR recognition endpoint called'); this.logger.log('OCR recognition endpoint called');
if (!image) { if (!image) {
console.error('No image uploaded'); this.logger.error('No image uploaded');
throw new Error(this.i18n.getMessage('noImageUploaded')); 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); 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 }; return { text };
} }
} }
+2 -5
View File
@@ -171,7 +171,7 @@ export class UserService implements OnModuleInit {
} }
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
console.log( this.logger.log(
`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`, `[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`,
); );
const user = await this.usersRepository.save({ const user = await this.usersRepository.save({
@@ -403,10 +403,7 @@ export class UserService implements OnModuleInit {
role: UserRole.SUPER_ADMIN, role: UserRole.SUPER_ADMIN,
}); });
console.log('\n=== Admin account created ==='); this.logger.log('Admin account created (username: admin, password: ' + randomPassword + ')');
console.log('Username: admin');
console.log('Password:', randomPassword);
console.log('========================================\n');
} }
} }
} }
@@ -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 countbased 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}`);
}
}
+214
View File
@@ -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);
}
});
});
});
+12
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
cd /d D:\AuraK\server
node --enable-source-maps dist/main
pause
+3
View File
@@ -0,0 +1,3 @@
cd /d D:\AuraK\web
npx vite --port 13001
pause
+1 -1
View File
@@ -34,7 +34,7 @@ export const WorkspaceLayout: React.FC<WorkspaceLayoutProps> = ({
appMode={appMode} appMode={appMode}
onSwitchMode={onSwitchMode} onSwitchMode={onSwitchMode}
/> />
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-auto relative">
{children} {children}
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../contexts/ConfirmContext'; import { useConfirm } from '../../contexts/ConfirmContext';
import { templateService } from '../../services/templateService'; import { templateService } from '../../services/templateService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService'; 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 = () => { export const AssessmentTemplateManager: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@@ -29,8 +29,12 @@ export const AssessmentTemplateManager: React.FC = () => {
difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%', difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
style: 'Professional', style: 'Professional',
knowledgeGroupId: '', knowledgeGroupId: '',
passingScore: 6,
totalTimeLimit: 1800,
perQuestionTimeLimit: 300,
}); });
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
const fetchTemplates = async () => { const fetchTemplates = async () => {
setIsLoading(true); setIsLoading(true);
@@ -72,7 +76,11 @@ export const AssessmentTemplateManager: React.FC = () => {
: (template.difficultyDistribution || ''), : (template.difficultyDistribution || ''),
style: template.style || 'Professional', style: template.style || 'Professional',
knowledgeGroupId: template.knowledgeGroupId || '', knowledgeGroupId: template.knowledgeGroupId || '',
passingScore: template.passingScore ? template.passingScore / 10 : 6,
totalTimeLimit: template.totalTimeLimit ?? 1800,
perQuestionTimeLimit: template.perQuestionTimeLimit ?? 300,
}); });
setDimensions(template.dimensions || []);
} else { } else {
setEditingTemplate(null); setEditingTemplate(null);
setFormData({ setFormData({
@@ -83,7 +91,11 @@ export const AssessmentTemplateManager: React.FC = () => {
difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}', difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
style: 'Professional', style: 'Professional',
knowledgeGroupId: '', knowledgeGroupId: '',
passingScore: 6,
totalTimeLimit: 1800,
perQuestionTimeLimit: 300,
}); });
setDimensions([]);
} }
setShowModal(true); setShowModal(true);
}; };
@@ -95,13 +107,10 @@ export const AssessmentTemplateManager: React.FC = () => {
// Convert UI strings back to required types // Convert UI strings back to required types
const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== ''); const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
let diffDist: any = formData.difficultyDistribution; let diffDist: any = formData.difficultyDistribution;
try { if (typeof diffDist === 'string' && diffDist.trim().startsWith('{')) {
if (formData.difficultyDistribution.startsWith('{')) { try { diffDist = JSON.parse(diffDist); } catch (e) { diffDist = undefined; }
diffDist = JSON.parse(formData.difficultyDistribution);
}
} catch (e) {
// Keep as string if parsing fails
} }
if (typeof diffDist !== 'object' || diffDist === null) diffDist = undefined;
const payload: CreateTemplateData = { const payload: CreateTemplateData = {
name: formData.name, name: formData.name,
@@ -111,6 +120,10 @@ export const AssessmentTemplateManager: React.FC = () => {
difficultyDistribution: diffDist, difficultyDistribution: diffDist,
style: formData.style, style: formData.style,
knowledgeGroupId: formData.knowledgeGroupId || undefined, knowledgeGroupId: formData.knowledgeGroupId || undefined,
dimensions: dimensions.length > 0 ? dimensions : undefined,
passingScore: formData.passingScore * 10,
totalTimeLimit: formData.totalTimeLimit,
perQuestionTimeLimit: formData.perQuestionTimeLimit,
}; };
if (editingTemplate) { if (editingTemplate) {
@@ -122,9 +135,10 @@ export const AssessmentTemplateManager: React.FC = () => {
} }
setShowModal(false); setShowModal(false);
fetchTemplates(); fetchTemplates();
} catch (error) { } catch (error: any) {
console.error('Save failed:', error); console.error('Save failed:', error);
showError(t('actionFailed')); const msg = error?.message;
showError(msg && msg !== 'Request failed' ? msg : t('actionFailed'));
} finally { } finally {
setIsSaving(false); 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) => { const handleDelete = async (id: string) => {
if (!(await confirm(t('confirmTitle')))) return; if (!(await confirm(t('confirmTitle')))) return;
try { try {
@@ -255,6 +283,16 @@ export const AssessmentTemplateManager: React.FC = () => {
</span> </span>
</div> </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"> <div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => ( {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"> <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">
@@ -383,6 +421,89 @@ export const AssessmentTemplateManager: React.FC = () => {
onChange={e => setFormData({ ...formData, style: e.target.value })} onChange={e => setFormData({ ...formData, style: e.target.value })}
/> />
</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">
<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> </div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
+219 -24
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { import {
Brain, Brain,
Send, Send,
@@ -13,7 +14,8 @@ import {
Star, Star,
Award, Award,
Trophy, Trophy,
Trash2 Trash2,
XCircle
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '../../contexts/LanguageContext'; import { useLanguage } from '../../contexts/LanguageContext';
@@ -51,6 +53,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]); const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | 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); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -103,6 +110,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
setTimeCheck(data); setTimeCheck(data);
if (data.isTotalTimeout || data.isQuestionTimeout) { if (data.isTotalTimeout || data.isQuestionTimeout) {
setError(t('timeLimitExceeded')); setError(t('timeLimitExceeded'));
if (!autoSubmitted && !isLoading) {
setAutoSubmitted(true);
await handleSubmitAnswer(true);
}
} }
} catch (err) { } catch (err) {
console.error('Failed to check time:', err); console.error('Failed to check time:', err);
@@ -137,7 +148,11 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
setState(histState); setState(histState);
setSession(histSession); setSession(histSession);
} catch (err: any) { } catch (err: any) {
if (histSession.status === 'IN_PROGRESS') {
setError(t('cannotResumeInProgress'));
} else {
setError(err.message || 'Failed to load historical assessment'); setError(err.message || 'Failed to load historical assessment');
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setLoadingHistoryId(null); setLoadingHistoryId(null);
@@ -184,7 +199,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
...prev, ...prev,
...event.data, ...event.data,
messages: event.data.messages 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, : prevMessages,
feedbackHistory: event.data.feedbackHistory feedbackHistory: event.data.feedbackHistory
? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))] ? [...(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 () => { const handleSubmitAnswer = async (forced = false) => {
if (!session || !inputValue.trim() || isLoading) return; 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(''); setInputValue('');
setSelectedChoice(null);
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...'); setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
@@ -252,7 +277,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
if (!prev) return event.data; if (!prev) return event.data;
const prevMessages = prev.messages || []; const prevMessages = prev.messages || [];
const mergedMessages = event.data.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; : prevMessages;
return { return {
@@ -428,7 +453,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
{/* Assessment History Sidebar */} {/* Assessment History Sidebar */}
{history.length > 0 && ( {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"> <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" /> <History size={18} className="text-indigo-600" />
{t('recentAssessments')} {t('recentAssessments')}
@@ -502,6 +527,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
!(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:'))) !(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 feedbackHistory = state?.feedbackHistory || [];
const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1]; const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
@@ -576,26 +605,79 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
</div> </div>
<div className="p-6 bg-white border-t border-slate-200/60 shadow-[0_-4px_20px_-10px_rgba(0,0,0,0.05)]"> <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"> <div className="max-w-3xl mx-auto flex items-end gap-3">
<textarea <textarea
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey && !isTimedOut) {
e.preventDefault(); e.preventDefault();
handleSubmitAnswer(); handleSubmitAnswer();
} }
}} }}
placeholder={t('typeAnswerPlaceholder')} placeholder={isTimedOut ? t('timeLimitExceeded') : 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" 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} rows={1}
/> />
<button <button
onClick={handleSubmitAnswer} onClick={handleSubmitAnswer}
disabled={!inputValue.trim() || isLoading} disabled={!inputValue.trim() || isLoading || isTimedOut}
className={cn( className={cn(
"w-14 h-14 flex items-center justify-center rounded-2xl transition-all shadow-lg", "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-slate-100 text-slate-400 shadow-none"
: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200 active:scale-95" : "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" : ""} /> <Send size={22} className={isLoading ? "animate-pulse" : ""} />
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>
{/* Right: Feedback Panel */} {/* 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"> <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" /> <ClipboardCheck size={18} className="text-indigo-600" />
{t('liveFeedback')} {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="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('status')}</span>
<span className={cn( <span className={cn(
"text-2xl font-black uppercase tracking-tighter", "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> </span>
</div> </div>
</div> </div>
<div className="space-y-8"> <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> <div>
<h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4"> <h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
<FileText size={20} className="text-indigo-600" /> <FileText size={20} className="text-indigo-600" />
@@ -777,15 +920,14 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
if (!session) return; if (!session) return;
try { try {
const result = await assessmentService.exportPdf(session.id); 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 url = URL.createObjectURL(blob);
const a = document.createElement('a'); window.open(url, '_blank');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
} catch (err) { } 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]" 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(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (err) { } 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]" 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; if (!session) return;
try { try {
const cert = await assessmentService.getCertificate(session.id); 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) { } catch (err) {
console.error('Failed to get certificate:', 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"> <div className="flex flex-col h-full bg-white animate-in flex-1">
{renderHeader()} {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"> <AnimatePresence mode="wait">
{error && ( {error && (
<motion.div <motion.div
+292 -160
View File
@@ -3,35 +3,31 @@ import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
ChevronLeft, Plus, Sparkles, Send, Check, X, ChevronLeft, Plus, Sparkles, Send, Check, X, XCircle, Clock,
Trash2, Edit2, FileText, Loader2, BookOpen, Brain, Trash2, Edit2, FileText, Loader2, BookOpen, Brain,
AlertCircle, Hash, Layers AlertCircle, Hash, Layers
} from 'lucide-react'; } from 'lucide-react';
import { questionBankService, QuestionBank, QuestionBankItem, CreateQuestionBankItemDto } from '../../services/questionBankService'; import { questionBankService, QuestionBank, QuestionBankItem, CreateQuestionBankItemDto } from '../../services/questionBankService';
import { templateService } from '../../services/templateService'; import { templateService } from '../../services/templateService';
import { AssessmentTemplate } from '../../types'; import { AssessmentTemplate } from '../../types';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useLanguage } from '../../contexts/LanguageContext';
const QUESTION_TYPES = [ const QUESTION_TYPES = [
{ value: 'SHORT_ANSWER', label: '简答题' }, { value: 'SHORT_ANSWER', labelKey: 'shortAnswer' as const },
{ value: 'MULTIPLE_CHOICE', label: '选择题' }, { value: 'MULTIPLE_CHOICE', labelKey: 'multipleChoice' as const },
{ value: 'TRUE_FALSE', label: '判断题' }, { value: 'TRUE_FALSE', labelKey: 'trueFalse' as const },
]; ];
const DIFFICULTIES = [ const DIFFICULTIES = [
{ value: 'STANDARD', label: '标准' }, { value: 'STANDARD', labelKey: 'standard' as const },
{ value: 'ADVANCED', label: '高级' }, { value: 'ADVANCED', labelKey: 'advanced' as const },
{ value: 'SPECIALIST', label: '专家' }, { value: 'SPECIALIST', labelKey: 'specialist' as const },
]; ];
const DIMENSIONS = [ type TypeIcon = { [key: string]: React.ReactNode };
{ value: 'PROMPT', label: 'Prompt' }, const typeIcons: TypeIcon = {
{ value: 'LLM', label: 'LLM' },
{ value: 'IDE', label: 'IDE' },
{ value: 'DEV_PATTERN', label: '开发模式' },
{ value: 'WORK_CAPABILITY', label: '工作能力' },
];
const typeIcons: Record<string, React.ReactNode> = {
SHORT_ANSWER: <FileText size={12} />, SHORT_ANSWER: <FileText size={12} />,
MULTIPLE_CHOICE: <Layers size={12} />, MULTIPLE_CHOICE: <Layers size={12} />,
TRUE_FALSE: <Check size={12} />, TRUE_FALSE: <Check size={12} />,
@@ -40,14 +36,19 @@ const typeIcons: Record<string, React.ReactNode> = {
export default function QuestionBankDetailView() { export default function QuestionBankDetailView() {
const { id: bankId } = useParams<{ id: string }>(); const { id: bankId } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useLanguage();
const { showSuccess, showError } = useToast();
const { confirm } = useConfirm();
if (!bankId) { if (!bankId) {
return ( return (
<div className="p-6"> <div className="p-6">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"> <button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors mb-4">
<ChevronLeft size={20} /> <ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
</button> </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> </div>
); );
} }
@@ -55,6 +56,7 @@ export default function QuestionBankDetailView() {
const [bank, setBank] = useState<QuestionBank | null>(null); const [bank, setBank] = useState<QuestionBank | null>(null);
const [items, setItems] = useState<QuestionBankItem[]>([]); const [items, setItems] = useState<QuestionBankItem[]>([]);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]); const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [template, setTemplate] = useState<AssessmentTemplate | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -72,30 +74,96 @@ export default function QuestionBankDetailView() {
}); });
const [keyPointsInput, setKeyPointsInput] = useState(''); const [keyPointsInput, setKeyPointsInput] = useState('');
const [generateForm, setGenerateForm] = useState({ const [generateForm, setGenerateForm] = useState({ count: 5, knowledgeBaseContent: '' });
count: 5,
knowledgeBaseContent: '',
});
const [generating, setGenerating] = useState(false); 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]); useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]);
const fetchData = async () => { const fetchData = async () => {
try { setLoading(true); try {
setLoading(true);
const bankData = await questionBankService.getBank(bankId); const bankData = await questionBankService.getBank(bankId);
setBank(bankData); setBank(bankData);
const itemsData = await questionBankService.getBankItems(bankId); const itemsData = await questionBankService.getBankItems(bankId);
setItems(itemsData); setItems(itemsData);
} catch (err: any) { setError(err.message || '加载失败'); } catch (err: any) {
} finally { setLoading(false); } setError(err.message || t('actionFailed'));
showError(err.message || t('actionFailed'));
} finally {
setLoading(false);
}
}; };
const fetchTemplates = async () => { const fetchTemplates = async () => {
try { const data = await templateService.getAll(); setTemplates(data); try {
} catch (err) { console.error('加载模板失败:', err); } 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) => { const handleCreateItem = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!itemForm.questionText.trim()) return; if (!itemForm.questionText.trim()) return;
@@ -103,8 +171,9 @@ export default function QuestionBankDetailView() {
try { try {
await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) }); await questionBankService.createItem(bankId, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm(); closeItemForm();
showSuccess(t('questionAdded'));
fetchData(); fetchData();
} catch (err: any) { alert('创建失败: ' + (err.message || '未知错误')); } catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setSaving(false); } } finally { setSaving(false); }
}; };
@@ -115,15 +184,17 @@ export default function QuestionBankDetailView() {
try { try {
await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) }); await questionBankService.updateItem(bankId, editingItem.id, { ...itemForm, keyPoints: keyPointsInput.split('\n').filter(k => k.trim()) });
closeItemForm(); closeItemForm();
showSuccess(t('questionUpdated'));
fetchData(); fetchData();
} catch (err: any) { alert('更新失败: ' + (err.message || '未知错误')); } catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setSaving(false); } } finally { setSaving(false); }
}; };
const handleDeleteItem = async (itemId: string) => { const handleDeleteItem = async (itemId: string) => {
if (!confirm('确定要删除这道题目吗?')) return; const ok = await confirm({ message: t('confirmDeleteQuestion'), confirmLabel: t('delete'), cancelLabel: t('cancel') });
try { await questionBankService.deleteItem(bankId, itemId); fetchData(); if (!ok) return;
} catch (err: any) { alert('删除失败: ' + (err.message || '未知错误')); } try { await questionBankService.deleteItem(bankId, itemId); showSuccess(t('questionDeleted')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
}; };
const handleGenerate = async () => { const handleGenerate = async () => {
@@ -132,26 +203,41 @@ export default function QuestionBankDetailView() {
await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent); await questionBankService.generateQuestions(bankId, generateForm.count, generateForm.knowledgeBaseContent);
setShowGenerate(false); setShowGenerate(false);
setGenerateForm({ count: 5, knowledgeBaseContent: '' }); setGenerateForm({ count: 5, knowledgeBaseContent: '' });
showSuccess(t('generatedQuestions').replace('$1', String(generateForm.count)));
fetchData(); fetchData();
} catch (err: any) { alert('生成失败: ' + (err.message || '未知错误')); } catch (err: any) { showError(err.message || t('actionFailed'));
} finally { setGenerating(false); } } finally { setGenerating(false); }
}; };
const handleSubmitForReview = async () => { const handleSubmitForReview = async () => {
if (!confirm('确定要提交审核吗?')) return; const ok = await confirm({ message: t('confirmSubmitReview'), confirmLabel: t('submitForReview'), cancelLabel: t('cancel') });
try { await questionBankService.submitForReview(bankId); fetchData(); if (!ok) return;
} catch (err: any) { alert('提交失败: ' + (err.message || '未知错误')); } try { await questionBankService.submitForReview(bankId); showSuccess(t('bankSubmittedForReview')); fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
}; };
const handlePublish = async () => { const handlePublish = async () => {
if (!confirm('确定要发布题库吗?')) return; const isPendingReview = bank?.status === 'PENDING_REVIEW';
try { await questionBankService.publishBank(bankId); fetchData(); const label = isPendingReview ? t('approve') : t('republish');
} catch (err: any) { alert('发布失败: ' + (err.message || '未知错误')); } 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) => { const handleApproveItem = async (itemId: string) => {
try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); fetchData(); try { await questionBankService.updateItem(bankId, itemId, { status: 'PUBLISHED' } as any); showSuccess(t('questionApproved')); fetchData();
} catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); } } 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) => { const openEditItem = (item: QuestionBankItem) => {
@@ -163,27 +249,24 @@ export default function QuestionBankDetailView() {
const closeItemForm = () => { setShowAddItem(false); setEditingItem(null); }; 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) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-30" /> <Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="p-6"> <div className="space-y-4">
<button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"><ChevronLeft size={20} /> </button> <button onClick={() => navigate('/question-banks')} className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
<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> <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> </div>
); );
} }
@@ -191,100 +274,182 @@ export default function QuestionBankDetailView() {
const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW'); const pendingItems = items.filter(i => i.status === 'PENDING_REVIEW');
const publishedItems = items.filter(i => i.status === 'PUBLISHED'); 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 ( return (
<div className="space-y-6"> <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 mb-2"> <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"></span> <ChevronLeft size={18} /><span className="text-xs font-black uppercase tracking-widest">{t('backToBankList')}</span>
</button> </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="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> <div>
<h1 className="text-2xl font-black text-slate-900">{bank?.name}</h1> <h1 className="text-2xl font-black text-slate-900">{bank?.name}</h1>
<p className="text-sm text-slate-500 mt-1">{bank?.description || '暂无描述'}</p> <p className="text-sm text-slate-500 mt-1">{bank?.description || t('noDescription')}</p>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2 flex-wrap">
<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> {template && (
{getStatusBadge(bank?.status || 'DRAFT')} <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>
</div> </div>
<div className="flex gap-2">
<div className="flex gap-2 shrink-0">
{bank?.status === 'DRAFT' && ( {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"> <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> </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"> <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>
)} )}
<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"> <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} /> AI生成 <Sparkles size={16} /> {t('aiGenerate')}
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{[ {statCards.map((stat, i) => (
{ label: '总题目数', value: items.length, color: 'blue', icon: <FileText size={16} /> }, <motion.div key={stat.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08 }}
{ label: '待审核', value: pendingItems.length, color: 'amber', icon: <Send size={16} /> }, className={`rounded-2xl border p-4 ${stat.classes}`}>
{ 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`}>
<div className="flex items-center justify-between mb-2"> <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-[10px] font-black uppercase tracking-widest opacity-70">{stat.label}</span>
<span className={`text-${stat.color}-500`}>{stat.icon}</span> {stat.icon}
</div> </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> </motion.div>
))} ))}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-black text-slate-900"></h2> <h2 className="text-lg font-black text-slate-900">{t('questionList')}</h2>
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: 'WORK_CAPABILITY' }); }} <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"> 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} /> <Plus size={16} /> {t('addQuestion')}
</button> </button>
</div> </div>
</div>
{items.length === 0 ? ( {items.length === 0 ? (
<div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center"> <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" /> <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-bold uppercase tracking-widest text-xs"></p> <p className="text-slate-400 font-black uppercase tracking-widest text-xs mb-1">{t('noQuestions')}</p>
<p className="text-slate-300 text-xs mt-2">使AI生成</p> <p className="text-slate-300 text-xs">{t('noQuestionsDesc')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4"> <div className="space-y-4">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{items.map((item, idx) => ( {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 }} 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"> 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={`absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl -mr-20 -mt-20 ${itemStat.blur}`} />
<div className="flex items-start justify-between relative z-10"> <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" />
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2.5 flex-wrap"> <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-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} />{DIFFICULTIES.find(d => d.value === item.difficulty)?.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} />{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} />{DIMENSIONS.find(d => d.value === item.dimension)?.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} />{dimensionOptions.find(d => d.value === item.dimension)?.label || item.dimension}</span>
{getStatusBadge(item.status)} <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> </div>
<p className="font-bold text-slate-900 leading-relaxed">{item.questionText}</p> <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 && ( {item.keyPoints.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5"> <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">:</span> <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>)} {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> </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"></span><span>{item.basis}</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>
<div className="flex gap-1 ml-4 shrink-0"> <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="通过"><Check size={15} /></button>} {item.status === 'PENDING_REVIEW' && (<>
<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={() => 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={() => handleDeleteItem(item.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-xl transition-all" title="删除"><Trash2 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> </div>
</motion.div> </motion.div>
))} );
})}
</AnimatePresence> </AnimatePresence>
</div> </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 }} <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"> 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="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
<div className="flex items-center gap-3"> <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>
<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>
<h3 className="text-xl font-black text-slate-900">{editingItem ? '编辑题目' : '添加题目'}</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> <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> </div>
<form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 space-y-5"> <form id="item-form" onSubmit={editingItem ? handleUpdateItem : handleCreateItem} className="p-8 space-y-5">
<div className="space-y-1.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" /> {t('questionContent')} <span className="text-red-500">*</span></label>
<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={t('questionContent')} rows={3} required />
<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> </div>
<div className="grid grid-cols-2 gap-5"> <div className="grid grid-cols-2 gap-5">
<div className="space-y-1.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" /> {t('questionType')}</label>
<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 cursor-pointer">{ QUESTION_TYPES.map(qt => <option key={qt.value} value={qt.value}>{t(qt.labelKey)}</option>) }</select>
<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> </div>
<div className="space-y-1.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-blue-500" /> {t('difficultyDistribution')}</label>
<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 cursor-pointer">{ DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{t(d.labelKey)}</option>) }</select>
<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> </div>
</div> </div>
<div className="space-y-1.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"><Brain size={12} className="text-blue-500" /> {t('dimension')}</label>
<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 cursor-pointer">{ dimensionOptions.map(d => <option key={d.value} value={d.value}>{d.label}</option>) }</select>
<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> </div>
<div className="space-y-1.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"><AlertCircle size={12} className="text-blue-500" /> {t('gradingPoints')}</label>
<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\n2\n3'} rows={4} />
<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> </div>
<div className="flex justify-end gap-3 pt-4"> <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="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} <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>
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>
</div> </div>
</form> </form>
</motion.div> </motion.div>
</div> </div>
)} )}
</AnimatePresence>, </AnimatePresence>, document.body
document.body
)} )}
{createPortal( {createPortal(
@@ -361,35 +503,25 @@ 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 }} <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"> 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="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
<div className="flex items-center gap-3"> <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>
<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>
<h3 className="text-xl font-black text-slate-900">AI生成题目</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> <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>
<div className="p-8 space-y-5"> <div className="p-8 space-y-5">
<div className="space-y-1.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" /> {t('generateCount')}</label>
<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} />
<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> </div>
<p className="text-[10px] text-slate-400 px-1"></p>
<div className="flex gap-3 pt-4"> <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={() => 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} <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">
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>
{generating ? <><Loader2 size={16} className="animate-spin" /> ...</> : <><Sparkles size={16} /> </>}</button>
</div> </div>
</div> </div>
</motion.div> </motion.div>
</div> </div>
)} )}
</AnimatePresence>, </AnimatePresence>, document.body
document.body
)} )}
</div> </div>
); );
+244 -148
View File
@@ -1,16 +1,19 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { apiClient } from '../../services/apiClient';
import { templateService } from '../../services/templateService'; import { templateService } from '../../services/templateService';
import { questionBankService } from '../../services/questionBankService';
import { AssessmentTemplate } from '../../types'; import { AssessmentTemplate } from '../../types';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useLanguage } from '../../contexts/LanguageContext';
interface QuestionBankViewProps { interface QuestionBankViewProps {
isAdmin?: boolean; isAdmin?: boolean;
} }
interface QuestionBank { interface QuestionBankItem {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
@@ -19,25 +22,27 @@ interface QuestionBank {
createdAt: string; 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 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 [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showDrawer, setShowDrawer] = useState(false); const [showDrawer, setShowDrawer] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({ name: '', description: '', templateId: '' });
name: '',
description: '',
templateId: ''
});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]); const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false); const [loadingTemplates, setLoadingTemplates] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => { useEffect(() => { fetchData(); }, []);
fetchData();
}, []);
const fetchData = async () => { const fetchData = async () => {
try { try {
@@ -47,7 +52,8 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
const data = await res.json(); const data = await res.json();
setBanks(Array.isArray(data) ? data : (data.data || [])); setBanks(Array.isArray(data) ? data : (data.data || []));
} catch (err: any) { } catch (err: any) {
setError(err.message || '加载失败'); setError(err.message || t('actionFailed'));
showError(err.message || t('actionFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -58,7 +64,7 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
setLoadingTemplates(true); setLoadingTemplates(true);
templateService.getAll() templateService.getAll()
.then(data => setTemplates(data)) .then(data => setTemplates(data))
.catch(err => console.error('加载模板失败:', err)) .catch(() => showError(t('actionFailed')))
.finally(() => setLoadingTemplates(false)); .finally(() => setLoadingTemplates(false));
setShowDrawer(true); setShowDrawer(true);
}; };
@@ -66,17 +72,10 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!formData.name.trim()) return; if (!formData.name.trim()) return;
setSaving(true); setSaving(true);
try { try {
const payload: any = { const payload: any = { name: formData.name, description: formData.description };
name: formData.name, if (formData.templateId) payload.templateId = formData.templateId;
description: formData.description,
};
if (formData.templateId) {
payload.templateId = formData.templateId;
}
const res = await apiClient.request('/question-banks', { const res = await apiClient.request('/question-banks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 {} try { const parsed = JSON.parse(errBody); if (parsed.message) msg = parsed.message; } catch {}
throw new Error(msg); throw new Error(msg);
} }
setShowDrawer(false); setShowDrawer(false);
showSuccess(t('questionBankCreated'));
fetchData(); fetchData();
} catch (err: any) { } catch (err: any) {
console.error('创建失败:', err); showError(err.message || t('actionFailed'));
alert('创建失败: ' + (err.message || '未知错误'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -101,182 +99,280 @@ export default function QuestionBankView({ isAdmin }: QuestionBankViewProps) {
const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => { const handleDelete = async (e: React.MouseEvent, bankId: string, bankName: string) => {
e.stopPropagation(); 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); setDeletingId(bankId);
try { 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(); fetchData();
} catch (err: any) { } catch (err: any) {
console.error('删除失败:', err); showError(err.message || t('questionBankDeleteFailed'));
alert('删除失败: ' + (err.message || '未知错误'));
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }
}; };
const handleCardClick = (bank: QuestionBank) => { const handleCardClick = (bank: QuestionBankItem) => {
navigate(`/question-banks/${bank.id}`); 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 ( return (
<div className="p-6 bg-white min-h-screen"> <div className="space-y-6 overflow-y-auto h-full">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1> <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 <button
onClick={openDrawer} 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} /> <Plus size={18} />
<span></span> {t('createQuestionBank')}
</button> </button>
</div> </div>
{!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>
<div className="text-2xl font-black">{stat.value}</div>
</motion.div>
))}
</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 ? ( {loading ? (
<div className="text-center py-8 text-gray-500">...</div> <div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 opacity-20" />
</div>
) : error ? ( ) : error ? (
<div className="text-center py-8 text-red-500">: {error}</div> <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 ? ( ) : banks.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-20 text-center">
<BookOpen size={48} className="mx-auto mb-4 text-gray-300" /> <div className="w-16 h-16 bg-slate-100 rounded-3xl flex items-center justify-center mx-auto mb-6">
<p></p> <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>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{banks.map((bank) => ( <AnimatePresence mode="popLayout">
<div {filteredBanks.map((bank) => (
<motion.div
key={bank.id} key={bank.id}
className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer group relative" layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
onClick={() => handleCardClick(bank)} 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-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className={`absolute top-0 right-0 w-32 h-32 rounded-full blur-3xl -mr-16 -mt-16 ${
<button bank.status === 'PUBLISHED' ? 'bg-emerald-500/5' :
onClick={(e) => { e.stopPropagation(); handleCardClick(bank); }} bank.status === 'PENDING_REVIEW' ? 'bg-amber-500/5' :
className="p-1.5 text-gray-400 hover:text-blue-600 rounded-md bg-white border shadow-sm" bank.status === 'REJECTED' ? 'bg-red-500/5' : 'bg-blue-500/5'
title="编辑" }`} />
>
<Edit2 size={14} /> <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>
<button <button onClick={(e) => handleDelete(e, bank.id, bank.name)} disabled={deletingId === bank.id}
onClick={(e) => handleDelete(e, bank.id, bank.name)} 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')}>
disabled={deletingId === bank.id} {deletingId === bank.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
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> </button>
</div> </div>
<h3 className="font-semibold pr-16">{bank.name}</h3> </div>
<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"> <p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{bank.description || t('noDescription')}</p>
<span className={`text-xs px-2 py-0.5 rounded-full ${
bank.status === 'PUBLISHED' ? 'bg-green-100 text-green-700' : <div className="flex items-center justify-between pt-3 border-t border-slate-50">
bank.status === 'PENDING_REVIEW' ? 'bg-yellow-100 text-yellow-700' : <span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-widest rounded-full border ${
bank.status === 'REJECTED' ? 'bg-red-100 text-red-700' : bank.status === 'PUBLISHED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200/50' :
'bg-gray-100 text-gray-600' 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'
}`}> }`}>
{bank.status === 'PUBLISHED' ? '已发布' : {statusLabels[bank.status] || bank.status}
bank.status === 'PENDING_REVIEW' ? '待审核' :
bank.status === 'REJECTED' ? '已否决' : '草稿'}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-[10px] text-slate-400 font-medium">
{new Date(bank.createdAt).toLocaleDateString()} {new Date(bank.createdAt).toLocaleDateString('zh-CN')}
</span> </span>
</div> </div>
</div> </div>
</motion.div>
))} ))}
</AnimatePresence>
</div> </div>
)} )}
{/* Drawer */} <AnimatePresence>
<>
{showDrawer && ( {showDrawer && (
<div <>
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300" <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={() => setShowDrawer(false)} 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 }}
<div className="fixed right-0 top-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col">
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 items-center justify-between px-6 py-4 border-b border-slate-100">
> <div className="flex items-center gap-3">
<div className="flex flex-col h-full"> <div className="w-10 h-10 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center"><Plus size={22} /></div>
<div className="flex items-center justify-between px-6 py-4 border-b bg-slate-50"> <h2 className="text-lg font-black text-slate-900">{t('createQuestionBank')}</h2>
<h2 className="text-xl font-semibold text-slate-800 flex items-center gap-2"> </div>
<Plus className="w-6 h-6 text-blue-600" /> <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>
</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>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<form id="create-form" onSubmit={handleCreate} className="space-y-6"> <form id="create-form" onSubmit={handleCreate} className="space-y-5">
<div> <div className="space-y-1.5">
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
<span className="text-red-500">*</span> <BookOpen size={12} className="text-blue-500" /> {t('name')} <span className="text-red-500">*</span>
</label> </label>
<input <input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
type="text" 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"
value={formData.name} placeholder={t('name')} required autoFocus />
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>
<div> <div className="space-y-1.5">
<label className="block text-sm font-medium text-slate-700 mb-1"> <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> </label>
<input <input type="text" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
type="text" 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"
value={formData.description} placeholder={t('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>
<div> <div className="space-y-1.5">
<label className="block text-sm font-medium text-slate-700 mb-1"> <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> </label>
<select <select value={formData.templateId} onChange={(e) => setFormData({ ...formData, templateId: e.target.value })}
value={formData.templateId} 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"
onChange={(e) => setFormData({...formData, templateId: e.target.value})} disabled={loadingTemplates}>
className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50" <option value="">{t('noTemplate')}</option>
disabled={loadingTemplates} {templates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
>
<option value=""></option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select> </select>
{loadingTemplates && <span className="text-xs text-slate-500">...</span>} {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> </div>
</form> </form>
</div> </div>
<div className="p-6 border-t bg-slate-50"> <div className="p-6 border-t border-slate-100">
<button <button type="submit" form="create-form" disabled={saving || !formData.name.trim()}
type="submit" 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">
form="create-form" {saving ? <Loader2 size={18} className="animate-spin" /> : <Plus size={18} />}
disabled={saving || !formData.name.trim()} {saving ? t('creating') : t('createQuestionBank')}
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> </button>
</div> </div>
</div> </motion.div>
</div>
</> </>
)}
</AnimatePresence>
</div> </div>
); );
} }
+1 -1
View File
@@ -33,7 +33,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null') { if (activeTenantId && activeTenantId !== 'undefined' && activeTenantId !== 'null' && activeTenantId !== 'default') {
headers['x-tenant-id'] = activeTenantId; headers['x-tenant-id'] = activeTenantId;
} }
+5 -2
View File
@@ -26,6 +26,9 @@ export interface AssessmentState {
status?: 'IN_PROGRESS' | 'COMPLETED'; status?: 'IN_PROGRESS' | 'COMPLETED';
report?: string; report?: string;
finalScore?: number; finalScore?: number;
passed?: boolean;
dimensionScores?: Record<string, number>;
radarData?: Record<string, number>;
} }
export interface Certificate { export interface Certificate {
@@ -139,8 +142,8 @@ export class AssessmentService {
return data; return data;
} }
async exportPdf(sessionId: string): Promise<{ filename: string; content: string }> { async exportPdf(sessionId: string): Promise<{ filename: string; buffer: string }> {
const { data } = await apiClient.get<{ filename: string; content: string }>(`/assessment/${sessionId}/export/pdf`); const { data } = await apiClient.get<{ filename: string; buffer: string }>(`/assessment/${sessionId}/export/pdf`);
return data; return data;
} }
+2
View File
@@ -17,6 +17,8 @@ export interface QuestionBankItem {
questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE'; questionType: 'SHORT_ANSWER' | 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
options?: string[] | null; options?: string[] | null;
correctAnswer?: string | null; correctAnswer?: string | null;
judgment?: string | null;
followupHints?: string[] | null;
keyPoints: string[]; keyPoints: string[];
difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST'; difficulty: 'STANDARD' | 'ADVANCED' | 'SPECIALIST';
dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY'; dimension: 'PROMPT' | 'LLM' | 'IDE' | 'DEV_PATTERN' | 'WORK_CAPABILITY';
+14
View File
@@ -332,6 +332,12 @@ export interface TenantMember {
} }
// Assessment Template Types // Assessment Template Types
export interface AssessmentDimension {
name: string;
label: string;
weight: number;
}
export interface AssessmentTemplate { export interface AssessmentTemplate {
id: string; id: string;
name: string; name: string;
@@ -343,6 +349,10 @@ export interface AssessmentTemplate {
knowledgeBaseId?: string; knowledgeBaseId?: string;
knowledgeGroupId?: string; knowledgeGroupId?: string;
knowledgeGroup?: KnowledgeGroup; knowledgeGroup?: KnowledgeGroup;
dimensions?: AssessmentDimension[];
passingScore?: number;
totalTimeLimit?: number;
perQuestionTimeLimit?: number;
isActive: boolean; isActive: boolean;
version: number; version: number;
creatorId: string; creatorId: string;
@@ -359,6 +369,10 @@ export interface CreateTemplateData {
style?: string; style?: string;
knowledgeBaseId?: string; knowledgeBaseId?: string;
knowledgeGroupId?: string; knowledgeGroupId?: string;
dimensions?: AssessmentDimension[];
passingScore?: number;
totalTimeLimit?: number;
perQuestionTimeLimit?: number;
} }
export interface UpdateTemplateData extends Partial<CreateTemplateData> { export interface UpdateTemplateData extends Partial<CreateTemplateData> {
+232 -3
View File
@@ -636,6 +636,12 @@ export const translations = {
style: "风格要求", style: "风格要求",
createTemplate: "创建模板", createTemplate: "创建模板",
editTemplate: "编辑模板", editTemplate: "编辑模板",
templateDimensions: "评估维度",
dimensionName: "维度名称",
dimensionLabel: "维度标签",
dimensionWeight: "权重",
addDimension: "添加维度",
removeDimension: "删除",
allNotes: "所有笔记", allNotes: "所有笔记",
filterNotesPlaceholder: "筛选笔记...", filterNotesPlaceholder: "筛选笔记...",
@@ -813,7 +819,7 @@ export const translations = {
questionBasis: "出题依据", questionBasis: "出题依据",
viewBasis: "查看依据", viewBasis: "查看依据",
hideBasis: "隐藏依据", hideBasis: "隐藏依据",
verified: "已验证", verified: "合格",
fail: "失败", fail: "失败",
comprehensiveMasteryReport: "综合能力报告", comprehensiveMasteryReport: "综合能力报告",
newAssessmentSession: "新评测会话", newAssessmentSession: "新评测会话",
@@ -828,6 +834,8 @@ export const translations = {
deleteAssessmentSuccess: "评测记录已成功删除", deleteAssessmentSuccess: "评测记录已成功删除",
deleteAssessmentFailed: '删除评估记录失败', deleteAssessmentFailed: '删除评估记录失败',
view: '查看', view: '查看',
exportAssessmentFailed: '导出评估报告失败',
cannotResumeInProgress: '此评估进行中,无法恢复查看',
// Plugins // Plugins
pluginTitle: "插件中心", pluginTitle: "插件中心",
@@ -933,6 +941,74 @@ export const translations = {
allFormats: "所有格式支持", allFormats: "所有格式支持",
visualVision: "视觉识别", visualVision: "视觉识别",
releaseToIngest: "释放以注入", 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: { en: {
aiCommandsError: "An error occurred", aiCommandsError: "An error occurred",
@@ -1573,6 +1649,12 @@ export const translations = {
style: "Style Requirements", style: "Style Requirements",
createTemplate: "Create Template", createTemplate: "Create Template",
editTemplate: "Edit Template", editTemplate: "Edit Template",
templateDimensions: "Evaluation Dimensions",
dimensionName: "Dimension Name",
dimensionLabel: "Label",
dimensionWeight: "Weight",
addDimension: "Add Dimension",
removeDimension: "Remove",
allNotes: "All Notes", allNotes: "All Notes",
filterNotesPlaceholder: "Filter notes...", filterNotesPlaceholder: "Filter notes...",
@@ -1750,7 +1832,7 @@ export const translations = {
questionBasis: "Question Basis", questionBasis: "Question Basis",
viewBasis: "View Basis", viewBasis: "View Basis",
hideBasis: "Hide Basis", hideBasis: "Hide Basis",
verified: "Verified", verified: "Qualified",
fail: "Fail", fail: "Fail",
comprehensiveMasteryReport: "Comprehensive Mastery Report", comprehensiveMasteryReport: "Comprehensive Mastery Report",
newAssessmentSession: "New Assessment Session", newAssessmentSession: "New Assessment Session",
@@ -1765,6 +1847,8 @@ export const translations = {
deleteAssessmentSuccess: "Assessment record deleted successfully", deleteAssessmentSuccess: "Assessment record deleted successfully",
deleteAssessmentFailed: 'Failed to delete assessment record', deleteAssessmentFailed: 'Failed to delete assessment record',
view: 'View', view: 'View',
exportAssessmentFailed: 'Failed to export assessment report',
cannotResumeInProgress: 'Assessment in progress, cannot view',
// Plugins // Plugins
pluginTitle: "Plugin Store", pluginTitle: "Plugin Store",
@@ -1877,6 +1961,74 @@ export const translations = {
allFormats: "All Formats Supported", allFormats: "All Formats Supported",
visualVision: "Visual Recognition", visualVision: "Visual Recognition",
releaseToIngest: "Release to Ingest", 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: { ja: {
aiCommandsError: "エラーが発生しました", aiCommandsError: "エラーが発生しました",
@@ -2610,6 +2762,13 @@ export const translations = {
style: "スタイル要件", style: "スタイル要件",
createTemplate: "テンプレートを作成", createTemplate: "テンプレートを作成",
editTemplate: "テンプレートを編集", editTemplate: "テンプレートを編集",
templateDimensions: "評価ディメンション",
dimensionName: "ディメンション名",
dimensionLabel: "ラベル",
dimensionWeight: "重み",
addDimension: "ディメンションを追加",
removeDimension: "削除",
allNotes: "すべてのノート", allNotes: "すべてのノート",
filterNotesPlaceholder: "ノートをフィルタリング...", filterNotesPlaceholder: "ノートをフィルタリング...",
startWritingPlaceholder: "書き始める...", startWritingPlaceholder: "書き始める...",
@@ -2688,7 +2847,7 @@ export const translations = {
questionBasis: "出題の根拠", questionBasis: "出題の根拠",
viewBasis: "根拠を表示", viewBasis: "根拠を表示",
hideBasis: "根拠を非表示", hideBasis: "根拠を非表示",
verified: "検証済み", verified: "合格",
fail: "失敗", fail: "失敗",
comprehensiveMasteryReport: "包括的習熟度レポート", comprehensiveMasteryReport: "包括的習熟度レポート",
newAssessmentSession: "新しいアセスメントセッション", newAssessmentSession: "新しいアセスメントセッション",
@@ -2703,6 +2862,8 @@ export const translations = {
deleteAssessmentSuccess: "評価記録が正常に削除されました", deleteAssessmentSuccess: "評価記録が正常に削除されました",
deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました', deleteAssessmentFailed: 'アセスメント記録の削除に失敗しました',
view: '表示', view: '表示',
exportAssessmentFailed: '評価レポートのエクスポートに失敗しました',
cannotResumeInProgress: '評価進行中、表示できません',
// Plugins // Plugins
pluginTitle: "プラグインストア", pluginTitle: "プラグインストア",
@@ -2817,5 +2978,73 @@ export const translations = {
allFormats: "すべてのフォーマット対応", allFormats: "すべてのフォーマット対応",
visualVision: "視覚認識", visualVision: "視覚認識",
releaseToIngest: "離して取り込む", 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: "この問題バンクを再公開しますか?",
}, },
}; };