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:
_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.
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 file | Characters after extraction | Estimate | Measured (local, 19–20 Oct) |
|---|---|---|---|
| Unit 2 (49 questions) | 10,500 | 25 + 14.7 = 39.7 → 40 s | 46.5 s |
| THPTQG mock exam (40 questions) | 60,700 | 25 + 85.0 = 110.0 → 110 s | 116 s |
| A large file | 110,000 | 25 + 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.
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.
Figure 2. The panel in each state. The texts are copied from en/course.json.
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.
extract_text(docx)10,500 charson_estimate(40) before calling Geminientry.estimated_seconds = 40gemini-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.
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.
_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
_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
)
_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
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
// 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} />
)}
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
- Backend: the estimate is reported before the provider is called; it grows with text length; the job stores it; the poll returns it;
thinking_configreaches only Gemini 3+ names (three model names tested). - Frontend: three Vitest cases with fake timers cover the countdown, overtime, and estimating → unknown.
- Full suites: backend 2,477 unit tests pass; frontend 770 tests pass, lint and build pass.
- Real run on a local stack with
gemini-3.8-flash: Unit 2 estimate 40 s, actual 46.5 s, 49 questions; THPTQG estimate 110 s, actual 116 s, 40 questions. The container sent{'thinking_level': 'low'}.
4. Quiz
Five questions. Click an answer to see whether it is right and why.