Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/game-server/src/colyseus/GameRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
validateChatMessage,
validateChatNpcId,
} from "./npc-chat.js";
import { getRecentTurns } from "../npc/dialogue-session.js";
import {
getColyseusRoom,
tryClaimMapRoom,
Expand Down Expand Up @@ -278,7 +279,8 @@ export class GameRoom extends Room {
});
// speakAck before Redis enqueue — fast-lane worker can finish before LPUSH returns otherwise.
client.send(COLYSEUS_SERVER_MESSAGES.speakAck, { jobId, npcId });
const casualStub = previewCasualSpeakStub(text);
const recentTurns = getRecentTurns(this.mapRoomId, playerId, npcId, 10);
const casualStub = previewCasualSpeakStub(text, recentTurns);
if (casualStub) {
emitJobEvent(jobId, "speakPartial", { text: casualStub, npcId });
}
Expand Down
4 changes: 3 additions & 1 deletion apps/game-server/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../colyseus/npc-chat.js";
import { getColyseusRoom } from "../colyseus/room-registry.js";
import type { GameRoom } from "../colyseus/GameRoom.js";
import { getRecentTurns } from "../npc/dialogue-session.js";
import { playerIdFromRequest } from "../http/player-id.js";
import { getOrCreate } from "../room/store.js";
import { emitJobEvent, subscribeJobEvents } from "../sse/hub.js";
Expand Down Expand Up @@ -55,7 +56,8 @@ export function createChatRouter(): Router {

let speakAcquired = Boolean(colyseusRoom);
try {
const casualStub = previewCasualSpeakStub(message);
const recentTurns = getRecentTurns(roomId, playerId, npcId, 10);
const casualStub = previewCasualSpeakStub(message, recentTurns);
if (colyseusRoom) {
registerJob(jobId, colyseusRoom, roomId, undefined, {
npcId,
Expand Down
8 changes: 8 additions & 0 deletions apps/game-server/src/speak/casual-stub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,12 @@ describe("previewCasualSpeakStub", () => {
it("returns null for physical message", () => {
expect(previewCasualSpeakStub("向右走一步")).toBeNull();
});

it("returns null when recent turns present", () => {
const history = [
{ role: "player" as const, text: "干嘛呢?" },
{ role: "npc" as const, text: "在忙" },
];
expect(previewCasualSpeakStub("你好,用一句话简短回复", history)).toBeNull();
});
});
54 changes: 54 additions & 0 deletions apps/web/src/hooks/npcChat/dialogueTurns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { recentDialogueTurnsForNpc } from "./dialogueTurns.js";
import type { ChatMessage } from "./types.js";

describe("recentDialogueTurnsForNpc", () => {
it("pairs player lines with matching npc thread only", () => {
const messages: ChatMessage[] = [
{ id: "1", role: "player", text: "a", npcId: "npc-5" },
{ id: "2", role: "npc", text: "r1", npcId: "npc-1" },
{ id: "3", role: "player", text: "b", npcId: "npc-5" },
{ id: "4", role: "npc", text: "r5", npcId: "npc-5" },
];
// Other-NPC replies must not drop pending player lines for npc-5.
expect(recentDialogueTurnsForNpc(messages, "npc-5")).toEqual([
{ role: "player", text: "a" },
{ role: "player", text: "b" },
{ role: "npc", text: "r5" },
]);
});

it("excludes interleaved player messages targeting other npcs", () => {
const messages: ChatMessage[] = [
{ id: "1", role: "player", text: "to-1a", npcId: "npc-1" },
{ id: "2", role: "npc", text: "from-1a", npcId: "npc-1" },
{ id: "3", role: "player", text: "to-5", npcId: "npc-5" },
{ id: "4", role: "npc", text: "from-5", npcId: "npc-5" },
{ id: "5", role: "player", text: "to-1b", npcId: "npc-1" },
{ id: "6", role: "npc", text: "from-1b", npcId: "npc-1" },
];
expect(recentDialogueTurnsForNpc(messages, "npc-1")).toEqual([
{ role: "player", text: "to-1a" },
{ role: "npc", text: "from-1a" },
{ role: "player", text: "to-1b" },
{ role: "npc", text: "from-1b" },
]);
expect(recentDialogueTurnsForNpc(messages, "npc-5")).toEqual([
{ role: "player", text: "to-5" },
{ role: "npc", text: "from-5" },
]);
});

it("preserves pending player turn when another NPC replies first", () => {
const messages: ChatMessage[] = [
{ id: "1", role: "player", text: "to-5", npcId: "npc-5" },
{ id: "2", role: "player", text: "to-1", npcId: "npc-1" },
{ id: "3", role: "npc", text: "from-1", npcId: "npc-1" },
{ id: "4", role: "npc", text: "from-5", npcId: "npc-5" },
];
expect(recentDialogueTurnsForNpc(messages, "npc-5")).toEqual([
{ role: "player", text: "to-5" },
{ role: "npc", text: "from-5" },
]);
});
});
31 changes: 31 additions & 0 deletions apps/web/src/hooks/npcChat/dialogueTurns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { DialogueTurn } from "@aetherlife/shared";
import type { ChatMessage } from "./types.js";

/** Completed player↔npc turns for one NPC thread (mirrors game-server dialogue-session). */
export function recentDialogueTurnsForNpc(
messages: readonly ChatMessage[],
npcId: string,
limit = 10,
): DialogueTurn[] {
const turns: DialogueTurn[] = [];
let pendingPlayers: DialogueTurn[] = [];

for (const m of messages) {
if (m.role === "error") continue;
if (m.role === "player") {
// Skip other-NPC lines without clearing pending for this thread.
if (m.npcId && m.npcId !== npcId) continue;
pendingPlayers.push({ role: "player", text: m.text });
continue;
}
if (m.role === "npc") {
// Unrelated NPC replies must not drop an in-flight player line for npcId.
if (m.npcId !== npcId) continue;
turns.push(...pendingPlayers);
pendingPlayers = [];
turns.push({ role: "npc", text: m.text });
}
}

return turns.slice(-limit);
}
1 change: 1 addition & 0 deletions apps/web/src/hooks/npcChat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type {
RoomStateShape,
UseNpcChatOptions,
} from "./types.js";
export { recentDialogueTurnsForNpc } from "./dialogueTurns.js";
export { attitudeGateHintCopy } from "./attitudeGate.js";
export {
dequeueNpcSpeak,
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/hooks/useNpcChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
type RoomNpc,
type RoomStateShape,
type UseNpcChatOptions,
recentDialogueTurnsForNpc,
} from "./npcChat/index.js";

export type {
Expand Down Expand Up @@ -88,6 +89,8 @@ export function useNpcChat(
};
}, []);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const messagesRef = useRef<ChatMessage[]>([]);
messagesRef.current = messages;
const [status, setStatus] = useState<ChatStatus>("idle");
const [roomState, setRoomState] = useState<RoomStateShape | null>(null);
const [memoryCounts, setMemoryCounts] = useState<Record<string, number>>({});
Expand Down Expand Up @@ -134,7 +137,7 @@ export function useNpcChat(
if (opts?.showPlayerBubble !== false) {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: "player", text },
{ id: crypto.randomUUID(), role: "player", text, npcId },
]);
}
},
Expand Down Expand Up @@ -497,7 +500,7 @@ export function useNpcChat(
if (!opts?.skipPlayerBubble) {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: "player", text: trimmed },
{ id: crypto.randomUUID(), role: "player", text: trimmed, npcId },
]);
}
inFlightTextRef.current.set(npcId, trimmed);
Expand All @@ -518,7 +521,8 @@ export function useNpcChat(
window.__speakLatencyT0 = performance.now();
}

