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
+1
View File
@@ -14,6 +14,7 @@
"@kokorone/shared": "*", "@kokorone/shared": "*",
"@nestjs/common": "^11.1.29", "@nestjs/common": "^11.1.29",
"@nestjs/core": "^11.1.29", "@nestjs/core": "^11.1.29",
"@nestjs/event-emitter": "^3.1.0",
"@nestjs/platform-express": "^11.1.29", "@nestjs/platform-express": "^11.1.29",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
+9 -1
View File
@@ -1,11 +1,19 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { HealthController } from "./health/health.controller.js"; import { HealthController } from "./health/health.controller.js";
import { CharactersModule } from "./characters/characters.module.js"; import { CharactersModule } from "./characters/characters.module.js";
import { MemoryModule } from "./memory/memory.module.js"; import { MemoryModule } from "./memory/memory.module.js";
import { EmotionModule } from "./emotion/emotion.module.js"; import { EmotionModule } from "./emotion/emotion.module.js";
import { RelationshipModule } from "./relationship/relationship.module.js";
@Module({ @Module({
imports: [CharactersModule, MemoryModule, EmotionModule], imports: [
EventEmitterModule.forRoot(),
CharactersModule,
MemoryModule,
EmotionModule,
RelationshipModule,
],
controllers: [HealthController], controllers: [HealthController],
}) })
export class AppModule {} export class AppModule {}
@@ -2,14 +2,21 @@ import { Injectable } from "@nestjs/common";
import type { EpisodicMemory } from "@kokorone/shared"; import type { EpisodicMemory } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
export interface RetrieveOptions {
limit?: number;
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
relatedUserId?: string;
}
export interface MemoryRetriever { export interface MemoryRetriever {
retrieve(characterId: string, query: string, limit?: number): Promise<EpisodicMemory[]>; retrieve(characterId: string, query: string, options?: RetrieveOptions): Promise<EpisodicMemory[]>;
} }
const WEIGHTS = { const WEIGHTS = {
keyword: 0.5, keyword: 0.4,
recency: 0.3, recency: 0.25,
emotion: 0.2, emotion: 0.15,
relationship: 0.2,
}; };
const DEFAULT_LIMIT = 5; const DEFAULT_LIMIT = 5;
@@ -28,7 +35,8 @@ function keywordRelevance(content: string, keywords: string[]): number {
export class KeywordMemoryRetriever implements MemoryRetriever { export class KeywordMemoryRetriever implements MemoryRetriever {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async retrieve(characterId: string, query: string, limit = DEFAULT_LIMIT): Promise<EpisodicMemory[]> { async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
const limit = options.limit ?? DEFAULT_LIMIT;
const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } }); const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } });
const now = Date.now(); const now = Date.now();
const keywords = query.split(/\s+/).filter(Boolean); const keywords = query.split(/\s+/).filter(Boolean);
@@ -38,8 +46,12 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
const daysSince = (now - row.occurredAt.getTime()) / (1000 * 60 * 60 * 24); const daysSince = (now - row.occurredAt.getTime()) / (1000 * 60 * 60 * 24);
const recencyScore = 1 / (1 + Math.max(0, daysSince)); const recencyScore = 1 / (1 + Math.max(0, daysSince));
const emotionScore = row.emotionIntensity; const emotionScore = row.emotionIntensity;
const relationshipScore = options.relatedUserId && row.relatedUserId === options.relatedUserId ? 1 : 0;
const score = const score =
keywordScore * WEIGHTS.keyword + recencyScore * WEIGHTS.recency + emotionScore * WEIGHTS.emotion; keywordScore * WEIGHTS.keyword +
recencyScore * WEIGHTS.recency +
emotionScore * WEIGHTS.emotion +
relationshipScore * WEIGHTS.relationship;
return { row, score }; return { row, score };
}); });
+5 -1
View File
@@ -54,8 +54,12 @@ export class MemoryController {
@Param("characterId") characterId: string, @Param("characterId") characterId: string,
@Query("query") query: string, @Query("query") query: string,
@Query("limit") limit?: string, @Query("limit") limit?: string,
@Query("relatedUserId") relatedUserId?: string,
) { ) {
const results = await this.retriever.retrieve(characterId, query ?? "", limit ? Number(limit) : undefined); const results = await this.retriever.retrieve(characterId, query ?? "", {
limit: limit ? Number(limit) : undefined,
relatedUserId,
});
return { results }; return { results };
} }
+28
View File
@@ -0,0 +1,28 @@
import type { RelationshipStage } from "@kokorone/shared";
// E-3 親密度分層:陌生 0-19/認識 20-39/朋友 40-59/摯友-曖昧 60-79/羈絆 80-100
const STAGE_THRESHOLDS: { stage: RelationshipStage; min: number }[] = [
{ stage: "BONDED", min: 80 },
{ stage: "CLOSE_AMBIGUOUS", min: 60 },
{ stage: "FRIEND", min: 40 },
{ stage: "ACQUAINTANCE", min: 20 },
{ stage: "STRANGER", min: 0 },
];
export function stageForIntimacy(intimacy: number): RelationshipStage {
for (const { stage, min } of STAGE_THRESHOLDS) {
if (intimacy >= min) {
return stage;
}
}
return "STRANGER";
}
// E-2 負向偏誤:負向事件的權重放大係數(可依角色參數覆寫)。
export const DEFAULT_NEGATIVE_BIAS_MULTIPLIER = 2;
// E-4 關係時間衰減:超過此天數未互動視為「久未見」,並開始衰減親密度。
export const LONG_ABSENCE_DAYS = 7;
export const RELATIONSHIP_DECAY_HALF_LIFE_DAYS = 30;
export const RELATIONSHIP_STAGE_CHANGED_EVENT = "relationship.stage-changed";
@@ -0,0 +1,35 @@
import { Injectable } from "@nestjs/common";
import type { RelationshipStage } from "@kokorone/shared";
import { RelationshipService } from "./relationship.service.js";
import { stageForIntimacy } from "./constants.js";
export type IntentTag = "JOKE" | "OFFENSE" | "NEUTRAL";
export interface IntentResult {
tag: IntentTag;
stage: RelationshipStage;
intimacy: number;
}
// 同一句話依對象歷史互動模式(親密度)解讀為玩笑或冒犯:熟到「朋友」以上才視為玩笑。
const AMBIGUOUS_PHRASES = ["笨蛋", "白痴", "討厭你", "欠揍", "有夠煩"];
const JOKE_THRESHOLD_INTIMACY = 40;
// E-6 意圖推測:心智理論 → 依此人歷史互動模式解讀當前訊息。
@Injectable()
export class IntentInterpreterService {
constructor(private readonly relationship: RelationshipService) {}
async interpret(characterId: string, userId: string, text: string, now: Date = new Date()): Promise<IntentResult> {
const { relationship } = await this.relationship.getState(characterId, userId, now);
const isAmbiguous = AMBIGUOUS_PHRASES.some((phrase) => text.includes(phrase));
const tag: IntentTag = !isAmbiguous
? "NEUTRAL"
: relationship.intimacy >= JOKE_THRESHOLD_INTIMACY
? "JOKE"
: "OFFENSE";
return { tag, stage: stageForIntimacy(relationship.intimacy), intimacy: relationship.intimacy };
}
}
@@ -0,0 +1,87 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { RelationshipService } from "./relationship.service.js";
import { SentimentLedgerService } from "./sentiment-ledger.service.js";
import { IntentInterpreterService } from "./intent-interpreter.service.js";
import { StageChangeLogService } from "./stage-change-log.service.js";
interface RecordEventBody {
event: string;
weight: number;
negativeBiasMultiplier?: number;
now?: string;
}
interface RecordInteractionBody {
intimacyDelta?: number;
now?: string;
}
@Controller("relationships")
export class RelationshipController {
constructor(
private readonly relationship: RelationshipService,
private readonly sentimentLedger: SentimentLedgerService,
private readonly intentInterpreter: IntentInterpreterService,
private readonly stageChangeLog: StageChangeLogService,
) {}
@Get("stage-events")
getStageEvents() {
return { events: this.stageChangeLog.getLog() };
}
@Get(":characterId/:userId")
async getState(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Query("now") now?: string,
) {
const { relationship, longAbsence } = await this.relationship.getState(
characterId,
userId,
now ? new Date(now) : undefined,
);
return { relationship, longAbsence };
}
@Post(":characterId/:userId/interactions")
async recordInteraction(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Body() body: RecordInteractionBody,
) {
return this.relationship.recordInteraction(
characterId,
userId,
body.now ? new Date(body.now) : undefined,
body.intimacyDelta,
);
}
@Post(":characterId/:userId/events")
async recordEvent(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Body() body: RecordEventBody,
) {
return this.sentimentLedger.recordEvent(characterId, userId, body.event, body.weight, {
negativeBiasMultiplier: body.negativeBiasMultiplier,
now: body.now ? new Date(body.now) : undefined,
});
}
@Get(":characterId/:userId/ledger-net")
async getLedgerNet(@Param("characterId") characterId: string, @Param("userId") userId: string) {
return { net: await this.sentimentLedger.getNetLedgerValue(characterId, userId) };
}
@Get(":characterId/:userId/intent")
async interpretIntent(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Query("text") text: string,
@Query("now") now?: string,
) {
return this.intentInterpreter.interpret(characterId, userId, text ?? "", now ? new Date(now) : undefined);
}
}
@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { RelationshipService } from "./relationship.service.js";
import { SentimentLedgerService } from "./sentiment-ledger.service.js";
import { IntentInterpreterService } from "./intent-interpreter.service.js";
import { StageChangeLogService } from "./stage-change-log.service.js";
import { RelationshipController } from "./relationship.controller.js";
@Module({
imports: [PrismaModule],
controllers: [RelationshipController],
providers: [RelationshipService, SentimentLedgerService, IntentInterpreterService, StageChangeLogService],
exports: [RelationshipService, SentimentLedgerService, IntentInterpreterService],
})
export class RelationshipModule {}
@@ -0,0 +1,115 @@
import { Injectable } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { PrismaService } from "../prisma/prisma.service.js";
import {
stageForIntimacy,
LONG_ABSENCE_DAYS,
RELATIONSHIP_DECAY_HALF_LIFE_DAYS,
RELATIONSHIP_STAGE_CHANGED_EVENT,
} from "./constants.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_INTERACTION_INTIMACY_DELTA = 2;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
// E-1/E-3/E-4:關係讀寫、親密度分層與跨階事件、久未互動的時間衰減。
@Injectable()
export class RelationshipService {
constructor(
private readonly prisma: PrismaService,
private readonly events: EventEmitter2,
) {}
// E-1 未知對象 → 建立新關係檔案,預設「禮貌+防備」(intimacy/trust 皆為 0,對應 STRANGER)。
async getOrCreate(characterId: string, userId: string, now: Date = new Date()) {
const existing = await this.prisma.client.relationship.findUnique({
where: { characterId_userId: { characterId, userId } },
});
if (existing) {
return existing;
}
return this.prisma.client.relationship.create({
data: { characterId, userId, intimacy: 0, trust: 0, firstMet: now, stage: "STRANGER" },
});
}
// E-4:久未互動時套用衰減,回傳目前關係與「久未見」旗標。
async getState(
characterId: string,
userId: string,
now: Date = new Date(),
): Promise<{ relationship: Awaited<ReturnType<RelationshipService["getOrCreate"]>>; longAbsence: boolean }> {
const relationship = await this.getOrCreate(characterId, userId, now);
if (!relationship.lastInteraction) {
return { relationship, longAbsence: false };
}
const elapsedMs = now.getTime() - relationship.lastInteraction.getTime();
const graceMs = LONG_ABSENCE_DAYS * DAY_MS;
const longAbsence = elapsedMs > graceMs;
if (!longAbsence) {
return { relationship, longAbsence: false };
}
const decayElapsedMs = elapsedMs - graceMs;
const factor = Math.pow(0.5, decayElapsedMs / (RELATIONSHIP_DECAY_HALF_LIFE_DAYS * DAY_MS));
const decayedIntimacy = relationship.intimacy * factor;
if (Math.abs(decayedIntimacy - relationship.intimacy) < 0.001) {
return { relationship, longAbsence: true };
}
const beforeStage = stageForIntimacy(relationship.intimacy);
const afterStage = stageForIntimacy(decayedIntimacy);
const updated = await this.prisma.client.relationship.update({
where: { characterId_userId: { characterId, userId } },
data: { intimacy: decayedIntimacy, stage: afterStage },
});
if (beforeStage !== afterStage) {
this.emitStageChanged(characterId, userId, beforeStage, afterStage);
}
return { relationship: updated, longAbsence: true };
}
// E-1/E-3:一次日常互動,親密度小幅穩定累積,跨越分層門檻時發出可訂閱事件。
async recordInteraction(
characterId: string,
userId: string,
now: Date = new Date(),
intimacyDelta = DEFAULT_INTERACTION_INTIMACY_DELTA,
) {
const { relationship: current } = await this.getState(characterId, userId, now);
const beforeStage = stageForIntimacy(current.intimacy);
const nextIntimacy = clamp(current.intimacy + intimacyDelta, 0, 100);
const afterStage = stageForIntimacy(nextIntimacy);
const updated = await this.prisma.client.relationship.update({
where: { characterId_userId: { characterId, userId } },
data: {
intimacy: nextIntimacy,
stage: afterStage,
lastInteraction: now,
interactionCount: { increment: 1 },
},
});
if (beforeStage !== afterStage) {
this.emitStageChanged(characterId, userId, beforeStage, afterStage);
}
return updated;
}
private emitStageChanged(
characterId: string,
userId: string,
fromStage: string,
toStage: string,
): void {
this.events.emit(RELATIONSHIP_STAGE_CHANGED_EVENT, { characterId, userId, fromStage, toStage });
}
}
@@ -0,0 +1,61 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RelationshipService } from "./relationship.service.js";
import { DEFAULT_NEGATIVE_BIAS_MULTIPLIER } from "./constants.js";
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export interface RecordEventOptions {
negativeBiasMultiplier?: number;
now?: Date;
}
// E-2 關係帳本與負向偏誤:負向事件的實際權重會被放大,一次背叛可抵銷多次善意。
@Injectable()
export class SentimentLedgerService {
constructor(
private readonly prisma: PrismaService,
private readonly relationship: RelationshipService,
) {}
async recordEvent(
characterId: string,
userId: string,
event: string,
rawWeight: number,
options: RecordEventOptions = {},
): Promise<{ biasedWeight: number; trust: number }> {
const now = options.now ?? new Date();
const { relationship } = await this.relationship.getState(characterId, userId, now);
const biasMultiplier = options.negativeBiasMultiplier ?? DEFAULT_NEGATIVE_BIAS_MULTIPLIER;
const biasedWeight = rawWeight < 0 ? rawWeight * biasMultiplier : rawWeight;
await this.prisma.client.sentimentLedgerEntry.create({
data: { relationshipId: relationship.id, date: now, event, weight: biasedWeight },
});
const trust = clamp(relationship.trust + biasedWeight, 0, 100);
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: { trust },
});
return { biasedWeight, trust };
}
async getNetLedgerValue(characterId: string, userId: string): Promise<number> {
const relationship = await this.prisma.client.relationship.findUnique({
where: { characterId_userId: { characterId, userId } },
});
if (!relationship) {
return 0;
}
const aggregate = await this.prisma.client.sentimentLedgerEntry.aggregate({
where: { relationshipId: relationship.id },
_sum: { weight: true },
});
return aggregate._sum.weight ?? 0;
}
}
@@ -0,0 +1,26 @@
import { Injectable } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { RELATIONSHIP_STAGE_CHANGED_EVENT } from "./constants.js";
export interface StageChangedPayload {
characterId: string;
userId: string;
fromStage: string;
toStage: string;
}
// 示範「跨階事件可被其他子系統訂閱」:本服務就是一個獨立訂閱者,只透過 @OnEvent 接收,
// 不直接呼叫 RelationshipService——證明分層解耦是真的成立,而不是同一支程式碼假裝訂閱。
@Injectable()
export class StageChangeLogService {
private readonly log: StageChangedPayload[] = [];
@OnEvent(RELATIONSHIP_STAGE_CHANGED_EVENT)
handleStageChanged(payload: StageChangedPayload): void {
this.log.push(payload);
}
getLog(): StageChangedPayload[] {
return [...this.log];
}
}
+20
View File
@@ -29,6 +29,7 @@
"@kokorone/shared": "*", "@kokorone/shared": "*",
"@nestjs/common": "^11.1.29", "@nestjs/common": "^11.1.29",
"@nestjs/core": "^11.1.29", "@nestjs/core": "^11.1.29",
"@nestjs/event-emitter": "^3.1.0",
"@nestjs/platform-express": "^11.1.29", "@nestjs/platform-express": "^11.1.29",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
@@ -1342,6 +1343,19 @@
} }
} }
}, },
"node_modules/@nestjs/event-emitter": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.1.0.tgz",
"integrity": "sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/platform-express": { "node_modules/@nestjs/platform-express": {
"version": "11.1.29", "version": "11.1.29",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz",
@@ -5594,6 +5608,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/expand-template": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -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;
+12 -9
View File
@@ -51,6 +51,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
relationships Relationship[] relationships Relationship[]
relatedMemories EpisodicMemory[]
@@map("users") @@map("users")
} }
@@ -72,7 +73,7 @@ model Work {
model Character { model Character {
id String @id @default(cuid()) id String @id @default(cuid())
workId String? workId String?
work Work? @relation(fields: [workId], references: [id]) work Work? @relation(fields: [workId], references: [id], onDelete: SetNull)
source CharacterSource source CharacterSource
buildStatus CharacterBuildStatus @default(CANDIDATE) buildStatus CharacterBuildStatus @default(CANDIDATE)
@@ -102,7 +103,7 @@ model Character {
model CharacterAlias { model CharacterAlias {
id String @id @default(cuid()) id String @id @default(cuid())
characterId String characterId String
character Character @relation(fields: [characterId], references: [id]) character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
formalName String // 正式名 formalName String // 正式名
nickname String? // 暱稱 nickname String? // 暱稱
calledByOthers String? // 他人稱呼(作品中其他角色如何稱呼) calledByOthers String? // 他人稱呼(作品中其他角色如何稱呼)
@@ -114,7 +115,7 @@ model CharacterAlias {
model EpisodicMemory { model EpisodicMemory {
id String @id @default(cuid()) id String @id @default(cuid())
characterId String characterId String
character Character @relation(fields: [characterId], references: [id]) character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
content String content String
occurredAt DateTime // 發生時間 occurredAt DateTime // 發生時間
emotionTag EmotionTag emotionTag EmotionTag
@@ -123,6 +124,8 @@ model EpisodicMemory {
lastRetrievedAt DateTime? lastRetrievedAt DateTime?
source MemorySource source MemorySource
weight Float @default(1) // 權重(供遺忘/檢索排序使用) weight Float @default(1) // 權重(供遺忘/檢索排序使用)
relatedUserId String? // 這段記憶與哪位使用者相關(E-5 關係加權檢索用)
relatedUser User? @relation(fields: [relatedUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@map("episodic_memories") @@map("episodic_memories")
@@ -131,7 +134,7 @@ model EpisodicMemory {
model SemanticMemory { model SemanticMemory {
id String @id @default(cuid()) id String @id @default(cuid())
characterId String characterId String
character Character @relation(fields: [characterId], references: [id]) character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
fact String // 去情境化事實 fact String // 去情境化事實
about String // 對象(關於誰/什麼) about String // 對象(關於誰/什麼)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -142,7 +145,7 @@ model SemanticMemory {
model ProceduralRule { model ProceduralRule {
id String @id @default(cuid()) id String @id @default(cuid())
characterId String characterId String
character Character @relation(fields: [characterId], references: [id]) character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
situation String // 情境 situation String // 情境
responsePattern String // 回應模式 responsePattern String // 回應模式
weight Float @default(1) weight Float @default(1)
@@ -155,7 +158,7 @@ model ProceduralRule {
model EmotionState { model EmotionState {
id String @id @default(cuid()) id String @id @default(cuid())
characterId String @unique characterId String @unique
character Character @relation(fields: [characterId], references: [id]) character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
calm Float @default(100) // 平靜 calm Float @default(100) // 平靜
joy Float @default(0) // 愉悅 joy Float @default(0) // 愉悅
@@ -172,9 +175,9 @@ model EmotionState {
model Relationship { model Relationship {
id String @id @default(cuid()) id String @id @default(cuid())
characterId String characterId String
character Character @relation(fields: [characterId], references: [id]) character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
intimacy Int @default(0) // 親密度 0~100 intimacy Int @default(0) // 親密度 0~100
trust Int @default(0) // 信任度 0~100 trust Int @default(0) // 信任度 0~100
@@ -195,7 +198,7 @@ model Relationship {
model SentimentLedgerEntry { model SentimentLedgerEntry {
id String @id @default(cuid()) id String @id @default(cuid())
relationshipId String relationshipId String
relationship Relationship @relation(fields: [relationshipId], references: [id]) relationship Relationship @relation(fields: [relationshipId], references: [id], onDelete: Cascade)
date DateTime date DateTime
event String event String
weight Float // 允許正負:正向事件為正值,負向事件為負值 weight Float // 允許正負:正向事件為正值,負向事件為負值
+1
View File
@@ -88,6 +88,7 @@ async function main() {
emotionIntensity: 0.9, emotionIntensity: 0.9,
source: "INTERACTION" as const, source: "INTERACTION" as const,
weight: 5, weight: 5,
relatedUserId: user.id,
}; };
await prisma.episodicMemory.upsert({ await prisma.episodicMemory.upsert({
where: { id: "seed-memory-episodic-cheer" }, where: { id: "seed-memory-episodic-cheer" },
+154
View File
@@ -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] } } });
}
+14 -7
View File
@@ -174,13 +174,20 @@ flowchart TB
### E. 關係子系統 ### E. 關係子系統
- [ ] **E-1 親密度與信任讀寫(XS)**:實作關係檔案的建立(未知對象預設「禮貌+防備」初始值)與讀寫服務。驗收:新對象首次互動自動建檔。依據:§關係如何影響對話行為「未知 → 建立新關係檔案,預設:禮貌+防備」。 - [x] **E-1 親密度與信任讀寫(XS)**:實作關係檔案的建立(未知對象預設「禮貌+防備」初始值)與讀寫服務。驗收:新對象首次互動自動建檔。依據:§關係如何影響對話行為「未知 → 建立新關係檔案,預設:禮貌+防備」。
- [ ] **E-2 關係帳本與負向偏誤(S)**:互動事件寫入 `SentimentLedgerEntry`,負向事件權重放大係數可由角色參數調整。驗收:同等級正負事件各一次後,淨值為負。依據:§關係如何被大腦儲存與更新「負向偏誤:一次背叛抵銷多次善意」。 - [x] **E-2 關係帳本與負向偏誤(S)**:互動事件寫入 `SentimentLedgerEntry`,負向事件權重放大係數可由角色參數調整。驗收:同等級正負事件各一次後,淨值為負。依據:§關係如何被大腦儲存與更新「負向偏誤:一次背叛抵銷多次善意」。
- [ ] **E-3 親密度分層(S)**:實作 陌生 0-19 /認識 20-39 /朋友 40-59 /摯友-曖昧 60-79 /羈絆 80-100 五階段與跨階解鎖旗標。驗收:跨越門檻時發出可被其他子系統訂閱的事件。依據:§動漫式關係進展(好感度系統)。 - [x] **E-3 親密度分層(S)**:實作 陌生 0-19 /認識 20-39 /朋友 40-59 /摯友-曖昧 60-79 /羈絆 80-100 五階段與跨階解鎖旗標。驗收:跨越門檻時發出可被其他子系統訂閱的事件。依據:§動漫式關係進展(好感度系統)。
- [ ] **E-4 關係時間衰減(S)**:久未互動自動降親密度,重逢時開場語氣可讀取「久未見」旗標。驗收:模擬長時間未互動後親密度下降且旗標為真。依據:§映射到聊天系統:關係模型「關係衰減:時間衰減函數」。 - [x] **E-4 關係時間衰減(S)**:久未互動自動降親密度,重逢時開場語氣可讀取「久未見」旗標。驗收:模擬長時間未互動後親密度下降且旗標為真。依據:§映射到聊天系統:關係模型「關係衰減:時間衰減函數」。
- [ ] **E-5 關係加權檢索接點(S)**:把「與當前對象相關」納入 C-6 檢索權重。驗收:與 A 對話時 A 相關記憶排序優先於同分數的無關記憶。依據:§與其他子系統的整合「關係 × 記憶」。 - [x] **E-5 關係加權檢索接點(S)**:把「與當前對象相關」納入 C-6 檢索權重。驗收:與 A 對話時 A 相關記憶排序優先於同分數的無關記憶。依據:§與其他子系統的整合「關係 × 記憶」。
- [ ] **E-6 意圖推測(M)**:依對象歷史互動模式解讀當前訊息(同一句話,高親密度判為玩笑、低親密度判為冒犯),輸出解讀標記供生成層使用。驗收:相同輸入在高/低親密度下得到不同解讀標記。依據:§映射到聊天系統:關係模型「心智理論 → 意圖推測」。 - [x] **E-6 意圖推測(M)**:依對象歷史互動模式解讀當前訊息(同一句話,高親密度判為玩笑、低親密度判為冒犯),輸出解讀標記供生成層使用。驗收:相同輸入在高/低親密度下得到不同解讀標記。依據:§映射到聊天系統:關係模型「心智理論 → 意圖推測」。
- [ ] **E-V 階段驗證(XS)**:`npm run restart && npm run smoke -- E`(E.mjs:帳本累積、負向偏誤、分層跨越事件、衰減、關係加權檢索、意圖推測差異)。 - [x] **E-V 階段驗證(XS)**:`npm run restart && npm run smoke -- E`(E.mjs:帳本累積、負向偏誤、分層跨越事件、衰減、關係加權檢索、意圖推測差異)。
> **實作記錄(E 群組)**:
> - 關係引擎放在 `apps/api/src/relationship/`。E-3 跨階事件用 `@nestjs/event-emitter`(`EventEmitterModule.forRoot()` 已在 `app.module.ts` 註冊),`RelationshipService` 只負責 `emit`,實際訂閱者是獨立的 `StageChangeLogService`(`@OnEvent` 訂閱),兩者互不直接呼叫——之後 I/J 群組要訂閱「關係跨階」時比照這個模式加一個新的訂閱者即可,不要改 `RelationshipService` 本身。
> - E-5 為此新增了 `EpisodicMemory.relatedUserId`(`prisma/migrations/20260813022124_add_episodic_memory_related_user`),讓記憶可以標記「與哪位使用者相關」;`KeywordMemoryRetriever.retrieve()` 簽名因此從 `(characterId, query, limit?)` 改成 `(characterId, query, options?: { limit?, relatedUserId? })`——C 群組完成後才加的參數,之後任何呼叫端都要用新簽名。
> - 親密度只用一條「日常互動」通道累積(`recordInteraction`,預設每次 +2),信任則只透過 `SentimentLedgerService.recordEvent` 的負向偏誤事件調整;兩者刻意分開更新,因為 L 群組的「心動值」也會是第三條獨立通道,現在先把「親密度/信任」两條分清楚,之後加心動值不會互相污染。
> - **重要教訓(本機環境限定,務必記住)**:`prisma migrate dev`/`generate`/`resolve` 這類 CLI 指令在本機環境會呼叫一個網路 checkpoint telemetry(`runCheckpointClientCheck`),此環境下該呼叫**不穩定**,曾多次造成指令看似「卡住無輸出」長達 20~30 秒以上。之後所有 Prisma CLI 呼叫一律加上 `CHECKPOINT_DISABLE=1` 環境變數(例:`CHECKPOINT_DISABLE=1 npx prisma migrate dev ...`),可完全避開這個網路呼叫。
> - **更重要的教訓**:本群組實際發生過一次「`migrate dev` 被判定卡住而中止,結果是 migration 已經對部份資料表執行完成」的部分套用(SQLite 對每張 `RedefineTable` 似乎是個別提交,不是整批一個交易)。之後若怀疑某次 `migrate dev`/`migrate deploy` 執行到一半被中斷:**先跑 `CHECKPOINT_DISABLE=1 npx prisma migrate status` 確認 `_prisma_migrations` 是否有 `finished_at` 為空的紀錄**,若有,需要用 `better-sqlite3` 直接檢查每張受影響資料表的 `sqlite_master.sql`(比對是否已含目標 schema/有無殘留的 `new_<table>` 暫存表),**手動補完尚未套用的部分**(可從 `migration.sql` 擷取對應片段執行),最後才下 `prisma migrate resolve --applied <migration_name>` 讓追蹤表與實際狀態一致。絕不可以在部分套用的狀態下直接重跑整份 `migrate dev`/`deploy`,也絕不可以不經確認就對 `_prisma_migrations` 動手。
### F. 對話生成管線與 LLM Provider 抽象 ### F. 對話生成管線與 LLM Provider 抽象