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:
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<EpisodicMemory[]>;
|
||||
retrieve(characterId: string, query: string, options?: RetrieveOptions): Promise<EpisodicMemory[]>;
|
||||
}
|
||||
|
||||
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<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 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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 // 允許正負:正向事件為正值,負向事件為負值
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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] } } });
|
||||
}
|
||||
@@ -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_<table>` 暫存表),**手動補完尚未套用的部分**(可從 `migration.sql` 擷取對應片段執行),最後才下 `prisma migrate resolve --applied <migration_name>` 讓追蹤表與實際狀態一致。絕不可以在部分套用的狀態下直接重跑整份 `migrate dev`/`deploy`,也絕不可以不經確認就對 `_prisma_migrations` 動手。
|
||||
|
||||
### F. 對話生成管線與 LLM Provider 抽象
|
||||
|
||||
|
||||
Reference in New Issue
Block a user