@iris-eval/mcp-server 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/config/defaults.js +9 -1
- package/dist/config/index.js +3 -1
- package/dist/dashboard/assets/index-Biw11Phy.js +43 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/evaluations.js +19 -10
- package/dist/dashboard/routes/summary.js +12 -3
- package/dist/dashboard/routes/traces.js +39 -21
- package/dist/eval/engine.d.ts +2 -1
- package/dist/eval/engine.js +62 -7
- package/dist/eval/rules/completeness.js +5 -3
- package/dist/eval/rules/cost.js +5 -2
- package/dist/eval/rules/custom.js +9 -0
- package/dist/eval/rules/relevance.js +16 -5
- package/dist/server.js +1 -1
- package/dist/storage/migrations/002-eval-skip-fields.d.ts +3 -0
- package/dist/storage/migrations/002-eval-skip-fields.js +8 -0
- package/dist/storage/migrations/index.js +2 -1
- package/dist/storage/sqlite-adapter.js +29 -9
- package/dist/tools/evaluate-output.js +3 -0
- package/dist/types/config.d.ts +8 -0
- package/dist/types/eval.d.ts +5 -0
- package/package.json +8 -8
- package/server.json +2 -2
- package/dist/dashboard/assets/index-neEIXwxp.js +0 -70
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>Iris — Agent Eval & Observability</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-Biw11Phy.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-C9BwWthL.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { evalQuerySchema } from '../validation.js';
|
|
2
2
|
export function registerEvaluationRoutes(router, storage) {
|
|
3
3
|
router.get('/evaluations', async (req, res) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
try {
|
|
5
|
+
const query = evalQuerySchema.parse(req.query);
|
|
6
|
+
const result = await storage.queryEvalResults({
|
|
7
|
+
eval_type: query.eval_type,
|
|
8
|
+
passed: query.passed,
|
|
9
|
+
since: query.since,
|
|
10
|
+
until: query.until,
|
|
11
|
+
limit: query.limit,
|
|
12
|
+
offset: query.offset,
|
|
13
|
+
});
|
|
14
|
+
res.json(result);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
if (err instanceof Error && err.name === 'ZodError') {
|
|
18
|
+
res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
14
23
|
});
|
|
15
24
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { summaryQuerySchema } from '../validation.js';
|
|
2
2
|
export function registerSummaryRoutes(router, storage) {
|
|
3
3
|
router.get('/summary', async (req, res) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
try {
|
|
5
|
+
const query = summaryQuerySchema.parse(req.query);
|
|
6
|
+
const summary = await storage.getDashboardSummary(query.hours);
|
|
7
|
+
res.json(summary);
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
if (err instanceof Error && err.name === 'ZodError') {
|
|
11
|
+
res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
7
16
|
});
|
|
8
17
|
}
|
|
@@ -1,29 +1,47 @@
|
|
|
1
1
|
import { traceQuerySchema } from '../validation.js';
|
|
2
2
|
export function registerTraceRoutes(router, storage) {
|
|
3
3
|
router.get('/traces', async (req, res) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
4
|
+
try {
|
|
5
|
+
const query = traceQuerySchema.parse(req.query);
|
|
6
|
+
const result = await storage.queryTraces({
|
|
7
|
+
filter: {
|
|
8
|
+
agent_name: query.agent_name,
|
|
9
|
+
framework: query.framework,
|
|
10
|
+
since: query.since,
|
|
11
|
+
until: query.until,
|
|
12
|
+
},
|
|
13
|
+
limit: query.limit,
|
|
14
|
+
offset: query.offset,
|
|
15
|
+
sort_by: query.sort_by,
|
|
16
|
+
sort_order: query.sort_order,
|
|
17
|
+
});
|
|
18
|
+
res.json(result);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err instanceof Error && err.name === 'ZodError') {
|
|
22
|
+
res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
18
27
|
});
|
|
19
28
|
router.get('/traces/:id', async (req, res) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
try {
|
|
30
|
+
const trace = await storage.getTrace(req.params.id);
|
|
31
|
+
if (!trace) {
|
|
32
|
+
res.status(404).json({ error: 'Trace not found' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const spans = await storage.getSpansByTraceId(req.params.id);
|
|
36
|
+
const evals = await storage.getEvalsByTraceId(req.params.id);
|
|
37
|
+
res.json({ trace, spans, evals });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err instanceof Error && err.name === 'ZodError') {
|
|
41
|
+
res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
24
45
|
}
|
|
25
|
-
const spans = await storage.getSpansByTraceId(req.params.id);
|
|
26
|
-
const evals = await storage.getEvalsByTraceId(req.params.id);
|
|
27
|
-
res.json({ trace, spans, evals });
|
|
28
46
|
});
|
|
29
47
|
}
|
package/dist/eval/engine.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { EvalRule, EvalContext, EvalResult, EvalType, CustomRuleDefinition
|
|
|
2
2
|
export declare class EvalEngine {
|
|
3
3
|
private additionalRules;
|
|
4
4
|
private threshold;
|
|
5
|
-
|
|
5
|
+
private ruleThresholds?;
|
|
6
|
+
constructor(threshold?: number, ruleThresholds?: Record<string, unknown>);
|
|
6
7
|
registerRule(evalType: EvalType, rule: EvalRule): void;
|
|
7
8
|
evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
|
|
8
9
|
}
|
package/dist/eval/engine.js
CHANGED
|
@@ -3,8 +3,10 @@ import { generateEvalId } from '../utils/ids.js';
|
|
|
3
3
|
export class EvalEngine {
|
|
4
4
|
additionalRules = new Map();
|
|
5
5
|
threshold;
|
|
6
|
-
|
|
6
|
+
ruleThresholds;
|
|
7
|
+
constructor(threshold = 0.7, ruleThresholds) {
|
|
7
8
|
this.threshold = threshold;
|
|
9
|
+
this.ruleThresholds = ruleThresholds;
|
|
8
10
|
}
|
|
9
11
|
registerRule(evalType, rule) {
|
|
10
12
|
const existing = this.additionalRules.get(evalType) ?? [];
|
|
@@ -12,6 +14,13 @@ export class EvalEngine {
|
|
|
12
14
|
this.additionalRules.set(evalType, existing);
|
|
13
15
|
}
|
|
14
16
|
evaluate(evalType, context, customRules) {
|
|
17
|
+
// Merge system-level thresholds into customConfig (user-provided values take precedence)
|
|
18
|
+
if (this.ruleThresholds) {
|
|
19
|
+
context = {
|
|
20
|
+
...context,
|
|
21
|
+
customConfig: { ...this.ruleThresholds, ...context.customConfig },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
15
24
|
let rules;
|
|
16
25
|
if (evalType === 'custom' && customRules) {
|
|
17
26
|
rules = customRules.map((def) => createCustomRule(def));
|
|
@@ -28,27 +37,70 @@ export class EvalEngine {
|
|
|
28
37
|
eval_type: evalType,
|
|
29
38
|
output_text: context.output,
|
|
30
39
|
expected_text: context.expected,
|
|
31
|
-
score:
|
|
32
|
-
passed:
|
|
40
|
+
score: 0,
|
|
41
|
+
passed: false,
|
|
33
42
|
rule_results: [],
|
|
34
43
|
suggestions: ['No rules configured for this eval type'],
|
|
44
|
+
rules_evaluated: 0,
|
|
45
|
+
rules_skipped: 0,
|
|
46
|
+
insufficient_data: true,
|
|
35
47
|
};
|
|
36
48
|
}
|
|
37
49
|
const ruleResults = rules.map((rule) => rule.evaluate(context));
|
|
38
|
-
|
|
39
|
-
const
|
|
50
|
+
// Partition into evaluated vs skipped
|
|
51
|
+
const evaluatedIndices = [];
|
|
52
|
+
const skippedIndices = [];
|
|
53
|
+
for (let i = 0; i < ruleResults.length; i++) {
|
|
54
|
+
if (ruleResults[i].skipped) {
|
|
55
|
+
skippedIndices.push(i);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
evaluatedIndices.push(i);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const rulesEvaluated = evaluatedIndices.length;
|
|
62
|
+
const rulesSkipped = skippedIndices.length;
|
|
63
|
+
// Handle "all rules skipped" — insufficient data
|
|
64
|
+
if (rulesEvaluated === 0) {
|
|
65
|
+
const skipMessages = ruleResults
|
|
66
|
+
.filter((r) => r.skipped)
|
|
67
|
+
.map((r) => `[${r.ruleName}] ${r.skipReason ?? r.message}`);
|
|
68
|
+
return {
|
|
69
|
+
id: generateEvalId(),
|
|
70
|
+
eval_type: evalType,
|
|
71
|
+
output_text: context.output,
|
|
72
|
+
expected_text: context.expected,
|
|
73
|
+
score: 0,
|
|
74
|
+
passed: false,
|
|
75
|
+
rule_results: ruleResults,
|
|
76
|
+
suggestions: [
|
|
77
|
+
'Insufficient context to evaluate. Provide: expected, input, costUsd, or tokenUsage.',
|
|
78
|
+
...skipMessages,
|
|
79
|
+
],
|
|
80
|
+
rules_evaluated: 0,
|
|
81
|
+
rules_skipped: rulesSkipped,
|
|
82
|
+
insufficient_data: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Weighted average across evaluated rules only (exclude skipped)
|
|
86
|
+
const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
|
|
87
|
+
const weightedScore = evaluatedIndices.reduce((sum, i) => {
|
|
40
88
|
const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
|
|
41
|
-
return sum + ruleScore *
|
|
89
|
+
return sum + ruleScore * rules[i].weight;
|
|
42
90
|
}, 0);
|
|
43
91
|
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|
44
92
|
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
45
93
|
const passed = score >= this.threshold;
|
|
46
94
|
const suggestions = [];
|
|
47
95
|
for (const result of ruleResults) {
|
|
48
|
-
if (!result.passed) {
|
|
96
|
+
if (!result.passed && !result.skipped) {
|
|
49
97
|
suggestions.push(`[${result.ruleName}] ${result.message}`);
|
|
50
98
|
}
|
|
51
99
|
}
|
|
100
|
+
if (rulesSkipped > 0) {
|
|
101
|
+
const skippedNames = ruleResults.filter((r) => r.skipped).map((r) => r.ruleName);
|
|
102
|
+
suggestions.push(`${rulesSkipped} rule(s) skipped (missing context): ${skippedNames.join(', ')}`);
|
|
103
|
+
}
|
|
52
104
|
return {
|
|
53
105
|
id: generateEvalId(),
|
|
54
106
|
eval_type: evalType,
|
|
@@ -58,6 +110,9 @@ export class EvalEngine {
|
|
|
58
110
|
passed,
|
|
59
111
|
rule_results: ruleResults,
|
|
60
112
|
suggestions,
|
|
113
|
+
rules_evaluated: rulesEvaluated,
|
|
114
|
+
rules_skipped: rulesSkipped,
|
|
115
|
+
insufficient_data: false,
|
|
61
116
|
};
|
|
62
117
|
}
|
|
63
118
|
}
|
|
@@ -4,7 +4,9 @@ export const minOutputLength = {
|
|
|
4
4
|
evalType: 'completeness',
|
|
5
5
|
weight: 1,
|
|
6
6
|
evaluate(context) {
|
|
7
|
-
const minLen = context.customConfig?.
|
|
7
|
+
const minLen = context.customConfig?.min_output_length
|
|
8
|
+
?? context.customConfig?.min_length
|
|
9
|
+
?? 50;
|
|
8
10
|
const len = context.output.length;
|
|
9
11
|
const passed = len >= minLen;
|
|
10
12
|
return {
|
|
@@ -36,7 +38,7 @@ export const sentenceCount = {
|
|
|
36
38
|
evalType: 'completeness',
|
|
37
39
|
weight: 0.5,
|
|
38
40
|
evaluate(context) {
|
|
39
|
-
const minSentences = context.customConfig?.min_sentences ??
|
|
41
|
+
const minSentences = context.customConfig?.min_sentences ?? 2;
|
|
40
42
|
const sentences = context.output.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
|
|
41
43
|
const passed = sentences >= minSentences;
|
|
42
44
|
return {
|
|
@@ -54,7 +56,7 @@ export const expectedCoverage = {
|
|
|
54
56
|
weight: 1.5,
|
|
55
57
|
evaluate(context) {
|
|
56
58
|
if (!context.expected) {
|
|
57
|
-
return { ruleName: 'expected_coverage', passed:
|
|
59
|
+
return { ruleName: 'expected_coverage', passed: false, score: 0, message: 'No expected output provided', skipped: true, skipReason: 'context.expected not provided' };
|
|
58
60
|
}
|
|
59
61
|
const expectedWords = new Set(context.expected.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
|
|
60
62
|
const outputWords = new Set(context.output.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
|
package/dist/eval/rules/cost.js
CHANGED
|
@@ -4,8 +4,11 @@ export const costUnderThreshold = {
|
|
|
4
4
|
evalType: 'cost',
|
|
5
5
|
weight: 1,
|
|
6
6
|
evaluate(context) {
|
|
7
|
+
if (context.costUsd === undefined || context.costUsd === null) {
|
|
8
|
+
return { ruleName: 'cost_under_threshold', passed: false, score: 0, message: 'Cost data not provided', skipped: true, skipReason: 'context.costUsd not provided' };
|
|
9
|
+
}
|
|
7
10
|
const threshold = context.customConfig?.cost_threshold ?? 0.10;
|
|
8
|
-
const cost = context.costUsd
|
|
11
|
+
const cost = context.costUsd;
|
|
9
12
|
const passed = cost <= threshold;
|
|
10
13
|
return {
|
|
11
14
|
ruleName: 'cost_under_threshold',
|
|
@@ -26,7 +29,7 @@ export const tokenEfficiency = {
|
|
|
26
29
|
const prompt = context.tokenUsage?.prompt_tokens;
|
|
27
30
|
const completion = context.tokenUsage?.completion_tokens;
|
|
28
31
|
if (prompt === undefined || completion === undefined || prompt === 0) {
|
|
29
|
-
return { ruleName: 'token_efficiency', passed:
|
|
32
|
+
return { ruleName: 'token_efficiency', passed: false, score: 0, message: 'Token usage not provided', skipped: true, skipReason: 'context.tokenUsage not provided' };
|
|
30
33
|
}
|
|
31
34
|
const ratio = completion / prompt;
|
|
32
35
|
const maxRatio = context.customConfig?.max_token_ratio ?? 5;
|
|
@@ -58,6 +58,9 @@ export function createCustomRule(definition) {
|
|
|
58
58
|
}
|
|
59
59
|
case 'contains_keywords': {
|
|
60
60
|
const keywords = definition.config.keywords;
|
|
61
|
+
if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
|
|
62
|
+
return { ruleName: definition.name, passed: false, score: 0, message: 'contains_keywords rule requires config.keywords (non-empty string array)' };
|
|
63
|
+
}
|
|
61
64
|
const lower = context.output.toLowerCase();
|
|
62
65
|
const found = keywords.filter((k) => lower.includes(k.toLowerCase()));
|
|
63
66
|
const ratio = found.length / keywords.length;
|
|
@@ -66,6 +69,9 @@ export function createCustomRule(definition) {
|
|
|
66
69
|
}
|
|
67
70
|
case 'excludes_keywords': {
|
|
68
71
|
const keywords = definition.config.keywords;
|
|
72
|
+
if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
|
|
73
|
+
return { ruleName: definition.name, passed: false, score: 0, message: 'excludes_keywords rule requires config.keywords (non-empty string array)' };
|
|
74
|
+
}
|
|
69
75
|
const lower = context.output.toLowerCase();
|
|
70
76
|
const found = keywords.filter((k) => lower.includes(k.toLowerCase()));
|
|
71
77
|
const passed = found.length === 0;
|
|
@@ -82,6 +88,9 @@ export function createCustomRule(definition) {
|
|
|
82
88
|
}
|
|
83
89
|
case 'cost_threshold': {
|
|
84
90
|
const max = definition.config.max_cost;
|
|
91
|
+
if (max == null || max < 0) {
|
|
92
|
+
return { ruleName: definition.name, passed: false, score: 0, message: 'cost_threshold rule requires config.max_cost (non-negative number)' };
|
|
93
|
+
}
|
|
85
94
|
const cost = context.costUsd ?? 0;
|
|
86
95
|
const passed = cost <= max;
|
|
87
96
|
return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? `Cost ($${cost}) within threshold ($${max})` : `Cost ($${cost}) exceeds threshold ($${max})` };
|
|
@@ -5,7 +5,7 @@ export const keywordOverlap = {
|
|
|
5
5
|
weight: 1,
|
|
6
6
|
evaluate(context) {
|
|
7
7
|
if (!context.input) {
|
|
8
|
-
return { ruleName: 'keyword_overlap', passed:
|
|
8
|
+
return { ruleName: 'keyword_overlap', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
|
|
9
9
|
}
|
|
10
10
|
const inputWords = new Set(context.input.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
|
|
11
11
|
const outputWords = new Set(context.output.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
|
|
@@ -18,7 +18,8 @@ export const keywordOverlap = {
|
|
|
18
18
|
overlap++;
|
|
19
19
|
}
|
|
20
20
|
const ratio = overlap / inputWords.size;
|
|
21
|
-
const
|
|
21
|
+
const threshold = context.customConfig?.keyword_overlap ?? 0.35;
|
|
22
|
+
const passed = ratio >= threshold;
|
|
22
23
|
return {
|
|
23
24
|
ruleName: 'keyword_overlap',
|
|
24
25
|
passed,
|
|
@@ -29,6 +30,7 @@ export const keywordOverlap = {
|
|
|
29
30
|
};
|
|
30
31
|
const HALLUCINATION_MARKERS = [
|
|
31
32
|
'as an ai',
|
|
33
|
+
'as a language model',
|
|
32
34
|
'i cannot',
|
|
33
35
|
'i don\'t have access',
|
|
34
36
|
'i apologize',
|
|
@@ -36,6 +38,14 @@ const HALLUCINATION_MARKERS = [
|
|
|
36
38
|
'i must clarify',
|
|
37
39
|
'it\'s important to note that i',
|
|
38
40
|
'i should mention that as',
|
|
41
|
+
'i\'m just an ai',
|
|
42
|
+
'i don\'t actually',
|
|
43
|
+
'i cannot provide',
|
|
44
|
+
'i\'m unable to',
|
|
45
|
+
'please note that i',
|
|
46
|
+
'as a digital assistant',
|
|
47
|
+
'i want to be transparent',
|
|
48
|
+
'i need to be honest',
|
|
39
49
|
];
|
|
40
50
|
export const noHallucinationMarkers = {
|
|
41
51
|
name: 'no_hallucination_markers',
|
|
@@ -61,12 +71,12 @@ export const topicConsistency = {
|
|
|
61
71
|
weight: 1,
|
|
62
72
|
evaluate(context) {
|
|
63
73
|
if (!context.input) {
|
|
64
|
-
return { ruleName: 'topic_consistency', passed:
|
|
74
|
+
return { ruleName: 'topic_consistency', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
|
|
65
75
|
}
|
|
66
76
|
const inputWords = context.input.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
|
|
67
77
|
const outputWords = context.output.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
|
|
68
78
|
if (inputWords.length === 0 || outputWords.length === 0) {
|
|
69
|
-
return { ruleName: 'topic_consistency', passed:
|
|
79
|
+
return { ruleName: 'topic_consistency', passed: false, score: 0, message: 'Insufficient text for topic analysis', skipped: true, skipReason: 'input or output has no words > 3 chars' };
|
|
70
80
|
}
|
|
71
81
|
const inputSet = new Set(inputWords);
|
|
72
82
|
let relevant = 0;
|
|
@@ -75,7 +85,8 @@ export const topicConsistency = {
|
|
|
75
85
|
relevant++;
|
|
76
86
|
}
|
|
77
87
|
const ratio = relevant / outputWords.length;
|
|
78
|
-
const
|
|
88
|
+
const threshold = context.customConfig?.topic_consistency ?? 0.10;
|
|
89
|
+
const passed = ratio >= threshold;
|
|
79
90
|
return {
|
|
80
91
|
ruleName: 'topic_consistency',
|
|
81
92
|
passed,
|
package/dist/server.js
CHANGED
|
@@ -7,7 +7,7 @@ export function createIrisServer(config, storage) {
|
|
|
7
7
|
name: config.server.name,
|
|
8
8
|
version: config.server.version,
|
|
9
9
|
});
|
|
10
|
-
const evalEngine = new EvalEngine(config.eval.defaultThreshold);
|
|
10
|
+
const evalEngine = new EvalEngine(config.eval.defaultThreshold, config.eval.ruleThresholds);
|
|
11
11
|
registerAllTools(mcpServer, storage, evalEngine);
|
|
12
12
|
registerAllResources(mcpServer, storage);
|
|
13
13
|
return { mcpServer, evalEngine };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const id = '002-eval-skip-fields';
|
|
2
|
+
export function up(db) {
|
|
3
|
+
db.exec(`
|
|
4
|
+
ALTER TABLE eval_results ADD COLUMN rules_evaluated INTEGER;
|
|
5
|
+
ALTER TABLE eval_results ADD COLUMN rules_skipped INTEGER;
|
|
6
|
+
ALTER TABLE eval_results ADD COLUMN insufficient_data INTEGER DEFAULT 0;
|
|
7
|
+
`);
|
|
8
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as migration001 from './001-initial-schema.js';
|
|
2
|
-
|
|
2
|
+
import * as migration002 from './002-eval-skip-fields.js';
|
|
3
|
+
const migrations = [migration001, migration002];
|
|
3
4
|
export function runMigrations(db) {
|
|
4
5
|
db.exec(`
|
|
5
6
|
CREATE TABLE IF NOT EXISTS _iris_migrations (
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import { runMigrations } from './migrations/index.js';
|
|
3
|
+
const ALLOWED_SORT_COLUMNS = new Set(['timestamp', 'latency_ms', 'cost_usd']);
|
|
4
|
+
const ALLOWED_SORT_ORDERS = new Set(['asc', 'desc']);
|
|
3
5
|
export class SqliteAdapter {
|
|
4
6
|
db;
|
|
5
7
|
constructor(dbPath) {
|
|
@@ -15,16 +17,23 @@ export class SqliteAdapter {
|
|
|
15
17
|
this.db.close();
|
|
16
18
|
}
|
|
17
19
|
async insertTrace(trace) {
|
|
18
|
-
const
|
|
20
|
+
const insertTraceStmt = this.db.prepare(`
|
|
19
21
|
INSERT INTO traces (trace_id, agent_name, framework, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, timestamp)
|
|
20
22
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
21
23
|
`);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
const insertSpanStmt = this.db.prepare(`
|
|
25
|
+
INSERT INTO spans (span_id, trace_id, parent_span_id, name, kind, status_code, status_message, start_time, end_time, attributes, events)
|
|
26
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
27
|
+
`);
|
|
28
|
+
const insertAll = this.db.transaction((t) => {
|
|
29
|
+
insertTraceStmt.run(t.trace_id, t.agent_name, t.framework ?? null, t.input ?? null, t.output ?? null, t.tool_calls ? JSON.stringify(t.tool_calls) : null, t.latency_ms ?? null, t.token_usage ? JSON.stringify(t.token_usage) : null, t.cost_usd ?? null, t.metadata ? JSON.stringify(t.metadata) : null, t.timestamp);
|
|
30
|
+
if (t.spans) {
|
|
31
|
+
for (const span of t.spans) {
|
|
32
|
+
insertSpanStmt.run(span.span_id, t.trace_id, span.parent_span_id ?? null, span.name, span.kind, span.status_code, span.status_message ?? null, span.start_time, span.end_time ?? null, span.attributes ? JSON.stringify(span.attributes) : null, span.events ? JSON.stringify(span.events) : null);
|
|
33
|
+
}
|
|
26
34
|
}
|
|
27
|
-
}
|
|
35
|
+
});
|
|
36
|
+
insertAll(trace);
|
|
28
37
|
}
|
|
29
38
|
async getTrace(traceId) {
|
|
30
39
|
const row = this.db.prepare('SELECT * FROM traces WHERE trace_id = ?').get(traceId);
|
|
@@ -55,6 +64,12 @@ export class SqliteAdapter {
|
|
|
55
64
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
56
65
|
const sortBy = options.sort_by ?? 'timestamp';
|
|
57
66
|
const sortOrder = options.sort_order ?? 'desc';
|
|
67
|
+
if (!ALLOWED_SORT_COLUMNS.has(sortBy)) {
|
|
68
|
+
throw new Error(`Invalid sort column: ${sortBy}`);
|
|
69
|
+
}
|
|
70
|
+
if (!ALLOWED_SORT_ORDERS.has(sortOrder)) {
|
|
71
|
+
throw new Error(`Invalid sort order: ${sortOrder}`);
|
|
72
|
+
}
|
|
58
73
|
const limit = options.limit ?? 50;
|
|
59
74
|
const offset = options.offset ?? 0;
|
|
60
75
|
const countRow = this.db
|
|
@@ -84,9 +99,9 @@ export class SqliteAdapter {
|
|
|
84
99
|
}
|
|
85
100
|
async insertEvalResult(result) {
|
|
86
101
|
this.db.prepare(`
|
|
87
|
-
INSERT INTO eval_results (id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions)
|
|
88
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
89
|
-
`).run(result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions));
|
|
102
|
+
INSERT INTO eval_results (id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data)
|
|
103
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
104
|
+
`).run(result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0);
|
|
90
105
|
}
|
|
91
106
|
async getEvalsByTraceId(traceId) {
|
|
92
107
|
const rows = this.db
|
|
@@ -281,6 +296,8 @@ export class SqliteAdapter {
|
|
|
281
296
|
for (const row of rows) {
|
|
282
297
|
const rules = JSON.parse(row.rule_results);
|
|
283
298
|
for (const r of rules) {
|
|
299
|
+
if (r.skipped)
|
|
300
|
+
continue;
|
|
284
301
|
const entry = ruleMap.get(r.ruleName) ?? { totalRun: 0, failCount: 0 };
|
|
285
302
|
entry.totalRun++;
|
|
286
303
|
if (!r.passed)
|
|
@@ -394,6 +411,9 @@ export class SqliteAdapter {
|
|
|
394
411
|
rule_results: JSON.parse(row.rule_results),
|
|
395
412
|
suggestions: JSON.parse(row.suggestions),
|
|
396
413
|
created_at: row.created_at,
|
|
414
|
+
rules_evaluated: row.rules_evaluated,
|
|
415
|
+
rules_skipped: row.rules_skipped,
|
|
416
|
+
insufficient_data: row.insufficient_data != null ? row.insufficient_data === 1 : undefined,
|
|
397
417
|
};
|
|
398
418
|
}
|
|
399
419
|
}
|
|
@@ -50,6 +50,9 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
|
50
50
|
passed: result.passed,
|
|
51
51
|
rule_results: result.rule_results,
|
|
52
52
|
suggestions: result.suggestions,
|
|
53
|
+
rules_evaluated: result.rules_evaluated,
|
|
54
|
+
rules_skipped: result.rules_skipped,
|
|
55
|
+
insufficient_data: result.insufficient_data,
|
|
53
56
|
}),
|
|
54
57
|
},
|
|
55
58
|
],
|
package/dist/types/config.d.ts
CHANGED
|
@@ -18,6 +18,14 @@ export interface IrisConfig {
|
|
|
18
18
|
};
|
|
19
19
|
eval: {
|
|
20
20
|
defaultThreshold: number;
|
|
21
|
+
ruleThresholds?: {
|
|
22
|
+
min_output_length?: number;
|
|
23
|
+
min_sentences?: number;
|
|
24
|
+
keyword_overlap?: number;
|
|
25
|
+
topic_consistency?: number;
|
|
26
|
+
cost_threshold?: number;
|
|
27
|
+
max_token_ratio?: number;
|
|
28
|
+
};
|
|
21
29
|
};
|
|
22
30
|
logging: {
|
|
23
31
|
level: 'debug' | 'info' | 'warn' | 'error';
|
package/dist/types/eval.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface EvalRuleResult {
|
|
|
29
29
|
passed: boolean;
|
|
30
30
|
score: number;
|
|
31
31
|
message: string;
|
|
32
|
+
skipped?: boolean;
|
|
33
|
+
skipReason?: string;
|
|
32
34
|
}
|
|
33
35
|
export interface EvalResult {
|
|
34
36
|
id: string;
|
|
@@ -41,6 +43,9 @@ export interface EvalResult {
|
|
|
41
43
|
rule_results: EvalRuleResult[];
|
|
42
44
|
suggestions: string[];
|
|
43
45
|
created_at?: string;
|
|
46
|
+
rules_evaluated?: number;
|
|
47
|
+
rules_skipped?: number;
|
|
48
|
+
insufficient_data?: boolean;
|
|
44
49
|
}
|
|
45
50
|
export type CustomRuleType = 'regex_match' | 'regex_no_match' | 'min_length' | 'max_length' | 'contains_keywords' | 'excludes_keywords' | 'json_schema' | 'cost_threshold';
|
|
46
51
|
export interface CustomRuleDefinition {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iris-eval/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The agent eval standard for MCP. Score every agent output for quality, safety, and cost.",
|
|
5
5
|
"mcpName": "io.github.iris-eval/mcp-server",
|
|
6
6
|
"type": "module",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"output-quality",
|
|
48
48
|
"quality-gate"
|
|
49
49
|
],
|
|
50
|
-
"author": "",
|
|
50
|
+
"author": "Ian Parent",
|
|
51
51
|
"license": "MIT",
|
|
52
52
|
"repository": {
|
|
53
53
|
"type": "git",
|
|
@@ -67,10 +67,10 @@
|
|
|
67
67
|
"node": ">=20.0.0"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
70
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
71
71
|
"better-sqlite3": "^12.8.0",
|
|
72
72
|
"express": "^5.1.0",
|
|
73
|
-
"express-rate-limit": "^8.3.
|
|
73
|
+
"express-rate-limit": "^8.3.2",
|
|
74
74
|
"helmet": "^8.1.0",
|
|
75
75
|
"pino": "^10.3.1",
|
|
76
76
|
"safe-regex2": "^5.1.0",
|
|
@@ -79,11 +79,11 @@
|
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/better-sqlite3": "^7.6.0",
|
|
81
81
|
"@types/express": "^5.0.0",
|
|
82
|
-
"@types/node": "^25.5.
|
|
83
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
84
|
-
"@typescript-eslint/parser": "^8.
|
|
82
|
+
"@types/node": "^25.5.2",
|
|
83
|
+
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
|
84
|
+
"@typescript-eslint/parser": "^8.58.0",
|
|
85
85
|
"@vitest/coverage-v8": "^4.1.1",
|
|
86
|
-
"eslint": "^10.
|
|
86
|
+
"eslint": "^10.2.0",
|
|
87
87
|
"prettier": "^3.0.0",
|
|
88
88
|
"tsx": "^4.0.0",
|
|
89
89
|
"typescript": "^5.7.0",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/iris-eval/mcp-server",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@iris-eval/mcp-server",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|