Spaces:
Sleeping
Sleeping
| 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 | |
| }); | |
| } | |
| // Helper: Extract content from API response (handles reasoning mode variants) | |
| _extractContentFromResponse(data) { | |
| const msg = data?.choices?.[0]?.message; | |
| if (!msg) return null; | |
| return msg.content || msg.reasoning || msg.reasoning_details?.[0]?.text || null; | |
| } | |
| // Helper: Build word map from passage for validation | |
| _createPassageWordMap(passage) { | |
| const passageWords = passage.split(/\s+/); | |
| const map = new Map(); | |
| passageWords.forEach((word, idx) => { | |
| const clean = word.replace(/[^\w]/g, ''); | |
| const lower = clean.toLowerCase(); | |
| const isCapitalized = clean.length > 0 && clean[0] === clean[0].toUpperCase(); | |
| if (!isCapitalized && idx >= 10) { | |
| if (!map.has(lower)) map.set(lower, []); | |
| map.get(lower).push(idx); | |
| } | |
| }); | |
| return map; | |
| } | |
| // Helper: Validate words against passage and level constraints | |
| // Note: Length constraints relaxed to ensure playability - difficulty comes from | |
| // AI word selection prompts ("easy/common" vs "challenging"), not strict length limits | |
| _validateWords(words, passageWordMap, level, passageText = null) { | |
| return words.filter(word => { | |
| if (!/[a-zA-Z]/.test(word)) return false; | |
| const clean = word.replace(/[^a-zA-Z]/g, ''); | |
| if (!clean.length) return false; | |
| if (/^(from|to|and)(the|a)$/i.test(clean)) return false; | |
| if (!passageWordMap.has(clean.toLowerCase())) return false; | |
| if (passageText && passageText.includes(word.toUpperCase()) && word === word.toUpperCase()) return false; | |
| // Relaxed: 4-12 letters for levels 1-4, 4-14 for level 5+ | |
| // Matches manual fallback in clozeGameEngine.js selectWordsManually() | |
| if (level <= 4) return clean.length >= 4 && clean.length <= 12; | |
| return clean.length >= 4 && clean.length <= 14; | |
| }); | |
| } | |
| // Helper: Clean up AI response artifacts | |
| _cleanupAIResponse(content) { | |
| return content | |
| .replace(/^\s*["']|["']\s*$/g, '') | |
| .replace(/^\s*[:;.!?]+\s*/, '') | |
| .replace(/\*+/g, '') | |
| .replace(/_+/g, '') | |
| .replace(/#+\s*/g, '') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| 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) { | |
| 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); | |
| 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(); | |
| // 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'; | |
| } | |
| // Extract content from response (handles reasoning mode variants) | |
| let content = this._extractContentFromResponse(data); | |
| 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 | |
| return this._cleanupAIResponse(content); | |
| } catch (error) { | |
| console.error('Error generating contextual hint:', error); | |
| return 'Unable to generate hint at this time'; | |
| } | |
| } | |
| async selectSignificantWords(passage, count, level = 1) { | |
| // Check for API key at runtime in case it was loaded after initialization | |
| const currentKey = this.getApiKey(); | |
| if (currentKey && !this.apiKey) { | |
| this.apiKey = currentKey; | |
| } | |
| if (!this.apiKey) { | |
| console.error('No API key for word selection'); | |
| throw new Error('API key required for word selection'); | |
| } | |
| // Define level-based constraints (relaxed length to ensure playability) | |
| let wordLengthConstraint, difficultyGuidance; | |
| if (level <= 2) { | |
| wordLengthConstraint = "4-12 letters"; | |
| difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know."; | |
| } else if (level <= 4) { | |
| wordLengthConstraint = "4-12 letters"; | |
| difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary."; | |
| } else { | |
| wordLengthConstraint = "4-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. | |
| CRITICAL RULES: | |
| - Select EXACT words that appear in the passage (copy them exactly as written) | |
| - ONLY select lowercase words (no capitalized words, no proper nouns) | |
| - ONLY select words from the MIDDLE or END of the passage (skip the first ~10 words) | |
| - Words must be ${wordLengthConstraint} | |
| - Choose nouns, verbs, or adjectives | |
| - AVOID compound words like "courthouse" or "steamboat" - choose single, verifiable words with semantic inbetweenness | |
| - AVOID indexes, tables of contents, and capitalized content | |
| - Return ONLY a JSON array like ["word1", "word2"] | |
| 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 | |
| // 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'); | |
| } | |
| // Extract content from response (handles reasoning mode variants) | |
| let content = this._extractContentFromResponse(data); | |
| 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 map and validate words | |
| const passageWordMap = this._createPassageWordMap(passage); | |
| const validWords = this._validateWords(words, passageWordMap, level); | |
| 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 map and validate words | |
| const passageWordMap = this._createPassageWordMap(passage); | |
| const validWords = this._validateWords(words, passageWordMap, level); | |
| 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 (relaxed length to ensure playability) | |
| let wordLengthConstraint, difficultyGuidance; | |
| if (level <= 2) { | |
| wordLengthConstraint = "4-12 letters"; | |
| difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know."; | |
| } else if (level <= 4) { | |
| wordLengthConstraint = "4-12 letters"; | |
| difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary."; | |
| } else { | |
| wordLengthConstraint = "4-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. | |
| CRITICAL RULES: | |
| - ONLY select lowercase words (no capitalized words, no proper nouns) | |
| - ONLY select words from the MIDDLE or END of each passage (skip the first ~10 words) | |
| - Words must be ${wordLengthConstraint} | |
| - AVOID compound words like "courthouse" or "steamboat" - choose single, verifiable words with semantic inbetweenness | |
| - AVOID indexes, tables of contents, and capitalized content | |
| 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)}`); | |
| } | |
| // 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'); | |
| } | |
| // Extract content from response (handles reasoning mode variants) | |
| let content = this._extractContentFromResponse(data); | |
| 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 words using helper methods | |
| const map1 = this._createPassageWordMap(passage1); | |
| const map2 = this._createPassageWordMap(passage2); | |
| parsed.passage1.words = this._validateWords(parsed.passage1.words, map1, level, passage1); | |
| parsed.passage2.words = this._validateWords(parsed.passage2.words, map2, level, 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) { | |
| // Check for API key at runtime | |
| const currentKey = this.getApiKey(); | |
| if (currentKey && !this.apiKey) { | |
| this.apiKey = currentKey; | |
| } | |
| if (!this.apiKey) { | |
| 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)}`); | |
| } | |
| // 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'); | |
| } | |
| // Extract content from response (handles reasoning mode variants) | |
| let content = this._extractContentFromResponse(data); | |
| if (!content) { | |
| console.error('No content found in context API response'); | |
| throw new Error('API response missing content'); | |
| } | |
| // Clean up AI response artifacts | |
| content = this._cleanupAIResponse(content.trim()); | |
| 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 }; | |