Quiz Import Wait Time

How two small changes make quiz import faster and tell the teacher how long it will take: a low thinking level for the AI, and a time estimate that the server computes and the browser counts down.

Branch hoantran1107/audit-ai-feature-accuracy, uncommitted changes, 20 Oct 2026. The same branch also holds the separate QZ-5 docx extraction fix. This page leaves it out.

1. Background

For beginners: why a quiz import waits at all

Skip this part if you already know how the import job works.

A teacher has a quiz in a Word file. They want it as questions in the LMS. Nobody wants to retype 40 questions, so the platform sends the text of the file to a large language model (Gemini) and asks for structured JSON: each question, its options, and the correct answer.

A language model writes its answer one small piece at a time. These pieces are called tokens. A token is roughly three or four characters. The model writes about 200 tokens each second, so a long answer takes a long time. A 40-question quiz is a long answer. This is the main reason for the wait, and it is why the wait grows with the size of the quiz.

A minute is too long for one HTTP request. A proxy can cut the connection, or the teacher can lose their network. So the import runs as a background job:

Figure 1. The import job. The browser polls the job until the status is DONE or FAILED. The job lives in a Python object (_ImportJobEntry) in the backend process.

The part this change touches

Before this change, two things were weak.

Before

  • The teacher saw only "Parsing your document…" and a spinner. They had no idea whether to wait 10 seconds or 5 minutes.
  • Gemini 3 used its default amount of "thinking" (hidden reasoning before the answer). On real production imports, 10–13k input tokens took 67–103 s, and 28–30k took 142–185 s.

After

  • A progress panel shows the file name, the time passed, a bar, and a sentence such as "About 35 sec left."
  • Quiz import asks Gemini 3 for thinking_level: "low". On the Unit 2 file, the wait fell from 79–92 s to 35–43 s with the same answer-key score.
Definition: thinking level. Gemini 3 models can "think" before they answer. They write hidden reasoning tokens first, and those tokens cost time too. thinking_level sets how much of this they do. Turning a quiz file into JSON is mostly copying, not reasoning, so a low level loses little.

2. Intuition

The feature rests on one observation: the wait is roughly a straight line in the size of the text. If the server knows how many characters the AI must read, it can make a fair guess of the wait before the AI starts. The browser does not need the guess to be perfect. It only needs a number to count down from.

The estimate

The formula was fitted on real imports with thinking_level: low:

seconds = 25 + 1.4 × (characters / 1000)      then round UP to a multiple of 5

The 25 seconds is the fixed cost (start-up, the prompt, the first tokens). The 1.4 seconds per thousand characters is the cost of writing out the questions.

Example fileCharacters after extractionEstimateMeasured (local, 19–20 Oct)
Unit 2 (49 questions)10,50025 + 14.7 = 39.7 → 40 s46.5 s
THPTQG mock exam (40 questions)60,70025 + 85.0 = 110.0 → 110 s116 s
A large file110,00025 + 154 = 179 → 180 s(not measured)

Both real runs took 6–7 s longer than the estimate. Part of that is the upload and the text extraction, which happen before the estimate exists, and part is the poll that runs every 2 s.

Try it. Move the sliders and watch the panel react.
📄 Reading questions from "Unit2.docx"
Keep this page open until the preview appears.

The four states of the panel

The panel is a small state machine. The input is the estimate (a number or null) and the elapsed time.

A. Estimating (no estimate, under 15 s)
📄 Reading questions from "Unit2.docx"Elapsed 2 sec
Reading the file and estimating the wait...
B. Counting down (estimate 40 s, at 8 s)
📄 Reading questions from "Unit2.docx"Elapsed 8 sec
About 35 sec left. A file this size usually takes about 40 sec.
C. Overtime (estimate passed)
📄 Reading questions from "Unit2.docx"Elapsed 45 sec
Almost done. This file is taking a little longer than usual.
D. Unknown (no estimate after 15 s)
📄 Reading questions from "scan.pdf"Elapsed 20 sec
This usually takes 1 to 3 minutes.

Figure 2. The panel in each state. The texts are copied from en/course.json.

Invariant: the bar never fills by itself. The bar stops at 95%. Only the server can say "done". A bar at 100% while the teacher still waits would be a lie, and the teacher would think the page is stuck.

Where the estimate comes from

Why does the server compute the estimate, and not the browser? The browser knows only the size of the file in bytes. A docx is a zip of XML, styles, and often images. A 2 MB docx with one photo can have less text than a 60 KB docx with no photos. The server knows the length of the text after extraction, and that is the text Gemini reads.

Figure 3. The estimate's journey. In the Unit 2 run, the estimate reached the browser at the first PROCESSING poll, 3.7 s after the upload.
Edge case: PDF. For a PDF, Gemini reads the file directly, so the server has no text before the call and sends no estimate. The panel goes from state A to state D. If Gemini cannot read the PDF, the server extracts the text and retries, and that retry does produce an estimate.
Edge case: model names. The low thinking level is sent only when the model name is explicitly gemini-3… or higher. Beta and local use the alias gemini-flash-latest, so they get no thinking level and keep the old speed. Production uses gemini-3.8-flash, so it gets the speed-up. The estimate formula was fitted on the fast path, so on beta the panel will often reach "Almost done" early.

3. Code

We follow the data, from the model call outward to the screen.

