diff --git a/apps/api/package.json b/apps/api/package.json index fc905a0..21f2098 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@kokorone/shared": "*", "@nestjs/common": "^11.1.29", "@nestjs/core": "^11.1.29", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/platform-express": "^11.1.29", "dotenv": "^17.4.2", "node-cron": "^4.6.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c99b76d..95d4977 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,11 +1,19 @@ import { Module } from "@nestjs/common"; +import { EventEmitterModule } from "@nestjs/event-emitter"; import { HealthController } from "./health/health.controller.js"; import { CharactersModule } from "./characters/characters.module.js"; import { MemoryModule } from "./memory/memory.module.js"; import { EmotionModule } from "./emotion/emotion.module.js"; +import { RelationshipModule } from "./relationship/relationship.module.js"; @Module({ - imports: [CharactersModule, MemoryModule, EmotionModule], + imports: [ + EventEmitterModule.forRoot(), + CharactersModule, + MemoryModule, + EmotionModule, + RelationshipModule, + ], controllers: [HealthController], }) export class AppModule {} diff --git a/apps/api/src/memory/memory-retriever.service.ts b/apps/api/src/memory/memory-retriever.service.ts index 5aa98e5..4a6a959 100644 --- a/apps/api/src/memory/memory-retriever.service.ts +++ b/apps/api/src/memory/memory-retriever.service.ts @@ -2,14 +2,21 @@ import { Injectable } from "@nestjs/common"; import type { EpisodicMemory } from "@kokorone/shared"; import { PrismaService } from "../prisma/prisma.service.js"; +export interface RetrieveOptions { + limit?: number; + // E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。 + relatedUserId?: string; +} + export interface MemoryRetriever { - retrieve(characterId: string, query: string, limit?: number): Promise; + retrieve(characterId: string, query: string, options?: RetrieveOptions): Promise; } const WEIGHTS = { - keyword: 0.5, - recency: 0.3, - emotion: 0.2, + keyword: 0.4, + recency: 0.25, + emotion: 0.15, + relationship: 0.2, }; const DEFAULT_LIMIT = 5; @@ -28,7 +35,8 @@ function keywordRelevance(content: string, keywords: string[]): number { export class KeywordMemoryRetriever implements MemoryRetriever { constructor(private readonly prisma: PrismaService) {} - async retrieve(characterId: string, query: string, limit = DEFAULT_LIMIT): Promise { + async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise { + const limit = options.limit ?? DEFAULT_LIMIT; const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } }); const now = Date.now(); 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 recencyScore = 1 / (1 + Math.max(0, daysSince)); const emotionScore = row.emotionIntensity; + const relationshipScore = options.relatedUserId && row.relatedUserId === options.relatedUserId ? 1 : 0; 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 }; }); diff --git a/apps/api/src/memory/memory.controller.ts b/apps/api/src/memory/memory.controller.ts index b6c9e5e..4652474 100644 --- a/apps/api/src/memory/memory.controller.ts +++ b/apps/api/src/memory/memory.controller.ts @@ -54,8 +54,12 @@ export class MemoryController { @Param("characterId") characterId: string, @Query("query") query: 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 }; } diff --git a/apps/api/src/relationship/constants.ts b/apps/api/src/relationship/constants.ts new file mode 100644 index 0000000..843ed9d --- /dev/null +++ b/apps/api/src/relationship/constants.ts @@ -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"; diff --git a/apps/api/src/relationship/intent-interpreter.service.ts b/apps/api/src/relationship/intent-interpreter.service.ts new file mode 100644 index 0000000..5e30b0d --- /dev/null +++ b/apps/api/src/relationship/intent-interpreter.service.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/relationship/relationship.controller.ts b/apps/api/src/relationship/relationship.controller.ts new file mode 100644 index 0000000..31225ce --- /dev/null +++ b/apps/api/src/relationship/relationship.controller.ts @@ -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); + } +} diff --git a/apps/api/src/relationship/relationship.module.ts b/apps/api/src/relationship/relationship.module.ts new file mode 100644 index 0000000..55c1937 --- /dev/null +++ b/apps/api/src/relationship/relationship.module.ts @@ -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 {} diff --git a/apps/api/src/relationship/relationship.service.ts b/apps/api/src/relationship/relationship.service.ts new file mode 100644 index 0000000..8a4a3ca --- /dev/null +++ b/apps/api/src/relationship/relationship.service.ts @@ -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>; 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 }); + } +} diff --git a/apps/api/src/relationship/sentiment-ledger.service.ts b/apps/api/src/relationship/sentiment-ledger.service.ts new file mode 100644 index 0000000..cfd9406 --- /dev/null +++ b/apps/api/src/relationship/sentiment-ledger.service.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/relationship/stage-change-log.service.ts b/apps/api/src/relationship/stage-change-log.service.ts new file mode 100644 index 0000000..3e98bf6 --- /dev/null +++ b/apps/api/src/relationship/stage-change-log.service.ts @@ -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]; + } +} diff --git a/package-lock.json b/package-lock.json index 62d825e..cd23ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@kokorone/shared": "*", "@nestjs/common": "^11.1.29", "@nestjs/core": "^11.1.29", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/platform-express": "^11.1.29", "dotenv": "^17.4.2", "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": { "version": "11.1.29", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz", @@ -5594,6 +5608,12 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", diff --git a/prisma/migrations/20260813022124_add_episodic_memory_related_user/migration.sql b/prisma/migrations/20260813022124_add_episodic_memory_related_user/migration.sql new file mode 100644 index 0000000..2848bf4 --- /dev/null +++ b/prisma/migrations/20260813022124_add_episodic_memory_related_user/migration.sql @@ -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; diff --git a/prisma/migrations/20260813023517_add_cascade_delete_rules/migration.sql b/prisma/migrations/20260813023517_add_cascade_delete_rules/migration.sql new file mode 100644 index 0000000..f498b70 --- /dev/null +++ b/prisma/migrations/20260813023517_add_cascade_delete_rules/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 31ce863..4cf240e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 // 允許正負:正向事件為正值,負向事件為負值 diff --git a/prisma/seed.ts b/prisma/seed.ts index 93ae4fb..6b48243 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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" }, diff --git a/scripts/smoke/E.mjs b/scripts/smoke/E.mjs new file mode 100644 index 0000000..c6785bc --- /dev/null +++ b/scripts/smoke/E.mjs @@ -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] } } }); +} diff --git a/todo.md b/todo.md index c51c4ec..f163384 100644 --- a/todo.md +++ b/todo.md @@ -174,13 +174,20 @@ flowchart TB ### E. 關係子系統 -- [ ] **E-1 親密度與信任讀寫(XS)**:實作關係檔案的建立(未知對象預設「禮貌+防備」初始值)與讀寫服務。驗收:新對象首次互動自動建檔。依據:§關係如何影響對話行為「未知 → 建立新關係檔案,預設:禮貌+防備」。 -- [ ] **E-2 關係帳本與負向偏誤(S)**:互動事件寫入 `SentimentLedgerEntry`,負向事件權重放大係數可由角色參數調整。驗收:同等級正負事件各一次後,淨值為負。依據:§關係如何被大腦儲存與更新「負向偏誤:一次背叛抵銷多次善意」。 -- [ ] **E-3 親密度分層(S)**:實作 陌生 0-19 /認識 20-39 /朋友 40-59 /摯友-曖昧 60-79 /羈絆 80-100 五階段與跨階解鎖旗標。驗收:跨越門檻時發出可被其他子系統訂閱的事件。依據:§動漫式關係進展(好感度系統)。 -- [ ] **E-4 關係時間衰減(S)**:久未互動自動降親密度,重逢時開場語氣可讀取「久未見」旗標。驗收:模擬長時間未互動後親密度下降且旗標為真。依據:§映射到聊天系統:關係模型「關係衰減:時間衰減函數」。 -- [ ] **E-5 關係加權檢索接點(S)**:把「與當前對象相關」納入 C-6 檢索權重。驗收:與 A 對話時 A 相關記憶排序優先於同分數的無關記憶。依據:§與其他子系統的整合「關係 × 記憶」。 -- [ ] **E-6 意圖推測(M)**:依對象歷史互動模式解讀當前訊息(同一句話,高親密度判為玩笑、低親密度判為冒犯),輸出解讀標記供生成層使用。驗收:相同輸入在高/低親密度下得到不同解讀標記。依據:§映射到聊天系統:關係模型「心智理論 → 意圖推測」。 -- [ ] **E-V 階段驗證(XS)**:`npm run restart && npm run smoke -- E`(E.mjs:帳本累積、負向偏誤、分層跨越事件、衰減、關係加權檢索、意圖推測差異)。 +- [x] **E-1 親密度與信任讀寫(XS)**:實作關係檔案的建立(未知對象預設「禮貌+防備」初始值)與讀寫服務。驗收:新對象首次互動自動建檔。依據:§關係如何影響對話行為「未知 → 建立新關係檔案,預設:禮貌+防備」。 +- [x] **E-2 關係帳本與負向偏誤(S)**:互動事件寫入 `SentimentLedgerEntry`,負向事件權重放大係數可由角色參數調整。驗收:同等級正負事件各一次後,淨值為負。依據:§關係如何被大腦儲存與更新「負向偏誤:一次背叛抵銷多次善意」。 +- [x] **E-3 親密度分層(S)**:實作 陌生 0-19 /認識 20-39 /朋友 40-59 /摯友-曖昧 60-79 /羈絆 80-100 五階段與跨階解鎖旗標。驗收:跨越門檻時發出可被其他子系統訂閱的事件。依據:§動漫式關係進展(好感度系統)。 +- [x] **E-4 關係時間衰減(S)**:久未互動自動降親密度,重逢時開場語氣可讀取「久未見」旗標。驗收:模擬長時間未互動後親密度下降且旗標為真。依據:§映射到聊天系統:關係模型「關係衰減:時間衰減函數」。 +- [x] **E-5 關係加權檢索接點(S)**:把「與當前對象相關」納入 C-6 檢索權重。驗收:與 A 對話時 A 相關記憶排序優先於同分數的無關記憶。依據:§與其他子系統的整合「關係 × 記憶」。 +- [x] **E-6 意圖推測(M)**:依對象歷史互動模式解讀當前訊息(同一句話,高親密度判為玩笑、低親密度判為冒犯),輸出解讀標記供生成層使用。驗收:相同輸入在高/低親密度下得到不同解讀標記。依據:§映射到聊天系統:關係模型「心智理論 → 意圖推測」。 +- [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_` 暫存表),**手動補完尚未套用的部分**(可從 `migration.sql` 擷取對應片段執行),最後才下 `prisma migrate resolve --applied ` 讓追蹤表與實際狀態一致。絕不可以在部分套用的狀態下直接重跑整份 `migrate dev`/`deploy`,也絕不可以不經確認就對 `_prisma_migrations` 動手。 ### F. 對話生成管線與 LLM Provider 抽象