fix(rectification): harden fallback response boundaries
This commit is contained in:
@@ -18,6 +18,9 @@ const quantifiedStructurePattern = /(?:(?:D\d{1,2}|KP|Vimshottari|Narayana|Shadb
|
||||
const exactMinutePattern = /(?:\b(?:[01]?\d|2[0-3]):[0-5]\d\b|(?:凌晨|清晨|上午|中午|下午|傍晚|晚上)?\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*[点时]\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*分)/u;
|
||||
const questionClausePattern = /(?:请|你(?:还)?(?:记得|能否|是否|有没有)|再(?:说|补充|回忆)|哪(?:一|个|年|月|天)?|什么|多少|几(?:年|月|号|日)?|吗|呢)/u;
|
||||
const genericAcknowledgementPattern = /^(?:好的|明白了|知道了|收到|已记录|我记下了|我已按你的描述整理这轮线索)[。!!]?$/u;
|
||||
const methodMappingPattern = /(?:方法(?:论)?映射|一般(?:关联|参考)|通常(?:关联|参考|用于|检查)|可(?:以)?参考|主要关联|方法层|尚未计算|还没有计算|未计算)/u;
|
||||
const observedResultClaimPattern = /(?:(?:已(?:经)?|实际|结果|显示|表明|验证|证明|支持|排除|倾向|更符合|领先|指向|确认|确定)[^。!?\n]{0,48}(?:这次|该(?:事件|经历)|候选|区间|范围|分钟|出生|较早|较晚)|(?:这次|该(?:事件|经历)|候选|区间|范围|分钟|出生|较早|较晚)[^。!?\n]{0,48}(?:显示|表明|验证|证明|支持|排除|倾向|更符合|领先|指向|确认|确定))/u;
|
||||
const candidateConclusionPattern = /(?:候选|区间|范围|分钟|出生|时段|较早|较晚|更早|更晚|偏前|偏后|靠前|靠后|前一组|后一组|第一组|第二组)/u;
|
||||
const groundedPublicReplyRequirement = "When the latest answer adds or refines a concrete event, the final public reply must: acknowledge the exact event and its date precision; summarize one to three evidence signals found in the user wording; use evidenceExplanation to map those signals to public method layers from dossier.capabilities.publicTechniqueCapabilities; distinguish a general method mapping from calculations actually present in tool observations; explain why the next question helps; and ask at most one question. Public method names such as D4, D24, Vimshottari, Narayana, UL, and A10 are allowed. Never expose internal ids, raw scores, weights, contribution matrices, raw tool traces, candidate minutes, or hidden reasoning. Never claim a numeric structural fact such as a division switching N times unless the dossier contains the matching window_sensitivity observation and publicExplanationGrounding cites its fact key.";
|
||||
|
||||
function containsExactMinute(value: string): boolean {
|
||||
@@ -110,14 +113,18 @@ function fallback(dossier: RectificationCaseDossier, latestAnswer: string): Rect
|
||||
const targetQuestion = targetNeedsMonth
|
||||
? `${safeTargetSummary}大概发生在哪个月,或一年中的哪个时间段?`
|
||||
: `关于${safeTargetSummary},你还记得更具体的时间或阶段吗?`;
|
||||
const question = keepTarget ? targetQuestion : "你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。";
|
||||
const activeEventCount = dossier.eventLedger.filter((event) => event.status === "active").length;
|
||||
const neutralLead = ["unknown", "declined", "direction_change"].includes(dossier.interviewState.targetDisposition)
|
||||
? "那就先跳过刚才那一项。"
|
||||
: `目前已有 ${activeEventCount} 条可继续核对的经历。`;
|
||||
const question = keepTarget ? targetQuestion : `${neutralLead}你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。`;
|
||||
const focus = keepTarget
|
||||
? { mode: "clarify_existing_event" as const, targetEventId, domain: targetEvent?.domain ?? null, requestedFacts: [targetNeedsMonth ? "month" as const : "day_or_period" as const], rationaleCodes: ["unresolved_current_event"] }
|
||||
: { mode: "collect_independent_event" as const, targetEventId: null, domain: null, requestedFacts: ["independent_event" as const, "year" as const], rationaleCodes: ["model_unavailable_neutral_fallback"] };
|
||||
const techniqueLayers = groundedEvent ? domainScorerRegistry[groundedEvent.domain].techniqueLayers : [];
|
||||
const evidenceExplanation = safeSummary && techniqueLayers.length
|
||||
? `按当前校正能力,这类事件通常会参考 ${techniqueLayers.join("、")};这里只是在说明方法映射,尚未把它写成某个候选时间的计算结论。`
|
||||
: safeSummary ? "这条经历提供了可核对的时间和变化类型;目前只是整理证据,还不是候选时间的计算结论。" : null;
|
||||
? `按当前校正能力,这类事件通常会参考 ${techniqueLayers.join("、")};这里只是在说明方法映射,尚未形成实际计算结果。`
|
||||
: safeSummary ? "这条经历提供了可核对的时间和变化类型;目前只是整理证据,还不是实际计算结果。" : null;
|
||||
return {
|
||||
contractVersion: "rectification-turn-plan-v1",
|
||||
targetDisposition: dossier.interviewState.targetDisposition,
|
||||
@@ -184,6 +191,14 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
|
||||
}
|
||||
if (!observationFacts.get(grounding.source)?.has(grounding.factKey)) issues.push("public_grounding_invalid");
|
||||
});
|
||||
const hasCapabilityGrounding = plan.publicExplanationGrounding.some((grounding) => grounding.source === "capability_matrix");
|
||||
const hasObservationGrounding = plan.publicExplanationGrounding.some((grounding) => grounding.source !== "capability_matrix");
|
||||
if (hasCapabilityGrounding && !hasObservationGrounding && plan.publicReply.evidenceExplanation
|
||||
&& (!methodMappingPattern.test(plan.publicReply.evidenceExplanation)
|
||||
|| observedResultClaimPattern.test(plan.publicReply.evidenceExplanation)
|
||||
|| candidateConclusionPattern.test(plan.publicReply.evidenceExplanation))) {
|
||||
issues.push("capability_claim_not_mapping");
|
||||
}
|
||||
if (groundedEvent && plan.publicReply.evidenceExplanation) {
|
||||
const citedCapabilities = plan.publicExplanationGrounding.flatMap((grounding) => {
|
||||
const capability = grounding.source === "capability_matrix" ? capabilityFacts.get(grounding.factKey) : null;
|
||||
@@ -205,7 +220,10 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
|
||||
if (plan.action.type === "ask_question") {
|
||||
if (asksMultipleQuestions(plan.action.question)) issues.push("multiple_questions");
|
||||
const nextQuestion = normalizedQuestion(plan.action.question);
|
||||
if (nextQuestion && input.dossier.interviewState.askedTopics.some((question) => normalizedQuestion(question).endsWith(nextQuestion))) issues.push("question_repeated");
|
||||
if (nextQuestion && input.dossier.interviewState.askedTopics.some((question) => {
|
||||
const previousQuestion = normalizedQuestion(question);
|
||||
return previousQuestion.endsWith(nextQuestion) || nextQuestion.endsWith(previousQuestion);
|
||||
})) issues.push("question_repeated");
|
||||
if (input.phase === "final" && groundedEvent) {
|
||||
if (genericAcknowledgementPattern.test(plan.publicReply.acknowledgement.trim())) issues.push("event_acknowledgement_generic");
|
||||
if (!plan.publicReply.evidenceExplanation || plan.publicReply.evidenceExplanation.trim().length < 12) issues.push("event_explanation_missing");
|
||||
@@ -216,7 +234,8 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
|
||||
if (currentTarget && ["unresolved", "answered_other_event"].includes(plan.targetDisposition)
|
||||
&& plan.action.focus.targetEventId !== currentTarget) issues.push("unresolved_target_abandoned");
|
||||
if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition)
|
||||
&& (plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId
|
||||
&& ((input.dossier.interviewState.currentTargetEventId !== null
|
||||
&& plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId)
|
||||
|| ["clarify_existing_event", "resolve_conflict"].includes(plan.action.focus.mode))) issues.push("declined_target_reopened");
|
||||
}
|
||||
if (plan.action.type === "offer_candidate_range" && (!input.dossier.candidateState.publicRangeAllowed || plan.action.snapshotId !== input.dossier.candidateState.currentSnapshotId)) issues.push("candidate_range_gate_failed");
|
||||
@@ -367,6 +386,16 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
|
||||
if (!validated.plan) throw new Error(`director_plan_rejected:${validated.issues.join(",")}`);
|
||||
return { plan: validated.plan, dossier, mode: "agent" as const, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
|
||||
} catch (error) {
|
||||
return { plan: fallback(dossier, input.latestAnswer), 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 fallbackPlan = fallback(dossier, input.latestAnswer);
|
||||
const validatedFallback = validateRectificationTurnPlan({ plan: fallbackPlan, dossier, latestAnswer: input.latestAnswer, phase: input.phase });
|
||||
const safePlan = validatedFallback.plan ?? {
|
||||
contractVersion: "rectification-turn-plan-v1" as const,
|
||||
targetDisposition: dossier.interviewState.targetDisposition,
|
||||
evidenceProposals: [],
|
||||
action: { type: "stop_low_confidence" as const, reasonCodes: ["deterministic_fallback_rejected"] },
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +497,7 @@ test("final Director repairs a generic acknowledgement and missing evidence-valu
|
||||
prompts.push(prompt);
|
||||
return {
|
||||
object: phase === "repair"
|
||||
? plan({ publicReply: { acknowledgement: "你提到2020年4月去石油化工研究院实习做研究员。", evidenceExplanation: "这是一条职业状态变化线索,按能力矩阵可参考 D10 与 Vimshottari;目前只是方法映射,不是候选结论。", candidateCommentary: "这段经历的时间和工作状态变化都很明确,可以和其他独立事件交叉比较候选范围。", limitation: null }, publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }] })
|
||||
? plan({ publicReply: { acknowledgement: "你提到2020年4月去石油化工研究院实习做研究员。", evidenceExplanation: "这是一条职业状态变化线索,按能力矩阵可参考 D10 与 Vimshottari;目前只是方法映射,不是实际计算结果。", candidateCommentary: "这段经历的时间和工作状态变化都很明确,可以和其他独立事件交叉比较候选范围。", limitation: null }, publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }] })
|
||||
: plan(),
|
||||
};
|
||||
},
|
||||
@@ -591,6 +591,24 @@ test("public explanations require real capability grounding for the current even
|
||||
publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }],
|
||||
});
|
||||
assert.deepEqual(validateRectificationTurnPlan({ plan: grounded, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues, []);
|
||||
|
||||
const inventedResult = plan({
|
||||
publicReply: { ...base, evidenceExplanation: "D10 已经显示这次职业变化支持较早的候选区间。" },
|
||||
publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }],
|
||||
});
|
||||
assert.ok(validateRectificationTurnPlan({ plan: inventedResult, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues.includes("capability_claim_not_mapping"));
|
||||
|
||||
const disguisedInventedResult = plan({
|
||||
publicReply: { ...base, evidenceExplanation: "这只是方法映射;D10 已经证明这次经历更符合较早出生。" },
|
||||
publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }],
|
||||
});
|
||||
assert.ok(validateRectificationTurnPlan({ plan: disguisedInventedResult, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues.includes("capability_claim_not_mapping"));
|
||||
|
||||
const uncaughtSynonym = plan({
|
||||
publicReply: { ...base, evidenceExplanation: "这只是方法映射;D10 说明该经历对应出生时段偏前。" },
|
||||
publicExplanationGrounding: [{ source: "capability_matrix", factKey: "domain:career" }],
|
||||
});
|
||||
assert.ok(validateRectificationTurnPlan({ plan: uncaughtSynonym, dossier: dossier([internship]), latestAnswer: internship.rawText, phase: "final" }).issues.includes("capability_claim_not_mapping"));
|
||||
});
|
||||
|
||||
test("the final plan cannot repeat a previously asked question", () => {
|
||||
@@ -604,6 +622,16 @@ test("the final plan cannot repeat a previously asked question", () => {
|
||||
},
|
||||
});
|
||||
assert.ok(validateRectificationTurnPlan({ plan: repeated, dossier: dossier([], [turn(0, { question: previous })]), latestAnswer: "", phase: "final" }).issues.includes("question_repeated"));
|
||||
const prefixedRepeat = plan({
|
||||
action: {
|
||||
type: "ask_question",
|
||||
focus: { mode: "collect_independent_event", targetEventId: null, domain: null, requestedFacts: ["independent_event"], rationaleCodes: ["test"] },
|
||||
question: `目前已有 0 条可继续核对的经历。${previous}`,
|
||||
optionalQuickReplies: [],
|
||||
},
|
||||
});
|
||||
assert.ok(validateRectificationTurnPlan({ plan: prefixedRepeat, dossier: dossier([], [turn(0, { question: previous })]), latestAnswer: "", phase: "final" }).issues.includes("question_repeated"));
|
||||
|
||||
const storedPublicReply = composeRectificationPublicTurn({
|
||||
acknowledgement: "你提到的是2020年4月去研究院实习。",
|
||||
evidenceExplanation: "这条经历有明确月份,可以和其他经历交叉比较。",
|
||||
@@ -703,6 +731,38 @@ test("deterministic fallback refines the known year before collecting another ev
|
||||
assert.doesNotMatch(result.plan.action.question, /另一件|还能想到一件/);
|
||||
});
|
||||
|
||||
test("deterministic fallback pauses instead of repeating a persisted public question", async () => {
|
||||
const repeatedQuestion = "目前已有 0 条可继续核对的经历。你愿意再讲一件与已有记录不同、时间大致明确的经历吗?没有、记不清或不想回答也可以换个方向。";
|
||||
const persisted = composeRectificationPublicTurn({
|
||||
acknowledgement: "本轮信息已经保留。",
|
||||
evidenceExplanation: null,
|
||||
candidateUpdate: null,
|
||||
limitation: null,
|
||||
question: repeatedQuestion,
|
||||
});
|
||||
const result = await runRectificationDirector({
|
||||
caseValue,
|
||||
dossier: buildRectificationCaseDossier({
|
||||
caseValue,
|
||||
turns: [turn(0, { question: persisted, answer: "2016年9月去外地上大学" })],
|
||||
events: [],
|
||||
snapshot: null,
|
||||
diagnostics: null,
|
||||
targetDisposition: "not_applicable",
|
||||
currentTargetEventId: null,
|
||||
}),
|
||||
latestAnswer: "",
|
||||
phase: "final",
|
||||
diagnostics,
|
||||
generatePlan: async () => { throw new Error("forced_fallback"); },
|
||||
});
|
||||
|
||||
assert.equal(result.mode, "deterministic_fallback");
|
||||
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"]);
|
||||
});
|
||||
|
||||
test("deterministic fallback stays domain-neutral when the Agent is unavailable", async () => {
|
||||
const university = event({
|
||||
summary: "2016年9月离家去外地上大学",
|
||||
|
||||
Reference in New Issue
Block a user