3.1 Faster: ask for less thinking

The adapter learns one new option. A small guard decides whether the model can take it.

apps/backend/app/services/ai_providers.py
def _supports_thinking_level(model: str) -> bool:
    """Gemini 3 and later take ``thinking_level``; older models reject it."""
    match = re.match(r"gemini-(\d+)", model)
    return bool(match) and int(match.group(1)) >= 3

class GeminiAdapter:
    def __init__(self, api_key, model, thinking_level=None):
        ...
        self._thinking_level = (
            thinking_level if _supports_thinking_level(self._model) else None
        )

    def _config(self, json_schema, temperature):
        ...
        if self._thinking_level is not None:
            config["thinking_config"] = {"thinking_level": self._thinking_level}

Two details are worth noticing. First, gemini-flash-latest does not match gemini-(\d+), so it is treated like an old model. This is on purpose: the alias could point anywhere. Second, parse_file (the native PDF call) used to build its config by hand. It now goes through _config, so PDFs get the low thinking level too.

build_ai_provider passes the option through. OpenAI and Anthropic adapters ignore it, so tenants who bring their own key for those providers see no change.

apps/backend/app/services/docx_import.py
_THINKING_LEVEL = "low"
...
provider = build_ai_provider(self.ai_credentials, thinking_level=_THINKING_LEVEL)

Only quiz import asks for it. The tutor, chatbot, and other AI features keep their defaults.

3.2 The estimate on the server

apps/backend/app/services/docx_import.py
_ESTIMATE_BASE_SECONDS = 25
_ESTIMATE_SECONDS_PER_1K_CHARS = 1.4

def estimate_parse_seconds(text: str) -> int:
    """Expected seconds for the AI to parse ``text``, rounded up to 5 s."""
    seconds = _ESTIMATE_BASE_SECONDS + len(text) / 1000 * _ESTIMATE_SECONDS_PER_1K_CHARS
    return math.ceil(seconds / 5) * 5

The parse code does not know about jobs. It takes a callback instead, and calls it once the text is known, just before the slow Gemini call:

async def _parse_text(self, provider, text, on_estimate=None):
    if on_estimate is not None:
        on_estimate(estimate_parse_seconds(text))
    raw = await provider.generate_json(...)

The background job wires that callback to the job record:

class _ImportJobEntry:
    ...
    estimated_seconds: int | None = None

    def set_estimate(self, seconds: int) -> None:
        self.estimated_seconds = seconds

# in _bg_run_import
llm_output, extra_warnings = await svc._parse_file(
    filename, file_bytes, on_estimate=entry.set_estimate
)
Why a callback? _parse_file is also used outside the job (for example by tests and scripts). A callback keeps it free of job state. The estimate is plain data written to the same in-memory object that the poll already reads, so it adds no network call and no database write.

3.3 The contract

apps/backend/app/schemas/question.py
class ImportJobStatusResponse(BaseModel):
    status: QuizImportJobStatus
    result: ImportPreviewResponse | None = None
    error: str | None = None
    estimated_seconds: int | None = Field(default=None, ...)

The field is optional and new, so old frontends ignore it. The generated client (types.gen.ts, zod.gen.ts) gained one line each: estimated_seconds?: number | null.

3.4 The panel in the browser

apps/frontend/src/pages/Instructor/CourseDetail/sections/QuizQuestionEditor.tsx
// when the upload starts
setImportStartedAt(Date.now());
setImportEstimatedSeconds(null);

// on every poll (every 2 s)
const { status: jobStatus, result, error, estimated_seconds } = res.data;
setImportEstimatedSeconds(estimated_seconds ?? null);

// render
{isImporting && (
    <QuizImportProgress filename={importFilename}
        startedAt={importStartedAt} estimatedSeconds={importEstimatedSeconds} />
)}
apps/frontend/src/pages/Instructor/CourseDetail/sections/QuizImportProgress.tsx
const MAX_PROGRESS = 95;
const ESTIMATING_GRACE_SECONDS = 15;

// a 1 s timer re-renders the panel
const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));

if (estimatedSeconds) {
    value = Math.min(MAX_PROGRESS, (elapsed / estimatedSeconds) * 100);
    const remaining = estimatedSeconds - elapsed;
    detail = remaining > 0
        ? t("…import-progress-remaining", {
              time: formatDuration(Math.ceil(remaining / 5) * 5),
              total: formatDuration(estimatedSeconds) })
        : t("…import-progress-overtime");
} else if (elapsed < ESTIMATING_GRACE_SECONDS) {
    detail = t("…import-progress-estimating");
} else {
    detail = t("…import-progress-unknown");
}

Note the two clocks. The server decides the total once. The browser measures the elapsed time from the moment of upload, with its own clock. So the countdown is smooth (1 s ticks) even though polls arrive only every 2 s. The remaining time is rounded up to 5 s so the text does not flicker every second.

The texts live in en/course.json and vi/course.json (11 new keys each), and resource.d.ts was regenerated so the keys are type-checked.

3.5 Tests and checks

Uncertainty. The speed-up is large on Unit 2 (about 2×) and small on THPTQG (99–114 s with low against 95–122 s with the default). The formula is fitted on two files. More real teacher files would make both numbers more reliable.

4. Quiz

Five questions. Click an answer to see whether it is right and why.