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 實作記錄,供後續群組參考)。
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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] } } });
|
||||
}
|
||||
Reference in New Issue
Block a user