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
Functions
| Function | Does |
|---|---|
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
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"');
}
}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.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.
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.
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
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 };
}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.
Compare with now() in SQL or on the server, never a timestamp sent by the client.
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.
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.