fix(rectification): separate question slots from model prose

This commit is contained in:
Jesse_Chen
2026-08-31 05:50:28 +08:00
parent 86ba17ee18
commit 6cbf1f22e2
14 changed files with 268 additions and 984 deletions
+24
View File
@@ -2942,6 +2942,30 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
}
.rectification-question-slot {
display: grid;
gap: var(--space-2);
margin-block: var(--space-3);
}
.rectification-question-slot__spoken {
display: grid;
gap: var(--space-1);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
}
.rectification-question-slot__prompt {
margin: 0;
color: var(--color-ink);
font-weight: 600;
}
.rectification-question-slot__hint,
.rectification-question-slot__status {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-snapshot {
display: grid;
gap: var(--space-3);
@@ -55,7 +55,7 @@ import {
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import {
isPersistedFocusId,
parseRectificationChoiceCard,
@@ -95,6 +95,23 @@ type PersistedTurn = Readonly<{
}>;
type CandidateResult = RectificationCandidateResult | null;
type CurrentQuestionModel = Readonly<{
kind: "choice" | "collect_spoken";
prompt: string | null;
}>;
function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const question = value as { kind?: unknown; prompt?: unknown };
if (question.kind !== "choice" && question.kind !== "collect_spoken") return null;
return {
kind: question.kind,
prompt: typeof question.prompt === "string" && question.prompt.trim()
? question.prompt.trim()
: null,
};
}
function RectificationCandidateCards({
result,
@@ -177,10 +194,6 @@ type RenderMessage = ChatMessageView & {
choiceAttachment?: SettledChoiceAttachment;
};
function turnOfferedSelection(message: RenderMessage): boolean {
return Boolean(message.completedReceipt?.steps.includes("rectification-offer-candidates"));
}
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
@@ -232,11 +245,10 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
const raw = failed ? "" : turn.text ?? "";
if (failed && !raw) return [];
if (isIncompleteRunBanner(raw)) return [];
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
const completedReceipt = completedReceiptFromPersisted(turn.receipt);
return [{
role: "assistant",
text: split.spoken,
text: raw,
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt,
@@ -286,6 +298,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -309,6 +324,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
const questionHintId = useId();
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
@@ -387,16 +403,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
latest_result?: unknown;
current_question?: unknown;
choice_card?: unknown;
case?: { accepted_time?: unknown; confirmed_time?: unknown };
case?: {
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
};
} | null) => {
if (!payload) return;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
const nextCaseStatus = isRectificationCaseStatus(payload.case?.status)
? payload.case.status
: null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
setCaseSnapshotLoaded(true);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1025,19 +1053,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? [message.choiceAttachment.card.question_id]
: []),
);
const liveChoiceHost = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text)
&& !message.choiceAttachment
));
const showLiveChoiceCard = Boolean(
choiceCard
&& liveChoiceHost
currentQuestion?.kind === "choice"
&& choiceCard
&& !answeredQuestionIds.has(choiceCard.question_id)
&& !turnOfferedSelection(liveChoiceHost)
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
@@ -1058,9 +1077,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
? latestSettledAssistant.renderKey
: undefined;
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const showMissingQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion === null,
);
const showUnavailableQuestion = Boolean(
caseSnapshotLoaded
&& resumableCase
&& !readonly
&& !busy
&& currentQuestion !== null
&& ((currentQuestion.kind === "choice" && !choiceCard)
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
);
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
@@ -1125,10 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
: message;
const settledChoice = message.choiceAttachment;
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
? choiceCard
: null;
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
const visibleChoiceCard = settledChoice?.card;
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
@@ -1173,7 +1206,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}`
: `${visibleChoiceCard.question_id}:${choiceNonce}`}
card={visibleChoiceCard}
pending={Boolean(liveChoice) && busy}
pending={false}
disabled={readonly || Boolean(settledChoice)}
selectedKey={settledChoice?.selectedKey ?? ""}
onSelect={submitChoice}
@@ -1183,6 +1216,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{collectSpokenPrompt && (
<div className="rectification-question-slot__spoken">
<p className="rectification-question-slot__prompt">{collectSpokenPrompt}</p>
<p id={questionHintId} className="rectification-question-slot__hint" role="note">
</p>
</div>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
{showUnavailableQuestion && (
<p className="rectification-question-slot__status" role="status">
</p>
)}
</section>
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -1221,13 +1285,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
aria-describedby={collectSpokenPrompt ? questionHintId : undefined}
value={draft}
disabled={!canSend}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: "继续说你记得的人生经历,或回答刚才的问题…"}
: collectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -233,7 +233,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
let collectSpokenEmitted = false;
const collectSpokenEmitted = false;
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
@@ -48,12 +48,7 @@ import {
spokenFollowupForUser,
} from "./method-followup";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { isSafeCollectSpokenPrompt } from "./spoken-answer";
import {
collectSpokenPromptForNewFocus,
composeCollectSpokenAssistantText,
projectCurrentQuestion,
} from "./turn-decision";
import { projectCurrentQuestion } from "./turn-decision";
export type ApplyChoiceCommand = Readonly<{
userId: string;
@@ -598,55 +593,10 @@ async function persistExhaustionCollect(input: {
return {
persisted,
choiceReady: false,
hostNarration: spoken
? composeCollectSpokenAssistantText(range, spoken).composed
: range,
hostNarration: range,
};
}
export async function persistCollectSpokenAssistantIfNew(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
answerText: string;
previousFocusId?: string | null;
alreadyEmitted?: boolean;
hostNarration?: string | null;
}): Promise<string | null> {
if (input.alreadyEmitted) return null;
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const fromFocus = collectSpokenPromptForNewFocus({
previousFocusId: input.previousFocusId ?? null,
focus: dossier.conversationSummary.activeFocus,
});
if (!fromFocus) return null;
const host = input.hostNarration?.trim() ?? "";
const prompt = host && isSafeCollectSpokenPrompt(host) ? host : fromFocus;
if (!isSafeCollectSpokenPrompt(prompt)) return null;
const { delta } = composeCollectSpokenAssistantText(input.answerText, prompt);
if (!delta) return null;
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: input.requestId,
userMessage: null,
assistantMessage: prompt,
});
return delta;
}
export async function persistEmptyCollectSpokenAssistant(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
requestId: string;
answerText: string;
previousFocusId?: string | null;
alreadyEmitted?: boolean;
hostNarration?: string | null;
}): Promise<string | null> {
return persistCollectSpokenAssistantIfNew(input);
}
async function persistApplied(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -1114,6 +1114,9 @@ export function decideConversationalSession(input: {
holdoutValidation?: HoldoutValidationStatus;
snapshotCurrent?: boolean;
accepted?: boolean;
inferenceRounds?: number;
effectiveAnswerCount?: number;
plateauRounds?: number;
}): ReturnType<typeof decideRectification> {
const coverageOpen = Boolean(
input.methods?.some((item) => BLOCKING_COVERAGE_IDS.has(item.method_id) && item.status === "uncovered"),
@@ -1135,6 +1138,9 @@ export function decideConversationalSession(input: {
engineAcceptAllowed: input.selectionAllowed,
engineProposeAllowed: input.proposeAllowed,
datedMethodCollectOpen: input.methods ? datedMethodCollectOpen(input.methods) : undefined,
inferenceRounds: input.inferenceRounds,
effectiveAnswerCount: input.effectiveAnswerCount,
plateauRounds: input.plateauRounds,
});
}
@@ -1,134 +0,0 @@
/**
* Hydrate recovery for old Turns that stored process talk in
* `assistant_message`. Live `answer.delta` is the model text as-is; the
* runner and chat must not call these helpers on the live stream.
*/
import {
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
RECTIFICATION_TOOL_DONE_LABELS,
} from "../../rectification-activity-labels.ts";
const CJK_RE = /[\u4e00-\u9fff]/;
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|current_question|current_probe|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast(?:_packet)?|choice_frame|deferred_followup|resolve-focus|questionId|active[\s_-]?focus)\b/i;
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用|看看|自然)|我(?:决定|倾向|batch|需要用|需要继续|需要考虑|继续收集|继续访谈|继续自然|自然地|用自然语言|应该|认为|可以尝试|不能把|直接自然|确认理解|先向用户)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|方法资料已齐|还不能出牌|不得出牌|不得\s*offer|本轮对照了|这意味着|服务器给了|第.{0,4}条边界|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup|根据规则|规则要求|不调用工具|严格来说|实际上规则|也许我应该|当前探针|这回应的是当前探针|账本|草稿|quote 路径|写入需要日期精度|没有具体日期|先确认草稿|纠缠草稿|自然访谈|待确认状态/;
const THIRD_PERSON_USER_RE = /用户/;
const AGENT_SELF_RE = /我(?:应该|认为|可以|先|需要|不能|直接)|让我|也许我/;
const ADDRESSES_USER_RE = /你|您|记下了|已经记下|已记录了|已收到|不用急|别担心|哪一年|有没有|哪件|大概年份|对吗|是不是|请你/;
const ACTIVITY_ECHO_LABELS = [
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS),
];
export type SplitSpokenAndThinking = Readonly<{
thinking: string;
spoken: string;
}>;
function isActivityEcho(text: string): boolean {
const trimmed = text.trim().replace(/[.]+$/u, "");
if (!trimmed) return false;
return ACTIVITY_ECHO_LABELS.some((label) => {
const bare = label.replace(/[.]+$/u, "");
return trimmed === bare || trimmed === `正在${bare}`;
});
}
export function isSafeCollectSpokenPrompt(prompt: string): boolean {
const trimmed = prompt.trim();
if (!trimmed) return false;
if (INTERNAL_TOKEN_RE.test(trimmed)) return false;
if (trimmed.includes("请点选") || trimmed.includes("看下面这一问")) return false;
return true;
}
export function isRectificationProcessNarration(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
if (/[A-Za-z]{4,}/.test(trimmed) && !CJK_RE.test(trimmed)) return true;
if (isActivityEcho(trimmed)) return true;
if (INTERNAL_TOKEN_RE.test(trimmed)) return true;
if (PROCESS_ZH_RE.test(trimmed)) return true;
if (THIRD_PERSON_USER_RE.test(trimmed)) return true;
if (AGENT_SELF_RE.test(trimmed) && !ADDRESSES_USER_RE.test(trimmed)) return true;
return false;
}
export function nextStableChannelDelta(published: string, next: string): string {
if (!next.startsWith(published)) return "";
return next.slice(published.length);
}
function splitSentences(text: string): string[] {
const parts = text.split(/(?<=[])\s*/u).map((part) => part.trim()).filter(Boolean);
return parts.length > 0 ? parts : [text];
}
function splitUnits(text: string): string[] {
const lines = text.split(/\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length > 1) return lines.flatMap(splitSentences);
return splitSentences(text);
}
function isMixed(units: readonly string[]): boolean {
return units.length > 1
&& units.some(isRectificationProcessNarration)
&& units.some((unit) => !isRectificationProcessNarration(unit));
}
function splitParagraphs(text: string): string[] {
return text
.split(/\n{2,}/)
.flatMap((block) => {
const trimmed = block.trim();
if (!trimmed) return [];
const units = splitUnits(trimmed);
return isMixed(units) ? units : [trimmed];
});
}
export function splitRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
const paragraphs = splitParagraphs(text);
if (paragraphs.length === 0) return { thinking: "", spoken: text };
const thinking: string[] = [];
const spoken: string[] = [];
for (const paragraph of paragraphs) {
if (isRectificationProcessNarration(paragraph)) thinking.push(paragraph);
else spoken.push(paragraph);
}
if (thinking.length === 0) return { thinking: "", spoken: text };
const userFacing: string[] = [];
for (const paragraph of spoken) {
if (ADDRESSES_USER_RE.test(paragraph) || /[?]/.test(paragraph) || paragraph.length > 60) {
userFacing.push(paragraph);
} else {
thinking.push(paragraph);
}
}
return {
thinking: thinking.join("\n\n"),
spoken: userFacing.join("\n\n"),
};
}
export function finalizeRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
const split = splitRectificationSpokenAndThinking(text);
if (split.spoken.trim()) return split;
if (!split.thinking.trim()) return { thinking: "", spoken: text };
return { thinking: split.thinking, spoken: "" };
}
export function settleRectificationSpokenAndThinking(
answerRaw: string,
thinkingRaw = "",
): SplitSpokenAndThinking {
const leak = splitRectificationSpokenAndThinking(answerRaw);
const channelThinking = thinkingRaw.trim();
const spoken = leak.spoken.trim();
if (channelThinking) return { thinking: channelThinking, spoken };
if (!spoken && leak.thinking.trim()) return { thinking: leak.thinking, spoken: "" };
return { thinking: leak.thinking, spoken: spoken || (!leak.thinking ? answerRaw.trim() : "") };
}
@@ -67,46 +67,6 @@ function looksLikeChoiceSchema(schema: Readonly<Record<string, unknown>> | null
);
}
export function emptyAnswerCollectSpokenFallback(
answerText: string,
question: Pick<CurrentQuestionProjection, "kind" | "prompt"> | null | undefined,
): string | null {
if (answerText.trim()) return null;
if (question?.kind !== "collect_spoken") return null;
const prompt = typeof question.prompt === "string" ? question.prompt.trim() : "";
return prompt || null;
}
export function collectSpokenPromptForNewFocus(input: {
previousFocusId: string | null | undefined;
focus: Parameters<typeof projectCurrentQuestion>[0];
}): string | null {
const question = projectCurrentQuestion(input.focus);
if (question?.kind !== "collect_spoken") return null;
const focusId = typeof input.focus?.id === "string" && input.focus.id.trim()
? input.focus.id
: question.focus_id;
if (!focusId || focusId === (input.previousFocusId ?? null)) return null;
const prompt = typeof question.prompt === "string" ? question.prompt.trim() : "";
return prompt || null;
}
export function composeCollectSpokenAssistantText(answerText: string, prompt: string): {
composed: string;
delta: string;
} {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) return { composed: answerText, delta: "" };
if (!answerText.trim()) {
return { composed: trimmedPrompt, delta: trimmedPrompt };
}
const delta = `\n\n${trimmedPrompt}`;
return {
composed: `${answerText.replace(/\s+$/u, "")}${delta}`,
delta,
};
}
export function projectCurrentQuestion(
focus: {
id?: string;