@realtimex/email-automator 2.6.4 → 2.6.5
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/api/src/middleware/validation.ts +7 -0
- package/api/src/services/intelligence.ts +227 -6
- package/api/src/services/processor.ts +88 -33
- package/api/src/services/supabase.ts +5 -2
- package/api/src/utils/contentCleaner.ts +80 -66
- package/dist/api/src/middleware/validation.js +7 -0
- package/dist/api/src/services/intelligence.js +187 -0
- package/dist/api/src/services/processor.js +52 -24
- package/dist/api/src/utils/contentCleaner.js +74 -58
- package/dist/assets/index-6zjdTO-X.css +1 -0
- package/dist/assets/{index-BSHZ3lFn.js → index-8DZIcZie.js} +25 -25
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/supabase/migrations/20260119000000_context_aware_rules.sql +44 -0
- package/supabase/migrations/20260119000001_compiled_rule_context.sql +128 -0
- package/dist/assets/index-CRQKk5IW.css +0 -1
|
@@ -1,96 +1,111 @@
|
|
|
1
1
|
export class ContentCleaner {
|
|
2
2
|
/**
|
|
3
3
|
* Cleans email body by removing noise, quoted replies, and footers.
|
|
4
|
-
*
|
|
4
|
+
* optimized for LLM processing.
|
|
5
5
|
*/
|
|
6
6
|
static cleanEmailBody(text: string): string {
|
|
7
7
|
if (!text) return "";
|
|
8
8
|
const originalText = text;
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
// Structure: <br>, <p> -> Newlines
|
|
13
|
-
text = text.replace(/<br\s*\/?>/gi, '\n');
|
|
14
|
-
text = text.replace(/<\/p>/gi, '\n\n');
|
|
15
|
-
text = text.replace(/<p.*?>/gi, ''); // Open p tags just gone
|
|
16
|
-
|
|
17
|
-
// Structure: Headers <h1>-<h6> -> # Title
|
|
18
|
-
text = text.replace(/<h[1-6].*?>(.*?)<\/h[1-6]>/gsi, (match, p1) => `\n# ${p1}\n`);
|
|
19
|
-
|
|
20
|
-
// Structure: Lists <li> -> - Item
|
|
21
|
-
text = text.replace(/<li.*?>(.*?)<\/li>/gsi, (match, p1) => `\n- ${p1}`);
|
|
22
|
-
text = text.replace(/<ul.*?>/gi, '');
|
|
23
|
-
text = text.replace(/<\/ul>/gi, '\n');
|
|
24
|
-
|
|
25
|
-
// Links: <a href=\"...\">text</a> -> [text](href)
|
|
26
|
-
text = text.replace(/<a\s+(?:[^>]*?\s+)?href=\"([^\"]*)\"[^>]*>(.*?)<\/a>/gsi, (match, href, content) => `[${content}](${href})`);
|
|
27
|
-
|
|
28
|
-
// Images: <img src=\"...\" alt=\"...\"> -> 
|
|
29
|
-
text = text.replace(/<img\s+(?:[^>]*?\s+)?src=\"([^\"]*)\"(?:[^>]*?\s+)?alt=\"([^\"]*)\"[^>]*>/gsi, (match, src, alt) => ``);
|
|
10
|
+
// 1. Detect if content is actually HTML
|
|
11
|
+
const isHtml = /<[a-z][\s\S]*>/i.test(text);
|
|
30
12
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
13
|
+
if (isHtml) {
|
|
14
|
+
// Lightweight HTML -> Markdown Conversion
|
|
15
|
+
// Structure: <br>, <p> -> Newlines
|
|
16
|
+
text = text.replace(/<br\s*\/?>/gi, '\n');
|
|
17
|
+
text = text.replace(/<\/p>/gi, '\n\n');
|
|
18
|
+
text = text.replace(/<p.*?>/gi, '');
|
|
19
|
+
|
|
20
|
+
// Structure: Headers <h1>-<h6> -> # Title
|
|
21
|
+
text = text.replace(/<h[1-6].*?>(.*?)<\/h[1-6]>/gsi, (match, p1) => `\n# ${p1}\n`);
|
|
22
|
+
|
|
23
|
+
// Structure: Lists <li> -> - Item
|
|
24
|
+
text = text.replace(/<li.*?>(.*?)<\/li>/gsi, (match, p1) => `\n- ${p1}`);
|
|
25
|
+
text = text.replace(/<ul.*?>/gi, '');
|
|
26
|
+
text = text.replace(/<\/ul>/gi, '\n');
|
|
27
|
+
|
|
28
|
+
// Links: <a href=\"...\">text</a> -> [text](href)
|
|
29
|
+
text = text.replace(/<a\s+(?:[^>]*?\s+)?href=\"([^\"]*)\"[^>]*>(.*?)<\/a>/gsi, (match, href, content) => `[${content}](${href})`);
|
|
30
|
+
|
|
31
|
+
// Images: <img src=\"...\" alt=\"...\"> -> 
|
|
32
|
+
text = text.replace(/<img\s+(?:[^>]*?\s+)?src=\"([^\"]*)\"(?:[^>]*?\s+)?alt=\"([^\"]*)\"[^>]*>/gsi, (match, src, alt) => ``);
|
|
33
|
+
|
|
34
|
+
// Style/Script removal (strictly remove content)
|
|
35
|
+
text = text.replace(/<script.*?>.*?<\/script>/gsi, '');
|
|
36
|
+
text = text.replace(/<style.*?>.*?<\/style>/gsi, '');
|
|
37
|
+
|
|
38
|
+
// Final Strip of remaining tags
|
|
39
|
+
text = text.replace(/<[^>]+>/g, ' ');
|
|
40
|
+
|
|
41
|
+
// Entity decoding (Basic)
|
|
42
|
+
text = text.replace(/ /gi, ' ');
|
|
43
|
+
text = text.replace(/&/gi, '&');
|
|
44
|
+
text = text.replace(/</gi, '<');
|
|
45
|
+
text = text.replace(/>/gi, '>');
|
|
46
|
+
text = text.replace(/"/gi, '"');
|
|
47
|
+
text = text.replace(/'/gi, "'");
|
|
48
|
+
}
|
|
45
49
|
|
|
46
50
|
const lines = text.split('\n');
|
|
47
51
|
const cleanedLines: string[] = [];
|
|
48
52
|
|
|
49
|
-
//
|
|
50
|
-
const
|
|
53
|
+
// Patterns that usually mark the START of a reply chain or a generic footer
|
|
54
|
+
const truncationPatterns = [
|
|
51
55
|
/^On .* wrote:$/i,
|
|
52
|
-
/^From:
|
|
53
|
-
|
|
54
|
-
/^
|
|
55
|
-
/^
|
|
56
|
+
/^From: .* <.*>$/i,
|
|
57
|
+
/^-----Original Message-----$/i,
|
|
58
|
+
/^________________________________$/i,
|
|
59
|
+
/^Sent from my iPhone$/i,
|
|
60
|
+
/^Sent from my Android$/i,
|
|
61
|
+
/^Get Outlook for/i,
|
|
62
|
+
/^--$/ // Standard signature separator
|
|
56
63
|
];
|
|
57
64
|
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
/
|
|
65
|
+
// Patterns for lines that should be stripped but NOT truncate the whole email
|
|
66
|
+
const noisePatterns = [
|
|
67
|
+
/view in browser/i,
|
|
68
|
+
/click here to view/i,
|
|
69
|
+
/legal notice/i,
|
|
70
|
+
/all rights reserved/i,
|
|
61
71
|
/privacy policy/i,
|
|
62
72
|
/terms of service/i,
|
|
63
|
-
/
|
|
64
|
-
/copyright \d{4}/i
|
|
73
|
+
/unsubscribe/i
|
|
65
74
|
];
|
|
66
75
|
|
|
67
76
|
for (let line of lines) {
|
|
68
77
|
let lineStripped = line.trim();
|
|
78
|
+
if (!lineStripped) {
|
|
79
|
+
cleanedLines.push("");
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
69
82
|
|
|
70
83
|
// 2. Quoted text removal (lines starting with >)
|
|
71
84
|
if (lineStripped.startsWith('>')) {
|
|
72
85
|
continue;
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
// 3.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
// 3. Truncation check: If we hit a reply header, we stop entirely
|
|
89
|
+
let shouldTruncate = false;
|
|
90
|
+
for (const pattern of truncationPatterns) {
|
|
91
|
+
if (pattern.test(lineStripped)) {
|
|
92
|
+
shouldTruncate = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
79
95
|
}
|
|
96
|
+
if (shouldTruncate) break;
|
|
80
97
|
|
|
81
|
-
// 4.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
for (const pattern of
|
|
98
|
+
// 4. Noise check: Strip boilerplate lines
|
|
99
|
+
let isNoise = false;
|
|
100
|
+
if (lineStripped.length < 100) {
|
|
101
|
+
for (const pattern of noisePatterns) {
|
|
85
102
|
if (pattern.test(lineStripped)) {
|
|
86
|
-
|
|
103
|
+
isNoise = true;
|
|
87
104
|
break;
|
|
88
105
|
}
|
|
89
106
|
}
|
|
90
|
-
if (isFooter) {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
107
|
}
|
|
108
|
+
if (isNoise) continue;
|
|
94
109
|
|
|
95
110
|
cleanedLines.push(line);
|
|
96
111
|
}
|
|
@@ -98,21 +113,20 @@ export class ContentCleaner {
|
|
|
98
113
|
// Reassemble
|
|
99
114
|
text = cleanedLines.join('\n');
|
|
100
115
|
|
|
101
|
-
//
|
|
102
|
-
if (!text.trim() || text.length < 10) {
|
|
103
|
-
text = originalText.substring(0, 3000);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Collapse multiple newlines
|
|
116
|
+
// Collapse whitespace
|
|
107
117
|
text = text.replace(/\n{3,}/g, '\n\n');
|
|
118
|
+
text = text.replace(/[ \t]{2,}/g, ' ');
|
|
108
119
|
|
|
120
|
+
// Safety Fallback: If cleaning stripped too much, return original text truncated
|
|
121
|
+
if (text.trim().length < 20 && originalText.trim().length > 20) {
|
|
122
|
+
return originalText.substring(0, 3000).trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
109
125
|
// Sanitize LLM Special Tokens
|
|
110
126
|
text = text.replace(/<\|/g, '< |');
|
|
111
127
|
text = text.replace(/\|>/g, '| >');
|
|
112
128
|
text = text.replace(/\[INST\]/gi, '[ INST ]');
|
|
113
129
|
text = text.replace(/\[\/INST\]/gi, '[ /INST ]');
|
|
114
|
-
text = text.replace(/<s>/gi, '<s>');
|
|
115
|
-
text = text.replace(/<\/s>/gi, '</s>');
|
|
116
130
|
|
|
117
131
|
return text.trim();
|
|
118
132
|
}
|
|
@@ -82,8 +82,12 @@ export const schemas = {
|
|
|
82
82
|
accessToken: z.string().optional(),
|
|
83
83
|
}),
|
|
84
84
|
// Rule schemas - supports both single action (legacy) and actions array
|
|
85
|
+
// Now includes description and intent for context-aware AI matching
|
|
85
86
|
createRule: z.object({
|
|
86
87
|
name: z.string().min(1).max(100),
|
|
88
|
+
description: z.string().max(500).optional(),
|
|
89
|
+
intent: z.string().max(200).optional(),
|
|
90
|
+
priority: z.number().int().min(0).max(100).optional(),
|
|
87
91
|
condition: z.record(z.unknown()),
|
|
88
92
|
action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
|
|
89
93
|
actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
|
|
@@ -94,6 +98,9 @@ export const schemas = {
|
|
|
94
98
|
}),
|
|
95
99
|
updateRule: z.object({
|
|
96
100
|
name: z.string().min(1).max(100).optional(),
|
|
101
|
+
description: z.string().max(500).optional(),
|
|
102
|
+
intent: z.string().max(200).optional(),
|
|
103
|
+
priority: z.number().int().min(0).max(100).optional(),
|
|
97
104
|
condition: z.record(z.unknown()).optional(),
|
|
98
105
|
action: z.enum(['delete', 'archive', 'draft', 'star', 'read']).optional(),
|
|
99
106
|
actions: z.array(z.enum(['delete', 'archive', 'draft', 'star', 'read'])).optional(),
|
|
@@ -24,6 +24,28 @@ export const EmailAnalysisSchema = z.object({
|
|
|
24
24
|
action_items: z.array(z.string()).optional()
|
|
25
25
|
.describe('Action items mentioned in the email'),
|
|
26
26
|
});
|
|
27
|
+
// Context-Aware Analysis Schema - AI evaluates email against user's rules
|
|
28
|
+
export const ContextAwareAnalysisSchema = z.object({
|
|
29
|
+
// Classification (kept for UI/logging)
|
|
30
|
+
summary: z.string().describe('A brief summary of the email content'),
|
|
31
|
+
category: z.enum(['spam', 'newsletter', 'promotional', 'transactional', 'social', 'support', 'client', 'internal', 'personal', 'other'])
|
|
32
|
+
.describe('The category of the email'),
|
|
33
|
+
priority: z.enum(['High', 'Medium', 'Low'])
|
|
34
|
+
.describe('The urgency of the email'),
|
|
35
|
+
// Rule Matching (core of context-aware engine)
|
|
36
|
+
matched_rule: z.object({
|
|
37
|
+
rule_id: z.string().nullable().describe('ID of the matched rule, or null if no match'),
|
|
38
|
+
rule_name: z.string().nullable().describe('Name of the matched rule'),
|
|
39
|
+
confidence: z.number().min(0).max(1).describe('Confidence score for the match (0-1)'),
|
|
40
|
+
reasoning: z.string().describe('Explanation of why this rule was matched or why no rule matched'),
|
|
41
|
+
}),
|
|
42
|
+
// Actions to execute (derived from matched rule)
|
|
43
|
+
actions_to_execute: z.array(z.enum(['none', 'delete', 'archive', 'draft', 'read', 'star']))
|
|
44
|
+
.describe('Actions to execute based on the matched rule'),
|
|
45
|
+
// Intent-aware draft content (if draft action is included)
|
|
46
|
+
draft_content: z.string().optional()
|
|
47
|
+
.describe('Generated draft reply if the action includes drafting'),
|
|
48
|
+
});
|
|
27
49
|
export class IntelligenceService {
|
|
28
50
|
client = null;
|
|
29
51
|
model = 'gpt-4o-mini';
|
|
@@ -210,6 +232,171 @@ Please write a reply.`,
|
|
|
210
232
|
return null;
|
|
211
233
|
}
|
|
212
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Context-Aware Analysis: AI evaluates email against user's rules semantically
|
|
237
|
+
* This is the core of the new automation engine
|
|
238
|
+
*
|
|
239
|
+
* @param compiledRulesContext - Pre-compiled rules context string (from user_settings.compiled_rule_context)
|
|
240
|
+
* OR RuleContext[] for backwards compatibility
|
|
241
|
+
*/
|
|
242
|
+
async analyzeEmailWithRules(content, context, compiledRulesContext, eventLogger, emailId) {
|
|
243
|
+
console.log('[Intelligence] analyzeEmailWithRules called for:', context.subject);
|
|
244
|
+
if (!this.isReady()) {
|
|
245
|
+
console.log('[Intelligence] Not ready, skipping');
|
|
246
|
+
logger.warn('Intelligence service not ready, skipping analysis');
|
|
247
|
+
if (eventLogger) {
|
|
248
|
+
await eventLogger.info('Skipped', 'AI Analysis skipped: Model not configured.', undefined, emailId);
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
// Prepare content
|
|
253
|
+
const cleanedContent = ContentCleaner.cleanEmailBody(content).substring(0, 2500);
|
|
254
|
+
// Use pre-compiled context if string, otherwise build from RuleContext[] (backwards compat)
|
|
255
|
+
let rulesContext;
|
|
256
|
+
let rulesCount;
|
|
257
|
+
if (typeof compiledRulesContext === 'string') {
|
|
258
|
+
// Fast path: use pre-compiled context
|
|
259
|
+
rulesContext = compiledRulesContext || '\n[No rules defined - analyze email but take no actions]\n';
|
|
260
|
+
rulesCount = (rulesContext.match(/Rule \d+/g) || []).length;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// Backwards compatibility: build from RuleContext[]
|
|
264
|
+
const rules = compiledRulesContext;
|
|
265
|
+
rulesCount = rules.length;
|
|
266
|
+
rulesContext = rules.length > 0
|
|
267
|
+
? rules.map((r, i) => `
|
|
268
|
+
### Rule ${i + 1}: "${r.name}" (ID: ${r.id})
|
|
269
|
+
- Description: ${r.description || 'No description provided'}
|
|
270
|
+
- Intent: ${r.intent || 'General automation'}
|
|
271
|
+
- Actions: ${r.actions.join(', ')}
|
|
272
|
+
${r.draft_instructions ? `- Draft Instructions: "${r.draft_instructions}"` : ''}
|
|
273
|
+
`).join('\n')
|
|
274
|
+
: '\n[No rules defined - analyze email but take no actions]\n';
|
|
275
|
+
}
|
|
276
|
+
const systemPrompt = `You are an AI Email Automation Agent.
|
|
277
|
+
|
|
278
|
+
## Your Operating Rules
|
|
279
|
+
The user has defined the following automation rules. Your job is to:
|
|
280
|
+
1. Analyze the incoming email
|
|
281
|
+
2. Determine if ANY rule semantically matches this email's context
|
|
282
|
+
3. Match based on INTENT, not just keywords
|
|
283
|
+
|
|
284
|
+
${rulesContext}
|
|
285
|
+
|
|
286
|
+
## Category Definitions (choose the most accurate)
|
|
287
|
+
- **client**: Business inquiries, RFPs, quote requests, project discussions, potential customers reaching out
|
|
288
|
+
- **support**: Help requests, bug reports, technical questions from existing users
|
|
289
|
+
- **internal**: Messages from colleagues, team communications
|
|
290
|
+
- **transactional**: Receipts, confirmations, shipping updates, account notifications
|
|
291
|
+
- **newsletter**: Subscribed content, digests, updates from services you signed up for
|
|
292
|
+
- **promotional**: UNSOLICITED marketing, cold sales pitches, ads - NOT legitimate business inquiries
|
|
293
|
+
- **spam**: Scams, phishing, junk mail
|
|
294
|
+
- **social**: Social media notifications, friend requests
|
|
295
|
+
- **personal**: Friends, family, personal matters
|
|
296
|
+
- **other**: Anything that doesn't fit above
|
|
297
|
+
|
|
298
|
+
## Matching Guidelines
|
|
299
|
+
- A "decline sales" rule should match ANY sales pitch, not just ones with "sales" in the subject
|
|
300
|
+
- Match the rule that best fits the USER'S INTENT
|
|
301
|
+
- Only match if you are confident (>= 0.7 confidence)
|
|
302
|
+
- If no rule clearly matches, return null for rule_id
|
|
303
|
+
- If a matched rule includes "draft" action, generate an appropriate draft using the rule's intent
|
|
304
|
+
|
|
305
|
+
## CRITICAL: Distinguish Between Inbound vs Outbound
|
|
306
|
+
**INBOUND (Client Inquiries - NOT promotional):**
|
|
307
|
+
- User is RECEIVING a request for quote/proposal/service
|
|
308
|
+
- Examples: "Please send me a quote", "RFP: [project]", "Can you provide pricing", "I need a quote asap"
|
|
309
|
+
- Category: client, support, or transactional (NEVER promotional)
|
|
310
|
+
|
|
311
|
+
**OUTBOUND (Sales/Marketing - IS promotional):**
|
|
312
|
+
- User is RECEIVING a sales pitch or marketing message
|
|
313
|
+
- Examples: "Get a FREE quote today!", "Limited offer", "Don't miss out", "Special discount"
|
|
314
|
+
- Category: promotional, spam, or newsletter
|
|
315
|
+
|
|
316
|
+
**Key Distinction:** If someone is ASKING the user for something (quote, proposal, service), it's a CLIENT INQUIRY, not promotional content.
|
|
317
|
+
|
|
318
|
+
## Email Context
|
|
319
|
+
- Current Date: ${new Date().toISOString()}
|
|
320
|
+
- Subject: ${context.subject}
|
|
321
|
+
- From: ${context.sender}
|
|
322
|
+
- Date: ${context.date}
|
|
323
|
+
|
|
324
|
+
## Required JSON Response
|
|
325
|
+
{
|
|
326
|
+
"summary": "Brief summary of the email",
|
|
327
|
+
"category": "spam|newsletter|promotional|transactional|social|support|client|internal|personal|other",
|
|
328
|
+
"priority": "High|Medium|Low",
|
|
329
|
+
"matched_rule": {
|
|
330
|
+
"rule_id": "UUID or null",
|
|
331
|
+
"rule_name": "Rule name or null",
|
|
332
|
+
"confidence": 0.0-1.0,
|
|
333
|
+
"reasoning": "Why this rule was or wasn't matched"
|
|
334
|
+
},
|
|
335
|
+
"actions_to_execute": ["none"] or ["archive", "read", etc.],
|
|
336
|
+
"draft_content": "Optional: draft reply if action includes 'draft'"
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
Return ONLY valid JSON.`;
|
|
340
|
+
// Log thinking phase
|
|
341
|
+
if (eventLogger) {
|
|
342
|
+
try {
|
|
343
|
+
await eventLogger.info('Thinking', `Context-aware analysis: ${context.subject}`, {
|
|
344
|
+
model: this.model,
|
|
345
|
+
system_prompt: systemPrompt,
|
|
346
|
+
content_preview: cleanedContent,
|
|
347
|
+
rules_count: rulesCount,
|
|
348
|
+
}, emailId);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
console.error('[Intelligence] Failed to log thinking event:', err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
let rawResponse = '';
|
|
355
|
+
try {
|
|
356
|
+
const response = await this.client.chat.completions.create({
|
|
357
|
+
model: this.model,
|
|
358
|
+
messages: [
|
|
359
|
+
{ role: 'system', content: systemPrompt },
|
|
360
|
+
{ role: 'user', content: cleanedContent || '[Empty email body]' },
|
|
361
|
+
],
|
|
362
|
+
temperature: 0.1,
|
|
363
|
+
});
|
|
364
|
+
rawResponse = response.choices[0]?.message?.content || '';
|
|
365
|
+
console.log('[Intelligence] Context-aware response received (length:', rawResponse.length, ')');
|
|
366
|
+
// Parse JSON from response
|
|
367
|
+
let jsonStr = rawResponse.trim();
|
|
368
|
+
const startIdx = jsonStr.indexOf('{');
|
|
369
|
+
const endIdx = jsonStr.lastIndexOf('}');
|
|
370
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
371
|
+
throw new Error('Response did not contain a valid JSON object');
|
|
372
|
+
}
|
|
373
|
+
jsonStr = jsonStr.substring(startIdx, endIdx + 1);
|
|
374
|
+
const parsed = JSON.parse(jsonStr);
|
|
375
|
+
const validated = ContextAwareAnalysisSchema.parse(parsed);
|
|
376
|
+
logger.debug('Context-aware analysis complete', {
|
|
377
|
+
matched_rule: validated.matched_rule.rule_name,
|
|
378
|
+
confidence: validated.matched_rule.confidence,
|
|
379
|
+
actions: validated.actions_to_execute,
|
|
380
|
+
});
|
|
381
|
+
if (eventLogger && emailId) {
|
|
382
|
+
await eventLogger.analysis('Decided', emailId, {
|
|
383
|
+
...validated,
|
|
384
|
+
_raw_response: rawResponse
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return validated;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
console.error('[Intelligence] Context-aware analysis failed:', error);
|
|
391
|
+
if (eventLogger) {
|
|
392
|
+
await eventLogger.error('Error', {
|
|
393
|
+
error: error instanceof Error ? error.message : String(error),
|
|
394
|
+
raw_response: rawResponse || 'No response received from LLM'
|
|
395
|
+
}, emailId);
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
213
400
|
async testConnection() {
|
|
214
401
|
if (!this.isReady()) {
|
|
215
402
|
return { success: false, message: 'Intelligence service not initialized. Check your API Key.' };
|
|
@@ -454,7 +454,33 @@ export class EmailProcessorService {
|
|
|
454
454
|
autoSubmitted: parsed.headers.get('auto-submitted')?.toString(),
|
|
455
455
|
mailer: parsed.headers.get('x-mailer')?.toString()
|
|
456
456
|
};
|
|
457
|
-
// 3.
|
|
457
|
+
// 3. Fetch account for action execution
|
|
458
|
+
const { data: account } = await this.supabase
|
|
459
|
+
.from('email_accounts')
|
|
460
|
+
.select('*')
|
|
461
|
+
.eq('id', email.account_id)
|
|
462
|
+
.single();
|
|
463
|
+
// 4. Fetch pre-compiled rule context (fast path - no loop/formatting)
|
|
464
|
+
// Falls back to building context if not cached
|
|
465
|
+
let compiledContext = settings?.compiled_rule_context || null;
|
|
466
|
+
// Fetch rules for action execution (need attachments, instructions)
|
|
467
|
+
const { data: rules } = await this.supabase
|
|
468
|
+
.from('rules')
|
|
469
|
+
.select('*')
|
|
470
|
+
.eq('user_id', userId)
|
|
471
|
+
.eq('is_enabled', true)
|
|
472
|
+
.order('priority', { ascending: false });
|
|
473
|
+
// Fallback: build context if not pre-compiled
|
|
474
|
+
if (!compiledContext && rules && rules.length > 0) {
|
|
475
|
+
compiledContext = rules.map((r, i) => `Rule ${i + 1} [ID: ${r.id}]\n` +
|
|
476
|
+
` Name: ${r.name}\n` +
|
|
477
|
+
(r.description ? ` Description: ${r.description}\n` : '') +
|
|
478
|
+
(r.intent ? ` Intent: ${r.intent}\n` : '') +
|
|
479
|
+
` Actions: ${r.actions?.join(', ') || r.action || 'none'}\n` +
|
|
480
|
+
(r.instructions ? ` Draft Instructions: ${r.instructions}\n` : '') +
|
|
481
|
+
'\n').join('');
|
|
482
|
+
}
|
|
483
|
+
// 5. Context-Aware Analysis: AI evaluates email against user's rules
|
|
458
484
|
const intelligenceService = getIntelligenceService(settings?.llm_model || settings?.llm_base_url || settings?.llm_api_key
|
|
459
485
|
? {
|
|
460
486
|
model: settings.llm_model,
|
|
@@ -462,7 +488,7 @@ export class EmailProcessorService {
|
|
|
462
488
|
apiKey: settings.llm_api_key,
|
|
463
489
|
}
|
|
464
490
|
: undefined);
|
|
465
|
-
const analysis = await intelligenceService.
|
|
491
|
+
const analysis = await intelligenceService.analyzeEmailWithRules(cleanContent, {
|
|
466
492
|
subject: email.subject || '',
|
|
467
493
|
sender: email.sender || '',
|
|
468
494
|
date: email.date || '',
|
|
@@ -471,39 +497,41 @@ export class EmailProcessorService {
|
|
|
471
497
|
autoTrashSpam: settings?.auto_trash_spam,
|
|
472
498
|
smartDrafts: settings?.smart_drafts,
|
|
473
499
|
},
|
|
474
|
-
},
|
|
500
|
+
}, compiledContext || '', // Pre-compiled context (fast path)
|
|
501
|
+
eventLogger || undefined, email.id);
|
|
475
502
|
if (!analysis) {
|
|
476
503
|
throw new Error('AI analysis returned no result');
|
|
477
504
|
}
|
|
478
|
-
//
|
|
505
|
+
// 6. Update the email record with context-aware results
|
|
479
506
|
await this.supabase
|
|
480
507
|
.from('emails')
|
|
481
508
|
.update({
|
|
482
509
|
category: analysis.category,
|
|
483
|
-
is_useless: analysis.is_useless,
|
|
484
510
|
ai_analysis: analysis,
|
|
485
|
-
suggested_actions: analysis.
|
|
486
|
-
suggested_action: analysis.
|
|
511
|
+
suggested_actions: analysis.actions_to_execute || [],
|
|
512
|
+
suggested_action: analysis.actions_to_execute?.[0] || 'none',
|
|
513
|
+
matched_rule_id: analysis.matched_rule.rule_id,
|
|
514
|
+
matched_rule_confidence: analysis.matched_rule.confidence,
|
|
487
515
|
processing_status: 'completed'
|
|
488
516
|
})
|
|
489
517
|
.eq('id', email.id);
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
await
|
|
518
|
+
// 7. Execute actions if rule matched with sufficient confidence
|
|
519
|
+
if (account && analysis.matched_rule.rule_id && analysis.matched_rule.confidence >= 0.7) {
|
|
520
|
+
const matchedRule = rules?.find(r => r.id === analysis.matched_rule.rule_id);
|
|
521
|
+
if (eventLogger) {
|
|
522
|
+
await eventLogger.info('Rule Matched', `"${analysis.matched_rule.rule_name}" matched with ${(analysis.matched_rule.confidence * 100).toFixed(0)}% confidence`, { reasoning: analysis.matched_rule.reasoning }, email.id);
|
|
523
|
+
}
|
|
524
|
+
// Execute each action from the AI's decision
|
|
525
|
+
for (const action of analysis.actions_to_execute) {
|
|
526
|
+
if (action === 'none')
|
|
527
|
+
continue;
|
|
528
|
+
// Use AI-generated draft content if available
|
|
529
|
+
const draftContent = action === 'draft' ? analysis.draft_content : undefined;
|
|
530
|
+
await this.executeAction(account, email, action, draftContent, eventLogger, `Rule: ${matchedRule?.name || analysis.matched_rule.rule_name}`, matchedRule?.attachments);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
else if (eventLogger && rules && rules.length > 0) {
|
|
534
|
+
await eventLogger.info('No Match', analysis.matched_rule.reasoning, { confidence: analysis.matched_rule.confidence }, email.id);
|
|
507
535
|
}
|
|
508
536
|
// Mark log as success
|
|
509
537
|
if (log) {
|