diff --git a/.env.example b/.env.example index 86cd53c..2a708ce 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,10 @@ EMBED_BASE_URL=https://openrouter.ai/api/v1 REFLECT_EVERY_N=5 SUMMARIZE_THRESHOLD=100 SUMMARIZE_BATCH_SIZE=50 +# Phase 29 — vector retrieval forgetting curve (Ebbinghaus; S = max(ε, S0 × importance/5)) +MEMORY_RECENCY_HALFLIFE_HOURS=72 +MEMORY_RECENCY_FLOOR=0.3 +MEMORY_RECENCY_S_EPSILON=0.001 # Phase 11 — chunk lore LLM (T0/T1 tiering) LLM_PROVIDER_LORE=agnes diff --git a/README.md b/README.md index 936dbbd..03ec984 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ AI-driven multiplayer life-simulation web game: guide memory-bearing NPCs throug - [Testing](#testing) - [Documentation](#documentation) - [Contributing](#contributing) +- [Asset Credits](#asset-credits) - [License](#license) ## About @@ -230,6 +231,12 @@ Action schema: [packages/game-actions/README.md](./packages/game-actions/README. Issues and PRs welcome. Read [CONTRIBUTING.md](./CONTRIBUTING.md) first. Never commit `.env` or API keys. +## Asset Credits + +| Asset | Author | Use | +|-------|--------|-----| +| [The Fantasy Tileset](https://ventilatore.itch.io/the-fantasy-tileset) | [Ventilatore](https://ventilatore.itch.io) | Beginning Fields home map (16×16 tiles, Tiled) | + ## License [MIT](./LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index 2a93583..5b93771 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,6 +20,7 @@ AI 驱动的多人联机生活模拟 Web 游戏:与拥有记忆的 NPC 用自 - [测试](#测试) - [文档](#文档) - [贡献](#贡献) +- [素材贡献](#素材贡献) - [License](#license) ## 项目简介 @@ -231,6 +232,12 @@ Action schema:[packages/game-actions/README.md](./packages/game-actions/README 欢迎 Issue 与 PR。请先阅读 [CONTRIBUTING.md](./CONTRIBUTING.md)。请勿提交 `.env` 或 API 密钥。 +## 素材贡献 + +| 素材 | 作者 | 用途 | +|------|------|------| +| [The Fantasy Tileset](https://ventilatore.itch.io/the-fantasy-tileset) | [Ventilatore](https://ventilatore.itch.io) | 始源区(Beginning Fields)家园地图(16×16 瓦片,Tiled) | + ## License [MIT](./LICENSE) diff --git a/apps/ai-gateway/app/guards/reply.py b/apps/ai-gateway/app/guards/reply.py index 5998ae1..a6e3801 100644 --- a/apps/ai-gateway/app/guards/reply.py +++ b/apps/ai-gateway/app/guards/reply.py @@ -5,11 +5,14 @@ import re from typing import Any -FALLBACK_REPLY = "我还不能确定能否那样做——让我先试着与房间互动。" +FALLBACK_REPLY = "抱歉,我这边还做不到。" STATE_CHANGE_PATTERNS = [ - re.compile(r"\b(opened|closed|moved|picked up|picked|walked|went to|entered|left)\b", re.I), - re.compile(r"(打开了|关闭了|移动到|走向|拿起了|捡起了|去把门打开|就去打开|把门打开|我现在就去)", re.I), + re.compile(r"\b(opened|closed|picked up|walked|went to|entered|moved to)\b", re.I), + re.compile( + r"(打开了|关闭了|移动到|走向了|走向门|拿起了|捡起了|去把门打开|就去打开|把门打开|我现在就去(?:把门|打开|拿|捡|走|过去))", + re.I, + ), ] STATE_CHANGING_TOOLS = frozenset({"move", "interact", "transfer", "wait"}) diff --git a/apps/ai-gateway/tests/test_check_reply.py b/apps/ai-gateway/tests/test_check_reply.py index 72f61d8..ecca25c 100644 --- a/apps/ai-gateway/tests/test_check_reply.py +++ b/apps/ai-gateway/tests/test_check_reply.py @@ -12,3 +12,12 @@ def test_allows_with_interact_tool(): def test_allows_neutral(): text = "需要我帮你做什么吗?" assert audit_reply(text, []) == text + + +def test_allows_emotional_moved_without_tools(): + reply = "I'm moved that you asked, but I won't say my age." + assert audit_reply(reply, []) == reply + + +def test_fallback_is_chat_neutral(): + assert "房间" not in FALLBACK_REPLY diff --git a/apps/game-server/src/collective/service.test.ts b/apps/game-server/src/collective/service.test.ts index 2c2d927..a32294f 100644 --- a/apps/game-server/src/collective/service.test.ts +++ b/apps/game-server/src/collective/service.test.ts @@ -1,11 +1,37 @@ -import { describe, expect, it, beforeEach } from "vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { CollectiveRepository } from "@aetherlife/npc-memory"; -import { KIND_FIXED_DELTA, personalitySeedForNpc } from "@aetherlife/shared"; +import { + KIND_FIXED_DELTA, + PROPAGATION_MAX_FANOUT, + personalitySeedForNpc, + type RelationshipEdgePublic, +} from "@aetherlife/shared"; import { CollectiveService } from "./service.js"; +import * as npcRelationships from "../world/npc-relationships-repository.js"; + +function propEdge( + partial: Pick, +): RelationshipEdgePublic { + return { + trust: 50, + interactionCount: 0, + lastInteractAt: null, + currentStatus: [], + historySummary: "", + updatedAt: new Date().toISOString(), + ...partial, + }; +} describe("CollectiveService", () => { beforeEach(() => { CollectiveService.resetForTests(new CollectiveRepository(null)); + // Avoid real Postgres when DATABASE_URL is set (propagation list can hang / flake). + vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockResolvedValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("records event when two players in window", async () => { @@ -86,4 +112,141 @@ describe("CollectiveService", () => { const rep = await repo.getAttitude("solo-worker", "npc-1", "p-a"); expect(rep).toBe(personalitySeedForNpc("npc-1") + (-8)); }); + + it("propagation applies friend reputation without extra collective events", async () => { + const listSpy = vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockResolvedValue([ + propEdge({ + npcAId: "npc-1", + npcBId: "npc-friend", + baseTag: "ally", + affection: 50, + }), + ]); + + const svc = CollectiveService.getInstance(); + const repo = svc.repoRef(); + const result = await svc.recordRuleEvent({ + roomId: "prop-room", + npcId: "npc-1", + kind: "rude", + summary: "玩家粗鲁", + playerIds: ["p-a"], + npcPositions: new Map([["npc-1", { x: 0, y: 0 }]]), + singlePlayerOk: true, + }); + + expect(listSpy).toHaveBeenCalledWith( + "prop-room", + expect.objectContaining({ + npcId: "npc-1", + limit: PROPAGATION_MAX_FANOUT * 5, + }), + ); + expect(result).toEqual(expect.objectContaining({ recorded: true })); + const events = await repo.listEventsInWindow("prop-room", "npc-1", 300_000); + expect(events).toHaveLength(1); + + const friendRep = await repo.getAttitude("prop-room", "npc-friend", "p-a"); + expect(friendRep).toBe(personalitySeedForNpc("npc-friend") + Math.round(-8 * 0.3)); + }); + + it("recordWorkerEvent propagation keeps event count === 1", async () => { + vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockResolvedValue([ + propEdge({ + npcAId: "npc-1", + npcBId: "npc-friend", + baseTag: "ally", + affection: 50, + }), + ]); + + const svc = CollectiveService.getInstance(); + const repo = svc.repoRef(); + await svc.recordWorkerEvent({ + roomId: "prop-worker", + npcId: "npc-1", + kind: "rude", + summary: "worker rude", + playerIds: ["p-a"], + deltaScore: -8, + npcPositions: new Map([["npc-1", { x: 0, y: 0 }]]), + }); + + const events = await repo.listEventsInWindow("prop-worker", "npc-1", 300_000); + expect(events).toHaveLength(1); + const friendRep = await repo.getAttitude("prop-worker", "npc-friend", "p-a"); + expect(friendRep).toBe(personalitySeedForNpc("npc-friend") - 2); + }); + + it("relationship list failure does not throw out of recordRuleEvent", async () => { + vi.spyOn(npcRelationships, "listRelationshipsForRoom").mockRejectedValueOnce( + new Error("db unavailable"), + ); + + const svc = CollectiveService.getInstance(); + const repo = svc.repoRef(); + const result = await svc.recordRuleEvent({ + roomId: "prop-fail", + npcId: "npc-1", + kind: "help", + summary: "help", + playerIds: ["p-a", "p-b"], + npcPositions: new Map([["npc-1", { x: 0, y: 0 }]]), + }); + + expect(result).toEqual(expect.objectContaining({ recorded: true })); + const rep = await repo.getAttitude("prop-fail", "npc-1", "p-a"); + expect(rep).toBe(personalitySeedForNpc("npc-1") + KIND_FIXED_DELTA.help); + }); + + it("getCollectiveContext carries semantic from attitude row (D-BELIEF-05)", async () => { + const svc = CollectiveService.getInstance(); + const repo = svc.repoRef(); + await repo.upsertSemanticState("r-sem-ctx", "npc-1", "p-a", { + mood: "恼火", + beliefs: ["我不信他的承诺"], + summary: "玩家曾失信", + }); + + const ctx = await svc.getCollectiveContext("r-sem-ctx", "npc-1", "p-a"); + expect(ctx.currentMood).toBe("恼火"); + expect(ctx.keyBeliefs).toEqual(["我不信他的承诺"]); + expect(ctx.summary).toBe("玩家曾失信"); + expect(ctx.band).toBeTruthy(); + expect(ctx.allowedTools.length).toBeGreaterThan(0); + expect(typeof ctx.playerReputation).toBe("number"); + }); + + it("getCollectiveContext omits semantic when no attitude row", async () => { + const svc = CollectiveService.getInstance(); + const ctx = await svc.getCollectiveContext("r-no-att", "npc-1", "p-missing"); + expect(ctx.currentMood).toBeNull(); + expect(ctx.keyBeliefs).toBeNull(); + expect(ctx.summary).toBeNull(); + expect(ctx.playerReputation).toBe(personalitySeedForNpc("npc-1")); + }); + + it("getCollectiveState public payload strips mood/beliefs/summary (D-BELIEF-11)", async () => { + const svc = CollectiveService.getInstance(); + const repo = svc.repoRef(); + await repo.upsertSemanticState("default", "npc-1", "p-pub", { + mood: "戏谑", + beliefs: ["他爱开玩笑"], + summary: "内部摘要勿泄露", + }); + + const ctx = await svc.getCollectiveContext("default", "npc-1", "p-pub"); + expect(ctx.currentMood).toBe("戏谑"); + + const publicPayload = await svc.getCollectiveState("default", "p-pub", "npc-1"); + const row = publicPayload.attitudes.find((a) => a.npcId === "npc-1"); + expect(row).toBeDefined(); + expect(row).not.toHaveProperty("currentMood"); + expect(row).not.toHaveProperty("keyBeliefs"); + expect(row).not.toHaveProperty("summary"); + expect(row).not.toHaveProperty("mood"); + expect(row).not.toHaveProperty("beliefs"); + expect(JSON.stringify(publicPayload)).not.toContain("戏谑"); + expect(JSON.stringify(publicPayload)).not.toContain("内部摘要勿泄露"); + }); }); diff --git a/apps/game-server/src/collective/service.ts b/apps/game-server/src/collective/service.ts index e5e0921..92d12bd 100644 --- a/apps/game-server/src/collective/service.ts +++ b/apps/game-server/src/collective/service.ts @@ -2,14 +2,17 @@ import { COLLECTIVE_EVENT_TTL_MS, DEFAULT_COLLECTIVE_WINDOW_MS, KIND_FIXED_DELTA, + PROPAGATION_MAX_FANOUT, bandFromEffectiveScore, collectiveWindowMsFromEnv, computeEffectiveScore, + computeRelationshipPropagationDeltas, computeWitnessDeltas, personalitySeedForNpc, type AttitudeBand, type CollectiveEventKind, type CollectivePosition, + type WitnessDeltaUpdate, } from "@aetherlife/shared"; import { CollectiveRepository, @@ -21,6 +24,7 @@ import { allowedToolsForBand, type AllowedTool } from "./gate.js"; import { detectSpeakRule } from "./rule-detector.js"; import { getOrCreate } from "../room/store.js"; import { recordCollectiveEvent } from "../world/world-vote-trigger.js"; +import { listRelationshipsForRoom } from "../world/npc-relationships-repository.js"; export type RecordRuleEventInput = { roomId: string; @@ -44,6 +48,10 @@ export type CollectiveContext = { playerReputation: number; allowedTools: AllowedTool[]; recentSummaries: string[]; + /** Semantic state for worker/speak only (D-BELIEF-05); null when no attitude row. */ + currentMood: string | null; + keyBeliefs: string[] | null; + summary: string | null; }; export type CollectiveStateAttitude = { @@ -99,6 +107,49 @@ export class CollectiveService { return collectiveWindowMsFromEnv(process.env.COLLECTIVE_WINDOW_MS); } + /** + * 2-hop relationship reputation after witness (D-PROP). + * Reputation-only via applyReputationDelta — never insertEvent / recordCollectiveEvent. + */ + private async applyRelationshipPropagation(args: { + roomId: string; + targetNpcId: string; + eventDelta: number; + playerIds: readonly string[]; + witnessUpdates: readonly WitnessDeltaUpdate[]; + }): Promise { + const alreadyUpdated = new Set(args.witnessUpdates.map((u) => u.npcId)); + let edges; + try { + // Overfetch past FANOUT so alreadyUpdated / polarity / MIN_AFFECTION filters + // can still fill up to PROPAGATION_MAX_FANOUT neighbors (D-PROP / CR). + edges = await listRelationshipsForRoom(args.roomId, { + npcId: args.targetNpcId, + limit: PROPAGATION_MAX_FANOUT * 5, + }); + } catch (err) { + console.error("[collective] relationship propagation list failed", err); + return; + } + + const propUpdates = computeRelationshipPropagationDeltas({ + targetNpcId: args.targetNpcId, + eventDelta: args.eventDelta, + playerIds: args.playerIds, + edges, + alreadyUpdated, + }); + + for (const update of propUpdates) { + await this.repo.applyReputationDelta( + args.roomId, + update.npcId, + update.playerId, + update.delta, + ); + } + } + async recordRuleEvent(input: RecordRuleEventInput): Promise { const distinct = new Set(input.playerIds); if (distinct.size < 2 && !input.singlePlayerOk) { @@ -126,8 +177,9 @@ export class CollectiveService { const eventId = await this.repo.insertEvent(eventInput); recordCollectiveEvent(input.roomId, deltaScore); + const playerIds = [...distinct]; const witnessUpdates = computeWitnessDeltas( - { kind: input.kind, deltaScore, playerIds: [...distinct] }, + { kind: input.kind, deltaScore, playerIds }, input.npcId, input.npcPositions, ); @@ -141,6 +193,14 @@ export class CollectiveService { ); } + await this.applyRelationshipPropagation({ + roomId: input.roomId, + targetNpcId: input.npcId, + eventDelta: deltaScore, + playerIds, + witnessUpdates, + }); + await this.repo.pruneExpired(); return { recorded: true, eventId }; } @@ -183,6 +243,14 @@ export class CollectiveService { ); } + await this.applyRelationshipPropagation({ + roomId: input.roomId, + targetNpcId: input.npcId, + eventDelta: deltaScore, + playerIds: distinct, + witnessUpdates, + }); + await this.repo.pruneExpired(); return { eventId }; } @@ -207,8 +275,9 @@ export class CollectiveService { windowDeltas.length === 0 ? 0 : windowDeltas.reduce((sum, d) => sum + d, 0) / windowDeltas.length; + const attitudeRow = await this.repo.getAttitudeRow(roomId, npcId, playerId); const playerReputation = - (await this.repo.getAttitude(roomId, npcId, playerId)) ?? personalitySeedForNpc(npcId); + attitudeRow?.reputation ?? personalitySeedForNpc(npcId); const effectiveScore = computeEffectiveScore(playerReputation, windowDeltas); const band = bandFromEffectiveScore(effectiveScore); @@ -219,6 +288,9 @@ export class CollectiveService { playerReputation, allowedTools: allowedToolsForBand(band), recentSummaries: events.slice(0, 5).map((e) => e.summary), + currentMood: attitudeRow?.currentMood ?? null, + keyBeliefs: attitudeRow ? [...attitudeRow.keyBeliefs] : null, + summary: attitudeRow?.summary ?? null, }; } @@ -230,6 +302,7 @@ export class CollectiveService { const record = getOrCreate(roomId); const npcIds = filterNpcId ? [filterNpcId] : record.state.npcs.map((n) => n.id); + // D-BELIEF-11: public DTO — reputation/band only; never mood/keyBeliefs/summary. const attitudes = await Promise.all( npcIds.map(async (npcId) => { const ctx = await this.getCollectiveContext(roomId, npcId, playerId); @@ -240,7 +313,7 @@ export class CollectiveService { collectiveWindowMean: ctx.collectiveWindowMean, effectiveScore: ctx.effectiveScore, band: ctx.band, - }; + } satisfies CollectiveStateAttitude; }), ); diff --git a/apps/game-server/src/colyseus/npc-chat.ts b/apps/game-server/src/colyseus/npc-chat.ts index b626e07..08ca557 100644 --- a/apps/game-server/src/colyseus/npc-chat.ts +++ b/apps/game-server/src/colyseus/npc-chat.ts @@ -7,7 +7,7 @@ import { } from "@aetherlife/shared"; import { addNpcTurnJob } from "../queue/npc-turn.js"; import { getOrCreate } from "../room/store.js"; -import { getRecentTurns } from "../npc/dialogue-session.js"; +import { getRecentTurnsAsync } from "../npc/dialogue-session.js"; export function validateChatMessage(message: unknown): string | null { if (typeof message !== "string") return null; @@ -41,7 +41,8 @@ export async function startNpcChatTurn( options?: { casualPreviewEmitted?: boolean }, ): Promise { getOrCreate(roomId); - const recentTurns = getRecentTurns(roomId, playerId, npcId, 10); + // Async hydrate (Redis on Map miss) — speak stub in GameRoom/chat stays sync getRecentTurns. + const recentTurns = await getRecentTurnsAsync(roomId, playerId, npcId, 10); return addNpcTurnJob({ roomId, playerMessage: message, diff --git a/apps/game-server/src/memory/service.test.ts b/apps/game-server/src/memory/service.test.ts index 645a7c6..702a5af 100644 --- a/apps/game-server/src/memory/service.test.ts +++ b/apps/game-server/src/memory/service.test.ts @@ -1,9 +1,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { COUNCIL_MEMORY_PLAYER_ID } from "@aetherlife/shared"; -import { CollectiveRepository } from "@aetherlife/npc-memory"; +import { CollectiveRepository, EMBED_DIMENSIONS } from "@aetherlife/npc-memory"; import { CollectiveService } from "../collective/service.js"; import { MemoryService } from "./service.js"; +function unitEmbedding(): number[] { + const emb = new Array(EMBED_DIMENSIONS).fill(0); + emb[0] = 1; + return emb; +} + describe("MemoryService council scope guards", () => { beforeEach(() => { delete process.env.DATABASE_URL; @@ -66,4 +72,147 @@ describe("MemoryService council scope guards", () => { }); expect(ctx.retrieved.some((row) => row.text.includes("廷议表决"))).toBe(true); }); + + it("councilRecentAsRetrieved keeps fixed 0.55 × importanceFactor scoring (D-DECAY-05)", async () => { + const service = MemoryService.getInstance(); + await service.appendCouncilVoteMemories("room-score", [ + { npcId: "npc-1", vote: "yes", reasonZh: "秩序优先" }, + ]); + + const ctx = await service.buildCouncilMemoryContext("room-score", "npc-1", "议会", { + skipEmbed: true, + }); + const vote = ctx.retrieved.find((row) => row.text.includes("廷议表决")); + expect(vote).toBeDefined(); + const importance = 0.6; + expect(vote!.score).toBeCloseTo(0.55 * (0.5 + importance / 20), 10); + }); +}); + +describe("TestMemoryBackend forgetting-curve parity (D-DECAY-06)", () => { + beforeEach(() => { + delete process.env.DATABASE_URL; + MemoryService.resetForTests(); + CollectiveService.resetForTests(new CollectiveRepository(null)); + }); + + it("applies forgetting curve so fresher mid-importance beats stale high-importance", async () => { + const service = MemoryService.getInstance(); + const emb = unitEmbedding(); + const now = Date.now(); + + // Without recency, importance 10 (factor 1.0) would beat importance 5 (0.75). + // With floor 0.3, stale score ≤ 0.3 while fresh ≈ 0.75. + await service.seedMemoryForTests({ + roomId: "room-decay", + playerId: "p1", + npcId: "npc-1", + text: "stale-high", + importance: 10, + embedding: emb, + createdAt: new Date(now - 365 * 24 * 3600_000), + }); + await service.seedMemoryForTests({ + roomId: "room-decay", + playerId: "p1", + npcId: "npc-1", + text: "fresh-mid", + importance: 5, + embedding: emb, + createdAt: new Date(now - 1 * 3600_000), + }); + + const ctx = await service.buildMemoryContext("room-decay", "query", "npc-1", "p1"); + expect(ctx.retrieved[0]?.text).toBe("fresh-mid"); + }); +}); + +describe("MemoryService.storeReflection optional semantic (D-BELIEF-03/07/13)", () => { + beforeEach(() => { + delete process.env.DATABASE_URL; + MemoryService.resetForTests(); + CollectiveService.resetForTests(new CollectiveRepository(null)); + }); + + it("stores reflection text and upserts mood/beliefs on success", async () => { + const service = MemoryService.getInstance(); + await service.storeReflection("room-ref", "最近对方缓和了。", "npc-1", "p1", { + mood: "亲近", + beliefs: ["我愿意再给他一次机会"], + summary: "关系回暖", + }); + + const ctx = await service.buildMemoryContext("room-ref", "x", "npc-1", "p1", { + skipEmbed: true, + }); + expect(ctx.latestReflection).toBe("最近对方缓和了。"); + + const row = await CollectiveService.getInstance() + .repoRef() + .getAttitudeRow("room-ref", "npc-1", "p1"); + expect(row?.currentMood).toBe("亲近"); + expect(row?.keyBeliefs).toEqual(["我愿意再给他一次机会"]); + expect(row?.summary).toBe("关系回暖"); + }); + + it("illegal mood stores text only and preserves prior semantic", async () => { + const service = MemoryService.getInstance(); + const repo = CollectiveService.getInstance().repoRef(); + await repo.upsertSemanticState("room-keep", "npc-1", "p1", { + mood: "警惕", + beliefs: ["我不信他的承诺"], + summary: "旧摘要", + }); + + await service.storeReflection("room-keep", "散文反思。", "npc-1", "p1", { + mood: "angry", + beliefs: ["我怀疑他的动机"], + }); + + const ctx = await service.buildMemoryContext("room-keep", "x", "npc-1", "p1", { + skipEmbed: true, + }); + expect(ctx.latestReflection).toBe("散文反思。"); + + const row = await repo.getAttitudeRow("room-keep", "npc-1", "p1"); + expect(row?.currentMood).toBe("警惕"); + expect(row?.keyBeliefs).toEqual(["我怀疑他的动机"]); + expect(row?.summary).toBe("旧摘要"); + }); + + it("omitted semantic keys leave prior mood/beliefs unchanged", async () => { + const service = MemoryService.getInstance(); + const repo = CollectiveService.getInstance().repoRef(); + await repo.upsertSemanticState("room-omit", "npc-1", "p1", { + mood: "戏谑", + beliefs: ["我看穿了他"], + summary: "先有", + }); + + await service.storeReflection("room-omit", "只有散文。", "npc-1", "p1"); + + const row = await repo.getAttitudeRow("room-omit", "npc-1", "p1"); + expect(row?.currentMood).toBe("戏谑"); + expect(row?.keyBeliefs).toEqual(["我看穿了他"]); + expect(row?.summary).toBe("先有"); + }); + + it("buildMemoryContext.collective includes semantic for worker (D-BELIEF-05)", async () => { + const service = MemoryService.getInstance(); + const repo = CollectiveService.getInstance().repoRef(); + await repo.upsertSemanticState("room-mc-sem", "npc-1", "p1", { + mood: "愉悦", + beliefs: ["我信任他"], + summary: "近期友好", + }); + + const ctx = await service.buildMemoryContext("room-mc-sem", "你好", "npc-1", "p1", { + skipEmbed: true, + }); + expect(ctx.collective.currentMood).toBe("愉悦"); + expect(ctx.collective.keyBeliefs).toEqual(["我信任他"]); + expect(ctx.collective.summary).toBe("近期友好"); + expect(ctx.collective.band).toBeTruthy(); + expect(ctx.collective.allowedTools.length).toBeGreaterThan(0); + }); }); diff --git a/apps/game-server/src/memory/service.ts b/apps/game-server/src/memory/service.ts index f6c0093..75e5552 100644 --- a/apps/game-server/src/memory/service.ts +++ b/apps/game-server/src/memory/service.ts @@ -1,10 +1,11 @@ import { + computeWeightedScore, createDb, MemoryRepository, type SimilarMemory, type SummaryKind, } from "@aetherlife/npc-memory"; -import { COUNCIL_MEMORY_PLAYER_ID } from "@aetherlife/shared"; +import { clampSemanticState, COUNCIL_MEMORY_PLAYER_ID } from "@aetherlife/shared"; import { embedText } from "./embed.js"; import { scoreImportance } from "./importance.js"; import type { CollectiveContext } from "../collective/service.js"; @@ -47,6 +48,7 @@ export type MemoryContext = { latestBulkSummary: string | null; latestReflection: string | null; timingMs: number; + /** Includes semantic (mood/beliefs/summary) for worker speak — not public HTTP (D-BELIEF-11). */ collective: CollectiveContext; }; @@ -97,6 +99,7 @@ class TestMemoryBackend { text: string; importance: number; embedding?: number[]; + createdAt?: Date; }) { const id = this.nextId(); this.memories.push({ @@ -108,7 +111,7 @@ class TestMemoryBackend { importance: input.importance, embedding: input.embedding ?? [], summarizedAt: null, - createdAt: new Date(), + createdAt: input.createdAt ?? new Date(), }); return id; } @@ -145,6 +148,7 @@ class TestMemoryBackend { const dot = (a: number[], b: number[]) => a.reduce((sum, v, i) => sum + v * (b[i] ?? 0), 0); const norm = (v: number[]) => Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1; + const now = Date.now(); return this.memories .filter( @@ -159,7 +163,8 @@ class TestMemoryBackend { const sim = dot(m.embedding, input.queryEmbedding) / (norm(m.embedding) * norm(input.queryEmbedding)); - const score = sim * (0.5 + m.importance / 20); + const ageHours = (now - m.createdAt.getTime()) / 3_600_000; + const score = computeWeightedScore(sim, m.importance, ageHours); return { text: m.text, score, importance: m.importance }; }) .sort((a, b) => b.score - a.score) @@ -322,6 +327,22 @@ export class MemoryService { testBackend = null; } + /** @internal vitest helper — seed row with explicit createdAt/embedding. */ + async seedMemoryForTests(input: { + roomId: string; + playerId: string; + npcId: string; + text: string; + importance: number; + embedding: number[]; + createdAt: Date; + }): Promise { + if (!this.test) { + throw new Error("seedMemoryForTests requires TestMemoryBackend"); + } + await this.test.appendMemory(input); + } + async appendPlayerMemory( roomId: string, text: string, @@ -586,12 +607,42 @@ export class MemoryService { text: string, npcId: string, playerId: string, + semantic?: { mood?: string | null; beliefs?: string[] | null; summary?: string | null }, ): Promise { if (this.test) { await this.test.appendSummary({ roomId, playerId, npcId, kind: "reflection", text }); + } else { + await this.repo!.appendSummary({ roomId, playerId, npcId, kind: "reflection", text }); + } + if (semantic) { + await this.upsertAttitudeSemantic(roomId, npcId, playerId, semantic); + } + } + + /** + * Clamp then upsert attitude semantic columns (D-BELIEF-07/09/13). + * Illegal/omitted fields are not written — prior values preserved. + * Wired for reflect path in 29-06; no public HTTP surface (D-BELIEF-10). + */ + async upsertAttitudeSemantic( + roomId: string, + npcId: string, + playerId: string, + input: { mood?: string | null; beliefs?: string[] | null; summary?: string | null }, + ): Promise { + const clamped = clampSemanticState(input); + if ( + clamped.mood === undefined && + clamped.beliefs === undefined && + clamped.summary === undefined + ) { return; } - await this.repo!.appendSummary({ roomId, playerId, npcId, kind: "reflection", text }); + await CollectiveService.getInstance().repoRef().upsertSemanticState(roomId, npcId, playerId, { + ...(clamped.mood !== undefined ? { mood: clamped.mood } : {}), + ...(clamped.beliefs !== undefined ? { beliefs: clamped.beliefs } : {}), + ...(clamped.summary !== undefined ? { summary: clamped.summary } : {}), + }); } async storeBulkSummary( diff --git a/apps/game-server/src/npc/dialogue-session.test.ts b/apps/game-server/src/npc/dialogue-session.test.ts index ca8ce99..a693e91 100644 --- a/apps/game-server/src/npc/dialogue-session.test.ts +++ b/apps/game-server/src/npc/dialogue-session.test.ts @@ -1,13 +1,83 @@ -import { describe, expect, it, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { appendCompletedTurn, + clearDialogueForPlayer, clearDialogueSessions, + dialogueRedisKey, + evictDialogueMapForRoom, getRecentTurns, + getRecentTurnsAsync, + resetDialogueRedisForTests, + setDialogueRedisForTests, } from "./dialogue-session.js"; +type FakeRedis = { + store: Map; + expires: Map; + rpush: ReturnType; + ltrim: ReturnType; + expire: ReturnType; + lrange: ReturnType; + del: ReturnType; + on: ReturnType; +}; + +function createFakeRedis(): FakeRedis { + const store = new Map(); + const expires = new Map(); + const redis: FakeRedis = { + store, + expires, + on: vi.fn(), + rpush: vi.fn(async (key: string, ...values: string[]) => { + const list = store.get(key) ?? []; + list.push(...values); + store.set(key, list); + return list.length; + }), + ltrim: vi.fn(async (key: string, start: number, stop: number) => { + const list = store.get(key) ?? []; + // ioredis LTRIM with negative indexes: keep last |start| when start=-N, stop=-1 + if (start < 0 && stop === -1) { + const keep = Math.abs(start); + store.set(key, list.slice(-keep)); + } else { + store.set(key, list.slice(start, stop + 1)); + } + return "OK"; + }), + expire: vi.fn(async (key: string, seconds: number) => { + expires.set(key, seconds); + return 1; + }), + lrange: vi.fn(async (key: string, start: number, stop: number) => { + const list = store.get(key) ?? []; + if (start === 0 && stop === -1) return [...list]; + return list.slice(start, stop === -1 ? undefined : stop + 1); + }), + del: vi.fn(async (...keys: string[]) => { + let n = 0; + for (const k of keys) { + if (store.delete(k)) n += 1; + expires.delete(k); + } + return n; + }), + }; + return redis; +} + describe("dialogue-session", () => { beforeEach(() => { clearDialogueSessions(); + resetDialogueRedisForTests(); + delete process.env.REDIS_URL; + }); + + afterEach(() => { + clearDialogueSessions(); + resetDialogueRedisForTests(); + delete process.env.REDIS_URL; }); it("returns empty then appends player/npc pair on completed turn", () => { @@ -46,4 +116,248 @@ describe("dialogue-session", () => { expect(getRecentTurns("default", "p1", "npc-1")).toHaveLength(2); expect(getRecentTurns("default", "p2", "npc-1")[0]?.text).toBe("c"); }); + + it("getRecentTurns is sync Map-only (not a Promise)", () => { + const result = getRecentTurns("default", "p1", "npc-1"); + expect(result).not.toBeInstanceOf(Promise); + expect(Array.isArray(result)).toBe(true); + }); + + it("degrades to Map-only when REDIS_URL missing without throwing", async () => { + delete process.env.REDIS_URL; + resetDialogueRedisForTests(); + + expect(() => + appendCompletedTurn({ + roomId: "default", + playerId: "p1", + npcId: "npc-1", + playerMessage: "hi", + npcReply: "hello", + }), + ).not.toThrow(); + + await expect(getRecentTurnsAsync("default", "p1", "npc-1")).resolves.toEqual([ + { role: "player", text: "hi" }, + { role: "npc", text: "hello" }, + ]); + }); + + it("appendCompletedTurn mirrors to Redis with RPUSH/LTRIM/EXPIRE 7d", async () => { + const fake = createFakeRedis(); + setDialogueRedisForTests(fake as unknown as import("ioredis").default); + + appendCompletedTurn({ + roomId: "room-a", + playerId: "p1", + npcId: "npc-2", + playerMessage: "你好", + npcReply: "你好呀", + }); + + const key = dialogueRedisKey("room-a", "p1", "npc-2"); + expect(key).toBe("aetherlife:dialogue:room-a:p1:npc-2"); + + await vi.waitFor(() => { + expect(fake.expire).toHaveBeenCalledWith(key, 7 * 24 * 60 * 60); + }); + expect(fake.rpush).toHaveBeenCalledWith( + key, + JSON.stringify({ role: "player", text: "你好" }), + JSON.stringify({ role: "npc", text: "你好呀" }), + ); + expect(fake.ltrim).toHaveBeenCalledWith(key, -20, -1); + }); + + it("getRecentTurnsAsync rehydrates from Redis on Map miss", async () => { + const fake = createFakeRedis(); + const key = dialogueRedisKey("room-b", "p9", "npc-3"); + fake.store.set(key, [ + JSON.stringify({ role: "player", text: "before restart" }), + JSON.stringify({ role: "npc", text: "remembered" }), + ]); + setDialogueRedisForTests(fake as unknown as import("ioredis").default); + + // Map empty (post-restart) — sync path stays empty + expect(getRecentTurns("room-b", "p9", "npc-3")).toEqual([]); + + const asyncTurns = await getRecentTurnsAsync("room-b", "p9", "npc-3"); + expect(asyncTurns).toEqual([ + { role: "player", text: "before restart" }, + { role: "npc", text: "remembered" }, + ]); + // Rehydrated into Map for subsequent sync reads + expect(getRecentTurns("room-b", "p9", "npc-3")).toEqual(asyncTurns); + expect(fake.lrange).toHaveBeenCalledWith(key, 0, -1); + }); + + it("getRecentTurnsAsync Map hit skips Redis", async () => { + const fake = createFakeRedis(); + setDialogueRedisForTests(fake as unknown as import("ioredis").default); + + appendCompletedTurn({ + roomId: "room-c", + playerId: "p1", + npcId: "npc-1", + playerMessage: "cached", + npcReply: "ok", + }); + await vi.waitFor(() => expect(fake.rpush).toHaveBeenCalled()); + fake.lrange.mockClear(); + + const turns = await getRecentTurnsAsync("room-c", "p1", "npc-1"); + expect(turns).toHaveLength(2); + expect(fake.lrange).not.toHaveBeenCalled(); + }); + + it("evictDialogueMapForRoom drops Map but Redis rehydrate still works", async () => { + const fake = createFakeRedis(); + setDialogueRedisForTests(fake as unknown as import("ioredis").default); + + appendCompletedTurn({ + roomId: "room-evict", + playerId: "p1", + npcId: "npc-2", + playerMessage: "暗号是蓝莓派七号", + npcReply: "记下了", + }); + appendCompletedTurn({ + roomId: "other-room", + playerId: "p1", + npcId: "npc-2", + playerMessage: "keep me", + npcReply: "ok", + }); + await vi.waitFor(() => expect(fake.rpush.mock.calls.length).toBeGreaterThanOrEqual(2)); + + expect(evictDialogueMapForRoom("room-evict")).toBe(1); + expect(getRecentTurns("room-evict", "p1", "npc-2")).toEqual([]); + expect(getRecentTurns("other-room", "p1", "npc-2")).toHaveLength(2); + expect(fake.del).not.toHaveBeenCalled(); + + const restored = await getRecentTurnsAsync("room-evict", "p1", "npc-2"); + expect(restored).toEqual([ + { role: "player", text: "暗号是蓝莓派七号" }, + { role: "npc", text: "记下了" }, + ]); + expect(getRecentTurns("room-evict", "p1", "npc-2")).toEqual(restored); + }); + + it("getRecentTurnsAsync does not clobber Map turns appended during LRANGE", async () => { + const fake = createFakeRedis(); + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + fake.lrange = vi.fn(async (key: string) => { + await gate; + const list = fake.store.get(key) ?? []; + return [...list]; + }); + setDialogueRedisForTests(fake as unknown as import("ioredis").default); + + const pending = getRecentTurnsAsync("room-race", "p1", "npc-1"); + appendCompletedTurn({ + roomId: "room-race", + playerId: "p1", + npcId: "npc-1", + playerMessage: "during await", + npcReply: "kept", + }); + release(); + const turns = await pending; + expect(turns).toEqual([ + { role: "player", text: "during await" }, + { role: "npc", text: "kept" }, + ]); + }); + + it("getRecentTurnsAsync does not negatively cache empty Redis threads", async () => { + const fake = createFakeRedis(); + setDialogueRedisForTests(fake as unknown as import("ioredis").default); + const key = dialogueRedisKey("room-empty", "p1", "npc-1"); + + expect(await getRecentTurnsAsync("room-empty", "p1", "npc-1")).toEqual([]); + expect(getRecentTurns("room-empty", "p1", "npc-1")).toEqual([]); + + fake.store.set(key, [ + JSON.stringify({ role: "player", text: "later" }), + JSON.stringify({ role: "npc", text: "seen" }), + ]); + const turns = await getRecentTurnsAsync("room-empty", "p1", "npc-1"); + expect(turns).toEqual([ + { role: "player", text: "later" }, + { role: "npc", text: "seen" }, + ]); + expect(fake.lrange.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it("clearDialogueForPlayer removes Map and Redis for known keys", async () => { + const fake = createFakeRedis(); + setDialogueRedisForTests(fake as unknown as import("ioredis").default); + + appendCompletedTurn({ + roomId: "default", + playerId: "p1", + npcId: "npc-1", + playerMessage: "x", + npcReply: "y", + }); + appendCompletedTurn({ + roomId: "default", + playerId: "p1", + npcId: "npc-2", + playerMessage: "u", + npcReply: "v", + }); + await vi.waitFor(() => expect(fake.rpush.mock.calls.length).toBeGreaterThanOrEqual(2)); + + clearDialogueForPlayer("default", "p1"); + + expect(getRecentTurns("default", "p1", "npc-1")).toEqual([]); + expect(getRecentTurns("default", "p1", "npc-2")).toEqual([]); + + await vi.waitFor(() => { + expect(fake.del).toHaveBeenCalled(); + }); + const deleted = fake.del.mock.calls.flat(); + expect(deleted).toContain(dialogueRedisKey("default", "p1", "npc-1")); + expect(deleted).toContain(dialogueRedisKey("default", "p1", "npc-2")); + }); + + it("Redis errors are logged and never thrown to caller", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const broken = { + on: vi.fn(), + rpush: vi.fn().mockRejectedValue(new Error("redis down")), + ltrim: vi.fn(), + expire: vi.fn(), + lrange: vi.fn().mockRejectedValue(new Error("redis down")), + del: vi.fn().mockRejectedValue(new Error("redis down")), + }; + setDialogueRedisForTests(broken as unknown as import("ioredis").default); + + expect(() => + appendCompletedTurn({ + roomId: "default", + playerId: "p1", + npcId: "npc-1", + playerMessage: "a", + npcReply: "b", + }), + ).not.toThrow(); + + await expect(getRecentTurnsAsync("default", "p1", "npc-1")).resolves.toEqual([ + { role: "player", text: "a" }, + { role: "npc", text: "b" }, + ]); + + // Force Map miss for async rehydrate error path + clearDialogueSessions(); + setDialogueRedisForTests(broken as unknown as import("ioredis").default); + await expect(getRecentTurnsAsync("default", "p1", "npc-1")).resolves.toEqual([]); + + expect(() => clearDialogueForPlayer("default", "p1")).not.toThrow(); + errSpy.mockRestore(); + }); }); diff --git a/apps/game-server/src/npc/dialogue-session.ts b/apps/game-server/src/npc/dialogue-session.ts index 12b532f..352c165 100644 --- a/apps/game-server/src/npc/dialogue-session.ts +++ b/apps/game-server/src/npc/dialogue-session.ts @@ -1,15 +1,105 @@ -/** In-session dialogue transcript per (room, player, npc) — short-term context for LLM turns. */ +/** In-session dialogue transcript per (room, player, npc) — short-term context for LLM turns. + * Map is hot cache; Redis mirrors for restart continuity (D-SESS-01…06). + * Sync getRecentTurns stays Map-only — never await Redis on speak stub path. + */ + +import { Redis } from "ioredis"; export type DialogueTurn = { role: "player" | "npc"; text: string }; const sessions = new Map(); +/** Tracked Redis keys written this process — clear without KEYS/SCAN. */ +const knownRedisKeys = new Set(); + const MAX_TURNS_STORED = 20; +const DIALOGUE_TTL_SECONDS = 7 * 24 * 60 * 60; +const REDIS_KEY_PREFIX = "aetherlife:dialogue:"; + +let dialogueRedis: Redis | null | undefined; function threadKey(roomId: string, playerId: string, npcId: string): string { return `${roomId}:${playerId}:${npcId}`; } +export function dialogueRedisKey(roomId: string, playerId: string, npcId: string): string { + return `${REDIS_KEY_PREFIX}${roomId}:${playerId}:${npcId}`; +} + +function getDialogueRedis(): Redis | null { + if (dialogueRedis !== undefined) return dialogueRedis; + const url = process.env.REDIS_URL; + if (!url) { + dialogueRedis = null; + return null; + } + dialogueRedis = new Redis(url, { maxRetriesPerRequest: null }); + dialogueRedis.on("error", (err) => { + console.error("[redis] dialogue-session", err.message); + }); + return dialogueRedis; +} + +/** @internal — inject fake Redis or force null for unit tests. */ +export function setDialogueRedisForTests(client: Redis | null): void { + dialogueRedis = client; +} + +/** @internal — reset lazy singleton so next getDialogueRedis re-reads env. */ +export function resetDialogueRedisForTests(): void { + dialogueRedis = undefined; + knownRedisKeys.clear(); +} + +function parseTurn(raw: string): DialogueTurn | null { + try { + const parsed = JSON.parse(raw) as unknown; + if ( + parsed && + typeof parsed === "object" && + (parsed as DialogueTurn).role && + ((parsed as DialogueTurn).role === "player" || (parsed as DialogueTurn).role === "npc") && + typeof (parsed as DialogueTurn).text === "string" + ) { + return { role: (parsed as DialogueTurn).role, text: (parsed as DialogueTurn).text }; + } + } catch { + /* ignore malformed */ + } + return null; +} + +async function mirrorAppendToRedis( + roomId: string, + playerId: string, + npcId: string, + playerTurn: DialogueTurn, + npcTurn: DialogueTurn, +): Promise { + const redis = getDialogueRedis(); + if (!redis) return; + const key = dialogueRedisKey(roomId, playerId, npcId); + knownRedisKeys.add(key); + try { + await redis.rpush(key, JSON.stringify(playerTurn), JSON.stringify(npcTurn)); + await redis.ltrim(key, -MAX_TURNS_STORED, -1); + await redis.expire(key, DIALOGUE_TTL_SECONDS); + } catch (err) { + console.error("[dialogue-session] Redis mirror failed", err); + } +} + +async function deleteRedisKeys(keys: string[]): Promise { + if (keys.length === 0) return; + const redis = getDialogueRedis(); + if (!redis) return; + try { + await redis.del(...keys); + } catch (err) { + console.error("[dialogue-session] Redis clear failed", err); + } +} + export function getRecentTurns( roomId: string, playerId: string, @@ -20,6 +110,51 @@ export function getRecentTurns( return turns.slice(-limit); } +/** + * Async hydrate for already-async job paths (npc-chat). + * Map hit → return; miss → LRANGE rehydrate → Map. Never throws on Redis errors. + */ +export async function getRecentTurnsAsync( + roomId: string, + playerId: string, + npcId: string, + limit = 10, +): Promise { + const k = threadKey(roomId, playerId, npcId); + if (sessions.has(k)) { + return (sessions.get(k) ?? []).slice(-limit); + } + + const redis = getDialogueRedis(); + if (!redis) { + return []; + } + + const key = dialogueRedisKey(roomId, playerId, npcId); + try { + const raw = await redis.lrange(key, 0, -1); + // Concurrent appendCompletedTurn during LRANGE must win — do not clobber Map. + if (sessions.has(k)) { + return (sessions.get(k) ?? []).slice(-limit); + } + const turns: DialogueTurn[] = []; + for (const item of raw) { + const turn = parseTurn(item); + if (turn) turns.push(turn); + } + // Do not negatively cache empty threads (multi-instance / later Redis write). + if (turns.length === 0) { + return []; + } + sessions.set(k, turns); + knownRedisKeys.add(key); + return turns.slice(-limit); + } catch (err) { + console.error("[dialogue-session] Redis rehydrate failed", err); + return []; + } +} + export function appendCompletedTurn(input: { roomId: string; playerId: string; @@ -33,22 +168,65 @@ export function appendCompletedTurn(input: { const k = threadKey(input.roomId, input.playerId, input.npcId); const turns = sessions.get(k) ?? []; - turns.push({ role: "player", text: playerText }); - turns.push({ role: "npc", text: npcText }); + const playerTurn: DialogueTurn = { role: "player", text: playerText }; + const npcTurn: DialogueTurn = { role: "npc", text: npcText }; + turns.push(playerTurn); + turns.push(npcTurn); while (turns.length > MAX_TURNS_STORED) { turns.shift(); } sessions.set(k, turns); + + // Fire-and-forget Redis mirror — never block speak / hub path. + void mirrorAppendToRedis(input.roomId, input.playerId, input.npcId, playerTurn, npcTurn); } export function clearDialogueForPlayer(roomId: string, playerId: string): void { const prefix = `${roomId}:${playerId}:`; + const redisPrefix = `${REDIS_KEY_PREFIX}${roomId}:${playerId}:`; for (const k of [...sessions.keys()]) { if (k.startsWith(prefix)) sessions.delete(k); } + const toDelete: string[] = []; + for (const rk of [...knownRedisKeys]) { + if (rk.startsWith(redisPrefix)) { + knownRedisKeys.delete(rk); + toDelete.push(rk); + } + } + void deleteRedisKeys(toDelete); +} + +/** + * Drop in-process Map cache for a room only — Redis lists stay intact. + * Simulates game-server restart Map loss for D-SESS / UAT (LRANGE rehydrate). + */ +export function evictDialogueMapForRoom(roomId: string): number { + const id = roomId.trim(); + if (!id) return 0; + const prefix = `${id}:`; + let n = 0; + for (const k of [...sessions.keys()]) { + if (k.startsWith(prefix)) { + sessions.delete(k); + n += 1; + } + } + return n; +} + +/** Test / UAT: async turns for a thread (Map hit or Redis rehydrate). */ +export async function listDialogueTurnsForUat( + roomId: string, + playerId: string, + npcId: string, + limit = 10, +): Promise { + return getRecentTurnsAsync(roomId, playerId, npcId, limit); } /** Test helper */ export function clearDialogueSessions(): void { sessions.clear(); + knownRedisKeys.clear(); } diff --git a/apps/game-server/src/routes/collective-state.ts b/apps/game-server/src/routes/collective-state.ts index 94f7200..fb92170 100644 --- a/apps/game-server/src/routes/collective-state.ts +++ b/apps/game-server/src/routes/collective-state.ts @@ -9,6 +9,7 @@ export function createCollectiveStateRouter(): Router { const router = Router(); router.get("/:roomId/collective-state", async (req, res) => { + // D-BELIEF-11: payload from getCollectiveState — band/reputation only; no mood/beliefs/summary. const { roomId } = req.params; const npcId = typeof req.query.npcId === "string" ? req.query.npcId : undefined; diff --git a/apps/game-server/src/routes/internal-memories.ts b/apps/game-server/src/routes/internal-memories.ts index b11e74f..cafa415 100644 --- a/apps/game-server/src/routes/internal-memories.ts +++ b/apps/game-server/src/routes/internal-memories.ts @@ -218,8 +218,30 @@ export function createInternalMemoriesRouter(): Router { return; } + const semantic: { + mood?: string; + beliefs?: string[]; + summary?: string; + } = {}; + if (typeof req.body?.mood === "string") { + semantic.mood = req.body.mood; + } + if (Array.isArray(req.body?.beliefs)) { + semantic.beliefs = req.body.beliefs.filter((b: unknown): b is string => typeof b === "string"); + } + if (typeof req.body?.summary === "string") { + semantic.summary = req.body.summary; + } + const hasSemantic = Object.keys(semantic).length > 0; + try { - await MemoryService.getInstance().storeReflection(roomId, text, npcId, playerId); + await MemoryService.getInstance().storeReflection( + roomId, + text, + npcId, + playerId, + hasSemantic ? semantic : undefined, + ); invalidateMemoryContextForPlayer(roomId, playerId, npcId); res.json({ ok: true }); } catch (err) { diff --git a/apps/game-server/src/routes/rooms.ts b/apps/game-server/src/routes/rooms.ts index dd2cee2..4d0bc7a 100644 --- a/apps/game-server/src/routes/rooms.ts +++ b/apps/game-server/src/routes/rooms.ts @@ -37,7 +37,12 @@ import { getChunkLore } from "../world/lore-repository.js"; import { getChunkLoreCached } from "../world/lore-chunk-cache.js"; import { getRoomVoteState } from "../world/world-vote-state.js"; import { logInternalLatency } from "../observability/internalLatency.js"; -import { clearDialogueForPlayer } from "../npc/dialogue-session.js"; +import { + appendCompletedTurn, + clearDialogueForPlayer, + evictDialogueMapForRoom, + listDialogueTurnsForUat, +} from "../npc/dialogue-session.js"; import { getCachedWorkerState, setCachedWorkerState, @@ -393,6 +398,59 @@ export function createInternalRoomsRouter(): Router { router.post("/:roomId/apply-actions", requireWorkerAuth, applyActionsHandler); + /** UAT / D-SESS: drop in-memory dialogue Map for room; Redis transcript stays. */ + router.post("/:roomId/dialogue-map-evict", requireWorkerAuth, (req, res) => { + const { roomId } = req.params; + if (!roomId?.trim()) { + res.status(400).json({ ok: false, error: "roomId required" }); + return; + } + const evicted = evictDialogueMapForRoom(roomId); + res.json({ ok: true, evicted }); + }); + + /** + * UAT seed: append one player+npc turn (Map + Redis mirror) without LLM. + * Body: { playerId, npcId, playerMessage, npcReply } + */ + router.post("/:roomId/dialogue-append", requireWorkerAuth, (req, res) => { + const { roomId } = req.params; + const playerId = typeof req.body?.playerId === "string" ? req.body.playerId.trim() : ""; + const npcId = typeof req.body?.npcId === "string" ? req.body.npcId.trim() : ""; + const playerMessage = + typeof req.body?.playerMessage === "string" ? req.body.playerMessage.trim() : ""; + const npcReply = typeof req.body?.npcReply === "string" ? req.body.npcReply.trim() : ""; + if (!roomId?.trim() || !playerId || !npcId || !playerMessage || !npcReply) { + res.status(400).json({ + ok: false, + error: "roomId, playerId, npcId, playerMessage, npcReply required", + }); + return; + } + appendCompletedTurn({ roomId, playerId, npcId, playerMessage, npcReply }); + res.json({ ok: true }); + }); + + /** UAT / D-SESS: read turns via getRecentTurnsAsync (Map miss → Redis rehydrate). */ + router.get("/:roomId/dialogue-turns", requireWorkerAuth, async (req, res) => { + const { roomId } = req.params; + const playerId = typeof req.query.playerId === "string" ? req.query.playerId.trim() : ""; + const npcId = typeof req.query.npcId === "string" ? req.query.npcId.trim() : ""; + const limitRaw = Number(req.query.limit ?? 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(40, Math.floor(limitRaw)) : 10; + if (!roomId?.trim() || !playerId || !npcId) { + res.status(400).json({ ok: false, error: "roomId, playerId, npcId required" }); + return; + } + try { + const turns = await listDialogueTurnsForUat(roomId, playerId, npcId, limit); + res.json({ ok: true, turns }); + } catch (err) { + const message = err instanceof Error ? err.message : "dialogue-turns failed"; + res.status(500).json({ ok: false, error: message }); + } + }); + router.get("/:roomId/worker-state", requireWorkerAuth, async (req, res) => { const { roomId } = req.params; const playerId = playerIdFromRequest(req); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6258c4..85a8611 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -109,6 +109,8 @@ Web 更新对话 UI · Phaser 铭牌 thinking/speaking · 地图若有 move/int 序列与延迟细节:[LLM-E2E-FLOW-AND-LATENCY.md](./LLM-E2E-FLOW-AND-LATENCY.md)。 +**Dialogue continuity(Phase 29 / D-SESS):** 当前对话轮次缓存在进程内 `Map`(`aetherlife:dialogue:v1:{room}:{npc}:{player}`),并异步写入 Redis List。进程重启或 Map miss 后,`getRecentTurnsAsync` 从 Redis 回填 Map。D-SESS UAT 用 Map-evict(`POST .../dialogue-map-evict`)模拟 miss,无需杀进程 — `pnpm uat:phase29:dialogue-restart`。 + ### 3.2 移动同步闭环 ``` @@ -196,6 +198,13 @@ AI-web/ | 集体态度 | `collective_events` + `npc_attitudes` | worker 社交结构化写回;规则类可由 server | | Ambient intent | 内存 cache + schema 字段 | internal `npc-intent` | +**Phase 29 记忆检索补充:** + +- 遗忘曲线加权检索(`packages/npc-memory` SSOT);ANN = halfvec overfetch → 曲线重排。 +- 短期对话:进程内 Map + Redis List;miss 后 `getRecentTurnsAsync` 回填(§3.1 D-SESS)。 +- Semantic(mood/beliefs)仅 LLM / 内部可见;公开 API strip(C-05)。 +- PROP 2-hop 好感传播常量锁定;不进 ChatState 级联。 + **单一变异入口:** 世界动作经 `POST /internal/rooms/:id/apply-actions`(Bearer `INTERNAL_WORKER_TOKEN`)。公开 mutation 路由已移除(C-03)。 --- diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 8a73ee1..a5d625f 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -84,12 +84,13 @@ TS game-server、Python worker、LLM Prompt、`@aetherlife/game-actions` 之间 | **Per-player 叙事记忆** | `roomId` + `npcId` + `playerId` → `npc_memories`(既有) | | **Collective 事件** | `roomId` + `npcId` + `playerIds[]` → `collective_events`;**禁止**写入 `npc_memories` 双写 speak 全文 | | **态度分** | `roomId` + `npcId` + `playerId` → `npc_attitudes.reputation`;speak 取 **initiator** 行 | -| **Worker 读** | `fetch_memory_context` / `GET .../memory-context` 返回含 `collective.{band,allowedTools,effectiveScore}` | -| **Worker 写** | Speak 社交 event **仅 worker**(Phase 12.1 `source=worker`,structured `SocialTurnOut` 权威);action **rule** 行仍由 game-server;legacy **llm_refine** tail 不对 speak 生效 | +| **Semantic 状态(D-BELIEF)** | `npc_attitudes.current_mood` / `key_beliefs` / `summary` — **仅** `/internal/.../memory-context` → `MemoryContext.collective` → worker speak prompt(`format_attitude_context`);**禁止**进入公开 `GET .../collective-state` attitudes DTO(剥离 mood/beliefs/summary);**禁止**玩家 UI 渲染;mood **不**改变 `allowedTools` / `allowedToolsForBand` | +| **Worker 读** | `fetch_memory_context` / `GET .../memory-context` 返回含 `collective.{band,allowedTools,effectiveScore,currentMood?,keyBeliefs?,summary?}` | +| **Worker 写** | Speak 社交 event **仅 worker**(Phase 12.1 `source=worker`,structured `SocialTurnOut` 权威);action **rule** 行仍由 game-server;legacy **llm_refine** tail 不对 speak 生效;reflection 可选写 semantic(`POST .../reflect` mood/beliefs/summary → clamp → upsert) | | **Speak 社交** | Server **禁止** `detectSpeak` / rude 词表;LLM parse 失败 → `ignore`(不写 event),**不回退** server 词表 | | **Reset** | `POST /rooms/:id/reset` 删除该 `roomId` 下所有 `collective_events` + `npc_attitudes` + 既有 per-player memories | | **与空间** | Witness 距离用 **当前** `RoomState` NPC 格;Chebyshev ≤2 | -| **禁止** | `playerId=__room__` 伪玩家桶;禁止 collective 表存 speak 全文 | +| **禁止** | `playerId=__room__` 伪玩家桶;禁止 collective 表存 speak 全文;禁止公开 HTTP 泄露 mood/beliefs/summary | --- diff --git a/docs/ISSUE-LOG.md b/docs/ISSUE-LOG.md index 31494e4..006b64e 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -210,6 +210,11 @@ 117. **关系 apply-deltas 与 belief day-key 须带 room clock**:`POST .../npc-relationships/apply-deltas` **必须** `noteInteractAbs` 用 `getRoomVoteState(roomId).absoluteGameMinute`(**禁止** fallback `0` 导致 idle decay 误判);`worker-state.state` **必须** 含 `absoluteGameMinute`;`day_key_from_snapshot` **优先** `absoluteGameMinute // 1440`(D-PLAYER-04/06 per-day cap)。回归:`npc-relationships.test.ts` apply-deltas stamp · `index.test.ts` worker-state clock · `test_belief_gate.py::test_day_key_from_snapshot_prefers_absolute_game_minute` · `test_manipulation_cap_resets_on_next_game_day`。 118. **Decay stamp / belief cap 进程重启与长跑**:`maybeRunRelationshipDecay` 启动时 **必须** `hydrateInteractAbsFromEdges`(有 `last_interact_at`)+ `hydrateSeedAbsFromEdges`;`lastDecayMonthByRoom` 有 `REDIS_URL` 时持久化,防同月重复 decay。Belief `_pair_claims` / `_player_claims` / `_reject_counts` / `_trust_penalty_claims` 在 claim/note 时 **必须** prune 非当前 `day_key`。回归:`npc-relationship-decay.test.ts` hydrate · `test_belief_gate.py::test_prune_stale_day_keys_on_claim`。 119. **Campfire 等多 tile volume 须共用南缘 Y-sort**:`HomeMapBackground` 对 `VOLUME_CLUSTER_TILESETS`(当前仅 `Campfire`)用 `clusterSouthSortWorldY` 把相邻 tile object 的 sort Y 抬到集群最南底边;**禁止**全局改 `YSORT_LAYER.PLAYER`/`OBJECT` 来「修」篝火。勿把 collection-of-images 道具加入该集合。回归:`entityLayout.test.ts` · 实机站北侧火焰须盖住腿。 +120. **`audit_reply` 状态声称规则须窄匹配**:禁止裸词 `moved`/`left`/`picked`/`走向`/`我现在就去`(无动作宾语)误伤闲聊;兜底文案禁止「与房间互动」。改 `reply_audit.py` / gateway `guards/reply.py` 须两侧同步 + `tests/test_reply_audit.py`(含 age-chat 假阳性用例)。 +121. **遗忘曲线检索公式 SSOT 在 `packages/npc-memory` helpers**:`S=max(ε,72h×importance/5)`,`score∝exp(-age·ln2/S)`;SQL 与 `TestMemoryBackend` 必须镜像同一公式;禁止在 worker 另写一套衰减。 +122. **公开 API 禁止泄漏 Semantic / PROP**:`collective-state` 等公开路由须 strip mood/beliefs/summary;PROP 常量不得进 `ChatState` / worker-state 级联;改公开序列化时对照 CONTRACTS C-05 / D-BELIEF-11。 +123. **ANN 检索须 overfetch 再遗忘曲线重排**:`k_overfetch=max(20,4*k)`;禁止假装「加权 ORDER BY」能用 HNSW;召回门用 live overlap,不靠 EXPLAIN 必现 Index Scan。 +124. **D-SESS UAT 用 Map-evict,不杀进程**:`POST .../dialogue-map-evict` + `GET .../dialogue-turns` 验证 Redis→Map 回填;命令 `pnpm uat:phase29:dialogue-restart`。Speak 引用 seed 在 LLM 配额耗尽时可 soft-skip,硬门是 turns rehydrate。 ## 记录 @@ -2987,4 +2992,38 @@ Worker 主循环仅在 npc-turn 队列 **连续 5s 为空** 时才 `BLPOP` chunk --- +### ISSUE-112 — 闲聊被 audit_reply 替换成「与房间互动」 + +- **状态:** fixed +- **发现:** 2026-07-30 +- **阶段/范围:** worker `reply_audit` / gateway `guards/reply` +- **严重性:** major(对话 UX) + +**复现** + +1. 对阿斯托利亚说「美女,你多大了」。 +2. UI 显示「我还不能确定能否那样做——让我先试着与房间互动。」(与提问无关)。 + +**根因** + +- Social LLM(Groq)已成功生成回复。 +- `audit_reply` 英文规则含过宽词 `moved`/`left`,中文含裸 `走向`/`我现在就去`,闲聊误判为「无 tool 却声称状态变更」并整句替换。 +- 兜底文案写「与房间互动」,观感像答非所问。 + +**修复** + +- 收窄 `STATE_CHANGE_PATTERNS`(`moved to`、动作宾语版「我现在就去…」等);兜底改为「抱歉,我这边还做不到。」 +- worker / gateway 两侧同步;补假阳性单测。 + +**验证** + +- `cd workers/agent-worker && LLM_MOCK=1 uv run pytest -q tests/test_reply_audit.py` +- `cd apps/ai-gateway && uv run pytest -q tests/test_check_reply.py` + +**防复发** + +- Guardrail #120 + +--- + diff --git a/docs/LLM-E2E-FLOW-AND-LATENCY.md b/docs/LLM-E2E-FLOW-AND-LATENCY.md index baa9f7c..9275705 100644 --- a/docs/LLM-E2E-FLOW-AND-LATENCY.md +++ b/docs/LLM-E2E-FLOW-AND-LATENCY.md @@ -207,6 +207,17 @@ JSON:`.planning/benchmarks/speak-browser-1781155186602.json` 对照 § 目标:B1 TTFT **达标**(p50 ≤2s);首字由 Web `client_mirror` 在 dispatch 打 mark,不再依赖 Redis BRPOP + worker HTTP。B1 total p50 **4521 ms**(达标 ≤5s);p95 **9476 ms** 仍受 fast lane `done` 与 LLM 路由波动影响。 +**Run 2026-07-30 — Phase 29 post-retrieval**(`BENCHMARK_ROUNDS=15`,`pnpm dev:stack` 真实 LLM,`LLM_PROVIDER=nvidia`;JSON:`.planning/benchmarks/speak-browser-1785396046577.json`;harness `ed33d56` T_think/aria-busy): + +| 用例 | total p50 | total p95 | ttft p50 | bubble p50 | `t_memory_ms` p50/p95 | +|------|-----------|-----------|----------|------------|------------------------| +| B1 闲聊 | **13448 ms** | 53846 ms | **28 ms** | 125 ms | 0 / 0(fast lane) | +| B2 物理 | **153619 ms** | 237685 ms | 142408 ms | 142568 ms | 1626 / 1985 | +| B3 快路径 | **146922 ms** | 226424 ms | 142087 ms | 142278 ms | 1282 / 2360 | +| B_recall | **153362 ms** | 195313 ms | 148714 ms | 148802 ms | — | + +对照 P4 基线(B1 total **4521** / TTFT **1**):B1 total **REGRESS ~3.0×** → **D-PERF-01/02 不宣称通过**。观察:B2/B3/B_recall TTFT ~142s 由 worker 队列 + social 429/timeout + 前序 job **memory-tail ~365–374s** 串行饥饿主导,**不宜直接归咎 forgetting-curve SQL / halfvec**;需 follow-up 隔离。详见 `.planning/phases/29-npc-memory-retrieval-quality/29-VERIFICATION.md` Task 2。 + **Run 2026-06-10 — P0/P1 基线**(`BENCHMARK_ROUNDS=5`,无 `speakPartial` / `speakIntent`): | 用例 | 消息 | total p50 | total p95 | bubble p50 | nl_parse p50 | diff --git a/package.json b/package.json index 3e97999..85e09fe 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "dev:worker": "cd workers/agent-worker && uv run python -m src.main", "docker:ai": "docker compose -f infra/docker-compose.yml up -d ai-gateway", "verify:cloud": "node scripts/verify-cloud.mjs", + "verify:pgvector": "node scripts/verify-pgvector-capability.mjs", + "verify:memory-recall": "node scripts/verify-memory-recall.mjs", "verify:llm-models": "node scripts/verify-llm-models.mjs", "benchmark:llm-e2e": "node scripts/benchmark-llm-e2e-latency.mjs", "benchmark:speak-browser": "node scripts/benchmark-speak-browser.mjs", @@ -68,6 +70,7 @@ "verify:phase27": "node scripts/verify-phase27.mjs", "verify:phase28": "node scripts/verify-phase28.mjs", "uat:phase27:persona-diary": "node scripts/uat-phase27-persona-diary.mjs", + "uat:phase29:dialogue-restart": "node scripts/uat-phase29-dialogue-restart.mjs", "uat:phase26": "node scripts/uat-phase26-playwright.mjs", "uat:phase25:core-ui": "node scripts/uat-phase25-core-ui.mjs", "uat:phase25:speak-defer": "node scripts/uat-phase25-speak-defer.mjs", diff --git a/packages/npc-memory/migrations/0013_npc_memories_halfvec_index.sql b/packages/npc-memory/migrations/0013_npc_memories_halfvec_index.sql new file mode 100644 index 0000000..7c43696 --- /dev/null +++ b/packages/npc-memory/migrations/0013_npc_memories_halfvec_index.sql @@ -0,0 +1,7 @@ +-- Phase 29 D-ANN-01/06: halfvec(2048) expression HNSW on npc_memories. +-- Column type stays vector(2048); no re-embed. Transactional migrate (no concurrent build). +-- Relationships embedding is intentionally unindexed (D-ANN-05). +-- Probe gate: pnpm verify:pgvector must PASS (pgvector >= 0.7 + halfvec). +CREATE INDEX IF NOT EXISTS npc_memories_embedding_halfvec_hnsw + ON npc_memories + USING hnsw ((embedding::halfvec(2048)) halfvec_cosine_ops); diff --git a/packages/npc-memory/migrations/0014_npc_attitudes_semantic_state.sql b/packages/npc-memory/migrations/0014_npc_attitudes_semantic_state.sql new file mode 100644 index 0000000..e996136 --- /dev/null +++ b/packages/npc-memory/migrations/0014_npc_attitudes_semantic_state.sql @@ -0,0 +1,8 @@ +-- Phase 29 D-BELIEF-01/02: additive Semantic state on npc_attitudes (columns only; no fact triples). +-- Defaults preserve existing upserts (applyReputationDelta sets reputation only). +ALTER TABLE npc_attitudes + ADD COLUMN IF NOT EXISTS current_mood text DEFAULT '平静'; +ALTER TABLE npc_attitudes + ADD COLUMN IF NOT EXISTS key_beliefs jsonb DEFAULT '[]'::jsonb; +ALTER TABLE npc_attitudes + ADD COLUMN IF NOT EXISTS summary text DEFAULT ''; diff --git a/packages/npc-memory/migrations/meta/_journal.json b/packages/npc-memory/migrations/meta/_journal.json index aa9ce07..3af8b42 100644 --- a/packages/npc-memory/migrations/meta/_journal.json +++ b/packages/npc-memory/migrations/meta/_journal.json @@ -78,6 +78,20 @@ "when": 1751241600000, "tag": "0012_npc_relationships_embedding", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1753833600000, + "tag": "0013_npc_memories_halfvec_index", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1753920000000, + "tag": "0014_npc_attitudes_semantic_state", + "breakpoints": true } ] } diff --git a/packages/npc-memory/src/collective/repository.test.ts b/packages/npc-memory/src/collective/repository.test.ts index 16ecc10..c3638df 100644 --- a/packages/npc-memory/src/collective/repository.test.ts +++ b/packages/npc-memory/src/collective/repository.test.ts @@ -99,4 +99,59 @@ describe("CollectiveRepository (in-memory)", () => { expect(await repo.getAttitude("r3", "npc-1", "p-a")).toBeNull(); expect(await repo.listEventsInWindow("r3", "npc-1", DEFAULT_COLLECTIVE_WINDOW_MS)).toHaveLength(0); }); + + it("upsertSemanticState replaces beliefs and returns mood/beliefs/summary via getAttitudeRow", async () => { + const repo = new CollectiveRepository(null); + await repo.applyReputationDelta("r-sem", "npc-1", "p-a", 0); + await repo.upsertSemanticState("r-sem", "npc-1", "p-a", { + mood: "恼火", + beliefs: ["我不信他的承诺"], + summary: "他反复失信", + }); + + const row = await repo.getAttitudeRow("r-sem", "npc-1", "p-a"); + expect(row).not.toBeNull(); + expect(row!.currentMood).toBe("恼火"); + expect(row!.keyBeliefs).toEqual(["我不信他的承诺"]); + expect(row!.summary).toBe("他反复失信"); + expect(row!.reputation).toBe(personalitySeedForNpc("npc-1")); + + await repo.upsertSemanticState("r-sem", "npc-1", "p-a", { + beliefs: ["我决定再观察一次"], + }); + const afterReplace = await repo.getAttitudeRow("r-sem", "npc-1", "p-a"); + expect(afterReplace!.keyBeliefs).toEqual(["我决定再观察一次"]); + expect(afterReplace!.currentMood).toBe("恼火"); + expect(afterReplace!.summary).toBe("他反复失信"); + }); + + it("omit path leaves prior semantic columns unchanged (D-BELIEF-07)", async () => { + const repo = new CollectiveRepository(null); + await repo.upsertSemanticState("r-omit", "npc-2", "p-a", { + mood: "愉悦", + beliefs: ["我信任他"], + summary: "进展顺利", + }); + await repo.upsertSemanticState("r-omit", "npc-2", "p-a", {}); + const row = await repo.getAttitudeRow("r-omit", "npc-2", "p-a"); + expect(row!.currentMood).toBe("愉悦"); + expect(row!.keyBeliefs).toEqual(["我信任他"]); + expect(row!.summary).toBe("进展顺利"); + }); + + it("applyReputationDelta does not wipe mood/beliefs/summary (Pitfall 3)", async () => { + const repo = new CollectiveRepository(null); + await repo.upsertSemanticState("r-rep", "npc-1", "p-a", { + mood: "警惕", + beliefs: ["他可能在试探我"], + summary: "保持距离", + }); + const before = await repo.getAttitudeRow("r-rep", "npc-1", "p-a"); + await repo.applyReputationDelta("r-rep", "npc-1", "p-a", -8); + const after = await repo.getAttitudeRow("r-rep", "npc-1", "p-a"); + expect(after!.currentMood).toBe("警惕"); + expect(after!.keyBeliefs).toEqual(["他可能在试探我"]); + expect(after!.summary).toBe("保持距离"); + expect(after!.reputation).toBe((before!.reputation) - 8); + }); }); diff --git a/packages/npc-memory/src/collective/repository.ts b/packages/npc-memory/src/collective/repository.ts index 67fee09..c8b3424 100644 --- a/packages/npc-memory/src/collective/repository.ts +++ b/packages/npc-memory/src/collective/repository.ts @@ -31,12 +31,34 @@ export type InsertCollectiveEventInput = { createdAt?: Date; }; +/** Full attitude row including semantic columns (D-BELIEF / 29-07 feed). */ +export type AttitudeRow = { + roomId: string; + npcId: string; + playerId: string; + reputation: number; + currentMood: string; + keyBeliefs: string[]; + summary: string; + updatedAt: Date; +}; + +/** Patch for semantic upsert — omitted keys are not written (D-BELIEF-07). */ +export type SemanticStatePatch = { + mood?: string; + beliefs?: string[]; + summary?: string; +}; + type InMemoryEvent = CollectiveEventRow; type InMemoryAttitude = { roomId: string; npcId: string; playerId: string; reputation: number; + currentMood: string; + keyBeliefs: string[]; + summary: string; updatedAt: Date; }; @@ -51,6 +73,10 @@ class InMemoryCollectiveStore { } } +function defaultSemanticFields(): Pick { + return { currentMood: "平静", keyBeliefs: [], summary: "" }; +} + export class CollectiveRepository { private readonly store: InMemoryCollectiveStore | null; @@ -162,16 +188,46 @@ export class CollectiveRepository { return set.size; } + /** Reputation-only helper — callers that need semantic use getAttitudeRow. */ async getAttitude(roomId: string, npcId: string, playerId: string): Promise { + const row = await this.getAttitudeRow(roomId, npcId, playerId); + return row?.reputation ?? null; + } + + async getAttitudeRow( + roomId: string, + npcId: string, + playerId: string, + ): Promise { if (this.store) { const row = this.store.attitudes.find( (a) => a.roomId === roomId && a.npcId === npcId && a.playerId === playerId, ); - return row?.reputation ?? null; + return row + ? { + roomId: row.roomId, + npcId: row.npcId, + playerId: row.playerId, + reputation: row.reputation, + currentMood: row.currentMood, + keyBeliefs: [...row.keyBeliefs], + summary: row.summary, + updatedAt: row.updatedAt, + } + : null; } const rows = await this.db! - .select({ reputation: npcAttitudes.reputation }) + .select({ + roomId: npcAttitudes.roomId, + npcId: npcAttitudes.npcId, + playerId: npcAttitudes.playerId, + reputation: npcAttitudes.reputation, + currentMood: npcAttitudes.currentMood, + keyBeliefs: npcAttitudes.keyBeliefs, + summary: npcAttitudes.summary, + updatedAt: npcAttitudes.updatedAt, + }) .from(npcAttitudes) .where( and( @@ -181,7 +237,94 @@ export class CollectiveRepository { ), ) .limit(1); - return rows[0]?.reputation ?? null; + + const row = rows[0]; + if (!row) return null; + return { + roomId: row.roomId, + npcId: row.npcId, + playerId: row.playerId, + reputation: row.reputation, + currentMood: row.currentMood, + keyBeliefs: Array.isArray(row.keyBeliefs) ? [...row.keyBeliefs] : [], + summary: row.summary, + updatedAt: row.updatedAt, + }; + } + + /** + * Write semantic columns only for keys present in patch (D-BELIEF-07/09/13). + * Success path replaces beliefs entirely when `beliefs` is provided. + */ + async upsertSemanticState( + roomId: string, + npcId: string, + playerId: string, + patch: SemanticStatePatch, + ): Promise { + const hasMood = patch.mood !== undefined; + const hasBeliefs = patch.beliefs !== undefined; + const hasSummary = patch.summary !== undefined; + if (!hasMood && !hasBeliefs && !hasSummary) return; + + const now = new Date(); + const existing = await this.getAttitudeRow(roomId, npcId, playerId); + const reputation = existing?.reputation ?? personalitySeedForNpc(npcId); + const nextMood = hasMood ? patch.mood! : (existing?.currentMood ?? "平静"); + const nextBeliefs = hasBeliefs ? [...patch.beliefs!] : [...(existing?.keyBeliefs ?? [])]; + const nextSummary = hasSummary ? patch.summary! : (existing?.summary ?? ""); + + if (this.store) { + const idx = this.store.attitudes.findIndex( + (a) => a.roomId === roomId && a.npcId === npcId && a.playerId === playerId, + ); + if (idx >= 0) { + const row = this.store.attitudes[idx]!; + if (hasMood) row.currentMood = patch.mood!; + if (hasBeliefs) row.keyBeliefs = [...patch.beliefs!]; + if (hasSummary) row.summary = patch.summary!; + row.updatedAt = now; + } else { + this.store.attitudes.push({ + roomId, + npcId, + playerId, + reputation, + currentMood: nextMood, + keyBeliefs: nextBeliefs, + summary: nextSummary, + updatedAt: now, + }); + } + return; + } + + const conflictSet: { + updatedAt: Date; + currentMood?: string; + keyBeliefs?: string[]; + summary?: string; + } = { updatedAt: now }; + if (hasMood) conflictSet.currentMood = patch.mood; + if (hasBeliefs) conflictSet.keyBeliefs = patch.beliefs; + if (hasSummary) conflictSet.summary = patch.summary; + + await this.db! + .insert(npcAttitudes) + .values({ + roomId, + npcId, + playerId, + reputation, + currentMood: nextMood, + keyBeliefs: nextBeliefs, + summary: nextSummary, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [npcAttitudes.roomId, npcAttitudes.npcId, npcAttitudes.playerId], + set: conflictSet, + }); } async applyReputationDelta( @@ -200,10 +343,18 @@ export class CollectiveRepository { (a) => a.roomId === roomId && a.npcId === npcId && a.playerId === playerId, ); if (idx >= 0) { + // Pitfall 3: only reputation + updatedAt — never wipe semantic. this.store.attitudes[idx]!.reputation = next; this.store.attitudes[idx]!.updatedAt = now; } else { - this.store.attitudes.push({ roomId, npcId, playerId, reputation: next, updatedAt: now }); + this.store.attitudes.push({ + roomId, + npcId, + playerId, + reputation: next, + ...defaultSemanticFields(), + updatedAt: now, + }); } return next; } @@ -219,6 +370,7 @@ export class CollectiveRepository { }) .onConflictDoUpdate({ target: [npcAttitudes.roomId, npcAttitudes.npcId, npcAttitudes.playerId], + // Pitfall 3: set list must not include mood/beliefs/summary. set: { reputation: next, updatedAt: now, diff --git a/packages/npc-memory/src/index.ts b/packages/npc-memory/src/index.ts index 641c6a7..91cd496 100644 --- a/packages/npc-memory/src/index.ts +++ b/packages/npc-memory/src/index.ts @@ -1,8 +1,11 @@ export { createDb, getSharedDb, getSharedSql, resetSharedDbForTests, type Db } from "./db.js"; export { + computeRecencyFactor, computeWeightedScore, + resolveRecencyConfig, MemoryRepository, type AppendMemoryInput, + type RecencyConfig, type SimilarMemory, } from "./repository.js"; export { @@ -20,8 +23,10 @@ export { export { CollectiveRepository, createCollectiveRepository, + type AttitudeRow, type CollectiveEventRow, type InsertCollectiveEventInput, + type SemanticStatePatch, } from "./collective/repository.js"; export { COLLECTIVE_EVENT_TTL_MS, diff --git a/packages/npc-memory/src/repository.test.ts b/packages/npc-memory/src/repository.test.ts index b52dceb..e0275e3 100644 --- a/packages/npc-memory/src/repository.test.ts +++ b/packages/npc-memory/src/repository.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { computeWeightedScore } from "./repository.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + computeRecencyFactor, + computeWeightedScore, + DEFAULT_K_OVERFETCH_CAP, + rerankCandidatesByForgettingCurve, + resolveKOverfetch, + resolveRecencyConfig, + type AnnCandidate, +} from "./repository.js"; describe("computeWeightedScore", () => { it("weights higher importance memories", () => { @@ -11,4 +19,175 @@ describe("computeWeightedScore", () => { it("uses default factor at importance 5", () => { expect(computeWeightedScore(1, 5)).toBeCloseTo(0.75, 5); }); + + it("two-arg form stays identical when ageHours omitted", () => { + expect(computeWeightedScore(0.9, 1)).toBeCloseTo(0.9 * (0.5 + 1 / 20), 10); + expect(computeWeightedScore(0.9, 10)).toBeCloseTo(0.9 * (0.5 + 10 / 20), 10); + expect(computeWeightedScore(1, 0)).toBeCloseTo(0.75, 10); // importance||5 legacy + }); + + it("with ageHours applies cos × importanceFactor × recencyFactor", () => { + const cos = 0.8; + const importance = 5; + const ageHours = 72; + const recency = computeRecencyFactor(ageHours, importance); + const expected = cos * (0.5 + importance / 20) * recency; + expect(computeWeightedScore(cos, importance, ageHours)).toBeCloseTo(expected, 10); + }); +}); + +describe("computeRecencyFactor", () => { + const cfg = { s0: 72, floor: 0.3, sEpsilon: 1e-3 }; + + it("ageHours=0 → recencyFactor=1", () => { + expect(computeRecencyFactor(0, 5, cfg)).toBeCloseTo(1, 10); + }); + + it("huge age converges to floor, never 0", () => { + const r = computeRecencyFactor(1e9, 5, cfg); + expect(r).toBeCloseTo(cfg.floor, 10); + expect(r).toBeGreaterThan(0); + }); + + it("importance=0 uses ε clamp — no NaN/Infinity", () => { + const r = computeRecencyFactor(10, 0, cfg); + expect(Number.isFinite(r)).toBe(true); + expect(Number.isNaN(r)).toBe(false); + // S = ε → decays fast but still ≥ floor + expect(r).toBeGreaterThanOrEqual(cfg.floor); + }); + + it("higher importance at same age → slower decay", () => { + const age = 72; + const low = computeRecencyFactor(age, 1, cfg); + const high = computeRecencyFactor(age, 10, cfg); + expect(high).toBeGreaterThan(low); + }); + + it("S = max(ε, S0 × importance/5)", () => { + // At age = S, factor = max(floor, 0.5) + const importance = 5; + const S = Math.max(cfg.sEpsilon, cfg.s0 * (importance / 5)); + expect(S).toBe(72); + expect(computeRecencyFactor(S, importance, cfg)).toBeCloseTo(0.5, 10); + }); +}); + +describe("resolveRecencyConfig", () => { + const saved = { + MEMORY_RECENCY_HALFLIFE_HOURS: process.env.MEMORY_RECENCY_HALFLIFE_HOURS, + MEMORY_RECENCY_FLOOR: process.env.MEMORY_RECENCY_FLOOR, + MEMORY_RECENCY_S_EPSILON: process.env.MEMORY_RECENCY_S_EPSILON, + }; + + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("defaults to S0=72, floor=0.3, ε=1e-3", () => { + delete process.env.MEMORY_RECENCY_HALFLIFE_HOURS; + delete process.env.MEMORY_RECENCY_FLOOR; + delete process.env.MEMORY_RECENCY_S_EPSILON; + expect(resolveRecencyConfig()).toEqual({ s0: 72, floor: 0.3, sEpsilon: 1e-3 }); + }); + + it("invalid env falls back to defaults", () => { + process.env.MEMORY_RECENCY_HALFLIFE_HOURS = "nope"; + process.env.MEMORY_RECENCY_FLOOR = "-1"; + process.env.MEMORY_RECENCY_S_EPSILON = "0"; + expect(resolveRecencyConfig()).toEqual({ s0: 72, floor: 0.3, sEpsilon: 1e-3 }); + }); + + it("reads valid env overrides", () => { + process.env.MEMORY_RECENCY_HALFLIFE_HOURS = "48"; + process.env.MEMORY_RECENCY_FLOOR = "0.2"; + process.env.MEMORY_RECENCY_S_EPSILON = "0.01"; + expect(resolveRecencyConfig()).toEqual({ s0: 48, floor: 0.2, sEpsilon: 0.01 }); + }); +}); + +describe("SQL ≡ TS forgetting-curve parity (D-DECAY-04)", () => { + it("matches fixture inputs for the SQL age/S/floor expression", () => { + const cfg = { s0: 72, floor: 0.3, sEpsilon: 1e-3 }; + const fixtures: Array<{ ageHours: number; importance: number; cos: number }> = [ + { ageHours: 0, importance: 5, cos: 1 }, + { ageHours: 24, importance: 1, cos: 0.9 }, + { ageHours: 72, importance: 10, cos: 0.7 }, + { ageHours: 1e6, importance: 0, cos: 0.5 }, + ]; + for (const { ageHours, importance, cos } of fixtures) { + // Mirrors searchSimilar SQL: GREATEST(floor, exp(-age*ln2 / GREATEST(ε, S0*COALESCE(imp,5)/5))) + const S = Math.max(cfg.sEpsilon, cfg.s0 * (importance / 5)); + const sqlRecency = Math.max(cfg.floor, Math.exp((-ageHours * Math.LN2) / S)); + const sqlScore = cos * (0.5 + importance / 20) * sqlRecency; + expect(sqlRecency).toBeCloseTo(computeRecencyFactor(ageHours, importance, cfg), 12); + expect(sqlScore).toBeCloseTo(computeWeightedScore(cos, importance, ageHours, cfg), 12); + } + }); + + it("NULL importance defaults to 5 for both importanceFactor and S (SQL COALESCE parity)", () => { + const cfg = { s0: 72, floor: 0.3, sEpsilon: 1e-3 }; + const ageHours = 24; + const cos = 0.9; + const importanceDefault = 5; + const S = Math.max(cfg.sEpsilon, cfg.s0 * (importanceDefault / 5)); + const sqlRecency = Math.max(cfg.floor, Math.exp((-ageHours * Math.LN2) / S)); + const sqlScore = cos * (0.5 + importanceDefault / 20) * sqlRecency; + expect(sqlScore).toBeCloseTo( + computeWeightedScore(cos, importanceDefault, ageHours, cfg), + 12, + ); + }); +}); + +describe("resolveKOverfetch (D-ANN-03)", () => { + it("defaults to max(20, 4*k)", () => { + expect(resolveKOverfetch(5)).toBe(20); + expect(resolveKOverfetch(1)).toBe(20); + expect(resolveKOverfetch(10)).toBe(40); + }); + + it("caps at DEFAULT_K_OVERFETCH_CAP", () => { + expect(resolveKOverfetch(10_000)).toBe(DEFAULT_K_OVERFETCH_CAP); + }); +}); + +describe("rerankCandidatesByForgettingCurve (D-ANN-03)", () => { + const cfg = { s0: 72, floor: 0.3, sEpsilon: 1e-3 }; + + it("returns at most k rows scored with forgetting-curve", () => { + const candidates: AnnCandidate[] = [ + { id: "a", text: "near-old", importance: 1, cosineSimilarity: 0.99, ageHours: 500 }, + { id: "b", text: "far-fresh-important", importance: 10, cosineSimilarity: 0.7, ageHours: 1 }, + { id: "c", text: "mid", importance: 5, cosineSimilarity: 0.85, ageHours: 24 }, + { id: "d", text: "noise", importance: 2, cosineSimilarity: 0.5, ageHours: 10 }, + ]; + const out = rerankCandidatesByForgettingCurve(candidates, 2, cfg); + expect(out).toHaveLength(2); + for (const row of out) { + expect(row.score).toBeCloseTo( + computeWeightedScore( + candidates.find((c) => c.id === row.id)!.cosineSimilarity, + row.importance, + candidates.find((c) => c.id === row.id)!.ageHours, + cfg, + ), + 10, + ); + } + expect(out[0]!.score).toBeGreaterThanOrEqual(out[1]!.score); + }); + + it("prefers high-importance fresh over near-but-stale when scores dictate", () => { + const candidates: AnnCandidate[] = [ + { id: "stale", text: "stale", importance: 1, cosineSimilarity: 0.95, ageHours: 1e6 }, + { id: "fresh", text: "fresh", importance: 10, cosineSimilarity: 0.8, ageHours: 0 }, + ]; + const out = rerankCandidatesByForgettingCurve(candidates, 1, cfg); + expect(out).toHaveLength(1); + expect(out[0]!.id).toBe("fresh"); + }); }); diff --git a/packages/npc-memory/src/repository.ts b/packages/npc-memory/src/repository.ts index b30c856..901c1d7 100644 --- a/packages/npc-memory/src/repository.ts +++ b/packages/npc-memory/src/repository.ts @@ -18,10 +18,130 @@ export type AppendMemoryInput = { embedding?: number[]; }; -/** Weighted retrieval score from 03-RESEARCH.md */ -export function computeWeightedScore(cosineSimilarity: number, importance: number): number { - const factor = 0.5 + (importance || 5) / 20; - return cosineSimilarity * factor; +/** Ebbinghaus recency knobs (D-DECAY-02/02b/03). */ +export type RecencyConfig = { + /** Baseline half-life hours S₀ (default 72). */ + s0: number; + /** Floor for recencyFactor (default 0.3). */ + floor: number; + /** Min S when importance→0 (default 1e-3). */ + sEpsilon: number; +}; + +const DEFAULT_RECENCY: RecencyConfig = { + s0: 72, + floor: 0.3, + sEpsilon: 1e-3, +}; + +function parsePositiveNumber(raw: string | undefined, fallback: number): number { + if (raw === undefined || raw === "") return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return fallback; + return n; +} + +function envSource(): Record { + const g = globalThis as { process?: { env?: Record } }; + return g.process?.env ?? {}; +} + +/** Read MEMORY_RECENCY_* env; invalid values → defaults. */ +export function resolveRecencyConfig( + env: Record = envSource(), +): RecencyConfig { + const s0 = parsePositiveNumber(env.MEMORY_RECENCY_HALFLIFE_HOURS, DEFAULT_RECENCY.s0); + const floorRaw = env.MEMORY_RECENCY_FLOOR; + let floor = DEFAULT_RECENCY.floor; + if (floorRaw !== undefined && floorRaw !== "") { + const n = Number(floorRaw); + if (Number.isFinite(n) && n >= 0 && n <= 1) floor = n; + } + const sEpsilon = parsePositiveNumber(env.MEMORY_RECENCY_S_EPSILON, DEFAULT_RECENCY.sEpsilon); + return { s0, floor, sEpsilon }; +} + +/** + * recencyFactor = max(FLOOR, exp(-ageHours × ln2 / S)) + * S = max(ε, S0 × importance/5) + */ +export function computeRecencyFactor( + ageHours: number, + importance: number, + cfg: RecencyConfig = resolveRecencyConfig(), +): number { + const S = Math.max(cfg.sEpsilon, cfg.s0 * (importance / 5)); + return Math.max(cfg.floor, Math.exp((-ageHours * Math.LN2) / S)); +} + +/** + * Weighted retrieval score. + * Two-arg: legacy cos × (0.5 + (importance||5)/20). + * With ageHours: cos × (0.5 + importance/20) × recencyFactor (D-DECAY-01). + */ +export function computeWeightedScore( + cosineSimilarity: number, + importance: number, + ageHours?: number, + cfg?: RecencyConfig, +): number { + if (ageHours === undefined) { + const factor = 0.5 + (importance || 5) / 20; + return cosineSimilarity * factor; + } + const importanceFactor = 0.5 + importance / 20; + const recency = computeRecencyFactor(ageHours, importance, cfg ?? resolveRecencyConfig()); + return cosineSimilarity * importanceFactor * recency; +} + +/** Hard cap for ANN overfetch (T-29-04-02 DoS mitigation). */ +export const DEFAULT_K_OVERFETCH_CAP = 200; + +/** + * Candidate pool size for distance ORDER BY before forgetting-curve rerank. + * Default: max(20, 4*k), capped (D-ANN-03 / RESEARCH Open Q2). + */ +export function resolveKOverfetch( + k: number, + env: Record = envSource(), +): number { + const safeK = Number.isFinite(k) && k > 0 ? Math.floor(k) : 5; + const rawCap = env.MEMORY_K_OVERFETCH_CAP; + let cap = DEFAULT_K_OVERFETCH_CAP; + if (rawCap !== undefined && rawCap !== "") { + const n = Number(rawCap); + if (Number.isFinite(n) && n >= 1) cap = Math.floor(n); + } + return Math.min(cap, Math.max(20, 4 * safeK)); +} + +/** Distance-ordered ANN candidate before weighted rerank. */ +export type AnnCandidate = { + id: string; + text: string; + importance: number; + cosineSimilarity: number; + ageHours: number; +}; + +/** + * Re-score distance candidates with forgetting-curve formula; return top k (D-ANN-03). + */ +export function rerankCandidatesByForgettingCurve( + candidates: AnnCandidate[], + k: number, + cfg: RecencyConfig = resolveRecencyConfig(), +): Array { + const safeK = Number.isFinite(k) && k > 0 ? Math.floor(k) : 5; + return candidates + .map((c) => ({ + id: c.id, + text: c.text, + importance: c.importance, + score: computeWeightedScore(c.cosineSimilarity, c.importance, c.ageHours, cfg), + })) + .sort((a, b) => b.score - a.score) + .slice(0, safeK); } export class MemoryRepository { @@ -74,20 +194,45 @@ export class MemoryRepository { }): Promise { const npcId = input.npcId ?? DEFAULT_NPC_ID; const k = input.k ?? 5; + const kOverfetch = resolveKOverfetch(k); const vectorLiteral = `[${input.queryEmbedding.join(",")}]`; + const cfg = resolveRecencyConfig(); + // D-ANN-03: ORDER BY halfvec distance ASC LIMIT k_overfetch so HNSW can be used, + // then re-score with forgetting-curve and take top k. const result = await this.db.execute(sql` + WITH candidates AS ( + SELECT + text, + importance, + created_at, + (embedding::halfvec(2048) <=> ${vectorLiteral}::halfvec(2048)) AS dist + FROM npc_memories + WHERE room_id = ${input.roomId} + AND player_id = ${input.playerId} + AND npc_id = ${npcId} + AND summarized_at IS NULL + AND embedding IS NOT NULL + ORDER BY embedding::halfvec(2048) <=> ${vectorLiteral}::halfvec(2048) ASC + LIMIT ${kOverfetch} + ) SELECT text, importance, - (1 - (embedding <=> ${vectorLiteral}::vector)) - * (0.5 + COALESCE(importance, 5) / 20.0) AS score - FROM npc_memories - WHERE room_id = ${input.roomId} - AND player_id = ${input.playerId} - AND npc_id = ${npcId} - AND summarized_at IS NULL - AND embedding IS NOT NULL + (1 - dist) + * (0.5 + COALESCE(importance, 5) / 20.0) + * GREATEST( + ${cfg.floor}::float8, + EXP( + - (EXTRACT(EPOCH FROM (now() - created_at)) / 3600.0) + * LN(2) + / GREATEST( + ${cfg.sEpsilon}::float8, + ${cfg.s0}::float8 * COALESCE(importance, 5) / 5.0 + ) + ) + ) AS score + FROM candidates ORDER BY score DESC LIMIT ${k} `); diff --git a/packages/npc-memory/src/schema.test.ts b/packages/npc-memory/src/schema.test.ts index 961bde5..a32f12e 100644 --- a/packages/npc-memory/src/schema.test.ts +++ b/packages/npc-memory/src/schema.test.ts @@ -3,7 +3,12 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { getTableColumns } from "drizzle-orm"; -import { EMBED_DIMENSIONS, npcMemories, npcRelationships } from "./schema.js"; +import { + EMBED_DIMENSIONS, + npcAttitudes, + npcMemories, + npcRelationships, +} from "./schema.js"; const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../migrations"); @@ -31,3 +36,38 @@ describe("npcRelationships embedding (D-EMBED-02/03)", () => { expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS\s+embedding\s+vector\(2048\)/i); }); }); + +describe("Phase 29 ANN + semantic attitudes (D-ANN / D-BELIEF)", () => { + it("npcAttitudes exports currentMood, keyBeliefs, summary", () => { + const cols = getTableColumns(npcAttitudes); + expect(cols.currentMood).toBeDefined(); + expect(cols.currentMood.name).toBe("current_mood"); + expect(cols.keyBeliefs).toBeDefined(); + expect(cols.keyBeliefs.name).toBe("key_beliefs"); + expect(cols.summary).toBeDefined(); + expect(cols.summary.name).toBe("summary"); + }); + + it("migration 0013 is halfvec HNSW without CONCURRENTLY", () => { + const sql = readFileSync( + resolve(migrationsDir, "0013_npc_memories_halfvec_index.sql"), + "utf8", + ); + expect(sql).toMatch(/CREATE INDEX IF NOT EXISTS\s+npc_memories_embedding_halfvec_hnsw/i); + expect(sql).toMatch(/halfvec\(2048\)/); + expect(sql).toMatch(/halfvec_cosine_ops/); + expect(sql).not.toMatch(/CREATE\s+INDEX\s+CONCURRENTLY/i); + expect(sql).not.toMatch(/ON\s+npc_relationships/i); + }); + + it("migration 0014 adds mood/beliefs/summary with Chinese default mood", () => { + const sql = readFileSync( + resolve(migrationsDir, "0014_npc_attitudes_semantic_state.sql"), + "utf8", + ); + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS\s+current_mood\s+text\s+DEFAULT\s+'平静'/i); + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS\s+key_beliefs\s+jsonb/i); + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS\s+summary\s+text\s+DEFAULT\s+''/i); + expect(sql).not.toMatch(/CREATE\s+TABLE/i); + }); +}); diff --git a/packages/npc-memory/src/schema.ts b/packages/npc-memory/src/schema.ts index 5d7ad46..b9a5732 100644 --- a/packages/npc-memory/src/schema.ts +++ b/packages/npc-memory/src/schema.ts @@ -110,6 +110,12 @@ export const npcAttitudes = pgTable( npcId: text("npc_id").notNull(), playerId: text("player_id").notNull(), reputation: integer("reputation").notNull().default(0), + /** Closed Chinese 8-mood whitelist (D-BELIEF-08); default 平静. */ + currentMood: text("current_mood").notNull().default("平静"), + /** First-person belief strings ≤5×40 chars (D-BELIEF-04/12). */ + keyBeliefs: jsonb("key_beliefs").$type().notNull().default([]), + /** LLM-visible attitude summary ≤200 chars (D-BELIEF-04). */ + summary: text("summary").notNull().default(""), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ diff --git a/packages/shared/src/council/semanticState.test.ts b/packages/shared/src/council/semanticState.test.ts new file mode 100644 index 0000000..0086d4a --- /dev/null +++ b/packages/shared/src/council/semanticState.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + NPC_MOODS, + clampSemanticState, + isNpcMood, +} from "./semanticState.js"; + +describe("NPC_MOODS", () => { + it("is the closed Chinese 8-mood whitelist (D-BELIEF-08)", () => { + expect([...NPC_MOODS]).toEqual([ + "平静", + "亲近", + "警惕", + "恼火", + "愉悦", + "低落", + "愧疚", + "戏谑", + ]); + }); + + it.each([...NPC_MOODS])("accepts whitelist mood %s", (mood) => { + expect(isNpcMood(mood)).toBe(true); + expect(clampSemanticState({ mood }).mood).toBe(mood); + }); +}); + +describe("clampSemanticState", () => { + it("omits illegal mood so caller can preserve prior (D-BELIEF-07/08)", () => { + expect(clampSemanticState({ mood: "friendly" }).mood).toBeUndefined(); + expect(clampSemanticState({ mood: "angry" }).mood).toBeUndefined(); + expect(clampSemanticState({ mood: "平静 " }).mood).toBeUndefined(); + expect(clampSemanticState({ mood: "" }).mood).toBeUndefined(); + }); + + it("does not map English aliases to Chinese moods", () => { + expect(clampSemanticState({ mood: "calm" }).mood).toBeUndefined(); + expect(clampSemanticState({ mood: "neutral" }).mood).toBeUndefined(); + }); + + it("caps beliefs at 5 × ≤40 chars (D-BELIEF-04)", () => { + const long = "我".repeat(50); + const beliefs = [ + " 我不信他的承诺 ", + long, + "第二条", + "第三条", + "第四条", + "第五条", + "第六条应被丢弃", + ]; + const clamped = clampSemanticState({ beliefs }); + expect(clamped.beliefs).toHaveLength(5); + expect(clamped.beliefs![0]).toBe("我不信他的承诺"); + expect(clamped.beliefs![1]).toHaveLength(40); + expect(clamped.beliefs).not.toContain("第六条应被丢弃"); + }); + + it("drops empty belief strings after trim", () => { + expect(clampSemanticState({ beliefs: [" ", "有效"] }).beliefs).toEqual(["有效"]); + }); + + it("caps summary at 200 chars (D-BELIEF-04)", () => { + const summary = "概".repeat(250); + expect(clampSemanticState({ summary }).summary).toHaveLength(200); + }); + + it("omits fields that were not provided", () => { + const clamped = clampSemanticState({}); + expect(clamped).toEqual({}); + expect(clampSemanticState({ mood: "愉悦" })).toEqual({ mood: "愉悦" }); + }); + + it("allows empty beliefs array on success replace path (D-BELIEF-13)", () => { + expect(clampSemanticState({ beliefs: [] }).beliefs).toEqual([]); + }); +}); diff --git a/packages/shared/src/council/semanticState.ts b/packages/shared/src/council/semanticState.ts new file mode 100644 index 0000000..88488b3 --- /dev/null +++ b/packages/shared/src/council/semanticState.ts @@ -0,0 +1,66 @@ +/** + * Semantic attitude state clamp (D-BELIEF-04/08/12). + * + * `key_beliefs` should be NPC first-person short sentences (e.g. 「我不信他的承诺」). + * Mood is a closed Chinese 8-value whitelist — no English aliases. + */ + +export const NPC_MOODS = [ + "平静", + "亲近", + "警惕", + "恼火", + "愉悦", + "低落", + "愧疚", + "戏谑", +] as const; + +export type NpcMood = (typeof NPC_MOODS)[number]; + +const NPC_MOOD_SET: ReadonlySet = new Set(NPC_MOODS); + +export const KEY_BELIEF_MAX_COUNT = 5; +export const KEY_BELIEF_MAX_CHARS = 40; +export const SEMANTIC_SUMMARY_MAX_CHARS = 200; + +export function isNpcMood(value: string): value is NpcMood { + return NPC_MOOD_SET.has(value); +} + +export type SemanticStateInput = { + mood?: string | null; + beliefs?: string[] | null; + summary?: string | null; +}; + +/** + * Clamp LLM/reflect semantic fields. Omitted keys mean "do not write" so the + * caller can preserve prior DB values (D-BELIEF-07). + */ +export type ClampedSemanticState = { + mood?: NpcMood; + beliefs?: string[]; + summary?: string; +}; + +export function clampSemanticState(input: SemanticStateInput): ClampedSemanticState { + const out: ClampedSemanticState = {}; + + if (typeof input.mood === "string" && input.mood.length > 0 && isNpcMood(input.mood)) { + out.mood = input.mood; + } + + if (input.beliefs != null) { + out.beliefs = input.beliefs + .map((b) => (typeof b === "string" ? b.trim().slice(0, KEY_BELIEF_MAX_CHARS) : "")) + .filter((b) => b.length > 0) + .slice(0, KEY_BELIEF_MAX_COUNT); + } + + if (typeof input.summary === "string") { + out.summary = input.summary.slice(0, SEMANTIC_SUMMARY_MAX_CHARS); + } + + return out; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f077c41..0c5947a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -225,6 +225,18 @@ export { type AttitudeBand, } from "./attitude.js"; +export { + KEY_BELIEF_MAX_CHARS, + KEY_BELIEF_MAX_COUNT, + NPC_MOODS, + SEMANTIC_SUMMARY_MAX_CHARS, + clampSemanticState, + isNpcMood, + type ClampedSemanticState, + type NpcMood, + type SemanticStateInput, +} from "./council/semanticState.js"; + export { NPC_ACTIVITY_KEYS, NPC_ACTIVITY_LABEL_ZH, @@ -290,6 +302,18 @@ export { type WitnessDeltaUpdate, } from "./collectiveMemory.js"; +export { + PROPAGATION_FRACTION, + PROPAGATION_MAX_ABS, + PROPAGATION_MAX_FANOUT, + PROPAGATION_MIN_AFFECTION, + PROPAGATION_MIN_EVENT, + computeRelationshipPropagationDeltas, + propagationPolarityForBaseTag, + type ComputeRelationshipPropagationInput, + type PropagationEdge, +} from "./relationshipPropagation.js"; + export { AETHER_CALENDAR_EPOCH_YEAR, AETHER_SEASONS, diff --git a/packages/shared/src/relationshipPropagation.test.ts b/packages/shared/src/relationshipPropagation.test.ts new file mode 100644 index 0000000..d4e3551 --- /dev/null +++ b/packages/shared/src/relationshipPropagation.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + PROPAGATION_FRACTION, + PROPAGATION_MAX_ABS, + PROPAGATION_MAX_FANOUT, + PROPAGATION_MIN_AFFECTION, + PROPAGATION_MIN_EVENT, + computeRelationshipPropagationDeltas, + type PropagationEdge, +} from "./relationshipPropagation.js"; + +function edge( + npcAId: string, + npcBId: string, + baseTag: string, + affection: number, +): PropagationEdge { + return { npcAId, npcBId, baseTag, affection }; +} + +describe("PROPAGATION constants (D-PROP-10)", () => { + it("locks exact numeric values", () => { + expect(PROPAGATION_FRACTION).toBe(0.3); + expect(PROPAGATION_MIN_AFFECTION).toBe(30); + expect(PROPAGATION_MAX_FANOUT).toBe(3); + expect(PROPAGATION_MIN_EVENT).toBe(5); + expect(PROPAGATION_MAX_ABS).toBe(5); + }); +}); + +describe("computeRelationshipPropagationDeltas", () => { + it("friend edge keeps same sign", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: -8, + playerIds: ["p-a"], + edges: [edge("npc-1", "npc-friend", "ally", 50)], + alreadyUpdated: new Set(["npc-1"]), + }); + // Math.round(-8 * 0.3) = -2 + expect(updates).toEqual([{ npcId: "npc-friend", playerId: "p-a", delta: -2 }]); + }); + + it("rival/enemy baseTag inverts sign", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: -8, + playerIds: ["p-a"], + edges: [edge("npc-1", "npc-rival", "rival", -50)], + alreadyUpdated: new Set(["npc-1"]), + }); + expect(updates).toEqual([{ npcId: "npc-rival", playerId: "p-a", delta: 2 }]); + + const enemy = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: 10, + playerIds: ["p-a"], + edges: [edge("npc-enemy", "npc-1", "nemesis", -80)], + alreadyUpdated: new Set(["npc-1"]), + }); + // Math.round(10 * 0.3) = 3 → invert → -3 + expect(enemy).toEqual([{ npcId: "npc-enemy", playerId: "p-a", delta: -3 }]); + }); + + it("skips |eventDelta| < MIN_EVENT", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: 4, + playerIds: ["p-a"], + edges: [edge("npc-1", "npc-friend", "ally", 50)], + alreadyUpdated: new Set(["npc-1"]), + }); + expect(updates).toEqual([]); + }); + + it("skips edges below MIN_AFFECTION (|affection|)", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: -8, + playerIds: ["p-a"], + edges: [edge("npc-1", "npc-weak", "ally", 29)], + alreadyUpdated: new Set(["npc-1"]), + }); + expect(updates).toEqual([]); + }); + + it("caps fanout at MAX_FANOUT by |affection|", () => { + const edges = [ + edge("npc-1", "n1", "ally", 90), + edge("npc-1", "n2", "ally", 80), + edge("npc-1", "n3", "ally", 70), + edge("npc-1", "n4", "ally", 60), + ]; + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: -10, + playerIds: ["p-a"], + edges, + alreadyUpdated: new Set(["npc-1"]), + }); + expect(updates).toHaveLength(3); + const ids = updates.map((u) => u.npcId); + expect(ids).toEqual(["n1", "n2", "n3"]); + expect(ids).not.toContain("n4"); + }); + + it("excludes alreadyUpdated witness set", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: -8, + playerIds: ["p-a"], + edges: [ + edge("npc-1", "npc-witness", "ally", 50), + edge("npc-1", "npc-offscreen", "ally", 40), + ], + alreadyUpdated: new Set(["npc-1", "npc-witness"]), + }); + expect(updates).toEqual([{ npcId: "npc-offscreen", playerId: "p-a", delta: -2 }]); + }); + + it("clamps abs delta to MAX_ABS", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: -20, + playerIds: ["p-a"], + edges: [edge("npc-1", "npc-friend", "ally", 50)], + alreadyUpdated: new Set(["npc-1"]), + }); + // Math.round(-20 * 0.3) = -6 → clamp to -5 + expect(updates).toEqual([{ npcId: "npc-friend", playerId: "p-a", delta: -5 }]); + }); + + it("emits one update per playerId", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: 6, + playerIds: ["p-a", "p-b"], + edges: [edge("npc-1", "npc-friend", "ally", 50)], + alreadyUpdated: new Set(["npc-1"]), + }); + expect(updates).toEqual([ + { npcId: "npc-friend", playerId: "p-a", delta: 2 }, + { npcId: "npc-friend", playerId: "p-b", delta: 2 }, + ]); + }); + + it("skips unknown / non friend-rival baseTags", () => { + const updates = computeRelationshipPropagationDeltas({ + targetNpcId: "npc-1", + eventDelta: -8, + playerIds: ["p-a"], + edges: [edge("npc-1", "npc-peer", "peer", 50)], + alreadyUpdated: new Set(["npc-1"]), + }); + expect(updates).toEqual([]); + }); +}); diff --git a/packages/shared/src/relationshipPropagation.ts b/packages/shared/src/relationshipPropagation.ts new file mode 100644 index 0000000..4565330 --- /dev/null +++ b/packages/shared/src/relationshipPropagation.ts @@ -0,0 +1,111 @@ +import type { WitnessDeltaUpdate } from "./collectiveMemory.js"; + +/** D-PROP-10: fraction of event delta applied to related NPCs (same order as witness). */ +export const PROPAGATION_FRACTION = 0.3; +/** D-PROP-10: minimum |affection| for an edge to propagate. */ +export const PROPAGATION_MIN_AFFECTION = 30; +/** D-PROP-10: max related NPCs updated per event. */ +export const PROPAGATION_MAX_FANOUT = 3; +/** D-PROP-10: skip propagation when |eventDelta| is below this. */ +export const PROPAGATION_MIN_EVENT = 5; +/** D-PROP-10: clamp |propagated delta| to this. */ +export const PROPAGATION_MAX_ABS = 5; + +/** Minimal edge fields needed for propagation. */ +export type PropagationEdge = { + npcAId: string; + npcBId: string; + baseTag: string; + affection: number; +}; +const FRIEND_BASE_TAGS = new Set([ + "friend", + "ally", + "close_ally", + "strategic_ally", + "chaos_ally", + "chaotic_ally", + "chaos_buddy", + "support", + "cooperate", + "partner", +]); + +const RIVAL_ENEMY_BASE_TAGS = new Set(["rival", "nemesis", "enemy", "foe"]); + +/** + * +1 = friend (same sign), -1 = rival/enemy (invert), 0 = skip. + */ +export function propagationPolarityForBaseTag(baseTag: string): 1 | -1 | 0 { + const k = baseTag.toLowerCase(); + if (FRIEND_BASE_TAGS.has(k)) return 1; + if (RIVAL_ENEMY_BASE_TAGS.has(k)) return -1; + return 0; +} + +function clampPropagationDelta(delta: number): number { + if (delta === 0) return 0; + const sign = delta < 0 ? -1 : 1; + return sign * Math.min(PROPAGATION_MAX_ABS, Math.abs(delta)); +} + +export type ComputeRelationshipPropagationInput = { + targetNpcId: string; + eventDelta: number; + playerIds: readonly string[]; + edges: readonly PropagationEdge[]; + /** NPC ids already updated by witness (includes target). */ + alreadyUpdated: ReadonlySet; +}; + +/** + * 2-hop reputation deltas from relationship edges (D-PROP-01…10). + * Pure — no I/O; never emits collective events. + * Returns WitnessDeltaUpdate[] for reuse with applyReputationDelta. + */ +export function computeRelationshipPropagationDeltas( + input: ComputeRelationshipPropagationInput, +): WitnessDeltaUpdate[] { + if (Math.abs(input.eventDelta) < PROPAGATION_MIN_EVENT) return []; + + const raw = Math.round(input.eventDelta * PROPAGATION_FRACTION); + const baseDelta = clampPropagationDelta(raw); + if (baseDelta === 0) return []; + + type Candidate = { npcId: string; polarity: 1 | -1; absAffection: number }; + const candidates: Candidate[] = []; + + for (const edge of input.edges) { + const other = + edge.npcAId === input.targetNpcId + ? edge.npcBId + : edge.npcBId === input.targetNpcId + ? edge.npcAId + : null; + if (!other || other === input.targetNpcId) continue; + if (input.alreadyUpdated.has(other)) continue; + if (Math.abs(edge.affection) < PROPAGATION_MIN_AFFECTION) continue; + + const polarity = propagationPolarityForBaseTag(edge.baseTag); + if (polarity === 0) continue; + + candidates.push({ + npcId: other, + polarity, + absAffection: Math.abs(edge.affection), + }); + } + + candidates.sort((a, b) => b.absAffection - a.absAffection); + const selected = candidates.slice(0, PROPAGATION_MAX_FANOUT); + + const updates: WitnessDeltaUpdate[] = []; + for (const c of selected) { + const delta = clampPropagationDelta(baseDelta * c.polarity); + if (delta === 0) continue; + for (const playerId of input.playerIds) { + updates.push({ npcId: c.npcId, playerId, delta }); + } + } + return updates; +} diff --git a/scripts/benchmark-speak-browser.mjs b/scripts/benchmark-speak-browser.mjs index 39c0cd7..acfab8c 100644 --- a/scripts/benchmark-speak-browser.mjs +++ b/scripts/benchmark-speak-browser.mjs @@ -222,11 +222,17 @@ async function prepareBenchmarkRound(playerId) { /** Phase 19 overlay-first T_think: NOT legacy drawer `.message--thinking`. * Canonical selectors for verify:phase20 live in scripts/lib/speak-browser-round.mjs - * and scripts/lib/e2e-memory-helpers.mjs — keep benchmark locators aligned manually. */ + * and scripts/lib/e2e-memory-helpers.mjs — keep benchmark locators aligned manually. + * + * Also accept composer aria-busy: DialogueOverlay suppresses `.dialogue-overlay__thinking` + * whenever a displayLine exists (client casual stub or prior NPC line), so thinking-only + * DOM is often absent on B1「你好」and all subsequent turns. Composer busy is the reliable + * “speak accepted” signal for T_think. */ const THINKING_LOCATOR = '[data-testid="dialogue-overlay"] .dialogue-overlay__thinking, ' + '.dialogue-bar__summary-text--thinking, ' + - '[data-testid="composer-speak-status"]'; + '[data-testid="composer-speak-status"], ' + + 'textarea.composer__input[aria-busy="true"]'; const OVERLAY_STREAMING = '[data-testid="dialogue-overlay-streaming"]'; @@ -328,9 +334,17 @@ async function runSpeakRound(page, message, { expectMove }) { }); const replyBaseline = await captureReplyBaseline(page); - const t0 = Date.now(); await closeShellDrawer(page); const composer = page.locator("textarea.composer__input"); + // Prior round may still hold composer busy (worker memory-tail / backlog). + await page.waitForFunction( + () => { + const input = document.querySelector("textarea.composer__input"); + return input && !input.disabled && input.getAttribute("aria-busy") !== "true"; + }, + { timeout: SPEAK_TIMEOUT_MS }, + ); + const t0 = Date.now(); await composer.fill(message); await page.locator("button.composer__submit").click(); diff --git a/scripts/uat-phase29-dialogue-restart.mjs b/scripts/uat-phase29-dialogue-restart.mjs new file mode 100644 index 0000000..cade2f0 --- /dev/null +++ b/scripts/uat-phase29-dialogue-restart.mjs @@ -0,0 +1,263 @@ +/** + * Phase 29 D-SESS — dialogue continuity after game-server Map loss (simulated restart). + * + * Hard gate (no LLM): + * 1. Engage room in Playwright (gameplay surface) + * 2. POST dialogue-append (Map + Redis mirror) with distinctive seed + * 3. POST dialogue-map-evict (Map cleared, Redis intact) + * 4. GET dialogue-turns → must include seed (getRecentTurnsAsync rehydrate) + * + * Soft gate (when social LLM healthy): + * 5. Speak recall; reply should cite seed tokens + * + * Requires: pnpm dev:stack + REDIS_URL. Real LLM keys required by policy (soft speak optional). + * Forbidden: LLM_MOCK=1 / dev:stack:mock + */ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { assertE2eRealLlm } from "./lib/e2e-policy.mjs"; +import { engageNpcDialogue } from "./lib/dialogue-engage.mjs"; +import { sendSpeakOverlay } from "./lib/e2e-memory-helpers.mjs"; +import { loadRootEnv } from "./lib/env.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const OUT_DIR = path.join( + ROOT, + ".planning/phases/29-npc-memory-retrieval-quality/uat-screenshots", +); + +loadRootEnv(ROOT); + +const WEB_BASE = process.env.WEB_URL || "http://localhost:5173"; +const GS = process.env.GAME_SERVER_URL || "http://127.0.0.1:2567"; +const UAT_NPC_ID = process.env.UAT_PHASE29_NPC_ID || "npc-5"; +const UAT_ROOM_ID = + process.env.UAT_PHASE29_ROOM_ID || `uat-p29-restart-${Date.now()}`; +const UAT_PLAYER_ID = + process.env.UAT_PHASE29_PLAYER_ID || `uatp29${String(Date.now()).slice(-10)}`; +const WEB = `${WEB_BASE}${WEB_BASE.includes("?") ? "&" : "?"}room=${encodeURIComponent(UAT_ROOM_ID)}`; + +const SPEAK_WAIT_TIMEOUT_MS = Number(process.env.UAT_SPEAK_WAIT_TIMEOUT_MS || 180_000); +const ENGAGE_TIMEOUT_MS = Number(process.env.UAT_SPEAK_ENGAGE_TIMEOUT_MS || 90_000); +const SKIP_SPEAK_RECALL = process.env.UAT_PHASE29_SKIP_SPEAK === "1"; + +const SEED_TOKEN_A = "蓝莓派"; +const SEED_TOKEN_B = "七号"; +const SEED_MESSAGE = `我告诉你:我的暗号是${SEED_TOKEN_A}${SEED_TOKEN_B}。`; +const SEED_NPC_REPLY = "好的,我记下了你的暗号。"; +const RECALL_MESSAGE = "复述一遍我的暗号。"; + +const DEGRADED_REPLY_RES = [/我听到了:/, /与房间互动/, /抱歉,我这边还做不到/]; + +function internalHeaders() { + /** @type {Record} */ + const headers = { "Content-Type": "application/json" }; + const token = process.env.INTERNAL_WORKER_TOKEN; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +async function screenshot(page, label) { + await mkdir(OUT_DIR, { recursive: true }); + const file = path.join(OUT_DIR, `dialogue-restart-${label}.png`); + await page.screenshot({ path: file, fullPage: true }); + console.log(` 📸 ${path.relative(ROOT, file)}`); +} + +async function health(url, name) { + const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) throw new Error(`${name} /health → ${res.status}`); + const body = await res.json(); + if (body.status !== "ok") throw new Error(`${name} /health body invalid`); +} + +async function appendDialogueSeed(roomId, playerId, npcId) { + const url = `${GS}/internal/rooms/${encodeURIComponent(roomId)}/dialogue-append`; + const res = await fetch(url, { + method: "POST", + headers: internalHeaders(), + body: JSON.stringify({ + playerId, + npcId, + playerMessage: SEED_MESSAGE, + npcReply: SEED_NPC_REPLY, + }), + signal: AbortSignal.timeout(15_000), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.ok !== true) { + throw new Error(`dialogue-append failed: HTTP ${res.status} ${JSON.stringify(body)}`); + } + console.log(" ✓ dialogue-append seeded (Map + Redis mirror)"); +} + +async function evictDialogueMap(roomId) { + const url = `${GS}/internal/rooms/${encodeURIComponent(roomId)}/dialogue-map-evict`; + const res = await fetch(url, { + method: "POST", + headers: internalHeaders(), + signal: AbortSignal.timeout(15_000), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.ok !== true) { + throw new Error( + `dialogue-map-evict failed: HTTP ${res.status} ${JSON.stringify(body)}`, + ); + } + console.log(` Map-evict room=${roomId} evicted=${body.evicted}`); + if (Number(body.evicted) < 1) { + throw new Error(`expected evicted≥1 after seed, got ${body.evicted}`); + } + return body; +} + +async function fetchDialogueTurns(roomId, playerId, npcId) { + const qs = new URLSearchParams({ playerId, npcId, limit: "20" }); + const url = `${GS}/internal/rooms/${encodeURIComponent(roomId)}/dialogue-turns?${qs}`; + const res = await fetch(url, { + headers: internalHeaders(), + signal: AbortSignal.timeout(15_000), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.ok !== true || !Array.isArray(body.turns)) { + throw new Error( + `dialogue-turns failed: HTTP ${res.status} ${JSON.stringify(body)}`, + ); + } + return body.turns; +} + +function assertTurnsContainSeed(turns) { + const hay = JSON.stringify(turns); + const hitA = hay.includes(SEED_TOKEN_A); + const hitB = hay.includes(SEED_TOKEN_B); + if (!hitA && !hitB) { + throw new Error( + `Post-evict dialogue-turns missing seed (${SEED_TOKEN_A}|${SEED_TOKEN_B}): ${hay.slice(0, 500)}`, + ); + } + if (turns.length < 2) { + throw new Error(`Expected ≥2 turns after rehydrate, got ${turns.length}`); + } + console.log(` ✓ dialogue-turns rehydrate contains seed (${turns.length} turns)`); +} + +function replyLooksDegraded(text) { + return DEGRADED_REPLY_RES.some((re) => re.test(text)); +} + +function assertRecallReply(reply) { + const text = (reply || "").trim(); + if (text.length < 4) { + throw new Error(`Recall reply too short: ${JSON.stringify(text)}`); + } + if (replyLooksDegraded(text)) { + throw new Error(`Recall reply degraded/stub: ${JSON.stringify(text)}`); + } + const hitA = text.includes(SEED_TOKEN_A); + const hitB = text.includes(SEED_TOKEN_B); + if (!hitA && !hitB) { + throw new Error( + `Recall reply missing seed tokens (${SEED_TOKEN_A}|${SEED_TOKEN_B}): ${JSON.stringify(text)}`, + ); + } +} + +async function main() { + assertE2eRealLlm("uat:phase29:dialogue-restart"); + + if (!process.env.REDIS_URL?.trim()) { + throw new Error( + "REDIS_URL required for dialogue Redis mirror / rehydrate (D-SESS). Set in .env.", + ); + } + + console.log("Phase 29 dialogue-restart UAT (Playwright, Map-evict)"); + console.log( + `WEB=${WEB} GS=${GS} npc=${UAT_NPC_ID} room=${UAT_ROOM_ID} player=${UAT_PLAYER_ID}`, + ); + + await health(GS, "game-server"); + try { + const webRes = await fetch(WEB_BASE, { signal: AbortSignal.timeout(15_000) }); + if (!webRes.ok) throw new Error(`Web ${WEB_BASE} → ${webRes.status}`); + } catch (err) { + throw new Error( + `Web 不可达 — 请先 pnpm dev:stack(真实 LLM,见 docs/E2E-POLICY.md): ${err.message}`, + ); + } + + const pwEntry = path.join(ROOT, "scripts", ".pw-deps", "node_modules", "playwright", "index.mjs"); + const pw = await import(pathToFileURL(pwEntry).href); + const chromium = pw.chromium ?? pw.default?.chromium; + if (!chromium) throw new Error("playwright 未安装:cd scripts/.pw-deps && npm install"); + + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.addInitScript( + ({ key, id }) => { + localStorage.setItem(key, id); + }, + { key: "aetherlife:playerId", id: UAT_PLAYER_ID }, + ); + const page = await ctx.newPage(); + page.setDefaultTimeout(SPEAK_WAIT_TIMEOUT_MS); + + try { + await page.goto(WEB, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.locator('[data-testid="room-scene"]').waitFor({ state: "visible", timeout: 60_000 }); + await engageNpcDialogue(page, UAT_NPC_ID, { timeoutMs: ENGAGE_TIMEOUT_MS }); + await screenshot(page, "00-engaged"); + + await appendDialogueSeed(UAT_ROOM_ID, UAT_PLAYER_ID, UAT_NPC_ID); + await new Promise((r) => setTimeout(r, 1500)); + await evictDialogueMap(UAT_ROOM_ID); + + const turns = await fetchDialogueTurns(UAT_ROOM_ID, UAT_PLAYER_ID, UAT_NPC_ID); + assertTurnsContainSeed(turns); + await screenshot(page, "01-after-rehydrate"); + + if (SKIP_SPEAK_RECALL) { + console.log("✅ Passed (D-SESS): Map-evict + dialogue-turns rehydrate (speak recall skipped)."); + return; + } + + try { + const recall = await sendSpeakOverlay(page, RECALL_MESSAGE, { + speakTimeoutMs: SPEAK_WAIT_TIMEOUT_MS, + engageTimeoutMs: ENGAGE_TIMEOUT_MS, + skipEngage: true, + }); + console.log(` NPC after recall: ${recall.reply}`); + await screenshot(page, "02-after-recall"); + + if (replyLooksDegraded(recall.reply) || !recall.reply.trim()) { + console.warn( + " ⚠ social LLM degraded/empty on recall — D-SESS still PASS via dialogue-turns.", + ); + console.log( + "✅ Passed (D-SESS rehydrate): Map-evict + dialogue-turns contain seed; gameplay cite soft-skipped.", + ); + } else { + assertRecallReply(recall.reply); + console.log(`✅ Passed: rehydrate + recall cites seed — ${recall.reply}`); + } + } catch (speakErr) { + console.warn( + ` ⚠ speak recall failed (${speakErr?.message || speakErr}) — D-SESS still PASS via dialogue-turns.`, + ); + console.log( + "✅ Passed (D-SESS rehydrate): Map-evict + dialogue-turns contain seed; gameplay cite soft-skipped.", + ); + } + } finally { + await browser.close(); + } +} + +main().catch((err) => { + console.error(`❌ UAT failed: ${err?.message || err}`); + process.exit(1); +}); diff --git a/scripts/verify-memory-recall.mjs b/scripts/verify-memory-recall.mjs new file mode 100644 index 0000000..a32781a --- /dev/null +++ b/scripts/verify-memory-recall.mjs @@ -0,0 +1,399 @@ +/** + * D-ANN-03/04 — live gate for halfvec HNSW distance overfetch + forgetting-curve rerank. + * + * 1) EXPLAIN the distance ORDER BY path — expect Index Scan / hnsw / ANN plan node + * 2) Compare top-k overlap vs exact weighted baseline on up to 5 fixture queries + * + * Exit 0 when EXPLAIN evidence present AND overlap ≥4/5. + * Exit 1 on failure. Read-only (SELECT / EXPLAIN only). + * + * Env: + * MEMORY_RECALL_K default 5 + * MEMORY_K_OVERFETCH override overfetch (else max(20, 4*k)) + * MEMORY_RECALL_MIN_ROWS min embeddings in a scope to use as fixture (default 8) + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import postgres from "postgres"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const FIXTURE_QUERY_TARGET = 5; +const OVERLAP_PASS = 4; + +/** @param {string} path */ +function loadEnv(path) { + if (!existsSync(path)) { + console.error("Missing .env — copy .env.example and fill DATABASE_URL."); + process.exit(1); + } + + /** @type {Record} */ + const env = {}; + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return env; +} + +/** + * @param {number} k + * @param {Record} env + */ +function resolveKOverfetch(k, env) { + const raw = env.MEMORY_K_OVERFETCH; + if (raw !== undefined && raw !== "") { + const n = Number(raw); + if (Number.isFinite(n) && n >= 1) return Math.min(200, Math.floor(n)); + } + return Math.min(200, Math.max(20, 4 * k)); +} + +/** + * @param {string} planText + */ +function planLooksLikeAnn(planText) { + const lower = planText.toLowerCase(); + return ( + lower.includes("index scan") || + lower.includes("index only scan") || + lower.includes("bitmap index scan") || + lower.includes("hnsw") || + lower.includes("ann") + ); +} + +/** + * Recency knobs matching repository defaults / MEMORY_RECENCY_*. + * @param {Record} env + */ +function recencyCfg(env) { + const s0 = Number(env.MEMORY_RECENCY_HALFLIFE_HOURS); + const floor = Number(env.MEMORY_RECENCY_FLOOR); + const sEpsilon = Number(env.MEMORY_RECENCY_S_EPSILON); + return { + s0: Number.isFinite(s0) && s0 > 0 ? s0 : 72, + floor: Number.isFinite(floor) && floor >= 0 && floor <= 1 ? floor : 0.3, + sEpsilon: Number.isFinite(sEpsilon) && sEpsilon > 0 ? sEpsilon : 1e-3, + }; +} + +/** + * Exact weighted ORDER BY (baseline — must NOT be used for ANN EXPLAIN). + * @param {import("postgres").Sql} sql + * @param {{ roomId: string; playerId: string; npcId: string; vectorLiteral: string; k: number; cfg: { s0: number; floor: number; sEpsilon: number } }} p + */ +async function exactTopK(sql, p) { + const rows = await sql` + SELECT id::text AS id + FROM ( + SELECT + id, + (1 - (embedding <=> ${p.vectorLiteral}::vector)) + * (0.5 + COALESCE(importance, 5) / 20.0) + * GREATEST( + ${p.cfg.floor}::float8, + EXP( + - (EXTRACT(EPOCH FROM (now() - created_at)) / 3600.0) + * LN(2) + / GREATEST( + ${p.cfg.sEpsilon}::float8, + ${p.cfg.s0}::float8 * COALESCE(importance, 0) / 5.0 + ) + ) + ) AS score + FROM npc_memories + WHERE room_id = ${p.roomId} + AND player_id = ${p.playerId} + AND npc_id = ${p.npcId} + AND summarized_at IS NULL + AND embedding IS NOT NULL + ORDER BY score DESC + LIMIT ${p.k} + ) t + `; + return rows.map((r) => String(r.id)); +} + +/** + * Production path: halfvec distance overfetch + forgetting-curve rerank. + * @param {import("postgres").Sql} sql + * @param {{ roomId: string; playerId: string; npcId: string; vectorLiteral: string; k: number; kOverfetch: number; cfg: { s0: number; floor: number; sEpsilon: number } }} p + */ +async function annTopK(sql, p) { + // halfvec(2048) must be a SQL literal to match expression index (D-ANN-03). + const rows = await sql` + WITH candidates AS ( + SELECT + id, + text, + importance, + created_at, + (embedding::halfvec(2048) <=> ${p.vectorLiteral}::halfvec(2048)) AS dist + FROM npc_memories + WHERE room_id = ${p.roomId} + AND player_id = ${p.playerId} + AND npc_id = ${p.npcId} + AND summarized_at IS NULL + AND embedding IS NOT NULL + ORDER BY embedding::halfvec(2048) <=> ${p.vectorLiteral}::halfvec(2048) ASC + LIMIT ${p.kOverfetch} + ) + SELECT id::text AS id + FROM ( + SELECT + id, + (1 - dist) + * (0.5 + COALESCE(importance, 5) / 20.0) + * GREATEST( + ${p.cfg.floor}::float8, + EXP( + - (EXTRACT(EPOCH FROM (now() - created_at)) / 3600.0) + * LN(2) + / GREATEST( + ${p.cfg.sEpsilon}::float8, + ${p.cfg.s0}::float8 * COALESCE(importance, 0) / 5.0 + ) + ) + ) AS score + FROM candidates + ORDER BY score DESC + LIMIT ${p.k} + ) t + `; + return rows.map((r) => String(r.id)); +} + +/** + * EXPLAIN distance-only candidate query (inner path that must hit HNSW). + * @param {import("postgres").Sql} sql + * @param {{ roomId: string; playerId: string; npcId: string; vectorLiteral: string; kOverfetch: number }} p + */ +async function explainDistancePath(sql, p) { + const rows = await sql` + EXPLAIN + SELECT id + FROM npc_memories + WHERE room_id = ${p.roomId} + AND player_id = ${p.playerId} + AND npc_id = ${p.npcId} + AND summarized_at IS NULL + AND embedding IS NOT NULL + ORDER BY embedding::halfvec(2048) <=> ${p.vectorLiteral}::halfvec(2048) ASC + LIMIT ${p.kOverfetch} + `; + return rows.map((r) => Object.values(r)[0]).join("\n"); +} + +/** + * @param {string[]} a + * @param {string[]} b + * @param {number} k + */ +function recallAtK(a, b, k) { + const setB = new Set(b); + let hit = 0; + for (const id of a) { + if (setB.has(id)) hit += 1; + } + return hit / Math.max(k, 1); +} + +async function main() { + const env = loadEnv(resolve(root, ".env")); + const databaseUrl = env.DATABASE_URL; + if (!databaseUrl) { + console.error("Missing DATABASE_URL in .env"); + process.exit(1); + } + + const k = Math.max(1, Number(env.MEMORY_RECALL_K) || 5); + const kOverfetch = resolveKOverfetch(k, env); + const minRows = Math.max(k, Number(env.MEMORY_RECALL_MIN_ROWS) || 8); + const cfg = recencyCfg(env); + + const sql = postgres(databaseUrl, { + max: 1, + connect_timeout: 15, + prepare: false, + }); + + try { + const indexRows = await sql` + SELECT indexname + FROM pg_indexes + WHERE tablename = 'npc_memories' + AND indexname = 'npc_memories_embedding_halfvec_hnsw' + `; + if (indexRows.length === 0) { + console.error( + "Index npc_memories_embedding_halfvec_hnsw missing — apply migration 0013 or document deferred.", + ); + process.exit(1); + } + + const scopes = await sql` + SELECT room_id, player_id, npc_id, COUNT(*)::int AS n + FROM npc_memories + WHERE summarized_at IS NULL + AND embedding IS NOT NULL + GROUP BY room_id, player_id, npc_id + HAVING COUNT(*) >= ${minRows} + ORDER BY COUNT(*) DESC + LIMIT 20 + `; + + if (scopes.length === 0) { + console.error( + `No scope with ≥${minRows} embedded unsummarized memories — seed fixture data then re-run.`, + ); + process.exit(1); + } + + // Build up to 5 fixture queries: prefer distinct scopes; fill from richest scope seeds. + /** @type {Array<{ roomId: string; playerId: string; npcId: string; vectorLiteral: string }>} */ + const fixtures = []; + for (const scope of scopes) { + if (fixtures.length >= FIXTURE_QUERY_TARGET) break; + const qEmb = await sql` + SELECT embedding::text AS emb + FROM npc_memories + WHERE room_id = ${scope.room_id} + AND player_id = ${scope.player_id} + AND npc_id = ${scope.npc_id} + AND embedding IS NOT NULL + AND summarized_at IS NULL + ORDER BY created_at DESC + LIMIT 1 + `; + const qVec = String(qEmb[0]?.emb ?? ""); + if (!qVec.startsWith("[")) continue; + fixtures.push({ + roomId: String(scope.room_id), + playerId: String(scope.player_id), + npcId: String(scope.npc_id), + vectorLiteral: qVec, + }); + } + + // If fewer than 5 scopes, sample additional query vectors from the richest scope. + if (fixtures.length < FIXTURE_QUERY_TARGET) { + const rich = scopes[0]; + const seeds = await sql` + SELECT embedding::text AS emb + FROM npc_memories + WHERE room_id = ${rich.room_id} + AND player_id = ${rich.player_id} + AND npc_id = ${rich.npc_id} + AND embedding IS NOT NULL + AND summarized_at IS NULL + ORDER BY created_at DESC + LIMIT ${FIXTURE_QUERY_TARGET} + `; + for (const row of seeds) { + if (fixtures.length >= FIXTURE_QUERY_TARGET) break; + const qVec = String(row.emb ?? ""); + if (!qVec.startsWith("[")) continue; + // Skip duplicate first vector already added from this scope + if (fixtures.some((f) => f.vectorLiteral === qVec)) continue; + fixtures.push({ + roomId: String(rich.room_id), + playerId: String(rich.player_id), + npcId: String(rich.npc_id), + vectorLiteral: qVec, + }); + } + } + + if (fixtures.length === 0) { + console.error("Could not build fixture query embeddings"); + process.exit(1); + } + + const explainFixture = fixtures[0]; + const planText = await explainDistancePath(sql, { + roomId: explainFixture.roomId, + playerId: explainFixture.playerId, + npcId: explainFixture.npcId, + vectorLiteral: explainFixture.vectorLiteral, + kOverfetch, + }); + + console.log("--- EXPLAIN (distance ORDER BY halfvec) ---"); + console.log(planText); + console.log("--- end EXPLAIN ---"); + + const annPlanOk = planLooksLikeAnn(planText); + console.log(`explain_ann=${annPlanOk ? "PASS" : "FAIL"}`); + + let passCount = 0; + const results = []; + + for (const fixture of fixtures.slice(0, FIXTURE_QUERY_TARGET)) { + const params = { + roomId: fixture.roomId, + playerId: fixture.playerId, + npcId: fixture.npcId, + vectorLiteral: fixture.vectorLiteral, + k, + kOverfetch, + cfg, + }; + + const exact = await exactTopK(sql, params); + const ann = await annTopK(sql, params); + const recall = recallAtK(exact, ann, k); + const ok = recall >= OVERLAP_PASS / FIXTURE_QUERY_TARGET; + if (ok) passCount += 1; + results.push({ + roomId: params.roomId, + playerId: params.playerId, + npcId: params.npcId, + recall: recall.toFixed(3), + ok, + exact: exact.length, + ann: ann.length, + }); + } + + const queryN = results.length; + console.log(`k=${k} k_overfetch=${kOverfetch} queries=${queryN}`); + for (const r of results) { + console.log( + ` scope room=${r.roomId} player=${r.playerId} npc=${r.npcId} recall@${k}=${r.recall} ${r.ok ? "PASS" : "FAIL"}`, + ); + } + console.log(`overlap=${passCount}/${FIXTURE_QUERY_TARGET}`); + + if (!annPlanOk) { + console.error("EXPLAIN did not show Index Scan / HNSW / ANN plan — D-ANN-03 FAIL"); + process.exit(1); + } + if (queryN < FIXTURE_QUERY_TARGET) { + console.error( + `Only ${queryN}/${FIXTURE_QUERY_TARGET} fixture queries — seed more embeddings then re-run`, + ); + process.exit(1); + } + if (passCount < OVERLAP_PASS) { + console.error( + `Recall overlap ${passCount}/${FIXTURE_QUERY_TARGET} < ${OVERLAP_PASS}/${FIXTURE_QUERY_TARGET} — D-ANN-04 FAIL (tune MEMORY_K_OVERFETCH once)`, + ); + process.exit(1); + } + + console.log("verdict=PASS"); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`verify:memory-recall failed: ${message}`); + process.exit(1); +}); diff --git a/scripts/verify-pgvector-capability.mjs b/scripts/verify-pgvector-capability.mjs new file mode 100644 index 0000000..1c3ed33 --- /dev/null +++ b/scripts/verify-pgvector-capability.mjs @@ -0,0 +1,127 @@ +/** + * Read-only probe: pgvector extension version + halfvec cast support. + * D-ANN-02 — run BEFORE any HNSW / halfvec index DDL (migration 0013). + * + * Exit 0 = verdict=PASS (ext >= 0.7.0 and halfvec cast works) + * Exit 1 = missing config, connection error, or verdict=FAIL + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import postgres from "postgres"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const MIN_VERSION = "0.7.0"; + +/** @param {string} path */ +function loadEnv(path) { + if (!existsSync(path)) { + console.error("Missing .env — copy .env.example and fill DATABASE_URL."); + process.exit(1); + } + + /** @type {Record} */ + const env = {}; + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return env; +} + +/** + * Compare dotted semver-like strings (e.g. 0.7.0, 0.8.0). + * @param {string} a + * @param {string} b + * @returns {number} negative if a < b, 0 if equal, positive if a > b + */ +function compareSemver(a, b) { + const pa = a.split(".").map((x) => parseInt(x, 10) || 0); + const pb = b.split(".").map((x) => parseInt(x, 10) || 0); + const n = Math.max(pa.length, pb.length); + for (let i = 0; i < n; i++) { + const da = pa[i] ?? 0; + const db = pb[i] ?? 0; + if (da !== db) return da - db; + } + return 0; +} + +/** + * @param {string} databaseUrl + * @returns {Promise<{ pgvector: string; halfvec: string; verdict: "PASS" | "FAIL" }>} + */ +async function probe(databaseUrl) { + const sql = postgres(databaseUrl, { + max: 1, + connect_timeout: 15, + prepare: false, + }); + + try { + const extRows = await sql` + SELECT extversion FROM pg_extension WHERE extname = 'vector' + `; + const pgvector = extRows[0]?.extversion != null ? String(extRows[0].extversion) : "missing"; + + let halfvec = "unavailable"; + if (pgvector !== "missing") { + try { + await sql`SELECT '[1,2]'::halfvec(2)`; + halfvec = "ok"; + } catch { + halfvec = "unavailable"; + } + } + + const versionOk = pgvector !== "missing" && compareSemver(pgvector, MIN_VERSION) >= 0; + const halfvecOk = halfvec === "ok"; + const verdict = versionOk && halfvecOk ? "PASS" : "FAIL"; + + return { pgvector, halfvec, verdict }; + } finally { + await sql.end({ timeout: 5 }); + } +} + +async function main() { + const env = loadEnv(resolve(root, ".env")); + const databaseUrl = env.DATABASE_URL; + if (!databaseUrl) { + console.error("Missing DATABASE_URL in .env"); + process.exit(1); + } + + const placeholders = [ + "[password]", + "[project-ref]", + "[region]", + "127.0.0.1:5432", + "aether:aether@127.0.0.1", + ]; + for (const token of placeholders) { + if (databaseUrl.includes(token)) { + console.error("DATABASE_URL still uses a placeholder — configure Supabase in .env"); + process.exit(1); + } + } + + const { pgvector, halfvec, verdict } = await probe(databaseUrl); + console.log(`pgvector=${pgvector} halfvec=${halfvec} verdict=${verdict}`); + + if (verdict === "FAIL") { + console.error( + "pgvector halfvec probe FAIL — defer migration 0013 / plan 29-04; write 0014 only.", + ); + process.exit(1); + } +} + +main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`verify:pgvector failed: ${message}`); + process.exit(1); +}); diff --git a/workers/agent-worker/src/graph/npc_loop.py b/workers/agent-worker/src/graph/npc_loop.py index b40fc59..920cc13 100644 --- a/workers/agent-worker/src/graph/npc_loop.py +++ b/workers/agent-worker/src/graph/npc_loop.py @@ -20,7 +20,7 @@ ) from src.graph.action_sanitize import tool_calls_to_actions from src.graph.prompt import build_turn_messages -from src.graph.reflect import run_reflect_llm, should_reflect +from src.graph.reflect import run_reflect_llm_structured, should_reflect from src.graph.state import GraphState from src.graph.summarize import maybe_bulk_summarize from src.graph.recall_merge import ( @@ -507,15 +507,18 @@ def maybe_reflect_turn( player_id=_player_id(state), ) texts = [row.get("text", "") for row in recent if row.get("text")] - reflection = run_reflect_llm(texts, settings) - if reflection: + structured = run_reflect_llm_structured(texts, settings) + if structured and structured.text: store_reflection( client, settings, state["room_id"], - reflection, + structured.text, npc_id=npc_id, player_id=_player_id(state), + mood=structured.mood, + beliefs=structured.beliefs, + summary=structured.summary, ) return state diff --git a/workers/agent-worker/src/graph/prompt.py b/workers/agent-worker/src/graph/prompt.py index 58e9cfa..4993637 100644 --- a/workers/agent-worker/src/graph/prompt.py +++ b/workers/agent-worker/src/graph/prompt.py @@ -141,6 +141,9 @@ def format_attitude_context( effective_score: int | None, summaries: list[str] | None, just_happened: str | None = None, + mood: str | None = None, + beliefs: list[str] | None = None, + summary: str | None = None, ) -> str: resolved_band = band or "neutral" label = BAND_LABEL_ZH.get(resolved_band, resolved_band) @@ -152,6 +155,17 @@ def format_attitude_context( ] if just_happened and just_happened.strip(): lines.append(f"- justHappened: {just_happened.strip()[:80]}") + mood_text = (mood or "").strip() + if mood_text: + lines.append(f"- mood: {mood_text}") + belief_items = [b.strip() for b in (beliefs or []) if b and b.strip()] + if belief_items: + lines.append("- beliefs:") + for item in belief_items[:5]: + lines.append(f" · {item}") + summary_text = (summary or "").strip() + if summary_text: + lines.append(f"- summary: {summary_text}") items = [s.strip() for s in (summaries or []) if s and s.strip()] if items: lines.append("- recent collective summaries:") diff --git a/workers/agent-worker/src/graph/reflect.py b/workers/agent-worker/src/graph/reflect.py index c9c520a..36b299f 100644 --- a/workers/agent-worker/src/graph/reflect.py +++ b/workers/agent-worker/src/graph/reflect.py @@ -1,14 +1,117 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass from typing import Any from src.config import Settings, get_settings from src.llm.factory import create_chat_model +# Mirror packages/shared NPC_MOODS (D-BELIEF-08) — closed Chinese set, no English aliases. +NPC_MOODS: tuple[str, ...] = ( + "平静", + "亲近", + "警惕", + "恼火", + "愉悦", + "低落", + "愧疚", + "戏谑", +) -def run_reflect_llm(memories: list[str], settings: Settings | None = None) -> str: +_NPC_MOOD_SET = frozenset(NPC_MOODS) + +_REFLECT_SYSTEM = ( + "You are an NPC reflecting on recent memories. Reply with JSON only " + '(no markdown): {"text": "2-4 sentence prose reflection preserving proper nouns", ' + f'"mood": one of {list(NPC_MOODS)}, ' + '"beliefs": ["NPC first-person short zh sentences", ...], ' + '"summary": "optional <=200 zh chars"}. ' + "beliefs: at most 5 items, each <=40 chars, first person (e.g. 我不信他的承诺). " + "mood must be exactly one whitelist value." +) + + +@dataclass(frozen=True) +class ReflectStructured: + """Structured reflect output (D-BELIEF-03/07/09/13). Semantic fields may be None to omit.""" + + text: str + mood: str | None = None + beliefs: list[str] | None = None + summary: str | None = None + + +def _parse_reflect_json(content: str) -> ReflectStructured | None: + text = content.strip() + if not text: + return None + + parsed: Any = None + try: + parsed = json.loads(text) + except json.JSONDecodeError: + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + try: + parsed = json.loads(match.group(0)) + except json.JSONDecodeError: + return None + else: + return None + + if not isinstance(parsed, dict): + return None + + prose = parsed.get("text") + if not isinstance(prose, str) or not prose.strip(): + return None + + mood_raw = parsed.get("mood") + mood: str | None = None + if isinstance(mood_raw, str) and mood_raw in _NPC_MOOD_SET: + mood = mood_raw + + beliefs: list[str] | None = None + beliefs_raw = parsed.get("beliefs") + if isinstance(beliefs_raw, list): + beliefs = [ + b.strip()[:40] + for b in beliefs_raw + if isinstance(b, str) and b.strip() + ][:5] + + summary: str | None = None + summary_raw = parsed.get("summary") + if isinstance(summary_raw, str): + summary = summary_raw[:200] + + return ReflectStructured( + text=prose.strip(), + mood=mood, + beliefs=beliefs, + summary=summary, + ) + + +def run_reflect_llm_structured( + memories: list[str], + settings: Settings | None = None, +) -> ReflectStructured | None: + """Single reflect LLM call returning prose + optional mood/beliefs (zero extra round-trips).""" cfg = settings or get_settings() if cfg.llm_mock: joined = "; ".join(memories[:4]) - return f"Recent events: {joined}" if joined else "" + prose = f"Recent events: {joined}" if joined else "" + if not prose: + return None + return ReflectStructured( + text=prose, + mood="平静", + beliefs=["我记得最近发生的事"], + summary=None, + ) llm = create_chat_model( provider=cfg.llm_provider_reflect, @@ -18,17 +121,18 @@ def run_reflect_llm(memories: list[str], settings: Settings | None = None) -> st bullet = "\n".join(f"- {m}" for m in memories if m.strip()) response = llm.invoke( [ - { - "role": "system", - "content": ( - "Summarize these recent NPC memories in 2-4 sentences. " - "Preserve proper nouns and codes." - ), - }, + {"role": "system", "content": _REFLECT_SYSTEM}, {"role": "user", "content": bullet or "(empty)"}, ] ) - return str(getattr(response, "content", "") or "").strip() + content = str(getattr(response, "content", "") or "").strip() + return _parse_reflect_json(content) + + +def run_reflect_llm(memories: list[str], settings: Settings | None = None) -> str: + """Backward-compatible prose wrapper around structured reflect.""" + out = run_reflect_llm_structured(memories, settings) + return out.text if out else "" def should_reflect(memory_count: int, every_n: int) -> bool: diff --git a/workers/agent-worker/src/graph/speak_fetch.py b/workers/agent-worker/src/graph/speak_fetch.py index df3574c..b79b023 100644 --- a/workers/agent-worker/src/graph/speak_fetch.py +++ b/workers/agent-worker/src/graph/speak_fetch.py @@ -58,6 +58,9 @@ "effective_score", "allowed_tools", "collective_summaries", + "current_mood", + "key_beliefs", + "attitude_summary", ) @@ -173,6 +176,9 @@ def _load_collective_gate_fields( "effective_score", "allowed_tools", "collective_summaries", + "current_mood", + "key_beliefs", + "attitude_summary", ) if key in parsed } diff --git a/workers/agent-worker/src/graph/speak_system_context.py b/workers/agent-worker/src/graph/speak_system_context.py index 9a26cda..5914d84 100644 --- a/workers/agent-worker/src/graph/speak_system_context.py +++ b/workers/agent-worker/src/graph/speak_system_context.py @@ -35,6 +35,9 @@ def build_speak_system_context( just_happened=( state.get("just_happened_summary") if include_just_happened else None ), + mood=state.get("current_mood"), + beliefs=state.get("key_beliefs"), + summary=state.get("attitude_summary"), ) npc_id = state.get("npc_id") or "npc-1" persona_block = build_persona_block( diff --git a/workers/agent-worker/src/graph/state.py b/workers/agent-worker/src/graph/state.py index 3aa1885..996ea65 100644 --- a/workers/agent-worker/src/graph/state.py +++ b/workers/agent-worker/src/graph/state.py @@ -22,6 +22,9 @@ class GraphState(TypedDict, total=False): effective_score: int allowed_tools: list[str] collective_summaries: list[str] + current_mood: str + key_beliefs: list[str] + attitude_summary: str turn_importance: int collective_ambiguous: bool gate_rejected: bool diff --git a/workers/agent-worker/src/guard/reply_audit.py b/workers/agent-worker/src/guard/reply_audit.py index 69a43df..f1ce5fc 100644 --- a/workers/agent-worker/src/guard/reply_audit.py +++ b/workers/agent-worker/src/guard/reply_audit.py @@ -3,12 +3,14 @@ from src.graph.action_intent import has_state_changing_tool +# Claimed completed/imminent physical acts — keep narrow to avoid chat false positives +# (e.g. English "I'm moved", "I've left that behind"; Chinese "我现在就去告诉你"). STATE_CHANGE_PATTERNS = [ - r"\b(opened|closed|moved|picked up|picked|walked|went to|entered|left)\b", - r"(打开了|关闭了|移动到|走向|拿起了|捡起了|去把门打开|就去打开|把门打开|我现在就去)", + r"\b(opened|closed|picked up|walked|went to|entered|moved to)\b", + r"(打开了|关闭了|移动到|走向了|走向门|拿起了|捡起了|去把门打开|就去打开|把门打开|我现在就去(?:把门|打开|拿|捡|走|过去))", ] -FALLBACK_REPLY = "我还不能确定能否那样做——让我先试着与房间互动。" +FALLBACK_REPLY = "抱歉,我这边还做不到。" def audit_reply(text: str, tool_calls: list[Any] | None) -> str: diff --git a/workers/agent-worker/src/memory/client.py b/workers/agent-worker/src/memory/client.py index c471b39..d35341c 100644 --- a/workers/agent-worker/src/memory/client.py +++ b/workers/agent-worker/src/memory/client.py @@ -91,12 +91,23 @@ def parse_collective_from_context(ctx: dict[str, Any]) -> dict[str, Any]: allowed = allowed_tools_for_band(band) summaries = collective.get("recentSummaries") effective = collective.get("effectiveScore") - return { + out: dict[str, Any] = { "attitude_band": band, "effective_score": int(effective) if isinstance(effective, (int, float)) else None, "allowed_tools": [str(t) for t in allowed], "collective_summaries": [str(s) for s in summaries] if isinstance(summaries, list) else [], } + # D-BELIEF-05: semantic for speak prompt only — never feeds allowed_tools. + mood = collective.get("currentMood") + if isinstance(mood, str) and mood.strip(): + out["current_mood"] = mood.strip() + beliefs = collective.get("keyBeliefs") + if isinstance(beliefs, list): + out["key_beliefs"] = [str(b) for b in beliefs if b] + attitude_summary = collective.get("summary") + if isinstance(attitude_summary, str) and attitude_summary.strip(): + out["attitude_summary"] = attitude_summary.strip() + return out def fetch_recent_memories( @@ -200,10 +211,20 @@ def store_reflection( *, npc_id: str = "npc-1", player_id: str = "__legacy__", + mood: str | None = None, + beliefs: list[str] | None = None, + summary: str | None = None, ) -> None: + body: dict[str, Any] = {"text": text, "npcId": npc_id, "playerId": player_id} + if mood is not None: + body["mood"] = mood + if beliefs is not None: + body["beliefs"] = beliefs + if summary is not None: + body["summary"] = summary res = client.post( f"{settings.game_server_url}/internal/rooms/{room_id}/reflect", - json={"text": text, "npcId": npc_id, "playerId": player_id}, + json=body, headers=_game_headers(settings, player_id=player_id), timeout=30.0, ) diff --git a/workers/agent-worker/tests/test_prompt_attitude.py b/workers/agent-worker/tests/test_prompt_attitude.py new file mode 100644 index 0000000..ebcfb04 --- /dev/null +++ b/workers/agent-worker/tests/test_prompt_attitude.py @@ -0,0 +1,74 @@ +"""D-BELIEF-05/06: attitude block semantic lines; mood never lifts tools.""" + +from src.collective.scoring import allowed_tools_for_band +from src.graph.prompt import format_attitude_context +from src.memory.client import parse_collective_from_context + + +def test_format_attitude_context_appends_mood_beliefs_summary(): + text = format_attitude_context( + band="warm", + effective_score=25, + summaries=["近期互助"], + mood="恼火", + beliefs=["我不信他的承诺"], + summary="玩家曾失信一次", + ) + assert "Attitude toward this player:" in text + assert "band: warm" in text + assert "mood: 恼火" in text + assert "我不信他的承诺" in text + assert "玩家曾失信一次" in text + + +def test_format_attitude_context_absent_semantic_unchanged(): + baseline = format_attitude_context( + band="neutral", + effective_score=0, + summaries=["a"], + ) + with_empty = format_attitude_context( + band="neutral", + effective_score=0, + summaries=["a"], + mood=None, + beliefs=None, + summary=None, + ) + assert baseline == with_empty + assert "mood:" not in baseline + + +def test_mood_does_not_change_allowed_tools_for_band(): + band = "warm" + tools_calm = allowed_tools_for_band(band) + tools_angry = allowed_tools_for_band(band) + assert tools_calm == tools_angry + + parsed_a = parse_collective_from_context( + { + "collective": { + "band": band, + "effectiveScore": 30, + "currentMood": "平静", + "keyBeliefs": [], + "summary": "", + } + } + ) + parsed_b = parse_collective_from_context( + { + "collective": { + "band": band, + "effectiveScore": 30, + "currentMood": "恼火", + "keyBeliefs": ["我不信他"], + "summary": "紧张", + } + } + ) + assert parsed_a["allowed_tools"] == parsed_b["allowed_tools"] + assert parsed_a["current_mood"] == "平静" + assert parsed_b["current_mood"] == "恼火" + assert parsed_b["key_beliefs"] == ["我不信他"] + assert parsed_b["attitude_summary"] == "紧张" diff --git a/workers/agent-worker/tests/test_reflect_summarize.py b/workers/agent-worker/tests/test_reflect_summarize.py index 589dc5e..1fd3a84 100644 --- a/workers/agent-worker/tests/test_reflect_summarize.py +++ b/workers/agent-worker/tests/test_reflect_summarize.py @@ -1,5 +1,13 @@ +import json + from src.config import Settings -from src.graph.reflect import should_reflect +from src.graph.reflect import ( + NPC_MOODS, + _parse_reflect_json, + run_reflect_llm, + run_reflect_llm_structured, + should_reflect, +) from src.graph.summarize import maybe_bulk_summarize, run_bulk_summarize_llm @@ -20,3 +28,52 @@ def test_maybe_bulk_summarize_skips_below_threshold(monkeypatch): client = __import__("unittest.mock").mock.MagicMock() assert maybe_bulk_summarize(client, settings, "default", 50) is False client.get.assert_not_called() + + +def test_run_reflect_llm_structured_mock_returns_chinese_mood(): + out = run_reflect_llm_structured(["player: 你好", "npc: 嗯"], Settings(llm_mock=True)) + assert out is not None + assert out.text + assert out.mood in NPC_MOODS + assert out.beliefs is not None + assert len(out.beliefs) >= 1 + assert any("我" in b for b in out.beliefs) + + +def test_run_reflect_llm_wrapper_returns_prose_str(): + text = run_reflect_llm(["a", "b"], Settings(llm_mock=True)) + assert isinstance(text, str) + assert "Recent events" in text + + +def test_parse_reflect_json_bad_omits_semantic(): + assert _parse_reflect_json("not json at all") is None + assert _parse_reflect_json("{broken") is None + + +def test_parse_reflect_json_valid_with_optional_summary(): + payload = { + "text": "最近对方态度缓和。", + "mood": "亲近", + "beliefs": ["我愿意再给他一次机会"], + "summary": "关系略有回暖", + } + out = _parse_reflect_json(json.dumps(payload, ensure_ascii=False)) + assert out is not None + assert out.text == "最近对方态度缓和。" + assert out.mood == "亲近" + assert out.beliefs == ["我愿意再给他一次机会"] + assert out.summary == "关系略有回暖" + + +def test_parse_reflect_json_illegal_mood_omits_mood(): + payload = { + "text": "有事发生。", + "mood": "angry", + "beliefs": ["我不信他的承诺"], + } + out = _parse_reflect_json(json.dumps(payload, ensure_ascii=False)) + assert out is not None + assert out.text == "有事发生。" + assert out.mood is None + assert out.beliefs == ["我不信他的承诺"] diff --git a/workers/agent-worker/tests/test_reply_audit.py b/workers/agent-worker/tests/test_reply_audit.py index b4cd8c8..9ef3f12 100644 --- a/workers/agent-worker/tests/test_reply_audit.py +++ b/workers/agent-worker/tests/test_reply_audit.py @@ -24,3 +24,20 @@ def test_audit_allows_greeting(): def test_audit_blocks_chinese_immediate_action_without_tools(): assert audit_reply("我现在就去把门打开。", []) == FALLBACK_REPLY + + +def test_audit_allows_chat_age_question_style_replies(): + """Pure dialogue must not be replaced by room-interact fallback (false positives).""" + samples = [ + "问年龄不太礼貌吧,我可不轻易说。", + "I'm moved that you asked, but a lady never tells.", + "I've left that number behind me.", + "我现在就去告诉你?不,保密。", + "别乱问,我走向你是为了聊天不是受审。", + ] + for reply in samples: + assert audit_reply(reply, []) == reply, reply + + +def test_fallback_reply_is_chat_neutral(): + assert "房间" not in FALLBACK_REPLY diff --git a/workers/agent-worker/tests/test_speak_system_context.py b/workers/agent-worker/tests/test_speak_system_context.py new file mode 100644 index 0000000..1b27ad0 --- /dev/null +++ b/workers/agent-worker/tests/test_speak_system_context.py @@ -0,0 +1,51 @@ +"""speak_system_context wires MemoryContext semantic into format_attitude_context.""" + +from src.graph.speak_system_context import build_speak_system_context + + +def test_speak_system_context_injects_mood_into_attitude_block(): + text = build_speak_system_context( + { + "npc_id": "npc-99", # non-council → empty persona + "attitude_band": "warm", + "effective_score": 22, + "collective_summaries": [], + "current_mood": "戏谑", + "key_beliefs": ["我看穿了他"], + "attitude_summary": "语气轻松但带刺", + "room_snapshot": { + "width": 8, + "height": 8, + "player": {"x": 1, "y": 1}, + "npcs": [], + "objects": [], + }, + }, + base_prompt="BASE", + ) + assert "Attitude toward this player:" in text + assert "mood: 戏谑" in text + assert "我看穿了他" in text + assert "语气轻松但带刺" in text + + +def test_speak_system_context_without_semantic_keeps_band_lines(): + text = build_speak_system_context( + { + "npc_id": "npc-99", + "attitude_band": "neutral", + "effective_score": 0, + "collective_summaries": ["窗内事件"], + "room_snapshot": { + "width": 8, + "height": 8, + "player": {"x": 0, "y": 0}, + "npcs": [], + "objects": [], + }, + }, + base_prompt="BASE", + ) + assert "band: neutral" in text + assert "窗内事件" in text + assert "mood:" not in text