Imported from Xuankhoa31789/Exam-Query-Forge (
AGENTS.md). Install upstream withnpx skills add Xuankhoa31789/Exam-Query-Forge. Copyright stays with the author.
AGENTS.md — Exam Query Forge (EQF)
Codex reads this file automatically before working. It is the source of truth for project context, conventions, current status, and prompt patterns. Keep it updated. The human developer communicates in Vietnamese (mixes English for technical terms).
What this project is
EQF is a web app for verified teachers to build exams collaboratively. Core idea: replace the traditional random-draw ("bốc thăm") method with a quality-controlled flow.
- Verified teachers contribute questions to a shared pool (tagged by subject, chapter, difficulty).
- A department head defines an exam matrix (e.g. 50 questions: 80% recognition + low-application, 20% high-application).
- The system pulls a wide candidate pool per difficulty bucket.
- Subject teachers vote on candidates (+1 use / 0 needs-fix / -1 reject).
- The system selects top-voted questions per bucket until the matrix is filled.
- The department head finalizes. Teachers never learn which questions made the final exam — this makes leaks low-value by design.
Tech stack
- Java 17, Spring Boot 3.3.5, Maven
- Spring Data JPA + Hibernate
- Dev DB: H2 (file-based) at
./data/exam-query-forge; console at/h2-console(JDBC URLjdbc:h2:file:./data/exam-query-forge, usersa, no password). Prod DB: PostgreSQL via Spring profileprod(application-prod.properties); entities are DB-agnostic, no code changes needed. See "Deploy" section below. - Spring Security + JWT (JJWT 0.12, HS256): stateless Bearer tokens, BCrypt hashing.
- Frontend: static HTML/CSS/JS in
src/main/resources/static/
Build & run
- Run:
mvn spring-boot:run→ app athttp://localhost:8080 - Pages:
/(login) →/home→/questions,/exams,/voting. - Dev login:
teacher@eqf.local/password123(only ifDevDataInitializerseeded — it skips when the subjects table is already populated). - Restart the app after adding/renaming any Java file — new endpoints won't exist until you restart (a 404 on a new endpoint usually means "you forgot to restart").
Conventions
- Base package
com.eqf; layout:model/(entities+enums),repository/,service/,controller/,config/,dto/,exception/. - Enums stored as VARCHAR via
@Enumerated(EnumType.STRING)(not native PG enums). - Timestamps via
LocalDateTime+@PrePersist/@PreUpdate. - Passwords: never plaintext, always BCrypt. Never log raw passwords.
src/main/resources/db/schema.postgres.sqlis a reference only. It is Postgres-specific and must NOT run on H2. Keepspring.sql.init.mode=never.- The nav bar lives in ONE place:
static/shell.js. Pages after login only carry<div id="appShell"></div>+<div id="pageHead"></div>and calleqfRenderShell({ title, active }). To add a nav entry, editEQF_NAVin shell.js — never re-type the header inside a page. Guard every logged-in page witheqfRequireLogin()(redirects to/when there is no valid session); no page has its own login form any more. - Clean URLs are declared in
config/WebConfig.java(/home→/home.html, etc.). Link to/questions, not/questions.html. A new page needs THREE edits: the file, aWebConfigview controller, and apermitAllentry inSecurityConfig. - Frontend CSS lives in ONE place:
static/styles.css. All pages link it; none of them has a<style>block any more. Never hard-code a colour in a page — use the design tokens (--bg,--card,--border,--ink,--muted,--accent,--green/--red/--amber/--violet+ their-bg/-bordervariants). Dark mode works automatically viaprefers-color-scheme, so an untokenised colour WILL look broken on a dark-themed OS. New components go in the section ofstyles.cssfor their page.
Data model (13 tables; see schema.postgres.sql)
subjects, users, credentials · chapters, questions, answer_options, ai_analyses · exams, exam_matrix, exam_candidates, votes · audit_log, question_usage_history
Key points:
votesreferenceexam_candidates(per-exam context), NOT rawquestions.ai_analysesholds AI difficulty suggestions + reasoning only; the human-setquestions.difficultyis authoritative. AI never overrides it.
Vertical-slice plan & CURRENT STATUS
Build one feature end-to-end (entity → repo → service → controller → view) until it runs, then move on. Do NOT build all entities, then all repos.
- Slice 1 — Auth & User. Subject/User/Credential, BCrypt, email login.
- Slice 2 — Question bank. Chapter/Question/AnswerOption + CRUD +
questions.htmlUI. Only VERIFIED users may author (enforced in QuestionService). - Slice 3 — AI difficulty (stub).
DifficultyAnalyzerinterface +StubDifficultyAnalyzer(heuristic). Endpoint/api/ai/analyze. In the form, pressing Tab in the content box auto-fills the difficulty dropdown. Real LLM = nhịp 2, pending an API key: add a newDifficultyAnalyzerimpl marked@Primary— do not touch the service/controller/UI. - Login/logout flow fixed: login stored in
sessionStorage('eqfCurrentUser'), shared across index.html, questions.html, and exams.html; logout redirects to/index.html. - Slice 4 — Exams + matrix + candidate pulling. Backend REST +
exams.htmlUI complete. - Slice 5 — Voting + selection + finalize.
voting.html, VoteService, select-by-score + finalize (only the exam creator), usage history for cooldown. - Slice 6 — Real JWT auth. Package
com.eqf.security(JwtService, JwtAuthFilter, AuthenticatedUser). Login returns a real JWT plususerId,fullName,role. Every/api/**except login/register/health requiresAuthorization: Bearer <token>(401 otherwise). authorId/createdById/voterId come from the JWT, NOT request bodies. Frontend: sharedstatic/auth.js—apiFetch()attaches the header; 401 → login page. - Slice 7 — Production PostgreSQL. postgresql driver (runtime),
application-prod.properties(all secrets from env), multi-stageDockerfile,DevDataInitializerrestricted to profile default/dev. - Slice 8 — UI pass. Shared design system in
static/styles.css(tokens + dark mode); the inline<style>blocks of questions/exams/voting were removed and those pages now link it.index.htmlrebuilt as a split-screen login (brand panel with the 4-step pipeline + live health dot on the left, form on the right), in Vietnamese, with show/hide password and a resume-session notice.app.jsrewritten for the new DOM. Fixed a stray}in the oldstyles.cssthat had been killing every rule after it, including the responsive media query. - Slice 9 — App shell.
static/shell.js(nav dùng chung +eqfRequireLogin), newhome.htmllanding page (tiles + live stats), clean URLs viaWebConfig. The legacy per-page login forms in questions/exams/voting were deleted — those pages now redirect to/when there is no session. Login lands on/home. - Slice 10 — Authorization + self-bootstrap.
getFinalExam,selectQuestionsandpullCandidatesnow require the exam creator (or ADMIN) — before this, ANY logged-in account could read any finalized exam, which broke the product's core promise. NewForbiddenException→ HTTP 403 (previously everything was 400). NewPOST /api/subjects(ADMIN/DEPARTMENT_HEAD) andAdminController(/api/admin/users,.../verify-status,.../role) plusadmin.html, so a fresh deployment no longer needs manual SQL. The first registered user on an empty users table becomes ADMIN + VERIFIED — that is the bootstrap. - Slice 11 — Voting UX + a data-corrupting bug.
VotingCandidateResponsenow carries the answer options (content + correct flag) and no longer carries the author — you cannot judge a multiple-choice item without seeing its distractors, and seeing the author's name biases voting upward.ExamCandidateRepository.findForVotingByExamIdfetch-joins question+options so a 120-candidate screen is one query, not 120.voting.htmlgained a progress bar, a "chưa bầu / đã bầu" filter, keyboard voting (1/0/-, then focus jumps to the next card) and in-place updates instead of refetching the whole list per vote. Bug found and fixed:OptionDtobinds the JSON keycorrect(Jackson derives it fromsetCorrect), but the UI sentisCorrect. Jackson silently dropped it, so EVERY multiple-choice question ever created had no correct answer stored — 26/26 rows in the dev DB. Fixed by@JsonAlias("isCorrect")on the setter plus sendingcorrectfrom the UI. Existing rows had to be repaired by hand; there is no edit endpoint. - (Later) role enforcement per endpoint (e.g. only DEPARTMENT_HEAD creates/finalizes exams); audit_log slice; real LLM DifficultyAnalyzer (pending API key).
Deploy (Slice 7)
Prod = Spring profile prod. Required env vars:
| Env var | Meaning |
|---|---|
SPRING_PROFILES_ACTIVE |
prod |
DATABASE_URL |
JDBC URL: jdbc:postgresql://host:5432/dbname. If your platform only provides postgres://user:pass@host/db, set SPRING_DATASOURCE_URL + SPRING_DATASOURCE_USERNAME + SPRING_DATASOURCE_PASSWORD instead. |
EQF_JWT_SECRET |
JWT signing secret, >= 32 bytes (app refuses to start otherwise) |
PORT |
optional; Render sets it automatically (default 8080) |
Run prod locally against a local Postgres (PowerShell):
$env:SPRING_PROFILES_ACTIVE = 'prod'
$env:DATABASE_URL = 'jdbc:postgresql://localhost:5432/eqf'
$env:SPRING_DATASOURCE_USERNAME = 'postgres'
$env:SPRING_DATASOURCE_PASSWORD = '<your-password>'
$env:EQF_JWT_SECRET = 'some-long-random-secret-at-least-32-bytes!!'
mvn spring-boot:run
Docker (what Render builds from the Dockerfile):
docker build -t eqf .
docker run -p 8080:8080 -e DATABASE_URL=... -e EQF_JWT_SECRET=... eqf
Notes: prod uses ddl-auto=update (Hibernate creates/updates tables on first boot),
H2 console is disabled, and DevDataInitializer does NOT run — so the database starts
completely empty. First run, in order:
- Register at
/— the first account becomes ADMIN + VERIFIED automatically. - Log in, open
/admin, create the subjects (nothing works without at least one). - As people register, approve them from the same page (
Duyệt) and set roles.
No SQL console needed at any point. Every later registration is TEACHER + PENDING.
Gotchas learned the hard way (READ THESE)
/api/loginreturns a real JWT plus explicituserId,fullName,rolefields. Read the id fromdata.userId(helpereqfUserId()inauth.js). The oldtoken.split('_')[1]trick is DEAD — never parse the token on the frontend.- All frontend API calls must go through
apiFetch()(sharedstatic/auth.js, loaded before each page's inline script) so the Bearer header is attached; a plainfetch()to a protected/api/**endpoint gets 401 and bounces the user to the login page. - When EDITING a file, preserve all existing features. Do NOT rewrite a whole file and silently drop unrelated code. (The AI Tab feature in questions.html was lost once this way.) Make targeted edits; after editing, verify nothing else disappeared.
- H2 resets/append behavior: dev data is seeded by
DevDataInitializeronly when the subjects table is empty. - Every static path must be listed in
SecurityConfig. A new page/script that is not in apermitAllmatcher gets 401 — including from the browser, so the page simply never loads. Symptom: a brand-new page 401s while the old ones work. - An unknown URL returns 401, not 404, because
anyRequest().authenticated()denies it before routing. That is intended (it does not leak which paths exist) but it does mean a typo'd URL looks like an auth failure./errorispermitAllso that a permitted path with no file behind it returns a real 404 (e.g./favicon.ico). - Authorization lives in the service, not the controller.
ExamService.requireExamManagergates anything that reveals or changes the final exam. If you add an endpoint that touchesExamCandidatewith status SELECTED, gate it too — otherwise you re-open the hole where any registered account could read a finalized exam. - Throw
ForbiddenException(403) for "logged in but not allowed",IllegalArgumentException(400) for bad input. 401 is only for missing/broken tokens and comes from Spring Security. - A wrong JSON key fails silently. Spring Boot leaves
FAIL_ON_UNKNOWN_PROPERTIESoff, so a request DTO quietly ignores keys it does not recognise — the row saves with a default value and nothing errors. This already cost the whole question bank once (isCorrectvscorrect). After changing any request DTO, POST one record and GET it back to confirm the field actually round-trips. - There is no way to edit or delete a question (
QuestionControlleris create/publish/read only). So a vote of0 = cần sửacurrently goes nowhere, and bad data can only be repaired through the H2/Postgres console. - The login response carries
verifyStatusfor the banner onhome.html. It is a UI hint only — never gate a real permission on it, the server owns that check.
Prompt patterns that work well here
- Start of session (grounding): "Read AGENTS.md and the whole project. Summarize the current state and what Slice 4 requires. Don't write code yet."
- Implement a slice: "Implement Slice 4 per AGENTS.md: . Show the solution
first, then a short explanation. Then run
mvn spring-boot:runand fix errors." - Fix without regressions: "Fix only in . Keep every other feature in that file intact. List what you changed."
- After finishing: "Update the CURRENT STATUS section in AGENTS.md and commit briefly."
The human prefers: the full solution first, then a short explanation of each file.
