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.
- package/.env +1 -1
- package/README.md +0 -0
- package/index.js +46 -1
- package/migrations/004_add_partner_products.sql +25 -0
- package/migrations/005_add_hair_trends.sql +23 -0
- package/migrations/006_add_funnel_events.sql +17 -0
- package/migrations/007_add_agent_memory.sql +23 -0
- package/package.json +3 -2
- package/src/__tests__/agentTools.test.js +165 -0
- package/src/__tests__/cacheManager.test.js +196 -0
- package/src/__tests__/conversation.test.js +151 -0
- package/src/__tests__/conversationDriver.test.js +233 -0
- package/src/__tests__/messageContextBuilder.test.js +192 -0
- package/src/__tests__/personaClassifier.test.js +253 -0
- package/src/__tests__/postResponseProcessing.test.js +199 -0
- package/src/__tests__/trendManager.test.js +166 -0
- package/src/agentMemory.js +122 -0
- package/src/agentTools.js +236 -0
- package/src/ai.js +1 -1
- package/src/cacheManager.js +17 -7
- package/src/config.js +2 -2
- package/src/conversation.js +1 -1
- package/src/conversationDriver.js +230 -0
- package/src/funnelEvents.js +32 -0
- package/src/messageContextBuilder.js +84 -0
- package/src/personaClassifier.js +214 -0
- package/src/postResponseProcessing.js +33 -3
- package/src/trendManager.js +240 -0
- package/src/unifiedChatHandler.js +2 -2
- package/src/utils.js +27 -5
|
@@ -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-
|
|
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-
|
|
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
|
|
21
|
-
{ name: 'gemini15Flash', model: googleAI.model('gemini-
|
|
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
|
|