fix: stop rectification question template fallback

This commit is contained in:
Jesse_Chen
2026-07-29 20:30:23 +08:00
parent f0f7c27382
commit 555e7d9bef
5 changed files with 113 additions and 25 deletions
+14
View File
@@ -1648,3 +1648,17 @@
- 防复发:公开 Case rollout 必须同时控制创建门与 Agent deployment mode,并验证运行容器的实际环境;仅有 `readyForNewCases=true` 不再视为新对话 Renderer 已启用的充分证据。
- 相关记录:BUG-085、BUG-086、BUG-087
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`
## BUG-092 | Semantic Renderer 成功后仍被静默替换成固定领域模板
- 状态:resolved
- 首次发现:2026-07-29
- 最近更新:2026-07-29
- 影响面:V6 `ask_new_event` Opportunity 排序、问题验证、Renderer telemetry 与 staging 用户可见下一问
- 用户现象:用户提交“2020 年 4 月去石油化工研究院实习做研究员”后,系统仍显示“承接……请再说一件……哪次搬家、离乡或长期迁居……”,像固定问卷。
- 根因:Builder 将最近六轮答案拼接为当前主题,使较早教育事件中的“离家/外地”再次提升 relocation,同时未覆盖领域奖励在已经满足最小领域数后仍占主导;Renderer 对自然 `new_dated_event` 问法使用过窄词面校验,校验失败后静默替换为 Builder 固定 fallbacktelemetry 仍记为 `renderer succeeded`
- 修复:当前主题只读取最新回答或最新事件;达到两个可评分领域后显著降低纯领域覆盖收益并提高最新主题连续性;移除“承接……请再说一件……”拼接,fallback 改为锚定当前经历的单句问题;放宽自然新事件词面但继续执行单问题、锚点、内部信息和出生分钟安全校验;validator 回退单独记录 `renderer rejected` 与脱敏错误码。
- 验证:真实两事件重放断言 career 机会优先于旧 relocation 关键词、月份不被细化、旧固定模板被拒绝、锚定研究院实习的自然问题被保留且不等于 fallback;完整前端、lint、TypeScript、Python V5 与 staging smoke 随发布记录执行。
- 防复发:模型调用成功、Schema 成功和问题被接受必须分开观测;Opportunity utility 不得把历史关键词与“未覆盖领域”组合成伪装的固定轮询。
- 相关记录:BUG-086、BUG-090、BUG-091
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`
@@ -11,17 +11,17 @@ const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [
const domainPolicy: Readonly<Record<Exclude<EvidenceDomain, "family" | "other">, Readonly<{
goal: string;
fallbackPrompt: string;
fallbackPrompt: (anchor: string | null) => string;
keywords: RegExp;
recallEase: number;
privacyCost: number;
}>>> = {
education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: "哪次入学、毕业或专业变化的时间你比较确定", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 },
relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: "哪次搬家、离乡或长期迁居的时间你比较确定?", keywords: /搬家|迁居|离家|外地|城市|北京|上海|出国/, recallEase: .78, privacyCost: .04 },
relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: "如果方便,哪关系开始、结束或进入婚姻的时间比较确定", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 },
career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: "哪次入职、离职、转行、创业或职责变化的时间你比较确定", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 },
finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: "如果方便,哪次收入、负债或资产明显变化的时间比较确定", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 },
health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: "如果方便,你本人哪次住院、手术、事故或健康转折的时间比较确定", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 },
education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: (anchor) => anchor ? `在“${anchor}”之外,你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?` : "你还记得哪次入学、毕业或专业变化大概发生在哪年哪月", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 },
relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: (anchor) => anchor ? `以“${anchor}”为时间参照,你哪次搬到新城市或长期离乡的年月最确定?` : "你哪次搬到新城市或长期离乡的年月最确定?", keywords: /搬家|迁居|离家|外地|城市|北京|上海|出国/, recallEase: .78, privacyCost: .04 },
relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: (anchor) => anchor ? `说到“${anchor}”这段时期,如果你愿意,哪次关系变化的大概年月还记得?` : "如果你愿意,哪关系变化的大概年月还记得", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 },
career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: (anchor) => anchor ? `在“${anchor}”之后,哪次工作或职责明显变化的年月你还记得?` : "哪次工作或职责明显变化的年月你还记得", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 },
finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: (anchor) => anchor ? `以“${anchor}”为时间参照,如果方便,哪次财务状况明显变化的年月你还记得?` : "如果方便,哪次财务状况明显变化的年月你还记得", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 },
health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: (anchor) => anchor ? `说到“${anchor}”前后,如果方便,你本人哪次健康变化的大概年月还记得?` : "如果方便,你本人哪次健康变化的大概年月还记得", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 },
};
function stableUuid(value: string): string {
@@ -70,10 +70,6 @@ function anchorFor(event: LifeEventRevision): string {
return event.summary.replace(/[“”"']/g, "").trim().slice(0, 80);
}
function recentText(turns: readonly RectificationV4Turn[]): string {
return turns.slice(-6).map((turn) => turn.answer).join(" ");
}
function declinedSensitiveDomains(turns: readonly RectificationV4Turn[]): ReadonlySet<EvidenceDomain> {
const result = new Set<EvidenceDomain>();
for (const turn of turns) {
@@ -98,7 +94,8 @@ export function buildQuestionOpportunities(input: Readonly<{
const retryTargets = new Set(input.retryTargetEventIds ?? []);
const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain));
const refusedDomains = declinedSensitiveDomains(input.turns);
const latestContext = recentText(input.turns);
const latestEvent = chronologicalEvents(input.events).at(-1);
const latestContext = input.turns.at(-1)?.answer ?? latestEvent?.rawText ?? "";
const opportunities: QuestionOpportunity[] = [];
if (input.targetDisposition === "answered_other_event") {
@@ -182,25 +179,21 @@ export function buildQuestionOpportunities(input: Readonly<{
}
const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length;
const latestEvent = chronologicalEvents(input.events).at(-1);
for (const [domain, policy] of Object.entries(domainPolicy) as [Exclude<EvidenceDomain, "family" | "other">, (typeof domainPolicy)[Exclude<EvidenceDomain, "family" | "other">]][]) {
if (refusedDomains.has(domain)) continue;
const covered = scoreableDomains.has(domain);
const themeBonus = policy.keywords.test(latestContext) ? .12 : 0;
const themeBonus = policy.keywords.test(latestContext) ? .22 : 0;
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
const prompt = latestAnchor
? `承接“${latestAnchor}”,请再说一件时间相对明确的经历:${policy.fallbackPrompt}`
: policy.fallbackPrompt;
opportunities.push(opportunity(input.caseId, {
kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal,
requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [],
contextFacts: [`已有 ${scoreableCount} 件可评分事件。`, `该领域${covered ? "已有覆盖" : "尚未覆盖"}`],
fallbackPrompt: prompt, reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
expectedInformationGain: covered ? .54 + themeBonus : .7 + themeBonus,
fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
expectedInformationGain: covered ? .54 + themeBonus : .65 + themeBonus / 2,
dateSensitivity: input.snapshot ? .5 : .35,
candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42,
domainCoverageGain: covered ? 0 : 1,
domainCoverageGain: covered ? 0 : scoreableDomains.size < 2 ? 1 : .15,
recallEase: policy.recallEase, novelty: alreadyAsked ? .35 : .9,
repetitionPenalty: alreadyAsked ? .3 : 0, privacyCost: policy.privacyCost,
}));
@@ -11,6 +11,7 @@ const bannedAcknowledgement = /(?:这个信息很有用|它不是单纯的|而
const overinterpretedAcknowledgement = /(?:职业方向正式落地|人生意义|意味着你|说明你(?:已经|开始|正式)|标志着你)/;
const internalTerms = /(?:opportunityId|snapshotId|eventId|targetEventId|requestedFields|fallbackPrompt|tool\s*call|tool_call|score|评分|模型名|opportunity|snapshot|D\d{1,2}|KP\b|Vimshottari)/i;
const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便|再告诉我)/;
const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']??请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对)/;
const exactClockMinute = /(?:[01]?\d|2[0-3])[:][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/;
const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/;
@@ -34,6 +35,16 @@ function normalized(value: string): string {
return value.normalize("NFKC").replace(/[“”"'\s,。.!?::;;]/g, "");
}
function includesAnchor(question: string, anchor: string): boolean {
const normalizedQuestion = normalized(question);
const normalizedAnchor = normalized(anchor);
if (normalizedQuestion.includes(normalizedAnchor)) return true;
for (let start = 0; start <= normalizedAnchor.length - 4; start += 1) {
if (normalizedQuestion.includes(normalizedAnchor.slice(start, start + 4))) return true;
}
return false;
}
function visibleTextSafetyIssues(value: string): string[] {
const issues: string[] = [];
if (internalTerms.test(value)) issues.push("internal_information_exposed");
@@ -51,9 +62,9 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques
if (/\n\s*(?:[-*•]|\d+[.)、])/.test(value)) issues.push("question_list_forbidden");
issues.push(...visibleTextSafetyIssues(value));
if (multiQuestionMoves.test(value)) issues.push("multiple_question_instruction");
if (opportunity.targetEventId) {
const questionText = normalized(value);
if (!opportunity.anchors.some((anchor) => questionText.includes(normalized(anchor)))) issues.push("target_anchor_missing");
if (cannedQuestion.test(value)) issues.push("canned_question_forbidden");
if (opportunity.targetEventId || (opportunity.kind === "ask_new_event" && opportunity.anchors.length > 0)) {
if (!opportunity.anchors.some((anchor) => includesAnchor(value, anchor))) issues.push("target_anchor_missing");
}
for (const field of opportunity.requestedFields) {
if (field === "event_subject" && !/(?:本人|你自己|家人|伴侣|配偶)/.test(value)) issues.push("event_subject_not_requested");
@@ -61,7 +72,7 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques
if (field === "event_day" && !/(?:哪一天|几号|具体日期|大概日期)/.test(value)) issues.push("event_day_not_requested");
if (field === "event_range" && !/(?:大概时间|时间范围|什么时候|哪个时间|哪一段时间)/.test(value)) issues.push("event_range_not_requested");
if (field === "event_stage" && !/(?:开始|高峰|结束|正式发生)/.test(value)) issues.push("event_stage_not_requested");
if (field === "new_dated_event" && !/(?:哪次|哪件|一件|经历)/.test(value)) issues.push("new_event_not_requested");
if (field === "new_dated_event" && !/(?:哪次|哪件|一件|经历|变化|转折|发生)/.test(value)) issues.push("new_event_not_requested");
if (field === "new_dated_event" && !/(?:时间|日期|什么时候|哪年|哪月|几月)/.test(value)) issues.push("new_event_date_not_requested");
if (field === "event_year" && !/(?:哪年|年份|哪一年)/.test(value)) issues.push("event_year_not_requested");
}
@@ -172,7 +183,14 @@ export async function renderPublicTurn(input: Readonly<{
forbiddenMoves: opportunity.forbiddenMoves,
} : null,
}), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } });
const message = realizePublicMessage(result.object, input);
const generated = publicMessageSchema.parse(result.object);
const questionValidation = opportunity ? validateQuestionRealization(generated.question, opportunity) : null;
const message = realizePublicMessage(generated, input);
if (questionValidation && !questionValidation.valid) {
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "rejected", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: questionValidation.issues[0] ?? "renderer_question_rejected", deploymentSha });
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_question_rejected", deploymentSha });
return message;
}
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: null, deploymentSha });
return message;
} catch {
@@ -191,6 +191,65 @@ test("Builder 的领域排序不受事件输入数组顺序影响", () => {
assert.deepEqual(domains([education, career]), domains([career, education]));
});
test("研究院实习后优先延续最新主题,不被旧教育事件的离家关键词拉回迁居问卷", () => {
const education = event({
summary: "离家去外地上大学",
rawText: "2016年9月离家去外地上大学",
createdAt: "2026-07-29T01:00:00.000Z",
});
const career = event({
domain: "career",
eventKind: "career_change",
summary: "去石油化工研究院实习做研究员",
rawText: "2020年4月去石油化工研究院实习做研究员",
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
createdAt: "2026-07-29T02:00:00.000Z",
});
const opportunities = buildQuestionOpportunities({
caseId,
events: [education, career],
turns: [
turn({ answer: education.rawText, createdAt: "2026-07-29T01:00:00.000Z" }),
turn({ answer: career.rawText, createdAt: "2026-07-29T02:00:00.000Z" }),
],
snapshot: null,
diagnostics: null,
});
assert.equal(opportunities.some((item) => item.kind === "refine_event_date"), false);
assert.equal(opportunities[0]?.kind, "ask_new_event");
assert.equal(opportunities[0]?.domain, "career");
assert.match(opportunities[0]?.fallbackPrompt ?? "", /研究院实习/);
assert.doesNotMatch(opportunities[0]?.fallbackPrompt ?? "", /承接.*请再说一件|哪次搬家、离乡或长期迁居/);
});
test("Renderer 接受锚定最新事件的自然新事件问题并拒绝旧固定模板", () => {
const latest = event({
domain: "career",
eventKind: "career_change",
summary: "去石油化工研究院实习做研究员",
rawText: "2020年4月去石油化工研究院实习做研究员",
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
});
const opportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [turn({ answer: latest.rawText })], snapshot: null, diagnostics: null })
.find((item) => item.kind === "ask_new_event" && item.domain === "career");
assert.ok(opportunity);
const naturalQuestion = "研究院实习之后,下一次工作发生明显变化大概是什么时候?";
const canned = `承接“${latest.summary}”,请再说一件时间相对明确的经历:哪次工作变化的时间你比较确定?`;
assert.equal(validateQuestionRealization(naturalQuestion, opportunity).valid, true);
assert.equal(validateQuestionRealization(canned, opportunity).valid, false);
const message = realizePublicMessage({ acknowledgement: `你提到的是“${latest.summary}”。`, candidateUpdate: null, limitation: null, question: naturalQuestion }, {
latestAnswer: latest.rawText,
acceptedEvents: [latest],
pendingEvidence: [],
snapshot: null,
previousSnapshot: null,
validated: validated(opportunity),
});
assert.equal(message.question, naturalQuestion);
assert.notEqual(message.question, opportunity.fallbackPrompt);
});
test("Builder 和 Reasoner 按事件创建时间承接最近经历而不是 UUID 顺序", () => {
const older = event({
eventId: "ffffffff-ffff-4fff-8fff-ffffffffffff",
@@ -6,6 +6,8 @@ Question opportunities describe meaning, not final prose. New opportunities use
The builder produces several candidates and publishes at most five active opportunities. Rank them by evidence and context: expected information gain, candidate-split relevance, date sensitivity, domain coverage, recent user topics, recall ease, novelty, repetition penalty, and privacy cost. Never select the first missing domain from a fixed education/relocation/relationship/career/finance/health sequence.
Use the latest answer and latest accepted event as the current topic. Do not let keywords from older turns pull the conversation back to a stale domain, and do not give an uncovered domain both a coverage reward and a second topic reward from the same older event. Once the minimum domain coverage is already present, continuity and information gain should outweigh collecting another domain merely because it is missing.
## One-turn rule
- Ask one question only.
@@ -14,6 +16,8 @@ The builder produces several candidates and publishes at most five active opport
- Do not ask a list of questions or combine a clarification with a new-domain request.
- Do not invent an event or date.
- Do not expose IDs, fields, scores, tools, models, or technique traces.
- Reject canned realizations such as `承接……请再说一件……`; a deterministic fallback must still read as one short contextual question.
- Validate the question semantically. Natural wording such as “下一次明显变化大概发生在什么时候” must not be rejected only because it omits a fixed phrase such as `哪次` or `哪件`.
## Target disposition