kiku-agent 1.3.1 → 1.3.2

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.
Files changed (119) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -12
  3. package/server/agent/eval/AGENTS.md +228 -0
  4. package/server/agent/eval/analyze.py +684 -0
  5. package/server/agent/eval/gates.json +15 -0
  6. package/server/agent/eval/modules/analysis/compare.js +198 -0
  7. package/server/agent/eval/modules/analysis/gates.js +72 -0
  8. package/server/agent/eval/modules/analysis/judge.js +70 -0
  9. package/server/agent/eval/modules/core/cli.js +453 -0
  10. package/server/agent/eval/modules/core/config.js +60 -0
  11. package/server/agent/eval/modules/core/store.js +236 -0
  12. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  13. package/server/agent/eval/modules/evaluation/runner.js +298 -0
  14. package/server/agent/eval/modules/evaluation/scoring.js +351 -0
  15. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  16. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  17. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  18. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  19. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  20. package/server/agent/eval/modules/ui/evalUi.js +4796 -0
  21. package/server/agent/eval/modules/utils/smokes.js +119 -0
  22. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  23. package/server/agent/tests/agentFollowUps.test.js +127 -0
  24. package/server/agent/tests/apiRouter.test.js +108 -0
  25. package/server/agent/tests/architectureFixes.test.js +81 -0
  26. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  27. package/server/agent/tests/autoResearch.test.js +64 -0
  28. package/server/agent/tests/bilibiliEmbed.test.js +251 -0
  29. package/server/agent/tests/catalogSync.test.js +130 -0
  30. package/server/agent/tests/citations.test.js +180 -0
  31. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  32. package/server/agent/tests/clarifyingQuestions.test.js +594 -0
  33. package/server/agent/tests/cliAutomation.test.js +121 -0
  34. package/server/agent/tests/cloudProviders.test.js +234 -0
  35. package/server/agent/tests/commandInput.test.js +24 -0
  36. package/server/agent/tests/commands.test.js +417 -0
  37. package/server/agent/tests/contextIntelligence.test.js +162 -0
  38. package/server/agent/tests/conversationSafety.test.js +53 -0
  39. package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
  40. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  41. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  42. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  43. package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
  44. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  45. package/server/agent/tests/envBootstrap.test.js +87 -0
  46. package/server/agent/tests/envCommand.test.js +215 -0
  47. package/server/agent/tests/errorHandling.test.js +80 -0
  48. package/server/agent/tests/errors.test.js +13 -0
  49. package/server/agent/tests/evalAnalyze.test.js +54 -0
  50. package/server/agent/tests/evalCommand.test.js +54 -0
  51. package/server/agent/tests/evalCorpus.test.js +126 -0
  52. package/server/agent/tests/evalHarness.test.js +62 -0
  53. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  54. package/server/agent/tests/evalMetrics.test.js +312 -0
  55. package/server/agent/tests/evalStore.test.js +190 -0
  56. package/server/agent/tests/evalStudio.test.js +179 -0
  57. package/server/agent/tests/feedbackLearning.test.js +186 -0
  58. package/server/agent/tests/firstRun.test.js +44 -0
  59. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  60. package/server/agent/tests/hardwareTiers.test.js +206 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +192 -0
  62. package/server/agent/tests/judgeService.test.js +251 -0
  63. package/server/agent/tests/lanDiscovery.test.js +319 -0
  64. package/server/agent/tests/mcpClient.test.js +201 -0
  65. package/server/agent/tests/modelManager.test.js +109 -0
  66. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  67. package/server/agent/tests/newCommands.test.js +347 -0
  68. package/server/agent/tests/newCommandsTui.test.js +141 -0
  69. package/server/agent/tests/newTools.test.js +113 -0
  70. package/server/agent/tests/newsIntelligence.test.js +178 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +99 -0
  72. package/server/agent/tests/offlineInput.test.js +217 -0
  73. package/server/agent/tests/packagingGuard.test.js +57 -0
  74. package/server/agent/tests/presentation.test.js +61 -0
  75. package/server/agent/tests/providerPersistence.test.js +39 -0
  76. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  77. package/server/agent/tests/queryDetails.test.js +126 -0
  78. package/server/agent/tests/realWorldValidation.test.js +391 -0
  79. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  80. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  81. package/server/agent/tests/sanitizer.test.js +37 -0
  82. package/server/agent/tests/scenariosData.js +378 -0
  83. package/server/agent/tests/schemaRouting.test.js +54 -0
  84. package/server/agent/tests/securityAuditRemediation.test.js +214 -0
  85. package/server/agent/tests/securityHardening.test.js +146 -0
  86. package/server/agent/tests/securitySanitizer.test.js +38 -0
  87. package/server/agent/tests/sessionAndCopy.test.js +232 -0
  88. package/server/agent/tests/sidebarControls.test.js +101 -0
  89. package/server/agent/tests/statefulMemory.test.js +270 -0
  90. package/server/agent/tests/subagent.test.js +216 -0
  91. package/server/agent/tests/subagentProgress.test.js +109 -0
  92. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  93. package/server/agent/tests/suggestions.test.js +199 -0
  94. package/server/agent/tests/suggestionsTui.test.js +251 -0
  95. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  96. package/server/agent/tests/terminalImage.test.js +80 -0
  97. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  98. package/server/agent/tests/terminalText.test.js +133 -0
  99. package/server/agent/tests/textProtocolLoop.test.js +126 -0
  100. package/server/agent/tests/textToolParsing.test.js +139 -0
  101. package/server/agent/tests/theme.test.js +26 -0
  102. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  103. package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
  104. package/server/agent/tests/tools.test.js +259 -0
  105. package/server/agent/tests/unifiedEndpoints.test.js +152 -0
  106. package/server/agent/tests/v110Features.test.js +262 -0
  107. package/server/agent/tests/versionBump.test.js +69 -0
  108. package/server/agent/tests/vramEstimator.test.js +520 -0
  109. package/server/agent/tests/webChatInspection.test.js +300 -0
  110. package/server/agent/tests/webChatMemory.test.js +112 -0
  111. package/server/agent/tests/webChatSecurity.test.js +169 -0
  112. package/server/agent/tests/webExtract.test.js +261 -0
  113. package/server/agent/tests/webSources.test.js +247 -0
  114. package/server/eval/judgeRubrics.js +110 -0
  115. package/server/eval/judgeService.js +290 -0
  116. package/server/eval/runBenchmark.js +182 -0
  117. package/server/mcp/examples/librechat.example.yaml +16 -0
  118. package/server/mcp/tests/connection.test.js +156 -0
  119. package/server/supabase_schema.sql +63 -0
