Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions apps/examlense/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,22 +99,35 @@ every request, and `src/lib/sse.ts` opens the SSE streams.
backend/
├── build.gradle.kts
├── settings.gradle.kts
├── src/main/java/app/
│ ├── ApiApplication.java
│ ├── security/ # StaticTokenAuthFilter, RateLimitFilter, CurrentUser
│ ├── config/ # SecurityConfig, AsyncConfig (solver/LGH pools, scheduling)
│ ├── api/ # CRUD controllers + CrudService + Dtos (snake_case) + Access/Patch helpers
│ ├── error/ # ApiException + GlobalExceptionHandler
│ ├── persistence/ # JPA entities + repositories + DefaultUser
│ ├── parse/ # parse pipeline: ParseExamService (orchestrator),
│ │ # ParseInputBuilder, ParsedExamPersister, ParseProgress, metrics
├── src/main/java/app/ # package-by-feature: each domain slice owns its
│ ├── ApiApplication.java # controller + service + entity + repository + DTOs
│ │
│ │ # ── domain feature slices ──
│ ├── exam/ # ExamController, ExamService (duplication), ExamProgressService,
│ │ # Exam entity + repo, ExamDtos (snake_case)
│ ├── section/ # Section/Block/Figure controllers, SectionService (insert/unconfirm/
│ │ # delete), Section/SectionBlock/SectionFigure entities + repos, SectionDtos
│ ├── task/ # Task + TaskAnswer controllers, TaskService (insert),
│ │ # Task/TaskOption/TaskAnswer entities + repos, TaskDtos
│ ├── grading/ # TaskGradeController, TaskGrade entity + repo, GradeDto
│ ├── parse/ # parse pipeline: ParseExamService (orchestrator), ParseInputBuilder,
│ │ # ParsedExamPersister, ParseProgress, metrics + parse-quality survey
│ ├── solve/ # SolveExam/Section/Task services + SolveCore (shared machinery)
│ ├── lgh/ # LearningGoalHub proxy + goal generation
│ ├── admin/ # AdminController (survey/metrics rollups) + SurveyModelDto
│ │
│ │ # ── shared technical infrastructure ──
│ ├── ai/ # AiProvider + factory, ProviderHttpCaller (shared transport/retry),
│ │ # per-provider request/response adapters, parser/solver strategies
│ ├── lgh/ # LearningGoalHub proxy + goal generation
│ ├── storage/ # StorageService, LocalFileSystemStorageService, SignedUrls, FileController
│ ├── sse/ # SseHub + SseController
│ ├── security/ # StaticTokenAuthFilter, RateLimitFilter, CurrentUser
│ ├── config/ # SecurityConfig, AsyncConfig (solver/LGH pools, scheduling)
│ ├── error/ # ApiException + GlobalExceptionHandler
│ ├── prompts/ # solver prompt + submit_answers schema
│ ├── storage/ # StorageService, LocalFileSystemStorageService, SignedUrls
│ └── sse/ # SseHub + SseController
│ ├── models/ # ModelsController (parser/solver model catalog endpoint)
│ ├── health/ # HealthController
│ └── shared/ # Access + Patch (ownership / PATCH-body helpers), DefaultUser
└── src/main/resources/
├── application.yml
└── db/migration/ # Flyway (V1__baseline.sql … V5)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package app.api;
package app.admin;

import app.persistence.repository.ParseSurveyRepository;
import app.parse.SurveyDto;

import app.parse.ParseSurveyRepository;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -22,13 +24,13 @@ public AdminController(ParseSurveyRepository surveyRepository) {
}

