Quiz generation and attempts

Pipelines

Quiz generation and attempts

A trainer asks for a questionnaire on a course. The API collects relevant chunks, sends them to the configured AI provider with a JSON-only prompt, validates the reply and saves a draft. The trainer edits and publishes. Trainees then attempt it once, before the deadline, and the server scores it.

Generation sequence

Question generation

McqProvider(AI_PROVIDER)PostgresExpress APIaiGenerationLimiter, 10per hour per trainervertex = Vertex AI(Gemini), ollama =Qwen3.5 9B/4BTrainerPOST /api/questionnaires/:id/generate1selectChunks(courseId), RLS scoped2chunk_text rows3generate(chunks, count)4JSON text5parseAndValidate()6saveDraft(), status draft, ai_generatedtrue7draft for review8PUT question edits9POST /api/questionnaires/:id/publish10publishQuestionnaire(), deadline set11
AI_PROVIDER decides which model answers. Everything else in the sequence is identical.

Functions

FunctionDoes
generateQuestionnaire(req)Route handler. Checks the trainer owns the course, then runs the steps below.
selectChunks(db, courseId, k)Pulls chunk_text under RLS, either the top-k by similarity or every chunk for a small course.
buildPrompt(chunks, count)Builds the system prompt (JSON only, use only the material) and the user message.
getProvider()Returns the provider selected by AI_PROVIDER.
provider.generate({ chunks, count })Calls Vertex AI or Ollama and returns raw text.
parseAndValidate(raw, expected)JSON.parse, then the shape and consistency checks below. Throws on any failure.
saveDraft(db, courseId, userId, mcqs)INSERT questionnaire (status draft, ai_generated true) and its questions in one transaction.
publishQuestionnaire(db, id, deadline)Requires status draft and at least one question, sets status published and the deadline.

Provider switch

services/api/src/ai/provider.ts
export type RawMcq = {
  prompt: string;
  options: { id: string; text: string }[];
  correct_option_id: string;
};

export interface McqProvider {
  name: 'vertex' | 'ollama';
  generate(input: { chunks: string[]; count: number }): Promise<string>;  // raw JSON text
}

export function getProvider(): McqProvider {
  switch (process.env.AI_PROVIDER) {
    case 'vertex': return new VertexProvider();   // Gemini on Vertex AI, cloud
    case 'ollama': return new OllamaProvider();   // Qwen3.5 9B/4B, local, works offline
    default: throw new Error('AI_PROVIDER must be "vertex" or "ollama"');
  }
}
prompt contract
System: You write multiple-choice questions for a training course.
Use ONLY the supplied material. Return ONLY JSON, no prose, no code fences:
{"questions":[{"prompt":"...","options":[{"id":"a","text":"..."},{"id":"b","text":"..."},
 {"id":"c","text":"..."},{"id":"d","text":"..."}],"correct_option_id":"b"}]}
Exactly one option is correct. Return exactly N questions.
Use structured output

Both providers can constrain the reply to a schema: Gemini through a JSON response mime type plus schema, Ollama through the format field. The design prefers that over prompt-only JSON, and still validates every reply.

Validate everything

Reject a reply unless: it parses, the question count matches, every prompt is non-empty, option ids are unique, there are 3 to 5 options, and correct_option_id names one of them. Retry once with the error appended, then return 502 to the trainer.

Keys stay server side

Vertex credentials are read from the environment by the API only. In ollama mode no data leaves the machine.

Attempts and scoring

Scoring is done on the server. The client never receives the answer key, and the deadline is checked against the database clock.

Attempt sequence

PostgresExpress APIchecks status published, now() beforedeadline, no earlier attemptTraineeGET /api/questionnaires/:id1load questions under RLS2prompt and options only,correct_option_id removed3POST /api/questionnaires/:id/attempts(answers)4submitAttempt() inside one transaction5scoreAttempt(answers,correct ids)6INSERT questionnaire_attempts (score,answers)7score8
services/api/src/assessments/attempts.ts
export async function submitAttempt(db, questionnaireId, traineeId, answers) {
  const q = await db.query(
    'SELECT status, deadline FROM questionnaires WHERE id = $1 FOR SHARE', [questionnaireId]);
  if (q.rows[0]?.status !== 'published')         throw httpError(409, 'not open');
  if (new Date() > q.rows[0].deadline)           throw httpError(409, 'deadline passed');

  const key   = await db.query('SELECT id, correct_option_id FROM questions WHERE questionnaire_id = $1', [questionnaireId]);
  const score = scoreAttempt(answers, key.rows);          // correct / total * 100

  const ins = await db.query(
    `INSERT INTO questionnaire_attempts (questionnaire_id, trainee_id, submitted_at, score, answers)
     VALUES ($1, $2, now(), $3, $4)
     ON CONFLICT (questionnaire_id, trainee_id) DO NOTHING RETURNING id`,
    [questionnaireId, traineeId, score, answers]);
  if (ins.rowCount === 0) throw httpError(409, 'already attempted');   // double click or retake
  return { score };
}
Strip the key

The GET route that serves questions to a trainee must not select correct_option_id. Use a separate query or a view for trainee reads.

Database decides time

Compare with now() in SQL or on the server, never a timestamp sent by the client.

One attempt

The unique constraint plus ON CONFLICT DO NOTHING makes retries and double clicks safe. A retake policy is a rule change, not a schema change.

Manual authoring is first-class

A hand-written questionnaire uses the same tables and the same publish flow as an AI draft. The AI path only adds one route that produces the same draft rows, so assessments never depend on the AI being available.

Capacity Connect · Team Syntax Squad · SIH 2026 · PS 26075Code samples are implementation sketches.