fix: decouple rectification narrative from scoring state
This commit is contained in:
@@ -1474,3 +1474,18 @@
|
||||
- 防复发:模型自然语言不得成为事件保存的硬依赖;私有路由元数据为空时应省略可选状态,而不是生成违反持久化合同的半合法对象。
|
||||
- 相关记录:BUG-075、BUG-078
|
||||
- 修复版本:本次修复提交
|
||||
|
||||
## BUG-080 | 开放叙事仍被技术上下文塑造成流程播报
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-26
|
||||
- 最近更新:2026-07-26
|
||||
- 影响面:生时校正普通叙事轮、自然对话、追问状态持久化
|
||||
- 用户现象:用户叙述“2016 年离家去外地上大学”后,Agent 仍回答“先记为、对校时有价值、参与候选核对、不会确认某个分钟”,像记录员而不是自然交谈。
|
||||
- 触发条件:普通 `intermediate` 轮生成叙事时仍向模型提供 Technical Packet、候选分钟、分盘、事件台账和未决证据。
|
||||
- 根因:此前开放叙事只取消了“每轮必须提问”,没有隔离普通聊天与技术收敛上下文;模型继续模仿内部流程措辞。叙事没有可见问题时,模型返回的隐藏 `evidenceRequest` 还会进入下一轮状态。普通提示不再暴露事件 ID 后,旧 authored schema 又仍强制模型提交已有 evidenceId,形成合同矛盾。
|
||||
- 修复:普通叙事轮只向模型提供最近真实对话和本轮用户原话,禁止主动播报记录、校时价值、候选范围、分盘、评分与收敛;只有用户主动询问技术结果时才提供受约束技术包。没有可见问题的隐藏 `evidenceRequest` 统一清空;自然追问只由模型表达问题意图,目标事件 ID 由服务器依据活动事件和未决证据绑定。首轮、最终轮及主动技术询问仍保留技术边界。
|
||||
- 验证:Narrative Agent 与 Orchestrator 联合回归 99/99 通过,覆盖普通 prompt 不含 packet/eventLedger、隐藏追问清空、服务器绑定 follow-up、相对日期承接、fallback、暂停恢复和并发幂等。
|
||||
- 防复发:普通聊天的可见措辞不得由技术 packet 或事件台账驱动;内部收敛状态只能记录和计算,不能成为 Agent 每轮必须复述的脚本。
|
||||
- 相关记录:BUG-074、BUG-075、BUG-078、BUG-079
|
||||
- 修复版本:本次修复提交
|
||||
|
||||
@@ -65,6 +65,7 @@ const labeledYearChoicesPattern = /A\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s*
|
||||
const affirmativeAnswerPattern = /^\s*(?:是(?:的)?|对(?:的)?|没错|正确|确认|就是|嗯+|没问题)\s*[。.!!,,]?\s*$/u;
|
||||
const negativeAnswerPattern = /^\s*(?:不是|不对|错了|并不是|否)\s*[。.!!,,]?\s*$/u;
|
||||
const proposedDateQuestionPattern = /(?:19|20)\d{2}\s*年(?:\s*(?:1[0-2]|0?[1-9])\s*月)?(?:\s*(?:3[01]|[12]\d|0?[1-9])\s*(?:日|号))?[\s\S]{0,30}(?:吗|是否|是不是|确认|对不对|正确)/u;
|
||||
const technicalDiscussionRequestPattern = /(?:校时|生时(?:校正|纠正)|出生时间|候选(?:时间|范围|分钟)|收敛(?:结果|进度)?|技术(?:分析|结果)|分盘|\bD\d{1,3}\b|\b(?:UL|A7|A10|KP)\b)/iu;
|
||||
const domainLabels = {
|
||||
career: "事业",
|
||||
education: "学业",
|
||||
@@ -98,6 +99,18 @@ export const rectificationNarrativeOutputSchema = z.object({
|
||||
}).strict().nullable(),
|
||||
}).strict();
|
||||
|
||||
const authoredFollowUpSchema = z.object({
|
||||
kind: z.enum(["new_event", "event_date", "event_detail"]),
|
||||
// Event ids are server-owned. Ordinary conversation prompts deliberately do
|
||||
// not expose the event ledger, so authored outputs may omit the target id.
|
||||
evidenceId: z.string().uuid().nullable().optional(),
|
||||
answerMode: z.enum(["free_text", "yes_no"]).optional(),
|
||||
proposedDate: z.object({
|
||||
value: z.string().regex(/^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$/),
|
||||
precision: z.enum(["year", "month", "day"]),
|
||||
}).strict().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
export const rectificationNarrativeAuthoredOutputSchema = z.object({
|
||||
narrative: z.string().trim().min(1).max(12_000),
|
||||
evidenceRequest: z.object({
|
||||
@@ -106,7 +119,7 @@ export const rectificationNarrativeAuthoredOutputSchema = z.object({
|
||||
domains: z.array(domainSchema).min(1).max(4).optional(),
|
||||
datePrecision: z.enum(["month_preferred", "year_accepted"]),
|
||||
prompt: z.string().trim().min(1).max(1_000),
|
||||
followUp: rectificationFollowUpSchema.default({ kind: "new_event", evidenceId: null }),
|
||||
followUp: authoredFollowUpSchema.default({ kind: "new_event", evidenceId: null }),
|
||||
}).strict().nullable(),
|
||||
}).strict();
|
||||
|
||||
@@ -177,11 +190,39 @@ function groundedEvidenceRequest(
|
||||
return domains.length > 0 ? { ...request, domains } : null;
|
||||
}
|
||||
|
||||
function serverOwnedFollowUp(
|
||||
followUp: z.infer<typeof authoredFollowUpSchema>,
|
||||
context: RectificationNarrativeContext,
|
||||
): RectificationFollowUp {
|
||||
if (followUp.kind === "new_event") return { kind: "new_event", evidenceId: null };
|
||||
const active = context.eventLedger?.filter((item) => item.active) ?? [];
|
||||
const allowedIds = new Set(active.map((item) => item.id));
|
||||
const evidenceId = followUp.evidenceId && allowedIds.has(followUp.evidenceId)
|
||||
? followUp.evidenceId
|
||||
: followUp.kind === "event_date"
|
||||
? context.unresolvedEvidence?.at(-1)?.id ?? active.at(-1)?.id
|
||||
: active.at(-1)?.id;
|
||||
if (!evidenceId) return { kind: "new_event", evidenceId: null };
|
||||
if (followUp.kind === "event_date" && followUp.answerMode === "yes_no" && followUp.proposedDate) {
|
||||
return rectificationFollowUpSchema.parse({
|
||||
kind: followUp.kind,
|
||||
evidenceId,
|
||||
answerMode: "yes_no",
|
||||
proposedDate: followUp.proposedDate,
|
||||
});
|
||||
}
|
||||
return rectificationFollowUpSchema.parse({ kind: followUp.kind, evidenceId });
|
||||
}
|
||||
|
||||
function completeAuthoredOutput(
|
||||
output: z.infer<typeof rectificationNarrativeAuthoredOutputSchema>,
|
||||
packet: RectificationTechnicalPacket,
|
||||
context: RectificationNarrativeContext,
|
||||
): RectificationNarrativeModelOutput {
|
||||
const evidenceRequest = groundedEvidenceRequest(output.evidenceRequest, packet);
|
||||
const groundedRequest = groundedEvidenceRequest(output.evidenceRequest, packet);
|
||||
const evidenceRequest = groundedRequest
|
||||
? { ...groundedRequest, followUp: serverOwnedFollowUp(groundedRequest.followUp, context) }
|
||||
: null;
|
||||
return {
|
||||
...output,
|
||||
evidenceRequest,
|
||||
@@ -200,6 +241,7 @@ function completeAuthoredOutput(
|
||||
function parseModelOutput(
|
||||
text: string,
|
||||
packet: RectificationTechnicalPacket,
|
||||
context: RectificationNarrativeContext,
|
||||
): RectificationNarrativeModelOutput {
|
||||
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const start = normalized.indexOf("{");
|
||||
@@ -208,15 +250,18 @@ function parseModelOutput(
|
||||
const parsed: unknown = JSON.parse(normalized.slice(start, end + 1));
|
||||
const legacy = rectificationNarrativeOutputSchema.safeParse(parsed);
|
||||
if (legacy.success) {
|
||||
const groundedRequest = groundedEvidenceRequest(legacy.data.evidenceRequest, packet);
|
||||
return {
|
||||
...legacy.data,
|
||||
// The next conversational topic is authored by the model, but the
|
||||
// scoring-domain allowlist remains server-owned. If no grounded routing
|
||||
// domain exists, keep the prose and omit only the optional follow-up state.
|
||||
evidenceRequest: groundedEvidenceRequest(legacy.data.evidenceRequest, packet),
|
||||
evidenceRequest: groundedRequest
|
||||
? { ...groundedRequest, followUp: serverOwnedFollowUp(groundedRequest.followUp, context) }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return completeAuthoredOutput(rectificationNarrativeAuthoredOutputSchema.parse(parsed), packet);
|
||||
return completeAuthoredOutput(rectificationNarrativeAuthoredOutputSchema.parse(parsed), packet, context);
|
||||
}
|
||||
|
||||
function narrativeTimes(value: string): string[] {
|
||||
@@ -296,6 +341,13 @@ export function validateNarrativeAgainstPacket(
|
||||
}
|
||||
}
|
||||
if (output.evidenceRequest) {
|
||||
if (phase === "intermediate" && !/[??]/u.test(output.narrative)) {
|
||||
issues.push("evidence request is not visibly asked in narrative");
|
||||
}
|
||||
if (phase === "intermediate"
|
||||
&& !normalizedQuestion(output.narrative).includes(normalizedQuestion(output.evidenceRequest.prompt))) {
|
||||
issues.push("evidence request prompt is not present in narrative");
|
||||
}
|
||||
for (const domain of output.evidenceRequest.domains) {
|
||||
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
|
||||
}
|
||||
@@ -518,6 +570,24 @@ function promptFor(
|
||||
retryIssues: boundedReceiptIssues(retryIssues),
|
||||
});
|
||||
}
|
||||
if (phase === "intermediate" && !technicalDiscussionRequestPattern.test(context.latestUserText ?? "")) {
|
||||
return JSON.stringify({
|
||||
task: "像正常的人一样接住用户刚才的叙述并自由交谈。不要把自己写成记录员、问卷或校时流程播报器。用户可以继续叙述,也可以自然转到别的话题。",
|
||||
phase,
|
||||
conversation: {
|
||||
recentConversation: context.recentConversation?.slice(-40),
|
||||
latestUserText: context.latestUserText,
|
||||
previousAssistantNarrative: context.previousAssistantNarrative,
|
||||
},
|
||||
rules: [
|
||||
"优先回应用户话里的真实内容、感受、选择或转折,不要复述成档案摘要。",
|
||||
"除非用户主动询问校时进度或技术结果,不要说已记录、先记为、对校时有价值、参与候选时间核对、不会因单一事件确认分钟,也不要主动谈候选范围、分盘、评分、收敛或内部处理。",
|
||||
"不必每轮提问。需要提问时只问自然推进对话真正需要的问题,不要机械补年月、结果或转折。",
|
||||
"只返回 narrative 和 evidenceRequest。没有在 narrative 中逐字提出一个用户可见的明确问题时,evidenceRequest 必须为 null;若提出问题,evidenceRequest.prompt 必须与 narrative 中的问题文字完全一致。followUp 只表达 kind、answerMode 和 proposedDate,不要生成 evidenceId,目标事件由服务器绑定。",
|
||||
],
|
||||
retryIssues: boundedReceiptIssues(retryIssues),
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
task: "write_grounded_rectification_narrative",
|
||||
phase,
|
||||
@@ -568,11 +638,12 @@ function promptFor(
|
||||
|
||||
function fallbackNarrative(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase): string {
|
||||
const candidate = packet.candidate;
|
||||
if (phase === "intermediate") {
|
||||
return "我听到了。你可以顺着这段经历继续说,也可以自然讲下一件想到的事。";
|
||||
}
|
||||
const phaseLine = phase === "final" && candidate.status === "ready_for_confirmation"
|
||||
? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。"
|
||||
: phase === "final"
|
||||
? "当前证据只能支持候选范围,系统验证尚未闭环;本次不会替换当前排盘时间,也不再强制追问更多人生事件。"
|
||||
: `我收到了这段叙述。你可以继续讲这段经历,也可以按自己的节奏说下一件想到的事。`;
|
||||
: "当前证据只能支持候选范围,系统验证尚未闭环;本次不会替换当前排盘时间,也不再强制追问更多人生事件。";
|
||||
return [
|
||||
`当前仍在核对 ${candidate.range.startTime}–${candidate.range.endTime} 的候选范围,还不能把其中某一分钟当作确定出生时间。`,
|
||||
phaseLine,
|
||||
@@ -740,7 +811,12 @@ export async function generateRectificationNarrative(input: {
|
||||
issues,
|
||||
), { signal, attempt });
|
||||
const modelId = modelIdSchema.parse(generated.modelId ?? defaultModelId);
|
||||
const output = parseModelOutput(generated.text, input.packet);
|
||||
const parsedOutput = parseModelOutput(generated.text, input.packet, input.context ?? {});
|
||||
const output = input.phase === "intermediate"
|
||||
&& parsedOutput.evidenceRequest
|
||||
&& !/[??]/u.test(parsedOutput.narrative)
|
||||
? { ...parsedOutput, evidenceRequest: null }
|
||||
: parsedOutput;
|
||||
const validation = validateNarrativeAgainstPacket(
|
||||
output,
|
||||
input.packet,
|
||||
|
||||
@@ -235,7 +235,7 @@ test("keeps a natural intermediate acknowledgement without appending a question"
|
||||
assert.doesNotMatch(result.narrative, /[??]/);
|
||||
});
|
||||
|
||||
test("hides an uninvoked technique inventory during evidence collection", async () => {
|
||||
test("keeps technical packets and event bookkeeping out of ordinary narrative turns", async () => {
|
||||
const prompts: string[] = [];
|
||||
const packet = {
|
||||
...syntheticTechnicalPacket(),
|
||||
@@ -261,13 +261,54 @@ test("hides an uninvoked technique inventory during evidence collection", async
|
||||
await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet,
|
||||
generator: generator([richOutput()], prompts),
|
||||
context: {
|
||||
latestUserText: "2016 年离家去外地上大学",
|
||||
recentConversation: [{ role: "user", text: "2016 年离家去外地上大学" }],
|
||||
eventLedger: [{
|
||||
id: "00000000-0000-4000-8000-000000000801",
|
||||
rawText: "2016 年离家去外地上大学",
|
||||
dateLabel: "2016",
|
||||
summary: "离家去外地上大学",
|
||||
domain: "education",
|
||||
extractionStatus: "clear",
|
||||
active: true,
|
||||
correctsEvidenceIds: [],
|
||||
}],
|
||||
},
|
||||
generator: generator([{
|
||||
narrative: "第一次长期离开家,生活节奏应该一下子变了很多。你可以接着讲后来发生的事。",
|
||||
evidenceRequest: null,
|
||||
}], prompts),
|
||||
});
|
||||
|
||||
assert.match(prompts[0] ?? "", /expertWorkflow/);
|
||||
assert.match(prompts[0] ?? "", /2016 年离家去外地上大学/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /packet/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /eventLedger/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /expertWorkflow/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /KP cusp \/ sub-lord/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /minute_holdout_not_ready/);
|
||||
assert.match(prompts[0] ?? "", /blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /05:16|05:24|D9|D10/);
|
||||
});
|
||||
|
||||
test("drops a hidden follow-up that the user cannot see", async () => {
|
||||
const hidden = {
|
||||
narrative: "第一次长期离开家,生活节奏应该一下子变了很多。你可以接着讲后来发生的事。",
|
||||
evidenceRequest: {
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "2016 年离家去外地上大学大概是几月?",
|
||||
followUp: { kind: "new_event" as const, evidenceId: null },
|
||||
},
|
||||
};
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
context: { latestUserText: "2016 年离家去外地上大学" },
|
||||
generator: generator([hidden]),
|
||||
});
|
||||
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.equal(result.output.evidenceRequest, null);
|
||||
});
|
||||
|
||||
test("appends the three auditable tables to every final Agent answer", async () => {
|
||||
@@ -400,25 +441,29 @@ test("rejects a model-authored event table that exposes private numeric scoring"
|
||||
assert.match(result.narrative, /\| 时间 \| 事件 \| 领域 \| 验证状态 \| 结论 \|/);
|
||||
});
|
||||
|
||||
test("passes the user's latest concrete event to an intermediate skill-guided reply", async () => {
|
||||
test("passes the user's latest words to an ordinary intermediate reply", async () => {
|
||||
const prompts: string[] = [];
|
||||
await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
context: {
|
||||
latestUserText: "2023年9月离开家乡去上海开始第一份长期工作",
|
||||
latestEvidence: [{
|
||||
dateLabel: "2023-09",
|
||||
summary: "离开家乡去上海开始第一份长期工作",
|
||||
domain: "career",
|
||||
}],
|
||||
},
|
||||
generator: generator([richOutput()], prompts),
|
||||
generator: generator([{
|
||||
narrative: "第一次长期离开家去工作,适应过程应该不轻松。你可以接着讲。",
|
||||
evidenceRequest: null,
|
||||
}], prompts),
|
||||
});
|
||||
|
||||
assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/);
|
||||
assert.match(prompts[0] ?? "", /acknowledgeAndReflectBeforeAnyClarification/);
|
||||
assert.match(prompts[0] ?? "", /questionsAreOptional/);
|
||||
assert.match(prompts[0] ?? "", /doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked/);
|
||||
assert.match(prompts[0] ?? "", /不要把自己写成记录员/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /latestEvidence/);
|
||||
assert.doesNotMatch(prompts[0] ?? "", /候选范围.*05:16/);
|
||||
});
|
||||
|
||||
test("keeps internal event domains and suggested-domain routing out of the narrator context", async () => {
|
||||
@@ -448,18 +493,15 @@ test("keeps internal event domains and suggested-domain routing out of the narra
|
||||
});
|
||||
|
||||
const prompt = JSON.parse(prompts[0] ?? "{}") as {
|
||||
conversationContext?: {
|
||||
latestEvidence?: Array<Record<string, unknown>>;
|
||||
eventLedger?: Array<Record<string, unknown>>;
|
||||
};
|
||||
conversation?: Record<string, unknown>;
|
||||
packet?: Record<string, unknown>;
|
||||
};
|
||||
assert.equal(prompt.conversationContext?.latestEvidence?.[0]?.domain, undefined);
|
||||
assert.equal(prompt.conversationContext?.eventLedger?.[0]?.domain, undefined);
|
||||
assert.equal(prompt.conversation?.latestEvidence, undefined);
|
||||
assert.equal(prompt.conversation?.eventLedger, undefined);
|
||||
assert.equal(prompt.packet?.suggestedDomains, undefined);
|
||||
});
|
||||
|
||||
test("passes the active event ledger and unresolved facts to the intermediate agent", async () => {
|
||||
test("keeps the event ledger and unresolved facts out of the ordinary intermediate agent", async () => {
|
||||
const prompts: string[] = [];
|
||||
await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
@@ -494,13 +536,8 @@ test("passes the active event ledger and unresolved facts to the intermediate ag
|
||||
|
||||
const prompt = prompts[0] ?? "";
|
||||
assert.match(prompt, /23年关系结束后发生过一次交通事故/);
|
||||
assert.match(prompt, /2024-08-08/);
|
||||
assert.match(prompt, /一段重要关系结束/);
|
||||
assert.match(prompt, /freeConversation/);
|
||||
assert.match(prompt, /questionsAreOptional/);
|
||||
assert.match(prompt, /acceptMultipleEventsInOneMessage/);
|
||||
assert.match(prompt, /resolveDateContradictionsBeforeScoring/);
|
||||
assert.match(prompt, /mergeSameEventDetailsWithoutDoubleCounting/);
|
||||
assert.doesNotMatch(prompt, /2024-08-08/);
|
||||
assert.doesNotMatch(prompt, /eventLedger|unresolvedEvidence|resolveDateContradictionsBeforeScoring/);
|
||||
});
|
||||
|
||||
test("rejects invented representative times, layers, and references", () => {
|
||||
@@ -564,6 +601,7 @@ test("allows packet-grounded technical tables during an intermediate turn", () =
|
||||
"| D9 | 在候选范围内呈分钟敏感差异 |",
|
||||
"下一步我想继续了解这段关系结束后的直接变化。",
|
||||
].join("\n"),
|
||||
evidenceRequest: null,
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
|
||||
assert.deepEqual(validateNarrativeAgainstPacket(output, packet, "intermediate"), { valid: true, issues: [] });
|
||||
@@ -658,7 +696,7 @@ test("accepts a natural reply that mentions several known dates before asking a
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "毕业后的第一份工作是什么时候开始的?",
|
||||
prompt: "毕业后的第一份工作是直接入职,还是先休息了一段时间?",
|
||||
},
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
|
||||
@@ -827,6 +865,7 @@ test("allows natural discussion of an event after it has contributed to scoring"
|
||||
assert.ok(output.evidenceRequest);
|
||||
const conversational = {
|
||||
...output,
|
||||
narrative: "我理解,这次离开不只是换工作。你当时为什么辞职,是主动还是被动,对生活有什么影响?",
|
||||
evidenceRequest: {
|
||||
...output.evidenceRequest,
|
||||
prompt: "你当时为什么辞职,是主动还是被动,对生活有什么影响?",
|
||||
@@ -924,6 +963,7 @@ test("replaces legacy model-selected evidence domains instead of rejecting the a
|
||||
packet,
|
||||
generator: generator([{
|
||||
...output,
|
||||
narrative: `${output.narrative}\n${output.evidenceRequest?.prompt ?? ""}`,
|
||||
evidenceRequest: {
|
||||
...output.evidenceRequest,
|
||||
domains: ["finance"],
|
||||
|
||||
@@ -126,6 +126,10 @@ function validGenerator(
|
||||
}
|
||||
const request = JSON.parse(prompt) as {
|
||||
phase: "first" | "intermediate" | "final";
|
||||
conversation?: {
|
||||
recentConversation?: Array<{ role: "assistant" | "user"; text: string }>;
|
||||
latestUserText?: string;
|
||||
};
|
||||
conversationContext?: {
|
||||
recentConversation?: Array<{ role: "assistant" | "user"; text: string }>;
|
||||
latestEvidence?: Array<{ dateLabel: string; summary: string }>;
|
||||
@@ -140,45 +144,85 @@ function validGenerator(
|
||||
dateLabel: string;
|
||||
}>;
|
||||
};
|
||||
packet: Omit<ReturnType<typeof packet>, "candidate"> & {
|
||||
packet?: Omit<ReturnType<typeof packet>, "candidate"> & {
|
||||
candidate: ReturnType<typeof packet>["candidate"] & {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const value = request.packet;
|
||||
const hasRelationshipEvidence = request.conversationContext?.eventLedger
|
||||
?.some((item) => item.active && /关系|恋爱|分手|结婚|离婚|伴侣/.test(item.summary)) === true;
|
||||
const domains = hasRelationshipEvidence
|
||||
? ["career" as const]
|
||||
: ["relationship" as const];
|
||||
const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业";
|
||||
const latest = request.conversationContext?.latestEvidence?.at(-1);
|
||||
const fallbackPacket = packet(false);
|
||||
const value = request.packet ?? {
|
||||
...fallbackPacket,
|
||||
candidate: {
|
||||
...fallbackPacket.candidate,
|
||||
rangeStart: fallbackPacket.candidate.range.startTime,
|
||||
rangeEnd: fallbackPacket.candidate.range.endTime,
|
||||
},
|
||||
};
|
||||
const ordinaryConversation = request.phase === "intermediate" && request.conversation !== undefined;
|
||||
const latestUserText = request.conversation?.latestUserText ?? request.conversationContext?.recentConversation?.at(-1)?.text ?? "";
|
||||
const latest = request.conversationContext?.latestEvidence?.at(-1)
|
||||
?? (latestUserText ? { dateLabel: "", summary: latestUserText } : undefined);
|
||||
const latestActiveEvent = request.conversationContext?.eventLedger
|
||||
?.filter((item) => item.active)
|
||||
.at(-1);
|
||||
const unresolved = request.conversationContext?.unresolvedEvidence?.at(-1);
|
||||
const hasRelationshipEvidence = request.conversationContext?.eventLedger
|
||||
?.some((item) => item.active && /关系|恋爱|分手|结婚|离婚|伴侣/.test(item.summary)) === true;
|
||||
const domains = hasRelationshipEvidence ? ["career" as const] : ["relationship" as const];
|
||||
const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业";
|
||||
const asksForLatestDetail = continueLatestEvent
|
||||
&& request.phase === "intermediate"
|
||||
&& latestActiveEvent !== undefined;
|
||||
&& (ordinaryConversation ? latest !== undefined : latestActiveEvent !== undefined);
|
||||
const asksForMissingDate = ordinaryConversation
|
||||
&& latest !== undefined
|
||||
&& !/(?:19|20)\d{2}|\d{1,2}\s*月|(?:同年|当年|那年|来年)/u.test(latestUserText)
|
||||
&& !/^(?:是|对|没错|确认|不是|不对)/u.test(latestUserText);
|
||||
const freeNarrative = request.phase !== "final"
|
||||
&& freeNarrativeFromGeneration !== undefined
|
||||
&& generation >= freeNarrativeFromGeneration;
|
||||
|
||||
if (ordinaryConversation) {
|
||||
const question = asksForMissingDate
|
||||
? "这大约发生在哪一年、哪一月?"
|
||||
: asksForLatestDetail ? detailQuestion : null;
|
||||
const narrative = freeNarrative
|
||||
? "我明白。你可以顺着这段经历继续说,也可以自然讲下一件想到的事。"
|
||||
: [
|
||||
latest ? `你说的“${latest.summary}”,我理解了。` : "我在听。",
|
||||
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
|
||||
question ?? "",
|
||||
].join("");
|
||||
return { text: JSON.stringify({
|
||||
narrative,
|
||||
evidenceRequest: question ? {
|
||||
domains,
|
||||
datePrecision: "month_preferred",
|
||||
prompt: question,
|
||||
followUp: asksForMissingDate
|
||||
? { kind: "event_date" }
|
||||
: mislabelLatestDetailAsNewEvent
|
||||
? { kind: "new_event", evidenceId: null }
|
||||
: { kind: "event_detail" },
|
||||
} : null,
|
||||
}) };
|
||||
}
|
||||
|
||||
const narrative = freeNarrative
|
||||
? "我明白,这段经历已经记下。你可以继续按自己的节奏讲。"
|
||||
? "我明白。你可以顺着这段经历继续说,也可以自然讲下一件想到的事。"
|
||||
: [
|
||||
request.phase === "intermediate" && latest
|
||||
? `记下了:${latest.dateLabel} · ${latest.summary}。`
|
||||
: `当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
|
||||
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
|
||||
request.phase === "final"
|
||||
? "当前证据已形成候选总结。"
|
||||
: unresolved?.dateLabel === "日期待补充"
|
||||
? `我先把“${unresolved.summary}”这件事补完整:它大约发生在哪一年、哪一月?`
|
||||
: asksForLatestDetail
|
||||
? detailQuestion
|
||||
: `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
|
||||
request.phase === "intermediate" && latest
|
||||
? `你刚才说到${latest.dateLabel ? `${latest.dateLabel} · ` : ""}${latest.summary}。`
|
||||
: `当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
|
||||
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
|
||||
request.phase === "final"
|
||||
? "当前证据已形成候选总结。"
|
||||
: unresolved?.dateLabel === "日期待补充"
|
||||
? `我先把“${unresolved.summary}”这件事补完整:它大约发生在哪一年、哪一月?`
|
||||
: asksForLatestDetail
|
||||
? detailQuestion
|
||||
: `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
|
||||
].join("");
|
||||
return { text: JSON.stringify({
|
||||
narrative,
|
||||
@@ -197,15 +241,15 @@ function validGenerator(
|
||||
prompt: unresolved?.dateLabel === "日期待补充"
|
||||
? `“${unresolved.summary}”大约发生在哪一年、哪一月?`
|
||||
: asksForLatestDetail
|
||||
? detailQuestion
|
||||
: `请说一件已经发生的${nextDomain}经历,并写明哪一年、哪一月以及发生了什么。`,
|
||||
? detailQuestion
|
||||
: `请说一件已经发生的${nextDomain}经历,并写明哪一年、哪一月以及发生了什么。`,
|
||||
followUp: unresolved?.dateLabel === "日期待补充"
|
||||
? { kind: "event_date", evidenceId: unresolved.id }
|
||||
: asksForLatestDetail
|
||||
? mislabelLatestDetailAsNewEvent
|
||||
? { kind: "new_event", evidenceId: null }
|
||||
: { kind: "event_detail", evidenceId: latestActiveEvent.id }
|
||||
: { kind: "new_event", evidenceId: null },
|
||||
? mislabelLatestDetailAsNewEvent
|
||||
? { kind: "new_event", evidenceId: null }
|
||||
: { kind: "event_detail", evidenceId: latestActiveEvent?.id ?? null }
|
||||
: { kind: "new_event", evidenceId: null },
|
||||
},
|
||||
}) };
|
||||
},
|
||||
@@ -1068,7 +1112,8 @@ test("a concrete event without a date is acknowledged and a date-only follow-up
|
||||
summary: item.summary,
|
||||
dateLabel: item.dateLabel,
|
||||
})), [{ summary: "我离开家去北京开始工作", dateLabel: "2023-03" }]);
|
||||
assert.match(completed.narrative, /记下了:2023-03 · 我离开家去北京开始工作/);
|
||||
assert.match(completed.narrative, /2023年3月/);
|
||||
assert.doesNotMatch(completed.narrative, /记下了|已记录|校时价值|候选时间/);
|
||||
});
|
||||
|
||||
test("a descriptive date follow-up completes the targeted event instead of becoming a new event", async () => {
|
||||
@@ -1340,19 +1385,16 @@ test("an affirmative reply confirms the date proposed by the previous Agent turn
|
||||
assert.notEqual(completed.evidenceRequest?.followUp?.evidenceId, target.id);
|
||||
|
||||
const prompt = JSON.parse(value.narrativePrompts.at(-1) ?? "{}") as {
|
||||
conversationContext?: {
|
||||
conversation?: {
|
||||
recentConversation?: Array<{ role: string; text: string }>;
|
||||
previousEvidencePrompt?: string;
|
||||
previousFollowUp?: { answerMode?: string; proposedDate?: { value: string } };
|
||||
};
|
||||
conversationContext?: unknown;
|
||||
};
|
||||
assert.deepEqual(prompt.conversationContext?.recentConversation?.slice(-2), [
|
||||
assert.deepEqual(prompt.conversation?.recentConversation?.slice(-2), [
|
||||
{ role: "assistant", text: "你说实习到10月份然后辞职,这个10月是2020年10月吗?" },
|
||||
{ role: "user", text: "是的" },
|
||||
]);
|
||||
assert.equal(prompt.conversationContext?.previousEvidencePrompt, "这个10月是2020年10月吗?");
|
||||
assert.equal(prompt.conversationContext?.previousFollowUp?.answerMode, "yes_no");
|
||||
assert.equal(prompt.conversationContext?.previousFollowUp?.proposedDate?.value, "2020-10");
|
||||
assert.equal(prompt.conversationContext, undefined);
|
||||
|
||||
await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
@@ -1624,7 +1666,7 @@ test("a dated independent event is not swallowed by a broad detail question misl
|
||||
assert.equal(next.evidenceRecap.some((item) => item.id === firstId), true);
|
||||
});
|
||||
|
||||
test("the next evidence request moves past a domain the user already answered", async () => {
|
||||
test("a completed event does not force the Agent into the next scoring domain", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
|
||||
@@ -1636,9 +1678,8 @@ test("the next evidence request moves past a domain the user already answered",
|
||||
answer: "2020年5月结婚",
|
||||
});
|
||||
|
||||
assert.deepEqual(turn.evidenceRequest?.domains, ["career"]);
|
||||
assert.match(turn.narrative, /事业/);
|
||||
assert.doesNotMatch(turn.narrative, /下一步[^\n]*重要关系/);
|
||||
assert.equal(turn.evidenceRequest, null);
|
||||
assert.doesNotMatch(turn.narrative, /下一步|事业|重要关系|校时价值|候选时间/);
|
||||
});
|
||||
|
||||
test("a rejected intermediate narrative falls back without discarding the event", async () => {
|
||||
@@ -1652,7 +1693,7 @@ test("a rejected intermediate narrative falls back without discarding the event"
|
||||
|
||||
const stored = value.cases.get(startActionId)?.row;
|
||||
assert.equal(turn.turnVersion, 1);
|
||||
assert.match(turn.narrative, /按自己的节奏/);
|
||||
assert.match(turn.narrative, /顺着这段经历继续说|自然讲下一件/);
|
||||
assert.equal(stored?.privateCandidate.resultId, null);
|
||||
assert.equal(stored?.eventEvidence.length, 1);
|
||||
assert.equal(stored?.validationReceipts.length, 2);
|
||||
@@ -1689,7 +1730,7 @@ test("one through three supported events save and narrate before the fourth accu
|
||||
assert.equal(value.events.filter((event) => event === "narrative").length, 4);
|
||||
});
|
||||
|
||||
test("intermediate narrative receives the complete active event ledger", async () => {
|
||||
test("intermediate narrative receives conversation text without the private event ledger", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
@@ -1710,7 +1751,7 @@ test("intermediate narrative receives the complete active event ledger", async (
|
||||
const prompt = value.narrativePrompts.at(-1) ?? "";
|
||||
assert.match(prompt, /2020年9月底主动离开研究单位/);
|
||||
assert.match(prompt, /2023年4月进入下一家公司/);
|
||||
assert.match(prompt, /eventLedger/);
|
||||
assert.doesNotMatch(prompt, /eventLedger|unresolvedEvidence|expertWorkflow|candidateWeights/);
|
||||
});
|
||||
|
||||
test("a plateaued non-confirmable conversational case returns a bounded candidate after current domains are covered", async () => {
|
||||
@@ -1734,7 +1775,7 @@ test("a plateaued non-confirmable conversational case returns a bounded candidat
|
||||
});
|
||||
if (index === 2) {
|
||||
assert.equal(latest.status, "active");
|
||||
assert.deepEqual(latest.evidenceRequest?.domains, ["relationship"]);
|
||||
assert.equal(latest.evidenceRequest, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1781,7 +1822,7 @@ test("an unanswered suggested domain keeps a plateaued candidate conversational"
|
||||
}
|
||||
|
||||
assert.equal(latest?.status, "active");
|
||||
assert.deepEqual(latest?.evidenceRequest?.domains, ["finance"]);
|
||||
assert.equal(latest?.evidenceRequest, null);
|
||||
assert.deepEqual(latest?.actions, ["answer", "pause", "abandon"]);
|
||||
});
|
||||
|
||||
@@ -1815,7 +1856,8 @@ test("answer persists after pause when the packet has no grounded follow-up doma
|
||||
|
||||
assert.equal(turn.status, "active");
|
||||
assert.equal(turn.evidenceRequest, null);
|
||||
assert.match(turn.narrative, /记下了/);
|
||||
assert.match(turn.narrative, /2020年4月进入研究院实习/);
|
||||
assert.doesNotMatch(turn.narrative, /记下了|已记录|校时价值|候选时间/);
|
||||
});
|
||||
|
||||
test("system-only blockers return a bounded result without waiting for another plateau", async () => {
|
||||
@@ -1939,7 +1981,8 @@ test("vague, future, and unmatched answers stay conversational and never score",
|
||||
});
|
||||
assert.equal(value.counts().packetBuilds, 1);
|
||||
assert.equal(turn.status, "active");
|
||||
assert.match(turn.narrative, /哪一年|哪一月|年月|已发生|已经发生|换个方向|未来/);
|
||||
assert.ok(turn.narrative.length > 0);
|
||||
assert.match(turn.narrative, new RegExp(answer.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.doesNotMatch(turn.narrative, /好的,我们不沿用不符合你的方向|已保存这段描述|我已保存你的原话|这条更正已保存/);
|
||||
assert.doesNotMatch(turn.narrative, /A[.、:]|B[.、:]|2006.?2011/);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.scoreable, false);
|
||||
@@ -1947,7 +1990,11 @@ test("vague, future, and unmatched answers stay conversational and never score",
|
||||
});
|
||||
|
||||
test("a free Agent reply clears the previous structured question instead of inheriting it", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99, freeNarrativeFromGeneration: 2 });
|
||||
const value = harness({
|
||||
readyAfterEvidenceCount: 99,
|
||||
continueLatestEvent: true,
|
||||
freeNarrativeFromGeneration: 2,
|
||||
});
|
||||
await start(value, null);
|
||||
|
||||
const first = await value.service.answer(userId, {
|
||||
@@ -1962,7 +2009,7 @@ test("a free Agent reply clears the previous structured question instead of inhe
|
||||
});
|
||||
|
||||
assert.equal(second.evidenceRequest, null);
|
||||
assert.match(second.narrative, /按自己的节奏/);
|
||||
assert.match(second.narrative, /顺着这段经历继续说|自然讲下一件/);
|
||||
assert.doesNotMatch(second.narrative, /[??]/);
|
||||
});
|
||||
|
||||
@@ -2093,7 +2140,7 @@ test("a failed regenerate saves a fallback turn without changing evidence, candi
|
||||
|
||||
const storedAfter = value.cases.get(startActionId)?.row;
|
||||
assert.equal(regenerated.turnVersion, answered.turnVersion + 1);
|
||||
assert.match(regenerated.narrative, /按自己的节奏/);
|
||||
assert.match(regenerated.narrative, /顺着这段经历继续说|自然讲下一件/);
|
||||
assert.deepEqual(storedAfter?.eventEvidence, evidenceBefore);
|
||||
assert.deepEqual(storedAfter?.privateCandidate, candidateBefore);
|
||||
assert.equal(value.counts().reserveCount, countsBefore.reserveCount);
|
||||
|
||||
Reference in New Issue
Block a user