Skip to main content

Writing Practice & AI-Graded Feedback

End-to-end flow

  1. The candidate drafts Task 1 and Task 2 responses in WritingSimulator.tsx (src/ui/pages/).
  2. submitWritingTest(sessionId, answers) (src/ui/services/writingPracticeService.ts) persists the raw answers to user_test_sessions.
  3. gradeWritingTest(tasks) (src/ui/services/aiGradingService.ts) calls evaluateWriting() for both tasks in parallel (Promise.all), each of which invokes the Rust grade_writing command.
  4. persistFeedback(sessionId, overallBand, feedbackData) stores the combined result back onto the session (score_band, feedback_data).
export async function gradeWritingTest(
tasks: Array<{ taskType: "task1" | "task2"; prompt: string; userResponse: string }>
): Promise<{ task1: GradingResult; task2: GradingResult; overallBand: number }> {
const [result1, result2] = await Promise.all(tasks.map((t) => evaluateWriting(t.taskType, t.prompt, t.userResponse)));

// Task 2 carries more weight (2/3) in official IELTS
const overallBand = Math.round(((result1.overallBand * 1 + result2.overallBand * 2) / 3) * 2) / 2;

return { task1: result1, task2: result2, overallBand };
}

Official IELTS weighting

The combined overallBand is not a simple average — Task 2 counts double Task 1, per the official IELTS Writing weighting: (band1 * 1 + band2 * 2) / 3, rounded to the nearest 0.5.

Grading contract

The Rust grade_writing command (src/core/src/commands/grade_writing.rs) returns a GradingResult:

export interface GradingResult {
overallBand: number;
criteria: {
taskAchievement: number;
coherenceCohesion: number;
lexicalResource: number;
grammaticalRange: number;
};
feedback: {
strengths: string[];
weaknesses: string[];
improvements: string;
};
}

