fix(rectification): run adopt narration on the last discriminator click
The early-exit reused the pre-click dossier, so the agent never ran on the common path. Use this-turn decision, log agent vs template outcome, and time out after 8s. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,46 @@ import {
|
||||
type AdoptNarrationWriter,
|
||||
} from "./adopt-narration.ts";
|
||||
|
||||
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
|
||||
|
||||
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
|
||||
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
|
||||
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
|
||||
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
|
||||
|
||||
export type AdoptNarrationOutcome =
|
||||
| "agent"
|
||||
| `template:${string}`;
|
||||
|
||||
export type AdoptNarrationDelivery = Readonly<{
|
||||
text: string;
|
||||
adopt_narration: AdoptNarrationOutcome;
|
||||
}>;
|
||||
|
||||
function composedAbortSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function whenAborted(signal: AbortSignal): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason ?? new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", () => {
|
||||
reject(signal.reason ?? new Error("aborted"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
|
||||
console.info(`[rectification-v9] adopt_narration=${outcome}`);
|
||||
}
|
||||
|
||||
export async function generateAdoptNarrationText(
|
||||
model: ResolvedLanguageModel,
|
||||
facts: AdoptDeliveryFacts,
|
||||
@@ -26,10 +66,7 @@ export async function generateAdoptNarrationText(
|
||||
id: `rectification-adopt-narration-${model.id}`,
|
||||
name: "Rectification Adopt Narration",
|
||||
model: model.model,
|
||||
instructions: `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
|
||||
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
|
||||
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
|
||||
不得承诺“确认”或“精确”,不得再提问,不要写“可以从下面选一个先用着”。`,
|
||||
instructions: ADOPT_NARRATION_INSTRUCTIONS,
|
||||
});
|
||||
const result = await agent.generate([{
|
||||
role: "user",
|
||||
@@ -59,48 +96,83 @@ export async function deliverAdoptNarration(input: {
|
||||
fallback: string;
|
||||
model?: ResolvedLanguageModel | null;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
generateText?: (
|
||||
facts: AdoptDeliveryFacts,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string>;
|
||||
}): Promise<string> {
|
||||
if (!shouldWriteAdoptNarration(input.facts)) return input.fallback;
|
||||
}): Promise<AdoptNarrationDelivery> {
|
||||
if (!shouldWriteAdoptNarration(input.facts)) {
|
||||
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
const generate = input.generateText ?? (input.model
|
||||
? (facts: AdoptDeliveryFacts, signal?: AbortSignal) => (
|
||||
generateAdoptNarrationText(input.model as ResolvedLanguageModel, facts, signal)
|
||||
)
|
||||
: null);
|
||||
if (!generate) return input.fallback;
|
||||
if (!generate) {
|
||||
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
const signal = composedAbortSignal(
|
||||
input.signal,
|
||||
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const text = await generate(input.facts, input.signal);
|
||||
const text = await Promise.race([
|
||||
generate(input.facts, signal),
|
||||
whenAborted(signal),
|
||||
]);
|
||||
const checked = validateAdoptNarration(text, input.facts);
|
||||
if (!checked.ok) return input.fallback;
|
||||
return appendAdoptCue(checked.text);
|
||||
if (!checked.ok) {
|
||||
const delivery = {
|
||||
text: input.fallback,
|
||||
adopt_narration: `template:${checked.reason}` as const,
|
||||
};
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
const delivery = {
|
||||
text: appendAdoptCue(checked.text),
|
||||
adopt_narration: "agent" as const,
|
||||
};
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
} catch {
|
||||
return input.fallback;
|
||||
const delivery = { text: input.fallback, adopt_narration: "template:model_error" as const };
|
||||
logAdoptNarrationOutcome(delivery.adopt_narration);
|
||||
return delivery;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdoptNarrationWriter(input: {
|
||||
model?: ResolvedLanguageModel | null;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
resolveModel?: () => Promise<ResolvedLanguageModel | null>;
|
||||
generateText?: (
|
||||
facts: AdoptDeliveryFacts,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string>;
|
||||
}): AdoptNarrationWriter {
|
||||
return async (facts, fallback) => deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
model: input.model,
|
||||
signal: input.signal,
|
||||
generateText: input.generateText ?? (input.resolveModel
|
||||
? async (nextFacts, signal) => {
|
||||
const model = input.model ?? await input.resolveModel!();
|
||||
if (!model) throw new Error("adopt_narration_model_unavailable");
|
||||
return generateAdoptNarrationText(model, nextFacts, signal);
|
||||
}
|
||||
: undefined),
|
||||
});
|
||||
return async (facts, fallback) => {
|
||||
const delivered = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
model: input.model,
|
||||
signal: input.signal,
|
||||
timeoutMs: input.timeoutMs,
|
||||
generateText: input.generateText ?? (input.resolveModel
|
||||
? async (nextFacts, signal) => {
|
||||
const model = input.model ?? await input.resolveModel!();
|
||||
if (!model) throw new Error("adopt_narration_model_unavailable");
|
||||
return generateAdoptNarrationText(model, nextFacts, signal);
|
||||
}
|
||||
: undefined),
|
||||
});
|
||||
return delivered.text;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,6 +141,23 @@ function adoptHostNarration(input: {
|
||||
}), input.receipt);
|
||||
}
|
||||
|
||||
function dossierWithCurrentInference(
|
||||
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"],
|
||||
state: InferenceState | null,
|
||||
): Parameters<typeof decideAfterInferenceChange>[0]["dossier"] {
|
||||
if (!state) return dossier;
|
||||
return {
|
||||
...dossier,
|
||||
latestResult: {
|
||||
...(dossier.latestResult ?? {}),
|
||||
decisionReceipt: {
|
||||
...(dossier.latestResult?.decisionReceipt ?? {}),
|
||||
inference_state: state,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
@@ -390,6 +407,7 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
|
||||
decisionState: InferenceState | null;
|
||||
nextAction: ReturnType<typeof publicNextAction>;
|
||||
decision?: ReturnType<typeof decideAfterInferenceChange>;
|
||||
birthDate?: string | null;
|
||||
askedTurnId?: string | null;
|
||||
narrateAdopt?: AdoptNarrationWriter;
|
||||
@@ -412,6 +430,13 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
decisionReceipt: input.decisionState ? { inference_state: input.decisionState } : null,
|
||||
};
|
||||
const birthDate = input.birthDate ?? null;
|
||||
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
|
||||
const decision = input.decision ?? decideAfterInferenceChange({
|
||||
dossier: input.dossier,
|
||||
state: input.decisionState,
|
||||
userStopped: false,
|
||||
birthDate,
|
||||
});
|
||||
const catalog = rectificationFollowupCatalog(latest, input.dossier.evidence);
|
||||
const sessionOutcome = typeof input.nextAction.session_outcome === "string"
|
||||
? input.nextAction.session_outcome as SessionOutcomeKind
|
||||
@@ -435,12 +460,11 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
followup,
|
||||
methods: plan.methods,
|
||||
})) {
|
||||
const decision = decideFromDossier(input.dossier, { birthDate });
|
||||
const facts = adoptDeliveryFacts(decision, input.dossier);
|
||||
const facts = adoptDeliveryFacts(decision, liveDossier);
|
||||
const fallback = adoptHostNarration({
|
||||
dossier: input.dossier,
|
||||
dossier: liveDossier,
|
||||
decision,
|
||||
receipt: input.dossier.latestResult?.decisionReceipt,
|
||||
receipt: liveDossier.latestResult?.decisionReceipt,
|
||||
});
|
||||
return {
|
||||
hostNarration: input.narrateAdopt
|
||||
@@ -660,7 +684,8 @@ export async function applyCollectFocusDenial(
|
||||
},
|
||||
},
|
||||
}, "declined");
|
||||
const nextAction = publicNextAction(decideFromDossier(withDeclined, { birthDate }));
|
||||
const decision = decideFromDossier(withDeclined, { birthDate });
|
||||
const nextAction = publicNextAction(decision);
|
||||
const nextInterview = await persistNextInterviewAfterChoice({
|
||||
accounting,
|
||||
userId: input.userId,
|
||||
@@ -668,6 +693,7 @@ export async function applyCollectFocusDenial(
|
||||
dossier: withDeclined,
|
||||
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
|
||||
nextAction,
|
||||
decision,
|
||||
birthDate,
|
||||
narrateAdopt: input.narrateAdopt,
|
||||
});
|
||||
@@ -793,6 +819,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
dossier,
|
||||
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
|
||||
nextAction,
|
||||
decision,
|
||||
birthDate,
|
||||
askedTurnId: input.askedTurnId ?? null,
|
||||
narrateAdopt: input.narrateAdopt,
|
||||
@@ -931,6 +958,7 @@ async function persistApplied(
|
||||
dossier: dossierWithClosedFocus(input.dossier, input.focusStatus),
|
||||
decisionState: input.decisionState ?? null,
|
||||
nextAction,
|
||||
decision: nextDecision,
|
||||
birthDate,
|
||||
narrateAdopt: command.narrateAdopt,
|
||||
});
|
||||
@@ -946,8 +974,9 @@ ${nextInterview.hostNarration}`;
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
}
|
||||
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
|
||||
const adoptionFacts = nextAction.can_adopt
|
||||
? adoptDeliveryFacts(nextDecision, input.dossier)
|
||||
? adoptDeliveryFacts(nextDecision, liveDossier)
|
||||
: null;
|
||||
const adoptionNarration = nextAction.can_adopt
|
||||
? withProspectiveWindows(deliveryAdoptNarration({
|
||||
@@ -956,7 +985,7 @@ ${nextInterview.hostNarration}`;
|
||||
openingRange: openingRangeFromDossier(input.dossier),
|
||||
stopReason: nextAction.stop_reason,
|
||||
stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null,
|
||||
}), input.dossier.latestResult?.decisionReceipt)
|
||||
}), liveDossier.latestResult?.decisionReceipt)
|
||||
: null;
|
||||
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
|
||||
const completedRangeNarration = nextAction.type === "complete_with_range"
|
||||
|
||||
@@ -8,8 +8,13 @@ import type { ConflictProbe, InferenceState } from "../src/lib/rectification-age
|
||||
import {
|
||||
adoptDeliveryFacts,
|
||||
validateAdoptNarration,
|
||||
type AdoptDeliveryFacts,
|
||||
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||||
import { createAdoptNarrationWriter } from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
|
||||
import {
|
||||
ADOPT_NARRATION_INSTRUCTIONS,
|
||||
createAdoptNarrationWriter,
|
||||
deliverAdoptNarration,
|
||||
} from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
|
||||
import {
|
||||
applyCollectFocusDenial,
|
||||
persistNextInterviewAfterChoice,
|
||||
@@ -426,11 +431,34 @@ function fourteenProbeState(): InferenceState {
|
||||
};
|
||||
}
|
||||
|
||||
function preClickFourteenProbeState(): InferenceState {
|
||||
const last = ANSWERED[ANSWERED.length - 1]!;
|
||||
const state = fourteenProbeState();
|
||||
return {
|
||||
...state,
|
||||
revision: 5,
|
||||
answered_probes: state.answered_probes.filter((item) => item.probe_id !== last.id),
|
||||
candidates: state.candidates.map((item) => {
|
||||
if (item.time === "05:00") {
|
||||
return { ...item, prior_score: 23, posterior_score: 23 };
|
||||
}
|
||||
if (item.time === "05:06") {
|
||||
return { ...item, prior_score: 16, posterior_score: 16 };
|
||||
}
|
||||
if (item.time === "04:53") {
|
||||
return { ...item, prior_score: 7, posterior_score: 7 };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function caseDossier(extra?: {
|
||||
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
|
||||
acceptedTime?: string | null;
|
||||
state?: InferenceState;
|
||||
}): DecisionDossier {
|
||||
const state = fourteenProbeState();
|
||||
const state = extra?.state ?? fourteenProbeState();
|
||||
return {
|
||||
evidence: EVIDENCE,
|
||||
conversationSummary: {
|
||||
@@ -700,6 +728,14 @@ test("adopt narration agent keeps in-fact copy and fail-closes the rest", async
|
||||
})(facts, fallback);
|
||||
assert.equal(unknownSupport, fallback);
|
||||
assert.equal(validateAdoptNarration("相对支持度 99,先用 05:00。", facts).ok, false);
|
||||
|
||||
const deniedPromise = "这不是确认的分钟,先用 05:00。";
|
||||
assert.equal(validateAdoptNarration(deniedPromise, facts).ok, false);
|
||||
const denied = await createAdoptNarrationWriter({
|
||||
generateText: async () => deniedPromise,
|
||||
})(facts, fallback);
|
||||
assert.equal(denied, fallback);
|
||||
assert.match(ADOPT_NARRATION_INSTRUCTIONS, /不要出现/);
|
||||
});
|
||||
|
||||
test("three adopt entry points call the model once on first ready_to_adopt and not after accept", async () => {
|
||||
@@ -709,36 +745,79 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
|
||||
assert.match(route, /applyCollectFocusDenial\([\s\S]*narrateAdopt/);
|
||||
assert.match(route, /persistNextInterviewIfIdle\(\{[\s\S]*narrateAdopt/);
|
||||
|
||||
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const persistFn = source.slice(
|
||||
source.indexOf("export async function persistNextInterviewAfterChoice"),
|
||||
source.indexOf("async function persistFocusAfterChoice"),
|
||||
);
|
||||
assert.doesNotMatch(persistFn, /decideFromDossier\(/);
|
||||
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const preClick = caseDossier({ state: preClickFourteenProbeState() });
|
||||
const preClickDecision = decideFromDossier(preClick, { birthDate: "1997-08-08" });
|
||||
assert.notEqual(preClickDecision.precisionStage, "ready_to_adopt");
|
||||
assert.ok(preClickDecision.probe);
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
|
||||
|
||||
function withCounter() {
|
||||
let calls = 0;
|
||||
const seen: AdoptDeliveryFacts[] = [];
|
||||
const narrateAdopt = createAdoptNarrationWriter({
|
||||
generateText: async () => {
|
||||
generateText: async (facts) => {
|
||||
calls += 1;
|
||||
seen.push(facts);
|
||||
return kept;
|
||||
},
|
||||
});
|
||||
return { narrateAdopt, count: () => calls };
|
||||
return { narrateAdopt, count: () => calls, seen };
|
||||
}
|
||||
|
||||
const afterChoice = withCounter();
|
||||
const accountingA = adoptAccounting(dossier);
|
||||
const accountingA = adoptAccounting(preClick);
|
||||
await persistNextInterviewAfterChoice({
|
||||
accounting: accountingA.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
dossier,
|
||||
dossier: preClick,
|
||||
decisionState: fourteenProbeState(),
|
||||
nextAction: publicNextAction(decision),
|
||||
decision,
|
||||
birthDate: "1997-08-08",
|
||||
narrateAdopt: afterChoice.narrateAdopt,
|
||||
});
|
||||
assert.equal(afterChoice.count(), 1);
|
||||
assert.equal(afterChoice.seen[0]?.representative_minute, "05:00");
|
||||
assert.equal(afterChoice.seen[0]?.precision_stage, "ready_to_adopt");
|
||||
assert.deepEqual(
|
||||
afterChoice.seen[0]?.active_candidates.slice(0, 3).map((item) => [item.time, item.relative_support]),
|
||||
[["05:00", 21], ["05:06", 18], ["04:53", 9]],
|
||||
);
|
||||
assertNoFocusWrite(accountingA);
|
||||
|
||||
const invalidChoice = withCounter();
|
||||
const invalidWriter = createAdoptNarrationWriter({
|
||||
generateText: async (facts) => {
|
||||
invalidChoice.seen.push(facts);
|
||||
return "更像 04:58,不要再问了。";
|
||||
},
|
||||
});
|
||||
const invalidAccounting = adoptAccounting(preClick);
|
||||
const invalid = await persistNextInterviewAfterChoice({
|
||||
accounting: invalidAccounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
dossier: preClick,
|
||||
decisionState: fourteenProbeState(),
|
||||
nextAction: publicNextAction(decision),
|
||||
decision,
|
||||
birthDate: "1997-08-08",
|
||||
narrateAdopt: invalidWriter,
|
||||
});
|
||||
assertAdoptTemplate(invalid.hostNarration);
|
||||
assert.match(invalid.hostNarration, /05:00/);
|
||||
assert.doesNotMatch(invalid.hostNarration, /04:58/);
|
||||
|
||||
const afterDenial = withCounter();
|
||||
let loads = 0;
|
||||
const accountingB = fakeAccounting({
|
||||
@@ -807,6 +886,63 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
|
||||
assert.equal(accepted.count(), 0);
|
||||
});
|
||||
|
||||
test("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => {
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
|
||||
|
||||
const agent = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
generateText: async () => kept,
|
||||
});
|
||||
assert.equal(agent.adopt_narration, "agent");
|
||||
assert.match(agent.text, /05:00/);
|
||||
|
||||
const invalid = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
generateText: async () => "更像 04:58,不要再问了。",
|
||||
});
|
||||
assert.equal(invalid.adopt_narration, "template:unknown_minute");
|
||||
assert.equal(invalid.text, fallback);
|
||||
|
||||
const errored = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
generateText: async () => {
|
||||
throw new Error("model down");
|
||||
},
|
||||
});
|
||||
assert.equal(errored.adopt_narration, "template:model_error");
|
||||
assert.equal(errored.text, fallback);
|
||||
|
||||
const notReady = await deliverAdoptNarration({
|
||||
facts: { ...facts, already_accepted: true },
|
||||
fallback,
|
||||
generateText: async () => kept,
|
||||
});
|
||||
assert.equal(notReady.adopt_narration, "template:not_ready");
|
||||
assert.equal(notReady.text, fallback);
|
||||
});
|
||||
|
||||
test("adopt narration times out to the template without throwing", async () => {
|
||||
const dossier = caseDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
|
||||
const timed = await deliverAdoptNarration({
|
||||
facts,
|
||||
fallback,
|
||||
timeoutMs: 30,
|
||||
generateText: async () => new Promise(() => {}),
|
||||
});
|
||||
assert.equal(timed.adopt_narration, "template:model_error");
|
||||
assert.equal(timed.text, fallback);
|
||||
});
|
||||
|
||||
test("applyCollectFocusDenial on the family collect uses the same adopt template", async () => {
|
||||
const dossier = caseDossier();
|
||||
let loads = 0;
|
||||
|
||||
Reference in New Issue
Block a user