fix: open rectification narrative flow
This commit is contained in:
@@ -1398,3 +1398,18 @@
|
||||
- 防复发:只要首轮已向用户展示生时校正状态,可见文案必须同时呈现结构化候选边界;不得只在隐藏状态中保存范围,也不得为了润色首问重新引入模型调用。
|
||||
- 相关记录:BUG-055、BUG-067、BUG-072
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-075 | 生时校正强制每轮提问并阻碍开放叙事
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-26
|
||||
- 最近更新:2026-07-26
|
||||
- 影响面:生时校正 Narrative Agent、非评分轮续接、确定性首轮、聊天消息完成态
|
||||
- 用户现象:用户叙述一段或多段人生经历后,Agent 会机械追加问题、反复继承上一轮追问,并在普通资料交流结束后显示“回答已完成”,整体表现像问卷收集器而不是能理解人生轨迹的校时助手。
|
||||
- 触发条件:非最终轮模型只做自然回应但同时返回结构化追问,或本轮没有生成新追问却沿用上一轮 `evidenceRequest`;所有 settled Assistant 消息都会渲染统一完成标签。
|
||||
- 根因:Narrative Prompt 强制每个非最终回复恰好以一个问题结束;服务端 repair 会把隐藏的 `evidenceRequest.prompt` 自动追加到正文;非评分轮会自动生成缺失信息追问并继承旧 `evidenceRequest`;通用消息组件把 settled 状态映射成“回答已完成”。
|
||||
- 修复:允许用户一次叙述一件或多件经历并自由连续表达,问题改为 Agent 可选行为;删除自动补问和已评分事件的聊天限制,普通自然回复使用 `evidenceRequest: null`;首轮只开放邀请叙述,非评分轮不再自动规划或继承问题,仅在明确的结构化纠正承接中保留必要状态;settled 消息不再渲染活动状态。事件抽取、保存、技术评分、候选收敛和分钟确认安全门保持不变。
|
||||
- 验证:Narrative Agent、Orchestrator 与聊天布局聚焦测试 101/101 通过;目标 ESLint 无错误或警告;Next.js 16 Webpack 生产构建与 `git diff --check` 通过。
|
||||
- 防复发:代码不得要求每轮必须提问,也不得把隐藏 prompt 自动拼接到 Agent 正文;没有可见短答问题时 `evidenceRequest` 应为 null,用户叙述节奏由 Agent 与用户共同决定,代码只维护记录、抽取、评分、收敛与确认边界。
|
||||
- 相关记录:BUG-070、BUG-072、BUG-074
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CircleCheck } from "lucide-react";
|
||||
import { ThinkingOrb, type OrbState } from "thinking-orbs";
|
||||
|
||||
const labels = {
|
||||
@@ -8,10 +7,9 @@ const labels = {
|
||||
listening: "正在聆听…",
|
||||
composing: "正在组织回答…",
|
||||
shaping: "正在生成结果…",
|
||||
completed: "回答已完成",
|
||||
} as const satisfies Record<OrbState | "completed", string>;
|
||||
} as const satisfies Record<OrbState, string>;
|
||||
|
||||
export type AgentActivityState = OrbState | "completed";
|
||||
export type AgentActivityState = OrbState;
|
||||
|
||||
export function AgentActivityStatus({
|
||||
state,
|
||||
@@ -21,10 +19,8 @@ export function AgentActivityStatus({
|
||||
label?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className="agent-activity-status" role={state === "completed" ? undefined : "status"}>
|
||||
{state === "completed"
|
||||
? <CircleCheck aria-hidden="true" size={20} strokeWidth={1.8} />
|
||||
: <ThinkingOrb aria-hidden="true" state={state} size={20} />}
|
||||
<div className="agent-activity-status" role="status">
|
||||
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,14 +51,12 @@ export function ChatMessageRow({ message }: { readonly message: ChatMessageView
|
||||
<div className="message-bubble">
|
||||
{message.role === "assistant" ? (
|
||||
<>
|
||||
<AgentActivityStatus
|
||||
state={message.state === "thinking"
|
||||
? "working"
|
||||
: message.state === "streaming"
|
||||
? "composing"
|
||||
: "completed"}
|
||||
label={message.state === "thinking" ? "正在核对星盘信息…" : undefined}
|
||||
/>
|
||||
{message.state !== "settled" && (
|
||||
<AgentActivityStatus
|
||||
state={message.state === "thinking" ? "working" : "composing"}
|
||||
label={message.state === "thinking" ? "正在核对星盘信息…" : undefined}
|
||||
/>
|
||||
)}
|
||||
{message.text && <ChatMessageContent text={message.text} />}
|
||||
</>
|
||||
) : <p>{message.text}</p>}
|
||||
|
||||
@@ -65,7 +65,6 @@ 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 nonScoringDetailPattern = /为什么|原因|主动|被动|自愿|被迫|影响|感受|具体(?:体现|情况|经过)|哪些方面|正式工作|实习|兼职/u;
|
||||
const domainLabels = {
|
||||
career: "事业",
|
||||
education: "学业",
|
||||
@@ -301,17 +300,6 @@ export function validateNarrativeAgainstPacket(
|
||||
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
|
||||
}
|
||||
const followUp = output.evidenceRequest.followUp;
|
||||
if (followUp?.kind === "event_detail"
|
||||
&& packet.scoredHistoricalEvidence.some((item) => item.evidenceId === followUp.evidenceId)) {
|
||||
issues.push("event detail follow-up targets already scored evidence");
|
||||
}
|
||||
const latestActiveEvidenceId = context.eventLedger?.filter((item) => item.active).at(-1)?.id;
|
||||
if (followUp?.kind === "new_event"
|
||||
&& latestActiveEvidenceId
|
||||
&& nonScoringDetailPattern.test(output.evidenceRequest.prompt)
|
||||
&& packet.scoredHistoricalEvidence.some((item) => item.evidenceId === latestActiveEvidenceId)) {
|
||||
issues.push("new-event follow-up disguises detail about already scored evidence");
|
||||
}
|
||||
if (proposedDateQuestionPattern.test(output.evidenceRequest.prompt)
|
||||
&& (followUp?.kind !== "event_date"
|
||||
|| followUp.answerMode !== "yes_no"
|
||||
@@ -454,45 +442,6 @@ function boundedReceiptIssues(issues: readonly string[]): string[] {
|
||||
.map((issue) => issue.trim().slice(0, 240) || "narrative_mismatch");
|
||||
}
|
||||
|
||||
function ensureSentence(value: string, sentence: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? `${trimmed}\n${sentence}` : sentence;
|
||||
}
|
||||
|
||||
function endsWithIncompletePrompt(value: string): boolean {
|
||||
return /(?:[-—–::,,、]|\.\.\.|…|我需要(?:确认|了解|知道)|关键信息)\s*$/.test(value.trim());
|
||||
}
|
||||
|
||||
function hasExplicitQuestion(value: string): boolean {
|
||||
return /[??]/.test(value);
|
||||
}
|
||||
|
||||
function repairRequiredSafetyLanguage(
|
||||
output: RectificationNarrativeModelOutput,
|
||||
phase: RectificationNarrativePhase,
|
||||
): RectificationNarrativeModelOutput {
|
||||
let narrative = output.narrative;
|
||||
const evidenceRequest = output.evidenceRequest;
|
||||
const modelEvidencePrompt = output.evidenceRequest?.prompt.trim() ?? "";
|
||||
|
||||
// Every collecting turn must end with one visible, model-authored question.
|
||||
// evidenceRequest.prompt is generated in the same model call, so appending it
|
||||
// repairs truncated or analysis-only prose without introducing a business
|
||||
// template or changing the Agent's chosen conversational direction.
|
||||
if (phase !== "final" && evidenceRequest && (
|
||||
endsWithIncompletePrompt(narrative)
|
||||
|| !hasExplicitQuestion(narrative)
|
||||
)) {
|
||||
narrative = ensureSentence(narrative, modelEvidencePrompt || evidenceRequest.prompt);
|
||||
}
|
||||
|
||||
return {
|
||||
...output,
|
||||
narrative,
|
||||
evidenceRequest,
|
||||
};
|
||||
}
|
||||
|
||||
type NarrativeDiagnosticIssueCode =
|
||||
| "candidate_status_mismatch"
|
||||
| "representative_time_mismatch"
|
||||
@@ -560,11 +509,11 @@ function promptFor(
|
||||
): string {
|
||||
if (phase === "first") {
|
||||
return JSON.stringify({
|
||||
task: "用自然、有人味的中文开启生时校正;简短回应当前范围,再只问一个最有信息量的问题。不要使用固定模板。",
|
||||
task: "用自然、有人味的中文开启生时校正。用户可以按自己的节奏自由叙述,一次说一件或多件经历;自然回应即可,不必每轮提问,也不要使用固定模板。",
|
||||
phase,
|
||||
conversationContext: narrativeConversationContext(context),
|
||||
packet: grounding(packet, phase),
|
||||
output: "只返回 narrative,以及 evidenceRequest。evidenceRequest 可用 domains 作为不可见路由元数据,并包含 datePrecision、prompt、followUp;narrative 不得输出或讨论事件分类、领域标签,也不要重复输出候选状态、时间、分盘或引用字段。",
|
||||
output: "只返回 narrative 和 evidenceRequest。没有提出需要下一轮短答承接的明确问题时,evidenceRequest 必须为 null;若提出明确问题,可用 domains 作为不可见路由元数据,并包含 datePrecision、prompt、followUp。narrative 不得输出或讨论事件分类、领域标签,也不要重复输出候选状态、时间、分盘或引用字段。",
|
||||
safety: "技术事实只能来自 packet;不能确认未经验证的分钟;不得展示内部权重、分数、事件分类或内部路由元数据。",
|
||||
retryIssues: boundedReceiptIssues(retryIssues),
|
||||
});
|
||||
@@ -592,21 +541,21 @@ function promptFor(
|
||||
internalEventDomainsAndRoutingMustNeverBeShown: true,
|
||||
},
|
||||
conversationGuidance: {
|
||||
preferOneHighInformationQuestion: phase !== "final",
|
||||
everyNonFinalReplyMustEndWithExactlyOneVisibleQuestion: phase !== "final",
|
||||
respondToLatestEvidenceBeforeAsking: phase === "intermediate",
|
||||
freeConversation: phase !== "final",
|
||||
questionsAreOptional: phase !== "final",
|
||||
userControlsNarrativePace: phase !== "final",
|
||||
acceptMultipleEventsInOneMessage: phase !== "final",
|
||||
acknowledgeAndReflectBeforeAnyClarification: phase === "intermediate",
|
||||
doNotTurnEveryMessageIntoAQuestionnaire: phase !== "final",
|
||||
askOnlyWhenAClarificationWouldMateriallyHelpTheConversation: phase !== "final",
|
||||
neverMentionHowTheEventWasClassifiedOrLabeledInternally: phase === "intermediate",
|
||||
doNotVolunteerNotEvaluatedOrBlockedTechniqueInventory: phase === "intermediate",
|
||||
doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked: phase === "intermediate",
|
||||
continueCurrentEventWhenItRemainsInformative: phase === "intermediate",
|
||||
resolveDateContradictionsBeforeScoring: phase === "intermediate",
|
||||
mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate",
|
||||
onlyAskForDateEventIdentityOrInformationThatCanChangeTheScoringDomain: phase === "intermediate",
|
||||
doNotAskWhyWhetherVoluntaryOrWhatImpactForAlreadyScoreableEvidence: phase === "intermediate",
|
||||
useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate",
|
||||
askForDatesOnlyWhenNeededToIdentifyOrScoreTheEvent: phase !== "final",
|
||||
persistFollowUpState: phase !== "final"
|
||||
? "Persist the exact question intent. Use event_detail or event_date with an existing evidenceId. For a yes/no date proposal, set answerMode=yes_no and proposedDate={value,precision}; otherwise use answerMode=free_text and no proposedDate. Use new_event with null evidenceId only for a genuinely new event."
|
||||
optionalFollowUpState: phase !== "final"
|
||||
? "Only when narrative contains a clear question that expects a short next-turn answer, persist its exact intent. Use event_detail or event_date with an existing evidenceId. For a yes/no date proposal, set answerMode=yes_no and proposedDate={value,precision}; otherwise use answerMode=free_text and no proposedDate. Use new_event with null evidenceId only for a genuinely new event. Otherwise evidenceRequest must be null."
|
||||
: false,
|
||||
boundedResultBoundary: phase === "final" && packet.candidate.status === "pending_validation"
|
||||
? "当前只支持候选范围,系统验证尚未闭环。本次不会替换当前排盘时间,也不再要求用户继续提供人生事件;evidenceRequest 必须为 null。"
|
||||
@@ -619,13 +568,11 @@ function promptFor(
|
||||
|
||||
function fallbackNarrative(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase): string {
|
||||
const candidate = packet.candidate;
|
||||
const nextDomain = packet.suggestedDomains[0]?.domain;
|
||||
const nextLabel = nextDomain ? domainLabels[nextDomain] : "重要经历";
|
||||
const phaseLine = phase === "final" && candidate.status === "ready_for_confirmation"
|
||||
? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。"
|
||||
: phase === "final"
|
||||
? "当前证据只能支持候选范围,系统验证尚未闭环;本次不会替换当前排盘时间,也不再强制追问更多人生事件。"
|
||||
: `先说一件已经发生的${nextLabel}事件好吗?尽量写明哪一年、哪一月以及发生了什么。`;
|
||||
: `我收到了这段叙述。你可以继续讲这段经历,也可以按自己的节奏说下一件想到的事。`;
|
||||
return [
|
||||
`当前仍在核对 ${candidate.range.startTime}–${candidate.range.endTime} 的候选范围,还不能把其中某一分钟当作确定出生时间。`,
|
||||
phaseLine,
|
||||
@@ -647,12 +594,7 @@ function fallbackOutput(
|
||||
sensitiveLayers: packet.sensitiveLayers.map((item) => item.layer),
|
||||
referenceIds: [],
|
||||
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
|
||||
evidenceRequest: phase === "final" ? null : {
|
||||
domains: packet.suggestedDomains.slice(0, 4).map((item) => item.domain),
|
||||
datePrecision: "month_preferred",
|
||||
prompt: "请提供已经发生的真实事件,并尽量写明哪一年、哪一月以及发生了什么。",
|
||||
followUp: { kind: "new_event", evidenceId: null },
|
||||
},
|
||||
evidenceRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -798,10 +740,7 @@ export async function generateRectificationNarrative(input: {
|
||||
issues,
|
||||
), { signal, attempt });
|
||||
const modelId = modelIdSchema.parse(generated.modelId ?? defaultModelId);
|
||||
const output = repairRequiredSafetyLanguage(
|
||||
parseModelOutput(generated.text, input.packet),
|
||||
input.phase,
|
||||
);
|
||||
const output = parseModelOutput(generated.text, input.packet);
|
||||
const validation = validateNarrativeAgainstPacket(
|
||||
output,
|
||||
input.packet,
|
||||
|
||||
@@ -603,7 +603,7 @@ function openingRectificationState(input: {
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status: "active",
|
||||
turnVersion: 0,
|
||||
narrative: `根据你填写的出生时间信息,当前先核对 ${range.startTime}–${range.endTime}。这只是待核对范围,还不能把其中某一分钟当作已确认出生时间。请先说一件时间最明确、影响比较大的真实经历,并尽量告诉我发生的年月。`,
|
||||
narrative: `根据你填写的出生时间信息,当前先核对 ${range.startTime}–${range.endTime}。这只是待核对范围,还不能把其中某一分钟当作已确认出生时间。你可以按自己的节奏讲已经发生的人生经历,一次说一件或连续说多件都可以;记得的年月可以自然地带上,不确定也没关系。`,
|
||||
candidate: {
|
||||
status: "pending_validation",
|
||||
representativeTime,
|
||||
@@ -616,13 +616,7 @@ function openingRectificationState(input: {
|
||||
sensitiveLayers: [],
|
||||
candidateDifferenceRefs: [],
|
||||
},
|
||||
evidenceRequest: {
|
||||
domains: ["career", "education", "relocation", "relationship"],
|
||||
datePrecision: "month_preferred",
|
||||
freeTextAllowed: true,
|
||||
prompt: "请说一件已经发生、时间比较明确的重要经历,并告诉我大约是哪一年、哪一月?",
|
||||
followUp: { kind: "new_event", evidenceId: null },
|
||||
},
|
||||
evidenceRequest: null,
|
||||
evidenceRecap: [],
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
pendingConsultationQuestion: input.pendingConsultationQuestion,
|
||||
@@ -659,45 +653,30 @@ function nonScoringTurn(input: {
|
||||
}>;
|
||||
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
|
||||
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
|
||||
const latestIncomplete = input.newEvidence
|
||||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||||
.at(-1);
|
||||
const authoredNarrative = input.authoredNarrative;
|
||||
const latestSummary = input.newEvidence.at(-1)?.eventSummary;
|
||||
const fallbackSubject = latestSummary && latestSummary !== "事件内容待补充"
|
||||
? latestSummary
|
||||
: input.latestUserText.trim().slice(0, 80);
|
||||
const narrative = authoredNarrative?.narrative
|
||||
?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,但这次分析暂时没有完成。内容会保留,你可以继续补充它的时间和经过,或直接说下一件已经发生的经历。`;
|
||||
?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,内容已经保留。你可以继续讲这段经历,也可以按自己的节奏说下一件想到的事。`;
|
||||
const status = input.correctionReset
|
||||
? "active" as const
|
||||
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
|
||||
const actions = actionsFor(status);
|
||||
const clarificationFollowUp = latestIncomplete?.dateValue === null
|
||||
&& latestIncomplete.eventSummary !== "事件内容待补充"
|
||||
? { kind: "event_date" as const, evidenceId: latestIncomplete.id }
|
||||
: latestIncomplete?.dateValue
|
||||
&& latestIncomplete.eventSummary === "事件内容待补充"
|
||||
? { kind: "event_detail" as const, evidenceId: latestIncomplete.id }
|
||||
const authoredRequest = authoredNarrative?.output.evidenceRequest;
|
||||
const priorRequest = input.current.latestTurn.evidenceRequest;
|
||||
const evidenceRequest = authoredRequest
|
||||
? {
|
||||
domains: authoredRequest.domains,
|
||||
datePrecision: authoredRequest.datePrecision,
|
||||
freeTextAllowed: true as const,
|
||||
prompt: authoredRequest.prompt,
|
||||
followUp: input.followUpOverride ?? authoredRequest.followUp,
|
||||
}
|
||||
: input.followUpOverride && priorRequest
|
||||
? { ...priorRequest, followUp: input.followUpOverride }
|
||||
: null;
|
||||
const authoredRequest = authoredNarrative?.output.evidenceRequest;
|
||||
const priorRequest = input.current.latestTurn.evidenceRequest;
|
||||
const evidenceRequest = status === "confirming" && priorRequest === null
|
||||
? null
|
||||
: authoredRequest
|
||||
? {
|
||||
domains: authoredRequest.domains,
|
||||
datePrecision: authoredRequest.datePrecision,
|
||||
freeTextAllowed: true as const,
|
||||
prompt: authoredRequest.prompt,
|
||||
followUp: input.followUpOverride ?? authoredRequest.followUp,
|
||||
}
|
||||
: priorRequest
|
||||
? {
|
||||
...priorRequest,
|
||||
followUp: input.followUpOverride ?? clarificationFollowUp ?? priorRequest.followUp,
|
||||
}
|
||||
: null;
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse({
|
||||
...input.current.latestTurn,
|
||||
status,
|
||||
|
||||
@@ -47,12 +47,12 @@ test("does not duplicate a completed assistant answer while loading state settle
|
||||
test("shows honest agent activity states before and during streamed text", () => {
|
||||
assert.match(messageRowSource, /message\.state === "thinking"[\s\S]*?\? "working"/);
|
||||
assert.match(messageRowSource, /message\.state === "thinking" \? "正在核对星盘信息…"/);
|
||||
assert.match(messageRowSource, /message\.state === "streaming"[\s\S]*?"composing"/);
|
||||
assert.match(messageRowSource, /: "completed"/);
|
||||
assert.match(messageRowSource, /message\.state !== "settled"/);
|
||||
assert.match(messageRowSource, /message\.state === "thinking" \? "working" : "composing"/);
|
||||
assert.match(messageRowSource, /message\.text && <ChatMessageContent text=\{message\.text\}/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
assert.match(activitySource, /<CircleCheck aria-hidden="true" size=\{20\}/);
|
||||
assert.match(activitySource, /satisfies Record<OrbState \| "completed", string>/);
|
||||
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成|completed/);
|
||||
assert.doesNotMatch(messageRowSource, /: "completed"/);
|
||||
assert.doesNotMatch(globalStyles, /\.thinking\b/);
|
||||
});
|
||||
|
||||
|
||||
@@ -144,23 +144,18 @@ test("preserves a complete model-authored first-turn narrative verbatim", async
|
||||
assert.equal(result.output.evidenceRequest?.prompt, "那段关系大约是什么时候发生的?");
|
||||
});
|
||||
|
||||
test("completes a first-turn introduction with the model-authored question", async () => {
|
||||
test("allows a first-turn acknowledgement without forcing a question", async () => {
|
||||
const narrative = "我们先把你记得的出生范围当作起点。你可以按自己的节奏往下讲。";
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "first",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
generator: generator([{
|
||||
narrative: "我们先把你记得的出生范围当作起点,再用真实经历逐步核对。",
|
||||
evidenceRequest: {
|
||||
domains: ["relationship"],
|
||||
datePrecision: "month_preferred",
|
||||
prompt: "先说一件时间比较明确的关系转折,好吗?",
|
||||
},
|
||||
}]),
|
||||
generator: generator([{ narrative, evidenceRequest: null }]),
|
||||
});
|
||||
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /真实经历逐步核对。[\s\S]*关系转折,好吗?/);
|
||||
assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1);
|
||||
assert.equal(result.narrative, narrative);
|
||||
assert.equal(result.output.evidenceRequest, null);
|
||||
assert.doesNotMatch(result.narrative, /[??]/);
|
||||
});
|
||||
|
||||
test("repairs a natural intermediate follow-up prompt without discarding the specific reply", async () => {
|
||||
@@ -226,73 +221,18 @@ test("preserves the target event in an intermediate detail follow-up", async ()
|
||||
});
|
||||
});
|
||||
|
||||
test("appends the model-authored question when an intermediate reply only acknowledges the event", async () => {
|
||||
const output = {
|
||||
...richOutput(),
|
||||
narrative: "已记录本科毕业后衔接读研,这是一段连续教育转折,暂时不重复计数。",
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "毕业后的第一份工作是什么时候开始的?",
|
||||
},
|
||||
};
|
||||
|
||||
test("keeps a natural intermediate acknowledgement without appending a question", async () => {
|
||||
const narrative = "已记录本科毕业后衔接读研,这是一段连续教育转折,暂时不重复计数。你可以继续讲。";
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
generator: generator([output]),
|
||||
generator: generator([{ narrative, evidenceRequest: null }]),
|
||||
});
|
||||
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /连续教育转折[\s\S]*第一份工作是什么时候开始的?/);
|
||||
assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("repairs a narrative that stops at a dangling question introduction", async () => {
|
||||
const output = {
|
||||
...richOutput(),
|
||||
narrative: [
|
||||
"谢谢你补充了当时的压力感受,这让这段经历更完整。",
|
||||
"为了判断这段经历的具体节奏,我需要确认一个关键信息——",
|
||||
].join("\n\n"),
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "你感到压力最强的时候,大约是哪一年、哪一个月?",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
generator: generator([output]),
|
||||
});
|
||||
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /关键信息——[\s\S]*压力最强的时候[\s\S]*哪一年、哪一个月/);
|
||||
assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("turns a complete intermediate acknowledgement into a visible one-question turn", async () => {
|
||||
const output = {
|
||||
...richOutput(),
|
||||
narrative: "这次入职会作为一条事业事件记录,但还需要知道后续是否发生过离职、转岗或升职。",
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "这份工作后来第一次发生明确变化是在什么时候?",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
generator: generator([output]),
|
||||
});
|
||||
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /离职、转岗或升职。[\s\S]*第一次发生明确变化是在什么时候?/);
|
||||
assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1);
|
||||
assert.equal(result.narrative, narrative);
|
||||
assert.equal(result.output.evidenceRequest, null);
|
||||
assert.doesNotMatch(result.narrative, /[??]/);
|
||||
});
|
||||
|
||||
test("hides an uninvoked technique inventory during evidence collection", async () => {
|
||||
@@ -476,7 +416,8 @@ test("passes the user's latest concrete event to an intermediate skill-guided re
|
||||
});
|
||||
|
||||
assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/);
|
||||
assert.match(prompts[0] ?? "", /respondToLatestEvidenceBeforeAsking/);
|
||||
assert.match(prompts[0] ?? "", /acknowledgeAndReflectBeforeAnyClarification/);
|
||||
assert.match(prompts[0] ?? "", /questionsAreOptional/);
|
||||
assert.match(prompts[0] ?? "", /doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked/);
|
||||
});
|
||||
|
||||
@@ -555,7 +496,9 @@ test("passes the active event ledger and unresolved facts to the intermediate ag
|
||||
assert.match(prompt, /23年关系结束后发生过一次交通事故/);
|
||||
assert.match(prompt, /2024-08-08/);
|
||||
assert.match(prompt, /一段重要关系结束/);
|
||||
assert.match(prompt, /continueCurrentEventWhenItRemainsInformative/);
|
||||
assert.match(prompt, /freeConversation/);
|
||||
assert.match(prompt, /questionsAreOptional/);
|
||||
assert.match(prompt, /acceptMultipleEventsInOneMessage/);
|
||||
assert.match(prompt, /resolveDateContradictionsBeforeScoring/);
|
||||
assert.match(prompt, /mergeSameEventDetailsWithoutDoubleCounting/);
|
||||
});
|
||||
@@ -868,7 +811,7 @@ test("rejects a follow-up that still targets evidence completed by an affirmativ
|
||||
assert.ok(result.issues.includes("resolved follow-up still targets completed evidence"));
|
||||
});
|
||||
|
||||
test("rejects non-scoring detail questions for evidence already used by the scorer", () => {
|
||||
test("allows natural discussion of an event after it has contributed to scoring", () => {
|
||||
const evidenceId = "11111111-1111-4111-8111-111111111111";
|
||||
const packet: RectificationTechnicalPacket = {
|
||||
...syntheticTechnicalPacket(),
|
||||
@@ -882,7 +825,7 @@ test("rejects non-scoring detail questions for evidence already used by the scor
|
||||
};
|
||||
const output = richOutput();
|
||||
assert.ok(output.evidenceRequest);
|
||||
const invalid = {
|
||||
const conversational = {
|
||||
...output,
|
||||
evidenceRequest: {
|
||||
...output.evidenceRequest,
|
||||
@@ -896,10 +839,10 @@ test("rejects non-scoring detail questions for evidence already used by the scor
|
||||
},
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
|
||||
const result = validateNarrativeAgainstPacket(invalid, packet, "intermediate");
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.issues.includes("event detail follow-up targets already scored evidence"));
|
||||
assert.deepEqual(validateNarrativeAgainstPacket(conversational, packet, "intermediate"), {
|
||||
valid: true,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("retries a grounded validation failure once with a compact packet", async () => {
|
||||
@@ -1042,7 +985,7 @@ test("retries when a final narrative asks for more evidence", async () => {
|
||||
assert.equal(result.output.evidenceRequest, null);
|
||||
});
|
||||
|
||||
test("retries a scored-event detail question mislabeled as new_event", async () => {
|
||||
test("does not force a retry when the Agent naturally discusses a scored event", async () => {
|
||||
const evidenceId = "00000000-0000-4000-8000-000000000709";
|
||||
const packet = {
|
||||
...syntheticTechnicalPacket(),
|
||||
@@ -1054,23 +997,10 @@ test("retries a scored-event detail question mislabeled as new_event", async ()
|
||||
ruleRefs: ["synthetic-education-rule"],
|
||||
}],
|
||||
};
|
||||
const invalid = {
|
||||
const output = {
|
||||
...richOutput(),
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?",
|
||||
followUp: { kind: "new_event" as const, evidenceId: null },
|
||||
},
|
||||
};
|
||||
const valid = {
|
||||
...richOutput(),
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "请再说一件已经发生的事业变化,并写明哪一年、哪一月。",
|
||||
followUp: { kind: "new_event" as const, evidenceId: null },
|
||||
},
|
||||
narrative: "我理解,这几个月的学业压力不只是结果,也影响了你当时的选择。",
|
||||
evidenceRequest: null,
|
||||
};
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
@@ -1087,12 +1017,13 @@ test("retries a scored-event detail question mislabeled as new_event", async ()
|
||||
correctsEvidenceIds: [],
|
||||
}],
|
||||
},
|
||||
generator: generator([invalid, valid]),
|
||||
generator: generator([output]),
|
||||
});
|
||||
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.equal(result.output.evidenceRequest?.prompt, valid.evidenceRequest.prompt);
|
||||
assert.equal(result.output.evidenceRequest, null);
|
||||
assert.equal(result.narrative, output.narrative);
|
||||
});
|
||||
|
||||
test("records first-turn generation timeouts separately from schema failures", async () => {
|
||||
|
||||
@@ -106,6 +106,7 @@ function validGenerator(
|
||||
detailQuestion = "这份工作是正式工作,还是实习或兼职?",
|
||||
mislabelLatestDetailAsNewEvent = false,
|
||||
classifyEvidenceDomain?: (text: string) => "career" | "education" | "finance" | "health_pressure" | "relocation" | "relationship" | "family" | "other" | null,
|
||||
freeNarrativeFromGeneration?: number,
|
||||
) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -161,7 +162,12 @@ function validGenerator(
|
||||
const asksForLatestDetail = continueLatestEvent
|
||||
&& request.phase === "intermediate"
|
||||
&& latestActiveEvent !== undefined;
|
||||
const narrative = [
|
||||
const freeNarrative = request.phase !== "final"
|
||||
&& freeNarrativeFromGeneration !== undefined
|
||||
&& generation >= freeNarrativeFromGeneration;
|
||||
const narrative = freeNarrative
|
||||
? "我明白,这段经历已经记下。你可以继续按自己的节奏讲。"
|
||||
: [
|
||||
request.phase === "intermediate" && latest
|
||||
? `记下了:${latest.dateLabel} · ${latest.summary}。`
|
||||
: `当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
|
||||
@@ -173,7 +179,7 @@ function validGenerator(
|
||||
: asksForLatestDetail
|
||||
? detailQuestion
|
||||
: `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
|
||||
].join("");
|
||||
].join("");
|
||||
return { text: JSON.stringify({
|
||||
narrative,
|
||||
candidateStatus: value.candidate.status,
|
||||
@@ -185,7 +191,7 @@ function validGenerator(
|
||||
sensitiveLayers: value.sensitiveLayers.map((item) => typeof item === "string" ? item : item.layer),
|
||||
referenceIds: [],
|
||||
domainReasons: [],
|
||||
evidenceRequest: request.phase === "final" ? null : {
|
||||
evidenceRequest: request.phase === "final" || freeNarrative ? null : {
|
||||
domains,
|
||||
datePrecision: "month_preferred",
|
||||
prompt: unresolved?.dateLabel === "日期待补充"
|
||||
@@ -224,6 +230,7 @@ function harness(options: {
|
||||
readonly detailQuestion?: string;
|
||||
readonly mislabelLatestDetailAsNewEvent?: boolean;
|
||||
readonly classifyEvidenceDomain?: (text: string) => "career" | "education" | "finance" | "health_pressure" | "relocation" | "relationship" | "family" | "other" | null;
|
||||
readonly freeNarrativeFromGeneration?: number;
|
||||
} = {}) {
|
||||
const events: string[] = [];
|
||||
const narrativePrompts: string[] = [];
|
||||
@@ -465,6 +472,7 @@ function harness(options: {
|
||||
options.detailQuestion,
|
||||
options.mislabelLatestDetailAsNewEvent,
|
||||
options.classifyEvidenceDomain,
|
||||
options.freeNarrativeFromGeneration,
|
||||
),
|
||||
asOfDate: () => "2026-07-21",
|
||||
};
|
||||
@@ -525,14 +533,15 @@ test("start creates a deterministic opening without scanning or narrative genera
|
||||
assert.equal(turn.candidate.rangeEnd, "05:50");
|
||||
assert.match(turn.narrative, /当前先核对 04:50–05:50/);
|
||||
assert.match(turn.narrative, /还不能把其中某一分钟当作已确认出生时间/);
|
||||
assert.match(turn.narrative, /请先说一件/);
|
||||
assert.match(turn.narrative, /按自己的节奏/);
|
||||
assert.match(turn.narrative, /一次说一件或连续说多件/);
|
||||
assert.deepEqual(turn.technicalReceipt, {
|
||||
calculationVersion: "rectification-opening-v1",
|
||||
stableLayers: [],
|
||||
sensitiveLayers: [],
|
||||
candidateDifferenceRefs: [],
|
||||
});
|
||||
assert.equal(turn.evidenceRequest?.followUp?.kind, "new_event");
|
||||
assert.equal(turn.evidenceRequest, null);
|
||||
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
|
||||
assert.equal(value.cases.get(startActionId)?.row.revisionOfCaseId, priorCaseId);
|
||||
assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58");
|
||||
@@ -1904,6 +1913,26 @@ 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 });
|
||||
await start(value, null);
|
||||
|
||||
const first = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2020年4月进入研究院实习",
|
||||
});
|
||||
assert.ok(first.evidenceRequest);
|
||||
|
||||
const second = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1, answer: "2020年10月主动辞职,之后准备考研",
|
||||
});
|
||||
|
||||
assert.equal(second.evidenceRequest, null);
|
||||
assert.match(second.narrative, /按自己的节奏/);
|
||||
assert.doesNotMatch(second.narrative, /[??]/);
|
||||
});
|
||||
|
||||
test("a non-scoring packet failure responds to the current turn instead of replaying the prior agent message", async () => {
|
||||
const value = harness({
|
||||
packetFailure: new Error("synthetic packet outage"),
|
||||
@@ -1917,8 +1946,8 @@ test("a non-scoring packet failure responds to the current turn instead of repla
|
||||
});
|
||||
|
||||
assert.notEqual(turn.narrative, initial.narrative);
|
||||
assert.match(turn.narrative, /化学专业|这轮|这次分析/);
|
||||
assert.match(turn.narrative, /暂时没有完成/);
|
||||
assert.match(turn.narrative, /化学专业|这轮|内容已经保留/);
|
||||
assert.match(turn.narrative, /按自己的节奏/);
|
||||
assert.equal(value.cases.get(startActionId)?.row.validationReceipts.at(-1)?.fallbackUsed, true);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.rawText, "化学专业");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user