diff --git a/src/lib/__tests__/testService.test.js b/src/lib/__tests__/testService.test.js index 51e2447..3cbdd66 100644 --- a/src/lib/__tests__/testService.test.js +++ b/src/lib/__tests__/testService.test.js @@ -19,7 +19,10 @@ vi.mock('../db', () => ({ getCurriculum: vi.fn(), })); -vi.mock('../llm', () => ({ callLLM: (...args) => callLLMMock(...args) })); +vi.mock('../llm', () => ({ + callLLM: (...args) => callLLMMock(...args), + cachedSystem: (text) => [{ type: 'text', text }], +})); vi.mock('../curriculumService', () => ({ getCurriculumTopic: vi.fn(async () => ({ topic: null })), getQuarterForWeek: vi.fn(() => 1), diff --git a/src/lib/db.js b/src/lib/db.js index 7e81c05..7c91242 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -1,5 +1,15 @@ import { pb } from './pb'; +// Upsert helper: update existing record if found, otherwise create. +async function pbUpsert(collection, filter, updateData, createData) { + try { + const r = await pb.collection(collection).getFirstListItem(filter); + return pb.collection(collection).update(r.id, updateData); + } catch { + return pb.collection(collection).create(createData); + } +} + // ── Topics ────────────────────────────────────────────────────────────────── export async function getTopics() { @@ -72,13 +82,8 @@ export async function getContent(topicId) { } catch { return null; } } -export async function setContent(topicId, data) { - try { - const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`); - return pb.collection('content').update(r.id, { data }); - } catch { - return pb.collection('content').create({ topic_id: topicId, data }); - } +export function setContent(topicId, data) { + return pbUpsert('content', `topic_id="${topicId}"`, { data }, { topic_id: topicId, data }); } export async function deleteContent(topicId) { @@ -102,13 +107,8 @@ export async function getQuizBank(topicId) { } catch { return []; } } -export async function setQuizBank(topicId, questions) { - try { - const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`); - return pb.collection('quiz_banks').update(r.id, { questions }); - } catch { - return pb.collection('quiz_banks').create({ topic_id: topicId, questions }); - } +export function setQuizBank(topicId, questions) { + return pbUpsert('quiz_banks', `topic_id="${topicId}"`, { questions }, { topic_id: topicId, questions }); } export async function deleteQuestionFromBank(topicId, questionId) { @@ -186,17 +186,10 @@ export async function getCachedQuiz(userId, weekNumber) { } catch { return null; } } -export async function setCachedQuiz(userId, weekNumber, quiz) { - try { - const r = await pb.collection('quiz_cache').getFirstListItem( - `user_id="${userId}" && week_number=${weekNumber}` - ); - return pb.collection('quiz_cache').update(r.id, { questions: quiz.questions, meta: quiz.meta }); - } catch { - return pb.collection('quiz_cache').create({ - user_id: userId, week_number: weekNumber, questions: quiz.questions, meta: quiz.meta, - }); - } +export function setCachedQuiz(userId, weekNumber, quiz) { + const data = { questions: quiz.questions, meta: quiz.meta }; + return pbUpsert('quiz_cache', `user_id="${userId}" && week_number=${weekNumber}`, + data, { user_id: userId, week_number: weekNumber, ...data }); } // ── Learn Progress ──────────────────────────────────────────────────────────── @@ -210,15 +203,9 @@ export async function getLearnDone(userId, weekNumber) { } catch { return false; } } -export async function setLearnDone(userId, weekNumber) { - try { - const r = await pb.collection('learn_progress').getFirstListItem( - `user_id="${userId}" && week_number=${weekNumber}` - ); - return pb.collection('learn_progress').update(r.id, { done: true }); - } catch { - return pb.collection('learn_progress').create({ user_id: userId, week_number: weekNumber, done: true }); - } +export function setLearnDone(userId, weekNumber) { + return pbUpsert('learn_progress', `user_id="${userId}" && week_number=${weekNumber}`, + { done: true }, { user_id: userId, week_number: weekNumber, done: true }); } // ── Leaderboard ─────────────────────────────────────────────────────────────── @@ -255,13 +242,8 @@ export async function getSetting(key, defaultValue = null) { } catch { return defaultValue; } } -export async function setSetting(key, value) { - try { - const r = await pb.collection('settings').getFirstListItem(`key="${key}"`); - return pb.collection('settings').update(r.id, { value: String(value) }); - } catch { - return pb.collection('settings').create({ key, value: String(value) }); - } +export function setSetting(key, value) { + return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) }); } // ── Curriculum ──────────────────────────────────────────────────────────────── @@ -283,19 +265,9 @@ export async function getCurriculumWeek(year, weekNumber) { } catch { return null; } } -export async function setCurriculumWeek(year, weekNumber, data) { - try { - const r = await pb.collection('curriculum').getFirstListItem( - `year=${year} && week_number=${weekNumber}` - ); - return pb.collection('curriculum').update(r.id, data); - } catch { - return pb.collection('curriculum').create({ - year, - week_number: weekNumber, - ...data, - }); - } +export function setCurriculumWeek(year, weekNumber, data) { + return pbUpsert('curriculum', `year=${year} && week_number=${weekNumber}`, + data, { year, week_number: weekNumber, ...data }); } export async function deleteCurriculumWeek(year, weekNumber) { diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js index 38afae1..499f65e 100644 --- a/src/lib/extractionPipeline.js +++ b/src/lib/extractionPipeline.js @@ -1,5 +1,5 @@ import * as db from './db'; -import { callLLM } from './llm'; +import { callLLM, cachedSystem } from './llm'; import { extractionLimiter } from './llmRetry'; import { EMIT_KNOWLEDGE_GRAPH_TOOL } from './llmTools'; @@ -42,8 +42,6 @@ Assign a learning_relevance to every topic: Relation types: related_to | depends_on | part_of | executed_by. `; -const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }]; - /** * Sentence-aware chunker with overlap. * @@ -171,7 +169,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {} } } - await mergeKnowledgeGraph({ topics: allExtractedTopics, relations: allExtractedRelations }); + await mergeKnowledgeGraph(existingTopics, { topics: allExtractedTopics, relations: allExtractedRelations }); await db.updateSourceStatus(sourceId, 'completed'); return { success: true, data: { topics: allExtractedTopics, relations: allExtractedRelations } }; @@ -183,8 +181,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {} } } -async function mergeKnowledgeGraph(newData) { - const existingTopics = await db.getTopics(); +async function mergeKnowledgeGraph(existingTopics, newData) { const existingRelations = await db.getRelations(); const topicsMap = new Map(existingTopics.map(t => [t.id, t])); diff --git a/src/lib/learningService.js b/src/lib/learningService.js index f47ac1c..7415b9a 100644 --- a/src/lib/learningService.js +++ b/src/lib/learningService.js @@ -1,5 +1,5 @@ import * as db from './db'; -import { callLLM } from './llm'; +import { callLLM, cachedSystem } from './llm'; import { EMIT_LEARNING_ARTICLE_TOOL, EMIT_LEARNING_SLIDES_TOOL, @@ -17,8 +17,6 @@ Always write in clear, professional English. Emit the requested content through the matching tool — do not return prose JSON.`; -const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }]; - const TOOL_BY_TYPE = { article: EMIT_LEARNING_ARTICLE_TOOL, slides: EMIT_LEARNING_SLIDES_TOOL, diff --git a/src/lib/llm.js b/src/lib/llm.js index 5ae41a0..b230e09 100644 --- a/src/lib/llm.js +++ b/src/lib/llm.js @@ -106,6 +106,8 @@ export function parseStructuredText(raw) { throw new LLMOutputError('No balanced JSON value found in LLM output.'); } +export const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }]; + function buildMessages({ messages, user }) { if (Array.isArray(messages) && messages.length) return messages; if (typeof user === 'string' && user.length) return [{ role: 'user', content: user }]; @@ -175,7 +177,7 @@ const SIMULATION_INFOGRAPHIC = { const SIMULATION_TOOL_STUBS = { emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH, - emit_handbook_delta: SIMULATION_EXTRACTION_GRAPH, + emit_learning_article: { article: SIMULATION_ARTICLE }, emit_learning_slides: { slides: [SIMULATION_SLIDE] }, emit_learning_infographic: { infographic: SIMULATION_INFOGRAPHIC }, diff --git a/src/lib/storage.js b/src/lib/storage.js index 066ba95..d3671d0 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -33,7 +33,6 @@ export const storage = { } catch (e) { if (e.name === 'QuotaExceededError' || e.code === 22) { console.error(`Storage quota exceeded when setting ${key}.`); - // We could implement a strategy here to clear old cached kb:tests } else { console.error(`Error saving ${key} to storage:`, e); } diff --git a/src/lib/testService.js b/src/lib/testService.js index 6391af3..7d3e4b2 100644 --- a/src/lib/testService.js +++ b/src/lib/testService.js @@ -1,5 +1,5 @@ import * as db from './db'; -import { callLLM } from './llm'; +import { callLLM, cachedSystem } from './llm'; import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools'; import { getCurriculumTopic, getQuarterForWeek } from './curriculumService'; import { shuffle, sample } from './random'; @@ -25,8 +25,6 @@ const BANNED_OPTION_PATTERNS = [ /both a and d/i, ]; -const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }]; - function normalizeQuestionText(text) { return String(text || '') .toLowerCase()