Add centralized pipeline fallback notifier

This commit is contained in:
2026-05-18 20:13:19 +03:00
parent d163d72a0b
commit 507b15aa5f
8 changed files with 177 additions and 6 deletions
+17
View File
@@ -2,6 +2,7 @@ import {AiProvider} from "../model/ai-provider";
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {Environment} from "../common/environment";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {ChatMessage} from "./chat-messages-types";
@@ -290,6 +291,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
];
const state = createAiRequestPipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg);
const pipeline = new UserRequestPipeline({
stages,
stageNames: [
@@ -301,6 +303,21 @@ export async function prepareUnifiedAiRequestPipeline(params: {
"document_rag",
"audit_finish",
],
onFallback: async decision => {
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: nowIso(),
finishedAt: nowIso(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
},
});
},
});
await pipeline.run(state, controller.signal);
await streamMessage.storePipelineAudit(state.audit);
+17
View File
@@ -24,6 +24,7 @@ import {runMistral} from "./unified-ai-runner.mistral";
import {summarizeModelOutput} from "./response-model-output";
import {summarizeToolLoop} from "./tool-loop-summary";
import {persistToolLoopSummaryArtifactAttachment} from "./tool-loop-artifact-store";
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import {
resolveTextToSpeechProviderForUser,
sendSynthesizedSpeech,
@@ -165,6 +166,7 @@ export async function runUnifiedAiResponsePipeline(params: {
}): Promise<void> {
const {options, config, downloads, prepared, streamMessage, controller} = params;
const state = createResponsePipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg);
const adapter = getProviderAdapter(options.provider);
let selectedToolNames: string[] = [];
let filteredTools: unknown[] = [];
@@ -392,6 +394,21 @@ export async function runUnifiedAiResponsePipeline(params: {
"persist_output_artifacts",
"audit_finish",
],
onFallback: async decision => {
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
},
});
},
});
try {
@@ -0,0 +1,16 @@
import type {PipelineFallbackDecision} from "./fallback-executor.js";
export function fallbackNotificationKey(requestId: string, decision: PipelineFallbackDecision): string {
return `${requestId}:${decision.stage}:${decision.action}`;
}
export class PipelineFallbackNotificationRegistry {
private readonly notifiedKeys = new Set<string>();
claim(requestId: string, decision: PipelineFallbackDecision): boolean {
const key = fallbackNotificationKey(requestId, decision);
if (this.notifiedKeys.has(key)) return false;
this.notifiedKeys.add(key);
return true;
}
}
@@ -0,0 +1,27 @@
import type {PipelineFallbackAction, PipelineStageName} from "./types.js";
const DEFAULT_TEXT = "⚠️ I had to skip part of the request, but I can continue.";
const NOTIFY_TEXT = "⚠️ I hit a problem and need to continue with a fallback.";
const FAIL_TEXT = "⚠️ I could not finish this request.";
const RAG_TEXT = "⚠️ Document retrieval failed, so I will answer without RAG.";
const STT_TEXT = "⚠️ Speech transcription failed, so I will continue without the audio transcript.";
const TTS_TEXT = "⚠️ Text-to-speech failed, so I will continue without audio output.";
const TOOL_TEXT = "⚠️ Tool execution failed, so I will continue without that tool.";
export function resolvePipelineFallbackText(stage: PipelineStageName, action: PipelineFallbackAction): string | undefined {
if (action === "continue_without_stage") return undefined;
if (action === "fail_request") return FAIL_TEXT;
switch (stage) {
case "speech_to_text":
return STT_TEXT;
case "document_rag":
return RAG_TEXT;
case "tool_loop":
return TOOL_TEXT;
case "text_to_speech":
return TTS_TEXT;
default:
return action === "notify_user" ? NOTIFY_TEXT : DEFAULT_TEXT;
}
}
@@ -0,0 +1,38 @@
import type {Message} from "typescript-telegram-bot-api";
import {replyToMessage, logError} from "../../util/utils.js";
import type {PipelineFallbackDecision} from "./fallback-executor.js";
import {PipelineFallbackNotificationRegistry} from "./fallback-notifier-registry.js";
import {resolvePipelineFallbackText} from "./fallback-notifier-text.js";
export class PipelineFallbackNotifier {
private readonly registry = new PipelineFallbackNotificationRegistry();
constructor(
private readonly sourceMessage: Message,
private readonly sendFallbackMessage: (text: string) => Promise<void> = async text => {
await replyToMessage({
message: this.sourceMessage,
text,
});
},
) {}
async notify(requestId: string, decision: PipelineFallbackDecision): Promise<{notified: boolean; text?: string}> {
if (!this.registry.claim(requestId, decision)) {
return {notified: false};
}
const text = resolvePipelineFallbackText(decision.stage, decision.action);
if (!text) {
return {notified: false};
}
try {
await this.sendFallbackMessage(text);
return {notified: true, text};
} catch (error) {
logError(error instanceof Error ? error : String(error));
return {notified: false, text};
}
}
}
+28
View File
@@ -823,6 +823,34 @@ export class Environment {
return this.text("noTextToSynthesizeText", "No text to synthesize.");
}
static get pipelineFallbackGenericText() {
return this.text("pipelineFallbackGenericText", "⚠️ I had to skip part of the request, but I can continue.");
}
static get pipelineFallbackNotifyText() {
return this.text("pipelineFallbackNotifyText", "⚠️ I hit a problem and need to continue with a fallback.");
}
static get pipelineFallbackFailText() {
return this.text("pipelineFallbackFailText", "⚠️ I could not finish this request.");
}
static get pipelineFallbackRagText() {
return this.text("pipelineFallbackRagText", "⚠️ Document retrieval failed, so I will answer without RAG.");
}
static get pipelineFallbackSpeechToTextText() {
return this.text("pipelineFallbackSpeechToTextText", "⚠️ Speech transcription failed, so I will continue without the audio transcript.");
}
static get pipelineFallbackTextToSpeechText() {
return this.text("pipelineFallbackTextToSpeechText", "⚠️ Text-to-speech failed, so I will continue without audio output.");
}
static get pipelineFallbackToolText() {
return this.text("pipelineFallbackToolText", "⚠️ Tool execution failed, so I will continue without that tool.");
}
static get mistralTtsNoAudioDataText() {
return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData.");
}