fix(rectification): pin the adopt card and drop casual adopt copy
The time card stayed under an earlier collect question, and the close said to just use a time for now. Keep the card on the delivery turn and present the analysis as this round's result. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-05 — 采用时间卡跟在最新旁白下面,单分钟不再念两遍
|
||||
|
||||
生时校正问到可以给出时间时,时间卡跟在最新旁白下面,不再留在更早的采集题底下。旁白会说明按你的经历认真分析过了,下面是这次的结果;单分钟只说一遍钟点。Skill 版本仍是 10.0.14。
|
||||
|
||||
## 2026-09-05 — 发布构建不再从网上拉 Inter
|
||||
|
||||
西文正文字体改成仓库里的 Inter 文件,镜像构建不再访问 Google Fonts。页面上看起来还是同一套字。Skill 版本未变。
|
||||
|
||||
@@ -8389,3 +8389,35 @@
|
||||
- 复发自:BUG-430(加载方式在无 Google Fonts 网络的镜像构建里不成立)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-544 | 采用卡钉在历史采集题下,最新旁白却说从下面选
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-05
|
||||
- 最近更新:2026-09-05
|
||||
- 影响面:`attachOfferResultToTurns`、`applyLiveCandidateOffer`、`rectification-agentic-chat` 的 `candidateOffer` 锚点
|
||||
- 用户现象:最新助手说「可以从下面选一个先用着」,下面没有时间卡。往上翻,卡钉在更早的「工作上呢,还记得哪年入职…」采集题下面。
|
||||
- 触发条件:公开 `can_adopt` 曾为真时,当时最新助手消息还是未答完的采集题;之后继续问了家人/核对,再出采用旁白。
|
||||
- 根因:三层叠加,复发 BUG-497/499。(1) 客户端 `useEffect` 只要某条消息已有同一 `resultId` 就不再改锚点。(2) `messages.find` 取第一条 `candidateOffer`,旧采集题永远赢过后面的采用旁白。(3) GET 把 `offer_result_id` 写在最新回合上时,`mergeTurnQuestions` 在旧消息 `offer_result_id` 为空时仍保留客户端脏锚点。
|
||||
- 修复:未采用时卡只挂在最新已落地、且没有未答采集/区分题的助手消息上;未答采集/选择题在场则不出卡。已采用后仍不把卡挪到核对题上。GET 水合时空的 `offer_result_id` 清掉该回合的客户端脏锚点。
|
||||
- 验证:`rectification-candidate-offer-anchor.test.ts`:采集题 + 后续旁白 → 卡在旁白;只有未答采集 → 无卡;已采用后卡留在原消息。`agent-voice-copy-contract`、`rectification-agentic-entry`、`rectification-adopt-flow-20260902` 既有合同仍过。
|
||||
- 防复发:未采用不得把采用卡锚在未答 `collect_spoken` / `choice` 上。不得用 `messages.find` 取第一条 offer。GET 空 `offer_result_id` 不得保留客户端脏锚点。已采用后仍不得把卡锚到最新核对题。
|
||||
- 相关记录:BUG-312、BUG-497、BUG-499、BUG-545
|
||||
- 复发自:BUG-497、BUG-499
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-545 | 单分钟采用旁白把同一时刻念两遍,并说「分不开当前候选」
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-05
|
||||
- 最近更新:2026-09-05
|
||||
- 影响面:`nonConvergingRangeNarration`、`templateStopExplain`、`RECTIFICATION_USER_COPY.adoptCue`
|
||||
- 用户现象:交付句为「剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。这只是代表性候选,不是已确认的唯一出生分钟。可以从下面选一个先用着。」听起来像内部状态,不像对人说话。
|
||||
- 触发条件:可信区间收成同一分钟,且 `stop_facts` 没有可并列的第二分钟。
|
||||
- 根因:模板把「范围」和「代表分钟」无条件拼在一起;`formatClockRange` 把 `05:15–05:15` 收成 `05:15` 后仍套「范围是 X,代表分钟 X」。停问原因用「当前候选」这种内部词。`adoptCue` 说「选一个」,卡片却不在这句下面(见 BUG-544)。
|
||||
- 修复:区间收成单分钟时改口「眼下更站得住的是 HH:MM」,不再重复。停问改成「再问下去也分不开 A 和 B」或「再问下去也分不出更准的时间了」。`adoptCue` 改为「我按你说的经历认真分析过了,下面是这次的结果。」不得说「先用着」。边界句「不是已确认的唯一出生分钟」保留。
|
||||
- 验证:`agent-voice-copy-contract` 锁单分钟句式与 `adoptCue`;`rectification-answer-choice` 最后一题采用旁白匹配新句式或跨分钟「代表分钟」。机器词表加入旧「当前候选 / 可以从下面选一个先用着 / 下面的时间可以先用着」。
|
||||
- 防复发:单分钟区间不得把范围和代表分钟念两遍。用户可见文案不得出现「当前候选」。交付收尾不得说「先用着」。交付/采用轮仍须出现代表性分钟边界语义。
|
||||
- 相关记录:BUG-544、VOICE.md
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# PROGRESS · 采用卡锚点与交付旁白(2026-09-05)
|
||||
|
||||
工作树:`.worktrees/rectification-quality-probe-dedupe-20260904`
|
||||
分支:`codex/rectification-quality-probe-dedupe-20260904`
|
||||
基线:`origin/staging` `7c776bdd`(Inter 本地字体)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`。
|
||||
|
||||
| 任务 | 状态 | BUG |
|
||||
| --- | --- | --- |
|
||||
| 采用卡跟最新旁白,采集题下不出卡 | 完成 | BUG-544 |
|
||||
| 单分钟交付旁白去机器腔 | 完成 | BUG-545 |
|
||||
|
||||
## 编号
|
||||
|
||||
开工时最大号 BUG-543。本单 **BUG-544、BUG-545**。未占用预留的 BUG-542。
|
||||
|
||||
## 实现要点
|
||||
|
||||
- `attachOfferResultToTurns` / `applyLiveCandidateOffer`:未采用时跳过未答 `collect_spoken` / `choice`;卡挂在最新已落地助手消息。已采用后不挪到核对题。
|
||||
- GET 水合:该回合 `offer_result_id` 为空则清掉客户端脏锚点。
|
||||
- 单分钟区间:`眼下更站得住的是 HH:MM`。停问:`再问下去也分不开 A 和 B` / `再问下去也分不出更准的时间了`。`adoptCue`:`我按你说的经历认真分析过了,下面是这次的结果。`
|
||||
|
||||
## 断言变更
|
||||
|
||||
| 项 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `adoptCue` | 可以从下面选一个先用着。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 付费校正不是随便先用着;仍不说成已确认 |
|
||||
| `rectification-answer-choice` 最后一题旁白 | `/代表分钟/` | `/眼下更站得住的是\|代表分钟/` | 单分钟区间不再念「代表分钟 05:00」 |
|
||||
| `site`/`agentic-entry` 卡锚点 | `messages.find` 第一条 offer | `reverse().find` + `applyLiveCandidateOffer` | 旧采集题抢锚点 |
|
||||
|
||||
## 测试
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `npx tsx --test tests/rectification-candidate-offer-anchor.test.ts tests/agent-voice-copy-contract.test.ts` | 15/15 |
|
||||
| 上两项 + adopt-flow / adopt-narration / answer-choice / collect-stall / provisional-adopt / range-offer / agentic-entry / activity-receipt | 160/160 |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| 改动文件 eslint `--quiet` | 0 error |
|
||||
| `page.tsx` | 未改 |
|
||||
|
||||
未做浏览器真人走查。清单:`docs/testing/rectification-adopt-card-anchor-20260905.md`。
|
||||
@@ -51,6 +51,7 @@
|
||||
| `TASK-rectification-collect-direction-fix-20260904.md` | `PROGRESS-rectification-collect-direction-fix-20260904.md` | dated 补采集分支插在方法轮转之前,新案例第二问被家人抢占感情/事业;`7ee7f825` CSS 与合同测试脱节挡住门禁 | 已验收通过(0be51e65),待 staging 部署核对 | `codex/rectification-collect-direction-fix-20260904`(BUG-531~532) |
|
||||
| `TASK-rectification-post-adopt-verify-20260904.md` | `PROGRESS-rectification-post-adopt-verify-20260904.md` | 采用后核对题重复已问题;核对卡「这题跳过」等于整案停止、已采用仍念"选一个先用着"、前端"没有拿到下一个问题";采用旁白承诺的 holdout/OOS 核对与实际计划不同源(原任务书 §4 错误);家庭采集题带年份前缀自相矛盾 | 待验收 | `codex/rectification-post-adopt-verify-20260904`(BUG-536~539) |
|
||||
| `TASK-rectification-quality-probe-dedupe-20260904.md` | `PROGRESS-rectification-quality-probe-dedupe-20260904.md` | 候选区分阶段同一道"上大学"题问两次:`pickProbe` 按种类取第一条质量探针无视 `semantic_key`(第二次答案绑到错的证据);引擎对毕业等学业 kind 也套"高考发挥"模板;同域同分组质量探针不去重 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-540~541) |
|
||||
| — | `PROGRESS-rectification-adopt-card-anchor-20260905.md` | 采用卡钉在历史采集题下,最新旁白没有卡;单分钟交付旁白机器腔 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-544~545) |
|
||||
| `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) |
|
||||
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Staging 人肉复核 · 采用卡位置与交付旁白(2026-09-05)
|
||||
|
||||
给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。
|
||||
|
||||
对应 BUG-544、BUG-545。测之前先做第 0 条。
|
||||
|
||||
## 0. 确认测的是新版本
|
||||
|
||||
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
|
||||
|
||||
## 1. 时间卡跟在「这次的结果」那句下面(P0 · BUG-544)
|
||||
|
||||
1. 把一场校正问到助手给出这次的结果。
|
||||
2. 看**最新**那条助手消息下面。
|
||||
- ✅ 预期:旁白以「我按你说的经历认真分析过了,下面是这次的结果。」收尾;`当前可能的出生时间` 卡紧挨着这句,能点「采用此时间」。
|
||||
- ❌ 失败:卡出现在更早的采集题或区分题下面,最新旁白下面没有卡。
|
||||
|
||||
## 2. 采集题还在问时不要出采用卡(P0 · BUG-544)
|
||||
|
||||
若过程中还在问「工作上呢,还记得哪年入职…」这类口述题:
|
||||
- ✅ 预期:这题下面没有采用卡;答完并出采用旁白后,卡才出现在最新旁白下。
|
||||
|
||||
## 3. 旁白像对人说话(P1 · BUG-545)
|
||||
|
||||
采用旁白:
|
||||
- ✅ 预期:不出现「当前候选」或「先用着」。收尾是「我按你说的经历认真分析过了,下面是这次的结果。」单分钟只说一遍钟点(例如「眼下更站得住的是 05:15」),不把同一时刻先当范围再当代表分钟。仍有「不是已确认的唯一出生分钟」。
|
||||
- ❌ 失败:出现「剩下的题分不开当前候选」、「范围是 05:15,代表分钟 05:15」,或「可以先用着」。
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
1. 新开一个生时校正会话,按平常走完区分题。
|
||||
2. **点选最后一道区分题**(不要用采集题「没有」代替)。
|
||||
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾。
|
||||
- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「我按你说的经历认真分析过了,下面是这次的结果。」收尾。
|
||||
- ❌ 改造前(BUG-521):点选入口旁白仍是模板句,Agent 没上场。
|
||||
|
||||
采集题路径仍要能走:
|
||||
|
||||
+1
-1
@@ -473,7 +473,7 @@ or user IDs.
|
||||
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
|
||||
|
||||
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。
|
||||
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出采用卡。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。
|
||||
|
||||
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。 | 直接点下面的选项就行,打字回答也一样算数。 | 内部计分规则不进聊天。 |
|
||||
| 我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。 | 我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。 | 承认不确定,不训人。 |
|
||||
| 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 |
|
||||
| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 |
|
||||
| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 |
|
||||
| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。卡片必须就在这句下面。 |
|
||||
| 采用后会拿盘外核对来验证。 | 前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 已采用且没有下一道核对题时用 `postAdoptVerifyDone`,不再念 `adoptCue`。 |
|
||||
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
|
||||
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
|
||||
|
||||
@@ -95,7 +95,15 @@ import {
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
|
||||
import { RectificationChoiceCard } from "./rectification-choice-card";
|
||||
import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question";
|
||||
import {
|
||||
applyLiveCandidateOffer,
|
||||
copyTextForMessage,
|
||||
interviewQuestionBlocksAdoptOffer,
|
||||
parseTurnQuestion,
|
||||
persistedOfferFromTurn,
|
||||
questionIsAnswered,
|
||||
type TurnQuestion,
|
||||
} from "@/lib/rectification-agentic/v9/turn-question";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type PersistedTurn = Readonly<{
|
||||
@@ -264,9 +272,11 @@ function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[])
|
||||
return {
|
||||
...message,
|
||||
question: question ?? undefined,
|
||||
candidateOffer: next?.offerResultId
|
||||
? { resultId: next.offerResultId }
|
||||
: message.candidateOffer,
|
||||
candidateOffer: persistedOfferFromTurn(
|
||||
next?.offerResultId,
|
||||
message.candidateOffer,
|
||||
true,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -609,25 +619,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (!canOffer && !adopted) return;
|
||||
const resultId = candidateResult.resultId;
|
||||
queueMicrotask(() => {
|
||||
setMessages((current) => {
|
||||
if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current;
|
||||
if (adopted && current.some((message) => message.candidateOffer)) {
|
||||
return current.map((message) => (
|
||||
message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message
|
||||
));
|
||||
}
|
||||
if (!canOffer || adopted) return current;
|
||||
const owner = [...current].reverse().find((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text)
|
||||
&& !message.failed
|
||||
));
|
||||
if (!owner) return current;
|
||||
return current.map((message) => (
|
||||
message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message
|
||||
));
|
||||
});
|
||||
setMessages((current) => applyLiveCandidateOffer(current, {
|
||||
resultId,
|
||||
canOffer,
|
||||
adopted,
|
||||
}));
|
||||
});
|
||||
}, [candidateResult]);
|
||||
|
||||
@@ -1375,13 +1371,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& regeneratingMessageKey === null,
|
||||
);
|
||||
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
|
||||
const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey;
|
||||
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
|
||||
const selectionCardMessageKey = persistedOfferKey
|
||||
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult
|
||||
&& selectionCardMessageKey
|
||||
&& (canOfferCards || Boolean(candidateResult.selectedTime)),
|
||||
&& (canOfferCards || Boolean(candidateResult.selectedTime))
|
||||
&& !messages.some((message) => (
|
||||
interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime))
|
||||
)),
|
||||
);
|
||||
const showReadonlyRange = Boolean(
|
||||
canShowRectificationReadonlyRange(candidateResult)
|
||||
|
||||
@@ -71,7 +71,7 @@ export const RECTIFICATION_USER_COPY = {
|
||||
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
|
||||
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
|
||||
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
|
||||
adoptCue: "可以从下面选一个先用着。",
|
||||
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
|
||||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||||
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
|
||||
collectDeclinedAck: "记下了,这方面先跳过。",
|
||||
@@ -170,6 +170,12 @@ export function nonConvergingRangeNarration(
|
||||
}
|
||||
|
||||
if (rangeText && representative) {
|
||||
const collapsed = Boolean(range && range[0] === range[1]);
|
||||
if (collapsed) {
|
||||
const head = progress ?? `眼下更站得住的是 ${representative}`;
|
||||
const body = `${head}。${boundaryCopy}`;
|
||||
return prefix ? `${prefix}${body}` : body;
|
||||
}
|
||||
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
|
||||
const representativeClause = progress ? `。代表分钟是 ${representative}。` : `。`;
|
||||
const body = `${head}${representativeClause}${boundaryCopy}`;
|
||||
@@ -189,7 +195,9 @@ export function nonConvergingRangeNarration(
|
||||
}
|
||||
|
||||
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
|
||||
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`;
|
||||
return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const INSTRUCTION_TONE_SENTENCE = /不得|请写成/;
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出
|
||||
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。
|
||||
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
|
||||
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
|
||||
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
|
||||
不要出现「确认」「精确」这两个词,不得再提问,不要写「我按你说的经历认真分析过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`;
|
||||
|
||||
export type AdoptNarrationOutcome =
|
||||
| "agent"
|
||||
|
||||
@@ -113,13 +113,13 @@ function stopFactsFromDropped(
|
||||
if (representative && runnerUp && representative !== runnerUp) {
|
||||
facts.push({
|
||||
kind: "indistinguishable",
|
||||
label: `剩下的题分不开 ${representative} 和 ${runnerUp}`,
|
||||
label: `再问下去也分不开 ${representative} 和 ${runnerUp}`,
|
||||
count: Math.max(1, noSplit),
|
||||
});
|
||||
} else if (noSplit > 0) {
|
||||
facts.push({
|
||||
kind: "indistinguishable",
|
||||
label: "剩下的题分不开当前候选",
|
||||
label: "再问下去也分不出更准的时间了",
|
||||
count: noSplit,
|
||||
});
|
||||
}
|
||||
@@ -224,10 +224,11 @@ export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
|
||||
|
||||
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
|
||||
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
|
||||
if (split && facts.representative_minute && facts.runner_up_minute) {
|
||||
return `剩下的问题分不开 ${facts.representative_minute} 和 ${facts.runner_up_minute}。`;
|
||||
if (split && facts.representative_minute && facts.runner_up_minute
|
||||
&& facts.representative_minute !== facts.runner_up_minute) {
|
||||
return `再问下去也分不开 ${facts.representative_minute} 和 ${facts.runner_up_minute}。`;
|
||||
}
|
||||
if (split) return `${split.label}。`;
|
||||
if (split) return `${split.label.replace(/。?$/, "")}。`;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export const MACHINE_VOICE_LEXICON = [
|
||||
"当前区分题",
|
||||
"接下来请点选下面这一问",
|
||||
"可以从下面的时间里选一个采用",
|
||||
"可以从下面选一个先用着",
|
||||
"下面的时间可以先用着",
|
||||
"剩下的题分不开当前候选",
|
||||
"我没能确定这句话是否在回答当前问题",
|
||||
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
|
||||
"有没有记得住时间的收入变化、大笔支出或欠债",
|
||||
|
||||
@@ -120,6 +120,89 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
|
||||
});
|
||||
}
|
||||
|
||||
export function interviewQuestionBlocksAdoptOffer(
|
||||
question: TurnQuestion | null | undefined,
|
||||
adopted: boolean,
|
||||
): boolean {
|
||||
if (adopted) return false;
|
||||
if (!question || questionIsAnswered(question)) return false;
|
||||
return question.kind === "collect_spoken" || question.kind === "choice";
|
||||
}
|
||||
|
||||
export type CandidateOfferAnchor = {
|
||||
renderKey: string;
|
||||
role: string;
|
||||
state?: string;
|
||||
text?: string | null;
|
||||
failed?: boolean;
|
||||
question?: TurnQuestion | null;
|
||||
candidateOffer?: Readonly<{ resultId: string }>;
|
||||
};
|
||||
|
||||
function isSettledAssistant(message: CandidateOfferAnchor): boolean {
|
||||
if (message.role !== "assistant" || message.failed) return false;
|
||||
if (message.state && message.state !== "settled") return false;
|
||||
return Boolean(message.text);
|
||||
}
|
||||
|
||||
export function applyLiveCandidateOffer<T extends CandidateOfferAnchor>(
|
||||
messages: readonly T[],
|
||||
input: { resultId: string; canOffer: boolean; adopted: boolean },
|
||||
): T[] {
|
||||
if (input.adopted) {
|
||||
if (!messages.some((message) => message.candidateOffer)) return [...messages];
|
||||
return messages.map((message) => (
|
||||
message.candidateOffer
|
||||
? { ...message, candidateOffer: { resultId: input.resultId } }
|
||||
: message
|
||||
));
|
||||
}
|
||||
if (!input.canOffer) return [...messages];
|
||||
const liveInterview = messages.some((message) => (
|
||||
interviewQuestionBlocksAdoptOffer(message.question, false)
|
||||
));
|
||||
if (liveInterview) {
|
||||
return messages.map((message) => (
|
||||
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
|
||||
));
|
||||
}
|
||||
const owner = [...messages].reverse().find((message) => (
|
||||
isSettledAssistant(message)
|
||||
&& !interviewQuestionBlocksAdoptOffer(message.question, false)
|
||||
));
|
||||
if (!owner) {
|
||||
return messages.map((message) => (
|
||||
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
|
||||
));
|
||||
}
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
candidateOffer: message.renderKey === owner.renderKey
|
||||
? { resultId: input.resultId }
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function persistedOfferFromTurn(
|
||||
offerResultId: string | null | undefined,
|
||||
previous: Readonly<{ resultId: string }> | undefined,
|
||||
turnHydrated: boolean,
|
||||
): Readonly<{ resultId: string }> | undefined {
|
||||
if (typeof offerResultId === "string" && offerResultId.length > 0) {
|
||||
return { resultId: offerResultId };
|
||||
}
|
||||
if (turnHydrated) return undefined;
|
||||
return previous;
|
||||
}
|
||||
|
||||
function assistantCanOwnAdoptOffer(
|
||||
turn: { role?: string; question?: TurnQuestion | null },
|
||||
adopted: boolean,
|
||||
): boolean {
|
||||
return turn.role === "assistant"
|
||||
&& !interviewQuestionBlocksAdoptOffer(turn.question, adopted);
|
||||
}
|
||||
|
||||
export function attachOfferResultToTurns<T extends {
|
||||
id: string;
|
||||
role: string;
|
||||
@@ -137,20 +220,21 @@ export function attachOfferResultToTurns<T extends {
|
||||
if (!offered || !resultId) {
|
||||
return turns.map((turn) => ({ ...turn, offer_result_id: null }));
|
||||
}
|
||||
const adopted = Boolean(input.acceptedTime);
|
||||
const firstVerify = turns.findIndex((turn) => (
|
||||
turn.role === "assistant" && turn.question?.kind === "reverse_verify"
|
||||
));
|
||||
const searchUntil = firstVerify >= 0 ? firstVerify : turns.length;
|
||||
let ownerIndex = -1;
|
||||
for (let index = searchUntil - 1; index >= 0; index -= 1) {
|
||||
if (turns[index]?.role === "assistant") {
|
||||
if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) {
|
||||
ownerIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ownerIndex < 0) {
|
||||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||
if (turns[index]?.role === "assistant") {
|
||||
if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) {
|
||||
ownerIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,18 @@ test("accident-case after copy is rebuilt from the shared module", () => {
|
||||
assert.ok(containsBoundarySemantics(after.deliveryAdopt));
|
||||
});
|
||||
|
||||
test("a single-minute range is not spoken twice as range and representative", () => {
|
||||
const text = nonConvergingRangeNarration({
|
||||
credibleRange: ["05:15", "05:15"],
|
||||
representativeTime: "05:15",
|
||||
});
|
||||
assert.match(text, /眼下更站得住的是 05:15/);
|
||||
assert.doesNotMatch(text, /代表分钟 05:15/);
|
||||
assert.doesNotMatch(text, /更站得住的范围是 05:15/);
|
||||
assert.equal(containsBoundarySemantics(text), true);
|
||||
assert.equal(RECTIFICATION_USER_COPY.adoptCue, "我按你说的经历认真分析过了,下面是这次的结果。");
|
||||
});
|
||||
|
||||
test("default range narration stays semantic even without an opening window", () => {
|
||||
const text = nonConvergingRangeNarration({
|
||||
credibleRange: ["04:47", "04:53"],
|
||||
|
||||
@@ -615,7 +615,7 @@ function adoptAccounting(dossier: DecisionDossier, activeFocus?: Record<string,
|
||||
|
||||
function assertAdoptTemplate(text: string) {
|
||||
assert.match(text, /分不开 05:00 和 05:06/);
|
||||
assert.match(text, /可以从下面选一个先用着/);
|
||||
assert.match(text, /我按你说的经历认真分析过了/);
|
||||
assert.doesNotMatch(text, /继续往下收/);
|
||||
}
|
||||
|
||||
@@ -699,7 +699,7 @@ test("adopt narration agent keeps in-fact copy and fail-closes the rest", async
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06,代表分钟 05:00。采用后会用 2016 年学业经历核对。";
|
||||
const valid = await createAdoptNarrationWriter({
|
||||
@@ -894,7 +894,7 @@ test("adopt narration diagnostics distinguish agent, validation, error, and not-
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
|
||||
|
||||
const agent = await deliverAdoptNarration({
|
||||
@@ -936,7 +936,7 @@ test("adopt narration times out to the template without throwing", async () => {
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
const timed = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
@@ -951,7 +951,7 @@ test("adopt narration does not leave an active timeout after the model returns",
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
|
||||
const source = readFileSync(
|
||||
new URL("../src/lib/rectification-agentic/v9/adopt-narration-agent.ts", import.meta.url),
|
||||
|
||||
@@ -565,9 +565,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
// 锚在 latestSettledAssistant。采集题没有 A–D 卡,所以 collect_evidence 会出采用卡;
|
||||
// 采用后 busy 把卡藏掉,新消息又把卡带走。
|
||||
// 新:canShowRectificationSelectionCards(含 session_outcome 门)+ candidateOffer 所有权;
|
||||
// 已采用后不再把卡锚到最新消息。核对题可与原消息上的结算卡并存。
|
||||
// 未采用时卡跟着最新采用旁白走,采集题还在问则不出卡;已采用后不再把卡锚到最新消息。
|
||||
// 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。
|
||||
assert.match(chat, /canShowRectificationSelectionCards/);
|
||||
assert.match(chat, /applyLiveCandidateOffer/);
|
||||
assert.match(chat, /persistedOfferFromTurn/);
|
||||
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& selectionCardMessageKey/);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -608,11 +608,13 @@ test("last structured choice emits the adoption range and persists the same narr
|
||||
assert.equal(applied.nextAction.type, "ready_to_adopt");
|
||||
assert.equal(applied.nextAction.can_adopt, true);
|
||||
assert.match(applied.narration, /05:\d{2}/);
|
||||
assert.match(applied.narration, /代表分钟/);
|
||||
// 旧:/代表分钟/ 锁「范围是 X,代表分钟 X」。单分钟区间会把同一时刻念两遍。
|
||||
// 新:单分钟说「眼下更站得住的是 HH:MM」;跨分钟范围仍可说代表分钟。
|
||||
assert.match(applied.narration, /眼下更站得住的是|代表分钟/);
|
||||
assert.equal(containsBoundarySemantics(applied.narration), true);
|
||||
assert.match(applied.narration, /可以从下面选一个先用着/);
|
||||
assert.match(applied.narration, /我按你说的经历认真分析过了/);
|
||||
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
|
||||
assert.match(String(turn?.args.p_assistant_message ?? ""), /可以从下面选一个先用着/);
|
||||
assert.match(String(turn?.args.p_assistant_message ?? ""), /我按你说的经历认真分析过了/);
|
||||
});
|
||||
|
||||
test("clicking A applies the choice without invoking a language model", async () => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyLiveCandidateOffer,
|
||||
attachOfferResultToTurns,
|
||||
persistedOfferFromTurn,
|
||||
type TurnQuestion,
|
||||
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
|
||||
function collect(status: "active" | "resolved"): TurnQuestion {
|
||||
return {
|
||||
focus_id: "focus-career",
|
||||
question_id: "collect:career:collect_method_evidence",
|
||||
kind: "collect_spoken",
|
||||
prompt: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
|
||||
options: null,
|
||||
status,
|
||||
answer_option: null,
|
||||
probe_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("adopt offer skips an unanswered collect question and lands on the later narration", () => {
|
||||
const attached = attachOfferResultToTurns([
|
||||
{ id: "collect-turn", role: "assistant", question: collect("active") },
|
||||
{ id: "user-turn", role: "user", question: null },
|
||||
{ id: "adopt-turn", role: "assistant", question: null },
|
||||
], {
|
||||
resultId: "result-1",
|
||||
canAdopt: true,
|
||||
acceptedTime: null,
|
||||
});
|
||||
assert.equal(attached[0]?.offer_result_id, null);
|
||||
assert.equal(attached[2]?.offer_result_id, "result-1");
|
||||
});
|
||||
|
||||
test("adopt offer is not pinned onto a live collect question even if it is the only assistant turn", () => {
|
||||
const attached = attachOfferResultToTurns([
|
||||
{ id: "collect-turn", role: "assistant", question: collect("active") },
|
||||
], {
|
||||
resultId: "result-1",
|
||||
canAdopt: true,
|
||||
acceptedTime: null,
|
||||
});
|
||||
assert.equal(attached[0]?.offer_result_id, null);
|
||||
});
|
||||
|
||||
test("live offer moves off a collect message once the adopt narration exists", () => {
|
||||
const next = applyLiveCandidateOffer([
|
||||
{
|
||||
renderKey: "collect",
|
||||
role: "assistant",
|
||||
state: "settled",
|
||||
text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
|
||||
question: collect("resolved"),
|
||||
candidateOffer: { resultId: "result-1" },
|
||||
},
|
||||
{
|
||||
renderKey: "user",
|
||||
role: "user",
|
||||
state: "settled",
|
||||
text: "2020 年 4 月实习然后 10 月离职",
|
||||
},
|
||||
{
|
||||
renderKey: "adopt",
|
||||
role: "assistant",
|
||||
state: "settled",
|
||||
text: "再问下去也分不出更准的时间了。眼下更站得住的是 05:15。这只是代表性候选,不是已确认的唯一出生分钟。我按你说的经历认真分析过了,下面是这次的结果。",
|
||||
},
|
||||
], { resultId: "result-1", canOffer: true, adopted: false });
|
||||
assert.equal(next[0]?.candidateOffer, undefined);
|
||||
assert.deepEqual(next[2]?.candidateOffer, { resultId: "result-1" });
|
||||
});
|
||||
|
||||
test("live offer stays hidden while an unanswered collect question is still on screen", () => {
|
||||
const next = applyLiveCandidateOffer([
|
||||
{
|
||||
renderKey: "collect",
|
||||
role: "assistant",
|
||||
state: "settled",
|
||||
text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
|
||||
question: collect("active"),
|
||||
candidateOffer: { resultId: "result-1" },
|
||||
},
|
||||
], { resultId: "result-1", canOffer: true, adopted: false });
|
||||
assert.equal(next[0]?.candidateOffer, undefined);
|
||||
});
|
||||
|
||||
test("after adopt the offer stays on the original message", () => {
|
||||
const next = applyLiveCandidateOffer([
|
||||
{
|
||||
renderKey: "offer",
|
||||
role: "assistant",
|
||||
state: "settled",
|
||||
text: "我按你说的经历认真分析过了,下面是这次的结果。",
|
||||
candidateOffer: { resultId: "result-1" },
|
||||
},
|
||||
{
|
||||
renderKey: "verify",
|
||||
role: "assistant",
|
||||
state: "settled",
|
||||
text: "2021 年前后,有没有家人结婚、添丁或住院?",
|
||||
question: {
|
||||
focus_id: "verify",
|
||||
question_id: "reverse_verify:family:score",
|
||||
kind: "reverse_verify",
|
||||
prompt: "2021 年前后,有没有家人结婚、添丁或住院?",
|
||||
options: null,
|
||||
status: "active",
|
||||
answer_option: null,
|
||||
probe_id: null,
|
||||
},
|
||||
},
|
||||
], { resultId: "result-2", canOffer: true, adopted: true });
|
||||
assert.deepEqual(next[0]?.candidateOffer, { resultId: "result-2" });
|
||||
assert.equal(next[1]?.candidateOffer, undefined);
|
||||
});
|
||||
|
||||
test("hydrated turns drop a stale client offer when GET says this turn is not the owner", () => {
|
||||
assert.equal(
|
||||
persistedOfferFromTurn(null, { resultId: "result-1" }, true),
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
persistedOfferFromTurn("result-2", { resultId: "result-1" }, true),
|
||||
{ resultId: "result-2" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
persistedOfferFromTurn(null, { resultId: "result-1" }, false),
|
||||
{ resultId: "result-1" },
|
||||
);
|
||||
});
|
||||
@@ -1124,7 +1124,7 @@ test("live five-evidence case opens adopt instead of occupation collect", async
|
||||
birthDate: "1997-08-08",
|
||||
});
|
||||
assert.equal(persisted.persisted, false);
|
||||
assert.match(persisted.hostNarration, /可以从下面选一个先用着/);
|
||||
assert.match(persisted.hostNarration, /我按你说的经历认真分析过了/);
|
||||
assert.equal(
|
||||
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
|
||||
false,
|
||||
|
||||
@@ -425,7 +425,7 @@ test("incident shape 1c: uncovered occupation still opens provisional adopt", as
|
||||
});
|
||||
assert.equal(persisted.persisted, false);
|
||||
assert.equal(activeFocus, null);
|
||||
assert.match(persisted.hostNarration ?? "", /可以从下面选一个先用着/);
|
||||
assert.match(persisted.hostNarration ?? "", /我按你说的经历认真分析过了/);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.equal(canRenderRectificationSelectionCards(parseRectificationCandidateResult({
|
||||
resultId: dossier.latestResult?.resultId,
|
||||
@@ -463,7 +463,7 @@ test("after-choice persist does not open another collect once dated coverage can
|
||||
});
|
||||
assert.equal(next.persisted, false);
|
||||
assert.equal(writes, 0);
|
||||
assert.match(next.hostNarration, /可以从下面选一个先用着/);
|
||||
assert.match(next.hostNarration, /我按你说的经历认真分析过了/);
|
||||
});
|
||||
|
||||
test("incident shape 2: evidence floor still blocks adopt", () => {
|
||||
@@ -610,7 +610,7 @@ test("delivery B2: adoptable offer produces adopt_representative with zero follo
|
||||
assert.match(idle.hostNarration ?? "", /05:00–05:07/);
|
||||
assert.match(idle.hostNarration ?? "", /05:00/);
|
||||
assert.equal(containsBoundarySemantics(idle.hostNarration ?? ""), true);
|
||||
assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/);
|
||||
assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/);
|
||||
});
|
||||
|
||||
test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => {
|
||||
|
||||
@@ -557,7 +557,7 @@ test("adoptable range offer does not persist another spoken collect", async () =
|
||||
});
|
||||
assert.equal(idle.persisted, false);
|
||||
assert.equal(idle.choiceReady, false);
|
||||
assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/);
|
||||
assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/);
|
||||
assert.match(idle.hostNarration ?? "", /04:47–04:53/);
|
||||
assert.match(idle.hostNarration ?? "", /04:51/);
|
||||
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
|
||||
|
||||
Reference in New Issue
Block a user