fix: restore birth-time rectification entry

This commit is contained in:
Jesse_Chen
2026-07-22 10:50:23 +08:00
parent 5e0c1933a3
commit f5c57efeba
8 changed files with 164 additions and 38 deletions
+19 -3
View File
@@ -208,11 +208,11 @@
- 首次发现:2026-07-22
- 最近更新:2026-07-22
- 影响面:普通问答消息、Agent 回答顶部区域
- 用户现象:回答正文上方显示“证据状态:not-applicable”和“证据链摘要 · not-applicable”等工程审计信息。
- 用户现象:回答正文上方显示“证据状态:not-applicable”和“证据链摘要 · not-applicable”等工程审计信息,正文下方还显示单条回答的报告下载控件
- 触发条件:任意已完成的 Agent 回答,尤其是后端返回 `not-applicable` 时。
- 根因:消息行对每条非思考态 Agent 消息无条件渲染 claim boundary 与 Technique Audit Table;未识别状态又直接回退显示原始状态值。
- 修复:从普通聊天消息行移除内部证据徽章审计面板;证据状态和 workflow receipt 仍随消息保存并供内部约束及报告生成使用。
- 验证:`frontend/tests/claim-boundary-badge.test.ts``frontend/tests/evidence-audit-panel.test.ts` 锁定聊天消息不再挂载两个内部组件。
- 修复:从普通聊天消息行移除内部证据徽章审计面板和单条回答报告下载控件;证据状态和 workflow receipt 仍随消息保存并供内部约束使用。
- 验证:`frontend/tests/claim-boundary-badge.test.ts``frontend/tests/evidence-audit-panel.test.ts``frontend/tests/consultation-report-export.test.ts` 锁定聊天消息不再挂载这些内部组件。
- 防复发:聊天消息渲染契约禁止直接展示 `techniqueTruth``workflowReceipt`;需要运营或调试时使用独立的受控界面。
- 相关记录:BUG-009
- 复发自:无
@@ -281,3 +281,19 @@
- 相关记录:BUG-009、BUG-014、ERR-045、ERR-053、ERR-086
- 复发自:无
- 修复版本:d7d9703
## BUG-016 | 生时校正首轮被扫描稀疏度或候选分区超时打成 503
- 状态:resolved
- 首次发现:2026-07-22
- 最近更新:2026-07-22
- 影响面:首页“先完成生时校正”、`POST /api/birth-time-conversation` 首轮技术包
- 用户现象:点击生时校正后仍停留在普通咨询页,并显示“生时校正暂时无法继续”;接口返回 503。
- 触发条件:候选扫描包含真实但不连续的时点差异,或动态候选分区依赖在 45 秒内没有返回。
- 根因:技术包只承认相邻分钟切换,错误拒绝了稀疏扫描中的真实范围差异;同时首轮把用于改善问题排序的候选分区依赖当成不可降级的硬依赖。生产版本 `b981c4e` 的日志分别记录了 `RectificationTechnicalPacketRangeError``candidate_differences TimeoutError`
- 修复:稀疏扫描差异改用明确的范围级文案,禁止伪称相邻分钟切换;超过六小时的宽范围首轮跳过分钟级候选分区,较窄范围在候选分区发生已知超时、取消、配置或服务故障时保留服务端扫描证据并继续收集,不再返回 503;未知编程或契约错误仍失败关闭。
- 验证:`frontend/tests/conversational-technical-packet.test.ts``frontend/tests/conversational-rectification-route.test.ts`、真实 Chromium 390px 组件回归和 `npm run build` 通过;完整前端套件 844 项中 837 项通过,余下 7 项均为沙箱 Chromium / PostgreSQL 端口环境失败,针对性 Chromium 在沙箱外 8/8 通过。
- 防复发:首轮必须区分核心扫描失败与可降级的候选排序依赖;稀疏差异文案必须显式否认相邻分钟切换;生产验收必须核对接口状态与实际进入校正会话。
- 相关记录:BUG-004、BUG-008、ERR-091
- 复发自:无
- 修复版本:待提交(本地可测)
@@ -23,6 +23,7 @@ import {
import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts";
import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts";
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
import type { CandidateDifferenceBuild } from "../../../lib/birth-time-dynamic-choice-internal.ts";
import { conversationalRectificationCreationPolicyFromEnvironment } from "../../../lib/conversational-rectification/creation-policy.ts";
import {
conversationalRectificationLatencyBucket,
@@ -449,6 +450,43 @@ function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: str
};
}
function unavailableCandidateDifferences(
caseId: string,
range: { readonly startTime: string; readonly endTime: string },
): CandidateDifferenceBuild {
return {
packet: {
caseId,
scoringVersion: "birth-time-choice-scoring-v2",
currentRange: range,
opportunities: [],
askedQuestionFingerprints: [],
candidatePartitionFingerprints: [],
recentRangeHistory: [],
},
candidateModel: { version: "birth-time-choice-scoring-v2" },
scoringPartitions: {},
};
}
function candidateDifferencesAreUnavailable(error: unknown): boolean {
return error instanceof Error && [
"AbortError",
"TimeoutError",
"BirthTimeJourneyEngineError",
"BirthTimeJourneyEngineConfigurationError",
].includes(error.name);
}
function candidateDifferenceRangeIsTooWide(
range: { readonly startTime: string; readonly endTime: string },
): boolean {
const start = minute(range.startTime);
let end = minute(range.endTime);
if (end < start) end += 1_440;
return end - start > 360;
}
function boundaryDistance(range: { readonly startTime: string; readonly endTime: string }, representative: string) {
const start = minute(range.startTime);
let end = minute(range.endTime);
@@ -529,26 +567,35 @@ export async function buildProductionConversationalRectificationPacket(
"merge_scans",
() => mergeQuestionnaireScans(questionnaires, range),
);
const candidateDifferences = await rectificationPacketStage(
"candidate_differences",
() => engine.buildDifferencePacket({
caseId: input.caseId,
asOfDate: input.asOfDate,
birthDate: input.declaredBirthInput.birthDate,
startTime: range.startTime,
endTime: range.endTime,
lat: latitude,
lon: longitude,
tz: place.timezoneOffset,
evidence: [],
events,
dismissedOpportunityIds: [],
questionFingerprints: [],
partitionFingerprints: [],
recentRanges: [],
candidateModel: null,
}),
);
let candidateDifferences = unavailableCandidateDifferences(input.caseId, range);
if (!candidateDifferenceRangeIsTooWide(range)) {
try {
candidateDifferences = await rectificationPacketStage(
"candidate_differences",
() => engine.buildDifferencePacket({
caseId: input.caseId,
asOfDate: input.asOfDate,
birthDate: input.declaredBirthInput.birthDate,
startTime: range.startTime,
endTime: range.endTime,
lat: latitude,
lon: longitude,
tz: place.timezoneOffset,
evidence: [],
events,
dismissedOpportunityIds: [],
questionFingerprints: [],
partitionFingerprints: [],
recentRanges: [],
candidateModel: null,
}),
);
} catch (error) {
if (!candidateDifferencesAreUnavailable(error)) throw error;
// Candidate partitions improve question ranking, but evidence collection can still
// proceed from the server scan. Do not turn an optional slow dependency into a 503.
}
}
const version = calculationVersion
? `${candidateDifferences.packet.scoringVersion}+${calculationVersion}`
: candidateDifferences.packet.scoringVersion;
-1
View File
@@ -161,7 +161,6 @@ button:disabled { cursor: default; opacity: .45; }
.raw-evidence-receipt { margin: 0 var(--space-3) var(--space-3); border-top: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent); padding-top: var(--space-2); }
.raw-evidence-receipt summary { padding: var(--space-1) 0; color: var(--color-ink-tertiary); font-size: 12px; }
.raw-evidence-receipt pre { max-height: 180px; overflow: auto; margin: var(--space-2) 0 0; padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink-secondary); font-size: 11px; line-height: 1.5; }
.report-download-button { margin-top: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); cursor: pointer; padding: 7px 10px; font-size: 12px; }
.message-markdown p { margin: 0 0 14px; }
.message-markdown ul, .message-markdown ol { margin: 10px 0 16px; padding-left: 24px; }
.message-markdown li { margin: 6px 0; }
+1 -2
View File
@@ -1,6 +1,5 @@
import { ChatMessageContent } from "@/components/chat-message-content";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { consultationReportMarkdown, downloadMarkdownReport } from "@/lib/consultation-report-export";
export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
@@ -24,7 +23,7 @@ export function ChatMessageRow({ message }: { readonly message: ChatMessageView
{message.role === "assistant" ? (
message.state === "thinking"
? <div className="thinking"><i /><i /><i /></div>
: <><ChatMessageContent text={message.text} /><button className="report-download-button" type="button" onClick={() => downloadMarkdownReport("Jyotisha 咨询报告", consultationReportMarkdown({ title: "Jyotisha 咨询报告", messages: [message] }))}></button></>
: <ChatMessageContent text={message.text} />
) : <p>{message.text}</p>}
</div>
</div>
@@ -240,13 +240,16 @@ function suggestedDomains(
&& Boolean(values[index]?.length)
&& value !== values[index]
)).length;
if (adjacentChanges === 0) return [];
const observedRangeDifference = item.values.length > 1;
if (adjacentChanges === 0 && !observedRangeDifference) return [];
return [{
domain,
layer: item.layer,
adjacentChanges,
valueCount: item.values.length,
reason: `${item.layer} 在相邻候选分钟中发生 ${adjacentChanges} 次实际切换,并呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件区分。`,
reason: adjacentChanges > 0
? `${item.layer} 在相邻候选分钟中发生 ${adjacentChanges} 次实际切换,并呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件区分。`
: `${item.layer} 在候选范围内已扫描的时点呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件继续区分;这不代表相邻分钟已经发生切换。`,
}];
}).sort((left, right) => right.adjacentChanges - left.adjacentChanges
|| right.valueCount - left.valueCount
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { consultationReportMarkdown, downloadMarkdownReport } from "../src/lib/consultation-report-export.ts";
import { consultationReportMarkdown } from "../src/lib/consultation-report-export.ts";
const rowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
@@ -32,9 +32,8 @@ test("exports latest consultation answer with workflow receipt and claim boundar
assert.match(report, /未闭环内容不得包装成确定预测/);
});
test("assistant answer exposes a markdown report download button", () => {
assert.equal(typeof downloadMarkdownReport, "function");
assert.match(rowSource, /下载本次报告/);
assert.match(rowSource, /downloadMarkdownReport/);
assert.match(globalStyles, /\.report-download-button/);
test("assistant answer does not expose internal report controls", () => {
assert.doesNotMatch(rowSource, /下载本次报告/);
assert.doesNotMatch(rowSource, /downloadMarkdownReport/);
assert.doesNotMatch(globalStyles, /\.report-download-button/);
});
@@ -96,6 +96,7 @@ function packetEngine(options: {
readonly scanTimes?: readonly string[];
readonly scanCalls?: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }>;
readonly scoreResults?: readonly CandidateResult[];
readonly differenceError?: Error;
} = {}): BirthTimeJourneyEngine {
let scoreResultIndex = 0;
const minute = (value: string) => {
@@ -168,6 +169,7 @@ function packetEngine(options: {
},
async buildDifferencePacket(input) {
options.differenceCalls?.push(input);
if (options.differenceError) throw options.differenceError;
return {
packet: {
caseId: input.caseId,
@@ -186,6 +188,62 @@ function packetEngine(options: {
};
}
test("production first turn continues when optional candidate differences time out", async () => {
const built = await buildProductionConversationalRectificationPacket(
packetEngine({ differenceError: new DOMException("timed out", "TimeoutError") }),
{
userId,
caseId,
asOfDate: "2026-07-22",
declaredBirthInput: {
source: "hospital_record",
birthDate: "1990-01-01",
reportedTime: "05:20",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence: [],
},
);
assert.equal(built.packet.candidate.status, "pending_validation");
assert.deepEqual(built.packet.candidate.range, { startTime: "05:18", endTime: "05:22" });
assert.deepEqual(built.packet.candidateDifferenceRefs.filter((item) => (
item.startsWith("birth-time-choice-scoring")
)), []);
assert.equal(built.packet.suggestedDomains.length >= 2, true);
});
test("production first turn skips the minute-heavy candidate partition call for a full day", async () => {
const differenceCalls: DifferencePacketInput[] = [];
const built = await buildProductionConversationalRectificationPacket(
packetEngine({ differenceCalls }),
{
userId,
caseId,
asOfDate: "2026-07-22",
declaredBirthInput: {
source: "unknown",
birthDate: "1990-01-01",
reportedTime: null,
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence: [],
},
);
assert.equal(differenceCalls.length, 0);
assert.deepEqual(built.packet.candidate.range, { startTime: "00:00", endTime: "23:59" });
assert.equal(built.packet.suggestedDomains.length >= 2, true);
});
const packetBirthplace = {
cityCode: "TPE-CITY",
latitude: 25.0268,
@@ -272,13 +272,13 @@ test("does not claim scan-wide 05:10-05:30 differences inside a 05:16-05:24 cand
&& error.reason === "insufficient_samples");
});
test("does not describe sparse in-range samples as adjacent-minute switches", () => {
test("uses sparse in-range differences without describing them as adjacent-minute switches", () => {
const sparseScan = {
...scan,
samples: [scan.samples[1], scan.samples[2]],
} satisfies RectificationQuestionnaire;
assert.throws(() => buildRectificationTechnicalPacket({
const packet = buildRectificationTechnicalPacket({
scan: sparseScan,
candidateDifferences,
eventScore,
@@ -298,8 +298,13 @@ test("does not describe sparse in-range samples as adjacent-minute switches", ()
boundaryDistanceMinutes: 4,
futureWindows: [],
},
}), (error) => error instanceof RectificationTechnicalPacketRangeError
&& error.reason === "insufficient_domains");
});
assert.equal(packet.suggestedDomains.length, 2);
for (const domain of packet.suggestedDomains) {
assert.match(domain.reason, /已扫描的时点/);
assert.match(domain.reason, /不代表相邻分钟已经发生切换/);
assert.doesNotMatch(domain.reason, /发生 \d+ 次实际切换/);
}
});
test("uses typed server time links when normalized scan raw metadata omits sample times", () => {