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:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-11 — 带年月选择题问完后会给出当前范围
|
||||
|
||||
生时校正里,训练门已经打开、带年月的选择题问完后,会直接给出当前范围和三列对照卡,并补一句还可以再想起什么来收一截。不会停在「没有拿到下一个问题」,也不会先逼你答性格对照题。时间线有卡时写「选择题已问完,下面是当前范围」;材料还不够时才请再说一件带年月的事。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-11 — 职业题答出的年月会记成可评分的工作经历
|
||||
|
||||
生时校正问「你平时主要做什么工作?」时,如果一并说出开始年月,会同时记下职业备注和一件可评分的入职经历,不再把年月抹掉。只说工种、没有年月时,仍只记备注、不拿它凑三件。开始年份改由毕业后第一份工作这类锚定题来问。Skill 版本不变。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:45–05:15,选择题已问完,再补带年月的经历才会变",
|
||||
"目前范围 04:45–05: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_style,dropped 不含 yearless_deferred
|
||||
// 新值: D9 记 yearless_deferred,next 不再是性格卡
|
||||
// 原因: 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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user