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:
Jeffery
2026-08-13 10:43:33 +08:00
parent 611c9b400c
commit 767b17e257
18 changed files with 733 additions and 25 deletions
@@ -0,0 +1,24 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_episodic_memories" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"occurredAt" DATETIME NOT NULL,
"emotionTag" TEXT NOT NULL,
"emotionIntensity" REAL NOT NULL,
"retrievalCount" INTEGER NOT NULL DEFAULT 0,
"lastRetrievedAt" DATETIME,
"source" TEXT NOT NULL,
"weight" REAL NOT NULL DEFAULT 1,
"relatedUserId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "episodic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "episodic_memories_relatedUserId_fkey" FOREIGN KEY ("relatedUserId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_episodic_memories" ("characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "lastRetrievedAt", "occurredAt", "retrievalCount", "source", "weight") SELECT "characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "lastRetrievedAt", "occurredAt", "retrievalCount", "source", "weight" FROM "episodic_memories";
DROP TABLE "episodic_memories";
ALTER TABLE "new_episodic_memories" RENAME TO "episodic_memories";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,107 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_character_aliases" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"formalName" TEXT NOT NULL,
"nickname" TEXT,
"calledByOthers" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_aliases_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_character_aliases" ("calledByOthers", "characterId", "createdAt", "formalName", "id", "nickname") SELECT "calledByOthers", "characterId", "createdAt", "formalName", "id", "nickname" FROM "character_aliases";
DROP TABLE "character_aliases";
ALTER TABLE "new_character_aliases" RENAME TO "character_aliases";
CREATE TABLE "new_emotion_states" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"calm" REAL NOT NULL DEFAULT 100,
"joy" REAL NOT NULL DEFAULT 0,
"sad" REAL NOT NULL DEFAULT 0,
"alert" REAL NOT NULL DEFAULT 0,
"shy" REAL NOT NULL DEFAULT 0,
"grumpy" REAL NOT NULL DEFAULT 0,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "emotion_states_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_emotion_states" ("alert", "calm", "characterId", "grumpy", "id", "joy", "sad", "shy", "updatedAt") SELECT "alert", "calm", "characterId", "grumpy", "id", "joy", "sad", "shy", "updatedAt" FROM "emotion_states";
DROP TABLE "emotion_states";
ALTER TABLE "new_emotion_states" RENAME TO "emotion_states";
CREATE UNIQUE INDEX "emotion_states_characterId_key" ON "emotion_states"("characterId");
CREATE TABLE "new_episodic_memories" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"occurredAt" DATETIME NOT NULL,
"emotionTag" TEXT NOT NULL,
"emotionIntensity" REAL NOT NULL,
"retrievalCount" INTEGER NOT NULL DEFAULT 0,
"lastRetrievedAt" DATETIME,
"source" TEXT NOT NULL,
"weight" REAL NOT NULL DEFAULT 1,
"relatedUserId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "episodic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "episodic_memories_relatedUserId_fkey" FOREIGN KEY ("relatedUserId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_episodic_memories" ("characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "lastRetrievedAt", "occurredAt", "relatedUserId", "retrievalCount", "source", "weight") SELECT "characterId", "content", "createdAt", "emotionIntensity", "emotionTag", "id", "lastRetrievedAt", "occurredAt", "relatedUserId", "retrievalCount", "source", "weight" FROM "episodic_memories";
DROP TABLE "episodic_memories";
ALTER TABLE "new_episodic_memories" RENAME TO "episodic_memories";
CREATE TABLE "new_procedural_rules" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"situation" TEXT NOT NULL,
"responsePattern" TEXT NOT NULL,
"weight" REAL NOT NULL DEFAULT 1,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "procedural_rules_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_procedural_rules" ("characterId", "createdAt", "id", "responsePattern", "situation", "updatedAt", "weight") SELECT "characterId", "createdAt", "id", "responsePattern", "situation", "updatedAt", "weight" FROM "procedural_rules";
DROP TABLE "procedural_rules";
ALTER TABLE "new_procedural_rules" RENAME TO "procedural_rules";
CREATE TABLE "new_relationships" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"intimacy" INTEGER NOT NULL DEFAULT 0,
"trust" INTEGER NOT NULL DEFAULT 0,
"firstMet" DATETIME,
"lastInteraction" DATETIME,
"interactionCount" INTEGER NOT NULL DEFAULT 0,
"stage" TEXT NOT NULL DEFAULT 'STRANGER',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "relationships_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "relationships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_relationships" ("characterId", "createdAt", "firstMet", "id", "interactionCount", "intimacy", "lastInteraction", "stage", "trust", "updatedAt", "userId") SELECT "characterId", "createdAt", "firstMet", "id", "interactionCount", "intimacy", "lastInteraction", "stage", "trust", "updatedAt", "userId" FROM "relationships";
DROP TABLE "relationships";
ALTER TABLE "new_relationships" RENAME TO "relationships";
CREATE UNIQUE INDEX "relationships_characterId_userId_key" ON "relationships"("characterId", "userId");
CREATE TABLE "new_semantic_memories" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"fact" TEXT NOT NULL,
"about" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "semantic_memories_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_semantic_memories" ("about", "characterId", "createdAt", "fact", "id") SELECT "about", "characterId", "createdAt", "fact", "id" FROM "semantic_memories";
DROP TABLE "semantic_memories";
ALTER TABLE "new_semantic_memories" RENAME TO "semantic_memories";
CREATE TABLE "new_sentiment_ledger_entries" (
"id" TEXT NOT NULL PRIMARY KEY,
"relationshipId" TEXT NOT NULL,
"date" DATETIME NOT NULL,
"event" TEXT NOT NULL,
"weight" REAL NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sentiment_ledger_entries_relationshipId_fkey" FOREIGN KEY ("relationshipId") REFERENCES "relationships" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_sentiment_ledger_entries" ("createdAt", "date", "event", "id", "relationshipId", "weight") SELECT "createdAt", "date", "event", "id", "relationshipId", "weight" FROM "sentiment_ledger_entries";
DROP TABLE "sentiment_ledger_entries";
ALTER TABLE "new_sentiment_ledger_entries" RENAME TO "sentiment_ledger_entries";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+13 -10
View File
@@ -50,7 +50,8 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
relationships Relationship[]
relationships Relationship[]
relatedMemories EpisodicMemory[]
@@map("users")
}
@@ -72,7 +73,7 @@ model Work {
model Character {
id String @id @default(cuid())
workId String?
work Work? @relation(fields: [workId], references: [id])
work Work? @relation(fields: [workId], references: [id], onDelete: SetNull)
source CharacterSource
buildStatus CharacterBuildStatus @default(CANDIDATE)
@@ -102,7 +103,7 @@ model Character {
model CharacterAlias {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
formalName String // 正式名
nickname String? // 暱稱
calledByOthers String? // 他人稱呼(作品中其他角色如何稱呼)
@@ -114,7 +115,7 @@ model CharacterAlias {
model EpisodicMemory {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
content String
occurredAt DateTime // 發生時間
emotionTag EmotionTag
@@ -123,6 +124,8 @@ model EpisodicMemory {
lastRetrievedAt DateTime?
source MemorySource
weight Float @default(1) // 權重(供遺忘/檢索排序使用)
relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用)
relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@map("episodic_memories")
@@ -131,7 +134,7 @@ model EpisodicMemory {
model SemanticMemory {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
fact String // 去情境化事實
about String // 對象(關於誰/什麼)
createdAt DateTime @default(now())
@@ -142,7 +145,7 @@ model SemanticMemory {
model ProceduralRule {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
situation String // 情境
responsePattern String // 回應模式
weight Float @default(1)
@@ -155,7 +158,7 @@ model ProceduralRule {
model EmotionState {
id String @id @default(cuid())
characterId String @unique
character Character @relation(fields: [characterId], references: [id])
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
calm Float @default(100) // 平靜
joy Float @default(0) // 愉悅
@@ -172,9 +175,9 @@ model EmotionState {
model Relationship {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id])
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
intimacy Int @default(0) // 親密度 0~100
trust Int @default(0) // 信任度 0~100
@@ -195,7 +198,7 @@ model Relationship {
model SentimentLedgerEntry {
id String @id @default(cuid())
relationshipId String
relationship Relationship @relation(fields: [relationshipId], references: [id])
relationship Relationship @relation(fields: [relationshipId], references: [id], onDelete: Cascade)
date DateTime
event String
weight Float // 允許正負:正向事件為正值,負向事件為負值
+1
View File
@@ -88,6 +88,7 @@ async function main() {
emotionIntensity: 0.9,
source: "INTERACTION" as const,
weight: 5,
relatedUserId: user.id,
};
await prisma.episodicMemory.upsert({
where: { id: "seed-memory-episodic-cheer" },