fix: open rectification narrative flow

This commit is contained in:
Jesse_Chen
2026-07-26 11:00:20 +08:00
parent 7837f6abd8
commit 5f492be1b9
8 changed files with 125 additions and 238 deletions
@@ -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>
);
+6 -8
View File
@@ -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、followUpnarrative 不得输出或讨论事件分类、领域标签,也不要重复输出候选状态、时间、分盘或引用字段。",
output: "只返回 narrative evidenceRequest。没有提出需要下一轮短答承接的明确问题时,evidenceRequest 必须为 null;若提出明确问题,可用 domains 作为不可见路由元数据,并包含 datePrecision、prompt、followUpnarrative 不得输出或讨论事件分类、领域标签,也不要重复输出候选状态、时间、分盘或引用字段。",
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,