fix(rectification): ask tie-break questions before the range card (BUG-685/686/687)
Close leads hold delivery until unused D9/D10 style questions are asked. Tie-break POST merges the new turn into the transcript. Range copy no longer lists declined lines.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-14 — 分数接近时先问两道参考题,再给出范围卡
|
||||
|
||||
生时校正里,前两名只差一分或打平时,会先连着问两道参考题(相处方式、做事风格),再给范围卡。点「再答两道参考题」后,题会出现在对话里。范围卡不再列出你已经说过没有或跳过的线。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-14 — 并列不等于可以结束:还有题就继续问,区间没收窄不说可采用
|
||||
|
||||
生时校正里,几个候选分数打平不再一上来就宣布「可采用」。还有带年月的区分题或定向补事时继续问。都问完了才给区间;如果区间还是一开始那一段,只给范围、不推荐代表分钟。交付之后如果还留着一张画不出的题,不再印「没有拿到下一个问题」。Skill 版本不变。
|
||||
|
||||
@@ -10661,3 +10661,51 @@
|
||||
- 复发自:BUG-682(`delivered` 终态要求无题,死卡把会话钉在问题态)
|
||||
- 修复版本:待发布
|
||||
- 证据缺口:同一会话 GET 的 `current_question`、`choice_card`、`question_source`,以及最后一条助手 turn 的 `offer_result_id`。缺这四项之前不得臆断写入路径。
|
||||
|
||||
## BUG-685 | 点「再答两道参考题」服务端成功,对话区没有任何变化
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-14
|
||||
- 最近更新:2026-09-14
|
||||
- 影响面:`requestTieBreak`、`loadCaseSnapshot`、`applySnapshotTurnsToMessages`、`rectificationQuestionGapState`
|
||||
- 用户现象:点「再答两道参考题微调排序」后 POST 返回 `ok: true`、`choiceReady: true`,界面仍是原来的范围卡,新参考题看不见。
|
||||
- 触发条件:范围卡在场时点参考题入口;服务端已写入助手 turn 和活动焦点。
|
||||
- 根因:`requestTieBreak` 调用不带合并回调的 `loadCaseSnapshot()`,新 turn 不进 `messages`。交付卡在场使缺口为 `idle`,`persisted_question` 也不会兜底画这道题。
|
||||
- 修复:`requestTieBreak` 用与 `submitChoice` 相同的 `mergeTurnQuestions` / `appendUnseenAssistantTurns`。未答焦点优先于「等读者点卡」的 idle。
|
||||
- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`。
|
||||
- 防复发:服务端返回 ok 而界面无变化,一律视为缺陷。任何新写 turn 的前端动作必须把 turn 并进对话区。
|
||||
- 相关记录:BUG-663、BUG-666、BUG-667
|
||||
- 复发自:BUG-663(入口只写焦点,前端刷新不合并 turn)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-686 | 平局直接出卡,参考题留成卡下可选按钮
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-14
|
||||
- 最近更新:2026-09-14
|
||||
- 影响面:`decideRectification` `heldForTieBreak`、`persistNextInterviewAfterChoice` / `persistNextInterviewIfIdle`、范围卡按钮
|
||||
- 用户现象:带年月题和定向补事问完后,前两名 16/16 并列,直接出范围卡让人在 26%/26%/20% 里选;参考题要自己点按钮。
|
||||
- 触发条件:前两名 posterior 分差 ≤ 1,D9/D10 参考题仍可用且未用过。
|
||||
- 根因:性格题被设计成出卡后的可选入口,而不是出卡前的必经步骤。
|
||||
- 修复:产品拍板方案 A(2026-09-14)。分差 ≤ 1 且参考题可用时先连问最多两道 `varga_style`(仍只 ±1、不淘汰),两道用完再出卡。仍平时卡上写「这两分钟按现有信息分不开,参考题已经用过。」用过之后不再显示入口按钮。
|
||||
- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`:16/16 不交付且下一问是 `varga_style`;两道用完才交付且按钮关闭;lead=2 不插参考题。
|
||||
- 防复发:不得把性格题改成淘汰候选。自动进入参考题时不得再写第二条交付消息。
|
||||
- 相关记录:BUG-663、BUG-667、BUG-629、BUG-597
|
||||
- 复发自:BUG-663(入口做成出卡后可选)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-687 | 交付文案把已经拒答的线又列一遍
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-14
|
||||
- 最近更新:2026-09-14
|
||||
- 影响面:`rangeNarrowHint`
|
||||
- 用户现象:结婚、收入、家人都答过「没有/跳过」,范围卡仍写「能把它们分开的是这几条线:哪年结婚或订婚、哪年收入明显变过、家里哪年添丁」。
|
||||
- 触发条件:定向补事已拒答或已关闭,随后交付区间。
|
||||
- 根因:`rangeNarrowHint` 调 `targetedCollectPool` 时传入空的 `declinedTopics`,有意忽略拒答。
|
||||
- 修复:传入真实 `declinedTopics`。一条都不剩时写「能问的都问完了」,不再暗示还有没问的线。
|
||||
- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`。
|
||||
- 防复发:交付文案不得列出已拒答或已回答的线。
|
||||
- 相关记录:BUG-661、BUG-662
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 进度 · 平局先问参考题再出卡(2026-09-14)
|
||||
|
||||
## 范围
|
||||
|
||||
- 分支:`codex/rectification-tiebreak-before-card-20260914`(基于 `origin/staging` @ `a6381e02`)
|
||||
- 任务单:`docs/tasks/TASK-rectification-tiebreak-before-card-20260914.md`
|
||||
- BUG-685 / 686 / 687 resolved
|
||||
- 任务 1–4 均做完,没有走让步
|
||||
|
||||
## 完成
|
||||
|
||||
- T1:`requestTieBreak` 成功路径用 `applySnapshotTurnsToMessages` 把新 turn 并进对话。缺口里未答焦点优先于「等读者点卡」。`refetchQuestion` 与 repair 失败回拉同样合并(服务端可能已写 turn)。
|
||||
- T2:`pendingTieBreak && lead ≤ 1` 时 `heldForTieBreak`,先持久化 D9/D10 `varga_style`(沿用 `tie_break_round`),用完再交付。仍平时卡上写「参考题已经用过」,按钮随 `tie_break_available` 收起。
|
||||
- T3:`rangeNarrowHint` 传入真实拒答;空池写「能问的都问完了」。
|
||||
- T4:`tieBreakGateInput` 供 GET 与 POST 共用。GET 本轮仍不另载 `birthDate`(与原先一致);POST 继续从 compute 读取。
|
||||
|
||||
## `loadCaseSnapshot` 逐处
|
||||
|
||||
| 位置 | 回调 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `send` 成功/失败 | 有(只 merge 已有消息上的题) | 模型 turn 已在 `messages` |
|
||||
| `submitChoice` | 有 merge + append | 结构化选择后可能多出助手 turn |
|
||||
| 采用成功后 | 无 | 随后 `send` 写采用旁白;快照只刷新已采用态 |
|
||||
| 参考题 409 | 无 | 没有新题,只重拉以藏按钮 |
|
||||
| 参考题成功 | 有 | BUG-685 |
|
||||
| `refetchQuestion` | 有 | 准备缺口期间服务端可能已写下下一问 |
|
||||
| repair-exit 失败回拉 | 有 | 修复失败后快照里可能已有新 turn |
|
||||
|
||||
## 验收数字
|
||||
|
||||
- `tsc --noEmit`:0 错
|
||||
- `npm run lint`:0 error / 120 warning(既有,未顺手改)
|
||||
- 定向:`rectification-tiebreak-before-card-20260914.test.ts` 8/8;tie-break-entry、collection-question-pool、delivery-vs-collect、tied-first、surface-state、probe-pool-exhausted T4、targeted-collect 相关全过
|
||||
- 全量 `npm test`:3011 / pass 2843 / fail 154 / skip 14(含 T4 旧断言,已改为「能问的都问完了」后定向 20/20)。失败条数与 Windows 基线 153 同型(CRLF、Skill SHA、Docker/DB、无 symlink)。本轮新测 8 条全过。
|
||||
- `next build`:本机 Turbopack 拒跨 worktree 的 `node_modules` junction。Linux 门禁会在树内安装后构建。
|
||||
- 浏览器级验收:无 staging 登录态,清单见 `docs/testing/rectification-tiebreak-before-card-20260914.md`。
|
||||
@@ -184,7 +184,7 @@
|
||||
|
||||
| `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` |
|
||||
|
||||
| `TASK-rectification-tiebreak-before-card-20260914.md` | `PROGRESS-rectification-tiebreak-before-card-20260914.md` | **P0**:点「再答两道参考题」服务端 ok 但界面无反应(`requestTieBreak` 的 `loadCaseSnapshot()` 不带合并回调,新 turn 不入对话区)(BUG-685);产品拍板放宽版 A——出交付卡前只要参考题可用且没用过就先问两道(不看分差),卡上收起按钮(BUG-686);交付文案列出用户已拒答的线(BUG-687) | 待执行 | `codex/rectification-tiebreak-before-card-20260914` |
|
||||
| `TASK-rectification-tiebreak-before-card-20260914.md` | `PROGRESS-rectification-tiebreak-before-card-20260914.md` | **P0**:点「再答两道参考题」服务端 ok 但界面无反应(`requestTieBreak` 的 `loadCaseSnapshot()` 不带合并回调,新 turn 不入对话区)(BUG-685);产品拍板放宽版 A——出交付卡前只要参考题可用且没用过就先问两道(不看分差),卡上收起按钮(BUG-686);交付文案列出用户已拒答的线(BUG-687) | 待验收 | `codex/rectification-tiebreak-before-card-20260914` |
|
||||
|
||||
| `TASK-rectification-tied-first-premature-delivery-fix-20260914.md` | `PROGRESS-rectification-tied-first-fix-20260914.md` | **P0 回归修复单**:BUG-680 的 `tied_first` 早退被实现到 `coverageBlocks` 之前且无 `probe` 守卫 → 新案子答两题、区间还是整个开局窗口就宣布 `can_adopt`/代表分钟(BUG-683);交付态下仍出现死卡致 `delivered` 终态不触发、印「没有拿到下一个问题」(BUG-684 investigating) | 待验收 | `codex/rectification-tied-first-fix-20260914` |
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 平局先问参考题:真机清单(2026-09-14)
|
||||
|
||||
分支 `codex/rectification-tiebreak-before-card-20260914`。本环境无 staging 登录态。
|
||||
|
||||
## BUG-685
|
||||
|
||||
- [ ] 范围卡上点「再答两道参考题微调排序」后,对话区立刻出现助手消息和可点的参考题,不是什么都没变。
|
||||
|
||||
## BUG-686
|
||||
|
||||
- [ ] 前两名分数打平或只差一分、参考题还没用过时:先连着出两道参考题(相处方式、做事风格),再出范围卡。
|
||||
- [ ] 两道答完后才出范围卡;卡上不再有「再答两道参考题微调排序」。
|
||||
- [ ] 若仍分不开,卡上有一句「这两分钟按现有信息分不开,参考题已经用过。」
|
||||
- [ ] 同一轮不会出现两条交付结论。
|
||||
|
||||
## BUG-687
|
||||
|
||||
- [ ] 范围卡文案不再出现已经答过「没有/跳过」的线(结婚、收入、添丁等)。
|
||||
- [ ] 能问的线都关闭时,写「能问的都问完了」,不暗示还有没问的线。
|
||||
+1
-1
@@ -234,7 +234,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
| `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled |
|
||||
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
|
||||
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
|
||||
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a “还能再收窄” caption under the title, then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold | enabled |
|
||||
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines (or “能问的都问完了”); optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过。”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. A close lead asks the two personality cards before this card appears. | enabled |
|
||||
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible |
|
||||
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
|
||||
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
|
||||
|
||||
@@ -120,13 +120,17 @@ import {
|
||||
interviewQuestionBlocksAdoptOffer,
|
||||
nextSelectionCardLock,
|
||||
parseTurnQuestion,
|
||||
persistedOfferFromTurn,
|
||||
questionIsAnswered,
|
||||
resolveSelectionCardMessageKey,
|
||||
type SelectionCardLock,
|
||||
type TurnQuestion,
|
||||
} from "@/lib/rectification-agentic/v9/turn-question";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
appendUnseenAssistantTurns,
|
||||
applySnapshotTurnsToMessages,
|
||||
mergeTurnQuestions,
|
||||
} from "@/lib/rectification-snapshot-messages";
|
||||
|
||||
type PersistedTurn = Readonly<{
|
||||
id: string;
|
||||
@@ -245,33 +249,6 @@ type RenderMessage = ChatMessageView & {
|
||||
candidateOffer?: Readonly<{ resultId: string }>;
|
||||
};
|
||||
|
||||
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
|
||||
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
|
||||
for (const item of turns) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
|
||||
if (typeof turn.id !== "string") continue;
|
||||
byId.set(turn.id, {
|
||||
question: parseTurnQuestion(turn.question),
|
||||
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
|
||||
});
|
||||
}
|
||||
return current.map((message) => {
|
||||
if (!message.turnId || !byId.has(message.turnId)) return message;
|
||||
const next = byId.get(message.turnId);
|
||||
const question = next?.question ?? undefined;
|
||||
return {
|
||||
...message,
|
||||
question: question ?? undefined,
|
||||
candidateOffer: persistedOfferFromTurn(
|
||||
next?.offerResultId,
|
||||
message.candidateOffer,
|
||||
true,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function markQuestionAnswered(
|
||||
current: RenderMessage[],
|
||||
focusId: string,
|
||||
@@ -295,16 +272,6 @@ function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonl
|
||||
return Array.isArray(payload?.turns) ? payload.turns : [];
|
||||
}
|
||||
|
||||
function appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
|
||||
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
|
||||
const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.turnId
|
||||
&& !known.has(message.turnId)
|
||||
));
|
||||
return extras.length ? [...current, ...extras] : current;
|
||||
}
|
||||
|
||||
function choiceCardFromQuestion(
|
||||
question: TurnQuestion,
|
||||
live: ChoiceCardModel | null,
|
||||
@@ -1194,7 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
|
||||
const withHistory = appendUnseenAssistantTurns(
|
||||
mergeTurnQuestions(withoutPlaceholder, turns),
|
||||
turns,
|
||||
messagesFromTurns(turns as readonly PersistedTurn[]),
|
||||
);
|
||||
if (withHistory.length > withoutPlaceholder.length) return withHistory;
|
||||
return [
|
||||
@@ -1396,7 +1363,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (!response.ok || payload?.ok !== true) {
|
||||
throw new Error(payload?.error || payload?.message || "暂时无法开始参考题");
|
||||
}
|
||||
await loadCaseSnapshot();
|
||||
await loadCaseSnapshot((turns) => {
|
||||
setMessages((current) => applySnapshotTurnsToMessages(
|
||||
current,
|
||||
turns,
|
||||
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
||||
));
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "暂时无法开始参考题");
|
||||
} finally {
|
||||
@@ -1579,7 +1552,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
questionMissing: currentQuestion === null,
|
||||
questionLoadFailed: questionSource === "unavailable" || deadChoice,
|
||||
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
|
||||
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
|
||||
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null,
|
||||
nextUserActionId,
|
||||
collectWaiting: collectWaiting && !deadChoice,
|
||||
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
|
||||
@@ -1622,7 +1595,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
},
|
||||
});
|
||||
async function refetchQuestion() {
|
||||
await loadCaseSnapshot();
|
||||
await loadCaseSnapshot((turns) => {
|
||||
setMessages((current) => applySnapshotTurnsToMessages(
|
||||
current,
|
||||
turns,
|
||||
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
||||
));
|
||||
});
|
||||
}
|
||||
async function repairQuestion() {
|
||||
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
|
||||
@@ -1650,7 +1629,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
);
|
||||
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
|
||||
} else {
|
||||
await loadCaseSnapshot();
|
||||
await loadCaseSnapshot((turns) => {
|
||||
setMessages((current) => applySnapshotTurnsToMessages(
|
||||
current,
|
||||
turns,
|
||||
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
||||
));
|
||||
});
|
||||
setQuestionRepairAttempts((current) => current + 1);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -59,6 +59,9 @@ export function RectificationRangeDelivery({
|
||||
{RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry}
|
||||
</button>
|
||||
) : null}
|
||||
{delivery?.tie_break_note ? (
|
||||
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>
|
||||
) : null}
|
||||
{sharedTraits.length > 0 ? (
|
||||
<ul className="rectification-range-delivery__shared">
|
||||
{sharedTraits.map((line) => (
|
||||
|
||||
@@ -197,6 +197,8 @@ export type RectificationDecision = Readonly<{
|
||||
droppedProbes: readonly DroppedProbe[];
|
||||
stopReason?: EvidenceStopReason | null;
|
||||
terminationCopy?: string | null;
|
||||
/** Close lead with unused D9/D10 style questions: ask those before delivering. */
|
||||
heldForTieBreak?: boolean;
|
||||
}>;
|
||||
|
||||
export type DecideRectificationInput = Readonly<{
|
||||
@@ -228,6 +230,8 @@ export type DecideRectificationInput = Readonly<{
|
||||
targetedCollectExhausted?: boolean;
|
||||
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
|
||||
openingCandidateRange?: readonly [string, string] | null;
|
||||
/** Unasked D9/D10 style questions remain. */
|
||||
pendingTieBreak?: boolean;
|
||||
}>;
|
||||
|
||||
function classifyStop(
|
||||
@@ -329,7 +333,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
}
|
||||
|
||||
if (userStopped && separation.ranked.length > 0) {
|
||||
return completeWithRange(separation, holdout, range, "user_stopped", rangeDeliveryCapability);
|
||||
return deliverRange(input, separation, holdout, range, "user_stopped", rangeDeliveryCapability);
|
||||
}
|
||||
|
||||
if (input.snapshotCurrent === false) {
|
||||
@@ -346,7 +350,8 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
&& !probe
|
||||
&& input.targetedCollectExhausted !== false
|
||||
) {
|
||||
return completeWithRange(
|
||||
return deliverRange(
|
||||
input,
|
||||
separation,
|
||||
holdout,
|
||||
range,
|
||||
@@ -367,6 +372,9 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
&& engineOffers
|
||||
&& !narrowingOpen
|
||||
) {
|
||||
if (shouldHoldForTieBreak(input, separation)) {
|
||||
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
|
||||
}
|
||||
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
|
||||
}
|
||||
return collect(
|
||||
@@ -382,7 +390,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
return collect(separation, holdout, range, probe, capability, stopClass.reason);
|
||||
}
|
||||
if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
||||
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
||||
}
|
||||
if (!separation.sufficient) {
|
||||
if (probe) {
|
||||
@@ -400,7 +408,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason);
|
||||
}
|
||||
if (stopClass?.kind === "exhausted") {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
||||
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
||||
}
|
||||
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
|
||||
return finish("adopt_representative", {
|
||||
@@ -413,10 +421,10 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
stopReason: "probe_pool_exhausted",
|
||||
});
|
||||
}
|
||||
return completeWithRange(separation, holdout, range, "offer", rangeDeliveryCapability);
|
||||
return deliverRange(input, separation, holdout, range, "offer", rangeDeliveryCapability);
|
||||
}
|
||||
if (stopClass?.kind === "exhausted") {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
||||
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
||||
}
|
||||
if (input.accepted) {
|
||||
return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", {
|
||||
@@ -448,9 +456,12 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
if (probe) {
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability);
|
||||
}
|
||||
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability);
|
||||
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability);
|
||||
}
|
||||
if (holdout === "unavailable") {
|
||||
if (shouldHoldForTieBreak(input, separation)) {
|
||||
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
|
||||
}
|
||||
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
|
||||
}
|
||||
return finish("adopt_representative", {
|
||||
@@ -479,7 +490,7 @@ function discriminateOrExhaust(
|
||||
stopReason: EvidenceStopReason | null = null,
|
||||
): RectificationDecision {
|
||||
if (budgetExhausted(input)) {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", capability, stopReason);
|
||||
return deliverRange(input, separation, holdout, range, "exhausted", capability, stopReason);
|
||||
}
|
||||
return discriminate(separation, holdout, range, probe, capability, stopReason);
|
||||
}
|
||||
@@ -565,6 +576,61 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
|
||||
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
|
||||
}
|
||||
|
||||
export function shouldHoldForTieBreak(
|
||||
input: Pick<DecideRectificationInput, "pendingTieBreak" | "userStopped">,
|
||||
separation: CandidateSeparation,
|
||||
kind?: "user_stopped" | "offer" | "exhausted",
|
||||
): boolean {
|
||||
if (kind === "user_stopped" || input.userStopped === true) return false;
|
||||
if (input.pendingTieBreak !== true) return false;
|
||||
if (separation.ranked.length < 2) return false;
|
||||
return separation.lead <= 1;
|
||||
}
|
||||
|
||||
function holdForTieBreak(
|
||||
separation: CandidateSeparation,
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
capability: DeliveryCapability,
|
||||
stopReason: EvidenceStopReason | null = null,
|
||||
): RectificationDecision {
|
||||
return {
|
||||
phase: "discrimination",
|
||||
nextAction: "ask_candidate_discriminator",
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
resultStatus: "discriminating",
|
||||
canOfferRange: false,
|
||||
...waitToNarrowCapability(capability),
|
||||
precisionStage: "theme_refine",
|
||||
activeFocusPolicy: "keep",
|
||||
completionStatus: null,
|
||||
validated: false,
|
||||
credibleRange: range,
|
||||
representativeTime: separation.representativeTime,
|
||||
separation,
|
||||
probe: null,
|
||||
holdoutValidation: holdout,
|
||||
droppedProbes: [],
|
||||
heldForTieBreak: true,
|
||||
...(stopReason ? { stopReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function deliverRange(
|
||||
input: DecideRectificationInput,
|
||||
separation: CandidateSeparation,
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
kind: "user_stopped" | "offer" | "exhausted",
|
||||
capability: DeliveryCapability,
|
||||
stopReason: EvidenceStopReason | null = null,
|
||||
): RectificationDecision {
|
||||
if (shouldHoldForTieBreak(input, separation, kind)) {
|
||||
return holdForTieBreak(separation, holdout, range, capability, stopReason);
|
||||
}
|
||||
return completeWithRange(separation, holdout, range, kind, capability, stopReason);
|
||||
}
|
||||
|
||||
function clockMinute(value: string): string {
|
||||
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
|
||||
if (!match) return value.trim().slice(0, 5);
|
||||
|
||||
@@ -148,6 +148,8 @@ export const RECTIFICATION_USER_COPY = {
|
||||
rangeDeliveryAdopted: "已采用",
|
||||
rangeDeliveryTieBreakEntry: "再答两道参考题微调排序",
|
||||
rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。",
|
||||
rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。",
|
||||
rangeDeliveryCollectClosed: "能问的都问完了",
|
||||
verificationReportSummary: "查看验证报告",
|
||||
} as const;
|
||||
|
||||
@@ -501,6 +503,8 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryAdopted,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed,
|
||||
RECTIFICATION_USER_COPY.verificationReportSummary,
|
||||
postAdoptVerifyDoneCopy("04:53", false),
|
||||
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
spokenCollectFallbackFollowup,
|
||||
spokenFollowupForUser,
|
||||
targetedCollectFollowup,
|
||||
tieBreakGateInput,
|
||||
tieBreakPersonalityFollowup,
|
||||
collectQuestionDomain,
|
||||
ledgerHasConfirmedDatedEvent,
|
||||
@@ -1019,6 +1020,30 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
};
|
||||
}
|
||||
}
|
||||
const heldFollowup = decision.heldForTieBreak === true
|
||||
? tieBreakPersonalityFollowup({ ...planInput, tieBreakRequested: true })
|
||||
: null;
|
||||
if (heldFollowup) {
|
||||
const persistedFocus = await persistFocusAfterChoice({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
|
||||
followup: heldFollowup,
|
||||
askedTurnId: input.askedTurnId ?? null,
|
||||
});
|
||||
const open = openQuestionFromPersistedFocus(persistedFocus);
|
||||
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
|
||||
return {
|
||||
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
||||
choiceReady: true,
|
||||
persisted: true,
|
||||
focusId: persistedFocus.focus?.id ?? null,
|
||||
focus: persistedFocus.focus,
|
||||
followup: heldFollowup,
|
||||
};
|
||||
}
|
||||
}
|
||||
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
|
||||
const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan);
|
||||
if (shouldSkipFollowupPersist({
|
||||
@@ -1229,22 +1254,18 @@ export async function requestTieBreakPersonality(input: {
|
||||
birthDate = null;
|
||||
}
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const followup = tieBreakPersonalityFollowup({
|
||||
const followup = tieBreakPersonalityFollowup(tieBreakGateInput({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
candidatesSeparated: false,
|
||||
...catalog,
|
||||
catalog,
|
||||
birthDate,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}),
|
||||
});
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}));
|
||||
if (!followup) {
|
||||
return {
|
||||
persisted: false,
|
||||
@@ -1631,6 +1652,38 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
});
|
||||
}
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
if (decision.heldForTieBreak === true) {
|
||||
const heldFollowup = tieBreakPersonalityFollowup(tieBreakGateInput({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
catalog,
|
||||
birthDate,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}));
|
||||
if (heldFollowup) {
|
||||
const persistedFocus = await persistFocusAfterChoice({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
decisionReceipt: dossier.latestResult?.decisionReceipt,
|
||||
followup: heldFollowup,
|
||||
askedTurnId: input.askedTurnId ?? null,
|
||||
});
|
||||
const open = openQuestionFromPersistedFocus(persistedFocus);
|
||||
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
|
||||
return finishIdle({
|
||||
persisted: true,
|
||||
choiceReady: true,
|
||||
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: dossier.evidence,
|
||||
activeFocus: null,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/ev
|
||||
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
|
||||
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
|
||||
import { rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
|
||||
import { tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
|
||||
import { tieBreakGateInput, tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
|
||||
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
|
||||
|
||||
export function dossierResponse(
|
||||
@@ -106,15 +106,21 @@ function publicLatestResult(
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
tieBreakAvailable: tieBreakPersonalityAvailable({
|
||||
tieBreakAvailable: tieBreakPersonalityAvailable(tieBreakGateInput({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
candidatesSeparated: false,
|
||||
...catalog,
|
||||
catalog,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
}),
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
})),
|
||||
tieBreakUsed: (catalog.askedProbeKeys ?? []).some((key) => (
|
||||
key.includes("varga.d9") || key.includes("varga.d10")
|
||||
)),
|
||||
stillTied: decision.separation.lead <= 1 && decision.separation.ranked.length >= 2,
|
||||
});
|
||||
const withDelivery = {
|
||||
...projected,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* value, not a fixed domain rotation. Age-band years never enter prompts.
|
||||
*/
|
||||
|
||||
import { USER_COLLECT_QUESTION } from "../user-copy.ts";
|
||||
import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../user-copy.ts";
|
||||
import { canonicalCollectDomain } from "./domain-alias.ts";
|
||||
|
||||
export const COLLECT_KIND_ORDER = [
|
||||
@@ -825,7 +825,6 @@ export function targetedCollectHintFromPool(
|
||||
return targetedCollectHint(items[0]);
|
||||
}
|
||||
|
||||
/** Card copy ignores a declined targeted collect so the delivered range still says what would narrow it. */
|
||||
export function rangeNarrowHint(
|
||||
remainingLayers: readonly string[],
|
||||
evidence: readonly CollectionEvidence[],
|
||||
@@ -834,7 +833,21 @@ export function rangeNarrowHint(
|
||||
candidateCount?: number,
|
||||
credibleRange?: readonly [string, string] | null,
|
||||
): string {
|
||||
const open = targetedCollectPool(remainingLayers, evidence, [], splitTimes, candidateCount, credibleRange);
|
||||
const open = targetedCollectPool(
|
||||
remainingLayers,
|
||||
evidence,
|
||||
declinedTopics,
|
||||
splitTimes,
|
||||
candidateCount,
|
||||
credibleRange,
|
||||
);
|
||||
if (open.length === 0) {
|
||||
const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes);
|
||||
if (range && (candidateCount ?? 0) > 0) {
|
||||
return `现在还剩 ${range[0]}–${range[1]} 里 ${candidateCount} 个候选。${RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed}`;
|
||||
}
|
||||
return RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed;
|
||||
}
|
||||
const remaining = remainingCandidatesLine(
|
||||
splitTimes,
|
||||
candidateCount ?? 0,
|
||||
@@ -842,14 +855,6 @@ export function rangeNarrowHint(
|
||||
credibleRange,
|
||||
);
|
||||
const hint = targetedCollectHintFromPool(open)
|
||||
?? targetedCollectHint(targetedCollectPool(
|
||||
remainingLayers,
|
||||
evidence,
|
||||
declinedTopics,
|
||||
splitTimes,
|
||||
candidateCount,
|
||||
credibleRange,
|
||||
)[0])
|
||||
?? `还能再收窄:${moreCollectHint(evidence, declinedTopics)}`;
|
||||
if (remaining && !hint.startsWith(remaining)) {
|
||||
return `${remaining}。${hint}`;
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
eventFamilyForDiscriminator,
|
||||
exhaustionSpokenCollectFollowup,
|
||||
isRemainingEvidenceCollect,
|
||||
tieBreakGateInput,
|
||||
tieBreakPersonalityAvailable,
|
||||
} from "./method-followup";
|
||||
import { buildConfirmationGate } from "./confirmation-gate";
|
||||
import {
|
||||
@@ -767,6 +769,18 @@ export function decideFromDossier(
|
||||
): RectificationDecision {
|
||||
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
|
||||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||||
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
catalog,
|
||||
birthDate: options?.birthDate,
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}));
|
||||
const oosBlindPrompts = refinementFromDecisionReceipt(
|
||||
dossier.latestResult?.decisionReceipt ?? null,
|
||||
).oos_blind_prompts;
|
||||
@@ -878,6 +892,7 @@ export function decideFromDossier(
|
||||
),
|
||||
windowWidenSuggested,
|
||||
openingCandidateRange: openingRangeFromCandidateRange(dossier.case.candidateRange),
|
||||
pendingTieBreak,
|
||||
...narrowingExhaustion(dossier, inference, options, catalog),
|
||||
}),
|
||||
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
|
||||
@@ -892,6 +907,18 @@ export function decideAfterInferenceChange(input: {
|
||||
snapshotCurrent?: boolean;
|
||||
}): RectificationDecision {
|
||||
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
|
||||
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
|
||||
evidence: input.dossier.evidence,
|
||||
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics,
|
||||
catalog,
|
||||
birthDate: input.birthDate,
|
||||
accepted: Boolean(input.dossier.case.acceptedTime),
|
||||
stage: input.dossier.case.stage,
|
||||
blockScan: input.dossier.case.blockScan,
|
||||
reportedBirthTime: input.dossier.case.reportedBirthTime,
|
||||
candidateRange: input.dossier.case.candidateRange,
|
||||
}));
|
||||
const collecting = buildMethodFollowupPlan({
|
||||
evidence: input.dossier.evidence,
|
||||
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
|
||||
@@ -947,6 +974,7 @@ export function decideAfterInferenceChange(input: {
|
||||
evidenceLedgerFingerprint(input.dossier.evidence as never),
|
||||
),
|
||||
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
|
||||
pendingTieBreak,
|
||||
});
|
||||
}
|
||||
const training = input.state.events.filter((item) => item.usage === "training");
|
||||
@@ -1024,6 +1052,7 @@ export function decideAfterInferenceChange(input: {
|
||||
evidenceLedgerFingerprint(input.dossier.evidence as never),
|
||||
),
|
||||
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
|
||||
pendingTieBreak,
|
||||
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
|
||||
}),
|
||||
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "../core/types.ts";
|
||||
import {
|
||||
RANGE_DELIVERY_MORE_MINUTES,
|
||||
RECTIFICATION_USER_COPY,
|
||||
REPRESENTATIVE_MINUTE_DISCLAIMER,
|
||||
rangeDeliveryFitLine,
|
||||
rangeDeliveryWindowLine,
|
||||
@@ -82,6 +83,7 @@ export type RangeDeliveryProjection = Readonly<{
|
||||
verification_markdown: string | null;
|
||||
narrow_hint: string | null;
|
||||
tie_break_available: boolean;
|
||||
tie_break_note: string | null;
|
||||
}>;
|
||||
|
||||
export type PublicCandidateClock = Readonly<{
|
||||
@@ -334,6 +336,8 @@ export function buildRangeDelivery(input: {
|
||||
}>[];
|
||||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||||
tieBreakAvailable?: boolean;
|
||||
tieBreakUsed?: boolean;
|
||||
stillTied?: boolean;
|
||||
}): RangeDeliveryProjection {
|
||||
const inference = input.inference;
|
||||
const range = input.credibleRange
|
||||
@@ -419,6 +423,11 @@ export function buildRangeDelivery(input: {
|
||||
inference?.credible_range,
|
||||
),
|
||||
tie_break_available: input.tieBreakAvailable === true,
|
||||
tie_break_note: input.tieBreakAvailable !== true
|
||||
&& input.tieBreakUsed === true
|
||||
&& input.stillTied === true
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -477,6 +486,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
|
||||
window_scan?: unknown;
|
||||
accepted?: boolean;
|
||||
tieBreakAvailable?: boolean;
|
||||
tieBreakUsed?: boolean;
|
||||
stillTied?: boolean;
|
||||
} | null | undefined): RangeDeliveryProjection {
|
||||
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
|
||||
const inference = previousInferenceFromReceipt(receipt);
|
||||
@@ -507,6 +518,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
|
||||
// judgment the tie-break route uses. Callers pass it; window_scan
|
||||
// alone must not keep the button after both cards are answered.
|
||||
tieBreakAvailable: snapshot?.accepted !== true && snapshot?.tieBreakAvailable === true,
|
||||
tieBreakUsed: snapshot?.tieBreakUsed === true,
|
||||
stillTied: snapshot?.stillTied === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -649,5 +662,10 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
|
||||
? row.narrowHint.trim()
|
||||
: null,
|
||||
tie_break_available: row.tie_break_available === true || row.tieBreakAvailable === true,
|
||||
tie_break_note: typeof row.tie_break_note === "string" && row.tie_break_note.trim()
|
||||
? row.tie_break_note.trim()
|
||||
: typeof row.tieBreakNote === "string" && row.tieBreakNote.trim()
|
||||
? row.tieBreakNote.trim()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
WIDEN_WINDOW_INTENT,
|
||||
blockPeriodsForChoice,
|
||||
buildBlockChoiceFrame,
|
||||
followupCaseArgs,
|
||||
type BlockScanBlock,
|
||||
type BlockScanPayload,
|
||||
type RectificationCaseStage,
|
||||
@@ -3015,6 +3016,53 @@ export function buildMethodFollowupPlan(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function tieBreakGateInput(input: {
|
||||
evidence: Parameters<typeof buildMethodFollowupPlan>[0]["evidence"];
|
||||
declinedTopics?: Parameters<typeof buildMethodFollowupPlan>[0]["declinedTopics"];
|
||||
closedCollectFocuses?: Parameters<typeof buildMethodFollowupPlan>[0]["closedCollectFocuses"];
|
||||
catalog: Pick<
|
||||
Parameters<typeof buildMethodFollowupPlan>[0],
|
||||
| "remainingLayers"
|
||||
| "remainingSplitTimes"
|
||||
| "remainingCandidateCount"
|
||||
| "remainingCredibleRange"
|
||||
| "answeredProbes"
|
||||
| "eventProbes"
|
||||
| "contrastPacket"
|
||||
| "topCandidateTimes"
|
||||
| "askedProbeKeys"
|
||||
| "eventClarificationProbes"
|
||||
| "evidenceCollectionProbes"
|
||||
| "precisionStage"
|
||||
| "nakshatraProbe"
|
||||
| "oosBlindPrompts"
|
||||
| "holdoutEvents"
|
||||
>;
|
||||
birthDate?: string | null;
|
||||
accepted?: boolean;
|
||||
stage?: string | null;
|
||||
blockScan?: BlockScanPayload | null;
|
||||
reportedBirthTime?: string | null;
|
||||
candidateRange?: { start_time?: string; end_time?: string } | null;
|
||||
}): Parameters<typeof tieBreakPersonalityFollowup>[0] & { accepted?: boolean } {
|
||||
return {
|
||||
evidence: input.evidence,
|
||||
declinedTopics: input.declinedTopics ?? [],
|
||||
closedCollectFocuses: input.closedCollectFocuses ?? input.declinedTopics ?? [],
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
candidatesSeparated: false,
|
||||
...input.catalog,
|
||||
birthDate: input.birthDate ?? null,
|
||||
accepted: input.accepted === true,
|
||||
...followupCaseArgs({
|
||||
stage: input.stage,
|
||||
blockScan: input.blockScan,
|
||||
reportedBirthTime: input.reportedBirthTime,
|
||||
candidateRange: input.candidateRange,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function tieBreakPersonalityFollowup(
|
||||
input: Parameters<typeof buildMethodFollowupPlan>[0],
|
||||
): MethodFollowup | null {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { parseTurnQuestion, persistedOfferFromTurn, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
|
||||
|
||||
export type SnapshotTurnMessage = {
|
||||
role: "assistant" | "user";
|
||||
turnId?: string;
|
||||
text: string;
|
||||
renderKey: string;
|
||||
question?: TurnQuestion;
|
||||
candidateOffer?: { resultId: string };
|
||||
};
|
||||
|
||||
export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
|
||||
current: T[],
|
||||
turns: readonly unknown[],
|
||||
): T[] {
|
||||
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
|
||||
for (const item of turns) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
|
||||
if (typeof turn.id !== "string") continue;
|
||||
byId.set(turn.id, {
|
||||
question: parseTurnQuestion(turn.question),
|
||||
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
|
||||
});
|
||||
}
|
||||
return current.map((message) => {
|
||||
if (!message.turnId || !byId.has(message.turnId)) return message;
|
||||
const next = byId.get(message.turnId);
|
||||
const question = next?.question ?? undefined;
|
||||
return {
|
||||
...message,
|
||||
question: question ?? undefined,
|
||||
candidateOffer: persistedOfferFromTurn(
|
||||
next?.offerResultId,
|
||||
message.candidateOffer,
|
||||
true,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
|
||||
current: T[],
|
||||
extras: readonly T[],
|
||||
): T[] {
|
||||
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
|
||||
const incoming = extras.filter((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.turnId
|
||||
&& !known.has(message.turnId)
|
||||
));
|
||||
return incoming.length ? [...current, ...incoming] : current;
|
||||
}
|
||||
|
||||
export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
|
||||
current: T[],
|
||||
turns: readonly unknown[],
|
||||
extrasFromTurns: (turns: readonly unknown[]) => T[],
|
||||
): T[] {
|
||||
return mergeTurnQuestions(
|
||||
appendUnseenAssistantTurns(current, extrasFromTurns(turns)),
|
||||
turns,
|
||||
);
|
||||
}
|
||||
@@ -321,10 +321,11 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
|
||||
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
|
||||
if (!input.snapshotLoaded) return retryGate;
|
||||
if (!input.resumableCase) return "idle";
|
||||
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
|
||||
if (input.liveQuestionVisible) return "idle";
|
||||
if (interviewDeliveredGap(input)) return "delivered";
|
||||
if (input.collectWaiting) return "collect_waiting";
|
||||
if (input.questionPersisted) return "persisted_question";
|
||||
if (input.offerAwaitingReader) return "idle";
|
||||
if (input.nextUserActionId === "start_consultation") return "verified_idle";
|
||||
if (input.questionLoadFailed) return "unavailable";
|
||||
// Either the snapshot names no question, or it names one that no settled
|
||||
|
||||
@@ -621,7 +621,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
// 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。
|
||||
assert.match(chat, /canShowRectificationSelectionCards/);
|
||||
assert.match(chat, /applyLiveCandidateOffer/);
|
||||
assert.match(chat, /persistedOfferFromTurn/);
|
||||
// 原值: chat 源码含 persistedOfferFromTurn(mergeTurnQuestions 写在组件内)
|
||||
// 新值: 合并抽到 rectification-snapshot-messages.ts,仍调用 persistedOfferFromTurn
|
||||
// 原因: BUG-685 要把新 turn 并进对话区,合并逻辑必须可单测
|
||||
const snapshotMessages = readFileSync(new URL("../src/lib/rectification-snapshot-messages.ts", import.meta.url), "utf8");
|
||||
assert.match(snapshotMessages, /persistedOfferFromTurn/);
|
||||
assert.match(chat, /applySnapshotTurnsToMessages|mergeTurnQuestions/);
|
||||
assert.match(chat, /resolveSelectionCardMessageKey/);
|
||||
assert.match(chat, /keepSelectionCardsWhileBusy/);
|
||||
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
|
||||
|
||||
@@ -835,7 +835,11 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前
|
||||
assert.match(title, /^目前范围 04:48–05:07(对照了 4 件经历)$/);
|
||||
assert.doesNotMatch(title, /这次给出|最终|结束| · /);
|
||||
assert.match(delivery.narrow_hint ?? "", /现在还剩 04:48–05:07 里 6 个候选/);
|
||||
assert.match(delivery.narrow_hint ?? "", /还能再收窄:如果记得/);
|
||||
// 原值: /还能再收窄:如果记得/(忽略全部拒答,仍列出可收窄的线)
|
||||
// 新值: 全部定向补事已拒答,收口「能问的都问完了」
|
||||
// 原因: BUG-687
|
||||
assert.match(delivery.narrow_hint ?? "", /能问的都问完了/);
|
||||
assert.doesNotMatch(delivery.narrow_hint ?? "", /还能再收窄:如果记得/);
|
||||
assert.doesNotMatch(delivery.narrow_hint ?? "", /能把 04:48 和 05:07 分开/);
|
||||
assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/);
|
||||
const publicAction = publicNextAction(decision);
|
||||
@@ -871,7 +875,7 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
assert.match(idle.hostNarration ?? "", /目前范围|还能再收窄/);
|
||||
assert.match(idle.hostNarration ?? "", /目前范围|能问的都问完了/);
|
||||
assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/);
|
||||
assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/);
|
||||
});
|
||||
|
||||
@@ -205,7 +205,11 @@ test("personality D9/D10 stay out of the scoring pool until the range-card opt-i
|
||||
},
|
||||
tieBreakAvailable: tieBreakPersonalityAvailable(planInput),
|
||||
});
|
||||
assert.match(delivery.narrow_hint ?? "", /还能再收窄/);
|
||||
// 原值: /还能再收窄/(rangeNarrowHint 忽略拒答,仍列出搬家/财务)
|
||||
// 新值: 拒答后不再列出那些线;此 fixture 剩余线都已覆盖或拒答,收口「能问的都问完了」
|
||||
// 原因: BUG-687 交付文案不得列出已关闭的线
|
||||
assert.match(delivery.narrow_hint ?? "", /能问的都问完了/);
|
||||
assert.doesNotMatch(delivery.narrow_hint ?? "", /搬家|收入/);
|
||||
assert.doesNotMatch(delivery.narrow_hint ?? "", SPLIT_ENDPOINT_PHRASE);
|
||||
assert.equal(delivery.tie_break_available, true);
|
||||
const incompleteScan = rangeDeliveryForSnapshot({
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
decideRectification,
|
||||
publicCanAdopt,
|
||||
sessionOutcomeAllowsDelivery,
|
||||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { rangeNarrowHint } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
|
||||
import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
|
||||
import {
|
||||
tieBreakGateInput,
|
||||
tieBreakPersonalityAvailable,
|
||||
tieBreakPersonalityFollowup,
|
||||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import {
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
rectificationQuestionGapState,
|
||||
} from "../src/lib/rectification-surface-state.ts";
|
||||
import { applySnapshotTurnsToMessages } from "../src/lib/rectification-snapshot-messages.ts";
|
||||
import type { TurnQuestion } from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
|
||||
import { alreadyDelivered, markDeliveryTurn, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
|
||||
|
||||
function contrastProbe(layer: "d9" | "d10") {
|
||||
const signs = layer === "d9" ? ["巨蟹座", "狮子座"] as const : ["狮子座", "处女座"];
|
||||
return {
|
||||
probeId: `contrast:varga.${layer}.${signs[0]}/${signs[1]}`,
|
||||
candidateSetVersion: "04:48-05:07",
|
||||
question: layer === "d9"
|
||||
? "亲密关系里更接近下面哪一种相处方式?"
|
||||
: "平时做事,你更接近下面哪一种?",
|
||||
expectedOutcomes: [
|
||||
{ outcomeId: "yes", supportsCandidateIds: ["04:53"], conflictsCandidateIds: ["05:06"] },
|
||||
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:06"], conflictsCandidateIds: ["04:53"] },
|
||||
],
|
||||
candidateSplitHash: `varga.${layer}.${signs[0]}/${signs[1]}`,
|
||||
informationGain: layer === "d9" ? 1.4 : 0.9,
|
||||
sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }],
|
||||
domain: layer === "d9" ? "relationship" : "career",
|
||||
year: null,
|
||||
semanticKey: `varga.${layer}.${signs[0]}/${signs[1]}`,
|
||||
choiceKind: "varga_style" as const,
|
||||
styleOptions: [
|
||||
{ label: "相处里更在意照顾对方的感受", answerClass: "yes" as const, sign: signs[0] },
|
||||
{ label: "习惯带头,也不排斥站到台前", answerClass: "weak_yes" as const, sign: signs[1] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const D9 = contrastProbe("d9");
|
||||
const D10 = contrastProbe("d10");
|
||||
|
||||
function planInput(askedProbeKeys: readonly string[] = []) {
|
||||
return {
|
||||
evidence: [
|
||||
{
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-09-01",
|
||||
occurredTo: "2016-09-30",
|
||||
eventKind: "education_start",
|
||||
summary: "上大学",
|
||||
},
|
||||
{
|
||||
status: "confirmed",
|
||||
domain: "career",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2020-04-01",
|
||||
occurredTo: null,
|
||||
eventKind: "career_entry",
|
||||
summary: "入职实习",
|
||||
},
|
||||
{
|
||||
status: "confirmed",
|
||||
domain: "relationship",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2024-05-01",
|
||||
occurredTo: null,
|
||||
eventKind: "relationship_start",
|
||||
summary: "确定关系",
|
||||
},
|
||||
],
|
||||
declinedTopics: [],
|
||||
sessionOutcome: "discriminate_candidates" as const,
|
||||
candidatesSeparated: false,
|
||||
remainingLayers: ["d9", "d10"],
|
||||
remainingSplitTimes: ["04:48", "05:07"] as const,
|
||||
remainingCandidateCount: 4,
|
||||
topCandidateTimes: ["04:48", "04:53", "05:06", "05:07"],
|
||||
askedProbeKeys,
|
||||
contrastPacket: {
|
||||
candidateSetVersion: "04:48-05:07",
|
||||
vargaDifferences: [],
|
||||
probes: [D9, D10],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tiedDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
|
||||
return decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: [
|
||||
{ time: "04:53", score: 16 },
|
||||
{ time: "05:00", score: 16 },
|
||||
{ time: "05:06", score: 15 },
|
||||
{ time: "04:51", score: 14 },
|
||||
{ time: "04:59", score: 13 },
|
||||
],
|
||||
discriminatorProbe: null,
|
||||
holdoutValidation: "unavailable",
|
||||
datedEventCount: 3,
|
||||
datedDomainCount: 3,
|
||||
targetedCollectExhausted: true,
|
||||
refreshExhausted: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("a tie-break snapshot turn is merged into messages with its question", () => {
|
||||
type Message = {
|
||||
role: "assistant";
|
||||
text: string;
|
||||
renderKey: string;
|
||||
turnId: string;
|
||||
question?: TurnQuestion;
|
||||
};
|
||||
const current: Message[] = [{
|
||||
role: "assistant",
|
||||
text: "目前范围 04:48–05:07",
|
||||
renderKey: "existing",
|
||||
turnId: "turn-1",
|
||||
}];
|
||||
const turns = [
|
||||
{ id: "turn-1", role: "assistant", text: "目前范围 04:48–05:07" },
|
||||
{
|
||||
id: "turn-2",
|
||||
role: "assistant",
|
||||
text: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
||||
question: {
|
||||
focus_id: "focus-d9",
|
||||
question_id: "varga.d9.巨蟹座/狮子座",
|
||||
kind: "choice",
|
||||
prompt: D9.question,
|
||||
options: [
|
||||
{ key: "A", label: "相处里更在意照顾对方的感受" },
|
||||
{ key: "B", label: "习惯带头,也不排斥站到台前" },
|
||||
{ key: "C", label: "一时说不好" },
|
||||
{ key: "D", label: "先这样" },
|
||||
],
|
||||
status: "active",
|
||||
answer_option: null,
|
||||
probe_id: D9.probeId,
|
||||
},
|
||||
},
|
||||
];
|
||||
const next = applySnapshotTurnsToMessages(current, turns, (incoming): Message[] => (
|
||||
incoming.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const turn = item as { id?: string; role?: string; text?: string; question?: unknown };
|
||||
if (turn.role !== "assistant" || typeof turn.id !== "string") return [];
|
||||
return [{
|
||||
role: "assistant" as const,
|
||||
text: turn.text ?? "",
|
||||
renderKey: turn.id,
|
||||
turnId: turn.id,
|
||||
}];
|
||||
})
|
||||
));
|
||||
assert.equal(next.length, 2);
|
||||
assert.equal(next[1]?.turnId, "turn-2");
|
||||
assert.equal(next[1]?.text, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck);
|
||||
assert.equal(next[1]?.question?.prompt, D9.question);
|
||||
assert.equal(next[1]?.question?.kind, "choice");
|
||||
});
|
||||
|
||||
test("a delivery card plus an unanswered focus is not an idle gap", () => {
|
||||
assert.equal(
|
||||
rectificationQuestionGapState({
|
||||
liveQuestionVisible: false,
|
||||
questionMissing: false,
|
||||
questionLoadFailed: false,
|
||||
questionPersisted: true,
|
||||
offerAwaitingReader: true,
|
||||
busy: false,
|
||||
readonly: false,
|
||||
regenerating: false,
|
||||
snapshotLoaded: true,
|
||||
resumableCase: true,
|
||||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
sessionOutcome: "completed_with_range",
|
||||
}),
|
||||
"persisted_question",
|
||||
);
|
||||
});
|
||||
|
||||
test("a first-place tie with unused personality questions holds delivery", () => {
|
||||
const decision = tiedDecision({ pendingTieBreak: true });
|
||||
assert.equal(decision.heldForTieBreak, true);
|
||||
assert.notEqual(decision.nextAction, "complete_with_range");
|
||||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
|
||||
assert.equal(publicCanAdopt(decision), false);
|
||||
const followup = tieBreakPersonalityFollowup(planInput());
|
||||
assert.equal(followup?.choice_kind, "varga_style");
|
||||
assert.match(followup?.semantic_key ?? "", /varga\.d9/);
|
||||
});
|
||||
|
||||
test("the same tie delivers after both personality questions are used", () => {
|
||||
const bothAsked = planInput([D9.semanticKey, D10.semanticKey]);
|
||||
assert.equal(tieBreakPersonalityAvailable(bothAsked), false);
|
||||
const decision = tiedDecision({ pendingTieBreak: false });
|
||||
assert.equal(decision.heldForTieBreak ?? false, false);
|
||||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
||||
assert.equal(rangeDeliveryForSnapshot({
|
||||
tieBreakAvailable: tieBreakPersonalityAvailable(bothAsked),
|
||||
tieBreakUsed: true,
|
||||
stillTied: decision.separation.lead <= 1,
|
||||
}).tie_break_available, false);
|
||||
assert.equal(
|
||||
rangeDeliveryForSnapshot({
|
||||
tieBreakAvailable: false,
|
||||
tieBreakUsed: true,
|
||||
stillTied: true,
|
||||
}).tie_break_note,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
|
||||
);
|
||||
});
|
||||
|
||||
test("a two-point lead does not insert personality questions before delivery", () => {
|
||||
const decision = tiedDecision({
|
||||
pendingTieBreak: true,
|
||||
candidateScores: [
|
||||
{ time: "04:53", score: 16 },
|
||||
{ time: "05:00", score: 14 },
|
||||
{ time: "05:06", score: 13 },
|
||||
],
|
||||
});
|
||||
assert.equal(decision.heldForTieBreak ?? false, false);
|
||||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
||||
});
|
||||
|
||||
test("holding for a tie-break does not mark a delivery turn", () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
const decision = tiedDecision({ pendingTieBreak: true });
|
||||
assert.equal(decision.heldForTieBreak, true);
|
||||
markDeliveryTurn({ caseId: "case-1", resultId: "result-1" });
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-1",
|
||||
resultId: "result-1",
|
||||
hasUserMessage: false,
|
||||
}), true);
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-1",
|
||||
resultId: "result-2",
|
||||
hasUserMessage: false,
|
||||
}), false);
|
||||
resetDeliveryTurnGuardForTests();
|
||||
});
|
||||
|
||||
test("range copy omits declined lines and closes when none remain", () => {
|
||||
const layers = ["d9", "d2", "d7", "d4"];
|
||||
const evidence = [
|
||||
{
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2016-09-01",
|
||||
occurredTo: null,
|
||||
eventKind: "education_start",
|
||||
},
|
||||
{
|
||||
status: "confirmed",
|
||||
domain: "career",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2020-04-01",
|
||||
occurredTo: null,
|
||||
eventKind: "career_entry",
|
||||
},
|
||||
{
|
||||
status: "confirmed",
|
||||
domain: "relocation",
|
||||
datePrecision: "month",
|
||||
occurredFrom: "2018-07-01",
|
||||
occurredTo: null,
|
||||
eventKind: "relocation",
|
||||
},
|
||||
];
|
||||
const declined = [
|
||||
{ questionId: "collect:targeted:relationship", status: "declined", target_kind: "targeted:relationship" },
|
||||
{ questionId: "collect:targeted:finance", status: "declined", target_kind: "targeted:finance" },
|
||||
{ questionId: "collect:targeted:family", status: "declined", target_kind: "targeted:family" },
|
||||
];
|
||||
const openCopy = rangeNarrowHint(layers, evidence, [], ["04:48", "05:07"], 5, ["04:48", "05:07"]);
|
||||
assert.match(openCopy, /结婚/);
|
||||
assert.match(openCopy, /收入/);
|
||||
assert.match(openCopy, /添丁/);
|
||||
const copy = rangeNarrowHint(layers, evidence, declined, ["04:48", "05:07"], 5, ["04:48", "05:07"]);
|
||||
assert.doesNotMatch(copy, /结婚/);
|
||||
assert.doesNotMatch(copy, /收入/);
|
||||
assert.doesNotMatch(copy, /添丁/);
|
||||
assert.match(copy, /能问的都问完了/);
|
||||
});
|
||||
|
||||
test("GET availability and the POST gate share one input builder", () => {
|
||||
const openPlan = planInput();
|
||||
const open = tieBreakGateInput({
|
||||
evidence: openPlan.evidence,
|
||||
declinedTopics: [],
|
||||
catalog: {
|
||||
remainingLayers: openPlan.remainingLayers,
|
||||
remainingSplitTimes: openPlan.remainingSplitTimes,
|
||||
remainingCandidateCount: openPlan.remainingCandidateCount,
|
||||
contrastPacket: openPlan.contrastPacket,
|
||||
askedProbeKeys: openPlan.askedProbeKeys,
|
||||
topCandidateTimes: openPlan.topCandidateTimes,
|
||||
},
|
||||
accepted: false,
|
||||
stage: "minute",
|
||||
candidateRange: { start_time: "04:45", end_time: "05:15" },
|
||||
});
|
||||
const closed = tieBreakGateInput({
|
||||
evidence: openPlan.evidence,
|
||||
declinedTopics: [],
|
||||
catalog: {
|
||||
remainingLayers: openPlan.remainingLayers,
|
||||
remainingSplitTimes: openPlan.remainingSplitTimes,
|
||||
remainingCandidateCount: openPlan.remainingCandidateCount,
|
||||
contrastPacket: openPlan.contrastPacket,
|
||||
askedProbeKeys: [D9.semanticKey, D10.semanticKey],
|
||||
topCandidateTimes: openPlan.topCandidateTimes,
|
||||
},
|
||||
accepted: false,
|
||||
stage: "minute",
|
||||
candidateRange: { start_time: "04:45", end_time: "05:15" },
|
||||
});
|
||||
assert.equal(tieBreakPersonalityAvailable(open), true);
|
||||
assert.equal(Boolean(tieBreakPersonalityFollowup(open)?.choice_kind === "varga_style"), true);
|
||||
assert.equal(tieBreakPersonalityAvailable(closed), false);
|
||||
assert.equal(tieBreakPersonalityFollowup(closed), null);
|
||||
});
|
||||
Reference in New Issue
Block a user