fix(web): stop adopting tied rectification candidates after method coverage
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Coverage complete only unlocks discrimination. A 34/33/33 window plus an
occupation note must ask a D9/D10 contrast probe instead of offering a
stale winner card.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-24 12:19:36 +08:00
co-authored by Cursor
parent 3b785b821d
commit 3f1998bdb6
19 changed files with 1345 additions and 80 deletions
@@ -56,6 +56,9 @@ export type ConfirmationGate = Readonly<{
export type SessionOutcomeKind =
| "collect_evidence"
| "discriminate_candidates"
| "validate_holdout"
| "provisional_range"
| "adopt_representative"
| "awaiting_confirmation";
@@ -77,6 +80,24 @@ export function sessionOutcomeView(kind: SessionOutcomeKind): SessionOutcome {
user_meaning: `这次校正的收口是采用代表性时间作当前排盘。${UNIQUE_MINUTE_CLOSED_COPY}。`,
};
}
if (kind === "discriminate_candidates") {
return {
kind,
user_meaning: "方法资料已齐,但候选还没拉开。下一步按候选盘面差异反问前事,不要采用时间卡。",
};
}
if (kind === "validate_holdout") {
return {
kind,
user_meaning: "领先候选还要用尚未计分的前事做独立核对,不能直接采用。",
};
}
if (kind === "provisional_range") {
return {
kind,
user_meaning: "当前几个候选基本并列。给出的是可信区间的代表点,不是已经分出的赢家。",
};
}
return {
kind: "collect_evidence",
user_meaning: "还需要能评分的带日期事件,才能给出可采用的代表性时间。",
@@ -27,8 +27,8 @@
* After the first dated event, remaining dasha conflict probes
* (year/activation differences) are asked before more method rotation
* and they block offering time cards so the window can be filtered.
* Once blocking methods are covered and propose_allowed, offer a
* representative time even if probes or horary remain.
* Once blocking methods are covered, move into candidate discrimination.
* Coverage complete never means adopt. Horary does not block cards.
* A/B/C/D choice frames attach only when candidates already diverge
* (event probes, precision stage, varga observation, nakshatra, or holdout).
*/
@@ -40,7 +40,17 @@ import {
type RectificationChoiceCard,
type RectificationChoiceFrame,
} from "./choice-card.ts";
import { sessionOutcomeFromGate, type SessionOutcomeKind } from "./confirmation-gate.ts";
import {
decideNextAction,
sessionKindFromNextAction,
type HoldoutValidationStatus,
} from "../core/decide-next-action.ts";
import {
selectDiscriminatorProbe,
type CandidateContrastPacket,
type CandidateDiscriminatorProbe,
} from "../core/candidate-contrast-packet.ts";
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
import type {
DiscriminatingEventProbe,
NakshatraBoundary,
@@ -308,6 +318,9 @@ export type NextUserActionId =
| "score_now"
| "record_stated_events"
| "ask_method_followup"
| "ask_candidate_discriminator"
| "ask_holdout_validation"
| "offer_provisional_range"
| "explain_current_window"
| "verify_adopted_time"
| "start_consultation";
@@ -316,7 +329,7 @@ export type NextUserAction = Readonly<{
id: NextUserActionId;
user_meaning: string;
on_user_stop: {
id: Exclude<NextUserActionId, "ask_method_followup">;
id: Exclude<NextUserActionId, "ask_method_followup" | "ask_candidate_discriminator" | "ask_holdout_validation">;
user_meaning: string;
};
}>;
@@ -346,19 +359,53 @@ export function latestUserStoppedCollecting(
export function isOfferBlockingFollowup(
followup: MethodFollowup | null,
methods?: readonly MethodCoverage[],
options?: { separated?: boolean },
): boolean {
if (methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered")) {
return true;
}
if (!followup) return false;
const separated = options?.separated === true;
if (followup.source === "event_probe") {
if (methods == null) return true;
return (followup.information_gain ?? 0) >= 0.08;
if (separated) return (followup.information_gain ?? 0) >= 0.08;
return true;
}
if (
followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.intent === "distinguish_candidates"
) {
return !separated;
}
if (followup.source !== "method_coverage") return false;
return BLOCKING_COVERAGE_IDS.has(followup.method_id as MethodFollowupId);
}
function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null {
if (!followup) return null;
const discriminator = followup.source === "event_probe"
|| followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.intent === "distinguish_candidates";
if (!discriminator) return null;
const split = followup.candidate_split_hash ?? followup.semantic_key ?? followup.method_id;
return {
probeId: split,
candidateSetVersion: split,
question: followup.user_prompt_hint,
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["left"], conflictsCandidateIds: ["right"] },
{ outcomeId: "no", supportsCandidateIds: ["right"], conflictsCandidateIds: ["left"] },
],
candidateSplitHash: split,
informationGain: followup.information_gain ?? 0,
sourceFeatures: [{ technique: followup.source, calculationResultId: null }],
domain: followup.domain,
year: followup.probe_year ?? null,
semanticKey: followup.semantic_key ?? followup.method_id,
};
}
export function conversationalSessionOutcome(input: {
selectionAllowed: boolean;
proposeAllowed: boolean;
@@ -366,14 +413,27 @@ export function conversationalSessionOutcome(input: {
nextFollowup: MethodFollowup | null;
methods?: readonly MethodCoverage[];
userStopped?: boolean;
candidateScores?: readonly Readonly<{ time: string; score: number }>[];
discriminatorProbe?: CandidateDiscriminatorProbe | null;
holdoutValidation?: HoldoutValidationStatus;
snapshotCurrent?: boolean;
}): SessionOutcomeKind {
return sessionOutcomeFromGate({
selectionAllowed: input.selectionAllowed,
if (input.confirmationAllowed) return "awaiting_confirmation";
const coverageOpen = Boolean(
input.methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"),
);
const decided = decideNextAction({
methodCoverageAll: !coverageOpen,
proposeAllowed: input.proposeAllowed,
confirmationAllowed: input.confirmationAllowed,
interviewOpen: isOfferBlockingFollowup(input.nextFollowup, input.methods),
confirmationAllowed: false,
userStopped: input.userStopped,
}).kind;
selectionAllowed: input.selectionAllowed,
snapshotCurrent: input.snapshotCurrent,
candidateScores: input.candidateScores ?? [],
discriminatorProbe: input.discriminatorProbe ?? discriminatorFromFollowup(input.nextFollowup),
holdoutValidation: input.holdoutValidation,
});
return sessionKindFromNextAction(decided.type);
}
export function buildNextUserAction(input: {
@@ -407,14 +467,39 @@ export function buildNextUserAction(input: {
"adopt_representative",
"本轮已有代表性候选时间。说明本会话以代表性时间收口,不确认唯一分钟,请用户采用下方时间卡片;采用后才用该时间看盘。不要只说记下了以后再说。",
);
const provisional = action(
"offer_provisional_range",
"当前几个候选基本并列。说明这是可信区间,代表分钟只是计算用的代表点,不要称某分钟为当前推荐;不要继续假装已经收敛。",
);
if (input.sessionOutcome === "awaiting_confirmation") {
return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt };
}
if (
input.sessionOutcome === "adopt_representative"
) {
if (input.sessionOutcome === "adopt_representative") {
return { id: adopt.id, user_meaning: adopt.user_meaning, on_user_stop: adopt };
}
if (input.sessionOutcome === "provisional_range") {
return { id: provisional.id, user_meaning: provisional.user_meaning, on_user_stop: provisional };
}
if (input.sessionOutcome === "validate_holdout" && input.nextFollowup) {
return {
id: "ask_holdout_validation",
user_meaning: input.nextFollowup.user_prompt_hint,
on_user_stop: input.selectionAllowed ? provisional : action(
"explain_current_window",
`${working}。独立核对还没做完。用户说没有更多时,说明这是并列区间,不要采用一张赢家卡。`,
),
};
}
if (input.sessionOutcome === "discriminate_candidates" && input.nextFollowup) {
return {
id: "ask_candidate_discriminator",
user_meaning: input.nextFollowup.user_prompt_hint,
on_user_stop: input.selectionAllowed ? provisional : action(
"explain_current_window",
`${working}。候选还没拉开。用户说没有更多时,给出并列可信区间,不要宣布某分钟胜出。`,
),
};
}
const scoreNow = action(
"score_now",
"已有可评分事件但还没有候选结果。本轮必须比较候选,不要只口头确认事件。",
@@ -456,6 +541,9 @@ export function buildMethodFollowupPlan(input: {
askedProbeKeys?: readonly string[];
birthDate?: string | null;
accepted?: boolean;
candidatesSeparated?: boolean;
contrastPacket?: CandidateContrastPacket | null;
holdoutValidation?: HoldoutValidationStatus;
}): MethodFollowupPlan {
const makeFollowup = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
@@ -506,6 +594,8 @@ export function buildMethodFollowupPlan(input: {
];
const sessionOutcome = input.sessionOutcome ?? "collect_evidence";
const candidatesSeparated = input.candidatesSeparated === true;
const contrastProbe = selectDiscriminatorProbe(input.contrastPacket ?? null);
const focus = input.activeFocus ?? null;
const keepAcceptedFocus = Boolean(
focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"),
@@ -525,7 +615,7 @@ export function buildMethodFollowupPlan(input: {
if (
focus
&& !staleCollectFocus
&& (sessionOutcome !== "adopt_representative" || keepAcceptedFocus)
&& (sessionOutcome !== "adopt_representative" && sessionOutcome !== "provisional_range" || keepAcceptedFocus)
&& (!input.accepted || keepAcceptedFocus)
) {
const existingChoice = parseAgentChoiceCopy(focus.expectedAnswerSchema ?? null);
@@ -603,7 +693,7 @@ export function buildMethodFollowupPlan(input: {
),
source: "method_coverage",
});
} else if (conflictProbe && (!coverageComplete || (conflictProbe.information_gain ?? 0) >= 0.08)) {
} else if (conflictProbe && (!coverageComplete || !candidatesSeparated || (conflictProbe.information_gain ?? 0) >= 0.08)) {
next = makeFollowup({
method_id: PROBE_METHOD_ID[conflictProbe.domain],
intent: "distinguish_candidates",
@@ -676,20 +766,25 @@ export function buildMethodFollowupPlan(input: {
),
source: "method_coverage",
});
} else if (horaryStatus === "uncovered") {
} else if (contrastProbe && !candidatesSeparated) {
const domain = contrastProbe.domain === "relationship" ? "relationship" : "career";
next = makeFollowup({
method_id: "horary",
intent: "collect_method_evidence",
ask_theme: "horary",
domain: "horary",
kind_hint: "horary_query",
user_prompt_hint: collectHint(
"有没有第一次认真问起这件事的时间?",
"占问观察盘",
"有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。",
method_id: domain === "relationship" ? "d9_relationship" : "d10_career",
intent: "distinguish_candidates",
ask_theme: domain === "relationship" ? "relationship_style" : "career_style",
domain,
kind_hint: domain === "relationship" ? "relationship_change" : "career_change",
user_prompt_hint: agentHint(
contrastProbe.question,
domain === "relationship" ? "D9" : "D10",
"按候选盘面差异核对前事,不要问两套盘哪个更像。",
),
source: "method_coverage",
});
source: "event_probe",
information_gain: contrastProbe.informationGain,
semantic_key: contrastProbe.semanticKey,
candidate_split_hash: contrastProbe.candidateSplitHash,
probe_year: contrastProbe.year ?? undefined,
}, true, true);
} else if (stage === "lagna_frame") {
next = makeFollowup({
method_id: "dasha_events",
@@ -870,10 +965,35 @@ export function buildMethodFollowupPlan(input: {
?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。",
source: "nakshatra_boundary",
});
} else if (input.holdoutValidation === "not_started" && (input.oosBlindPrompts?.length ?? 0) > 0) {
const prompt = input.oosBlindPrompts![0]!;
next = makeFollowup({
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "oos_blind",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
}, false, true);
} else if (horaryStatus === "uncovered") {
next = makeFollowup({
method_id: "horary",
intent: "collect_method_evidence",
ask_theme: "horary",
domain: "horary",
kind_hint: "horary_query",
user_prompt_hint: collectHint(
"有没有第一次认真问起这件事的时间?",
"占问观察盘",
"有的话可以按那个时间观察;没有也不挡给出时间卡。状态是 observation_only。",
),
source: "method_coverage",
});
}
}
const deferAdoption = sessionOutcome === "adopt_representative";
const deferAdoption = sessionOutcome === "adopt_representative" || sessionOutcome === "provisional_range";
return {
methods,
next_followup: deferAdoption ? null : next,
@@ -890,6 +1010,7 @@ export function projectRectificationChoiceCard(
selectionAllowed?: boolean;
proposeAllowed?: boolean;
userStopped?: boolean;
candidateScores?: readonly Readonly<{ time: string; score: number }>[];
},
): RectificationChoiceCard | null {
const plan = buildMethodFollowupPlan(input);
@@ -900,10 +1021,13 @@ export function projectRectificationChoiceCard(
nextFollowup: plan.next_followup,
methods: plan.methods,
userStopped: input.userStopped,
candidateScores: input.candidateScores,
discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null),
holdoutValidation: input.holdoutValidation,
});
if (
!input.accepted
&& (sessionOutcome === "adopt_representative" || sessionOutcome === "awaiting_confirmation")
&& (sessionOutcome === "adopt_representative" || sessionOutcome === "awaiting_confirmation" || sessionOutcome === "provisional_range")
) {
return null;
}
@@ -8,6 +8,7 @@
import type { EventDashaLedgerRow } from "./refinement-packet";
import type { WindowScan } from "./varga-observations";
import type { CandidateSeparation } from "../core/candidate-separation";
const D9_TYPE_TABLE: Readonly<Record<string, { trait: string; spouse: string; marriage: string }>> = {
白羊座: { trait: "主动、热情、冲动", spouse: "独立、有活力", marriage: "早期婚姻、激情型" },
@@ -73,7 +74,14 @@ export type SkillVerificationReportInput = Readonly<{
unique_minute_claim: false;
}> | null;
dashaAgreement?: Readonly<{ status: string; user_meaning: string }> | null;
techniqueAuditTable?: readonly Readonly<{ technique?: string; status?: string; note?: string }>[];
techniqueAuditTable?: readonly Readonly<{
technique?: string;
status?: string;
note?: string;
calculation_result_id?: string;
}>[];
separation?: CandidateSeparation;
engineResultId?: string | null;
}>;
export function buildSkillVerificationReport(input: SkillVerificationReportInput): string {
@@ -84,6 +92,7 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
const ledger = input.eventDashaLedger ?? [];
const d9Signs = input.windowScan?.d9_sign_names ?? [];
const d10Signs = input.windowScan?.d10_sign_names ?? [];
const tied = input.separation?.status === "not_separated";
const candidateLines = input.candidates.slice(0, 5).map((row) => (
`| ${row.rank} | ${row.time} | ${row.relativeSupport} |`
));
@@ -98,12 +107,19 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
: ["当前窗还没有可对照的 D10 上升名,类型表待候选换升后填写。"];
const audit = (input.techniqueAuditTable ?? []).slice(0, 16);
const auditRows = audit.length > 0
? audit.map((row) => `| ${row.technique ?? "技法"} | ${row.status ?? "blocked"} | ${row.note ?? ""} |`)
? audit.map((row) => {
const executed = row.status === "executed" && Boolean(row.calculation_result_id);
const status = executed ? "executed" : (row.status === "executed" ? "input_covered" : (row.status ?? "blocked"));
const note = executed
? (row.note ?? "")
: (row.note || "该方法所需资料已覆盖,没有可追溯的 calculationResultId,不能写成已执行。");
return `| ${row.technique ?? "技法"} | ${status} | ${note} |`;
})
: [
"| Vimshottari + 受控行运 | executed | 权重口径 40;事件吻合率不是唯一分钟 |",
"| D9/D10 分盘 | executed | 权重口径 35;类型表是校时方法 |",
"| 宫位 / 六亲六步 | executed | 权重口径 15;后三步方法论层 |",
"| Nakshatra Pada | observation_only | 权重口径 10;不确认唯一分钟 |",
"| Vimshottari + 受控行运 | input_covered | 没有 calculationResultId 时不能写成已执行 |",
"| D9/D10 分盘 | input_covered | 类型表是校时方法;差异要进入区分探针 |",
"| 宫位 / 六亲六步 | input_covered | 后三步方法论层 |",
"| Nakshatra Pada | observation_only | 不确认唯一分钟 |",
"| KP / D60 / Gulika | observation_only | 不进提出门或确认门计分 |",
"| 占问 | observation_only | AI 暂不支持独立占问;有问起时间则观察 |",
];
@@ -113,11 +129,17 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
"这是当前窗的相对拟合,标签是 `candidate_range_not_birth_time_truth`。采用把代表性时间写入当前排盘;本会话以代表性时间收口,不是已确认唯一出生分钟。",
"",
"### 筛选",
`- 当前代表分钟:${time}`,
tied
? `- 当前候选基本并列:${(input.separation?.credibleRange ?? []).join("、") || "尚未拉开"}。${time} 只是后续计算的代表点,不是赢家。`
: `- 当前代表分钟:${time}`,
`- 不可分宽度:${width}`,
`- 本命上升(该分钟):${lagna}`,
input.windowScan?.d9_candidates_differ ? "- D9 仍会换升,关系类型对照只用来分开候选。" : "- D9 上升在当前窗较稳。",
input.windowScan?.d10_candidates_differ ? "- D10 仍会换升,事业类型对照只用来分开候选。" : "- D10 上升在当前窗较稳。",
input.windowScan?.d9_candidates_differ
? "- D9 仍会换升。这是候选结构差异,应生成区分探针,不能直接宣布不可分。"
: "- D9 上升在当前窗较稳。",
input.windowScan?.d10_candidates_differ
? "- D10 仍会换升。这是候选结构差异,应生成区分探针,不能直接宣布不可分。"
: "- D10 上升在当前窗较稳。",
"",
"### 候选窗(正文复述;卡片仍作采用控件)",
"| 排名 | 时间 | 相对支持 |",
@@ -126,8 +148,8 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
"",
"### 方法1:Dasha + Gochara",
fit
? `${fit.user_meaning} ${fit.label}。`
: "还没有事件吻合率。有已确认事件后,80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟。",
? `${fit.user_meaning} ${fit.label}。这是事件拟合程度,不是候选区分程度。`
: "还没有事件吻合率。有已确认事件后,80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。",
"",
"| 事件 | 吻合 | Dasha / Gochara |",
"|---|---|---|",
@@ -153,7 +175,7 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
"状态:`not_used` / `observation_only`。产品可观察一次问起时间,不挡出牌。",
"",
"### Technique Audit Table",
"权重口径:Dasha 40 / 分盘 35 / 宫位 15 / Pada 10。KP、D60、Gulika 观察或 blocked。",
"权重口径:Dasha 40 / 分盘 35 / 宫位 15 / Pada 10。KP、D60、Gulika 观察或 blocked。只有带 calculationResultId 的行才能写 executed。",
"| 技法 | 状态 | 说明 |",
"|---|---|---|",
...auditRows,