diff --git a/apps/game-server/src/colyseus/GameRoom.ts b/apps/game-server/src/colyseus/GameRoom.ts index c6332c2..2bafd6f 100644 --- a/apps/game-server/src/colyseus/GameRoom.ts +++ b/apps/game-server/src/colyseus/GameRoom.ts @@ -30,6 +30,7 @@ import { validateChatMessage, validateChatNpcId, } from "./npc-chat.js"; +import { getRecentTurns } from "../npc/dialogue-session.js"; import { getColyseusRoom, tryClaimMapRoom, @@ -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 }); } diff --git a/apps/game-server/src/routes/chat.ts b/apps/game-server/src/routes/chat.ts index 87cb71d..270f41e 100644 --- a/apps/game-server/src/routes/chat.ts +++ b/apps/game-server/src/routes/chat.ts @@ -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"; @@ -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, diff --git a/apps/game-server/src/speak/casual-stub.test.ts b/apps/game-server/src/speak/casual-stub.test.ts index e128ff7..b9944ad 100644 --- a/apps/game-server/src/speak/casual-stub.test.ts +++ b/apps/game-server/src/speak/casual-stub.test.ts @@ -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(); + }); }); diff --git a/apps/web/src/hooks/npcChat/dialogueTurns.test.ts b/apps/web/src/hooks/npcChat/dialogueTurns.test.ts new file mode 100644 index 0000000..e23744e --- /dev/null +++ b/apps/web/src/hooks/npcChat/dialogueTurns.test.ts @@ -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" }, + ]); + }); +}); diff --git a/apps/web/src/hooks/npcChat/dialogueTurns.ts b/apps/web/src/hooks/npcChat/dialogueTurns.ts new file mode 100644 index 0000000..5dfd092 --- /dev/null +++ b/apps/web/src/hooks/npcChat/dialogueTurns.ts @@ -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); +} diff --git a/apps/web/src/hooks/npcChat/index.ts b/apps/web/src/hooks/npcChat/index.ts index d9066b5..b945ba0 100644 --- a/apps/web/src/hooks/npcChat/index.ts +++ b/apps/web/src/hooks/npcChat/index.ts @@ -7,6 +7,7 @@ export type { RoomStateShape, UseNpcChatOptions, } from "./types.js"; +export { recentDialogueTurnsForNpc } from "./dialogueTurns.js"; export { attitudeGateHintCopy } from "./attitudeGate.js"; export { dequeueNpcSpeak, diff --git a/apps/web/src/hooks/useNpcChat.ts b/apps/web/src/hooks/useNpcChat.ts index ada31f8..2ea126a 100644 --- a/apps/web/src/hooks/useNpcChat.ts +++ b/apps/web/src/hooks/useNpcChat.ts @@ -41,6 +41,7 @@ import { type RoomNpc, type RoomStateShape, type UseNpcChatOptions, + recentDialogueTurnsForNpc, } from "./npcChat/index.js"; export type { @@ -88,6 +89,8 @@ export function useNpcChat( }; }, []); const [messages, setMessages] = useState([]); + const messagesRef = useRef([]); + messagesRef.current = messages; const [status, setStatus] = useState("idle"); const [roomState, setRoomState] = useState(null); const [memoryCounts, setMemoryCounts] = useState>({}); @@ -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 }, ]); } }, @@ -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); @@ -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, diff --git a/docs/ISSUE-LOG.md b/docs/ISSUE-LOG.md index ba86944..7d74c9b 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -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`。 ## 记录 @@ -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 + +--- + diff --git a/package.json b/package.json index b1abbb3..f0329e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/src/casualSpeakStub.ts b/packages/shared/src/casualSpeakStub.ts index fb353d5..3e9ec20 100644 --- a/packages/shared/src/casualSpeakStub.ts +++ b/packages/shared/src/casualSpeakStub.ts @@ -6,8 +6,10 @@ import { classifySpeakIntent, inferSocialFromMessage, isCasualGreetingOnly, + playerOffersHelp, playerRequestsPhysicalAction, SpeakIntent, + type DialogueTurn, type SpeakIntentValue, } from "./speakIntent.js"; import { stableStringHash } from "./stableStringHash.js"; @@ -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)}`; } @@ -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); } @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 19a33a4..dad4633 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -243,6 +243,7 @@ export { playerRequestsInteract, playerRequestsMove, shouldSkipMemoryContext, + type DialogueTurn, type SpeakIntentValue, } from "./speakIntent.js"; diff --git a/packages/shared/src/speakIntent.test.ts b/packages/shared/src/speakIntent.test.ts index afb8193..8fbe13a 100644 --- a/packages/shared/src/speakIntent.test.ts +++ b/packages/shared/src/speakIntent.test.ts @@ -6,6 +6,8 @@ import { } from "./casualSpeakStub.js"; import { classifySpeakIntent, + inferSocialFromMessage, + playerOffersHelp, shouldSkipMemoryContext, SpeakIntent, } from "./speakIntent.js"; @@ -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); @@ -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", () => { diff --git a/packages/shared/src/speakIntent.ts b/packages/shared/src/speakIntent.ts index 82c01f6..dfe57e2 100644 --- a/packages/shared/src/speakIntent.ts +++ b/packages/shared/src/speakIntent.ts @@ -10,6 +10,9 @@ export const SpeakIntent = { export type SpeakIntentValue = (typeof SpeakIntent)[keyof typeof SpeakIntent]; +/** Short-term dialogue turn — mirrors game-server dialogue-session + worker recent_turns. */ +export type DialogueTurn = { role: "player" | "npc"; text: string }; + const RECALL_MARKERS = [ "记得", "还记得", @@ -74,7 +77,53 @@ const INTERACT_PATTERNS: RegExp[] = [ /\binteract\b/i, ]; -const INSULT_MARKERS = ["丑", "滚", "蠢", "有病", "变态", "活该", "什么玩意", "傻", "废物", "去死"]; +const INSULT_MARKERS = [ + "粗鲁", + "丑", + "滚", + "蠢", + "有病", + "变态", + "活该", + "什么玩意", + "傻", + "废物", + "去死", + "讨厌", + "笨蛋", + "侮辱", + "辱骂", +]; + +const HELP_OFFER_RE = /(?:我可以|我能|我愿意|我想|我来|让我)(?:帮|协助)/; +/** Avoid narrative false positives (帮别人 / 帮腔) — 帮 + request pronoun/cue only. */ +const HELP_REQUEST_FALLBACK_RE = /帮[我你他她它个一上下把忙]/; + +/** Player volunteers to help the NPC — not a help request. Mirrors social_turn.player_offers_help */ +export function playerOffersHelp(message: string): boolean { + const msg = (message || "").trim(); + if (!msg) return false; + if (HELP_OFFER_RE.test(msg)) return true; + if (msg.startsWith("我帮你")) return true; + if (msg.startsWith("帮你")) return true; + return false; +} + +function messageImpliesHelpRequest(message: string): boolean { + const msg = (message || "").trim(); + if (!msg || playerOffersHelp(msg)) return false; + if ( + ["帮帮我", "帮个忙", "请帮", "你能帮", "能帮我", "能请你帮", "帮忙"].some((marker) => + msg.includes(marker), + ) + ) { + return true; + } + if (HELP_REQUEST_FALLBACK_RE.test(msg)) return true; + if (!msg.includes("请")) return false; + const normalized = msg.replace(/回复/g, "").replace(/请假/g, ""); + return normalized.includes("请"); +} export function playerRequestsMove(message: string): boolean { const text = (message || "").trim(); @@ -103,7 +152,7 @@ export function inferSocialFromMessage(message: string): "rude" | "help" | null const msg = (message || "").trim(); if (!msg) return null; if (INSULT_MARKERS.some((marker) => msg.includes(marker))) return "rude"; - if (msg.includes("帮") || msg.includes("请")) return "help"; + if (messageImpliesHelpRequest(msg)) return "help"; return null; } @@ -114,11 +163,17 @@ export function isCasualGreetingOnly(message: string): boolean { return CASUAL_GREETING_ONLY_RE.test(msg); } -export function classifySpeakIntent(message: string): SpeakIntentValue { +export function classifySpeakIntent( + message: string, + recentTurns?: readonly DialogueTurn[] | null, +): SpeakIntentValue { const msg = (message || "").trim(); if (!msg) return SpeakIntent.NARRATIVE; if (playerRequestsPhysicalAction(msg)) return SpeakIntent.PHYSICAL; if (isRecallQuestion(msg)) return SpeakIntent.RECALL; + if (recentTurns?.length && msg.length < 20 && inferSocialFromMessage(msg) === null) { + return SpeakIntent.NARRATIVE; + } if (inferSocialFromMessage(msg) !== null) return SpeakIntent.SOCIAL_EDGE; if (isCasualGreetingOnly(msg)) return SpeakIntent.CASUAL; if (META_BRIEF_RE.test(msg)) return SpeakIntent.CASUAL; diff --git a/scripts/uat-speak-help-offer-playwright.mjs b/scripts/uat-speak-help-offer-playwright.mjs new file mode 100644 index 0000000..cd21dcc --- /dev/null +++ b/scripts/uat-speak-help-offer-playwright.mjs @@ -0,0 +1,133 @@ +/** + * Speak coherence UAT (targeted): ensure help offers are not treated as help requests. + * + * Flow: engage NPC → 「干嘛呢?」→ 「我可以帮你!」 + * Assert second reply does NOT contain deterministic help-request stub: + * 「好的,我会尽力帮忙。」 + * + * Requires: pnpm dev:stack (real LLM). See docs/E2E-POLICY.md + */ +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/08-multiplayer-room/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_SPEAK_HELP_OFFER_NPC_ID || "npc-5"; +const UAT_ROOM_ID = process.env.UAT_SPEAK_HELP_OFFER_ROOM_ID || `uat-help-offer-${Date.now()}`; +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 FORBIDDEN_STUB_RE = /好的,我会尽力帮忙。/; +const HELP_REQUEST_REPLY_PATTERNS = [ + FORBIDDEN_STUB_RE, + /有什么我能帮你的/, + /需要我做什么/, + /我会尽力帮忙/, +]; +const HELP_OFFER_ACCEPTANCE_RE = /感谢|谢谢|好意|感动|愿意|陪|太好了/; + +function assertHelpOfferReply(firstReply, secondReply) { + const second = (secondReply || "").trim(); + if (second.length < 10) { + throw new Error(`Help-offer reply too short: ${JSON.stringify(second)}`); + } + for (const pattern of HELP_REQUEST_REPLY_PATTERNS) { + if (pattern.test(second)) { + throw new Error( + `Regression: NPC reply sounds like help-request stub: ${JSON.stringify(second)}`, + ); + } + } + if (!HELP_OFFER_ACCEPTANCE_RE.test(second)) { + throw new Error( + `Help-offer reply lacks acceptance/gratitude cue: ${JSON.stringify(second)}`, + ); + } + if (firstReply.trim() && second === firstReply.trim()) { + throw new Error("Help-offer reply duplicated first NPC line"); + } +} + +async function screenshot(page, label) { + await mkdir(OUT_DIR, { recursive: true }); + const file = path.join(OUT_DIR, `help-offer-${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 main() { + assertE2eRealLlm("uat:speak-help-offer:playwright"); + + console.log("Speak help-offer UAT (Playwright)"); + console.log(`WEB=${WEB} GS=${GS} npc=${UAT_NPC_ID} room=${UAT_ROOM_ID}`); + + await health(GS, "game-server"); + try { + const webRes = await fetch(WEB, { signal: AbortSignal.timeout(15_000) }); + if (!webRes.ok) throw new Error(`Web ${WEB} → ${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 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(SPEAK_WAIT_TIMEOUT_MS); + + await page.goto(WEB, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.locator('[data-testid="room-scene"]').waitFor({ state: "visible", timeout: 45_000 }); + await engageNpcDialogue(page, UAT_NPC_ID, { timeoutMs: ENGAGE_TIMEOUT_MS }); + await screenshot(page, "00-engaged"); + + const speakOpts = { + speakTimeoutMs: SPEAK_WAIT_TIMEOUT_MS, + engageTimeoutMs: ENGAGE_TIMEOUT_MS, + skipEngage: true, + }; + + const first = await sendSpeakOverlay(page, "干嘛呢?", speakOpts); + console.log(` NPC after 「干嘛呢?」: ${first.reply.slice(0, 80)}…`); + await screenshot(page, "01-npc-after-first-message"); + if (!first.reply.trim()) throw new Error("First NPC reply empty"); + + const second = await sendSpeakOverlay(page, "我可以帮你!", speakOpts); + await screenshot(page, "02-npc-after-help-offer"); + + assertHelpOfferReply(first.reply, second.reply); + + console.log(`✅ Passed: NPC reply after help offer: ${second.reply}`); + + await browser.close(); +} + +main().catch((err) => { + console.error(`❌ UAT failed: ${err?.message || err}`); + process.exit(1); +}); diff --git a/workers/agent-worker/src/collective/social_turn.py b/workers/agent-worker/src/collective/social_turn.py index 2a6a71e..3dba5fb 100644 --- a/workers/agent-worker/src/collective/social_turn.py +++ b/workers/agent-worker/src/collective/social_turn.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import Any @@ -45,8 +46,34 @@ class CollectiveApplyResult: player_reputation: int | None = None +_HELP_OFFER_RE = re.compile(r"(?:我可以|我能|我愿意|我想|我来|让我)(?:帮|协助)") +# Avoid narrative false positives: 帮别人 / 帮腔 — require 帮 + request pronoun/cue. +_HELP_REQUEST_FALLBACK_RE = re.compile(r"帮[我你他她它个一上下把忙]") + + +def player_offers_help(message: str) -> bool: + """Player volunteers to help the NPC — not a help request.""" + msg = (message or "").strip() + if not msg: + return False + if _HELP_OFFER_RE.search(msg): + return True + if msg.startswith("我帮你"): + return True + if msg.startswith("帮你"): + return True + return False + + def _message_implies_help_request(msg: str) -> bool: - if "帮" in msg: + if player_offers_help(msg): + return False + if any( + marker in msg + for marker in ("帮帮我", "帮个忙", "请帮", "你能帮", "能帮我", "能请你帮", "帮忙") + ): + return True + if _HELP_REQUEST_FALLBACK_RE.search(msg): return True if "请" not in msg: return False diff --git a/workers/agent-worker/src/graph/nodes/llm_social_turn.py b/workers/agent-worker/src/graph/nodes/llm_social_turn.py index f9fa841..1ad4085 100644 --- a/workers/agent-worker/src/graph/nodes/llm_social_turn.py +++ b/workers/agent-worker/src/graph/nodes/llm_social_turn.py @@ -8,10 +8,10 @@ from typing import Any import httpx -from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from src.collective.schemas import SOCIAL_SKIP_KIND, SocialPerception, SocialTurnOut -from src.collective.social_turn import infer_social_from_message, reconcile_social_perception +from src.collective.social_turn import infer_social_from_message, player_offers_help, reconcile_social_perception from src.config import Settings, get_settings from src.graph.action_intent import ( build_dialogue_context, @@ -24,6 +24,7 @@ ) from src.graph.job_context import get_partial_emit, record_phase_ms from src.graph.recall_merge import is_recall_question +from src.graph.prompt import SOCIAL_DIALOGUE_CONTEXT_APPEND, append_recent_dialogue_messages from src.graph.speak_intent import SpeakIntent, is_casual_greeting_only from src.graph.stable_string_hash import stable_string_hash from src.graph.speak_system_context import ( @@ -160,6 +161,7 @@ def _deterministic_social_turn( *, speak_intent: str = "", npc_id: str = "npc-1", + recent_turns: list | None = None, ) -> SocialTurnOut | None: """Rule-based social + reply — skip social LLM when heuristics are sufficient.""" msg = player_message.strip() @@ -167,6 +169,8 @@ def _deterministic_social_turn( return None inferred = infer_social_from_message(msg) if inferred is not None: + if (recent_turns or player_offers_help(msg)) and inferred.kind in ("rude", "help"): + return None if inferred.kind == "rude": reply = "请不要这样说话。" elif inferred.kind == "help": @@ -176,6 +180,9 @@ def _deterministic_social_turn( else: reply = f"我听到了:{msg[:120]}" return SocialTurnOut(social=inferred, reply=reply) + # With session history, never emit CASUAL/meta stubs — interactive LLM must see turns. + if recent_turns: + return None if speak_intent == SpeakIntent.CASUAL.value or _META_BRIEF_RE.search(msg): if not player_requests_physical_action(msg): return SocialTurnOut( @@ -302,12 +309,18 @@ def _build_social_messages( state: GraphState, *, system_append: str = "", -) -> list[SystemMessage | HumanMessage]: +) -> list[SystemMessage | HumanMessage | AIMessage]: + dialogue_append = SOCIAL_DIALOGUE_CONTEXT_APPEND + combined_append = ( + f"{system_append}\n\n{dialogue_append}".strip() + if system_append.strip() + else dialogue_append + ) system_text = build_speak_system_context( state, base_prompt=SOCIAL_SYSTEM_PROMPT, memory_suffix=SOCIAL_MEMORY_RECALL_HINT, - system_append=system_append, + system_append=combined_append, ) player_message = state.get("player_message") or "" human = ( @@ -315,10 +328,12 @@ def _build_social_messages( "Respond with JSON only. Put \"reply\" as the first key, then \"social\".\n" 'Example: {"reply":"…","social":{"kind":"ignore","summary":"","delta":0}}' ) - return [ + messages: list[SystemMessage | HumanMessage | AIMessage] = [ SystemMessage(content=system_text), - HumanMessage(content=human), ] + append_recent_dialogue_messages(messages, state.get("recent_turns")) + messages.append(HumanMessage(content=human)) + return messages def run_social_turn_llm( @@ -611,6 +626,7 @@ def llm_social_turn(state: GraphState, *, settings: Settings | None = None) -> G player_message, speak_intent=speak_intent, npc_id=state.get("npc_id") or "npc-1", + recent_turns=state.get("recent_turns"), ) if deterministic is not None: record_phase_ms("t_social_llm_ms", 0) diff --git a/workers/agent-worker/src/graph/prompt.py b/workers/agent-worker/src/graph/prompt.py index 93918b3..58e9cfa 100644 --- a/workers/agent-worker/src/graph/prompt.py +++ b/workers/agent-worker/src/graph/prompt.py @@ -20,6 +20,31 @@ RECENT_DIALOGUE_TURN_LIMIT = 10 +SOCIAL_DIALOGUE_CONTEXT_APPEND = """\ +Recent dialogue 是真实会话历史。请针对玩家**最新一条**消息作答,结合上文语境;勿复读上一轮几乎相同的回复。 +若玩家主动提出帮助(如「我可以帮你」),须以第一人称接受或回应,**禁止**像玩家向你求助一样说「我会尽力帮你」。""" + + +def append_recent_dialogue_messages( + messages: list[SystemMessage | HumanMessage | AIMessage], + recent_turns: list | dict[str, str] | None, + *, + limit: int = RECENT_DIALOGUE_TURN_LIMIT, +) -> None: + """Append Human/AI alternating chain from in-session transcript.""" + turns = recent_turns if isinstance(recent_turns, list) else [] + for turn in turns[-limit:]: + if not isinstance(turn, dict): + continue + role = (turn.get("role") or "").strip() + text = (turn.get("text") or "").strip() + if not text: + continue + if role == "player": + messages.append(HumanMessage(content=text)) + elif role == "npc": + messages.append(AIMessage(content=text)) + def format_memory_summary( *, @@ -151,16 +176,7 @@ def build_turn_messages(state: GraphState) -> list[SystemMessage | HumanMessage SystemMessage(content=system_text) ] - recent = state.get("recent_turns") or [] - for turn in recent[-RECENT_DIALOGUE_TURN_LIMIT:]: - role = (turn.get("role") or "").strip() - text = (turn.get("text") or "").strip() - if not text: - continue - if role == "player": - messages.append(HumanMessage(content=text)) - elif role == "npc": - messages.append(AIMessage(content=text)) + append_recent_dialogue_messages(messages, state.get("recent_turns")) player_message = state.get("player_message") or "" human_text = f"Player message: {player_message}\n\nRoom snapshot (JSON):\n{room_json}" diff --git a/workers/agent-worker/src/graph/recall_merge.py b/workers/agent-worker/src/graph/recall_merge.py index 81ce3f7..36123a6 100644 --- a/workers/agent-worker/src/graph/recall_merge.py +++ b/workers/agent-worker/src/graph/recall_merge.py @@ -318,27 +318,31 @@ def augment_retrieved_with_dialogue_turns( retrieved: list[dict[str, Any]] | None, recent_turns: list[dict[str, str]] | None, ) -> list[dict[str, Any]]: - """Merge in-session player turns ahead of DB/embed rows (memory tail may lag).""" + """Merge in-session player/npc turns ahead of DB/embed rows (memory tail may lag).""" if not recent_turns: return list(retrieved or []) seen: set[str] = set() merged: list[dict[str, Any]] = [] - player_texts: list[str] = [] + session_texts: list[str] = [] for turn in reversed(recent_turns): role = (turn.get("role") or "").lower() - if role != "player": - continue raw = str(turn.get("text") or "").strip() if not raw: continue - text = raw if raw.lower().startswith("player:") else f"player: {raw}" + if role == "player": + text = raw if raw.lower().startswith("player:") else f"player: {raw}" + elif role == "npc": + clipped = raw[:120] + text = clipped if clipped.lower().startswith("npc:") else f"npc: {clipped}" + else: + continue if text in seen: continue seen.add(text) - player_texts.append(text) + session_texts.append(text) - for idx, text in enumerate(player_texts): + for idx, text in enumerate(session_texts): merged.append( { "text": text, diff --git a/workers/agent-worker/src/graph/speak_intent.py b/workers/agent-worker/src/graph/speak_intent.py index 7f2086f..a8e3f1c 100644 --- a/workers/agent-worker/src/graph/speak_intent.py +++ b/workers/agent-worker/src/graph/speak_intent.py @@ -6,12 +6,12 @@ from enum import Enum from typing import TYPE_CHECKING, Any -from src.collective.social_turn import infer_social_from_message +from src.collective.social_turn import infer_social_from_message, player_offers_help if TYPE_CHECKING: from src.collective.schemas import SocialTurnOut from src.graph.action_intent import player_requests_physical_action -from src.graph.recall_merge import _RECALL_MARKERS, is_recall_question +from src.graph.recall_merge import is_recall_question _CASUAL_GREETING_ONLY_RE = re.compile( r"^(你好(呀|啊|哦|呐|呢)?|嗨(呀|啊)?|hello|hi|hey|早上好|晚上好|下午好|在吗|在不在)([~!!??。…]*)?$", @@ -51,8 +51,7 @@ def classify_speak_intent( message: str, recent_turns: list | None = None, ) -> SpeakIntent: - """Order: PHYSICAL → RECALL → SOCIAL_EDGE → CASUAL → NARRATIVE (default).""" - del recent_turns # reserved for future context-aware routing + """Order: PHYSICAL → RECALL → continuation → SOCIAL_EDGE → CASUAL → NARRATIVE (default).""" msg = (message or "").strip() if not msg: return SpeakIntent.NARRATIVE @@ -60,6 +59,8 @@ def classify_speak_intent( return SpeakIntent.PHYSICAL if is_recall_question(msg): return SpeakIntent.RECALL + if recent_turns and len(msg) < 20 and infer_social_from_message(msg) is None: + return SpeakIntent.NARRATIVE if infer_social_from_message(msg) is not None: return SpeakIntent.SOCIAL_EDGE if is_casual_greeting_only(msg): @@ -98,10 +99,13 @@ def can_use_casual_fast_lane( intent = classify_speak_intent(player_message, recent_turns) if intent != SpeakIntent.CASUAL: return intent, None + if recent_turns: + return intent, None turn: SocialTurnOut | None = _deterministic_social_turn( player_message, speak_intent=intent.value, npc_id=npc_id, + recent_turns=recent_turns, ) if turn is None: return intent, None @@ -120,10 +124,13 @@ def can_use_social_edge_fast_lane( intent = classify_speak_intent(player_message, recent_turns) if intent != SpeakIntent.SOCIAL_EDGE: return intent, None + if recent_turns or player_offers_help(player_message): + return intent, None turn: SocialTurnOut | None = _deterministic_social_turn( player_message, speak_intent=intent.value, npc_id=npc_id, + recent_turns=recent_turns, ) if turn is None or turn.social.kind == "ignore": return intent, None diff --git a/workers/agent-worker/tests/test_help_reply_by_npc.py b/workers/agent-worker/tests/test_help_reply_by_npc.py index 4766f44..6fad39c 100644 --- a/workers/agent-worker/tests/test_help_reply_by_npc.py +++ b/workers/agent-worker/tests/test_help_reply_by_npc.py @@ -1,5 +1,5 @@ from src.graph.nodes.llm_social_turn import _deterministic_social_turn -from src.graph.speak_intent import can_use_social_edge_fast_lane +from src.graph.speak_intent import SpeakIntent, can_use_social_edge_fast_lane def test_help_fast_lane_reply_varies_by_npc(): @@ -16,3 +16,44 @@ def test_farm_relay_not_help_fast_lane(): intent, turn = can_use_social_edge_fast_lane(msg, npc_id="npc-2") assert turn is None assert intent.value == "physical" + + +def test_help_offer_not_social_edge_fast_lane(): + intent, turn = can_use_social_edge_fast_lane("我可以帮你!", npc_id="npc-4") + assert intent == SpeakIntent.NARRATIVE + assert turn is None + + +def test_uat_hack_thread_offer_no_deterministic_stub(): + history = [ + {"role": "player", "text": "干嘛呢?"}, + {"role": "npc", "text": "呀!我正在嚼着草莓棒棒糖,想着怎么才能把这里的系统黑掉"}, + ] + intent, turn = can_use_social_edge_fast_lane("我可以帮你!", recent_turns=history, npc_id="npc-4") + assert turn is None + stub = _deterministic_social_turn("我可以帮你!", recent_turns=history, npc_id="npc-4") + assert stub is None + + +def test_deterministic_casual_blocked_with_history(): + history = [ + {"role": "player", "text": "干嘛呢?"}, + {"role": "npc", "text": "在忙"}, + ] + msg = "你好,用一句话简短回复一下你的计划安排吧" + stub = _deterministic_social_turn( + msg, + speak_intent=SpeakIntent.CASUAL.value, + recent_turns=history, + npc_id="npc-1", + ) + assert stub is None + # First-turn CASUAL still allowed + first = _deterministic_social_turn( + msg, + speak_intent=SpeakIntent.CASUAL.value, + recent_turns=[], + npc_id="npc-1", + ) + assert first is not None + assert first.reply diff --git a/workers/agent-worker/tests/test_llm_social_memory.py b/workers/agent-worker/tests/test_llm_social_memory.py index 56c8a93..d175317 100644 --- a/workers/agent-worker/tests/test_llm_social_memory.py +++ b/workers/agent-worker/tests/test_llm_social_memory.py @@ -1,3 +1,5 @@ +from langchain_core.messages import AIMessage, HumanMessage + from src.graph.nodes.llm_social_turn import _build_social_messages from src.graph.prompt import format_memory_summary from src.graph.state import GraphState @@ -19,9 +21,34 @@ def test_build_social_messages_includes_memory_summary(): } messages = _build_social_messages(state) system = messages[0].content - human = messages[1].content + human = messages[-1].content assert "Memory summary:" in system assert "门禁密码是 7" in system assert "禁止 meta 套话" in system assert "必须先写 reply" in system or "reply" in system + assert "我会尽力帮你" in system assert "Put \"reply\" as the first key" in human + + +def test_build_social_messages_includes_recent_dialogue(): + state: GraphState = { + "room_id": "default", + "player_message": "我可以帮你!", + "npc_id": "npc-4", + "player_id": "p1", + "recent_turns": [ + {"role": "player", "text": "干嘛呢?"}, + { + "role": "npc", + "text": "呀!我正在嚼着草莓棒棒糖,想着怎么才能把这里的系统黑掉", + }, + ], + "room_snapshot": {"width": 8, "height": 8, "player": {"x": 1, "y": 1}, "npcs": []}, + } + messages = _build_social_messages(state) + assert len(messages) == 4 + assert isinstance(messages[1], HumanMessage) + assert messages[1].content == "干嘛呢?" + assert isinstance(messages[2], AIMessage) + assert "系统黑掉" in messages[2].content + assert "我可以帮你!" in messages[-1].content diff --git a/workers/agent-worker/tests/test_recall_merge.py b/workers/agent-worker/tests/test_recall_merge.py index 232ac13..7cb8f11 100644 --- a/workers/agent-worker/tests/test_recall_merge.py +++ b/workers/agent-worker/tests/test_recall_merge.py @@ -140,6 +140,21 @@ def test_extract_food_preference(): assert extract_food_preference("npc: 你没告诉过我你喜欢吃什么。") is None +def test_augment_dialogue_turns_includes_npc_lines(): + recent_turns = [ + {"role": "player", "text": "干嘛呢?"}, + { + "role": "npc", + "text": "呀!我正在嚼着草莓棒棒糖,想着怎么才能把这里的系统黑掉,超级有趣的对吧!", + }, + ] + merged = augment_retrieved_with_dialogue_turns([], recent_turns) + texts = [row["text"] for row in merged] + assert any(t.startswith("player:") for t in texts) + assert any(t.startswith("npc:") and "系统黑掉" in t for t in texts) + assert all(len(t) <= 130 for t in texts) + + def test_merge_recall_mango_over_watermelon_with_dialogue_turns(): """Mango disclosed in-session but not yet in DB — dialogue turns must win.""" db_augmented = [ diff --git a/workers/agent-worker/tests/test_social_turn.py b/workers/agent-worker/tests/test_social_turn.py index 9fdb676..7e7603d 100644 --- a/workers/agent-worker/tests/test_social_turn.py +++ b/workers/agent-worker/tests/test_social_turn.py @@ -9,6 +9,7 @@ compute_applied_delta, infer_social_from_message, personality_multiplier, + player_offers_help, reconcile_social_perception, refresh_collective_snapshot, ) @@ -131,3 +132,38 @@ def test_infer_help_not_triggered_by_hui_fu_substring(): assert infer_social_from_message("你好,用一句话简短回复") is None assert infer_social_from_message("请帮帮我") is not None assert infer_social_from_message("请帮帮我").kind == "help" + + +def test_player_offers_help_detection(): + assert player_offers_help("我可以帮你!") + assert player_offers_help("我能帮你") + assert player_offers_help("我愿意帮你") + assert player_offers_help("我想帮你") + assert player_offers_help("我来帮") + assert player_offers_help("让我帮你") + assert player_offers_help("我帮你看看") + assert not player_offers_help("请帮帮忙") + assert not player_offers_help("帮帮我") + assert not player_offers_help("你能帮我吗") + + +def test_infer_help_offer_not_request(): + assert infer_social_from_message("我可以帮你!") is None + assert infer_social_from_message("我能帮你") is None + assert infer_social_from_message("我愿意帮你") is None + assert infer_social_from_message("我想帮你") is None + assert infer_social_from_message("我来帮") is None + assert infer_social_from_message("让我帮你") is None + + +def test_infer_help_request_still_detected(): + for msg in ("请帮帮忙", "帮帮我", "你能帮我吗", "能请你帮个忙吗"): + inferred = infer_social_from_message(msg) + assert inferred is not None + assert inferred.kind == "help" + assert inferred.summary == "玩家请求帮助" + + +def test_infer_help_not_triggered_by_narrative_bang_compounds(): + assert infer_social_from_message("帮别人做事") is None + assert infer_social_from_message("别在这里帮腔") is None diff --git a/workers/agent-worker/tests/test_speak_intent.py b/workers/agent-worker/tests/test_speak_intent.py index af30e48..4317e66 100644 --- a/workers/agent-worker/tests/test_speak_intent.py +++ b/workers/agent-worker/tests/test_speak_intent.py @@ -38,6 +38,12 @@ def test_social_edge_intent(): assert classify_speak_intent("能请你帮个忙吗") == SpeakIntent.SOCIAL_EDGE +def test_help_offer_not_social_edge(): + assert classify_speak_intent("我可以帮你!") == SpeakIntent.NARRATIVE + assert classify_speak_intent("我来帮") == SpeakIntent.NARRATIVE + assert classify_speak_intent("让我帮你") == SpeakIntent.NARRATIVE + + def test_casual_intent(): assert classify_speak_intent("你好") == SpeakIntent.CASUAL assert classify_speak_intent("Hi") == SpeakIntent.CASUAL @@ -73,6 +79,24 @@ def test_can_use_casual_fast_lane_b1(): assert turn.reply +def test_can_use_casual_fast_lane_b1_empty_history(): + intent, turn = can_use_casual_fast_lane("你好", recent_turns=[]) + assert intent == SpeakIntent.CASUAL + assert turn is not None + + +def test_can_use_casual_fast_lane_blocked_with_history(): + history = [{"role": "player", "text": "干嘛呢?"}, {"role": "npc", "text": "在忙"}] + intent, turn = can_use_casual_fast_lane("你好", recent_turns=history) + assert intent == SpeakIntent.NARRATIVE + assert turn is None + + +def test_continuation_short_with_history(): + history = [{"role": "player", "text": "干嘛呢?"}, {"role": "npc", "text": "在忙"}] + assert classify_speak_intent("好的", recent_turns=history) == SpeakIntent.NARRATIVE + + def test_can_use_casual_fast_lane_recall_blocked(): intent, turn = can_use_casual_fast_lane("你好,还记得密码吗") assert intent == SpeakIntent.RECALL @@ -113,6 +137,13 @@ def test_can_use_social_edge_fast_lane_help(): assert turn.social.kind == "help" +def test_can_use_social_edge_fast_lane_help_blocked_with_history(): + history = [{"role": "player", "text": "干嘛呢?"}, {"role": "npc", "text": "在忙"}] + intent, turn = can_use_social_edge_fast_lane("请帮帮忙", recent_turns=history) + assert intent == SpeakIntent.SOCIAL_EDGE + assert turn is None + + def test_can_use_social_edge_fast_lane_narrative_blocked(): intent, turn = can_use_social_edge_fast_lane("故宫在哪里,给我讲讲历史") assert intent == SpeakIntent.NARRATIVE