@ainyc/canonry 4.33.1 → 4.35.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-Cfv0_lwq.css +1 -0
- package/assets/assets/index-u7ZXZ5mA.js +302 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-5EBN7736.js → chunk-B3FBOECD.js} +1 -1
- package/dist/{chunk-XW3F5EEW.js → chunk-EM5GVF3C.js} +73 -20
- package/dist/{chunk-BJXHETQW.js → chunk-MLS5KJWK.js} +243 -39
- package/dist/{chunk-DZENHID5.js → chunk-NLV4MZZF.js} +1105 -294
- package/dist/cli.js +100 -563
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-XKOUBRCE.js → intelligence-service-WAJOEOJV.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +8 -8
- 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-EM5GVF3C.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("[]"),
|
|
@@ -123,7 +124,14 @@ var runs = sqliteTable("runs", {
|
|
|
123
124
|
var querySnapshots = sqliteTable("query_snapshots", {
|
|
124
125
|
id: text("id").primaryKey(),
|
|
125
126
|
runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
126
|
-
|
|
127
|
+
// `query_id` is nullable + `ON DELETE SET NULL` so historical snapshots
|
|
128
|
+
// outlive their queries row. Pre-v58 this FK cascaded — deleting a tracked
|
|
129
|
+
// query (PUT /queries replace, individual delete, `canonry apply` dropping
|
|
130
|
+
// one) silently wiped the entire citation history for that query. With SET
|
|
131
|
+
// NULL the snapshot survives; `queryText` keeps it self-describing when
|
|
132
|
+
// the queries row is gone.
|
|
133
|
+
queryId: text("query_id").references(() => queries.id, { onDelete: "set null" }),
|
|
134
|
+
queryText: text("query_text"),
|
|
127
135
|
provider: text("provider").notNull().default("gemini"),
|
|
128
136
|
model: text("model"),
|
|
129
137
|
citationState: text("citation_state").notNull(),
|
|
@@ -146,7 +154,12 @@ var querySnapshots = sqliteTable("query_snapshots", {
|
|
|
146
154
|
]);
|
|
147
155
|
var auditLog = sqliteTable("audit_log", {
|
|
148
156
|
id: text("id").primaryKey(),
|
|
149
|
-
|
|
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" }),
|
|
150
163
|
actor: text("actor").notNull(),
|
|
151
164
|
action: text("action").notNull(),
|
|
152
165
|
entityType: text("entity_type").notNull(),
|
|
@@ -1804,6 +1817,129 @@ var MIGRATION_VERSIONS = [
|
|
|
1804
1817
|
statements: [
|
|
1805
1818
|
`ALTER TABLE runs ADD COLUMN queries TEXT`
|
|
1806
1819
|
]
|
|
1820
|
+
},
|
|
1821
|
+
{
|
|
1822
|
+
version: 58,
|
|
1823
|
+
name: "snapshots-preserve-on-query-delete",
|
|
1824
|
+
// The legacy `query_snapshots.query_id` FK was `ON DELETE CASCADE`, so a
|
|
1825
|
+
// routine basket edit (PUT /queries replace, individual delete, `canonry
|
|
1826
|
+
// apply` dropping a query) silently destroyed every historical citation
|
|
1827
|
+
// snapshot for the removed queries — the regression history, transitions,
|
|
1828
|
+
// and competitor-overlap evidence that are canonry's whole value.
|
|
1829
|
+
//
|
|
1830
|
+
// Fix: rebuild `query_snapshots` with `query_id` nullable + `ON DELETE
|
|
1831
|
+
// SET NULL`, and add a denormalized `query_text` column populated from
|
|
1832
|
+
// `queries.query` via the join. SQLite can't change FK or NOT NULL in
|
|
1833
|
+
// place — same canonical table-rebuild pattern v53 used. All statements
|
|
1834
|
+
// run inside the migration runner's single transaction.
|
|
1835
|
+
//
|
|
1836
|
+
// `run_id` keeps `ON DELETE CASCADE` — deleting a run legitimately
|
|
1837
|
+
// removes its snapshots. Indexes are recreated on the renamed table.
|
|
1838
|
+
statements: [
|
|
1839
|
+
`CREATE TABLE IF NOT EXISTS query_snapshots_v58 (
|
|
1840
|
+
id TEXT PRIMARY KEY,
|
|
1841
|
+
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
1842
|
+
query_id TEXT REFERENCES queries(id) ON DELETE SET NULL,
|
|
1843
|
+
query_text TEXT,
|
|
1844
|
+
provider TEXT NOT NULL DEFAULT 'gemini',
|
|
1845
|
+
model TEXT,
|
|
1846
|
+
citation_state TEXT NOT NULL,
|
|
1847
|
+
answer_mentioned INTEGER,
|
|
1848
|
+
answer_text TEXT,
|
|
1849
|
+
cited_domains TEXT NOT NULL DEFAULT '[]',
|
|
1850
|
+
competitor_overlap TEXT NOT NULL DEFAULT '[]',
|
|
1851
|
+
recommended_competitors TEXT NOT NULL DEFAULT '[]',
|
|
1852
|
+
location TEXT,
|
|
1853
|
+
screenshot_path TEXT,
|
|
1854
|
+
raw_response TEXT,
|
|
1855
|
+
created_at TEXT NOT NULL
|
|
1856
|
+
)`,
|
|
1857
|
+
// Backfill `query_text` from joined queries.query so existing snapshots
|
|
1858
|
+
// stay readable even if their query is later deleted.
|
|
1859
|
+
//
|
|
1860
|
+
// IMPORTANT: we use `q.id` (the JOINED queries.id), not `qs.query_id`.
|
|
1861
|
+
// Production DBs may already contain snapshots whose `qs.query_id`
|
|
1862
|
+
// dangles — a queries row was hard-deleted at some point without
|
|
1863
|
+
// cascading (PRAGMA foreign_keys was OFF, or pre-FK schema). Copying
|
|
1864
|
+
// `qs.query_id` directly would re-introduce those dangling refs into
|
|
1865
|
+
// the new table, which now validates them at INSERT (the new FK still
|
|
1866
|
+
// requires query_id values to match queries.id when non-null). Reading
|
|
1867
|
+
// through the LEFT JOIN forces every value to be either a valid `q.id`
|
|
1868
|
+
// or NULL — pre-existing orphans land with NULL `query_id` / NULL
|
|
1869
|
+
// `query_text`, preserving the snapshot row instead of failing the
|
|
1870
|
+
// migration. The May 2026 azcoatings DB had 459 such pre-existing
|
|
1871
|
+
// orphans; without this guard, migrate() throws SQLITE_CONSTRAINT_FOREIGNKEY.
|
|
1872
|
+
`INSERT INTO query_snapshots_v58 (
|
|
1873
|
+
id, run_id, query_id, query_text, provider, model, citation_state,
|
|
1874
|
+
answer_mentioned, answer_text, cited_domains, competitor_overlap,
|
|
1875
|
+
recommended_competitors, location, screenshot_path, raw_response,
|
|
1876
|
+
created_at
|
|
1877
|
+
)
|
|
1878
|
+
SELECT qs.id, qs.run_id, q.id, q.query, qs.provider, qs.model,
|
|
1879
|
+
qs.citation_state, qs.answer_mentioned, qs.answer_text,
|
|
1880
|
+
qs.cited_domains, qs.competitor_overlap, qs.recommended_competitors,
|
|
1881
|
+
qs.location, qs.screenshot_path, qs.raw_response, qs.created_at
|
|
1882
|
+
FROM query_snapshots qs
|
|
1883
|
+
LEFT JOIN queries q ON q.id = qs.query_id`,
|
|
1884
|
+
`DROP TABLE query_snapshots`,
|
|
1885
|
+
`ALTER TABLE query_snapshots_v58 RENAME TO query_snapshots`,
|
|
1886
|
+
// Recreate the indexes that didn't survive the rename.
|
|
1887
|
+
`CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id)`,
|
|
1888
|
+
`CREATE INDEX IF NOT EXISTS idx_snapshots_query ON query_snapshots(query_id)`,
|
|
1889
|
+
`CREATE INDEX IF NOT EXISTS idx_snapshots_citation_state ON query_snapshots(citation_state)`,
|
|
1890
|
+
`CREATE INDEX IF NOT EXISTS idx_snapshots_provider_model ON query_snapshots(provider, model)`,
|
|
1891
|
+
`CREATE INDEX IF NOT EXISTS idx_snapshots_location ON query_snapshots(location)`,
|
|
1892
|
+
`CREATE INDEX IF NOT EXISTS idx_snapshots_created_at ON query_snapshots(created_at)`
|
|
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
|
+
]
|
|
1807
1943
|
}
|
|
1808
1944
|
];
|
|
1809
1945
|
function isDuplicateColumnError(err) {
|
|
@@ -1987,20 +2123,32 @@ function pickGroupRepresentative(group) {
|
|
|
1987
2123
|
return best;
|
|
1988
2124
|
}
|
|
1989
2125
|
|
|
2126
|
+
// ../db/src/snapshot-helpers.ts
|
|
2127
|
+
function filterTrackedSnapshots(rows) {
|
|
2128
|
+
return rows.filter((r) => r.queryId !== null);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
1990
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
|
+
}
|
|
1991
2136
|
function detectRegressions(currentRun, previousRun) {
|
|
2137
|
+
if ((currentRun.location ?? null) !== (previousRun.location ?? null)) {
|
|
2138
|
+
return [];
|
|
2139
|
+
}
|
|
1992
2140
|
const regressions = [];
|
|
1993
2141
|
const previousCited = /* @__PURE__ */ new Map();
|
|
1994
2142
|
for (const snap of previousRun.snapshots) {
|
|
1995
2143
|
if (snap.cited) {
|
|
1996
|
-
previousCited.set(
|
|
2144
|
+
previousCited.set(snapshotKey(snap), {
|
|
1997
2145
|
citationUrl: snap.citationUrl,
|
|
1998
2146
|
position: snap.position
|
|
1999
2147
|
});
|
|
2000
2148
|
}
|
|
2001
2149
|
}
|
|
2002
2150
|
for (const snap of currentRun.snapshots) {
|
|
2003
|
-
const key =
|
|
2151
|
+
const key = snapshotKey(snap);
|
|
2004
2152
|
if (!snap.cited && previousCited.has(key)) {
|
|
2005
2153
|
const prev = previousCited.get(key);
|
|
2006
2154
|
regressions.push({
|
|
@@ -2017,16 +2165,23 @@ function detectRegressions(currentRun, previousRun) {
|
|
|
2017
2165
|
}
|
|
2018
2166
|
|
|
2019
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
|
+
}
|
|
2020
2172
|
function detectGains(currentRun, previousRun) {
|
|
2173
|
+
if ((currentRun.location ?? null) !== (previousRun.location ?? null)) {
|
|
2174
|
+
return [];
|
|
2175
|
+
}
|
|
2021
2176
|
const gains = [];
|
|
2022
2177
|
const previousCited = /* @__PURE__ */ new Set();
|
|
2023
2178
|
for (const snap of previousRun.snapshots) {
|
|
2024
2179
|
if (snap.cited) {
|
|
2025
|
-
previousCited.add(
|
|
2180
|
+
previousCited.add(snapshotKey2(snap));
|
|
2026
2181
|
}
|
|
2027
2182
|
}
|
|
2028
2183
|
for (const snap of currentRun.snapshots) {
|
|
2029
|
-
const key =
|
|
2184
|
+
const key = snapshotKey2(snap);
|
|
2030
2185
|
if (snap.cited && !previousCited.has(key)) {
|
|
2031
2186
|
gains.push({
|
|
2032
2187
|
query: snap.query,
|
|
@@ -2087,17 +2242,26 @@ function computeHealthTrend(runs2) {
|
|
|
2087
2242
|
|
|
2088
2243
|
// ../intelligence/src/causes.ts
|
|
2089
2244
|
function analyzeCause(regression, currentSnapshots) {
|
|
2090
|
-
const
|
|
2091
|
-
(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
|
|
2092
2247
|
);
|
|
2093
|
-
|
|
2094
|
-
|
|
2248
|
+
const withCompetitor = matchingSnaps.find((s) => s.competitorDomains?.length);
|
|
2249
|
+
if (withCompetitor) {
|
|
2250
|
+
const competitor = withCompetitor.competitorDomains[0];
|
|
2095
2251
|
return {
|
|
2096
2252
|
cause: "competitor_gain",
|
|
2097
2253
|
competitorDomain: competitor,
|
|
2098
2254
|
details: `Competitor ${competitor} now cited for "${regression.query}" on ${regression.provider}`
|
|
2099
2255
|
};
|
|
2100
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
|
+
}
|
|
2101
2265
|
return {
|
|
2102
2266
|
cause: "unknown",
|
|
2103
2267
|
details: `No specific cause identified for loss of "${regression.query}" on ${regression.provider}`
|
|
@@ -2540,10 +2704,13 @@ function buildDrivers(input) {
|
|
|
2540
2704
|
if (input.action === "create" && input.position === null) {
|
|
2541
2705
|
drivers.push("no existing page");
|
|
2542
2706
|
}
|
|
2543
|
-
if (input.position !== null
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
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
|
+
}
|
|
2547
2714
|
}
|
|
2548
2715
|
if (input.action === "add-schema") {
|
|
2549
2716
|
drivers.push("cited by LLMs but lacks structured data");
|
|
@@ -2814,7 +2981,7 @@ function classifyRegressionSeverity(signals) {
|
|
|
2814
2981
|
}
|
|
2815
2982
|
|
|
2816
2983
|
// ../intelligence/src/insight-grouping.ts
|
|
2817
|
-
function groupInsights(insights2, keyFn = (i) =>
|
|
2984
|
+
function groupInsights(insights2, keyFn = (i) => JSON.stringify([i.query, i.provider, i.type])) {
|
|
2818
2985
|
const order = [];
|
|
2819
2986
|
const buckets = /* @__PURE__ */ new Map();
|
|
2820
2987
|
for (const i of insights2) {
|
|
@@ -2846,14 +3013,15 @@ var MIN_BRAND_TOKEN_LENGTH = 3;
|
|
|
2846
3013
|
function compact(value) {
|
|
2847
3014
|
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2848
3015
|
}
|
|
2849
|
-
function buildBrandTokens(canonicalDomain,
|
|
3016
|
+
function buildBrandTokens(canonicalDomain, brandNames = []) {
|
|
2850
3017
|
const seen = /* @__PURE__ */ new Set();
|
|
2851
3018
|
const stem = canonicalDomain.toLowerCase().replace(/\.[a-z]{2,}$/, "");
|
|
2852
3019
|
const stemCompact = compact(stem);
|
|
2853
3020
|
if (stemCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(stemCompact);
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
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);
|
|
2857
3025
|
}
|
|
2858
3026
|
return [...seen];
|
|
2859
3027
|
}
|
|
@@ -3000,7 +3168,7 @@ function extractHostFromUri(uri) {
|
|
|
3000
3168
|
}
|
|
3001
3169
|
|
|
3002
3170
|
// ../intelligence/src/mention-landscape.ts
|
|
3003
|
-
function buildMentionLandscape(snapshots, competitorDomains,
|
|
3171
|
+
function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
|
|
3004
3172
|
let projectMentionCount = 0;
|
|
3005
3173
|
let totalAnswerSnapshots = 0;
|
|
3006
3174
|
const competitorMap = /* @__PURE__ */ new Map();
|
|
@@ -3014,13 +3182,13 @@ function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName,
|
|
|
3014
3182
|
const q = queryLookup.byId.get(snap.queryId);
|
|
3015
3183
|
const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
|
|
3016
3184
|
text2,
|
|
3017
|
-
|
|
3185
|
+
[...projectBrandNames],
|
|
3018
3186
|
[...projectDomains]
|
|
3019
3187
|
);
|
|
3020
3188
|
if (projectMentioned) projectMentionCount++;
|
|
3021
3189
|
for (const competitor of competitorDomains) {
|
|
3022
3190
|
const brand = brandLabelFromDomain(competitor);
|
|
3023
|
-
const mentioned = determineAnswerMentioned(text2, brand, [competitor]);
|
|
3191
|
+
const mentioned = determineAnswerMentioned(text2, brand ? [brand] : [], [competitor]);
|
|
3024
3192
|
if (mentioned) {
|
|
3025
3193
|
const entry = competitorMap.get(competitor);
|
|
3026
3194
|
entry.count++;
|
|
@@ -3388,17 +3556,29 @@ var IntelligenceService = class {
|
|
|
3388
3556
|
log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
|
|
3389
3557
|
return null;
|
|
3390
3558
|
}
|
|
3391
|
-
const currentRun = this.buildRunData(
|
|
3559
|
+
const currentRun = this.buildRunData(
|
|
3560
|
+
runId,
|
|
3561
|
+
projectId,
|
|
3562
|
+
currentRunRecord.finishedAt ?? currentRunRecord.createdAt,
|
|
3563
|
+
currentRunRecord.location ?? null
|
|
3564
|
+
);
|
|
3392
3565
|
if (currentRun.snapshots.length === 0) {
|
|
3393
3566
|
log.info("intelligence.skip", { runId, reason: "no snapshots" });
|
|
3394
3567
|
return null;
|
|
3395
3568
|
}
|
|
3396
3569
|
const orderedRecent = [...recentRuns].reverse();
|
|
3397
|
-
const
|
|
3398
|
-
const
|
|
3399
|
-
const
|
|
3570
|
+
const currentLocation = currentRunRecord.location ?? null;
|
|
3571
|
+
const sameLocationOrdered = orderedRecent.filter((r) => (r.location ?? null) === currentLocation);
|
|
3572
|
+
const currentLocIdx = sameLocationOrdered.findIndex((r) => r.id === runId);
|
|
3573
|
+
const previousRunRecord = currentLocIdx > 0 ? sameLocationOrdered[currentLocIdx - 1] : null;
|
|
3574
|
+
const previousRun = previousRunRecord ? this.buildRunData(
|
|
3575
|
+
previousRunRecord.id,
|
|
3576
|
+
projectId,
|
|
3577
|
+
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
3578
|
+
previousRunRecord.location ?? null
|
|
3579
|
+
) : null;
|
|
3400
3580
|
const trackedCompetitors = this.loadTrackedCompetitors(projectId);
|
|
3401
|
-
const history =
|
|
3581
|
+
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));
|
|
3402
3582
|
if (!previousRun) {
|
|
3403
3583
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
3404
3584
|
log.info("intelligence.analyzed", {
|
|
@@ -3433,13 +3613,23 @@ var IntelligenceService = class {
|
|
|
3433
3613
|
* Used by backfill where we control the run ordering.
|
|
3434
3614
|
*/
|
|
3435
3615
|
analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords) {
|
|
3436
|
-
const currentRun = this.buildRunData(
|
|
3616
|
+
const currentRun = this.buildRunData(
|
|
3617
|
+
runRecord.id,
|
|
3618
|
+
runRecord.projectId,
|
|
3619
|
+
runRecord.finishedAt ?? runRecord.createdAt,
|
|
3620
|
+
runRecord.location ?? null
|
|
3621
|
+
);
|
|
3437
3622
|
if (currentRun.snapshots.length === 0) {
|
|
3438
3623
|
return null;
|
|
3439
3624
|
}
|
|
3440
|
-
const previousRun = previousRunRecord ? this.buildRunData(
|
|
3625
|
+
const previousRun = previousRunRecord ? this.buildRunData(
|
|
3626
|
+
previousRunRecord.id,
|
|
3627
|
+
previousRunRecord.projectId,
|
|
3628
|
+
previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
|
|
3629
|
+
previousRunRecord.location ?? null
|
|
3630
|
+
) : null;
|
|
3441
3631
|
const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
|
|
3442
|
-
const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt));
|
|
3632
|
+
const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
|
|
3443
3633
|
if (!previousRun) {
|
|
3444
3634
|
const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
|
|
3445
3635
|
this.persistResult(this.emptyAnalysisResult(result2), runRecord.id, runRecord.projectId);
|
|
@@ -3483,10 +3673,12 @@ var IntelligenceService = class {
|
|
|
3483
3673
|
let totalInsights = 0;
|
|
3484
3674
|
for (let i = 0; i < targetRuns.length; i++) {
|
|
3485
3675
|
const run = targetRuns[i];
|
|
3486
|
-
const
|
|
3487
|
-
const
|
|
3488
|
-
const
|
|
3489
|
-
const
|
|
3676
|
+
const runLocation = run.location ?? null;
|
|
3677
|
+
const sameLocationRuns = allRuns.filter((r) => (r.location ?? null) === runLocation);
|
|
3678
|
+
const sameLocIdx = sameLocationRuns.indexOf(run);
|
|
3679
|
+
const previousRun = sameLocIdx > 0 ? sameLocationRuns[sameLocIdx - 1] : null;
|
|
3680
|
+
const historyStart = Math.max(0, sameLocIdx - (HISTORY_WINDOW_RUNS - 1));
|
|
3681
|
+
const historyRecords = sameLocationRuns.slice(historyStart, sameLocIdx + 1);
|
|
3490
3682
|
const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords);
|
|
3491
3683
|
if (result) {
|
|
3492
3684
|
processed++;
|
|
@@ -3643,13 +3835,14 @@ var IntelligenceService = class {
|
|
|
3643
3835
|
return { ...insight, severity };
|
|
3644
3836
|
});
|
|
3645
3837
|
}
|
|
3646
|
-
buildRunData(runId, projectId, completedAt) {
|
|
3838
|
+
buildRunData(runId, projectId, completedAt, location = null) {
|
|
3647
3839
|
const rows = this.db.select({
|
|
3648
3840
|
query: queries.query,
|
|
3649
3841
|
provider: querySnapshots.provider,
|
|
3650
3842
|
citationState: querySnapshots.citationState,
|
|
3651
3843
|
citedDomains: querySnapshots.citedDomains,
|
|
3652
|
-
competitorOverlap: querySnapshots.competitorOverlap
|
|
3844
|
+
competitorOverlap: querySnapshots.competitorOverlap,
|
|
3845
|
+
snapshotLocation: querySnapshots.location
|
|
3653
3846
|
}).from(querySnapshots).leftJoin(queries, eq(querySnapshots.queryId, queries.id)).where(eq(querySnapshots.runId, runId)).all();
|
|
3654
3847
|
const snapshots = rows.map((r) => {
|
|
3655
3848
|
const domains = parseJsonColumn(r.citedDomains, []);
|
|
@@ -3659,10 +3852,20 @@ var IntelligenceService = class {
|
|
|
3659
3852
|
provider: r.provider,
|
|
3660
3853
|
cited: r.citationState === CitationStates.cited,
|
|
3661
3854
|
citationUrl: domains[0] ?? void 0,
|
|
3662
|
-
|
|
3855
|
+
// Snapshots carry their own location for downstream detectors. In
|
|
3856
|
+
// practice every snapshot in a single runId shares the run's
|
|
3857
|
+
// location; the per-row column is the same value duplicated, but
|
|
3858
|
+
// we read it from the snapshot row so a stale runs.location can't
|
|
3859
|
+
// mask snapshot truth.
|
|
3860
|
+
location: r.snapshotLocation ?? location ?? null,
|
|
3861
|
+
competitorDomains: competitors2,
|
|
3862
|
+
// citedDomains is the FULL set (tracked competitors + third-party
|
|
3863
|
+
// sources). Cause analysis uses it to name the displacing source
|
|
3864
|
+
// when no tracked competitor appears in the response.
|
|
3865
|
+
citedDomains: domains
|
|
3663
3866
|
};
|
|
3664
3867
|
});
|
|
3665
|
-
return { runId, projectId, completedAt, snapshots };
|
|
3868
|
+
return { runId, projectId, completedAt, location, snapshots };
|
|
3666
3869
|
}
|
|
3667
3870
|
};
|
|
3668
3871
|
|
|
@@ -3707,6 +3910,7 @@ export {
|
|
|
3707
3910
|
migrate,
|
|
3708
3911
|
groupRunsByCreatedAt,
|
|
3709
3912
|
pickGroupRepresentative,
|
|
3913
|
+
filterTrackedSnapshots,
|
|
3710
3914
|
isBlogShapedQuery,
|
|
3711
3915
|
buildInventory,
|
|
3712
3916
|
buildContentTargetRows,
|