Compare commits

...

2 Commits

Author SHA1 Message Date
davidherran 8941d1a2c3 feat(ai): FluxAI Level 2 — smart recommender, funnel-aware SPIN, contextual quick-replies
Deploy to VPS / deploy (push) Has been cancelled
Three improvements to the FluxAI sales intelligence:

1. New `recommend_application` tool: analyzes prospect's industry,
   problem, and process keywords against the database to rank-match
   the best FLUX products with confidence scores. Bridges the gap
   between "I have a problem" and "here's our solution."

2. Funnel-aware system prompt: replaces flat SPIN with 4-stage
   pipeline (Qualify → Recommend+Educate → Quantify+Prove →
   Specify+Convert) with clear rules for when to ask vs. act.

3. Contextual quick-reply buttons: after each AI response, dynamic
   suggestions appear based on which tools were used — guiding the
   prospect through the natural next step in the funnel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 08:36:30 -05:00
davidherran 95132476ae feat(ai): extend FluxAI navigation with cross-page routing
The navigate_to_section tool now supports two modes:
A) Same-page scroll — scrollIntoView to real homepage DOM IDs
   (technology, applications-dashboard, applications-deep, global,
   our-story, legacy)
B) Cross-page routing — router.push to /applications/{slug},
   /news, /heritage, /parts with automatic locale prefix.

