edsger 0.72.6 → 0.73.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.
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report payload normalization for the direct-SDK write path.
|
|
3
|
+
*
|
|
4
|
+
* The LLM that produces intelligence reports sometimes returns structured
|
|
5
|
+
* arrays (`key_findings`, `recommendations`, …) as bare strings, or as objects
|
|
6
|
+
* keyed under synonyms (`description` instead of `finding`, `recommendation`
|
|
7
|
+
* instead of `action`, …). We canonicalize before writing so every reader — the
|
|
8
|
+
* desktop UI especially — can rely on the shape declared in the migration.
|
|
9
|
+
*
|
|
10
|
+
* This mirrors the normalizer in the MCP edge handler
|
|
11
|
+
* (`supabase/functions/mcp/handlers/intelligence.ts`). The two can't share a
|
|
12
|
+
* single module because the edge function runs on Deno (zod v3, URL imports)
|
|
13
|
+
* while this runs on Node — keep them in sync if either changes.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Normalize every structured array field present in the payload. Fields that
|
|
17
|
+
* are `undefined` are left untouched so partial updates don't overwrite stored
|
|
18
|
+
* values with empty arrays.
|
|
19
|
+
*/
|
|
20
|
+
export declare function normalizeReportArrays<T extends Record<string, unknown>>(payload: T): T;
|
|
21
|
+
/**
|
|
22
|
+
* Coerce an LLM-supplied value to a finite number, or null. Handles numbers,
|
|
23
|
+
* numeric strings, and null/garbage uniformly.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toNumberOrNull(value: unknown): number | null;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report payload normalization for the direct-SDK write path.
|
|
3
|
+
*
|
|
4
|
+
* The LLM that produces intelligence reports sometimes returns structured
|
|
5
|
+
* arrays (`key_findings`, `recommendations`, …) as bare strings, or as objects
|
|
6
|
+
* keyed under synonyms (`description` instead of `finding`, `recommendation`
|
|
7
|
+
* instead of `action`, …). We canonicalize before writing so every reader — the
|
|
8
|
+
* desktop UI especially — can rely on the shape declared in the migration.
|
|
9
|
+
*
|
|
10
|
+
* This mirrors the normalizer in the MCP edge handler
|
|
11
|
+
* (`supabase/functions/mcp/handlers/intelligence.ts`). The two can't share a
|
|
12
|
+
* single module because the edge function runs on Deno (zod v3, URL imports)
|
|
13
|
+
* while this runs on Node — keep them in sync if either changes.
|
|
14
|
+
*/
|
|
15
|
+
const NORMALIZE_SPECS = {
|
|
16
|
+
key_findings: {
|
|
17
|
+
primary: 'finding',
|
|
18
|
+
aliases: ['description', 'insight', 'text', 'title'],
|
|
19
|
+
defaults: { severity: 'fyi', category: '' },
|
|
20
|
+
},
|
|
21
|
+
recommendations: {
|
|
22
|
+
primary: 'action',
|
|
23
|
+
aliases: ['recommendation', 'description', 'text', 'title'],
|
|
24
|
+
defaults: { priority: 'medium', effort: '', impact: '', rationale: '' },
|
|
25
|
+
},
|
|
26
|
+
competitor_highlights: {
|
|
27
|
+
primary: 'highlight',
|
|
28
|
+
aliases: ['description', 'text', 'title'],
|
|
29
|
+
defaults: { competitor_name: '', sentiment: 'neutral' },
|
|
30
|
+
},
|
|
31
|
+
market_signals: {
|
|
32
|
+
primary: 'signal',
|
|
33
|
+
aliases: ['description', 'text', 'title'],
|
|
34
|
+
defaults: { source: '', relevance: '', url: '' },
|
|
35
|
+
},
|
|
36
|
+
trends: {
|
|
37
|
+
primary: 'trend',
|
|
38
|
+
aliases: ['description', 'text', 'title'],
|
|
39
|
+
defaults: { direction: '', evidence: '' },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
function normalizeItem(item, spec) {
|
|
43
|
+
if (typeof item === 'string') {
|
|
44
|
+
return { [spec.primary]: item, ...spec.defaults };
|
|
45
|
+
}
|
|
46
|
+
if (!item || typeof item !== 'object') {
|
|
47
|
+
return { [spec.primary]: '', ...spec.defaults };
|
|
48
|
+
}
|
|
49
|
+
const obj = { ...item };
|
|
50
|
+
if (typeof obj[spec.primary] !== 'string' || !obj[spec.primary]) {
|
|
51
|
+
for (const alias of spec.aliases) {
|
|
52
|
+
const v = obj[alias];
|
|
53
|
+
if (typeof v === 'string' && v.trim().length > 0) {
|
|
54
|
+
obj[spec.primary] = v;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (typeof obj[spec.primary] !== 'string') {
|
|
59
|
+
obj[spec.primary] = '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [key, fallback] of Object.entries(spec.defaults)) {
|
|
63
|
+
if (obj[key] === undefined || obj[key] === null) {
|
|
64
|
+
obj[key] = fallback;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return obj;
|
|
68
|
+
}
|
|
69
|
+
function normalizeArray(value, spec) {
|
|
70
|
+
if (!Array.isArray(value)) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
return value.map((item) => normalizeItem(item, spec));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Normalize every structured array field present in the payload. Fields that
|
|
77
|
+
* are `undefined` are left untouched so partial updates don't overwrite stored
|
|
78
|
+
* values with empty arrays.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeReportArrays(payload) {
|
|
81
|
+
const result = { ...payload };
|
|
82
|
+
for (const [field, spec] of Object.entries(NORMALIZE_SPECS)) {
|
|
83
|
+
if (result[field] !== undefined) {
|
|
84
|
+
;
|
|
85
|
+
result[field] = normalizeArray(result[field], spec);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Coerce an LLM-supplied value to a finite number, or null. Handles numbers,
|
|
92
|
+
* numeric strings, and null/garbage uniformly.
|
|
93
|
+
*/
|
|
94
|
+
export function toNumberOrNull(value) {
|
|
95
|
+
if (typeof value === 'number') {
|
|
96
|
+
return Number.isFinite(value) ? value : null;
|
|
97
|
+
}
|
|
98
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
99
|
+
const n = Number(value);
|
|
100
|
+
return Number.isFinite(n) ? n : null;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
package/dist/api/intelligence.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { getSupabase, hasSupabaseSession } from '../supabase/client.js';
|
|
1
|
+
import { ensureSupabaseSession, getSupabase, hasSupabaseSession, } from '../supabase/client.js';
|
|
2
2
|
import { logError, logInfo } from '../utils/logger.js';
|
|
3
|
+
import { normalizeReportArrays, toNumberOrNull, } from './intelligence-normalize.js';
|
|
3
4
|
import { callMcpEndpoint } from './mcp-client.js';
|
|
4
5
|
// =============================================================================
|
|
5
6
|
// MCP response helper
|
|
@@ -180,8 +181,41 @@ export async function saveSnapshot(snapshot, verbose) {
|
|
|
180
181
|
logInfo(`Saving snapshot for competitor: ${snapshot.competitor_id}`);
|
|
181
182
|
}
|
|
182
183
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
// Direct SDK write under the user's RLS session — no MCP fallback.
|
|
185
|
+
// Await the synced session first so the insert carries the user JWT
|
|
186
|
+
// (otherwise it races out as `anon` and trips RLS).
|
|
187
|
+
const ready = await ensureSupabaseSession();
|
|
188
|
+
if (!ready) {
|
|
189
|
+
logError('Failed to save snapshot: no Supabase session (sign in to the desktop app)');
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const source = ['ai_analysis', 'manual', 'scheduled'].includes(snapshot.source)
|
|
193
|
+
? snapshot.source
|
|
194
|
+
: 'ai_analysis';
|
|
195
|
+
const { data, error } = await getSupabase()
|
|
196
|
+
.from('competitor_snapshots')
|
|
197
|
+
.insert({
|
|
198
|
+
competitor_id: snapshot.competitor_id,
|
|
199
|
+
product_id: snapshot.product_id,
|
|
200
|
+
issues: snapshot.issues ?? [],
|
|
201
|
+
pricing: snapshot.pricing ?? {},
|
|
202
|
+
tech_stack: snapshot.tech_stack ?? [],
|
|
203
|
+
app_rating: toNumberOrNull(snapshot.app_rating),
|
|
204
|
+
app_review_count: toNumberOrNull(snapshot.app_review_count),
|
|
205
|
+
app_version: snapshot.app_version ?? null,
|
|
206
|
+
app_last_updated: snapshot.app_last_updated ?? null,
|
|
207
|
+
recent_reviews: snapshot.recent_reviews ?? [],
|
|
208
|
+
social_mentions: snapshot.social_mentions ?? {},
|
|
209
|
+
changes_detected: snapshot.changes_detected ?? [],
|
|
210
|
+
source,
|
|
211
|
+
raw_data: snapshot.raw_data ?? null,
|
|
212
|
+
})
|
|
213
|
+
.select()
|
|
214
|
+
.single();
|
|
215
|
+
if (error) {
|
|
216
|
+
throw new Error(error.message);
|
|
217
|
+
}
|
|
218
|
+
return data ?? null;
|
|
185
219
|
}
|
|
186
220
|
catch (error) {
|
|
187
221
|
logError(`Failed to save snapshot: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -273,8 +307,54 @@ export async function saveReport(report, verbose) {
|
|
|
273
307
|
logInfo(`Saving intelligence report: ${report.title}`);
|
|
274
308
|
}
|
|
275
309
|
try {
|
|
276
|
-
|
|
277
|
-
|
|
310
|
+
// Direct SDK write — no MCP fallback. intelligence_reports.created_by is
|
|
311
|
+
// NOT NULL and RLS checks `created_by = auth.uid()`, so we resolve the
|
|
312
|
+
// user from the synced session and canonicalize the LLM arrays first.
|
|
313
|
+
const ready = await ensureSupabaseSession();
|
|
314
|
+
if (!ready) {
|
|
315
|
+
logError('Failed to save report: no Supabase session (sign in to the desktop app)');
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const { data: sessionData } = await getSupabase().auth.getSession();
|
|
319
|
+
const userId = sessionData.session?.user?.id;
|
|
320
|
+
if (!userId) {
|
|
321
|
+
logError('Failed to save report: could not resolve user from session');
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const normalized = normalizeReportArrays({
|
|
325
|
+
key_findings: report.key_findings ?? [],
|
|
326
|
+
recommendations: report.recommendations ?? [],
|
|
327
|
+
competitor_highlights: report.competitor_highlights ?? [],
|
|
328
|
+
market_signals: report.market_signals ?? [],
|
|
329
|
+
trends: report.trends ?? [],
|
|
330
|
+
});
|
|
331
|
+
const { data, error } = await getSupabase()
|
|
332
|
+
.from('intelligence_reports')
|
|
333
|
+
.insert({
|
|
334
|
+
product_id: report.product_id,
|
|
335
|
+
report_type: report.report_type,
|
|
336
|
+
title: report.title,
|
|
337
|
+
summary: report.summary ?? null,
|
|
338
|
+
full_report: report.full_report ?? null,
|
|
339
|
+
key_findings: normalized.key_findings,
|
|
340
|
+
recommendations: normalized.recommendations,
|
|
341
|
+
competitor_highlights: normalized.competitor_highlights,
|
|
342
|
+
market_signals: normalized.market_signals,
|
|
343
|
+
trends: normalized.trends,
|
|
344
|
+
persona_updates: report.persona_updates ?? null,
|
|
345
|
+
pmf_score: toNumberOrNull(report.pmf_score),
|
|
346
|
+
pmf_signals: report.pmf_signals ?? null,
|
|
347
|
+
guidance: report.guidance ?? null,
|
|
348
|
+
status: report.status ?? 'completed',
|
|
349
|
+
snapshot_ids: report.snapshot_ids ?? [],
|
|
350
|
+
created_by: userId,
|
|
351
|
+
})
|
|
352
|
+
.select()
|
|
353
|
+
.single();
|
|
354
|
+
if (error) {
|
|
355
|
+
throw new Error(error.message);
|
|
356
|
+
}
|
|
357
|
+
return data ?? null;
|
|
278
358
|
}
|
|
279
359
|
catch (error) {
|
|
280
360
|
logError(`Failed to save report: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -286,11 +366,34 @@ export async function updateReport(reportId, updates, verbose) {
|
|
|
286
366
|
logInfo(`Updating report: ${reportId}`);
|
|
287
367
|
}
|
|
288
368
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
369
|
+
// Direct SDK write — no MCP fallback.
|
|
370
|
+
const ready = await ensureSupabaseSession();
|
|
371
|
+
if (!ready) {
|
|
372
|
+
logError('Failed to update report: no Supabase session (sign in to the desktop app)');
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
// Only send keys the caller provided so a partial update doesn't clobber
|
|
376
|
+
// stored columns. Numeric/array fields are coerced/canonicalized.
|
|
377
|
+
const payload = {};
|
|
378
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
379
|
+
if (value !== undefined) {
|
|
380
|
+
payload[key] = value;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if ('pmf_score' in payload) {
|
|
384
|
+
payload.pmf_score = toNumberOrNull(payload.pmf_score);
|
|
385
|
+
}
|
|
386
|
+
const normalized = normalizeReportArrays(payload);
|
|
387
|
+
const { data, error } = await getSupabase()
|
|
388
|
+
.from('intelligence_reports')
|
|
389
|
+
.update(normalized)
|
|
390
|
+
.eq('id', reportId)
|
|
391
|
+
.select()
|
|
392
|
+
.single();
|
|
393
|
+
if (error) {
|
|
394
|
+
throw new Error(error.message);
|
|
395
|
+
}
|
|
396
|
+
return data ?? null;
|
|
294
397
|
}
|
|
295
398
|
catch (error) {
|
|
296
399
|
logError(`Failed to update report: ${error instanceof Error ? error.message : String(error)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edsger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"edsger": "dist/index.js"
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"commander": "^12.0.0",
|
|
55
55
|
"cosmiconfig": "^9.0.0",
|
|
56
56
|
"dotenv": "^16.4.5",
|
|
57
|
-
"edsger-contract": "0.
|
|
58
|
-
"edsger-tools": "0.
|
|
57
|
+
"edsger-contract": "0.10.0",
|
|
58
|
+
"edsger-tools": "0.10.0",
|
|
59
59
|
"gray-matter": "^4.0.3",
|
|
60
60
|
"zod": "^4.0.0"
|
|
61
61
|
},
|