diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 22bbd43c..70ea2bca 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1489,3 +1489,17 @@ - 防复发:普通聊天的可见措辞不得由技术 packet 或事件台账驱动;内部收敛状态只能记录和计算,不能成为 Agent 每轮必须复述的脚本。 - 相关记录:BUG-074、BUG-075、BUG-078、BUG-079 - 修复版本:本次修复提交 + +## BUG-081 | 开放叙事只共情不继续引导 + +- 状态:resolved +- 首次发现:2026-07-26 +- 最近更新:2026-07-26 +- 影响面:生时校正开放叙事的中间轮回复 +- 用户现象:用户讲完一段包含明确行动与转折的经历后,Agent 只分析感受和性格,没有自然询问后续经历,对话停在原地。 +- 根因:为消除问卷式强制追问,中间轮 prompt 将提问设为完全可选,且校验器只检查已有问题是否可见,没有要求事件轮承担继续引导叙事的职责。 +- 修复:普通中间轮 prompt 要求在本轮新事件后以一个贴着上下文的开放问题收尾;优先询问事件之后的下一步,继续禁止机械补年月、结果或转折。若模型仍只共情不引导,服务端直接补一句开放问题,不增加第二次模型调用;无模型可用时的安全兜底也保留自然后续问题。 +- 验证:回归覆盖“研究院实习后因价值观冲突辞职”场景,确认只共情不引导的回复会保留原文并补上后续问题,且不触发模型重试。 +- 防复发:开放叙事不等于被动倾听;事件轮默认负责自然推进,但不得恢复固定问卷模板。 +- 相关记录:BUG-075、BUG-080 +- 修复版本:本次修复提交 diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index e21e0c4e..d085c00e 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -582,7 +582,7 @@ function promptFor( rules: [ "优先回应用户话里的真实内容、感受、选择或转折,不要复述成档案摘要。", "除非用户主动询问校时进度或技术结果,不要说已记录、先记为、对校时有价值、参与候选时间核对、不会因单一事件确认分钟,也不要主动谈候选范围、分盘、评分、收敛或内部处理。", - "不必每轮提问。需要提问时只问自然推进对话真正需要的问题,不要机械补年月、结果或转折。", + "用户刚提供实际经历时,回应之后必须用一个贴着上下文的开放问题收尾,自然引导他继续往后讲;优先问这件事之后发生了什么、接下来去了哪里或做了什么,不要机械补年月、结果或转折。", "只返回 narrative 和 evidenceRequest。没有在 narrative 中逐字提出一个用户可见的明确问题时,evidenceRequest 必须为 null;若提出问题,evidenceRequest.prompt 必须与 narrative 中的问题文字完全一致。followUp 只表达 kind、answerMode 和 proposedDate,不要生成 evidenceId,目标事件由服务器绑定。", ], retryIssues: boundedReceiptIssues(retryIssues), @@ -639,7 +639,7 @@ function promptFor( function fallbackNarrative(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase): string { const candidate = packet.candidate; if (phase === "intermediate") { - return "我听到了。你可以顺着这段经历继续说,也可以自然讲下一件想到的事。"; + return "我听到了。你愿意接着说说这件事之后发生了什么吗?"; } const phaseLine = phase === "final" && candidate.status === "ready_for_confirmation" ? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。" @@ -812,11 +812,21 @@ export async function generateRectificationNarrative(input: { ), { signal, attempt }); const modelId = modelIdSchema.parse(generated.modelId ?? defaultModelId); const parsedOutput = parseModelOutput(generated.text, input.packet, input.context ?? {}); - const output = input.phase === "intermediate" + const withoutHiddenFollowUp = input.phase === "intermediate" && parsedOutput.evidenceRequest && !/[??]/u.test(parsedOutput.narrative) ? { ...parsedOutput, evidenceRequest: null } : parsedOutput; + const output = input.phase === "intermediate" + && input.context?.latestEvidence?.length + && !technicalDiscussionRequestPattern.test(input.context.latestUserText ?? "") + && !/[??]/u.test(withoutHiddenFollowUp.narrative) + ? { + ...withoutHiddenFollowUp, + narrative: `${withoutHiddenFollowUp.narrative.trim()}\n\n这件事之后,紧接着发生了什么?`, + evidenceRequest: null, + } + : withoutHiddenFollowUp; const validation = validateNarrativeAgainstPacket( output, input.packet, diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index 2db931e7..126234db 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -455,17 +455,43 @@ test("passes the user's latest words to an ordinary intermediate reply", async ( }], }, generator: generator([{ - narrative: "第一次长期离开家去工作,适应过程应该不轻松。你可以接着讲。", + narrative: "第一次长期离开家去工作,适应过程应该不轻松。后来最先发生了什么?", evidenceRequest: null, }], prompts), }); assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/); assert.match(prompts[0] ?? "", /不要把自己写成记录员/); + assert.match(prompts[0] ?? "", /必须用一个贴着上下文的开放问题收尾/); assert.doesNotMatch(prompts[0] ?? "", /latestEvidence/); assert.doesNotMatch(prompts[0] ?? "", /候选范围.*05:16/); }); +test("adds a lightweight guide when an event reply only empathizes", async () => { + const prompts: string[] = []; + const result = await generateRectificationNarrative({ + phase: "intermediate", + packet: syntheticTechnicalPacket(), + context: { + latestUserText: "实习到10月份,发现有人抢项目骗经费,我就辞职了", + latestEvidence: [{ + dateLabel: "2020-10", + summary: "因价值观冲突辞职", + domain: "career", + }], + }, + generator: generator([{ + narrative: "这段经历确实很冲击,也能理解你为什么不愿意继续留下。", + evidenceRequest: null, + }], prompts), + }); + + assert.equal(result.attempts, 1); + assert.equal(result.fallbackUsed, false); + assert.match(result.narrative, /这件事之后,紧接着发生了什么?$/); + assert.equal(prompts.length, 1); +}); + test("keeps internal event domains and suggested-domain routing out of the narrator context", async () => { const prompts: string[] = []; await generateRectificationNarrative({ diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 10c98a1c..1cc3814d 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -1693,7 +1693,7 @@ test("a rejected intermediate narrative falls back without discarding the event" const stored = value.cases.get(startActionId)?.row; assert.equal(turn.turnVersion, 1); - assert.match(turn.narrative, /顺着这段经历继续说|自然讲下一件/); + assert.match(turn.narrative, /这件事之后.*发生了什么/); assert.equal(stored?.privateCandidate.resultId, null); assert.equal(stored?.eventEvidence.length, 1); assert.equal(stored?.validationReceipts.length, 2); @@ -1989,7 +1989,7 @@ test("vague, future, and unmatched answers stay conversational and never score", } }); -test("a free Agent reply clears the previous structured question instead of inheriting it", async () => { +test("a free Agent reply clears the previous structured question and adds an open guide", async () => { const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true, @@ -2010,7 +2010,7 @@ test("a free Agent reply clears the previous structured question instead of inhe assert.equal(second.evidenceRequest, null); assert.match(second.narrative, /顺着这段经历继续说|自然讲下一件/); - assert.doesNotMatch(second.narrative, /[??]/); + assert.match(second.narrative, /这件事之后,紧接着发生了什么?/); }); test("a non-scoring packet failure responds to the current turn instead of replaying the prior agent message", async () => { @@ -2140,7 +2140,7 @@ test("a failed regenerate saves a fallback turn without changing evidence, candi const storedAfter = value.cases.get(startActionId)?.row; assert.equal(regenerated.turnVersion, answered.turnVersion + 1); - assert.match(regenerated.narrative, /顺着这段经历继续说|自然讲下一件/); + assert.match(regenerated.narrative, /这件事之后.*发生了什么/); assert.deepEqual(storedAfter?.eventEvidence, evidenceBefore); assert.deepEqual(storedAfter?.privateCandidate, candidateBefore); assert.equal(value.counts().reserveCount, countsBefore.reserveCount);