Rectification answers now advance chat_sessions.updated_at (BUG-704). A ?c= id missing from the loaded page is fetched before anyone may call it deleted (BUG-705). The range card no longer has a post-card tie-break button; live questions leave the card visible with adopt locked (BUG-706/708). Spoken copy bans 相对支持度 (BUG-709). Task docs assigned 700-704; qizheng already took 700-703.
225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
/**
|
|
* Single-shot adopt-delivery narrator. No tools. It only writes copy from
|
|
* structured facts; validation fail-closes to the template.
|
|
*
|
|
* Billing matches the turn-intent classifier: this call is not reserved.
|
|
*/
|
|
|
|
import { Agent } from "@mastra/core/agent";
|
|
|
|
import type { ResolvedLanguageModel } from "@/mastra/model";
|
|
|
|
import {
|
|
appendAdoptCue,
|
|
shouldWriteAdoptNarration,
|
|
validateAdoptNarration,
|
|
type AdoptDeliveryFacts,
|
|
type AdoptNarrationWriter,
|
|
} from "./adopt-narration.ts";
|
|
|
|
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
|
|
|
|
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
|
|
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。
|
|
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
|
|
只能使用输入事实里出现的时间和年份;输入里没有的数字一律不要写。
|
|
不要写「相对支持度」「概率」「置信度」。两个时间分不开时说「这两个时间按现有信息分不开」,不要念分数。
|
|
只有 stop_facts 里真有「不再问」时才可以那么说。
|
|
不要出现「确认」「精确」这两个词,不得再提问,不要写「我按你说的经历认真分析过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`;
|
|
|
|
export type AdoptNarrationOutcome =
|
|
| "agent"
|
|
| `template:${string}`;
|
|
|
|
export type AdoptNarrationDelivery = Readonly<{
|
|
text: string;
|
|
adopt_narration: AdoptNarrationOutcome;
|
|
}>;
|
|
|
|
type DisposableAbort = Readonly<{
|
|
signal: AbortSignal;
|
|
dispose: () => void;
|
|
}>;
|
|
|
|
function composedAbortSignal(
|
|
signal: AbortSignal | undefined,
|
|
timeoutMs: number,
|
|
): DisposableAbort {
|
|
const controller = new AbortController();
|
|
// Must stay ref'd. The platform timeout signal uses an unref timer, so a hanging
|
|
// generateText lets the event loop drain before abort (BUG-523).
|
|
const timeoutId = globalThis.setTimeout(() => {
|
|
if (!controller.signal.aborted) {
|
|
controller.abort(new DOMException("adopt narration timed out", "TimeoutError"));
|
|
}
|
|
}, timeoutMs);
|
|
const onExternalAbort = () => {
|
|
if (!controller.signal.aborted) {
|
|
controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError"));
|
|
}
|
|
};
|
|
if (signal) {
|
|
if (signal.aborted) onExternalAbort();
|
|
else signal.addEventListener("abort", onExternalAbort);
|
|
}
|
|
return {
|
|
signal: controller.signal,
|
|
dispose: () => {
|
|
globalThis.clearTimeout(timeoutId);
|
|
signal?.removeEventListener("abort", onExternalAbort);
|
|
},
|
|
};
|
|
}
|
|
|
|
function whenAborted(signal: AbortSignal): {
|
|
promise: Promise<never>;
|
|
dispose: () => void;
|
|
} {
|
|
let onAbort: (() => void) | undefined;
|
|
const promise = new Promise<never>((_, reject) => {
|
|
const fail = () => {
|
|
reject(signal.reason ?? new Error("aborted"));
|
|
};
|
|
if (signal.aborted) {
|
|
fail();
|
|
return;
|
|
}
|
|
onAbort = fail;
|
|
signal.addEventListener("abort", fail, { once: true });
|
|
});
|
|
return {
|
|
promise,
|
|
dispose: () => {
|
|
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
|
|
console.info(`[rectification-v9] adopt_narration=${outcome}`);
|
|
}
|
|
|
|
export async function generateAdoptNarrationText(
|
|
model: ResolvedLanguageModel,
|
|
facts: AdoptDeliveryFacts,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
const agent = new Agent({
|
|
id: `rectification-adopt-narration-${model.id}`,
|
|
name: "Rectification Adopt Narration",
|
|
model: model.model,
|
|
instructions: ADOPT_NARRATION_INSTRUCTIONS,
|
|
});
|
|
const result = await agent.generate([{
|
|
role: "user",
|
|
content: JSON.stringify({
|
|
credible_range: facts.credible_range,
|
|
representative_minute: facts.representative_minute,
|
|
runner_up_minute: facts.runner_up_minute,
|
|
opening_window: facts.opening_window,
|
|
active_candidates: facts.active_candidates,
|
|
answered_rounds: facts.answered_rounds,
|
|
stop_facts: facts.stop_facts,
|
|
post_adopt_verification: facts.post_adopt_verification.map((item) => ({
|
|
kind: item.kind,
|
|
domain: item.domain,
|
|
year_label: item.year_label,
|
|
})),
|
|
}),
|
|
}], {
|
|
abortSignal: signal,
|
|
});
|
|
const text = typeof result.text === "string" ? result.text : "";
|
|
return text.trim();
|
|
}
|
|
|
|
export async function deliverAdoptNarration(input: {
|
|
facts: AdoptDeliveryFacts;
|
|
fallback: string;
|
|
model?: ResolvedLanguageModel | null;
|
|
signal?: AbortSignal;
|
|
timeoutMs?: number;
|
|
generateText?: (
|
|
facts: AdoptDeliveryFacts,
|
|
signal?: AbortSignal,
|
|
) => Promise<string>;
|
|
}): 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) {
|
|
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
|
|
logAdoptNarrationOutcome(delivery.adopt_narration);
|
|
return delivery;
|
|
}
|
|
const composed = composedAbortSignal(
|
|
input.signal,
|
|
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
|
|
);
|
|
const aborted = whenAborted(composed.signal);
|
|
try {
|
|
const text = await Promise.race([
|
|
generate(input.facts, composed.signal),
|
|
aborted.promise,
|
|
]);
|
|
const checked = validateAdoptNarration(text, input.facts);
|
|
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 {
|
|
const delivery = { text: input.fallback, adopt_narration: "template:model_error" as const };
|
|
logAdoptNarrationOutcome(delivery.adopt_narration);
|
|
return delivery;
|
|
} finally {
|
|
aborted.dispose();
|
|
composed.dispose();
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
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;
|
|
};
|
|
}
|