diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 68b2daa9..2310c28d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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-309(validate 跳过 `next build`,publish 才暴露类型错误) +- 修复版本:待发布 + ## BUG-408 | 反推题丢掉大运起点的月份,同年 3 月对 9 月问不出来 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index c7332c04..95c86e14 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -60,7 +60,7 @@ export type DecisionDossier = Readonly<{ }; latestResult: { resultId?: string; - decisionReceipt: Readonly> | null; + decisionReceipt?: Readonly> | 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, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index c2703280..8dc39aec 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -189,7 +189,7 @@ function contrastPacketFromLatest( latest: NonNullable | null | undefined, evidence: DossierForTools["evidence"] = [], ) { - return contrastPacketFromLatestResult(latest, evidence); + return contrastPacketFromLatestResult(latest ?? null, evidence); } function snapshotSourceFromDossier( diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 0134e839..14f743e5 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -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"] },