Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts
T
jesse-ux f51e494c5a
Independent Staging Quality Gate / validate (push) Failing after 6m45s
Independent Staging Quality Gate / publish (push) Skipped
fix(chat): keep rectification sessions findable and keep the delivery card
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.
2026-09-15 17:10:53 +08:00

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;
};
}