Add message intent classification (edit/info/help) before routing

Introduces a two-LLM-call pipeline: the first call classifies the user's
message intent as "edit", "info", or "help". Edit messages proceed through
the existing routing → edit → propose flow. Info messages get a generated
response about site content from the manifest. Help messages get a
templated capabilities overview. This handles open-ended questions like
"What can I do?" or "What does my site have on it?" which previously
had no path through the system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-17 20:17:22 -05:00
parent 464d2c8230
commit 3cf3694ee7
4 changed files with 120 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { routingOutputSchema, type RoutingOutput } from '@dynamic-sites/shared';
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';
@@ -148,6 +148,90 @@ Example response:
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<ClassificationOutput> {
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<string> {
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;

View File

@@ -5,7 +5,7 @@ import type { EditJobPayload } from '@dynamic-sites/shared';
import { schemaForRepoRelativePath } from '@dynamic-sites/shared';
import { createProposal, getProposal, updateProposalStatus } from '../db.js';
import { writeContentFile } from '../io/write-content.js';
import { generateEditedJson, routeEditIntent, generateSummary } from '../llm/client.js';
import { generateEditedJson, routeEditIntent, generateSummary, classifyMessageIntent, generateInfoResponse } from '../llm/client.js';
import { buildSectionManifest } from './manifest.js';
import { sendSms } from '../sms/reply.js';
import { SMS_TEMPLATES } from '../sms/templates.js';
@@ -25,6 +25,30 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
const log = logger.child({ jobId: job.id, kind: 'propose' });
try {
// Step 0: Classify message intent (edit, info, or help)
const classification = await classifyMessageIntent({ userMessage: job.message });
log.info({ event: 'classification.result', intent: classification.intent, reason: classification.reason }, 'Message classified');
// ── HELP intent: send help message, no further LLM calls ──
if (classification.intent === 'help') {
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.HELP());
}
return;
}
// ── INFO intent: generate informational response about site ──
if (classification.intent === 'info') {
const manifest = buildSectionManifest();
const infoResponse = await generateInfoResponse({ userMessage: job.message, manifest });
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, infoResponse);
}
return;
}
// ── EDIT intent: proceed with existing edit flow ──
// Step 1: Route — determine which file the edit targets
let repoRelativePath = job.repo_relative_path;

View File

@@ -31,4 +31,7 @@ export const SMS_TEMPLATES = {
MMS_NOT_SUPPORTED: () =>
`Image uploads aren't supported yet. Please describe your change in text.`,
HELP: () =>
`I can help you edit your website via text! Just tell me what to change. Examples:\n- "Change the hero headline to Welcome Home"\n- "Hide the testimonials section"\n- "Add an event: Wine Tasting, June 15, 7pm"\n- "What does my about section say?"\n\nAfter each edit, I'll show you the change and you reply YES to apply or NO to cancel.`,
} as const;

View File

@@ -142,6 +142,13 @@ export const editJobPayloadSchema = z.discriminatedUnion('kind', [
]);
export type EditJobPayload = z.infer<typeof editJobPayloadSchema>;
// ── Classification output (first LLM call) ──
export const classificationSchema = z.object({
intent: z.enum(['edit', 'info', 'help']),
reason: z.string(),
});
export type ClassificationOutput = z.infer<typeof classificationSchema>;
// ── Routing output (LLM structured output) ──
export const routingOutputSchema = z.object({
repo_relative_path: z.string(),