import { buildSystemPrompt } from "../config/prompt.js"; import { createAdapter } from "./llm-adapters/factory.js"; import { BATCH_CONFIG } from "../config/batch.js"; import type { Language, Pos, EnrichedSense } from "./merge-enriched-data.js"; import { LANG_MAP, POS_MAP, ALL_LANGUAGES } from "../config/constants.js"; interface LlmResponse { content: string; promptTokens: number; completionTokens: number; totalTokens: number; promptTimeMs: number | null; completionTimeMs: number | null; totalTimeMs: number; } export interface EnrichmentResult { results: Map; metrics: { promptTokens: number; completionTokens: number; totalTokens: number; promptTimeMs: number | null; completionTimeMs: number | null; totalTimeMs: number; }; } /** * Calls the LLM with the enrichment prompt. * Returns the response content and timing metrics. */ async function callLlm( words: string[], rawLanguage: string, rawPos: string, ): Promise { const adapter = createAdapter(); const sourceCode = LANG_MAP[rawLanguage] || rawLanguage; const targetLanguages = ALL_LANGUAGES.filter((lang) => lang !== sourceCode); const prompt = buildSystemPrompt(rawLanguage, rawPos, targetLanguages); return adapter.call(words, prompt); } /** * Strips markdown code blocks and extracts the JSON object from raw LLM output. * Throws if no valid JSON object braces are found. */ function sanitizeLlmOutput(raw: string): string { const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*$/g, ""); const start = cleaned.indexOf("{"); const end = cleaned.lastIndexOf("}"); if (start === -1 || end === -1) { throw new Error("No JSON object found in LLM output"); } return cleaned.slice(start, end + 1); } function validateSense(item: unknown, word: string, index: number): void { if (typeof item !== "object" || item === null || Array.isArray(item)) { throw new Error(`Sense ${index} for "${word}" is not an object`); } const sense = item as Record; if (typeof sense["sense"] !== "string" || !sense["sense"]) { throw new Error(`Sense ${index} for "${word}": missing or invalid "sense"`); } if (typeof sense["example"] !== "string" || !sense["example"]) { throw new Error( `Sense ${index} for "${word}": missing or invalid "example"`, ); } if ( !["easy", "medium", "hard"].includes(sense["difficulty_level"] as string) ) { throw new Error(`Sense ${index} for "${word}": invalid "difficulty_level"`); } if ( typeof sense["translations"] !== "object" || sense["translations"] === null ) { throw new Error(`Sense ${index} for "${word}": missing "translations"`); } const trans = sense["translations"] as Record; for (const lang of ["de", "it", "es", "fr"]) { if (!Array.isArray(trans[lang])) { throw new Error( `Sense ${index} for "${word}": missing or invalid "${lang}" translations`, ); } for (let j = 0; j < (trans[lang] as unknown[]).length; j++) { const t = (trans[lang] as unknown[])[j] as Record; if (typeof t["word"] !== "string" || !t["word"]) { throw new Error( `Sense ${index} for "${word}": ${lang}[${j}] missing "word"`, ); } if ( !["masculine", "feminine", "neuter", null].includes( t["gender"] as string | null, ) ) { throw new Error( `Sense ${index} for "${word}": ${lang}[${j}] invalid "gender"`, ); } } } } /** * Parses the LLM response string into a JavaScript object. * Throws if the response is not valid JSON or not an object with expected keys. */ export function parseLlmResponse( rawJson: string, expectedWords: string[], ): Record { let parsed: unknown; try { const sanitized = sanitizeLlmOutput(rawJson); parsed = JSON.parse(sanitized); } catch (error: unknown) { throw new Error(`Failed to parse LLM output as JSON: ${rawJson}`, { cause: error, }); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("LLM output is not a JSON object"); } const obj = parsed as Record; for (const word of expectedWords) { if (!(word in obj)) { throw new Error(`Missing key in LLM output: "${word}"`); } if (!Array.isArray(obj[word]) || (obj[word] as unknown[]).length === 0) { throw new Error(`LLM output for "${word}" is not a non-empty array`); } // Validate each sense in the array const senses = obj[word] as unknown[]; for (let i = 0; i < senses.length; i++) { validateSense(senses[i], word, i); } } return obj; } /** * Takes parsed LLM output and builds final enriched objects with composite IDs. */ export function buildEnrichedData( parsed: Record, rawLanguage: string, rawPos: string, ): Map { const language = (LANG_MAP[rawLanguage] || rawLanguage) as Language; const pos = (POS_MAP[rawPos] || rawPos) as Pos; const results = new Map(); for (const [word, sensesArray] of Object.entries(parsed)) { const senses = (sensesArray as unknown[]).map((item, index) => { const sense = item as Omit< EnrichedSense, "id" | "word" | "language" | "pos" >; return { id: `${word}:${language}:${pos}:${index}`, word, language, pos, ...sense, } as EnrichedSense; }); results.set(word, senses); } return results; } /** * Enriches a batch of words by calling the LLM, parsing the response, and building final data. */ export async function enrichWord( words: string[], rawLanguage: string, rawPos: string, ): Promise { const llmResponse = await callLlm(words, rawLanguage, rawPos); const parsed = parseLlmResponse(llmResponse.content, words); const results = buildEnrichedData(parsed, rawLanguage, rawPos); return { results, metrics: { promptTokens: llmResponse.promptTokens, completionTokens: llmResponse.completionTokens, totalTokens: llmResponse.totalTokens, promptTimeMs: llmResponse.promptTimeMs, completionTimeMs: llmResponse.completionTimeMs, totalTimeMs: llmResponse.totalTimeMs, }, }; } /** * Enriches a batch of words with retry and split-on-failure logic. * Retries up to BATCH_CONFIG.maxRetries times, then splits batch in half and retries each half. * Continues splitting until batch size is 1, then throws if still failing. */ export async function enrichWordWithRetry( words: string[], rawLanguage: string, rawPos: string, attempt: number = 1, ): Promise { try { return await enrichWord(words, rawLanguage, rawPos); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); if (words.length === 1) { throw new Error( `Failed to enrich word "${words[0]}" after ${attempt} attempts: ${errorMessage}`, { cause: error }, ); } if (attempt < BATCH_CONFIG.maxRetries) { console.log( ` Retry ${attempt}/${BATCH_CONFIG.maxRetries} for batch [${words.join(", ")}]: ${errorMessage}`, ); return enrichWordWithRetry(words, rawLanguage, rawPos, attempt + 1); } // Max retries reached — split and retry console.log( ` Splitting batch [${words.join(", ")}] after ${BATCH_CONFIG.maxRetries} failed attempts`, ); const half = Math.ceil(words.length / 2); const left = words.slice(0, half); const right = words.slice(half); const leftResult = await enrichWordWithRetry(left, rawLanguage, rawPos, 1); const rightResult = await enrichWordWithRetry( right, rawLanguage, rawPos, 1, ); // Merge results const merged = new Map([...leftResult.results, ...rightResult.results]); const mergedMetrics = { promptTokens: leftResult.metrics.promptTokens + rightResult.metrics.promptTokens, completionTokens: leftResult.metrics.completionTokens + rightResult.metrics.completionTokens, totalTokens: leftResult.metrics.totalTokens + rightResult.metrics.totalTokens, promptTimeMs: (leftResult.metrics.promptTimeMs ?? 0) + (rightResult.metrics.promptTimeMs ?? 0), completionTimeMs: (leftResult.metrics.completionTimeMs ?? 0) + (rightResult.metrics.completionTimeMs ?? 0), totalTimeMs: leftResult.metrics.totalTimeMs + rightResult.metrics.totalTimeMs, }; return { results: merged, metrics: mergedMetrics }; } }