diff --git a/CHANGELOG.md b/CHANGELOG.md index eab463c0..7f250e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-08 — 生时校正每一轮只说一段旁白,题目只出现在问题块 + +生时校正记下你刚说的事后,助手气泡里只留一段说明。下一问只出现在下面的问题块,不会在正文里再问一遍。Skill 版本仍是 10.0.15。 + ## 2026-09-08 — 七个领域问完后是职业题或区间卡,不再补问「再说一件」 生时校正把学业、感情、事业、家人、财务、迁居、健康问过或跳过后,下一问是「你平时主要做什么工作?」,或者直接出区间交付卡。不会再出现「也可以再说一件你记得大概时间的事」。Skill 版本仍是 10.0.15。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6451434d..7d09238b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7517,7 +7517,7 @@ - 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿后的 Case snapshot、发送下一条、以及挂载后的 snapshot 回调把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。 - 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`)。run 2305 前端 2509/2509 后 ESLint 在 `currentQuestionRef.current = currentQuestion`(render)和 snapshot `setMessages` effect 上报 `react-hooks/refs` / `react-hooks/set-state-in-effect`;ref 改到 `useLayoutEffect`,题干写入改到 snapshot/send 回调。 - 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。 -- 相关记录:BUG-471、BUG-485、BUG-487 +- 相关记录:BUG-471、BUG-485、BUG-487、BUG-585 - 复发自:BUG-487(服务端拼接后槽位兜底仍可见) - 修复版本:待发布 @@ -8241,7 +8241,7 @@ - 修复:`rectification-set-focus` 的 call/result 不再收回已播出正文。读诊断、记证据仍收回。 - 验证:`rectification-step-answer`:打招呼 + set-focus 为 `none`,其后短句仍 `live`;read-diagnostics 仍 `retract`。 - 防复发:set-focus 不得把同轮已播出的正文 replace 成空。不得把这个例外扩到 record/compare/diagnostics。 -- 相关记录:无 +- 相关记录:BUG-584 - 复发自:无 - 修复版本:待发布 @@ -9046,6 +9046,38 @@ - 复发自:无(区间卡 `ab57d03f` 引入;任务书 4.2 验收未过) - 修复版本:待发布(`codex/rectification-range-delivery-fix-20260907`) +## BUG-584 | 同一轮采集旁白说两遍 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-08 +- 影响面:`applyStepAnswerChunk`、`KEEP_LIVE_SPOKEN_TOOLS`、`publishSpokenStep`、`POST /api/rectification/agent` +- 用户现象:每一轮采集助手气泡里有两段近似旁白。第一段已经承接用户刚说的事,第二段换个说法再讲一遍。 +- 触发条件:模型在 `rectification-set-focus` 前写出正文,拿到工具结果后再写一段。 +- 根因:BUG-533 让 set-focus 不撤回已直播正文。set-focus 之后下一个 step 的正文仍按 `shouldPublishStepText` 发布;`publishSpokenStep` 把两段无分隔拼进 `assistant_message`。 +- 修复:set-focus 结果后置 `stemAttached`。本轮已经发布过正文则丢掉后一段;尚未发布则仍放行开场问候。set-focus 调用时把未直播的中文半句 `publish` 出去,英文规划稿仍 discard。 +- 验证:`frontend/tests/rectification-step-answer.test.ts`;`frontend/tests/rectification-v9-agent.test.ts` 文本→set-focus→文本只保留第一段。 +- 防复发:set-focus 之后不得再拼接第二段正文。开场仍允许「先问候、后 set-focus」或「只在 set-focus 后说一句」。不得把这个例外扩到 record/compare/diagnostics。 +- 相关记录:BUG-533 +- 复发自:无(533 的放行例外把第二段也留下了) +- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`) + +## BUG-585 | 题干在正文和问题块各出现一次 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-08 +- 影响面:`stripQuestionSentences`、`attachQuestionsToTurns`、`runV9AgentTurn`、`RECTIFICATION_USER_COPY.collectHandoff` +- 用户现象:旁白末尾已经用自然语言问了下一题,问题块再显示同一句服务端题干。 +- 触发条件:本轮 set-focus 成功,模型把问句写进正文;`detachCollectSpokenAssistantText` 只剥逐字后缀。 +- 根因:不让模型提问的约束只在提示词。BUG-488 防复发写「不得同时让模型提问又拼接同一句服务端题干」,`d9404976` 把题干改成消息内问题块后没有代码守卫。`collectSpokenEmitted` 是从未使用的常量。 +- 修复:有本轮 focus 时,`answer.composed` 前按句删掉与题干相同、题干前 12 字开头、或以问号结尾的句子;剪空则用 `collectHandoff`。变化时 `answer.delta replace:true`。历史回看同样走 `stripQuestionSentences`。删除 `collectSpokenEmitted`。 +- 验证:`frontend/tests/rectification-collect-prompt.test.ts`;`frontend/tests/rectification-v9-agent.test.ts`;`frontend/tests/agent-voice-copy-contract.test.ts`。 +- 防复发:有 focus 的已结算助手正文不得再出现以问号结尾的句子。不得靠提示词单独保证模型不问。不得恢复把题干拼进 `assistant_message`。 +- 相关记录:BUG-488、BUG-461、BUG-471、`d9404976` +- 复发自:BUG-488(防线只在提示词) +- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`) + ## BUG-586 | 七领域问完落到补问兜底句,职业题被覆盖、区间卡出不来 - 状态:mitigated diff --git a/docs/tasks/PROGRESS-rectification-duplicate-narration-20260907.md b/docs/tasks/PROGRESS-rectification-duplicate-narration-20260907.md new file mode 100644 index 00000000..7465dceb --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-duplicate-narration-20260907.md @@ -0,0 +1,41 @@ +# PROGRESS · 同一轮旁白说两遍、题干出现两次(2026-09-07) + +工作树:`.worktrees/rectification-duplicate-narration-20260907` +分支:`codex/rectification-duplicate-narration-20260907` +任务书:`docs/tasks/TASK-rectification-duplicate-narration-20260907.md` +基线:`origin/staging` @ `6c7a8b02`(任务书写 `f3e72702` / 代码头 `ab57d03f`;其后 `a00b069d` 已合入,tsc 不再报 `rectification-agentic-chat.tsx` L1655) + +本单状态:**待验收**。未 commit、未 push。 + +未改:提示词、Skill 版本(仍 10.0.15)、采用门、确认门、`MIN_SEPARATION_LEAD`、`_relative_support`、`minute_step=1`、`page.tsx`。 + +并行:`codex/rectification-other-collect-fallback-20260908`(BUG-586)也改 `user-copy.ts` 与 `agent-voice-copy-contract.test.ts`。本单只加 `collectHandoff`;谁后合谁 rebase。 + +## 做了什么 + +- **4.2 / BUG-585** `stripQuestionSentences`:按句删掉与题干相同、题干前 12 字开头、或以问号结尾的句子;问号后的非叙事尾巴一并丢掉。`composeCollectSpokenAssistantText` 改调它。agent-run 在 `answer.composed` 前复用 discriminator 那次 `loadV9CaseDossier`:本轮 focus 有 spokenPrompt 则剪,剪空用 `collectHandoff`,变化时 `replace:true`。`attachQuestionsToTurns` 同样剪。删除 `collectSpokenEmitted`。 +- **4.1 / BUG-584** `StepAnswerState` 增加 `stemAttached`、`publishedAny`,不随 `resetBuffers` 清。set-focus 之后若本轮已发布正文,后续 `text-delta` discard。 +- **决策 3** set-focus `tool-call` 把未直播的中文半句 `publish`;无 CJK 的规划稿 discard。未后置为 P3。 + +## 三栏(被触碰断言) + +| 用例 | 原值 | 新值 | 理由 | +| --- | --- | --- | --- | +| set-focus 后第二段正文 | `live` 发布 | `discard` | 决策 2,丢掉工具结果后的复述 | +| set-focus 前无正文、后有一段 | (原测试未覆盖) | 后段 `live` | BUG-533 开场仍通 | +| 无句末标点的中文半句 + set-focus | 静默丢掉 | `publish` | 决策 3 | +| 英文规划稿 + set-focus | 不直播 | `discard` | 维持原 discard | +| `collectSpokenEmitted` | 恒 `false` | 字段删除 | 决策 4 | +| 历史回看剥题干 | 只剥 `\n\n` 后缀 | `stripQuestionSentences` | 决策 1 | + +## 测试 + +- `frontend` `./node_modules/.bin/tsc --noEmit`:exit 0 +- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**988 passed / 0 failed** +- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked +- 浏览器真人走查:无登录态,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §6 + +## 偏离 + +1. 任务书点名的 `rectification-v9-agent-run*.test.ts` 不存在;agent-run 序列写在既有 `rectification-v9-agent.test.ts`。 +2. 问号后的「有年份就行。」按非叙事续句丢掉,否则验收 (a) 会留下这半句。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 2c7de373..489bbd48 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -72,7 +72,7 @@ | `TASK-rectification-stop-rescore-fix-20260907.md` | `PROGRESS-rectification-stop-rescore-fix-20260907.md` | **P0**:BUG-579 的停止路径用「裸重算」(`rescoreMinuteAfterWindowChange` 不重建 `inference_state`、不重放已答探针、不写 transition),停止后范围变宽、卡片变成引擎裸支持度、采用 RPC 报 `candidate_state_inconsistent`;无框区分题经 `spokenCollectFallbackFollowup` 变成 domain=other 的采集题,再次冒出开场句 `GENERIC_COLLECT_QUESTION`;holdout 采集排在带年月采集之前 | 已验收通过(逻辑);但同提交含区间卡 tsc 错,整体不可部署 | `ab57d03f`(BUG-581~582) | | `TASK-rectification-range-delivery-card-20260907.md` | `PROGRESS-rectification-range-delivery-card-20260907.md` | 结尾重设计:四张分钟卡 + 相对支持度换成一张区间交付卡(范围、代表分钟、稳定/敏感主题、边界句)+ 只列未问 D9/D10/月宿分歧的 2~3 列对比面板(文案只取类型表),动作只有「按这个范围用 / 再补一件经历」;删交付旁白里三行预测窗口;Skill §9 口径改、bump 10.0.15 | **未通过**:tsc 1 错(判空顺序);P2 预测窗口句兑现不了;P3 再补经历无提示 | `ab57d03f`;修复单 `TASK-rectification-range-delivery-fix-20260907.md` | | `TASK-rectification-range-delivery-fix-20260907.md` | `PROGRESS-rectification-range-delivery-fix-20260907.md` | 区间卡修复单:`candidateResult` 判空提到最前(tsc 阻断);预测窗口接到采用后首轮或删句;「再补一件经历」隐藏卡后给采集提示;进度记录须贴 tsc 原文 | 待验收 | `codex/rectification-range-delivery-fix-20260907`(BUG-583) | -| `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 待执行 | `codex/rectification-duplicate-narration-20260907`(BUG-584~585) | +| `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 待验收 | `codex/rectification-duplicate-narration-20260907`(BUG-584~585) | | `TASK-rectification-other-collect-fallback-20260908.md` | `PROGRESS-rectification-other-collect-fallback-20260908.md` | 七领域问完落到「也可以再说一件」、职业题从未出现、不自动出卡:`USER_COLLECT_QUESTION.other` 仍有三条活路(职业焦点 `target_domain` 压成 other 后被 active-focus 承接 followup 重建;set-focus 两次无效兜底查表;无领域采集归 other),`collect:other:*` 焦点挂着让 `persistNextInterviewIfIdle` 的出卡路径永远不跑 | 待验收(2.2 触发链仍 investigating) | `codex/rectification-other-collect-fallback-20260908`(BUG-586) | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 1c5ede74..2c78ef24 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -102,3 +102,15 @@ - 不得出现补问兜底句「也可以再说一件你记得大概时间的事」 - 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮 +## 7. 每轮助手气泡只有一段旁白,题干只在问题块出现一次 + +资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。 + +开场后说一件带年月的虚构经历,等到出现下一问。 + +期望: + +- 助手气泡正文只有一段旁白,没有第二段换个说法再讲一遍 +- 题干只出现在同一条消息的问题块里一次 +- 正文不以问号结尾,也不把下一题再问一遍 + diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index daa4099d..05ebbddb 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -74,6 +74,7 @@ export const RECTIFICATION_USER_COPY = { questionUpdated: "这一问刚换成新的,刷新后再答就行。", adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。", hostNarrationFallback: "我按现有材料继续往下收。", + collectHandoff: "接下来我们继续。", continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。", collectDeclinedAck: "记下了,这方面先跳过。", uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。", @@ -315,6 +316,7 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.questionUpdated, RECTIFICATION_USER_COPY.adoptCue, RECTIFICATION_USER_COPY.hostNarrationFallback, + RECTIFICATION_USER_COPY.collectHandoff, RECTIFICATION_USER_COPY.continueCollectFallback, RECTIFICATION_USER_COPY.collectDeclinedAck, RECTIFICATION_USER_COPY.uncertaintyStop, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index fedfadc8..3ce2437c 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -34,7 +34,9 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat import { decideFromDossier } from "./decision-from-dossier"; import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice"; import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; -import { withCompareFailedRetryNotice } from "../user-copy"; +import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice } from "../user-copy"; +import { stripQuestionSentences } from "./collect-prompt"; +import { focusSpokenPrompt } from "./turn-question"; import { resolveExactSkillPackage, type ResolvedSkillPackageIdentity, @@ -110,7 +112,6 @@ export type V9AgentRunResult = Readonly<{ toolsUsed: readonly string[]; errorCode: string | null; previousFocusId: string | null; - collectSpokenEmitted: boolean; }>; type AttemptStatus = "completed" | "failed" | "retryable"; @@ -281,7 +282,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { @@ -885,25 +881,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise => { - try { - const latest = await loadV9CaseDossier(accounting, userId, caseId); - const decision = decideFromDossier(latest); - if (decision.nextAction !== "ask_candidate_discriminator") return { ok: true }; - const focus = latest.conversationSummary.activeFocus; - if ( - focus - && isPersistedFocusId(focus.id) - && parseAgentChoiceCopy(focus.expectedAnswerSchema) - ) { - return { ok: true }; - } - return { ok: false, errorCode: "state_invariant_failed" }; - } catch { - return { ok: false, errorCode: "state_invariant_failed" }; - } - }; - const completeAttempt = async (): Promise => { let inputTokens = 0; let outputTokens = 0; @@ -967,8 +944,32 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { - const text = sentence.trim(); - if (!text) return false; - if (text === stem) return false; - return !(prefix && text.startsWith(prefix)); - }) - .join("") - .trim(); + const stripped = stripQuestionSentences(spoken, stem); if (!stripped) return stem; const suffix = `\n\n${stem}`; if (stripped.includes(stem)) return stripped; diff --git a/frontend/src/lib/rectification-agentic/v9/step-answer.ts b/frontend/src/lib/rectification-agentic/v9/step-answer.ts index 45dc9309..1c3f558b 100644 --- a/frontend/src/lib/rectification-agentic/v9/step-answer.ts +++ b/frontend/src/lib/rectification-agentic/v9/step-answer.ts @@ -7,7 +7,8 @@ * or live-unlock after compare/offer/result tools — tokens may stream as * `live`. A later public tool-call retracts that speculative stream so * "Let me set" never stays in `answer.delta`. Exception: `rectification-set-focus` - * only attaches the question stem; live greeting/handoff in the same step stays. + * keeps already-live greeting/handoff and does not retract it; after the stem + * is attached, a second spoken paragraph in the same turn is discarded. */ export type StepAnswerChunk = Readonly<{ @@ -27,6 +28,8 @@ export type StepAnswerState = { live: boolean; publishedUpTo: number; unlocked: boolean; + stemAttached: boolean; + publishedAny: boolean; }; export type StepAnswerEffect = @@ -67,6 +70,8 @@ export function createStepAnswerState(): StepAnswerState { live: false, publishedUpTo: 0, unlocked: false, + stemAttached: false, + publishedAny: false, }; } @@ -116,6 +121,7 @@ function liveRemainder(state: StepAnswerState): StepAnswerEffect { const next = state.text.slice(state.publishedUpTo); if (!next) return { kind: "none" }; state.live = true; + state.publishedAny = true; state.publishedUpTo = state.text.length; return { kind: "live", text: next }; } @@ -127,9 +133,24 @@ function retractLive(state: StepAnswerState): StepAnswerEffect { state.live = false; state.publishedUpTo = 0; state.unlocked = false; + state.publishedAny = false; return wasLive ? { kind: "retract" } : { kind: "none" }; } +function publishUnpublishedSetFocus(state: StepAnswerState): StepAnswerEffect { + const unpublished = state.text.slice(state.publishedUpTo); + if (hasCjk(unpublished)) { + state.publishedAny = true; + state.publishedUpTo = state.text.length; + const pieces = [unpublished]; + resetBuffers(state); + return { kind: "publish", pieces }; + } + const leftover = unpublished.trim().length > 0; + resetBuffers(state); + return leftover ? { kind: "discard" } : { kind: "none" }; +} + /** * Advance the per-step buffer. Terminal Chinese may stream as `live` once * the step is unlocked or a spoken sentence has closed. Tool-result ends @@ -149,6 +170,7 @@ export function applyStepAnswerChunk( resetBuffers(state); return { kind: "none" }; case "text-delta": { + if (state.stemAttached && state.publishedAny) return { kind: "discard" }; const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : ""; if (text) { state.text += text; @@ -160,7 +182,9 @@ export function applyStepAnswerChunk( case "tool-call": if (isPublicToolCall(chunk, isPublicTool)) { state.calledTool = true; - if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) return { kind: "none" }; + if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) { + return publishUnpublishedSetFocus(state); + } const retracted = retractLive(state); return retracted.kind === "retract" ? retracted : { kind: "none" }; } @@ -169,6 +193,7 @@ export function applyStepAnswerChunk( case "tool-error": { if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true; if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) { + state.stemAttached = true; resetBuffers(state); return { kind: "none" }; } @@ -188,11 +213,13 @@ export function applyStepAnswerChunk( const reason = stepFinishReason(chunk); if (state.live) { const rest = state.text.slice(state.publishedUpTo); + if (rest) state.publishedAny = true; resetBuffers(state); return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" }; } const publish = shouldPublishStepText(state, reason); const pieces = publish ? [...state.pieces] : []; + if (publish) state.publishedAny = true; resetBuffers(state); return publish ? { kind: "publish", pieces } : { kind: "discard" }; } @@ -207,6 +234,7 @@ export function flushStepAnswerOnStreamFinish( ): StepAnswerEffect { if (state.live) { const rest = state.text.slice(state.publishedUpTo); + if (rest) state.publishedAny = true; resetBuffers(state); return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" }; } diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index 7fe10129..883b253a 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -1,4 +1,4 @@ -import { detachCollectSpokenAssistantText } from "./collect-prompt"; +import { stripQuestionSentences } from "./collect-prompt"; import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card"; import type { ConversationFocus } from "./tool-service"; @@ -114,7 +114,7 @@ export function attachQuestionsToTurns item.includes(banned)), false); }); +test("settled assistant body with a focus has no question-mark sentences", () => { + const stem = "你大概是哪一年搬的家?"; + const linked = attachQuestionsToTurns([{ + id: TURN_ID, + role: "assistant", + text: "范围收到 05:00–05:10。你大概哪一年搬过家?", + }], [{ + id: FOCUS_ID, + caseId: CASE_ID, + questionId: "collect:relocation:collect_method_evidence", + intent: "collect_method_evidence", + targetEvidenceId: null, + targetDomain: "relocation", + targetKind: null, + expectedAnswerSchema: { prompt: stem, collect: true }, + status: "active", + askedAt: "2026-09-07T00:00:00.000Z", + resolvedAt: null, + askedTurnId: TURN_ID, + }]); + assert.doesNotMatch(linked[0]?.text ?? "", /[??]/); + assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff)); + assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/); + const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); + assert.match(agentRun, /stripQuestionSentences/); +}); + test("delivery narration defers career windows and the range card replaces minute cards", () => { const choice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); diff --git a/frontend/tests/rectification-collect-prompt.test.ts b/frontend/tests/rectification-collect-prompt.test.ts index 26192995..e0f3463e 100644 --- a/frontend/tests/rectification-collect-prompt.test.ts +++ b/frontend/tests/rectification-collect-prompt.test.ts @@ -2,9 +2,9 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts"; +import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences } from "../src/lib/rectification-agentic/v9/collect-prompt.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; -import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; +import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts"; test("composeCollectSpokenAssistantText joins by exact prompt identity", () => { @@ -88,7 +88,36 @@ test("runtime no longer composes the stem into assistant_message", () => { const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8"); assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/); - assert.match(attach, /detachCollectSpokenAssistantText/); + assert.match(attach, /stripQuestionSentences/); assert.match(attach, /if \(!focus\.askedTurnId\) continue/); }); +test("stripQuestionSentences drops a rewritten trailing question and its tag", () => { + const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。"; + const stem = "你大概是哪一年搬的家?"; + assert.equal(stripQuestionSentences(body, stem), "范围已经收到,收在 05:00–05:10。"); +}); + +test("stripQuestionSentences drops a mid-body stem and keeps the surrounding sentences", () => { + const stem = "你大概是哪一年搬的家?"; + const body = "记下了。你大概是哪一年搬的家?范围还在 05:00–05:10。"; + assert.equal(stripQuestionSentences(body, stem), "记下了。范围还在 05:00–05:10。"); +}); + +test("stripQuestionSentences returns empty when the body is only a question", () => { + const stem = "你大概是哪一年搬的家?"; + assert.equal(stripQuestionSentences("你大概哪一年搬过家?", stem), ""); + assert.equal(RECTIFICATION_USER_COPY.collectHandoff.includes("请回答下面的问题"), false); +}); + +test("attachQuestionsToTurns leaves body unchanged when the turn has no focus", () => { + const body = "范围收到 05:00–05:10。你大概哪一年搬过家?"; + const turns = attachQuestionsToTurns([{ + id: TURN_ID, + role: "assistant" as const, + text: body, + }], []); + assert.equal(turns[0]?.text, body); + assert.equal(turns[0]?.question, null); +}); + diff --git a/frontend/tests/rectification-step-answer.test.ts b/frontend/tests/rectification-step-answer.test.ts index 0fcc2648..6bc0b401 100644 --- a/frontend/tests/rectification-step-answer.test.ts +++ b/frontend/tests/rectification-step-answer.test.ts @@ -132,13 +132,80 @@ test("keeps a live opening greeting when the next public tool is set-focus", () ).kind, "none", ); - assert.deepEqual( + // 原值: set-focus 后第二段 live 发布 + // 新值: discard + // 原因: BUG-584 决策 2,保留 set-focus 前的问候,丢掉工具结果后的复述 + assert.equal( applyStepAnswerChunk( state, chunk("text-delta", { text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }), isPublicTool, + ).kind, + "discard", + ); +}); + +test("set-focus with no prior spoken text still publishes the later greeting", () => { + const state = createStepAnswerState(); + assert.equal( + applyStepAnswerChunk( + state, + chunk("tool-call", { toolName: "rectification-set-focus" }), + isPublicTool, + ).kind, + "none", + ); + assert.equal( + applyStepAnswerChunk( + state, + chunk("tool-result", { toolName: "rectification-set-focus" }), + isPublicTool, + ).kind, + "none", + ); + assert.deepEqual( + applyStepAnswerChunk( + state, + chunk("text-delta", { text: "你好,我们从你最容易想起的经历开始就行。" }), + isPublicTool, ), - { kind: "live", text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }, + { kind: "live", text: "你好,我们从你最容易想起的经历开始就行。" }, + ); +}); + +test("set-focus publishes an unfinished CJK remainder instead of dropping it", () => { + const state = createStepAnswerState(); + assert.equal( + applyStepAnswerChunk( + state, + chunk("text-delta", { text: "范围已经收到 05:00 到 05:10" }), + isPublicTool, + ).kind, + "none", + ); + assert.deepEqual( + applyStepAnswerChunk( + state, + chunk("tool-call", { toolName: "rectification-set-focus" }), + isPublicTool, + ), + { kind: "publish", pieces: ["范围已经收到 05:00 到 05:10"] }, + ); +}); + +test("English planning before set-focus is still discarded", () => { + const state = createStepAnswerState(); + assert.equal( + applyStepAnswerChunk(state, chunk("text-delta", { text: "Let me set the next collect focus" }), isPublicTool).kind, + "none", + ); + assert.equal( + applyStepAnswerChunk( + state, + chunk("tool-call", { toolName: "rectification-set-focus" }), + isPublicTool, + ).kind, + "discard", ); }); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 242e347b..9c59d811 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -23,7 +23,7 @@ import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts"; -import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; +import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import { CASE_ID, CANDIDATE_RANGE, @@ -393,7 +393,6 @@ test("collect_spoken stem is persisted on the same turn via asked_turn_id, not a const result = await runV9AgentTurn(options); assert.equal(result.ok, true); assert.equal(result.answerText, greeting); - assert.equal(result.collectSpokenEmitted, false); const replaced = emitted.find((event) => ( event.type === "answer.delta" && (event as { replace?: unknown }).replace === true )) as { text?: string } | undefined; @@ -677,3 +676,112 @@ test("reply regeneration is a separate Jyotisha agent with only read-case access assert.match(agentSource, /这不是新一轮校正/); assert.match(agentSource, /只能使用 rectification-read-case/); }); + +test("set-focus after spoken text does not append a second paragraph", async () => { + const first = "2016 年 9 月去北京工作,记下了。范围收到 05:00 到 05:10。"; + const second = "2016 年那件事对校正很有帮助,我们再对一下搬家。"; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0 }), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + }); + const { options, emitted } = runOptions({ + accounting: accounting.client, + buildAgent: async () => fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("text-delta", { text: first }), + chunk("tool-call", { toolName: "rectification-set-focus" }), + chunk("tool-result", { toolName: "rectification-set-focus" }), + chunk("text-delta", { text: second }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.equal(result.answerText, first); + assert.equal(result.answerText.includes(second), false); + const deltas = emitted.filter((event) => event.type === "answer.delta") as Array<{ + text?: string; + replace?: boolean; + }>; + assert.equal(deltas.some((item) => (item.text ?? "").includes(second)), false); + assert.equal(deltas.some((item) => item.replace === true && item.text === ""), false); +}); + +test("agent-run strips question sentences when this turn owns the focus", async () => { + const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。"; + const stem = "你大概是哪一年搬的家?"; + const collectDossier = dossierFixture({ + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + askedTurnId: TURN_ID, + intent: "collect_method_evidence", + expectedAnswerSchema: { prompt: stem, collect: true }, + }), + }), + }); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => collectDossier, + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + }); + const { options, emitted } = runOptions({ + accounting: accounting.client, + buildAgent: async () => fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("text-delta", { text: body }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.equal(result.answerText, "范围已经收到,收在 05:00–05:10。"); + const replaced = emitted.find((event) => ( + event.type === "answer.delta" && (event as { replace?: unknown }).replace === true + )) as { text?: string } | undefined; + assert.equal(replaced?.text, "范围已经收到,收在 05:00–05:10。"); +}); + +test("agent-run uses collectHandoff when the owned-focus body is only a question", async () => { + const stem = "你大概是哪一年搬的家?"; + const collectDossier = dossierFixture({ + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + askedTurnId: TURN_ID, + intent: "collect_method_evidence", + expectedAnswerSchema: { prompt: stem, collect: true }, + }), + }), + }); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => collectDossier, + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + }); + const { options } = runOptions({ + accounting: accounting.client, + buildAgent: async () => fakeAgentStream([ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + chunk("text-delta", { text: "你大概哪一年搬过家?" }), + chunk("finish"), + ]) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.equal(result.answerText, RECTIFICATION_USER_COPY.collectHandoff); +});