fix(rectification): inherit target year for partial dates
This commit is contained in:
@@ -1928,3 +1928,19 @@
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-110 | 月份简答未继承目标事件年份导致重复追问并暂停
|
||||
|
||||
- 状态:resolved(local)
|
||||
- 首次发现:2026-07-31
|
||||
- 最近更新:2026-07-31
|
||||
- 影响面:V5 生时校正的目标事件日期补充、Director fallback 与下一问生成
|
||||
- 用户现象:已有“2016 年离家去外地上大学”事件时,用户回答“9 月”后没有生成同一事件的新 revision;系统仍把目标视为 unresolved,重复月份问题,随后因 `question_repeated` 进入暂停。
|
||||
- 触发条件:当前问题绑定一个仅有年份或季度精度的事件,用户只回答月份、月日、半年或月份区间,且 Agent 不可用或返回重复的临时问题。
|
||||
- 根因:确定性 reconciliation 只接受答案中重新出现完整年份的日期;Evidence 阶段又提前校验临时公开问题,导致有效证据提议可能被重复问题校验一并拒绝。
|
||||
- 修复:复用目标事件已有年份补全局部日期回答,成功后追加同一 Event ID 的 revision 并关闭目标;Evidence 阶段只验证证据和 target disposition,公开问题只在 final 阶段验证;fallback reason 同时保留原始异常和二次拒绝原因。
|
||||
- 验证:回归覆盖“2016 年事件 + 9 月 + Agent 强制不可用”的完整 Orchestrator 流程,确认生成 `2016-09` revision、无 pending、下一问不再绑定原事件且状态保持 `awaiting_answer`;Director 测试确认记录 `fallback_rejected:question_repeated`。
|
||||
- 防复发:单元测试分别锁定确定性日期继承、Evidence 阶段边界、fallback 原因和完整两轮回放。
|
||||
- 相关记录:BUG-104、BUG-107、BUG-109
|
||||
- 复发自:无
|
||||
- 修复版本:local / pending release
|
||||
|
||||
@@ -494,7 +494,7 @@ export const agentRunSchema = z.object({
|
||||
decision: rectificationDecisionSchema.nullable(),
|
||||
validatedDecision: validatedDecisionSchema,
|
||||
toolCalls: z.array(toolCallTraceSchema).max(10),
|
||||
fallbackReason: nonblank(120).nullable(),
|
||||
fallbackReason: nonblank(240).nullable(),
|
||||
inputTokenCount: z.number().int().nonnegative().nullable(),
|
||||
outputTokenCount: z.number().int().nonnegative().nullable(),
|
||||
latencyMs: z.number().int().nonnegative().max(300_000),
|
||||
|
||||
@@ -211,6 +211,7 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
|
||||
if (plan.targetDisposition === "resolved" && !revisedCurrentTarget) issues.push("resolved_target_not_revised");
|
||||
if (plan.targetDisposition === "answered_other_event" && !createdOtherEvent) issues.push("other_event_not_proposed");
|
||||
}
|
||||
if (input.phase === "evidence") return { plan: issues.length ? null : plan, issues };
|
||||
const groundedEvent = latestGroundedEvent(input.dossier, input.latestAnswer);
|
||||
const capabilityFacts = new Map(input.dossier.capabilities.publicTechniqueCapabilities.map((item) => [`domain:${item.domain}`, item]));
|
||||
const observationFacts = new Map<"window_sensitivity" | "candidate_scan" | "diagnostic", Set<string>>([
|
||||
@@ -425,6 +426,9 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
|
||||
publicReply: { acknowledgement: "本轮信息已经保留。", evidenceExplanation: null, candidateCommentary: null, limitation: "当前无法安全生成新的不重复问题,先暂停在这里。" },
|
||||
publicExplanationGrounding: [],
|
||||
};
|
||||
return { plan: safePlan, dossier, mode: "deterministic_fallback" as const, fallbackReason: error instanceof Error ? error.message.slice(0, 120) : "director_failed", toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
|
||||
const primaryReason = error instanceof Error ? error.message : "director_failed";
|
||||
const fallbackValidationReason = validatedFallback.plan ? null : `fallback_rejected:${validatedFallback.issues.join(",")}`;
|
||||
const fallbackReason = [primaryReason, fallbackValidationReason].filter((value): value is string => Boolean(value)).join(";").slice(0, 240);
|
||||
return { plan: safePlan, dossier, mode: "deterministic_fallback" as const, fallbackReason, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.t
|
||||
import type { CandidateSnapshot, RectificationAnalysisTrace, RectificationV4Question } from "../rectification-v4/contracts.ts";
|
||||
import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts";
|
||||
import { reconcileV4Evidence, stageAgentEvidenceProposals, type ReconciledV4Evidence, type TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
import { buildRectificationCaseDossier, runRectificationDirector } from "./director-agent.ts";
|
||||
import { buildRectificationCaseDossier, runRectificationDirector, type RectificationDirectorGenerator } from "./director-agent.ts";
|
||||
import { candidateUpdateFor } from "./renderer-agent.ts";
|
||||
import { extractEventWithModel } from "./event-extractor-agent.ts";
|
||||
import { evidenceSetHash } from "../rectification-v4/fingerprints.ts";
|
||||
@@ -138,6 +138,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
engine: RectificationV4CandidateEngine;
|
||||
now: Date;
|
||||
onPhase?: (phase: "extracting_evidence" | "scoring_candidates" | "checking_robustness" | "planning_question" | "reasoning" | "rendering") => Promise<void>;
|
||||
generateDirectorPlan?: RectificationDirectorGenerator;
|
||||
}>): Promise<Readonly<{
|
||||
newEventRevisions: ClaimedRectificationV4Job["events"];
|
||||
pendingEvidence: import("../rectification-v4/contracts.ts").PendingEvidence[];
|
||||
@@ -190,7 +191,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
currentTargetEventId: claimed.turn.questionTargetEventId,
|
||||
});
|
||||
evidenceDirector = await runRectificationDirector({
|
||||
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics,
|
||||
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics, generatePlan: input.generateDirectorPlan,
|
||||
});
|
||||
const serverReconciliation = reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now });
|
||||
reconciliation = claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent" && evidenceDirector.plan.evidenceProposals.length
|
||||
@@ -395,7 +396,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
});
|
||||
await enterPhase("reasoning");
|
||||
const directed = await runRectificationDirector({
|
||||
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics,
|
||||
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics, generatePlan: input.generateDirectorPlan,
|
||||
});
|
||||
const plan = directed.plan;
|
||||
const action = plan.action;
|
||||
@@ -447,7 +448,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
: "awaiting_answer" as const;
|
||||
const totalInput = [evidenceDirector?.inputTokenCount, directed.inputTokenCount].filter((value): value is number => value !== null && value !== undefined).reduce((sum, value) => sum + value, 0);
|
||||
const totalOutput = [evidenceDirector?.outputTokenCount, directed.outputTokenCount].filter((value): value is number => value !== null && value !== undefined).reduce((sum, value) => sum + value, 0);
|
||||
const fallbackReason = [evidenceDirector?.fallbackReason, directed.fallbackReason].filter(Boolean).join(";").slice(0, 120) || null;
|
||||
const fallbackReason = [evidenceDirector?.fallbackReason, directed.fallbackReason].filter(Boolean).join(";").slice(0, 240) || null;
|
||||
const agentRun: AgentRun = {
|
||||
id: randomUUID(), caseId: claimed.case.id, jobId: claimed.job.id, caseVersion: claimed.case.version,
|
||||
modelId: claimed.case.orchestrationModelId, skillVersion: claimed.case.skillVersion, promptVersion: claimed.case.promptVersion,
|
||||
|
||||
@@ -242,24 +242,42 @@ export function reconcileV4Evidence(input: {
|
||||
revisions.push(clarified);
|
||||
targetResolved = true;
|
||||
} else {
|
||||
const targetAnswer = extracted.find((event) => event.dateValue && event.datePrecision !== "unknown" && describesTarget(event, target));
|
||||
if (targetAnswer?.dateValue && targetAnswer.datePrecision !== "unknown") {
|
||||
const dateRange = dateRangeFromDeclared(targetAnswer.dateValue, targetAnswer.datePrecision);
|
||||
if (dateRange.start <= input.asOfDate) {
|
||||
revisions.push(appendEventRevision(input.existing, {
|
||||
eventId: target.eventId,
|
||||
domain: target.domain,
|
||||
eventKind: target.eventKind,
|
||||
subject: target.subject,
|
||||
relatedPerson: target.relatedPerson,
|
||||
summary: target.summary,
|
||||
rawText: input.answer,
|
||||
dateRange,
|
||||
...eventDateProvenance(target),
|
||||
scoreability: target.scoreability,
|
||||
}, { id: targetAnswer.id, now: input.now }));
|
||||
consumed.add(targetAnswer.id);
|
||||
targetResolved = true;
|
||||
const inheritedDateRange = inferredTargetDateRange(input.answer, target, input.asOfDate);
|
||||
if (inheritedDateRange && inheritedDateRange.start <= input.asOfDate) {
|
||||
revisions.push(appendEventRevision(input.existing, {
|
||||
eventId: target.eventId,
|
||||
domain: target.domain,
|
||||
eventKind: target.eventKind,
|
||||
subject: target.subject,
|
||||
relatedPerson: target.relatedPerson,
|
||||
summary: target.summary,
|
||||
rawText: input.answer,
|
||||
dateRange: inheritedDateRange,
|
||||
...eventDateProvenance(target),
|
||||
scoreability: target.scoreability,
|
||||
}, { now: input.now }));
|
||||
extracted.forEach((event) => consumed.add(event.id));
|
||||
targetResolved = true;
|
||||
} else {
|
||||
const targetAnswer = extracted.find((event) => event.dateValue && event.datePrecision !== "unknown" && describesTarget(event, target));
|
||||
if (targetAnswer?.dateValue && targetAnswer.datePrecision !== "unknown") {
|
||||
const dateRange = dateRangeFromDeclared(targetAnswer.dateValue, targetAnswer.datePrecision);
|
||||
if (dateRange.start <= input.asOfDate) {
|
||||
revisions.push(appendEventRevision(input.existing, {
|
||||
eventId: target.eventId,
|
||||
domain: target.domain,
|
||||
eventKind: target.eventKind,
|
||||
subject: target.subject,
|
||||
relatedPerson: target.relatedPerson,
|
||||
summary: target.summary,
|
||||
rawText: input.answer,
|
||||
dateRange,
|
||||
...eventDateProvenance(target),
|
||||
scoreability: target.scoreability,
|
||||
}, { id: targetAnswer.id, now: input.now }));
|
||||
consumed.add(targetAnswer.id);
|
||||
targetResolved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,7 +303,7 @@ export function reconcileV4Evidence(input: {
|
||||
const suppressPending = targetDisposition === "unknown"
|
||||
|| targetDisposition === "declined"
|
||||
|| targetDisposition === "direction_change";
|
||||
if (extracted.length === 0 && !suppressPending) unresolvedReason = "event_unparsed";
|
||||
if (extracted.length === 0 && !suppressPending && !targetResolved) unresolvedReason = "event_unparsed";
|
||||
const pending = unresolvedReason && !suppressPending ? [
|
||||
pendingEvidence({
|
||||
caseId: input.caseId,
|
||||
|
||||
@@ -121,6 +121,33 @@ test("不知道或换方向不产生 pending,也不再生成同 target 机会"
|
||||
assert.ok(opportunities.some((item) => item.kind === "ask_new_event"));
|
||||
});
|
||||
|
||||
test("month-only answer refines the targeted year event", () => {
|
||||
const target = event({
|
||||
summary: "离家去外地上大学",
|
||||
rawText: "2016 年离家去外地上大学",
|
||||
dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" },
|
||||
});
|
||||
const result = reconcileV4Evidence({
|
||||
caseId,
|
||||
answer: "9 月",
|
||||
sourceTurnId: randomUUID(),
|
||||
asOfDate: "2026-07-31",
|
||||
existing: [target],
|
||||
targetEventId: target.eventId,
|
||||
now: new Date(now),
|
||||
});
|
||||
|
||||
assert.equal(result.targetDisposition, "resolved");
|
||||
assert.equal(result.unansweredTargetEventId, null);
|
||||
assert.deepEqual(result.pending, []);
|
||||
assert.equal(result.revisions.length, 1);
|
||||
assert.equal(result.revisions[0]?.eventId, target.eventId);
|
||||
assert.equal(result.revisions[0]?.revision, 2);
|
||||
assert.equal(result.revisions[0]?.dateRange.start, "2016-09-01");
|
||||
assert.equal(result.revisions[0]?.dateRange.end, "2016-09-30");
|
||||
assert.equal(result.revisions[0]?.dateRange.precision, "month");
|
||||
});
|
||||
|
||||
test("无 target 的换方向表达也不会被记为 event_unparsed", () => {
|
||||
const result = reconcileV4Evidence({ caseId, answer: "后来有一次搬家,但我记不清时间了,换一个吧。", sourceTurnId: randomUUID(), asOfDate: "2026-07-29", existing: [] });
|
||||
assert.equal(result.targetDisposition, "direction_change");
|
||||
|
||||
@@ -562,6 +562,48 @@ test("a newly collected year-only event stays current until its month is refined
|
||||
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /还能想到一件/);
|
||||
});
|
||||
|
||||
test("a month-only answer closes the targeted event even when the Agent is unavailable", async () => {
|
||||
const university: LifeEventRevision = {
|
||||
...event("education", "education_milestone", "离家去外地上大学", "2016-01"),
|
||||
dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" },
|
||||
};
|
||||
const base = makeClaimed([university]);
|
||||
const earlierTurn: RectificationV4Turn = {
|
||||
...base.turn,
|
||||
id: randomUUID(),
|
||||
caseVersion: 1,
|
||||
questionDomain: "education",
|
||||
questionTargetEventId: null,
|
||||
question: "请先说一段自己记得比较清楚的人生经历。",
|
||||
answer: "2016 年离家去外地上大学",
|
||||
};
|
||||
const turn: RectificationV4Turn = {
|
||||
...base.turn,
|
||||
id: randomUUID(),
|
||||
caseVersion: 2,
|
||||
questionDomain: "education",
|
||||
questionTargetEventId: university.eventId,
|
||||
question: "你还记得“离家去外地上大学”大概发生在哪个月,或一年中的哪个时间段吗?",
|
||||
answer: "9 月",
|
||||
};
|
||||
const result = await processRectificationAgentTurn({
|
||||
claimed: { ...base, turn, turns: [earlierTurn, turn] },
|
||||
engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } },
|
||||
generateDirectorPlan: async () => { throw new Error("forced_agent_unavailable"); },
|
||||
now: new Date(now),
|
||||
});
|
||||
|
||||
const revision = result.newEventRevisions[0];
|
||||
assert.equal(revision?.eventId, university.eventId);
|
||||
assert.equal(revision?.revision, 2);
|
||||
assert.deepEqual(revision?.dateRange, { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "9 月" });
|
||||
assert.deepEqual(result.pendingEvidence, []);
|
||||
assert.equal(result.status, "awaiting_answer");
|
||||
assert.notEqual(result.nextQuestion?.targetEventId, university.eventId);
|
||||
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /哪个月|一年中的哪个时间段/);
|
||||
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /当前无法安全生成新的不重复问题|先暂停在这里/);
|
||||
});
|
||||
|
||||
test("a completed internship answer is acknowledged, explained, and followed by one contrast-driven domain question", async () => {
|
||||
const university = event("education", "education_milestone", "离家去外地上大学", "2016-09");
|
||||
const base = makeClaimed([university]);
|
||||
|
||||
@@ -242,6 +242,50 @@ test("a natural question and multiple grounded event proposals pass without doma
|
||||
assert.deepEqual(new Set(staged.revisions.map((item) => item.domain)), new Set(["relocation", "career"]));
|
||||
});
|
||||
|
||||
test("evidence proposal survives a repeated provisional question", () => {
|
||||
const target = event({
|
||||
summary: "离家去外地上大学",
|
||||
rawText: "2016 年离家去外地上大学",
|
||||
dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" },
|
||||
});
|
||||
const repeatedQuestion = "你还记得“离家去外地上大学”大概发生在哪个月,或一年中的哪个时间段吗?";
|
||||
const targetDossier = buildRectificationCaseDossier({
|
||||
caseValue,
|
||||
turns: [turn(0, { question: repeatedQuestion, answer: target.rawText, questionTargetEventId: target.eventId })],
|
||||
events: [target],
|
||||
pendingEvidence: [],
|
||||
snapshot: null,
|
||||
diagnostics: null,
|
||||
targetDisposition: "unresolved",
|
||||
currentTargetEventId: target.eventId,
|
||||
});
|
||||
const value = plan({
|
||||
targetDisposition: "resolved",
|
||||
evidenceProposals: [{
|
||||
operation: "revise_date",
|
||||
targetEventId: target.eventId,
|
||||
sourceSpan: "9 月",
|
||||
dateText: "9 月",
|
||||
proposedSummary: target.summary,
|
||||
proposedDomain: target.domain,
|
||||
proposedEventKind: target.eventKind,
|
||||
proposedSubject: target.subject,
|
||||
proposedRelatedPerson: target.relatedPerson,
|
||||
confidence: "high",
|
||||
}],
|
||||
action: {
|
||||
type: "ask_question",
|
||||
focus: { mode: "clarify_existing_event", targetEventId: target.eventId, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["provisional"] },
|
||||
question: repeatedQuestion,
|
||||
optionalQuickReplies: [],
|
||||
},
|
||||
});
|
||||
|
||||
const validated = validateRectificationTurnPlan({ plan: value, dossier: targetDossier, latestAnswer: "9 月", phase: "evidence" });
|
||||
assert.deepEqual(validated.issues, []);
|
||||
assert.equal(validated.plan?.evidenceProposals[0]?.operation, "revise_date");
|
||||
});
|
||||
|
||||
test("server rejects invented sources, private details, exact minutes, and ungated ranges", () => {
|
||||
const latestAnswer = "2018年9月搬到北京。";
|
||||
const invented = plan({ evidenceProposals: [{ operation: "create", targetEventId: null, sourceSpan: "2020年工作", dateText: "2020年", proposedSummary: "开始工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "low" }] });
|
||||
@@ -822,6 +866,8 @@ test("deterministic fallback pauses instead of repeating a persisted public ques
|
||||
assert.equal(result.plan.action.type, "stop_low_confidence");
|
||||
if (result.plan.action.type !== "stop_low_confidence") return;
|
||||
assert.deepEqual(result.plan.action.reasonCodes, ["deterministic_fallback_rejected"]);
|
||||
assert.match(result.fallbackReason ?? "", /forced_fallback/);
|
||||
assert.match(result.fallbackReason ?? "", /fallback_rejected:question_repeated/);
|
||||
});
|
||||
|
||||
test("deterministic fallback stays domain-neutral when the Agent is unavailable", async () => {
|
||||
|
||||
Reference in New Issue
Block a user