import { z } from 'zod'; import { routingOutputSchema, type RoutingOutput, classificationSchema, type ClassificationOutput } from '@dynamic-sites/shared'; import { logger } from '../logger.js'; const OLLAMA_HOST = process.env.OLLAMA_HOST || 'https://ollama.com'; const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || ''; const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud'; const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || 'gpt-oss:120b'; const MAX_RETRIES = 3; export interface LlmChatCaller { (messages: Array<{ role: string; content: string }>, model: string): Promise; } /** Default chat caller using Ollama HTTP API */ async function ollamaChat(messages: Array<{ role: string; content: string }>, model: string): Promise { const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(OLLAMA_API_KEY ? { Authorization: `Bearer ${OLLAMA_API_KEY}` } : {}), }, body: JSON.stringify({ model, messages, stream: false, format: 'json' }), }); if (!resp.ok) { throw new Error(`Ollama ${resp.status}: ${await resp.text().catch(() => 'no body')}`); } const data = await resp.json() as { message?: { content?: string } }; return data.message?.content || ''; } /** * Validate-then-retry loop: parse LLM output as JSON, validate against schema, * re-prompt with errors if invalid, up to MAX_RETRIES per model. */ async function generateWithValidation(params: { messages: Array<{ role: string; content: string }>; schema: z.ZodType; chat?: LlmChatCaller; }): Promise { const chat = params.chat || ollamaChat; const models = [PRIMARY_MODEL, FALLBACK_MODEL]; for (const model of models) { const msgs = [...params.messages]; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { logger.debug({ event: 'llm.request', model, attempt }, 'LLM call'); try { const raw = await chat(msgs, model); // Extract JSON from response (handle markdown code blocks) const jsonMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, raw]; const jsonStr = (jsonMatch[1] || raw).trim(); const parsed = JSON.parse(jsonStr); const result = params.schema.safeParse(parsed); if (result.success) return result.data; const errors = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('\n'); logger.debug({ event: 'llm.retry', model, attempt, errors }, 'Validation failed, retrying'); msgs.push( { role: 'assistant', content: raw }, { role: 'user', content: `Your JSON output failed validation:\n${errors}\n\nPlease fix the issues and return valid JSON only.` } ); } catch (err) { logger.warn({ event: 'llm.retry', model, attempt, error: (err as Error).message }, 'LLM call or parse failed'); if (attempt === MAX_RETRIES - 1) break; msgs.push( { role: 'user', content: `Your response was not valid JSON. Please respond with ONLY a JSON object, no markdown or extra text.` } ); } } logger.warn({ event: 'llm.fallback', from: model }, 'Exhausted retries, trying fallback'); } logger.error({ event: 'llm.exhausted' }, 'All LLM models exhausted'); throw new Error('LLM_UNAVAILABLE'); } // ── Public API ── export interface GenerateEditedJsonParams { currentJson: unknown; siteContext: unknown; userMessage: string; repoRelativePath: string; schema: z.ZodTypeAny; } export async function generateEditedJson(params: GenerateEditedJsonParams, chat?: LlmChatCaller): Promise { const messages = [ { role: 'system', content: `You are a website content editor. You edit JSON content files for a website. SITE CONTEXT: ${JSON.stringify(params.siteContext, null, 2)} You will receive the current JSON content of a section file and a natural language edit request. Return ONLY the complete updated JSON object — no explanation, no markdown, just the JSON. Preserve all existing fields and structure. Only change what the user requested. The output must be valid JSON matching the exact same schema as the input.`, }, { role: 'user', content: `Current content of "${params.repoRelativePath}":\n\`\`\`json\n${JSON.stringify(params.currentJson, null, 2)}\n\`\`\`\n\nEdit request: "${params.userMessage}"\n\nReturn the complete updated JSON:`, }, ]; return generateWithValidation({ messages, schema: params.schema, chat }); } export interface RouteEditIntentParams { userMessage: string; manifest: Array<{ id: string; type: string; title?: string; headline?: string; heading?: string; repo_relative_path: string; visible: boolean }>; } export async function routeEditIntent(params: RouteEditIntentParams, chat?: LlmChatCaller): Promise { const messages = [ { role: 'system', content: `You are a routing assistant for a website CMS. Given a natural language edit request and a manifest of available content sections, determine which section file the edit applies to. Return a JSON object with EXACTLY these fields: - "repo_relative_path": a STRING — the path of the target section file from the manifest (e.g. "content/sections/hero.json"). NEVER null or empty. - "needs_clarification": a boolean — true only if the request is genuinely ambiguous between multiple sections - "reason": a short string explaining the routing decision - "clarification_message": a string (only when needs_clarification is true) — a question to ask the user Rules: - "repo_relative_path" MUST be one of the paths listed in the MANIFEST. Copy it exactly. - If the request is about showing/hiding/enabling/disabling a section, route to that section's file. - If the request mentions events, route to "content/events.json". - When in doubt, pick the most likely section rather than marking ambiguous. Example response: {"repo_relative_path": "content/sections/hero.json", "needs_clarification": false, "reason": "Request about hero section headline"}`, }, { role: 'user', content: `MANIFEST:\n${JSON.stringify(params.manifest, null, 2)}\n\nEDIT REQUEST: "${params.userMessage}"\n\nReturn JSON:`, }, ]; return generateWithValidation({ messages, schema: routingOutputSchema, chat }); } // ── Message Classification ── export interface ClassifyIntentParams { userMessage: string; } /** * First LLM call: classify the user's message intent. * - edit: user wants to change content * - info: user is asking about their site/content * - help: user wants to know what they can do */ export async function classifyMessageIntent(params: ClassifyIntentParams, chat?: LlmChatCaller): Promise { const messages = [ { role: 'system', content: `You classify messages sent to a website CMS by a site owner via SMS. Return a JSON object with EXACTLY these fields: - "intent": one of "edit", "info", or "help" - "reason": a short string explaining the classification Rules: - "edit" — The user wants to CHANGE content (update text, add/remove sections, modify events, toggle visibility, etc.). Any request that implies a modification is an edit. - "info" — The user is ASKING about their current site content (what's on the site, what sections exist, what a section says, etc.). No changes requested. - "help" — The user is asking what they CAN do, how the system works, or needs general assistance. Includes greetings if paired with a question. Examples: "Change the hero headline to Welcome" → {"intent":"edit","reason":"User wants to modify hero headline"} "What does my about section say?" → {"intent":"info","reason":"User is asking about current content"} "What can I do?" → {"intent":"help","reason":"User asking about capabilities"} "Hide the testimonials" → {"intent":"edit","reason":"User wants to change visibility"} "Tell me about my events" → {"intent":"info","reason":"User asking about current events"}`, }, { role: 'user', content: `MESSAGE: "${params.userMessage}"\n\nReturn JSON:`, }, ]; return generateWithValidation({ messages, schema: classificationSchema, chat }); } // ── Info Response Generation ── export interface GenerateInfoResponseParams { userMessage: string; manifest: Array<{ id: string; type: string; title?: string; headline?: string; heading?: string; repo_relative_path: string; visible: boolean }>; chat?: LlmChatCaller; } /** * Generate an informational response about the site's content. * Uses the manifest to describe what sections exist and their content. */ export async function generateInfoResponse(params: GenerateInfoResponseParams, chat?: LlmChatCaller): Promise { const chatFn = params.chat || ollamaChat; const messages = [ { role: 'system', content: `You are a helpful assistant for a website owner. They asked a question about their website's content. Answer their question based on the MANIFEST of sections below. Keep your response under 300 characters (it will be sent via SMS). Be concise and specific. No markdown, just plain text. If they ask what's on their site, list the visible sections briefly. If they ask about a specific section, describe its content briefly.`, }, { role: 'user', content: `MANIFEST:\n${JSON.stringify(params.manifest, null, 2)}\n\nQUESTION: "${params.userMessage}"\n\nAnswer in under 300 chars:`, }, ]; try { const result = await chatFn(messages, PRIMARY_MODEL); return result.trim().slice(0, 320); } catch { // Fallback: generate a basic listing from the manifest const visibleSections = params.manifest.filter(m => m.visible); const sectionNames = visibleSections.map(m => m.title || m.headline || m.heading || m.id).join(', '); return `Your site has these sections: ${sectionNames}`; } } /** Simple summary generation (no schema validation needed) */ export async function generateSummary(params: { before: unknown; after: unknown; repoRelativePath: string; userMessage: string; chat?: LlmChatCaller; }): Promise { const chat = params.chat || ollamaChat; const messages = [ { role: 'system', content: `You summarize content changes for a website owner. Keep summaries under 140 characters, plain text, no markdown. Be specific about what changed. Format: "Change X from A to B" or "Add/remove X".`, }, { role: 'user', content: `File: ${params.repoRelativePath}\nRequest: "${params.userMessage}"\n\nBefore:\n${JSON.stringify(params.before, null, 2)}\n\nAfter:\n${JSON.stringify(params.after, null, 2)}\n\nSummarize the change in under 140 chars:`, }, ]; try { const result = await chat(messages, PRIMARY_MODEL); return result.replace(/["'`]/g, '').trim().slice(0, 280); } catch { // Fallback: generate a basic diff summary return `Update ${params.repoRelativePath} as requested: "${params.userMessage.slice(0, 80)}"`; } }