learning_ai_notes/backend/src/modules/note-prompts/runner.ts

132 lines
4.1 KiB
TypeScript

/**
* Prompt runner — executes a PromptTemplate against note content via @bytelyst/llm.
*/
import { llm } from '../../lib/llm.js';
import { config } from '../../lib/config.js';
import { trackEvent } from '../../lib/telemetry.js';
import {
buildVisionMessage,
hasVisionContent,
type ChatMessage,
} from '@bytelyst/llm';
import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js';
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)),
]);
}
/**
* Interpolate {{variable}} placeholders in a template string.
*/
function interpolate(template: string, vars: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
}
/**
* Run a prompt template against provided input.
* Handles text-only, image-only, and text+image inputs.
*/
export async function executePrompt(
template: PromptTemplateDoc,
input: RunPromptInput,
noteBody: string,
): Promise<RunPromptOutput> {
const provider = llm();
if (!provider.isConfigured()) {
throw new Error('LLM provider is not configured. Set LLM_PROVIDER and the required API key.');
}
// Build variables map
const vars: Record<string, string> = {
...input.variables,
noteBody,
noteId: input.noteId,
workspaceId: input.workspaceId,
};
if (input.inputText) vars.inputText = input.inputText;
const userPrompt = interpolate(template.userPromptTemplate, vars);
// Build messages
const messages: ChatMessage[] = [
{ role: 'system', content: template.systemPrompt },
];
if (input.imageUrl && (template.inputType === 'image' || template.inputType === 'text+image')) {
messages.push(buildVisionMessage(userPrompt, input.imageUrl));
} else {
messages.push({ role: 'user', content: userPrompt });
}
// Select model: vision model for image content, custom model, or default
const req = { messages };
let model = template.model || config.LLM_DEFAULT_MODEL;
if (hasVisionContent(req)) {
model = config.LLM_VISION_MODEL;
}
const maxRetries = 3;
const baseDelayMs = 1000;
let lastError: unknown;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const result = await withTimeout(provider.chatCompletion({
messages,
model,
temperature: template.temperature ?? 0.7,
maxTokens: template.maxTokens ?? 4096,
}), 60_000);
if (!result.content || result.content.trim().length === 0) {
throw new Error('LLM returned empty response');
}
const output: RunPromptOutput = {
content: result.content,
model: result.model,
usage: result.usage,
templateSlug: template.slug,
outputType: template.outputType,
};
// F27: Approval-gated actions — produce proposed state instead of applied
if (template.requiresApproval) {
output.approvalState = 'proposed';
} else {
output.approvalState = 'applied';
}
trackEvent('smart_action_run', 'system', {
templateSlug: template.slug,
inputType: template.inputType,
model: result.model,
totalTokens: String(result.usage.totalTokens),
});
trackEvent('smart_action_result_saved', 'system', {
outputAction: template.outputType,
resultType: output.approvalState ?? 'applied',
});
return output;
} catch (err: unknown) {
lastError = err;
const errorType = err instanceof Error ? (err.message.includes('429') ? 'rate_limit' : err.message.includes('timed out') ? 'timeout' : 'llm_error') : 'unknown';
trackEvent('smart_action_error', 'system', { errorType, templateSlug: template.slug });
const isRateLimit = errorType === 'rate_limit';
if (isRateLimit && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
continue;
}
break;
}
}
throw lastError instanceof Error ? lastError : new Error('LLM call failed after retries');
}