Merge pull request #69 from jesse-ux/staging

fix(rectification): guide recall with semantic event cues
This commit is contained in:
jesse-ux
2026-07-30 14:43:57 +08:00
committed by GitHub
7 changed files with 180 additions and 20 deletions
+16
View File
@@ -1756,3 +1756,19 @@
- 防复发:公开候选必须同时通过事件/领域覆盖、范围宽度、邻近分钟、LOEO、LODO、日期敏感性、计算规格和 required-technique 门禁;任何 holdout 完成声明必须先有稳定分区持久化与校准验收证据。
- 相关记录:BUG-082、BUG-090、BUG-093、BUG-095
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`,独立 holdout 延期
## BUG-099 | 新事件追问可能预设事件存在、换词重复且 Renderer 回退不可追溯
- 状态:resolved
- 首次发现:2026-07-30
- 最近更新:2026-07-30
- 影响面:Semantic Question Opportunity 生成与排序、跨领域事件去重、Renderer 问题校验、持久化分析历史
- 用户现象:新事件问题可能直接询问“哪次”经历而暗示该事件必然发生;提示缺少便于回忆但不限定答案的具体线索;与最新事件语义相同的内容可能换一个领域名称再次追问;Renderer 模型问题被接受、被拒绝或改由服务器 fallback 后,历史分析收据无法明确区分实际路径。
- 触发条件:生成 `ask_new_event` 时仅依赖领域模板或缺失领域;跨领域候选与最新事件共享同一人物、行动或生活转折但没有语义重叠降权;Renderer 不可用、调用失败或问题未通过校验而进入 deterministic fallback。
- 根因:问题政策尚未明确存在性询问、非穷举回忆线索数量和禁止虚构年龄/日期窗口;utility 合同没有钉死跨领域语义重叠 penalty;分析历史没有要求持久化 Renderer 校验结果与服务器 fallback 来源。
- 修复要求:所有新事件问题先询问相关经历是否存在,不得预设发生;提供 2–5 个明确标注为示例而非穷举的回忆线索,并允许用户回答其他经历或表示没有;不得发明年龄、人生阶段或日期窗口。若候选与最新事件跨领域但语义重叠,必须在排序前降低 utility,足以判定为同一事件换词时不得再次提问。每次 Renderer 尝试必须在分析历史中留下安全的分类收据,区分模型问题通过校验、模型问题被拒绝,以及服务器 deterministic fallback,并记录不含原始 Prompt 或用户敏感文本的原因类别。
- 验证:`rectification-agent-v6.test.ts` 覆盖存在性问题、具体回忆线索、退出方式、禁止虚构年龄/日期窗口和跨领域同事件降权;`rectification-analysis-trace.test.ts` 覆盖持久化分析历史中的模型安全校验与服务器 fallback 分类;完整前端测试 1123/1123 通过。
- 安全边界:分析历史只记录阶段、校验结果、fallback 布尔值或安全原因类别,不记录模型 Prompt、原始候选文本、隐藏推理、内部评分、事件原文或出生资料。
- 防复发:新事件问题和 Renderer 分析收据必须作为服务端合同测试,而不是只测试最终展示文案;领域名称不同不得绕过同一事件的语义去重。
- 相关记录:BUG-087、BUG-095、BUG-097
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`
@@ -10,18 +10,61 @@ const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [
];
const domainPolicy: Readonly<Record<Exclude<EvidenceDomain, "family" | "other">, Readonly<{
goal: string;
goal: (anchor: string | null) => string;
fallbackPrompt: (anchor: string | null) => string;
keywords: RegExp;
recallCues: string;
signals: RegExp;
recallEase: number;
privacyCost: number;
}>>> = {
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 },
education: {
goal: (anchor) => `${anchor ? `${anchor}之外,` : ""}引导用户回忆一件学习路径变化,用复读、转学、换专业、毕业或重要考试等非穷举线索,不预设一定发生。`,
fallbackPrompt: (anchor) => `${anchor ? `${anchor}之外,` : ""}有没有一件学习路径明显变化的经历,比如复读、转学、换专业、毕业或重要考试改变去向;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`,
recallCues: "复读、转学、换专业、毕业或重要考试改变去向",
signals: /大学|学校|入学|升学|毕业|考试|专业|读书|复读|转学/,
recallEase: .82,
privacyCost: .03,
},
relocation: {
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件真正改变居住基地的经历,用搬家、住校或到另一座城市长期生活等非穷举线索,不把当前事件换词重问。`,
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件真正改变居住地点的经历,比如独立搬家、住校或到另一座城市长期生活;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`,
recallCues: "独立搬家、住校或到另一座城市长期生活",
signals: /搬家|搬到|搬去|迁居|迁到|迁往|移居|定居|住校|长期居住|生活基地|离家|外地|异地/,
recallEase: .78,
privacyCost: .04,
},
relationship: {
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件关系状态变化,用关系确立、分开、结婚或共同生活等非穷举线索,不预设一定发生。`,
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果你愿意,有没有一件关系状态明显变化的经历,比如关系确立、分开、结婚或开始共同生活;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`,
recallCues: "关系确立、分开、结婚或开始共同生活",
signals: /恋爱|关系|结婚|离婚|分手|伴侣|对象|共同生活/,
recallEase: .62,
privacyCost: .22,
},
career: {
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}引导用户回忆一件工作状态变化,用第一次正式入职、离职、换岗、创业或职责增加等非穷举线索,不预设一定发生。`,
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}有没有一件工作状态明显变化的经历,比如第一次正式入职、离职、换岗、创业或职责明显增加;如果有,大概是哪年哪月,没有或记不清也可以换一类经历?`,
recallCues: "第一次正式入职、离职、换岗、创业或职责明显增加",
signals: /工作|实习|公司|研究院|职业|入职|离职|创业|负责|换岗|职责/,
recallEase: .85,
privacyCost: .03,
},
finance: {
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件财务结构变化,用收入来源、负债、购房或重大投资等非穷举线索,不预设一定发生。`,
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果方便,有没有一件财务结构明显变化的经历,比如收入来源改变、开始或还清大额负债、购房或重大投资;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`,
recallCues: "收入来源改变、开始或还清大额负债、购房或重大投资",
signals: /收入|负债|投资|资产|财务|买房|卖房|购房/,
recallEase: .6,
privacyCost: .18,
},
health_pressure: {
goal: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}在用户愿意的前提下,引导回忆一件本人健康或高压状态变化,用手术、住院、事故、确诊或明显恢复等非穷举线索,不预设一定发生。`,
fallbackPrompt: (anchor) => `${anchor ? `在“${anchor}”之外,` : ""}如果方便,你本人有没有一件健康或高压状态明显变化的经历,比如手术、住院、事故、确诊或明显恢复;如果有,大概是哪年哪月,没有或不想回答可以换一类经历?`,
recallCues: "手术、住院、事故、确诊或明显恢复",
signals: /住院|手术|事故|健康|生病|确诊|康复|高压|恢复/,
recallEase: .58,
privacyCost: .28,
},
};
function stableUuid(value: string): string {
@@ -182,22 +225,32 @@ export function buildQuestionOpportunities(input: Readonly<{
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 = latestEvent
? latestEvent.domain === domain ? .22 : 0
: policy.keywords.test(latestContext) ? .22 : 0;
const latestEventText = latestEvent ? `${latestEvent.summary} ${latestEvent.rawText}` : latestContext;
const semanticOverlap = Boolean(latestEvent && latestEvent.domain !== domain && policy.signals.test(latestEventText));
const latestDomainContinuity = latestEvent?.domain === domain ? .22 : 0;
const pendingThemeBonus = !latestEvent && policy.signals.test(latestContext) ? .12 : 0;
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
opportunities.push(opportunity(input.caseId, {
kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal,
kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal(latestAnchor),
requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [],
contextFacts: [`已有 ${scoreableCount} 件可评分事件。`, `该领域${covered ? "已有覆盖" : "尚未覆盖"}`, "需要另一件独立事件,不要把最新事件换词重问。"],
contextFacts: [
`已有 ${scoreableCount} 件可评分事件。`,
`该领域${covered ? "已有覆盖" : "尚未覆盖"}`,
`可使用${policy.recallCues}作为非穷举回忆线索。`,
"这是存在性询问,不得假定用户一定经历过该事件。",
"只询问一件带大致年月的新事件,不要求用户逐项回答例子。",
"允许用户回答没有、记不清、不想回答或换方向。",
"不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。",
...(semanticOverlap ? ["该领域与最新事件语义重叠,必须降低优先级,避免把同一经历换词重问。"] : []),
],
fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
expectedInformationGain: covered ? .54 + themeBonus : .65 + themeBonus / 2,
expectedInformationGain: covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus,
dateSensitivity: input.snapshot ? .5 : .35,
candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42,
domainCoverageGain: covered ? 0 : scoreableDomains.size < 2 ? 1 : .15,
recallEase: policy.recallEase, novelty: alreadyAsked ? .35 : .9,
repetitionPenalty: alreadyAsked ? .3 : 0, privacyCost: policy.privacyCost,
recallEase: policy.recallEase, novelty: semanticOverlap ? .45 : alreadyAsked ? .35 : .9,
repetitionPenalty: (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost,
}));
}
@@ -413,6 +413,8 @@ export async function processRectificationAgentTurn(input: Readonly<{
snapshot,
});
const agentVisible = claimed.case.deploymentMode === "v5_agent";
let rendererRealization: "model_validated" | "server_fallback" | null = null;
let rendererFallbackReason: "model_unavailable" | "question_rejected" | "model_failed" | null = null;
const renderedMessage = agentVisible
? await renderPublicTurn({
caseValue: claimed.case,
@@ -422,9 +424,25 @@ export async function processRectificationAgentTurn(input: Readonly<{
snapshot,
previousSnapshot: claimed.case.latestSnapshot,
validated: validatedDecision,
onRealization: (outcome) => {
rendererRealization = outcome.mode;
rendererFallbackReason = outcome.reason;
},
})
: legacyProjection.publicMessage;
finishPhase();
if (agentVisible && rendererRealization) {
stages.push({
phase: "rendering",
label: rendererRealization === "model_validated"
? "自然语言问题已通过安全校验"
: rendererFallbackReason === "question_rejected"
? "模型问题未通过安全校验,已使用服务器安全问题"
: "模型回复不可用,已使用服务器安全问题",
status: "completed",
durationMs: null,
});
}
for (const call of reasoned.toolCalls) {
analysisToolCalls.push({
category: "agent_diagnostic",
@@ -15,7 +15,7 @@ const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便
const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']??请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对|以[“\"']?.{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}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/;
const distinctEventMove = /(?:除了|另一(?:件|次)|下(?:一|1)次|之后|后来|此后|还记得)/;
const distinctEventMove = /(?:除了|之外|另一(?:件|次)|下(?:一|1)次|之后|后来|此后|还记得)/;
const explicitAnchorReference = /(?:这次经历|这段经历|刚才那段|刚才这段|你刚说的|你刚提到的|刚说的|刚提到的|前面那段|这件事)/;
const newEventDomainTerms: Readonly<Partial<Record<QuestionOpportunity["domain"], RegExp>>> = {
education: /(?:入学|升学|毕业|学校|大学|专业|考试|读书)/,
@@ -37,7 +37,7 @@ function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
name: "Birth Time Rectification Response Renderer",
model: selected.model,
skills: [skillPath],
instructions: "Write concise natural Simplified Chinese. Realize exactly one question from the supplied semantic opportunity. Do not invent events or dates, switch targets, interpret the life meaning of an experience, expose ids/scores/techniques, mention a representative minute, or claim an exact birth minute. Avoid canned acknowledgement. Return strict JSON only.",
instructions: "Write concise natural Simplified Chinese. Realize exactly one question from the supplied semantic opportunity. For a new event, use the supplied recall cues as optional examples, ask whether one such event happened instead of assuming it did, and preserve the user's ability to say no, forget, decline, or change direction. Do not invent events, ages, date windows or dates, switch targets, interpret the life meaning of an experience, expose ids/scores/techniques, mention a representative minute, or claim an exact birth minute. Avoid canned acknowledgement. Return strict JSON only.",
});
agents.set(selected.id, agent);
return { id: selected.id, agent };
@@ -193,6 +193,10 @@ export async function renderPublicTurn(input: Readonly<{
previousSnapshot: CandidateSnapshot | null;
validated: ValidatedDecision;
timeoutMs?: number;
onRealization?: (outcome: Readonly<{
mode: "model_validated" | "server_fallback";
reason: "model_unavailable" | "question_rejected" | "model_failed" | null;
}>) => void;
}>): Promise<PublicMessage> {
const started = Date.now();
const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null;
@@ -200,6 +204,7 @@ export async function renderPublicTurn(input: Readonly<{
const selected = agentFor(input.caseValue.narrationModelId);
if (!selected) {
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: input.caseValue.narrationModelId, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_model_unavailable", deploymentSha });
input.onRealization?.({ mode: "server_fallback", reason: "model_unavailable" });
return fallback;
}
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "started", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: null, errorCode: null, deploymentSha });
@@ -226,13 +231,16 @@ export async function renderPublicTurn(input: Readonly<{
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 });
input.onRealization?.({ mode: "server_fallback", reason: "question_rejected" });
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 });
input.onRealization?.({ mode: "model_validated", reason: null });
return message;
} catch {
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "failed", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_failed", 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_failed", deploymentSha });
input.onRealization?.({ mode: "server_fallback", reason: "model_failed" });
return fallback;
}
}
+49 -1
View File
@@ -208,7 +208,8 @@ test("外地上大学不会被换词提升为迁居问题", () => {
const relocation = opportunities.find((item) => item.kind === "ask_new_event" && item.domain === "relocation");
assert.ok(relocation);
assert.doesNotMatch(relocation.fallbackPrompt, /搬到新城市|长期离乡|以.*为(?:时间)?参照/);
assert.match(relocation.fallbackPrompt, /除了.*离家去外地上大学.*搬家或迁居/);
assert.match(relocation.fallbackPrompt, /离家去外地上大学.*真正改变居住地点/);
assert.match(relocation.fallbackPrompt, /没有|记不清|换一类经历/);
const repeated = "以“离家去外地上大学”为时间参照,你哪次搬到新城市或长期离乡的年月最确定?";
assert.equal(validateQuestionRealization(repeated, relocation).valid, false);
@@ -433,3 +434,50 @@ test("V6 迁移只更新未完成 Case 版本且不写 active_birth_time", () =>
assert.match(migration, /where status in \('awaiting_answer', 'processing', 'paused'\)/);
assert.doesNotMatch(migration, /profiles\s*\.\s*active_birth_time|active_birth_time/i);
});
test("新事件机会提供具体回忆线索和退出方式,不把离家上大学换词重问成迁居", () => {
const university = event({ summary: "离家去外地上大学", rawText: "2016年9月离家去外地上大学" });
const opportunities = buildQuestionOpportunities({
caseId,
events: [university],
turns: [turn()],
snapshot: null,
diagnostics: null,
});
const career = opportunities.find((item) => item.kind === "ask_new_event" && item.domain === "career");
const relocation = opportunities.find((item) => item.kind === "ask_new_event" && item.domain === "relocation");
assert.ok(career);
assert.ok(relocation);
assert.ok(career.utility > relocation.utility);
assert.match(career.fallbackPrompt, /第一次正式入职|离职|换岗|创业|职责明显增加/);
assert.match(career.fallbackPrompt, /没有|记不清|换一类经历/);
assert.equal((career.fallbackPrompt.match(/[?]/g) ?? []).length, 1);
assert.doesNotMatch(career.fallbackPrompt, /\b20\d{2}\b|\d+岁|A\/B\/C\/D/);
assert.ok(career.contextFacts.some((fact) => /不得假定/.test(fact)));
assert.ok(career.contextFacts.some((fact) => /没有、记不清、不想回答或换方向/.test(fact)));
assert.equal(validateQuestionRealization(career.fallbackPrompt, career).valid, true);
});
test("研究院实习后的迁居机会以存在性问题主动引导,不要求用户自己发明事件", () => {
const internship = 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 relocation = buildQuestionOpportunities({
caseId,
events: [internship],
turns: [],
snapshot: null,
diagnostics: null,
}).find((item) => item.kind === "ask_new_event" && item.domain === "relocation");
assert.ok(relocation);
assert.match(relocation.fallbackPrompt, /有没有一件/);
assert.match(relocation.fallbackPrompt, /独立搬家|住校|另一座城市长期生活/);
assert.match(relocation.fallbackPrompt, /大概是哪年哪月/);
assert.match(relocation.fallbackPrompt, /没有|记不清|换一类经历/);
assert.doesNotMatch(relocation.fallbackPrompt, /你还记得哪次独立搬家|请继续讲另一件/);
assert.equal(validateQuestionRealization(relocation.fallbackPrompt, relocation).valid, true);
});
@@ -253,6 +253,7 @@ test("below the scoring gate does not claim candidate scanning, diagnostics, or
assert.equal(result.snapshot, null);
assert.equal(trace.stages.some((stage) => stage.phase === "scoring_candidates"), false);
assert.equal(trace.stages.some((stage) => stage.phase === "checking_robustness"), false);
assert.equal(trace.stages.some((stage) => /安全校验|服务器安全问题/.test(stage.label)), true);
assert.deepEqual(trace.toolCalls, []);
assert.deepEqual(trace.techniques, []);
});
@@ -8,6 +8,15 @@ The builder produces several candidates and publishes at most five active opport
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.
### New-event existence and recall cues
- Ask whether a relevant event exists before asking for its details. Use an existence form such as “过去是否有过……” or “如果有……”, not a presuppositional form that implies the user must have had that event.
- Offer 25 concrete recall cues as non-exhaustive examples. Make it explicit that they are examples, allow any other relevant event, and allow the user to say that none occurred.
- Recall cues may name ordinary event types supported by the selected domain, but must not assert that any cue happened to this user.
- Do not invent an age, life stage, year, month, date range, or relative time window. A time or age may appear only when it already comes from accepted user evidence or another server-owned fact allowed by the opportunity contract.
- Compare every new-event opportunity with the latest accepted event even when their domain labels differ. If they overlap semantically in subject, action, transition, or outcome, apply a utility penalty before ranking. If the candidate is merely a cross-domain paraphrase of the latest event, suppress it instead of asking the same event again with different words.
- Domain coverage must not override semantic continuity or duplicate-event protection. A missing domain is not sufficient reason to ask a semantically overlapping question.
## One-turn rule
- Ask one question only.
@@ -17,7 +26,14 @@ Use the latest answer and latest accepted event as the current topic. Do not let
- 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 `哪件`.
- Validate the question semantically rather than by fixed phrases. For a follow-up about an already accepted event, natural wording must not be rejected only because it omits a token such as `哪次` or `哪件`; a new-event question must still satisfy the existence form above.
## Renderer validation and fallback trace
- Analysis history must make each Renderer path auditable after persistence and reload: model question accepted by validation, model question rejected by validation, or server deterministic fallback used.
- Record only safe categorical provenance, including the validation outcome, whether server fallback was used, and a bounded reason category such as model unavailable, model failure, or question rejected. Do not store the raw model prompt, rejected prose, hidden reasoning, private scores, or user event text in the trace.
- A Renderer rejection followed by a successful fallback is not equivalent to a model-rendered success. Preserve both facts in the same turn's analysis receipt.
- The deterministic fallback must remain server-owned and must obey the same existence, recall-cue, non-assumption, anti-invention, and semantic-overlap rules as a model realization.
## Target disposition