@ainyc/canonry 4.34.0 → 4.36.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 +2 -2
- package/assets/assets/index-CAmKaZIt.js +302 -0
- package/assets/assets/index-CTrHzgs-.css +1 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-7AF6B3L6.js → chunk-F2G67CIU.js} +1412 -484
- package/dist/{chunk-7256SFYT.js → chunk-JQQXMCQ7.js} +286 -53
- package/dist/{chunk-5EBN7736.js → chunk-O7S623DL.js} +15 -1
- package/dist/{chunk-XW3F5EEW.js → chunk-XJVYVURK.js} +76 -21
- package/dist/cli.js +157 -586
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-3P2DMYRR.js → intelligence-service-7AWRUNI2.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +7 -7
- package/assets/assets/index-47V0U52s.js +0 -302
- package/assets/assets/index-CNKAwZMB.css +0 -1
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
categoryLabel,
|
|
9
9
|
determineAnswerMentioned,
|
|
10
10
|
normalizeProjectDomain
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-XJVYVURK.js";
|
|
12
12
|
|
|
13
13
|
// src/intelligence-service.ts
|
|
14
14
|
import { eq, desc, asc, and, or, inArray } from "drizzle-orm";
|
|
@@ -68,6 +68,7 @@ var projects = sqliteTable("projects", {
|
|
|
68
68
|
displayName: text("display_name").notNull(),
|
|
69
69
|
canonicalDomain: text("canonical_domain").notNull(),
|
|
70
70
|
ownedDomains: text("owned_domains").notNull().default("[]"),
|
|
71
|
+
aliases: text("aliases").notNull().default("[]"),
|
|
71
72
|
country: text("country").notNull(),
|
|
72
73
|
language: text("language").notNull(),
|
|
73
74
|
tags: text("tags").notNull().default("[]"),
|
|
@@ -153,7 +154,12 @@ var querySnapshots = sqliteTable("query_snapshots", {
|
|
|
153
154
|
]);
|
|
154
155
|
var auditLog = sqliteTable("audit_log", {
|
|
155
156
|
id: text("id").primaryKey(),
|
|
156
|
-
|
|
157
|
+
// SET NULL (not CASCADE) so deleting a project preserves its audit trail.
|
|
158
|
+
// The DELETE /projects route writes a "project.deleted" row immediately
|
|
159
|
+
// before the delete — a CASCADE here would wipe that record before any
|
|
160
|
+
// reader could see it (the deletion would erase the only evidence it
|
|
161
|
+
// happened). Detached rows surface in audit queries with project_id=NULL.
|
|
162
|
+
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
|
157
163
|
actor: text("actor").notNull(),
|
|
158
164
|
action: text("action").notNull(),
|
|
159
165
|
entityType: text("entity_type").notNull(),
|
|
@@ -1885,6 +1891,55 @@ var MIGRATION_VERSIONS = [
|
|
|
1885
1891
|
`CREATE INDEX IF NOT EXISTS idx_snapshots_location ON query_snapshots(location)`,
|
|
1886
1892
|
`CREATE INDEX IF NOT EXISTS idx_snapshots_created_at ON query_snapshots(created_at)`
|
|
1887
1893
|
]
|
|
1894
|
+
},
|
|
1895
|
+
{
|
|
1896
|
+
version: 59,
|
|
1897
|
+
name: "projects-aliases",
|
|
1898
|
+
statements: [
|
|
1899
|
+
`ALTER TABLE projects ADD COLUMN aliases TEXT NOT NULL DEFAULT '[]'`
|
|
1900
|
+
]
|
|
1901
|
+
},
|
|
1902
|
+
{
|
|
1903
|
+
version: 60,
|
|
1904
|
+
name: "audit-log-preserve-on-project-delete",
|
|
1905
|
+
// The legacy `audit_log.project_id` FK was `ON DELETE CASCADE`, so any
|
|
1906
|
+
// `DELETE /projects/:name` call cascade-wiped every audit row for that
|
|
1907
|
+
// project — including the `project.deleted` row the route handler had
|
|
1908
|
+
// just written in the same path. The deletion erased the only record
|
|
1909
|
+
// that the deletion happened, defeating the entire purpose of the
|
|
1910
|
+
// audit log.
|
|
1911
|
+
//
|
|
1912
|
+
// Fix: rebuild `audit_log` with `project_id` as `ON DELETE SET NULL`.
|
|
1913
|
+
// Existing rows survive verbatim; future deletions detach audit rows
|
|
1914
|
+
// from the project (project_id=NULL) instead of erasing them. SQLite
|
|
1915
|
+
// can't change FK behavior in place — same canonical table-rebuild
|
|
1916
|
+
// pattern v58 used for `query_snapshots`.
|
|
1917
|
+
statements: [
|
|
1918
|
+
`CREATE TABLE IF NOT EXISTS audit_log_v60 (
|
|
1919
|
+
id TEXT PRIMARY KEY,
|
|
1920
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
1921
|
+
actor TEXT NOT NULL,
|
|
1922
|
+
action TEXT NOT NULL,
|
|
1923
|
+
entity_type TEXT NOT NULL,
|
|
1924
|
+
entity_id TEXT,
|
|
1925
|
+
diff TEXT,
|
|
1926
|
+
created_at TEXT NOT NULL
|
|
1927
|
+
)`,
|
|
1928
|
+
// LEFT JOIN guard mirrors v58: if a pre-existing row carries a
|
|
1929
|
+
// dangling project_id (from a pre-FK era or a write with
|
|
1930
|
+
// PRAGMA foreign_keys=OFF), the join nulls it out rather than
|
|
1931
|
+
// failing the migration on the new FK validation.
|
|
1932
|
+
`INSERT INTO audit_log_v60 (
|
|
1933
|
+
id, project_id, actor, action, entity_type, entity_id, diff, created_at
|
|
1934
|
+
)
|
|
1935
|
+
SELECT a.id, p.id, a.actor, a.action, a.entity_type, a.entity_id, a.diff, a.created_at
|
|
1936
|
+
FROM audit_log a
|
|
1937
|
+
LEFT JOIN projects p ON p.id = a.project_id`,
|
|
1938
|
+
`DROP TABLE audit_log`,
|
|
1939
|
+
`ALTER TABLE audit_log_v60 RENAME TO audit_log`,
|
|
1940
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id)`,
|
|
1941
|
+
`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at)`
|
|
1942
|
+
]
|
|
1888
1943
|
}
|
|
1889
1944
|
];
|
|
1890
1945
|
function isDuplicateColumnError(err) {
|
|
@@ -2074,19 +2129,26 @@ function filterTrackedSnapshots(rows) {
|
|
|
2074
2129
|
}
|
|
2075
2130
|
|
|
2076
2131
|
// ../intelligence/src/regressions.ts
|
|
2132
|
+
function snapshotKey(snap) {
|
|
2133
|
+
const loc = snap.location ?? "__none__";
|
|
2134
|
+
return JSON.stringify([snap.query, snap.provider, loc]);
|
|
2135
|
+
}
|
|
2077
2136
|
function detectRegressions(currentRun, previousRun) {
|
|
2137
|
+
if ((currentRun.location ?? null) !== (previousRun.location ?? null)) {
|
|
2138
|
+
return [];
|
|
2139
|
+
}
|
|
2078
2140
|
const regressions = [];
|
|
2079
2141
|
const previousCited = /* @__PURE__ */ new Map();
|
|
2080
2142
|
for (const snap of previousRun.snapshots) {
|
|
2081
2143
|
if (snap.cited) {
|
|
2082
|
-
previousCited.set(
|
|
2144
|
+
previousCited.set(snapshotKey(snap), {
|
|
2083
2145
|
citationUrl: snap.citationUrl,
|
|
2084
2146
|
position: snap.position
|
|
2085
2147
|
});
|
|
2086
2148
|
}
|
|
2087
2149
|
}
|
|
2088
2150
|
for (const snap of currentRun.snapshots) {
|
|
2089
|
-
const key =
|
|
2151
|
+
const key = snapshotKey(snap);
|
|
2090
2152
|
if (!snap.cited && previousCited.has(key)) {
|
|
2091
2153
|
const prev = previousCited.get(key);
|
|
2092
2154
|
regressions.push({
|
|
@@ -2103,16 +2165,23 @@ function detectRegressions(currentRun, previousRun) {
|
|
|
2103
2165
|
}
|
|
2104
2166
|
|
|
2105
2167
|
// ../intelligence/src/gains.ts
|
|
2168
|
+
function snapshotKey2(snap) {
|
|
2169
|
+
const loc = snap.location ?? "__none__";
|
|
2170
|
+
return JSON.stringify([snap.query, snap.provider, loc]);
|
|
2171
|
+
}
|
|
2106
2172
|
function detectGains(currentRun, previousRun) {
|
|
2173
|
+
if ((currentRun.location ?? null) !== (previousRun.location ?? null)) {
|
|
2174
|
+
return [];
|
|
2175
|
+
}
|
|
2107
2176
|
const gains = [];
|
|
2108
2177
|
const previousCited = /* @__PURE__ */ new Set();
|
|
2109
2178
|
for (const snap of previousRun.snapshots) {
|
|
2110
2179
|
if (snap.cited) {
|
|
2111
|
-
previousCited.add(
|
|
2180
|
+
previousCited.add(snapshotKey2(snap));
|
|
2112
2181
|
}
|
|
2113
2182
|
}
|
|
2114
2183
|
for (const snap of currentRun.snapshots) {
|
|
2115
|
-
const key =
|
|
2184
|
+
const key = snapshotKey2(snap);
|
|
2116
2185
|
if (snap.cited && !previousCited.has(key)) {
|
|
2117
2186
|
gains.push({
|
|
2118
2187
|
query: snap.query,
|
|
@@ -2173,17 +2242,26 @@ function computeHealthTrend(runs2) {
|
|
|
2173
2242
|
|
|
2174
2243
|
// ../intelligence/src/causes.ts
|
|
2175
2244
|
function analyzeCause(regression, currentSnapshots) {
|
|
2176
|
-
const
|
|
2177
|
-
(s) => s.query === regression.query && s.provider === regression.provider && !s.cited
|
|
2245
|
+
const matchingSnaps = currentSnapshots.filter(
|
|
2246
|
+
(s) => s.query === regression.query && s.provider === regression.provider && !s.cited
|
|
2178
2247
|
);
|
|
2179
|
-
|
|
2180
|
-
|
|
2248
|
+
const withCompetitor = matchingSnaps.find((s) => s.competitorDomains?.length);
|
|
2249
|
+
if (withCompetitor) {
|
|
2250
|
+
const competitor = withCompetitor.competitorDomains[0];
|
|
2181
2251
|
return {
|
|
2182
2252
|
cause: "competitor_gain",
|
|
2183
2253
|
competitorDomain: competitor,
|
|
2184
2254
|
details: `Competitor ${competitor} now cited for "${regression.query}" on ${regression.provider}`
|
|
2185
2255
|
};
|
|
2186
2256
|
}
|
|
2257
|
+
const withCited = matchingSnaps.find((s) => s.citedDomains?.length);
|
|
2258
|
+
if (withCited) {
|
|
2259
|
+
const top = withCited.citedDomains.slice(0, 3);
|
|
2260
|
+
return {
|
|
2261
|
+
cause: "third_party_displacement",
|
|
2262
|
+
details: `${regression.provider} now grounds on ${top.join(", ")} for "${regression.query}" \u2014 none are tracked competitors.`
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2187
2265
|
return {
|
|
2188
2266
|
cause: "unknown",
|
|
2189
2267
|
details: `No specific cause identified for loss of "${regression.query}" on ${regression.provider}`
|
|
@@ -2626,10 +2704,13 @@ function buildDrivers(input) {
|
|
|
2626
2704
|
if (input.action === "create" && input.position === null) {
|
|
2627
2705
|
drivers.push("no existing page");
|
|
2628
2706
|
}
|
|
2629
|
-
if (input.position !== null
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2707
|
+
if (input.position !== null) {
|
|
2708
|
+
const positionDisplay = Math.round(input.position);
|
|
2709
|
+
if (input.position > 30) {
|
|
2710
|
+
drivers.push(`page ranks #${positionDisplay} (effectively invisible)`);
|
|
2711
|
+
} else if (input.position > 10) {
|
|
2712
|
+
drivers.push(`page ranks #${positionDisplay}`);
|
|
2713
|
+
}
|
|
2633
2714
|
}
|
|
2634
2715
|
if (input.action === "add-schema") {
|
|
2635
2716
|
drivers.push("cited by LLMs but lacks structured data");
|
|
@@ -2900,7 +2981,7 @@ function classifyRegressionSeverity(signals) {
|
|
|
2900
2981
|
}
|
|
2901
2982
|
|
|
2902
2983
|
// ../intelligence/src/insight-grouping.ts
|
|
2903
|
-
function groupInsights(insights2, keyFn = (i) =>
|
|
2984
|
+
function groupInsights(insights2, keyFn = (i) => JSON.stringify([i.query, i.provider, i.type])) {
|
|
2904
2985
|
const order = [];
|
|
2905
2986
|
const buckets = /* @__PURE__ */ new Map();
|
|
2906
2987
|
for (const i of insights2) {
|
|
@@ -2932,14 +3013,15 @@ var MIN_BRAND_TOKEN_LENGTH = 3;
|
|
|
2932
3013
|
function compact(value) {
|
|
2933
3014
|
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2934
3015
|
}
|
|
2935
|
-
function buildBrandTokens(canonicalDomain,
|
|
3016
|
+
function buildBrandTokens(canonicalDomain, brandNames = []) {
|
|
2936
3017
|
const seen = /* @__PURE__ */ new Set();
|
|
2937
3018
|
const stem = canonicalDomain.toLowerCase().replace(/\.[a-z]{2,}$/, "");
|
|
2938
3019
|
const stemCompact = compact(stem);
|
|
2939
3020
|
if (stemCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(stemCompact);
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
3021
|
+
for (const name of brandNames) {
|
|
3022
|
+
if (!name) continue;
|
|
3023
|
+
const nameCompact = compact(name);
|
|
3024
|
+
if (nameCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(nameCompact);
|
|
2943
3025
|
}
|
|
2944
3026
|
return [...seen];
|
|
2945
3027
|
}
|
|
@@ -3086,7 +3168,7 @@ function extractHostFromUri(uri) {
|
|
|
3086
3168
|
}
|
|
3087
3169
|
|
|
3088
3170
|
// ../intelligence/src/mention-landscape.ts
|
|
3089
|
-
function buildMentionLandscape(snapshots, competitorDomains,
|
|
3171
|
+
function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
|
|
3090
3172
|
let projectMentionCount = 0;
|
|
3091
3173
|
let totalAnswerSnapshots = 0;
|
|
3092
3174
|
const competitorMap = /* @__PURE__ */ new Map();
|
|
@@ -3100,13 +3182,13 @@ function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName,
|
|
|
3100
3182
|
const q = queryLookup.byId.get(snap.queryId);
|
|
3101
3183
|
const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
|
|
3102
3184
|
text2,
|
|
3103
|
-
|
|
3185
|
+
[...projectBrandNames],
|
|
3104
3186
|
[...projectDomains]
|
|
3105
3187
|
);
|
|
3106
3188
|
if (projectMentioned) projectMentionCount++;
|
|
3107
3189
|
for (const competitor of competitorDomains) {
|
|
3108
3190
|
const brand = brandLabelFromDomain(competitor);
|
|
3109
|
-
const mentioned = determineAnswerMentioned(text2, brand, [competitor]);
|
|
3191
|
+
const mentioned = determineAnswerMentioned(text2, brand ? [brand] : [], [competitor]);
|
|
3110
3192
|
if (mentioned) {
|
|
3111
3193
|
const entry = competitorMap.get(competitor);
|
|
3112
3194
|
entry.count++;
|
|
@@ -3221,14 +3303,14 @@ function gapTone(gapCount, totalCount) {
|
|
|
3221
3303
|
|
|
3222
3304
|
// ../intelligence/src/visibility-score.ts
|
|
3223
3305
|
function buildVisibilityScore(snapshots, options) {
|
|
3224
|
-
const tooltip =
|
|
3306
|
+
const tooltip = "An LLM used a page on your domain as a source for its answer.";
|
|
3225
3307
|
if (snapshots.length === 0) {
|
|
3226
3308
|
return {
|
|
3227
|
-
label: "
|
|
3309
|
+
label: "Citation Coverage",
|
|
3228
3310
|
value: "No data",
|
|
3229
3311
|
delta: "Run a sweep first",
|
|
3230
3312
|
tone: "neutral",
|
|
3231
|
-
description: "No
|
|
3313
|
+
description: "No citation data yet. Trigger a run to start tracking.",
|
|
3232
3314
|
tooltip,
|
|
3233
3315
|
trend: []
|
|
3234
3316
|
};
|
|
@@ -3245,9 +3327,9 @@ function buildVisibilityScore(snapshots, options) {
|
|
|
3245
3327
|
const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
|
|
3246
3328
|
const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
|
|
3247
3329
|
return {
|
|
3248
|
-
label: "
|
|
3330
|
+
label: "Citation Coverage",
|
|
3249
3331
|
value: `${score}`,
|
|
3250
|
-
delta: `${citedCount} of ${totalCount} queries
|
|
3332
|
+
delta: `${citedCount} of ${totalCount} queries cited`,
|
|
3251
3333
|
tone: isPartialProviderRun ? "caution" : scoreTone(score),
|
|
3252
3334
|
description: `${citedCount} of ${totalCount} tracked queries found your domain in at least one AI answer engine.`,
|
|
3253
3335
|
tooltip,
|
|
@@ -3257,6 +3339,44 @@ function buildVisibilityScore(snapshots, options) {
|
|
|
3257
3339
|
};
|
|
3258
3340
|
}
|
|
3259
3341
|
|
|
3342
|
+
// ../intelligence/src/mention-coverage.ts
|
|
3343
|
+
function buildMentionCoverage(snapshots, options) {
|
|
3344
|
+
const tooltip = "Your domain or company name was in the answer returned by the LLM.";
|
|
3345
|
+
if (snapshots.length === 0) {
|
|
3346
|
+
return {
|
|
3347
|
+
label: "Mention Coverage",
|
|
3348
|
+
value: "No data",
|
|
3349
|
+
delta: "Run a sweep first",
|
|
3350
|
+
tone: "neutral",
|
|
3351
|
+
description: "No mention data yet. Trigger a run to start tracking.",
|
|
3352
|
+
tooltip,
|
|
3353
|
+
trend: []
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
const queryMentioned = /* @__PURE__ */ new Map();
|
|
3357
|
+
for (const snap of snapshots) {
|
|
3358
|
+
if (!queryMentioned.has(snap.queryId)) queryMentioned.set(snap.queryId, false);
|
|
3359
|
+
if (snap.answerMentioned === true) queryMentioned.set(snap.queryId, true);
|
|
3360
|
+
}
|
|
3361
|
+
const totalCount = queryMentioned.size;
|
|
3362
|
+
const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
|
|
3363
|
+
const score = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
|
|
3364
|
+
const runProviders = new Set(snapshots.map((s) => s.provider));
|
|
3365
|
+
const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
|
|
3366
|
+
const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
|
|
3367
|
+
return {
|
|
3368
|
+
label: "Mention Coverage",
|
|
3369
|
+
value: `${score}`,
|
|
3370
|
+
delta: `${mentionedCount} of ${totalCount} queries mentioned`,
|
|
3371
|
+
tone: isPartialProviderRun ? "caution" : scoreTone(score),
|
|
3372
|
+
description: `${mentionedCount} of ${totalCount} tracked queries had your brand or domain in the AI answer text.`,
|
|
3373
|
+
tooltip,
|
|
3374
|
+
trend: [],
|
|
3375
|
+
progress: score,
|
|
3376
|
+
providerCoverage: isPartialProviderRun ? `${runApiProviderCount} of ${options.configuredApiProviders.length} providers` : void 0
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3260
3380
|
// ../intelligence/src/gap-query-score.ts
|
|
3261
3381
|
function buildGapQueryScore(snapshots) {
|
|
3262
3382
|
const tooltip = "Tracked queries where a competitor is cited in the latest run but your domain is not.";
|
|
@@ -3474,17 +3594,29 @@ var IntelligenceService = class {
|
|
|
3474
3594
|
log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
|
|
3475
3595
|
return null;
|
|
3476
3596
|
}
|
|
3477
|
-
const currentRun = this.buildRunData(
|
|
3597
|
+
const currentRun = this.buildRunData(
|
|
3598
|
+
runId,
|
|
3599
|
+
projectId,
|
|
3600
|
+
currentRunRecord.finishedAt ?? currentRunRecord.createdAt,
|
|
3601
|
+
currentRunRecord.location ?? null
|
|
3602
|
+
);
|
|
3478
3603
|
if (currentRun.snapshots.length === 0) {
|
|
3479
3604
|
log.info("intelligence.skip", { runId, reason: "no snapshots" });
|
|
3480
3605
|
return null;
|
|
3481
3606
|
}
|
|
3482
3607
|
const orderedRecent = [...recentRuns].reverse();
|
|
3483
|
-
const
|
|
3484
|
-
const
|
|
3485
|
-
const
|
|
3608
|
+
const currentLocation = currentRunRecord.location ?? null;
|
|
3609
|
+
const sameLocationOrdered = orderedRecent.filter((r) => (r.location ?? null) === currentLocation);
|
|
3610
|
+
const currentLocIdx = sameLocationOrdered.findIndex((r) => r.id === runId);
|
|
3611
|
+
const previousRunRecord = currentLocIdx > 0 ? sameLocationOrdered[currentLocIdx - 1] : null;
|
|
3612
|
+
const previousRun = previousRunRecord ? this.buildRunData(
|
|
3613
|
+
previousRunRecord.id,
|
|
3614
|
+
projectId,
|
|
3615
|
+
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
3616
|
+
previousRunRecord.location ?? null
|
|
3617
|
+
) : null;
|
|
3486
3618
|
const trackedCompetitors = this.loadTrackedCompetitors(projectId);
|
|
3487
|
-
const history =
|
|
3619
|
+
const history = sameLocationOrdered.slice(0, currentLocIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(r.id, projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
|
|
3488
3620
|
if (!previousRun) {
|
|
3489
3621
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
3490
3622
|
log.info("intelligence.analyzed", {
|
|
@@ -3517,34 +3649,70 @@ var IntelligenceService = class {
|
|
|
3517
3649
|
/**
|
|
3518
3650
|
* Analyze a single run given an explicit previous run (or null for first run).
|
|
3519
3651
|
* Used by backfill where we control the run ordering.
|
|
3652
|
+
*
|
|
3653
|
+
* `dryRun: true` skips the DB write — `persistResult` is not called and
|
|
3654
|
+
* dismissed flags / health rows are untouched. Callers receive the same
|
|
3655
|
+
* AnalysisResult they would have, suitable for previewing what a write
|
|
3656
|
+
* would have produced.
|
|
3520
3657
|
*/
|
|
3521
|
-
analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords) {
|
|
3522
|
-
const currentRun = this.buildRunData(
|
|
3658
|
+
analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords, opts) {
|
|
3659
|
+
const currentRun = this.buildRunData(
|
|
3660
|
+
runRecord.id,
|
|
3661
|
+
runRecord.projectId,
|
|
3662
|
+
runRecord.finishedAt ?? runRecord.createdAt,
|
|
3663
|
+
runRecord.location ?? null
|
|
3664
|
+
);
|
|
3523
3665
|
if (currentRun.snapshots.length === 0) {
|
|
3524
3666
|
return null;
|
|
3525
3667
|
}
|
|
3526
|
-
const previousRun = previousRunRecord ? this.buildRunData(
|
|
3668
|
+
const previousRun = previousRunRecord ? this.buildRunData(
|
|
3669
|
+
previousRunRecord.id,
|
|
3670
|
+
previousRunRecord.projectId,
|
|
3671
|
+
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
3672
|
+
previousRunRecord.location ?? null
|
|
3673
|
+
) : null;
|
|
3527
3674
|
const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
|
|
3528
|
-
const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt));
|
|
3675
|
+
const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
|
|
3529
3676
|
if (!previousRun) {
|
|
3530
3677
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
3531
|
-
this.
|
|
3678
|
+
const emptyResult = this.emptyAnalysisResult(result2);
|
|
3679
|
+
if (!opts?.dryRun) this.persistResult(emptyResult, runRecord.id, runRecord.projectId);
|
|
3532
3680
|
return result2;
|
|
3533
3681
|
}
|
|
3534
3682
|
const result = analyzeRuns(currentRun, previousRun, { trackedCompetitors, history });
|
|
3535
3683
|
const tieredResult = this.tierResult(result, runRecord.id, runRecord.projectId);
|
|
3536
|
-
this.persistResult(tieredResult, runRecord.id, runRecord.projectId);
|
|
3684
|
+
if (!opts?.dryRun) this.persistResult(tieredResult, runRecord.id, runRecord.projectId);
|
|
3537
3685
|
return tieredResult;
|
|
3538
3686
|
}
|
|
3539
3687
|
/**
|
|
3540
3688
|
* Backfill intelligence for all completed/partial runs of a project.
|
|
3541
3689
|
* Processes runs in chronological order so each run compares against its predecessor.
|
|
3690
|
+
*
|
|
3691
|
+
* Scoping options:
|
|
3692
|
+
* - `fromRunId` / `toRunId`: bound the target range by exact run ID.
|
|
3693
|
+
* - `since`: bound the target range by `finishedAt >= <date>`. Accepts
|
|
3694
|
+
* any string that `Date.parse` understands (ISO 8601, `YYYY-MM-DD`,
|
|
3695
|
+
* etc.). Runs before the cutoff are *not* re-processed but stay
|
|
3696
|
+
* available for predecessor lookup, so transition detection at the
|
|
3697
|
+
* boundary stays correct. Composes with `fromRunId` / `toRunId` —
|
|
3698
|
+
* all three filters intersect.
|
|
3699
|
+
* - `dryRun`: compute the analysis without writing. The return value
|
|
3700
|
+
* includes a `delta` describing what would change (rows to delete vs
|
|
3701
|
+
* create per run + aggregate). DB is left untouched.
|
|
3542
3702
|
*/
|
|
3543
3703
|
backfill(projectName, opts, onProgress) {
|
|
3544
3704
|
const project = this.db.select().from(projects).where(eq(projects.name, projectName)).get();
|
|
3545
3705
|
if (!project) {
|
|
3546
3706
|
throw new Error(`Project "${projectName}" not found`);
|
|
3547
3707
|
}
|
|
3708
|
+
let sinceTimestamp = null;
|
|
3709
|
+
if (opts?.since !== void 0) {
|
|
3710
|
+
const parsed = Date.parse(opts.since);
|
|
3711
|
+
if (Number.isNaN(parsed)) {
|
|
3712
|
+
throw new Error(`Invalid --since value "${opts.since}": expected a parseable date (ISO 8601 or YYYY-MM-DD)`);
|
|
3713
|
+
}
|
|
3714
|
+
sinceTimestamp = parsed;
|
|
3715
|
+
}
|
|
3548
3716
|
const allRuns = this.db.select().from(runs).where(
|
|
3549
3717
|
and(
|
|
3550
3718
|
eq(runs.projectId, project.id),
|
|
@@ -3563,26 +3731,65 @@ var IntelligenceService = class {
|
|
|
3563
3731
|
if (idx === -1) throw new Error(`Run "${opts.toRunId}" not found in project`);
|
|
3564
3732
|
endIdx = idx + 1;
|
|
3565
3733
|
}
|
|
3566
|
-
|
|
3734
|
+
let targetRuns = allRuns.slice(startIdx, endIdx);
|
|
3735
|
+
if (sinceTimestamp !== null) {
|
|
3736
|
+
targetRuns = targetRuns.filter((r) => {
|
|
3737
|
+
const ts = r.finishedAt ?? r.createdAt;
|
|
3738
|
+
const t = Date.parse(ts);
|
|
3739
|
+
return !Number.isNaN(t) && t >= sinceTimestamp;
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3567
3742
|
let processed = 0;
|
|
3568
3743
|
let skipped = 0;
|
|
3569
3744
|
let totalInsights = 0;
|
|
3745
|
+
const isDryRun = opts?.dryRun === true;
|
|
3746
|
+
const perRunDelta = [];
|
|
3747
|
+
let wouldDeleteTotal = 0;
|
|
3748
|
+
const existingByRunId = /* @__PURE__ */ new Map();
|
|
3749
|
+
if (isDryRun && targetRuns.length > 0) {
|
|
3750
|
+
const rows = this.db.select({ runId: insights.runId }).from(insights).where(inArray(insights.runId, targetRuns.map((r) => r.id))).all();
|
|
3751
|
+
for (const r of rows) {
|
|
3752
|
+
if (r.runId == null) continue;
|
|
3753
|
+
existingByRunId.set(r.runId, (existingByRunId.get(r.runId) ?? 0) + 1);
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3570
3756
|
for (let i = 0; i < targetRuns.length; i++) {
|
|
3571
3757
|
const run = targetRuns[i];
|
|
3572
|
-
const
|
|
3573
|
-
const
|
|
3574
|
-
const
|
|
3575
|
-
const
|
|
3576
|
-
const
|
|
3758
|
+
const runLocation = run.location ?? null;
|
|
3759
|
+
const sameLocationRuns = allRuns.filter((r) => (r.location ?? null) === runLocation);
|
|
3760
|
+
const sameLocIdx = sameLocationRuns.indexOf(run);
|
|
3761
|
+
const previousRun = sameLocIdx > 0 ? sameLocationRuns[sameLocIdx - 1] : null;
|
|
3762
|
+
const historyStart = Math.max(0, sameLocIdx - (HISTORY_WINDOW_RUNS - 1));
|
|
3763
|
+
const historyRecords = sameLocationRuns.slice(historyStart, sameLocIdx + 1);
|
|
3764
|
+
const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords, { dryRun: isDryRun });
|
|
3577
3765
|
if (result) {
|
|
3578
3766
|
processed++;
|
|
3579
3767
|
totalInsights += result.insights.length;
|
|
3768
|
+
if (isDryRun) {
|
|
3769
|
+
const existing = existingByRunId.get(run.id) ?? 0;
|
|
3770
|
+
wouldDeleteTotal += existing;
|
|
3771
|
+
perRunDelta.push({ runId: run.id, existingInsights: existing, newInsights: result.insights.length });
|
|
3772
|
+
}
|
|
3580
3773
|
onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: result.insights.length });
|
|
3581
3774
|
} else {
|
|
3582
3775
|
skipped++;
|
|
3583
3776
|
onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: 0 });
|
|
3584
3777
|
}
|
|
3585
3778
|
}
|
|
3779
|
+
if (isDryRun) {
|
|
3780
|
+
return {
|
|
3781
|
+
processed,
|
|
3782
|
+
skipped,
|
|
3783
|
+
totalInsights,
|
|
3784
|
+
dryRun: true,
|
|
3785
|
+
delta: {
|
|
3786
|
+
wouldDelete: wouldDeleteTotal,
|
|
3787
|
+
wouldCreate: totalInsights,
|
|
3788
|
+
netChange: totalInsights - wouldDeleteTotal,
|
|
3789
|
+
perRun: perRunDelta
|
|
3790
|
+
}
|
|
3791
|
+
};
|
|
3792
|
+
}
|
|
3586
3793
|
return { processed, skipped, totalInsights };
|
|
3587
3794
|
}
|
|
3588
3795
|
loadTrackedCompetitors(projectId) {
|
|
@@ -3729,26 +3936,51 @@ var IntelligenceService = class {
|
|
|
3729
3936
|
return { ...insight, severity };
|
|
3730
3937
|
});
|
|
3731
3938
|
}
|
|
3732
|
-
buildRunData(runId, projectId, completedAt) {
|
|
3939
|
+
buildRunData(runId, projectId, completedAt, location = null) {
|
|
3733
3940
|
const rows = this.db.select({
|
|
3734
3941
|
query: queries.query,
|
|
3942
|
+
// Denormalized query text persisted by v58 — the fallback when the
|
|
3943
|
+
// joined queries.query has been hard-deleted (or the query_id was
|
|
3944
|
+
// nulled by the v58 dangling-FK cleanup).
|
|
3945
|
+
queryText: querySnapshots.queryText,
|
|
3735
3946
|
provider: querySnapshots.provider,
|
|
3736
3947
|
citationState: querySnapshots.citationState,
|
|
3737
3948
|
citedDomains: querySnapshots.citedDomains,
|
|
3738
|
-
competitorOverlap: querySnapshots.competitorOverlap
|
|
3949
|
+
competitorOverlap: querySnapshots.competitorOverlap,
|
|
3950
|
+
snapshotLocation: querySnapshots.location
|
|
3739
3951
|
}).from(querySnapshots).leftJoin(queries, eq(querySnapshots.queryId, queries.id)).where(eq(querySnapshots.runId, runId)).all();
|
|
3740
|
-
const snapshots =
|
|
3952
|
+
const snapshots = [];
|
|
3953
|
+
let orphanCount = 0;
|
|
3954
|
+
for (const r of rows) {
|
|
3955
|
+
const resolvedQuery = r.query ?? r.queryText ?? null;
|
|
3956
|
+
if (!resolvedQuery) {
|
|
3957
|
+
orphanCount++;
|
|
3958
|
+
continue;
|
|
3959
|
+
}
|
|
3741
3960
|
const domains = parseJsonColumn(r.citedDomains, []);
|
|
3742
3961
|
const competitors2 = parseJsonColumn(r.competitorOverlap, []);
|
|
3743
|
-
|
|
3744
|
-
query:
|
|
3962
|
+
snapshots.push({
|
|
3963
|
+
query: resolvedQuery,
|
|
3745
3964
|
provider: r.provider,
|
|
3746
3965
|
cited: r.citationState === CitationStates.cited,
|
|
3747
3966
|
citationUrl: domains[0] ?? void 0,
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3967
|
+
// Snapshots carry their own location for downstream detectors. In
|
|
3968
|
+
// practice every snapshot in a single runId shares the run's
|
|
3969
|
+
// location; the per-row column is the same value duplicated, but
|
|
3970
|
+
// we read it from the snapshot row so a stale runs.location can't
|
|
3971
|
+
// mask snapshot truth.
|
|
3972
|
+
location: r.snapshotLocation ?? location ?? null,
|
|
3973
|
+
competitorDomains: competitors2,
|
|
3974
|
+
// citedDomains is the FULL set (tracked competitors + third-party
|
|
3975
|
+
// sources). Cause analysis uses it to name the displacing source
|
|
3976
|
+
// when no tracked competitor appears in the response.
|
|
3977
|
+
citedDomains: domains
|
|
3978
|
+
});
|
|
3979
|
+
}
|
|
3980
|
+
if (orphanCount > 0) {
|
|
3981
|
+
log.warn("snapshot.orphan-skip", { runId, projectId, orphanCount });
|
|
3982
|
+
}
|
|
3983
|
+
return { runId, projectId, completedAt, location, snapshots };
|
|
3752
3984
|
}
|
|
3753
3985
|
};
|
|
3754
3986
|
|
|
@@ -3811,6 +4043,7 @@ export {
|
|
|
3811
4043
|
buildAiSourceOrigin,
|
|
3812
4044
|
buildMovementSummary,
|
|
3813
4045
|
buildVisibilityScore,
|
|
4046
|
+
buildMentionCoverage,
|
|
3814
4047
|
buildGapQueryScore,
|
|
3815
4048
|
buildCompetitorPressureScore,
|
|
3816
4049
|
buildOverviewCompetitors,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
trafficConnectVercelRequestSchema,
|
|
23
23
|
trafficConnectWordpressRequestSchema,
|
|
24
24
|
trafficEventKindSchema
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-XJVYVURK.js";
|
|
26
26
|
|
|
27
27
|
// src/config.ts
|
|
28
28
|
import fs from "fs";
|
|
@@ -466,6 +466,9 @@ var ApiClient = class {
|
|
|
466
466
|
async deleteProject(name) {
|
|
467
467
|
await this.request("DELETE", `/projects/${encodeURIComponent(name)}`);
|
|
468
468
|
}
|
|
469
|
+
async previewProjectDelete(name) {
|
|
470
|
+
return this.request("GET", `/projects/${encodeURIComponent(name)}/delete-preview`);
|
|
471
|
+
}
|
|
469
472
|
async putQueries(project, queries) {
|
|
470
473
|
await this.request("PUT", `/projects/${encodeURIComponent(project)}/queries`, { queries });
|
|
471
474
|
}
|
|
@@ -1333,6 +1336,17 @@ var canonryMcpTools = [
|
|
|
1333
1336
|
openApiOperations: ["GET /api/v1/projects/{name}"],
|
|
1334
1337
|
handler: (client, input) => client.getProject(input.project)
|
|
1335
1338
|
}),
|
|
1339
|
+
defineTool({
|
|
1340
|
+
name: "canonry_project_delete_preview",
|
|
1341
|
+
title: "Preview project delete impact",
|
|
1342
|
+
description: "Returns the cascade impact of deleting a project \u2014 how many queries, competitors, runs, snapshots, and insights would be removed, plus how many audit_log rows would be detached (project_id set NULL). Read-only. Use this BEFORE invoking project delete on any project you didn't create yourself; the underlying delete is irreversible.",
|
|
1343
|
+
access: "read",
|
|
1344
|
+
tier: "setup",
|
|
1345
|
+
inputSchema: projectInputSchema,
|
|
1346
|
+
annotations: readAnnotations(),
|
|
1347
|
+
openApiOperations: ["GET /api/v1/projects/{name}/delete-preview"],
|
|
1348
|
+
handler: (client, input) => client.previewProjectDelete(input.project)
|
|
1349
|
+
}),
|
|
1336
1350
|
defineTool({
|
|
1337
1351
|
name: "canonry_project_overview",
|
|
1338
1352
|
title: "Get project overview (composite)",
|