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"); } }