diff --git a/src/core/langfuse/PromptGateway.ts b/src/core/langfuse/PromptGateway.ts new file mode 100644 index 0000000..ec5ccd3 --- /dev/null +++ b/src/core/langfuse/PromptGateway.ts @@ -0,0 +1,10 @@ +export interface RawPrompt { + name: string; + version: number; + config: Record; + compile(variables: Record): { role: 'system' | 'user' | 'assistant'; content: string }[]; +} + +export interface PromptFetcher { + fetch(promptName: string): Promise; +} diff --git a/src/core/langfuse/PromptService.ts b/src/core/langfuse/PromptService.ts new file mode 100644 index 0000000..493b2d7 --- /dev/null +++ b/src/core/langfuse/PromptService.ts @@ -0,0 +1,46 @@ +import type { PromptFetcher, RawPrompt } from './PromptGateway'; +import type { CompiledPrompt } from './types'; + +interface CacheEntry { + raw: RawPrompt; + fetchedAt: number; +} + +export class PromptService { + private readonly cache = new Map(); + + constructor( + private readonly fetcher: PromptFetcher, + private readonly cacheEnabled: boolean + ) {} + + async getCompiled(promptName: string, variables: Record): Promise { + const raw = await this.getRaw(promptName); + return { + name: raw.name, + version: raw.version, + config: raw.config, + messages: raw.compile(variables), + }; + } + + invalidate(promptName?: string): void { + if (promptName) { + this.cache.delete(promptName); + } else { + this.cache.clear(); + } + } + + private async getRaw(promptName: string): Promise { + if (this.cacheEnabled) { + const cached = this.cache.get(promptName); + if (cached) return cached.raw; + } + const raw = await this.fetcher.fetch(promptName); + if (this.cacheEnabled) { + this.cache.set(promptName, { raw, fetchedAt: Date.now() }); + } + return raw; + } +} diff --git a/src/core/langfuse/types.ts b/src/core/langfuse/types.ts new file mode 100644 index 0000000..1e1f77b --- /dev/null +++ b/src/core/langfuse/types.ts @@ -0,0 +1,6 @@ +export interface CompiledPrompt { + name: string; + version: number; + config: Record; + messages: { role: 'system' | 'user' | 'assistant'; content: string }[]; +} diff --git a/test/core/langfuse/PromptService.test.ts b/test/core/langfuse/PromptService.test.ts new file mode 100644 index 0000000..1b7333a --- /dev/null +++ b/test/core/langfuse/PromptService.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PromptService } from '../../../src/core/langfuse/PromptService'; +import type { PromptFetcher, RawPrompt } from '../../../src/core/langfuse/PromptGateway'; + +function makeRawPrompt(name: string, version = 1): RawPrompt { + return { + name, + version, + config: { model: 'gpt-4o-mini' }, + compile: (variables) => [{ role: 'user', content: `hello ${String(variables.who)}` }], + }; +} + +describe('PromptService', () => { + it('fetches once and reuses the cache on subsequent calls when caching is enabled', async () => { + const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet')); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, true); + + await service.getCompiled('greet', { who: 'a' }); + const compiled = await service.getCompiled('greet', { who: 'b' }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(compiled.messages).toEqual([{ role: 'user', content: 'hello b' }]); + expect(compiled.config).toEqual({ model: 'gpt-4o-mini' }); + }); + + it('always refetches when caching is disabled', async () => { + const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet')); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, false); + + await service.getCompiled('greet', { who: 'a' }); + await service.getCompiled('greet', { who: 'b' }); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('invalidate(name) forces a refetch for that name only', async () => { + const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet')); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, true); + + await service.getCompiled('greet', {}); + service.invalidate('greet'); + await service.getCompiled('greet', {}); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('invalidate() with no name clears the whole cache', async () => { + const fetch = vi.fn().mockImplementation((name: string) => Promise.resolve(makeRawPrompt(name))); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, true); + + await service.getCompiled('a', {}); + await service.getCompiled('b', {}); + service.invalidate(); + await service.getCompiled('a', {}); + await service.getCompiled('b', {}); + + expect(fetch).toHaveBeenCalledTimes(4); + }); +});