diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ecfdfa7e..99307c0e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -15235,6 +15235,7 @@ - 修复:新模块 `rectification-adopted-chart-tiers.ts` 按来源记录读该 Case 的档案(`get_agentic_rectification_case_dossier`,服务账号),解析 `segment_summary`(采用的那次结果;若之后同一 Case 有更新的结果则用更新的,不跨 Case),5 分钟缓存;共用的范围读取函数在有不可判分盘时附带 `unreliableVargas`。聊天把它写进排盘工具输入,工作流调用时从发给引擎的载荷里剔除,写入 `answer_policy.unreliable_vargas` / `deterministic_claims_forbidden_for` 与一句服务端说明(VOICE「采用后判断不了的分盘」)。为不改另一单正在改的 `consult/route.ts`,挂接点放在路由已经注入的范围读取函数里。 - 验证:`tests/rectification-adopted-chart-tiers.test.ts`(segment-v1 / 旧采用 / 未校正 / 结果缺失 / 摘要损坏五种输入、跨 Case 拒绝、缓存);`tests/rectification-chart-tier-caveats-consult.test.ts`(范围读取 A/B、工具输入 A/B 与旧资料字节快照、引擎载荷不含该字段、模型数据包带策略与说明);旧资料聊天工具输入、引擎请求、工作流上下文、模型数据包与基线 `437dcecb` 逐字节相同(脚本见 PROGRESS);`tests/database-segment-adoption.test.ts` 追加真库读取断言(PostgreSQL 17 本地替身,正式证据待真 Docker)。 - 防复发:读取失败只记日志不阻断聊天;非 segment-v1 资料不发 RPC;模型侧沿用 `mastra/index.ts` 既有「`deterministic_claims_forbidden_for` 是硬禁令」规则,不新增提示词。 +- Claude 验收修正(2026-10-01):执行方实现总读 Case 最新结果、只看档位;但存下的分钟不随新结果变,且 D7 在头段无交集时退回 D1 头段中点,存盘分钟的 D9/D10 可能不在该盘头段——只看档位会把存盘分钟读不到的星座说成「较可信」(放宽置信边界)。改为 `unreliableChartsForSavedMinute`:档位不可判,或(非全窗唯一且)存盘分钟 `window_offset_minutes` 不在该盘头段,或偏移缺失,均标不可判。新增两条测试(存盘分钟落在非头段 → D9 标注;偏移缺失 → 所有窗内会变的盘标注),原测试 fixture 补真实采用必带的 `window_offset_minutes`(原值:无该字段 / 新值:60 或该 summary 的 adoption_minute.offset / 原因:真实 segment-v1 来源恒含此字段,新规则依赖它)。 - 相关记录:BUG-1115~1117、BUG-1131、BUG-1139、BUG-690 - 复发自:无 - 修复版本:`codex/rectification-chart-tier-caveats-20261001`(未合入、未部署) diff --git a/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md b/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md index 68239bc1..019a3e73 100644 --- a/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md +++ b/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md @@ -76,3 +76,11 @@ Linux,Node 22.14(`/exec-daemon/node`),Python 3.13(系统 `python3`, - 写作模型报告路径(`PERSONAL_REPORT_WRITER_ENABLED = false`)未接 `demoteThemesMissingRequiredCharts`;该路径生产关闭,开启前需补。 - 只给图盘 / 数值位置表加提示,未给依赖 D9/D10 的主题段落(报告正文为引擎输出,无稳定的主题→分盘映射);若要按主题降级需引擎侧配合,另立单。 - 已生成的旧报告不回改(任务书 D3)。 + +## Claude 验收修正(2026-10-01) + +| 项 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 分盘可靠性判定 | 最新结果 `segment_summary` 中档位为 blocked / indistinct 即不可判 | 另加:非全窗唯一的分盘,存盘分钟(来源记录 `window_offset_minutes`)不在该盘头段,或偏移缺失,也不可判(`unreliableChartsForSavedMinute`) | 存盘分钟不随采用后的新结果变;D7 头段无交集时退回 D1 头段中点;只看档位会把存盘分钟读不到的星座标为较可信,放宽置信边界(AGENTS §8.4) | +| `rectification-adopted-chart-tiers.test.ts` fixture | 来源记录无 `window_offset_minutes` | 补该字段(60 / summary.adoption_minute.offset) | 真实 segment-v1 来源恒含此字段 | +| 新增测试 | — | 存盘分钟落在 D9 非头段 → 标注 D9;偏移缺失 → 窗内会变的盘全部标注 | 锁定上面的规则 | diff --git a/frontend/src/lib/rectification-adopted-chart-tiers.ts b/frontend/src/lib/rectification-adopted-chart-tiers.ts index 5d607646..e401b0f4 100644 --- a/frontend/src/lib/rectification-adopted-chart-tiers.ts +++ b/frontend/src/lib/rectification-adopted-chart-tiers.ts @@ -13,7 +13,7 @@ import { parseSegmentSummary, TARGET_CHARTS, type SegmentSummary, type TargetCha export const UNRELIABLE_CHART_TIERS = new Set(["blocked", "indistinct"]); export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier"; -export type SegmentProvenance = Readonly<{ caseId: string; resultId: string }>; +export type SegmentProvenance = Readonly<{ caseId: string; resultId: string; windowOffset: number | null }>; export type AdoptedChartTiers = Readonly<{ source: "segment-v1"; @@ -38,7 +38,9 @@ export function segmentProvenance(value: unknown): SegmentProvenance | null { if (!row || row.contract !== "segment-v1") return null; const caseId = typeof row.case_id === "string" ? row.case_id : ""; const resultId = typeof row.result_id === "string" ? row.result_id : ""; - return uuidPattern.test(caseId) && uuidPattern.test(resultId) ? { caseId, resultId } : null; + const offset = row.window_offset_minutes; + const windowOffset = typeof offset === "number" && Number.isInteger(offset) && offset >= 0 ? offset : null; + return uuidPattern.test(caseId) && uuidPattern.test(resultId) ? { caseId, resultId, windowOffset } : null; } export function unreliableChartsFromSummary(summary: SegmentSummary): TargetChart[] { @@ -46,9 +48,28 @@ export function unreliableChartsFromSummary(summary: SegmentSummary): TargetChar } /** - * Prefer the adopted result. A later result of the same Case (answers after - * adoption) carries strictly more evidence about the same window, so its tiers - * are used instead; a different Case is never read. + * A chart is reliable for the saved minute only when its tier is not unreliable and the + * saved minute sits in that chart's head segment (or the chart never changes in the window). + * D7 adopts the head-intersection midpoint and falls back to the D1 head when the heads do not + * intersect, and a later result may move a head after adoption, so the tier alone can claim a + * sign the saved minute does not have. Missing offset is treated as unknown: not reliable. + */ +export function unreliableChartsForSavedMinute(summary: SegmentSummary, windowOffset: number | null): TargetChart[] { + return TARGET_CHARTS.filter((chart) => { + const row = summary.charts.find((item) => item.chart === chart); + if (!row) return false; + if (UNRELIABLE_CHART_TIERS.has(row.tier)) return true; + if (row.full_window_unique) return false; + if (windowOffset === null) return true; + const segment = row.segments.find((item) => item.start <= windowOffset && windowOffset <= item.end); + return !segment || segment.index !== row.top_segment; + }); +} + +/** + * Reads the Case's latest result (the adopted one, or a later one after more answers) and + * judges each chart against the saved minute, not the result's own head: see + * unreliableChartsForSavedMinute. A different Case is never read. */ export function chartTiersFromDossier(dossier: unknown, provenance: SegmentProvenance): AdoptedChartTiers | "invalid" { const root = record(dossier); @@ -65,7 +86,7 @@ export function chartTiersFromDossier(dossier: unknown, provenance: SegmentProve return { source: "segment-v1", resultMatch: resultId === provenance.resultId ? "adopted" : "latest", - unreliable: unreliableChartsFromSummary(summary), + unreliable: unreliableChartsForSavedMinute(summary, provenance.windowOffset), }; } diff --git a/frontend/tests/rectification-adopted-chart-tiers.test.ts b/frontend/tests/rectification-adopted-chart-tiers.test.ts index cdc2ff26..4cc22001 100644 --- a/frontend/tests/rectification-adopted-chart-tiers.test.ts +++ b/frontend/tests/rectification-adopted-chart-tiers.test.ts @@ -18,7 +18,7 @@ const userId = "11111111-1111-4111-8111-111111111111"; const caseId = "22222222-2222-4222-8222-222222222222"; const resultId = "33333333-3333-4333-8333-333333333333"; const newerResultId = "44444444-4444-4444-8444-444444444444"; -const segmentV1 = { contract: "segment-v1", case_id: caseId, result_id: resultId, candidate_time: "09:05" }; +const segmentV1 = { contract: "segment-v1", case_id: caseId, result_id: resultId, candidate_time: "09:05", window_offset_minutes: 60 }; // Real 31-minute scan: D1 is one sign (certain); every candidate weighs on the // first D9/D10 segments so the posterior picks them. @@ -49,7 +49,8 @@ function client(data: unknown, error: unknown = null) { } test("provenance: only segment-v1 with uuid case/result is read", () => { - assert.deepEqual(segmentProvenance(segmentV1), { caseId, resultId }); + assert.deepEqual(segmentProvenance(segmentV1), { caseId, resultId, windowOffset: 60 }); + assert.deepEqual(segmentProvenance({ ...segmentV1, window_offset_minutes: undefined }), { caseId, resultId, windowOffset: null }); for (const value of [null, {}, { contract: "candidate-v2", case_id: caseId, result_id: resultId }, { ...segmentV1, case_id: "x" }, "segment-v1"]) { assert.equal(segmentProvenance(value), null); } @@ -88,7 +89,8 @@ test("real narrow scan: certain D1 and credible D9/D10 are not flagged", () => { const summary = realSummary(); const tiers = Object.fromEntries(summary.charts.map((chart) => [chart.chart, chart.tier])); assert.equal(tiers.D1, "certain"); - const result = chartTiersFromDossier(dossier(summary), { caseId, resultId }); + // The saved minute is what D7 adopted from this very summary. + const result = chartTiersFromDossier(dossier(summary), { caseId, resultId, windowOffset: summary.adoption_minute!.offset }); assert.notEqual(result, "invalid"); if (result === "invalid") return; assert.deepEqual(result.unreliable, summary.charts.filter((chart) => chart.tier === "blocked" || chart.tier === "indistinct").map((chart) => chart.chart)); @@ -96,9 +98,9 @@ test("real narrow scan: certain D1 and credible D9/D10 are not flagged", () => { }); test("a newer result of the same Case is read; another Case never is", () => { - const newer = chartTiersFromDossier(dossier(wideSummary(), newerResultId), { caseId, resultId }); + const newer = chartTiersFromDossier(dossier(wideSummary(), newerResultId), { caseId, resultId, windowOffset: 60 }); assert.deepEqual(newer, { source: "segment-v1", resultMatch: "latest", unreliable: ["D9", "D10"] }); - assert.equal(chartTiersFromDossier(dossier(wideSummary(), resultId, "55555555-5555-4555-8555-555555555555"), { caseId, resultId }), "invalid"); + assert.equal(chartTiersFromDossier(dossier(wideSummary(), resultId, "55555555-5555-4555-8555-555555555555"), { caseId, resultId, windowOffset: 60 }), "invalid"); }); test("results are cached per adopted result for five minutes", async () => { @@ -113,3 +115,25 @@ test("results are cached per adopted result for five minutes", async () => { await loadAdoptedChartTiers(reader, { userId, provenance: segmentV1 }, () => {}, () => now); assert.equal(reader.calls.length, 2); }); + +// Reviewer addition (Claude 2026-10-01): reliability is judged for the saved minute, not the head. +test("a credible chart is still flagged when the saved minute is outside its head segment", () => { + const summary = realSummary(); + const d9 = summary.charts.find((chart) => chart.chart === "D9")!; + assert.ok(!["blocked", "indistinct"].includes(d9.tier) && !d9.full_window_unique, "fixture: D9 credible and changing"); + const outside = d9.segments.find((segment) => segment.index !== d9.top_segment)!; + const moved = chartTiersFromDossier(dossier(summary, newerResultId), { caseId, resultId, windowOffset: outside.start }); + assert.notEqual(moved, "invalid"); + if (moved === "invalid") return; + assert.equal(moved.resultMatch, "latest"); + assert.ok(moved.unreliable.includes("D9"), "saved minute's D9 differs from the head the card would name"); + assert.ok(!moved.unreliable.includes("D1"), "D1 never changes in this window"); +}); + +test("an unknown saved-minute offset flags every chart that changes in the window", () => { + const summary = realSummary(); + const result = chartTiersFromDossier(dossier(summary), { caseId, resultId, windowOffset: null }); + assert.notEqual(result, "invalid"); + if (result === "invalid") return; + assert.deepEqual(result.unreliable, summary.charts.filter((chart) => !chart.full_window_unique).map((chart) => chart.chart)); +});