Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 17 additions & 67 deletions packages/assertions/src/engine/AssertionEngine.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { EvaliphyError, EvaliphyErrorCode, logger } from '@evaliphy/core';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { EvaliphyError, EvaliphyErrorCode } from '@evaliphy/core';
import type { BaseMatcher } from '../matchers/base/BaseMatcher.js';
import { PromptLoader } from '../promptManager/promptLoader.js';
import { PromptRenderer } from '../promptManager/promptRenderer.js';
import { assertionRegistry } from "../registry.js";
import { type AssertionContext, type AssertionResult } from './types.js';

export class AssertionEngine {
/**
* Runs the assertion logic, executing an LLM call if the matcher requires it.
*/
static async run(
matcher: BaseMatcher,
context: AssertionContext
): Promise<AssertionResult> {
const startTime = Date.now();
const { input, options, llmClient, config } = context;
const { input, options, llmClient } = context;

try {
matcher.validate(input);
Expand All @@ -33,7 +34,6 @@ export class AssertionEngine {
usage = response.llmUsages;
}


const threshold = options.threshold ?? 0.7;
const passed = score >= threshold;

Expand All @@ -58,6 +58,10 @@ export class AssertionEngine {
}
}

/**
* Prepares the LLM prompt and schema for the assertion.
* Centralizes prompt loading through PromptLoader.
*/
private static prepareLLMRequest(matcher: BaseMatcher, context: AssertionContext) {
const { input, config } = context;
const assertionDef = assertionRegistry[matcher.name];
Expand All @@ -69,75 +73,18 @@ export class AssertionEngine {
);
}

const loadedPrompt = this.loadPrompt(matcher.name, assertionDef, config);
// Call the centralized PromptLoader to handle path resolution and loading
const loadedPrompt = PromptLoader.resolveAndLoad(matcher.name, assertionDef, config);
const variables = this.prepareVariables(input);
const finalPrompt = PromptRenderer.render(loadedPrompt.template, variables, assertionDef);
const outputSchema = assertionDef.outputSchema.zodSchema as any;

return { finalPrompt, outputSchema };
}

private static loadPrompt(matcherName: string, assertionDef: any, config: any) {
const configDir = config.configFile ? path.dirname(config.configFile) : process.cwd();

// 1. Check for custom prompts directory from config
const customPromptsDir = config.llmAsJudgeConfig?.promptsDir
? path.resolve(configDir, config.llmAsJudgeConfig.promptsDir)
: null;

// 2. Check for default prompts directory in consumer's root (convention)
const localPromptsDir = path.join(process.cwd(), 'prompts');

// 3. SDK internal prompts directory
const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Support both source (../../prompts) and bundled dist (./prompts)
const sdkPromptsDirSource = path.resolve(__dirname, '../../prompts');
const sdkPromptsDirDist = path.resolve(__dirname, './prompts');

const searchPaths = [
customPromptsDir ? path.join(customPromptsDir, `${matcherName}.md`) : null,
path.join(localPromptsDir, `${matcherName}.md`),
path.join(sdkPromptsDirDist, `${matcherName}.md`),
path.join(sdkPromptsDirSource, `${matcherName}.md`),
].filter(Boolean) as string[];

try {
// 1. Try custom prompts directory from config
if (customPromptsDir) {
const customPath = path.join(customPromptsDir, `${matcherName}.md`);
if (PromptLoader.exists(customPath)) {
return PromptLoader.load(customPath, assertionDef);
} else {
console.warn(`Custom prompt file not found at: ${customPath}. Falling back to defaults.`);
}
}

// 2. Try other search paths (local convention, SDK dist, SDK source)
const fallbackPaths = [
path.join(localPromptsDir, `${matcherName}.md`),
path.join(sdkPromptsDirDist, `${matcherName}.md`),
path.join(sdkPromptsDirSource, `${matcherName}.md`),
];

for (const promptPath of fallbackPaths) {
if (PromptLoader.exists(promptPath)) {
return PromptLoader.load(promptPath, assertionDef);
}
}

// If none found, try the primary SDK path one last time to trigger the standard error
return PromptLoader.load(path.join(sdkPromptsDirDist, `${matcherName}.md`), assertionDef);
} catch (error: any) {
throw new EvaliphyError(
EvaliphyErrorCode.PROMPT_LOAD_ERROR,
`Failed to load prompt for "${matcherName}": ${error.message}`,
'Check your prompt file formatting and variables.',
error
);
}
}

/**
* Prepares the variables for the prompt renderer.
*/
private static prepareVariables(input: any) {
return {
...Object.fromEntries(
Expand All @@ -149,6 +96,9 @@ export class AssertionEngine {
} as Record<string, string>;
}

/**
* Executes the actual LLM call using the provided client.
*/
private static async executeLLMCall(matcher: BaseMatcher, llmClient: any, prompt: string, schema: any) {
try {
return await llmClient.generateObject(prompt, schema);
Expand Down
91 changes: 85 additions & 6 deletions packages/assertions/src/promptManager/promptLoader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { EvaliphyError, EvaliphyErrorCode } from '@evaliphy/core';
import { EvaliphyError, EvaliphyErrorCode, logger } from '@evaliphy/core';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AssertionDefinition } from "../registry.js";

export interface LoadedPrompt {
Expand All @@ -11,14 +13,96 @@
};
}

/**
* Service to handle centralized prompt searching and loading.
*/
export class PromptLoader {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we break this function further?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I agree the previous function was doing too much. I've broken it down into smaller, single-responsibility private methods (checkUserConfig, checkSdkDist, and checkSdkSource) so resolvePromptPath just acts as a clean orchestrator now. Just pushed the update!

/**
* Main entry point to resolve and load a prompt.
*/
static resolveAndLoad(matcherName: string, assertion: AssertionDefinition, config: any): LoadedPrompt {
try {
const filePath = this.resolvePromptPath(matcherName, config);
return this.load(filePath, assertion);
} catch (error: any) {
if (error instanceof EvaliphyError) throw error;
throw new EvaliphyError(
EvaliphyErrorCode.PROMPT_LOAD_ERROR,
`Failed to load prompt for "${matcherName}": ${error.message}`,
'Check your prompt file formatting and variables.',
error
);
}
}

/**
* Orchestrates the discovery process using single-responsibility check methods.
*/
private static resolvePromptPath(matcherName: string, config: any): string {
const fileName = `${matcherName}.md`;

// 1. Check User Config (Priority 1)
const customPath = this.checkUserConfig(config, fileName);
if (customPath) return customPath;

// 2. Check SDK Dist (Fallback 1)
const distPath = this.checkSdkDist(fileName);
if (distPath) return distPath;

// 3. Check SDK Source (Fallback 2)
const sourcePath = this.checkSdkSource(fileName);
if (sourcePath) return sourcePath;

// Default return to Dist path if none found (to trigger FILE_NOT_FOUND error in load())
const __dirname = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(__dirname, './prompts', fileName);
}

/**
* Priority 1: Check for custom prompts directory from user config.
*/
private static checkUserConfig(config: any, fileName: string): string | null {
const configDir = config.configFile ? path.dirname(config.configFile) : process.cwd();
const promptsDir = config.llmAsJudgeConfig?.promptsDir;

if (!promptsDir) return null;

const customPath = path.resolve(configDir, promptsDir, fileName);
if (this.exists(customPath)) {
return customPath;
}

logger.warn(`Custom prompt file not found at: ${customPath}. Falling back to defaults.`);
return null;
}

/**
* Fallback 1: Check for default prompts directory in bundled SDK (Dist).
*/
private static checkSdkDist(fileName: string): string | null {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const distPath = path.resolve(__dirname, './prompts', fileName);

return this.exists(distPath) ? distPath : null;
}

/**
* Fallback 2: Check for default prompts directory in SDK Source (for development).
*/
private static checkSdkSource(fileName: string): string | null {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(__dirname, '../../prompts', fileName);

return this.exists(sourcePath) ? sourcePath : null;
}

static exists(filePath: string): boolean {
return fs.existsSync(filePath);
}

static load(filePath: string, assertion: AssertionDefinition): LoadedPrompt {
if (!this.exists(filePath)) {
throw new EvaliphyError(

Check failure on line 105 in packages/assertions/src/promptManager/promptLoader.ts

View workflow job for this annotation

GitHub Actions / test

packages/assertions/tests/judge/promptLoader.test.ts > PromptLoader > resolveAndLoad > should fallback to Fallback 2: SDK Source if Dist fails

EvaliphyError: Prompt file not found at: /home/runner/work/evaliphy/evaliphy/packages/assertions/src/promptManager/prompts/testMatcher.md ❯ Function.load packages/assertions/src/promptManager/promptLoader.ts:105:13 ❯ Function.resolveAndLoad packages/assertions/src/promptManager/promptLoader.ts:26:19 ❯ packages/assertions/tests/judge/promptLoader.test.ts:81:35 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'FILE_NOT_FOUND', hint: undefined }
EvaliphyErrorCode.FILE_NOT_FOUND,
`Prompt file not found at: ${filePath}`
);
Expand Down Expand Up @@ -52,22 +136,19 @@
}
const frontmatter: any = {};

// Simple YAML-like parser for basic frontmatter
yamlStr.split('\n').forEach(line => {
const [key, ...rest] = line.split(':');
if (key && rest.length > 0) {
const value = rest.join(':').trim();
if (value === '') {
frontmatter[key.trim()] = [];
} else if (value.startsWith('-')) {
// Handle simple lists
const listKey = key.trim();
if (!frontmatter[listKey]) frontmatter[listKey] = [];
} else {
frontmatter[key.trim()] = value;
}
} else if (line.trim().startsWith('-')) {
// Continue list
const lastKey = Object.keys(frontmatter).pop();
if (lastKey && Array.isArray(frontmatter[lastKey])) {
frontmatter[lastKey].push(line.trim().substring(1).trim());
Expand All @@ -88,7 +169,6 @@
const declared = frontmatter.input_variables ?? [];
const usedInTemplate = this.extractTemplateVariables(template);

// check declared variables match what the assertion requires
const missingDeclared = required.filter(v => !declared.includes(v));
if (missingDeclared.length > 0) {
throw new EvaliphyError(
Expand All @@ -98,7 +178,6 @@
);
}

// check the template actually uses the variables it declares
const missingInTemplate = declared.filter((v: string) => !usedInTemplate.includes(v));
if (missingInTemplate.length > 0) {
throw new EvaliphyError(
Expand Down
Loading
Loading