fix(rectification): keep hour-window tail clusters and lock the search window (BUG-623, BUG-624, BUG-625)
Independent Staging Quality Gate / validate (push) Successful in 17m15s
Independent Staging Quality Gate / publish (push) Successful in 2m15s

Hour windows no longer drop later signature clusters. Credible range uses cluster coverage, and a mid-session spoken birth window gets a fixed reply without calling the model.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 20:45:11 +08:00
co-authored by Cursor
parent a31a5e2426
commit 6008c07c81
41 changed files with 1164 additions and 44 deletions
@@ -46,6 +46,7 @@ import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocus
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { parseDeclaredBirthWindow } from "@/lib/rectification-agentic/v9/declared-window-utterance";
import {
awaitTurnExitBeforeResponse,
finalizeSuccessfulTurnExit,
@@ -268,6 +269,30 @@ export async function POST(request: Request) {
);
}
if (action === "message" && parseDeclaredBirthWindow(parsed.data.message ?? "")) {
try {
const narration = RECTIFICATION_USER_COPY.declaredWindowLockedReply;
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const mapped = mapRectificationRpcError(error);
return NextResponse.json(
{ error: mapped.message, message: mapped.message, code: mapped.code },
{ status: mapped.status },
);
}
return NextResponse.json(
{ error: "校正回复保存失败", message: "请稍后重试。" },
{ status: 500 },
);
}
}
const selectedModel = isStructuredChoice
? null
: await resolveSessionLanguageModel(
@@ -2,7 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco
import type { TransitionSignLookup } from "./sign-from-transitions.ts";
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
import { unionStillValidRange } from "./credible-range.ts";
import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
import { selectHighestGainProbe } from "./select-probe.ts";
import { holdoutDomainYears, holdoutEventIds, stickyHoldoutEvents } from "./split-holdout.ts";
@@ -22,8 +22,16 @@ export type EngineCandidateInput = Readonly<{
id: string;
time: string;
relative_support: number;
cluster_times?: readonly string[];
cluster_start?: string;
cluster_end?: string;
}>;
function engineClusterRange(item: EngineCandidateInput): readonly [string, string] | null {
if (item.cluster_start && item.cluster_end) return [item.cluster_start, item.cluster_end];
return item.cluster_times?.length ? rangeFromTimes(item.cluster_times) : null;
}
export type EngineEventInput = Readonly<{
id: string;
domain: string;
@@ -126,7 +134,7 @@ export function buildInferenceState(input: {
return {
id: item.id,
time: item.time,
cluster_range: clusterRangeFor(clusters, item.id, item.time),
cluster_range: engineClusterRange(item) ?? clusterRangeFor(clusters, item.id, item.time),
prior_score: trainingPrior[item.id] ?? 0,
posterior_score: scores[item.id] ?? 0,
probability: eliminated.has(item.id) ? 0 : probabilities[item.id] ?? 0,
@@ -254,6 +262,8 @@ export function replayInferenceState(
id: item.id,
time: item.time,
relative_support: item.prior_score,
cluster_start: item.cluster_range[0],
cluster_end: item.cluster_range[1],
})),
events: state.events,
probes: state.probes,
@@ -348,6 +358,8 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe
id: item.id,
time: item.time,
relative_support: item.prior_score,
cluster_start: item.cluster_range[0],
cluster_end: item.cluster_range[1],
})),
events: state.events,
probes: state.probes,
@@ -111,6 +111,7 @@ export const RECTIFICATION_USER_COPY = {
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
declaredWindowLockedReply: "搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
@@ -461,6 +462,7 @@ export function listUserVisibleCopy(): string[] {
GENERIC_COLLECT_QUESTION,
RECTIFICATION_USER_COPY.choicePrompt,
RECTIFICATION_USER_COPY.unclearFocusReply,
RECTIFICATION_USER_COPY.declaredWindowLockedReply,
RECTIFICATION_USER_COPY.questionUpdated,
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
@@ -43,7 +43,7 @@ import {
withCompareFailedRetryNotice,
withRangeChangedAfterEvidence,
} from "../user-copy";
import { stripQuestionSentences, trimSpokenTurnForInterview } from "./collect-prompt";
import { stripQuestionSentences, stripVerbalWindowChange, trimSpokenTurnForInterview } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
@@ -1031,6 +1031,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
rangeBeforeCompare,
rangeAfterEvidence,
);
answerText = stripVerbalWindowChange(answerText)
|| RECTIFICATION_USER_COPY.declaredWindowLockedReply;
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
return completeAttempt();
} finally {
@@ -24,4 +24,5 @@ export const MACHINE_VOICE_LEXICON = [
"强相关",
"有关联",
"弱关联",
"以你说的为准",
] as const;
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.19";
export const RECTIFICATION_SKILL_VERSION = "10.0.20";
@@ -51,6 +51,17 @@ function isQuestionSentence(text: string, stem: string): boolean {
return /[?]$/.test(text);
}
export function stripVerbalWindowChange(body: string): string {
const stripped = body
.replace(/以你说的.{0,40}为准/g, "")
.replace(/[,、]{2,}/g, "")
.replace(/[ \t]+/g, " ")
.replace(/[,、]+\s*(?=[。..!?!?;]|$)/g, "")
.trim();
if (!stripped || /^[,、。..!?!?;:\s]+$/.test(stripped)) return "";
return stripped;
}
export function stripQuestionSentences(body: string, stem: string): string {
const prompt = stem.trim();
const spoken = body.trim();
@@ -0,0 +1,40 @@
/**
* Mid-session spoken birth-time windows are not a search-window change.
* Detect them so the route can answer with a fixed reply and skip the model.
*/
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*[0-5]?\d(?:\s*分)?/;
const RANGE_SEP = /\s*(?:到|至|[-–—~])\s*/;
const AROUND = /\s*(?:左右|前后)/;
const RANGE_PATTERN = new RegExp(`(${CLOCK.source})${RANGE_SEP.source}(${CLOCK.source})`);
const AROUND_PATTERN = new RegExp(`(${CLOCK.source})${AROUND.source}`);
export type DeclaredBirthWindow =
| { kind: "range"; start: string; end: string }
| { kind: "around"; time: string };
export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow | null {
const text = message.trim();
if (!text) return null;
const range = RANGE_PATTERN.exec(text);
if (range) {
const start = normalizeClock(range[1] ?? "");
const end = normalizeClock(range[2] ?? "");
if (start && end && start !== end) return { kind: "range", start, end };
}
const around = AROUND_PATTERN.exec(text);
if (around) {
const time = normalizeClock(around[1] ?? "");
if (time) return { kind: "around", time };
}
return null;
}
function normalizeClock(raw: string): string | null {
const match = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
@@ -58,6 +58,9 @@ export type V9EngineCandidate = Readonly<{
rank: number;
relativeSupport: number;
tiedMinuteCount: number;
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
}>;
export type V9DecisionReceipt = Readonly<Record<string, unknown>>;
@@ -282,6 +285,31 @@ export async function readV9EngineScoringIdentity(): Promise<LiveEngineScoringId
}
}
function readClusterCoverage(row: Record<string, unknown> | null): {
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
} {
if (!row) return {};
const times = Array.isArray(row.cluster_times)
? row.cluster_times.flatMap((item) => (
typeof item === "string" && timePattern.test(item) ? [item] : []
))
: [];
const start = typeof row.cluster_start === "string" && timePattern.test(row.cluster_start)
? row.cluster_start
: times[0];
const end = typeof row.cluster_end === "string" && timePattern.test(row.cluster_end)
? row.cluster_end
: times[times.length - 1];
if (!start || !end) return {};
return {
...(times.length > 0 ? { clusterTimes: times } : {}),
clusterStart: start,
clusterEnd: end,
};
}
function readCandidates(
value: unknown,
range: { start_time: string; end_time: string },
@@ -311,7 +339,15 @@ function readCandidates(
}
seenIds.add(candidateId);
seenTimes.add(time);
candidates.push({ candidateId, time, rank, relativeSupport, tiedMinuteCount });
const cluster = readClusterCoverage(row);
candidates.push({
candidateId,
time,
rank,
relativeSupport,
tiedMinuteCount,
...cluster,
});
}
return candidates;
}
@@ -387,7 +387,14 @@ export function compactInferenceProjection(state: InferenceState | null | undefi
export function buildCaseInferenceState(input: {
range: { start_time: string; end_time: string };
candidates: readonly Readonly<{ candidateId: string; time: string; relativeSupport: number }>[];
candidates: readonly Readonly<{
candidateId: string;
time: string;
relativeSupport: number;
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
}>[];
evidence: readonly Readonly<{
id: string;
domain: string;
@@ -421,6 +428,9 @@ export function buildCaseInferenceState(input: {
id: item.time,
time: item.time,
relative_support: item.relativeSupport,
...(item.clusterTimes ? { cluster_times: item.clusterTimes } : {}),
...(item.clusterStart ? { cluster_start: item.clusterStart } : {}),
...(item.clusterEnd ? { cluster_end: item.clusterEnd } : {}),
})),
events,
probes,
@@ -280,6 +280,9 @@ export type V9Candidate = Readonly<{
rank: number;
relativeSupport: number;
tiedMinuteCount: number;
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
}>;
export type V9CandidateSnapshot = Readonly<{
@@ -303,6 +306,28 @@ export type V9CandidateSnapshot = Readonly<{
invalidatedAt: string | null;
}>;
function clusterCoverageFromRow(row: Record<string, unknown> | null): {
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
} {
if (!row) return {};
const times = Array.isArray(row.cluster_times)
? row.cluster_times.flatMap((item) => {
const time = timeValue(item);
return time ? [time] : [];
})
: [];
const start = timeValue(row.cluster_start) ?? times[0];
const end = timeValue(row.cluster_end) ?? times[times.length - 1];
if (!start || !end) return {};
return {
...(times.length > 0 ? { clusterTimes: times } : {}),
clusterStart: start,
clusterEnd: end,
};
}
function timeValue(value: unknown): string | null {
const time = typeof value === "string" ? value.slice(0, 5) : "";
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
@@ -489,7 +514,14 @@ export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot |
|| tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
) return null;
seenIds.add(candidateId);
candidates.push({ candidateId, time: candidateTime, rank, relativeSupport, tiedMinuteCount });
candidates.push({
candidateId,
time: candidateTime,
rank,
relativeSupport,
tiedMinuteCount,
...clusterCoverageFromRow(candidate),
});
}
const decisionReceipt = rowObject(row.decision_receipt);
const executionLedger = Array.isArray(row.execution_ledger)
@@ -1469,6 +1501,9 @@ export async function persistV9Candidate(
rank: candidate.rank,
relative_support: candidate.relativeSupport,
tied_minute_count: candidate.tiedMinuteCount,
...(candidate.clusterTimes ? { cluster_times: candidate.clusterTimes } : {}),
...(candidate.clusterStart ? { cluster_start: candidate.clusterStart } : {}),
...(candidate.clusterEnd ? { cluster_end: candidate.clusterEnd } : {}),
})),
p_decision_receipt: input.decisionReceipt,
p_execution_ledger: input.executionLedger,