Files
Kokorone/scripts/smoke/E.mjs
T
Jeffery 767b17e257 feat: 完成 E 群組 — 關係子系統
- RelationshipService:E-1 未知對象自動建檔(禮貌+防備=intimacy/trust=0)、
  E-3 親密度五階分層+跨階事件(@nestjs/event-emitter,StageChangeLogService 獨立訂閱)、
  E-4 久未互動時親密度半衰期衰減並標記「久未見」
- SentimentLedgerService:E-2 關係帳本,負向事件套用放大係數,驗證同等級正負事件淨值為負
- IntentInterpreterService:E-6 依親密度將同一句模稜兩可的話解讀為玩笑或冒犯
- KeywordMemoryRetriever(C-6)擴充 E-5:新增 relatedUserId 加權,retrieve() 簽名改為接受 options 物件
- schema 新增 EpisodicMemory.relatedUserId,並補上所有子表對父表的 onDelete: Cascade/SetNull
  (E-2 測試時發現先前 B 群組遺漏這些規則會導致刪除關係時外鍵違反)
- apps/api 新增 /relationships/* 端點作為引擎驗證介面
- scripts/smoke/E.mjs:涵蓋 E-1~E-6 全部驗收情境

npm run restart && npm run smoke -- E 皆通過(E-V),A/B/C/D 群組冒煙測試無回歸。

本次另排除一個本機環境限定的 Prisma CLI 網路 checkpoint 卡住問題,並從一次部分套用的
migration 中手動恢復資料庫一致性(過程記錄於 todo.md 實作記錄,供後續群組參考)。
2026-08-13 10:43:33 +08:00

155 lines
7.0 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 { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const CHARACTER_ID = "seed-character-genki";
const TEST_USER_ID = "smoke-e-test-user";
const OTHER_USER_ID = "smoke-e-other-user";
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 ?? {}),
});
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();
}
async function resetRelationship(userId, data) {
await prisma.relationship.upsert({
where: { characterId_userId: { characterId: CHARACTER_ID, userId } },
update: { ...data, updatedAt: new Date() },
create: { characterId: CHARACTER_ID, userId, ...data },
});
}
export default async function smokeE() {
await prisma.user.upsert({
where: { id: TEST_USER_ID },
update: {},
create: { id: TEST_USER_ID, name: "關係測試使用者" },
});
await prisma.user.upsert({
where: { id: OTHER_USER_ID },
update: {},
create: { id: OTHER_USER_ID, name: "另一位測試使用者" },
});
await prisma.relationship.deleteMany({ where: { characterId: CHARACTER_ID, userId: TEST_USER_ID } });
// E-1 親密度與信任讀寫:未知對象首次互動自動建檔,預設「禮貌+防備」
const created = await get(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}`);
if (created.relationship.intimacy !== 0 || created.relationship.trust !== 0 || created.relationship.stage !== "STRANGER") {
throw new Error("新對象自動建檔的預設值不符「禮貌+防備」(intimacy/trust 應為 0,stage 應為 STRANGER)");
}
// E-2 關係帳本與負向偏誤:同等級正負事件各一次後,淨值為負
await post(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}/events`, { event: "稱讚了她的努力", weight: 10 });
await post(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}/events`, { event: "取消約定放她鴿子", weight: -10 });
const { net } = await get(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}/ledger-net`);
if (net >= 0) {
throw new Error(`同等級正負事件後淨值應為負,實際為 ${net}`);
}
// E-3 親密度分層:連續日常互動累積親密度並跨越門檻,事件可被獨立訂閱者記錄
await resetRelationship(TEST_USER_ID, { intimacy: 0, trust: 0, stage: "STRANGER" });
for (let i = 0; i < 11; i++) {
await post(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}/interactions`, {});
}
const afterInteractions = await get(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}`);
if (afterInteractions.relationship.stage !== "ACQUAINTANCE") {
throw new Error(`累積 11 次互動(+22 親密度)後應進入認識階段,實際為 ${afterInteractions.relationship.stage}`);
}
const { events } = await get("/relationships/stage-events");
const crossedEvent = events.find(
(e) => e.characterId === CHARACTER_ID && e.userId === TEST_USER_ID && e.fromStage === "STRANGER" && e.toStage === "ACQUAINTANCE",
);
if (!crossedEvent) {
throw new Error("跨越親密度分層門檻時未發出可訂閱事件(StageChangeLogService 未收到)");
}
// E-4 關係時間衰減:久未互動時親密度回落,並標記「久未見」
const fortyDaysLater = new Date(Date.now() + 40 * 24 * 60 * 60 * 1000).toISOString();
const decayed = await get(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}?now=${encodeURIComponent(fortyDaysLater)}`);
if (!decayed.longAbsence) {
throw new Error("久未互動應標記 longAbsence 為 true");
}
if (decayed.relationship.intimacy >= afterInteractions.relationship.intimacy) {
throw new Error("久未互動後親密度應回落");
}
// E-6 意圖推測:同一句話在高/低親密度下得到不同解讀
await resetRelationship(TEST_USER_ID, { intimacy: 80, trust: 50, stage: "BONDED" });
await resetRelationship(OTHER_USER_ID, { intimacy: 0, trust: 0, stage: "STRANGER" });
const ambiguousText = encodeURIComponent("你這個笨蛋");
const highIntimacyIntent = await get(`/relationships/${CHARACTER_ID}/${TEST_USER_ID}/intent?text=${ambiguousText}`);
const lowIntimacyIntent = await get(`/relationships/${CHARACTER_ID}/${OTHER_USER_ID}/intent?text=${ambiguousText}`);
if (highIntimacyIntent.tag !== "JOKE") {
throw new Error(`高親密度下同一句話應解讀為玩笑,實際為 ${highIntimacyIntent.tag}`);
}
if (lowIntimacyIntent.tag !== "OFFENSE") {
throw new Error(`低親密度下同一句話應解讀為冒犯,實際為 ${lowIntimacyIntent.tag}`);
}
// E-5 關係加權檢索接點:關鍵字/時間/情緒條件相同時,與當前對象相關的記憶排序優先
const now = new Date("2026-08-01T00:00:00.000Z");
await prisma.episodicMemory.upsert({
where: { id: "smoke-e5-related" },
update: { relatedUserId: TEST_USER_ID, occurredAt: now, emotionIntensity: 0.5 },
create: {
id: "smoke-e5-related",
characterId: CHARACTER_ID,
content: "smoke-e5 測試記憶(與測試使用者相關)",
occurredAt: now,
emotionTag: "CALM",
emotionIntensity: 0.5,
source: "INTERACTION",
weight: 1,
relatedUserId: TEST_USER_ID,
},
});
await prisma.episodicMemory.upsert({
where: { id: "smoke-e5-unrelated" },
update: { relatedUserId: null, occurredAt: now, emotionIntensity: 0.5 },
create: {
id: "smoke-e5-unrelated",
characterId: CHARACTER_ID,
content: "smoke-e5 測試記憶(與測試使用者無關)",
occurredAt: now,
emotionTag: "CALM",
emotionIntensity: 0.5,
source: "INTERACTION",
weight: 1,
},
});
const withoutRelation = await get(`/memory/${CHARACTER_ID}/retrieve?query=smoke-e5&limit=2`);
const relatedRankWithout = withoutRelation.results.findIndex((r) => r.id === "smoke-e5-related");
const unrelatedRankWithout = withoutRelation.results.findIndex((r) => r.id === "smoke-e5-unrelated");
if (relatedRankWithout === -1 || unrelatedRankWithout === -1) {
throw new Error("E-5 測試記憶未被檢索到");
}
const withRelation = await get(
`/memory/${CHARACTER_ID}/retrieve?query=smoke-e5&limit=2&relatedUserId=${TEST_USER_ID}`,
);
const relatedRankWith = withRelation.results.findIndex((r) => r.id === "smoke-e5-related");
const unrelatedRankWith = withRelation.results.findIndex((r) => r.id === "smoke-e5-unrelated");
if (relatedRankWith !== 0 || relatedRankWith >= unrelatedRankWith) {
throw new Error("帶入 relatedUserId 後,與當前對象相關的記憶應排序優先於無關記憶");
}
// 清理本次測試建立的資料,避免重複執行時累積髒資料
await prisma.episodicMemory.deleteMany({ where: { id: { in: ["smoke-e5-related", "smoke-e5-unrelated"] } } });
await prisma.relationship.deleteMany({ where: { characterId: CHARACTER_ID, userId: { in: [TEST_USER_ID, OTHER_USER_ID] } } });
}