myavana-bot-test-core 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ // funnelEvents.js
2
+ const { pgClient } = require('./database');
3
+
4
+ /**
5
+ * Logs a D2C conversion-funnel milestone (intake_completed, paywall_shown,
6
+ * purchase_completed, hhcp_delivered, consult_triaged, ...). Additive to
7
+ * conversation_metrics.conversion_event — lets funnel drop-off be queried per stage.
8
+ *
9
+ * @param {string} userId
10
+ * @param {string} conversationId
11
+ * @param {string} eventType
12
+ * @param {object} [metadata]
13
+ */
14
+ const logFunnelEvent = async (userId, conversationId, eventType, metadata = {}) => {
15
+ const query = `
16
+ INSERT INTO chatbot_funnel_events (user_id, conversation_id, event_type, metadata)
17
+ VALUES ($1, $2, $3, $4)
18
+ RETURNING *;
19
+ `;
20
+ const values = [userId, conversationId, eventType, JSON.stringify(metadata)];
21
+ try {
22
+ const res = await pgClient.query(query, values);
23
+ return res.rows[0];
24
+ } catch (err) {
25
+ console.error('Error logging funnel event:', err);
26
+ return null;
27
+ }
28
+ };
29
+
30
+ module.exports = {
31
+ logFunnelEvent,
32
+ };
@@ -0,0 +1,84 @@
1
+ // messageContextBuilder.js
2
+ // Single injection point for all per-message dynamic context.
3
+ // Order of blocks injected before each chat.send():
4
+ // 1. Persona — tone, style, trend framing instruction
5
+ // 2. Trends — what's currently popular in hair care (48h TTL)
6
+ // 3. Driver — what the AI should DO this turn (trend-aware)
7
+ // 4. Memory — durable facts/preferences relevant to the current message (Local Agent Memory)
8
+
9
+ const { getUserPersona, buildContextBlock } = require('./personaClassifier');
10
+ const { getConversationDirective, inferStage, extractTrendSummary } = require('./conversationDriver');
11
+ const { getTrendContext } = require('./trendManager');
12
+ const { retrieveRelevantMemories } = require('./agentMemory');
13
+
14
+ /**
15
+ * Assembles the dynamic context block to prepend to each user message before chat.send().
16
+ * Returns an empty string when there is nothing to inject (first-time users, errors).
17
+ *
18
+ * @param {string} userId
19
+ * @param {Array<{role:string, content:string}>} [chatHistory=[]]
20
+ * @param {string} [currentMessage] - the message about to be sent; falls back to the last user turn in chatHistory
21
+ * @returns {Promise<string>}
22
+ */
23
+ async function buildMessageContext(userId, chatHistory = [], currentMessage) {
24
+ const parts = [];
25
+
26
+ // Shared state — built up across steps so later steps can use earlier results
27
+ let personaType = null;
28
+ let trendContext = '';
29
+
30
+ // 1. Persona context (tone + communication style)
31
+ // Built first so trendFraming can be attached to persona block below
32
+ let persona = null;
33
+ try {
34
+ persona = await getUserPersona(userId);
35
+ if (persona) personaType = persona.personaType || null;
36
+ } catch (err) {
37
+ console.error('buildMessageContext: persona fetch failed:', err.message);
38
+ }
39
+
40
+ // 2. Trend context (what's trending — 48h cache, background refresh)
41
+ try {
42
+ const stage = inferStage(chatHistory);
43
+ trendContext = await getTrendContext(stage, personaType);
44
+ } catch (err) {
45
+ console.error('buildMessageContext: trend fetch failed:', err.message);
46
+ }
47
+
48
+ // Now build the persona block — hasTrends flag triggers trend framing instruction
49
+ if (persona) {
50
+ try {
51
+ const block = buildContextBlock(persona, trendContext.length > 0);
52
+ if (block) parts.push(block);
53
+ } catch (err) {
54
+ console.error('buildMessageContext: buildContextBlock failed:', err.message);
55
+ }
56
+ }
57
+
58
+ // Add trend block after persona
59
+ if (trendContext) parts.push(trendContext);
60
+
61
+ // 3. Conversation-driving directive (trend-aware, persona-aware)
62
+ try {
63
+ const trendSummary = extractTrendSummary(trendContext);
64
+ const directive = getConversationDirective(chatHistory, personaType, trendSummary);
65
+ if (directive) parts.push(directive);
66
+ } catch (err) {
67
+ console.error('buildMessageContext: conversation driver failed:', err.message);
68
+ }
69
+
70
+ // 4. Relevant long-term memory (Local Agent Memory — durable facts/preferences)
71
+ try {
72
+ const queryText = currentMessage || [...chatHistory].reverse().find(m => m.role === 'user')?.content;
73
+ const memories = await retrieveRelevantMemories(userId, queryText);
74
+ if (memories.length > 0) {
75
+ parts.push(`\nRelevant memory:\n${memories.map(m => `- ${m}`).join('\n')}\n`);
76
+ }
77
+ } catch (err) {
78
+ console.error('buildMessageContext: memory retrieval failed:', err.message);
79
+ }
80
+
81
+ return parts.join('');
82
+ }
83
+
84
+ module.exports = { buildMessageContext };
@@ -0,0 +1,214 @@
1
+ // personaClassifier.js
2
+ const { pgClient, redisClient } = require('./database');
3
+
4
+ const PERSONA_TYPES = {
5
+ OVERWHELMED: 'overwhelmed', // stressed, multiple issues, needs reassurance
6
+ RESEARCHER: 'researcher', // detail-oriented, wants science and data
7
+ EXPLORER: 'explorer', // curious, open to experimenting, asks broad questions
8
+ ACTION_ORIENTED: 'action-oriented', // short messages, wants quick steps and no fluff
9
+ ENGAGED: 'engaged', // high session depth, follows through on suggestions
10
+ };
11
+
12
+ const COMMUNICATION_STYLES = {
13
+ NURTURING: 'nurturing', // warm, validating, patient
14
+ PROFESSIONAL: 'professional', // clear, structured, minimal filler
15
+ CASUAL: 'casual', // friendly, relatable, light
16
+ CONCISE: 'concise', // brief, direct, bullet points preferred
17
+ };
18
+
19
+ const REDIS_TTL = 60 * 60 * 24; // 24h
20
+
21
+ /**
22
+ * Classifies user persona from post-processing signals and conversation patterns.
23
+ * Pure function — no I/O. Safe to call any time without side effects.
24
+ *
25
+ * @param {object} opts
26
+ * @param {object} opts.profileUpdates - Fields extracted by postProcessConversation
27
+ * @param {Array} opts.chatHistory - Full conversation history array
28
+ * @returns {{ personaType, communicationStyle, toneDirectives: string[] }}
29
+ */
30
+ function classifyFromSignals({ profileUpdates = {}, chatHistory = [] }) {
31
+ const {
32
+ confidence_level_inferred,
33
+ desperation_level_inferred,
34
+ experimenting_interest,
35
+ boredom_expression,
36
+ } = profileUpdates;
37
+
38
+ const userMessages = chatHistory.filter(m => m.role === 'user');
39
+ const messageCount = userMessages.length;
40
+ const avgLength = messageCount > 0
41
+ ? userMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0) / messageCount
42
+ : 0;
43
+
44
+ let personaType = PERSONA_TYPES.ENGAGED;
45
+ let communicationStyle = COMMUNICATION_STYLES.CASUAL;
46
+ const toneDirectives = [];
47
+
48
+ // Overwhelmed: high desperation or expressed low confidence
49
+ if (desperation_level_inferred === 'high' || confidence_level_inferred === 'low') {
50
+ personaType = PERSONA_TYPES.OVERWHELMED;
51
+ communicationStyle = COMMUNICATION_STYLES.NURTURING;
52
+ toneDirectives.push('lead with empathy before solutions');
53
+ toneDirectives.push('break advice into small, clear steps');
54
+ toneDirectives.push('avoid information overload — one thing at a time');
55
+ }
56
+ // Researcher: long detailed messages or self-reported high confidence
57
+ else if (avgLength > 200 || confidence_level_inferred === 'high') {
58
+ personaType = PERSONA_TYPES.RESEARCHER;
59
+ communicationStyle = COMMUNICATION_STYLES.PROFESSIONAL;
60
+ toneDirectives.push('skip basic explanations — treat them as a peer');
61
+ toneDirectives.push('include specific reasoning and science where relevant');
62
+ toneDirectives.push('precise language over reassuring language');
63
+ }
64
+ // Explorer: interested in experimenting, open to discovery
65
+ else if (experimenting_interest === true) {
66
+ personaType = PERSONA_TYPES.EXPLORER;
67
+ communicationStyle = COMMUNICATION_STYLES.CASUAL;
68
+ toneDirectives.push('encourage curiosity and experimentation');
69
+ toneDirectives.push('offer options rather than one prescriptive answer');
70
+ toneDirectives.push('frame suggestions as things worth trying');
71
+ }
72
+ // Action-oriented: short messages after multiple turns — wants speed
73
+ else if (avgLength < 60 && messageCount > 2) {
74
+ personaType = PERSONA_TYPES.ACTION_ORIENTED;
75
+ communicationStyle = COMMUNICATION_STYLES.CONCISE;
76
+ toneDirectives.push('lead with the answer — skip the preamble');
77
+ toneDirectives.push('use bullet points for multi-step advice');
78
+ toneDirectives.push('keep responses short and direct');
79
+ }
80
+
81
+ // Boredom modifier — user seems disengaged, regardless of persona
82
+ if (boredom_expression === true) {
83
+ toneDirectives.push('vary your approach — try an unexpected angle or a direct question');
84
+ }
85
+
86
+ return { personaType, communicationStyle, toneDirectives };
87
+ }
88
+
89
+ // How each persona should frame any trending topic that surfaces in the conversation.
90
+ // Appended to persona directives when trends are present in context.
91
+ const TREND_FRAMING = {
92
+ [COMMUNICATION_STYLES.NURTURING]: 'if a trend is relevant, offer it as one gentle option — do not overwhelm with choices',
93
+ [COMMUNICATION_STYLES.PROFESSIONAL]: 'when referencing trends, include the reason it works — evidence or mechanism, not just hype',
94
+ [COMMUNICATION_STYLES.CASUAL]: 'weave trending topics in naturally; frame them as exciting things worth trying',
95
+ [COMMUNICATION_STYLES.CONCISE]: 'state any relevant trend as a single quick tip — no backstory',
96
+ };
97
+
98
+ /**
99
+ * Builds the short context string to prepend to each user message.
100
+ * When hasTrends is true, appends a per-persona trend framing instruction.
101
+ *
102
+ * @param {{ personaType, communicationStyle, toneDirectives }} persona
103
+ * @param {boolean} [hasTrends=false] - Whether trend context is also being injected
104
+ * @returns {string}
105
+ */
106
+ function buildContextBlock({ personaType, communicationStyle, toneDirectives = [] } = {}, hasTrends = false) {
107
+ if (!personaType) return '';
108
+
109
+ const allDirectives = [...toneDirectives];
110
+ if (hasTrends && communicationStyle && TREND_FRAMING[communicationStyle]) {
111
+ allDirectives.push(TREND_FRAMING[communicationStyle]);
112
+ }
113
+
114
+ const directives = allDirectives.length > 0 ? ` — ${allDirectives.join('; ')}` : '';
115
+ return `[User persona: ${personaType}, style: ${communicationStyle}${directives}]\n`;
116
+ }
117
+
118
+ /**
119
+ * Persists classified persona to user_preferences (upsert) and Redis.
120
+ * Non-throwing — persona save is best-effort, never blocks the response.
121
+ *
122
+ * @param {string} userId
123
+ * @param {{ personaType, communicationStyle, toneDirectives }} persona
124
+ */
125
+ async function saveUserPersona(userId, { personaType, communicationStyle, toneDirectives }) {
126
+ try {
127
+ await pgClient.query(
128
+ `INSERT INTO user_preferences (user_id, persona_type, communication_style, engagement_preferences)
129
+ VALUES ($1, $2, $3, $4)
130
+ ON CONFLICT (user_id) DO UPDATE SET
131
+ persona_type = EXCLUDED.persona_type,
132
+ communication_style = EXCLUDED.communication_style,
133
+ engagement_preferences = EXCLUDED.engagement_preferences`,
134
+ [userId, personaType, communicationStyle, JSON.stringify({ toneDirectives })]
135
+ );
136
+ } catch (err) {
137
+ console.error('PersonaClassifier: PostgreSQL save failed:', err.message);
138
+ }
139
+
140
+ try {
141
+ if (redisClient) {
142
+ await redisClient.set(
143
+ `persona:${userId}`,
144
+ JSON.stringify({ personaType, communicationStyle, toneDirectives }),
145
+ { EX: REDIS_TTL }
146
+ );
147
+ }
148
+ } catch (err) {
149
+ console.error('PersonaClassifier: Redis save failed:', err.message);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Reads the saved persona for a user (Redis → PostgreSQL).
155
+ * Returns null if none has been saved yet.
156
+ *
157
+ * @param {string} userId
158
+ * @returns {Promise<{ personaType, communicationStyle, toneDirectives }|null>}
159
+ */
160
+ async function getUserPersona(userId) {
161
+ try {
162
+ if (redisClient) {
163
+ const cached = await redisClient.get(`persona:${userId}`);
164
+ if (cached) return JSON.parse(cached);
165
+ }
166
+ } catch (err) {
167
+ console.error('PersonaClassifier: Redis read failed:', err.message);
168
+ }
169
+
170
+ try {
171
+ const result = await pgClient.query(
172
+ `SELECT persona_type, communication_style, engagement_preferences
173
+ FROM user_preferences WHERE user_id = $1`,
174
+ [userId]
175
+ );
176
+ if (result.rows.length > 0) {
177
+ const row = result.rows[0];
178
+ return {
179
+ personaType: row.persona_type,
180
+ communicationStyle: row.communication_style,
181
+ toneDirectives: row.engagement_preferences?.toneDirectives || [],
182
+ };
183
+ }
184
+ } catch (err) {
185
+ console.error('PersonaClassifier: PostgreSQL read failed:', err.message);
186
+ }
187
+
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * Full classification pipeline: classify from current signals, save to DB/Redis.
193
+ * Call this from postResponseProcessing after extracting profile signals.
194
+ *
195
+ * @param {string} userId
196
+ * @param {{ profileUpdates: object, chatHistory: Array }} opts
197
+ * @returns {Promise<{ personaType, communicationStyle, toneDirectives }>}
198
+ */
199
+ async function classifyAndSavePersona(userId, { profileUpdates, chatHistory }) {
200
+ const persona = classifyFromSignals({ profileUpdates, chatHistory });
201
+ await saveUserPersona(userId, persona);
202
+ console.log(`🎭 Persona classified for ${userId}: ${persona.personaType} / ${persona.communicationStyle}`);
203
+ return persona;
204
+ }
205
+
206
+ module.exports = {
207
+ classifyFromSignals,
208
+ buildContextBlock,
209
+ saveUserPersona,
210
+ getUserPersona,
211
+ classifyAndSavePersona,
212
+ PERSONA_TYPES,
213
+ COMMUNICATION_STYLES,
214
+ };
@@ -6,6 +6,7 @@ const { pgClient, redisClient } = require('./database');
6
6
  const {generateAndSaveSummary, saveConversationSummary, saveConversation} = require("./conversation");
7
7
  const {saveHairIssue} = require("./hairIssues");
8
8
  const {getProductByName, addProductRecommendation} = require("./utils");
9
+ const { classifyAndSavePersona } = require("./personaClassifier");
9
10
 
10
11
  const postProcessingPrompt = (chatHistory, message) => {
11
12
  return `
@@ -219,6 +220,14 @@ const processBotResponse = (botResponse) => {
219
220
  return processedResponse.trim(); // Remove trailing newline and whitespace
220
221
  };
221
222
 
223
+ /**
224
+ * Uses Gemini to extract structured user profile updates and reported hair issues
225
+ * from the current conversation. Run non-blocking after sending the response.
226
+ *
227
+ * @param {Array<{role:string, content:string}>} chatHistory
228
+ * @param {string} message - User's latest message
229
+ * @returns {Promise<{user_profile_updates, hair_issue_extractions}>} Empty object on failure
230
+ */
222
231
  const postProcessConversation = async (chatHistory, message) => {
223
232
 
224
233
 
@@ -226,7 +235,7 @@ const postProcessConversation = async (chatHistory, message) => {
226
235
  //console.log('Extraction prompt: ', extractionPrompt);
227
236
  try {
228
237
  const chat = ai.chat({
229
- model: googleAI.model('gemini-2.5-flash'),
238
+ model: googleAI.model('gemini-3.6-flash'),
230
239
  config: {
231
240
  temperature: 1.1,
232
241
  },
@@ -314,12 +323,33 @@ const updateUserProfile = async (userId, updates) => {
314
323
  }
315
324
  };
316
325
 
326
+ /**
327
+ * Persists all post-response data: profile updates, hair issues, product recommendations,
328
+ * and conversation history. Run non-blocking via setImmediate after sending the response.
329
+ *
330
+ * @param {{user_profile_updates, hair_issue_extractions}} postProcessData - Output of postProcessConversation()
331
+ * @param {string} userId
332
+ * @param {any} output - Raw AI output
333
+ * @param {string|null} allchatsSummary - Existing summary (skips generation if truthy)
334
+ * @param {string} conversationId
335
+ * @param {Array} chatHistory - Full history including latest turn
336
+ * @param {string} botMessage - JSON-stringified AI output (used for product check)
337
+ */
317
338
  const postResponseProcessing = async (postProcessData, userId, output, allchatsSummary, conversationId, chatHistory, botMessage )=>{
318
339
  if (postProcessData.user_profile_updates) {
319
- //console.log("User profile updates to be applied:", postProcessData.user_profile_updates);
320
340
  await updateUserProfile(userId, postProcessData.user_profile_updates);
321
341
  }
322
342
 
343
+ // Classify and persist user persona from the latest signals — non-blocking, best-effort
344
+ try {
345
+ await classifyAndSavePersona(userId, {
346
+ profileUpdates: postProcessData.user_profile_updates || {},
347
+ chatHistory,
348
+ });
349
+ } catch (err) {
350
+ console.error('Persona classification failed (non-fatal):', err.message);
351
+ }
352
+
323
353
  if (postProcessData.hair_issue_extractions && postProcessData.hair_issue_extractions.hair_issues_reported && postProcessData.hair_issue_extractions.hair_issues_reported.length > 0) {
324
354
  for (const issue of postProcessData.hair_issue_extractions.hair_issues_reported) {
325
355
  await saveHairIssue(userId, issue, JSON.stringify(output)); // Or refine advice from output
@@ -385,7 +415,7 @@ const postResponseProductCheck = async (chatHistory, message) => {
385
415
  // Try Gemini first
386
416
  try {
387
417
  const chat = ai.chat({
388
- model: googleAI.model('gemini-2.5-flash'),
418
+ model: googleAI.model('gemini-3.6-flash'),
389
419
  config: {
390
420
  temperature: 0.3, // Reduced temperature for more consistent JSON formatting
391
421
  },
@@ -0,0 +1,240 @@
1
+ // trendManager.js
2
+ // Keeps the AI aware of current hair, beauty, and wellness trends.
3
+ //
4
+ // Trends are AI-generated every 48h and stored in PostgreSQL.
5
+ // Redis caches the current set for fast per-request reads.
6
+ // Regeneration always runs in the background — it never blocks a response.
7
+ //
8
+ // Injection into conversation:
9
+ // buildMessageContext() calls getTrendContext(stage, personaType)
10
+ // which returns a short 1-line block like:
11
+ // "[Trending: scalp detoxing as foundation for growth; rice water protein rinses]\n"
12
+
13
+ const { pgClient, redisClient } = require('./database');
14
+ const ai = require('./ai');
15
+ const { googleAI } = require('@genkit-ai/google-genai');
16
+ const { z } = require('zod');
17
+
18
+ const REDIS_KEY = 'hair_trends:current';
19
+ const REDIS_LOCK_KEY = 'hair_trends:generating';
20
+ const REDIS_CACHE_TTL = 60 * 60 * 6; // 6h Redis cache
21
+ const TREND_MAX_AGE_MS = 48 * 60 * 60 * 1000; // 48h before DB trends are stale
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Trend generation (AI → DB)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const TREND_SCHEMA = z.object({
28
+ trends: z.array(z.object({
29
+ trend_name: z.string(),
30
+ description: z.string(),
31
+ category: z.enum(['technique', 'ingredient', 'style', 'wellness']),
32
+ relevance_stages: z.array(z.number().int().min(1).max(8)),
33
+ trend_score: z.number().min(0).max(1),
34
+ })),
35
+ });
36
+
37
+ const GENERATION_PROMPT = `
38
+ You are a hair care industry analyst. Generate a list of exactly 10 hair care trends
39
+ that are currently popular in 2025–2026 across social media, salons, and natural hair communities.
40
+
41
+ Focus on trends relevant to textured, curly, and natural hair (4a, 4b, 4c, 3a–3c types).
42
+ Include a mix of techniques, ingredients, styles, and wellness practices.
43
+
44
+ For each trend:
45
+ - trend_name: short name (3–6 words)
46
+ - description: one sentence explaining what it is and why it works (max 20 words)
47
+ - category: one of "technique" | "ingredient" | "style" | "wellness"
48
+ - relevance_stages: array of Myavana journey stage numbers most relevant (1=Something New,
49
+ 2=Rocking This, 3=Experimenting, 4=Boredom, 5=Desperation, 6=Damage/Protection,
50
+ 7=Repair/Decision, 8=Hair Nirvana) — list 2–4 stages per trend
51
+ - trend_score: float 0.0–1.0 indicating how hot this trend is right now
52
+
53
+ Return ONLY a JSON object with a "trends" array. No markdown, no explanations.
54
+ `;
55
+
56
+ /**
57
+ * Checks whether the stored trends are older than TREND_MAX_AGE_MS.
58
+ * Returns true if trends need refreshing.
59
+ */
60
+ async function isTrendsStale() {
61
+ try {
62
+ const result = await pgClient.query(
63
+ `SELECT MAX(generated_at) AS latest FROM hair_trends WHERE active = TRUE`
64
+ );
65
+ const latest = result.rows[0]?.latest;
66
+ if (!latest) return true;
67
+ return (Date.now() - new Date(latest).getTime()) > TREND_MAX_AGE_MS;
68
+ } catch {
69
+ return false; // if DB unavailable, don't trigger generation
70
+ }
71
+ }
72
+
73
+ /**
74
+ * AI-generates 10 trends and upserts them into the DB.
75
+ * Acquires a Redis lock to prevent concurrent generation.
76
+ * Safe to call concurrently — only one generation runs at a time.
77
+ */
78
+ async function generateTrends() {
79
+ // Distributed lock — skip if another instance is already generating
80
+ try {
81
+ if (redisClient) {
82
+ const locked = await redisClient.set(REDIS_LOCK_KEY, '1', { NX: true, EX: 300 });
83
+ if (!locked) {
84
+ console.log('🌊 Trend generation already in progress (locked), skipping');
85
+ return;
86
+ }
87
+ }
88
+ } catch { /* Redis unavailable — proceed without lock */ }
89
+
90
+ try {
91
+ console.log('🌊 Generating fresh hair trends via AI...');
92
+
93
+ const result = await ai.generate({
94
+ model: googleAI.model('gemini-3.6-flash'),
95
+ output: { format: 'json', schema: TREND_SCHEMA },
96
+ config: { temperature: 0.7 },
97
+ prompt: GENERATION_PROMPT,
98
+ });
99
+
100
+ const { trends } = result.output;
101
+ if (!trends || !trends.length) {
102
+ console.warn('🌊 Trend generation returned empty — skipping DB write');
103
+ return;
104
+ }
105
+
106
+ // Expire existing AI-generated trends, insert new batch
107
+ await pgClient.query(
108
+ `UPDATE hair_trends SET active = FALSE WHERE source = 'ai-generated'`
109
+ );
110
+
111
+ for (const t of trends) {
112
+ await pgClient.query(
113
+ `INSERT INTO hair_trends
114
+ (trend_name, description, category, relevance_stages, trend_score, source, active, generated_at, expires_at)
115
+ VALUES ($1, $2, $3, $4, $5, 'ai-generated', TRUE, NOW(), NOW() + INTERVAL '48 hours')`,
116
+ [t.trend_name, t.description, t.category, t.relevance_stages, t.trend_score]
117
+ );
118
+ }
119
+
120
+ // Bust Redis cache so next read fetches fresh data
121
+ if (redisClient) {
122
+ await redisClient.del(REDIS_KEY).catch(() => {});
123
+ }
124
+
125
+ console.log(`🌊 ${trends.length} hair trends generated and saved`);
126
+ } catch (err) {
127
+ console.error('🌊 Trend generation failed:', err.message);
128
+ } finally {
129
+ if (redisClient) {
130
+ await redisClient.del(REDIS_LOCK_KEY).catch(() => {});
131
+ }
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Trend retrieval
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Fetches active trends from Redis (fast) → DB (fallback).
141
+ * Filters to trends relevant to the given stage; if none match, returns top by score.
142
+ *
143
+ * @param {number|null} stage - Journey stage 1–8, or null
144
+ * @param {number} limit - Max trends to return
145
+ * @returns {Promise<Array>}
146
+ */
147
+ async function getRelevantTrends(stage = null, limit = 3) {
148
+ // Try Redis first
149
+ try {
150
+ if (redisClient) {
151
+ const cached = await redisClient.get(REDIS_KEY);
152
+ if (cached) {
153
+ const all = JSON.parse(cached);
154
+ return selectTrends(all, stage, limit);
155
+ }
156
+ }
157
+ } catch { /* Redis miss — fall through to DB */ }
158
+
159
+ // DB fetch
160
+ try {
161
+ const result = await pgClient.query(
162
+ `SELECT trend_name, description, category, relevance_stages, trend_score
163
+ FROM hair_trends
164
+ WHERE active = TRUE AND expires_at > NOW()
165
+ ORDER BY trend_score DESC
166
+ LIMIT 20`
167
+ );
168
+
169
+ const trends = result.rows;
170
+
171
+ // Populate Redis for subsequent requests
172
+ if (trends.length > 0 && redisClient) {
173
+ await redisClient.set(REDIS_KEY, JSON.stringify(trends), { EX: REDIS_CACHE_TTL }).catch(() => {});
174
+ }
175
+
176
+ return selectTrends(trends, stage, limit);
177
+ } catch (err) {
178
+ console.error('trendManager: DB read failed:', err.message);
179
+ return [];
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Selects the most relevant trends for the given stage.
185
+ * Stage-matched trends come first; remaining slots filled from top-scored.
186
+ */
187
+ function selectTrends(allTrends, stage, limit) {
188
+ if (!allTrends.length) return [];
189
+
190
+ if (stage) {
191
+ const stageMatch = allTrends.filter(t =>
192
+ Array.isArray(t.relevance_stages) && t.relevance_stages.includes(stage)
193
+ );
194
+ const others = allTrends.filter(t =>
195
+ !Array.isArray(t.relevance_stages) || !t.relevance_stages.includes(stage)
196
+ );
197
+ return [...stageMatch, ...others].slice(0, limit);
198
+ }
199
+
200
+ return allTrends.slice(0, limit);
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Context block builder
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Builds the trend context string to inject into per-message context.
209
+ * Also triggers background refresh if trends are stale.
210
+ *
211
+ * @param {number|null} stage - Inferred journey stage
212
+ * @param {string|null} personaType - From personaClassifier
213
+ * @returns {Promise<string>}
214
+ */
215
+ async function getTrendContext(stage = null, personaType = null) {
216
+ // Kick off background refresh if stale — never awaited, never blocks
217
+ isTrendsStale().then(stale => {
218
+ if (stale) {
219
+ generateTrends().catch(err =>
220
+ console.error('trendManager: background refresh failed:', err.message)
221
+ );
222
+ }
223
+ }).catch(() => {});
224
+
225
+ const trends = await getRelevantTrends(stage, 2);
226
+ if (!trends.length) return '';
227
+
228
+ const names = trends.map(t => t.trend_name.toLowerCase()).join('; ');
229
+ return `[Trending in hair care: ${names}]\n`;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+
234
+ module.exports = {
235
+ getTrendContext,
236
+ getRelevantTrends,
237
+ generateTrends,
238
+ isTrendsStale,
239
+ selectTrends, // exported for tests
240
+ };
@@ -17,8 +17,8 @@ class UnifiedChatHandler {
17
17
 
18
18
  // Initialize model priority list - GEMINI FIRST!
19
19
  this.modelPriority = [
20
- { name: 'gemini20FlashExp', model: googleAI.model('gemini-3-flash-preview'), type: 'primary' },
21
- { name: 'gemini15Flash', model: googleAI.model('gemini-2.5-flash'), type: 'primary' },
20
+ { name: 'gemini20FlashExp', model: googleAI.model('gemini-3.7-flash'), type: 'primary' },
21
+ { name: 'gemini15Flash', model: googleAI.model('gemini-3.6-flash'), type: 'primary' },
22
22
  { name: 'gemini15Pro', model: googleAI.model('gemini-3-pro-preview'), type: 'backup' }
23
23
  ];
24
24