(Rust field names are snake_case internally but each field carries an explicit #[serde(rename = "...")] to camelCase for this particular struct — an exception to the general snake_case wire convention described in IPC Contract.)

Configuring an AI provider

grade_writing no longer takes credentials from the frontend. Instead, an admin configures one or more providers from Admin → AI Configurations (src/ui/pages/admin/AiConfigurations.tsx):

  • Supported providers: chatgpt (OpenAI), claude (Anthropic), gemini (Google), local (any OpenAI-compatible local runtime, e.g. Ollama/LM Studio), and general (any other OpenAI-compatible endpoint).
  • Saving a provider's credentials makes it the sole active provider — only one provider is active at a time, matching the previous single-gateway design's simplicity.
  • Credentials are encrypted (AES-256-GCM) before being written to the ai_configurations SQLite table (src/core/src/crypto.rs); the encryption key is a separate file stored next to imh.db. They are only decrypted in-process, transiently, when grade_writing needs to call the provider — never returned to the frontend (see Local Data Storage & Privacy).
  • grade_writing loads the active provider's decrypted credentials via ai_configuration_service::get_active_credentials and fails with "No AI provider is configured" if none is active.

No .env/VITE_-prefixed environment variables are involved in AI grading anymore.

grade_writing command constraints

ConstraintValue
Max user_response length10,000 characters (MAX_RESPONSE_LEN)
Max prompt length5,000 characters (MAX_PROMPT_LEN)
Allowed task_type"task1" or "task2" only
Model / request shapeDepends on the active provider — see table below
Temperature0.3

The exact HTTP request/response shape and default model are provider-specific, isolated in src/core/src/services/ai_provider_client.rs:

ProviderEndpointDefault modelAuth
chatgptPOST https://api.openai.com/v1/chat/completionsgpt-4o-mini (overridable via an optional model credential, like local/general/gemini)API key sent via the HTTP Authorization request header
claudePOST https://api.anthropic.com/v1/messagesclaude-3-5-sonnet-latest (overridable via an optional model credential, like local/general/gemini/chatgpt)x-api-key header + anthropic-version: 2023-06-01
geminiPOST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContentgemini-2.5-flash (overridable via an optional model credential, like local/general)API key as a ?key= query parameter
localPOST <endpoint>/chat/completions(whatever the local runtime serves)none
generalPOST <endpoint>(provider-defined)optional custom header, both admin-configured

System prompt

You are an expert IELTS examiner with years of experience. Grade the following user response to the provided IELTS writing prompt. Evaluate strictly according to the official IELTS band descriptors.

You must return ONLY a valid JSON object with this exact structure (no markdown, no extra text):
{
"overallBand": <number between 0 and 9, in 0.5 increments>,
"criteria": {
"taskAchievement": <number>,
"coherenceCohesion": <number>,
"lexicalResource": <number>,
"grammaticalRange": <number>
},
"feedback": {
"strengths": ["<string>", "<string>"],
"weaknesses": ["<string>", "<string>"],
"improvemegit add src/core/src/services/ai_provider_client.rs \
src/core/src/services/ai_configuration_service.rs \
src/ui/pages/admin/AiConfigurations.tsx \
website/docs/internals/features/writing-module-and-ai-grading.mdx
git commit -m "feat: make Gemini model name configurable; update default to gemini-2.5-flash

The hardcoded gemini-1.5-flash URL is replaced with a template so the model
is injectable at runtime, matching the existing local/general pattern:

- ai_provider_client: replace GEMINI_MODEL_URL constant with a
GEMINI_MODEL_URL_TEMPLATE + gemini_model_url(model) builder; add
GEMINI_MODEL = 'gemini-2.5-flash' default; update get_optional_model to
accept a caller-supplied default rather than always falling back to
OPENAI_MODEL; pass the resolved model into gemini_completion
- ai_configuration_service: add comment noting model is optional for gemini
- AiConfigurations.tsx: add optional Model Name field to the Gemini provider
form (placeholder gemini-2.5-flash, defaults note in hint)
- docs: update provider table to show the templated URL and new default

Unit tests added for gemini_model_url (default and override) and
get_optional_model (no credential falls back to supplied default,
configured credential wins over default)."cific actionable advice>"
}
}

Rules:
- All band scores must be between 0.0 and 9.0 in 0.5 increments.
- overallBand is the average of the 4 criteria scores, rounded to nearest 0.5.
- Be fair but rigorous. Do not inflate scores.
- strengths and weaknesses should each have 2-4 bullet points.
- improvements should be 2-3 sentences of actionable advice.

The Rust command dispatches this system prompt (unchanged regardless of provider) plus the prompt/response as user content to ai_provider_client::complete(provider_id, credentials, system_prompt, user_content) (src/core/src/services/ai_provider_client.rs), which builds the right HTTP request for whichever provider is active (see the endpoint/model table above) and returns the raw completion text.

The returned text is parsed as GradingResult JSON, stripping any Markdown code fences first:

fn extract_json(content: &str) -> String {
if let Some(start) = content.find("```") {
let after = &content[start + 3..];
let after = after.trim_start_matches("json").trim_start_matches('\n');
if let Some(end) = after.find("```") {
return after[..end].trim().to_string();
}
}
content.trim().to_string()
}

Error handling

ConditionError returned
No provider is configured/active"No AI provider is configured"
Active provider's API call failsThe underlying HTTP/validation error message (e.g. a non-2xx response)
Provider response body isn't valid GradingResult JSON"Invalid AI response format"
task_type not "task1"/"task2""taskType must be 'task1' or 'task2'"
user_response over 10,000 chars"userResponse must be under 10000 characters"
prompt over 5,000 chars"prompt must be under 5000 characters"

See Error Handling Conventions for how these surface in the UI.

:::warning Requires network access AI grading calls the configured active AI provider over the network. It does not work offline — unlike Reading and Listening, which are fully local. See Local Data Storage & Privacy for the privacy implications of sending writing submissions to a third-party provider. :::