/** * 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; dispose: () => void; } { let onAbort: (() => void) | undefined; const promise = new Promise((_, 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 { 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; }): Promise { 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; generateText?: ( facts: AdoptDeliveryFacts, signal?: AbortSignal, ) => Promise; }): 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; }; }