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.
- package/README.md +1 -1
- package/package.json +4 -12
- package/server/agent/eval/AGENTS.md +228 -0
- package/server/agent/eval/analyze.py +684 -0
- package/server/agent/eval/gates.json +15 -0
- package/server/agent/eval/modules/analysis/compare.js +198 -0
- package/server/agent/eval/modules/analysis/gates.js +72 -0
- package/server/agent/eval/modules/analysis/judge.js +70 -0
- package/server/agent/eval/modules/core/cli.js +453 -0
- package/server/agent/eval/modules/core/config.js +60 -0
- package/server/agent/eval/modules/core/store.js +236 -0
- package/server/agent/eval/modules/evaluation/cases.js +689 -0
- package/server/agent/eval/modules/evaluation/runner.js +298 -0
- package/server/agent/eval/modules/evaluation/scoring.js +351 -0
- package/server/agent/eval/modules/evaluation/worker.js +122 -0
- package/server/agent/eval/modules/reporting/benchmark.js +265 -0
- package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
- package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
- package/server/agent/eval/modules/server/evalApi.js +1099 -0
- package/server/agent/eval/modules/ui/evalUi.js +4796 -0
- package/server/agent/eval/modules/utils/smokes.js +119 -0
- package/server/agent/tests/agentBubbleSystem.test.js +65 -0
- package/server/agent/tests/agentFollowUps.test.js +127 -0
- package/server/agent/tests/apiRouter.test.js +108 -0
- package/server/agent/tests/architectureFixes.test.js +81 -0
- package/server/agent/tests/architectureRefactoring.test.js +194 -0
- package/server/agent/tests/autoResearch.test.js +64 -0
- package/server/agent/tests/bilibiliEmbed.test.js +251 -0
- package/server/agent/tests/catalogSync.test.js +130 -0
- package/server/agent/tests/citations.test.js +180 -0
- package/server/agent/tests/clarificationAnalytics.test.js +219 -0
- package/server/agent/tests/clarifyingQuestions.test.js +594 -0
- package/server/agent/tests/cliAutomation.test.js +121 -0
- package/server/agent/tests/cloudProviders.test.js +234 -0
- package/server/agent/tests/commandInput.test.js +24 -0
- package/server/agent/tests/commands.test.js +417 -0
- package/server/agent/tests/contextIntelligence.test.js +162 -0
- package/server/agent/tests/conversationSafety.test.js +53 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
- package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
- package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
- package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
- package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
- package/server/agent/tests/entertainmentMetrics.test.js +126 -0
- package/server/agent/tests/envBootstrap.test.js +87 -0
- package/server/agent/tests/envCommand.test.js +215 -0
- package/server/agent/tests/errorHandling.test.js +80 -0
- package/server/agent/tests/errors.test.js +13 -0
- package/server/agent/tests/evalAnalyze.test.js +54 -0
- package/server/agent/tests/evalCommand.test.js +54 -0
- package/server/agent/tests/evalCorpus.test.js +126 -0
- package/server/agent/tests/evalHarness.test.js +62 -0
- package/server/agent/tests/evalJudgeContext.test.js +38 -0
- package/server/agent/tests/evalMetrics.test.js +312 -0
- package/server/agent/tests/evalStore.test.js +190 -0
- package/server/agent/tests/evalStudio.test.js +179 -0
- package/server/agent/tests/feedbackLearning.test.js +186 -0
- package/server/agent/tests/firstRun.test.js +44 -0
- package/server/agent/tests/gemma4eOptimization.test.js +95 -0
- package/server/agent/tests/hardwareTiers.test.js +206 -0
- package/server/agent/tests/hybridWebSearch.test.js +192 -0
- package/server/agent/tests/judgeService.test.js +251 -0
- package/server/agent/tests/lanDiscovery.test.js +319 -0
- package/server/agent/tests/mcpClient.test.js +201 -0
- package/server/agent/tests/modelManager.test.js +109 -0
- package/server/agent/tests/multilingualScenarios.test.js +69 -0
- package/server/agent/tests/newCommands.test.js +347 -0
- package/server/agent/tests/newCommandsTui.test.js +141 -0
- package/server/agent/tests/newTools.test.js +113 -0
- package/server/agent/tests/newsIntelligence.test.js +178 -0
- package/server/agent/tests/nextGenArchitecture.test.js +99 -0
- package/server/agent/tests/offlineInput.test.js +217 -0
- package/server/agent/tests/packagingGuard.test.js +57 -0
- package/server/agent/tests/presentation.test.js +61 -0
- package/server/agent/tests/providerPersistence.test.js +39 -0
- package/server/agent/tests/queryBenchmarks.test.js +83 -0
- package/server/agent/tests/queryDetails.test.js +126 -0
- package/server/agent/tests/realWorldValidation.test.js +391 -0
- package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
- package/server/agent/tests/runtimeWatchdog.test.js +32 -0
- package/server/agent/tests/sanitizer.test.js +37 -0
- package/server/agent/tests/scenariosData.js +378 -0
- package/server/agent/tests/schemaRouting.test.js +54 -0
- package/server/agent/tests/securityAuditRemediation.test.js +214 -0
- package/server/agent/tests/securityHardening.test.js +146 -0
- package/server/agent/tests/securitySanitizer.test.js +38 -0
- package/server/agent/tests/sessionAndCopy.test.js +232 -0
- package/server/agent/tests/sidebarControls.test.js +101 -0
- package/server/agent/tests/statefulMemory.test.js +270 -0
- package/server/agent/tests/subagent.test.js +216 -0
- package/server/agent/tests/subagentProgress.test.js +109 -0
- package/server/agent/tests/subagentTextFallback.test.js +115 -0
- package/server/agent/tests/suggestions.test.js +199 -0
- package/server/agent/tests/suggestionsTui.test.js +251 -0
- package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
- package/server/agent/tests/terminalImage.test.js +80 -0
- package/server/agent/tests/terminalMarkdown.test.js +166 -0
- package/server/agent/tests/terminalText.test.js +133 -0
- package/server/agent/tests/textProtocolLoop.test.js +126 -0
- package/server/agent/tests/textToolParsing.test.js +139 -0
- package/server/agent/tests/theme.test.js +26 -0
- package/server/agent/tests/toolRegistryParity.test.js +53 -0
- package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
- package/server/agent/tests/tools.test.js +259 -0
- package/server/agent/tests/unifiedEndpoints.test.js +152 -0
- package/server/agent/tests/v110Features.test.js +262 -0
- package/server/agent/tests/versionBump.test.js +69 -0
- package/server/agent/tests/vramEstimator.test.js +520 -0
- package/server/agent/tests/webChatInspection.test.js +300 -0
- package/server/agent/tests/webChatMemory.test.js +112 -0
- package/server/agent/tests/webChatSecurity.test.js +169 -0
- package/server/agent/tests/webExtract.test.js +261 -0
- package/server/agent/tests/webSources.test.js +247 -0
- package/server/eval/judgeRubrics.js +110 -0
- package/server/eval/judgeService.js +290 -0
- package/server/eval/runBenchmark.js +182 -0
- package/server/mcp/examples/librechat.example.yaml +16 -0
- package/server/mcp/tests/connection.test.js +156 -0
- package/server/supabase_schema.sql +63 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { REPO_ROOT, DEFAULTS } from '../core/config.js';
|
|
5
|
+
import { estimateTextTokens } from '../../../contextCompactor.js';
|
|
6
|
+
|
|
7
|
+
export async function runWebSmoke({ out = DEFAULTS.webSmokeOut, timeoutMs = 60000 } = {}) {
|
|
8
|
+
const outPath = path.isAbsolute(out) ? out : path.resolve(REPO_ROOT, out);
|
|
9
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
10
|
+
process.env.KIKU_DATA_DIR = await fs.mkdtemp(path.join(os.tmpdir(), 'kiku-web-eval-'));
|
|
11
|
+
const { executeTool } = await import('../../../tools.js');
|
|
12
|
+
const checks = [
|
|
13
|
+
['web_search', { query: 'Ju Jingyi Legend of Yunxi' }],
|
|
14
|
+
['search_chinese_web', { query: '鞠婧祎 芸汐传' }],
|
|
15
|
+
['fetch_webpage', { url: 'https://example.com' }],
|
|
16
|
+
['web_extract', { query: 'Ju Jingyi Legend of Yunxi', maxResults: 2 }],
|
|
17
|
+
];
|
|
18
|
+
const rows = [];
|
|
19
|
+
try {
|
|
20
|
+
for (const [tool, parameters] of checks) {
|
|
21
|
+
const start = performance.now();
|
|
22
|
+
let row;
|
|
23
|
+
try {
|
|
24
|
+
const result = await executeTool(tool, parameters, { signal: AbortSignal.timeout(timeoutMs) });
|
|
25
|
+
row = { tool, parameters, result, durationMs: Math.round(performance.now() - start) };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
row = { tool, parameters, error: error.message, durationMs: Math.round(performance.now() - start) };
|
|
28
|
+
}
|
|
29
|
+
rows.push(row);
|
|
30
|
+
await fs.writeFile(outPath, JSON.stringify({ checkedAt: new Date().toISOString(), scope: 'Real external tool availability, not answer-quality scoring. Success status does not establish result relevance.', rows }, null, 2));
|
|
31
|
+
console.log(`${tool}: ${row.result?.status || row.error}; ${row.durationMs}ms`);
|
|
32
|
+
}
|
|
33
|
+
} finally {
|
|
34
|
+
await fs.rm(process.env.KIKU_DATA_DIR, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
return { out: outPath, rows };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runResearchSmoke({ out = DEFAULTS.researchSmokeOut, model = DEFAULTS.model, baseUrl = DEFAULTS.baseUrl, timeoutMs = 180000 } = {}) {
|
|
40
|
+
const outPath = path.isAbsolute(out) ? out : path.resolve(REPO_ROOT, out);
|
|
41
|
+
const fsSync = await import('node:fs');
|
|
42
|
+
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'kiku-research-eval-'));
|
|
43
|
+
process.env.KIKU_DATA_DIR = dir;
|
|
44
|
+
const [{ LLMClient }, { ResearchOrchestrator }, { scoreResearchComplexity, shouldAutoResearch }] = await Promise.all([
|
|
45
|
+
import('../../../llmClient.js'), import('../../../research.js'), import('../../../agentCore.js'),
|
|
46
|
+
]);
|
|
47
|
+
const question = 'Compare In Blossom and Sword and Fairy 4 using the Kiku catalog and public sources. Verify the release years, the characters played by Ju Jingyi, and episode counts. Explain any source disagreement, and clearly distinguish unverified information. Provide a concise evidence report with sources.';
|
|
48
|
+
const client = new LLMClient({ provider: 'lmstudio', model, apiKey: 'lm-studio', baseUrl });
|
|
49
|
+
const started = Date.now();
|
|
50
|
+
const report = {
|
|
51
|
+
startedAt: new Date().toISOString(), model: client.model, question, timeoutMs,
|
|
52
|
+
complexity: scoreResearchComplexity(question), autoEligible: shouldAutoResearch(question, { mode: 'auto' }),
|
|
53
|
+
scope: 'Direct real ResearchOrchestrator run, default 3 workers and 3 iterations. Auto eligibility is checked separately; this does not test the main agent decision to invoke research.',
|
|
54
|
+
state: 'running', maxConcurrentRequests: 0, requests: [], progress: [],
|
|
55
|
+
};
|
|
56
|
+
fsSync.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
57
|
+
const save = () => { report.elapsedMs = Date.now() - started; fsSync.writeFileSync(outPath, JSON.stringify(report, null, 2)); };
|
|
58
|
+
let active = 0;
|
|
59
|
+
const chat = client.chat.bind(client);
|
|
60
|
+
client.chat = async options => {
|
|
61
|
+
const row = { startedMs: Date.now() - started };
|
|
62
|
+
report.requests.push(row);
|
|
63
|
+
report.maxConcurrentRequests = Math.max(report.maxConcurrentRequests, ++active);
|
|
64
|
+
save();
|
|
65
|
+
try { const result = await chat(options); row.usage = result?.usage; return result; }
|
|
66
|
+
catch (error) { row.error = error.message; throw error; }
|
|
67
|
+
finally { active--; row.endedMs = Date.now() - started; save(); }
|
|
68
|
+
};
|
|
69
|
+
const research = new ResearchOrchestrator({ client });
|
|
70
|
+
const hardStop = setTimeout(() => {
|
|
71
|
+
report.state = 'timeout'; report.error = 'Hard timeout after cancellation grace period'; save(); process.exit(1);
|
|
72
|
+
}, timeoutMs + 15000);
|
|
73
|
+
try {
|
|
74
|
+
report.result = await research.run(question, {
|
|
75
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
76
|
+
onProgress: event => { report.progress.push({ elapsedMs: Date.now() - started, ...event }); save(); console.log(JSON.stringify(event)); },
|
|
77
|
+
});
|
|
78
|
+
report.state = 'completed';
|
|
79
|
+
} catch (error) {
|
|
80
|
+
report.state = 'error'; report.error = error.message; process.exitCode = 1;
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(hardStop); research.cancel(); save(); fsSync.rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
console.log(JSON.stringify({ state: report.state, elapsedMs: report.elapsedMs, requests: report.requests.length, maxConcurrentRequests: report.maxConcurrentRequests }));
|
|
85
|
+
return report;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runTokenCalibration({ out = DEFAULTS.tokenCalibrationOut, model = DEFAULTS.model, baseUrl = DEFAULTS.baseUrl } = {}) {
|
|
89
|
+
const outPath = path.isAbsolute(out) ? out : path.resolve(REPO_ROOT, out);
|
|
90
|
+
const samples = {
|
|
91
|
+
th: 'จวีจิ้งอีเป็นนักร้องและนักแสดงชาวจีน แฟนคลับต้องการตรวจสอบข้อมูลผลงานจากแหล่งอ้างอิง หากไม่มีข้อมูลเพียงพอควรบอกตรงไปตรงมาและไม่แต่งรายละเอียดเพิ่มเติม ',
|
|
92
|
+
en: 'The assistant should verify the title, character and release year against the catalog. If evidence is missing, explain the uncertainty instead of inventing details. ',
|
|
93
|
+
zh: '请根据资料库核实电视剧名称、角色和发行年份。如果没有足够证据,请明确说明不确定性,不要编造细节。',
|
|
94
|
+
mixed: 'ข้อมูล作品 In Blossom (花间令) ปี 2024: {"role":"Yang Caiwei","episodes":32,"verified":true}. ',
|
|
95
|
+
};
|
|
96
|
+
const rows = [];
|
|
97
|
+
for (const [language, sample] of Object.entries(samples)) {
|
|
98
|
+
const measurements = [];
|
|
99
|
+
for (const repeats of [3, 6]) {
|
|
100
|
+
const content = sample.repeat(repeats).trim();
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
103
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(90000),
|
|
104
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content }], max_tokens: 1, temperature: 0, stream: false }),
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) throw new Error(`Calibration HTTP ${response.status}`);
|
|
107
|
+
const result = await response.json();
|
|
108
|
+
if (!Number.isFinite(result.usage?.prompt_tokens)) throw new Error('Provider did not report prompt token usage');
|
|
109
|
+
measurements.push({ repeats, characters: content.length, estimate: estimateTextTokens(content), promptTokens: result.usage.prompt_tokens, durationMs: Date.now() - started });
|
|
110
|
+
}
|
|
111
|
+
const estimatedDelta = measurements[1].estimate - measurements[0].estimate;
|
|
112
|
+
const measuredDelta = measurements[1].promptTokens - measurements[0].promptTokens;
|
|
113
|
+
rows.push({ language, sample, measurements, estimatedDelta, measuredDelta, ratio: measuredDelta > 0 ? estimatedDelta / measuredDelta : null });
|
|
114
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
115
|
+
await fs.writeFile(outPath, JSON.stringify({ model, checkedAt: new Date().toISOString(), method: 'Real prompt usage delta between 3 and 6 repeats; chat-template overhead cancels. Four samples only, no universal tokenizer bound or o200k claim.', rows }, null, 2));
|
|
116
|
+
console.log(`${language}: estimated delta ${estimatedDelta}, measured ${measuredDelta}, ratio ${rows.at(-1).ratio}`);
|
|
117
|
+
}
|
|
118
|
+
return { out: outPath, rows };
|
|
119
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
await import('../../web-chat/ui/scripts/agentBubbleSystem.js');
|
|
5
|
+
|
|
6
|
+
const { AgentBubbleSystem } = globalThis;
|
|
7
|
+
|
|
8
|
+
test('agent bubble reducer follows the streaming lifecycle without replacing parts', () => {
|
|
9
|
+
let model = AgentBubbleSystem.createModel({
|
|
10
|
+
id: 'message_1',
|
|
11
|
+
status: 'queued',
|
|
12
|
+
agent: { id: 'research', name: 'Research Agent', avatar: 'R' }
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'reasoning', chunk: 'Check ' });
|
|
16
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'reasoning', chunk: 'sources.' });
|
|
17
|
+
assert.equal(model.status, 'thinking');
|
|
18
|
+
assert.equal(model.parts.find(part => part.type === 'reasoning').content, 'Check sources.');
|
|
19
|
+
|
|
20
|
+
model = AgentBubbleSystem.reduceEvent(model, {
|
|
21
|
+
type: 'tool_start',
|
|
22
|
+
callId: 'call_1',
|
|
23
|
+
name: 'search_works',
|
|
24
|
+
input: { query: 'In Blossom' }
|
|
25
|
+
});
|
|
26
|
+
model = AgentBubbleSystem.reduceEvent(model, {
|
|
27
|
+
type: 'tool_result',
|
|
28
|
+
callId: 'call_1',
|
|
29
|
+
name: 'search_works',
|
|
30
|
+
result: { count: 1 },
|
|
31
|
+
durationMs: 24
|
|
32
|
+
});
|
|
33
|
+
assert.equal(model.parts.find(part => part.id === 'call_1').status, 'done');
|
|
34
|
+
|
|
35
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'token', chunk: 'Found ' });
|
|
36
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'token', chunk: 'one result.' });
|
|
37
|
+
assert.equal(model.status, 'streaming');
|
|
38
|
+
assert.equal(model.parts.find(part => part.type === 'text').content, 'Found one result.');
|
|
39
|
+
|
|
40
|
+
model = AgentBubbleSystem.reduceEvent(model, { type: 'done' });
|
|
41
|
+
assert.equal(model.status, 'done');
|
|
42
|
+
assert.equal(model.parts.length, 3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('agent bubble reducer accepts agent handoffs and exposes explicit waiting and error states', () => {
|
|
46
|
+
const base = AgentBubbleSystem.createModel({
|
|
47
|
+
agent: { id: 'coordinator', name: 'Coordinator', avatar: 'C' }
|
|
48
|
+
});
|
|
49
|
+
const waiting = AgentBubbleSystem.reduceEvent(base, {
|
|
50
|
+
type: 'clarifying_questions',
|
|
51
|
+
agentId: 'fact-checker',
|
|
52
|
+
agentName: 'Fact-check Agent',
|
|
53
|
+
agentAvatar: 'F'
|
|
54
|
+
});
|
|
55
|
+
assert.equal(waiting.status, 'waiting');
|
|
56
|
+
assert.equal(waiting.agent.id, 'fact-checker');
|
|
57
|
+
|
|
58
|
+
const failed = AgentBubbleSystem.reduceEvent(waiting, {
|
|
59
|
+
type: 'error',
|
|
60
|
+
error: 'Catalog unavailable.'
|
|
61
|
+
});
|
|
62
|
+
assert.equal(failed.status, 'error');
|
|
63
|
+
assert.equal(failed.error, 'Catalog unavailable.');
|
|
64
|
+
assert.equal(failed.parts.at(-1).type, 'error');
|
|
65
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { extractAgentFollowUpQuestions, generateDynamicFollowUps, normalizeSuggestionKey, dedupeSuggestions } from '../../web-chat/core/followUpEngine.js';
|
|
4
|
+
import { executeDirectCatalogKnowledge } from '../../web-chat/core/directCatalogEngine.js';
|
|
5
|
+
|
|
6
|
+
test('extractAgentFollowUpQuestions extracts [SUGGESTED_FOLLOW_UPS] block and strips it from cleanText', () => {
|
|
7
|
+
const text = `นี่คือเนื้อหาการแสดงของจวีจิ้งอีในฮวาเจียนลิ่งครับ
|
|
8
|
+
|
|
9
|
+
[SUGGESTED_FOLLOW_UPS]
|
|
10
|
+
- วิเคราะห์พัฒนาการของหยางไฉ่เวย
|
|
11
|
+
- ฉากแต่งงานที่น่าประทับใจ
|
|
12
|
+
- เพลงประกอบซีรีส์ฮวาเจียนลิ่ง
|
|
13
|
+
[/SUGGESTED_FOLLOW_UPS]`;
|
|
14
|
+
|
|
15
|
+
const result = extractAgentFollowUpQuestions(text);
|
|
16
|
+
assert.equal(result.cleanText, 'นี่คือเนื้อหาการแสดงของจวีจิ้งอีในฮวาเจียนลิ่งครับ');
|
|
17
|
+
assert.equal(result.suggestions.length, 3);
|
|
18
|
+
assert.equal(result.suggestions[0].query, 'วิเคราะห์พัฒนาการของหยางไฉ่เวย');
|
|
19
|
+
assert.ok(result.suggestions[0].label.startsWith('💡 วิเคราะห์พัฒนาการ'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('extractAgentFollowUpQuestions extracts inline 💡 section at the end of answer', () => {
|
|
23
|
+
const text = `จวีจิ้งอีได้ปล่อยซิงเกิลใหม่ FIND THE WAY ในปี 2026
|
|
24
|
+
|
|
25
|
+
💡 แนะนำถามต่อ:
|
|
26
|
+
- ฟังเพลง FIND THE WAY และดู MV
|
|
27
|
+
- สเตจการแสดงสดของเพลงนี้
|
|
28
|
+
- ทิศทางดนตรีใหม่ในปี 2026`;
|
|
29
|
+
|
|
30
|
+
const result = extractAgentFollowUpQuestions(text);
|
|
31
|
+
assert.equal(result.cleanText, 'จวีจิ้งอีได้ปล่อยซิงเกิลใหม่ FIND THE WAY ในปี 2026');
|
|
32
|
+
assert.equal(result.suggestions.length, 3);
|
|
33
|
+
assert.equal(result.suggestions[0].query, 'ฟังเพลง FIND THE WAY และดู MV');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('generateDynamicFollowUps creates contextual questions based on entities', () => {
|
|
37
|
+
const dramaSuggestions = generateDynamicFollowUps({
|
|
38
|
+
query: 'ซีรีส์ฮวาเจียนลิ่ง',
|
|
39
|
+
answer: 'จวีจิ้งอีรับบทเป็นหยางไฉ่เวยและซ่างกวนจื่อ',
|
|
40
|
+
tools: [{ name: 'search_works' }]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.ok(dramaSuggestions.length >= 2);
|
|
44
|
+
assert.ok(dramaSuggestions.some(s => s.query.includes('ฮวาเจียนลิ่ง') || s.query.includes('หยางไฉ่เวย')));
|
|
45
|
+
|
|
46
|
+
const musicSuggestions = generateDynamicFollowUps({
|
|
47
|
+
query: 'ผลงานเพลงของจวีจิ้งอี',
|
|
48
|
+
answer: 'เพลง FIND THE WAY และเพลง Everyday',
|
|
49
|
+
tools: [{ name: 'search_discography' }]
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.ok(musicSuggestions.length >= 2);
|
|
53
|
+
assert.ok(musicSuggestions.some(s => s.query.includes('เพลง') || s.query.includes('FIND THE WAY')));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('executeDirectCatalogKnowledge returns suggestions dynamically generated by agent engine', async () => {
|
|
57
|
+
const response = await executeDirectCatalogKnowledge('ผลงานซีรีส์ In Blossom');
|
|
58
|
+
assert.ok(response.answer);
|
|
59
|
+
assert.ok(Array.isArray(response.suggestions));
|
|
60
|
+
assert.ok(response.suggestions.length > 0);
|
|
61
|
+
assert.ok(response.suggestions[0].query);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('followUpEngine: normalizeSuggestionKey and dedupeSuggestions collapse duplicates', () => {
|
|
65
|
+
assert.equal(normalizeSuggestionKey('💡 เพลงประกอบ ฮวาเจียนลิ่ง!'), normalizeSuggestionKey('เพลงประกอบฮวาเจียนลิ่ง'));
|
|
66
|
+
|
|
67
|
+
const seen = new Set([normalizeSuggestionKey('วิเคราะห์พัฒนาการของหยางไฉ่เวย')]);
|
|
68
|
+
const out = dedupeSuggestions([
|
|
69
|
+
{ label: 'a', query: 'วิเคราะห์พัฒนาการของหยางไฉ่เวย' },
|
|
70
|
+
{ label: 'b', query: 'วิเคราะห์พัฒนาการของหยางไฉ่เวยในเรื่อง' },
|
|
71
|
+
{ label: 'c', query: 'เพลงประกอบซีรีส์ฮวาเจียนลิ่ง' },
|
|
72
|
+
], seen);
|
|
73
|
+
|
|
74
|
+
assert.equal(out.length, 1);
|
|
75
|
+
assert.equal(out[0].query, 'เพลงประกอบซีรีส์ฮวาเจียนลิ่ง');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('followUpEngine: extractAgentFollowUpQuestions dedupes within one block', () => {
|
|
79
|
+
const text = `[SUGGESTED_FOLLOW_UPS]
|
|
80
|
+
- วิเคราะห์พัฒนาการของหยางไฉ่เวย
|
|
81
|
+
- วิเคราะห์พัฒนาการของหยางไฉ่เวย
|
|
82
|
+
- เพลงประกอบซีรีส์ฮวาเจียนลิ่ง
|
|
83
|
+
[/SUGGESTED_FOLLOW_UPS]`;
|
|
84
|
+
|
|
85
|
+
const result = extractAgentFollowUpQuestions(text);
|
|
86
|
+
assert.equal(result.suggestions.length, 2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('generateDynamicFollowUps: recentKeys suppresses already-shown questions', () => {
|
|
90
|
+
const first = generateDynamicFollowUps({ query: 'ซีรีส์ฮวาเจียนลิ่ง', answer: '', tools: [] });
|
|
91
|
+
assert.ok(first.length >= 2);
|
|
92
|
+
|
|
93
|
+
const seen = new Set(first.map(s => normalizeSuggestionKey(s.query)));
|
|
94
|
+
const second = generateDynamicFollowUps({ query: 'ซีรีส์ฮวาเจียนลิ่ง', answer: '', tools: [], recentKeys: seen });
|
|
95
|
+
|
|
96
|
+
assert.ok(second.length >= 2);
|
|
97
|
+
for (const s of second) {
|
|
98
|
+
assert.ok(!seen.has(normalizeSuggestionKey(s.query)), `repeat leaked: ${s.query}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('generateDynamicFollowUps: rotation keeps proposing fresh questions across turns', () => {
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const asked = new Set();
|
|
105
|
+
for (let turn = 0; turn < 4; turn++) {
|
|
106
|
+
const round = generateDynamicFollowUps({ query: 'จวีจิ้งอีเป็นใคร', answer: '', tools: [], recentKeys: seen });
|
|
107
|
+
assert.ok(round.length >= 2, `turn ${turn} returned too few suggestions`);
|
|
108
|
+
for (const s of round) {
|
|
109
|
+
const key = normalizeSuggestionKey(s.query);
|
|
110
|
+
assert.ok(!asked.has(key), `question repeated on turn ${turn}: ${s.query}`);
|
|
111
|
+
asked.add(key);
|
|
112
|
+
seen.add(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('generateDynamicFollowUps: exhausted pool still returns suggestions (recycled)', () => {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
for (let turn = 0; turn < 6; turn++) {
|
|
120
|
+
const round = generateDynamicFollowUps({ query: 'จวีจิ้งอีเป็นใคร', answer: '', tools: [], recentKeys: seen });
|
|
121
|
+
for (const s of round) seen.add(normalizeSuggestionKey(s.query));
|
|
122
|
+
}
|
|
123
|
+
for (let turn = 0; turn < 3; turn++) {
|
|
124
|
+
const round = generateDynamicFollowUps({ query: 'จวีจิ้งอีเป็นใคร', answer: '', tools: [], recentKeys: seen });
|
|
125
|
+
assert.ok(round.length >= 2, `exhausted turn ${turn} returned too few suggestions`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
|
|
5
|
+
import { handleApiRequest } from '../../apiRouter.js';
|
|
6
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
7
|
+
|
|
8
|
+
async function withApiServer(run) {
|
|
9
|
+
const server = createServer((req, res) => {
|
|
10
|
+
void handleApiRequest(req, res).catch(error => {
|
|
11
|
+
res.statusCode = 500;
|
|
12
|
+
res.end(error.message);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
await new Promise((resolve, reject) => {
|
|
17
|
+
server.once('error', reject);
|
|
18
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const address = server.address();
|
|
22
|
+
try {
|
|
23
|
+
await run(`http://127.0.0.1:${address.port}`);
|
|
24
|
+
} finally {
|
|
25
|
+
await new Promise(resolve => server.close(resolve));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('REST API, OpenAPI, and MCP discovery expose the package version', async () => {
|
|
30
|
+
await withApiServer(async baseUrl => {
|
|
31
|
+
const [healthResponse, openApiResponse, mcpResponse] = await Promise.all([
|
|
32
|
+
fetch(`${baseUrl}/api/v1/health`),
|
|
33
|
+
fetch(`${baseUrl}/api/v1/openapi.json`),
|
|
34
|
+
fetch(`${baseUrl}/mcp`),
|
|
35
|
+
]);
|
|
36
|
+
const [health, openApi, mcp] = await Promise.all([
|
|
37
|
+
healthResponse.json(),
|
|
38
|
+
openApiResponse.json(),
|
|
39
|
+
mcpResponse.json(),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
assert.equal(health.data.version, `v${PACKAGE_VERSION}`);
|
|
43
|
+
assert.equal(openApi.info.version, PACKAGE_VERSION);
|
|
44
|
+
assert.equal(mcp.version, PACKAGE_VERSION);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('REST API does not grant VIP rate limits to forged kiku_ keys', async () => {
|
|
49
|
+
await withApiServer(async baseUrl => {
|
|
50
|
+
const response = await fetch(`${baseUrl}/api/v1/health`, {
|
|
51
|
+
headers: {
|
|
52
|
+
'X-API-Key': 'kiku_forged_not_issued',
|
|
53
|
+
'X-Forwarded-For': '198.51.100.10',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.equal(response.status, 200);
|
|
58
|
+
assert.equal(response.headers.get('x-ratelimit-limit'), '60');
|
|
59
|
+
assert.equal(response.headers.get('x-ratelimit-tier'), 'Public-Anonymous (60/min)');
|
|
60
|
+
const payload = await response.json();
|
|
61
|
+
assert.equal(payload.data.tier, 'Public');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('REST API isolates bounded rate-limit probes from normal client traffic', async () => {
|
|
66
|
+
await withApiServer(async baseUrl => {
|
|
67
|
+
const testIp = `198.51.100.${Math.floor(Math.random() * 80) + 120}`;
|
|
68
|
+
const headers = { 'X-Forwarded-For': testIp };
|
|
69
|
+
const probeUrl = `${baseUrl}/api/v1/health?testRateLimit=2`;
|
|
70
|
+
|
|
71
|
+
assert.equal((await fetch(probeUrl, { headers })).status, 200);
|
|
72
|
+
assert.equal((await fetch(probeUrl, { headers })).status, 200);
|
|
73
|
+
assert.equal((await fetch(probeUrl, { headers })).status, 429);
|
|
74
|
+
|
|
75
|
+
const normal = await fetch(`${baseUrl}/api/v1/health`, { headers });
|
|
76
|
+
assert.equal(normal.status, 200);
|
|
77
|
+
const remaining = Number(normal.headers.get('x-ratelimit-remaining'));
|
|
78
|
+
assert.ok(Number.isFinite(remaining) && remaining >= 0 && remaining < 60);
|
|
79
|
+
|
|
80
|
+
const malformed = await fetch(`${baseUrl}/api/v1/health?testRateLimit=not-a-number`, {
|
|
81
|
+
headers: { 'X-Forwarded-For': `198.51.100.${Math.floor(Math.random() * 80) + 200}` },
|
|
82
|
+
});
|
|
83
|
+
assert.equal(malformed.status, 200);
|
|
84
|
+
assert.equal(malformed.headers.get('x-ratelimit-limit'), '60');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('REST API discography type filters return real catalog subsets', async () => {
|
|
89
|
+
await withApiServer(async baseUrl => {
|
|
90
|
+
const [allResponse, ostResponse, singlesResponse] = await Promise.all([
|
|
91
|
+
fetch(`${baseUrl}/api/v1/discography`),
|
|
92
|
+
fetch(`${baseUrl}/api/v1/discography?type=ost`),
|
|
93
|
+
fetch(`${baseUrl}/api/v1/discography?type=singles`),
|
|
94
|
+
]);
|
|
95
|
+
const [all, ost, singles] = await Promise.all([
|
|
96
|
+
allResponse.json(),
|
|
97
|
+
ostResponse.json(),
|
|
98
|
+
singlesResponse.json(),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
assert.ok(ost.meta.count > 0);
|
|
102
|
+
assert.ok(singles.meta.count > 0);
|
|
103
|
+
assert.ok(ost.meta.count < all.meta.count);
|
|
104
|
+
assert.ok(singles.meta.count < all.meta.count);
|
|
105
|
+
assert.ok(ost.data.every(track => track.catalogTypes.includes('ost')));
|
|
106
|
+
assert.ok(singles.data.every(track => track.catalogTypes.includes('singles')));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { detectDominantLanguage } from '../agentCore.js';
|
|
4
|
+
import { levenshteinDistance, levenshteinSimilarity, resolveQueryEntities } from '../catalogEntityResolver.js';
|
|
5
|
+
import { DynamicToolRouter } from '../toolRouter.js';
|
|
6
|
+
import { routingInterceptor } from '../routingInterceptor.js';
|
|
7
|
+
import { executeTool } from '../tools.js';
|
|
8
|
+
|
|
9
|
+
test('Architecture Fix 1: Language detection handles mixed Thai queries and session stickiness without English drift', () => {
|
|
10
|
+
|
|
11
|
+
assert.equal(detectDominantLanguage('By My POI หละ'), 'th');
|
|
12
|
+
assert.equal(detectDominantLanguage('รู้จัก Be My POI ไหม'), 'th');
|
|
13
|
+
assert.equal(detectDominantLanguage('เพลง FIND THE WAY เพราะไหม'), 'th');
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
assert.equal(detectDominantLanguage('Be My POI', 'th'), 'th');
|
|
17
|
+
assert.equal(detectDominantLanguage('By My POI', 'th'), 'th');
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
assert.equal(detectDominantLanguage('What are the main awards won by Ju Jingyi in her solo career?'), 'en');
|
|
21
|
+
assert.equal(detectDominantLanguage('Ju Jingyi Chinese name 鞠婧祎 and fandom name Miju 蜜橘 details'), 'en');
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
assert.equal(detectDominantLanguage('请问鞠婧祎在2026年有哪些新剧待播?'), 'zh');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('Architecture Fix 2: Levenshtein & fuzzy matching resolves typos to catalog entities', () => {
|
|
28
|
+
|
|
29
|
+
assert.equal(levenshteinDistance('by my poi', 'be my poi'), 1);
|
|
30
|
+
assert.ok(levenshteinSimilarity('by my poi', 'be my poi') >= 0.85);
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
const resolution = resolveQueryEntities('By My POI หละ');
|
|
34
|
+
assert.ok(resolution.matchedEntities.length > 0, 'Should match at least one entity');
|
|
35
|
+
assert.equal(resolution.matchedEntities[0].id, 'mv-be-my-poi');
|
|
36
|
+
assert.equal(resolution.hasBrand, false, 'Should not match brand');
|
|
37
|
+
assert.equal(resolution.hasSong, true, 'Should match song/media');
|
|
38
|
+
assert.ok(resolution.matchedEntities[0].recommendedTools.includes('get_music_videos'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('Architecture Fix 3: Tool routing and routingInterceptor redirect misrouted brand calls to media', () => {
|
|
42
|
+
const router = new DynamicToolRouter();
|
|
43
|
+
const routed = router.routeToolsForQuery('By My POI หละ');
|
|
44
|
+
const toolNames = routed.map(t => t.function.name);
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
assert.ok(toolNames.includes('get_music_videos'));
|
|
48
|
+
assert.ok(toolNames.includes('search_discography'));
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
const intercepted = routingInterceptor.interceptToolCall({
|
|
52
|
+
name: 'get_brand_endorsements',
|
|
53
|
+
params: { brand: 'By My POI' }
|
|
54
|
+
});
|
|
55
|
+
assert.equal(intercepted.rerouted, true);
|
|
56
|
+
assert.equal(intercepted.replacement.name, 'get_music_videos');
|
|
57
|
+
assert.equal(intercepted.replacement.params.query, 'By My POI');
|
|
58
|
+
routingInterceptor.reset();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('Architecture Fix 4: Cascade retrieval and fuzzy matching in tools return accurate ground-truth facts', async () => {
|
|
62
|
+
|
|
63
|
+
const mvRes = await executeTool('get_music_videos', { query: 'By My POI' });
|
|
64
|
+
assert.equal(mvRes.status, 'success');
|
|
65
|
+
assert.ok(mvRes.count > 0);
|
|
66
|
+
assert.equal(mvRes.videos[0].id, 'mv-be-my-poi');
|
|
67
|
+
assert.equal(mvRes.videos[0].year, 2022);
|
|
68
|
+
assert.equal(mvRes.videos[0].youtubeUrl, 'https://www.youtube.com/watch?v=IolEgdwvjEU');
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
const discoRes = await executeTool('search_discography', { query: 'By My POI' });
|
|
72
|
+
assert.equal(discoRes.status, 'success');
|
|
73
|
+
assert.equal(discoRes.cascadeMatched, true);
|
|
74
|
+
assert.equal(discoRes.source, 'musicVideoData');
|
|
75
|
+
assert.ok(discoRes.songs[0].title.includes('Be My POI'));
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
const brandRes = await executeTool('get_brand_endorsements', { query: 'By My POI' });
|
|
79
|
+
assert.equal(brandRes.status, 'not_found');
|
|
80
|
+
assert.ok(brandRes.message.includes('search_discography') || brandRes.message.includes('get_music_videos'));
|
|
81
|
+
});
|