const clientStub = previewCasualSpeakStub(trimmed);
const recentTurns = recentDialogueTurnsForNpc(messagesRef.current, npcId);
const clientStub = previewCasualSpeakStub(trimmed, recentTurns);
if (clientStub) {
setStreamingByNpc((prev) => ({
...prev,
Expand Down
39 changes: 39 additions & 0 deletions docs/ISSUE-LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
112. **个人传记隔离**:`npc_personal_timeline` 是唯一个人人生时间线存储;**禁止**把个人 biography 写入 `__council__`(C-07)或玩家 speak `npc_memories`(C-05)。Worker/seed 只经 `insertPersonalTimelineEntry` / internal POST;改写路径须 `pnpm --filter @aetherlife/game-server test -- personal-timeline-repository`(含 isolation 源码断言)。
113. **个人日记须按席位人设写**:weekly/polish/multi/rel/event prompt **必须** `persona_block_for`(speak mirror);**禁止**无口吻的「人生札记」通稿导致 ENTJ/ESFP 写同款文艺腔(ISSUE-106)。周记须带 `recentBullets`;非廷议双边走 `kind=event` + `min_abs_delta=DYAD_REL_MIN_ABS_DELTA`(|Δ|≥4,禁止无关键词 casual mention)。回归:`pytest tests/test_personal_timeline.py tests/test_personal_timeline_rel07.py -q` · `pnpm --filter @aetherlife/game-server test -- personal-timeline-dyad personal-timeline-weekly`。
114. **Personal-timeline job 入队须 durable claim**:`claimPersonalTimelineJobId` / worker `claim_personal_timeline_job_id`(SET NX,前缀 `aetherlife:personal-timeline:job-claimed:`)覆盖 polish/weekly/multi/rel/event **以及** dyad pair/ambient-slot;**禁止**仅靠进程内存 debounce(重启会重复 LPUSH → 重复 LLM 行)。传记 UI:fetch 失败须展示 error(勿静默空列表);已缓存条目在 `personalTimelineSync` 时须后台 refetch。回归:`personal-timeline.claim.test.ts` · `personal-timeline-dyad.test.ts` · `usePersonalTimeline.test.ts` · `pnpm uat:phase27:persona-diary`。
115. **Speak 多轮连贯(260720-m4b)**:interactive 路径 `llm_social_turn._build_social_messages` **必须**注入 `recent_turns` Human/AI 链(`append_recent_dialogue_messages`);**禁止** help offer(`player_offers_help` /「我可以帮你」)走 SOCIAL_EDGE deterministic stub;`recent_turns` 非空时 **禁止** CASUAL/SOCIAL_EDGE fast lane(B1 例外:空历史纯问候)。`augment_retrieved_with_dialogue_turns` 须含 `npc:` 行。回归:`pytest tests/test_speak_intent.py tests/test_help_reply_by_npc.py tests/test_llm_social_memory.py tests/test_casual_fast_lane.py tests/test_recall_merge.py -q` · `pnpm --filter @aetherlife/shared test -- speakIntent` · `pnpm agent:verify`。

## 记录

Expand Down Expand Up @@ -2808,4 +2809,42 @@ Worker 主循环仅在 npc-turn 队列 **连续 5s 为空** 时才 `BLPOP` chunk

---

### ISSUE-107 — Speak 多轮对话 stub 断链:「我可以帮你」回复反向

- **状态:** fixed
- **发现:** 2026-07-20
- **阶段/范围:** speak intent / `llm_social_turn` / fast lane(worker + `packages/shared`)
- **严重性:** major(对话连贯 / 人设)

**复现**

1. 对糖果说「干嘛呢?」→ NPC 正常回复想黑系统
2. 接着说「我可以帮你!」
3. NPC 回复「好的,我会尽力帮忙。」(像玩家在求助,忽略上文)

**根因**

- ISSUE-018 修复了 `build_turn_messages` 历史,但 interactive 主路径改用 `llm_social_turn._build_social_messages` 后**未注入 `recent_turns`**
- `infer_social_from_message` 凡含「帮」即 help-request → SOCIAL_EDGE fast lane deterministic stub
- Fast lane / `_deterministic_social_turn` 不读 session,npc-4 等席别落默认套话

**修复**

- `player_offers_help` 区分 offer vs request(TS/Python parity)
- `recent_turns` 非空或 offer 时 gate CASUAL/SOCIAL_EDGE fast lane;continuation 短路
- `_build_social_messages` + `append_recent_dialogue_messages` 注入 Human/AI 链;`augment_retrieved_with_dialogue_turns` 含 npc 行

**验证**

- `pnpm agent:verify`
- `pnpm uat:speak-help-offer:playwright`(真实 LLM + Playwright;`pnpm dev:stack`)
- `cd workers/agent-worker && LLM_MOCK=1 uv run pytest tests/test_speak_intent.py tests/test_help_reply_by_npc.py tests/test_llm_social_memory.py tests/test_casual_fast_lane.py tests/test_recall_merge.py tests/test_graph_tools.py -q`
- `pnpm --filter @aetherlife/shared test -- speakIntent`

**防复发**

- Guardrail #115

---

<!-- 新问题上文追加,保持 ISSUE 编号递增 -->
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"uat:phase6:playwright": "node scripts/uat-phase6-playwright.mjs",
"uat:phase7:playwright": "node scripts/uat-phase7-playwright.mjs",
"uat:phase8:playwright": "node scripts/uat-phase8-playwright.mjs",
"uat:speak-help-offer:playwright": "node scripts/uat-speak-help-offer-playwright.mjs",
"uat:phase10:playwright": "node scripts/uat-phase10-playwright.mjs",
"uat:phase11:playwright": "node scripts/uat-phase11-playwright.mjs",
"uat:phase7:reset-snap": "node scripts/uat-phase7-reset-snap.mjs",
Expand Down
23 changes: 18 additions & 5 deletions packages/shared/src/casualSpeakStub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
classifySpeakIntent,
inferSocialFromMessage,
isCasualGreetingOnly,
playerOffersHelp,
playerRequestsPhysicalAction,
SpeakIntent,
type DialogueTurn,
type SpeakIntentValue,
} from "./speakIntent.js";
import { stableStringHash } from "./stableStringHash.js";
Expand Down Expand Up @@ -54,7 +56,10 @@ function deterministicSocialReply(message: string, speakIntent: SpeakIntentValue
const inferred = inferSocialFromMessage(msg);
if (inferred !== null) {
if (inferred === "rude") return "请不要这样说话。";
if (inferred === "help") return "好的,我会尽力帮忙。";
if (inferred === "help") {
if (playerOffersHelp(msg)) return null;
return "好的,我会尽力帮忙。";
}
return `我听到了:${msg.slice(0, 120)}`;
}

Expand All @@ -72,8 +77,12 @@ function deterministicSocialReply(message: string, speakIntent: SpeakIntentValue
}

/** Early speakPartial text for CASUAL deterministic turns. */
export function previewCasualSpeakStub(message: string): string | null {
const intent = classifySpeakIntent(message);
export function previewCasualSpeakStub(
message: string,
recentTurns?: readonly DialogueTurn[] | null,
): string | null {
if (recentTurns?.length) return null;
const intent = classifySpeakIntent(message, recentTurns);
if (intent !== SpeakIntent.CASUAL) return null;
return deterministicSocialReply(message, intent);
}
Expand All @@ -83,8 +92,12 @@ export type CasualFastLanePreview = {
stub: string;
};

export function canUseCasualFastLane(message: string): CasualFastLanePreview | null {
const intent = classifySpeakIntent(message);
export function canUseCasualFastLane(
message: string,
recentTurns?: readonly DialogueTurn[] | null,
): CasualFastLanePreview | null {
if (recentTurns?.length) return null;
const intent = classifySpeakIntent(message, recentTurns);
if (intent !== SpeakIntent.CASUAL) return null;
const stub = deterministicSocialReply(message, intent);
if (!stub) return null;
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export {
playerRequestsInteract,
playerRequestsMove,
shouldSkipMemoryContext,
type DialogueTurn,
type SpeakIntentValue,
} from "./speakIntent.js";

Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/speakIntent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
} from "./casualSpeakStub.js";
import {
classifySpeakIntent,
inferSocialFromMessage,
playerOffersHelp,
shouldSkipMemoryContext,
SpeakIntent,
} from "./speakIntent.js";
Expand Down Expand Up @@ -39,9 +41,45 @@ describe("classifySpeakIntent", () => {
expect(classifySpeakIntent("请帮帮我")).toBe(SpeakIntent.SOCIAL_EDGE);
expect(classifySpeakIntent("滚开")).toBe(SpeakIntent.SOCIAL_EDGE);
expect(classifySpeakIntent("你真蠢")).toBe(SpeakIntent.SOCIAL_EDGE);
expect(classifySpeakIntent("你真粗鲁")).toBe(SpeakIntent.SOCIAL_EDGE);
expect(classifySpeakIntent("能请你帮个忙吗")).toBe(SpeakIntent.SOCIAL_EDGE);
});

it("continuation short with history routes to narrative", () => {
const history = [
{ role: "player" as const, text: "干嘛呢?" },
{ role: "npc" as const, text: "在忙" },
];
expect(classifySpeakIntent("好的", history)).toBe(SpeakIntent.NARRATIVE);
expect(classifySpeakIntent("你好", history)).toBe(SpeakIntent.NARRATIVE);
});

it("help offer is not social edge", () => {
expect(playerOffersHelp("我可以帮你!")).toBe(true);
expect(playerOffersHelp("我能帮你!")).toBe(true);
expect(playerOffersHelp("我愿意帮你")).toBe(true);
expect(playerOffersHelp("我想帮你")).toBe(true);
expect(playerOffersHelp("我来帮")).toBe(true);
expect(playerOffersHelp("让我帮你")).toBe(true);
expect(playerOffersHelp("请帮帮我")).toBe(false);
expect(inferSocialFromMessage("我可以帮你!")).toBeNull();
expect(inferSocialFromMessage("我能帮你!")).toBeNull();
expect(inferSocialFromMessage("我愿意帮你")).toBeNull();
expect(inferSocialFromMessage("我想帮你")).toBeNull();
expect(inferSocialFromMessage("请帮帮我")).toBe("help");
expect(classifySpeakIntent("我可以帮你!")).toBe(SpeakIntent.NARRATIVE);
expect(classifySpeakIntent("我能帮你!")).toBe(SpeakIntent.NARRATIVE);
expect(classifySpeakIntent("我愿意帮你")).toBe(SpeakIntent.NARRATIVE);
expect(classifySpeakIntent("我想帮你")).toBe(SpeakIntent.NARRATIVE);
expect(classifySpeakIntent("我来帮")).toBe(SpeakIntent.NARRATIVE);
});

it("narrative bang compounds are not help requests", () => {
expect(inferSocialFromMessage("帮别人做事")).toBeNull();
expect(inferSocialFromMessage("别在这里帮腔")).toBeNull();
expect(classifySpeakIntent("帮别人做事")).toBe(SpeakIntent.NARRATIVE);
});

it("casual intent", () => {
expect(classifySpeakIntent("你好")).toBe(SpeakIntent.CASUAL);
expect(classifySpeakIntent("Hi")).toBe(SpeakIntent.CASUAL);
Expand Down Expand Up @@ -99,6 +137,16 @@ describe("casual reply pool", () => {
expect(previewCasualSpeakStub("你在做什么呢?")).toBeNull();
expect(previewCasualSpeakStub("你好狂啊~")).toBeNull();
expect(previewCasualSpeakStub("在啥啊")).toBeNull();
expect(previewCasualSpeakStub("我可以帮你!")).toBeNull();
});

it("preview casual stub blocked with recent turns", () => {
const history = [
{ role: "player" as const, text: "干嘛呢?" },
{ role: "npc" as const, text: "在忙" },
];
expect(previewCasualSpeakStub("你好", history)).toBeNull();
expect(canUseCasualFastLane("你好,用一句话简短回复", history)).toBeNull();
});

it("can use casual fast lane b1", () => {
Expand Down
Loading
Loading