fix(rectification): out-of-window event dates no longer fail every rescore; future dates refused at intake; corrections revise without a focus (BUG-1178, BUG-1179)
Independent Staging Quality Gate / validate (push) Successful in 13m37s
Independent Staging Quality Gate / publish (push) Successful in 3m37s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-02 09:03:22 +08:00
co-authored by Claude Opus 5.5
parent 8e978cabc4
commit 3bbe65a215
18 changed files with 439 additions and 27 deletions
@@ -223,6 +223,8 @@ export const RECTIFICATION_USER_COPY = {
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
rescoreSkipped: "这次没有重新比较,稍后再比一次。",
/** BUG-1178: an event dated after today is not recorded or scored. */
futureDatedEvent: "有一件事的日期在今天之后,这件先不算;如果年份打错了,直接告诉我正确的年份就行。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。",
rangeDeliveryTitle: "目前范围",
@@ -662,6 +664,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
RECTIFICATION_USER_COPY.compareFailedRetry,
RECTIFICATION_USER_COPY.rescoreSkipped,
RECTIFICATION_USER_COPY.futureDatedEvent,
RECTIFICATION_USER_COPY.lastSuccessfulCompareRange,
RECTIFICATION_USER_COPY.deferredCareerWindow,
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
@@ -46,6 +46,7 @@ import {
import { currentEngineCallTimings, reportTurnProgress } from "./turn-instrumentation.ts";
import {
batchResultFromToolChunk,
batchHasFutureDatedItem,
batchRescoreFailed,
acceptedRecapLines,
composeHostFallbackNarration,
@@ -620,6 +621,7 @@ export async function streamV9Attempt(
serverFacts = {
compareFailed,
rescoreSkipped,
futureDated: batchHasFutureDatedItem(batchToolResult),
rangeBefore: rangeBeforeCompare,
rangeAfter: rangeAfterEvidence,
};
@@ -10,6 +10,7 @@ import { toAgentModelFinishReason } from "../../agent-observability.ts";
import type { PublicStreamEvent } from "./stream-mapping";
import type { SpokenFactWhitelist } from "./spoken-grounding";
import {
RECTIFICATION_USER_COPY,
withCompareFailedRetryNotice,
withRangeChangedAfterEvidence,
withRescoreSkippedNotice,
@@ -51,6 +52,8 @@ export type AttemptOutcome = Readonly<{
export type TurnServerFacts = Readonly<{
compareFailed: boolean;
rescoreSkipped: boolean;
/** BUG-1178: an item of this turn's batch was dated after today and was not recorded. */
futureDated?: boolean;
rangeBefore: readonly [string, string] | null;
rangeAfter: readonly [string, string] | null;
}>;
@@ -62,9 +65,11 @@ export function composeSpokenWithServerFacts(
): string {
let spoken = body.trim();
if (!facts) return spoken;
const future = facts.futureDated === true ? RECTIFICATION_USER_COPY.futureDatedEvent : "";
const withFuture = (text: string) => !future || text.includes(future) ? text : text ? `${text}${/[。!?]$/.test(text) ? "" : "。"}${future}` : future;
if (facts.compareFailed) spoken = withCompareFailedRetryNotice(spoken);
if (facts.rescoreSkipped) return withRescoreSkippedNotice(spoken);
return withRangeChangedAfterEvidence(spoken, facts.rangeBefore, facts.rangeAfter);
if (facts.rescoreSkipped) return withFuture(withRescoreSkippedNotice(spoken));
return withFuture(withRangeChangedAfterEvidence(spoken, facts.rangeBefore, facts.rangeAfter));
}
export const MAX_ATTEMPTS = 2;
@@ -769,6 +769,30 @@ export function sanitizeAskedProbeKeysForEngine(
return next;
}
/**
* BUG-1178: the engine rejects the whole request when any event lies outside
* [birth_date, today] (`events[i] dates must be between birth_date and today`). A typo'd future
* year, or any 「今年 / 这个月」 event whose period ends after today, then failed every rescore
* of the Case and the delivery card never came. Before sending: drop events that start after
* the cap or end before birth; clip the rest into the window. The cap is yesterday (UTC) so the
* engine's own clock can never be behind ours.
*/
export function boundEngineEventDates(
events: readonly V9EngineEvent[],
birthDate: string,
now: Date = new Date(),
): V9EngineEvent[] {
const cap = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
const floor = /^\d{4}-\d{2}-\d{2}$/.test(birthDate) ? birthDate : null;
return events.flatMap((event): V9EngineEvent[] => {
if (event.date_start > cap) return [];
if (floor && event.date_end < floor) return [];
const start = floor && event.date_start < floor ? floor : event.date_start;
const end = event.date_end > cap ? cap : event.date_end;
return start === event.date_start && end === event.date_end ? [event] : [{ ...event, date_start: start, date_end: end }];
});
}
export function engineRequestBody(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: DatedCandidateRange;
@@ -786,7 +810,8 @@ export function engineRequestBody(input: {
if (!birthDate || lat === null || lon === null || tz === null) {
throw new RectificationEngineError("engine_profile_incomplete", "server profile snapshot is incomplete");
}
if (input.events.length === 0) {
const events = boundEngineEventDates(input.events, birthDate);
if (events.length === 0) {
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
}
const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys);
@@ -805,7 +830,7 @@ export function engineRequestBody(input: {
tz,
ayanamsa: resolveAyanamsa(snapshot),
node_mode: "mean",
events: input.events,
events,
birth_time_source: snapshot.birth_time_source,
timezone_id: snapshot.timezone_id,
timezone_source: snapshot.timezone_source,
@@ -119,6 +119,15 @@ export function batchResultFromToolChunk(chunk: {
return result;
}
/** BUG-1178: the batch turned an item away because it starts after today. */
export function batchHasFutureDatedItem(batchResult: unknown): boolean {
if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false;
const items = (batchResult as { items?: unknown }).items;
return Array.isArray(items) && items.some((item) => (
Boolean(item) && typeof item === "object" && (item as { error_code?: unknown }).error_code === "future_date"
));
}
export function batchRescoreFailed(batchResult: unknown): boolean {
if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false;
const rescore = (batchResult as { rescore?: { status?: unknown } }).rescore;
@@ -254,7 +254,10 @@ export function projectTurnDecision(
}
return [{ role: turn.role, text }];
});
const evidence = dossier.evidence.slice(-TURN_DECISION_EVIDENCE_LIMIT).map((item) => ({
// BUG-1179: the id lets the agent revise a record the user corrects (「2028 打错了,是 2018」).
const evidence = dossier.evidence.filter((item) => item.status !== "superseded")
.slice(-TURN_DECISION_EVIDENCE_LIMIT).map((item) => ({
evidence_id: item.id,
domain: item.domain,
kind: item.eventKind,
status: item.status,