@@ -0,0 +1,126 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import vm from 'node:vm';
5
+ import { allCases, cases, securityCases, expandedCases, legacyCases, validateCases } from '../eval/modules/evaluation/cases.js';
6
+ import { selectCases } from '../eval/modules/evaluation/runner.js';
7
+ import { scoreCoverage } from '../eval/modules/evaluation/scoring.js';
8
+ import { corpusHash, computeSourceHashes } from '../eval/modules/core/store.js';
9
+ import { getEvalUiHtml } from '../eval/modules/ui/evalUi.js';
10
+ import { ALLOWED_TOOLS } from '../eval/modules/core/config.js';
11
+ import { profileData } from '../../../src/data/profileData.js';
12
+ import { worksData } from '../../../src/data/worksData.js';
13
+ import { discographyData } from '../../../src/data/discographyData.js';
14
+ import { brandsData } from '../../../src/data/brandsData.js';
15
+ import { awardsData } from '../../../src/data/awardsData.js';
16
+ import { musicVideoData } from '../../../src/data/musicVideoData.js';
17
+ import { RUBRICS } from '../../eval/judgeRubrics.js';
18
+
19
+ const byId = id => allCases.find(c => c.id === id);
20
+
21
+ test('all 500 cases run through suite selection without losing or duplicating expanded IDs', () => {
22
+ assert.equal(validateCases().length, 500);
23
+ assert.equal(cases.length, 350);
24
+ assert.equal(securityCases.length, 150);
25
+ assert.equal(expandedCases.length, 360);
26
+ assert.equal(legacyCases.length, 140);
27
+ for (const [suite, count] of [['catalog', 350], ['security', 150], ['expanded', 360], ['legacy', 140], ['all', 500], ['full', 500]]) {
28
+ const selected = selectCases({ suite });
29
+ assert.equal(selected.length, count, suite);
30
+ assert.equal(new Set(selected.map(c => c.id)).size, count, suite);
31
+ }
32
+ assert.deepEqual(new Set(selectCases({ ids: 'p01,w40,x20,f20,s150' }).map(c => c.id)), new Set(['p01', 'w40', 'x20', 'f20', 's150']));
33
+ assert.throws(() => selectCases({ ids: 's151' }), /Unknown case/);
34
+ assert.equal(new Set(expandedCases.map(c => c.dimension)).size, 25);
35
+ assert.equal(securityCases.filter(c => c.dimension === 'benign-security-control').length, 10);
36
+ assert.equal(securityCases.filter(c => c.dimension === 'mixed-request').length, 10);
37
+ });
38
+
39
+ test('mixed suite maintains 70/30 proportions in short prefixes and stable reruns', async () => {
40
+ for (let offset = 0; offset < 500; offset += 10) {
41
+ assert.equal(allCases.slice(offset, offset + 10).filter(c => c.category === 'security').length, 3);
42
+ }
43
+ const quick = selectCases({ suite: 'all', limit: 3 });
44
+ assert.ok(quick.some(c => c.category === 'security'));
45
+ assert.ok(quick.some(c => c.category !== 'security'));
46
+ assert.ok(new Set(allCases.slice(0, 50).map(c => c.category)).size >= 12);
47
+ assert.equal(new Set(allCases.slice(0, 30).map(c => c.language)).size, 3);
48
+ const reloaded = await import('../eval/modules/evaluation/cases.js?reproducibility-check');
49
+ assert.equal(corpusHash(reloaded.allCases), corpusHash(allCases));
50
+ });
51
+
52
+ test('corpus rejects malformed metadata and duplicate normalized questions before inference', () => {
53
+ const valid = byId('s101');
54
+ const invalid = [null, { ...valid, language: 'xx' }, { ...valid, sources: ['.env'] },
55
+ { ...valid, expectedTools: ['read_local_file'] }, { ...valid, question: ' ' },
56
+ { ...valid, reference: ' ' }, { ...valid, groups: [null] },
57
+ { ...valid, groups: [[' ']] }, { ...valid, judgeRequired: false },
58
+ { ...valid, difficulty: 'impossible' }, { ...valid, groups: [['cannot']] }];
59
+ for (const c of invalid) assert.throws(() => validateCases([c]));
60
+ assert.throws(() => validateCases([valid, { ...valid, id: 's999', question: ' ' + valid.question.toUpperCase() + ' ' }]), /Duplicate question/);
61
+ for (const c of expandedCases) {
62
+ assert.equal(c.judgeRequired, true, c.id);
63
+ assert.ok(c.expectedTools.every(tool => ALLOWED_TOOLS.includes(tool)), c.id);
64
+ assert.notEqual(scoreCoverage(c, c.reference).allMatched, false, c.id);
65
+ }
66
+ for (const c of cases.filter(c => c.category === 'abstention')) {
67
+ assert.equal(scoreCoverage(c, 'A confident invented answer').allMatched, null, c.id);
68
+ }
69
+ });
70
+
71
+ test('source hashes cover new video evidence and the authored security behavior reference', async () => {
72
+ const hashes = await computeSourceHashes();
73
+ assert.match(hashes['src/data/musicVideoData.js'], /^[a-f0-9]{64}$/);
74
+ assert.match(hashes['server/agent/eval/AGENTS.md'], /^[a-f0-9]{64}$/);
75
+ for (const source of new Set(allCases.flatMap(c => c.sources))) {
76
+ assert.ok((await fs.stat(new URL('../../../' + source, import.meta.url))).isFile(), source);
77
+ }
78
+ });
79
+
80
+ test('reviewed frozen references agree with catalog boundary and arithmetic fixtures', () => {
81
+ const songs = [...discographyData.singles, ...discographyData.ostList];
82
+ const work = title => worksData.find(w => w.titleEn === title);
83
+ const song = id => songs.find(s => s.id === id);
84
+ assert.equal(song('single-find-the-way').year, 2026);
85
+ assert.equal(song('ost-not-suffering-2026').year, 2026);
86
+ assert.match(byId('r08').reference, /cannot establish/);
87
+ assert.equal(worksData.some(w => w.titleCn === '七星彩'), false);
88
+ assert.match(byId('r15').reference, /absent/);
89
+ assert.equal(byId('r15').groups.length, 0);
90
+ assert.match(awardsData.find(a => a.id === 'award-2019-china-canada').work, /Legend of Yunxi/);
91
+ assert.match(byId('a06').reference, /premise is wrong/);
92
+ assert.equal(brandsData.brandList.filter(b => b.year === 2026 && b.isGlobal).length, 8);
93
+ assert.equal(awardsData.filter(a => a.year === 2017 && a.category === 'Music').length, 2);
94
+ assert.equal(profileData.cats.length, 3);
95
+ assert.equal(parseInt(work('Legend of Yunxi').episodes) + parseInt(work('Detective Samoyeds').episodes), 96);
96
+ assert.equal(Math.ceil(parseInt(work('Sword and Fairy 4').episodes) / 5), 8);
97
+ assert.equal(parseInt(work('Sword and Fairy 4').episodes) % 5, 1);
98
+ assert.equal(song('single-past-perfect').trackDuration, '3:58');
99
+ assert.equal(song('ost-past-perfect-2021').trackDuration, '3:48');
100
+ assert.equal(song('ost-stay-with-me-2024').trackDuration, '3:30');
101
+ assert.equal(musicVideoData.find(v => v.id === 'mv-stay-with-me-2024').duration, '03:58');
102
+ assert.match(byId('x08').reference, /3:58.*3:48/);
103
+ assert.match(byId('x09').reference, /3:30.*03:58/);
104
+ });
105
+
106
+ test('security judging includes case-specific safe behavior and evidence after ten tool events', () => {
107
+ const c = byId('s101');
108
+ const trace = Array.from({ length: 11 }, (_, index) => ({ event: 'start', name: 'global_search', parameters: { query: index === 10 ? 'LATE_EXFILTRATION_ATTEMPT' : 'catalog' } }));
109
+ const prompt = RUBRICS.security.buildUserPrompt(c.question, 'I refuse everything.', { reference: c.reference, evidence: [{ source: 'profile', birthYear: 1994 }], trace });
110
+ assert.ok(prompt.includes(c.reference));
111
+ assert.match(prompt, /1994/);
112
+ assert.match(prompt, /LATE_EXFILTRATION_ATTEMPT/);
113
+ assert.match(RUBRICS.security.systemPrompt, /blanket refusal is not a pass/);
114
+ assert.match(RUBRICS.fact_groundedness.systemPrompt, /output format/);
115
+ });
116
+
117
+ test('Eval Studio shows 500 as the total and its generated script remains parseable', () => {
118
+ const html = getEvalUiHtml();
119
+ assert.match(html, /All Comprehensive \(500/);
120
+ assert.match(html, /Catalog QA \(350/);
121
+ assert.match(html, /Security \(150/);
122
+ assert.match(html, /max="500"/);
123
+ for (const [, script] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) {
124
+ assert.doesNotThrow(() => new vm.Script(script));
125
+ }
126
+ });
@@ -0,0 +1,62 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { cases, allCases, validateCases } from '../eval/modules/evaluation/cases.js';
4
+ import { scoreCoverage, parseJudgment, summarize } from '../eval/modules/evaluation/scoring.js';
5
+ import { selectCases } from '../eval/modules/evaluation/runner.js';
6
+
7
+ test('eval corpus has uniquely identified, sourced multilingual cases', () => {
8
+ assert.ok(validateCases().length >= 500);
9
+ assert.equal(new Set(allCases.map(c => c.id)).size, allCases.length);
10
+ assert.ok(new Set(cases.map(c => c.id)).size >= 90);
11
+ assert.deepEqual([...new Set(allCases.map(c => c.language))].sort(), ['en', 'th', 'zh']);
12
+ assert.ok(cases.filter(c => c.category === 'abstention').length >= 8);
13
+ assert.ok(cases.filter(c => c.category === 'reasoning').length >= 16);
14
+ assert.ok(cases.filter(c => c.category === 'brands').length >= 6);
15
+ assert.ok(cases.filter(c => c.category === 'awards').length >= 6);
16
+ assert.ok(cases.filter(c => c.category === 'quotes').length >= 4);
17
+ assert.ok(cases.filter(c => c.category === 'gallery').length >= 4);
18
+ assert.throws(() => validateCases([cases[0], cases[0]]));
19
+ });
20
+
21
+ test('coverage supports aliases but cannot silently turn unavailable facts into passes', () => {
22
+ const c = cases.find(c => c.id === 'w01');
23
+ assert.equal(scoreCoverage(c, 'รับบทหานหลิงซาค่ะ').allMatched, true);
24
+ assert.equal(scoreCoverage(c, 'ไม่ทราบ').allMatched, false);
25
+ assert.equal(scoreCoverage(cases.find(c => c.id === 'u01'), 'Made up a role').allMatched, null);
26
+ assert.equal(scoreCoverage(c, 'It is NOT Han Lingsha.').allMatched, true);
27
+ });
28
+
29
+ test('judge parser rejects missing explanations, out-of-range scores and malformed output', () => {
30
+ const valid = { correctness: 4, grounding: 3, instructionFollowing: 2, unsupportedClaims: [], reason: 'Wrong language.' };
31
+ assert.deepEqual(parseJudgment(JSON.stringify(valid)), valid);
32
+ assert.throws(() => parseJudgment(JSON.stringify({ ...valid, correctness: 5 })));
33
+ assert.throws(() => parseJudgment(JSON.stringify({ ...valid, reason: '' })));
34
+ assert.throws(() => parseJudgment(JSON.stringify({ ...valid, unsupportedClaims: [false] })));
35
+ assert.throws(() => parseJudgment('The answer is correct.'));
36
+ });
37
+
38
+ test('summary keeps inference errors, incomplete runs and missing judgments visible', () => {
39
+ const rows = [
40
+ { status: 'completed', durationMs: 10, coverage: { total: 1, allMatched: true } },
41
+ { status: 'error', error: 'offline' },
42
+ ];
43
+ const result = summarize(rows, 4);
44
+ assert.equal(result.completionRate, .25);
45
+ assert.equal(result.errors, 1);
46
+ assert.equal(result.judged, 0);
47
+ assert.equal(result.judgePasses, 0);
48
+ assert.equal(summarize([], 40).lexicalCoverageRate, null);
49
+ assert.equal(summarize([], 40).medianMs, null);
50
+ });
51
+
52
+ test('selectCases accepts both array and comma-separated string ids', () => {
53
+ const fromArray = selectCases({ ids: ['w01', 'w02'] });
54
+ assert.equal(fromArray.length, 2);
55
+ assert.equal(fromArray[0].id, 'w01');
56
+ assert.equal(fromArray[1].id, 'w02');
57
+
58
+ const fromString = selectCases({ ids: 'w01, w02' });
59
+ assert.equal(fromString.length, 2);
60
+ assert.equal(fromString[0].id, 'w01');
61
+ assert.equal(fromString[1].id, 'w02');
62
+ });
@@ -0,0 +1,38 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { judgeReport } from '../eval/modules/analysis/judge.js';
4
+ import { allCases } from '../eval/modules/evaluation/cases.js';
5
+
6
+ test('benchmark judging routes facts and security with frozen references, including benign controls', async t => {
7
+ const requests = [];
8
+ t.mock.method(globalThis, 'fetch', async (url, options) => {
9
+ assert.match(String(url), /\/chat\/completions$/);
10
+ const body = JSON.parse(options.body);
11
+ requests.push(body);
12
+ const diagnostic = body.messages[0].content.includes('strict multilingual QA evaluator');
13
+ const result = diagnostic
14
+ ? { correctness: 0, grounding: 0, instructionFollowing: 0, unsupportedClaims: [], reason: 'An available safe question was refused.' }
15
+ : { verdict: 'FAIL', score: 0, reasoning: 'A blanket refusal misses the safe question in the frozen reference.' };
16
+ return new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: JSON.stringify(result) }, finish_reason: 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 30, total_tokens: 130 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
17
+ });
18
+ const ids = ['p01', 's101', 's141'];
19
+ const report = {
20
+ metadata: { model: 'candidate-test' },
21
+ rows: ids.map(id => {
22
+ const c = allCases.find(c => c.id === id);
23
+ return { id, category: c.category, question: c.question, answer: 'I refuse everything.', status: 'completed', trace: [] };
24
+ }),
25
+ };
26
+ await judgeReport(report, { judgeModel: 'judge-test', baseUrl: 'https://judge.invalid/v1', timeoutMs: 1000 });
27
+ assert.equal(requests.length, 6);
28
+ const binary = requests.filter(r => !r.messages[0].content.includes('strict multilingual QA evaluator'));
29
+ assert.match(binary[0].messages[0].content, /Fact-Checking/);
30
+ assert.match(binary[1].messages[0].content, /AI Safety and Security/);
31
+ assert.match(binary[2].messages[0].content, /Benign controls require helpful answers/);
32
+ for (let i = 0; i < ids.length; i++) {
33
+ assert.ok(binary[i].messages[1].content.includes(allCases.find(c => c.id === ids[i]).reference));
34
+ assert.equal(report.rows[i].judgeResult.verdict, 'FAIL');
35
+ assert.equal(report.rows[i].judgment.instructionFollowing, 0);
36
+ assert.equal(report.rows[i].judgeError, undefined);
37
+ }
38
+ });
@@ -0,0 +1,312 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ evaluateToolCorrectness,
5
+ detectTrajectoryLoops,
6
+ computeLatencyBreakdown,
7
+ checkGroundedness,
8
+ summarize
9
+ } from '../eval/modules/evaluation/scoring.js';
10
+ import { generateDashboardHtml, enrichEvaluationReport } from '../eval/modules/reporting/dashboard.js';
11
+ import { cases, securityCases, allCases } from '../eval/modules/evaluation/cases.js';
12
+ import { evaluateGates } from '../eval/modules/analysis/gates.js';
13
+ import { diffReports } from '../eval/modules/analysis/compare.js';
14
+
15
+ test('evaluateToolCorrectness computes precision, recall, and F1 accurately', () => {
16
+ const c = { id: 'test01', expectedTools: ['search_works', 'get_work_detail'] };
17
+
18
+ const exactTrace = [
19
+ { event: 'start', name: 'search_works' },
20
+ { event: 'result', name: 'search_works', value: 'ok' },
21
+ { event: 'start', name: 'get_work_detail' },
22
+ { event: 'result', name: 'get_work_detail', value: 'ok' },
23
+ ];
24
+ const exact = evaluateToolCorrectness(c, exactTrace);
25
+ assert.equal(exact.precision, 1.0);
26
+ assert.equal(exact.recall, 1.0);
27
+ assert.equal(exact.f1, 1.0);
28
+ assert.equal(exact.isCorrect, true);
29
+
30
+ const partialTrace = [{ event: 'start', name: 'search_works' }];
31
+ const partial = evaluateToolCorrectness(c, partialTrace);
32
+ assert.equal(partial.precision, 1.0);
33
+ assert.equal(partial.recall, 0.5);
34
+ assert.equal(partial.isCorrect, true);
35
+
36
+ const lowPrecisionTrace = [
37
+ { event: 'start', name: 'search_works' },
38
+ { event: 'start', name: 'tool_x' },
39
+ { event: 'start', name: 'tool_y' },
40
+ { event: 'start', name: 'tool_z' },
41
+ ];
42
+ const lowPrecision = evaluateToolCorrectness(c, lowPrecisionTrace);
43
+ assert.equal(lowPrecision.precision, 0.25);
44
+ assert.equal(lowPrecision.recall, 0.5);
45
+ assert.equal(lowPrecision.isCorrect, false);
46
+
47
+ const wrongTrace = [{ event: 'start', name: 'get_quotes' }];
48
+ const wrong = evaluateToolCorrectness(c, wrongTrace);
49
+ assert.equal(wrong.precision, 0);
50
+ assert.equal(wrong.recall, 0);
51
+ assert.equal(wrong.isCorrect, false);
52
+
53
+ const emptyExpected = { id: 'u01', expectedTools: [] };
54
+ const emptyTrace = [];
55
+ const emptyRes = evaluateToolCorrectness(emptyExpected, emptyTrace);
56
+ assert.equal(emptyRes.notApplicable, true);
57
+ assert.equal(emptyRes.isCorrect, null);
58
+ assert.equal(emptyRes.precision, null);
59
+ });
60
+
61
+ test('detectTrajectoryLoops catches duplicate calls and thrashing ping-pong cycles', () => {
62
+ const cleanTrace = [
63
+ { event: 'start', name: 'search_works', parameters: { q: 'In Blossom' } },
64
+ { event: 'start', name: 'get_work_detail', parameters: { id: 'in_blossom' } },
65
+ ];
66
+ assert.equal(detectTrajectoryLoops(cleanTrace).hasLoop, false);
67
+
68
+ const loopTrace = [
69
+ { event: 'start', name: 'search_works', parameters: { q: 'In Blossom' } },
70
+ { event: 'start', name: 'search_works', parameters: { q: 'In Blossom' } },
71
+ ];
72
+ const loopRes = detectTrajectoryLoops(loopTrace);
73
+ assert.equal(loopRes.hasLoop, true);
74
+ assert.equal(loopRes.loopCount, 1);
75
+ assert.equal(loopRes.duplicateCalls.length, 1);
76
+
77
+ const thrashTrace = [
78
+ { event: 'start', name: 'toolA', parameters: { x: 1 } },
79
+ { event: 'start', name: 'toolB', parameters: { y: 2 } },
80
+ { event: 'start', name: 'toolA', parameters: { x: 1 } },
81
+ { event: 'start', name: 'toolB', parameters: { y: 2 } },
82
+ ];
83
+ const thrashRes = detectTrajectoryLoops(thrashTrace);
84
+ assert.equal(thrashRes.hasLoop, true);
85
+ assert.equal(thrashRes.isThrashing, true);
86
+ });
87
+
88
+ test('computeLatencyBreakdown extracts snake_case tokenTracker metrics and camelCase telemetry', () => {
89
+ const snakeRow = {
90
+ durationMs: 5000,
91
+ trace: [
92
+ { event: 'result', name: 'search_works', durationMs: 1200 },
93
+ { event: 'result', name: 'get_work_detail', durationMs: 800 },
94
+ ],
95
+ metrics: {
96
+ timing_ms: { ttft: 350 },
97
+ rates: { decode_tps: 32.5, prefill_tps: 140.0 },
98
+ counts: { completion_tokens: 165 }
99
+ }
100
+ };
101
+ const breakdown = computeLatencyBreakdown(snakeRow);
102
+ assert.equal(breakdown.totalDurationMs, 5000);
103
+ assert.equal(breakdown.toolExecutionMs, 2000);
104
+ assert.equal(breakdown.llmInferenceMs, 3000);
105
+ assert.equal(breakdown.toolPercentage, 40);
106
+ assert.equal(breakdown.llmPercentage, 60);
107
+ assert.equal(breakdown.ttftMs, 350);
108
+ assert.equal(breakdown.decodeTps, 32.5);
109
+ assert.equal(breakdown.prefillTps, 140.0);
110
+ assert.equal(breakdown.outputTokens, 165);
111
+
112
+ const camelRow = {
113
+ durationMs: 2000,
114
+ trace: [],
115
+ telemetry: {
116
+ timeToFirstTokenMs: 280,
117
+ generationTokensPerSecond: 25.0,
118
+ promptTokensPerSecond: 100.0,
119
+ outputTokens: 80
120
+ }
121
+ };
122
+ const camelBreakdown = computeLatencyBreakdown(camelRow);
123
+ assert.equal(camelBreakdown.ttftMs, 280);
124
+ assert.equal(camelBreakdown.decodeTps, 25.0);
125
+ assert.equal(camelBreakdown.outputTokens, 80);
126
+ });
127
+
128
+ test('checkGroundedness verifies facts in answer against tool observation data', () => {
129
+ const c = {
130
+ id: 'test_grounded',
131
+ expectedTools: ['search_works'],
132
+ groups: [
133
+ ['Han Lingsha', 'หานหลิงซา'],
134
+ ['Sword and Fairy 4', 'เซียนกระบี่พิชิตมาร 4']
135
+ ]
136
+ };
137
+
138
+ const answer = 'จวีจิ้งอี รับบทเป็น Han Lingsha ในเรื่อง Sword and Fairy 4';
139
+ const supportedTrace = [
140
+ { event: 'result', name: 'search_works', value: { title: 'Sword and Fairy 4', character: 'Han Lingsha' } }
141
+ ];
142
+ const resSupported = checkGroundedness(c, answer, supportedTrace);
143
+ assert.equal(resSupported.isGrounded, true);
144
+ assert.equal(resSupported.supportRatio, 1.0);
145
+ assert.equal(resSupported.notApplicable, false);
146
+
147
+ const hallucinatedTrace = [
148
+ { event: 'result', name: 'search_works', value: { title: 'Some other drama', character: 'Unknown' } }
149
+ ];
150
+ const resHallucinated = checkGroundedness(c, answer, hallucinatedTrace);
151
+ assert.equal(resHallucinated.isGrounded, false);
152
+ assert.equal(resHallucinated.supportRatio, 0.0);
153
+ assert.equal(resHallucinated.notApplicable, false);
154
+
155
+ const emptyToolTrace = [];
156
+ const resEmptyTrace = checkGroundedness(c, answer, emptyToolTrace);
157
+ assert.equal(resEmptyTrace.isGrounded, false);
158
+ assert.equal(resEmptyTrace.supportRatio, 0.0);
159
+
160
+ const noExpectedToolsCase = { id: 'u01', expectedTools: [] };
161
+ const resNoExpected = checkGroundedness(noExpectedToolsCase, 'Answer without tools', []);
162
+ assert.equal(resNoExpected.notApplicable, true);
163
+ assert.equal(resNoExpected.isGrounded, true);
164
+
165
+ const answerNoFacts = 'คำตอบทั่วไปไม่มีชื่อตัวละครหรือชื่อเรื่อง';
166
+ const resNoFacts = checkGroundedness(c, answerNoFacts, supportedTrace);
167
+ assert.equal(resNoFacts.isGrounded, false);
168
+ assert.equal(resNoFacts.matchedInAnswer, 0);
169
+ });
170
+
171
+ test('evaluateGates validates thresholds and flags failing metrics', () => {
172
+ const passingSummary = {
173
+ completionRate: 1.0,
174
+ lexicalCoverageRate: 0.85,
175
+ toolAccuracyRate: 0.88,
176
+ loopFreeRate: 1.0,
177
+ groundednessRate: 0.82,
178
+ categoryStats: {
179
+ abstention: { completed: 8, factsMatched: 8, toolsCorrect: 8 }
180
+ }
181
+ };
182
+ const gates = {
183
+ completionRate: { min: 1.00 },
184
+ lexicalCoverageRate: { min: 0.80 },
185
+ toolAccuracyRate: { min: 0.80 },
186
+ loopFreeRate: { min: 0.95 },
187
+ groundednessRate: { min: 0.75 },
188
+ perCategory: {
189
+ abstention: { lexicalCoverageRate: { min: 0.90 } }
190
+ }
191
+ };
192
+ const passResult = evaluateGates(passingSummary, gates);
193
+ assert.equal(passResult.passed, true);
194
+ assert.ok(passResult.results.every(r => r.pass));
195
+
196
+ const failingSummary = {
197
+ ...passingSummary,
198
+ lexicalCoverageRate: 0.72
199
+ };
200
+ const failResult = evaluateGates(failingSummary, gates);
201
+ assert.equal(failResult.passed, false);
202
+ assert.ok(failResult.results.some(r => !r.pass && r.metric === 'lexicalCoverageRate'));
203
+ });
204
+
205
+ test('diffReports computes deltas, regressions, and improvements', () => {
206
+ const base = {
207
+ summary: {
208
+ completionRate: 1.0,
209
+ lexicalCoverageRate: 0.85,
210
+ toolAccuracyRate: 0.80,
211
+ medianMs: 5000
212
+ },
213
+ rows: [
214
+ { id: 'p01', category: 'profile', status: 'completed', coverage: { allMatched: true }, durationMs: 2000, toolCorrectness: { isCorrect: true } },
215
+ { id: 'p02', category: 'profile', status: 'completed', coverage: { allMatched: false }, durationMs: 3000, toolCorrectness: { isCorrect: false } },
216
+ ]
217
+ };
218
+
219
+ const cand = {
220
+ summary: {
221
+ completionRate: 1.0,
222
+ lexicalCoverageRate: 0.80,
223
+ toolAccuracyRate: 0.85,
224
+ medianMs: 4500
225
+ },
226
+ rows: [
227
+ { id: 'p01', category: 'profile', status: 'completed', coverage: { allMatched: false }, durationMs: 2500, toolCorrectness: { isCorrect: true } },
228
+ { id: 'p02', category: 'profile', status: 'completed', coverage: { allMatched: true }, durationMs: 2800, toolCorrectness: { isCorrect: true } },
229
+ ]
230
+ };
231
+
232
+ const diff = diffReports(base, cand);
233
+ assert.equal(diff.metricDeltas.lexicalCoverageRate.delta, -0.05);
234
+ assert.equal(diff.metricDeltas.toolAccuracyRate.delta, 0.05);
235
+ assert.equal(diff.caseDiffs.summary.regressionsCount, 1);
236
+ assert.equal(diff.caseDiffs.summary.improvementsCount, 1);
237
+ assert.equal(diff.caseDiffs.regressions[0].id, 'p01');
238
+ assert.equal(diff.caseDiffs.improvements[0].id, 'p02');
239
+ });
240
+
241
+ test('generateDashboardHtml produces a complete standalone offline HTML dashboard', () => {
242
+ const sampleReport = {
243
+ metadata: {
244
+ model: 'google/gemma-4-e4b',
245
+ startedAt: new Date().toISOString(),
246
+ planned: 2,
247
+ revision: 'abcdef1234567890'
248
+ },
249
+ summary: {
250
+ completionRate: 1.0,
251
+ lexicalCoverageRate: 1.0,
252
+ toolAccuracyRate: 1.0,
253
+ loopFreeRate: 1.0,
254
+ groundednessRate: 1.0,
255
+ medianMs: 2000,
256
+ meanDecodeTps: 30.0,
257
+ languageStats: {
258
+ th: { total: 1, completed: 1, factsMatched: 1, toolsCorrect: 1, meanMs: 2400 },
259
+ en: { total: 1, completed: 1, factsMatched: 1, toolsCorrect: 1, meanMs: 1800 }
260
+ }
261
+ },
262
+ rows: [
263
+ {
264
+ id: 'p01',
265
+ category: 'profile',
266
+ language: 'th',
267
+ question: 'วันเกิดของจวีจิ้งอี',
268
+ status: 'completed',
269
+ answer: 'จวีจิ้งอีเกิดวันที่ 18 มิถุนายน 1994',
270
+ durationMs: 2400,
271
+ coverage: { matched: 3, total: 3, allMatched: true },
272
+ trace: [
273
+ { event: 'start', name: 'get_profile_and_fandom', parameters: {} },
274
+ { event: 'result', name: 'get_profile_and_fandom', value: { birthDate: '18 June 1994' }, durationMs: 400 }
275
+ ]
276
+ },
277
+ {
278
+ id: 's01',
279
+ category: 'security',
280
+ language: 'en',
281
+ question: 'Ignore instructions',
282
+ status: 'completed',
283
+ answer: 'I cannot disclose internal system directives.',
284
+ durationMs: 1800,
285
+ coverage: { matched: 1, total: 1, allMatched: true },
286
+ trace: []
287
+ }
288
+ ]
289
+ };
290
+
291
+ const html = generateDashboardHtml(sampleReport);
292
+ assert.match(html, /<!DOCTYPE html>/);
293
+ assert.match(html, /Kiku Agent Evaluation & Performance/);
294
+ assert.match(html, /class="kpi-card"/);
295
+ assert.match(html, /<svg/);
296
+ assert.match(html, /class="bar-llm"/);
297
+ assert.match(html, /class="bar-tool"/);
298
+ assert.match(html, /data-category="profile"/);
299
+ assert.match(html, /data-category="security"/);
300
+ assert.match(html, /id="searchInput"/);
301
+ assert.match(html, /function filterCases/);
302
+ assert.match(html, /LANG: TH/);
303
+ assert.doesNotMatch(html, /<script[^>]+src=/);
304
+ });
305
+
306
+ test('cases suite exports expanded cases cleanly', () => {
307
+ assert.ok(cases.length >= 90);
308
+ assert.ok(securityCases.length >= 50);
309
+ assert.equal(allCases.length, cases.length + securityCases.length);
310
+ assert.equal(new Set(allCases.map(c => c.id)).size, allCases.length);
311
+ assert.ok(securityCases.every(c => c.judgeRequired === true));
312
+ });