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
+9 -1
View File
@@ -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 };
});
+5 -1
View File
@@ -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 };
}
+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];
}
}