@ainyc/canonry 4.34.0 → 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/assets/index.html CHANGED
@@ -12,8 +12,8 @@
12
12
  <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
13
13
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
14
14
  <title>Canonry</title>
15
- <script type="module" crossorigin src="./assets/index-47V0U52s.js"></script>
16
- <link rel="stylesheet" crossorigin href="./assets/index-CNKAwZMB.css">
15
+ <script type="module" crossorigin src="./assets/index-u7ZXZ5mA.js"></script>
16
+ <link rel="stylesheet" crossorigin href="./assets/index-Cfv0_lwq.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -22,7 +22,7 @@ import {
22
22
  trafficConnectVercelRequestSchema,
23
23
  trafficConnectWordpressRequestSchema,
24
24
  trafficEventKindSchema
25
- } from "./chunk-XW3F5EEW.js";
25
+ } from "./chunk-EM5GVF3C.js";
26
26
 
27
27
  // src/config.ts
28
28
  import fs from "fs";
@@ -99,8 +99,12 @@ var citationStateSchema = z2.enum(["cited", "not-cited"]);
99
99
  var CitationStates = citationStateSchema.enum;
100
100
  var visibilityStateSchema = z2.enum(["visible", "not-visible"]);
101
101
  var VisibilityStates = visibilityStateSchema.enum;
102
+ var mentionStateSchema = z2.enum(["mentioned", "not-mentioned"]);
103
+ var MentionStates = mentionStateSchema.enum;
102
104
  var computedTransitionSchema = z2.enum(["new", "cited", "lost", "emerging", "not-cited"]);
103
105
  var ComputedTransitions = computedTransitionSchema.enum;
