@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.
@@ -8,7 +8,7 @@ import {
8
8
  categoryLabel,
9
9
  determineAnswerMentioned,
10
10
  normalizeProjectDomain
11
- } from "./chunk-XW3F5EEW.js";
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
- queryId: text("query_id").notNull().references(() => queries.id, { onDelete: "cascade" }),
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
- projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
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(`${snap.query}:${snap.provider}`, {
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 = `${snap.query}:${snap.provider}`;
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(`${snap.query}:${snap.provider}`);
2180
+ previousCited.add(snapshotKey2(snap));
2026
2181
  }
2027
2182
  }
2028
2183
  for (const snap of currentRun.snapshots) {
2029
- const key = `${snap.query}:${snap.provider}`;
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 currentSnap = currentSnapshots.find(
2091
- (s) => s.query === regression.query && s.provider === regression.provider && !s.cited && s.competitorDomains && s.competitorDomains.length > 0
2245
+ const matchingSnaps = currentSnapshots.filter(
2246
+ (s) => s.query === regression.query && s.provider === regression.provider && !s.cited
2092
2247
  );
2093
- if (currentSnap) {
2094
- const competitor = currentSnap.competitorDomains[0];
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 && input.position > 30) {
2544
- drivers.push(`page ranks #${input.position} (effectively invisible)`);
2545
- } else if (input.position !== null && input.position > 10) {
2546
- drivers.push(`page ranks #${input.position}`);
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) => `${i.query} ${i.provider} ${i.type}`) {
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, displayName) {
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
- if (displayName) {
2855
- const displayCompact = compact(displayName);
2856
- if (displayCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(displayCompact);
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, projectDisplayName, projectDomains, queryLookup) {
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
- projectDisplayName,
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(runId, projectId, currentRunRecord.finishedAt ?? currentRunRecord.createdAt);
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 currentIdx = orderedRecent.findIndex((r) => r.id === runId);
3398
- const previousRunRecord = currentIdx > 0 ? orderedRecent[currentIdx - 1] : null;
3399
- const previousRun = previousRunRecord ? this.buildRunData(previousRunRecord.id, projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
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 = orderedRecent.slice(0, currentIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(r.id, projectId, r.finishedAt ?? r.createdAt));
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(runRecord.id, runRecord.projectId, runRecord.finishedAt ?? runRecord.createdAt);
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(previousRunRecord.id, previousRunRecord.projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
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 globalIdx = allRuns.indexOf(run);
3487
- const previousRun = globalIdx > 0 ? allRuns[globalIdx - 1] : null;
3488
- const historyStart = Math.max(0, globalIdx - (HISTORY_WINDOW_RUNS - 1));
3489
- const historyRecords = allRuns.slice(historyStart, globalIdx + 1);
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
- competitorDomains: competitors2
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,