fix: speed up rectification start

This commit is contained in:
Jesse_Chen
2026-07-25 22:11:13 +08:00
parent 3e56389aeb
commit 031c275324
5 changed files with 192 additions and 74 deletions
+15
View File
@@ -1353,3 +1353,18 @@
- 防复发:数据库 JSON 合同的日期格式只维护一组精度分支;新增生产迁移时必须执行真实 PostgreSQL 函数调用,而不是只做 SQL 文本断言。
- 相关记录:BUG-067、BUG-070
- 修复版本:本次修复提交(生产已向前迁移)
## BUG-072 | 生时校正首轮在零事件时执行全范围扫描并阻塞输入
- 状态:resolved
- 首次发现:2026-07-25
- 最近更新:2026-07-25
- 影响面:首页生时校正入口、`start` 编排、首次会话持久化与咨询问题 handoff
- 用户现象:从首页进入生时校正后长时间停留在加载状态;未知时间或宽时段资料最明显,首问出现后输入框也可能继续等待会话同步。
- 触发条件:新建生时校正 case,尤其声明时间为全天未知或宽时段;首页没有待转交咨询问题时也会读取 durable handoff。
- 根因:`start` 在尚无人生事件时仍构建 Technical Packet,按声明范围逐分钟重排,随后再调用叙事模型生成固定性质的首问;前端收到首轮后又串行等待 `persistSession()`,并且直接首页入口无条件读取 durable handoff。
- 修复:`start` 只按已声明时间范围创建确定性首轮和待验证候选,不做分钟扫描或模型生成;第一条有效事件回答后才进入原技术计算与叙事链。首页先展示首轮并开放输入,再后台同步会话;只有存在本地或显式待转交问题时才读取 durable handoff。
- 验证:Orchestrator、入口、客户端、路由、持久化与同会话写入队列聚焦测试 144/144 通过;目标 ESLint 与 `git diff --check` 通过。覆盖首轮不扫描/不生成叙事、先展示后按序后台持久化,以及无 handoff 时跳过 durable 读取。
- 防复发:零证据首轮不得执行分钟级技术计算或模型调用;非关键会话同步不得阻塞已持久化 case 的首轮交互;可选 handoff 读取必须由实际 handoff 状态触发。
- 相关记录:BUG-055、BUG-065、BUG-067
- 修复版本:待提交(本地可测)
+23 -19
View File
@@ -2087,10 +2087,11 @@ export default function Home() {
: resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification");
const reusingRectificationSession = canReuseSourceRectificationSession
|| resumableSession !== null;
const localHandoff = rectificationQuestionHandoff.current.peek();
const requestedQuestion = pendingConsultationQuestion
?? (reusingRectificationSession
? null
: rectificationQuestionHandoff.current.peek()?.question)
: localHandoff?.question)
?? null;
rectificationOpenInFlight.current = true;
setDraft("");
@@ -2116,7 +2117,9 @@ export default function Home() {
try {
let turn: ConversationalRectificationTurn;
if (!resumeTarget) {
const durable = await durableRectificationQuestionHandoff.current.load();
const durable = requestedQuestion !== null || localHandoff !== null
? await durableRectificationQuestionHandoff.current.load()
: null;
if (durable && durable.status !== "consumed") {
turn = durable.turn;
} else {
@@ -2226,26 +2229,27 @@ export default function Home() {
? rectificationSession.updatedAt
: timestamp(),
};
let sessionSyncFailed = false;
if (!reusingRectificationSession) {
try {
await persistSession(boundSession, "create");
} catch {
sessionSyncFailed = true;
}
setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]);
} else if (boundSession !== rectificationSession) {
updateSession(rectificationSession.id, () => boundSession);
try {
await persistSession(boundSession);
} catch {
sessionSyncFailed = true;
}
}
setRectificationInitialTurn(turn);
setRectificationOpeningAssistantText("");
synchronizeRectificationQuestion(turn, sourceSession);
setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : "");
setComposerNotice("");
if (!reusingRectificationSession) {
setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]);
void rectificationPersistence.current.enqueue(
boundSession.id,
() => persistSession(boundSession, "create"),
).catch(() => {
setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。");
});
} else if (boundSession !== rectificationSession) {
updateSession(rectificationSession.id, () => boundSession);
void rectificationPersistence.current.enqueue(
boundSession.id,
() => persistSession(boundSession),
).catch(() => {
setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。");
});
}
} catch (caught) {
const message = caught instanceof Error
? caught.message
@@ -559,6 +559,89 @@ function midpointOfRange(range: Readonly<{ startTime: string; endTime: string }>
return clock(Math.round((start + end) / 2));
}
function declaredRange(input: DeclaredBirthInput): Readonly<{ startTime: string; endTime: string }> {
if (input.source === "period_only") {
return {
early_morning: { startTime: "04:00", endTime: "07:59" },
morning: { startTime: "08:00", endTime: "11:59" },
afternoon: { startTime: "12:00", endTime: "17:59" },
evening: { startTime: "18:00", endTime: "22:59" },
late_night: { startTime: "23:00", endTime: "03:59" },
}[input.reportedPeriod];
}
if (input.source === "unknown") return { startTime: "00:00", endTime: "23:59" };
if (input.source === "legacy_import" && !input.reportedTime) {
return input.reportedPeriod
? declaredRange({ ...input, source: "period_only", reportedPeriod: input.reportedPeriod })
: { startTime: "00:00", endTime: "23:59" };
}
if (!input.reportedTime) throw new ConversationalRectificationError("profile_incomplete");
const minute = (value: string) => {
const [hour = 0, part = 0] = value.split(":").map(Number);
return hour * 60 + part;
};
const clock = (value: number) => {
const normalized = ((value % 1_440) + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
};
return {
startTime: clock(minute(input.reportedTime) - (input.uncertaintyBeforeMinutes ?? 2)),
endTime: clock(minute(input.reportedTime) + (input.uncertaintyAfterMinutes ?? 2)),
};
}
function openingRectificationState(input: {
readonly caseId: string;
readonly declaredBirthInput: DeclaredBirthInput;
readonly pendingConsultationQuestion: string | null;
}) {
const range = declaredRange(input.declaredBirthInput);
const representativeTime = midpointOfRange(range);
const calculationVersion = "rectification-opening-v1";
const turn = conversationalRectificationTurnSchema.parse({
caseId: input.caseId,
journeyProtocol: "conversational-evidence-v3",
status: "active",
turnVersion: 0,
narrative: "我们先从一件时间最明确、影响比较大的真实经历开始。请只说一件,并告诉我大约发生在哪一年、哪一月?",
candidate: {
status: "pending_validation",
representativeTime,
rangeStart: range.startTime,
rangeEnd: range.endTime,
},
technicalReceipt: {
calculationVersion,
stableLayers: [],
sensitiveLayers: [],
candidateDifferenceRefs: [],
},
evidenceRequest: {
domains: ["career", "education", "relocation", "relationship"],
datePrecision: "month_preferred",
freeTextAllowed: true,
prompt: "请说一件已经发生、时间比较明确的重要经历,并告诉我大约是哪一年、哪一月?",
followUp: { kind: "new_event", evidenceId: null },
},
evidenceRecap: [],
actions: ["answer", "pause", "abandon"],
pendingConsultationQuestion: input.pendingConsultationQuestion,
});
const privateCandidate = privateCandidateSchema.parse({
resultId: null,
representativeTime,
rangeStart: range.startTime,
rangeEnd: range.endTime,
calculationVersion,
workingState: { phase: "collecting_evidence", iteration: 0, notes: [] },
});
return {
turn,
privateCandidate,
validationReceipt: transitionReceipt("deterministic-rectification-opening"),
};
}
type CorrectionResetReason =
| "needs_clarification"
| "non_scoreable"
@@ -1193,33 +1276,10 @@ export function createConversationalRectificationService(
});
reserved = reservation.billingState === "reserved";
if (reserved) lastTelemetryOutcome = { billingState: "unknown", caseStatus: null };
const computed = await ports.buildTechnicalPacket({
userId,
const opening = openingRectificationState({
caseId,
asOfDate: ports.asOfDate(),
declaredBirthInput: declared.data,
privateCandidate: null,
evidence: [],
});
const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0);
const narrative = await generateRectificationNarrative({
phase: "first",
packet: gatedPacket,
generator: ports.narrativeGenerator,
});
const privateCandidate = privateCandidateFromPacket({
packet: gatedPacket,
resultId: null,
iteration: 0,
forceCollecting: true,
});
const firstTurn = turnFromNarrative({
caseId,
turnVersion: 0,
pendingConsultationQuestion: command.pendingConsultationQuestion ?? null,
packet: gatedPacket,
narrative,
evidence: [],
declaredBirthInput: declared.data,
});
const created = await ports.store.createCaseWithFirstTurn({
userId,
@@ -1229,9 +1289,9 @@ export function createConversationalRectificationService(
revisionOfCaseId: profile.revisionOfCaseId,
pendingConsultationQuestion: command.pendingConsultationQuestion ?? null,
declaredBirthInput: declared.data,
firstTurn,
validationReceipt: narrative.validationReceipt,
privateCandidate,
firstTurn: opening.turn,
validationReceipt: opening.validationReceipt,
privateCandidate: opening.privateCandidate,
});
observeCase(created, "unknown");
await ports.billing.complete({
@@ -140,6 +140,32 @@ test("homepage birth-time card opens its dedicated session before the first turn
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}[\s\S]*?openingAssistantText=\{rectificationOpeningAssistantText\}/);
});
test("the first rectification turn becomes interactive before session persistence finishes", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
const turnVisible = handler.indexOf("setRectificationInitialTurn(turn)");
const backgroundPersist = handler.indexOf("void rectificationPersistence.current.enqueue(");
assert.ok(turnVisible >= 0);
assert.ok(backgroundPersist > turnVisible);
assert.match(handler, /void rectificationPersistence\.current\.enqueue\([\s\S]*?\(\) => persistSession\([\s\S]*?\.catch\(\(\) => \{[\s\S]*?校正已经开始,但会话关联暂时未同步到云端。/);
});
test("a direct homepage start skips the durable handoff read when no question was handed off", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
assert.match(handler, /const localHandoff = rectificationQuestionHandoff\.current\.peek\(\)/);
assert.match(
handler,
/const durable = requestedQuestion !== null \|\| localHandoff !== null\s*\? await durableRectificationQuestionHandoff\.current\.load\(\)\s*:\s*null/,
);
});
test("rectification cards render only inside the active rectification session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
@@ -211,6 +211,7 @@ type MutableCase = {
};
function harness(options: {
readonly createFailure?: Error;
readonly packetFailure?: Error;
readonly packetFailureFromBuild?: number;
readonly completeFailures?: number;
@@ -330,6 +331,7 @@ function harness(options: {
async createCaseWithFirstTurn(input) {
events.push("create");
mutations.push("create");
if (options.createFailure) throw options.createFailure;
return replay(input.actionId, input, () => {
const row = stored({
userId: input.userId,
@@ -508,18 +510,30 @@ async function start(value: ReturnType<typeof harness>, pendingConsultationQuest
});
}
test("start validates profile, reads server price, reserves, computes, saves, then charges", async () => {
test("start creates a deterministic opening without scanning or narrative generation", async () => {
const value = harness();
const turn = await start(value);
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "narrative", "create", "complete"]);
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "create", "complete"]);
assert.equal(value.counts().packetBuilds, 0);
assert.equal(turn.caseId, startActionId);
assert.equal(turn.turnVersion, 0);
assert.equal(turn.pendingConsultationQuestion, "我的工作何时变化?");
assert.deepEqual(turn.technicalReceipt.sensitiveLayers, ["D9", "D10"]);
assert.equal(turn.candidate.status, "pending_validation");
assert.equal(turn.candidate.representativeTime, "05:20");
assert.equal(turn.candidate.rangeStart, "04:50");
assert.equal(turn.candidate.rangeEnd, "05:50");
assert.deepEqual(turn.technicalReceipt, {
calculationVersion: "rectification-opening-v1",
stableLayers: [],
sensitiveLayers: [],
candidateDifferenceRefs: [],
});
assert.equal(turn.evidenceRequest?.followUp?.kind, "new_event");
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
assert.equal(value.cases.get(startActionId)?.row.revisionOfCaseId, priorCaseId);
assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58");
assert.equal(value.cases.get(startActionId)?.row.validationReceipts[0]?.modelId, "deterministic-rectification-opening");
});
test("start rejects a client price before profile, billing, or calculation", async () => {
@@ -533,11 +547,11 @@ test("start rejects a client price before profile, billing, or calculation", asy
test("every post-reservation failure releases exactly once and never leaks its cause", async () => {
const raw = "SQL model browser secret detail";
const value = harness({ packetFailure: new Error(raw) });
const value = harness({ createFailure: new Error(raw) });
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "service_unavailable" && !error.message.includes(raw));
assert.equal(value.counts().releaseCount, 1);
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "release"]);
assert.deepEqual(value.events, ["profile", "price", "reserve:9", "create", "release"]);
});
test("a start retry settles an existing reservation without reserving or computing again", async () => {
@@ -550,7 +564,7 @@ test("a start retry settles an existing reservation without reserving or computi
assert.equal(replayed.status, "active");
assert.equal(value.cases.get(startActionId)?.row.billingState, "charged");
assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 });
assert.deepEqual(value.counts(), { packetBuilds: 0, reserveCount: 1, releaseCount: 1 });
assert.deepEqual(value.events.slice(-3), ["profile", "price", "complete"]);
});
@@ -568,7 +582,7 @@ test("a duplicate start with the same declared birth input reuses the unfinished
assert.deepEqual(replayed, first);
assert.deepEqual(value.counts(), countsBeforeRetry);
assert.deepEqual(value.events, [
"profile", "price", "reserve:9", "packet", "narrative", "create", "complete",
"profile", "price", "reserve:9", "create", "complete",
"profile", "price",
]);
});
@@ -595,7 +609,7 @@ test("a duplicate start with changed declared birth input remains a conflict", a
}), (error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "action_conflict");
assert.equal(value.counts().reserveCount, 1);
assert.equal(value.counts().packetBuilds, 1);
assert.equal(value.counts().packetBuilds, 0);
});
test("a settlement failure releases its created case once and cannot replay as success", async () => {
@@ -607,7 +621,7 @@ test("a settlement failure releases its created case once and cannot replay as s
await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "billing_failed");
assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 });
assert.deepEqual(value.counts(), { packetBuilds: 0, reserveCount: 1, releaseCount: 1 });
});
test("clear historical evidence is extracted, scored, narrated, recapped, and atomically saved", async () => {
@@ -621,7 +635,7 @@ test("clear historical evidence is extracted, scored, narrated, recapped, and at
answer: "2018年6月毕业,2019年7月开始第一份工作,2020年3月去外地工作,2022年8月结婚",
});
assert.equal(value.counts().packetBuilds, 2);
assert.equal(value.counts().packetBuilds, 1);
assert.equal(turn.status, "confirming");
assert.equal(turn.candidate.status, "ready_for_confirmation");
assert.equal(turn.turnVersion, 1);
@@ -685,7 +699,7 @@ test("evidence corrections are append-only while recap and scoring use only the
domain: "career",
isCorrection: true,
}]);
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1, 1]);
assert.deepEqual(value.packetEvidenceCounts, [1, 1, 1]);
});
test("uses Agent semantic classification when a single event falls through the deterministic keywords", async () => {
@@ -732,7 +746,7 @@ test("repeated evidence remains auditable but identical date-domain-semantics sc
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
assert.equal(stored.length, 2, "both user submissions remain in the audit ledger");
assert.equal(repeated.evidenceRecap.length, 2);
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1]);
assert.deepEqual(value.packetEvidenceCounts, [1, 1]);
assert.deepEqual(value.packetEvidenceIds.at(-1), [firstId]);
assert.equal(repeated.status, "active");
assert.equal(repeated.actions.includes("confirm"), false);
@@ -771,13 +785,13 @@ test("an unclear correction immediately retires the wrong fact and stays retired
value.cases.get(startActionId)?.row.privateCandidate.scoredHistoricalEvidence ?? [],
[],
);
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 0]);
assert.deepEqual(value.packetEvidenceCounts, [1, 0]);
const later = await value.service.answer(userId, {
type: "answer", caseId: startActionId, actionId: thirdAnswerActionId,
turnVersion: 2, answer: "2022年3月搬家",
});
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 0, 1]);
assert.deepEqual(value.packetEvidenceCounts, [1, 0, 1]);
assert.equal(later.evidenceRecap.some((item) => item.id === wrongId), false);
assert.equal(later.evidenceRecap.some((item) => item.id === unclear?.id), true);
});
@@ -838,7 +852,7 @@ test("every non-confirmable correction rescans the declared range and withdraws
});
test("a narrative fallback cannot discard a confirmation candidate produced by a valid correction", async () => {
const value = harness({ invalidNarrativeFromGeneration: 3 });
const value = harness({ invalidNarrativeFromGeneration: 2 });
await start(value, null);
await value.service.answer(userId, {
type: "answer", caseId: startActionId, actionId: answerActionId,
@@ -944,7 +958,6 @@ test("ordinary new evidence continues incrementally from the current candidate r
});
assert.deepEqual(value.packetPrivateCandidates, [
null,
{ rangeStart: "04:50", rangeEnd: "05:50", resultId: null },
{ rangeStart: "05:16", rangeEnd: "05:20", resultId: null },
]);
@@ -999,7 +1012,7 @@ test("generic date uncertainty does not suppress clear historical evidence", asy
assert.equal(turn.status, "active");
assert.equal(turn.candidate.status, "pending_validation");
assert.equal(value.counts().packetBuilds, 2);
assert.equal(value.counts().packetBuilds, 1);
assert.ok(value.events.includes("score-packet"));
assert.ok((value.cases.get(startActionId)?.row.eventEvidence ?? [])
.some((item) => item.eventSummary.includes("毕业") && item.scoreable === true));
@@ -1617,7 +1630,7 @@ test("the next evidence request moves past a domain the user already answered",
});
test("a rejected intermediate narrative returns a retryable error without saving a template turn", async () => {
const value = harness({ invalidNarrativeFromGeneration: 2 });
const value = harness({ invalidNarrativeFromGeneration: 1 });
await start(value, null);
await assert.rejects(value.service.answer(userId, {
@@ -1660,8 +1673,8 @@ test("one through three supported events save and narrate before the fourth accu
assert.doesNotMatch(turn.narrative, /当前累计|本轮已纳入|本轮区分重点|下一步:/);
}
assert.equal(value.counts().packetBuilds, 5);
assert.equal(value.events.filter((event) => event === "narrative").length, 5);
assert.equal(value.counts().packetBuilds, 4);
assert.equal(value.events.filter((event) => event === "narrative").length, 4);
});
test("intermediate narrative receives the complete active event ledger", async () => {
@@ -1827,7 +1840,7 @@ test("family evidence remains stored and public without changing its domain", as
dateLabel: "2020-07",
domain: "family",
}]);
assert.deepEqual(value.packetEvidenceCounts, [0, 0]);
assert.deepEqual(value.packetEvidenceCounts, [0]);
assert.equal(turn.status, "active");
});
@@ -1860,7 +1873,7 @@ test("three valid events plus pre-birth evidence wait until a later valid fourth
assert.equal(preBirth?.extractionStatus, "needs_clarification");
assert.equal(stored?.eventEvidence.length, 5);
assert.equal(latest?.evidenceRecap.length, 5);
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 2, 2, 3, 4]);
assert.deepEqual(value.packetEvidenceCounts, [1, 2, 2, 3, 4]);
});
test("vague, future, and unmatched answers stay conversational and never score", async () => {
@@ -1879,7 +1892,7 @@ test("vague, future, and unmatched answers stay conversational and never score",
answer,
...(domain ? { domain } : {}),
});
assert.equal(value.counts().packetBuilds, 2);
assert.equal(value.counts().packetBuilds, 1);
assert.equal(turn.status, "active");
assert.match(turn.narrative, /哪一年|哪一月|年月|已发生|已经发生|换个方向|未来/);
assert.doesNotMatch(turn.narrative, /好的,我们不沿用不符合你的方向|已保存这段描述|我已保存你的原话|这条更正已保存/);
@@ -1891,7 +1904,7 @@ test("vague, future, and unmatched answers stay conversational and never score",
test("a non-scoring packet failure responds to the current turn instead of replaying the prior agent message", async () => {
const value = harness({
packetFailure: new Error("synthetic packet outage"),
packetFailureFromBuild: 2,
packetFailureFromBuild: 1,
});
const initial = await start(value, null);
@@ -1947,7 +1960,7 @@ test("a lost-response retry replays the saved answer without rescoring or regene
const before = [...value.events];
const replayed = await value.service.answer(userId, command);
assert.deepEqual(replayed, first);
assert.equal(value.counts().packetBuilds, 2);
assert.equal(value.counts().packetBuilds, 1);
assert.deepEqual(value.events, before);
});
@@ -1990,7 +2003,7 @@ test("regenerate rewrites only the current narrative and preserves evidence, sco
});
test("a failed regenerate preserves the prior turn, evidence, candidate, and billing", async () => {
const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 3 });
const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 2 });
await start(value, null);
const answered = await value.service.answer(userId, {
type: "answer",
@@ -2044,7 +2057,7 @@ test("overlapping identical answers converge on the first receipt despite differ
assert.deepEqual(second, first);
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 2);
assert.equal(value.events.filter((event) => event === "narrative").length, 3);
assert.equal(value.events.filter((event) => event === "narrative").length, 2);
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2);
});