106
+ var mentionTransitionSchema = z2.enum(["new", "mentioned", "lost", "emerging", "not-mentioned"]);
107
+ var MentionTransitions = mentionTransitionSchema.enum;
104
108
  var runTriggerRequestSchema = z2.object({
105
109
  kind: z2.literal(RunKinds["answer-visibility"]).optional(),
106
110
  trigger: z2.literal(RunTriggers.manual).optional(),
@@ -211,7 +215,10 @@ var querySnapshotDtoSchema = z2.object({
211
215
  provider: providerNameSchema,
212
216
  citationState: citationStateSchema,
213
217
  answerMentioned: z2.boolean().optional(),
218
+ /** @deprecated legacy name for `mentionState`; same data, kept for backwards compatibility. */
214
219
  visibilityState: visibilityStateSchema.optional(),
220
+ /** Mention state for this snapshot — see `mentionStateSchema`. Prefer this over the legacy `visibilityState`. */
221
+ mentionState: mentionStateSchema.optional(),
215
222
  transition: computedTransitionSchema.optional(),
216
223
  answerText: z2.string().nullable().optional(),
217
224
  citedDomains: z2.array(z2.string()).default([]),
@@ -235,8 +242,14 @@ var snapshotDiffRowSchema = z2.object({
235
242
  run2State: citationStateSchema.nullable(),
236
243
  run1AnswerMentioned: z2.boolean().nullable(),
237
244
  run2AnswerMentioned: z2.boolean().nullable(),
245
+ /** @deprecated legacy name for `run1MentionState`. */
238
246
  run1VisibilityState: visibilityStateSchema.nullable(),
247
+ /** @deprecated legacy name for `run2MentionState`. */
239
248
  run2VisibilityState: visibilityStateSchema.nullable(),
249
+ /** Mention state in run 1 — prefer this over the legacy `run1VisibilityState`. */
250
+ run1MentionState: mentionStateSchema.nullable().optional(),
251
+ /** Mention state in run 2 — prefer this over the legacy `run2VisibilityState`. */
252
+ run2MentionState: mentionStateSchema.nullable().optional(),
240
253
  changed: z2.boolean(),
241
254
  visibilityChanged: z2.boolean()
242
255
  });
@@ -409,6 +422,7 @@ var projectUpsertRequestSchema = z4.object({
409
422
  displayName: z4.string().min(1),
410
423
  canonicalDomain: z4.string().min(1),
411
424
  ownedDomains: z4.array(z4.string().min(1)).optional(),
425
+ aliases: z4.array(z4.string()).optional(),
412
426
  country: z4.string().length(2),
413
427
  language: z4.string().min(2),
414
428
  tags: z4.array(z4.string()).optional(),
@@ -425,6 +439,7 @@ var projectDtoSchema = z4.object({
425
439
  displayName: z4.string().optional(),
426
440
  canonicalDomain: z4.string(),
427
441
  ownedDomains: z4.array(z4.string()).default([]),
442
+ aliases: z4.array(z4.string()).default([]),
428
443
  country: z4.string().length(2),
429
444
  language: z4.string().min(2),
430
445
  tags: z4.array(z4.string()).default([]),
@@ -548,6 +563,31 @@ function effectiveDomains(project) {
548
563
  }
549
564
  return result;
550
565
  }
566
+ function normalizeProjectAliases(displayName, aliases) {
567
+ if (!aliases || aliases.length === 0) return [];
568
+ const displayKey = displayName?.trim().toLowerCase() ?? "";
569
+ const seen = /* @__PURE__ */ new Set();
570
+ const result = [];
571
+ for (const raw of aliases) {
572
+ const trimmed = raw.trim();
573
+ if (!trimmed) continue;
574
+ const key = trimmed.toLowerCase();
575
+ if (key === displayKey) continue;
576
+ if (seen.has(key)) continue;
577
+ seen.add(key);
578
+ result.push(trimmed);
579
+ }
580
+ return result;
581
+ }
582
+ function effectiveBrandNames(project) {
583
+ const names = [];
584
+ const display = project.displayName?.trim() ?? "";
585
+ if (display) names.push(display);
586
+ for (const alias of normalizeProjectAliases(project.displayName, project.aliases)) {
587
+ names.push(alias);
588
+ }
589
+ return names;
590
+ }
551
591
 
552
592
  // ../contracts/src/config-schema.ts
553
593
  var configMetadataSchema = z5.object({
@@ -584,6 +624,7 @@ var configSpecSchema = z5.object({
584
624
  displayName: z5.string().min(1),
585
625
  canonicalDomain: z5.string().min(1),
586
626
  ownedDomains: z5.array(z5.string().min(1)).optional().default([]),
627
+ aliases: z5.array(z5.string().min(1)).optional().default([]),
587
628
  country: z5.string().length(2),
588
629
  language: z5.string().min(2),
589
630
  queries: configQueryListSchema.optional(),
@@ -1407,7 +1448,7 @@ var BUSINESS_SUFFIXES = [
1407
1448
  "inc",
1408
1449
  "ltd"
1409
1450
  ];
1410
- function extractAnswerMentions(answerText, displayName, domains) {
1451
+ function extractAnswerMentions(answerText, brandNames, domains) {
1411
1452
  if (!answerText) return { mentioned: false, matchedTerms: [] };
1412
1453
  const matchedTerms = [];
1413
1454
  const lowerAnswer = answerText.toLowerCase();
@@ -1420,18 +1461,21 @@ function extractAnswerMentions(answerText, displayName, domains) {
1420
1461
  }
1421
1462
  const answerNormalized = normalizeText(answerText);
1422
1463
  const answerBrandKey = brandKeyFromText(answerText);
1423
- const normalizedCandidates = brandNormalizedCandidates(displayName);
1424
- const brandKeyCandidates = brandKeyCandidatesForMatch(displayName);
1425
- const matchesNormalized = normalizedCandidates.some(
1426
- (c) => new RegExp(`\\b${escapeRegExp(c)}\\b`).test(answerNormalized)
1427
- );
1428
- const matchesBrandKey = brandKeyCandidates.some(
1429
- (c) => c.length >= MIN_BRAND_KEY_LENGTH && answerBrandKey.includes(c)
1430
- );
1431
- if (matchesNormalized || matchesBrandKey) {
1432
- matchedTerms.push(displayName);
1464
+ for (const brandName of brandNames) {
1465
+ if (!brandName || !brandName.trim()) continue;
1466
+ const normalizedCandidates = brandNormalizedCandidates(brandName);
1467
+ const brandKeyCandidates = brandKeyCandidatesForMatch(brandName);
1468
+ const matchesNormalized = normalizedCandidates.some(
1469
+ (c) => new RegExp(`\\b${escapeRegExp(c)}\\b`).test(answerNormalized)
1470
+ );
1471
+ const matchesBrandKey = brandKeyCandidates.some(
1472
+ (c) => c.length >= MIN_BRAND_KEY_LENGTH && answerBrandKey.includes(c)
1473
+ );
1474
+ if (matchesNormalized || matchesBrandKey) {
1475
+ matchedTerms.push(brandName);
1476
+ }
1433
1477
  }
1434
- const brandTokens = collectBrandTokens(displayName, domains);
1478
+ const brandTokens = collectBrandTokens(brandNames, domains);
1435
1479
  const allTokens = [...brandTokens.primary, ...brandTokens.secondary];
1436
1480
  const secondarySet = new Set(brandTokens.secondary);
1437
1481
  let tokenMatches = 0;
@@ -1454,12 +1498,15 @@ function extractAnswerMentions(answerText, displayName, domains) {
1454
1498
  });
1455
1499
  return { mentioned: dedupedFinal.length > 0, matchedTerms: dedupedFinal };
1456
1500
  }
1457
- function determineAnswerMentioned(answerText, displayName, domains) {
1458
- return extractAnswerMentions(answerText, displayName, domains).mentioned;
1501
+ function determineAnswerMentioned(answerText, brandNames, domains) {
1502
+ return extractAnswerMentions(answerText, brandNames, domains).mentioned;
1459
1503
  }
1460
1504
  function visibilityStateFromAnswerMentioned(answerMentioned) {
1461
1505
  return answerMentioned ? "visible" : "not-visible";
1462
1506
  }
1507
+ function mentionStateFromAnswerMentioned(answerMentioned) {
1508
+ return answerMentioned ? "mentioned" : "not-mentioned";
1509
+ }
1463
1510
  function brandKeyFromText(value) {
1464
1511
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
1465
1512
  }
@@ -1472,14 +1519,17 @@ function domainMentioned(lowerAnswer, normalizedDomain) {
1472
1519
  ];
1473
1520
  return patterns.some((pattern) => pattern.test(lowerAnswer));
1474
1521
  }
1475
- function collectBrandTokens(displayName, domains) {
1522
+ function collectBrandTokens(brandNames, domains) {
1476
1523
  const primary = /* @__PURE__ */ new Set();
1477
1524
  const secondary = /* @__PURE__ */ new Set();
1478
- const distinctiveWords = extractDistinctiveTokens(displayName);
1479
- if (distinctiveWords.length > 0) {
1480
- primary.add(distinctiveWords[0]);
1481
- for (let i = 1; i < distinctiveWords.length; i++) {
1482
- secondary.add(distinctiveWords[i]);
1525
+ for (const brandName of brandNames) {
1526
+ if (!brandName || !brandName.trim()) continue;
1527
+ const distinctiveWords = extractDistinctiveTokens(brandName);
1528
+ if (distinctiveWords.length > 0) {
1529
+ primary.add(distinctiveWords[0]);
1530
+ for (let i = 1; i < distinctiveWords.length; i++) {
1531
+ secondary.add(distinctiveWords[i]);
1532
+ }
1483
1533
  }
1484
1534
  }
1485
1535
  for (const domain of domains) {
@@ -2671,6 +2721,8 @@ export {
2671
2721
  registrableDomain,
2672
2722
  brandLabelFromDomain,
2673
2723
  effectiveDomains,
2724
+ normalizeProjectAliases,
2725
+ effectiveBrandNames,
2674
2726
  projectConfigSchema,
2675
2727
  resolveConfigSpecQueries,
2676
2728
  wordpressEnvSchema,
@@ -2700,6 +2752,7 @@ export {
2700
2752
  extractAnswerMentions,
2701
2753
  determineAnswerMentioned,
2702
2754
  visibilityStateFromAnswerMentioned,
2755
+ mentionStateFromAnswerMentioned,
2703
2756
  brandKeyFromText,
2704
2757
  MemorySources,
2705
2758
  AGENT_MEMORY_VALUE_MAX_BYTES,
@@ -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("[]"),
@@ -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
- 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" }),
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(`${snap.query}:${snap.provider}`, {
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 = `${snap.query}:${snap.provider}`;
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(`${snap.query}:${snap.provider}`);
2180
+ previousCited.add(snapshotKey2(snap));
2112
2181
  }
2113
2182
  }
2114
2183
  for (const snap of currentRun.snapshots) {
2115
- const key = `${snap.query}:${snap.provider}`;
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 currentSnap = currentSnapshots.find(
2177
- (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
2178
2247
  );
2179
- if (currentSnap) {
2180
- const competitor = currentSnap.competitorDomains[0];
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 && input.position > 30) {
2630
- drivers.push(`page ranks #${input.position} (effectively invisible)`);
2631
- } else if (input.position !== null && input.position > 10) {
2632
- 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
+ }
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) => `${i.query} ${i.provider} ${i.type}`) {
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, displayName) {
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
- if (displayName) {
2941
- const displayCompact = compact(displayName);
2942
- 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);
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, projectDisplayName, projectDomains, queryLookup) {
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
- projectDisplayName,
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++;
@@ -3474,17 +3556,29 @@ var IntelligenceService = class {
3474
3556
  log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
3475
3557
  return null;
3476
3558
  }
3477
- 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
+ );
3478
3565
  if (currentRun.snapshots.length === 0) {
3479
3566
  log.info("intelligence.skip", { runId, reason: "no snapshots" });
3480
3567
  return null;
3481
3568
  }
3482
3569
  const orderedRecent = [...recentRuns].reverse();
3483
- const currentIdx = orderedRecent.findIndex((r) => r.id === runId);
3484
- const previousRunRecord = currentIdx > 0 ? orderedRecent[currentIdx - 1] : null;
3485
- 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;
3486
3580
  const trackedCompetitors = this.loadTrackedCompetitors(projectId);
3487
- 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));
3488
3582
  if (!previousRun) {
3489
3583
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
3490
3584
  log.info("intelligence.analyzed", {
@@ -3519,13 +3613,23 @@ var IntelligenceService = class {
3519
3613
  * Used by backfill where we control the run ordering.
3520
3614
  */
3521
3615
  analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords) {
3522
- 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
+ );
3523
3622
  if (currentRun.snapshots.length === 0) {
3524
3623
  return null;
3525
3624
  }
3526
- 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;
3527
3631
  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));
3632
+ const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
3529
3633
  if (!previousRun) {
3530
3634
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
3531
3635
  this.persistResult(this.emptyAnalysisResult(result2), runRecord.id, runRecord.projectId);
@@ -3569,10 +3673,12 @@ var IntelligenceService = class {
3569
3673
  let totalInsights = 0;
3570
3674
  for (let i = 0; i < targetRuns.length; i++) {
3571
3675
  const run = targetRuns[i];
3572
- const globalIdx = allRuns.indexOf(run);
3573
- const previousRun = globalIdx > 0 ? allRuns[globalIdx - 1] : null;
3574
- const historyStart = Math.max(0, globalIdx - (HISTORY_WINDOW_RUNS - 1));
3575
- 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);
3576
3682
  const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords);
3577
3683
  if (result) {
3578
3684
  processed++;
@@ -3729,13 +3835,14 @@ var IntelligenceService = class {
3729
3835
  return { ...insight, severity };
3730
3836
  });
3731
3837
  }
3732
- buildRunData(runId, projectId, completedAt) {
3838
+ buildRunData(runId, projectId, completedAt, location = null) {
3733
3839
  const rows = this.db.select({
3734
3840
  query: queries.query,
3735
3841
  provider: querySnapshots.provider,
3736
3842
  citationState: querySnapshots.citationState,
3737
3843
  citedDomains: querySnapshots.citedDomains,
3738
- competitorOverlap: querySnapshots.competitorOverlap
3844
+ competitorOverlap: querySnapshots.competitorOverlap,
3845
+ snapshotLocation: querySnapshots.location
3739
3846
  }).from(querySnapshots).leftJoin(queries, eq(querySnapshots.queryId, queries.id)).where(eq(querySnapshots.runId, runId)).all();
3740
3847
  const snapshots = rows.map((r) => {
3741
3848
  const domains = parseJsonColumn(r.citedDomains, []);
@@ -3745,10 +3852,20 @@ var IntelligenceService = class {
3745
3852
  provider: r.provider,
3746
3853
  cited: r.citationState === CitationStates.cited,
3747
3854
  citationUrl: domains[0] ?? void 0,
3748
- 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
3749
3866
  };
3750
3867
  });
3751
- return { runId, projectId, completedAt, snapshots };
3868
+ return { runId, projectId, completedAt, location, snapshots };
3752
3869
  }
3753
3870
  };
3754
3871