prompt-learning.test.js
1 /** 2 * Tests for src/utils/prompt-learning.js 3 * 4 * Tests: logPromptFeedback, analyzePromptFeedback, generatePromptRecommendations, 5 * getPromptHistory. 6 * 7 * versionPromptFile() reads from disk (prompts/FILE) — not tested here. 8 * 9 * Uses pg-mock pattern since prompt-learning.js imports from db.js. 10 */ 11 12 import { test, describe, mock } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 import Database from 'better-sqlite3'; 15 import { createPgMock } from '../helpers/pg-mock.js'; 16 17 // Seed DB BEFORE importing (module uses db.js internally) 18 const db = new Database(':memory:'); 19 db.exec(` 20 CREATE TABLE IF NOT EXISTS sites ( 21 id INTEGER PRIMARY KEY AUTOINCREMENT, 22 domain TEXT NOT NULL, 23 rescored_at DATETIME 24 ); 25 26 CREATE TABLE IF NOT EXISTS messages ( 27 id INTEGER PRIMARY KEY AUTOINCREMENT, 28 site_id INTEGER REFERENCES sites(id), 29 read_at TEXT, 30 message_type TEXT DEFAULT 'outreach', 31 raw_payload TEXT 32 ); 33 34 CREATE TABLE IF NOT EXISTS prompt_feedback ( 35 id INTEGER PRIMARY KEY AUTOINCREMENT, 36 message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, 37 site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, 38 prompt_file TEXT NOT NULL, 39 prompt_version INTEGER DEFAULT 1, 40 feedback_type TEXT NOT NULL, 41 feedback_text TEXT, 42 feedback_category TEXT, 43 resulted_in_sale INTEGER DEFAULT 0, 44 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 45 ); 46 47 CREATE TABLE IF NOT EXISTS prompt_versions ( 48 id INTEGER PRIMARY KEY AUTOINCREMENT, 49 prompt_file TEXT NOT NULL, 50 version INTEGER NOT NULL, 51 content TEXT NOT NULL, 52 change_summary TEXT, 53 learning_applied TEXT, 54 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 55 UNIQUE(prompt_file, version) 56 ); 57 `); 58 59 // Insert a site and a message 60 db.exec(`INSERT INTO sites (domain) VALUES ('example.com')`); 61 const siteId = db.prepare('SELECT id FROM sites LIMIT 1').get().id; 62 db.exec(`INSERT INTO messages (site_id) VALUES (${siteId})`); 63 const msgId = db.prepare('SELECT id FROM messages LIMIT 1').get().id; 64 65 // Insert prompt feedback for 'PROPOSAL.md' 66 const insert = db.prepare(` 67 INSERT INTO prompt_feedback (message_id, site_id, prompt_file, feedback_type, feedback_text, feedback_category) 68 VALUES (?, ?, ?, ?, ?, ?) 69 `); 70 71 insert.run(msgId, siteId, 'PROPOSAL.md', 'approved', 'Great proposal', 'tone'); 72 insert.run(msgId, siteId, 'PROPOSAL.md', 'approved', 'Good job', 'length'); 73 insert.run(msgId, siteId, 'PROPOSAL.md', 'rework', 'Too salesy', 'tone'); 74 insert.run(msgId, siteId, 'PROPOSAL.md', 'rework', 'Too long text', 'length'); 75 insert.run(msgId, siteId, 'PROPOSAL.md', 'rework', 'Very pushy tone', 'tone'); 76 insert.run(msgId, siteId, 'PROPOSAL.md', 'rejected', 'Not relevant', 'other'); 77 insert.run(msgId, siteId, 'PROPOSAL.md', 'conversion', null, null); 78 // different prompt file 79 insert.run(msgId, siteId, 'SCORING.md', 'approved', 'Looks good', null); 80 81 mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) }); 82 83 const { 84 logPromptFeedback, 85 analyzePromptFeedback, 86 generatePromptRecommendations, 87 getPromptHistory, 88 } = await import('../../src/utils/prompt-learning.js'); 89 90 // ─── logPromptFeedback ──────────────────────────────────────────────────────── 91 92 describe('logPromptFeedback', () => { 93 test('inserts feedback without throwing', async () => { 94 await assert.doesNotReject(() => 95 logPromptFeedback({ 96 messageId: null, 97 siteId: null, 98 promptFile: 'TEST.md', 99 feedbackType: 'approved', 100 feedbackText: 'Looks great', 101 }) 102 ); 103 }); 104 105 test('auto-categorizes feedback text', async () => { 106 // After logging, we can verify via analyzePromptFeedback 107 await logPromptFeedback({ 108 promptFile: 'TEST.md', 109 feedbackType: 'rework', 110 feedbackText: 'This is too salesy and pushy', 111 }); 112 113 const analysis = await analyzePromptFeedback('TEST.md', 1); 114 assert.ok(analysis.stats.total > 0); 115 }); 116 117 test('accepts outreachId as alias for messageId', async () => { 118 await assert.doesNotReject(() => 119 logPromptFeedback({ 120 outreachId: null, 121 siteId: null, 122 promptFile: 'ALIAS.md', 123 feedbackType: 'rejected', 124 feedbackText: 'Not relevant', 125 }) 126 ); 127 }); 128 }); 129 130 // ─── analyzePromptFeedback ──────────────────────────────────────────────────── 131 132 describe('analyzePromptFeedback', () => { 133 test('returns analysis object for known prompt file', async () => { 134 const analysis = await analyzePromptFeedback('PROPOSAL.md', 30); 135 assert.ok(typeof analysis === 'object'); 136 assert.equal(analysis.promptFile, 'PROPOSAL.md'); 137 assert.equal(analysis.period, '30 days'); 138 }); 139 140 test('stats object has expected fields', async () => { 141 const { stats } = await analyzePromptFeedback('PROPOSAL.md', 30); 142 assert.ok('approved' in stats); 143 assert.ok('rework' in stats); 144 assert.ok('rejected' in stats); 145 assert.ok('conversions' in stats); 146 assert.ok('total' in stats); 147 assert.ok('approvalRate' in stats); 148 }); 149 150 test('counts feedback correctly', async () => { 151 const { stats } = await analyzePromptFeedback('PROPOSAL.md', 30); 152 assert.equal(stats.approved, 2); 153 assert.equal(stats.rework, 3); 154 assert.equal(stats.rejected, 1); 155 assert.equal(stats.conversions, 1); 156 assert.equal(stats.total, 7); 157 }); 158 159 test('calculates approvalRate as (approved+conversions)/total', async () => { 160 const { stats } = await analyzePromptFeedback('PROPOSAL.md', 30); 161 // (2 approved + 1 conversion) / 7 total = 42.9% 162 assert.ok(stats.approvalRate > 40 && stats.approvalRate < 45); 163 }); 164 165 test('returns empty stats for unknown prompt file', async () => { 166 const analysis = await analyzePromptFeedback('UNKNOWN.md', 30); 167 assert.equal(analysis.stats.total, 0); 168 assert.equal(analysis.stats.approvalRate, 0); 169 }); 170 171 test('includes distribution array', async () => { 172 const analysis = await analyzePromptFeedback('PROPOSAL.md', 30); 173 assert.ok(Array.isArray(analysis.distribution)); 174 assert.ok(analysis.distribution.length > 0); 175 }); 176 177 test('includes categories array', async () => { 178 const analysis = await analyzePromptFeedback('PROPOSAL.md', 30); 179 assert.ok(Array.isArray(analysis.categories)); 180 }); 181 182 test('includes topIssues (up to 5 categories)', async () => { 183 const analysis = await analyzePromptFeedback('PROPOSAL.md', 30); 184 assert.ok(Array.isArray(analysis.topIssues)); 185 assert.ok(analysis.topIssues.length <= 5); 186 }); 187 }); 188 189 // ─── generatePromptRecommendations ─────────────────────────────────────────── 190 191 describe('generatePromptRecommendations', () => { 192 test('returns object with recommendations array', async () => { 193 const result = await generatePromptRecommendations('PROPOSAL.md'); 194 assert.ok(typeof result === 'object'); 195 assert.ok(Array.isArray(result.recommendations)); 196 }); 197 198 test('includes high-priority recommendation when approval rate < 70%', async () => { 199 // PROPOSAL.md has ~42.9% approval rate 200 const result = await generatePromptRecommendations('PROPOSAL.md'); 201 const highPriority = result.recommendations.filter(r => r.priority === 'high'); 202 assert.ok( 203 highPriority.length > 0, 204 'should have high priority recommendation for low approval rate' 205 ); 206 }); 207 208 test('includes analysis stats in result', async () => { 209 const result = await generatePromptRecommendations('PROPOSAL.md'); 210 assert.ok('stats' in result); 211 assert.ok('distribution' in result); 212 }); 213 }); 214 215 // ─── getPromptHistory ───────────────────────────────────────────────────────── 216 217 describe('getPromptHistory', () => { 218 test('returns empty array when no versions exist', async () => { 219 const history = await getPromptHistory('NO-VERSIONS.md'); 220 assert.deepEqual(history, []); 221 }); 222 223 test('returns array (possibly empty) for any prompt file', async () => { 224 const history = await getPromptHistory('PROPOSAL.md'); 225 assert.ok(Array.isArray(history)); 226 }); 227 228 test('respects limit parameter', async () => { 229 // Insert a few versions for testing 230 db.prepare( 231 `INSERT INTO prompt_versions (prompt_file, version, content) VALUES ('LIMIT-TEST.md', 1, 'v1')` 232 ).run(); 233 db.prepare( 234 `INSERT INTO prompt_versions (prompt_file, version, content) VALUES ('LIMIT-TEST.md', 2, 'v2')` 235 ).run(); 236 db.prepare( 237 `INSERT INTO prompt_versions (prompt_file, version, content) VALUES ('LIMIT-TEST.md', 3, 'v3')` 238 ).run(); 239 240 const history = await getPromptHistory('LIMIT-TEST.md', 2); 241 assert.equal(history.length, 2); 242 }); 243 });