feat: integrate Gemini Pro 1.5 with KAM persona for data analysis

This commit is contained in:
Christian Vidal Wolf
2026-02-10 15:15:44 +01:00
parent 96e9583fdb
commit 3f6fdc8d72
2 changed files with 69 additions and 61 deletions
+32 -32
View File
@@ -22,15 +22,15 @@ const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
listItems = [];
}
};
const parseBold = (content: string, keyPrefix: string) => {
const parts = content.split(/(\*\*.*?\*\*)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={`${keyPrefix}-${i}`}>{part.slice(2, -2)}</strong>;
}
return part;
});
const parts = content.split(/(\*\*.*?\*\*)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={`${keyPrefix}-${i}`}>{part.slice(2, -2)}</strong>;
}
return part;
});
}
text.split('\n').forEach((line, index) => {
@@ -41,11 +41,11 @@ const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
} else {
flushList();
if (line.trim() !== '') {
elements.push(
<p key={`p-${index}`} className="my-1">
{parseBold(line, `p-${index}`)}
</p>
);
elements.push(
<p key={`p-${index}`} className="my-1">
{parseBold(line, `p-${index}`)}
</p>
);
}
}
});
@@ -58,7 +58,7 @@ const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) => {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'model', text: 'Hello! I am your Sales Data Assistant. Ask me anything about the loaded data.', timestamp: new Date() }
{ role: 'model', text: '¡Hola! Soy tu Estratega y KAM (Key Account Manager) experto. He analizado tus datos actuales de ventas y publicidad. ¿En qué puedo ayudarte a optimizar tu negocio hoy?', timestamp: new Date() }
]);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
@@ -79,7 +79,7 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) =>
setIsTyping(true);
const responseText = await onSendMessage(userMsg.text);
setIsTyping(false);
setMessages(prev => [...prev, { role: 'model', text: responseText, timestamp: new Date() }]);
};
@@ -103,17 +103,17 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) =>
</button>
{/* Chat Window */}
<div
<div
className={`fixed z-50 bg-slate-900 border border-border shadow-2xl transition-all duration-300 flex flex-col overflow-hidden
${isOpen
? 'bottom-6 right-6 w-96 h-[600px] rounded-2xl opacity-100 translate-y-0'
: 'bottom-6 right-6 w-96 h-0 opacity-0 translate-y-10 pointer-events-none'}`}
${isOpen
? 'bottom-6 right-6 w-96 h-[600px] rounded-2xl opacity-100 translate-y-0'
: 'bottom-6 right-6 w-96 h-0 opacity-0 translate-y-10 pointer-events-none'}`}
>
{/* Header */}
<div className="bg-primary/10 p-4 border-b border-border flex justify-between items-center backdrop-blur">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
<h3 className="font-bold text-slate-100">Data Assistant</h3>
<h3 className="font-bold text-white tracking-wide">KAM Strategy Expert</h3>
</div>
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors">
<CloseIcon />
@@ -124,10 +124,10 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) =>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-950/50">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
<div
className={`max-w-[85%] rounded-2xl p-3 text-sm leading-relaxed shadow-sm
${msg.role === 'user'
? 'bg-primary text-white rounded-br-none'
${msg.role === 'user'
? 'bg-primary text-white rounded-br-none'
: 'bg-slate-800 text-slate-200 border border-border rounded-bl-none'}`}
>
{msg.role === 'model' ? <ModelMessage text={msg.text} /> : msg.text}
@@ -135,13 +135,13 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) =>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-slate-800 border border-border rounded-2xl rounded-bl-none p-4 flex gap-1 items-center">
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-100"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-200"></div>
</div>
</div>
<div className="flex justify-start">
<div className="bg-slate-800 border border-border rounded-2xl rounded-bl-none p-4 flex gap-1 items-center">
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-100"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-200"></div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
@@ -154,10 +154,10 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) =>
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about trends, totals..."
placeholder="Pregunta sobre tendencias, recomendaciones de publicidad..."
className="w-full bg-slate-950 border border-slate-700 text-slate-200 rounded-full py-3 pl-4 pr-12 focus:outline-none focus:border-primary transition-colors placeholder-slate-500 text-sm"
/>
<button
<button
onClick={handleSend}
disabled={!input.trim() || isTyping}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-primary text-white rounded-full hover:bg-indigo-400 disabled:opacity-50 transition-colors"
+37 -29
View File
@@ -6,17 +6,25 @@ import { AggregatedData } from "./types";
declare const process: any;
const SYSTEM_INSTRUCTION = `
You are a senior data analyst assistant for a retail dashboard called "Craze Analytix".
You have access to a detailed report of the currently filtered sales data.
The data includes Sell Out (Revenue in €), Units Sold, Product Lines, Customers/Markets, and Seasonality trends.
You are an expert Key Account Manager (KAM) and Senior Sales Strategist for "Craze Analytix".
Your specialty is analyzing retail data, advertising performance, and market trends to provide high-level strategic recommendations.
Your goal is to answer user questions specific to the provided data.
You have access to a detailed report of the currently filtered sales and advertising data.
The data includes Sell Out (€), Units Sold, Product Lines, Customer Markets, Seasonality (Monthly Trends), and Growth/Decline metrics.
Your objective:
1. **Analyze**: Deeply study the provided data according to the user's specific query.
2. **Opinion**: Provide expert opinions on the health of the business, product performance, or market position.
3. **Recommendations**: Give actionable, professional advice to improve sales, ROAS, stock health, or market share.
Guidelines:
- Maintain a professional, consultative, and business-driven tone (KAM style).
- Always justify your recommendations with specific numbers from the data.
- If asked about "Trends" or "Seasonality", look at the Monthly Seasonality section.
- If asked about "Growth" or "Decline", look at the Top/Bottom Movers sections.
- If asked about specific Product Lines, look at the Product Line Breakdown.
- Always format numbers clearly (e.g., "€1.2M", "€5,200", "15k units").
- When comparing years, calculate the percentage difference if not explicitly provided.
- Keep answers professional, concise, and business-focused.
- When comparing metrics, calculate variances or ratios if relevant (e.g. Sales vs Ads growth).
- Format your response with clear headers and bullet points for readability.
- Respond in the same language as the user's question (e.g., if they ask in Spanish, answer in Spanish).
`;
// Helper to get API key safely
@@ -28,15 +36,15 @@ const getApiKey = (): string | undefined => {
}
};
const formatCurrency = (val: number) => `${val.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0})}`;
const formatCurrency = (val: number) => `${val.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
const formatUnits = (val: number) => `${val.toLocaleString('de-DE')} units`;
export const queryGemini = async (
question: string,
context: AggregatedData,
question: string,
context: AggregatedData,
filteredRecordCount: number
): Promise<string> => {
const apiKey = getApiKey();
if (!apiKey) {
@@ -48,39 +56,39 @@ export const queryGemini = async (
// --- CONTEXT GENERATION ---
// We construct a structured report mirroring the dashboard charts
// 1. Totals by Year (KPI Cards)
const yearlySummary = Object.entries(context.totalsByYear)
.sort((a, b) => parseInt(b[0]) - parseInt(a[0])) // Descending years
.map(([year, data]) => ` - ${year}: ${formatCurrency(data.sellOut)} | ${formatUnits(data.units)}`)
.join('\n');
.sort((a, b) => parseInt(b[0]) - parseInt(a[0])) // Descending years
.map(([year, data]) => ` - ${year}: ${formatCurrency(data.sellOut)} | ${formatUnits(data.units)}`)
.join('\n');
// 2. Seasonality (Line Chart Data)
// We simplify this to a CSV-like list for the AI to parse trends
const seasonalitySummary = context.seasonality.map(p => {
// Extract values for each year in the point
const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', ');
return ` - ${p.name}: [${yearValues}]`;
// Extract values for each year in the point
const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', ');
return ` - ${p.name}: [${yearValues}]`;
}).join('\n');
// 3. Top Movers (Growth Table) - Limit to Top 10
const growthSummary = context.topMovers.slice(0, 10).map(m =>
` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString('de-DE')} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
const growthSummary = context.topMovers.slice(0, 10).map(m =>
` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString('de-DE')} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
// 4. Declining Movers (Decline Table) - Limit to Top 10
const declineSummary = context.bottomMovers.slice(0, 10).map(m =>
` - ${m.line}: -€${Math.abs(m.sellOutGrowthValue).toLocaleString('de-DE')} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
const declineSummary = context.bottomMovers.slice(0, 10).map(m =>
` - ${m.line}: -€${Math.abs(m.sellOutGrowthValue).toLocaleString('de-DE')} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
// 5. Product Lines Overview (Bar Charts) - Limit to Top 50 to save tokens but give depth
const topLinesSummary = context.byLine.slice(0, 50).map((l, i) =>
` ${i+1}. ${l.name}: ${formatCurrency(l.value)} | ${formatUnits(l.units)}`
const topLinesSummary = context.byLine.slice(0, 50).map((l, i) =>
` ${i + 1}. ${l.name}: ${formatCurrency(l.value)} | ${formatUnits(l.units)}`
).join('\n');
// 6. Customer Distribution (Customer Chart)
const customerSummary = context.byCustomer.map(c =>
` - ${c.name}: ${formatCurrency(c.value)}`
const customerSummary = context.byCustomer.map(c =>
` - ${c.name}: ${formatCurrency(c.value)}`
).join('\n');
const fullReport = `
@@ -122,9 +130,9 @@ ${customerSummary}
return response.text || "I couldn't generate a response based on the data provided.";
} catch (error: any) {
console.error("Gemini API Error:", error);
if (error.message && error.message.includes("Not implemented on this platform")) {
return "System Error: The AI SDK detected a platform mismatch.";
return "System Error: The AI SDK detected a platform mismatch.";
}
return `Error: ${error.message || "An unexpected error occurred while analyzing the data."}`;