Skip to content

Questionnaires

The questionnaire system allows event organizers to gate participation behind customizable questionnaires with support for AI-powered evaluation, prompt injection detection, and flexible scoring rules.

Data Model

erDiagram
    Questionnaire ||--o{ QuestionnaireSection : "has sections"
    Questionnaire ||--o{ MultipleChoiceQuestion : "has MC questions"
    Questionnaire ||--o{ FreeTextQuestion : "has FT questions"
    Questionnaire ||--o{ FileUploadQuestion : "has FU questions"
    Questionnaire ||--o{ QuestionnaireSubmission : "receives submissions"
    QuestionnaireSection ||--o{ MultipleChoiceQuestion : "contains"
    QuestionnaireSection ||--o{ FreeTextQuestion : "contains"
    QuestionnaireSection ||--o{ FileUploadQuestion : "contains"
    QuestionnaireSection }o--o| MultipleChoiceOption : "depends_on_option"
    MultipleChoiceQuestion ||--o{ MultipleChoiceOption : "has options"
    MultipleChoiceQuestion ||--o{ MultipleChoiceAnswer : "receives answers"
    MultipleChoiceQuestion }o--o| MultipleChoiceOption : "depends_on_option"
    FreeTextQuestion ||--o{ FreeTextAnswer : "receives answers"
    FreeTextQuestion }o--o| MultipleChoiceOption : "depends_on_option"
    FileUploadQuestion ||--o{ FileUploadAnswer : "receives answers"
    FileUploadQuestion }o--o| MultipleChoiceOption : "depends_on_option"
    QuestionnaireSubmission ||--o{ MultipleChoiceAnswer : "contains"
    QuestionnaireSubmission ||--o{ FreeTextAnswer : "contains"
    QuestionnaireSubmission ||--o{ FileUploadAnswer : "contains"
    QuestionnaireSubmission ||--o| QuestionnaireEvaluation : "evaluated by"
    FileUploadAnswer }o--o{ QuestionnaireFile : "references files"

    Questionnaire {
        string name
        string description
        decimal min_score
        string llm_guidelines
        string llm_backend
        string status
        string evaluation_mode
        boolean shuffle_questions
        boolean shuffle_sections
        int max_attempts
        duration can_retake_after
    }

    QuestionnaireSection {
        string name
        string description
        int order
        uuid depends_on_option FK
    }

    MultipleChoiceQuestion {
        string question
        string hint
        string reviewer_notes
        decimal positive_weight
        decimal negative_weight
        boolean is_fatal
        boolean is_mandatory
        int order
        boolean allow_multiple_answers
        boolean shuffle_options
        uuid depends_on_option FK
    }

    FreeTextQuestion {
        string question
        string hint
        string reviewer_notes
        decimal positive_weight
        decimal negative_weight
        boolean is_fatal
        boolean is_mandatory
        int order
        string llm_guidelines
        uuid depends_on_option FK
    }

    FileUploadQuestion {
        string question
        string hint
        string reviewer_notes
        decimal positive_weight
        decimal negative_weight
        boolean is_fatal
        boolean is_mandatory
        int order
        string[] allowed_mime_types
        int max_file_size
        int max_files
        uuid depends_on_option FK
    }

    MultipleChoiceOption {
        string option
        boolean is_correct
        int order
    }

    MultipleChoiceAnswer {
        uuid submission FK
        uuid question FK
        uuid option FK
    }

    FreeTextAnswer {
        uuid submission FK
        uuid question FK
        string answer
    }

    FileUploadAnswer {
        uuid submission FK
        uuid question FK
        uuid[] files
    }

    QuestionnaireFile {
        uuid uploader FK
        binary file
        string original_filename
        string file_hash
        string mime_type
        int file_size
    }

    QuestionnaireSubmission {
        uuid questionnaire FK
        uuid user FK
        string status
        datetime submitted_at
        json metadata
    }

    QuestionnaireEvaluation {
        uuid submission FK
        decimal score
        string status
        string proposed_status
        string comments
        boolean automatically_evaluated
        uuid evaluator FK
        json raw_evaluation_data
    }

Hierarchy

Questionnaire → QuestionnaireSection (optional) → Questions → Options/Answers

Sections are optional

Questions can belong directly to a questionnaire or be organized into sections. Sections support independent shuffling for randomized question order.

Question Types

Type Model Description Evaluation Default positive_weight
Multiple Choice MultipleChoiceQuestion Predefined options via MultipleChoiceOption, single or multiple correct answers Automatic: correct options are marked via is_correct on each MultipleChoiceOption 1.0
Free Text FreeTextQuestion Open-ended text response LLM-powered (batch) or manual evaluation; supports per-question llm_guidelines 1.0
File Upload FileUploadQuestion File/image uploads (PDF, images, etc.) Informational only; no automatic scoring; manual review by evaluators 0.0

Common Fields (BaseQuestion)

All question types inherit from BaseQuestion and share these fields:

Field Type Default Description
question MarkdownField (required) The question text (supports Markdown)
hint MarkdownField null Additional context shown to the user below the question
reviewer_notes MarkdownField null Notes for reviewers, not shown to users
positive_weight Decimal (0 to 100) 1.0 Points awarded for a correct answer
negative_weight Decimal (-100 to 100) 0.0 Points deducted for an incorrect answer
is_fatal Boolean False Failing this question fails the entire submission
is_mandatory Boolean False The question must be answered for submission
order Integer 0 Display order (ignored if shuffle_questions is enabled)
depends_on_option FK to MultipleChoiceOption null Conditional display: question shown only if this option was selected

FileUploadQuestion Extra Fields

Field Type Default Description
allowed_mime_types ArrayField (list of strings) [] (allow all) Restrict accepted file types (e.g., image/jpeg, application/pdf)
max_file_size PositiveInteger 5242880 (5 MB) Maximum file size in bytes per file
max_files PositiveInteger (1 to 10) 1 Maximum number of files allowed

File Upload Scoring

FileUploadQuestion uses the InformationalQuestionMixin which overrides the default positive_weight to 0.0. These questions are treated as informational/supplementary since automatic LLM evaluation of files is not yet implemented.

QuestionnaireFile Model

Uploaded files are stored in a user-scoped library (QuestionnaireFile) and can be reused across multiple questions and questionnaires via M2M relationship with FileUploadAnswer. Files are stored with UUID-based paths to prevent enumeration attacks, and duplicate uploads per user are prevented via a SHA-256 hash constraint.

Privacy-first deletion policy

When a user deletes a QuestionnaireFile, it is hard deleted from storage immediately, even if referenced by submitted questionnaires. User privacy (GDPR compliance) takes precedence over data integrity. Submissions with deleted files will show the file as unavailable.

Questionnaire Status Lifecycle

Questionnaires follow a three-stage status lifecycle:

stateDiagram-v2
    [*] --> DRAFT
    DRAFT --> READY : All questions configured
    READY --> PUBLISHED : Ready for users
Status Description
DRAFT Questionnaire is being authored; not visible to users
READY Configuration is complete; can be reviewed before publishing
PUBLISHED Live and accepting submissions from users

Submission Status

Submissions also have their own status flow:

Status Description
DRAFT User has started but not yet submitted; only one draft per user per questionnaire is allowed (enforced by a unique constraint)
READY User has finalized and submitted; submitted_at is set automatically

Conditional Sections and Questions

Both QuestionnaireSection and all question types support conditional display via the depends_on_option foreign key to MultipleChoiceOption.

  • A section with depends_on_option set is only shown if the referenced option was selected by the user.
  • A question with depends_on_option set is only shown if the referenced option was selected.
  • The referenced option must belong to the same questionnaire (validated in clean()).

Conditional logic

A multiple choice question asks "Do you have dietary restrictions?" with options "Yes" and "No". A follow-up free text question "Please describe your restrictions" has depends_on_option set to the "Yes" option. The follow-up is only shown (and only scored) if the user selected "Yes".

During evaluation, the SubmissionEvaluator computes which questions are applicable based on the user's selected options. Questions that were not shown (because their condition was not met) are excluded from scoring and mandatory checks.

Retry Mechanics

Questionnaires support configurable retry behavior:

Field Type Default Description
max_attempts Integer 0 Maximum number of submissions allowed. 0 means unlimited
can_retake_after DurationField null Cooldown period before retake. Zero means immediate retake; null is read differently per questionnaire type (see below)

For admission questionnaires, max_attempts alone controls whether retakes are possible; can_retake_after only controls the cooldown duration between attempts. For example, max_attempts=3 with can_retake_after=null allows up to 3 attempts with no waiting period between failures.

null means the opposite for membership questionnaires

On the membership path a null can_retake_after is a terminal verdict — the rejected applicant may never retake (MEMBERSHIP_QUESTIONNAIRE_FAILED). See Membership eligibility → MembershipQuestionnaireGate. Since OrganizationQuestionnaire.questionnaire is a OneToOneField, no questionnaire row is ever read both ways.

Both readings live in one place — questionnaires/utils/retake_policy.py — selected by the null_cooldown_is_terminal flag. The eligibility gates and the submit endpoints share it, so a gate can never promise a submission the endpoint would reject. The cooldown blocks only while retry_on is still in the future: at exactly retry_on the retake is allowed.

Evaluation Modes

Questionnaires support three evaluation modes:

Mode Description
AUTOMATIC Submission is fully evaluated programmatically (MC) and by LLM (free text). The proposed_status is finalized immediately as the status
MANUAL All submissions require human review by organization staff. Status is set to PENDING_REVIEW
HYBRID Automatic evaluation runs and produces a proposed_status, but a human reviewer must confirm or override. Status is set to PENDING_REVIEW

Choose wisely

Use AUTOMATIC for high-volume events where speed matters. Use MANUAL when human judgment is critical. Use HYBRID for a balance of both.

Feature flag

LLM evaluation for questionnaires with free-text questions is gated behind the FEATURE_LLM_EVALUATION environment variable (default: True). When disabled, creating or updating a questionnaire with AUTOMATIC or HYBRID mode that contains free-text questions will return a validation error. Questionnaires with only multiple-choice questions are unaffected.

QuestionnaireEvaluation

Each submission can have at most one QuestionnaireEvaluation (OneToOne relationship). The evaluation stores:

Field Type Description
score Decimal Percentage score (can be -100.0 for fatal/mandatory failures)
status Enum APPROVED, REJECTED, or PENDING_REVIEW
proposed_status Enum APPROVED or REJECTED (the automatic evaluation's recommendation)
comments Text Evaluator comments
automatically_evaluated Boolean Whether this evaluation was created by the automatic pipeline
evaluator FK to User The human evaluator (if manually reviewed)
raw_evaluation_data JSON Structured EvaluationAuditData for the full audit trail

The raw_evaluation_data field stores an EvaluationAuditData Pydantic model containing:

  • mc_points_scored / max_mc_points: multiple choice scoring breakdown
  • ft_points_scored / max_ft_points: free text scoring breakdown
  • missing_mandatory: list of UUIDs for unanswered mandatory questions
  • llm_response: the complete EvaluationResponse from the LLM batch evaluation

Scoring System

Weights and Thresholds

Each question has a positive_weight (points for correct answers) and a negative_weight (points deducted for incorrect answers). The questionnaire defines a min_score (0 to 100 scale) that the percentage score must meet or exceed.

total_points_scored = mc_points_scored + ft_points_scored
total_max_points   = max_mc_points + max_ft_points

score_percent = (total_points_scored / total_max_points) * 100
pass          = score_percent >= min_score

File upload questions excluded from scoring

FileUploadQuestion defaults to positive_weight=0.0 and is not included in the automatic scoring calculation. Only MC and FT points contribute to the score.

Multiple Choice Scoring

For each MultipleChoiceAnswer:

  • If option.is_correct is True: mc_points_scored += question.positive_weight
  • If option.is_correct is False: mc_points_scored -= question.negative_weight

Free Text Scoring

Free text answers are evaluated in a single batch call to the configured LLM backend. For each answer:

  • If is_passing is True: ft_points_scored += question.positive_weight
  • If is_passing is False: ft_points_scored -= question.negative_weight

LLM guidelines are provided at two levels:

  1. Questionnaire-level: Questionnaire.llm_guidelines: global guidelines sent to every LLM call
  2. Question-level: FreeTextQuestion.llm_guidelines: per-question guidelines that supplement the questionnaire-level guidelines

Fatal Questions

Single wrong answer = automatic fail

Questions marked as is_fatal=True cause an immediate failure regardless of the overall score. When a fatal question is answered incorrectly, the score is set to -100.0. Use these for non-negotiable requirements (e.g., age verification, code of conduct agreement).

Mandatory Questions

Questions marked as is_mandatory=True must be answered. If any applicable mandatory question is left unanswered, the score is set to -100.0 and the submission fails. Only questions that are applicable (based on conditional dependencies) are checked for mandatory compliance.

LLM Evaluation

Free text questions are evaluated by a pluggable LLM backend. The backend is configured per questionnaire via the llm_backend field, which stores the fully-qualified Python path to the evaluator class.

The system uses Instructor with an OpenAI-compatible client for vendor-agnostic LLM access. Any OpenAI-compatible provider (OpenAI, Ollama, Azure, vLLM, etc.) can be used — only Django settings need to change.

Backend Pipeline

flowchart TD
    Answer["User's Free Text Answers<br/>(batch)"] --> Backend["LLM Backend"]

    Backend --> Mock["MockEvaluator<br/>(testing)"]
    Backend --> Sanitizing["SanitizingLLMEvaluator<br/>(input sanitization + defense)"]
    Backend --> Sentinel["SentinelLLMEvaluator<br/>(ML detection + sanitization)"]

    Mock -->|"keyword match"| Result["EvaluationResponse"]
    Sanitizing -->|"strip tags + defensive prompting"| Result
    Sentinel -->|"ML check first"| Detection{"Injection<br/>detected?"}
    Detection -->|"Yes"| Reject["Automatic Fail"]
    Detection -->|"No"| SanitizeFirst["Sanitize + evaluate"]
    SanitizeFirst --> Result

    style Reject fill:#c62828,color:#fff
    style Sentinel fill:#1565c0,color:#fff

Available Backends

All backends implement the FreeTextEvaluator protocol and accept a batch of AnswerToEvaluate objects.

Backend Class Security Level Description
MockEvaluator questionnaires.llms.MockEvaluator N/A Approves answers containing the word "good", rejects otherwise. For testing only
SanitizingLLMEvaluator questionnaires.llms.SanitizingLLMEvaluator High Strips all XML-like tags from user answers, then evaluates with defensive prompting that instructs the LLM to ignore injected instructions
SentinelLLMEvaluator questionnaires.llms.SentinelLLMEvaluator Highest Extends SanitizingLLMEvaluator with a local ML model (Hugging Face Transformers) that classifies answers as benign or jailbreak before sanitization and LLM evaluation

Default backend

The default LLM backend is SanitizingLLMEvaluator. In DEMO_MODE, the backend is overridden to MockEvaluator regardless of configuration.

Sentinel model setup

The SentinelLLMEvaluator requires the transformers library (uv sync --group sentinel) and a downloaded model (python manage.py download_sentinel_model). It is conditionally imported only when transformers is available.

LLM Provider Configuration

The LLM provider is configured via Django settings (see revel/settings/llm.py), all sourced from environment variables:

# Model identifier including provider prefix: "provider/model-name"
LLM_DEFAULT_MODEL="ollama/llama3.1:8b"  # Ollama (default for local dev)
# LLM_DEFAULT_MODEL="openai/gpt-4o-mini"  # OpenAI (hosted provider example)

# API key — not needed for local providers like Ollama
# LLM_API_KEY=sk-...

# Override the provider's default base URL (typically not needed)
# LLM_BASE_URL=

# Max retries on validation failure (Instructor handles this)
# LLM_MAX_RETRIES=3

# Instructor mode for structured output extraction (see below)
# LLM_INSTRUCTOR_MODE=JSON

See Instructor integrations for all supported providers.

Instructor Mode (JSON vs TOOLS)

Instructor extracts structured output from LLMs in two ways:

  • JSON mode (LLM_INSTRUCTOR_MODE=JSON): LLM returns JSON in the message content, validated against the Pydantic schema. Works universally.
  • TOOLS mode (LLM_INSTRUCTOR_MODE=TOOLS): Uses the provider's native function/tool-calling API. More reliable with large models that support it well (GPT-4o, Claude, etc.).

The default is JSON because Instructor's auto-detection can misfire for smaller models. For example, ollama/llama3.1:8b is listed as "tool capable" in Instructor's internal registry, but the 8B variant doesn't handle TOOLS mode reliably — it fails with Instructor does not support multiple tool calls. Forcing JSON mode avoids this.

When to change the mode

If you deploy with a provider that fully supports function calling (OpenAI, Anthropic, etc.), you can set LLM_INSTRUCTOR_MODE=TOOLS for potentially better structured output adherence, or leave it empty to let Instructor auto-detect.

Inheritance Hierarchy

FreeTextEvaluator (Protocol)
├── MockEvaluator
└── BaseLLMEvaluator (defensive prompting)
    └── SanitizingLLMEvaluator (sanitizes input, then calls super) — DEFAULT
        └── SentinelLLMEvaluator (ML check, then delegates to sanitize + evaluate)

Prompt Injection Protection

LLM evaluators are attack surfaces

Users submitting free text answers can attempt prompt injection attacks to manipulate the LLM into granting a passing score.

The SentinelLLMEvaluator provides the strongest protection. It works in three stages:

  1. Detection: Each answer is classified by the local Sentinel ML model as benign or jailbreak
  2. Sanitization: XML-like tags and their content are stripped from user answers
  3. Evaluation: Defensive prompting instructs the LLM to ignore any injected instructions

Zero tolerance policy

If the Sentinel model detects prompt injection in any answer, the entire batch is short-circuited: flagged answers receive automatic failure results, and the remaining answers are not forwarded to the LLM. This prevents any possibility of an injected prompt reaching the evaluator.

Evaluation Pipeline (SubmissionEvaluator)

The SubmissionEvaluator class orchestrates the full evaluation of a submitted questionnaire:

flowchart TD
    Start["SubmissionEvaluator.evaluate()"] --> Applicable["Compute applicable questions<br/>(based on conditional deps)"]
    Applicable --> Mandatory["Check for missing<br/>mandatory answers"]
    Mandatory --> MC["Score multiple choice answers"]
    MC --> FT["Evaluate free text answers<br/>(batch LLM call)"]
    FT --> Score["Calculate score_percent"]
    Score --> Mode{"Evaluation<br/>mode?"}
    Mode -->|AUTOMATIC| Final["Set status = proposed_status"]
    Mode -->|MANUAL / HYBRID| Pending["Set status = PENDING_REVIEW"]
    Final --> Save["Create/Update QuestionnaireEvaluation"]
    Pending --> Save

The entire evaluation runs inside a database transaction (@transaction.atomic) to ensure it is an all-or-nothing operation.

Service Layer (QuestionnaireService)

The QuestionnaireService class provides CRUD operations for questionnaires and handles submission:

  • build(): Constructs the questionnaire schema for API responses, applying shuffle logic for questions, options, and sections
  • submit(): Validates answers against the questionnaire, checks mandatory questions, validates file uploads, and creates the submission with all answers
  • create_questionnaire(): Creates a complete questionnaire with sections, questions, and options from a single schema
  • create_section() / create_mc_question() / create_ft_question() / create_fu_question(): Individual creation methods supporting nested conditional content
  • evaluate_submission(): Creates or updates a manual evaluation for a submission

Security

Beyond prompt-injection protection, two access-control invariants guard the questionnaire system.

Admin reads are org-admin gated

The admin questionnaire schema exposes the answer key — is_correct on each option, per-question positive_weight / negative_weight, the questionnaire's min_score, reviewer_notes, and llm_guidelines. None of this may reach a candidate who is about to take the questionnaire.

Accordingly, the admin detail (GET /{org_questionnaire_id}) and list (GET /) endpoints are restricted to organization administrators:

  • The detail endpoint requires QuestionnairePermission("evaluate_questionnaire") (owner or staff with that flag).
  • The list endpoint is scoped to OrganizationQuestionnaire.objects.for_admin(self.user()), so only organizations the user owns or staffs are returned.

Why this matters

Before this was tightened, the admin detail read could leak the full answer key (is_correct, weights, min_score, reviewer_notes, llm_guidelines) to any authenticated user. Candidates take questionnaires through the separate user-facing build() flow, which never serializes those fields.

Submission invariants enforced in the service layer

Answers are persisted with bulk_create, which skips Django's Model.clean(). So model-level checks (MultipleChoiceAnswer.clean()) are not a sufficient guard on the write path. The QuestionnaireService re-enforces the invariants explicitly at submission time:

  • Single-answer questions (allow_multiple_answers=False) may receive at most one selected option. Options are aggregated per question across all answer entries (de-duplicated), so a client cannot split selections into several entries to slip past a per-entry length check. A violation raises DisallowedMultipleAnswersError.
  • Options must belong to their question. Each (question_id, option_id) pair is checked against the options actually owned by that question (queried fresh, since the prefetched cache can be stale). A foreign option id raises CrossQuestionnaireSubmissionError.

Why service-layer enforcement is required

Without these checks, a submitter could pair a single-answer question with every option (guaranteeing the correct one is selected) or borrow a correct option from another question — the evaluator would credit the paired question's weight and, in AUTOMATIC mode, auto-approve an admission gate. The model clean() alone does not catch this because answers are bulk-created.

Information-Gathering Mode (requires_evaluation)

The OrganizationQuestionnaire wrapper has a requires_evaluation boolean field (default True). When set to False, the questionnaire gates access based on submission existence alone — no LLM evaluation, no manual review, no scoring.

This is useful for admission questionnaires that collect information without judgment (e.g., dietary preferences, accessibility needs, emergency contacts).

requires_evaluation Behavior
True (default) Normal flow: submission triggers evaluation (automatic, manual, or hybrid). Gate checks evaluation status.
False Submission is sufficient. No evaluation task is dispatched. Gate only checks that a READY submission exists.

Feedback questionnaires

Feedback questionnaires (questionnaire_type=FEEDBACK) must always have requires_evaluation=False. Creating or updating a feedback questionnaire with requires_evaluation=True returns a 400 error. Existing feedback questionnaires were backpopulated with requires_evaluation=False via data migration.

Interaction with evaluation_mode

The requires_evaluation flag takes precedence over the underlying Questionnaire.evaluation_mode. Even if the questionnaire is configured with AUTOMATIC evaluation mode, setting requires_evaluation=False on the OrganizationQuestionnaire wrapper will skip evaluation entirely.

Submission responses expose requires_evaluation

Submission response schemas (QuestionnaireSubmissionResponseSchema and the staff-side SubmissionListItemSchema) now include the requires_evaluation boolean (resolved from the wrapping OrganizationQuestionnaire). Clients can branch on it — for example, to avoid showing an information-gathering submission as "pending review" when no evaluation will ever run. The resolver is idempotent: when ninja re-validates the pre-built schema instance on the submit endpoint (it has no .questionnaire relation), the already-computed value is reused rather than re-walking the ORM, defaulting to True when no organization wrapper exists.

Integration with Eligibility

Questionnaires integrate with the Eligibility Pipeline through the Questionnaire Gate:

  1. Event is configured to require one or more questionnaires
  2. When a user attempts to RSVP/purchase a ticket, the eligibility pipeline checks questionnaire status
  3. If questionnaires are incomplete, the user receives next_step: COMPLETE_QUESTIONNAIRE
  4. If requires_evaluation=False, submission existence is sufficient — no evaluation checks are performed
  5. If questionnaires are submitted but failed, the user is rejected with the failure reason
flowchart LR
    User["User"] -->|"RSVP"| Eligibility["Eligibility Pipeline"]
    Eligibility -->|"Questionnaire Gate"| Check{"Questionnaires<br/>complete?"}
    Check -->|"Not submitted"| Redirect["next_step:<br/>COMPLETE_QUESTIONNAIRE"]
    Check -->|"Submitted &<br/>requires_evaluation=False"| Continue
    Check -->|"Submitted & passed"| Continue["Continue pipeline"]
    Check -->|"Submitted & failed"| Reject["Rejected"]

    style Reject fill:#c62828,color:#fff
    style Continue fill:#2e7d32,color:#fff