Public Access
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import { z } from 'zod'
|
|
import { getAIClient, getAIModelForTask } from './aiClient.js'
|
|
import { AiUsageResult, estimateTokensFromMessages, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
|
|
|
|
export type QuestionType =
|
|
| 'multiple-choice'
|
|
| 'multiple-select'
|
|
| 'short-answer'
|
|
| 'translation'
|
|
| 'math-input'
|
|
| 'essay'
|
|
| 'match'
|
|
| 'order'
|
|
| 'fill-in-the-blanks'
|
|
| 'true-false'
|
|
| 'code-submission'
|
|
| 'dropdown-completion'
|
|
| 'numeric-range'
|
|
| 'dictation'
|
|
| 'grammar-correction'
|
|
| 'flashcard'
|
|
|
|
const choiceSchema = z.object({
|
|
id: z.string().min(1),
|
|
text: z.string(),
|
|
})
|
|
|
|
const matchItemSchema = z.object({
|
|
id: z.string().min(1),
|
|
text: z.string(),
|
|
})
|
|
|
|
const generatedQuestionSchema = z.object({
|
|
type: z.enum([
|
|
'multiple-choice',
|
|
'multiple-select',
|
|
'short-answer',
|
|
'translation',
|
|
'math-input',
|
|
'essay',
|
|
'match',
|
|
'order',
|
|
'fill-in-the-blanks',
|
|
'true-false',
|
|
'code-submission',
|
|
'dropdown-completion',
|
|
'numeric-range',
|
|
'dictation',
|
|
'grammar-correction',
|
|
'flashcard',
|
|
]),
|
|
prompt: z.string().min(1),
|
|
category: z.string().optional(),
|
|
points: z.number().int().positive().optional(),
|
|
explanation: z.string().optional(),
|
|
answer: z.union([z.string(), z.array(z.string()), z.record(z.string())]).optional(),
|
|
acceptedForms: z.array(z.string()).optional(),
|
|
caseSensitive: z.boolean().optional(),
|
|
sourceLanguage: z.string().optional(),
|
|
targetLanguage: z.string().optional(),
|
|
choices: z.array(choiceSchema).optional(),
|
|
minWords: z.number().int().nonnegative().optional(),
|
|
rubric: z.array(z.string()).optional(),
|
|
left: z.array(matchItemSchema).optional(),
|
|
right: z.array(matchItemSchema).optional(),
|
|
items: z.array(matchItemSchema).optional(),
|
|
textSegments: z.array(z.string()).optional(),
|
|
blanks: z.array(
|
|
z.object({
|
|
id: z.string().min(1),
|
|
acceptedAnswers: z.array(z.string()),
|
|
acceptedForms: z.array(z.string()).optional(),
|
|
caseSensitive: z.boolean().optional(),
|
|
ignorePunctuation: z.boolean().optional(),
|
|
ignoreAccents: z.boolean().optional(),
|
|
}),
|
|
).optional(),
|
|
dropdowns: z.array(
|
|
z.object({
|
|
id: z.string().min(1),
|
|
options: z.array(choiceSchema),
|
|
correctOptionId: z.string(),
|
|
}),
|
|
).optional(),
|
|
codeConfig: z.object({
|
|
language: z.string(),
|
|
starterCode: z.string().optional(),
|
|
}).optional(),
|
|
numericConfig: z.object({
|
|
answer: z.number(),
|
|
tolerance: z.number(),
|
|
min: z.number().optional(),
|
|
max: z.number().optional(),
|
|
}).optional(),
|
|
textToCorrect: z.string().optional(),
|
|
})
|
|
|
|
export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema>
|
|
|
|
export interface GenerateQuestionsRequest {
|
|
topic: string
|
|
type: QuestionType
|
|
count: number
|
|
language: string
|
|
context?: string
|
|
}
|
|
|
|
function shapeHintForType(type: QuestionType): string {
|
|
switch (type) {
|
|
case 'multiple-choice':
|
|
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"; provide 3-4 plausible distractors), answer (string id of the correct choice).`
|
|
case 'multiple-select':
|
|
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"), answer (array of correct choice ids).`
|
|
case 'short-answer':
|
|
return `Required fields: prompt (string), answer (string), acceptedForms (array of acceptable alternate strings, may be empty), caseSensitive (boolean, usually false).`
|
|
case 'translation':
|
|
return `Required fields: prompt (the source text), sourceLanguage (e.g. "en"), targetLanguage (e.g. "es"), answer (the canonical translation), acceptedForms (other valid translations).`
|
|
case 'math-input':
|
|
return `Required fields: prompt (string, may include LaTeX), answer (LaTeX string), acceptedForms (array of equivalent LaTeX forms).`
|
|
case 'essay':
|
|
return `Required fields: prompt (string), minWords (integer, e.g. 100), rubric (array of 3-5 short rubric criteria strings).`
|
|
case 'match':
|
|
return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).`
|
|
case 'order':
|
|
return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).`
|
|
case 'fill-in-the-blanks':
|
|
return `Required fields: prompt (string), textSegments (array of text strings to interleave with blanks), blanks (array of {id, acceptedAnswers} where acceptedAnswers is an array of correct strings for that blank). The number of textSegments must be blanks.length + 1.`
|
|
case 'true-false':
|
|
return `Required fields: prompt (string), answer (boolean, true or false).`
|
|
case 'code-submission':
|
|
return `Required fields: prompt (string), codeConfig ({language: string, starterCode?: string}), rubric (array of 3-5 short rubric criteria strings).`
|
|
case 'dropdown-completion':
|
|
return `Required fields: prompt (string), textSegments (array of text strings to interleave with dropdowns), dropdowns (array of {id, options: [{id, text}], correctOptionId}). The number of textSegments must be dropdowns.length + 1.`
|
|
case 'numeric-range':
|
|
return `Required fields: prompt (string), numericConfig ({answer: number, tolerance: number, min?: number, max?: number}), answer (same number as numericConfig.answer).`
|
|
case 'dictation':
|
|
return `Required fields: prompt (string), audio ({src: string, a URL to an audio file}), answer (the correct text), acceptedForms (array of acceptable alternate strings), caseSensitive (boolean, usually false).`
|
|
case 'grammar-correction':
|
|
return `Required fields: prompt (string), textToCorrect (the sentence with errors to fix), answer (the corrected sentence), acceptedForms (array of acceptable corrected forms), caseSensitive (boolean, usually false).`
|
|
case 'flashcard':
|
|
return `Required fields: prompt (the front of the card, e.g. a word or phrase), answer (the back of the card, e.g. translation or definition), explanation (optional, extra info to show after reveal).`
|
|
}
|
|
}
|
|
|
|
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<AiUsageResult<GeneratedQuestion[]>> {
|
|
const client = getAIClient()
|
|
if (!client) {
|
|
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
|
}
|
|
|
|
const count = Math.max(1, Math.min(10, Math.floor(req.count)))
|
|
const model = getAIModelForTask('question_generation')
|
|
const shape = shapeHintForType(req.type)
|
|
|
|
const prompt = `You are an exam author. Generate ${count} high-quality "${req.type}" exam question(s) about the following topic.
|
|
|
|
Topic: ${req.topic}
|
|
${req.context ? `\nAdditional context / source material:\n${req.context}\n` : ''}
|
|
Write all user-facing text (prompts, choices, rubric items, explanations) in ${req.language}.
|
|
|
|
${shape}
|
|
|
|
Respond ONLY with a JSON object of the form:
|
|
{ "questions": [ ... ${count} question object(s) ... ] }
|
|
|
|
Each question object must have "type": "${req.type}" and the fields listed above. Do not include "id" — that will be assigned later. Do not include any text outside the JSON object.`
|
|
|
|
const promptTokens = estimateTokensFromText(prompt)
|
|
const estimatedTokens = estimateTotalTokens(promptTokens, 1800)
|
|
|
|
const completion = await client.chat.completions.create({
|
|
model,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
response_format: { type: 'json_object' },
|
|
temperature: 0.6,
|
|
max_completion_tokens: 1800,
|
|
})
|
|
|
|
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
|
|
|
const raw = completion.choices[0]?.message?.content
|
|
if (!raw) {
|
|
throw new Error('Empty response from AI provider')
|
|
}
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(raw)
|
|
} catch {
|
|
throw new Error('Invalid JSON response from AI provider')
|
|
}
|
|
|
|
const obj = parsed as { questions?: unknown }
|
|
if (!Array.isArray(obj.questions)) {
|
|
throw new Error('AI response did not include a "questions" array')
|
|
}
|
|
|
|
const validated: GeneratedQuestion[] = []
|
|
for (const q of obj.questions) {
|
|
const result = generatedQuestionSchema.safeParse(q)
|
|
if (result.success && result.data.type === req.type) {
|
|
validated.push(result.data)
|
|
}
|
|
}
|
|
|
|
if (validated.length === 0) {
|
|
throw new Error('AI returned no valid questions for the requested type')
|
|
}
|
|
return { data: validated, usageTokens }
|
|
}
|
|
|
|
export interface HintRequest {
|
|
question: Record<string, unknown>
|
|
language: string
|
|
}
|
|
|
|
const HINT_STRIP_KEYS = ['answer', 'acceptedForms']
|
|
|
|
function stripAnswerFields(question: Record<string, unknown>): Record<string, unknown> {
|
|
const clone: Record<string, unknown> = {}
|
|
for (const [k, v] of Object.entries(question)) {
|
|
if (HINT_STRIP_KEYS.includes(k)) continue
|
|
clone[k] = v
|
|
}
|
|
return clone
|
|
}
|
|
|
|
export async function getHint(req: HintRequest): Promise<AiUsageResult<{ hint: string }>> {
|
|
const client = getAIClient()
|
|
if (!client) {
|
|
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
|
}
|
|
|
|
const safeQuestion = stripAnswerFields(req.question)
|
|
const model = getAIModelForTask('hint')
|
|
|
|
const messages = [
|
|
{
|
|
role: 'system',
|
|
content: `You are a tutor helping a student during an exam. Give exactly one short hint (1-2 sentences) in ${req.language} that nudges the student toward the right approach. Never reveal the answer, never restate the answer in different words, and never give the answer to a multiple-choice option. Focus on the concept or strategy. Respond ONLY with a JSON object: { "hint": string }.`,
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: `Question:\n${JSON.stringify(safeQuestion)}`,
|
|
},
|
|
]
|
|
|
|
const promptTokens = estimateTokensFromMessages(messages)
|
|
const estimatedTokens = estimateTotalTokens(promptTokens, 160)
|
|
|
|
const completion = await client.chat.completions.create({
|
|
model,
|
|
messages,
|
|
response_format: { type: 'json_object' },
|
|
temperature: 0.4,
|
|
max_completion_tokens: 160,
|
|
})
|
|
|
|
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
|
|
|
const raw = completion.choices[0]?.message?.content
|
|
if (!raw) {
|
|
throw new Error('Empty response from AI provider')
|
|
}
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(raw)
|
|
} catch {
|
|
throw new Error('Invalid JSON response from AI provider')
|
|
}
|
|
const obj = parsed as Record<string, unknown>
|
|
const hint = typeof obj.hint === 'string' ? obj.hint.trim() : ''
|
|
if (!hint) {
|
|
throw new Error('AI did not return a hint')
|
|
}
|
|
return { data: { hint }, usageTokens }
|
|
}
|
|
|
|
export interface TutorChatMessage {
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
}
|
|
|
|
export interface TutorChatRequest {
|
|
exam: Record<string, unknown>
|
|
state: Record<string, unknown>
|
|
scoreSummary: {
|
|
title: string
|
|
scorePoints: number
|
|
totalPoints: number
|
|
correctCount: number
|
|
gradedCount: number
|
|
}
|
|
messages: TutorChatMessage[]
|
|
language: string
|
|
}
|
|
|
|
export async function tutorChat(req: TutorChatRequest): Promise<AiUsageResult<{ reply: string }>> {
|
|
const client = getAIClient()
|
|
if (!client) {
|
|
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
|
}
|
|
|
|
const model = getAIModelForTask('tutor_chat')
|
|
const recent = req.messages.slice(-10)
|
|
|
|
const systemPrompt = `You are a friendly, concise tutor reviewing a completed exam attempt with a student. Reply in ${req.language}. Keep answers short (under 200 words). When the student references a question by number, use the exam's question order. You may explain mistakes, give similar practice problems, or clarify rubric criteria. You may reference the student's actual answers and the AI feedback already produced. Never invent score numbers — use only what's in the exam summary.
|
|
|
|
Exam summary:
|
|
- Title: ${req.scoreSummary.title}
|
|
- Score: ${req.scoreSummary.scorePoints}/${req.scoreSummary.totalPoints}
|
|
- Correct: ${req.scoreSummary.correctCount}/${req.scoreSummary.gradedCount}
|
|
|
|
Exam (JSON): ${JSON.stringify(req.exam).slice(0, 8000)}
|
|
|
|
Student's submission state (answers, flags, etc., JSON): ${JSON.stringify(req.state).slice(0, 4000)}`
|
|
|
|
const messages = [
|
|
{ role: 'system', content: systemPrompt },
|
|
...recent.map((m) => ({ role: m.role, content: m.content })),
|
|
]
|
|
const promptTokens = estimateTokensFromMessages(messages)
|
|
const estimatedTokens = estimateTotalTokens(promptTokens, 600)
|
|
|
|
const completion = await client.chat.completions.create({
|
|
model,
|
|
messages,
|
|
temperature: 0.5,
|
|
max_completion_tokens: 600,
|
|
})
|
|
|
|
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
|
|
|
const reply = completion.choices[0]?.message?.content?.trim()
|
|
if (!reply) {
|
|
throw new Error('Empty response from AI provider')
|
|
}
|
|
return { data: { reply }, usageTokens }
|
|
}
|