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,122 @@
|
|
|
1
|
+
// agentMemory.js
|
|
2
|
+
// Local Agent Memory (LAM) — a self-hosted, TAM-inspired long-term memory layer
|
|
3
|
+
// built entirely on this Postgres instance (pgvector), so retrieval never leaves
|
|
4
|
+
// the existing AWS/GCP infra. Extraction follows the same LLM-structured-extraction
|
|
5
|
+
// pattern already used by postProcessConversation/classifyAndSavePersona.
|
|
6
|
+
const ai = require('./ai');
|
|
7
|
+
const { googleAI } = require('@genkit-ai/google-genai');
|
|
8
|
+
const { pgClient } = require('./database');
|
|
9
|
+
|
|
10
|
+
const EMBEDDER = googleAI.embedder('gemini-embedding-001'); // 768-dim output, matches agent_memories.embedding
|
|
11
|
+
const DUPLICATE_DISTANCE_THRESHOLD = 0.15; // cosine distance below this = treat as the same memory
|
|
12
|
+
|
|
13
|
+
async function embedText(text) {
|
|
14
|
+
const [{ embedding }] = await ai.embed({ embedder: EMBEDDER, content: text });
|
|
15
|
+
return `[${embedding.join(',')}]`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const extractionPrompt = (chatHistory, latestMessage) => `
|
|
19
|
+
You are extracting durable, long-term memory facts about a hair-care customer from a conversation, for future personalization.
|
|
20
|
+
|
|
21
|
+
Only extract facts likely still true weeks or months from now (hair type/texture, product preferences or dislikes, allergies/sensitivities, long-term goals, established routines). Do NOT extract one-off statements, small talk, or anything already obviously covered by a generic hair-care questionnaire answer.
|
|
22
|
+
|
|
23
|
+
Conversation history:
|
|
24
|
+
${JSON.stringify(chatHistory)}
|
|
25
|
+
|
|
26
|
+
User's latest message:
|
|
27
|
+
"${latestMessage}"
|
|
28
|
+
|
|
29
|
+
Return a JSON array (0 to 3 items) of objects: { "content": string, "memory_type": "fact" | "preference" | "episodic", "importance_score": number between 0 and 1 }. Return an empty array if nothing durable was mentioned.
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extracts durable facts/preferences from the latest turn and stores them,
|
|
34
|
+
* deduping against the user's existing memories by embedding similarity.
|
|
35
|
+
* Non-blocking, best-effort — call from the async post-processing step.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} userId
|
|
38
|
+
* @param {string} conversationId
|
|
39
|
+
* @param {Array<{role:string, content:string}>} chatHistory
|
|
40
|
+
*/
|
|
41
|
+
const extractAndStoreMemories = async (userId, conversationId, chatHistory) => {
|
|
42
|
+
try {
|
|
43
|
+
const latestUserTurn = [...chatHistory].reverse().find(m => m.role === 'user');
|
|
44
|
+
if (!latestUserTurn?.content) return;
|
|
45
|
+
|
|
46
|
+
const chat = ai.chat({
|
|
47
|
+
model: googleAI.model('gemini-3.6-flash'),
|
|
48
|
+
config: { temperature: 0.3 },
|
|
49
|
+
});
|
|
50
|
+
const { output } = await chat.send(extractionPrompt(chatHistory, latestUserTurn.content));
|
|
51
|
+
|
|
52
|
+
const candidates = Array.isArray(output) ? output : [];
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
if (!candidate?.content) continue;
|
|
55
|
+
await upsertMemory(userId, conversationId, candidate);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error('Error extracting agent memories:', err);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function upsertMemory(userId, conversationId, candidate) {
|
|
63
|
+
const vectorLiteral = await embedText(candidate.content);
|
|
64
|
+
|
|
65
|
+
const existing = await pgClient.query(
|
|
66
|
+
`SELECT id, embedding <=> $2::vector AS distance
|
|
67
|
+
FROM agent_memories
|
|
68
|
+
WHERE user_id = $1
|
|
69
|
+
ORDER BY embedding <=> $2::vector
|
|
70
|
+
LIMIT 1`,
|
|
71
|
+
[userId, vectorLiteral]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (existing.rows.length > 0 && existing.rows[0].distance < DUPLICATE_DISTANCE_THRESHOLD) {
|
|
75
|
+
await pgClient.query(
|
|
76
|
+
`UPDATE agent_memories SET last_accessed_at = NOW(), access_count = access_count + 1 WHERE id = $1`,
|
|
77
|
+
[existing.rows[0].id]
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await pgClient.query(
|
|
83
|
+
`INSERT INTO agent_memories (user_id, memory_type, content, embedding, importance_score, source_conversation_id)
|
|
84
|
+
VALUES ($1, $2, $3, $4::vector, $5, $6)`,
|
|
85
|
+
[
|
|
86
|
+
userId,
|
|
87
|
+
candidate.memory_type || 'fact',
|
|
88
|
+
candidate.content,
|
|
89
|
+
vectorLiteral,
|
|
90
|
+
typeof candidate.importance_score === 'number' ? candidate.importance_score : 0.5,
|
|
91
|
+
conversationId,
|
|
92
|
+
]
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Retrieves the k memories most semantically relevant to queryText for this user.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} userId
|
|
100
|
+
* @param {string} queryText - typically the current user message
|
|
101
|
+
* @param {number} [k=5]
|
|
102
|
+
* @returns {Promise<string[]>}
|
|
103
|
+
*/
|
|
104
|
+
const retrieveRelevantMemories = async (userId, queryText, k = 5) => {
|
|
105
|
+
if (!queryText) return [];
|
|
106
|
+
try {
|
|
107
|
+
const vectorLiteral = await embedText(queryText);
|
|
108
|
+
const res = await pgClient.query(
|
|
109
|
+
`SELECT content FROM agent_memories WHERE user_id = $1 ORDER BY embedding <=> $2::vector LIMIT $3`,
|
|
110
|
+
[userId, vectorLiteral, k]
|
|
111
|
+
);
|
|
112
|
+
return res.rows.map(row => row.content);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error('Error retrieving agent memories:', err);
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
extractAndStoreMemories,
|
|
121
|
+
retrieveRelevantMemories,
|
|
122
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// agentTools.js
|
|
2
|
+
// Defines Genkit tools that the AI can call during conversations.
|
|
3
|
+
//
|
|
4
|
+
// Two categories:
|
|
5
|
+
// Global tools — stateless DB lookups, registered once at module load.
|
|
6
|
+
// Dynamic tools — user-scoped actions (saveHairGoal, getUserHairProfile)
|
|
7
|
+
// created per-request so userId is bound in the closure,
|
|
8
|
+
// not supplied by the model (avoids hallucination risk).
|
|
9
|
+
//
|
|
10
|
+
// Usage in index.js:
|
|
11
|
+
// const { globalTools, createUserTools } = require('...agentTools');
|
|
12
|
+
// const tools = [...globalTools, ...createUserTools(userId)];
|
|
13
|
+
// session.chat({ ..., tools });
|
|
14
|
+
|
|
15
|
+
const { z } = require('zod');
|
|
16
|
+
const ai = require('./ai');
|
|
17
|
+
const { pgClient } = require('./database');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Global tools (no user context — safe to register once)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Search Myavana services and partner products by keyword.
|
|
25
|
+
* ILIKE match against name, description, and category columns.
|
|
26
|
+
* Partner products are stored in a separate table once available.
|
|
27
|
+
*/
|
|
28
|
+
const searchServices = ai.defineTool(
|
|
29
|
+
{
|
|
30
|
+
name: 'searchServices',
|
|
31
|
+
description:
|
|
32
|
+
'Search Myavana services and partner products by keyword, hair concern, or category. ' +
|
|
33
|
+
'Call this when the user asks what services Myavana offers, asks for product or service recommendations, ' +
|
|
34
|
+
'or mentions a specific concern (e.g. "moisture", "curl analysis", "damage repair", "membership"). ' +
|
|
35
|
+
'Returns matching service/product names, descriptions, and any pricing or URL info available.',
|
|
36
|
+
inputSchema: z.object({
|
|
37
|
+
query: z.string().describe(
|
|
38
|
+
'Search keyword — hair concern, service type, or product name ' +
|
|
39
|
+
'(e.g. "moisture treatment", "analysis", "subscription")'
|
|
40
|
+
),
|
|
41
|
+
limit: z.number().int().min(1).max(10).optional().describe(
|
|
42
|
+
'Max number of results to return (default 5)'
|
|
43
|
+
),
|
|
44
|
+
}),
|
|
45
|
+
outputSchema: z.array(z.record(z.unknown())),
|
|
46
|
+
},
|
|
47
|
+
async ({ query, limit = 5 }) => {
|
|
48
|
+
const pattern = `%${query}%`;
|
|
49
|
+
try {
|
|
50
|
+
// Search Myavana services
|
|
51
|
+
const servicesResult = await pgClient.query(
|
|
52
|
+
`SELECT * FROM products
|
|
53
|
+
WHERE product_name ILIKE $1
|
|
54
|
+
OR description ILIKE $1
|
|
55
|
+
OR category ILIKE $1
|
|
56
|
+
LIMIT $2`,
|
|
57
|
+
[pattern, limit]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Search partner products if table exists
|
|
61
|
+
let partnerRows = [];
|
|
62
|
+
try {
|
|
63
|
+
const partnerResult = await pgClient.query(
|
|
64
|
+
`SELECT *, 'partner' AS source FROM partner_products
|
|
65
|
+
WHERE product_name ILIKE $1
|
|
66
|
+
OR description ILIKE $1
|
|
67
|
+
OR brand_name ILIKE $1
|
|
68
|
+
LIMIT $2`,
|
|
69
|
+
[pattern, limit]
|
|
70
|
+
);
|
|
71
|
+
partnerRows = partnerResult.rows;
|
|
72
|
+
} catch {
|
|
73
|
+
// partner_products table doesn't exist yet — skip silently
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const combined = [...servicesResult.rows, ...partnerRows].slice(0, limit);
|
|
77
|
+
return combined.length > 0
|
|
78
|
+
? combined
|
|
79
|
+
: [{ message: `No services or products found matching "${query}".` }];
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error('searchServices tool error:', err.message);
|
|
82
|
+
return [{ error: 'Service search temporarily unavailable.' }];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Search FAQs by keyword.
|
|
89
|
+
* ILIKE match against both question and answer text so the AI can ground
|
|
90
|
+
* brand-specific factual answers rather than guessing.
|
|
91
|
+
*/
|
|
92
|
+
const lookupFAQ = ai.defineTool(
|
|
93
|
+
{
|
|
94
|
+
name: 'lookupFAQ',
|
|
95
|
+
description:
|
|
96
|
+
'Look up Myavana FAQ entries by keyword. ' +
|
|
97
|
+
'Call this when the user asks a factual question about Myavana (pricing, policies, how something works, ' +
|
|
98
|
+
'shipping, cancellations, what is included in a service) where you want to ground your answer in ' +
|
|
99
|
+
'official content rather than inference.',
|
|
100
|
+
inputSchema: z.object({
|
|
101
|
+
query: z.string().describe(
|
|
102
|
+
'The topic or question to search for in FAQs (e.g. "cancel subscription", "how does analysis work")'
|
|
103
|
+
),
|
|
104
|
+
limit: z.number().int().min(1).max(5).optional().describe(
|
|
105
|
+
'Max results to return (default 3)'
|
|
106
|
+
),
|
|
107
|
+
}),
|
|
108
|
+
outputSchema: z.array(z.object({
|
|
109
|
+
question: z.string().optional(),
|
|
110
|
+
answer: z.string().optional(),
|
|
111
|
+
}).passthrough()),
|
|
112
|
+
},
|
|
113
|
+
async ({ query, limit = 3 }) => {
|
|
114
|
+
const pattern = `%${query}%`;
|
|
115
|
+
try {
|
|
116
|
+
const result = await pgClient.query(
|
|
117
|
+
`SELECT * FROM faqs
|
|
118
|
+
WHERE question ILIKE $1 OR answer ILIKE $1
|
|
119
|
+
LIMIT $2`,
|
|
120
|
+
[pattern, limit]
|
|
121
|
+
);
|
|
122
|
+
return result.rows.length > 0
|
|
123
|
+
? result.rows
|
|
124
|
+
: [{ question: query, answer: `No FAQ found for "${query}". Answer based on your knowledge.` }];
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error('lookupFAQ tool error:', err.message);
|
|
127
|
+
return [{ error: 'FAQ lookup temporarily unavailable.' }];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Dynamic tools (per-request, userId bound in closure)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Factory — call once per request to get user-scoped tools.
|
|
138
|
+
* @param {string} userId
|
|
139
|
+
* @returns {import('genkit').ToolAction[]}
|
|
140
|
+
*/
|
|
141
|
+
function createUserTools(userId) {
|
|
142
|
+
/**
|
|
143
|
+
* Save a hair goal the user has explicitly stated.
|
|
144
|
+
* Appended to user_preferences.hair_priorities JSONB so it persists across sessions.
|
|
145
|
+
*/
|
|
146
|
+
const saveHairGoal = ai.dynamicTool(
|
|
147
|
+
{
|
|
148
|
+
name: 'saveHairGoal',
|
|
149
|
+
description:
|
|
150
|
+
'Save a specific hair goal the user has clearly stated they want to achieve. ' +
|
|
151
|
+
'Call this when the user explicitly says something like "my goal is...", "I want to...", ' +
|
|
152
|
+
'"I\'m trying to...", or "I\'d love to achieve...". ' +
|
|
153
|
+
'Do NOT call this for vague preferences — only concrete, stated goals. ' +
|
|
154
|
+
'Confirm to the user that their goal has been saved.',
|
|
155
|
+
inputSchema: z.object({
|
|
156
|
+
goal: z.string().describe(
|
|
157
|
+
'The specific hair goal stated by the user, in their own words ' +
|
|
158
|
+
'(e.g. "grow hair 4 inches by December", "retain moisture without frizz", "transition to natural")'
|
|
159
|
+
),
|
|
160
|
+
}),
|
|
161
|
+
outputSchema: z.object({ saved: z.boolean(), goal: z.string() }),
|
|
162
|
+
},
|
|
163
|
+
async ({ goal }) => {
|
|
164
|
+
try {
|
|
165
|
+
await pgClient.query(
|
|
166
|
+
`INSERT INTO user_preferences (user_id, hair_priorities)
|
|
167
|
+
VALUES ($1, $2::jsonb)
|
|
168
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
169
|
+
hair_priorities = COALESCE(user_preferences.hair_priorities, '{}') || $2::jsonb`,
|
|
170
|
+
[
|
|
171
|
+
userId,
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
goals: goal,
|
|
174
|
+
savedAt: new Date().toISOString(),
|
|
175
|
+
}),
|
|
176
|
+
]
|
|
177
|
+
);
|
|
178
|
+
console.log(`🎯 Hair goal saved for ${userId}: ${goal}`);
|
|
179
|
+
return { saved: true, goal };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.error('saveHairGoal tool error:', err.message);
|
|
182
|
+
return { saved: false, goal };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Fetch the user's current hair profile from the DB.
|
|
189
|
+
* Useful mid-conversation when the cached system prompt may be stale (>24h old)
|
|
190
|
+
* and the user's hair type or concerns have been updated since.
|
|
191
|
+
*/
|
|
192
|
+
const getUserHairProfile = ai.dynamicTool(
|
|
193
|
+
{
|
|
194
|
+
name: 'getUserHairProfile',
|
|
195
|
+
description:
|
|
196
|
+
'Retrieve the current hair profile on file for this user: hair type, texture, concerns, goals, ' +
|
|
197
|
+
'porosity, and other stored attributes. ' +
|
|
198
|
+
'Call this when you need to personalize a recommendation but are unsure whether your context ' +
|
|
199
|
+
'has the most up-to-date profile, or when the user references a characteristic you don\'t have.',
|
|
200
|
+
inputSchema: z.object({}),
|
|
201
|
+
outputSchema: z.record(z.unknown()),
|
|
202
|
+
},
|
|
203
|
+
async () => {
|
|
204
|
+
try {
|
|
205
|
+
const result = await pgClient.query(
|
|
206
|
+
`SELECT hair_type, hair_concerns, hair_texture, porosity, elasticity, density,
|
|
207
|
+
curl_pattern, hair_goals, hair_concerns, damage_level_self_reported,
|
|
208
|
+
confidence_level_inferred, experimenting_interest
|
|
209
|
+
FROM users WHERE user_id = $1`,
|
|
210
|
+
[userId]
|
|
211
|
+
);
|
|
212
|
+
if (result.rows.length === 0) {
|
|
213
|
+
return { message: 'No hair profile on file yet for this user.' };
|
|
214
|
+
}
|
|
215
|
+
// Strip null fields to keep context concise
|
|
216
|
+
const profile = Object.fromEntries(
|
|
217
|
+
Object.entries(result.rows[0]).filter(([, v]) => v !== null && v !== undefined)
|
|
218
|
+
);
|
|
219
|
+
return Object.keys(profile).length > 0
|
|
220
|
+
? profile
|
|
221
|
+
: { message: 'Hair profile exists but no attributes recorded yet.' };
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error('getUserHairProfile tool error:', err.message);
|
|
224
|
+
return { error: 'Hair profile temporarily unavailable.' };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return [saveHairGoal, getUserHairProfile];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
const globalTools = [searchServices, lookupFAQ];
|
|
235
|
+
|
|
236
|
+
module.exports = { globalTools, createUserTools };
|
package/src/ai.js
CHANGED
package/src/cacheManager.js
CHANGED
|
@@ -28,7 +28,11 @@ class CacheManager {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Fast check for a valid Gemini cache across all storage layers (PG → Redis → memory).
|
|
32
|
+
* Call this before constructPrompt() — if hasValidCache is true, skip prompt construction.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} userId
|
|
35
|
+
* @returns {Promise<{hasValidCache, cacheName, needsRefresh, reason, source}>}
|
|
32
36
|
*/
|
|
33
37
|
async checkCacheValidity(userId) {
|
|
34
38
|
console.log('🔍 Quick cache check for user:', userId);
|
|
@@ -112,7 +116,13 @@ class CacheManager {
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
/**
|
|
115
|
-
*
|
|
119
|
+
* Returns the existing valid Gemini cache name for this user, or creates a new one
|
|
120
|
+
* from the provided system prompt string. Saves to all available storage layers.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} userId
|
|
123
|
+
* @param {string} systemPrompt - Full system instruction text to cache
|
|
124
|
+
* @returns {Promise<{cacheName: string|null, isNewCache: boolean, error?: string}>}
|
|
125
|
+
* cacheName is null if cache creation failed — caller should fall back to sending full prompt
|
|
116
126
|
*/
|
|
117
127
|
async getOrCreateCache(userId, systemPrompt) {
|
|
118
128
|
console.log('🔍 ===== CACHE MANAGER: GET OR CREATE =====');
|
|
@@ -138,7 +148,7 @@ class CacheManager {
|
|
|
138
148
|
// Create new cache
|
|
139
149
|
console.log('🆕 Creating new Gemini cache...');
|
|
140
150
|
const cacheStartTime = performance.now();
|
|
141
|
-
const modelName = "gemini-3-flash
|
|
151
|
+
const modelName = "gemini-3.7-flash";
|
|
142
152
|
|
|
143
153
|
const cache = await cacheAi.caches.create({
|
|
144
154
|
model: modelName,
|
|
@@ -314,10 +324,10 @@ class CacheManager {
|
|
|
314
324
|
|
|
315
325
|
try {
|
|
316
326
|
const key = `gemini_cache:${userId}`;
|
|
317
|
-
await redisClient.
|
|
318
|
-
key,
|
|
319
|
-
|
|
320
|
-
|
|
327
|
+
await redisClient.set(
|
|
328
|
+
key,
|
|
329
|
+
JSON.stringify(cacheData),
|
|
330
|
+
{ EX: this.CACHE_TTL_SECONDS }
|
|
321
331
|
);
|
|
322
332
|
console.log('✅ Redis save successful');
|
|
323
333
|
return true;
|
package/src/config.js
CHANGED
|
@@ -27,7 +27,7 @@ module.exports = {
|
|
|
27
27
|
},
|
|
28
28
|
genkitApiKey: process.env.GENKIT_API_KEY,
|
|
29
29
|
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
30
|
-
defaultModel: '
|
|
31
|
-
experimentalModel: '
|
|
30
|
+
defaultModel: 'gemini-3.7-flash',
|
|
31
|
+
experimentalModel: 'gemini-3.7-flash',
|
|
32
32
|
temperature: 1.1,
|
|
33
33
|
};
|
package/src/conversation.js
CHANGED
|
@@ -28,7 +28,7 @@ const generateConversationSummary = async (chatHistory) => {
|
|
|
28
28
|
}
|
|
29
29
|
const summaryPrompt = `Summarize the following conversation, getting all important information, especially the user's hair details, name, and location: ${JSON.stringify(chatHistory)}`;
|
|
30
30
|
const { text } = await ai.chat({
|
|
31
|
-
model: googleAI.model('gemini-
|
|
31
|
+
model: googleAI.model('gemini-3.6-flash'),
|
|
32
32
|
system: summaryPrompt,
|
|
33
33
|
});
|
|
34
34
|
return text;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// conversationDriver.js
|
|
2
|
+
// Analyzes conversation state and generates per-message behavioral directives
|
|
3
|
+
// that guide how the AI should engage — not just what to say, but what to DO.
|
|
4
|
+
//
|
|
5
|
+
// Injected as a small context block via buildMessageContext(), alongside
|
|
6
|
+
// the persona block from personaClassifier.
|
|
7
|
+
//
|
|
8
|
+
// The goal: make the AI a conversation driver, not just a responder.
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Stage definitions — mirrors the 8-stage framework in system.js
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const STAGES = {
|
|
15
|
+
1: { name: 'something-new', label: 'Something New' },
|
|
16
|
+
2: { name: 'rocking-this', label: 'Rocking This' },
|
|
17
|
+
3: { name: 'experimenting', label: 'Experimenting' },
|
|
18
|
+
4: { name: 'boredom', label: 'Boredom' },
|
|
19
|
+
5: { name: 'desperation', label: 'Desperation' },
|
|
20
|
+
6: { name: 'damage-protection', label: 'Damage/Protection' },
|
|
21
|
+
7: { name: 'repair-decision', label: 'Repair/Decision' },
|
|
22
|
+
8: { name: 'hair-nirvana', label: 'Hair Nirvana' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Keywords used to infer stage from recent messages (subset of system.js table)
|
|
26
|
+
const STAGE_KEYWORDS = [
|
|
27
|
+
{ stage: 8, words: ['nirvana', 'love my routine', 'healthy and happy', 'low maintenance', 'carefree', 'content', 'maintaining my hair'] },
|
|
28
|
+
{ stage: 7, words: ['repairing', 'restoring', 'healing', 'new direction', 'making progress', 'growing out'] },
|
|
29
|
+
{ stage: 6, words: ['damage', 'heat damage', 'protective style', 'split ends', 'overprocessed', 'breakage', 'weak hair'] },
|
|
30
|
+
{ stage: 5, words: ['desperate', 'overwhelmed', 'hate my hair', "don't know what to do", 'frustrated', 'give up', 'stressed', 'hopeless'] },
|
|
31
|
+
{ stage: 4, words: ['bored', 'need a change', 'stale', 'want something different', 'tired of my hair', 'ready for something new'] },
|
|
32
|
+
{ stage: 3, words: ['experimenting', 'trying new things', 'curious about', 'exploring', 'want to try', 'different styles'] },
|
|
33
|
+
{ stage: 2, words: ['love my hair', 'happy with it', 'feeling good', "i'm rocking", 'in my element', 'confident'] },
|
|
34
|
+
{ stage: 1, words: ['big chop', 'new color', 'first time', 'starting over', 'fresh start', 'just cut my hair', 'new style'] },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Analysis helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Infer the most likely stage from recent user messages.
|
|
43
|
+
* Scans last 4 user messages — recent context is more reliable than old.
|
|
44
|
+
* Returns the highest-priority (lowest number wins ties) matched stage, or null.
|
|
45
|
+
*
|
|
46
|
+
* @param {Array<{role:string, content:string}>} chatHistory
|
|
47
|
+
* @returns {number|null}
|
|
48
|
+
*/
|
|
49
|
+
function inferStage(chatHistory) {
|
|
50
|
+
const recentUserMessages = chatHistory
|
|
51
|
+
.filter(m => m.role === 'user')
|
|
52
|
+
.slice(-4)
|
|
53
|
+
.map(m => (m.content || '').toLowerCase());
|
|
54
|
+
|
|
55
|
+
const combined = recentUserMessages.join(' ');
|
|
56
|
+
if (!combined) return null;
|
|
57
|
+
|
|
58
|
+
for (const { stage, words } of STAGE_KEYWORDS) {
|
|
59
|
+
if (words.some(w => combined.includes(w))) return stage;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Count how many of the last N AI turns ended with a question.
|
|
66
|
+
* Rough but effective: any turn containing '?' is considered a question turn.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<{role:string, content:string}>} chatHistory
|
|
69
|
+
* @param {number} lookback How many recent AI turns to check (default 3)
|
|
70
|
+
* @returns {{ total: number, withQuestion: number, lastTurnHadQuestion: boolean }}
|
|
71
|
+
*/
|
|
72
|
+
function analyzeAIQuestions(chatHistory, lookback = 3) {
|
|
73
|
+
const aiTurns = chatHistory
|
|
74
|
+
.filter(m => m.role === 'model' || m.role === 'assistant')
|
|
75
|
+
.slice(-lookback);
|
|
76
|
+
|
|
77
|
+
const withQuestion = aiTurns.filter(m => (m.content || '').includes('?')).length;
|
|
78
|
+
const lastTurnHadQuestion = aiTurns.length > 0
|
|
79
|
+
? (aiTurns[aiTurns.length - 1].content || '').includes('?')
|
|
80
|
+
: false;
|
|
81
|
+
|
|
82
|
+
return { total: aiTurns.length, withQuestion, lastTurnHadQuestion };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns rough conversation depth category.
|
|
87
|
+
*
|
|
88
|
+
* @param {Array} chatHistory
|
|
89
|
+
* @returns {'opening'|'exploring'|'deepening'|'action-ready'}
|
|
90
|
+
*/
|
|
91
|
+
function getDepthCategory(chatHistory) {
|
|
92
|
+
const userTurns = chatHistory.filter(m => m.role === 'user').length;
|
|
93
|
+
if (userTurns <= 1) return 'opening';
|
|
94
|
+
if (userTurns <= 3) return 'exploring';
|
|
95
|
+
if (userTurns <= 7) return 'deepening';
|
|
96
|
+
return 'action-ready';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Directive builder
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generates a short behavioral directive string based on conversation state.
|
|
105
|
+
* Returns empty string when the conversation needs no additional steering.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} opts
|
|
108
|
+
* @param {number|null} opts.stage - Inferred stage number (1–8) or null
|
|
109
|
+
* @param {string} opts.depth - 'opening'|'exploring'|'deepening'|'action-ready'
|
|
110
|
+
* @param {object} opts.questionStats - From analyzeAIQuestions()
|
|
111
|
+
* @param {string|null} opts.personaType - From personaClassifier (optional)
|
|
112
|
+
* @param {string|null} opts.trendSummary - Short trend names string from trendManager (optional)
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
function buildDrivingDirective({ stage, depth, questionStats, personaType, trendSummary = null }) {
|
|
116
|
+
const parts = [];
|
|
117
|
+
|
|
118
|
+
// Opening: always gather profile info if we don't have a stage read yet
|
|
119
|
+
if (depth === 'opening' && stage === null) {
|
|
120
|
+
parts.push('ask one open question to understand their hair type or primary concern — do not assume');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Exploring: AI should be asking questions to understand, not just informing
|
|
124
|
+
else if (depth === 'exploring') {
|
|
125
|
+
if (!questionStats.lastTurnHadQuestion) {
|
|
126
|
+
parts.push('end your response with a specific follow-up question to learn more about their routine or goals');
|
|
127
|
+
}
|
|
128
|
+
if (stage !== null) {
|
|
129
|
+
parts.push(`stage context: ${STAGES[stage]?.label} — tailor your depth and tone accordingly`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Deepening: AI has enough info — guide toward insight or next step
|
|
134
|
+
else if (depth === 'deepening') {
|
|
135
|
+
if (questionStats.withQuestion === 0) {
|
|
136
|
+
// Stagnating — use a trend as a new angle if available
|
|
137
|
+
if (trendSummary) {
|
|
138
|
+
parts.push(`conversation needs new momentum — introduce a trending topic (${trendSummary}) as a natural next angle`);
|
|
139
|
+
} else {
|
|
140
|
+
parts.push('the conversation needs forward momentum — ask something that surfaces a new dimension (routine, history, environment, goals)');
|
|
141
|
+
}
|
|
142
|
+
} else if (stage === 3 && trendSummary) {
|
|
143
|
+
// Experimenting + trend: give them concrete options to try
|
|
144
|
+
parts.push(`user is experimenting — reference a trending technique (${trendSummary}) as one option worth exploring`);
|
|
145
|
+
} else if (stage === 4 && trendSummary) {
|
|
146
|
+
// Boredom + trend: use trend as the inspiration for change
|
|
147
|
+
parts.push(`user is bored — use a current trend (${trendSummary}) as inspiration to spark excitement about a change`);
|
|
148
|
+
} else if (stage !== null && stage >= 5 && stage <= 7) {
|
|
149
|
+
// High-need stages — gently surface Myavana solutions
|
|
150
|
+
parts.push('user is in a high-need phase — after addressing their question, briefly introduce a relevant Myavana service as a natural next step (do not push)');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Action-ready: longer conversation — move toward a decision or commitment
|
|
155
|
+
else if (depth === 'action-ready') {
|
|
156
|
+
if (stage !== null && stage >= 5 && stage <= 7) {
|
|
157
|
+
if (trendSummary) {
|
|
158
|
+
parts.push(`well-developed conversation — guide toward a concrete action; a trending approach (${trendSummary}) could be the bridge to a Myavana service`);
|
|
159
|
+
} else {
|
|
160
|
+
parts.push('this is a well-developed conversation — guide the user toward a concrete action: a Myavana service, a specific routine step, or a commitment to try something');
|
|
161
|
+
}
|
|
162
|
+
} else if (stage === 8) {
|
|
163
|
+
parts.push('user is thriving — celebrate their progress and offer maintenance tips; keep the energy positive and light');
|
|
164
|
+
} else if (trendSummary) {
|
|
165
|
+
parts.push(`offer a specific personalized recommendation; consider whether a trending technique (${trendSummary}) is relevant to their situation`);
|
|
166
|
+
} else {
|
|
167
|
+
parts.push('this conversation is deep enough to offer a specific personalized recommendation; do not stay generic');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Cross-cutting: overwhelmed users should not be pushed toward action too soon
|
|
172
|
+
if (personaType === 'overwhelmed' && parts.some(p => p.includes('action') || p.includes('service') || p.includes('trending'))) {
|
|
173
|
+
const idx = parts.findIndex(p => p.includes('action') || p.includes('service') || p.includes('trending'));
|
|
174
|
+
if (idx !== -1) {
|
|
175
|
+
parts[idx] = 'when the moment feels right, gently surface one option — but lead with empathy first';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (parts.length === 0) return '';
|
|
180
|
+
return `[Conversation driver: ${parts.join('; ')}]\n`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Public API
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Analyses chat history and returns a driving directive string to inject
|
|
189
|
+
* into the message context. Returns empty string when nothing needed.
|
|
190
|
+
*
|
|
191
|
+
* @param {Array<{role:string, content:string}>} chatHistory
|
|
192
|
+
* @param {string|null} personaType - From personaClassifier (optional)
|
|
193
|
+
* @param {string|null} trendSummary - Trend names from trendManager (optional)
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
function getConversationDirective(chatHistory, personaType = null, trendSummary = null) {
|
|
197
|
+
if (!chatHistory || chatHistory.length === 0) {
|
|
198
|
+
return '[Conversation driver: this is the very first message — warmly open with a question about their hair type or what brings them in today]\n';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const stage = inferStage(chatHistory);
|
|
202
|
+
const depth = getDepthCategory(chatHistory);
|
|
203
|
+
const questionStats = analyzeAIQuestions(chatHistory);
|
|
204
|
+
|
|
205
|
+
return buildDrivingDirective({ stage, depth, questionStats, personaType, trendSummary });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Convenience — extracts just the trend summary string from a trend context block.
|
|
210
|
+
* e.g. "[Trending in hair care: scalp detoxing; rice water]\n" → "scalp detoxing; rice water"
|
|
211
|
+
*
|
|
212
|
+
* @param {string} trendContext - Full trend block from trendManager.getTrendContext()
|
|
213
|
+
* @returns {string|null}
|
|
214
|
+
*/
|
|
215
|
+
function extractTrendSummary(trendContext) {
|
|
216
|
+
if (!trendContext) return null;
|
|
217
|
+
const match = trendContext.match(/\[Trending in hair care: (.+?)\]/);
|
|
218
|
+
return match ? match[1] : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
getConversationDirective,
|
|
223
|
+
extractTrendSummary,
|
|
224
|
+
// Exported for testing
|
|
225
|
+
inferStage,
|
|
226
|
+
analyzeAIQuestions,
|
|
227
|
+
getDepthCategory,
|
|
228
|
+
buildDrivingDirective,
|
|
229
|
+
STAGES,
|
|
230
|
+
};
|