@GetMapping("/survey")
public List<Dtos.SurveyDto> survey() {
return surveyRepository.findAllByOrderByCreatedAtDesc().stream().map(Dtos.SurveyDto::from).toList();
public List<SurveyDto> survey() {
return surveyRepository.findAllByOrderByCreatedAtDesc().stream().map(SurveyDto::from).toList();
}

/** Parsing-quality survey scores grouped by parser model — which model does the job best. */
@GetMapping("/survey-by-model")
public List<Dtos.SurveyModelDto> surveyByModel() {
return surveyRepository.aggregateByModel().stream().map(Dtos.SurveyModelDto::from).toList();
public List<SurveyModelDto> surveyByModel() {
return surveyRepository.aggregateByModel().stream().map(SurveyModelDto::from).toList();
}
}
21 changes: 21 additions & 0 deletions apps/examlense/backend/src/main/java/app/admin/SurveyModelDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package app.admin;

/**
* Per-model rollup of parsing-quality survey scores (see
* {@code app.parse.ParseSurveyRepository#aggregateByModel()}). Averages are null
* when no response for that model rated the aspect.
*/
public record SurveyModelDto(
String model_id, long responses, Double avg_speed,
Double avg_content_correctness, Double avg_structure
) {
public static SurveyModelDto from(Object[] row) {
return new SurveyModelDto(
(String) row[0],
((Number) row[1]).longValue(),
row[2] == null ? null : ((Number) row[2]).doubleValue(),
row[3] == null ? null : ((Number) row[3]).doubleValue(),
row[4] == null ? null : ((Number) row[4]).doubleValue()
);
}
}
155 changes: 0 additions & 155 deletions apps/examlense/backend/src/main/java/app/api/Dtos.java

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* Stateless API security. Real user authentication is deferred to a later phase;
* for now the API is gated by a single static bearer token plus a coarse per-IP
* rate limiter (bot/overload protection only). A valid token authenticates the
* request as the single seeded user (see {@link app.persistence.DefaultUser}).
* request as the single seeded user (see {@link app.shared.DefaultUser}).
*
* Public endpoints: /api/healthz (liveness probe) and CORS preflight.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package app.persistence.entity;
package app.exam;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package app.api;
package app.exam;
import app.shared.Patch;
import app.shared.Access;

import app.error.ApiException;
import app.persistence.entity.Exam;
import app.persistence.repository.ExamRepository;
import app.security.CurrentUser;
import app.storage.StorageService;
import java.util.List;
Expand Down Expand Up @@ -32,36 +32,36 @@ public record CreateExamRequest(

private final ExamRepository examRepository;
private final Access access;
private final CrudService crud;
private final ExamService examService;
private final StorageService storage;
private final ExamProgressService progress;

public ExamController(ExamRepository examRepository, Access access, CrudService crud,
public ExamController(ExamRepository examRepository, Access access, ExamService examService,
StorageService storage, ExamProgressService progress) {
this.examRepository = examRepository;
this.access = access;
this.crud = crud;
this.examService = examService;
this.storage = storage;
this.progress = progress;
}

@GetMapping
public List<Dtos.ExamListItemDto> list(@CurrentUser String userId) {
public List<ExamDtos.ExamListItemDto> list(@CurrentUser String userId) {
List<Exam> exams = examRepository.findByOwnerIdOrderByCreatedAtDesc(UUID.fromString(userId));
var counts = progress.countsFor(exams.stream().map(Exam::getId).toList());
return exams.stream()
.map(e -> Dtos.ExamListItemDto.from(e,
.map(e -> ExamDtos.ExamListItemDto.from(e,
counts.getOrDefault(e.getId(), ExamProgressService.Counts.EMPTY)))
.toList();
}

@GetMapping("/{id}")
public Dtos.ExamDto get(@PathVariable String id, @CurrentUser String userId) {
return Dtos.ExamDto.from(access.requireExam(Access.id(id), userId));
public ExamDtos.ExamDto get(@PathVariable String id, @CurrentUser String userId) {
return ExamDtos.ExamDto.from(access.requireExam(Access.id(id), userId));
}

@PostMapping
public Dtos.ExamDto create(@RequestBody CreateExamRequest req, @CurrentUser String userId) {
public ExamDtos.ExamDto create(@RequestBody CreateExamRequest req, @CurrentUser String userId) {
Exam e = new Exam();
e.setOwnerId(UUID.fromString(userId));
if (req.title() != null) e.setTitle(req.title());
Expand All @@ -75,15 +75,15 @@ public Dtos.ExamDto create(@RequestBody CreateExamRequest req, @CurrentUser Stri
if (req.parser_model() != null) e.setParserModel(req.parser_model());
if (req.solver_model() != null) e.setSolverModel(req.solver_model());
if (req.lgh_course_id() != null) e.setLghCourseId(req.lgh_course_id());
return Dtos.ExamDto.from(examRepository.save(e));
return ExamDtos.ExamDto.from(examRepository.save(e));
}

/** Statuses the client may set directly (mirrors the DB check constraint). */
private static final Set<String> PATCHABLE_STATUSES =
Set.of("draft", "parsing", "failed", "ready", "evaluating", "grading", "finished");

@PatchMapping("/{id}")
public Dtos.ExamDto patch(@PathVariable String id, @RequestBody Map<String, Object> body,
public ExamDtos.ExamDto patch(@PathVariable String id, @RequestBody Map<String, Object> body,
@CurrentUser String userId) {
Exam e = access.requireExam(Access.id(id), userId);
if (Patch.has(body, "title")) e.setTitle(Patch.str(body.get("title")));
Expand Down Expand Up @@ -118,7 +118,7 @@ public Dtos.ExamDto patch(@PathVariable String id, @RequestBody Map<String, Obje
if (Patch.has(body, "solver_model")) e.setSolverModel(Patch.str(body.get("solver_model")));
if (Patch.has(body, "source_file_url")) e.setSourceFileUrl(Patch.str(body.get("source_file_url")));
if (Patch.has(body, "lgh_course_id")) e.setLghCourseId(Patch.longVal(body.get("lgh_course_id")));
return Dtos.ExamDto.from(examRepository.save(e));
return ExamDtos.ExamDto.from(examRepository.save(e));
}

@DeleteMapping("/{id}")
Expand All @@ -143,18 +143,18 @@ public ResponseEntity<Void> delete(@PathVariable String id, @CurrentUser String
* if the exam is not currently processing.
*/
@PostMapping("/{id}/cancel")
public Dtos.ExamDto cancel(@PathVariable String id, @CurrentUser String userId) {
public ExamDtos.ExamDto cancel(@PathVariable String id, @CurrentUser String userId) {
Exam e = access.requireExam(Access.id(id), userId); // 404 if missing, 403 if not owner
int updated = examRepository.cancelProcessing(e.getId(), "Cancelled.");
if (updated == 0) {
throw new ApiException(HttpStatus.CONFLICT, "Exam is not currently processing");
}
return Dtos.ExamDto.from(examRepository.findById(e.getId()).orElse(e));
return ExamDtos.ExamDto.from(examRepository.findById(e.getId()).orElse(e));
}

@PostMapping("/{id}/duplicate")
public Dtos.ExamDto duplicate(@PathVariable String id, @CurrentUser String userId) {
public ExamDtos.ExamDto duplicate(@PathVariable String id, @CurrentUser String userId) {
Exam src = access.requireExam(Access.id(id), userId);
return Dtos.ExamDto.from(crud.duplicateExam(src, userId));
return ExamDtos.ExamDto.from(examService.duplicateExam(src, userId));
}
}
Loading
Loading