Fixed: system prompt listed phantom section IDs (hero, news,
heritage, timeline, parts-catalog, contact) that don't exist in
the DOM — causing all non-homepage navigations to silently fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 07:58:05 -05:00
2 changed files with 264 additions and 48 deletions
+171 -32
View File
@@ -89,43 +89,56 @@ Example of a perfect autonomous flow:
6. You output your final text referencing real data, the case study card, and gently offer a consultation. 6. You output your final text referencing real data, the case study card, and gently offer a consultation.
═══════════════════════════════════════════ ═══════════════════════════════════════════
SALES METHODOLOGY — SPIN FRAMEWORK: SALES METHODOLOGY — FUNNEL-AWARE SPIN:
═══════════════════════════════════════════ ═══════════════════════════════════════════
Before deploying tools, qualify the prospect through natural conversation:
S (Situación): What's their current process? What method? What volume? STAGE 1 — QUALIFY (S+P from SPIN):
P (Problema): What's not working? Energy costs? Quality issues? Speed? Trigger: User mentions an industry or problem WITHOUT specifics.
I (Implicación): What does the problem cost them? Rejected batches? Downtime? Action: Ask 1-2 qualifying questions. DO NOT fire tools yet.
N (Necesidad): Confirm the need before recommending. Example: "Estoy en textiles" → "What specific process — post-dye drying, finishing, moisture leveling? And what method do you use currently?"
Example: "I need to reduce costs" → "Which industry and production process? What throughput per hour?"
STAGE 2 — RECOMMEND + EDUCATE:
Trigger: User provides industry + process OR industry + problem.
Action: Call 'recommend_application' first to match their needs to FLUX products. Then chain 'rf_technology_explainer' or 'get_application_knowledge' for the top match.
Example: User says "I dry textiles after dyeing, about 800 kg/h" → recommend_application → navigate to the recommended app page → get_application_knowledge.
STAGE 3 — QUANTIFY + PROVE:
Trigger: User understands the application and wants numbers.
Action: Chain 'energy_savings_calculator' → 'search_installations' → 'show_case_study' with the most relevant real installation.
STAGE 4 — SPECIFY + CONVERT:
Trigger: User asks about equipment, pricing, or next steps.
Action: 'show_equipment_specs' → 'schedule_consultation'. This is the PRIMARY goal.
RULES: RULES:
- If the user mentions an industry WITHOUT specifics → ask 1-2 qualifying questions BEFORE firing tools. - Progress through stages naturally. Do not skip Stage 1 unless the user provides enough context.
Example: "Estoy en textiles" → "What specific process are you evaluating — post-dye drying, finishing, moisture leveling? And what method do you currently use?" - If the user provides DETAILED context (industry + process + volume + problem), jump to Stage 2 or 3.
- If the user provides DETAILED context (industry + process + volume OR problem) → proceed directly to tools. - EXCEPTION: If the user explicitly asks for something specific ("show me case studies", "calculate savings for 500 kg/h"), deliver it immediately regardless of stage.
- Never fire more than 2 tools in a single autonomous sequence without including meaningful analysis text. - Never fire more than 3 tools in a single autonomous sequence without including analysis text between them.
- EXCEPTION: If the user explicitly asks for something specific ("show me case studies", "calculate savings for 500 kg/h"), deliver it immediately. - After EVERY tool result, include a brief human-readable insight before the next tool or suggestion.
IDEAL CONVERSION FLOW:
Qualify → Educate (explainer/comparison) → Quantify (calculator) → Prove (case study) → Recommend (equipment specs) → Convert (consultation)
═══════════════════════════════════════════ ═══════════════════════════════════════════
TOOL USAGE RULES: TOOL USAGE RULES:
═══════════════════════════════════════════ ═══════════════════════════════════════════
1. SEARCH INSTALLATIONS: Use 'search_installations' to find real installations from our database. This is a DATA tool — you receive the results and reason about them before responding. 1. RECOMMEND APPLICATION (NEW — Stage 2): Use 'recommend_application' when the user describes their industry or problem and you need to identify which FLUX product fits. This is your FIRST tool when entering Stage 2. It returns ranked matches from the database.
2. SHOW CASE STUDY: Use 'show_case_study' to display a rich case study card for a specific installation. Requires a nodeId (get it from search_installations first) or an application slug for auto-match. 2. SEARCH INSTALLATIONS: Use 'search_installations' to find real installations. DATA tool — read results and reference them in your response.
3. SAVINGS/ROI: Use 'energy_savings_calculator' when discussing costs, energy, ROI. If volume is missing, assume 500 kg/h and 16h/day. 3. SHOW CASE STUDY: Use 'show_case_study' for a rich case study card. Get nodeId from search_installations first.
4. NAVIGATION: Use 'navigate_to_section' to move the user around the site. 4. SAVINGS/ROI: Use 'energy_savings_calculator' for cost/energy/ROI discussions. Default: 500 kg/h, 16h/day if volume unknown.
5. COMPARISONS: Use 'process_comparison_table' for RF vs conventional tech. 5. NAVIGATION: Use 'navigate_to_section'. Mode A "section" for homepage scroll (valid: "technology", "applications-dashboard", "applications-deep", "global", "our-story", "legacy"). Mode B "url" for cross-page (url="/applications/{slug}", url="/news", url="/heritage", url="/parts").
6. EXPLAIN TECH: Use 'rf_technology_explainer' for physics/mechanism questions. 6. COMPARISONS: Use 'process_comparison_table' for RF vs conventional tech.
7. APPLICATION KNOWLEDGE: Use 'get_application_knowledge' to retrieve deep technical theory, advantages, and datasheets from our knowledge base. 7. EXPLAIN TECH: Use 'rf_technology_explainer' for physics/mechanism questions.
8. EQUIPMENT SPECS: Use 'show_equipment_specs' to display real machine specifications from an actual installation. 8. APPLICATION KNOWLEDGE: Use 'get_application_knowledge' for deep technical theory after identifying the right application.
9. CONSULTATION (PRIMARY GOAL): Use 'schedule_consultation' when the user shows buying intent. 9. EQUIPMENT SPECS: Use 'show_equipment_specs' for real machine specifications.
10. CONSULTATION (PRIMARY GOAL): Use 'schedule_consultation' when the user shows buying intent.
PROACTIVE NEXT STEPS: PROACTIVE NEXT STEPS (always suggest the next logical action):
After showing results, gently suggest the logical next action: recommend → "Let me show you the details of this application..." → navigate to app page or get_application_knowledge
savings → case study ("We have real installations proving these numbers...") knowledge/explainer → "Want to see what this means for your energy costs?" → energy_savings_calculator
case study → equipment specs ("Want to see the technical specs of the system used?") savings → "We have real installations proving these numbers..." → search_installations + show_case_study
equipment → consultation ("Shall I arrange a conversation with our engineering team?") case study → "Want to see the technical specs of the system used?" → show_equipment_specs
equipment → "Shall I arrange a conversation with our engineering team?" → schedule_consultation
comparison → "Let me quantify the difference for your specific operation..." → energy_savings_calculator
LANGUAGE: Respond in the exact same language the user writes in.`; LANGUAGE: Respond in the exact same language the user writes in.`;
} }
@@ -199,6 +212,106 @@ export async function POST(req: Request) {
// DATA TOOLS (have execute, return data for AI to reason about) // DATA TOOLS (have execute, return data for AI to reason about)
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// ── TOOL 0: Recommend Application (DATA — smart product matching) ──
recommend_application: tool({
description: `Analyze the user's needs and recommend the best FLUX application(s) from the database. Use this FIRST when a prospect describes their industry, problem, or process without specifying a particular FLUX product. Returns ranked matches with confidence scores and reasoning. After getting results, chain into 'navigate_to_section' (url) to show them the application page, or 'get_application_knowledge' for deep technical detail.`,
inputSchema: z.object({
industryKeywords: z.array(z.string())
.describe('Keywords about their industry, e.g. ["textile", "fabric", "drying", "moisture"]'),
problemDescription: z.string()
.describe('What the user is trying to solve, e.g. "too much moisture after dyeing, high energy costs"'),
processType: z.string().optional()
.describe('Specific process if mentioned, e.g. "post-dye drying", "defrosting meat blocks"'),
currentMethod: z.string().optional()
.describe('Their current equipment/method if mentioned, e.g. "stenter", "steam autoclave"'),
}),
execute: async ({ industryKeywords, problemDescription, processType, currentMethod }) => {
const apps = await prisma.application.findMany({
where: { isActive: true },
select: {
slug: true,
title: true,
subtitle: true,
category: true,
shortDescription: true,
heroDescription: true,
dashboardMetricsJson: true,
},
});
// Score each application against the user's needs
const scored = apps.map((app: any) => {
const searchText = `${app.title} ${app.subtitle || ''} ${app.shortDescription} ${app.category} ${(app.heroDescription || '').slice(0, 800)}`.toLowerCase();
let score = 0;
const matchedKeywords: string[] = [];
for (const kw of industryKeywords) {
if (searchText.includes(kw.toLowerCase())) {
score += 10;
matchedKeywords.push(kw);
}
}
// Check problem description words
const problemWords = problemDescription.toLowerCase().split(/\s+/).filter((w: string) => w.length > 3);
for (const pw of problemWords) {
if (searchText.includes(pw)) score += 3;
}
// Bonus for process type match
if (processType && searchText.includes(processType.toLowerCase())) score += 15;
// Bonus for current method match (they're looking to replace it)
if (currentMethod && searchText.includes(currentMethod.toLowerCase())) score += 8;
return {
slug: app.slug,
title: app.title,
subtitle: app.subtitle,
category: app.category,
shortDescription: app.shortDescription,
score,
matchedKeywords,
metrics: safeParseJson(app.dashboardMetricsJson, []),
};
});
const ranked = scored
.filter((a: any) => a.score > 0)
.sort((a: any, b: any) => b.score - a.score)
.slice(0, 3);
if (ranked.length === 0) {
return {
found: 0,
message: 'No direct match found. Ask the user for more details about their specific process and materials.',
allApplications: apps.map((a: any) => ({ slug: a.slug, title: a.title, category: a.category })),
};
}
return {
found: ranked.length,
recommendations: ranked.map((r: any, idx: number) => ({
rank: idx + 1,
slug: r.slug,
title: r.title,
subtitle: r.subtitle,
category: r.category,
shortDescription: r.shortDescription,
matchedKeywords: r.matchedKeywords,
confidenceScore: Math.min(100, r.score),
topMetrics: r.metrics.slice(0, 3),
})),
userContext: {
industryKeywords,
problemDescription,
processType: processType || null,
currentMethod: currentMethod || null,
},
};
},
}),
// ── TOOL 1: Search Installations (DATA — queries Prisma) ── // ── TOOL 1: Search Installations (DATA — queries Prisma) ──
search_installations: tool({ search_installations: tool({
description: `Search the FLUX global installation database for real case studies and projects. Returns summaries for you to analyze and reference in your response. Use when you need to find relevant installations to back up your claims, or when the user asks about references, case studies, or proven results. You can then call 'show_case_study' with a specific nodeId to display the full card to the user.`, description: `Search the FLUX global installation database for real case studies and projects. Returns summaries for you to analyze and reference in your response. Use when you need to find relevant installations to back up your claims, or when the user asks about references, case studies, or proven results. You can then call 'show_case_study' with a specific nodeId to display the full card to the user.`,
@@ -459,13 +572,39 @@ export async function POST(req: Request) {
}, },
}), }),
// ── TOOL 6: Navigate to Section (client-side — NO execute) ── // ── TOOL 6: Navigate (client-side — NO execute) ──────────────
// Handles BOTH same-page scrolling (section) and cross-page
// routing (url). The client inspects which field is set.
navigate_to_section: tool({ navigate_to_section: tool({
description: `Maps the user to a specific section of the FLUX website. Use when the user says "show me", "take me to", "where is", or asks about a specific page section. Available sections: "hero", "applications-dashboard", "applications-deep", "global" (globe), "timeline", "heritage", "news", "parts-catalog", "contact".`, description: `Navigate the user to any part of the FLUX website.
TWO MODES:
A) SAME-PAGE SCROLL — set "section" to scroll to a homepage element by its DOM id:
"technology" (hero/intro), "applications-dashboard", "applications-deep", "global" (interactive globe), "our-story" (timeline), "legacy" (Patrizio legacy)
ONLY use these exact IDs. They only work when the user is on the homepage.
B) CROSS-PAGE NAVIGATION — set "url" to a route path (WITHOUT locale prefix). The client adds the locale automatically:
"/news" — news hub listing
"/news/{slug}" — specific article (use a real slug from context)
"/heritage" — company heritage deep-dive
"/parts" — spare parts catalog (B2B portal)
"/applications/{slug}" — application detail page (use real slug from the database list above)
RULES:
- ALWAYS prefer mode B for news, heritage, parts, and application detail pages.
- Only use mode A for scrolling within the homepage.
- When using mode B, use application slugs from the database list in this prompt.
- "show me textile drying" → url="/applications/textile-drying"
- "take me to the news" → url="/news"
- "show me the heritage" → url="/heritage"
- "show me the spare parts" → url="/parts"
- "show me the globe" → section="global"
- "go to the top" → section="technology"`,
inputSchema: z.object({ inputSchema: z.object({
section: z.string().describe('Target section ID'), section: z.string().optional().describe('Homepage element ID for same-page scroll'),
url: z.string().optional().describe('Route path for cross-page navigation (e.g. "/applications/textile-drying", "/news", "/heritage"). No locale prefix.'),
subAction: z.enum(['none', 'activate-tab', 'highlight-node']).default('none'), subAction: z.enum(['none', 'activate-tab', 'highlight-node']).default('none'),
tabId: z.string().optional().describe('Application slug to activate'), tabId: z.string().optional().describe('Application slug to activate on dashboard tab'),
nodeId: z.string().optional().describe('Globe node ID to highlight'), nodeId: z.string().optional().describe('Globe node ID to highlight'),
}), }),
}), }),
+79 -2
View File
@@ -5,6 +5,7 @@ import { Sparkles, ArrowRight, X, Minus, Database, Maximize2, Minimize2 } from "
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { useUIStore } from "@/lib/store/uiStore"; import { useUIStore } from "@/lib/store/uiStore";
import { useRouter, usePathname } from "next/navigation";
import { useState, useEffect, useRef, useMemo } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
// ── Renderers ── // ── Renderers ──
@@ -24,6 +25,10 @@ export default function SilentObserver() {
setHighlightedMapNode, setSelectedMarkerId, setHighlightedMapNode, setSelectedMarkerId,
} = useUIStore(); } = useUIStore();
const router = useRouter();
const pathname = usePathname();
const locale = pathname?.split('/')[1] || 'en';
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
const [isWideMode, setIsWideMode] = useState(false); const [isWideMode, setIsWideMode] = useState(false);
@@ -68,10 +73,23 @@ export default function SilentObserver() {
if (toolCall.dynamic) return; if (toolCall.dynamic) return;
if (toolCall.toolName === "navigate_to_section") { if (toolCall.toolName === "navigate_to_section") {
const { section, subAction, tabId, nodeId } = toolCall.input as { const { section, url, subAction, tabId, nodeId } = toolCall.input as {
section: string; subAction?: string; tabId?: string; nodeId?: string; section?: string; url?: string; subAction?: string; tabId?: string; nodeId?: string;
}; };
handleClose(); handleClose();
if (url) {
// Mode B: Cross-page navigation
setTimeout(() => {
router.push(`/${locale}${url}`);
}, 400);
addToolOutput({
tool: "navigate_to_section" as any,
toolCallId: toolCall.toolCallId,
output: `Navigated to page "${url}"`,
});
} else if (section) {
// Mode A: Same-page scroll (existing behavior)
setTimeout(() => { setTimeout(() => {
const el = document.getElementById(section); const el = document.getElementById(section);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
@@ -87,6 +105,7 @@ export default function SilentObserver() {
output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`, output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`,
}); });
} }
}
}, },
}); });
@@ -168,6 +187,11 @@ export default function SilentObserver() {
const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>); const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>);
const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>); const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>);
if (part.type === "tool-recommend_application") {
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Analyzing your needs..." />;
if (part.state === "output-available") return null;
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
}
if (part.type === "tool-search_installations") { if (part.type === "tool-search_installations") {
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />; if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />;
if (part.state === "output-available") return null; if (part.state === "output-available") return null;
@@ -218,6 +242,41 @@ export default function SilentObserver() {
return null; return null;
} }
// ═══ Contextual Quick-Replies based on last assistant message ═══
function getContextualSuggestions(): string[] {
if (isLoading || messages.length === 0) return [];
const lastAssistant = [...messages].reverse().find(m => m.role === "assistant");
if (!lastAssistant?.parts) return [];
const toolTypes = new Set(
lastAssistant.parts
.filter((p: any) => p.type?.startsWith("tool-") && p.state === "output-available")
.map((p: any) => p.type)
);
// Priority order: suggest the next logical funnel step
if (toolTypes.has("tool-schedule_consultation")) return []; // End of funnel
if (toolTypes.has("tool-show_equipment_specs"))
return ["Schedule a consultation", "Compare with traditional methods"];
if (toolTypes.has("tool-show_case_study"))
return ["Show me equipment specs", "Calculate savings for my operation", "Schedule a consultation"];
if (toolTypes.has("tool-energy_savings_calculator"))
return ["Show me a real installation", "See equipment specs", "How does RF heating work?"];
if (toolTypes.has("tool-process_comparison_table"))
return ["Calculate savings for my operation", "Show me proven installations"];
if (toolTypes.has("tool-rf_technology_explainer"))
return ["What would I save in energy costs?", "Show me real installations"];
if (toolTypes.has("tool-recommend_application") || toolTypes.has("tool-get_application_knowledge"))
return ["Calculate energy savings", "Show me case studies", "Compare RF vs my current method"];
if (toolTypes.has("tool-navigate_to_section"))
return ["Tell me more about this", "How much energy can I save?"];
// Default: if assistant responded with text only (Stage 1 qualification)
return [];
}
const suggestions = getContextualSuggestions();
return ( return (
<> <>
<AnimatePresence> <AnimatePresence>
@@ -304,6 +363,24 @@ export default function SilentObserver() {
{[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))} {[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))}
</div> </div>
)} )}
{suggestions.length > 0 && !isLoading && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
className="flex flex-wrap gap-1.5 pt-1 pb-2"
>
{suggestions.map((s) => (
<button
key={s}
onClick={() => { sendMessage({ text: s }); }}
className="px-3 py-1.5 rounded-full text-[11px] font-medium bg-[#0066CC]/[0.06] dark:bg-[#4DA6FF]/[0.08] border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 text-[#0066CC] dark:text-[#4DA6FF] hover:bg-[#0066CC]/10 dark:hover:bg-[#4DA6FF]/15 hover:border-[#0066CC]/20 dark:hover:border-[#4DA6FF]/20 active:scale-95 transition-all duration-200"
>
{s}
</button>
))}
</motion.div>
)}
</div> </div>
<form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300"> <form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300">