Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { listGeneratedNotes, readGeneratedNote, saveGeneratedNote, getNotesDir }
import { isWordLookup } from './utils/detect.js';
import { extractSpeakable } from './utils/speakable.js';
import { pushHistory, navigateHistory } from './utils/inputHistory.js';
import { getQuizEntries, buildQuizPrompt, parseQuizResponse } from './utils/quiz.js';
import * as tts from './tts.js';

const h = React.createElement;
Expand Down Expand Up @@ -71,6 +72,7 @@ function App() {
const [isLoading, setIsLoading] = useState(false);
const [loadingLabel, setLoadingLabel] = useState('Processing...');
const [isSpeaking, setIsSpeaking] = useState(false);
const [quizState, setQuizState] = useState(null);
const [activeStream, setActiveStream] = useState(null);
const [inputHistory, setInputHistory] = useState([]);
const [inputEpoch, setInputEpoch] = useState(0);
Expand All @@ -85,6 +87,7 @@ function App() {
const appendMessage = (text, options = {}) => {
if (!text) return;
setMessages((prev) => [...prev, makeMessage(text, options)]);
if (quizState) setQuizState((prev) => prev ? { ...prev, awaitingAnswer: false } : null);
};

const clearOutput = () => {
Expand Down Expand Up @@ -278,6 +281,100 @@ function App() {
});
};

const runQuizCommand = async (fullInput) => {
const arg = parseCommandArg(fullInput, '/quiz');
const daysBack = arg ? parseInt(arg, 10) : 7;
if (Number.isNaN(daysBack) || daysBack < 1) {
appendMessage('Tip: Use /quiz [days] where days is a positive number (default 7).', { color: 'yellow' });
return;
}

const entries = getQuizEntries(daysBack);
if (entries.length === 0) {
appendMessage('No history entries found for the given period. Translate something first!', { color: 'yellow' });
return;
}

appendMessage(`Starting quiz with ${entries.length} entries. Type your answers after each question.`, { color: 'cyan' });
setQuizState({
entries,
index: 0,
score: 0,
total: entries.length,
awaitingAnswer: false,
currentQuestion: null,
userAnswer: ''
});
await presentQuizQuestion(entries[0], 0, entries.length);
};

const presentQuizQuestion = async (entry, index, total) => {
const prompt = buildQuizPrompt([entry]);
setIsLoading(true);
setLoadingLabel(`Quiz question ${index + 1} of ${total}...`);
setActiveStream({ color: 'cyan', text: '' });

let responseBuffer = '';
try {
await callDeepSeekStream(prompt, entry.input, (chunk) => {
responseBuffer += chunk;
setActiveStream((prev) => {
const previous = prev || { color: 'cyan', text: '' };
return { ...previous, text: previous.text + chunk };
});
});

const question = responseBuffer.trim();
if (question) {
appendMessage(question, { color: 'cyan' });
setQuizState((prev) => prev ? { ...prev, currentQuestion: question, awaitingAnswer: true, userAnswer: '' } : null);
}
} catch (error) {
appendMessage(`Quiz error: ${error.message}`, { color: 'red' });
setQuizState(null);
} finally {
setActiveStream(null);
setIsLoading(false);
}
};

const handleQuizAnswer = async (answer) => {
const state = quizState;
if (!state || !state.awaitingAnswer) return;

const entry = state.entries[state.index];
const scoringPrompt = `You are a quiz scorer. The user was asked to fill in the blank for this translation entry:\n\nInput: ${entry.input}\nExpected translation: ${entry.response}\n\nThe user answered: "${answer}"\n\nRespond with a JSON object: {"correct": true/false, "explanation": "brief explanation"}`;

setIsLoading(true);
setLoadingLabel('Scoring answer...');

let responseBuffer = '';
try {
await callDeepSeekStream(scoringPrompt, '', (chunk) => {
responseBuffer += chunk;
});

const result = parseQuizResponse(responseBuffer);
const newScore = state.score + (result.correct ? 1 : 0);
appendMessage(result.correct ? '✓ Correct!' : `✗ Incorrect. Expected: ${entry.response}`, { color: result.correct ? 'green' : 'red' });
if (result.explanation) appendMessage(result.explanation, { dim: true });

const nextIndex = state.index + 1;
if (nextIndex >= state.total) {
appendMessage(`Quiz complete! Score: ${newScore}/${state.total}`, { color: 'cyan', bold: true });
setQuizState(null);
} else {
setQuizState((prev) => prev ? { ...prev, index: nextIndex, score: newScore, awaitingAnswer: false } : null);
await presentQuizQuestion(state.entries[nextIndex], nextIndex, state.total);
}
} catch (error) {
appendMessage(`Scoring error: ${error.message}`, { color: 'red' });
setQuizState(null);
} finally {
setIsLoading(false);
}
};

const runSayCommand = async (fullInput) => {
const arg = parseCommandArg(fullInput, '/say');

Expand Down Expand Up @@ -326,6 +423,11 @@ function App() {
return;
}

if (lowerInput === '/quiz' || lowerInput.startsWith('/quiz ')) {
await runQuizCommand(inputText);
return;
}

if (lowerInput === '/clear') {
clearOutput();
return;
Expand Down Expand Up @@ -388,6 +490,12 @@ function App() {
};

const onSubmitInput = (value) => {
if (quizState && quizState.awaitingAnswer) {
setInputHistory((prev) => pushHistory(prev, value));
setInput('');
void handleQuizAnswer(value);
return;
}
if (loadingRef.current) return;
setInputHistory((prev) => pushHistory(prev, value));
historyIndexRef.current = null;
Expand Down
221 changes: 221 additions & 0 deletions src/utils/quiz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { readHistory, listHistory } from '../history.js';

/**
* @typedef {Object} QuizEntry
* @property {string} date - The date string (YYYY-MM-DD) of the history entry
* @property {string} source - The source text that was translated/checked
* @property {string} target - The translated/checked result
* @property {string} type - The type of entry ('translate' or 'check')
*/

/**
* Get quiz entries from history files within the specified number of days back.
* Filters entries that have translation content (not just checks) and picks random entries.
*
* @param {number} [daysBack=7] - Number of days to look back for history entries
* @returns {Promise<QuizEntry[]>} Array of quiz entries with source, target, date, and type
* @throws {Error} If daysBack is negative or NaN
*/
export async function getQuizEntries(daysBack = 7) {
if (typeof daysBack !== 'number' || isNaN(daysBack) || daysBack < 0) {
throw new Error('daysBack must be a non-negative number');
}

const historyFiles = await listHistory();

// Filter files within the specified date range
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysBack);
const cutoffStr = cutoffDate.toISOString().split('T')[0];

const relevantFiles = historyFiles.filter(file => {
// Extract date from filename (assumes format YYYY-MM-DD.md)
const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
if (!dateMatch) return false;
return dateMatch[1] >= cutoffStr;
});

const entries = [];

for (const file of relevantFiles) {
const date = file.replace('.md', '');
const content = await readHistory(date);

if (!content) continue;

// Parse history entries - each entry is separated by "---" or newlines
// Format: ## type\n- **Input:** source\n- **Response:** target
const blocks = content.split(/^---\s*$/m);

for (const block of blocks) {
const trimmedBlock = block.trim();
if (!trimmedBlock) continue;

// Extract type from heading
const typeMatch = trimmedBlock.match(/^##\s+(translate|check)/im);
if (!typeMatch) continue;

const type = typeMatch[1].toLowerCase();

// Only include translation entries (not checks) for quiz material
if (type !== 'translate') continue;

// Extract source text
const sourceMatch = trimmedBlock.match(/\*\*Input:\*\*\s*(.+)/i);
if (!sourceMatch) continue;

// Extract target/response text
const targetMatch = trimmedBlock.match(/\*\*Response:\*\*\s*(.+)/i);
if (!targetMatch) continue;

const source = sourceMatch[1].trim();
const target = targetMatch[1].trim();

// Skip empty entries or entries that are just checks
if (!source || !target) continue;

entries.push({
date,
source,
target,
type
});
}
}

// Shuffle entries randomly using Fisher-Yates algorithm
for (let i = entries.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[entries[i], entries[j]] = [entries[j], entries[i]];
}

return entries;
}

/**
* Build a system prompt for DeepSeek to present quiz entries as fill-in-the-blank questions
* and score user answers.
*
* @param {QuizEntry[]} entries - Array of quiz entries to include in the prompt
* @returns {string} The constructed system prompt
* @throws {Error} If entries array is empty
*/
export function buildQuizPrompt(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error('entries must be a non-empty array');
}

const entryList = entries.map((entry, index) => {
return `Entry ${index + 1}:
- Source: ${entry.source}
- Target: ${entry.target}
- Date: ${entry.date}`;
}).join('\n\n');

return `You are an active recall quiz master. I will provide you with translation pairs from my learning history. For each pair, you need to:

1. Present the source text and ask me to provide the translation (fill-in-the-blank style)
2. Wait for my answer
3. Score my answer (correct/partially correct/incorrect)
4. Provide the correct answer and any helpful notes

Here are the entries to quiz me on:

${entryList}

Instructions:
- Present ONE entry at a time
- For each entry, first show the source text and ask for the translation
- After I respond, evaluate my answer and provide:
- A score (0-100)
- The correct answer
- Brief feedback on what I got right/wrong
- Then move to the next entry
- At the end, provide a summary of my overall score

Format your response for each entry as:
QUESTION: [source text]
[wait for user answer]
SCORE: [score]/100
CORRECT: [correct translation]
FEEDBACK: [brief feedback]`;
}

/**
* Parse the raw response from DeepSeek to extract score and corrections.
*
* @param {string} raw - The raw response string from DeepSeek
* @returns {Object} Parsed quiz result with score and corrections
* @property {number} score - The overall score (0-100)
* @property {Array<{question: string, userAnswer: string, correctAnswer: string, score: number, feedback: string}>} corrections - Array of individual entry results
* @property {string} summary - Overall summary text if present
*/
export function parseQuizResponse(raw) {
if (!raw || typeof raw !== 'string') {
return {
score: 0,
corrections: [],
summary: ''
};
}

const corrections = [];
let overallScore = 0;
let summary = '';

// Split response into individual entry blocks
const entryBlocks = raw.split(/(?=QUESTION:)/i);

for (const block of entryBlocks) {
const trimmedBlock = block.trim();
if (!trimmedBlock) continue;

// Extract question
const questionMatch = trimmedBlock.match(/QUESTION:\s*(.+?)(?:\n|$)/i);
const question = questionMatch ? questionMatch[1].trim() : '';

// Extract user answer (text between QUESTION and SCORE)
const userAnswerMatch = trimmedBlock.match(/QUESTION:.*?\n([\s\S]*?)(?=SCORE:)/i);
const userAnswer = userAnswerMatch ? userAnswerMatch[1].trim() : '';

// Extract score
const scoreMatch = trimmedBlock.match(/SCORE:\s*(\d+)\/100/i);
const score = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;

// Extract correct answer
const correctMatch = trimmedBlock.match(/CORRECT:\s*(.+?)(?:\n|$)/i);
const correctAnswer = correctMatch ? correctMatch[1].trim() : '';

// Extract feedback
const feedbackMatch = trimmedBlock.match(/FEEDBACK:\s*(.+?)(?:\n|$)/i);
const feedback = feedbackMatch ? feedbackMatch[1].trim() : '';

if (question || correctAnswer) {
corrections.push({
question,
userAnswer,
correctAnswer,
score,
feedback
});
}
}

// Try to extract overall summary
const summaryMatch = raw.match(/SUMMARY:?\s*([\s\S]*?)$/i);
if (summaryMatch) {
summary = summaryMatch[1].trim();
}

// Calculate overall score from individual scores
if (corrections.length > 0) {
const totalScore = corrections.reduce((sum, entry) => sum + entry.score, 0);
overallScore = Math.round(totalScore / corrections.length);
}

return {
score: overallScore,
corrections,
summary
};
}
Loading