Skip to content

Commit 35b403c

Browse files
y4nderclaude
andauthored
[STAGING] FAC-114 feat: add CSV test submission generator (#273) (#274)
* chore: initialize tech-spec WIP for CSV test submission generator https://claude.ai/code/session_01VbeiqCo7jDVMYYi9dgp5kr * chore: update tech-spec WIP with deep investigation findings https://claude.ai/code/session_01VbeiqCo7jDVMYYi9dgp5kr * chore: finalize tech-spec for CSV test submission generator Spec passed two rounds of adversarial review. Key decisions: - Direct submitQuestionnaire() calls instead of ingestion pipeline - Import QuestionnaireModule for QuestionnaireService access - em.clear() on failure for EM state recovery - OpenAI gpt-4o-mini for multilingual comment generation - Admin console builder UI with two-track selection flow https://claude.ai/code/session_01VbeiqCo7jDVMYYi9dgp5kr * feat: add CSV test submission generator API Add backend endpoints for generating realistic test questionnaire submissions with OpenAI-powered multilingual comments. New endpoints: - GET /admin/generate-submissions/status (lightweight pre-check) - POST /admin/generate-submissions/preview (generate with comments) - POST /admin/generate-submissions/commit (submit via QuestionnaireService) - GET /admin/filters/faculty, courses, questionnaire-types, versions Includes CommentGeneratorService with gpt-4o-mini integration and fallback, AdminGenerateService with preview/commit flow, and 31 unit tests across 3 test suites. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4109c9f commit 35b403c

22 files changed

Lines changed: 2685 additions & 3 deletions

_bmad-output/implementation-artifacts/tech-spec-csv-test-submission-generator.md

Lines changed: 899 additions & 0 deletions
Large diffs are not rendered by default.

src/modules/admin/admin-filters.controller.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { UserRole } from '../auth/roles.enum';
1111
import { AdminFiltersService } from './services/admin-filters.service';
1212
import { FilterDepartmentsQueryDto } from './dto/requests/filter-departments-query.dto';
1313
import { FilterProgramsQueryDto } from './dto/requests/filter-programs-query.dto';
14+
import { FilterCoursesQueryDto } from './dto/requests/filter-courses-query.dto';
15+
import { FilterVersionsQueryDto } from './dto/requests/filter-versions-query.dto';
1416
import { FilterOptionResponseDto } from './dto/responses/filter-option.response.dto';
17+
import { FilterFacultyResponseDto } from './dto/responses/filter-faculty.response.dto';
18+
import { FilterCourseResponseDto } from './dto/responses/filter-course.response.dto';
19+
import { FilterVersionResponseDto } from './dto/responses/filter-version.response.dto';
1520

1621
@ApiTags('Admin')
1722
@Controller('admin/filters')
@@ -74,4 +79,51 @@ export class AdminFiltersController {
7479
GetRoles(): { roles: UserRole[] } {
7580
return { roles: this.filtersService.GetRoles() };
7681
}
82+
83+
@Get('faculty')
84+
@ApiOperation({
85+
summary:
86+
'List faculty members (users with active editing teacher enrollments)',
87+
})
88+
@ApiResponse({ status: 200, type: [FilterFacultyResponseDto] })
89+
async GetFaculty(): Promise<FilterFacultyResponseDto[]> {
90+
return this.filtersService.GetFaculty();
91+
}
92+
93+
@Get('courses')
94+
@ApiOperation({ summary: 'List courses for a specific faculty member' })
95+
@ApiQuery({
96+
name: 'facultyUsername',
97+
required: true,
98+
type: String,
99+
description: 'Faculty username to filter courses by',
100+
})
101+
@ApiResponse({ status: 200, type: [FilterCourseResponseDto] })
102+
async GetCourses(
103+
@Query() query: FilterCoursesQueryDto,
104+
): Promise<FilterCourseResponseDto[]> {
105+
return this.filtersService.GetCoursesForFaculty(query.facultyUsername);
106+
}
107+
108+
@Get('questionnaire-types')
109+
@ApiOperation({ summary: 'List all questionnaire types' })
110+
@ApiResponse({ status: 200, type: [FilterOptionResponseDto] })
111+
async GetQuestionnaireTypes(): Promise<FilterOptionResponseDto[]> {
112+
return this.filtersService.GetQuestionnaireTypes();
113+
}
114+
115+
@Get('questionnaire-versions')
116+
@ApiOperation({ summary: 'List active versions for a questionnaire type' })
117+
@ApiQuery({
118+
name: 'typeId',
119+
required: true,
120+
type: String,
121+
description: 'Questionnaire type UUID',
122+
})
123+
@ApiResponse({ status: 200, type: [FilterVersionResponseDto] })
124+
async GetQuestionnaireVersions(
125+
@Query() query: FilterVersionsQueryDto,
126+
): Promise<FilterVersionResponseDto[]> {
127+
return this.filtersService.GetQuestionnaireVersions(query.typeId);
128+
}
77129
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
2+
import {
3+
ApiBearerAuth,
4+
ApiOperation,
5+
ApiQuery,
6+
ApiResponse,
7+
ApiTags,
8+
} from '@nestjs/swagger';
9+
import { UseJwtGuard } from 'src/security/decorators';
10+
import { UserRole } from '../auth/roles.enum';
11+
import { AdminGenerateService } from './services/admin-generate.service';
12+
import { GeneratePreviewRequestDto } from './dto/requests/generate-preview.request.dto';
13+
import { GenerateCommitRequestDto } from './dto/requests/generate-commit.request.dto';
14+
import { GeneratePreviewResponseDto } from './dto/responses/generate-preview.response.dto';
15+
import { CommitResultDto } from './dto/responses/commit-result.response.dto';
16+
import { SubmissionStatusResponseDto } from './dto/responses/submission-status.response.dto';
17+
import { SubmissionStatusQueryDto } from './dto/requests/submission-status-query.dto';
18+
19+
@ApiTags('Admin')
20+
@Controller('admin/generate-submissions')
21+
@UseJwtGuard(UserRole.SUPER_ADMIN)
22+
@ApiBearerAuth()
23+
export class AdminGenerateController {
24+
constructor(private readonly generateService: AdminGenerateService) {}
25+
26+
@Get('status')
27+
@ApiOperation({
28+
summary: 'Check submission status for a faculty+course+version combination',
29+
})
30+
@ApiQuery({ name: 'versionId', required: true, type: String })
31+
@ApiQuery({ name: 'facultyUsername', required: true, type: String })
32+
@ApiQuery({ name: 'courseShortname', required: true, type: String })
33+
@ApiResponse({ status: 200, type: SubmissionStatusResponseDto })
34+
async Status(
35+
@Query() query: SubmissionStatusQueryDto,
36+
): Promise<SubmissionStatusResponseDto> {
37+
return this.generateService.GetSubmissionStatus(query);
38+
}
39+
40+
@Post('preview')
41+
@HttpCode(200)
42+
@ApiOperation({
43+
summary: 'Generate preview of test submissions for a questionnaire version',
44+
})
45+
@ApiResponse({ status: 200, type: GeneratePreviewResponseDto })
46+
async Preview(
47+
@Body() dto: GeneratePreviewRequestDto,
48+
): Promise<GeneratePreviewResponseDto> {
49+
return this.generateService.GeneratePreview(dto);
50+
}
51+
52+
@Post('commit')
53+
@ApiOperation({
54+
summary:
55+
'Commit generated submissions (may take time due to per-row processing)',
56+
})
57+
@ApiResponse({ status: 201, type: CommitResultDto })
58+
async Commit(
59+
@Body() dto: GenerateCommitRequestDto,
60+
): Promise<CommitResultDto> {
61+
return this.generateService.CommitSubmissions(dto);
62+
}
63+
}

src/modules/admin/admin.module.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ import { Program } from 'src/entities/program.entity';
99
import { Semester } from 'src/entities/semester.entity';
1010
import { UserInstitutionalRole } from 'src/entities/user-institutional-role.entity';
1111
import { User } from 'src/entities/user.entity';
12+
import { QuestionnaireType } from 'src/entities/questionnaire-type.entity';
13+
import { QuestionnaireVersion } from 'src/entities/questionnaire-version.entity';
14+
import { QuestionnaireSubmission } from 'src/entities/questionnaire-submission.entity';
15+
import { QuestionnaireModule } from 'src/modules/questionnaires/questionnaires.module';
1216
import { AdminController } from './admin.controller';
1317
import { AdminFiltersController } from './admin-filters.controller';
18+
import { AdminGenerateController } from './admin-generate.controller';
1419
import { AdminService } from './services/admin.service';
1520
import { AdminFiltersService } from './services/admin-filters.service';
21+
import { AdminGenerateService } from './services/admin-generate.service';
22+
import { CommentGeneratorService } from './services/comment-generator.service';
1623

1724
@Module({
1825
imports: [
@@ -26,9 +33,22 @@ import { AdminFiltersService } from './services/admin-filters.service';
2633
Semester,
2734
UserInstitutionalRole,
2835
User,
36+
QuestionnaireType,
37+
QuestionnaireVersion,
38+
QuestionnaireSubmission,
2939
]),
40+
QuestionnaireModule,
41+
],
42+
controllers: [
43+
AdminController,
44+
AdminFiltersController,
45+
AdminGenerateController,
46+
],
47+
providers: [
48+
AdminService,
49+
AdminFiltersService,
50+
AdminGenerateService,
51+
CommentGeneratorService,
3052
],
31-
controllers: [AdminController, AdminFiltersController],
32-
providers: [AdminService, AdminFiltersService],
3353
})
3454
export class AdminModule {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
4+
export class FilterCoursesQueryDto {
5+
@ApiProperty({ description: 'Faculty username to filter courses by' })
6+
@IsString()
7+
@IsNotEmpty()
8+
facultyUsername: string;
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsUUID } from 'class-validator';
3+
4+
export class FilterVersionsQueryDto {
5+
@ApiProperty({ description: 'Questionnaire type UUID' })
6+
@IsUUID()
7+
@IsNotEmpty()
8+
typeId: string;
9+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import {
3+
ArrayMaxSize,
4+
ArrayNotEmpty,
5+
IsArray,
6+
IsNotEmpty,
7+
IsObject,
8+
IsOptional,
9+
IsString,
10+
IsUUID,
11+
ValidateNested,
12+
} from 'class-validator';
13+
import { Type } from 'class-transformer';
14+
15+
export class GeneratedRowDto {
16+
@ApiProperty()
17+
@IsString()
18+
@IsNotEmpty()
19+
externalId: string;
20+
21+
@ApiProperty()
22+
@IsString()
23+
@IsNotEmpty()
24+
username: string;
25+
26+
@ApiProperty()
27+
@IsString()
28+
@IsNotEmpty()
29+
facultyUsername: string;
30+
31+
@ApiProperty()
32+
@IsString()
33+
@IsNotEmpty()
34+
courseShortname: string;
35+
36+
@ApiProperty({ description: 'Map of questionId -> numeric value' })
37+
@IsObject()
38+
answers: Record<string, number>;
39+
40+
@ApiPropertyOptional()
41+
@IsString()
42+
@IsOptional()
43+
comment?: string;
44+
}
45+
46+
export class GenerateCommitRequestDto {
47+
@ApiProperty({ description: 'Questionnaire version UUID' })
48+
@IsUUID()
49+
@IsNotEmpty()
50+
versionId: string;
51+
52+
@ApiProperty({ type: [GeneratedRowDto] })
53+
@IsArray()
54+
@ArrayNotEmpty()
55+
@ArrayMaxSize(200)
56+
@ValidateNested({ each: true })
57+
@Type(() => GeneratedRowDto)
58+
rows: GeneratedRowDto[];
59+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
3+
4+
export class GeneratePreviewRequestDto {
5+
@ApiProperty({ description: 'Questionnaire version UUID' })
6+
@IsUUID()
7+
@IsNotEmpty()
8+
versionId: string;
9+
10+
@ApiProperty({ description: 'Faculty username (exact match)' })
11+
@IsString()
12+
@IsNotEmpty()
13+
facultyUsername: string;
14+
15+
@ApiProperty({ description: 'Course shortname (exact match)' })
16+
@IsString()
17+
@IsNotEmpty()
18+
courseShortname: string;
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
3+
4+
export class SubmissionStatusQueryDto {
5+
@ApiProperty({ description: 'Questionnaire version UUID' })
6+
@IsUUID()
7+
@IsNotEmpty()
8+
versionId: string;
9+
10+
@ApiProperty({ description: 'Faculty username (exact match)' })
11+
@IsString()
12+
@IsNotEmpty()
13+
facultyUsername: string;
14+
15+
@ApiProperty({ description: 'Course shortname (exact match)' })
16+
@IsString()
17+
@IsNotEmpty()
18+
courseShortname: string;
19+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
3+
export class CommitRecordResultDto {
4+
@ApiProperty()
5+
externalId: string;
6+
7+
@ApiProperty()
8+
success: boolean;
9+
10+
@ApiPropertyOptional()
11+
error?: string;
12+
13+
@ApiPropertyOptional()
14+
internalId?: string;
15+
}
16+
17+
export class CommitResultDto {
18+
@ApiProperty()
19+
commitId: string;
20+
21+
@ApiProperty()
22+
total: number;
23+
24+
@ApiProperty()
25+
successes: number;
26+
27+
@ApiProperty()
28+
failures: number;
29+
30+
@ApiProperty()
31+
dryRun: boolean;
32+
33+
@ApiProperty({ type: [CommitRecordResultDto] })
34+
records: CommitRecordResultDto[];
35+
}

0 commit comments

Comments
 (0)