fix(rectification): preserve event date precision
This commit is contained in:
@@ -2098,3 +2098,18 @@
|
||||
- 防复发:任何非唯一候选卡必须由显式选择阶段开启;同一 Agent 回复不得既索取新证据又提供采用操作;候选采用测试必须覆盖幂等、改选、过期结果和 Profile 基线漂移。
|
||||
- 相关记录:BUG-117、BUG-118、BUG-119
|
||||
- 修复版本:待提交与发布
|
||||
|
||||
## BUG-121 | 月份与区间事件在确认工具中被序列化成错误日期格式并耗尽 Agent 步骤
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-04
|
||||
- 最近更新:2026-08-04
|
||||
- 影响面:Agentic 生时校正结束收集、V5 评分/诊断、旧确认门适配、无回复退款兜底
|
||||
- 用户现象:用户明确表示不再补充事件后,接口返回“生时校正没有生成有效回复,本次不会扣除点数,请重新发送”,没有展示最终候选或后续选择。
|
||||
- 触发条件:历史证据同时包含 `year`、`month` 或 `range` 精度;Agent 在结束收集时调用 `rectification-confirm`。月份或年份事件被发送成完整日期,区间事件又可能使用 `/`、`to` 等自然分隔形式。
|
||||
- 根因:共享事件 schema 只检查字符串长度;V5 转换只识别 `..` 区间;`toV3Event()` 又把标准化后的 `YYYY-MM-DD` 与 `month`/`year` 精度一起发送给只接受 `YYYY-MM`/`YYYY` 的旧确认端点。引擎持续返回 `event date does not match its precision`,Agent 在 8 个工具步骤内反复修正和重试,最终没有剩余步骤生成公开文本。该问题是 BUG-116 的输入契约残余变体,提高步骤数只能延后失败。
|
||||
- 修复:事件日期统一复用严格日历范围转换;V5 保留年月日和区间的 `date_start`/`date_end`,并兼容 `..`、`/`、`to`、中文范围符和紧凑年月范围;旧确认端点按精度发送严格的 `YYYY`、`YYYY-MM`、`YYYY-MM-DD`,区间按旧端点能力降级为年份证据且摘要仍保留原区间语义。工具 schema 同时明确推荐日期格式。
|
||||
- 验证:新增 V5 区间归一化和旧确认精度序列化回归;Agentic 工具/入口/会话合同测试 51/51,通过针对性 ESLint、`tsc --noEmit` 和 production webpack build。使用脱敏后的原始长对话本地重放,Agent 在 5 次工具调用内完成 `gate -> score -> diagnostics -> confirm`,工具错误 0,生成 610 字可见候选回复,不再触发空回复退款。
|
||||
- 防复发:任何送往旧事件引擎的日期必须由精度契约测试断言;新增日期表示必须先走共享日历校验,不能在调用端自行拼接或仅增加 Agent 重试步数。
|
||||
- 相关记录:BUG-116、BUG-118、BUG-120
|
||||
- 修复版本:本记录所在 staging 发布提交
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { dateRangeFromDeclared } from "../lib/rectification-v4/date-range.ts";
|
||||
|
||||
/**
|
||||
* Agentic birth-time rectification tool layer.
|
||||
@@ -86,7 +87,7 @@ export type RectificationDomain = z.infer<typeof rectificationDomainSchema>;
|
||||
export const agenticRectificationEventSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
domain: rectificationDomainSchema,
|
||||
date: z.string().min(4).max(23),
|
||||
date: z.string().min(4).max(23).describe("YYYY, YYYY-MM, YYYY-MM-DD, or start..end for a range"),
|
||||
precision: z.enum(["year", "month", "day", "range"]),
|
||||
summary: z.string().max(1000).optional(),
|
||||
});
|
||||
@@ -152,39 +153,59 @@ function toV5Event(event: AgenticRectificationEvent): Readonly<{
|
||||
precision: "day" | "month" | "quarter" | "year" | "range";
|
||||
summary?: string;
|
||||
}> {
|
||||
const [startPart, endPart] = event.date.includes("..")
|
||||
? event.date.split("..", 2)
|
||||
: [event.date, ""];
|
||||
const startDate = normalizeDateStart(startPart, event.precision);
|
||||
const endDate = endPart ? normalizeDateStart(endPart, event.precision) : normalizeDateEnd(startPart, event.precision);
|
||||
const normalizedPrecision = event.precision === "range" || endPart ? "range" : event.precision === "day" ? "day" : event.precision === "month" ? "month" : "year";
|
||||
const range = normalizedEventDateRange(event);
|
||||
return {
|
||||
id: stableEventId(event.id),
|
||||
domain: event.domain,
|
||||
event_kind: defaultEventKind[event.domain],
|
||||
date_start: startDate,
|
||||
date_end: endDate,
|
||||
precision: normalizedPrecision,
|
||||
date_start: range.start,
|
||||
date_end: range.end,
|
||||
precision: range.precision,
|
||||
summary: event.summary,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDateStart(date: string, precision: AgenticRectificationEvent["precision"]): string {
|
||||
const [year, month = "01", day = "01"] = date.split("-");
|
||||
const paddedMonth = month.length === 1 ? `0${month}` : month;
|
||||
const paddedDay = day.length === 1 ? `0${day}` : day;
|
||||
if (precision === "year" || !paddedMonth) return `${year}-01-01`;
|
||||
return `${year}-${paddedMonth}-${paddedDay}`;
|
||||
function normalizedDeclaredDate(value: string, precision: "year" | "month" | "day") {
|
||||
const matched = precision === "year"
|
||||
? /^(\d{4})$/.exec(value.trim())
|
||||
: precision === "month"
|
||||
? /^(\d{4})-(\d{1,2})$/.exec(value.trim())
|
||||
: /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value.trim());
|
||||
if (!matched) throw new Error(`event_date_precision_mismatch: ${value} is not ${precision}`);
|
||||
return matched.slice(1).map((part, index) => index === 0 ? part : part!.padStart(2, "0")).join("-");
|
||||
}
|
||||
|
||||
function normalizeDateEnd(date: string, precision: AgenticRectificationEvent["precision"]): string {
|
||||
const [year, month, day] = date.split("-");
|
||||
if (precision === "year" || !month) return `${year}-12-31`;
|
||||
if (precision === "month" || !day) {
|
||||
const last = new Date(Number(year), Number(month), 0).getDate();
|
||||
return `${year}-${month.length === 1 ? `0${month}` : month}-${String(last).padStart(2, "0")}`;
|
||||
function declaredDateRange(value: string) {
|
||||
const precision = /^\d{4}$/.test(value.trim())
|
||||
? "year"
|
||||
: /^\d{4}-\d{1,2}$/.test(value.trim())
|
||||
? "month"
|
||||
: "day";
|
||||
return dateRangeFromDeclared(normalizedDeclaredDate(value, precision), precision);
|
||||
}
|
||||
|
||||
function splitRangeDate(value: string): readonly [string, string] {
|
||||
const normalized = value.trim();
|
||||
const compactDayRange = /^(\d{4}-\d{1,2}-\d{1,2})-(\d{4}-\d{1,2}-\d{1,2})$/.exec(normalized);
|
||||
if (compactDayRange) return [compactDayRange[1]!, compactDayRange[2]!];
|
||||
const compactMonthRange = /^(\d{4}-\d{1,2})-(\d{4}-\d{1,2})$/.exec(normalized);
|
||||
if (compactMonthRange) return [compactMonthRange[1]!, compactMonthRange[2]!];
|
||||
const parts = normalized.split(/\s*(?:\.\.|\/|\bto\b|至|到|[–—])\s*/iu);
|
||||
if (parts.length > 2 || !parts[0] || (parts.length === 2 && !parts[1])) {
|
||||
throw new Error(`invalid_event_date_range: ${value}`);
|
||||
}
|
||||
return `${year}-${month.length === 1 ? `0${month}` : month}-${day.length === 1 ? `0${day}` : day}`;
|
||||
return [parts[0], parts[1] ?? parts[0]];
|
||||
}
|
||||
|
||||
function normalizedEventDateRange(event: AgenticRectificationEvent) {
|
||||
if (event.precision !== "range") {
|
||||
return dateRangeFromDeclared(normalizedDeclaredDate(event.date, event.precision), event.precision);
|
||||
}
|
||||
const [start, end] = splitRangeDate(event.date);
|
||||
const startRange = declaredDateRange(start);
|
||||
const endRange = declaredDateRange(end);
|
||||
if (startRange.start > endRange.end) throw new Error(`invalid_event_date_range: ${event.date}`);
|
||||
return { start: startRange.start, end: endRange.end, precision: "range" as const };
|
||||
}
|
||||
|
||||
/** Convert a V5 event to the v3 events schema used by `/api/active_rectification_events`. */
|
||||
@@ -197,7 +218,8 @@ function toV3Event(event: AgenticRectificationEvent): Readonly<{
|
||||
}> {
|
||||
const v5 = toV5Event(event);
|
||||
const precision = v5.precision === "day" ? "day" : v5.precision === "month" ? "month" : "year";
|
||||
return { id: v5.id, domain: v5.domain, date: v5.date_start, precision, summary: v5.summary };
|
||||
const date = precision === "day" ? v5.date_start : precision === "month" ? v5.date_start.slice(0, 7) : v5.date_start.slice(0, 4);
|
||||
return { id: v5.id, domain: v5.domain, date, precision, summary: v5.summary };
|
||||
}
|
||||
|
||||
async function postEngine(base: string, path: string, body: unknown): Promise<Record<string, unknown>> {
|
||||
|
||||
@@ -192,6 +192,7 @@ test("score tool normalizes year-precision events into the V5 date range contrac
|
||||
events: [
|
||||
{ id: "marriage-2015", domain: "relationship", date: "2015", precision: "year", summary: "结婚" },
|
||||
{ id: "moved", domain: "relocation", date: "2015-06", precision: "month", summary: "搬家" },
|
||||
{ id: "salary-range", domain: "finance", date: "2026-01/2026-07", precision: "range", summary: "欠薪区间" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -199,7 +200,7 @@ test("score tool normalizes year-precision events into the V5 date range contrac
|
||||
const events = (sent.events ?? []) as Array<Record<string, unknown>>;
|
||||
assert.equal(sent.start_time, "14:00");
|
||||
assert.equal(sent.end_time, "15:00");
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events.length, 3);
|
||||
|
||||
const marriage = events[0]!;
|
||||
assert.equal(marriage.date_start, "2015-01-01");
|
||||
@@ -212,6 +213,11 @@ test("score tool normalizes year-precision events into the V5 date range contrac
|
||||
assert.equal(moved.date_start, "2015-06-01");
|
||||
assert.equal(moved.date_end, "2015-06-30");
|
||||
|
||||
const salaryRange = events[2]!;
|
||||
assert.equal(salaryRange.date_start, "2026-01-01");
|
||||
assert.equal(salaryRange.date_end, "2026-07-31");
|
||||
assert.equal(salaryRange.precision, "range");
|
||||
|
||||
assert.equal(result.candidate_count, 1);
|
||||
const top = (result.top_candidates as Array<{ time: string }>)[0];
|
||||
assert.equal(top?.time, "14:30");
|
||||
@@ -354,6 +360,32 @@ test("confirm then save with the matching minute applies the write", async () =>
|
||||
engine.restore();
|
||||
});
|
||||
|
||||
test("confirm serializes event dates to the legacy precision contract", async () => {
|
||||
const engine = installEngine([
|
||||
{ path: "/api/active_rectification_events", respond: confirmedEngineResponse },
|
||||
]);
|
||||
const tools = createAgenticRectificationTools(makeCtx());
|
||||
|
||||
await runTool(tools, "rectification-confirm", {
|
||||
candidate_range: { start_time: "14:00", end_time: "15:00" },
|
||||
events: [
|
||||
{ id: "year", domain: "education", date: "2016", precision: "year" },
|
||||
{ id: "month", domain: "career", date: "2020-04", precision: "month" },
|
||||
{ id: "day", domain: "relationship", date: "2024-08-08", precision: "day" },
|
||||
{ id: "range", domain: "finance", date: "2026-01/2026-07", precision: "range" },
|
||||
],
|
||||
});
|
||||
|
||||
const events = requestBody(engine).events as Array<Record<string, unknown>>;
|
||||
assert.deepEqual(events.map(({ date, precision }) => ({ date, precision })), [
|
||||
{ date: "2016", precision: "year" },
|
||||
{ date: "2020-04", precision: "month" },
|
||||
{ date: "2024-08-08", precision: "day" },
|
||||
{ date: "2026", precision: "year" },
|
||||
]);
|
||||
engine.restore();
|
||||
});
|
||||
|
||||
test("confirm persists ranked candidates with relative support totaling 100", async () => {
|
||||
const engine = installEngine([
|
||||
{ path: "/api/active_rectification_events", respond: confirmedEngineResponse },
|
||||
|
||||
Reference in New Issue
Block a user