myavana-bot-test-core 2.3.4 → 2.4.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/index.js +17 -0
- package/migrations/001_add_analytics_tables.sql +4 -2
- package/migrations/002_add_advanced_features_tables.sql +15 -8
- package/migrations/008_platform_identity.sql +18 -0
- package/migrations/run_migrations.sql +6 -2
- package/package.json +1 -4
- package/scripts/runMigrations.js +68 -0
- package/src/__tests__/cacheManager.test.js +7 -10
- package/src/__tests__/hairJourneyService.test.js +162 -0
- package/src/agentMemory.js +2 -4
- package/src/agentTools.js +200 -1
- package/src/ai/aiCore.js +458 -0
- package/src/ai/index.js +8 -0
- package/src/ai.js +2 -12
- package/src/cacheManager.js +1 -12
- package/src/chatProtocol/index.js +14 -0
- package/src/chatProtocol/ndjson.js +144 -0
- package/src/chatProtocol/responseParser.js +384 -0
- package/src/chatProtocol/types.js +66 -0
- package/src/config.js +3 -0
- package/src/context/promptBudgetManager.js +134 -0
- package/src/conversation.js +37 -8
- package/src/database.js +38 -8
- package/src/experience/experienceContextService.js +100 -0
- package/src/hairJourney/hairJourneyProvider.js +98 -0
- package/src/hairJourney/hairJourneyService.js +320 -0
- package/src/hairJourney/wordPressHairJourneyProvider.js +343 -0
- package/src/messageContextBuilder.js +13 -1
- package/src/postResponseProcessing.js +2 -4
- package/src/trendManager.js +1 -2
- package/src/unifiedChatHandler.js +4 -5
- package/src/user.js +1 -0
- package/src/utils.js +3 -1
package/index.js
CHANGED
|
@@ -38,6 +38,7 @@ const modules = {
|
|
|
38
38
|
getConversationHistory: safeRequire('./src/conversation', 'conversation')?.getConversationHistory,
|
|
39
39
|
saveConversation: safeRequire('./src/conversation', 'conversation')?.saveConversation,
|
|
40
40
|
generateConversationSummary: safeRequire('./src/conversation', 'conversation')?.generateConversationSummary,
|
|
41
|
+
getUserConversationsList: safeRequire('./src/conversation', 'conversation')?.getUserConversationsList,
|
|
41
42
|
|
|
42
43
|
// Post-processing and hair issues
|
|
43
44
|
postProcessConversation: safeRequire('./src/postResponseProcessing', 'postResponseProcessing')?.postProcessConversation,
|
|
@@ -55,6 +56,7 @@ const modules = {
|
|
|
55
56
|
getAdditionalInstructions: safeRequire('./src/utils', 'utils')?.getAdditionalInstructions,
|
|
56
57
|
getTrainingData: safeRequire('./src/utils', 'utils')?.getTrainingData,
|
|
57
58
|
getChatbotWelcomeSettings: safeRequire('./src/utils', 'utils')?.getChatbotWelcomeSettings,
|
|
59
|
+
buildBudgetedPromptContext: safeRequire('./src/context/promptBudgetManager', 'promptBudgetManager')?.buildBudgetedPromptContext,
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
// Smart systems - Load with proper error handling and fallbacks
|
|
@@ -170,6 +172,19 @@ const enhancedModules = {
|
|
|
170
172
|
HealthMonitor: safeRequire('./src/healthMonitor', 'HealthMonitor')
|
|
171
173
|
};
|
|
172
174
|
|
|
175
|
+
// Hair Journey Platform Services
|
|
176
|
+
const hairJourneyModules = {
|
|
177
|
+
HairJourneyService: safeRequire('./src/hairJourney/hairJourneyService', 'hairJourneyService')?.HairJourneyService,
|
|
178
|
+
WordPressHairJourneyProvider: safeRequire('./src/hairJourney/wordPressHairJourneyProvider', 'wordPressHairJourneyProvider')?.WordPressHairJourneyProvider,
|
|
179
|
+
ExperienceContextService: safeRequire('./src/experience/experienceContextService', 'experienceContextService')?.ExperienceContextService,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Chat Protocol (NDJSON streaming, semantic blocks, response parsing)
|
|
183
|
+
const chatProtocolModules = {
|
|
184
|
+
chatProtocol: safeRequire('./src/chatProtocol', 'chatProtocol'),
|
|
185
|
+
...(safeRequire('./src/chatProtocol', 'chatProtocol') || {}),
|
|
186
|
+
};
|
|
187
|
+
|
|
173
188
|
// Export all modules
|
|
174
189
|
module.exports = {
|
|
175
190
|
...modules,
|
|
@@ -181,6 +196,8 @@ module.exports = {
|
|
|
181
196
|
...agentToolModules,
|
|
182
197
|
...funnelEventModules,
|
|
183
198
|
...agentMemoryModules,
|
|
199
|
+
...hairJourneyModules,
|
|
200
|
+
...chatProtocolModules,
|
|
184
201
|
};
|
|
185
202
|
|
|
186
203
|
// Log export summary
|
|
@@ -68,6 +68,7 @@ CREATE INDEX IF NOT EXISTS idx_user_journey_events_user ON user_journey_events(u
|
|
|
68
68
|
CREATE INDEX IF NOT EXISTS idx_ai_model_performance_date ON ai_model_performance(date_tracked);
|
|
69
69
|
|
|
70
70
|
-- Views for easy analytics queries
|
|
71
|
+
DROP VIEW IF EXISTS daily_conversation_stats CASCADE;
|
|
71
72
|
CREATE OR REPLACE VIEW daily_conversation_stats AS
|
|
72
73
|
SELECT
|
|
73
74
|
DATE(created_at) as date,
|
|
@@ -79,14 +80,15 @@ FROM conversation_metrics
|
|
|
79
80
|
GROUP BY DATE(created_at)
|
|
80
81
|
ORDER BY date DESC;
|
|
81
82
|
|
|
83
|
+
DROP VIEW IF EXISTS model_reliability_stats CASCADE;
|
|
82
84
|
CREATE OR REPLACE VIEW model_reliability_stats AS
|
|
83
85
|
SELECT
|
|
84
86
|
model_name,
|
|
85
87
|
SUM(request_count) as total_requests,
|
|
86
88
|
SUM(success_count) as total_successes,
|
|
87
89
|
SUM(failure_count) as total_failures,
|
|
88
|
-
ROUND((SUM(success_count)::
|
|
89
|
-
ROUND(AVG(avg_response_time_ms), 2) as avg_response_time
|
|
90
|
+
ROUND((SUM(success_count)::numeric / NULLIF(SUM(request_count), 0)::numeric * 100), 2) as success_rate,
|
|
91
|
+
ROUND(AVG(avg_response_time_ms)::numeric, 2) as avg_response_time
|
|
90
92
|
FROM ai_model_performance
|
|
91
93
|
WHERE date_tracked >= CURRENT_DATE - INTERVAL '30 days'
|
|
92
94
|
GROUP BY model_name;
|
|
@@ -131,6 +131,10 @@ CREATE TABLE IF NOT EXISTS product_recommendations (
|
|
|
131
131
|
created_at TIMESTAMP DEFAULT NOW()
|
|
132
132
|
);
|
|
133
133
|
|
|
134
|
+
-- Ensure compatibility with existing schemas
|
|
135
|
+
ALTER TABLE hair_issues ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
|
|
136
|
+
ALTER TABLE product_recommendations ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
|
|
137
|
+
|
|
134
138
|
-- Indexes for better performance
|
|
135
139
|
CREATE INDEX IF NOT EXISTS idx_error_logs_user_created ON error_logs(user_id, created_at);
|
|
136
140
|
CREATE INDEX IF NOT EXISTS idx_error_logs_type_model ON error_logs(error_type, model_name);
|
|
@@ -142,6 +146,7 @@ CREATE INDEX IF NOT EXISTS idx_product_recommendations_user ON product_recommend
|
|
|
142
146
|
CREATE INDEX IF NOT EXISTS idx_ab_assignments_experiment ON ab_test_assignments(experiment_id, assigned_variant);
|
|
143
147
|
|
|
144
148
|
-- Views for analytics and reporting
|
|
149
|
+
DROP VIEW IF EXISTS error_recovery_stats CASCADE;
|
|
145
150
|
CREATE OR REPLACE VIEW error_recovery_stats AS
|
|
146
151
|
SELECT
|
|
147
152
|
error_type,
|
|
@@ -150,29 +155,31 @@ SELECT
|
|
|
150
155
|
COUNT(CASE WHEN recovery_attempted THEN 1 END) as recovery_attempts,
|
|
151
156
|
COUNT(CASE WHEN recovery_successful THEN 1 END) as successful_recoveries,
|
|
152
157
|
ROUND(
|
|
153
|
-
COUNT(CASE WHEN recovery_successful THEN 1 END)::
|
|
154
|
-
NULLIF(COUNT(CASE WHEN recovery_attempted THEN 1 END), 0) * 100, 2
|
|
158
|
+
(COUNT(CASE WHEN recovery_successful THEN 1 END)::numeric /
|
|
159
|
+
NULLIF(COUNT(CASE WHEN recovery_attempted THEN 1 END), 0)::numeric * 100), 2
|
|
155
160
|
) as recovery_success_rate,
|
|
156
161
|
DATE(created_at) as error_date
|
|
157
162
|
FROM error_logs
|
|
158
163
|
GROUP BY error_type, model_name, DATE(created_at)
|
|
159
164
|
ORDER BY total_errors DESC;
|
|
160
165
|
|
|
166
|
+
DROP VIEW IF EXISTS personalization_effectiveness CASCADE;
|
|
161
167
|
CREATE OR REPLACE VIEW personalization_effectiveness AS
|
|
162
168
|
SELECT
|
|
163
169
|
persona_assigned,
|
|
164
170
|
COUNT(*) as total_interactions,
|
|
165
|
-
AVG(user_satisfaction_score) as avg_satisfaction,
|
|
171
|
+
ROUND(AVG(user_satisfaction_score)::numeric, 2) as avg_satisfaction,
|
|
166
172
|
COUNT(CASE WHEN user_satisfaction_score >= 4 THEN 1 END) as satisfied_users,
|
|
167
173
|
ROUND(
|
|
168
|
-
COUNT(CASE WHEN user_satisfaction_score >= 4 THEN 1 END)::
|
|
169
|
-
COUNT(*) * 100, 2
|
|
174
|
+
(COUNT(CASE WHEN user_satisfaction_score >= 4 THEN 1 END)::numeric /
|
|
175
|
+
NULLIF(COUNT(*), 0)::numeric * 100), 2
|
|
170
176
|
) as satisfaction_rate
|
|
171
177
|
FROM personalization_metrics
|
|
172
178
|
WHERE persona_assigned IS NOT NULL
|
|
173
179
|
GROUP BY persona_assigned
|
|
174
180
|
ORDER BY avg_satisfaction DESC;
|
|
175
181
|
|
|
182
|
+
DROP VIEW IF EXISTS proactive_engagement_performance CASCADE;
|
|
176
183
|
CREATE OR REPLACE VIEW proactive_engagement_performance AS
|
|
177
184
|
SELECT
|
|
178
185
|
c.campaign_name,
|
|
@@ -180,10 +187,10 @@ SELECT
|
|
|
180
187
|
COUNT(h.id) as messages_sent,
|
|
181
188
|
COUNT(CASE WHEN h.delivery_status = 'responded' THEN 1 END) as responses_received,
|
|
182
189
|
ROUND(
|
|
183
|
-
COUNT(CASE WHEN h.delivery_status = 'responded' THEN 1 END)::
|
|
184
|
-
COUNT(h.id) * 100, 2
|
|
190
|
+
(COUNT(CASE WHEN h.delivery_status = 'responded' THEN 1 END)::numeric /
|
|
191
|
+
NULLIF(COUNT(h.id), 0)::numeric * 100), 2
|
|
185
192
|
) as response_rate,
|
|
186
|
-
AVG(h.engagement_score) as avg_engagement_score
|
|
193
|
+
ROUND(AVG(h.engagement_score)::numeric, 2) as avg_engagement_score
|
|
187
194
|
FROM proactive_campaigns c
|
|
188
195
|
LEFT JOIN proactive_engagement_history h ON c.id = h.campaign_id
|
|
189
196
|
WHERE c.active = true
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Migration 008: Platform Identity Links
|
|
2
|
+
-- Maps internal canonical myavana_user_id to external provider identities
|
|
3
|
+
-- (wordpress, kommunicate, mobile, widget_anon)
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS identity_links (
|
|
6
|
+
id SERIAL PRIMARY KEY,
|
|
7
|
+
myavana_user_id VARCHAR(255) NOT NULL,
|
|
8
|
+
provider VARCHAR(50) NOT NULL, -- 'wordpress', 'kommunicate', 'widget_anon', 'mobile'
|
|
9
|
+
external_id VARCHAR(255) NOT NULL,
|
|
10
|
+
metadata JSONB DEFAULT '{}',
|
|
11
|
+
verified_at TIMESTAMP,
|
|
12
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
13
|
+
updated_at TIMESTAMP DEFAULT NOW(),
|
|
14
|
+
UNIQUE(provider, external_id)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_identity_links_lookup ON identity_links(provider, external_id);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_identity_links_canonical ON identity_links(myavana_user_id);
|
|
@@ -145,6 +145,7 @@ BEGIN
|
|
|
145
145
|
CREATE INDEX IF NOT EXISTS idx_ai_model_performance_date ON ai_model_performance(date_tracked);
|
|
146
146
|
|
|
147
147
|
-- Views
|
|
148
|
+
DROP VIEW IF EXISTS daily_conversation_stats CASCADE;
|
|
148
149
|
CREATE OR REPLACE VIEW daily_conversation_stats AS
|
|
149
150
|
SELECT
|
|
150
151
|
DATE(created_at) as date,
|
|
@@ -156,14 +157,15 @@ BEGIN
|
|
|
156
157
|
GROUP BY DATE(created_at)
|
|
157
158
|
ORDER BY date DESC;
|
|
158
159
|
|
|
160
|
+
DROP VIEW IF EXISTS model_reliability_stats CASCADE;
|
|
159
161
|
CREATE OR REPLACE VIEW model_reliability_stats AS
|
|
160
162
|
SELECT
|
|
161
163
|
model_name,
|
|
162
164
|
SUM(request_count) as total_requests,
|
|
163
165
|
SUM(success_count) as total_successes,
|
|
164
166
|
SUM(failure_count) as total_failures,
|
|
165
|
-
ROUND((SUM(success_count)::
|
|
166
|
-
ROUND(AVG(avg_response_time_ms), 2) as avg_response_time
|
|
167
|
+
ROUND((SUM(success_count)::numeric / NULLIF(SUM(request_count), 0)::numeric * 100), 2) as success_rate,
|
|
168
|
+
ROUND(AVG(avg_response_time_ms)::numeric, 2) as avg_response_time
|
|
167
169
|
FROM ai_model_performance
|
|
168
170
|
WHERE date_tracked >= CURRENT_DATE - INTERVAL '30 days'
|
|
169
171
|
GROUP BY model_name;
|
|
@@ -265,6 +267,8 @@ BEGIN
|
|
|
265
267
|
resolved_at TIMESTAMP
|
|
266
268
|
);
|
|
267
269
|
|
|
270
|
+
ALTER TABLE hair_issues ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
|
|
271
|
+
|
|
268
272
|
-- Indexes
|
|
269
273
|
CREATE INDEX IF NOT EXISTS idx_error_logs_user_created ON error_logs(user_id, created_at);
|
|
270
274
|
CREATE INDEX IF NOT EXISTS idx_error_logs_type_model ON error_logs(error_type, model_name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myavana-bot-test-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Shared bot functionality with enhanced features",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -11,13 +11,10 @@
|
|
|
11
11
|
"lint:fix": "eslint src/ --fix"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@genkit-ai/google-genai": "1.28.0",
|
|
15
|
-
"@genkit-ai/googleai": "1.28.0",
|
|
16
14
|
"@google/genai": "1.39.0",
|
|
17
15
|
"dotenv": "^16.4.7",
|
|
18
16
|
"express-rate-limit": "^7.1.5",
|
|
19
17
|
"express-slow-down": "^2.0.1",
|
|
20
|
-
"genkit": "1.28.0",
|
|
21
18
|
"isomorphic-dompurify": "^2.9.0",
|
|
22
19
|
"joi": "^17.11.0",
|
|
23
20
|
"node-cache": "^5.1.2",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { Client } = require('pg');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
require('dotenv').config({ path: path.join(__dirname, '../../myavana/.env') });
|
|
5
|
+
|
|
6
|
+
async function run() {
|
|
7
|
+
const client = new Client({
|
|
8
|
+
host: process.env.DB_HOST,
|
|
9
|
+
user: process.env.DB_USER,
|
|
10
|
+
password: process.env.DB_PASSWORD,
|
|
11
|
+
database: process.env.DB_NAME,
|
|
12
|
+
port: parseInt(process.env.DB_PORT, 10) || 5432
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
await client.connect();
|
|
16
|
+
console.log('Connected to PostgreSQL database at', process.env.DB_HOST);
|
|
17
|
+
|
|
18
|
+
// Ensure schema_migrations table exists
|
|
19
|
+
await client.query(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
21
|
+
id SERIAL PRIMARY KEY,
|
|
22
|
+
migration_name VARCHAR(255) NOT NULL UNIQUE,
|
|
23
|
+
applied_at TIMESTAMP DEFAULT NOW()
|
|
24
|
+
);
|
|
25
|
+
`);
|
|
26
|
+
|
|
27
|
+
const migrationsToRun = [
|
|
28
|
+
'001_add_analytics_tables.sql',
|
|
29
|
+
'002_add_advanced_features_tables.sql',
|
|
30
|
+
'008_platform_identity.sql'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const file of migrationsToRun) {
|
|
34
|
+
const migrationName = path.basename(file, '.sql');
|
|
35
|
+
const checkRes = await client.query('SELECT 1 FROM schema_migrations WHERE migration_name = $1', [migrationName]);
|
|
36
|
+
if (checkRes.rows.length > 0) {
|
|
37
|
+
console.log(`Migration ${migrationName} already marked as applied, running idempotently...`);
|
|
38
|
+
} else {
|
|
39
|
+
console.log(`Applying migration ${migrationName}...`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const filePath = path.join(__dirname, '../migrations', file);
|
|
43
|
+
const sql = fs.readFileSync(filePath, 'utf8');
|
|
44
|
+
|
|
45
|
+
await client.query('BEGIN');
|
|
46
|
+
try {
|
|
47
|
+
await client.query(sql);
|
|
48
|
+
await client.query(
|
|
49
|
+
`INSERT INTO schema_migrations (migration_name) VALUES ($1) ON CONFLICT (migration_name) DO NOTHING`,
|
|
50
|
+
[migrationName]
|
|
51
|
+
);
|
|
52
|
+
await client.query('COMMIT');
|
|
53
|
+
console.log(`✅ ${migrationName} successfully applied!`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
await client.query('ROLLBACK');
|
|
56
|
+
console.error(`❌ Migration ${migrationName} failed:`, err.message);
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await client.end();
|
|
62
|
+
console.log('🏁 All requested migrations applied successfully!');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
run().catch((err) => {
|
|
66
|
+
console.error('Fatal error during migration execution:', err);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
});
|
|
@@ -7,18 +7,15 @@ jest.mock('../database', () => ({
|
|
|
7
7
|
redisClient: { get: mockRedisGet, set: mockRedisSet },
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
jest.mock('../ai', () => ({}));
|
|
11
|
-
jest.mock('@genkit-ai/google-genai', () => ({
|
|
12
|
-
googleAI: { model: jest.fn(() => 'mocked-model') },
|
|
13
|
-
}));
|
|
14
|
-
jest.mock('../config', () => ({ genkitApiKey: 'test-key' }));
|
|
15
|
-
|
|
16
10
|
const mockCacheCreate = jest.fn();
|
|
17
|
-
jest.mock('
|
|
18
|
-
|
|
19
|
-
caches: {
|
|
20
|
-
|
|
11
|
+
jest.mock('../ai', () => ({
|
|
12
|
+
client: {
|
|
13
|
+
caches: {
|
|
14
|
+
create: mockCacheCreate,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
21
17
|
}));
|
|
18
|
+
jest.mock('../config', () => ({ genkitApiKey: 'test-key' }));
|
|
22
19
|
|
|
23
20
|
const CacheManager = require('../cacheManager');
|
|
24
21
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const { HairJourneyService } = require('../hairJourney/hairJourneyService');
|
|
2
|
+
const { BlockType, ActionType } = require('../chatProtocol');
|
|
3
|
+
|
|
4
|
+
describe('HairJourneyService', () => {
|
|
5
|
+
let service;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
service = new HairJourneyService();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('buildGoalCardBlock returns valid GOAL_CARD block', () => {
|
|
12
|
+
const goal = {
|
|
13
|
+
id: 'goal_test_1',
|
|
14
|
+
title: 'Grow 4 inches',
|
|
15
|
+
category: 'Length',
|
|
16
|
+
progressPercent: 50,
|
|
17
|
+
targetMetric: '4 Inches',
|
|
18
|
+
targetDate: 'Dec 2026'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const block = service.buildGoalCardBlock(goal);
|
|
22
|
+
expect(block.type).toBe(BlockType.GOAL_CARD);
|
|
23
|
+
expect(block.data.title).toBe('Grow 4 inches');
|
|
24
|
+
expect(block.data.progressPercent).toBe(50);
|
|
25
|
+
expect(block.actions.length).toBeGreaterThan(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('buildTodayChecklistBlock returns valid TODAY_CHECKLIST block', () => {
|
|
29
|
+
const todayData = {
|
|
30
|
+
date: '2026-09-04',
|
|
31
|
+
streakDays: 5,
|
|
32
|
+
checklist: [
|
|
33
|
+
{ id: 's1', title: 'Hydration Mist', completed: true },
|
|
34
|
+
{ id: 's2', title: 'Deep Condition', completed: false }
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const block = service.buildTodayChecklistBlock(todayData);
|
|
39
|
+
expect(block.type).toBe(BlockType.TODAY_CHECKLIST);
|
|
40
|
+
expect(block.data.streakDays).toBe(5);
|
|
41
|
+
expect(block.data.steps.length).toBe(2);
|
|
42
|
+
expect(block.data.steps[0].completed).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('buildJournalEntryBlock returns valid JOURNAL_ENTRY block with UPLOAD_PHOTO action', () => {
|
|
46
|
+
const entry = {
|
|
47
|
+
id: 123,
|
|
48
|
+
title: 'Wash Day Success',
|
|
49
|
+
notes: 'Hair felt deeply moisturized and shiny',
|
|
50
|
+
moistureLevel: 5,
|
|
51
|
+
scalpState: 'Healthy',
|
|
52
|
+
photos: ['https://example.com/photo1.jpg']
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const block = service.buildJournalEntryBlock(entry);
|
|
56
|
+
expect(block.type).toBe(BlockType.JOURNAL_ENTRY);
|
|
57
|
+
expect(block.data.title).toBe('Wash Day Success');
|
|
58
|
+
expect(block.data.moistureLevel).toBe(5);
|
|
59
|
+
expect(block.data.photos.length).toBe(1);
|
|
60
|
+
|
|
61
|
+
const uploadAction = block.actions.find(a => a.type === ActionType.UPLOAD_PHOTO);
|
|
62
|
+
expect(uploadAction).toBeDefined();
|
|
63
|
+
expect(uploadAction.payload.entryId).toBe(123);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('buildHairProfileBlock preserves nulls and does not fabricate Type 4 or Normal porosity', () => {
|
|
67
|
+
const emptyProfile = {
|
|
68
|
+
hairType: null,
|
|
69
|
+
porosity: null,
|
|
70
|
+
hairConcerns: []
|
|
71
|
+
};
|
|
72
|
+
const block = service.buildHairProfileBlock(emptyProfile);
|
|
73
|
+
expect(block).toBeDefined();
|
|
74
|
+
expect(block.data.hairType).toBeNull();
|
|
75
|
+
expect(block.data.porosity).toBeNull();
|
|
76
|
+
expect(block.data.regimen).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('buildHairProfileBlock returns null when profile is unavailable', () => {
|
|
80
|
+
const unavailableProfile = { available: false, error: 'API offline' };
|
|
81
|
+
const block = service.buildHairProfileBlock(unavailableProfile);
|
|
82
|
+
expect(block).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('honest unavailable handling when backend is offline', async () => {
|
|
86
|
+
// Service with offline/unreachable provider
|
|
87
|
+
const offlineProvider = {
|
|
88
|
+
getGoals: jest.fn().mockResolvedValue({ available: false, error: 'offline', goals: [] }),
|
|
89
|
+
getTodayData: jest.fn().mockResolvedValue({ available: false, error: 'offline', checklist: [] }),
|
|
90
|
+
getJournalEntries: jest.fn().mockResolvedValue({ available: false, error: 'offline', items: [] }),
|
|
91
|
+
getProfile: jest.fn().mockResolvedValue({ available: false, error: 'offline' })
|
|
92
|
+
};
|
|
93
|
+
const offlineService = new HairJourneyService({ provider: offlineProvider });
|
|
94
|
+
|
|
95
|
+
const goalsRes = await offlineService.getGoalsWithBlocks('user_123');
|
|
96
|
+
expect(goalsRes.result.available).toBe(false);
|
|
97
|
+
expect(goalsRes.blocks).toEqual([]);
|
|
98
|
+
|
|
99
|
+
const todayRes = await offlineService.getTodayWithBlocks('user_123');
|
|
100
|
+
expect(todayRes.result.available).toBe(false);
|
|
101
|
+
expect(todayRes.blocks).toEqual([]);
|
|
102
|
+
|
|
103
|
+
const profileRes = await offlineService.getProfileWithBlocks('user_123');
|
|
104
|
+
expect(profileRes.result.available).toBe(false);
|
|
105
|
+
expect(profileRes.blocks).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('getGoalsWithBlocks returns goals and blocks when provider has authentic data', async () => {
|
|
109
|
+
const mockProvider = {
|
|
110
|
+
getGoals: jest.fn().mockResolvedValue({
|
|
111
|
+
available: true,
|
|
112
|
+
goals: [
|
|
113
|
+
{ id: 'g1', title: 'Retain 4 Inches Length', progressPercent: 68, targetMetric: '14.5 in', targetDate: '2026-12-15' }
|
|
114
|
+
]
|
|
115
|
+
})
|
|
116
|
+
};
|
|
117
|
+
const activeService = new HairJourneyService({ provider: mockProvider });
|
|
118
|
+
const { result, blocks } = await activeService.getGoalsWithBlocks('test_user');
|
|
119
|
+
expect(result.available).toBe(true);
|
|
120
|
+
expect(blocks.length).toBe(1);
|
|
121
|
+
expect(blocks[0].type).toBe(BlockType.GOAL_CARD);
|
|
122
|
+
expect(blocks[0].data.title).toBe('Retain 4 Inches Length');
|
|
123
|
+
expect(blocks[0].data.progressPercent).toBe(68);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('getTodayWithBlocks returns today checklist block when provider has authentic data', async () => {
|
|
127
|
+
const mockProvider = {
|
|
128
|
+
getTodayData: jest.fn().mockResolvedValue({
|
|
129
|
+
available: true,
|
|
130
|
+
streakDays: 9,
|
|
131
|
+
checklist: [{ id: 's1', title: 'Morning Hydration', completed: true }]
|
|
132
|
+
})
|
|
133
|
+
};
|
|
134
|
+
const activeService = new HairJourneyService({ provider: mockProvider });
|
|
135
|
+
const { result, blocks } = await activeService.getTodayWithBlocks('test_user');
|
|
136
|
+
expect(result.available).toBe(true);
|
|
137
|
+
expect(blocks.length).toBe(1);
|
|
138
|
+
expect(blocks[0].type).toBe(BlockType.TODAY_CHECKLIST);
|
|
139
|
+
expect(blocks[0].data.streakDays).toBe(9);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('getProfileWithBlocks returns authentic hair profile without fabrication', async () => {
|
|
143
|
+
const mockProvider = {
|
|
144
|
+
getProfile: jest.fn().mockResolvedValue({
|
|
145
|
+
available: true,
|
|
146
|
+
hairType: '4A',
|
|
147
|
+
porosity: 'Medium',
|
|
148
|
+
elasticity: 'Medium',
|
|
149
|
+
strandDiameter: 'Medium',
|
|
150
|
+
hairConcerns: ['Moisture Retention']
|
|
151
|
+
})
|
|
152
|
+
};
|
|
153
|
+
const activeService = new HairJourneyService({ provider: mockProvider });
|
|
154
|
+
const { result, blocks } = await activeService.getProfileWithBlocks('test_user');
|
|
155
|
+
expect(result.available).toBe(true);
|
|
156
|
+
expect(result.hairType).toBe('4A');
|
|
157
|
+
expect(result.porosity).toBe('Medium');
|
|
158
|
+
expect(blocks.length).toBe(1);
|
|
159
|
+
expect(blocks[0].data.hairType).toBe('4A');
|
|
160
|
+
expect(blocks[0].data.porosity).toBe('Medium');
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/agentMemory.js
CHANGED
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
// the existing AWS/GCP infra. Extraction follows the same LLM-structured-extraction
|
|
5
5
|
// pattern already used by postProcessConversation/classifyAndSavePersona.
|
|
6
6
|
const ai = require('./ai');
|
|
7
|
-
const { googleAI } = require('@genkit-ai/google-genai');
|
|
8
7
|
const { pgClient } = require('./database');
|
|
9
8
|
|
|
10
|
-
const EMBEDDER = googleAI.embedder('gemini-embedding-001'); // 768-dim output, matches agent_memories.embedding
|
|
11
9
|
const DUPLICATE_DISTANCE_THRESHOLD = 0.15; // cosine distance below this = treat as the same memory
|
|
12
10
|
|
|
13
11
|
async function embedText(text) {
|
|
14
|
-
const [{ embedding }] = await ai.embed({ embedder:
|
|
12
|
+
const [{ embedding }] = await ai.embed({ embedder: 'gemini-embedding-001', content: text });
|
|
15
13
|
return `[${embedding.join(',')}]`;
|
|
16
14
|
}
|
|
17
15
|
|
|
@@ -44,7 +42,7 @@ const extractAndStoreMemories = async (userId, conversationId, chatHistory) => {
|
|
|
44
42
|
if (!latestUserTurn?.content) return;
|
|
45
43
|
|
|
46
44
|
const chat = ai.chat({
|
|
47
|
-
model:
|
|
45
|
+
model: 'gemini-2.5-flash',
|
|
48
46
|
config: { temperature: 0.3 },
|
|
49
47
|
});
|
|
50
48
|
const { output } = await chat.send(extractionPrompt(chatHistory, latestUserTurn.content));
|