cloze-reader / src /aiService.js
milwright
remove word logging from console for cleaner debugging output
2ecb883
raw
history blame
34.3 kB
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 };