@iris-eval/mcp-server 0.3.1 → 0.4.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 +11 -3
- package/dist/audit-log-reader.d.ts +24 -0
- package/dist/audit-log-reader.js +87 -0
- package/dist/config/defaults.js +7 -1
- package/dist/custom-rule-store.d.ts +27 -0
- package/dist/custom-rule-store.js +188 -0
- package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
- package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/audit.d.ts +3 -0
- package/dist/dashboard/routes/audit.js +36 -0
- package/dist/dashboard/routes/eval-stats.js +9 -4
- package/dist/dashboard/routes/evaluations.js +3 -1
- package/dist/dashboard/routes/filters.js +5 -3
- package/dist/dashboard/routes/health.js +8 -1
- package/dist/dashboard/routes/index.d.ts +4 -0
- package/dist/dashboard/routes/index.js +4 -0
- package/dist/dashboard/routes/moments.d.ts +3 -0
- package/dist/dashboard/routes/moments.js +115 -0
- package/dist/dashboard/routes/preferences.d.ts +3 -0
- package/dist/dashboard/routes/preferences.js +52 -0
- package/dist/dashboard/routes/rules.d.ts +10 -0
- package/dist/dashboard/routes/rules.js +169 -0
- package/dist/dashboard/routes/summary.js +3 -1
- package/dist/dashboard/routes/traces.js +7 -4
- package/dist/dashboard/server.d.ts +9 -1
- package/dist/dashboard/server.js +52 -3
- package/dist/eval/citation-verify/extract.d.ts +11 -0
- package/dist/eval/citation-verify/extract.js +102 -0
- package/dist/eval/citation-verify/resolve.d.ts +26 -0
- package/dist/eval/citation-verify/resolve.js +237 -0
- package/dist/eval/citation-verify/verifier.d.ts +43 -0
- package/dist/eval/citation-verify/verifier.js +203 -0
- package/dist/eval/decision-moment.d.ts +12 -0
- package/dist/eval/decision-moment.js +181 -0
- package/dist/eval/llm-judge/client.d.ts +28 -0
- package/dist/eval/llm-judge/client.js +183 -0
- package/dist/eval/llm-judge/evaluator.d.ts +32 -0
- package/dist/eval/llm-judge/evaluator.js +138 -0
- package/dist/eval/llm-judge/pricing.d.ts +9 -0
- package/dist/eval/llm-judge/pricing.js +31 -0
- package/dist/eval/llm-judge/templates/index.d.ts +20 -0
- package/dist/eval/llm-judge/templates/index.js +170 -0
- package/dist/eval/rules/custom.js +13 -2
- package/dist/index.js +77 -14
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/tenant.d.ts +17 -0
- package/dist/middleware/tenant.js +26 -0
- package/dist/otel/exporter.d.ts +24 -0
- package/dist/otel/exporter.js +116 -0
- package/dist/otel/lazy.d.ts +5 -0
- package/dist/otel/lazy.js +31 -0
- package/dist/otel/mapper.d.ts +24 -0
- package/dist/otel/mapper.js +208 -0
- package/dist/preferences.d.ts +129 -0
- package/dist/preferences.js +152 -0
- package/dist/resources/dashboard-summary.js +3 -1
- package/dist/resources/trace-detail.js +5 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +9 -3
- package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
- package/dist/storage/migrations/004-tenant-id.js +40 -0
- package/dist/storage/migrations/index.js +2 -1
- package/dist/storage/sqlite-adapter.d.ts +17 -15
- package/dist/storage/sqlite-adapter.js +130 -79
- package/dist/tools/delete-rule.d.ts +3 -0
- package/dist/tools/delete-rule.js +53 -0
- package/dist/tools/delete-trace.d.ts +3 -0
- package/dist/tools/delete-trace.js +54 -0
- package/dist/tools/deploy-rule.d.ts +3 -0
- package/dist/tools/deploy-rule.js +91 -0
- package/dist/tools/evaluate-output.js +23 -2
- package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
- package/dist/tools/evaluate-with-llm-judge.js +147 -0
- package/dist/tools/get-traces.js +23 -3
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +13 -1
- package/dist/tools/list-rules.d.ts +3 -0
- package/dist/tools/list-rules.js +66 -0
- package/dist/tools/log-trace.js +30 -2
- package/dist/tools/verify-citations.d.ts +3 -0
- package/dist/tools/verify-citations.js +157 -0
- package/dist/types/custom-rule.d.ts +70 -0
- package/dist/types/custom-rule.js +1 -0
- package/dist/types/decision-moment.d.ts +122 -0
- package/dist/types/decision-moment.js +17 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -1
- package/dist/types/query.d.ts +25 -15
- package/dist/types/tenant.d.ts +26 -0
- package/dist/types/tenant.js +58 -0
- package/dist/utils/open-browser.d.ts +1 -0
- package/dist/utils/open-browser.js +45 -0
- package/dist/utils/validate-port-config.d.ts +2 -0
- package/dist/utils/validate-port-config.js +9 -0
- package/package.json +4 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
- package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
|
@@ -5,8 +5,8 @@
|
|
|
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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-D9JHfSB2.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BEG5FYWH.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readAuditLog } from '../../audit-log-reader.js';
|
|
3
|
+
const QuerySchema = z.object({
|
|
4
|
+
action: z.enum(['rule.deploy', 'rule.delete', 'rule.toggle', 'rule.update']).optional(),
|
|
5
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
6
|
+
search: z.string().max(200).optional(),
|
|
7
|
+
limit: z.coerce.number().int().min(1).max(1000).default(100),
|
|
8
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
9
|
+
});
|
|
10
|
+
export function registerAuditRoutes(router, store) {
|
|
11
|
+
router.get('/audit', (req, res) => {
|
|
12
|
+
try {
|
|
13
|
+
const query = QuerySchema.parse(req.query);
|
|
14
|
+
const result = readAuditLog({
|
|
15
|
+
// CustomRuleStore controls the audit file path; defer to it when
|
|
16
|
+
// available so tests + production match the same target.
|
|
17
|
+
filePath: store?.auditPath,
|
|
18
|
+
filter: {
|
|
19
|
+
action: query.action,
|
|
20
|
+
since: query.since,
|
|
21
|
+
search: query.search,
|
|
22
|
+
},
|
|
23
|
+
limit: query.limit,
|
|
24
|
+
offset: query.offset,
|
|
25
|
+
});
|
|
26
|
+
res.json(result);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err instanceof z.ZodError) {
|
|
30
|
+
res.status(400).json({ error: 'Invalid audit query', details: err.issues });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireTenant } from '../../middleware/tenant.js';
|
|
1
2
|
import { evalStatsPeriodSchema, evalStatsFailuresSchema } from '../validation.js';
|
|
2
3
|
export function registerEvalStatsRoutes(router, storage) {
|
|
3
4
|
/**
|
|
@@ -6,8 +7,9 @@ export function registerEvalStatsRoutes(router, storage) {
|
|
|
6
7
|
*/
|
|
7
8
|
router.get('/eval-stats', async (req, res) => {
|
|
8
9
|
try {
|
|
10
|
+
const tenantId = requireTenant(req);
|
|
9
11
|
const { period } = evalStatsPeriodSchema.parse(req.query);
|
|
10
|
-
const stats = await storage.getEvalStats(period);
|
|
12
|
+
const stats = await storage.getEvalStats(tenantId, period);
|
|
11
13
|
res.json(stats);
|
|
12
14
|
}
|
|
13
15
|
catch (err) {
|
|
@@ -24,8 +26,9 @@ export function registerEvalStatsRoutes(router, storage) {
|
|
|
24
26
|
*/
|
|
25
27
|
router.get('/eval-stats/trend', async (req, res) => {
|
|
26
28
|
try {
|
|
29
|
+
const tenantId = requireTenant(req);
|
|
27
30
|
const { period } = evalStatsPeriodSchema.parse(req.query);
|
|
28
|
-
const trend = await storage.getEvalStatsTrend(period);
|
|
31
|
+
const trend = await storage.getEvalStatsTrend(tenantId, period);
|
|
29
32
|
res.json(trend);
|
|
30
33
|
}
|
|
31
34
|
catch (err) {
|
|
@@ -43,8 +46,9 @@ export function registerEvalStatsRoutes(router, storage) {
|
|
|
43
46
|
*/
|
|
44
47
|
router.get('/eval-stats/rules', async (req, res) => {
|
|
45
48
|
try {
|
|
49
|
+
const tenantId = requireTenant(req);
|
|
46
50
|
const { period } = evalStatsPeriodSchema.parse(req.query);
|
|
47
|
-
const rules = await storage.getEvalStatsRules(period);
|
|
51
|
+
const rules = await storage.getEvalStatsRules(tenantId, period);
|
|
48
52
|
res.json(rules);
|
|
49
53
|
}
|
|
50
54
|
catch (err) {
|
|
@@ -61,8 +65,9 @@ export function registerEvalStatsRoutes(router, storage) {
|
|
|
61
65
|
*/
|
|
62
66
|
router.get('/eval-stats/failures', async (req, res) => {
|
|
63
67
|
try {
|
|
68
|
+
const tenantId = requireTenant(req);
|
|
64
69
|
const query = evalStatsFailuresSchema.parse(req.query);
|
|
65
|
-
const failures = await storage.getEvalStatsFailures(query.period, query.limit);
|
|
70
|
+
const failures = await storage.getEvalStatsFailures(tenantId, query.period, query.limit);
|
|
66
71
|
res.json(failures);
|
|
67
72
|
}
|
|
68
73
|
catch (err) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { requireTenant } from '../../middleware/tenant.js';
|
|
1
2
|
import { evalQuerySchema } from '../validation.js';
|
|
2
3
|
export function registerEvaluationRoutes(router, storage) {
|
|
3
4
|
router.get('/evaluations', async (req, res) => {
|
|
4
5
|
try {
|
|
6
|
+
const tenantId = requireTenant(req);
|
|
5
7
|
const query = evalQuerySchema.parse(req.query);
|
|
6
|
-
const result = await storage.queryEvalResults({
|
|
8
|
+
const result = await storage.queryEvalResults(tenantId, {
|
|
7
9
|
eval_type: query.eval_type,
|
|
8
10
|
passed: query.passed,
|
|
9
11
|
since: query.since,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { requireTenant } from '../../middleware/tenant.js';
|
|
1
2
|
export function registerFilterRoutes(router, storage) {
|
|
2
|
-
router.get('/filters', async (
|
|
3
|
+
router.get('/filters', async (req, res) => {
|
|
4
|
+
const tenantId = requireTenant(req);
|
|
3
5
|
const [agentNames, frameworks] = await Promise.all([
|
|
4
|
-
storage.getDistinctValues('agent_name'),
|
|
5
|
-
storage.getDistinctValues('framework'),
|
|
6
|
+
storage.getDistinctValues(tenantId, 'agent_name'),
|
|
7
|
+
storage.getDistinctValues(tenantId, 'framework'),
|
|
6
8
|
]);
|
|
7
9
|
res.json({ agent_names: agentNames, frameworks });
|
|
8
10
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LOCAL_TENANT } from '../../types/tenant.js';
|
|
1
2
|
const startTime = Date.now();
|
|
2
3
|
export function registerHealthRoutes(router, storage, version) {
|
|
3
4
|
const serverVersion = version ?? 'unknown';
|
|
@@ -5,7 +6,13 @@ export function registerHealthRoutes(router, storage, version) {
|
|
|
5
6
|
const uptime_seconds = Math.floor((Date.now() - startTime) / 1000);
|
|
6
7
|
if (storage) {
|
|
7
8
|
try {
|
|
8
|
-
|
|
9
|
+
/* Health probes use LOCAL_TENANT directly — the health endpoint
|
|
10
|
+
* is pre-tenant-resolution (runs for unauthenticated callers on
|
|
11
|
+
* Cloud too) and reports server-level stats. This is the ONE
|
|
12
|
+
* place storage is called with an explicit LOCAL_TENANT rather
|
|
13
|
+
* than a resolved tenantId; deliberate, documented here so it
|
|
14
|
+
* stays the only exception. */
|
|
15
|
+
const summary = await storage.getDashboardSummary(LOCAL_TENANT, 1);
|
|
9
16
|
res.json({
|
|
10
17
|
status: 'ok',
|
|
11
18
|
version: serverVersion,
|
|
@@ -4,3 +4,7 @@ export { registerEvaluationRoutes } from './evaluations.js';
|
|
|
4
4
|
export { registerEvalStatsRoutes } from './eval-stats.js';
|
|
5
5
|
export { registerFilterRoutes } from './filters.js';
|
|
6
6
|
export { registerHealthRoutes } from './health.js';
|
|
7
|
+
export { registerMomentRoutes } from './moments.js';
|
|
8
|
+
export { registerRuleRoutes } from './rules.js';
|
|
9
|
+
export { registerPreferencesRoutes } from './preferences.js';
|
|
10
|
+
export { registerAuditRoutes } from './audit.js';
|
|
@@ -4,3 +4,7 @@ export { registerEvaluationRoutes } from './evaluations.js';
|
|
|
4
4
|
export { registerEvalStatsRoutes } from './eval-stats.js';
|
|
5
5
|
export { registerFilterRoutes } from './filters.js';
|
|
6
6
|
export { registerHealthRoutes } from './health.js';
|
|
7
|
+
export { registerMomentRoutes } from './moments.js';
|
|
8
|
+
export { registerRuleRoutes } from './rules.js';
|
|
9
|
+
export { registerPreferencesRoutes } from './preferences.js';
|
|
10
|
+
export { registerAuditRoutes } from './audit.js';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { requireTenant } from '../../middleware/tenant.js';
|
|
3
|
+
import { deriveMoment, deriveMomentDetail } from '../../eval/decision-moment.js';
|
|
4
|
+
const VERDICT_VALUES = ['pass', 'fail', 'partial', 'unevaluated'];
|
|
5
|
+
const SIGNIFICANCE_KINDS = [
|
|
6
|
+
'safety-violation',
|
|
7
|
+
'cost-spike',
|
|
8
|
+
'first-failure',
|
|
9
|
+
'novel-pattern',
|
|
10
|
+
'rule-collision',
|
|
11
|
+
'normal-pass',
|
|
12
|
+
'normal-fail',
|
|
13
|
+
];
|
|
14
|
+
const momentQuerySchema = z.object({
|
|
15
|
+
agent_name: z.string().min(1).max(200).optional(),
|
|
16
|
+
verdict: z.enum(VERDICT_VALUES).optional(),
|
|
17
|
+
min_significance: z.coerce.number().min(0).max(1).optional(),
|
|
18
|
+
significance_kind: z
|
|
19
|
+
.enum(SIGNIFICANCE_KINDS)
|
|
20
|
+
.optional(),
|
|
21
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
22
|
+
until: z.string().datetime({ offset: true }).optional(),
|
|
23
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
24
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
25
|
+
sort_order: z.enum(['asc', 'desc']).default('desc'),
|
|
26
|
+
});
|
|
27
|
+
export function registerMomentRoutes(router, storage) {
|
|
28
|
+
router.get('/moments', async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const tenantId = requireTenant(req);
|
|
31
|
+
const query = momentQuerySchema.parse(req.query);
|
|
32
|
+
// Pull the underlying traces. We over-fetch when post-filtering by
|
|
33
|
+
// significance to give the moment classifier headroom, then trim.
|
|
34
|
+
const wantsSignificanceFilter = query.min_significance !== undefined || query.significance_kind !== undefined;
|
|
35
|
+
const fetchLimit = wantsSignificanceFilter
|
|
36
|
+
? Math.min(query.limit * 4, 200)
|
|
37
|
+
: query.limit;
|
|
38
|
+
const traceResult = await storage.queryTraces(tenantId, {
|
|
39
|
+
filter: {
|
|
40
|
+
agent_name: query.agent_name,
|
|
41
|
+
since: query.since,
|
|
42
|
+
until: query.until,
|
|
43
|
+
},
|
|
44
|
+
limit: fetchLimit,
|
|
45
|
+
offset: query.offset,
|
|
46
|
+
sort_by: 'timestamp',
|
|
47
|
+
sort_order: query.sort_order,
|
|
48
|
+
});
|
|
49
|
+
// Hydrate moments by fetching evals per trace. Acceptable for limit ≤ 200;
|
|
50
|
+
// batching is a v0.4.1 optimization once we have moment-volume data.
|
|
51
|
+
const moments = [];
|
|
52
|
+
for (const trace of traceResult.traces) {
|
|
53
|
+
const evals = await storage.getEvalsByTraceId(tenantId, trace.trace_id);
|
|
54
|
+
const moment = deriveMoment(trace, evals);
|
|
55
|
+
if (query.verdict && moment.verdict !== query.verdict)
|
|
56
|
+
continue;
|
|
57
|
+
if (query.min_significance !== undefined &&
|
|
58
|
+
moment.significance.score < query.min_significance)
|
|
59
|
+
continue;
|
|
60
|
+
if (query.significance_kind &&
|
|
61
|
+
moment.significance.kind !== query.significance_kind)
|
|
62
|
+
continue;
|
|
63
|
+
moments.push(moment);
|
|
64
|
+
if (moments.length >= query.limit)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
const result = {
|
|
68
|
+
moments,
|
|
69
|
+
// total reflects the underlying trace count (pre-filter) — significance-
|
|
70
|
+
// filtered totals would require materializing the full set, which we
|
|
71
|
+
// avoid for now. Clients should treat this as "at least this many."
|
|
72
|
+
total: traceResult.total,
|
|
73
|
+
limit: query.limit,
|
|
74
|
+
offset: query.offset,
|
|
75
|
+
};
|
|
76
|
+
res.json(result);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof Error && err.name === 'ZodError') {
|
|
80
|
+
res.status(400).json({
|
|
81
|
+
error: 'Invalid query parameters',
|
|
82
|
+
details: err.issues,
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
router.get('/moments/:id', async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const tenantId = requireTenant(req);
|
|
92
|
+
const trace = await storage.getTrace(tenantId, req.params.id);
|
|
93
|
+
if (!trace) {
|
|
94
|
+
res.status(404).json({ error: 'Decision moment not found' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const [evals, spans] = await Promise.all([
|
|
98
|
+
storage.getEvalsByTraceId(tenantId, req.params.id),
|
|
99
|
+
storage.getSpansByTraceId(tenantId, req.params.id),
|
|
100
|
+
]);
|
|
101
|
+
const detail = deriveMomentDetail(trace, evals, spans);
|
|
102
|
+
res.json(detail);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (err instanceof Error && err.name === 'ZodError') {
|
|
106
|
+
res.status(400).json({
|
|
107
|
+
error: 'Invalid query parameters',
|
|
108
|
+
details: err.issues,
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/* PATCH body schema. Every field optional + strict (rejects unknown keys
|
|
3
|
+
* to catch typos at the API boundary). Inner schemas mirror preferences.ts. */
|
|
4
|
+
const MomentFiltersPatchSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
agentName: z.string().max(200).optional(),
|
|
7
|
+
verdict: z.enum(['pass', 'fail', 'partial', 'unevaluated']).optional(),
|
|
8
|
+
significanceKind: z
|
|
9
|
+
.enum([
|
|
10
|
+
'safety-violation',
|
|
11
|
+
'cost-spike',
|
|
12
|
+
'first-failure',
|
|
13
|
+
'novel-pattern',
|
|
14
|
+
'rule-collision',
|
|
15
|
+
'normal-pass',
|
|
16
|
+
'normal-fail',
|
|
17
|
+
])
|
|
18
|
+
.optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
const PatchSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
autoLaunch: z.boolean().optional(),
|
|
24
|
+
dismissedBanners: z.array(z.string().max(80)).optional(),
|
|
25
|
+
theme: z.enum(['dark', 'light', 'system']).optional(),
|
|
26
|
+
momentFilters: MomentFiltersPatchSchema.optional(),
|
|
27
|
+
dismissedTours: z.array(z.string().max(80)).optional(),
|
|
28
|
+
archivedMoments: z.array(z.string().max(200)).optional(),
|
|
29
|
+
density: z.enum(['compact', 'comfortable']).optional(),
|
|
30
|
+
sidebarCollapsed: z.boolean().optional(),
|
|
31
|
+
notificationsLastSeen: z.string().datetime({ offset: true }).optional(),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
export function registerPreferencesRoutes(router, store) {
|
|
35
|
+
router.get('/preferences', (_req, res) => {
|
|
36
|
+
res.json({ preferences: store.read(), path: store.path });
|
|
37
|
+
});
|
|
38
|
+
router.patch('/preferences', (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const patch = PatchSchema.parse(req.body);
|
|
41
|
+
const updated = store.patch(patch);
|
|
42
|
+
res.json({ preferences: updated, path: store.path });
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err instanceof z.ZodError) {
|
|
46
|
+
res.status(400).json({ error: 'Invalid preferences patch', details: err.issues });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { IStorageAdapter } from '../../types/query.js';
|
|
3
|
+
import type { CustomRuleStore } from '../../custom-rule-store.js';
|
|
4
|
+
import type { EvalEngine } from '../../eval/engine.js';
|
|
5
|
+
interface RoutesOptions {
|
|
6
|
+
customRuleStore: CustomRuleStore;
|
|
7
|
+
evalEngine: EvalEngine;
|
|
8
|
+
}
|
|
9
|
+
export declare function registerRuleRoutes(router: Router, storage: IStorageAdapter, opts: RoutesOptions): void;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createCustomRule } from '../../eval/rules/custom.js';
|
|
3
|
+
import { requireTenant } from '../../middleware/tenant.js';
|
|
4
|
+
const SeveritySchema = z.enum(['low', 'medium', 'high', 'critical']);
|
|
5
|
+
const EvalTypeSchema = z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']);
|
|
6
|
+
const RuleTypeSchema = z.enum([
|
|
7
|
+
'regex_match',
|
|
8
|
+
'regex_no_match',
|
|
9
|
+
'min_length',
|
|
10
|
+
'max_length',
|
|
11
|
+
'contains_keywords',
|
|
12
|
+
'excludes_keywords',
|
|
13
|
+
'json_schema',
|
|
14
|
+
'cost_threshold',
|
|
15
|
+
]);
|
|
16
|
+
const DefinitionSchema = z.object({
|
|
17
|
+
name: z.string().min(1).max(80),
|
|
18
|
+
type: RuleTypeSchema,
|
|
19
|
+
config: z.record(z.unknown()),
|
|
20
|
+
weight: z.number().positive().optional(),
|
|
21
|
+
});
|
|
22
|
+
const DeploySchema = z.object({
|
|
23
|
+
name: z.string().min(1).max(80).regex(/^[a-z0-9._-]+$/i, 'Use letters, digits, dot, dash, underscore'),
|
|
24
|
+
description: z.string().max(500).optional(),
|
|
25
|
+
evalType: EvalTypeSchema,
|
|
26
|
+
severity: SeveritySchema.optional(),
|
|
27
|
+
definition: DefinitionSchema,
|
|
28
|
+
sourceMomentId: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
const PreviewSchema = z.object({
|
|
31
|
+
definition: DefinitionSchema,
|
|
32
|
+
evalType: EvalTypeSchema.default('custom'),
|
|
33
|
+
/** Window in days; default 7. Hard cap at 30 to keep memory bounded. */
|
|
34
|
+
windowDays: z.coerce.number().int().min(1).max(30).default(7),
|
|
35
|
+
/** Trace cap; default 1000. */
|
|
36
|
+
maxTraces: z.coerce.number().int().min(1).max(5000).default(1000),
|
|
37
|
+
});
|
|
38
|
+
export function registerRuleRoutes(router, storage, opts) {
|
|
39
|
+
router.get('/rules/custom', (_req, res) => {
|
|
40
|
+
const rules = opts.customRuleStore.list();
|
|
41
|
+
res.json({ rules });
|
|
42
|
+
});
|
|
43
|
+
router.post('/rules/custom', async (req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
const input = DeploySchema.parse(req.body);
|
|
46
|
+
// Server overrides the inner definition's `name` so it always matches the
|
|
47
|
+
// user-facing rule name. Avoids confusion when the rule name and the
|
|
48
|
+
// inner definition's check name diverge.
|
|
49
|
+
const definition = {
|
|
50
|
+
...input.definition,
|
|
51
|
+
name: input.name,
|
|
52
|
+
};
|
|
53
|
+
const rule = opts.customRuleStore.deploy({
|
|
54
|
+
name: input.name,
|
|
55
|
+
description: input.description,
|
|
56
|
+
evalType: input.evalType,
|
|
57
|
+
severity: input.severity,
|
|
58
|
+
definition,
|
|
59
|
+
sourceMomentId: input.sourceMomentId,
|
|
60
|
+
});
|
|
61
|
+
// Register the new rule with the live engine so it fires on subsequent
|
|
62
|
+
// evaluate_output calls without requiring a server restart.
|
|
63
|
+
opts.evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition));
|
|
64
|
+
res.status(201).json({ rule });
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err instanceof z.ZodError) {
|
|
68
|
+
res.status(400).json({ error: 'Invalid rule definition', details: err.issues });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
router.delete('/rules/custom/:id', (req, res) => {
|
|
75
|
+
const removed = opts.customRuleStore.delete(req.params.id);
|
|
76
|
+
if (!removed) {
|
|
77
|
+
res.status(404).json({ error: 'Rule not found' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Note: removing from the live engine requires a registry reset, which
|
|
81
|
+
// the engine doesn't expose in v0.4. The deleted rule continues to fire
|
|
82
|
+
// until the next iris-mcp restart. Documented behavior; v0.4.1 adds
|
|
83
|
+
// engine.unregisterRule for hot-removal.
|
|
84
|
+
res.status(204).end();
|
|
85
|
+
});
|
|
86
|
+
router.post('/rules/custom/preview', async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const tenantId = requireTenant(req);
|
|
89
|
+
const input = PreviewSchema.parse(req.body);
|
|
90
|
+
const result = await previewRule(tenantId, input, storage);
|
|
91
|
+
res.json(result);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err instanceof z.ZodError) {
|
|
95
|
+
res.status(400).json({ error: 'Invalid preview request', details: err.issues });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const status = err.status;
|
|
99
|
+
if (status === 422) {
|
|
100
|
+
res.status(422).json({ error: 'Rule definition rejected', message: err.message });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async function previewRule(tenantId, input, storage) {
|
|
108
|
+
const since = new Date(Date.now() - input.windowDays * 24 * 60 * 60 * 1000).toISOString();
|
|
109
|
+
const traceResult = await storage.queryTraces(tenantId, {
|
|
110
|
+
filter: { since },
|
|
111
|
+
limit: input.maxTraces,
|
|
112
|
+
sort_by: 'timestamp',
|
|
113
|
+
sort_order: 'desc',
|
|
114
|
+
});
|
|
115
|
+
const rule = createCustomRule(input.definition);
|
|
116
|
+
// Sanity-probe the rule against an empty input. Compile-time errors
|
|
117
|
+
// (invalid regex, pattern too long, ReDoS rejection) surface here as a
|
|
118
|
+
// failed result with a recognizable error prefix. Surface as a 422 so
|
|
119
|
+
// the UI can show the error instead of a misleading "5 traces fail."
|
|
120
|
+
const probe = rule.evaluate({ output: '' });
|
|
121
|
+
if (!probe.skipped &&
|
|
122
|
+
!probe.passed &&
|
|
123
|
+
/^(?:Invalid regex|Regex pattern (?:too long|rejected))/.test(probe.message)) {
|
|
124
|
+
const err = new Error(probe.message);
|
|
125
|
+
err.status = 422;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
let wouldPass = 0;
|
|
129
|
+
let wouldFail = 0;
|
|
130
|
+
let wouldSkip = 0;
|
|
131
|
+
const examples = [];
|
|
132
|
+
for (const trace of traceResult.traces) {
|
|
133
|
+
if (trace.output === undefined) {
|
|
134
|
+
wouldSkip++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const result = rule.evaluate({
|
|
138
|
+
output: trace.output,
|
|
139
|
+
input: trace.input,
|
|
140
|
+
costUsd: trace.cost_usd,
|
|
141
|
+
tokenUsage: trace.token_usage,
|
|
142
|
+
});
|
|
143
|
+
if (result.skipped) {
|
|
144
|
+
wouldSkip++;
|
|
145
|
+
}
|
|
146
|
+
else if (result.passed) {
|
|
147
|
+
wouldPass++;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
wouldFail++;
|
|
151
|
+
if (examples.length < 5) {
|
|
152
|
+
examples.push({
|
|
153
|
+
traceId: trace.trace_id,
|
|
154
|
+
agentName: trace.agent_name,
|
|
155
|
+
timestamp: trace.timestamp,
|
|
156
|
+
outputPreview: trace.output.slice(0, 200),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
tracesEvaluated: traceResult.traces.length,
|
|
163
|
+
wouldFail,
|
|
164
|
+
wouldPass,
|
|
165
|
+
wouldSkip,
|
|
166
|
+
examples,
|
|
167
|
+
windowSinceIso: since,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { requireTenant } from '../../middleware/tenant.js';
|
|
1
2
|
import { summaryQuerySchema } from '../validation.js';
|
|
2
3
|
export function registerSummaryRoutes(router, storage) {
|
|
3
4
|
router.get('/summary', async (req, res) => {
|
|
4
5
|
try {
|
|
6
|
+
const tenantId = requireTenant(req);
|
|
5
7
|
const query = summaryQuerySchema.parse(req.query);
|
|
6
|
-
const summary = await storage.getDashboardSummary(query.hours);
|
|
8
|
+
const summary = await storage.getDashboardSummary(tenantId, query.hours);
|
|
7
9
|
res.json(summary);
|
|
8
10
|
}
|
|
9
11
|
catch (err) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { requireTenant } from '../../middleware/tenant.js';
|
|
1
2
|
import { traceQuerySchema } from '../validation.js';
|
|
2
3
|
export function registerTraceRoutes(router, storage) {
|
|
3
4
|
router.get('/traces', async (req, res) => {
|
|
4
5
|
try {
|
|
6
|
+
const tenantId = requireTenant(req);
|
|
5
7
|
const query = traceQuerySchema.parse(req.query);
|
|
6
|
-
const result = await storage.queryTraces({
|
|
8
|
+
const result = await storage.queryTraces(tenantId, {
|
|
7
9
|
filter: {
|
|
8
10
|
agent_name: query.agent_name,
|
|
9
11
|
framework: query.framework,
|
|
@@ -27,13 +29,14 @@ export function registerTraceRoutes(router, storage) {
|
|
|
27
29
|
});
|
|
28
30
|
router.get('/traces/:id', async (req, res) => {
|
|
29
31
|
try {
|
|
30
|
-
const
|
|
32
|
+
const tenantId = requireTenant(req);
|
|
33
|
+
const trace = await storage.getTrace(tenantId, req.params.id);
|
|
31
34
|
if (!trace) {
|
|
32
35
|
res.status(404).json({ error: 'Trace not found' });
|
|
33
36
|
return;
|
|
34
37
|
}
|
|
35
|
-
const spans = await storage.getSpansByTraceId(req.params.id);
|
|
36
|
-
const evals = await storage.getEvalsByTraceId(req.params.id);
|
|
38
|
+
const spans = await storage.getSpansByTraceId(tenantId, req.params.id);
|
|
39
|
+
const evals = await storage.getEvalsByTraceId(tenantId, req.params.id);
|
|
37
40
|
res.json({ trace, spans, evals });
|
|
38
41
|
}
|
|
39
42
|
catch (err) {
|
|
@@ -3,8 +3,16 @@ import type { Server } from 'node:http';
|
|
|
3
3
|
import type { IStorageAdapter } from '../types/query.js';
|
|
4
4
|
import type { IrisConfig } from '../types/config.js';
|
|
5
5
|
import type { Logger } from '../utils/logger.js';
|
|
6
|
+
import type { CustomRuleStore } from '../custom-rule-store.js';
|
|
7
|
+
import type { EvalEngine } from '../eval/engine.js';
|
|
8
|
+
import type { PreferenceStore } from '../preferences.js';
|
|
6
9
|
export interface DashboardServer {
|
|
7
10
|
app: express.Application;
|
|
8
11
|
start(): Server;
|
|
9
12
|
}
|
|
10
|
-
export
|
|
13
|
+
export interface DashboardServerOptions {
|
|
14
|
+
customRuleStore?: CustomRuleStore;
|
|
15
|
+
evalEngine?: EvalEngine;
|
|
16
|
+
preferenceStore?: PreferenceStore;
|
|
17
|
+
}
|
|
18
|
+
export declare function createDashboardServer(storage: IStorageAdapter, config: IrisConfig, logger: Logger, options?: DashboardServerOptions): DashboardServer;
|