Spaces:
Running
Running
| class OpenRouterService { | |
| constructor() { | |
| // Check for local LLM mode | |
| this.isLocalMode = this.checkLocalMode(); | |
| this.apiUrl = this.isLocalMode ? 'http://localhost:1234/v1/chat/completions' : 'https://openrouter.ai/api/v1/chat/completions'; | |
| this.apiKey = this.getApiKey(); | |
| // Single model configuration: Gemma-3-27b for all operations | |
| this.hintModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it'; | |
| this.primaryModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it'; | |
| this.model = this.primaryModel; // Default model for backward compatibility | |
| console.log('AI Service initialized:', { | |
| mode: this.isLocalMode ? 'Local LLM' : 'OpenRouter', | |
| url: this.apiUrl, | |
| primaryModel: this.primaryModel, | |
| hintModel: this.hintModel | |
| }); | |
| } | |
| checkLocalMode() { | |
| if (typeof window !== 'undefined' && window.location) { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| return urlParams.get('local') === 'true'; | |
| } | |
| return false; | |
| } | |
| getApiKey() { | |
| // Local mode doesn't need API key | |
| if (this.isLocalMode) { | |
| return 'local-mode-no-key'; | |
| } | |
| if (typeof process !== 'undefined' && process.env && process.env.OPENROUTER_API_KEY) { | |
| return process.env.OPENROUTER_API_KEY; | |
| } | |
| if (typeof window !== 'undefined' && window.OPENROUTER_API_KEY) { | |
| return window.OPENROUTER_API_KEY; | |
| } | |
| // console.warn('No API key found in getApiKey()'); | |
| return ''; | |
| } | |
| setApiKey(key) { | |
| this.apiKey = key; | |
| } | |
| async retryRequest(requestFn, maxRetries = 3, delayMs = 500) { | |
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | |
| try { | |
| return await requestFn(); | |
| } catch (error) { | |
| console.log(`API request attempt ${attempt}/${maxRetries} failed:`, error.message); | |
| if (attempt === maxRetries) { | |
| throw error; // Final attempt failed, throw the error | |
| } | |
| // Wait before retrying, with exponential backoff | |
| const delay = delayMs * Math.pow(2, attempt - 1); | |
| console.log(`Retrying in ${delay}ms...`); | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| } | |
| } | |
| } | |
| async generateContextualHint(prompt) { | |
| // Check for API key at runtime | |
| const currentKey = this.getApiKey(); | |
| if (currentKey && !this.apiKey) { | |
| this.apiKey = currentKey; | |
| } | |
| if (!this.apiKey) { | |
| return 'API key required for hints'; | |
| } | |
| try { | |
| const headers = { | |
| 'Content-Type': 'application/json' | |
| }; | |
| // Only add auth headers for OpenRouter | |
| if (!this.isLocalMode) { | |
| headers['Authorization'] = `Bearer ${this.apiKey}`; | |
| headers['HTTP-Referer'] = window.location.origin; | |
| headers['X-Title'] = 'Cloze Reader'; | |
| } | |
| const response = await fetch(this.apiUrl, { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify({ | |
| model: this.hintModel, // Use Gemma-3-27b for hints | |
| messages: [{ | |
| role: 'system', | |
| content: 'You are a helpful assistant that provides hints for word puzzles. Never reveal the answer word directly.' | |
| }, { | |
| role: 'user', | |
| content: prompt | |
| }], | |
| max_tokens: 150, | |
| temperature: 0.7, | |
| // Try to disable reasoning mode for hints | |
| response_format: { type: "text" } | |
| }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API request failed: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| console.log('Hint API response:', JSON.stringify(data, null, 2)); | |
| // Check if data and choices exist before accessing | |
| if (!data || !data.choices || data.choices.length === 0) { | |
| console.error('Invalid API response structure:', data); | |
| return 'Unable to generate hint at this time'; | |
| } | |
| // Check if message exists | |
| if (!data.choices[0].message) { | |
| console.error('No message in API response'); | |
| return 'Unable to generate hint at this time'; | |
| } | |
| // OSS-20B model returns content in 'reasoning' field when using reasoning mode | |
| let content = data.choices[0].message.content || ''; | |
| // If content is empty, check for reasoning field | |
| if (!content && data.choices[0].message.reasoning) { | |
| content = data.choices[0].message.reasoning; | |
| } | |
| // Still no content? Check reasoning_details | |
| if (!content && data.choices[0].message.reasoning_details?.length > 0) { | |
| content = data.choices[0].message.reasoning_details[0].text; | |
| } | |
| if (!content) { | |
| console.error('No content found in hint response'); | |
| // Provide a generic hint based on the prompt type | |
| if (prompt.toLowerCase().includes('synonym')) { | |
| return 'Think of a word that means something similar'; | |
| } else if (prompt.toLowerCase().includes('definition')) { | |
| return 'Consider what this word means in context'; | |
| } else if (prompt.toLowerCase().includes('category')) { | |
| return 'Think about what type or category this word belongs to'; | |
| } else { | |
| return 'Consider the context around the blank'; | |
| } | |
| } | |
| content = content.trim(); | |
| // For OSS-20B, extract hint from reasoning text if needed | |
| if (content.includes('The user') || content.includes('We need to')) { | |
| // This looks like reasoning text, try to extract the actual hint | |
| // Look for text about synonyms, definitions, or clues | |
| const hintPatterns = [ | |
| /synonym[s]?.*?(?:is|are|include[s]?|would be)\s+([^.]+)/i, | |
| /means?\s+([^.]+)/i, | |
| /refers? to\s+([^.]+)/i, | |
| /describes?\s+([^.]+)/i, | |
| ]; | |
| for (const pattern of hintPatterns) { | |
| const match = content.match(pattern); | |
| if (match) { | |
| content = match[1]; | |
| break; | |
| } | |
| } | |
| // If still has reasoning markers, just return a fallback | |
| if (content.includes('The user') || content.includes('We need to')) { | |
| return 'Think about words that mean something similar'; | |
| } | |
| } | |
| // Clean up AI response artifacts | |
| content = content | |
| .replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes | |
| .replace(/^\s*[:;]+\s*/, '') // Remove leading colons and semicolons | |
| .replace(/\*+/g, '') // Remove asterisks (markdown bold/italic) | |
| .replace(/_+/g, '') // Remove underscores (markdown) | |
| .replace(/#+\s*/g, '') // Remove hash symbols (markdown headers) | |
| .replace(/\s+/g, ' ') // Normalize whitespace | |
| .trim(); | |
| return content; | |
| } catch (error) { | |
| console.error('Error generating contextual hint:', error); | |
| return 'Unable to generate hint at this time'; | |
| } | |
| } | |
| async selectSignificantWords(passage, count, level = 1) { | |
| console.log('selectSignificantWords called with count:', count, 'level:', level); | |
| // Check for API key at runtime in case it was loaded after initialization | |
| const currentKey = this.getApiKey(); | |
| if (currentKey && !this.apiKey) { | |
| this.apiKey = currentKey; | |
| } | |
| console.log('API key available:', !!this.apiKey); | |
| if (!this.apiKey) { | |
| console.error('No API key for word selection'); | |
| throw new Error('API key required for word selection'); | |
| } | |
| // Define level-based constraints | |
| let wordLengthConstraint, difficultyGuidance; | |
| if (level <= 2) { | |
| wordLengthConstraint = "EXACTLY 4-7 letters (no words longer than 7 letters)"; | |
| difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know. NEVER select words longer than 7 letters."; | |
| } else if (level <= 4) { | |
| wordLengthConstraint = "EXACTLY 4-10 letters (no words longer than 10 letters)"; | |
| difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary. NEVER select words longer than 10 letters."; | |
| } else { | |
| wordLengthConstraint = "5-14 letters"; | |
| difficultyGuidance = "Select CHALLENGING words - sophisticated vocabulary that requires strong reading skills"; | |
| } | |
| try { | |
| return await this.retryRequest(async () => { | |
| const response = await fetch(this.apiUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.apiKey}`, | |
| 'HTTP-Referer': window.location.origin, | |
| 'X-Title': 'Cloze Reader' | |
| }, | |
| body: JSON.stringify({ | |
| model: this.primaryModel, // Use Gemma-3-12b for word selection | |
| messages: [{ | |
| role: 'system', | |
| content: 'Select words for a cloze exercise. Return ONLY a JSON array of words, nothing else.' | |
| }, { | |
| role: 'user', | |
| content: `Select ${count} ${level <= 2 ? 'easy' : level <= 4 ? 'medium' : 'challenging'} words (${wordLengthConstraint}) from this passage. Choose meaningful nouns, verbs, or adjectives. Avoid capitalized words and proper nouns. | |
| Passage: "${passage}"` | |
| }], | |
| max_tokens: 200, | |
| temperature: 0.5, | |
| // Try to disable reasoning mode for word selection | |
| response_format: { type: "text" } | |
| }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API request failed: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| // Check for OpenRouter error response | |
| if (data.error) { | |
| console.error('OpenRouter API error for word selection:', data.error); | |
| throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`); | |
| } | |
| // Log the full response to debug structure | |
| console.log('Full API response:', JSON.stringify(data, null, 2)); | |
| // Check if response has expected structure | |
| if (!data.choices || !data.choices[0] || !data.choices[0].message) { | |
| console.error('Invalid word selection API response structure:', data); | |
| console.error('Choices[0]:', data.choices?.[0]); | |
| throw new Error('API response missing expected structure'); | |
| } | |
| // OSS-20B model returns content in 'reasoning' field when using reasoning mode | |
| let content = data.choices[0].message.content || ''; | |
| // If content is empty, check for reasoning field | |
| if (!content && data.choices[0].message.reasoning) { | |
| content = data.choices[0].message.reasoning; | |
| } | |
| // Still no content? Check reasoning_details | |
| if (!content && data.choices[0].message.reasoning_details?.length > 0) { | |
| content = data.choices[0].message.reasoning_details[0].text; | |
| } | |
| if (!content) { | |
| console.error('No content found in API response'); | |
| throw new Error('API response missing content'); | |
| } | |
| content = content.trim(); | |
| // Clean up local LLM artifacts | |
| if (this.isLocalMode) { | |
| content = this.cleanLocalLLMResponse(content); | |
| } | |
| // Try to parse as JSON array | |
| try { | |
| let words; | |
| // Try to parse JSON first | |
| try { | |
| // Check if content contains JSON array anywhere in it | |
| const jsonMatch = content.match(/\[[\s\S]*?\]/); | |
| if (jsonMatch) { | |
| words = JSON.parse(jsonMatch[0]); | |
| } else { | |
| words = JSON.parse(content); | |
| } | |
| } catch { | |
| // If not JSON, check if this is reasoning text from OSS-20B | |
| if (content.includes('pick') || content.includes('Let\'s')) { | |
| // Extract words from reasoning text | |
| // Look for quoted words or words after "pick" | |
| const quotedWords = content.match(/"([^"]+)"/g); | |
| if (quotedWords) { | |
| words = quotedWords.map(w => w.replace(/"/g, '')); | |
| } else { | |
| // Look for pattern like "Let's pick 'word'" or "pick word" | |
| const pickMatch = content.match(/pick\s+['"]?(\w+)['"]?/i); | |
| if (pickMatch) { | |
| words = [pickMatch[1]]; | |
| } else { | |
| // For local LLM, try comma-separated | |
| if (this.isLocalMode && content.includes(',')) { | |
| words = content.split(',').map(w => w.trim()); | |
| } else { | |
| // Single word | |
| words = [content.trim()]; | |
| } | |
| } | |
| } | |
| } else if (this.isLocalMode) { | |
| // For local LLM, try comma-separated | |
| if (content.includes(',')) { | |
| words = content.split(',').map(w => w.trim()); | |
| } else { | |
| // Single word | |
| words = [content.trim()]; | |
| } | |
| } else { | |
| throw new Error('Could not parse words from response'); | |
| } | |
| } | |
| if (Array.isArray(words)) { | |
| // Create passage word array with position and capitalization info (matches clozeGameEngine logic) | |
| const passageWords = passage.split(/\s+/); | |
| const passageWordMap = new Map(); | |
| passageWords.forEach((word, idx) => { | |
| const cleanOriginal = word.replace(/[^\w]/g, ''); | |
| const cleanLower = cleanOriginal.toLowerCase(); | |
| const isCapitalized = cleanOriginal.length > 0 && cleanOriginal[0] === cleanOriginal[0].toUpperCase(); | |
| // Only track non-capitalized words after position 10 (matches game engine constraints) | |
| if (!isCapitalized && idx >= 10) { | |
| if (!passageWordMap.has(cleanLower)) { | |
| passageWordMap.set(cleanLower, []); | |
| } | |
| passageWordMap.get(cleanLower).push(idx); | |
| } | |
| }); | |
| // Validate word lengths based on level and passage presence | |
| const validWords = words.filter(word => { | |
| // First check if the word contains at least one letter | |
| if (!/[a-zA-Z]/.test(word)) { | |
| return false; | |
| } | |
| const cleanWord = word.replace(/[^a-zA-Z]/g, ''); | |
| // If cleanWord is empty after removing non-letters, reject | |
| if (cleanWord.length === 0) { | |
| return false; | |
| } | |
| // Check if word exists as non-capitalized word after position 10 (matches game engine) | |
| if (!passageWordMap.has(cleanWord.toLowerCase())) { | |
| return false; | |
| } | |
| // Check length constraints | |
| if (level <= 2) { | |
| return cleanWord.length >= 4 && cleanWord.length <= 7; | |
| } else if (level <= 4) { | |
| return cleanWord.length >= 4 && cleanWord.length <= 10; | |
| } else { | |
| return cleanWord.length >= 5 && cleanWord.length <= 14; | |
| } | |
| }); | |
| if (validWords.length > 0) { | |
| return validWords.slice(0, count); | |
| } else { | |
| console.warn(`No words met requirements for level ${level}`); | |
| throw new Error(`No valid words for level ${level}`); | |
| } | |
| } | |
| } catch (e) { | |
| // If not valid JSON, try to extract words from the response | |
| const matches = content.match(/"([^"]+)"/g); | |
| if (matches) { | |
| const words = matches.map(m => m.replace(/"/g, '')); | |
| // Create passage word array with position and capitalization info (matches clozeGameEngine logic) | |
| const passageWords = passage.split(/\s+/); | |
| const passageWordMap = new Map(); | |
| passageWords.forEach((word, idx) => { | |
| const cleanOriginal = word.replace(/[^\w]/g, ''); | |
| const cleanLower = cleanOriginal.toLowerCase(); | |
| const isCapitalized = cleanOriginal.length > 0 && cleanOriginal[0] === cleanOriginal[0].toUpperCase(); | |
| // Only track non-capitalized words after position 10 (matches game engine constraints) | |
| if (!isCapitalized && idx >= 10) { | |
| if (!passageWordMap.has(cleanLower)) { | |
| passageWordMap.set(cleanLower, []); | |
| } | |
| passageWordMap.get(cleanLower).push(idx); | |
| } | |
| }); | |
| // Validate word lengths and passage presence | |
| const validWords = words.filter(word => { | |
| // First check if the word contains at least one letter | |
| if (!/[a-zA-Z]/.test(word)) { | |
| return false; | |
| } | |
| const cleanWord = word.replace(/[^a-zA-Z]/g, ''); | |
| // If cleanWord is empty after removing non-letters, reject | |
| if (cleanWord.length === 0) { | |
| return false; | |
| } | |
| // Check if word exists as non-capitalized word after position 10 (matches game engine) | |
| if (!passageWordMap.has(cleanWord.toLowerCase())) { | |
| return false; | |
| } | |
| // Check length constraints | |
| if (level <= 2) { | |
| return cleanWord.length >= 4 && cleanWord.length <= 7; | |
| } else if (level <= 4) { | |
| return cleanWord.length >= 4 && cleanWord.length <= 10; | |
| } else { | |
| return cleanWord.length >= 5 && cleanWord.length <= 14; | |
| } | |
| }); | |
| if (validWords.length > 0) { | |
| return validWords.slice(0, count); | |
| } else { | |
| throw new Error(`No valid words for level ${level}`); | |
| } | |
| } | |
| } | |
| throw new Error('Failed to parse AI response'); | |
| }); | |
| } catch (error) { | |
| console.error('Error selecting words with AI:', error); | |
| throw error; | |
| } | |
| } | |
| async processBothPassages(passage1, book1, passage2, book2, blanksPerPassage, level = 1) { | |
| // Process both passages in a single API call to avoid rate limits | |
| const currentKey = this.getApiKey(); | |
| if (currentKey && !this.apiKey) { | |
| this.apiKey = currentKey; | |
| } | |
| if (!this.apiKey) { | |
| throw new Error('API key required for passage processing'); | |
| } | |
| // Define level-based constraints | |
| let wordLengthConstraint, difficultyGuidance; | |
| if (level <= 2) { | |
| wordLengthConstraint = "EXACTLY 4-7 letters (no words longer than 7 letters)"; | |
| difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know. NEVER select words longer than 7 letters."; | |
| } else if (level <= 4) { | |
| wordLengthConstraint = "EXACTLY 4-10 letters (no words longer than 10 letters)"; | |
| difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary. NEVER select words longer than 10 letters."; | |
| } else { | |
| wordLengthConstraint = "5-14 letters"; | |
| difficultyGuidance = "Select CHALLENGING words - sophisticated vocabulary that requires strong reading skills"; | |
| } | |
| try { | |
| // Add timeout controller to prevent aborted operations | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout | |
| const headers = { | |
| 'Content-Type': 'application/json' | |
| }; | |
| // Only add auth headers for OpenRouter | |
| if (!this.isLocalMode) { | |
| headers['Authorization'] = `Bearer ${this.apiKey}`; | |
| headers['HTTP-Referer'] = window.location.origin; | |
| headers['X-Title'] = 'Cloze Reader'; | |
| } | |
| const response = await fetch(this.apiUrl, { | |
| method: 'POST', | |
| headers, | |
| signal: controller.signal, | |
| body: JSON.stringify({ | |
| model: this.primaryModel, // Use Gemma-3-12b for batch processing | |
| messages: [{ | |
| role: 'system', | |
| content: 'Process passages for cloze exercises. Return ONLY a JSON object.' | |
| }, { | |
| role: 'user', | |
| content: `Select ${blanksPerPassage} ${level <= 2 ? 'easy' : level <= 4 ? 'medium' : 'challenging'} words (${wordLengthConstraint}) from each passage. | |
| Passage 1 ("${book1.title}" by ${book1.author}): | |
| ${passage1} | |
| Passage 2 ("${book2.title}" by ${book2.author}): | |
| ${passage2} | |
| Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one sentence about book"}, "passage2": {"words": [${blanksPerPassage} words], "context": "one sentence about book"}}` | |
| }], | |
| max_tokens: 800, | |
| temperature: 0.5, | |
| response_format: { type: "text" } | |
| }) | |
| }); | |
| // Clear timeout on successful response | |
| clearTimeout(timeoutId); | |
| if (!response.ok) { | |
| throw new Error(`API request failed: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| // Check for error response | |
| if (data.error) { | |
| console.error('OpenRouter API error for batch processing:', data.error); | |
| throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`); | |
| } | |
| console.log('Batch API response:', JSON.stringify(data, null, 2)); | |
| // Check if response has expected structure | |
| if (!data.choices || !data.choices[0] || !data.choices[0].message) { | |
| console.error('Invalid batch API response structure:', data); | |
| console.error('Choices[0]:', data.choices?.[0]); | |
| throw new Error('API response missing expected structure'); | |
| } | |
| // OSS-20B model returns content in 'reasoning' field when using reasoning mode | |
| let content = data.choices[0].message.content || ''; | |
| // If content is empty, check for reasoning field | |
| if (!content && data.choices[0].message.reasoning) { | |
| content = data.choices[0].message.reasoning; | |
| } | |
| // Still no content? Check reasoning_details | |
| if (!content && data.choices[0].message.reasoning_details?.length > 0) { | |
| content = data.choices[0].message.reasoning_details[0].text; | |
| } | |
| if (!content) { | |
| console.error('No content found in batch API response'); | |
| throw new Error('API response missing content'); | |
| } | |
| content = content.trim(); | |
| try { | |
| // Try to extract JSON from the response | |
| // Sometimes the model returns JSON wrapped in markdown code blocks | |
| const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) || content.match(/```\s*([\s\S]*?)\s*```/); | |
| let jsonString = jsonMatch ? jsonMatch[1] : content; | |
| // Clean up the JSON string | |
| jsonString = jsonString | |
| .replace(/^\s*```json\s*/, '') | |
| .replace(/\s*```\s*$/, '') | |
| .trim(); | |
| // Try to fix common JSON issues | |
| // Fix trailing commas in arrays | |
| jsonString = jsonString.replace(/,(\s*])/g, '$1'); | |
| // Check for truncated strings (unterminated quotes) | |
| const quoteCount = (jsonString.match(/"/g) || []).length; | |
| if (quoteCount % 2 !== 0) { | |
| // Add missing closing quote | |
| jsonString += '"'; | |
| } | |
| // Check if JSON is truncated (missing closing braces) | |
| const openBraces = (jsonString.match(/{/g) || []).length; | |
| const closeBraces = (jsonString.match(/}/g) || []).length; | |
| if (openBraces > closeBraces) { | |
| // Add missing closing braces | |
| jsonString += '}'.repeat(openBraces - closeBraces); | |
| } | |
| // Remove any trailing garbage after the last closing brace | |
| const lastBrace = jsonString.lastIndexOf('}'); | |
| if (lastBrace !== -1 && lastBrace < jsonString.length - 1) { | |
| jsonString = jsonString.substring(0, lastBrace + 1); | |
| } | |
| const parsed = JSON.parse(jsonString); | |
| // Validate the structure | |
| if (!parsed.passage1 || !parsed.passage2) { | |
| console.error('Parsed response missing expected structure:', parsed); | |
| throw new Error('Response missing passage1 or passage2'); | |
| } | |
| // Ensure words arrays exist and are arrays | |
| if (!Array.isArray(parsed.passage1.words)) { | |
| parsed.passage1.words = []; | |
| } | |
| if (!Array.isArray(parsed.passage2.words)) { | |
| parsed.passage2.words = []; | |
| } | |
| // Filter out empty strings from words arrays (caused by trailing commas) | |
| parsed.passage1.words = parsed.passage1.words.filter(word => word && word.trim() !== ''); | |
| parsed.passage2.words = parsed.passage2.words.filter(word => word && word.trim() !== ''); | |
| // Validate word lengths based on level and passage presence | |
| const validateWords = (words, passageText) => { | |
| // Create passage word array with position and capitalization info (matches clozeGameEngine logic) | |
| const passageWords = passageText.split(/\s+/); | |
| const passageWordMap = new Map(); | |
| passageWords.forEach((word, idx) => { | |
| const cleanOriginal = word.replace(/[^\w]/g, ''); | |
| const cleanLower = cleanOriginal.toLowerCase(); | |
| const isCapitalized = cleanOriginal.length > 0 && cleanOriginal[0] === cleanOriginal[0].toUpperCase(); | |
| // Only track non-capitalized words after position 10 (matches game engine constraints) | |
| if (!isCapitalized && idx >= 10) { | |
| if (!passageWordMap.has(cleanLower)) { | |
| passageWordMap.set(cleanLower, []); | |
| } | |
| passageWordMap.get(cleanLower).push(idx); | |
| } | |
| }); | |
| return words.filter(word => { | |
| // First check if the word contains at least one letter | |
| if (!/[a-zA-Z]/.test(word)) { | |
| return false; | |
| } | |
| const cleanWord = word.replace(/[^a-zA-Z]/g, ''); | |
| // If cleanWord is empty after removing non-letters, reject | |
| if (cleanWord.length === 0) { | |
| return false; | |
| } | |
| // Check if word exists as non-capitalized word after position 10 (matches game engine) | |
| if (!passageWordMap.has(cleanWord.toLowerCase())) { | |
| return false; | |
| } | |
| // Check if word appears in all caps in the passage (like "VOLUME") | |
| if (passageText.includes(word.toUpperCase()) && word === word.toUpperCase()) { | |
| return false; | |
| } | |
| // Check length constraints | |
| if (level <= 2) { | |
| return cleanWord.length >= 4 && cleanWord.length <= 7; | |
| } else if (level <= 4) { | |
| return cleanWord.length >= 4 && cleanWord.length <= 10; | |
| } else { | |
| return cleanWord.length >= 5 && cleanWord.length <= 14; | |
| } | |
| }); | |
| }; | |
| parsed.passage1.words = validateWords(parsed.passage1.words, passage1); | |
| parsed.passage2.words = validateWords(parsed.passage2.words, passage2); | |
| return parsed; | |
| } catch (e) { | |
| console.error('Failed to parse batch response:', e); | |
| console.error('Raw content:', content); | |
| // Try to extract any usable data from the partial response | |
| try { | |
| // Extract passage contexts using regex | |
| const context1Match = content.match(/"context":\s*"([^"]+)"/); | |
| const context2Match = content.match(/"passage2"[\s\S]*?"context":\s*"([^"]+)"/); | |
| // Extract words arrays using regex | |
| const words1Match = content.match(/"words":\s*\[([^\]]+)\]/); | |
| const words2Match = content.match(/"passage2"[\s\S]*?"words":\s*\[([^\]]+)\]/); | |
| const extractWords = (match) => { | |
| if (!match) return []; | |
| try { | |
| return JSON.parse(`[${match[1]}]`); | |
| } catch { | |
| return match[1].split(',').map(w => w.trim().replace(/['"]/g, '')); | |
| } | |
| }; | |
| return { | |
| passage1: { | |
| words: extractWords(words1Match), | |
| context: context1Match ? context1Match[1] : `From "${book1.title}" by ${book1.author}` | |
| }, | |
| passage2: { | |
| words: extractWords(words2Match), | |
| context: context2Match ? context2Match[1] : `From "${book2.title}" by ${book2.author}` | |
| } | |
| }; | |
| } catch (extractError) { | |
| console.error('Failed to extract partial data:', extractError); | |
| throw new Error('Invalid API response format'); | |
| } | |
| } | |
| } catch (error) { | |
| // Clear timeout in error case too | |
| if (typeof timeoutId !== 'undefined') { | |
| clearTimeout(timeoutId); | |
| } | |
| // Handle specific abort error | |
| if (error.name === 'AbortError') { | |
| console.error('Batch processing timed out after 15 seconds'); | |
| throw new Error('Request timed out - falling back to sequential processing'); | |
| } | |
| console.error('Error processing passages:', error); | |
| throw error; | |
| } | |
| } | |
| async generateContextualization(title, author, passage) { | |
| console.log('generateContextualization called for:', title, 'by', author); | |
| // Check for API key at runtime | |
| const currentKey = this.getApiKey(); | |
| if (currentKey && !this.apiKey) { | |
| this.apiKey = currentKey; | |
| } | |
| console.log('API key available for contextualization:', !!this.apiKey); | |
| if (!this.apiKey) { | |
| console.log('No API key, returning fallback contextualization'); | |
| return `A passage from ${author}'s "${title}"`; | |
| } | |
| try { | |
| return await this.retryRequest(async () => { | |
| const response = await fetch(this.apiUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.apiKey}`, | |
| 'HTTP-Referer': window.location.origin, | |
| 'X-Title': 'Cloze Reader' | |
| }, | |
| body: JSON.stringify({ | |
| model: this.primaryModel, // Use Gemma-3-27b for contextualization | |
| messages: [{ | |
| role: 'system', | |
| content: 'Provide a single contextual insight about the passage: historical context, literary technique, thematic observation, or relevant fact. Be specific and direct. Maximum 25 words. Do not use dashes or em-dashes. Output ONLY the insight itself with no preamble, acknowledgments, or meta-commentary.' | |
| }, { | |
| role: 'user', | |
| content: `From "${title}" by ${author}:\n\n${passage}` | |
| }], | |
| max_tokens: 150, | |
| temperature: 0.7, | |
| response_format: { type: "text" } | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error('Contextualization API error:', response.status, errorText); | |
| throw new Error(`API request failed: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| // Check for OpenRouter error response | |
| if (data.error) { | |
| console.error('OpenRouter API error for contextualization:', data.error); | |
| throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`); | |
| } | |
| console.log('Context API response:', JSON.stringify(data, null, 2)); | |
| // Check if response has expected structure | |
| if (!data.choices || !data.choices[0] || !data.choices[0].message) { | |
| console.error('Invalid contextualization API response structure:', data); | |
| console.error('Choices[0]:', data.choices?.[0]); | |
| throw new Error('API response missing expected structure'); | |
| } | |
| // OSS-20B model returns content in 'reasoning' field when using reasoning mode | |
| let content = data.choices[0].message.content || ''; | |
| // If content is empty, check for reasoning field | |
| if (!content && data.choices[0].message.reasoning) { | |
| content = data.choices[0].message.reasoning; | |
| } | |
| // Still no content? Check reasoning_details | |
| if (!content && data.choices[0].message.reasoning_details?.length > 0) { | |
| content = data.choices[0].message.reasoning_details[0].text; | |
| } | |
| if (!content) { | |
| console.error('No content found in context API response'); | |
| throw new Error('API response missing content'); | |
| } | |
| content = content.trim(); | |
| // Clean up AI response artifacts | |
| content = content | |
| .replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes | |
| .replace(/^\s*[:;.!?]+\s*/, '') // Remove leading punctuation | |
| .replace(/\*+/g, '') // Remove asterisks (markdown bold/italic) | |
| .replace(/_+/g, '') // Remove underscores (markdown) | |
| .replace(/#+\s*/g, '') // Remove hash symbols (markdown headers) | |
| .replace(/\s+/g, ' ') // Normalize whitespace | |
| .trim(); | |
| console.log('Contextualization received:', content); | |
| return content; | |
| }); | |
| } catch (error) { | |
| console.error('Error getting contextualization:', error); | |
| return `A passage from ${author}'s "${title}"`; | |
| } | |
| } | |
| cleanLocalLLMResponse(content) { | |
| // Remove common artifacts from local LLM responses | |
| return content | |
| .replace(/\["?/g, '') // Remove opening bracket and quote | |
| .replace(/"?\]/g, '') // Remove closing quote and bracket | |
| .replace(/^[>"|']+/g, '') // Remove leading > or quotes | |
| .replace(/[>"|']+$/g, '') // Remove trailing > or quotes | |
| .replace(/\\n/g, ' ') // Replace escaped newlines | |
| .trim(); | |
| } | |
| } | |
| export { OpenRouterService as AIService }; | |