fix(rectification): typecheck answered probe_id for staging publish
Independent Staging Quality Gate / validate (push) Successful in 10m47s
Independent Staging Quality Gate / publish (push) Successful in 11m26s

Docker next build failed on ProbeAnswer.id and optional decisionReceipt,
so staging never received the month-lock SHA.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-28 11:00:18 +08:00
parent 797a423af7
commit 589ada6d03
4 changed files with 88 additions and 8 deletions
+16
View File
@@ -6221,6 +6221,22 @@
- 复发自:BUG-405(排序公式对,目录被投影饿死,已打开低分卡锁题)
- 修复版本:待发布
## BUG-409 | staging publish 的 next build 因 answered_probes.id 与 optional receipt 失败
- 状态:resolved
- 首次发现:2026-08-28
- 最近更新:2026-08-28
- 影响面:Gitea `backend-quality-gate.yml` `publish``deploy/railway-web.Dockerfile``RUN npm run build``contrastPacketFromLatestResult`、Mastra `rectification-v9-tools`
- 用户现象:向 `staging` 推送月份精度修复后,run `2128` validate 通过,publish 在 web 镜像 `next build` 失败。没有 dispatch `Deploy staging`。公网仍停在上一成功 SHA。
- 触发条件:staging push 的 validate 跳过 `npm run build`publish 才在 Docker 里跑 TypeScript。
- 根因:`answered_probes` 的字段是 `probe_id`,目录过滤写成了不存在的 `item.id``tsc` 报 TS2339。工具侧 `latestResult.decisionReceipt` 可为 `undefined`,目录函数要求 `| null`,报 TS2345。BuildKit 日志末尾仍是 `skill-package-registry.ts` 的 Import traces,真正失败是 `Failed to type check`
- 修复:已答探针按 `probe_id` 过滤。目录函数接受 `undefined` latest,并把缺省 receipt 收成 `null` 再往下传。
- 验证:`npx tsc --noEmit``rectification-decision-authority` 锁定已答探针按 `probe_id` 退出目录。
- 防复发:改生时纠正目录或 Mastra 工具类型后,推 staging 前必须跑 `npx tsc --noEmit` 或等价 `next build`,不能只跑 `tsx --test`
- 相关记录:BUG-309、BUG-315、BUG-321、BUG-371、BUG-408
- 复发自:BUG-309validate 跳过 `next build`publish 才暴露类型错误)
- 修复版本:待发布
## BUG-408 | 反推题丢掉大运起点的月份,同年 3 月对 9 月问不出来
- 状态:resolved
@@ -60,7 +60,7 @@ export type DecisionDossier = Readonly<{
};
latestResult: {
resultId?: string;
decisionReceipt: Readonly<Record<string, unknown>> | null;
decisionReceipt?: Readonly<Record<string, unknown>> | null;
selectionAllowed?: boolean;
confirmationAllowed?: boolean;
candidates?: readonly Readonly<{
@@ -124,13 +124,13 @@ function holdoutStatusFromState(state: InferenceState) {
}
export function contrastPacketFromLatestResult(
latest: DecisionDossier["latestResult"],
latest: DecisionDossier["latestResult"] | undefined,
evidence: DecisionDossier["evidence"] = [],
): CandidateContrastPacket {
const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null);
const refinement = refinementFromDecisionReceipt(latest?.decisionReceipt ?? null);
const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null);
const answered = new Set((inference?.answered_probes ?? []).map((item) => item.id));
const answered = new Set((inference?.answered_probes ?? []).map((item) => item.probe_id));
const fromInference: EngineContrastProbe[] = (inference?.probes ?? []).flatMap((probe) => {
if (answered.has(probe.id) || probe.information_gain <= 0) return [];
if (probe.source === "known_event_quality") return [];
@@ -172,7 +172,7 @@ export function contrastPacketFromLatestResult(
? [{ layer: "d10", signs: windowScan.d10_sign_names }]
: []),
],
candidateTimes: discriminatorCandidateTimes(latest),
candidateTimes: discriminatorCandidateTimes(latest ?? null),
transitions: windowScan?.transitions ?? [],
askedKeys: askedDiscriminatorKeys(latest?.decisionReceipt, evidence),
volunteeredDomains: volunteeredDomainsFromEvidence(evidence),
@@ -202,15 +202,15 @@ export function contrastPacketFromDossier(dossier: DecisionDossier): CandidateCo
}
export function rectificationFollowupCatalog(
latest: DecisionDossier["latestResult"],
latest: DecisionDossier["latestResult"] | undefined,
evidence: DecisionDossier["evidence"] = [],
) {
const receipt = latest?.decisionReceipt ?? null;
const refinement = refinementFromDecisionReceipt(receipt);
const inference = previousInferenceFromReceipt(receipt);
return {
contrastPacket: contrastPacketFromLatestResult(latest, evidence),
topCandidateTimes: discriminatorCandidateTimes(latest),
contrastPacket: contrastPacketFromLatestResult(latest ?? null, evidence),
topCandidateTimes: discriminatorCandidateTimes(latest ?? null),
askedProbeKeys: askedDiscriminatorKeys(receipt, evidence),
eventProbes: refinement.discriminating_event_probes,
eventClarificationProbes: refinement.event_clarification_probes,
@@ -189,7 +189,7 @@ function contrastPacketFromLatest(
latest: NonNullable<DossierForTools["latestResult"]> | null | undefined,
evidence: DossierForTools["evidence"] = [],
) {
return contrastPacketFromLatestResult(latest, evidence);
return contrastPacketFromLatestResult(latest ?? null, evidence);
}
function snapshotSourceFromDossier(
@@ -146,6 +146,70 @@ test("recorded education evidence does not suppress an unasked D24 discriminator
assert.equal(packet.probes[0]?.expectedOutcomes.at(-1)?.outcomeId, "unsure");
});
test("answered inference probes drop out of the catalog by probe_id", () => {
const outcomes = [
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:12"] },
{ answer_class: "no" as const, supports: ["05:12"], conflicts: ["05:00"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
];
const packet = contrastPacketFromDossier({
evidence: [],
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
latestResult: {
resultId: "result-answered-probe",
candidates: [
{ candidateId: "c-0500", time: "05:00", relativeSupport: 20 },
{ candidateId: "c-0512", time: "05:12", relativeSupport: 15 },
],
decisionReceipt: {
inference_state: {
algorithm_version: "rectification-inference-v1",
candidate_set_id: "05:00-05:12:05:00,05:12",
revision: 1,
phase: "discrimination",
result_status: "discriminating",
range_start: "05:00",
range_end: "05:12",
candidates: [
{ id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 20, posterior_score: 20, probability: 0.6, status: "active", rank: 1, strong_conflict_count: 0 },
{ id: "05:12", time: "05:12", cluster_range: ["05:12", "05:12"], prior_score: 15, posterior_score: 15, probability: 0.4, status: "active", rank: 2, strong_conflict_count: 0 },
],
events: [],
probes: [{
id: "probe:career.2018.dasha_boundary:hash",
year: 2018,
domain: "career",
source: "dasha_boundary",
question: "2018 年 3 月前后 career",
semantic_key: "career.2018.dasha_boundary",
candidate_ids: ["05:00", "05:12"],
information_gain: 1.2,
expected_outcomes: outcomes,
candidate_split_hash: "hash",
}],
answered_probes: [{
probe_id: "probe:career.2018.dasha_boundary:hash",
semantic_key: "career.2018.dasha_boundary",
candidate_split_hash: "hash",
answer_class: "yes",
classified_from: "choice",
}],
rounds: [],
entropy: 1,
representative_time: "05:00",
credible_range: ["05:00", "05:12"],
},
},
},
case: { acceptedTime: null },
});
assert.equal(packet.probes.some((probe) => probe.semanticKey === "career.2018.dasha_boundary"), false);
const adapter = readSource("../src/lib/rectification-agentic/v9/decision-from-dossier.ts");
assert.match(adapter, /answered_probes[\s\S]{0,120}item\.probe_id/);
assert.doesNotMatch(adapter, /answered_probes[\s\S]{0,120}item\.id\)/);
});
test("scored inference catalog outranks a low-gain Python career probe when snapshot candidates are empty", () => {
const careerOutcomes = [
{ answer_class: "yes", supports: ["04:45", "05:00", "05:14"], conflicts: ["05:15"] },