refactor services to use typescript

This commit is contained in:
KS Jannette
2026-05-09 16:41:50 -04:00
parent c865e8f497
commit 1a1161b509
21 changed files with 970 additions and 531 deletions

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
const { mockCreate } = vi.hoisted(() => ({ mockCreate: vi.fn() }));
@@ -14,14 +14,18 @@ import {
generateStudyGuide,
generateFaq,
generateExecutiveBrief,
type SourceGroup,
type StudyGuide,
type Faq,
type ExecutiveBrief,
} from './documentService.js';
const SOURCE_GROUPS = [
const SOURCE_GROUPS: SourceGroup[] = [
{ docIndex: 1, name: 'Doc A', chunks: [{ text: 'Alpha content.' }] },
{ docIndex: 2, name: 'Doc B', chunks: [{ text: 'Beta first.' }, { text: 'Beta second.' }] },
];
function apiResponse(obj) {
function apiResponse<T>(obj: T): { content: Array<{ text: string }> } {
return { content: [{ text: JSON.stringify(obj) }] };
}
@@ -35,7 +39,7 @@ afterEach(() => {
});
describe('generateStudyGuide', () => {
const STUDY_GUIDE = {
const STUDY_GUIDE: StudyGuide = {
title: 'Test Guide',
sections: [{ heading: 'Intro', bullets: ['b1'], keyTerms: [], reviewQuestions: [] }],
mnemonics: ['ABC'],
@@ -51,7 +55,7 @@ describe('generateStudyGuide', () => {
mockCreate.mockResolvedValue(apiResponse(STUDY_GUIDE));
await generateStudyGuide(SOURCE_GROUPS);
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
const prompt = (mockCreate as Mock).mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('[Source 1] (Doc A)');
expect(prompt).toContain('Alpha content.');
expect(prompt).toContain('[Source 2] (Doc B)');
@@ -63,13 +67,13 @@ describe('generateStudyGuide', () => {
mockCreate.mockResolvedValue(apiResponse(STUDY_GUIDE));
await generateStudyGuide(SOURCE_GROUPS);
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
const prompt = (mockCreate as Mock).mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('---');
});
});
describe('generateFaq', () => {
const FAQ = {
const FAQ: Faq = {
subject: 'Testing',
faqPairs: [{ question: 'Q?', answer: 'A.' }],
};
@@ -84,14 +88,14 @@ describe('generateFaq', () => {
mockCreate.mockResolvedValue(apiResponse(FAQ));
await generateFaq(SOURCE_GROUPS);
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
const prompt = (mockCreate as Mock).mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('[Source 1] (Doc A)');
expect(prompt).toContain('[Source 2] (Doc B)');
});
});
describe('generateExecutiveBrief', () => {
const BRIEF = {
const BRIEF: ExecutiveBrief = {
title: 'Exec Brief',
sections: [{ subhead: 'Overview', prose: 'Some prose.' }],
};
@@ -106,7 +110,7 @@ describe('generateExecutiveBrief', () => {
mockCreate.mockResolvedValue(apiResponse(BRIEF));
await generateExecutiveBrief(SOURCE_GROUPS);
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
const prompt = (mockCreate as Mock).mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('[Source 1] (Doc A)');
expect(prompt).toContain('[Source 2] (Doc B)');
});
@@ -126,31 +130,25 @@ describe('shared behaviour', () => {
it('truncates sources that exceed the per-source character budget', async () => {
const longText = 'x'.repeat(35_000);
const bigGroups = [
{ docIndex: 1, name: 'Big', chunks: [{ text: longText }] },
];
mockCreate.mockResolvedValue(
apiResponse({ title: 't', sections: [], mnemonics: [] }),
);
const bigGroups: SourceGroup[] = [{ docIndex: 1, name: 'Big', chunks: [{ text: longText }] }];
mockCreate.mockResolvedValue(apiResponse({ title: 't', sections: [], mnemonics: [] }));
await generateStudyGuide(bigGroups);
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
const prompt = (mockCreate as Mock).mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('[...truncated]');
expect(prompt.length).toBeLessThan(longText.length);
});
it('combines multiple chunks within a source with double newlines', async () => {
const groups = [
const groups: SourceGroup[] = [
{ docIndex: 1, name: 'Multi', chunks: [{ text: 'AAA' }, { text: 'BBB' }] },
];
mockCreate.mockResolvedValue(
apiResponse({ title: 't', sections: [], mnemonics: [] }),
);
mockCreate.mockResolvedValue(apiResponse({ title: 't', sections: [], mnemonics: [] }));
await generateStudyGuide(groups);
const prompt = mockCreate.mock.calls[0][0].messages[0].content;
const prompt = (mockCreate as Mock).mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('AAA\n\nBBB');
});
});

View File

@@ -5,7 +5,7 @@ import logger from '../logger.js';
const MODEL = 'claude-sonnet-4-5-20250929';
function getClient() {
function getClient(): Anthropic {
const key = process.env.ANTHROPIC_API_KEY;
if (!key) throw new Error('ANTHROPIC_API_KEY is not set');
return new Anthropic({ apiKey: key });
@@ -13,7 +13,17 @@ function getClient() {
const MAX_SOURCE_CHARS = 30000;
function buildSourceBlock(sourceGroups) {
export interface SourceChunk {
text: string;
}
export interface SourceGroup {
docIndex: number;
name: string;
chunks: SourceChunk[];
}
function buildSourceBlock(sourceGroups: SourceGroup[]): string {
const perSourceBudget = Math.floor(MAX_SOURCE_CHARS / (sourceGroups.length || 1));
return sourceGroups
@@ -51,7 +61,11 @@ const STUDY_GUIDE_SCHEMA = {
additionalProperties: false,
},
},
reviewQuestions: { type: 'array', items: { type: 'string' }, description: 'Self-test questions for this section' },
reviewQuestions: {
type: 'array',
items: { type: 'string' },
description: 'Self-test questions for this section',
},
},
required: ['heading', 'bullets', 'keyTerms', 'reviewQuestions'],
additionalProperties: false,
@@ -65,9 +79,27 @@ const STUDY_GUIDE_SCHEMA = {
},
required: ['title', 'sections', 'mnemonics'],
additionalProperties: false,
};
} as const;
export async function generateStudyGuide(sourceGroups) {
export interface KeyTerm {
term: string;
definition: string;
}
export interface StudyGuideSection {
heading: string;
bullets: string[];
keyTerms: KeyTerm[];
reviewQuestions: string[];
}
export interface StudyGuide {
title: string;
sections: StudyGuideSection[];
mnemonics: string[];
}
export async function generateStudyGuide(sourceGroups: SourceGroup[]): Promise<StudyGuide> {
const client = getClient();
const sources = buildSourceBlock(sourceGroups);
const start = Date.now();
@@ -90,7 +122,9 @@ ${sources}`;
output_config: { format: { type: 'json_schema', schema: STUDY_GUIDE_SCHEMA } },
});
const parsed = JSON.parse(message.content[0]?.text || '{}');
const textBlock = message.content[0];
const raw = textBlock && 'text' in textBlock && textBlock.text ? textBlock.text : '{}';
const parsed = JSON.parse(raw) as StudyGuide;
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
logger.info({ sections: parsed.sections?.length, elapsedSec: elapsed }, 'study guide generated');
return parsed;
@@ -116,9 +150,19 @@ const FAQ_SCHEMA = {
},
required: ['subject', 'faqPairs'],
additionalProperties: false,
};
} as const;
export async function generateFaq(sourceGroups) {
export interface FaqPair {
question: string;
answer: string;
}
export interface Faq {
subject: string;
faqPairs: FaqPair[];
}
export async function generateFaq(sourceGroups: SourceGroup[]): Promise<Faq> {
const client = getClient();
const sources = buildSourceBlock(sourceGroups);
const start = Date.now();
@@ -141,7 +185,9 @@ ${sources}`;
output_config: { format: { type: 'json_schema', schema: FAQ_SCHEMA } },
});
const parsed = JSON.parse(message.content[0]?.text || '{}');
const textBlock = message.content[0];
const raw = textBlock && 'text' in textBlock && textBlock.text ? textBlock.text : '{}';
const parsed = JSON.parse(raw) as Faq;
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
logger.info({ subject: parsed.subject, pairs: parsed.faqPairs?.length, elapsedSec: elapsed }, 'FAQ generated');
return parsed;
@@ -166,9 +212,19 @@ const EXEC_BRIEF_SCHEMA = {
},
required: ['title', 'sections'],
additionalProperties: false,
};
} as const;
export async function generateExecutiveBrief(sourceGroups) {
export interface ExecutiveBriefSection {
subhead: string;
prose: string;
}
export interface ExecutiveBrief {
title: string;
sections: ExecutiveBriefSection[];
}
export async function generateExecutiveBrief(sourceGroups: SourceGroup[]): Promise<ExecutiveBrief> {
const client = getClient();
const sources = buildSourceBlock(sourceGroups);
const start = Date.now();
@@ -192,8 +248,13 @@ ${sources}`;
output_config: { format: { type: 'json_schema', schema: EXEC_BRIEF_SCHEMA } },
});
const parsed = JSON.parse(message.content[0]?.text || '{}');
const textBlock = message.content[0];
const raw = textBlock && 'text' in textBlock && textBlock.text ? textBlock.text : '{}';
const parsed = JSON.parse(raw) as ExecutiveBrief;
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
logger.info({ title: parsed.title, sections: parsed.sections?.length, elapsedSec: elapsed }, 'executive brief generated');
logger.info(
{ title: parsed.title, sections: parsed.sections?.length, elapsedSec: elapsed },
'executive brief generated'
);
return parsed;
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { buildPrompt } from './generationService.js';
import { buildPrompt, type SourceGroup } from './generationService.js';
describe('buildPrompt', () => {
const sourceGroups = [
const sourceGroups: SourceGroup[] = [
{
docIndex: 1,
name: 'Weather Facts',
@@ -11,10 +11,7 @@ describe('buildPrompt', () => {
{
docIndex: 2,
name: 'Water Science',
chunks: [
{ text: 'Water is wet.' },
{ text: 'Water boils at 100°C.' },
],
chunks: [{ text: 'Water is wet.' }, { text: 'Water boils at 100°C.' }],
},
];

View File

@@ -26,15 +26,50 @@ const RESPONSE_SCHEMA = {
},
required: ['answer', 'citedSourceIndices', 'followUpQuestions'],
additionalProperties: false,
};
} as const;
function getClient() {
export interface SourceChunk {
text: string;
}
export interface SourceGroup {
docIndex: number;
name: string;
chunks: SourceChunk[];
}
export interface GenerationResult {
answer: string;
citedSourceIndices: number[];
followUpQuestions: string[];
}
export interface CitationDetailParams {
chunkTexts: string[];
sourceName: string;
answer: string;
citationIndex: number;
}
export interface RelevantLink {
title: string;
url: string;
}
export interface CitationDetail {
citedSentence: string;
topicSummary: string;
additionalInsight: string;
relevantLinks: RelevantLink[];
}
function getClient(): Anthropic {
const key = process.env.ANTHROPIC_API_KEY;
if (!key) throw new Error('ANTHROPIC_API_KEY is not set');
return new Anthropic({ apiKey: key });
}
export function buildPrompt(query, sourceGroups) {
export function buildPrompt(query: string, sourceGroups: SourceGroup[]): string {
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
@@ -64,15 +99,13 @@ ${sources}
${query}`;
}
export async function generate(query, sourceGroups) {
export async function generate(query: string, sourceGroups: SourceGroup[]): Promise<GenerationResult> {
const client = getClient();
const message = await client.messages.create({
model: MODEL,
max_tokens: 2048,
messages: [
{ role: 'user', content: buildPrompt(query, sourceGroups) },
],
messages: [{ role: 'user', content: buildPrompt(query, sourceGroups) }],
output_config: {
format: {
type: 'json_schema',
@@ -81,16 +114,19 @@ export async function generate(query, sourceGroups) {
},
});
const raw = message.content[0]?.text || '';
const textBlock = message.content[0];
const raw = textBlock && 'text' in textBlock ? textBlock.text : '';
logger.debug({ rawLength: raw.length }, 'claude response received');
const parsed = JSON.parse(raw);
const parsed = JSON.parse(raw) as {
answer?: string;
citedSourceIndices?: number[];
followUpQuestions?: string[];
};
const validIndices = new Set(sourceGroups.map((g) => g.docIndex));
const citedSourceIndices = [
...new Set(
(parsed.citedSourceIndices || []).filter((idx) => validIndices.has(idx)),
),
...new Set((parsed.citedSourceIndices || []).filter((idx) => validIndices.has(idx))),
];
return {
@@ -113,7 +149,8 @@ const CITATION_DETAIL_SCHEMA = {
},
additionalInsight: {
type: 'string',
description: 'Additional expert insight, analysis, or context on the subject beyond what the source states',
description:
'Additional expert insight, analysis, or context on the subject beyond what the source states',
},
relevantLinks: {
type: 'array',
@@ -131,9 +168,14 @@ const CITATION_DETAIL_SCHEMA = {
},
required: ['citedSentence', 'topicSummary', 'additionalInsight', 'relevantLinks'],
additionalProperties: false,
};
} as const;
export async function generateCitationDetail({ chunkTexts, sourceName, answer, citationIndex }) {
export async function generateCitationDetail({
chunkTexts,
sourceName,
answer,
citationIndex,
}: CitationDetailParams): Promise<CitationDetail> {
const client = getClient();
const sourceText = chunkTexts.join('\n\n');
@@ -166,8 +208,9 @@ ${sourceText}`;
},
});
const raw = message.content[0]?.text || '';
const textBlock = message.content[0];
const raw = textBlock && 'text' in textBlock ? textBlock.text : '';
logger.debug({ citationIndex, rawLength: raw.length }, 'citation detail response received');
return JSON.parse(raw);
return JSON.parse(raw) as CitationDetail;
}

View File

@@ -1,216 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../stores/notebookStore.js', () => ({
getNotebook: vi.fn(),
getSources: vi.fn(),
getChunksForNotebook: vi.fn(),
buildSourceGroups: vi.fn(),
}));
vi.mock('../stores/documentCacheStore.js', () => ({
isGenerating: vi.fn(),
invalidate: vi.fn(),
markGenerating: vi.fn(),
clearGenerating: vi.fn(),
setCachedDocument: vi.fn(),
}));
vi.mock('./documentService.js', () => ({
generateStudyGuide: vi.fn(),
generateFaq: vi.fn(),
generateExecutiveBrief: vi.fn(),
}));
vi.mock('../logger.js', () => ({
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { triggerPreGeneration } from './preGenerationService.js';
import * as notebookStore from '../stores/notebookStore.js';
import * as documentCacheStore from '../stores/documentCacheStore.js';
import {
generateStudyGuide,
generateFaq,
generateExecutiveBrief,
} from './documentService.js';
function setupNotebook(id) {
notebookStore.getNotebook.mockReturnValue({ id });
notebookStore.getSources.mockReturnValue([{ id: 's1' }, { id: 's2' }]);
notebookStore.getChunksForNotebook.mockReturnValue([{ id: 'c1', text: 'hello' }]);
notebookStore.buildSourceGroups.mockReturnValue([
{ docIndex: 1, name: 'Doc', chunks: [{ text: 'hello' }] },
]);
}
function stubGeneratorsOk() {
generateStudyGuide.mockResolvedValue({ title: 'guide' });
generateFaq.mockResolvedValue({ subject: 'topic' });
generateExecutiveBrief.mockResolvedValue({ title: 'brief' });
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('triggerPreGeneration guards', () => {
it('does nothing if the notebook does not exist', () => {
notebookStore.getNotebook.mockReturnValue(null);
triggerPreGeneration('nb-missing');
expect(notebookStore.getSources).not.toHaveBeenCalled();
});
it('does nothing if fewer than 2 sources', () => {
notebookStore.getNotebook.mockReturnValue({ id: 'nb-few' });
notebookStore.getSources.mockReturnValue([{ id: 's1' }]);
triggerPreGeneration('nb-few');
expect(documentCacheStore.markGenerating).not.toHaveBeenCalled();
});
});
describe('queuing when already generating', () => {
it('invalidates cache and skips runPreGeneration', () => {
setupNotebook('nb-q');
documentCacheStore.isGenerating.mockReturnValue(true);
triggerPreGeneration('nb-q');
expect(documentCacheStore.invalidate).toHaveBeenCalledWith('nb-q');
expect(documentCacheStore.markGenerating).not.toHaveBeenCalled();
});
});
describe('debounce', () => {
it('does not run generation immediately', () => {
setupNotebook('nb-debounce');
documentCacheStore.isGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-debounce');
expect(documentCacheStore.markGenerating).not.toHaveBeenCalled();
});
it('runs generation after the debounce delay', async () => {
setupNotebook('nb-delay');
documentCacheStore.isGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-delay');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-delay');
});
expect(documentCacheStore.markGenerating).toHaveBeenCalledWith('nb-delay');
});
it('resets the timer on repeated calls, running generation only once', async () => {
setupNotebook('nb-batch');
documentCacheStore.isGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-batch');
await vi.advanceTimersByTimeAsync(3000);
triggerPreGeneration('nb-batch');
await vi.advanceTimersByTimeAsync(3000);
triggerPreGeneration('nb-batch');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(documentCacheStore.clearGenerating).toHaveBeenCalled();
});
expect(documentCacheStore.markGenerating).toHaveBeenCalledTimes(1);
});
});
describe('runPreGeneration (via triggerPreGeneration)', () => {
it('runs all three generators and caches results', async () => {
setupNotebook('nb-happy');
documentCacheStore.isGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-happy');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-happy');
});
expect(generateStudyGuide).toHaveBeenCalled();
expect(generateFaq).toHaveBeenCalled();
expect(generateExecutiveBrief).toHaveBeenCalled();
expect(documentCacheStore.setCachedDocument).toHaveBeenCalledTimes(3);
});
it('marks generating before starting and clears it after', async () => {
setupNotebook('nb-flag');
documentCacheStore.isGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-flag');
await vi.advanceTimersByTimeAsync(5000);
expect(documentCacheStore.markGenerating).toHaveBeenCalledWith('nb-flag');
await vi.waitFor(() => {
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-flag');
});
});
it('still clears generating flag when a generator fails', async () => {
setupNotebook('nb-partial');
documentCacheStore.isGenerating.mockReturnValue(false);
generateStudyGuide.mockRejectedValue(new Error('boom'));
generateFaq.mockResolvedValue({ subject: 'ok' });
generateExecutiveBrief.mockResolvedValue({ title: 'ok' });
triggerPreGeneration('nb-partial');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(documentCacheStore.clearGenerating).toHaveBeenCalledWith('nb-partial');
});
expect(documentCacheStore.setCachedDocument).toHaveBeenCalledTimes(2);
});
});
describe('re-trigger for queued notebooks', () => {
it('runs a second generation cycle after the first finishes', async () => {
setupNotebook('nb-re');
documentCacheStore.isGenerating
.mockReturnValueOnce(false) // first trigger → debounced
.mockReturnValueOnce(true); // second trigger → queues
let resolveFirst;
generateStudyGuide
.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r; }))
.mockResolvedValue({ title: 'guide' });
generateFaq.mockResolvedValue({ subject: 'topic' });
generateExecutiveBrief.mockResolvedValue({ title: 'brief' });
triggerPreGeneration('nb-re');
await vi.advanceTimersByTimeAsync(5000);
// First run is in-flight, blocked on generateStudyGuide
triggerPreGeneration('nb-re');
// Queued via pendingReGen since isGenerating returns true
resolveFirst({ title: 'guide' });
await vi.waitFor(() => {
expect(documentCacheStore.markGenerating).toHaveBeenCalledTimes(2);
});
expect(documentCacheStore.clearGenerating).toHaveBeenCalledTimes(2);
expect(documentCacheStore.setCachedDocument).toHaveBeenCalledTimes(6);
});
});

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
vi.mock('../stores/notebookStore.js', () => ({
getNotebook: vi.fn(),
getSources: vi.fn(),
getChunksForNotebook: vi.fn(),
buildSourceGroups: vi.fn(),
}));
vi.mock('../stores/documentCacheStore.js', () => ({
isGenerating: vi.fn(),
invalidate: vi.fn(),
markGenerating: vi.fn(),
clearGenerating: vi.fn(),
setCachedDocument: vi.fn(),
}));
vi.mock('./documentService.js', () => ({
generateStudyGuide: vi.fn(),
generateFaq: vi.fn(),
generateExecutiveBrief: vi.fn(),
}));
vi.mock('../logger.js', () => ({
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { triggerPreGeneration } from './preGenerationService.js';
import * as notebookStore from '../stores/notebookStore.js';
import * as documentCacheStore from '../stores/documentCacheStore.js';
import { generateStudyGuide, generateFaq, generateExecutiveBrief } from './documentService.js';
const mockedGetNotebook = notebookStore.getNotebook as Mock;
const mockedGetSources = notebookStore.getSources as Mock;
const mockedGetChunksForNotebook = notebookStore.getChunksForNotebook as Mock;
const mockedBuildSourceGroups = notebookStore.buildSourceGroups as Mock;
const mockedIsGenerating = documentCacheStore.isGenerating as Mock;
const mockedInvalidate = documentCacheStore.invalidate as Mock;
const mockedMarkGenerating = documentCacheStore.markGenerating as Mock;
const mockedClearGenerating = documentCacheStore.clearGenerating as Mock;
const mockedSetCachedDocument = documentCacheStore.setCachedDocument as Mock;
const mockedGenerateStudyGuide = generateStudyGuide as Mock;
const mockedGenerateFaq = generateFaq as Mock;
const mockedGenerateExecutiveBrief = generateExecutiveBrief as Mock;
function setupNotebook(id: string): void {
mockedGetNotebook.mockReturnValue({ id });
mockedGetSources.mockReturnValue([{ id: 's1' }, { id: 's2' }]);
mockedGetChunksForNotebook.mockReturnValue([{ id: 'c1', text: 'hello' }]);
mockedBuildSourceGroups.mockReturnValue([{ docIndex: 1, name: 'Doc', chunks: [{ text: 'hello' }] }]);
}
function stubGeneratorsOk(): void {
mockedGenerateStudyGuide.mockResolvedValue({ title: 'guide' });
mockedGenerateFaq.mockResolvedValue({ subject: 'topic' });
mockedGenerateExecutiveBrief.mockResolvedValue({ title: 'brief' });
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('triggerPreGeneration guards', () => {
it('does nothing if the notebook does not exist', () => {
mockedGetNotebook.mockReturnValue(null);
triggerPreGeneration('nb-missing');
expect(mockedGetSources).not.toHaveBeenCalled();
});
it('does nothing if fewer than 2 sources', () => {
mockedGetNotebook.mockReturnValue({ id: 'nb-few' });
mockedGetSources.mockReturnValue([{ id: 's1' }]);
triggerPreGeneration('nb-few');
expect(mockedMarkGenerating).not.toHaveBeenCalled();
});
});
describe('queuing when already generating', () => {
it('invalidates cache and skips runPreGeneration', () => {
setupNotebook('nb-q');
mockedIsGenerating.mockReturnValue(true);
triggerPreGeneration('nb-q');
expect(mockedInvalidate).toHaveBeenCalledWith('nb-q');
expect(mockedMarkGenerating).not.toHaveBeenCalled();
});
});
describe('debounce', () => {
it('does not run generation immediately', () => {
setupNotebook('nb-debounce');
mockedIsGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-debounce');
expect(mockedMarkGenerating).not.toHaveBeenCalled();
});
it('runs generation after the debounce delay', async () => {
setupNotebook('nb-delay');
mockedIsGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-delay');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(mockedClearGenerating).toHaveBeenCalledWith('nb-delay');
});
expect(mockedMarkGenerating).toHaveBeenCalledWith('nb-delay');
});
it('resets the timer on repeated calls, running generation only once', async () => {
setupNotebook('nb-batch');
mockedIsGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-batch');
await vi.advanceTimersByTimeAsync(3000);
triggerPreGeneration('nb-batch');
await vi.advanceTimersByTimeAsync(3000);
triggerPreGeneration('nb-batch');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(mockedClearGenerating).toHaveBeenCalled();
});
expect(mockedMarkGenerating).toHaveBeenCalledTimes(1);
});
});
describe('runPreGeneration (via triggerPreGeneration)', () => {
it('runs all three generators and caches results', async () => {
setupNotebook('nb-happy');
mockedIsGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-happy');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(mockedClearGenerating).toHaveBeenCalledWith('nb-happy');
});
expect(mockedGenerateStudyGuide).toHaveBeenCalled();
expect(mockedGenerateFaq).toHaveBeenCalled();
expect(mockedGenerateExecutiveBrief).toHaveBeenCalled();
expect(mockedSetCachedDocument).toHaveBeenCalledTimes(3);
});
it('marks generating before starting and clears it after', async () => {
setupNotebook('nb-flag');
mockedIsGenerating.mockReturnValue(false);
stubGeneratorsOk();
triggerPreGeneration('nb-flag');
await vi.advanceTimersByTimeAsync(5000);
expect(mockedMarkGenerating).toHaveBeenCalledWith('nb-flag');
await vi.waitFor(() => {
expect(mockedClearGenerating).toHaveBeenCalledWith('nb-flag');
});
});
it('still clears generating flag when a generator fails', async () => {
setupNotebook('nb-partial');
mockedIsGenerating.mockReturnValue(false);
mockedGenerateStudyGuide.mockRejectedValue(new Error('boom'));
mockedGenerateFaq.mockResolvedValue({ subject: 'ok' });
mockedGenerateExecutiveBrief.mockResolvedValue({ title: 'ok' });
triggerPreGeneration('nb-partial');
await vi.advanceTimersByTimeAsync(5000);
await vi.waitFor(() => {
expect(mockedClearGenerating).toHaveBeenCalledWith('nb-partial');
});
expect(mockedSetCachedDocument).toHaveBeenCalledTimes(2);
});
});
describe('re-trigger for queued notebooks', () => {
it('runs a second generation cycle after the first finishes', async () => {
setupNotebook('nb-re');
mockedIsGenerating.mockReturnValueOnce(false).mockReturnValueOnce(true);
let resolveFirst: (value: { title: string }) => void;
mockedGenerateStudyGuide
.mockImplementationOnce(
() =>
new Promise<{ title: string }>((r) => {
resolveFirst = r;
})
)
.mockResolvedValue({ title: 'guide' });
mockedGenerateFaq.mockResolvedValue({ subject: 'topic' });
mockedGenerateExecutiveBrief.mockResolvedValue({ title: 'brief' });
triggerPreGeneration('nb-re');
await vi.advanceTimersByTimeAsync(5000);
triggerPreGeneration('nb-re');
resolveFirst!({ title: 'guide' });
await vi.waitFor(() => {
expect(mockedMarkGenerating).toHaveBeenCalledTimes(2);
});
expect(mockedClearGenerating).toHaveBeenCalledTimes(2);
expect(mockedSetCachedDocument).toHaveBeenCalledTimes(6);
});
});

View File

@@ -7,22 +7,29 @@ import {
generateStudyGuide,
generateFaq,
generateExecutiveBrief,
type SourceGroup,
type StudyGuide,
type Faq,
type ExecutiveBrief,
} from './documentService.js';
const MIN_SOURCES = 2;
const GENERATORS = {
type DocumentType = 'study-guide' | 'faq' | 'executive-brief';
type GeneratorFn = (sourceGroups: SourceGroup[]) => Promise<StudyGuide | Faq | ExecutiveBrief>;
const GENERATORS: Record<DocumentType, GeneratorFn> = {
'study-guide': generateStudyGuide,
'faq': generateFaq,
faq: generateFaq,
'executive-brief': generateExecutiveBrief,
};
const DEBOUNCE_MS = 5000;
const pendingReGen = new Set();
const debounceTimers = new Map();
const pendingReGen = new Set<string>();
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
export function triggerPreGeneration(notebookId) {
export function triggerPreGeneration(notebookId: string): void {
const notebook = notebookStore.getNotebook(notebookId);
if (!notebook) return;
@@ -36,40 +43,46 @@ export function triggerPreGeneration(notebookId) {
return;
}
clearTimeout(debounceTimers.get(notebookId));
const existingTimer = debounceTimers.get(notebookId);
if (existingTimer !== undefined) {
clearTimeout(existingTimer);
}
debounceTimers.set(
notebookId,
setTimeout(() => {
debounceTimers.delete(notebookId);
runPreGeneration(notebookId);
}, DEBOUNCE_MS),
}, DEBOUNCE_MS)
);
logger.debug({ notebookId, debounceMs: DEBOUNCE_MS }, 'pre-generation debounced');
}
function runPreGeneration(notebookId) {
function runPreGeneration(notebookId: string): void {
try {
const sources = notebookStore.getSources(notebookId);
const chunks = notebookStore.getChunksForNotebook(notebookId);
const sourceGroups = notebookStore.buildSourceGroups(notebookId, chunks);
const sourceGroups = notebookStore.buildSourceGroups(notebookId, chunks) as SourceGroup[];
documentCacheStore.invalidate(notebookId);
documentCacheStore.markGenerating(notebookId);
logger.info(
{ notebookId, sourceCount: sources.length, chunkCount: chunks.length },
'background pre-generation started',
'background pre-generation started'
);
const jobs = Object.entries(GENERATORS).map(async ([type, generator]) => {
try {
const document = await generator(sourceGroups);
documentCacheStore.setCachedDocument(notebookId, type, document, sources);
logger.info({ notebookId, type }, 'background pre-generation complete for type');
} catch (err) {
logger.error({ notebookId, type, err: err.message }, 'background pre-generation failed for type');
const jobs = (Object.entries(GENERATORS) as Array<[DocumentType, GeneratorFn]>).map(
async ([type, generator]) => {
try {
const document = await generator(sourceGroups);
documentCacheStore.setCachedDocument(notebookId, type, document, sources);
logger.info({ notebookId, type }, 'background pre-generation complete for type');
} catch (err) {
const error = err as Error;
logger.error({ notebookId, type, err: error.message }, 'background pre-generation failed for type');
}
}
});
);
Promise.all(jobs)
.then(() => {
@@ -85,7 +98,8 @@ function runPreGeneration(notebookId) {
}
});
} catch (err) {
logger.error({ notebookId, err: err.message }, 'pre-generation setup failed');
const error = err as Error;
logger.error({ notebookId, err: error.message }, 'pre-generation setup failed');
documentCacheStore.clearGenerating(notebookId);
}
}

View File

@@ -1,138 +0,0 @@
'use strict';
import logger from '../logger.js';
import * as vectorStore from '../stores/vectorStore.js';
import * as notebookStore from '../stores/notebookStore.js';
const VOYAGE_API_URL = 'https://api.voyageai.com/v1';
const EMBED_MODEL = 'voyage-3';
const RERANK_MODEL = 'rerank-2';
function getApiKey() {
const key = process.env.VOYAGE_API_KEY;
if (!key) throw new Error('VOYAGE_API_KEY is not set');
return key;
}
export async function embedTexts(texts, inputType = 'document') {
const res = await fetch(`${VOYAGE_API_URL}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getApiKey()}`,
},
body: JSON.stringify({
model: EMBED_MODEL,
input: texts,
input_type: inputType,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Voyage embed failed (${res.status}): ${body}`);
}
const json = await res.json();
return json.data.map((d) => d.embedding);
}
export function storeChunkEmbeddings(chunks, embeddings) {
for (let i = 0; i < chunks.length; i++) {
vectorStore.storeVector(chunks[i].id, embeddings[i]);
}
logger.debug({ count: chunks.length }, 'stored chunk embeddings');
}
export function search(queryEmbedding, notebookId, topK = 10) {
const chunks = notebookStore.getChunksForNotebook(notebookId);
if (chunks.length === 0) return [];
const scored = [];
for (const chunk of chunks) {
const vec = vectorStore.getVector(chunk.id);
if (!vec) continue;
scored.push({ chunk, score: cosineSimilarity(queryEmbedding, vec) });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, topK);
}
export async function rerank(query, chunks) {
if (chunks.length === 0) return [];
const res = await fetch(`${VOYAGE_API_URL}/rerank`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getApiKey()}`,
},
body: JSON.stringify({
model: RERANK_MODEL,
query,
documents: chunks.map((c) => c.text),
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Voyage rerank failed (${res.status}): ${body}`);
}
const json = await res.json();
return json.data.map((item) => ({
chunk: chunks[item.index],
relevanceScore: item.relevance_score,
}));
}
// Computes cosine similarity between two vectors `a` and `b`.
// Cosine similarity measures how similar two vectors' *directions* are,
// ignoring their magnitudes. It returns a value from -1 (opposite) to 1 (identical direction).
// Formula: cos(θ) = (a · b) / (||a|| * ||b||)
function cosineSimilarity(a, b) {
// Accumulator for the dot product (a · b): the sum of element-wise products.
// The dot product captures how much the two vectors "agree" — it grows
// when corresponding elements point the same way and shrinks when they oppose.
let dot = 0;
// Accumulator for the squared magnitude of vector `a` (sum of a[i]²).
// After the loop, Math.sqrt(normA) will give ||a||, the Euclidean length of `a`.
// This is needed for the denominator, which normalizes out each vector's magnitude
// so the result reflects only directional similarity, not scale.
let normA = 0;
// Same as above, but for vector `b`. Together with normA, these two values
// will form the denominator ||a|| * ||b|| that scales the dot product into
// the -1..1 cosine similarity range.
let normB = 0;
// Walk through every dimension of the two vectors in lockstep.
for (let i = 0; i < a.length; i++) {
// Multiply the i-th elements of `a` and `b` and add to the running dot product.
// Each term a[i]*b[i] contributes positively when both components share the same
// sign (both positive or both negative) and negatively when they differ.
dot += a[i] * b[i];
// Square the i-th element of `a` and accumulate it toward `a`'s squared magnitude.
// Squaring ensures every component contributes positively regardless of sign.
normA += a[i] * a[i];
// Same for vector `b` — accumulate toward `b`'s squared magnitude.
normB += b[i] * b[i];
}
// Compute the denominator: the product of the two vectors' Euclidean lengths.
// Taking the square root of each accumulated sum-of-squares converts them from
// squared magnitudes back into actual magnitudes (lengths).
const denom = Math.sqrt(normA) * Math.sqrt(normB);
// If either vector has zero magnitude (all zeros), the denominator is 0 and
// division would produce NaN, so we return 0 (no meaningful similarity).
// Otherwise, dividing the dot product by the combined magnitudes yields the
// cosine of the angle between the vectors — our similarity score.
return denom === 0 ? 0 : dot / denom;
}
export { cosineSimilarity };

View File

@@ -0,0 +1,131 @@
'use strict';
import logger from '../logger.js';
import * as vectorStore from '../stores/vectorStore.js';
import * as notebookStore from '../stores/notebookStore.js';
const VOYAGE_API_URL = 'https://api.voyageai.com/v1';
const EMBED_MODEL = 'voyage-3';
const RERANK_MODEL = 'rerank-2';
function getApiKey(): string {
const key = process.env.VOYAGE_API_KEY;
if (!key) throw new Error('VOYAGE_API_KEY is not set');
return key;
}
export interface TextChunk {
id: string;
text: string;
sourceId: string;
index: number;
}
interface VoyageEmbeddingResponse {
data: Array<{ embedding: number[] }>;
}
interface VoyageRerankResponse {
data: Array<{ index: number; relevance_score: number }>;
}
export interface ScoredChunk {
chunk: TextChunk;
score: number;
}
export interface RankedChunk {
chunk: TextChunk;
relevanceScore: number;
}
export async function embedTexts(
texts: string[],
inputType: 'document' | 'query' = 'document'
): Promise<number[][]> {
const res = await fetch(`${VOYAGE_API_URL}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getApiKey()}`,
},
body: JSON.stringify({
model: EMBED_MODEL,
input: texts,
input_type: inputType,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Voyage embed failed (${res.status}): ${body}`);
}
const json = (await res.json()) as VoyageEmbeddingResponse;
return json.data.map((d) => d.embedding);
}
export function storeChunkEmbeddings(chunks: TextChunk[], embeddings: number[][]): void {
for (let i = 0; i < chunks.length; i++) {
vectorStore.storeVector(chunks[i].id, embeddings[i]);
}
logger.debug({ count: chunks.length }, 'stored chunk embeddings');
}
export function search(queryEmbedding: number[], notebookId: string, topK = 10): ScoredChunk[] {
const chunks = notebookStore.getChunksForNotebook(notebookId) as TextChunk[];
if (chunks.length === 0) return [];
const scored: ScoredChunk[] = [];
for (const chunk of chunks) {
const vec = vectorStore.getVector(chunk.id);
if (!vec) continue;
scored.push({ chunk, score: cosineSimilarity(queryEmbedding, vec) });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, topK);
}
export async function rerank(query: string, chunks: TextChunk[]): Promise<RankedChunk[]> {
if (chunks.length === 0) return [];
const res = await fetch(`${VOYAGE_API_URL}/rerank`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getApiKey()}`,
},
body: JSON.stringify({
model: RERANK_MODEL,
query,
documents: chunks.map((c) => c.text),
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Voyage rerank failed (${res.status}): ${body}`);
}
const json = (await res.json()) as VoyageRerankResponse;
return json.data.map((item) => ({
chunk: chunks[item.index],
relevanceScore: item.relevance_score,
}));
}
export function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
vi.mock('./retrievalService.js', () => ({
embedTexts: vi.fn(),
@@ -17,6 +17,10 @@ import { computeGroundedness } from './scoringService.js';
import { embedTexts, cosineSimilarity } from './retrievalService.js';
import * as vectorStore from '../stores/vectorStore.js';
const mockedEmbedTexts = embedTexts as Mock;
const mockedCosineSimilarity = cosineSimilarity as Mock;
const mockedGetVector = vectorStore.getVector as Mock;
beforeEach(() => {
vi.clearAllMocks();
});
@@ -36,16 +40,16 @@ describe('computeGroundedness', () => {
});
it('returns 0 when no chunk vectors are found', async () => {
vectorStore.getVector.mockReturnValue(null);
mockedGetVector.mockReturnValue(null);
const answer = 'This is a sentence that is definitely long enough to pass the filter.';
expect(await computeGroundedness(answer, ['chunk-1'])).toBe(0);
});
it('returns 1.0 when all sentences have similarity at or above the ceiling', async () => {
const fakeVec = new Float32Array([1, 0, 0]);
vectorStore.getVector.mockReturnValue(fakeVec);
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
cosineSimilarity.mockReturnValue(0.70);
mockedGetVector.mockReturnValue(fakeVec);
mockedEmbedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
mockedCosineSimilarity.mockReturnValue(0.7);
const answer = 'This sentence is long enough to be included in the analysis.';
const score = await computeGroundedness(answer, ['chunk-1']);
@@ -54,9 +58,9 @@ describe('computeGroundedness', () => {
it('returns 0 when all sentences have similarity at or below the floor', async () => {
const fakeVec = new Float32Array([1, 0, 0]);
vectorStore.getVector.mockReturnValue(fakeVec);
embedTexts.mockResolvedValue([new Float32Array([0, 1, 0])]);
cosineSimilarity.mockReturnValue(0.20);
mockedGetVector.mockReturnValue(fakeVec);
mockedEmbedTexts.mockResolvedValue([new Float32Array([0, 1, 0])]);
mockedCosineSimilarity.mockReturnValue(0.2);
const answer = 'This sentence is long enough to be included in the analysis.';
const score = await computeGroundedness(answer, ['chunk-1']);
@@ -65,9 +69,9 @@ describe('computeGroundedness', () => {
it('returns a value between 0 and 1 for mid-range similarity', async () => {
const fakeVec = new Float32Array([1, 0, 0]);
vectorStore.getVector.mockReturnValue(fakeVec);
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
cosineSimilarity.mockReturnValue(0.50);
mockedGetVector.mockReturnValue(fakeVec);
mockedEmbedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
mockedCosineSimilarity.mockReturnValue(0.5);
const answer = 'This sentence is long enough to be included in the analysis.';
const score = await computeGroundedness(answer, ['chunk-1']);
@@ -78,14 +82,9 @@ describe('computeGroundedness', () => {
it('averages across multiple sentences', async () => {
const fakeVec = new Float32Array([1, 0, 0]);
vectorStore.getVector.mockReturnValue(fakeVec);
embedTexts.mockResolvedValue([
new Float32Array([1, 0, 0]),
new Float32Array([0, 1, 0]),
]);
cosineSimilarity
.mockReturnValueOnce(0.65)
.mockReturnValueOnce(0.35);
mockedGetVector.mockReturnValue(fakeVec);
mockedEmbedTexts.mockResolvedValue([new Float32Array([1, 0, 0]), new Float32Array([0, 1, 0])]);
mockedCosineSimilarity.mockReturnValueOnce(0.65).mockReturnValueOnce(0.35);
const answer =
'This is the first sentence that is long enough. This is the second sentence that is also long enough.';
@@ -94,13 +93,11 @@ describe('computeGroundedness', () => {
});
it('picks the best similarity across multiple chunk vectors', async () => {
vectorStore.getVector
mockedGetVector
.mockReturnValueOnce(new Float32Array([1, 0, 0]))
.mockReturnValueOnce(new Float32Array([0, 1, 0]));
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
cosineSimilarity
.mockReturnValueOnce(0.40)
.mockReturnValueOnce(0.65);
mockedEmbedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
mockedCosineSimilarity.mockReturnValueOnce(0.4).mockReturnValueOnce(0.65);
const answer = 'This sentence is long enough to be included in the analysis.';
const score = await computeGroundedness(answer, ['chunk-1', 'chunk-2']);
@@ -109,14 +106,14 @@ describe('computeGroundedness', () => {
it('strips citation markers [1] [2] before splitting sentences', async () => {
const fakeVec = new Float32Array([1, 0, 0]);
vectorStore.getVector.mockReturnValue(fakeVec);
embedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
cosineSimilarity.mockReturnValue(0.50);
mockedGetVector.mockReturnValue(fakeVec);
mockedEmbedTexts.mockResolvedValue([new Float32Array([1, 0, 0])]);
mockedCosineSimilarity.mockReturnValue(0.5);
const answer = 'The sky is blue according to science [1]. Water covers most of the earth [2].';
await computeGroundedness(answer, ['chunk-1']);
const embeddedTexts = embedTexts.mock.calls[0][0];
const embeddedTexts = mockedEmbedTexts.mock.calls[0][0] as string[];
for (const text of embeddedTexts) {
expect(text).not.toMatch(/\[\d+\]/);
}

View File

@@ -4,42 +4,33 @@ import { embedTexts, cosineSimilarity } from './retrievalService.js';
import * as vectorStore from '../stores/vectorStore.js';
import logger from '../logger.js';
// Calibration bounds for voyage-3 cosine similarity.
// Empirically, near-direct-quote sentences top out around 0.68-0.72
// raw cosine similarity; unrelated text falls below ~0.35.
const SIM_FLOOR = 0.35;
const SIM_CEILING = 0.65;
const MIN_SENTENCE_LENGTH = 20;
function splitIntoSentences(text) {
function splitIntoSentences(text: string): string[] {
const cleaned = text.replace(/\[\d+\]/g, '').trim();
const raw = cleaned.split(/(?<=[.!?])\s+/);
return raw
.map((s) => s.trim())
.filter((s) => s.length >= MIN_SENTENCE_LENGTH);
return raw.map((s) => s.trim()).filter((s) => s.length >= MIN_SENTENCE_LENGTH);
}
function calibrate(rawSim) {
const scaled =
(rawSim - SIM_FLOOR) / (SIM_CEILING - SIM_FLOOR);
function calibrate(rawSim: number): number {
const scaled = (rawSim - SIM_FLOOR) / (SIM_CEILING - SIM_FLOOR);
return Math.max(0, Math.min(1, scaled));
}
export async function computeGroundedness(
answerText,
citedChunkIds
) {
answerText: string,
citedChunkIds: string[] | null | undefined
): Promise<number> {
if (!citedChunkIds || citedChunkIds.length === 0) return 0;
const sentences = splitIntoSentences(answerText);
if (sentences.length === 0) return 0;
const sentenceEmbeddings = await embedTexts(
sentences,
'document'
);
const sentenceEmbeddings = await embedTexts(sentences, 'document');
const chunkVectors = [];
const chunkVectors: number[][] = [];
for (const chunkId of citedChunkIds) {
const vec = vectorStore.getVector(chunkId);
if (vec) {
@@ -73,4 +64,3 @@ export async function computeGroundedness(
return totalCalibrated / sentenceEmbeddings.length;
}

View File

@@ -1,9 +1,16 @@
import { describe, it, expect } from 'vitest';
import { chunkText, parseFile, isYoutubeUrl, extractVideoId, parseUrl } from './sourceService.js';
import {
chunkText,
parseFile,
isYoutubeUrl,
extractVideoId,
parseUrl,
type TextChunk,
} from './sourceService.js';
describe('chunkText', () => {
it('returns a single chunk for short text', () => {
const chunks = chunkText('Hello world.', 'src-1');
const chunks: TextChunk[] = chunkText('Hello world.', 'src-1');
expect(chunks).toHaveLength(1);
expect(chunks[0].text).toBe('Hello world.');
expect(chunks[0].sourceId).toBe('src-1');
@@ -42,7 +49,6 @@ describe('chunkText', () => {
const chunks = chunkText(text, 'src-5');
if (chunks.length >= 2) {
const end0 = chunks[0].text.slice(-100);
const start1 = chunks[1].text.slice(0, 100);
const hasOverlap = chunks[1].text.includes(end0.slice(-50));
expect(hasOverlap).toBe(true);
}

View File

@@ -19,7 +19,7 @@ const execFileAsync = promisify(execFile);
const CHARS_PER_CHUNK = 2000;
const OVERLAP_CHARS = 200;
const AUDIO_MIMETYPES = new Set([
const AUDIO_MIMETYPES = new Set<string>([
'audio/mpeg',
'audio/mp3',
'audio/wav',
@@ -31,11 +31,15 @@ const AUDIO_MIMETYPES = new Set([
'audio/webm',
]);
export function isAudioMimetype(mimetype) {
export function isAudioMimetype(mimetype: string): boolean {
return AUDIO_MIMETYPES.has(mimetype);
}
export async function parseFile(buffer, mimetype, filename) {
export async function parseFile(
buffer: Buffer,
mimetype: string,
filename?: string
): Promise<string> {
if (mimetype === 'application/pdf') {
const result = await pdfParse(buffer);
return result.text;
@@ -58,7 +62,7 @@ export async function parseFile(buffer, mimetype, filename) {
const WHISPER_MAX_BYTES = 25 * 1024 * 1024;
async function transcribeAudio(buffer, filename) {
async function transcribeAudio(buffer: Buffer, filename?: string): Promise<string> {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY is required for audio transcription');
@@ -69,7 +73,7 @@ async function transcribeAudio(buffer, filename) {
const client = new OpenAI({ apiKey: key });
const ext = filename?.split('.').pop()?.toLowerCase() || 'mp3';
const file = new File([buffer], `audio.${ext}`, { type: `audio/${ext}` });
const file = new File([new Uint8Array(buffer)], `audio.${ext}`, { type: `audio/${ext}` });
logger.info({ filename, bytes: buffer.length }, 'transcribing audio with Whisper');
@@ -81,7 +85,7 @@ async function transcribeAudio(buffer, filename) {
return response.text;
}
const BLOCKED_IP_RANGES = [
const BLOCKED_IP_RANGES: RegExp[] = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
@@ -94,12 +98,12 @@ const BLOCKED_IP_RANGES = [
/^fd/i,
];
function isBlockedIp(ip) {
function isBlockedIp(ip: string): boolean {
return BLOCKED_IP_RANGES.some((re) => re.test(ip));
}
async function validateUrl(raw) {
let parsed;
async function validateUrl(raw: string): Promise<string> {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
@@ -126,14 +130,14 @@ async function validateUrl(raw) {
return parsed.href;
}
export async function parseUrl(url) {
export async function parseUrl(url: string): Promise<string> {
const safeUrl = await validateUrl(url);
logger.info({ url: safeUrl }, 'fetching URL content');
const res = await fetch(safeUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; NotebookClone/1.0)',
'Accept': 'text/html,application/xhtml+xml,text/plain',
Accept: 'text/html,application/xhtml+xml,text/plain',
},
signal: AbortSignal.timeout(15000),
});
@@ -164,16 +168,24 @@ export async function parseUrl(url) {
const YT_URL_RE = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/;
export function isYoutubeUrl(url) {
export function isYoutubeUrl(url: string): boolean {
return YT_URL_RE.test(url);
}
export function extractVideoId(url) {
export function extractVideoId(url: string): string | null {
const match = url.match(YT_URL_RE);
return match ? match[1] : null;
}
export async function parseYoutubeUrl(url) {
interface YouTubeSubtitleEvent {
segs?: Array<{ utf8?: string }>;
}
interface YouTubeSubtitleData {
events?: YouTubeSubtitleEvent[];
}
export async function parseYoutubeUrl(url: string): Promise<string> {
const videoId = extractVideoId(url);
if (!videoId) {
throw new Error('Could not extract YouTube video ID from URL');
@@ -183,16 +195,23 @@ export async function parseYoutubeUrl(url) {
const tmpDir = mkdtempSync(join(tmpdir(), 'yt-'));
try {
const { stderr } = await execFileAsync('yt-dlp', [
'--skip-download',
'--write-auto-sub',
'--write-sub',
'--sub-lang', 'en',
'--sub-format', 'json3',
'--no-warnings',
'-o', join(tmpDir, '%(id)s'),
`https://www.youtube.com/watch?v=${videoId}`,
], { timeout: 20000 });
const { stderr } = await execFileAsync(
'yt-dlp',
[
'--skip-download',
'--write-auto-sub',
'--write-sub',
'--sub-lang',
'en',
'--sub-format',
'json3',
'--no-warnings',
'-o',
join(tmpDir, '%(id)s'),
`https://www.youtube.com/watch?v=${videoId}`,
],
{ timeout: 20000 }
);
if (stderr) {
logger.warn({ videoId, stderr: stderr.slice(0, 500) }, 'yt-dlp stderr output');
@@ -204,10 +223,10 @@ export async function parseYoutubeUrl(url) {
throw new Error('No English subtitles available for this YouTube video');
}
const data = JSON.parse(readFileSync(join(tmpDir, subFile), 'utf-8'));
const data: YouTubeSubtitleData = JSON.parse(readFileSync(join(tmpDir, subFile), 'utf-8'));
const events = (data.events || []).filter((e) => e.segs);
const text = events
.map((e) => e.segs.map((s) => s.utf8 || '').join(''))
.map((e) => e.segs!.map((s) => s.utf8 || '').join(''))
.join(' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
@@ -220,27 +239,35 @@ export async function parseYoutubeUrl(url) {
logger.info({ videoId, textLength: text.length }, 'YouTube subtitles extracted');
return text;
} catch (err) {
if (err.code === 'ENOENT') {
const error = err as NodeJS.ErrnoException & { killed?: boolean; stderr?: string };
if (error.code === 'ENOENT') {
throw new Error('YouTube support requires yt-dlp to be installed (brew install yt-dlp)');
}
if (err.killed) {
if (error.killed) {
throw new Error('yt-dlp timed out fetching subtitles');
}
if (err.message.startsWith('No English') || err.message.startsWith('Subtitles')) {
throw err;
if (error.message?.startsWith('No English') || error.message?.startsWith('Subtitles')) {
throw error;
}
const detail = err.stderr?.slice(0, 300) || err.message;
const detail = error.stderr?.slice(0, 300) || error.message;
throw new Error(`Failed to extract YouTube subtitles: ${detail}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
export function chunkText(text, sourceId) {
export interface TextChunk {
id: string;
text: string;
sourceId: string;
index: number;
}
export function chunkText(text: string, sourceId: string): TextChunk[] {
const cleaned = text.replace(/\r\n/g, '\n').trim();
if (!cleaned) return [];
const chunks = [];
const chunks: TextChunk[] = [];
let start = 0;
let index = 0;