fix(rectification): deliver range when dated discriminator pool is empty (BUG-651/652)

When dated choice probes are exhausted after the training gate, stop treating yearless D9/D10 cards as the next discriminator and persist a range carrier in the same answer transaction.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-11 14:33:39 +08:00
co-authored by Cursor
parent 1fa994ea63
commit 66f63c7643
15 changed files with 895 additions and 134 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-11 — 带年月选择题问完后会给出当前范围
生时校正里,训练门已经打开、带年月的选择题问完后,会直接给出当前范围和三列对照卡,并补一句还可以再想起什么来收一截。不会停在「没有拿到下一个问题」,也不会先逼你答性格对照题。时间线有卡时写「选择题已问完,下面是当前范围」;材料还不够时才请再说一件带年月的事。Skill 版本不变。
## 2026-09-11 — 职业题答出的年月会记成可评分的工作经历
生时校正问「你平时主要做什么工作?」时,如果一并说出开始年月,会同时记下职业备注和一件可评分的入职经历,不再把年月抹掉。只说工种、没有年月时,仍只记备注、不拿它凑三件。开始年份改由毕业后第一份工作这类锚定题来问。Skill 版本不变。
+32
View File
@@ -10116,3 +10116,35 @@
- 相关记录:BUG-646、BUG-596、BUG-649
- 复发自:BUG-596(禁止第二条门槛 turn 后缺口没并入正文)
- 修复版本:待发布
## BUG-651 | 带年月区分题池空必须交付区间,不得改问性格题
- 状态:resolved
- 首次发现:2026-09-11
- 最近更新:2026-09-11
- 影响面:`decideRectification``discriminatorProbeIfFollowupCanAsk``buildMethodFollowupPlan`
- 用户现象:训练门已开、带年月选择题问完后,决策仍是 `discriminate_candidates`,界面没有结果卡。
- 触发条件:训练门开、`acceptance_allowed` 为真、带年月探针无可问,只剩 D9/D10 `varga_style` 或月宿边界题。
- 根因:`!separation.sufficient` 把无年月性格题当成下一道区分题;`buildMethodFollowupPlan` 在带年月池空时走 `firstRenderableYearlessFollowup` / 性格卡。重设计单 S3「探针池空 → 交付」以带年月题为准,性格题不得挡在结果前面。
- 修复:probe 只认带年月探针;性格题 / 月宿一律 `yearless_deferred`,不再作为 `ask_candidate_discriminator`。池空时按 `canAdopt` 落到 `offer_provisional_range` / `adopt_representative`。本单按让步顺序先不问性格题,可选「再答两道参考题」入口另开一单。
- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts``rectification-yearless-probe-downgrade-20260909.test.ts` 带年月池空不再出 D9。
- 防复发:不得把 `varga_style` / `nakshatra_boundary` 加回带年月区分池(BUG-629);不得让性格题参与淘汰。
- 相关记录:BUG-629、BUG-442、BUG-627、BUG-652
- 复发自:BUG-629(性格题降权后仍被当成必答区分题)
- 修复版本:待发布
## BUG-652 | 答题事务有决策无载体时必须回落到交付或缺口句
- 状态:resolved
- 首次发现:2026-09-11
- 最近更新:2026-09-11
- 影响面:`persistNextInterviewIfIdle``persistNextInterviewAfterChoice``persistServerOwnedFocus`、时间线 `rectificationReadonlyRangeCopy`
- 用户现象:六道带年月选择题答完后,助手只回「已记录,范围没变…」,时间线写「选择题已问完,再补带年月的经历才会变」,界面「没有拿到下一个问题。」没有结果卡、没有采用入口、没有缺口句。
- 触发条件:训练门已开、引擎已允许交付,决策仍是 `ask_candidate_discriminator`,但下一张区分卡没落下(persist 返回 skipped / invalid_choice_schema / 空正文)。
- 根因:`persistNextInterviewIfIdle``ask_candidate_discriminator` / `ask_holdout_validation` 豁免了 `persistExhaustionCollect``persistNextInterviewAfterChoice` 在焦点不可渲染时用 `spokenFollowupForUser(followup) ?? fallback`,空串不触发兜底。时间线在没有结果卡时仍写「才会变」。
- 修复:撤回 IfIdle 豁免;答题事务在无可渲染焦点且无正文时同一事务回落到 `persistExhaustionCollect`persist 非 created/already_open 时 `console.warn` `rectification_discriminator_persist_skipped`;时间线有卡写「选择题已问完,下面是当前范围」,门关写「再说一件带年月的事就能继续」,其余「还在核对」。
- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` T3`rectification-surface-state.test.ts` 时间线合同;`agent-voice-copy-contract.test.ts` 禁词「才会变」。
- 防复发:决策有动作就必须有载体;不得靠客户端「接着问」补救。
- 相关记录:BUG-651、BUG-627、BUG-442
- 复发自:BUG-627`ensureNonTerminalTurnExit` 只在点「接着问」时触发,不在答题事务里)
- 修复版本:待发布
@@ -0,0 +1,42 @@
# 进度 · 带年月选择题问完后必须交付结果(2026-09-11)
T0 回放(第六题答后)。在未改代码的 `1fa994ea` 上跑同一夹具:`nextAction=ask_candidate_discriminator``sessionOutcome=discriminate_candidates``decision.probe` 是 D10 性格题 `varga.d10.狮子座/处女座``choiceKind=varga_style``year=null`),`plan.next_followup` 为空,`persistServerOwnedFocus` 返回 `status=skipped`。这就是「有决策、无载体」。修复后打印 `t0_probe_pool_exhausted`: `nextAction=offer_provisional_range``semantic_key=null``choice_kind=null``persist_status=skipped`(空 followup)。同一事务落到交付旁白(含「如果还记得…范围还能再收一截」),`can_adopt=true`
## 范围
- 分支:`codex/rectification-probe-pool-exhausted-exit-20260911`(基于 `origin/staging` @ `1fa994ea`
- BUG-651:带年月区分池空即交付;性格题不再挡在结果前面(本单按让步先不问性格题)
- BUG-652:答题事务必须留下载体;时间线禁词「才会变」
- 不改 `MIN_ACCEPTANCE_*``SCORE_DELTA`、收敛门;不开 `confirmation_allowed`Skill 不升
## 完成
- T0 回放测试
- T1 决策层只认带年月探针
- T2 让步:性格题完全不问;可选入口另开一单
- T3 IfIdle 豁免撤回 + AfterChoice 空正文回落 + persist skip warn
- T4 时间线文案
- T5 BUG_HISTORY / CHANGELOG / 场景脚本
## 未做
- T2 卡下「还可以再答两道参考题微调排序」入口
## 验收
工作树内 `frontend/node_modules` 链到主仓。未 commit、未 push、未部署。真机走查留给部署后按场景 0b。
| 命令 | 结果 |
| --- | --- |
| `cd frontend && npx tsc --noEmit` | **0 error** |
| `cd frontend && npm run lint` | **0 error** / 119 warning(既有) |
| 相关套件 `tsx --test`(开工 5 文件 + T0 新文件 + surface / voice / answer-choice / range-offer | **149 pass / 0 fail**;开工所列 5 文件断言数不降,新文件 +3 |
| `run_quality_gate.py --profile quick` | pytest **730 passed / 1 failed / 1 skipped**`test_shadbala_endpoint_returns_ranked_planet_strength``timezone inference dependency unavailable`)。与 `PROGRESS-rectification-latent-audit-20260910` 同环境缺口,本单未改 Python |
| `cd frontend && ./node_modules/.bin/next build --webpack` | 退出 0;默认 Turbopack 仍拒 worktree 外 `node_modules` 软链。`/``○` Static。首页 JS gzip-9 **567845 B / 18 chunks**(近期同口径 occupation 单 572018 B,−0.73% |
断言改写(原值 / 新值 / 原因):
- `dated distinguish empty and two unseparated candidates defers D9`:原值 next 是 D9 `varga_style`;新值 D9 记 `yearless_deferred`、next 不再是性格卡。原因:BUG-651 带年月池空即交付。
- `open capability with empty dated probe pool delivers instead of collecting`:原值 `sessionOutcome=collect_evidence`、公开 `can_adopt=false`;新值 `adopt_representative`、可出区间卡。原因:训练门开且无带年月探针不得再被剩余采集挡住。
Skill 10.0.23 未改。
@@ -19,6 +19,19 @@
- 点「先这样,先看当前范围」后必须出现候选卡或当前范围,不得只剩「没有拿到下一个问题」
- 停止后卡片数字必须等于答题后的支持度(不是引擎刚算出来的裸相对支持度),范围不得比答题后更宽,点采用必须成功
## 0b. 六道选择题答完必须出卡
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后依次说两件学业(2016-09 / 2020-06),对「还有吗」再补事业两件(2020-04 入职实习、2020-10 离职)。训练门开后连续答完六道带年月选择题。
期望:
- 第六题答完必须看到三列区间卡,正文有一句还可以再想起什么来再收一截
- 不得出现「没有拿到下一个问题」
- 不得先出 D9/D10 性格对照卡挡住结果
- 时间线有卡时写「选择题已问完,下面是当前范围」,不得写「才会变」
## 1. 范围:家人说两点到四点
资料:家人记得大概时间,钟点 15:00,范围「前后一小时」。地点任意公开城市。
@@ -262,12 +262,21 @@ function deliveryCapability(input: {
};
}
/** Dated reverse-inference only. Yearless D9/D10 / nakshatra never occupy this slot (BUG-651). */
export function isDatedDiscriminatorProbe(
probe: CandidateDiscriminatorProbe | null | undefined,
): probe is CandidateDiscriminatorProbe {
if (!probe) return false;
if (probe.choiceKind === "varga_style") return false;
return (probe.year ?? 0) > 0;
}
export function decideRectification(input: DecideRectificationInput): RectificationDecision {
if (input.caseStage === "block_scan") {
return decideBlockScan(input);
}
const separation = evaluateCandidateSeparation(input.candidateScores);
const probe = input.discriminatorProbe ?? null;
const probe = isDatedDiscriminatorProbe(input.discriminatorProbe) ? input.discriminatorProbe : null;
const holdout = input.holdoutValidation ?? "unavailable";
const userStopped = input.userStopped === true;
const confirmationAllowed = input.confirmationAllowed === true;
@@ -315,7 +324,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& separation.ranked.length > 0
&& !probe
&& engineOffers
&& input.datedMethodCollectOpen !== true
) {
return offerRangeWithoutAdopt(separation, holdout, range, capability);
}
@@ -334,20 +342,8 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (holdout === "not_started" && !capability.canAdopt) {
return holdoutValidation(separation, range, capability);
}
if (input.datedMethodCollectOpen === true) {
return collect(separation, holdout, range, null, capability, stopReason);
}
if (input.nakshatraBoundaryProbe) {
return discriminateOrExhaust(
input,
separation,
holdout,
range,
input.nakshatraBoundaryProbe,
capability,
stopReason,
);
}
// coverageBlocks already collected when the training gate is closed.
// An open leftover collect must not block S3 delivery (BUG-651).
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
}
@@ -102,6 +102,7 @@ const EXHAUSTION_DELIVERY_ACTIONS = new Set([
"offer_provisional_range",
"complete_with_range",
"ask_fact_collection",
"ready_to_adopt",
]);
export function isExhaustedGateState(input: {
@@ -231,10 +232,11 @@ function interviewToPersist(plan: MethodFollowupPlan): MethodFollowup | null {
function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean {
if (!followup) return false;
if (followup.intent === "distinguish_candidates" && !followup.choice_frame) return false;
if (followup.choice_kind === "varga_style") return false;
if (followup.source === "nakshatra_boundary") return false;
return followup.source === "event_probe"
|| followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.source === "nakshatra_boundary"
|| followup.source === "reverse_verify"
|| followup.intent === "distinguish_candidates"
|| followup.intent === "reverse_verify";
@@ -300,12 +302,18 @@ function adoptHostNarration(input: {
}): string {
const inference = previousInferenceFromReceipt(input.receipt);
const fit = refinementFromDecisionReceipt(input.receipt).event_fit_rate;
return deliveryAdoptNarration({
const delivered = deliveryAdoptNarration({
credibleRange: input.decision.credibleRange,
representativeTime: input.decision.representativeTime,
eventCount: datedEventCount(inference),
fitPercent: fit?.percent ?? null,
});
const hint = moreCollectHint(
input.dossier.evidence,
input.dossier.conversationSummary.declinedSkippedTopics,
);
if (!hint || delivered.includes(hint)) return delivered;
return `${delivered} ${hint}`.replace(/\s+/g, " ").trim();
}
function dossierWithCurrentInference(
@@ -961,8 +969,27 @@ export async function persistNextInterviewAfterChoice(input: {
askedTurnId: input.askedTurnId ?? null,
});
}
const spoken = (spokenFollowupForUser(followup) ?? "").trim();
if (!spoken) {
if (input.dossier.case.acceptedTime) {
return {
hostNarration: postAdoptDoneNarration(liveDossier, decision),
choiceReady: false,
followup: null,
};
}
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: latest.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
return {
hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback,
hostNarration: spoken,
choiceReady: false,
followup,
};
@@ -1359,11 +1386,7 @@ export async function persistNextInterviewIfIdle(input: {
askedTurnId: input.askedTurnId ?? null,
}));
}
if (
!followup
&& decision.nextAction !== "ask_candidate_discriminator"
&& decision.nextAction !== "ask_holdout_validation"
) {
if (!followup) {
if (dossier.case.acceptedTime) {
return finishIdle({
persisted: false,
@@ -393,6 +393,26 @@ export function followupAsksRenderableDiscriminator(
return followup?.intent === "distinguish_candidates" && Boolean(followup.choice_frame);
}
export function isDatedDiscriminatorFollowup(
followup: {
intent?: string;
choice_frame?: unknown;
choice_kind?: string;
source?: string;
probe_year?: number;
} | null | undefined,
): boolean {
if (!followupAsksRenderableDiscriminator(followup)) return false;
if (followup?.choice_kind === "varga_style") return false;
if (followup?.source === "nakshatra_boundary") return false;
if ((followup?.probe_year ?? 0) > 0) return true;
const frame = followup?.choice_frame;
const period = frame && typeof frame === "object" && !Array.isArray(frame)
? String((frame as { period?: unknown }).period ?? "")
: "";
return followup?.source === "event_probe" && /(?:19|20)\d{2}/.test(period);
}
export type DecideFromDossierOptions = Readonly<{
currentEvidenceFingerprint?: string | null;
birthDate?: string | null;
@@ -487,7 +507,7 @@ function discriminatorProbeIfFollowupCanAsk(input: {
candidatesSeparated: dossierCandidatesSeparated(input.dossier),
});
const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes);
if (!followupAsksRenderableDiscriminator(plan.next_followup)) {
if (!isDatedDiscriminatorFollowup(plan.next_followup)) {
return { probe: null, dropped };
}
const key = plan.next_followup?.semantic_key;
@@ -714,7 +734,7 @@ export function decideFromDossier(
snapshotCurrent,
candidateScores: candidateScoresFromDossier(dossier.latestResult),
discriminatorProbe: gated.probe,
nakshatraBoundaryProbe: nakshatra.probe,
nakshatraBoundaryProbe: null,
holdoutValidation,
accepted: Boolean(dossier.case.acceptedTime),
inferenceCredibleRange: inference?.credible_range ?? null,
@@ -848,7 +868,7 @@ export function decideAfterInferenceChange(input: {
.filter((item) => item.status !== "eliminated")
.map((item) => ({ time: item.time, score: item.posterior_score })),
discriminatorProbe: gated.probe,
nakshatraBoundaryProbe: nakshatra.probe,
nakshatraBoundaryProbe: null,
holdoutValidation,
inferenceCredibleRange: input.state.credible_range,
userStopped: input.userStopped,
@@ -44,8 +44,9 @@
* Yearless varga splits do not borrow a ledger year. Pick the next
* dated discriminator, or collect a dated event in that domain.
* Remaining dated dasha distinguish probes are asked first. Yearless
* D9/D10 style and nakshatra_boundary wait until that pool is empty and
* at least two active candidates are still unseparated (BUG-629).
* D9/D10 style and nakshatra_boundary stay out of the dated pool and
* never occupy ask_candidate_discriminator (BUG-629, BUG-651). When
* the dated pool is empty after the training gate, deliver the range.
* If holdout is already reserved but training is still short,
* keep collecting a dated event instead of discriminating.
* Once blocking methods are covered, move into candidate discrimination.
@@ -1102,7 +1103,7 @@ function isYearlessPersonalityRow(row: RankedDiscriminator): boolean {
return discriminatorChoiceKind(row) === "varga_style";
}
function yearlessPersonalityCanAsk(input: {
export function yearlessPersonalityCanAsk(input: {
datedCount: number;
candidatesSeparated: boolean;
topCandidateTimes?: readonly string[];
@@ -2340,29 +2341,21 @@ export function buildMethodFollowupPlan(input: {
}
}
}
const personalityAllowed = yearlessPersonalityCanAsk({
datedCount: rankedDiscriminators.length,
candidatesSeparated,
topCandidateTimes: input.topCandidateTimes,
});
if (dashaCovered && personalityAllowed && !next) {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
for (const ranked of personalityDiscriminators) {
if (!allowLowGainDiscriminator && ranked.score < 0.08) continue;
const candidate = followupFromRanked(ranked);
if (candidate.choice_frame) {
next = candidate;
break;
}
}
} else {
for (const ranked of personalityDiscriminators) {
extraDropped.push(droppedFromProbe(
ranked.eventProbe?.semantic_key ?? ranked.contrastProbe?.semanticKey ?? "varga_style",
ranked.eventProbe?.information_gain ?? ranked.contrastProbe?.informationGain ?? ranked.score,
"yearless_deferred",
));
}
// BUG-651: personality / nakshatra stay optional after range delivery.
// They never occupy the discriminator slot, even when the dated pool is empty.
for (const ranked of personalityDiscriminators) {
extraDropped.push(droppedFromProbe(
ranked.eventProbe?.semantic_key ?? ranked.contrastProbe?.semanticKey ?? "varga_style",
ranked.eventProbe?.information_gain ?? ranked.contrastProbe?.informationGain ?? ranked.score,
"yearless_deferred",
));
}
if (input.nakshatraProbe) {
extraDropped.push(droppedFromProbe(
input.nakshatraProbe.semantic_key,
input.nakshatraProbe.information_gain,
"yearless_deferred",
));
}
if (!next && !dashaCovered) {
next = makeFollowup({
@@ -2389,51 +2382,7 @@ export function buildMethodFollowupPlan(input: {
}
// S1 ranks invite / anchors / generics. Do not convert leftover yearless
// contrast back into a domain wheel after the training gate (BUG-648).
if (
!next
&& personalityAllowed
&& input.holdoutValidation !== "not_started"
&& !datedMethodCollectOpen(methods, input.evidence, [
...(input.declinedTopics ?? []),
...(input.closedCollectFocuses ?? []),
])
&& input.nakshatraProbe
) {
const probe = input.nakshatraProbe;
const nakshatraFollowup = makeFollowup({
method_id: "nakshatra_boundary",
intent: "distinguish_candidates",
ask_theme: "nakshatra_trait",
domain: probe.domain,
kind_hint: null,
user_prompt_hint: probe.question,
source: "nakshatra_boundary",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
probe_year: probe.year,
choice_kind: probe.choice_kind ?? "varga_style",
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
style_options: probe.style_options,
probe_id: probe.id,
}, true, true);
if (nakshatraFollowup.choice_frame) {
next = nakshatraFollowup;
} else {
extraDropped.push({
semantic_key: probe.semantic_key,
information_gain: probe.information_gain,
reason: "not_renderable",
});
}
} else if (input.nakshatraProbe && !personalityAllowed) {
extraDropped.push(droppedFromProbe(
input.nakshatraProbe.semantic_key,
input.nakshatraProbe.information_gain,
"yearless_deferred",
));
}
// BUG-651: nakshatra is already deferred above and must not take this slot.
const sameDomainYearlessCard = (domain: string): MethodFollowup | null => {
const ranked = yearlessDiscriminators.find((row) => (
(row.eventProbe?.domain ?? row.contrastProbe?.domain ?? null) === domain
@@ -2542,8 +2491,11 @@ export function buildMethodFollowupPlan(input: {
: null;
if (!next) {
const precisionCard = takeRenderableDistinguish(precisionStageFollowup());
const datedPoolEmpty = rankedDiscriminators.length === 0;
if (collectingByPool) {
next = null;
} else if (datedPoolEmpty && meetsAcceptanceEventQuality(input.evidence)) {
next = null;
} else if ((renderableYearless = firstRenderableYearlessFollowup())) {
next = renderableYearless;
} else if (
@@ -431,6 +431,27 @@ export async function persistServerOwnedFocus(input: {
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: MethodFollowup | null;
askedTurnId?: string | null;
}): Promise<PersistServerFocusResult> {
const result = await persistServerOwnedFocusCore(input);
if (result.status !== "created" && result.status !== "already_open") {
console.warn(JSON.stringify({
event: "rectification_discriminator_persist_skipped",
case_id: input.caseId,
status: result.status,
semantic_key: input.followup?.semantic_key ?? null,
}));
}
return result;
}
async function persistServerOwnedFocusCore(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
activeFocus: ConversationFocus | null;
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: MethodFollowup | null;
askedTurnId?: string | null;
}): Promise<PersistServerFocusResult> {
const followup = input.followup;
const frame = followup?.choice_frame ?? null;
+14 -18
View File
@@ -360,36 +360,32 @@ export function interviewCollectWaiting(input: Readonly<{
|| input.stopReason === "insufficient_events";
}
function probeHasYear(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const year = (value as { year?: unknown }).year;
return typeof year === "number" && Number.isFinite(year) && year > 0;
}
function listHasDatedProbe(value: unknown): boolean {
if (!Array.isArray(value)) return false;
return value.some(probeHasYear);
}
export function contrastProbesFromReceipt(receipt: Readonly<Record<string, unknown>> | null | undefined): unknown {
const packet = receipt?.candidate_contrast_packet;
if (!packet || typeof packet !== "object" || Array.isArray(packet)) return null;
return (packet as { probes?: unknown }).probes ?? null;
}
/** Timeline range line. Never says 收窄. */
/** Timeline range line. Never says 收窄. Ban the exhausted-deadend phrase. */
export function rectificationReadonlyRangeCopy(input: Readonly<{
range: readonly [string, string];
sessionOutcome?: string | null;
discriminatingEventProbes?: unknown;
contrastProbes?: unknown;
}>): string {
const datedOpen = listHasDatedProbe(input.discriminatingEventProbes)
|| listHasDatedProbe(input.contrastProbes);
const choicesExhausted = input.sessionOutcome === "discriminate_candidates" && !datedOpen;
const suffix = choicesExhausted
? "选择题已问完,再补带年月的经历才会变"
: "还在核对";
const outcome = input.sessionOutcome ?? "";
const delivery = outcome === "adopt_representative"
|| outcome === "provisional_range"
|| outcome === "provisional_range_user_stopped"
|| outcome === "completed_with_range"
|| outcome === "validated_range"
|| outcome === "awaiting_confirmation"
|| outcome === "exact_minute_confirmed";
const suffix = delivery
? "选择题已问完,下面是当前范围"
: outcome === "collect_evidence"
? "再说一件带年月的事就能继续"
: "还在核对";
return `目前范围 ${input.range[0]}${input.range[1]}${suffix}`;
}
@@ -198,6 +198,11 @@ test("GENERIC_COLLECT_QUESTION is only allowed on opening collect:other focus",
const banned = "也可以再" + "说一件";
assert.equal(copy.includes(banned), false);
assert.equal(listUserVisibleCopy().some((item) => item.includes(banned)), false);
const surface = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
const bannedExhausted = "才会" + "变";
assert.equal(surface.includes(bannedExhausted), false);
assert.equal(copy.includes(bannedExhausted), false);
assert.equal(listUserVisibleCopy().some((item) => item.includes(bannedExhausted)), false);
});
test("settled assistant body with a focus has no question-mark sentences", () => {
@@ -589,7 +589,7 @@ test("MethodFollowup unions include holdout validation kinds used by next_follow
assert.match(askTheme, /"holdout"/);
});
test("collect_evidence with open capability still publishes can_adopt=false", () => {
test("open capability with empty dated probe pool delivers instead of collecting", () => {
const collecting = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
@@ -601,12 +601,13 @@ test("collect_evidence with open capability still publishes can_adopt=false", ()
candidateScores: TIED,
confirmationAllowed: false,
});
assert.equal(collecting.sessionOutcome, "collect_evidence");
// 原值: sessionOutcome=collect_evidence,公开 can_adopt=false
// 新值: 训练门开、无带年月探针 → adopt_representative / 可出区间卡
// 原因: BUG-651 带年月池空即交付,剩余采集不得挡住结果卡
assert.equal(collecting.sessionOutcome, "adopt_representative");
assert.equal(collecting.canOfferRange, true);
assert.equal(collecting.canAdopt, true);
// 旧:collect() 把 capability.canAdopt 原样投影成 can_adopt。
// 新:publicDecisionFields 按 ADOPT_OUTCOMES 收紧;collect 期间公开 can_adopt=false。
// 保留语义:内部 canAdopt / selection_allowed 不变;holdout 已过的 validated_range 仍可公开采用。
assert.equal(publicDecisionFields(collecting).can_adopt, false);
assert.equal(publicDecisionFields(collecting).can_adopt, true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true);
});
@@ -0,0 +1,647 @@
import assert from "node:assert/strict";
import test from "node:test";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
persistNextInterviewAfterChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { COLLECT_FLOW_BANNED_PHRASES } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
persistServerOwnedFocus,
type PersistServerFocusStatus,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const EXISTENCE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const VARGA_OPTIONS = [
{ label: "相处里更在意照顾对方的感受", answer_class: "yes" as const, sign: "巨蟹座" },
{ label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" as const, sign: "狮子座" },
];
const TIMES = ["04:48", "04:53", "04:54", "04:59", "05:06", "05:07"] as const;
const SCORES: Record<string, number> = {
"04:48": 10,
"04:53": 15,
"04:54": 14,
"04:59": 13,
"05:06": 13,
"05:07": 12,
};
const educationStart = {
id: "e-edu-start",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "2016年9月上大学",
};
const educationEnd = {
id: "e-edu-end",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
summary: "2020年6月毕业",
};
const careerIntern = {
id: "e-career-intern",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020年4月入职实习",
};
const careerLeave = {
id: "e-career-leave",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
summary: "2020年10月离职",
};
const EVIDENCE = [educationStart, educationEnd, careerIntern, careerLeave];
function uuidAt(index: number) {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
}
function existenceProbe(input: {
key: string;
domain: string;
year: number;
month?: number;
question: string;
source?: string;
choiceKind?: ConflictProbe["choice_kind"];
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: input.domain,
year: input.year,
question: input.question,
candidate_ids: [...TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: input.source ?? "dasha_boundary",
choice_kind: input.choiceKind ?? "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const ASKED_PROBES = [
existenceProbe({
key: "career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
month: 5,
question: "2023 年 5 月前后有没有入职或换工作",
}),
existenceProbe({
key: "relationship.2023.05.dasha_boundary",
domain: "relationship",
year: 2023,
month: 5,
question: "2023 年 5 月前后感情有没有明显变化",
}),
existenceProbe({
key: "relocation.2015.05.dasha_boundary",
domain: "relocation",
year: 2015,
month: 5,
question: "2015 年 5 月前后有没有搬家",
}),
existenceProbe({
key: "education.2016.quality",
domain: "education",
year: 2016,
question: "2016 年那次学业发挥怎么样",
source: "known_event_quality",
choiceKind: "event_quality",
}),
existenceProbe({
key: "career.2024.04.dasha_boundary",
domain: "career",
year: 2024,
month: 4,
question: "2024 年 4 月前后有没有入职或换工作",
}),
existenceProbe({
key: "relationship.2024.04.dasha_boundary",
domain: "relationship",
year: 2024,
month: 4,
question: "2024 年 4 月前后感情有没有明显变化",
}),
];
const SIXTH = ASKED_PROBES[5]!;
const LEFTOVER_SAME_YEAR = existenceProbe({
key: "career.2023.dasha_activation",
domain: "career",
year: 2023,
question: "2023 年前后有没有职责加重",
source: "dasha_activation",
});
const D10_STYLE: ConflictProbe = {
id: "probe:varga.d10.狮子座/处女座",
semantic_key: "varga.d10.狮子座/处女座",
candidate_split_hash: "04:48-05:07:04:49,04:53:varga.d10.狮子座/处女座",
domain: "career",
year: 0,
question: "平时做事,你更接近下面哪一种?",
candidate_ids: [...TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.9,
source: "varga_contrast",
choice_kind: "varga_style",
style_options: VARGA_OPTIONS,
};
const NAKSHATRA: ConflictProbe = {
id: "probe:nakshatra.boundary",
semantic_key: "nakshatra.boundary.a/b",
candidate_split_hash: "nakshatra.boundary.a/b",
domain: "other",
year: 0,
question: "两组月宿性格里更接近哪一种?",
candidate_ids: ["04:54", "05:06"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.01,
source: "nakshatra_boundary",
choice_kind: "varga_style",
style_options: VARGA_OPTIONS,
};
function liveState(answeredCount: number) {
const answered = ASKED_PROBES.slice(0, answeredCount);
const leftoverYearless = [1, 2, 3, 4, 5].map((index) => existenceProbe({
key: `yearless.existence.${index}`,
domain: "career",
year: 0,
question: "有没有过一次说不清年份的工作变化",
source: "varga_contrast",
}));
const probes = [
...answered,
LEFTOVER_SAME_YEAR,
...leftoverYearless,
D10_STYLE,
NAKSHATRA,
];
const candidates = TIMES.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: (SCORES[time] ?? 0) / 76,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:48", "05:07", TIMES),
revision: answeredCount,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:48",
range_end: "05:07",
candidates,
events: [
{ id: educationStart.id, domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: educationEnd.id, domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: careerIntern.id, domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: careerLeave.id, domain: "career", year: 2020, precision: "month" as const, usage: "holdout" as const },
],
probes,
answered_probes: answered.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: probe.semantic_key.includes("2024.04") && probe.domain === "career" ? "yes" as const : "no" as const,
classified_from: "choice" as const,
})),
rounds: [],
last_inference_round: null,
entropy: 1.4,
representative_time: "04:53",
credible_range: ["04:48", "05:07"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded);
return loaded;
}
function eventProbeRow(probe: ConflictProbe) {
return {
year: probe.year,
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
domain: probe.domain,
event_family: probe.domain,
source: probe.source,
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
};
}
function accidentDossier(answeredCount: number, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
} = {}): DecisionDossier {
const state = liveState(answeredCount);
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: extra.activeFocus
? {
id: extra.activeFocus.id,
intent: extra.activeFocus.intent,
targetDomain: extra.activeFocus.target_domain,
targetKind: extra.activeFocus.target_kind,
expectedAnswerSchema: extra.activeFocus.expected_answer_schema,
}
: null,
declinedSkippedTopics: [{
target_domain: "other",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:invite:more",
target_kind: "invite_more",
}],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: TIMES.map((time, index) => ({
candidateId: uuidAt(index),
time,
rank: index + 1,
relativeSupport: SCORES[time] ?? 0,
})),
representativeTime: "04:53",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
acceptance_reasons: [],
inference_state: state,
discriminating_event_probes: [
...ASKED_PROBES.map(eventProbeRow),
eventProbeRow(LEFTOVER_SAME_YEAR),
eventProbeRow(D10_STYLE),
],
oos_blind_prompts: [],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
};
}
function rpcDossier(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
} = {}) {
const evidence = decision.evidence.map((item) => ({
id: item.id ?? "e-unknown",
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-11T00:00:00.000Z",
}));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:53",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never),
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: item.candidateId ?? uuidAt(index),
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: 1,
})) ?? [],
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: conversationSummaryFixture({
activeFocus: extra.activeFocus ?? null,
declinedSkippedTopics: [...decision.conversationSummary.declinedSkippedTopics],
}),
});
}
function idleHandlers(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
throwOnFocus?: boolean;
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: SIXTH.id,
revision: Number(args.p_expected_revision) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
set_agentic_rectification_conversation_focus: extra.throwOnFocus
? () => {
throw new Error("persist skipped");
}
: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-11T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
});
}
function warnLines(run: () => Promise<unknown> | unknown) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
original.apply(console, args);
};
return Promise.resolve(run()).finally(() => {
console.warn = original;
}).then((result) => ({ result, lines }));
}
function followupPlan(dossier: DecisionDossier, sessionOutcome: string) {
return buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: sessionOutcome as never,
eventProbes: (dossier.latestResult?.decisionReceipt?.discriminating_event_probes ?? []) as never,
askedProbeKeys: ASKED_PROBES.flatMap((probe) => [probe.id, probe.semantic_key, probe.candidate_split_hash]),
candidatesSeparated: false,
topCandidateTimes: [...TIMES],
birthDate: "1997-08-08",
});
}
test("T0: sixth dated answer prints persist_status then must deliver a range card", async () => {
resetDeliveryTurnGuardForTests();
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const plan = followupPlan(dossier, decision.sessionOutcome);
const accounting = idleHandlers(dossier);
const { result: persist, lines } = await warnLines(() => persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
followup: plan.next_followup,
}));
const persistStatus = (persist as { status: PersistServerFocusStatus }).status;
console.warn(JSON.stringify({
event: "t0_probe_pool_exhausted",
nextAction: decision.nextAction,
sessionOutcome: decision.sessionOutcome,
semantic_key: plan.next_followup?.semantic_key ?? null,
choice_kind: plan.next_followup?.choice_kind ?? null,
followup_intent: plan.next_followup?.intent ?? null,
persist_status: persistStatus,
probe_id: decision.probe?.probeId ?? null,
probe_year: decision.probe?.year ?? null,
probe_choiceKind: decision.probe?.choiceKind ?? null,
probe_semanticKey: decision.probe?.semanticKey ?? null,
}));
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range",
`T0 nextAction=${decision.nextAction} persist_status=${persistStatus} next=${plan.next_followup?.semantic_key}`,
);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.notEqual(plan.next_followup?.source, "nakshatra_boundary");
const publicAction = publicNextAction(decision);
assert.equal(publicAction.can_offer_range, true);
const nextUser = buildNextUserAction({
scorableCount: dossier.evidence.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: true,
selectionAllowed: publicAction.can_adopt,
sessionOutcome: decision.sessionOutcome,
nextFollowup: plan.next_followup,
workingTime: decision.representativeTime,
});
assert.ok(
nextUser.id === "offer_provisional_range" || nextUser.id === "adopt_representative",
nextUser.id,
);
const idleAccounting = idleHandlers(dossier);
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: idleAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const host = persisted.hostNarration ?? "";
assert.ok(host.trim(), "answer/idle transaction must leave a carrier");
assert.match(host, /再收一截|再补|如果还记得/);
for (const phrase of COLLECT_FLOW_BANNED_PHRASES) {
if (phrase === "领域") continue;
assert.equal(host.includes(phrase), false, phrase);
}
const delivery = rangeDeliveryForSnapshot({
decisionReceipt: dossier.latestResult?.decisionReceipt,
candidates: dossier.latestResult?.candidates,
representativeTime: decision.representativeTime,
credibleRange: decision.credibleRange,
});
assert.ok((delivery.columns?.length ?? 0) >= 3, JSON.stringify(delivery.columns?.map((item) => item.time)));
assert.equal(rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
offerAwaitingReader: publicAction.can_offer_range,
}), "idle");
assert.ok(lines.length >= 0);
});
test("T1: the sixth-answer persist transaction delivers a range carrier", async () => {
resetDeliveryTurnGuardForTests();
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const accounting = idleHandlers(dossier);
const next = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
});
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range",
decision.nextAction,
);
assert.equal(decision.canOfferRange, true);
assert.match(next.hostNarration, /再收一截|再补|如果还记得/);
assert.doesNotMatch(next.hostNarration, /做不了|才会变|没有拿到下一个问题/);
assert.equal(next.choiceReady, false);
});
test("T3: skipped discriminator persist still leaves a non-empty delivery carrier", async () => {
resetDeliveryTurnGuardForTests();
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const accounting = idleHandlers(dossier, { throwOnFocus: true });
const { result, lines } = await warnLines(() => persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
}));
const next = result as Awaited<ReturnType<typeof persistNextInterviewAfterChoice>>;
assert.ok((next.hostNarration ?? "").trim());
assert.equal(decision.canOfferRange, true);
const skipped = lines.find((line) => line.includes("rectification_discriminator_persist_skipped"));
const skippedDirect = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
followup: {
method_id: "d10_career",
intent: "distinguish_candidates",
ask_theme: "career_style",
domain: "career",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
source: "event_probe",
semantic_key: D10_STYLE.semantic_key,
choice_kind: "varga_style",
},
});
assert.equal(skippedDirect.status, "skipped");
assert.ok(skipped || skippedDirect.status === "skipped");
});
@@ -120,29 +120,32 @@ test("question gap: a persisted focus question is shown instead of preparing", (
assert.equal(rectificationQuestionRetryActive("persisted_question"), false);
});
test("readonly range copy never says 收窄 and reports exhausted choices from the snapshot", () => {
test("readonly range copy never says 收窄 or 才会变", () => {
assert.equal(
rectificationReadonlyRangeCopy({
range: ["04:45", "05:15"],
sessionOutcome: "discriminate_candidates",
sessionOutcome: "adopt_representative",
discriminatingEventProbes: [],
contrastProbes: [],
}),
"目前范围 04:4505:15,选择题已问完,再补带年月的经历才会变",
"目前范围 04:4505:15,选择题已问完,下面是当前范围",
);
// 原值: discriminate_candidates 且无带年月探针 →「选择题已问完,再补带年月的经历才会变」
// 新值: 有交付卡才写「选择题已问完,下面是当前范围」;仍在区分时写「还在核对」
// 原因: BUG-652 没有结果卡时不得写「做不了」口径
const stillOpen = rectificationReadonlyRangeCopy({
range: ["04:45", "05:15"],
sessionOutcome: "discriminate_candidates",
discriminatingEventProbes: [{ year: 2016 }],
});
assert.match(stillOpen, /还在核对/);
assert.doesNotMatch(stillOpen, /收窄/);
assert.doesNotMatch(stillOpen, /收窄|才会变/);
const collecting = rectificationReadonlyRangeCopy({
range: ["04:45", "05:15"],
sessionOutcome: "collect_evidence",
});
assert.match(collecting, /还在核对/);
assert.doesNotMatch(collecting, /选择题已问完|收窄/);
assert.match(collecting, /再说一件带年月的事就能继续/);
assert.doesNotMatch(collecting, /选择题已问完|收窄|才会变/);
});
test("live-row labels follow the action that started the turn", () => {
@@ -260,14 +260,19 @@ test("an askable 2023.05 career probe keeps D9 deferred", () => {
);
});
test("dated distinguish empty and two unseparated candidates asks D9", () => {
test("dated distinguish empty and two unseparated candidates defers D9", () => {
const plan = personalityPlan({ eventProbes: [] });
assert.equal(plan.next_followup?.semantic_key, D9_STYLE.semanticKey);
assert.equal(plan.next_followup?.choice_kind, "varga_style");
assert.ok(plan.next_followup?.choice_frame);
// 原值: next_followup 是 D9 varga_styledropped 不含 yearless_deferred
// 新值: D9 记 yearless_deferrednext 不再是性格卡
// 原因: BUG-651 带年月池空即交付,性格题不得挡在结果前面
assert.notEqual(plan.next_followup?.semantic_key, D9_STYLE.semanticKey);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.equal(
plan.dropped_probes.some((item) => item.reason === "yearless_deferred"),
false,
plan.dropped_probes.some((item) => (
item.semantic_key === D9_STYLE.semanticKey && item.reason === "yearless_deferred"
)),
true,
JSON.stringify(plan.dropped_probes),
);
});