fix(rectification): keep one spoken paragraph and strip body questions (BUG-584, BUG-585)
Set-focus must not append a second narration, and a server-owned stem must not also appear as a question in the assistant body. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -74,6 +74,7 @@ export const RECTIFICATION_USER_COPY = {
|
||||
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
|
||||
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
|
||||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||||
collectHandoff: "接下来我们继续。",
|
||||
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
|
||||
collectDeclinedAck: "记下了,这方面先跳过。",
|
||||
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
|
||||
@@ -315,6 +316,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.questionUpdated,
|
||||
RECTIFICATION_USER_COPY.adoptCue,
|
||||
RECTIFICATION_USER_COPY.hostNarrationFallback,
|
||||
RECTIFICATION_USER_COPY.collectHandoff,
|
||||
RECTIFICATION_USER_COPY.continueCollectFallback,
|
||||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
|
||||
@@ -34,7 +34,9 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
|
||||
import { decideFromDossier } from "./decision-from-dossier";
|
||||
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import { withCompareFailedRetryNotice } from "../user-copy";
|
||||
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice } from "../user-copy";
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import { focusSpokenPrompt } from "./turn-question";
|
||||
import {
|
||||
resolveExactSkillPackage,
|
||||
type ResolvedSkillPackageIdentity,
|
||||
@@ -110,7 +112,6 @@ export type V9AgentRunResult = Readonly<{
|
||||
toolsUsed: readonly string[];
|
||||
errorCode: string | null;
|
||||
previousFocusId: string | null;
|
||||
collectSpokenEmitted: boolean;
|
||||
}>;
|
||||
|
||||
type AttemptStatus = "completed" | "failed" | "retryable";
|
||||
@@ -281,7 +282,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}
|
||||
}
|
||||
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
|
||||
const collectSpokenEmitted = false;
|
||||
if (dossier.case.sessionId !== sessionId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
|
||||
}
|
||||
@@ -378,7 +378,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: [],
|
||||
errorCode: null,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
if (existingStatus !== "pending") await billing.release();
|
||||
@@ -489,7 +488,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: outcome.toolsUsed,
|
||||
errorCode: outcome.errorCode,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -524,7 +522,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: [],
|
||||
errorCode: "usage_settlement_failed",
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -592,7 +589,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: outcome.toolsUsed,
|
||||
errorCode: null,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
|
||||
async function streamAttempt(attemptNumber: number, attemptId: string): Promise<AttemptOutcome> {
|
||||
@@ -885,25 +881,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
|
||||
}
|
||||
|
||||
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const decision = decideFromDossier(latest);
|
||||
if (decision.nextAction !== "ask_candidate_discriminator") return { ok: true };
|
||||
const focus = latest.conversationSummary.activeFocus;
|
||||
if (
|
||||
focus
|
||||
&& isPersistedFocusId(focus.id)
|
||||
&& parseAgentChoiceCopy(focus.expectedAnswerSchema)
|
||||
) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, errorCode: "state_invariant_failed" };
|
||||
} catch {
|
||||
return { ok: false, errorCode: "state_invariant_failed" };
|
||||
}
|
||||
};
|
||||
|
||||
const completeAttempt = async (): Promise<AttemptOutcome> => {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
@@ -967,8 +944,32 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
return failedAttempt(attemptId, mapped);
|
||||
}
|
||||
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
|
||||
const invariant = await discriminatorInvariant();
|
||||
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
|
||||
let latestDossier: V9CaseDossier;
|
||||
try {
|
||||
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
} catch {
|
||||
return failedAttempt(attemptId, "state_invariant_failed");
|
||||
}
|
||||
const decision = decideFromDossier(latestDossier);
|
||||
if (decision.nextAction === "ask_candidate_discriminator") {
|
||||
const openFocus = latestDossier.conversationSummary.activeFocus;
|
||||
if (!(
|
||||
openFocus
|
||||
&& isPersistedFocusId(openFocus.id)
|
||||
&& parseAgentChoiceCopy(openFocus.expectedAnswerSchema)
|
||||
)) {
|
||||
return failedAttempt(attemptId, "state_invariant_failed");
|
||||
}
|
||||
}
|
||||
const askedFocus = latestDossier.conversationSummary.activeFocus;
|
||||
if (askedFocus?.askedTurnId === turnId) {
|
||||
const stem = focusSpokenPrompt(askedFocus.expectedAnswerSchema);
|
||||
if (stem) {
|
||||
const stripped = stripQuestionSentences(answerText, stem);
|
||||
const next = stripped || RECTIFICATION_USER_COPY.collectHandoff;
|
||||
if (next !== answerText) await emitVisibleSpoken(next);
|
||||
}
|
||||
}
|
||||
return completeAttempt();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -4,22 +4,46 @@
|
||||
* “did this body already ask”.
|
||||
*/
|
||||
|
||||
const SENTENCE_SPLIT = /(?<=[。!??\n])/;
|
||||
const NARRATIVE_SENTENCE = /范围|记下|对照|\d{1,2}:\d{2}/;
|
||||
|
||||
function isQuestionSentence(text: string, stem: string): boolean {
|
||||
if (!text) return false;
|
||||
if (stem && text === stem) return true;
|
||||
const prefix = stem.slice(0, 12);
|
||||
if (prefix && text.startsWith(prefix)) return true;
|
||||
return /[??]$/.test(text);
|
||||
}
|
||||
|
||||
export function stripQuestionSentences(body: string, stem: string): string {
|
||||
const prompt = stem.trim();
|
||||
const spoken = body.trim();
|
||||
if (!spoken) return "";
|
||||
const kept: string[] = [];
|
||||
let dropContinuation = false;
|
||||
for (const part of spoken.split(SENTENCE_SPLIT)) {
|
||||
const text = part.trim();
|
||||
if (!text) continue;
|
||||
if (isQuestionSentence(text, prompt)) {
|
||||
dropContinuation = true;
|
||||
continue;
|
||||
}
|
||||
if (dropContinuation && !NARRATIVE_SENTENCE.test(text)) {
|
||||
dropContinuation = false;
|
||||
continue;
|
||||
}
|
||||
dropContinuation = false;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("").trim();
|
||||
}
|
||||
|
||||
export function composeCollectSpokenAssistantText(body: string, prompt: string): string {
|
||||
const stem = prompt.trim();
|
||||
const spoken = body.trim();
|
||||
if (!stem) return spoken;
|
||||
if (!spoken || spoken === stem) return stem;
|
||||
const prefix = stem.slice(0, 12);
|
||||
const stripped = spoken
|
||||
.split(/(?<=[。!?\n])/)
|
||||
.filter((sentence) => {
|
||||
const text = sentence.trim();
|
||||
if (!text) return false;
|
||||
if (text === stem) return false;
|
||||
return !(prefix && text.startsWith(prefix));
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
const stripped = stripQuestionSentences(spoken, stem);
|
||||
if (!stripped) return stem;
|
||||
const suffix = `\n\n${stem}`;
|
||||
if (stripped.includes(stem)) return stripped;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* or live-unlock after compare/offer/result tools — tokens may stream as
|
||||
* `live`. A later public tool-call retracts that speculative stream so
|
||||
* "Let me set" never stays in `answer.delta`. Exception: `rectification-set-focus`
|
||||
* only attaches the question stem; live greeting/handoff in the same step stays.
|
||||
* keeps already-live greeting/handoff and does not retract it; after the stem
|
||||
* is attached, a second spoken paragraph in the same turn is discarded.
|
||||
*/
|
||||
|
||||
export type StepAnswerChunk = Readonly<{
|
||||
@@ -27,6 +28,8 @@ export type StepAnswerState = {
|
||||
live: boolean;
|
||||
publishedUpTo: number;
|
||||
unlocked: boolean;
|
||||
stemAttached: boolean;
|
||||
publishedAny: boolean;
|
||||
};
|
||||
|
||||
export type StepAnswerEffect =
|
||||
@@ -67,6 +70,8 @@ export function createStepAnswerState(): StepAnswerState {
|
||||
live: false,
|
||||
publishedUpTo: 0,
|
||||
unlocked: false,
|
||||
stemAttached: false,
|
||||
publishedAny: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +121,7 @@ function liveRemainder(state: StepAnswerState): StepAnswerEffect {
|
||||
const next = state.text.slice(state.publishedUpTo);
|
||||
if (!next) return { kind: "none" };
|
||||
state.live = true;
|
||||
state.publishedAny = true;
|
||||
state.publishedUpTo = state.text.length;
|
||||
return { kind: "live", text: next };
|
||||
}
|
||||
@@ -127,9 +133,24 @@ function retractLive(state: StepAnswerState): StepAnswerEffect {
|
||||
state.live = false;
|
||||
state.publishedUpTo = 0;
|
||||
state.unlocked = false;
|
||||
state.publishedAny = false;
|
||||
return wasLive ? { kind: "retract" } : { kind: "none" };
|
||||
}
|
||||
|
||||
function publishUnpublishedSetFocus(state: StepAnswerState): StepAnswerEffect {
|
||||
const unpublished = state.text.slice(state.publishedUpTo);
|
||||
if (hasCjk(unpublished)) {
|
||||
state.publishedAny = true;
|
||||
state.publishedUpTo = state.text.length;
|
||||
const pieces = [unpublished];
|
||||
resetBuffers(state);
|
||||
return { kind: "publish", pieces };
|
||||
}
|
||||
const leftover = unpublished.trim().length > 0;
|
||||
resetBuffers(state);
|
||||
return leftover ? { kind: "discard" } : { kind: "none" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the per-step buffer. Terminal Chinese may stream as `live` once
|
||||
* the step is unlocked or a spoken sentence has closed. Tool-result ends
|
||||
@@ -149,6 +170,7 @@ export function applyStepAnswerChunk(
|
||||
resetBuffers(state);
|
||||
return { kind: "none" };
|
||||
case "text-delta": {
|
||||
if (state.stemAttached && state.publishedAny) return { kind: "discard" };
|
||||
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
|
||||
if (text) {
|
||||
state.text += text;
|
||||
@@ -160,7 +182,9 @@ export function applyStepAnswerChunk(
|
||||
case "tool-call":
|
||||
if (isPublicToolCall(chunk, isPublicTool)) {
|
||||
state.calledTool = true;
|
||||
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) return { kind: "none" };
|
||||
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
|
||||
return publishUnpublishedSetFocus(state);
|
||||
}
|
||||
const retracted = retractLive(state);
|
||||
return retracted.kind === "retract" ? retracted : { kind: "none" };
|
||||
}
|
||||
@@ -169,6 +193,7 @@ export function applyStepAnswerChunk(
|
||||
case "tool-error": {
|
||||
if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true;
|
||||
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
|
||||
state.stemAttached = true;
|
||||
resetBuffers(state);
|
||||
return { kind: "none" };
|
||||
}
|
||||
@@ -188,11 +213,13 @@ export function applyStepAnswerChunk(
|
||||
const reason = stepFinishReason(chunk);
|
||||
if (state.live) {
|
||||
const rest = state.text.slice(state.publishedUpTo);
|
||||
if (rest) state.publishedAny = true;
|
||||
resetBuffers(state);
|
||||
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
|
||||
}
|
||||
const publish = shouldPublishStepText(state, reason);
|
||||
const pieces = publish ? [...state.pieces] : [];
|
||||
if (publish) state.publishedAny = true;
|
||||
resetBuffers(state);
|
||||
return publish ? { kind: "publish", pieces } : { kind: "discard" };
|
||||
}
|
||||
@@ -207,6 +234,7 @@ export function flushStepAnswerOnStreamFinish(
|
||||
): StepAnswerEffect {
|
||||
if (state.live) {
|
||||
const rest = state.text.slice(state.publishedUpTo);
|
||||
if (rest) state.publishedAny = true;
|
||||
resetBuffers(state);
|
||||
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { detachCollectSpokenAssistantText } from "./collect-prompt";
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
|
||||
import type { ConversationFocus } from "./tool-service";
|
||||
|
||||
@@ -114,7 +114,7 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
|
||||
if (!focus) return { ...turn, question: null };
|
||||
const question = turnQuestionFromFocus(focus);
|
||||
const text = question && turn.text
|
||||
? detachCollectSpokenAssistantText(turn.text, question.prompt)
|
||||
? stripQuestionSentences(turn.text, question.prompt)
|
||||
: turn.text;
|
||||
return { ...turn, text, question };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user