From fe69df40e5bdc715503be54a425fb26d493ce613 Mon Sep 17 00:00:00 2001 From: jshuao Date: Tue, 20 Oct 2026 20:04:38 -0400 Subject: [PATCH 1/4] Make True/False question generation more reliable - Ask for a "statement" field and a True/False statement package, so small models stop returning questions; reject items that are still phrased as questions. - When a generated package is rejected, regenerate it once and tell the model what was wrong, and log the reason as practice_question_validation_failed. - Credit evidence quoted verbatim from a different supplied passage to that passage, and ignore stray PDF spaces before punctuation. --- src/main/index.ts | 5 ++- src/preload/index.ts | 1 + src/renderer/src/practice-client.ts | 24 +++++++--- src/shared/bridge.ts | 1 + src/shared/practice-evidence.ts | 3 ++ src/shared/quiz.ts | 43 ++++++++++++------ tests/ui/practice.jsx | 4 +- tests/unit/typescript/practice.test.mjs | 38 +++++++++++++++- tests/unit/typescript/quiz.test.mjs | 60 ++++++++++++++++++++++++- 9 files changed, 157 insertions(+), 22 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ff4f48da..7829f59b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ import type { IndexMaterialOptions } from '../shared/preparation' -import { preparationReportWithPython } from './python/python-engine-service' +import { preparationReportWithPython, writeTokenSmithLog } from './python/python-engine-service' import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, safeStorage, shell } from 'electron' import { createHash } from 'node:crypto' import { existsSync, readFileSync, statSync } from 'node:fs' @@ -530,6 +530,9 @@ app.whenReady().then(async () => { ipcMain.handle('library:practice-sources', (_event, documents: StudyDocumentRef[], usedSourceKeys: string[], questionIndex: number) => practiceSourcesWithPython(documents, usedSourceKeys, questionIndex) ) + ipcMain.handle('practice:log-question-rejected', (_event, detail: { attempt: number; reason: string; response: string }) => + writeTokenSmithLog('practice_question_validation_failed', detail) + ) ipcMain.handle('library:search', (_event, query: string, materials: CourseMaterial[], limit: number, embeddingModels?: LocalModel[], searchMode?: SearchMode, options?: LibrarySearchOptions) => searchLibraryWithPython(query, materials, limit, embeddingModels, searchMode, options) ) diff --git a/src/preload/index.ts b/src/preload/index.ts index 537a997a..8bd8c4a1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -47,6 +47,7 @@ const tokenSmithBridge: TokenSmithBridge = { studyDocuments: () => ipcRenderer.invoke('library:study-documents'), practiceSources: (documents, usedSourceKeys, questionIndex) => ipcRenderer.invoke('library:practice-sources', documents, usedSourceKeys, questionIndex), + logPracticeQuestionRejected: (detail) => ipcRenderer.invoke('practice:log-question-rejected', detail), getPdfForSource: (source) => ipcRenderer.invoke('library:get-pdf-for-source', source) as Promise< Awaited> diff --git a/src/renderer/src/practice-client.ts b/src/renderer/src/practice-client.ts index 51fef630..89661eaf 100644 --- a/src/renderer/src/practice-client.ts +++ b/src/renderer/src/practice-client.ts @@ -6,6 +6,8 @@ import { trimReferenceExchange } from '../../shared/study-chat-pipeline' import { parsePracticeQuestion, parsePracticeTopics, quizQuestionPrompt } from '../../shared/quiz' import { sourcesInScope, type StudyDocumentRef } from '../../shared/study-scope' +const practiceQuestionAttempts = 2 + export function createPracticeClient(bridge: TokenSmithBridge, model: LocalModel, settings: TokenSmithSettings, retrieval?: { materials: CourseMaterial[]; embeddingModels: LocalModel[] }) { async function send(session: PracticeSession, requestId: string, prompt: string, sources: ChatSource[]) { @@ -67,11 +69,23 @@ export function createPracticeClient(bridge: TokenSmithBridge, model: LocalModel session.questions.flatMap(question => question.sources.map(practiceSourceKey)), session.questions.length) signal.throwIfAborted() if (!sources.length) return null - const reply = await send(session, requestId, - quizQuestionPrompt({ questionNumber: session.questions.length + 1, - previousQuestions: session.questions.slice(-12).map(practiceQuestionText), topic: session.topic }), sources) - signal.throwIfAborted() - const prepared = parsePracticeQuestion(reply.text, reply.sources, 'true_false') + const previousQuestions = session.questions.slice(-12).map(practiceQuestionText) + let prepared: ReturnType | undefined + let rejection: string | undefined + for (let attempt = 1; !prepared; attempt++) { + // Repeating an identical request returns nearly the same package, so say what to fix. + const prompt = quizQuestionPrompt({ questionNumber: session.questions.length + 1, previousQuestions, topic: session.topic, rejection }) + const reply = await send(session, requestId, prompt, sources) + signal.throwIfAborted() + try { + prepared = parsePracticeQuestion(reply.text, reply.sources, 'true_false') + } catch (error) { + const reason = error instanceof Error ? String(error.cause ?? error.message) : String(error) + rejection = reason + void Promise.resolve(bridge.logPracticeQuestionRejected?.({ attempt, reason, response: reply.text })).catch(() => {}) + if (attempt >= practiceQuestionAttempts) throw error + } + } if (session.topic && session.questions.some(question => question.text.trim().toLocaleLowerCase() === prepared.text.trim().toLocaleLowerCase())) { throw new Error('The model repeated an earlier question. Use the arrow to try another, or choose Practice another topic.') } diff --git a/src/shared/bridge.ts b/src/shared/bridge.ts index e12aef3f..9341d90c 100644 --- a/src/shared/bridge.ts +++ b/src/shared/bridge.ts @@ -55,6 +55,7 @@ export interface TokenSmithBridge { starterSources: (materials: CourseMaterial[], limit?: number, documents?: StudyDocumentRef[]) => Promise studyDocuments: () => Promise practiceSources: (documents: StudyDocumentRef[], usedSourceKeys: string[], questionIndex: number) => Promise + logPracticeQuestionRejected: (detail: { attempt: number; reason: string; response: string }) => Promise searchLibrary: (query: string, materials: CourseMaterial[], limit: number, embeddingModels?: LocalModel[], searchMode?: SearchMode, options?: LibrarySearchOptions) => Promise getPdfForSource: (source: ChatSource) => Promise getPdfThumbnailForSource: (source: ChatSource) => Promise diff --git a/src/shared/practice-evidence.ts b/src/shared/practice-evidence.ts index c183f47e..ae310fc4 100644 --- a/src/shared/practice-evidence.ts +++ b/src/shared/practice-evidence.ts @@ -1,4 +1,6 @@ const whitespace = (value: string) => value.replace(/\s+/g, ' ').trim() +// PDF extraction often separates punctuation from the word before it, as in "N3 ." +const punctuationSpacing = (value: string) => whitespace(value).replace(/ ([.,;:!?)\]])/g, '$1').replace(/([(\[]) /g, '$1') // Compare presentation syntax, never paraphrases or algebraic equivalence. Keep // identifiers, numbers, operators, subscripts and superscripts distinguishable. @@ -24,6 +26,7 @@ export function hasSupportingQuote(passage: string, quote: string): boolean { const exact = whitespace(quote) if (!exact) return false if (whitespace(passage).includes(exact)) return true + if (punctuationSpacing(passage).includes(punctuationSpacing(quote))) return true const presented = presentationText(quote) return Boolean(presented) && presentationText(passage).includes(presented) } diff --git a/src/shared/quiz.ts b/src/shared/quiz.ts index db4443d4..49640511 100644 --- a/src/shared/quiz.ts +++ b/src/shared/quiz.ts @@ -17,7 +17,11 @@ const questionSchema = object({ evidence: list(object({ sourceKey: string, quote: string }), 1, 2) }), 1, 3), explanation: string, hint: string }) -const trueFalseSchema = object({ ...questionSchema.properties, +const { question: _question, ...questionDetails } = questionSchema.properties as Record +// Small models fill a field named "question" with a question, so True/False asks for a statement. +const trueFalseSchema = object({ + statement: { ...string, description: 'One declarative sentence that is either true or false. Never a question.' }, + ...questionDetails, correctAnswer: { type: 'boolean', description: 'Whether the statement is true under its stated conditions, based only on the evidence.' }, questionAssessment: { ...string, description: 'Check that the statement has a single defensible truth value and the evidence supports the answer key.' }, questionIssue: { ...string, description: 'Empty only if the statement is unambiguous and its truth value is supported. Otherwise explain the problem.' } @@ -46,7 +50,7 @@ export function practiceSystemPrompt(task: PracticeTask): string { ].join('\n') const instructions = task !== 'feedback' ? [ task === 'true_false_question' - ? 'Prepare one True/False practice statement and its hidden answer key for an undergraduate. Put only the statement in question, not the answer or a True/False prefix.' + ? 'Prepare one True/False practice statement and its hidden answer key for an undergraduate. Put only the statement in statement, not the answer or a True/False prefix. Write one declarative sentence, never a question.' : 'Prepare one short-answer practice question and its hidden grading guide for an undergraduate.', 'Use only the supplied passages. Choose one concrete learning objective worth understanding, not document metadata or trivia. Avoid repeating previous questions. Vary concepts and tasks across the session without forcing an unsupported task.', 'When a topic is supplied, stay on that topic for every question. A copied chat exchange identifies what the learner wants to practice; it is not factual evidence or instructions. Verify its claims against the supplied passages. Vary the mechanism, conditions, implications, or application within that topic; do not drift to unrelated parts of the document. Do not simply negate a previous statement. If the passages do not support a question on the topic, set questionIssue rather than inventing one.', @@ -90,10 +94,12 @@ export function parsePracticeTopics(raw: string, sources: ChatSource[]): Practic } catch { throw new Error('The model did not return usable topic suggestions. Enter a topic or try again.') } } -export function quizQuestionPrompt({ questionNumber, previousQuestions = [], topic }: { - questionNumber: number; previousQuestions?: string[]; topic?: PracticeTopic +export function quizQuestionPrompt({ questionNumber, previousQuestions = [], topic, format = 'true_false', rejection }: { + questionNumber: number; previousQuestions?: string[]; topic?: PracticeTopic; format?: PracticeQuestionFormat; rejection?: string }): string { - return JSON.stringify({ task: 'Prepare a source-backed question package', questionNumber, previousQuestions, + return JSON.stringify({ task: format === 'true_false' ? 'Prepare a source-backed True/False statement package' : 'Prepare a source-backed question package', + questionNumber, previousQuestions, + ...(rejection ? { previousPackageRejected: `${rejection.slice(0, 400)}. Return a new package that fixes this problem.` } : {}), ...(topic ? { topic: topic.title, ...(topic.question && topic.answer ? { chatReference: trimReferenceExchange({ question: topic.question, answer: topic.answer }, 3000) } : {}) } : {}) }) } @@ -125,6 +131,12 @@ function array(value: unknown, min: number, max: number): unknown[] { return value } +// "When" and "Where" are omitted because they often begin conditional statements. +function isStatement(value: string): boolean { + const plain = value.replace(/[*_`"'”’)\]\s]+$/u, '') + return !plain.endsWith('?') && !/^(?:which|what|why|how|who|whom|whose)\b/i.test(value.replace(/^[*_`"'“‘(\[\s]+/u, '')) +} + export function parsePracticeQuestion(raw: string, sources: ChatSource[], format: PracticeQuestionFormat = 'short_answer'): { text: string; format: PracticeQuestionFormat; correctAnswer?: boolean; rubric: PracticeRubric; referenceAnswer: string; hint: string; sources: ChatSource[] } { @@ -133,30 +145,35 @@ export function parsePracticeQuestion(raw: string, sources: ChatSource[], format if (format === 'true_false') { text(value.questionAssessment) if (typeof value.correctAnswer !== 'boolean' || text(value.questionIssue, false)) throw new Error('No supported, unambiguous answer key.') + if (!isStatement(text(value.statement ?? value.question))) { + throw new Error(`The True/False item is a question, not a statement: ${text(value.statement ?? value.question).slice(0, 160)}`) + } } const cited = new Set() const criteria = array(value.criteria, 1, 3).map(item => { const criterion = record(item) const evidence = array(criterion.evidence, 1, 2).map(item => { const citation = record(item) - const sourceKey = text(citation.sourceKey), quote = text(citation.quote) - const source = sources.find(source => practiceSourceKey(source) === sourceKey) - if (!source || !hasSupportingQuote(source.context || source.excerpt, quote)) { - throw new Error('The cited evidence was not present in the supplied passages.') - } + const citedKey = text(citation.sourceKey), quote = text(citation.quote) + const contains = (source: ChatSource) => hasSupportingQuote(source.context || source.excerpt, quote) + const citedSource = sources.find(source => practiceSourceKey(source) === citedKey) + const source = citedSource && contains(citedSource) ? citedSource : sources.find(contains) + if (!source) throw new Error(`Evidence for ${text(criterion.id, false) || 'a criterion'} was not found in the supplied passages: ${quote.slice(0, 160)}`) + const sourceKey = practiceSourceKey(source) cited.add(sourceKey) return { sourceKey, quote } }) return { id: text(criterion.id), description: text(criterion.description), evidence } }) if (new Set(criteria.map(criterion => criterion.id)).size !== criteria.length) throw new Error('Duplicate criteria.') - return { text: text(value.question), format, ...(format === 'true_false' ? { correctAnswer: value.correctAnswer as boolean } : {}), + return { text: text(format === 'true_false' ? value.statement ?? value.question : value.question), format, ...(format === 'true_false' ? { correctAnswer: value.correctAnswer as boolean } : {}), rubric: { objective: text(value.objective), criteria, assumptions: array(value.assumptions, 0, 3).map(item => text(item)) }, referenceAnswer: text(value.explanation), hint: text(value.hint), sources: sources.filter(source => cited.has(practiceSourceKey(source))) } - } catch { - throw new Error('The model did not return a valid question with verifiable supporting passages. Try preparing the question again.') + } catch (error) { + throw new Error('The model did not return a valid question with verifiable supporting passages. Try preparing the question again.', + { cause: error instanceof Error ? error.message : String(error) }) } } diff --git a/tests/ui/practice.jsx b/tests/ui/practice.jsx index 3000feb8..351a09a1 100644 --- a/tests/ui/practice.jsx +++ b/tests/ui/practice.jsx @@ -39,6 +39,7 @@ let state = JSON.parse(sessionStorage.getItem(storageKey) || 'null') || { modelSettingsById: { test: { reasoningMode: 'off', contextLength: 8192, maxLength: 1536 }, cloud: { contextLength: 16384, maxLength: 2048 } } } } const calls = [] +const tracking = [] const progressListeners = new Set() let questionCalls = 0 const sourceFor = (document, index) => ({ @@ -47,7 +48,7 @@ const sourceFor = (document, index) => ({ excerpt: 'The ancestor path allows a split to propagate to parent nodes.', context: 'The ancestor path allows a split to propagate to parent nodes.', lineFrom: 1, lineTo: 4 }) -window.practiceTest = { calls, state: () => state, +window.practiceTest = { calls, tracking, state: () => state, removeLocalModel: () => progressListeners.forEach(listener => listener({ model: 'ui-test', status: 'removed', percent: 0 })), disconnectCloud: () => { state.models = state.models.map(model => model.id === 'cloud' ? { ...model, status: 'needsRuntime', cloudCredentialStatus: 'reconnect' } : model); sessionStorage.setItem(storageKey, JSON.stringify(state)) } } window.tokensmith = { @@ -90,6 +91,7 @@ window.tokensmith = { if (params.has('error-suggestions') && calls.filter(call => call.kind === 'suggestions').length === 1) throw new Error('Suggestions unavailable') return { suggestions: ['Why record ancestors during insertion?', 'How can a split reach the root?', 'What changes in the parent when a leaf splits?'], sources: [] } }, + logPracticeQuestionRejected: async detail => { tracking.push({ kind: 'question-rejected', detail }) }, practiceSources: async (selected, used, index) => { calls.push({ kind: 'sources', selected, used, index }) if (params.has('slow-sources')) await new Promise(resolve => setTimeout(resolve, 2000)) diff --git a/tests/unit/typescript/practice.test.mjs b/tests/unit/typescript/practice.test.mjs index 3be231d6..80f52e34 100644 --- a/tests/unit/typescript/practice.test.mjs +++ b/tests/unit/typescript/practice.test.mjs @@ -259,11 +259,47 @@ test('invalid model output fails explicitly; older questions never get invented assert.equal(await empty.client.question(session, 'r', new AbortController().signal), null) for (const reply of [{ text: '', sources: [source] }, { text: 'Question?', sources: [] }, { text: JSON.stringify(packageData), sources: [source] }]) { - const { client } = harness({ sendChatMessage: async () => reply }) + let sent = 0 + const { client } = harness({ sendChatMessage: async () => { sent++; return reply } }) await assert.rejects(client.question(session, 'r', new AbortController().signal), /valid question/) + assert.equal(sent, 2) } }) +test('a rejected question is logged and regenerated once from the same passages', async () => { + const rejected = [] + let replies = 0 + const { client, calls } = harness({ + sendChatMessage: async request => { calls.push(['model', request]); replies++ + return { text: replies === 1 ? '{"question": "Unsupported?"}' : JSON.stringify(trueFalseData), sources: [source] } }, + logPracticeQuestionRejected: async detail => { rejected.push(detail) } + }) + const generated = await client.question(session, 'r', new AbortController().signal) + assert.equal(generated.text, trueFalseData.question) + assert.equal(calls.filter(call => call[0] === 'sources').length, 1) + const requests = calls.filter(call => call[0] === 'model').map(call => call[1]) + assert.equal(requests.length, 2) + assert.deepEqual(requests[0].retrievedSources, requests[1].retrievedSources) + const [first, second] = requests.map(request => JSON.parse(request.prompt)) + assert.equal(first.previousPackageRejected, undefined) + assert.equal(second.previousPackageRejected, `${rejected[0].reason}. Return a new package that fixes this problem.`) + assert.deepEqual({ ...second, previousPackageRejected: undefined }, { ...first, previousPackageRejected: undefined }) + assert.deepEqual(rejected.map(item => [item.attempt, item.response]), [[1, '{"question": "Unsupported?"}']]) + assert.ok(rejected[0].reason) +}) + +test('a cancelled or repeated question is not regenerated', async () => { + const controller = new AbortController() + const cancelled = harness({ sendChatMessage: async request => { cancelled.calls.push(['model', request]); controller.abort(); return { text: 'Question?', sources: [source] } } }) + await assert.rejects(cancelled.client.question(session, 'r', controller.signal)) + assert.equal(cancelled.calls.filter(call => call[0] === 'model').length, 1) + const topicSession = { ...session, topic: { title: 'Paths', sources: [source], prepared: true }, + questions: [{ id: 'q0', text: trueFalseData.question, format: 'true_false', sources: [source], attempts: [] }] } + const repeated = harness() + await assert.rejects(repeated.client.question(topicSession, 'r', new AbortController().signal), /repeated an earlier question/) + assert.equal(repeated.calls.filter(call => call[0] === 'model').length, 1) +}) + test('open-ended practice bounds question history while retaining all used source identities', async () => { const { calls, client } = harness() const questions = Array.from({ length: 80 }, (_, index) => ({ ...question, id: `q${index}`, text: `Question ${index}` })) diff --git a/tests/unit/typescript/quiz.test.mjs b/tests/unit/typescript/quiz.test.mjs index 6941dd89..dd1e6237 100644 --- a/tests/unit/typescript/quiz.test.mjs +++ b/tests/unit/typescript/quiz.test.mjs @@ -27,6 +27,22 @@ test('topic suggestions require evidence from the packed documents and short con assert.match(practiceSystemPrompt('topics'), /do not suggest them unless the documents cover them/) }) +test('True/False packages ask for a statement field and say why a previous package was rejected', () => { + const schema = practiceResponseSchema('true_false_question') + assert.equal(schema.properties.question, undefined) + assert.match(schema.properties.statement.description, /declarative sentence.*Never a question/) + assert.equal(schema.required[0], 'statement') + assert.match(practiceSystemPrompt('true_false_question'), /statement in statement.*never a question/) + assert.equal(practiceResponseSchema('question').properties.question.type, 'string') + const first = JSON.parse(quizQuestionPrompt({ questionNumber: 1 })) + assert.equal(first.task, 'Prepare a source-backed True/False statement package') + assert.equal(first.previousPackageRejected, undefined) + assert.equal(JSON.parse(quizQuestionPrompt({ questionNumber: 1, format: 'short_answer' })).task, 'Prepare a source-backed question package') + const retry = JSON.parse(quizQuestionPrompt({ questionNumber: 1, rejection: 'The True/False item is a question, not a statement: Why?' })) + assert.equal(retry.previousPackageRejected, 'The True/False item is a question, not a statement: Why?. Return a new package that fixes this problem.') + assert.ok(JSON.parse(quizQuestionPrompt({ questionNumber: 1, rejection: 'x'.repeat(2000) })).previousPackageRejected.length < 460) +}) + test('topic prompts preserve the chosen concept and bound the copied Chat exchange', () => { const prompt = JSON.parse(quizQuestionPrompt({ questionNumber: 4, previousQuestions: ['Earlier statement'], topic: { title: 'Heap files', question: 'A question '.repeat(500), answer: 'An answer '.repeat(1000) } })) @@ -50,8 +66,8 @@ test('question packages require evidence in the actual packed sources, not model assert.equal(question.text, packageData.question) assert.equal(question.referenceAnswer, packageData.explanation) for (const mutate of [ - p => p.criteria[0].evidence[0].sourceKey = 'another-document', p => p.criteria[0].evidence[0].quote = 'A quotation that is not present.', + p => { p.criteria[0].evidence[0].sourceKey = 'another-document'; p.criteria[0].evidence[0].quote = 'A quotation that is not present.' }, p => p.criteria[0].evidence = [], p => p.criteria[1].id = 'c1', p => p.criteria = [], @@ -67,6 +83,34 @@ test('question packages require evidence in the actual packed sources, not model criteria: [{ ...packageData.criteria[0], evidence: [{ sourceKey: 'c:1:1', quote: 'F = ma' }] }] }), [short])) }) +test('question evidence quoted from a different supplied passage is credited to that passage', () => { + const other = { materialId: 'c', documentId: 1, chunkId: '2', excerpt: 'Buffer pools cache disk pages in memory.' } + const changed = structuredClone(packageData) + changed.criteria[1].evidence[0] = { sourceKey: 'c:1:1', quote: 'Buffer pools cache disk pages in memory.' } + const parsed = parsePracticeQuestion(JSON.stringify(changed), [source, other]) + assert.equal(parsed.rubric.criteria[1].evidence[0].sourceKey, 'c:1:2') + assert.deepEqual(parsed.sources, [source, other]) + changed.criteria[1].evidence[0].sourceKey = 'invented-key' + assert.equal(parsePracticeQuestion(JSON.stringify(changed), [source, other]).rubric.criteria[1].evidence[0].sourceKey, 'c:1:2') +}) + +test('question evidence ignores stray spaces that PDF extraction leaves around punctuation', () => { + const pdf = { ...source, excerpt: 'Let V2 and V3 denote the version vectors at N2 and N3 . Then V2 [2] = 2 ( for N2 ) .' } + const changed = structuredClone(packageData) + changed.criteria = [{ ...packageData.criteria[0], evidence: [{ sourceKey: 'c:1:1', quote: 'Let V2 and V3 denote the version vectors at N2 and N3.' }] }, + { ...packageData.criteria[1], evidence: [{ sourceKey: 'c:1:1', quote: 'Then V2 [2] = 2 (for N2).' }] }] + assert.doesNotThrow(() => parsePracticeQuestion(JSON.stringify(changed), [pdf])) + changed.criteria[1].evidence[0].quote = 'Then V2[2] = 2' + assert.throws(() => parsePracticeQuestion(JSON.stringify(changed), [pdf]), /valid question/) +}) + +test('rejected question packages keep the specific reason for logging', () => { + const changed = structuredClone(packageData) + changed.criteria[0].evidence[0].quote = 'A quotation that is not present.' + assert.throws(() => parsePracticeQuestion(JSON.stringify(changed), [source]), + error => /valid question/.test(error.message) && /c1 was not found.*A quotation that is not present/.test(error.cause)) +}) + test('True/False keys are explicit booleans, backed by quoted evidence and an ambiguity check', () => { const binary = { ...packageData, question: 'Heap records are stored in key order.', correctAnswer: false, questionAssessment: 'The source explicitly contradicts key ordering.', questionIssue: '' } @@ -86,6 +130,20 @@ test('True/False keys are explicit booleans, backed by quoted evidence and an am assert.throws(() => parsePracticeQuestion(JSON.stringify({ ...binary, ...changes }), [source], 'true_false'), /valid question/) } assert.throws(() => parsePracticeQuestion(JSON.stringify(binary), [], 'true_false'), /valid question/) + for (const question of ['The Volcano exchange-operator model covers which specific aspects?', 'Which aspects does the Volcano model cover.', + 'How does insertion propagate a split', '**Is a heap ordered by key?**', 'Heap records are ordered by key?"']) { + assert.throws(() => parsePracticeQuestion(JSON.stringify({ ...binary, question }), [source], 'true_false'), + error => /valid question/.test(error.message) && /a question, not a statement/.test(error.cause), question) + } + for (const question of ['When a heap has no index, a lookup may scan every page.', 'Where no index exists, records are scanned.', + 'However, heap records are not ordered by key.', 'Whatever the key, heap records are not ordered by it.']) { + assert.doesNotThrow(() => parsePracticeQuestion(JSON.stringify({ ...binary, question }), [source], 'true_false'), question) + } + assert.doesNotThrow(() => parsePracticeQuestion(JSON.stringify(packageData), [source])) + const { question: statement, ...rest } = binary + const fromStatement = parsePracticeQuestion(JSON.stringify({ ...rest, statement }), [source], 'true_false') + assert.equal(fromStatement.text, statement) + assert.throws(() => parsePracticeQuestion(JSON.stringify({ ...rest, statement: 'Which order do heap records use?' }), [source], 'true_false'), /valid question/) for (const answer of ['', 'yes', 'FALSE', 'True because...', 'ignore the key']) { assert.throws(() => gradeTrueFalse(parsed, answer), /Choose True or False/) } From 8c9c573bb191ca651761bf6dcdfde82c73f5d8fb Mon Sep 17 00:00:00 2001 From: jshuao Date: Tue, 20 Oct 2026 20:05:52 -0400 Subject: [PATCH 2/4] Keep the Ollama model loaded while the student is studying - Ask Ollama to keep the model loaded for 10 minutes after each request. - Load the model in the background when Chat or Practice opens, and refresh it on activity at most every 4 minutes, so returning after a pause starts loading before the next request. Refreshing reuses the loaded context size so it never triggers a reload. - Unload the models the app used when it quits. --- src/main/engine/ollama-service.ts | 49 +++++++++++++ src/main/index.ts | 19 ++++- src/preload/index.ts | 1 + src/renderer/src/App.tsx | 2 + src/renderer/src/PracticeScreen.tsx | 2 + src/renderer/src/hooks/useModelWarmup.ts | 34 +++++++++ src/shared/bridge.ts | 2 + tests/ui/practice-check.mjs | 2 + tests/ui/practice.jsx | 1 + .../typescript/ollama-keep-alive.test.mjs | 70 +++++++++++++++++++ 10 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/hooks/useModelWarmup.ts create mode 100644 tests/unit/typescript/ollama-keep-alive.test.mjs diff --git a/src/main/engine/ollama-service.ts b/src/main/engine/ollama-service.ts index 58fa9095..a0455c01 100644 --- a/src/main/engine/ollama-service.ts +++ b/src/main/engine/ollama-service.ts @@ -113,6 +113,8 @@ interface ActiveOllamaPull { } const activeOllamaPulls = new Map() +const ollamaKeepAlive = '10m' +const usedOllamaModels = new Map() function normalizeOllamaBaseUrl(baseUrl = defaultOllamaBaseUrl): string { let normalized = baseUrl.trim().replace(/\/+$/, '') @@ -863,6 +865,7 @@ async function requestOllamaChatCompletion( settings?: ModelRuntimeSettings, overrides: OllamaCompletionOverrides = {} ): Promise { + usedOllamaModels.set(`${baseUrl}\n${modelName}`, { baseUrl, modelName }) if (settings && usesAutomaticContext(settings)) { const promptEstimate = Math.ceil(messages.reduce((sum, message) => sum + message.content.length, 0) / 2.5) + 64 settings = { ...settings, contextLength: localContextForTokens(promptEstimate + (overrides.maxTokens ?? settings.maxLength) + 512, @@ -885,6 +888,7 @@ async function requestOllamaChatCompletion( ...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}) }, ...(overrides.format ? { format: overrides.format } : {}), + keep_alive: ollamaKeepAlive, stream: false, think: overrides.thinking ?? false }), @@ -898,6 +902,51 @@ async function requestOllamaChatCompletion( return (await response.json()) as OllamaChatResponse } +export async function warmOllamaModel(model: LocalModel, modelSettings?: ModelRuntimeSettings): Promise { + assertOllamaModel(model) + const baseUrl = model.ollamaBaseUrl || defaultOllamaBaseUrl + usedOllamaModels.set(`${baseUrl}\n${model.ollamaModelName}`, { baseUrl, modelName: model.ollamaModelName }) + // Refreshing a loaded model with a different num_ctx would make Ollama reload it. + const loaded = await loadedOllamaContextLength(baseUrl, model.ollamaModelName) + const runtime = loaded ? undefined : (await requestWithOllamaRuntimeContext({ model, modelSettings }, baseUrl, model.ollamaModelName)).modelSettings + const contextLength = loaded ?? (runtime && (usesAutomaticContext(runtime) ? localContextForTokens(0, runtime.contextLength) : runtime.contextLength)) + const response = await fetchWithTimeout(`${ollamaApiBaseUrl(baseUrl)}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + model: model.ollamaModelName, + messages: [], + keep_alive: ollamaKeepAlive, + ...(contextLength ? { options: { num_ctx: contextLength } } : {}) + }) + }, 180_000) + if (!response.ok) { + throw new Error(`Ollama could not load the model: HTTP ${response.status}${await responseErrorDetail(response)}.`) + } +} + +async function loadedOllamaContextLength(baseUrl: string, modelName: string): Promise { + try { + const response = await fetchWithTimeout(`${ollamaApiBaseUrl(baseUrl)}/ps`, {}, 3_000) + if (!response.ok) return undefined + const { models = [] } = await response.json() as { models?: (OllamaModelInfo & { context_length?: number })[] } + const contextLength = models.find(item => ollamaModelMatches(item, modelName))?.context_length + return Number.isInteger(contextLength) && Number(contextLength) > 0 ? Number(contextLength) : undefined + } catch { + return undefined + } +} + +export async function unloadOllamaModels(): Promise { + const models = [...usedOllamaModels.values()] + usedOllamaModels.clear() + await Promise.allSettled(models.map(({ baseUrl, modelName }) => fetchWithTimeout(`${ollamaApiBaseUrl(baseUrl)}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: modelName, keep_alive: 0 }) + }, 3_000))) +} + async function runOllamaChatCompletion( baseUrl: string, modelName: string, diff --git a/src/main/index.ts b/src/main/index.ts index 7829f59b..7a28d0c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,7 +31,9 @@ import { openOllamaApp, openOllamaDownloadPage, pullOllamaModel, - startOllamaService + startOllamaService, + unloadOllamaModels, + warmOllamaModel } from './engine/ollama-service' import { searchOllamaLibrary } from './engine/ollama-library-search' import { detectDeviceCapabilities } from './system/detect-device-capabilities' @@ -41,7 +43,7 @@ import { modelWithRememberedRemoteApiKey, sanitizeAppStateSecrets } from './engine/remote-model-secrets' -import type { AppStateSnapshot, ChatSource, CourseMaterial, LocalModel, LocalModelRole, SearchMode } from '../shared/app-state' +import type { AppStateSnapshot, ChatSource, CourseMaterial, LocalModel, LocalModelRole, ModelRuntimeSettings, SearchMode } from '../shared/app-state' import type { StudyDocumentRef, LibrarySearchOptions } from '../shared/study-scope' import { removeRetiredModelState } from '../shared/retired-model-state' import type { CleaningProfileId, CleaningRuleId } from '../shared/cleaning' @@ -533,6 +535,9 @@ app.whenReady().then(async () => { ipcMain.handle('practice:log-question-rejected', (_event, detail: { attempt: number; reason: string; response: string }) => writeTokenSmithLog('practice_question_validation_failed', detail) ) + ipcMain.handle('engine:warm-model', (_event, model: LocalModel, modelSettings: ModelRuntimeSettings) => + model.engine === 'ollama' ? warmOllamaModel(model, modelSettings) : undefined + ) ipcMain.handle('library:search', (_event, query: string, materials: CourseMaterial[], limit: number, embeddingModels?: LocalModel[], searchMode?: SearchMode, options?: LibrarySearchOptions) => searchLibraryWithPython(query, materials, limit, embeddingModels, searchMode, options) ) @@ -601,6 +606,16 @@ app.whenReady().then(async () => { }) }) +let ollamaModelsReleased = false +app.on('will-quit', event => { + if (ollamaModelsReleased) return + event.preventDefault() + void unloadOllamaModels().finally(() => { + ollamaModelsReleased = true + app.quit() + }) +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() diff --git a/src/preload/index.ts b/src/preload/index.ts index 8bd8c4a1..7f96c9b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -48,6 +48,7 @@ const tokenSmithBridge: TokenSmithBridge = { practiceSources: (documents, usedSourceKeys, questionIndex) => ipcRenderer.invoke('library:practice-sources', documents, usedSourceKeys, questionIndex), logPracticeQuestionRejected: (detail) => ipcRenderer.invoke('practice:log-question-rejected', detail), + warmModel: (model, modelSettings) => ipcRenderer.invoke('engine:warm-model', model, modelSettings), getPdfForSource: (source) => ipcRenderer.invoke('library:get-pdf-for-source', source) as Promise< Awaited> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6e883664..9a0964e7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -103,6 +103,7 @@ import { } from '@shared/model-defaults' import { DeviceRecommendationPanel } from './components/DeviceRecommendationPanel' import { useDeviceCapabilities } from './hooks/useDeviceCapabilities' +import { useModelWarmup } from './hooks/useModelWarmup' import { modelAwareRetrievalLimit } from '@shared/retrieval-budget' import { contextMetadataIdentity, fallbackContextLength, mergeModelContextMetadata, migratedContextMode, normalizeTokenLimit, reportedContextLimit, usesAutomaticContext } from '@shared/model-context' @@ -2487,6 +2488,7 @@ function ChatScreen({ setChatError(null) }, [activeMaterialKey]) const selectedModelSettings = selectedModel ? modelSettingsFor(settings, selectedModel.id) : settings.modelDefaults + useModelWarmup(isActive, selectedModel, selectedModelSettings) const canSuggestStarterQuestions = isActive && !activeConversation.practiceReference && diff --git a/src/renderer/src/PracticeScreen.tsx b/src/renderer/src/PracticeScreen.tsx index 6a844bf6..e7653d54 100644 --- a/src/renderer/src/PracticeScreen.tsx +++ b/src/renderer/src/PracticeScreen.tsx @@ -18,6 +18,7 @@ import { PracticeActions, PracticeComposer } from './PracticeComposer' import { ConversationViewport } from './ConversationViewport' import { PracticeFeedbackView } from './PracticeFeedbackView' import { createPracticeClient } from './practice-client' +import { useModelWarmup } from './hooks/useModelWarmup' import type { GenerationProgressApi } from './hooks/useGenerationProgress' import './practice.css' @@ -64,6 +65,7 @@ export function PracticeScreen(props: Props) { const navigationQuestions = useMemo(() => session?.questions.map(item => ({ id: item.id, text: practiceQuestionText(item) })) ?? [], [session?.questions]) const model = session ? models.find(item => item.id === session.modelId) : props.selectedModel const available = model?.status === 'ready' + useModelWarmup(isActive, model, model && (session?.modelSettings ?? props.modelSettingsFor(model.id))) const scope = props.scope const scopeKey = studyScopeKey(scope) const selected = scope.map(studyDocumentKey) diff --git a/src/renderer/src/hooks/useModelWarmup.ts b/src/renderer/src/hooks/useModelWarmup.ts new file mode 100644 index 00000000..428748f9 --- /dev/null +++ b/src/renderer/src/hooks/useModelWarmup.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react' +import type { LocalModel, ModelRuntimeSettings } from '@shared/app-state' + +// Well inside Ollama's 10-minute keep-alive, so an active student never meets a cold model, +// while an idle one releases the model's memory after the keep-alive expires. +export const modelWarmupRefreshMs = 4 * 60_000 + +export function useModelWarmup(enabled: boolean, model?: LocalModel, settings?: ModelRuntimeSettings) { + const target = useRef({ model, settings }) + target.current = { model, settings } + const ready = enabled && model?.engine === 'ollama' && model.status === 'ready' && Boolean(settings) + useEffect(() => { + if (!ready) return + let warmedAt = 0 + const warm = () => { + const { model, settings } = target.current + if (!model || !settings || Date.now() - warmedAt < modelWarmupRefreshMs) return + warmedAt = Date.now() + void Promise.resolve().then(() => window.tokensmith?.warmModel(model, settings)).catch(() => { warmedAt = 0 }) + } + const onVisible = () => { if (document.visibilityState === 'visible') warm() } + warm() + window.addEventListener('pointerdown', warm) + window.addEventListener('keydown', warm) + window.addEventListener('focus', warm) + document.addEventListener('visibilitychange', onVisible) + return () => { + window.removeEventListener('pointerdown', warm) + window.removeEventListener('keydown', warm) + window.removeEventListener('focus', warm) + document.removeEventListener('visibilitychange', onVisible) + } + }, [ready, model?.id, settings?.contextLength, settings?.contextLengthMode]) +} diff --git a/src/shared/bridge.ts b/src/shared/bridge.ts index 9341d90c..6d73d923 100644 --- a/src/shared/bridge.ts +++ b/src/shared/bridge.ts @@ -7,6 +7,7 @@ import type { LocalModel, LocalModelRole, MaterialIndexProgress, + ModelRuntimeSettings, SearchMode } from './app-state' import type { @@ -56,6 +57,7 @@ export interface TokenSmithBridge { studyDocuments: () => Promise practiceSources: (documents: StudyDocumentRef[], usedSourceKeys: string[], questionIndex: number) => Promise logPracticeQuestionRejected: (detail: { attempt: number; reason: string; response: string }) => Promise + warmModel: (model: LocalModel, modelSettings: ModelRuntimeSettings) => Promise searchLibrary: (query: string, materials: CourseMaterial[], limit: number, embeddingModels?: LocalModel[], searchMode?: SearchMode, options?: LibrarySearchOptions) => Promise getPdfForSource: (source: ChatSource) => Promise getPdfThumbnailForSource: (source: ChatSource) => Promise diff --git a/tests/ui/practice-check.mjs b/tests/ui/practice-check.mjs index ad09796d..e2e173ec 100644 --- a/tests/ui/practice-check.mjs +++ b/tests/ui/practice-check.mjs @@ -53,6 +53,8 @@ try { assert.equal(await button(practice, 'True').isDisabled(), true) assert.equal((await session()).questions[0].attempts.length, 1) assert.equal((await session()).questions[0].attempts[0].assisted, false) + const tracked = await page.evaluate(() => window.practiceTest.tracking) + assert.ok(tracked.some(c => c.kind === 'warm' && c.modelId === original.modelId), 'Opening Practice loads the session model') const savedAttempt = (await session()).questions[0].attempts[0] await page.screenshot({ path: `${output}/feedback-${name}.png` }) const practiceStyle = await button(practice, 'Chat about this question').evaluate(el => { diff --git a/tests/ui/practice.jsx b/tests/ui/practice.jsx index 351a09a1..de9b08a8 100644 --- a/tests/ui/practice.jsx +++ b/tests/ui/practice.jsx @@ -92,6 +92,7 @@ window.tokensmith = { return { suggestions: ['Why record ancestors during insertion?', 'How can a split reach the root?', 'What changes in the parent when a leaf splits?'], sources: [] } }, logPracticeQuestionRejected: async detail => { tracking.push({ kind: 'question-rejected', detail }) }, + warmModel: async (model, settings) => { tracking.push({ kind: 'warm', modelId: model.id, contextLength: settings.contextLength }) }, practiceSources: async (selected, used, index) => { calls.push({ kind: 'sources', selected, used, index }) if (params.has('slow-sources')) await new Promise(resolve => setTimeout(resolve, 2000)) diff --git a/tests/unit/typescript/ollama-keep-alive.test.mjs b/tests/unit/typescript/ollama-keep-alive.test.mjs new file mode 100644 index 00000000..7bfdc9fa --- /dev/null +++ b/tests/unit/typescript/ollama-keep-alive.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { requireTranspiledTs } from './ts-module-loader.mjs' + +const { runOllamaStudyEngine, unloadOllamaModels, warmOllamaModel } = requireTranspiledTs('src/main/engine/ollama-service.ts') +const model = { id: 'ollama:test', engine: 'ollama', name: 'Test', ollamaModelName: 'test', status: 'ready', contextLength: 131072 } +const auto = { contextLengthMode: 'auto', contextLength: 8192, maxLength: 1024, reasoningMode: 'off', thinking: false, systemMessage: '' } + +async function mocked(handler, run) { + const saved = globalThis.fetch + const calls = [] + globalThis.fetch = async (url, options = {}) => { + const call = { url: String(url), body: options.body ? JSON.parse(options.body) : undefined } + calls.push(call) + return { ok: true, json: async () => handler(call) } + } + try { await unloadOllamaModels(); calls.length = 0; await run(); return calls } finally { globalThis.fetch = saved } +} + +test('warming a model that is already loaded keeps its context size, so Ollama does not reload it', async () => { + const calls = await mocked(({ url }) => url.endsWith('/ps') ? { models: [{ name: 'test:latest', context_length: 16384 }] } : {}, + () => warmOllamaModel(model, auto)) + const warm = calls.find(call => call.url.endsWith('/chat')) + assert.deepEqual(warm.body, { model: 'test', messages: [], keep_alive: '10m', options: { num_ctx: 16384 } }) +}) + +test('warming an unloaded model uses the same automatic context size as practice requests', async () => { + const calls = await mocked(({ url }) => url.endsWith('/ps') ? { models: [] } : {}, () => warmOllamaModel(model, auto)) + assert.deepEqual(calls.find(call => call.url.endsWith('/chat')).body.options, { num_ctx: 8192 }) + const manual = await mocked(({ url }) => url.endsWith('/ps') ? { models: [] } : {}, + () => warmOllamaModel(model, { ...auto, contextLengthMode: 'manual', contextLength: 12288 })) + assert.deepEqual(manual.find(call => call.url.endsWith('/chat')).body.options, { num_ctx: 12288 }) +}) + +test('model requests ask Ollama to keep the model loaded for ten minutes', async () => { + const request = { practiceTask: 'true_false_question', prompt: '{"questionNumber":1}', messages: [], materials: [], settings: {}, + retrievedSources: [{ title: 'Notes', materialId: 'c1', documentId: 1, chunkId: 'ch.1', excerpt: 'Ancestor paths propagate splits.' }], + applicationSettings: { suggestionMode: 'off' }, model, modelSettings: auto } + const calls = await mocked(({ url, body }) => { + if (url.endsWith('/show')) return { model_info: { 'test.context_length': 131072 } } + return body.options.num_predict === 1 ? { prompt_eval_count: 900 } : { done_reason: 'stop', message: { content: '{}' } } + }, () => runOllamaStudyEngine(request)) + const chats = calls.filter(call => call.url.endsWith('/chat')) + assert.equal(chats.length, 2) + assert.ok(chats.every(call => call.body.keep_alive === '10m')) +}) + +test('quitting unloads every model the app used, once', async () => { + const calls = await mocked(({ url }) => url.endsWith('/ps') ? { models: [] } : {}, async () => { + await warmOllamaModel(model, auto) + await warmOllamaModel({ ...model, ollamaBaseUrl: 'http://127.0.0.1:11500', ollamaModelName: 'other' }, auto) + await warmOllamaModel(model, auto) + await unloadOllamaModels() + }) + const unloads = calls.filter(call => call.url.endsWith('/generate')) + assert.deepEqual(unloads.map(call => [call.url, call.body]).sort(), [ + ['http://127.0.0.1:11434/api/generate', { model: 'test', keep_alive: 0 }], + ['http://127.0.0.1:11500/api/generate', { model: 'other', keep_alive: 0 }] + ]) + const again = await mocked(() => ({}), () => unloadOllamaModels()) + assert.equal(again.filter(call => call.url.endsWith('/generate')).length, 0) +}) + +test('an unreachable Ollama does not delay quitting', async () => { + const saved = globalThis.fetch + globalThis.fetch = async () => ({ ok: true, json: async () => ({ models: [] }) }) + try { await warmOllamaModel(model, auto) } finally { globalThis.fetch = saved } + globalThis.fetch = async () => { throw new Error('connection refused') } + try { await assert.doesNotReject(unloadOllamaModels()) } finally { globalThis.fetch = saved } +}) From 220959ffb00f497265a0f865b8542c43363df107 Mon Sep 17 00:00:00 2001 From: jshuao Date: Tue, 20 Oct 2026 20:07:12 -0400 Subject: [PATCH 3/4] Add topic mastery tracking to practice - Record each checked True/False answer under the session's topic and derive mastery from the attempt log: a correct answer counts 1 (0.5 with help), an incorrect answer counts 1 against. - Show mastery in the topic bar, the progress review, and a "Topics to revisit" list on the topic picker, weakest first. - Schema 13: one practice_attempts table. Databases with the schema 12 attempt log and topic_mastery table are migrated. --- python_engine/tokensmith_engine.py | 23 ++++ python_engine/tokensmith_store.py | 94 +++++++++++++++- src/main/index.ts | 5 + src/main/python/python-engine-service.ts | 17 +++ src/preload/index.ts | 2 + src/renderer/src/PracticeMasteryList.tsx | 18 +++ src/renderer/src/PracticeScreen.tsx | 27 ++++- src/renderer/src/PracticeTopicPicker.tsx | 8 +- src/renderer/src/practice-client.ts | 22 +++- src/renderer/src/practice.css | 8 ++ src/shared/bridge.ts | 3 + src/shared/practice.ts | 40 +++++++ tests/python/test_tokensmith_mastery.py | 136 +++++++++++++++++++++++ tests/ui/practice-check.mjs | 4 + tests/ui/practice.jsx | 9 ++ tests/unit/typescript/practice.test.mjs | 68 +++++++++++- 16 files changed, 472 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/PracticeMasteryList.tsx create mode 100644 tests/python/test_tokensmith_mastery.py diff --git a/python_engine/tokensmith_engine.py b/python_engine/tokensmith_engine.py index 0caec297..8cc4604b 100644 --- a/python_engine/tokensmith_engine.py +++ b/python_engine/tokensmith_engine.py @@ -41,6 +41,8 @@ keyword_search, keyword_terms_for_query, list_materials, + list_topic_mastery, + record_practice_attempt, study_documents, validate_study_documents, practice_source_rows, @@ -67,6 +69,8 @@ keyword_search, keyword_terms_for_query, list_materials, + list_topic_mastery, + record_practice_attempt, study_documents, validate_study_documents, practice_source_rows, @@ -2039,6 +2043,23 @@ def resolve_source_document(payload: Dict[str, Any]) -> Dict[str, Any]: return {"source": source_document_for_source(payload["userDataPath"], source)} + +def practice_record_attempt(payload: Dict[str, Any]) -> Dict[str, Any]: + entry = record_practice_attempt( + payload["userDataPath"], + session_id=str(payload.get("sessionId") or ""), + question_id=str(payload.get("questionId") or ""), + topic=str(payload.get("topic") or ""), + correct=bool(payload.get("correct")), + assisted=bool(payload.get("assisted")), + ) + return {"entry": entry} + + +def practice_list_mastery(payload: Dict[str, Any]) -> Dict[str, Any]: + return {"entries": list_topic_mastery(payload["userDataPath"])} + + COMMANDS = { "health": health, "preview_cleaning": preview_cleaning, @@ -2048,6 +2069,8 @@ def resolve_source_document(payload: Dict[str, Any]) -> Dict[str, Any]: "starter_sources": starter_sources, "study_documents": list_study_documents, "practice_sources": practice_sources, + "practice_record_attempt": practice_record_attempt, + "practice_list_mastery": practice_list_mastery, "list_materials": list_indexed_materials, "set_material_enabled": set_material_enabled, "remove_material": remove_material, diff --git a/python_engine/tokensmith_store.py b/python_engine/tokensmith_store.py index ba536d70..2feda64b 100644 --- a/python_engine/tokensmith_store.py +++ b/python_engine/tokensmith_store.py @@ -22,8 +22,9 @@ DB_NAME = "tokensmith.sqlite" FAISS_NAME = "tokensmith.faiss" -SCHEMA_VERSION = 11 +SCHEMA_VERSION = 13 ALIAS_EXTRACTION_VERSION = 1 +PRACTICE_ASSISTED_CREDIT = 0.5 KEYWORD_STOPWORDS: Set[str] = { "a", "about", @@ -183,6 +184,7 @@ def connect(user_data_path: str) -> sqlite3.Connection: def init_db(user_data_path: str) -> None: with connect(user_data_path) as conn: create_schema(conn) + create_practice_schema(conn) ensure_column(conn, "tokensmith_collection_state", "embedding_model_id", "TEXT") ensure_column(conn, "tokensmith_collection_state", "embedding_model_name", "TEXT") ensure_column(conn, "tokensmith_collection_state", "cleaning_profile_id", "TEXT") @@ -2511,3 +2513,93 @@ def dump_index(user_data_path: str) -> Dict[str, Any]: chunks.append(chunk) return {"version": SCHEMA_VERSION, "documents": documents, "chunks": chunks, "updatedAt": now_iso()} + + +def create_practice_schema(conn: sqlite3.Connection) -> None: + columns = {row["name"] for row in conn.execute("PRAGMA table_info(practice_attempts)")} + legacy = "verdict" in columns + if legacy: + # Schema 12 logged every grading detail and kept a separate mastery table. + conn.execute("DROP INDEX IF EXISTS idx_practice_attempts_session_id") + conn.execute("DROP INDEX IF EXISTS idx_practice_attempts_topic") + conn.execute("ALTER TABLE practice_attempts RENAME TO practice_attempts_v12") + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS practice_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + question_id TEXT NOT NULL, + topic TEXT NOT NULL, + correct INTEGER NOT NULL, + assisted INTEGER NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(session_id, question_id) + ); + CREATE INDEX IF NOT EXISTS idx_practice_attempts_topic ON practice_attempts(topic); + """ + ) + if legacy: + conn.execute( + """ + INSERT OR IGNORE INTO practice_attempts(session_id, question_id, topic, correct, assisted, created_at) + SELECT session_id, question_id, TRIM(topic), verdict = 'correct', assisted, created_at + FROM practice_attempts_v12 + WHERE verdict IN ('correct', 'incorrect') AND TRIM(COALESCE(topic, '')) != '' + ORDER BY id + """ + ) + conn.execute("DROP TABLE practice_attempts_v12") + conn.execute("DROP TABLE IF EXISTS topic_mastery") + + +def _topic_mastery(conn: sqlite3.Connection, topic: Optional[str] = None) -> List[Dict[str, Any]]: + rows = conn.execute( + f""" + SELECT + topic, + 1 + SUM(CASE WHEN correct THEN (CASE WHEN assisted THEN ? ELSE 1 END) ELSE 0 END) AS met, + 1 + SUM(CASE WHEN correct THEN 0 ELSE 1 END) AS missed, + COUNT(*) AS attempts, + MAX(created_at) AS updated_at + FROM practice_attempts + {"WHERE topic = ?" if topic is not None else ""} + GROUP BY topic + ORDER BY topic + """, + (PRACTICE_ASSISTED_CREDIT, *([topic] if topic is not None else [])), + ).fetchall() + return [ + { + "topic": row["topic"], + "mastery": row["met"] / (row["met"] + row["missed"]), + "attempts": row["attempts"], + "updatedAt": row["updated_at"], + } + for row in rows + ] + + +def record_practice_attempt( + user_data_path: str, session_id: str, question_id: str, topic: str, correct: bool, assisted: bool +) -> Optional[Dict[str, Any]]: + topic = (topic or "").strip() + if not topic: + return None + init_db(user_data_path) + + with connect(user_data_path) as conn: + conn.execute( + """ + INSERT OR IGNORE INTO practice_attempts(session_id, question_id, topic, correct, assisted, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (session_id, question_id, topic, 1 if correct else 0, 1 if assisted else 0, now_iso()), + ) + return _topic_mastery(conn, topic)[0] + + +def list_topic_mastery(user_data_path: str) -> List[Dict[str, Any]]: + init_db(user_data_path) + + with connect(user_data_path) as conn: + return _topic_mastery(conn) diff --git a/src/main/index.ts b/src/main/index.ts index 7a28d0c2..6d511bfc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,5 @@ import type { IndexMaterialOptions } from '../shared/preparation' +import type { PracticeAttemptInput } from '../shared/practice' import { preparationReportWithPython, writeTokenSmithLog } from './python/python-engine-service' import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, safeStorage, shell } from 'electron' import { createHash } from 'node:crypto' @@ -13,6 +14,8 @@ import { previewCleaningWithPython, studyDocumentsWithPython, practiceSourcesWithPython, + recordPracticeAttemptWithPython, + listTopicMasteryWithPython, removeMaterialWithPython, readTokenSmithLogFile, resolveSourceDocumentWithPython, @@ -532,6 +535,8 @@ app.whenReady().then(async () => { ipcMain.handle('library:practice-sources', (_event, documents: StudyDocumentRef[], usedSourceKeys: string[], questionIndex: number) => practiceSourcesWithPython(documents, usedSourceKeys, questionIndex) ) + ipcMain.handle('practice:record-attempt', (_event, input: PracticeAttemptInput) => recordPracticeAttemptWithPython(input)) + ipcMain.handle('practice:list-mastery', () => listTopicMasteryWithPython()) ipcMain.handle('practice:log-question-rejected', (_event, detail: { attempt: number; reason: string; response: string }) => writeTokenSmithLog('practice_question_validation_failed', detail) ) diff --git a/src/main/python/python-engine-service.ts b/src/main/python/python-engine-service.ts index 60e20ec0..61e813f9 100644 --- a/src/main/python/python-engine-service.ts +++ b/src/main/python/python-engine-service.ts @@ -12,6 +12,7 @@ import type { ChatSource, CourseMaterial, LocalModel, MaterialIndexProgress, Sea import type { CleaningPreviewResult, LibrarySearchResult, TokenSmithLogFile } from '../../shared/engine' import type { CleaningProfileId, CleaningRuleId } from '../../shared/cleaning' import { normalizeLatencyStageName, sanitizeLatencyDetails, type LatencySpan, type LatencySpanStatus } from '../../shared/latency-trace' +import type { PracticeAttemptInput, TopicMastery } from '../../shared/practice' interface PythonRequest { id: string @@ -22,6 +23,8 @@ interface PythonRequest { | 'starter_sources' | 'study_documents' | 'practice_sources' + | 'practice_record_attempt' + | 'practice_list_mastery' | 'list_materials' | 'set_material_enabled' | 'remove_material' @@ -835,6 +838,20 @@ export async function practiceSourcesWithPython(documents: StudyDocumentRef[], u return result.sources } +export async function recordPracticeAttemptWithPython(input: PracticeAttemptInput): Promise { + const result = await requestPython<{ entry: TopicMastery | null }>('practice_record_attempt', { + userDataPath: app.getPath('userData'), ...input + }, 30_000) + return result.entry +} + +export async function listTopicMasteryWithPython(): Promise { + const result = await requestPython<{ entries: TopicMastery[] }>('practice_list_mastery', { + userDataPath: app.getPath('userData') + }, 30_000) + return result.entries +} + export async function setMaterialEnabledWithPython(materialId: string, isActive: boolean): Promise { await requestPython<{ ok: boolean }>( 'set_material_enabled', diff --git a/src/preload/index.ts b/src/preload/index.ts index 7f96c9b9..87595748 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -47,6 +47,8 @@ const tokenSmithBridge: TokenSmithBridge = { studyDocuments: () => ipcRenderer.invoke('library:study-documents'), practiceSources: (documents, usedSourceKeys, questionIndex) => ipcRenderer.invoke('library:practice-sources', documents, usedSourceKeys, questionIndex), + recordPracticeAttempt: (input) => ipcRenderer.invoke('practice:record-attempt', input), + listTopicMastery: () => ipcRenderer.invoke('practice:list-mastery'), logPracticeQuestionRejected: (detail) => ipcRenderer.invoke('practice:log-question-rejected', detail), warmModel: (model, modelSettings) => ipcRenderer.invoke('engine:warm-model', model, modelSettings), getPdfForSource: (source) => diff --git a/src/renderer/src/PracticeMasteryList.tsx b/src/renderer/src/PracticeMasteryList.tsx new file mode 100644 index 00000000..218f287a --- /dev/null +++ b/src/renderer/src/PracticeMasteryList.tsx @@ -0,0 +1,18 @@ +import { practiceMasteryLabel, practiceMasteryPercent, type TopicMastery } from '@shared/practice' + +export function PracticeMasteryList({ title, entries, disabled, onChoose }: { + title: string; entries: TopicMastery[]; disabled?: boolean; onChoose?: (topic: string) => void +}) { + if (!entries.length) return null + return
+

{title}

+
    {entries.map(entry =>
  • + {onChoose + ? + : {entry.topic}} + {practiceMasteryLabel(entry)} · {entry.attempts} attempt{entry.attempts === 1 ? '' : 's'} + +
  • )}
+
+} diff --git a/src/renderer/src/PracticeScreen.tsx b/src/renderer/src/PracticeScreen.tsx index e7653d54..b3c0a432 100644 --- a/src/renderer/src/PracticeScreen.tsx +++ b/src/renderer/src/PracticeScreen.tsx @@ -1,8 +1,9 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ArrowLeft, ArrowRight, ChartColumn, Plus, RefreshCw } from 'lucide-react' import type { ChatSelectedPassage, ChatSource, CourseMaterial, LocalModel, ModelRuntimeSettings, TokenSmithSettings } from '@shared/app-state' -import { practiceAssisted, practiceMessages, practiceQuestionText, practiceTimeline, practiceReviewLabel, - type PracticeTopic, type PracticeQuestion, type PracticeSession, type PracticeState, type StudyMode } from '@shared/practice' +import { mergeTopicMastery, practiceAssisted, practiceMasteryLabel, practiceMasteryPercent, practiceMasteryWeakestFirst, practiceMessages, + practiceQuestionText, practiceTimeline, practiceTopic, practiceReviewLabel, + type PracticeAttempt, type PracticeTopic, type PracticeQuestion, type PracticeSession, type PracticeState, type StudyMode, type TopicMastery } from '@shared/practice' import { gradeTrueFalse } from '@shared/quiz' import { ChatModelPicker } from './ChatModelPicker' import { MessageText } from './MessageText' @@ -17,7 +18,8 @@ import { PracticeTopicPicker } from './PracticeTopicPicker' import { PracticeActions, PracticeComposer } from './PracticeComposer' import { ConversationViewport } from './ConversationViewport' import { PracticeFeedbackView } from './PracticeFeedbackView' -import { createPracticeClient } from './practice-client' +import { PracticeMasteryList } from './PracticeMasteryList' +import { createPracticeClient, loadTopicMastery, trackPracticeAttempt } from './practice-client' import { useModelWarmup } from './hooks/useModelWarmup' import type { GenerationProgressApi } from './hooks/useGenerationProgress' import './practice.css' @@ -54,6 +56,7 @@ export function PracticeScreen(props: Props) { const [error, setError] = useState('') const [pending, setPending] = useState<{ id: string } | null>(null) const [reviewOpen, setReviewOpen] = useState(false) + const [mastery, setMastery] = useState([]) const operation = useRef<{ id: string; controller: AbortController; sessionId: string; scopeKey: string } | null>(null) const session = state.sessions.find(item => item.id === state.activeSessionId) const question = session?.questions[session.currentIndex] @@ -65,7 +68,16 @@ export function PracticeScreen(props: Props) { const navigationQuestions = useMemo(() => session?.questions.map(item => ({ id: item.id, text: practiceQuestionText(item) })) ?? [], [session?.questions]) const model = session ? models.find(item => item.id === session.modelId) : props.selectedModel const available = model?.status === 'ready' + useEffect(() => { + if (!isActive) return + let stale = false + void loadTopicMastery().then(entries => { if (!stale) setMastery(entries) }) + return () => { stale = true } + }, [isActive]) + const noteMastery = (entry: TopicMastery | null) => setMastery(current => mergeTopicMastery(current, entry)) useModelWarmup(isActive, model, model && (session?.modelSettings ?? props.modelSettingsFor(model.id))) + const sessionTopic = session && practiceTopic(session, session.questions[0] ?? { sources: [] }) + const sessionMastery = mastery.find(entry => entry.topic === sessionTopic) const scope = props.scope const scopeKey = studyScopeKey(scope) const selected = scope.map(studyDocumentKey) @@ -174,10 +186,12 @@ export function PracticeScreen(props: Props) { try { const feedback = gradeTrueFalse(question, answer) const id = crypto.randomUUID() + const attempt: PracticeAttempt = { id, answer, feedback, assisted, durationMs: 0 } const checked: PracticeQuestion = { ...question, draft: answer, reviewing: true, attempts: [...question.attempts, - { id, answer, feedback, assisted, durationMs: 0 }], timeline: [...practiceTimeline(question), { kind: 'attempt', id }] } + attempt], timeline: [...practiceTimeline(question), { kind: 'attempt', id }] } const updated = { ...session, questions: session.questions.map(item => item.id === question.id ? checked : item) } updateSession(session.id, () => updated) + void trackPracticeAttempt(session, checked, attempt).then(noteMastery) const nextIndex = updated.questions.findIndex((item, index) => index > updated.currentIndex && item.format === 'true_false') if (nextIndex >= 0) updateSession(session.id, current => ({ ...current, currentIndex: nextIndex })) else if (available && !session.sourcesExhausted) generate(updated) @@ -221,6 +235,7 @@ export function PracticeScreen(props: Props) { aria-pressed={reviewOpen} disabled={Boolean(pending)} onClick={() => setReviewOpen(open => !open)}>