From 393f352a8d67e70ba5319f0d8ca7c99237829f1d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 4 Aug 2026 14:32:09 +0800 Subject: [PATCH] fix(rectification): stream persisted candidate choices --- docs/BUG_HISTORY.md | 14 +++++++ .../src/lib/db/local-postgres-client-core.ts | 9 +++- frontend/src/mastra/agentic-rectification.ts | 1 + frontend/src/mastra/rectification-tools.ts | 16 ++++++- .../tests/database-local-business.test.ts | 14 ++++++- .../tests/rectification-agentic-tools.test.ts | 42 ++++++++++++++++++- 6 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index adce28cb..8a800747 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2054,3 +2054,17 @@ - 验证:TypeScript 通过;聚焦测试 90/90;完整测试 1221/1221;lint 0 error、3 个既有 warning;production build 通过;本地 PostgreSQL 验证 `04:55` 写为 `accepted`、保留 `05:00` reported time、重复采纳幂等,并验证出生申报时间变化会使结果失效且拒绝再次采纳。 - 相关记录:BUG-113、BUG-114、BUG-115、BUG-116 - 修复版本:待提交与发布 + +## BUG-118 | 候选已落库但确认卡不显示,VedAstro 未执行被误述为未通过 + +- 状态:resolved(local,pending deployment) +- 首次发现:2026-08-04 +- 最近更新:2026-08-04 +- 影响面:Agentic 生时校正候选 SSE、self-hosted PostgreSQL 查询兼容层、确认门公开语义 +- 用户现象:`rectification-confirm` 已返回并持久化 `selection_allowed=true` 的候选时间,但页面只显示 Agent 文本,不显示候选确认卡;Agent 同时把 VedAstro 未执行、邻近分钟诊断和留一事件诊断混写成确认门未通过。 +- 根因:候选恢复查询调用 `.gt("expires_at", now)`,而 staging 使用的 `LocalPostgresQueryBuilder` 未实现 `gt`,路由捕获读取异常后仍发送完成事件;外部验证本身只有在本地候选满足事件数、领域数、窄区间、唯一领先和必需层完整时才执行,`missing_mandatory_layers` 会使其保持 `not_evaluated`,并非 VedAstro 调用失败。邻近分钟与留一事件在 technique contract 中仅为诊断项。 +- 修复:在共享本地 PostgreSQL query builder 中实现参数化 `gt` 过滤;保留现有候选卡与 SSE 协议不另起状态;确认工具显式返回外部验证是否已调用、状态和原因,并要求 Agent 区分 `not_evaluated` 与 `fail`,不得把诊断项描述为硬阻塞。 +- 验证:真实 local PostgreSQL business client 回归覆盖未过期候选读取;Agentic 工具回归覆盖 `not_evaluated` 映射为 `external_validation_invoked=false`;Session、entry、candidate persistence 聚焦测试通过。 +- 防复发:self-hosted query builder 新增 Supabase/PostgREST 链式操作时必须由真实 PostgreSQL fixture 覆盖;公开文案必须按 `external_engines.status` 区分未执行、失败和通过。 +- 相关记录:BUG-116、BUG-117 +- 修复版本:待本次 staging 修复提交与部署验收 diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index 57a780dd..c166e769 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -13,6 +13,7 @@ type QueryResult = Readonly<{ type Filter = | Readonly<{ kind: "eq"; column: string; value: unknown }> | Readonly<{ kind: "neq"; column: string; value: unknown }> + | Readonly<{ kind: "gt"; column: string; value: unknown }> | Readonly<{ kind: "in"; column: string; value: readonly unknown[] }> | Readonly<{ kind: "is"; column: string; value: unknown }> | Readonly<{ kind: "notContains"; column: string; value: unknown }>; @@ -176,6 +177,12 @@ class LocalPostgresQueryBuilder implements PromiseLike { return this; } + gt(column: string, value: unknown) { + identifier(column); + this.filters.push({ kind: "gt", column, value }); + return this; + } + in(column: string, value: readonly unknown[]) { identifier(column); this.filters.push({ kind: "in", column, value }); @@ -257,7 +264,7 @@ class LocalPostgresQueryBuilder implements PromiseLike { return `not (${column} @> $${parameters.length})`; } parameters.push(databaseValue(types.get(filter.column), filter.value)); - return `${column} ${filter.kind === "neq" ? "<>" : "="} $${parameters.length}`; + return `${column} ${filter.kind === "neq" ? "<>" : filter.kind === "gt" ? ">" : "="} $${parameters.length}`; }); return ` where ${parts.join(" and ")}`; } diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 7025baa2..6f755a6f 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -21,6 +21,7 @@ TRUTH BOUNDARIES (from the skill overlay) - Keep three states distinct: candidate is an engine comparison result; accepted is the user's chosen working birth time; confirmed is a unique minute that passed the engine confirmation gate and was accepted by the user. - You may show only the server-returned candidate times and relative_support values. Call them “相对支持度”, never probability, statistical confidence, or certainty. Never expose raw scores, weights, event ids, payloads, or chain-of-thought. - If confirmation_allowed=false but selection_allowed=true, explain that the engine has not uniquely confirmed one minute and let the user choose among the returned candidates. Never call that choice engine-confirmed. +- Read external_validation_status literally: not_evaluated means official VedAstro was not invoked because its local entry gate was not ready, not that VedAstro ran and failed. Neighbor stability and leave-one-event-out are diagnostic confidence indicators, not hard blockers. - If confirmation_allowed=true, still require explicit user agreement before saving the representative minute. SAVING diff --git a/frontend/src/mastra/rectification-tools.ts b/frontend/src/mastra/rectification-tools.ts index 913132ed..5fb6a4f6 100644 --- a/frontend/src/mastra/rectification-tools.ts +++ b/frontend/src/mastra/rectification-tools.ts @@ -531,7 +531,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext const confirmTool = createTool({ id: "rectification-confirm", description: - "Run the high-rigor confirmation gate for the candidate range and the user's dated events: three-engine parity, external VedAstro validation, neighbor stability, leave-one-out retention, width and margin thresholds. Returns whether a precise minute can be confirmed, the representative minute, and the reasons. Only call once you have enough confirmed dated events across domains. This does NOT write anything.", + "Run the high-rigor confirmation gate for the candidate range and the user's dated events. Official VedAstro runs only after the local external-validation entry gate is ready; external_validation_status=not_evaluated means it was not invoked, not that it failed. Neighbor stability and leave-one-out are diagnostic confidence indicators, not hard blockers. Returns whether a precise minute can be confirmed, the representative minute, and the reasons. Only call once you have enough confirmed dated events across domains. This does NOT write anything.", inputSchema: z.object({ candidate_range: candidateRangeSchema, events: z.array(agenticRectificationEventSchema).min(1).max(40), @@ -558,6 +558,15 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext const gates = (technique?.gates && typeof technique.gates === "object") ? technique.gates as Record : {}; + const externalEngines = (technique?.external_engines && typeof technique.external_engines === "object") + ? technique.external_engines as Record + : {}; + const externalValidation = (externalEngines.validation && typeof externalEngines.validation === "object") + ? externalEngines.validation as Record + : {}; + const externalValidationStatus = typeof externalEngines.status === "string" + ? externalEngines.status + : "not_evaluated"; const confirmationAllowed = technique?.confirmation_allowed === true && technique?.decision === "confirm_minute"; const representativeTime = winning && timePattern.test(String(winning.representative_time ?? "")) @@ -604,6 +613,11 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext } : null, reasons: Array.isArray(data.reasons) ? data.reasons : [], stability_diagnostics: data.stability_diagnostics, + external_validation_status: externalValidationStatus, + external_validation_invoked: externalValidationStatus !== "not_evaluated", + external_validation_reason: typeof externalValidation.reason === "string" + ? externalValidation.reason + : typeof externalValidation.vedastro_reason === "string" ? externalValidation.vedastro_reason : null, technique_contract: { decision: technique?.decision, confirmation_allowed: technique?.confirmation_allowed, diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index b70a2c45..383a1dc0 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { loadLatestAgenticRectificationResult } from "../src/lib/rectification-agentic/session.ts"; import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const runnerPath = fileURLToPath( @@ -216,11 +217,22 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic ) values ( '33333333-3333-4333-8333-333333333333', '${userId}', '${rectificationSessionId}', 'engine-result-1', 'canonical-hash-1', 'test-v1', '{}', - '[{"time":"04:55","relative_support":60},{"time":"05:07","relative_support":40}]', + '[{"rank":1,"time":"04:55","relative_support":60,"tied_minute_count":1},{"rank":2,"time":"05:07","relative_support":40,"tied_minute_count":1}]', 'medium', true, false, '04:55', '1997-08-08', '05:00', 'family_exact', 10, 10, 36.420487, 114.209936, 8 ); `); + const latestCandidate = await loadLatestAgenticRectificationResult( + admin as never, + userId, + rectificationSessionId, + ); + assert.equal(latestCandidate?.resultId, "33333333-3333-4333-8333-333333333333"); + assert.equal(latestCandidate?.selectionAllowed, true); + assert.deepEqual(latestCandidate?.candidates.map(({ time, relative_support }) => ({ time, relative_support })), [ + { time: "04:55", relative_support: 60 }, + { time: "05:07", relative_support: 40 }, + ]); assert.equal( fixture.psqlAs( "admin_runtime", diff --git a/frontend/tests/rectification-agentic-tools.test.ts b/frontend/tests/rectification-agentic-tools.test.ts index 84bc3473..0ea9d6cf 100644 --- a/frontend/tests/rectification-agentic-tools.test.ts +++ b/frontend/tests/rectification-agentic-tools.test.ts @@ -98,7 +98,11 @@ const confirmedEngineResponse = () => ({ domain_count: 3, can_apply: true, winning_segment: { start_time: "14:28", end_time: "14:32", representative_time: "14:30", width_minutes: 4 }, - technique_contract: { confirmation_allowed: true, decision: "confirm_minute" }, + technique_contract: { + confirmation_allowed: true, + decision: "confirm_minute", + external_engines: { status: "pass", validation: { reason: "validated" } }, + }, reasons: [], missing_layers: [], candidate_ranking_summary: [ @@ -375,6 +379,42 @@ test("confirm persists ranked candidates with relative support totaling 100", as engine.restore(); }); +test("confirm distinguishes skipped external validation from a failed VedAstro run", async () => { + const engine = installEngine([{ + path: "/api/active_rectification_events", + respond: () => { + const response = confirmedEngineResponse(); + return { + ...response, + body: { + ...response.body, + can_apply: false, + technique_contract: { + confirmation_allowed: false, + decision: "continue_rectification", + external_engines: { + status: "not_evaluated", + validation: { reason: "local_candidate_not_ready_for_external_validation" }, + }, + }, + }, + }; + }, + }]); + const tools = createAgenticRectificationTools(makeCtx()); + + const result = await runTool(tools, "rectification-confirm", { + candidate_range: { start_time: "14:00", end_time: "15:00" }, + events: sampleEvents, + }); + + assert.equal(result.selection_allowed, true); + assert.equal(result.external_validation_status, "not_evaluated"); + assert.equal(result.external_validation_invoked, false); + assert.equal(result.external_validation_reason, "local_candidate_not_ready_for_external_validation"); + engine.restore(); +}); + test("accept candidate tool delegates the exact persisted candidate and preserves accepted status", async () => { const calls: Array<{ time: string; resultId?: string }> = []; const tools = createAgenticRectificationTools(makeCtx(async (time, resultId) => {