@conversionpros/aiva 1.0.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/README.md +148 -0
- package/auto-deploy.js +190 -0
- package/bin/aiva.js +81 -0
- package/cli-sync.js +126 -0
- package/d2a-prompt-template.txt +106 -0
- package/diagnostics-api.js +304 -0
- package/docs/ara-dedup-fix-scope.md +112 -0
- package/docs/ara-fix-round2-scope.md +61 -0
- package/docs/ara-greeting-fix-scope.md +70 -0
- package/docs/calendar-date-fix-scope.md +28 -0
- package/docs/getting-started.md +115 -0
- package/docs/network-architecture-rollout-scope.md +43 -0
- package/docs/scope-google-oauth-integration.md +351 -0
- package/docs/settings-page-scope.md +50 -0
- package/docs/xai-imagine-scope.md +116 -0
- package/docs/xai-voice-integration-scope.md +115 -0
- package/docs/xai-voice-tools-scope.md +165 -0
- package/email-router.js +512 -0
- package/follow-up-handler.js +606 -0
- package/gateway-monitor.js +158 -0
- package/google-email.js +379 -0
- package/google-oauth.js +310 -0
- package/grok-imagine.js +97 -0
- package/health-reporter.js +287 -0
- package/invisible-prefix-base.txt +206 -0
- package/invisible-prefix-owner.txt +26 -0
- package/invisible-prefix-slim.txt +10 -0
- package/invisible-prefix.txt +43 -0
- package/knowledge-base.js +472 -0
- package/lib/cli.js +19 -0
- package/lib/config.js +124 -0
- package/lib/health.js +57 -0
- package/lib/process.js +207 -0
- package/lib/server.js +42 -0
- package/lib/setup.js +472 -0
- package/meta-capi.js +206 -0
- package/meta-leads.js +411 -0
- package/notion-oauth.js +323 -0
- package/package.json +61 -0
- package/public/agent-config.html +241 -0
- package/public/aiva-avatar-anime.png +0 -0
- package/public/css/docs.css.bak +688 -0
- package/public/css/onboarding.css +543 -0
- package/public/diagrams/claude-subscription-pool.html +329 -0
- package/public/diagrams/claude-subscription-pool.png +0 -0
- package/public/docs-icon.png +0 -0
- package/public/escalation.html +237 -0
- package/public/group-config.html +300 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/agents.svg +1 -0
- package/public/icons/attach.svg +1 -0
- package/public/icons/characters.svg +1 -0
- package/public/icons/chat.svg +1 -0
- package/public/icons/docs.svg +1 -0
- package/public/icons/heartbeat.svg +1 -0
- package/public/icons/messages.svg +1 -0
- package/public/icons/mic.svg +1 -0
- package/public/icons/notes.svg +1 -0
- package/public/icons/settings.svg +1 -0
- package/public/icons/tasks.svg +1 -0
- package/public/images/onboarding/p0-communication-layer.png +0 -0
- package/public/images/onboarding/p0-infinite-surface.png +0 -0
- package/public/images/onboarding/p0-learning-model.png +0 -0
- package/public/images/onboarding/p0-meet-aiva.png +0 -0
- package/public/images/onboarding/p4-contact-intelligence.png +0 -0
- package/public/images/onboarding/p4-context-compounds.png +0 -0
- package/public/images/onboarding/p4-message-router.png +0 -0
- package/public/images/onboarding/p4-per-contact-rules.png +0 -0
- package/public/images/onboarding/p4-send-messages.png +0 -0
- package/public/images/onboarding/p6-be-precise.png +0 -0
- package/public/images/onboarding/p6-review-escalations.png +0 -0
- package/public/images/onboarding/p6-voice-input.png +0 -0
- package/public/images/onboarding/p7-completion.png +0 -0
- package/public/index.html +11594 -0
- package/public/js/onboarding.js +699 -0
- package/public/manifest.json +24 -0
- package/public/messages-v2.html +2824 -0
- package/public/permission-approve.html.bak +107 -0
- package/public/permissions.html +150 -0
- package/public/styles/design-system.css +68 -0
- package/router-db.js +604 -0
- package/router-utils.js +28 -0
- package/router-v2/adapters/imessage.js +191 -0
- package/router-v2/adapters/quo.js +82 -0
- package/router-v2/adapters/whatsapp.js +192 -0
- package/router-v2/contact-manager.js +234 -0
- package/router-v2/conversation-engine.js +498 -0
- package/router-v2/data/knowledge-base.json +176 -0
- package/router-v2/data/router-v2.db +0 -0
- package/router-v2/data/router-v2.db-shm +0 -0
- package/router-v2/data/router-v2.db-wal +0 -0
- package/router-v2/data/router.db +0 -0
- package/router-v2/db.js +457 -0
- package/router-v2/escalation-bridge.js +540 -0
- package/router-v2/follow-up-engine.js +347 -0
- package/router-v2/index.js +441 -0
- package/router-v2/ingestion.js +213 -0
- package/router-v2/knowledge-base.js +231 -0
- package/router-v2/lead-qualifier.js +152 -0
- package/router-v2/learning-loop.js +202 -0
- package/router-v2/outbound-sender.js +160 -0
- package/router-v2/package.json +13 -0
- package/router-v2/permission-gate.js +86 -0
- package/router-v2/playbook.js +177 -0
- package/router-v2/prompts/base.js +52 -0
- package/router-v2/prompts/first-contact.js +38 -0
- package/router-v2/prompts/lead-qualification.js +37 -0
- package/router-v2/prompts/scheduling.js +72 -0
- package/router-v2/prompts/style-overrides.js +22 -0
- package/router-v2/scheduler.js +301 -0
- package/router-v2/scripts/migrate-v1-to-v2.js +215 -0
- package/router-v2/scripts/seed-faq.js +67 -0
- package/router-v2/seed-knowledge-base.js +39 -0
- package/router-v2/utils/ai.js +129 -0
- package/router-v2/utils/phone.js +52 -0
- package/router-v2/utils/response-validator.js +98 -0
- package/router-v2/utils/sanitize.js +222 -0
- package/router.js +5005 -0
- package/routes/google-calendar.js +186 -0
- package/scripts/deploy.sh +62 -0
- package/scripts/macos-calendar.sh +232 -0
- package/scripts/onboard-device.sh +466 -0
- package/server.js +5131 -0
- package/start.sh +24 -0
- package/templates/AGENTS.md +548 -0
- package/templates/IDENTITY.md +15 -0
- package/templates/docs-agents.html +132 -0
- package/templates/docs-app.html +130 -0
- package/templates/docs-home.html +83 -0
- package/templates/docs-imessage.html +121 -0
- package/templates/docs-tasks.html +123 -0
- package/templates/docs-tips.html +175 -0
- package/templates/getting-started.html +809 -0
- package/templates/invisible-prefix-base.txt +171 -0
- package/templates/invisible-prefix-owner.txt +282 -0
- package/templates/invisible-prefix.txt +338 -0
- package/templates/manifest.json +61 -0
- package/templates/memory-org/clients.md +7 -0
- package/templates/memory-org/credentials.md +9 -0
- package/templates/memory-org/devices.md +7 -0
- package/templates/updates.html +464 -0
- package/templates/workspace/AGENTS.md.tmpl +161 -0
- package/templates/workspace/HEARTBEAT.md.tmpl +17 -0
- package/templates/workspace/IDENTITY.md.tmpl +15 -0
- package/templates/workspace/MEMORY.md.tmpl +16 -0
- package/templates/workspace/SOUL.md.tmpl +51 -0
- package/templates/workspace/USER.md.tmpl +25 -0
- package/tts-proxy.js +96 -0
- package/voice-call-local.js +731 -0
- package/voice-call.js +732 -0
- package/wa-listener.js +354 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// ── Knowledge Base - Structured FAQ + BM25 Workspace Search ──
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { getStmts } = require('./db');
|
|
7
|
+
|
|
8
|
+
const WORKSPACE = path.join(process.env.HOME || '/Users/brandonburgan', '.openclaw', 'workspace');
|
|
9
|
+
|
|
10
|
+
function log(msg, data) {
|
|
11
|
+
const ts = new Date().toISOString();
|
|
12
|
+
if (data) console.log(`[${ts}] [KB] ${msg}`, JSON.stringify(data));
|
|
13
|
+
else console.log(`[${ts}] [KB] ${msg}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── BM25 Search Implementation ──
|
|
17
|
+
|
|
18
|
+
function tokenize(text) {
|
|
19
|
+
return (text || '').toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
21
|
+
.split(/\s+/)
|
|
22
|
+
.filter(t => t.length > 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bm25Score(queryTokens, docTokens, avgDl, N, dfMap) {
|
|
26
|
+
const k1 = 1.5, b = 0.75;
|
|
27
|
+
const dl = docTokens.length;
|
|
28
|
+
const tf = {};
|
|
29
|
+
for (const t of docTokens) tf[t] = (tf[t] || 0) + 1;
|
|
30
|
+
|
|
31
|
+
let score = 0;
|
|
32
|
+
for (const qt of queryTokens) {
|
|
33
|
+
const termTf = tf[qt] || 0;
|
|
34
|
+
if (termTf === 0) continue;
|
|
35
|
+
const df = dfMap[qt] || 0;
|
|
36
|
+
const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
|
|
37
|
+
const tfNorm = (termTf * (k1 + 1)) / (termTf + k1 * (1 - b + b * (dl / avgDl)));
|
|
38
|
+
score += idf * tfNorm;
|
|
39
|
+
}
|
|
40
|
+
return score;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Search FAQ entries for a query. Checks device-specific FAQ first, then global.
|
|
45
|
+
* @param {string} query - Search query
|
|
46
|
+
* @param {string|null} [deviceId] - Device ID for device-specific FAQ
|
|
47
|
+
* @param {number} [limit=5] - Max results
|
|
48
|
+
* @returns {Array<{question: string, answer: string, score: number}>}
|
|
49
|
+
*/
|
|
50
|
+
function searchFaq(query, deviceId = null, limit = 5) {
|
|
51
|
+
const stmts = getStmts();
|
|
52
|
+
const entries = deviceId
|
|
53
|
+
? stmts.getFaqByDevice.all(deviceId)
|
|
54
|
+
: stmts.getAllFaq.all();
|
|
55
|
+
|
|
56
|
+
if (entries.length === 0) return [];
|
|
57
|
+
|
|
58
|
+
const queryTokens = tokenize(query);
|
|
59
|
+
if (queryTokens.length === 0) return [];
|
|
60
|
+
|
|
61
|
+
// Build document frequency map
|
|
62
|
+
const N = entries.length;
|
|
63
|
+
const dfMap = {};
|
|
64
|
+
const docTokensList = entries.map(e => {
|
|
65
|
+
const tokens = tokenize(`${e.question} ${e.answer} ${e.keywords || ''}`);
|
|
66
|
+
const tokenSet = new Set(tokens);
|
|
67
|
+
for (const qt of queryTokens) {
|
|
68
|
+
if (tokenSet.has(qt)) dfMap[qt] = (dfMap[qt] || 0) + 1;
|
|
69
|
+
}
|
|
70
|
+
return tokens;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const avgDl = docTokensList.reduce((s, t) => s + t.length, 0) / N;
|
|
74
|
+
|
|
75
|
+
const scored = entries.map((entry, i) => ({
|
|
76
|
+
...entry,
|
|
77
|
+
score: bm25Score(queryTokens, docTokensList[i], avgDl, N, dfMap),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
scored.sort((a, b) => b.score - a.score);
|
|
81
|
+
return scored.filter(s => s.score > 0).slice(0, limit);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Search workspace files as a fallback when FAQ doesn't have answers.
|
|
86
|
+
* Searches MEMORY.md, SOPs, and other workspace files.
|
|
87
|
+
* @param {string} query
|
|
88
|
+
* @param {number} [limit=3]
|
|
89
|
+
* @returns {Array<{file: string, excerpt: string, score: number}>}
|
|
90
|
+
*/
|
|
91
|
+
function searchWorkspace(query, limit = 3) {
|
|
92
|
+
const queryTokens = tokenize(query);
|
|
93
|
+
if (queryTokens.length === 0) return [];
|
|
94
|
+
|
|
95
|
+
const filesToSearch = [];
|
|
96
|
+
const watchPaths = [
|
|
97
|
+
path.join(WORKSPACE, 'memory'),
|
|
98
|
+
path.join(WORKSPACE, 'MEMORY.md'),
|
|
99
|
+
path.join(WORKSPACE, 'TOOLS.md'),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
for (const p of watchPaths) {
|
|
103
|
+
try {
|
|
104
|
+
const stat = fs.statSync(p);
|
|
105
|
+
if (stat.isDirectory()) {
|
|
106
|
+
const files = fs.readdirSync(p).filter(f => f.endsWith('.md'));
|
|
107
|
+
for (const f of files) filesToSearch.push(path.join(p, f));
|
|
108
|
+
} else {
|
|
109
|
+
filesToSearch.push(p);
|
|
110
|
+
}
|
|
111
|
+
} catch { /* skip missing files */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const chunks = [];
|
|
115
|
+
for (const filePath of filesToSearch) {
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
118
|
+
// Split into paragraphs
|
|
119
|
+
const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 50);
|
|
120
|
+
for (const para of paragraphs) {
|
|
121
|
+
chunks.push({ file: path.basename(filePath), text: para.trim() });
|
|
122
|
+
}
|
|
123
|
+
} catch { /* skip unreadable files */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (chunks.length === 0) return [];
|
|
127
|
+
|
|
128
|
+
const N = chunks.length;
|
|
129
|
+
const dfMap = {};
|
|
130
|
+
const docTokensList = chunks.map(c => {
|
|
131
|
+
const tokens = tokenize(c.text);
|
|
132
|
+
const tokenSet = new Set(tokens);
|
|
133
|
+
for (const qt of queryTokens) {
|
|
134
|
+
if (tokenSet.has(qt)) dfMap[qt] = (dfMap[qt] || 0) + 1;
|
|
135
|
+
}
|
|
136
|
+
return tokens;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const avgDl = docTokensList.reduce((s, t) => s + t.length, 0) / N;
|
|
140
|
+
|
|
141
|
+
const scored = chunks.map((chunk, i) => ({
|
|
142
|
+
file: chunk.file,
|
|
143
|
+
excerpt: chunk.text.substring(0, 300),
|
|
144
|
+
score: bm25Score(queryTokens, docTokensList[i], avgDl, N, dfMap),
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
scored.sort((a, b) => b.score - a.score);
|
|
148
|
+
return scored.filter(s => s.score > 0).slice(0, limit);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Combined search - FAQ first, workspace as fallback.
|
|
153
|
+
* @param {string} query
|
|
154
|
+
* @param {string|null} [deviceId]
|
|
155
|
+
* @returns {Array}
|
|
156
|
+
*/
|
|
157
|
+
function search(query, deviceId = null) {
|
|
158
|
+
const faqResults = searchFaq(query, deviceId, 3);
|
|
159
|
+
if (faqResults.length > 0) return faqResults.map(r => ({ type: 'faq', question: r.question, answer: r.answer, score: r.score }));
|
|
160
|
+
|
|
161
|
+
const wsResults = searchWorkspace(query, 3);
|
|
162
|
+
return wsResults.map(r => ({ type: 'workspace', file: r.file, excerpt: r.excerpt, score: r.score }));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── CRUD for FAQ management API ──
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* List all FAQ entries.
|
|
169
|
+
* @param {string|null} [deviceId]
|
|
170
|
+
* @returns {Array}
|
|
171
|
+
*/
|
|
172
|
+
function listFaq(deviceId = null) {
|
|
173
|
+
return deviceId ? getStmts().getFaqByDevice.all(deviceId) : getStmts().getAllFaq.all();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Create a FAQ entry.
|
|
178
|
+
* @param {Object} entry
|
|
179
|
+
* @returns {Object}
|
|
180
|
+
*/
|
|
181
|
+
function createFaq(entry) {
|
|
182
|
+
const stmts = getStmts();
|
|
183
|
+
const info = stmts.insertFaq.run(
|
|
184
|
+
entry.device_id || null,
|
|
185
|
+
entry.question,
|
|
186
|
+
entry.answer,
|
|
187
|
+
entry.category || 'general',
|
|
188
|
+
entry.keywords || '',
|
|
189
|
+
);
|
|
190
|
+
log('Created FAQ entry', { id: info.lastInsertRowid });
|
|
191
|
+
return stmts.getFaqById.get(info.lastInsertRowid);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Update a FAQ entry.
|
|
196
|
+
* @param {number} id
|
|
197
|
+
* @param {Object} updates
|
|
198
|
+
* @returns {Object|null}
|
|
199
|
+
*/
|
|
200
|
+
function updateFaq(id, updates) {
|
|
201
|
+
const stmts = getStmts();
|
|
202
|
+
const existing = stmts.getFaqById.get(id);
|
|
203
|
+
if (!existing) return null;
|
|
204
|
+
stmts.updateFaq.run(
|
|
205
|
+
updates.question ?? existing.question,
|
|
206
|
+
updates.answer ?? existing.answer,
|
|
207
|
+
updates.category ?? existing.category,
|
|
208
|
+
updates.keywords ?? existing.keywords,
|
|
209
|
+
updates.enabled ?? existing.enabled,
|
|
210
|
+
id,
|
|
211
|
+
);
|
|
212
|
+
return stmts.getFaqById.get(id);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Delete a FAQ entry.
|
|
217
|
+
* @param {number} id
|
|
218
|
+
*/
|
|
219
|
+
function deleteFaq(id) {
|
|
220
|
+
getStmts().deleteFaq.run(id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
searchFaq,
|
|
225
|
+
searchWorkspace,
|
|
226
|
+
search,
|
|
227
|
+
listFaq,
|
|
228
|
+
createFaq,
|
|
229
|
+
updateFaq,
|
|
230
|
+
deleteFaq,
|
|
231
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// ── Lead Qualifier - Scoring + Pipeline Stage ────────────
|
|
2
|
+
// Natural conversation-based qualification. Invisible to contacts,
|
|
3
|
+
// visible to Brandon in the AIVA app.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { getStmts } = require('./db');
|
|
7
|
+
const { callAI } = require('./utils/ai');
|
|
8
|
+
const { updateQualification } = require('./contact-manager');
|
|
9
|
+
|
|
10
|
+
function log(msg, data) {
|
|
11
|
+
const ts = new Date().toISOString();
|
|
12
|
+
if (data) console.log(`[${ts}] [LEAD-QUAL] ${msg}`, JSON.stringify(data));
|
|
13
|
+
else console.log(`[${ts}] [LEAD-QUAL] ${msg}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Pipeline stage thresholds
|
|
17
|
+
const STAGE_THRESHOLDS = {
|
|
18
|
+
cold: { min: 0, max: 20 },
|
|
19
|
+
warm: { min: 21, max: 50 },
|
|
20
|
+
hot: { min: 51, max: 75 },
|
|
21
|
+
qualified: { min: 76, max: 100 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Determine pipeline stage from score.
|
|
26
|
+
* @param {number} score - 0-100
|
|
27
|
+
* @returns {string} Pipeline stage
|
|
28
|
+
*/
|
|
29
|
+
function scoreToStage(score) {
|
|
30
|
+
if (score >= 76) return 'qualified';
|
|
31
|
+
if (score >= 51) return 'hot';
|
|
32
|
+
if (score >= 21) return 'warm';
|
|
33
|
+
return 'cold';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run post-message qualification analysis.
|
|
38
|
+
* Analyzes the latest conversation to update qualification score.
|
|
39
|
+
* Only runs for lead and qualified-lead categories.
|
|
40
|
+
* @param {Object} contact - Contact record
|
|
41
|
+
* @param {Array} recentMessages - Recent messages (last 10-15)
|
|
42
|
+
* @returns {Promise<{score: number, stage: string, changed: boolean}>}
|
|
43
|
+
*/
|
|
44
|
+
async function analyzeQualification(contact, recentMessages) {
|
|
45
|
+
if (!['lead', 'qualified-lead'].includes(contact.category)) {
|
|
46
|
+
return { score: contact.qualification_score || 0, stage: contact.pipeline_stage || 'none', changed: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const currentScore = contact.qualification_score || 0;
|
|
50
|
+
const history = recentMessages.map(m => {
|
|
51
|
+
const role = m.sent_by === 'contact' ? 'Contact' : 'AIVA';
|
|
52
|
+
return `${role}: ${m.text}`;
|
|
53
|
+
}).join('\n');
|
|
54
|
+
|
|
55
|
+
const systemPrompt = `You are a lead qualification scorer. Analyze the conversation and return a qualification score (0-100) based on these signals:
|
|
56
|
+
|
|
57
|
+
POSITIVE SIGNALS (add points):
|
|
58
|
+
- Stated a specific need (+20): "I need a new website"
|
|
59
|
+
- Mentioned timeline (+15): "We're launching in March"
|
|
60
|
+
- Asked about pricing (+15): "What do you charge?"
|
|
61
|
+
- Mentioned budget (+20): "Our budget is around $5k"
|
|
62
|
+
- Business context shared (+10): "We're a dental practice"
|
|
63
|
+
- Responded to follow-up (+10): engagement signal
|
|
64
|
+
- Multiple conversations (+10): came back after initial chat
|
|
65
|
+
|
|
66
|
+
NEGATIVE SIGNALS (subtract points):
|
|
67
|
+
- Just browsing (-10): "Just looking around"
|
|
68
|
+
- Competitor research (-20): "How do you compare to X?"
|
|
69
|
+
- No clear need expressed (-5)
|
|
70
|
+
- Short, disengaged responses (-5)
|
|
71
|
+
|
|
72
|
+
Current score: ${currentScore}
|
|
73
|
+
|
|
74
|
+
Respond with ONLY a JSON object:
|
|
75
|
+
{"score": <0-100>, "reasoning": "<brief explanation>", "signals": ["<signal1>", "<signal2>"]}`;
|
|
76
|
+
|
|
77
|
+
const userMsg = `Contact: ${contact.name} (${contact.phone})\n\nConversation:\n${history}`;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result = await callAI(systemPrompt, userMsg, { maxTokens: 300, temperature: 0.3 });
|
|
81
|
+
if (!result) return { score: currentScore, stage: scoreToStage(currentScore), changed: false };
|
|
82
|
+
|
|
83
|
+
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
|
84
|
+
if (!jsonMatch) return { score: currentScore, stage: scoreToStage(currentScore), changed: false };
|
|
85
|
+
|
|
86
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
87
|
+
const newScore = Math.max(0, Math.min(100, parsed.score || 0));
|
|
88
|
+
const newStage = scoreToStage(newScore);
|
|
89
|
+
const changed = newScore !== currentScore;
|
|
90
|
+
|
|
91
|
+
if (changed) {
|
|
92
|
+
updateQualification(contact.phone, newScore, newStage);
|
|
93
|
+
log('Score updated', {
|
|
94
|
+
phone: contact.phone,
|
|
95
|
+
oldScore: currentScore,
|
|
96
|
+
newScore,
|
|
97
|
+
stage: newStage,
|
|
98
|
+
reasoning: parsed.reasoning,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { score: newScore, stage: newStage, changed, reasoning: parsed.reasoning, signals: parsed.signals };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log('Analysis error', { phone: contact.phone, error: err.message });
|
|
105
|
+
return { score: currentScore, stage: scoreToStage(currentScore), changed: false };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a lead should be escalated for personal outreach (score >= 76).
|
|
111
|
+
* @param {Object} contact
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function shouldEscalateForOutreach(contact) {
|
|
115
|
+
return (contact.qualification_score || 0) >= 76 && contact.pipeline_stage !== 'client';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get follow-up behavior for a lead's pipeline stage.
|
|
120
|
+
* @param {string} stage
|
|
121
|
+
* @returns {{ template: string, urgency: string }}
|
|
122
|
+
*/
|
|
123
|
+
function getFollowUpBehavior(stage) {
|
|
124
|
+
const behaviors = {
|
|
125
|
+
cold: {
|
|
126
|
+
template: 'Hey! Just wanted to see if you had any questions about what we do.',
|
|
127
|
+
urgency: 'low',
|
|
128
|
+
},
|
|
129
|
+
warm: {
|
|
130
|
+
template: 'Hey {name}! You mentioned {need} - I had a thought about that. Got a sec?',
|
|
131
|
+
urgency: 'medium',
|
|
132
|
+
},
|
|
133
|
+
hot: {
|
|
134
|
+
template: 'Hi {name}, just following up on {need}. Brandon has some availability this week if you\'d like to hop on a quick call.',
|
|
135
|
+
urgency: 'high',
|
|
136
|
+
},
|
|
137
|
+
qualified: {
|
|
138
|
+
template: 'Hi {name}, wanted to follow up on our conversation. Brandon would love to connect - can I set up a quick call?',
|
|
139
|
+
urgency: 'high',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return behaviors[stage] || behaviors.cold;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
analyzeQualification,
|
|
148
|
+
scoreToStage,
|
|
149
|
+
shouldEscalateForOutreach,
|
|
150
|
+
getFollowUpBehavior,
|
|
151
|
+
STAGE_THRESHOLDS,
|
|
152
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ── Learning Loop - Three-Tier Preference Cascade ────────
|
|
2
|
+
// Router -> Main Agent -> User. Every resolved escalation becomes
|
|
3
|
+
// a learned preference for future use.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { getStmts, getDb } = require('./db');
|
|
7
|
+
|
|
8
|
+
function log(msg, data) {
|
|
9
|
+
const ts = new Date().toISOString();
|
|
10
|
+
if (data) console.log(`[${ts}] [LEARNING] ${msg}`, JSON.stringify(data));
|
|
11
|
+
else console.log(`[${ts}] [LEARNING] ${msg}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find matching preferences for a question/context.
|
|
16
|
+
* Returns per-contact preferences first, then global.
|
|
17
|
+
* @param {string} phone - Contact phone (for per-contact prefs)
|
|
18
|
+
* @param {string} questionText - The question/message to match
|
|
19
|
+
* @param {number} [limit=5] - Max preferences to return
|
|
20
|
+
* @returns {Array} Matching preferences sorted by relevance
|
|
21
|
+
*/
|
|
22
|
+
function findMatchingPreferences(phone, questionText, limit = 5) {
|
|
23
|
+
const stmts = getStmts();
|
|
24
|
+
// Get preferences scoped to this contact or global, with sufficient confidence
|
|
25
|
+
const prefs = stmts.getPreferencesByScope.all(phone, 'global', limit * 3);
|
|
26
|
+
|
|
27
|
+
if (prefs.length === 0) return [];
|
|
28
|
+
|
|
29
|
+
// Simple keyword-based relevance scoring
|
|
30
|
+
const queryTokens = tokenize(questionText);
|
|
31
|
+
if (queryTokens.length === 0) return prefs.slice(0, limit);
|
|
32
|
+
|
|
33
|
+
const scored = prefs.map(pref => {
|
|
34
|
+
const patternTokens = tokenize(pref.question_pattern);
|
|
35
|
+
const overlap = queryTokens.filter(t => patternTokens.includes(t)).length;
|
|
36
|
+
const score = overlap / Math.max(queryTokens.length, 1);
|
|
37
|
+
return { ...pref, relevanceScore: score };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
scored.sort((a, b) => {
|
|
41
|
+
// Per-contact prefs first, then by relevance, then by confidence
|
|
42
|
+
if (a.scope === phone && b.scope !== phone) return -1;
|
|
43
|
+
if (b.scope === phone && a.scope !== phone) return 1;
|
|
44
|
+
if (b.relevanceScore !== a.relevanceScore) return b.relevanceScore - a.relevanceScore;
|
|
45
|
+
return b.confidence - a.confidence;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Only return prefs with some relevance
|
|
49
|
+
return scored.filter(s => s.relevanceScore > 0.1).slice(0, limit);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Save a learned preference from an escalation resolution.
|
|
54
|
+
* @param {Object} pref
|
|
55
|
+
* @param {string} pref.scope - 'global' or phone number
|
|
56
|
+
* @param {string} pref.questionPattern - Normalized question pattern
|
|
57
|
+
* @param {string} pref.answer - The learned answer
|
|
58
|
+
* @param {string} pref.source - 'router', 'main_agent', or 'user'
|
|
59
|
+
* @param {number} [pref.confidence] - 0.0-1.0 (defaults based on source)
|
|
60
|
+
* @param {string} [pref.escalationId] - Which escalation triggered this
|
|
61
|
+
* @param {string} [pref.phone] - Contact whose question led to this
|
|
62
|
+
* @param {string} [pref.originalQuestion] - What the contact asked
|
|
63
|
+
* @returns {Object} Created preference
|
|
64
|
+
*/
|
|
65
|
+
function savePreference(pref) {
|
|
66
|
+
const stmts = getStmts();
|
|
67
|
+
|
|
68
|
+
// Confidence defaults by source
|
|
69
|
+
const defaultConfidence = { user: 1.0, main_agent: 0.9, router: 0.7 };
|
|
70
|
+
const confidence = pref.confidence ?? defaultConfidence[pref.source] ?? 0.7;
|
|
71
|
+
|
|
72
|
+
// Check for existing matching preference to update rather than duplicate
|
|
73
|
+
const db = getDb();
|
|
74
|
+
const existing = db.prepare(
|
|
75
|
+
"SELECT * FROM preferences WHERE scope = ? AND question_pattern = ?"
|
|
76
|
+
).get(pref.scope || 'global', pref.questionPattern);
|
|
77
|
+
|
|
78
|
+
let prefId;
|
|
79
|
+
if (existing) {
|
|
80
|
+
stmts.updatePreference.run(pref.answer, pref.source, confidence, existing.id);
|
|
81
|
+
prefId = existing.id;
|
|
82
|
+
log('Updated existing preference', { id: prefId, pattern: pref.questionPattern });
|
|
83
|
+
} else {
|
|
84
|
+
const info = stmts.insertPreference.run(
|
|
85
|
+
pref.scope || 'global',
|
|
86
|
+
pref.questionPattern,
|
|
87
|
+
pref.answer,
|
|
88
|
+
pref.source,
|
|
89
|
+
confidence,
|
|
90
|
+
);
|
|
91
|
+
prefId = info.lastInsertRowid;
|
|
92
|
+
log('Created new preference', { id: prefId, pattern: pref.questionPattern, source: pref.source });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Audit trail
|
|
96
|
+
if (pref.escalationId || pref.phone) {
|
|
97
|
+
stmts.insertPreferenceLog.run(
|
|
98
|
+
prefId,
|
|
99
|
+
pref.escalationId || null,
|
|
100
|
+
pref.phone || null,
|
|
101
|
+
pref.originalQuestion || null,
|
|
102
|
+
pref.source,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { id: prefId, questionPattern: pref.questionPattern, confidence };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Bump the hit count on a preference (when it's used to answer a question).
|
|
111
|
+
* @param {number} prefId
|
|
112
|
+
*/
|
|
113
|
+
function recordPreferenceHit(prefId) {
|
|
114
|
+
getStmts().bumpPreferenceHit.run(prefId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run monthly confidence decay for non-user preferences.
|
|
119
|
+
* - main_agent: -0.05/month
|
|
120
|
+
* - router: -0.1/month
|
|
121
|
+
*/
|
|
122
|
+
function decayConfidence() {
|
|
123
|
+
const stmts = getStmts();
|
|
124
|
+
stmts.decayPreferences.run(0.05, 'main_agent');
|
|
125
|
+
stmts.decayPreferences.run(0.1, 'router');
|
|
126
|
+
log('Ran confidence decay');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a preferences prompt section for the Conversation Engine.
|
|
131
|
+
* @param {string} phone - Contact phone
|
|
132
|
+
* @param {string} messageText - Current message text
|
|
133
|
+
* @returns {string} Formatted preferences for prompt injection
|
|
134
|
+
*/
|
|
135
|
+
function buildPreferencesPrompt(phone, messageText) {
|
|
136
|
+
const matches = findMatchingPreferences(phone, messageText);
|
|
137
|
+
if (matches.length === 0) return '';
|
|
138
|
+
|
|
139
|
+
const lines = matches.map(p => {
|
|
140
|
+
const scopeLabel = p.scope === 'global' ? '' : ` (specific to this contact)`;
|
|
141
|
+
return `- "${p.question_pattern}" -> "${p.answer}"${scopeLabel}`;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return `[Learned Preferences - use these when relevant]\n${lines.join('\n')}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get learning velocity metrics.
|
|
149
|
+
* @returns {Object} Metrics about learning progress
|
|
150
|
+
*/
|
|
151
|
+
function getMetrics() {
|
|
152
|
+
const db = getDb();
|
|
153
|
+
const totalPrefs = db.prepare('SELECT COUNT(*) as count FROM preferences').get().count;
|
|
154
|
+
const activePrefs = db.prepare('SELECT COUNT(*) as count FROM preferences WHERE confidence >= 0.5').get().count;
|
|
155
|
+
const bySource = db.prepare('SELECT source, COUNT(*) as count FROM preferences GROUP BY source').all();
|
|
156
|
+
const recentEscalations = db.prepare("SELECT COUNT(*) as count FROM escalations WHERE created_at >= datetime('now', '-7 days')").get().count;
|
|
157
|
+
const recentResolved = db.prepare("SELECT COUNT(*) as count FROM escalations WHERE status = 'responded' AND created_at >= datetime('now', '-7 days')").get().count;
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
totalPreferences: totalPrefs,
|
|
161
|
+
activePreferences: activePrefs,
|
|
162
|
+
bySource: Object.fromEntries(bySource.map(r => [r.source, r.count])),
|
|
163
|
+
escalationsLast7Days: recentEscalations,
|
|
164
|
+
resolvedLast7Days: recentResolved,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* List all preferences (for management API).
|
|
170
|
+
* @returns {Array}
|
|
171
|
+
*/
|
|
172
|
+
function listPreferences() {
|
|
173
|
+
return getStmts().getAllPreferences.all();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete a preference.
|
|
178
|
+
* @param {number} id
|
|
179
|
+
*/
|
|
180
|
+
function deletePreference(id) {
|
|
181
|
+
getStmts().deletePreference.run(id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Helper ──
|
|
185
|
+
|
|
186
|
+
function tokenize(text) {
|
|
187
|
+
return (text || '').toLowerCase()
|
|
188
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
189
|
+
.split(/\s+/)
|
|
190
|
+
.filter(t => t.length > 1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
findMatchingPreferences,
|
|
195
|
+
savePreference,
|
|
196
|
+
recordPreferenceHit,
|
|
197
|
+
decayConfidence,
|
|
198
|
+
buildPreferencesPrompt,
|
|
199
|
+
getMetrics,
|
|
200
|
+
listPreferences,
|
|
201
|
+
deletePreference,
|
|
202
|
+
};
|