Files
Kokorone/scripts/smoke/F.mjs
T

197 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
return res;
}
async function postJson(path, body) {
const res = await post(path, body);
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
function sendMessage(sessionId, text, extra = {}) {
return postJson(`/dialogue/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
userId: USER_ID,
text,
...extra,
});
}
export default async function smokeF() {
// F-7 + F-1:同 seed 兩次獨立 session 應得到完全相同的輸出(可重現)
const seedA = "smoke-f-seed-a-" + Date.now();
const seedB = "smoke-f-seed-b-" + Date.now();
const r1 = await sendMessage(seedA, "今天過得如何?", { seed: 12345 });
const r2 = await sendMessage(seedB, "今天過得如何?", { seed: 12345 });
if (r1.text !== r2.text) {
throw new Error(`同 seed 應輸出相同文字,實際:「${r1.text}」vs「${r2.text}」`);
}
if (r1.isFastChannel) {
throw new Error("一般輸入不應被判定為快速通道");
}
// F-6 上下文組裝器:一次對話應可輸出完整組裝內容快照
const requiredKeys = [
"character",
"emotion",
"relationship",
"retrievedMemories",
"history",
"userInput",
"injectedMemoryIds",
"seed",
];
for (const key of requiredKeys) {
if (!(key in r1.context)) {
throw new Error(`上下文快照缺少欄位:${key}`);
}
}
if (r1.context.injectedMemoryIds.length === 0) {
throw new Error("一般輸入的上下文組裝應能檢索到至少一筆既有記憶(種子記憶)");
}
// F-4 雙速通道:問候與危險輸入不應觸發記憶檢索(injectedMemoryIds 應為空)
const greetingSession = "smoke-f-greeting-" + Date.now();
const greeting = await sendMessage(greetingSession, "早安!");
if (!greeting.isFastChannel) {
throw new Error("固定問候應被判定為快速通道");
}
if (greeting.context.injectedMemoryIds.length !== 0) {
throw new Error("快速通道(問候)不應觸發記憶檢索");
}
const dangerSession = "smoke-f-danger-" + Date.now();
const danger = await sendMessage(dangerSession, "我覺得好痛苦,好想死");
if (!danger.isFastChannel) {
throw new Error("明確危險輸入應被判定為快速通道");
}
if (danger.context.injectedMemoryIds.length !== 0) {
throw new Error("快速通道(危險輸入)不應觸發記憶檢索");
}
if (!danger.text.includes("1995")) {
throw new Error("危險輸入的快速通道回應應包含求助資源");
}
// F-5 行為強化迴路:稱讚加權、糾正後修正版取代舊模式
const situation = "fast-channel:greeting";
const oldPattern = "smoke-f-old-pattern-" + Date.now();
const newPattern = "smoke-f-new-pattern-" + Date.now();
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: oldPattern });
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: oldPattern });
const beforeCorrection = await get(
`/dialogue/${CHARACTER_ID}/top-rule?situation=${encodeURIComponent(situation)}`,
);
if (beforeCorrection.responsePattern !== oldPattern || beforeCorrection.weight !== 2) {
throw new Error("稱讚加權後應為權重最高的規則(weight=2)");
}
await postJson(`/dialogue/${CHARACTER_ID}/correct`, {
situation,
oldResponsePattern: oldPattern,
newResponsePattern: newPattern,
});
const afterCorrection = await get(
`/dialogue/${CHARACTER_ID}/top-rule?situation=${encodeURIComponent(situation)}`,
);
if (afterCorrection.responsePattern !== newPattern) {
throw new Error("糾正後修正版應取代舊模式成為權重最高者");
}
const greetingAfterCorrection = await sendMessage("smoke-f-greeting2-" + Date.now(), "早安!");
if (greetingAfterCorrection.text !== newPattern) {
throw new Error("糾正後的快速通道回應應套用修正版模式");
}
// F-3 輸出過濾層:含禁則詞的模板輸出應被攔截或改寫
const forbiddenPattern = "早安,你這個死八嘎";
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern });
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern });
await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern });
const filteredGreeting = await sendMessage("smoke-f-filter-" + Date.now(), "早安!");
if (filteredGreeting.text.includes("死八嘎")) {
throw new Error("含禁則詞的回應未被輸出過濾層攔截或改寫");
}
// 清理本次測試建立的 ProceduralRule,避免重複執行時累積髒資料
await prisma.proceduralRule.deleteMany({ where: { characterId: CHARACTER_ID, situation } });
// R-3:ClaudeProvider 已經是真的呼叫,不再是空殼。這裡只驗證 Provider 切換的「配線」本身
// (選對了 ClaudeProvider、缺金鑰時的錯誤處理正確),不對 CLIProxy 發真的請求——真正的
// 回歸測試基準仍是 Mock 模板庫(同 R-3 驗收註記),自動化冒煙測試不應依賴外部服務目前是否可用。
await verifyClaudeProviderWiring();
}
async function verifyClaudeProviderWiring() {
const port = "3091";
const childEnv = { ...process.env, PORT: port, LLM_PROVIDER: "claude" };
delete childEnv.CLAUDE_API_KEY;
const child = spawn("node", ["apps/api/dist/main.js"], {
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk) => (output += chunk.toString()));
child.stderr.on("data", (chunk) => (output += chunk.toString()));
try {
const deadline = Date.now() + 15_000;
while (!output.includes("啟動") && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (!output.includes("LLM_PROVIDER=claude")) {
throw new Error("LLM_PROVIDER=claude 啟動時未輸出對應的 INF log");
}
let healthOk = false;
const healthDeadline = Date.now() + 15_000;
while (!healthOk && Date.now() < healthDeadline) {
try {
const res = await fetch(`http://localhost:${port}/health`);
healthOk = res.ok;
} catch {
// 尚未就緒,繼續等待
}
if (!healthOk) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
}
if (!healthOk) {
throw new Error("LLM_PROVIDER=claude 的測試行程未能成功啟動");
}
const res = await fetch(`http://localhost:${port}/dialogue/${CHARACTER_ID}/sessions/claude-stub-test/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: USER_ID, text: "今天發生了很多事情想跟你分享" }),
});
// 沒有金鑰應該在呼叫 generate() 時就快速、確定性地失敗(不對 CLIProxy 發任何請求)。
if (res.status < 500) {
throw new Error(`缺少 CLAUDE_API_KEY 時應該呼叫失敗,實際回傳 ${res.status}`);
}
} finally {
child.kill("SIGKILL");
}
}