@canonry/canonry 4.172.1 → 4.172.2

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.
Files changed (31) hide show
  1. package/assets/agent-workspace/skills/aero/references/reporting.md +8 -4
  2. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +1 -1
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +4 -3
  4. package/assets/assets/{AuditHistoryPanel-BO_TSFPx.js → AuditHistoryPanel-DnqySUw3.js} +1 -1
  5. package/assets/assets/{BacklinksPage-CNrzKbiR.js → BacklinksPage-CbcHWD-G.js} +1 -1
  6. package/assets/assets/{HistoryPage-CvzLzfj3.js → HistoryPage-BYFSyxuB.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-CUdAqft3.js → MeasurementPropertyPage-BBnXQPIv.js} +1 -1
  8. package/assets/assets/ProjectPage-Cq5bHpmk.js +9 -0
  9. package/assets/assets/{RunRow-BLZsQTiY.js → RunRow-lfyHktTf.js} +1 -1
  10. package/assets/assets/{RunsPage-CTuTDUcn.js → RunsPage-ngnP0Y_D.js} +1 -1
  11. package/assets/assets/{SettingsPage-BY-nM5uj.js → SettingsPage-DEg74T20.js} +1 -1
  12. package/assets/assets/{SiteHealthSection-br9-ha8G.js → SiteHealthSection-C6ycXwbt.js} +3 -3
  13. package/assets/assets/{TrafficPage-BgX1QlXy.js → TrafficPage-hrJ1IDqA.js} +1 -1
  14. package/assets/assets/{TrafficSourceDetailPage-D5bsBc6A.js → TrafficSourceDetailPage-fu7nPl3E.js} +1 -1
  15. package/assets/assets/{extract-error-message-BywQFP69.js → extract-error-message-dcdO9njP.js} +1 -1
  16. package/assets/assets/index-D1mXm81Z.css +1 -0
  17. package/assets/assets/{index-BpHZirJJ.js → index-DpQ_-QC_.js} +27 -27
  18. package/assets/assets/{react-sigma_core.esm.min-Do4rkEBs.js → react-sigma_core.esm.min-DZGxGBSe.js} +1 -1
  19. package/assets/index.html +2 -2
  20. package/dist/{chunk-X63CLHYM.js → chunk-EGGQSFQL.js} +4748 -4679
  21. package/dist/{chunk-3XN226N6.js → chunk-PXTSQ67Z.js} +651 -243
  22. package/dist/{chunk-IAX4WRAR.js → chunk-TI3SZAVI.js} +4 -4
  23. package/dist/{chunk-7L4453BU.js → chunk-U5JTZM66.js} +2 -2
  24. package/dist/{chunk-5MRE2TXR.js → chunk-YXDCVM5U.js} +8 -5
  25. package/dist/cli.js +36 -14
  26. package/dist/index.js +4 -4
  27. package/dist/{intelligence-service-FSHZXSGK.js → intelligence-service-H7756PBD.js} +2 -2
  28. package/dist/mcp.js +3 -3
  29. package/package.json +13 -13
  30. package/assets/assets/ProjectPage-xbRWsne7.js +0 -9
  31. package/assets/assets/index-BsQGKWA6.css +0 -1
@@ -231,6 +231,7 @@ import {
231
231
  compileBrandAliases,
232
232
  compileMeasurementPlan,
233
233
  compileMeasurementPlanPreview,
234
+ compileQueryClassifier,
234
235
  computeDedupSimilarityStats,
235
236
  contentActionLabel,
236
237
  contentGapsResponseDtoSchema,
@@ -277,6 +278,7 @@ import {
277
278
  escapeLikePattern,
278
279
  evaluateModelPointerExposure,
279
280
  extractAnswerMentions,
281
+ extractDomainsFromText,
280
282
  factorStatusFromScore,
281
283
  filterBrandedSeedCandidates,
282
284
  filterCapturedCitedUrls,
@@ -372,6 +374,7 @@ import {
372
374
  locationContextSchema,
373
375
  loginRequestSchema,
374
376
  mapWithConcurrency,
377
+ matchedAliasKeys,
375
378
  matcherMatchesText,
376
379
  measurementChangesQuerySchema,
377
380
  measurementChangesResponseSchema,
@@ -625,7 +628,7 @@ import {
625
628
  wordpressSchemaDeployResultDtoSchema,
626
629
  wordpressSchemaStatusResultDtoSchema,
627
630
  wordpressStatusDtoSchema
628
- } from "./chunk-X63CLHYM.js";
631
+ } from "./chunk-EGGQSFQL.js";
629
632
 
630
633
  // src/intelligence-service.ts
631
634
  import { eq as eq60, desc as desc27, asc as asc11, and as and48, ne as ne8, or as or14, inArray as inArray21, gte as gte14, lte as lte12 } from "drizzle-orm";
@@ -1020,6 +1023,21 @@ var querySnapshots = sqliteTable("query_snapshots", {
1020
1023
  // observation predates the field, NOT that retrieval did not happen.
1021
1024
  retrievalStatus: text("retrieval_status").$type(),
1022
1025
  retrievalContract: text("retrieval_contract").$type(),
1026
+ /**
1027
+ * LEGACY MIXED SIGNAL — never read this for a mention or a citation metric.
1028
+ *
1029
+ * The run writer unions three different things into it: cited domains,
1030
+ * grounding-source hosts, and answer-text brand matches. A count taken from it
1031
+ * is therefore neither the citation signal nor the mention signal, and naming
1032
+ * it either one reports a number for one signal under the other's name (see
1033
+ * AGENTS.md "Vocabulary"). It is also frozen at run time against the competitor
1034
+ * set and the alias threshold that existed then, so it drifts from any
1035
+ * read-time recomputation as soon as either changes.
1036
+ *
1037
+ * For citations read `citedDomains`. For mentions match the project/competitor
1038
+ * brand aliases against `answerText` with `packages/contracts/src/brand-matching.ts`.
1039
+ * The column is retained for the raw results export and historical inspection.
1040
+ */
1023
1041
  competitorOverlap: text("competitor_overlap", { mode: "json" }).$type().notNull().default([]),
1024
1042
  recommendedCompetitors: text("recommended_competitors", { mode: "json" }).$type().notNull().default([]),
1025
1043
  location: text("location"),
@@ -7248,9 +7266,9 @@ function analyzeCause(regression, currentSnapshots) {
7248
7266
  const matchingSnaps = currentSnapshots.filter(
7249
7267
  (s) => s.query === regression.query && s.provider === regression.provider && !s.cited
7250
7268
  );
7251
- const withCompetitor = matchingSnaps.find((s) => s.competitorDomains?.length);
7269
+ const withCompetitor = matchingSnaps.find((s) => s.citedCompetitorDomains?.length);
7252
7270
  if (withCompetitor) {
7253
- const competitor = withCompetitor.competitorDomains[0];
7271
+ const competitor = withCompetitor.citedCompetitorDomains[0];
7254
7272
  return {
7255
7273
  cause: "competitor_gain",
7256
7274
  competitorDomain: competitor,
@@ -7488,8 +7506,8 @@ function detectPersistentGaps(runs2, threshold = PERSISTENT_GAP_THRESHOLD) {
7488
7506
  function buildCompetitorQueryMap(run, tracked) {
7489
7507
  const result = /* @__PURE__ */ new Map();
7490
7508
  for (const snap of run.snapshots) {
7491
- if (!snap.query || !snap.competitorDomains || snap.competitorDomains.length === 0) continue;
7492
- for (const domain of snap.competitorDomains) {
7509
+ if (!snap.query || !snap.citedCompetitorDomains || snap.citedCompetitorDomains.length === 0) continue;
7510
+ for (const domain of snap.citedCompetitorDomains) {
7493
7511
  if (!tracked.has(domain)) continue;
7494
7512
  const existing = result.get(domain) ?? /* @__PURE__ */ new Set();
7495
7513
  existing.add(snap.query);
@@ -7754,6 +7772,7 @@ function calculateActionConfidence(input) {
7754
7772
  function buildContentTargetRows(input) {
7755
7773
  const rows = [];
7756
7774
  for (const cq of input.candidateQueries) {
7775
+ const competitiveDomains = competitivePresenceDomains(cq);
7757
7776
  const ourPage = resolveOurPage(cq, input.inventory);
7758
7777
  const ourPageInGroundingSources = cq.ourCitedInLatestRun;
7759
7778
  const ourPageHasSchema = ourPage ? input.wpSchemaAudit.get(ourPage.url) ?? null : null;
@@ -7764,7 +7783,7 @@ function buildContentTargetRows(input) {
7764
7783
  });
7765
7784
  if (!action) continue;
7766
7785
  const hasGsc = cq.gscImpressions > 0;
7767
- const hasCompetitor = cq.competitorDomains.length > 0;
7786
+ const hasCompetitor = competitiveDomains.length > 0;
7768
7787
  if (!hasGsc && !hasCompetitor && !cq.ourCitedInLatestRun) continue;
7769
7788
  const aiReferralFactor = computeAiReferralFactor(
7770
7789
  input.totalAiReferralSessions,
@@ -7773,7 +7792,7 @@ function buildContentTargetRows(input) {
7773
7792
  const scoring = scoreContentTarget({
7774
7793
  gscImpressions: cq.gscImpressions,
7775
7794
  aiReferralFactor,
7776
- competitorCount: cq.competitorDomains.length,
7795
+ competitorCount: competitiveDomains.length,
7777
7796
  recentMissRate: cq.recentMissRate,
7778
7797
  citationCount: cq.competitorCitationCount,
7779
7798
  ourCitedRate: cq.ourCitedRate,
@@ -7784,7 +7803,7 @@ function buildContentTargetRows(input) {
7784
7803
  hasGsc: cq.gscPage !== null,
7785
7804
  gscImpressions: cq.gscImpressions,
7786
7805
  runsOfHistory: cq.runsOfHistory,
7787
- hasCompetitorEvidence: cq.competitorDomains.length > 0,
7806
+ hasCompetitorEvidence: competitiveDomains.length > 0,
7788
7807
  hasInventoryMatch: ourPage?.source === "inventory"
7789
7808
  });
7790
7809
  const targetRef = computeTargetRef({
@@ -7855,12 +7874,12 @@ function buildContentSourceRows(input) {
7855
7874
  function buildContentGapRows(input) {
7856
7875
  const gaps = [];
7857
7876
  for (const cq of input.candidateQueries) {
7858
- if (cq.competitorDomains.length === 0) continue;
7877
+ if (cq.competitorCitedDomains.length === 0) continue;
7859
7878
  if (cq.ourCitedRate >= 1) continue;
7860
7879
  gaps.push({
7861
7880
  query: cq.query,
7862
- competitorDomains: cq.competitorDomains,
7863
- competitorCount: cq.competitorDomains.length,
7881
+ competitorDomains: cq.competitorCitedDomains,
7882
+ competitorCount: cq.competitorCitedDomains.length,
7864
7883
  missRate: clamp012(cq.recentMissRate),
7865
7884
  lastSeenInRunId: input.latestRunId
7866
7885
  });
@@ -7870,6 +7889,12 @@ function buildContentGapRows(input) {
7870
7889
  return b.competitorCount - a.competitorCount;
7871
7890
  });
7872
7891
  }
7892
+ function competitivePresenceDomains(cq) {
7893
+ return [.../* @__PURE__ */ new Set([
7894
+ ...cq.competitorCitedDomains,
7895
+ ...cq.competitorMentionedDomains
7896
+ ])];
7897
+ }
7873
7898
  function resolveOurPage(cq, inventory) {
7874
7899
  if (cq.gscPage && cq.gscPosition !== null) {
7875
7900
  return { url: cq.gscPage, position: cq.gscPosition, source: "gsc" };
@@ -8107,6 +8132,46 @@ function citedDomainBelongsToProject(citedDomain, projectDomains) {
8107
8132
  return hostMatchesAnyDomain(citedDomain, projectDomains);
8108
8133
  }
8109
8134
 
8135
+ // ../intelligence/src/competitive-signals.ts
8136
+ function compileCompetitiveSignalResolver(competitorDomains) {
8137
+ const identities = [];
8138
+ const seen = /* @__PURE__ */ new Set();
8139
+ for (const candidate of competitorDomains) {
8140
+ const domain = hostOf(candidate);
8141
+ if (!domain || seen.has(domain)) continue;
8142
+ seen.add(domain);
8143
+ const domainBrandKey = brandKeyFromText(brandLabelFromDomain(domain));
8144
+ identities.push({
8145
+ domain,
8146
+ domainBrandKey: domainBrandKey.length >= MIN_DOMAIN_BRAND_KEY_LENGTH ? domainBrandKey : null
8147
+ });
8148
+ }
8149
+ const domainBrandMatcher = compileBrandAliases(
8150
+ identities.filter((identity) => identity.domainBrandKey !== null).map((identity) => brandLabelFromDomain(identity.domain))
8151
+ );
8152
+ return {
8153
+ resolve(evidence) {
8154
+ const citationCandidates = [
8155
+ ...evidence.citedDomains ?? [],
8156
+ ...(evidence.groundingSources ?? []).map((source) => source.uri)
8157
+ ];
8158
+ const answerDomains = extractDomainsFromText(evidence.answerText);
8159
+ const mentionedBrandKeys = matchedAliasKeys(domainBrandMatcher, evidence.answerText);
8160
+ const citedCompetitorDomains = [];
8161
+ const mentionedCompetitorDomains = [];
8162
+ for (const identity of identities) {
8163
+ if (citationCandidates.some((candidate) => hostMatchesDomain(candidate, identity.domain))) {
8164
+ citedCompetitorDomains.push(identity.domain);
8165
+ }
8166
+ if (answerDomains.some((candidate) => hostMatchesDomain(candidate, identity.domain)) || identity.domainBrandKey !== null && mentionedBrandKeys.has(identity.domainBrandKey)) {
8167
+ mentionedCompetitorDomains.push(identity.domain);
8168
+ }
8169
+ }
8170
+ return { citedCompetitorDomains, mentionedCompetitorDomains };
8171
+ }
8172
+ };
8173
+ }
8174
+
8110
8175
  // ../intelligence/src/competitor-landscape.ts
8111
8176
  function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
8112
8177
  let projectCitationCount = 0;
@@ -8114,14 +8179,22 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
8114
8179
  for (const c of competitorDomains) {
8115
8180
  competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
8116
8181
  }
8182
+ const competitorResolver = compileCompetitiveSignalResolver(competitorDomains);
8183
+ const projectResolver = compileCompetitiveSignalResolver(projectDomains);
8117
8184
  for (const snap of snapshots) {
8118
8185
  const q = queryLookup.byId.get(snap.queryId);
8119
- const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
8120
- if (allDomains.some((d) => citedDomainBelongsToProject(d, projectDomains))) {
8186
+ const evidence = {
8187
+ citedDomains: snap.citedDomains,
8188
+ groundingSources: snap.groundingSources
8189
+ };
8190
+ if (projectResolver.resolve(evidence).citedCompetitorDomains.length > 0) {
8121
8191
  projectCitationCount++;
8122
8192
  }
8193
+ const citedCompetitors = new Set(
8194
+ competitorResolver.resolve(evidence).citedCompetitorDomains
8195
+ );
8123
8196
  for (const competitor of competitorDomains) {
8124
- if (allDomains.some((d) => citedDomainBelongsToProject(d, [competitor]))) {
8197
+ if (citedCompetitors.has(competitor)) {
8125
8198
  const entry = competitorMap.get(competitor);
8126
8199
  entry.count++;
8127
8200
  if (q) entry.queries.add(q);
@@ -8163,55 +8236,84 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
8163
8236
  }
8164
8237
 
8165
8238
  // ../intelligence/src/mention-landscape.ts
8239
+ function emptySection(competitorDomains) {
8240
+ return {
8241
+ projectMentionCount: 0,
8242
+ totalAnswerSnapshots: 0,
8243
+ competitorMap: new Map(competitorDomains.map((c) => [c, { count: 0, queries: /* @__PURE__ */ new Set() }]))
8244
+ };
8245
+ }
8246
+ function toSection(tally) {
8247
+ const totalMentionedSlots = tally.projectMentionCount + [...tally.competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
8248
+ const competitorRows = [...tally.competitorMap.entries()].map(([domain, data]) => {
8249
+ const ratio = tally.totalAnswerSnapshots > 0 ? data.count / tally.totalAnswerSnapshots : 0;
8250
+ let pressureLabel = "None";
8251
+ if (data.count > 0) {
8252
+ if (ratio >= 0.5) pressureLabel = "High";
8253
+ else if (ratio >= 0.2) pressureLabel = "Moderate";
8254
+ else pressureLabel = "Low";
8255
+ }
8256
+ const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : null;
8257
+ return {
8258
+ domain,
8259
+ mentionCount: data.count,
8260
+ totalCount: tally.totalAnswerSnapshots,
8261
+ pressureLabel,
8262
+ mentionedQueries: [...data.queries].sort(),
8263
+ sharePct
8264
+ };
8265
+ });
8266
+ competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
8267
+ return {
8268
+ projectMentionCount: tally.projectMentionCount,
8269
+ totalAnswerSnapshots: tally.totalAnswerSnapshots,
8270
+ competitors: competitorRows
8271
+ };
8272
+ }
8166
8273
  function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
8167
- let projectMentionCount = 0;
8168
- let totalAnswerSnapshots = 0;
8169
- const competitorMap = /* @__PURE__ */ new Map();
8170
- for (const c of competitorDomains) {
8171
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set() });
8172
- }
8274
+ const classifier = compileQueryClassifier(projectBrandNames);
8275
+ const nonBrand = emptySection(competitorDomains);
8276
+ const branded = emptySection(competitorDomains);
8277
+ const unclassified = emptySection(competitorDomains);
8278
+ const competitorAliases = new Map(
8279
+ competitorDomains.map((domain) => {
8280
+ const label = brandLabelFromDomain(domain);
8281
+ return [domain, brandKeyFromText(label).length >= MIN_DOMAIN_BRAND_KEY_LENGTH ? [label] : []];
8282
+ })
8283
+ );
8173
8284
  for (const snap of snapshots) {
8174
8285
  const text2 = snap.answerText;
8175
8286
  if (!text2) continue;
8176
- totalAnswerSnapshots++;
8177
8287
  const q = queryLookup.byId.get(snap.queryId);
8178
- const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
8288
+ const queryClass = classifier ? classifier.classify(q ?? null) : null;
8289
+ const tally = queryClass === "branded" ? branded : queryClass === "non-brand" ? nonBrand : unclassified;
8290
+ tally.totalAnswerSnapshots++;
8291
+ const projectMentioned = determineAnswerMentioned(
8179
8292
  text2,
8180
8293
  [...projectBrandNames],
8181
8294
  [...projectDomains]
8182
8295
  );
8183
- if (projectMentioned) projectMentionCount++;
8296
+ if (projectMentioned) tally.projectMentionCount++;
8184
8297
  for (const competitor of competitorDomains) {
8185
- const brand = brandLabelFromDomain(competitor);
8186
- const mentioned = determineAnswerMentioned(text2, brand ? [brand] : [], [competitor]);
8298
+ const aliases = competitorAliases.get(competitor) ?? [];
8299
+ const mentioned = determineAnswerMentioned(text2, aliases, [competitor]);
8187
8300
  if (mentioned) {
8188
- const entry = competitorMap.get(competitor);
8301
+ const entry = tally.competitorMap.get(competitor);
8189
8302
  entry.count++;
8190
8303
  if (q) entry.queries.add(q);
8191
8304
  }
8192
8305
  }
8193
8306
  }
8194
- const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
8195
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
8196
- const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
8197
- let pressureLabel = "None";
8198
- if (data.count > 0) {
8199
- if (ratio >= 0.5) pressureLabel = "High";
8200
- else if (ratio >= 0.2) pressureLabel = "Moderate";
8201
- else pressureLabel = "Low";
8202
- }
8203
- const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
8204
- return {
8205
- domain,
8206
- mentionCount: data.count,
8207
- totalCount: totalAnswerSnapshots,
8208
- pressureLabel,
8209
- mentionedQueries: [...data.queries].sort(),
8210
- sharePct
8211
- };
8212
- });
8213
- competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
8214
- return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
8307
+ const scope = classifier ? "non-brand" : "pooled";
8308
+ const headline = toSection(scope === "non-brand" ? nonBrand : unclassified);
8309
+ return {
8310
+ // Kept at the top level so every existing reader of `projectMentionCount` /
8311
+ // `competitors` gets the competitive figure rather than the pooled one.
8312
+ ...headline,
8313
+ scope,
8314
+ nonBrand: headline,
8315
+ branded: toSection(branded)
8316
+ };
8215
8317
  }
8216
8318
 
8217
8319
  // ../intelligence/src/ai-source-origin.ts
@@ -8504,14 +8606,14 @@ function buildGapQueryScore(snapshots) {
8504
8606
  const byQuery = /* @__PURE__ */ new Map();
8505
8607
  for (const snap of snapshots) {
8506
8608
  const key = snap.queryId;
8507
- const current = byQuery.get(key) ?? { cited: false, competitorOverlap: /* @__PURE__ */ new Set() };
8609
+ const current = byQuery.get(key) ?? { cited: false, citedCompetitors: /* @__PURE__ */ new Set() };
8508
8610
  if (snap.citationState === CitationStates.cited) current.cited = true;
8509
- for (const domain of snap.competitorOverlap) current.competitorOverlap.add(domain);
8611
+ for (const domain of snap.citedCompetitorDomains) current.citedCompetitors.add(domain);
8510
8612
  byQuery.set(key, current);
8511
8613
  }
8512
8614
  const totalCount = byQuery.size;
8513
8615
  const gapCount = [...byQuery.values()].filter(
8514
- (entry) => !entry.cited && entry.competitorOverlap.size > 0
8616
+ (entry) => !entry.cited && entry.citedCompetitors.size > 0
8515
8617
  ).length;
8516
8618
  const gapQueryLabel = gapCount === 1 ? "query" : "queries";
8517
8619
  return {
@@ -8541,14 +8643,14 @@ function buildMentionGapScore(snapshots) {
8541
8643
  const byQuery = /* @__PURE__ */ new Map();
8542
8644
  for (const snap of snapshots) {
8543
8645
  const key = snap.queryId;
8544
- const current = byQuery.get(key) ?? { mentioned: false, competitorOverlap: /* @__PURE__ */ new Set() };
8646
+ const current = byQuery.get(key) ?? { mentioned: false, mentionedCompetitors: /* @__PURE__ */ new Set() };
8545
8647
  if (snap.answerMentioned === true) current.mentioned = true;
8546
- for (const domain of snap.competitorOverlap) current.competitorOverlap.add(domain);
8648
+ for (const domain of snap.mentionedCompetitorDomains) current.mentionedCompetitors.add(domain);
8547
8649
  byQuery.set(key, current);
8548
8650
  }
8549
8651
  const totalCount = byQuery.size;
8550
8652
  const gapCount = [...byQuery.values()].filter(
8551
- (entry) => !entry.mentioned && entry.competitorOverlap.size > 0
8653
+ (entry) => !entry.mentioned && entry.mentionedCompetitors.size > 0
8552
8654
  ).length;
8553
8655
  const gapQueryLabel = gapCount === 1 ? "query" : "queries";
8554
8656
  return {
@@ -8580,7 +8682,7 @@ function buildCompetitorPressureScore(snapshots, competitorDomains, totalTracked
8580
8682
  }
8581
8683
  let overlapCount = 0;
8582
8684
  for (const snap of snapshots) {
8583
- if (snap.competitorOverlap.some((domain) => hostMatchesAnyDomain(domain, competitorDomains))) {
8685
+ if (snap.citedDomains.some((domain) => hostMatchesAnyDomain(domain, competitorDomains))) {
8584
8686
  overlapCount++;
8585
8687
  }
8586
8688
  }
@@ -8605,7 +8707,7 @@ function buildOverviewCompetitors(snapshots, competitors2, queryLookup) {
8605
8707
  return competitors2.map((competitor, index2) => {
8606
8708
  const citedQuerySet = /* @__PURE__ */ new Set();
8607
8709
  for (const snap of snapshots) {
8608
- if (snap.competitorOverlap.some((domain) => hostMatchesDomain(domain, competitor.domain)) || snap.citedDomains.some((domain) => hostMatchesDomain(domain, competitor.domain))) {
8710
+ if (snap.citedDomains.some((domain) => hostMatchesDomain(domain, competitor.domain))) {
8609
8711
  if (snap.queryId) citedQuerySet.add(snap.queryId);
8610
8712
  }
8611
8713
  }
@@ -8716,15 +8818,62 @@ function collectProviderKeys(perRun) {
8716
8818
  }
8717
8819
 
8718
8820
  // ../intelligence/src/mention-share.ts
8719
- function buildMentionShare(snapshots, options) {
8720
- const tooltip = 'When AI answers your tracked queries and names a brand, the % of brand-name-drops that are you vs your tracked competitors. Cleaner than Citation Coverage for "am I winning the conversation".';
8721
- const emptyBreakdown = {
8821
+ var MIN_BRAND_ALIAS_KEY_LENGTH = 3;
8822
+ function usableBrandAliases(aliases) {
8823
+ return aliases.filter((alias2) => brandKeyFromText(alias2).length >= MIN_BRAND_ALIAS_KEY_LENGTH);
8824
+ }
8825
+ function emptyTally(competitors2) {
8826
+ return {
8722
8827
  projectMentionSnapshots: 0,
8723
- competitorMentionSnapshots: 0,
8724
- perCompetitor: [],
8725
8828
  snapshotsWithAnswerText: 0,
8726
- snapshotsTotal: snapshots.length
8829
+ snapshotsTotal: 0,
8830
+ competitorCounts: new Map(competitors2.map((c) => [c.domain, 0]))
8831
+ };
8832
+ }
8833
+ function toBreakdown(tally, competitors2) {
8834
+ const competitorMentionSnapshots = [...tally.competitorCounts.values()].reduce((a, b) => a + b, 0);
8835
+ const denom = tally.projectMentionSnapshots + competitorMentionSnapshots;
8836
+ const perCompetitor = competitors2.map((c) => ({
8837
+ domain: c.domain,
8838
+ mentionSnapshots: tally.competitorCounts.get(c.domain) ?? 0,
8839
+ shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((tally.competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
8840
+ })).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1));
8841
+ return {
8842
+ projectMentionSnapshots: tally.projectMentionSnapshots,
8843
+ competitorMentionSnapshots,
8844
+ perCompetitor,
8845
+ snapshotsWithAnswerText: tally.snapshotsWithAnswerText,
8846
+ snapshotsTotal: tally.snapshotsTotal,
8847
+ score: denom > 0 ? Math.round(tally.projectMentionSnapshots / denom * 100) : null
8727
8848
  };
8849
+ }
8850
+ function buildMentionShare(snapshots, options) {
8851
+ const competitorMatchers = new Map(
8852
+ options.competitors.map((c) => [c.domain, compileBrandAliases(usableBrandAliases([...c.brandTokens]))])
8853
+ );
8854
+ const nonBrand = emptyTally(options.competitors);
8855
+ const branded = emptyTally(options.competitors);
8856
+ const unclassified = emptyTally(options.competitors);
8857
+ for (const snap of snapshots) {
8858
+ const tally = snap.queryClass === "branded" ? branded : snap.queryClass === "non-brand" ? nonBrand : unclassified;
8859
+ tally.snapshotsTotal++;
8860
+ const text2 = snap.answerText ?? "";
8861
+ if (text2.length === 0) continue;
8862
+ tally.snapshotsWithAnswerText++;
8863
+ if (snap.projectMentioned) tally.projectMentionSnapshots++;
8864
+ for (const competitor of options.competitors) {
8865
+ const matcher = competitorMatchers.get(competitor.domain);
8866
+ if (matcher && matcherMatchesText(matcher, text2)) {
8867
+ tally.competitorCounts.set(competitor.domain, (tally.competitorCounts.get(competitor.domain) ?? 0) + 1);
8868
+ }
8869
+ }
8870
+ }
8871
+ const classified = nonBrand.snapshotsTotal + branded.snapshotsTotal;
8872
+ const classificationAvailable = options.classificationAvailable === true || classified > 0;
8873
+ const scope = classificationAvailable ? "non-brand" : "pooled";
8874
+ let headline = toBreakdown(scope === "non-brand" ? nonBrand : unclassified, options.competitors);
8875
+ let brandedBreakdown = toBreakdown(branded, options.competitors);
8876
+ const tooltip = scope === "non-brand" ? "On queries that do NOT contain your name, the % of brand-name-drops in AI answers that are you vs your tracked competitors. Branded queries are excluded on purpose: you are named on almost all of them and a competitor cannot be, so pooling them would hide how you place in your category." : "The % of brand-name-drops in AI answers that are you vs your tracked competitors across all tracked queries. This project has no usable brand alias, so branded and non-brand queries could not be separated and the result is labelled pooled.";
8728
8877
  if (snapshots.length === 0) {
8729
8878
  return {
8730
8879
  label: "Mention Share",
@@ -8734,10 +8883,14 @@ function buildMentionShare(snapshots, options) {
8734
8883
  description: "No mention share data yet. Trigger a run to start tracking.",
8735
8884
  tooltip,
8736
8885
  trend: [],
8737
- breakdown: emptyBreakdown
8886
+ scope,
8887
+ breakdown: headline,
8888
+ branded: brandedBreakdown
8738
8889
  };
8739
8890
  }
8740
8891
  if (options.competitors.length === 0) {
8892
+ headline = { ...headline, score: null };
8893
+ brandedBreakdown = { ...brandedBreakdown, score: null };
8741
8894
  return {
8742
8895
  label: "Mention Share",
8743
8896
  value: "Add competitors",
@@ -8746,83 +8899,95 @@ function buildMentionShare(snapshots, options) {
8746
8899
  description: "Mention Share is a head-to-head competitive metric \u2014 add tracked competitors to compare brand mention rates.",
8747
8900
  tooltip,
8748
8901
  trend: [],
8749
- breakdown: emptyBreakdown
8902
+ scope,
8903
+ breakdown: headline,
8904
+ branded: brandedBreakdown
8750
8905
  };
8751
8906
  }
8752
- let projectMentionSnapshots = 0;
8753
- let snapshotsWithAnswerText = 0;
8754
- const competitorCounts = /* @__PURE__ */ new Map();
8755
- const competitorAliases = /* @__PURE__ */ new Map();
8756
- for (const competitor of options.competitors) {
8757
- competitorCounts.set(competitor.domain, 0);
8758
- competitorAliases.set(
8759
- competitor.domain,
8760
- competitor.brandTokens.filter((alias2) => brandKeyFromText(alias2).length >= 3)
8761
- );
8907
+ const score = headline.score;
8908
+ const denom = headline.projectMentionSnapshots + headline.competitorMentionSnapshots;
8909
+ if (scope === "non-brand" && nonBrand.snapshotsTotal === 0) {
8910
+ return {
8911
+ label: "Mention Share",
8912
+ value: "No non-brand queries",
8913
+ delta: branded.snapshotsTotal > 0 ? `${branded.snapshotsTotal} branded ${branded.snapshotsTotal === 1 ? "snapshot" : "snapshots"} only` : "No answers to score",
8914
+ tone: "neutral",
8915
+ description: "Every tracked query names your brand. Branded queries measure recognition, not competitive placement \u2014 add category queries to measure where you place.",
8916
+ tooltip,
8917
+ trend: [],
8918
+ scope,
8919
+ breakdown: headline,
8920
+ branded: brandedBreakdown
8921
+ };
8762
8922
  }
8763
- for (const snap of snapshots) {
8764
- const text2 = snap.answerText ?? "";
8765
- if (text2.length === 0) continue;
8766
- snapshotsWithAnswerText++;
8767
- if (snap.projectMentioned) projectMentionSnapshots++;
8768
- for (const competitor of options.competitors) {
8769
- const aliases = competitorAliases.get(competitor.domain) ?? [];
8770
- if (textContainsAnyBrandAlias(text2, aliases)) {
8771
- competitorCounts.set(competitor.domain, (competitorCounts.get(competitor.domain) ?? 0) + 1);
8772
- }
8773
- }
8923
+ if (headline.snapshotsTotal > 0 && headline.snapshotsWithAnswerText === 0) {
8924
+ const brandedAnswers = brandedBreakdown.snapshotsWithAnswerText;
8925
+ return {
8926
+ label: "Mention Share",
8927
+ value: scope === "non-brand" ? "No non-brand answers" : "No answers",
8928
+ delta: scope === "non-brand" && brandedAnswers > 0 ? `${brandedAnswers} branded ${brandedAnswers === 1 ? "answer" : "answers"} only` : "No answers to score",
8929
+ tone: "neutral",
8930
+ description: scope === "non-brand" ? "Non-brand queries are tracked, but no answer text was recorded for them in this run." : "Tracked queries exist, but no answer text was recorded in this run.",
8931
+ tooltip,
8932
+ trend: [],
8933
+ scope,
8934
+ breakdown: headline,
8935
+ branded: brandedBreakdown
8936
+ };
8937
+ }
8938
+ if (score === null) {
8939
+ return {
8940
+ label: "Mention Share",
8941
+ value: "No mentions",
8942
+ delta: scopeLabel(scope, "No brand mentions in this run"),
8943
+ tone: "neutral",
8944
+ description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
8945
+ tooltip,
8946
+ trend: [],
8947
+ scope,
8948
+ breakdown: headline,
8949
+ branded: brandedBreakdown
8950
+ };
8774
8951
  }
8775
- const competitorMentionSnapshots = [...competitorCounts.values()].reduce((a, b) => a + b, 0);
8776
- const denom = projectMentionSnapshots + competitorMentionSnapshots;
8777
- const score = denom > 0 ? Math.round(projectMentionSnapshots / denom * 100) : 0;
8778
- const perCompetitor = options.competitors.map((c) => ({
8779
- domain: c.domain,
8780
- mentionSnapshots: competitorCounts.get(c.domain) ?? 0,
8781
- shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
8782
- })).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots);
8783
- const breakdown = {
8784
- projectMentionSnapshots,
8785
- competitorMentionSnapshots,
8786
- perCompetitor,
8787
- snapshotsWithAnswerText,
8788
- snapshotsTotal: snapshots.length
8789
- };
8790
- const description = describe({
8791
- score,
8792
- projectMentionSnapshots,
8793
- competitorMentionSnapshots,
8794
- perCompetitor
8795
- });
8796
8952
  return {
8797
8953
  label: "Mention Share",
8798
- value: denom > 0 ? `${score}` : "0",
8799
- delta: denom > 0 ? `${projectMentionSnapshots} of ${denom} brand mentions` : "No brand mentions in this run",
8800
- tone: denom > 0 ? mentionShareTone(score) : "neutral",
8801
- description,
8954
+ value: `${score}`,
8955
+ delta: scopeLabel(scope, `${headline.projectMentionSnapshots} of ${denom} brand mentions`),
8956
+ tone: mentionShareTone(score),
8957
+ description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
8802
8958
  tooltip,
8803
8959
  trend: [],
8804
- progress: denom > 0 ? score : 0,
8805
- breakdown
8960
+ progress: score,
8961
+ scope,
8962
+ breakdown: headline,
8963
+ branded: brandedBreakdown
8806
8964
  };
8807
8965
  }
8966
+ function scopeLabel(scope, body) {
8967
+ return scope === "non-brand" ? `${body} \xB7 non-brand queries` : `${body} \xB7 pooled queries \xB7 classification unavailable`;
8968
+ }
8808
8969
  function mentionShareTone(score) {
8809
8970
  if (score >= 50) return "positive";
8810
8971
  if (score >= 25) return "caution";
8811
8972
  return "negative";
8812
8973
  }
8813
8974
  function describe(parts) {
8814
- const { score, projectMentionSnapshots, competitorMentionSnapshots, perCompetitor } = parts;
8975
+ const { scope, score, breakdown, branded } = parts;
8976
+ const { projectMentionSnapshots, competitorMentionSnapshots, perCompetitor } = breakdown;
8977
+ const where = scope === "non-brand" ? "on non-brand queries" : "across your tracked queries";
8978
+ const brandedNote = branded.snapshotsWithAnswerText > 0 && branded.score !== null ? ` Separately, you are named in ${branded.projectMentionSnapshots} of ${branded.snapshotsWithAnswerText} answers to queries that contain your name.` : "";
8815
8979
  if (projectMentionSnapshots === 0 && competitorMentionSnapshots === 0) {
8816
- return "No brand mentions detected for you or your tracked competitors in this run.";
8980
+ return `No brand mentions detected for you or your tracked competitors ${where}.${brandedNote}`;
8817
8981
  }
8818
8982
  if (competitorMentionSnapshots === 0) {
8819
- return `${projectMentionSnapshots} brand mentions of you, zero competitor mentions \u2014 you own the conversation.`;
8983
+ return `${projectMentionSnapshots} brand mentions of you and zero competitor mentions ${where} \u2014 you own the conversation.${brandedNote}`;
8820
8984
  }
8821
8985
  const top = perCompetitor[0];
8986
+ const total = projectMentionSnapshots + competitorMentionSnapshots;
8822
8987
  if (!top) {
8823
- return `${score}% of brand mentions are you (${projectMentionSnapshots} of ${projectMentionSnapshots + competitorMentionSnapshots}).`;
8988
+ return `${score}% of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
8824
8989
  }
8825
- return `${score}% of brand mentions are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).`;
8990
+ return `${score}% of brand mentions ${where} are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).${brandedNote}`;
8826
8991
  }
8827
8992
 
8828
8993
  // ../intelligence/src/suggested-queries.ts
@@ -13284,6 +13449,8 @@ function loadRunDetail(app, run) {
13284
13449
  ownedDomains: projects.ownedDomains,
13285
13450
  aliases: projects.aliases
13286
13451
  }).from(projects).where(eq11(projects.id, run.projectId)).get();
13452
+ const competitorDomains = app.db.select({ domain: competitors.domain }).from(competitors).where(eq11(competitors.projectId, run.projectId)).all().map((row) => row.domain);
13453
+ const competitiveSignalResolver = compileCompetitiveSignalResolver(competitorDomains);
13287
13454
  const snapshots = app.db.select({
13288
13455
  id: querySnapshots.id,
13289
13456
  runId: querySnapshots.runId,
@@ -13317,6 +13484,14 @@ function loadRunDetail(app, run) {
13317
13484
  ...formatRun(run),
13318
13485
  snapshots: snapshots.map((s) => {
13319
13486
  const rawParsed = parseSnapshotRawResponse(s.rawResponse);
13487
+ const signalGroundingSources = rawParsed.groundingSources.filter(
13488
+ (source) => typeof source === "object" && source !== null && typeof source.uri === "string"
13489
+ );
13490
+ const competitiveSignals = competitiveSignalResolver.resolve({
13491
+ citedDomains: s.citedDomains,
13492
+ groundingSources: signalGroundingSources,
13493
+ answerText: s.answerText
13494
+ });
13320
13495
  const answerMentioned = project ? resolveSnapshotAnswerMentioned(s, project) : s.answerMentioned ?? false;
13321
13496
  return {
13322
13497
  id: s.id,
@@ -13337,6 +13512,8 @@ function loadRunDetail(app, run) {
13337
13512
  sourceCount: s.sourceCount,
13338
13513
  resolvedCount: s.resolvedCount,
13339
13514
  captureVersion: s.captureVersion,
13515
+ ...competitiveSignals,
13516
+ // Legacy mixed signal retained for backwards compatibility.
13340
13517
  competitorOverlap: s.competitorOverlap,
13341
13518
  recommendedCompetitors: s.recommendedCompetitors,
13342
13519
  matchedTerms: project ? resolveSnapshotMatchedTerms(s, project) : [],
@@ -20165,6 +20342,8 @@ async function historyRoutes(app) {
20165
20342
  if (projectRuns.length === 0) {
20166
20343
  return reply.send({ snapshots: [], total: 0 });
20167
20344
  }
20345
+ const competitorDomains = app.db.select({ domain: competitors.domain }).from(competitors).where(eq22(competitors.projectId, project.id)).all().map((row) => row.domain);
20346
+ const competitiveSignalResolver = compileCompetitiveSignalResolver(competitorDomains);
20168
20347
  const allSnapshots = app.db.select({
20169
20348
  id: querySnapshots.id,
20170
20349
  runId: querySnapshots.runId,
@@ -20184,6 +20363,7 @@ async function historyRoutes(app) {
20184
20363
  competitorOverlap: querySnapshots.competitorOverlap,
20185
20364
  recommendedCompetitors: querySnapshots.recommendedCompetitors,
20186
20365
  location: querySnapshots.location,
20366
+ rawResponse: querySnapshots.rawResponse,
20187
20367
  createdAt: querySnapshots.createdAt
20188
20368
  }).from(querySnapshots).leftJoin(queries, eq22(querySnapshots.queryId, queries.id)).where(inArray8(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc7(querySnapshots.createdAt)).all();
20189
20369
  const locationFilter = request.query.location;
@@ -20191,29 +20371,43 @@ async function historyRoutes(app) {
20191
20371
  const total = filtered.length;
20192
20372
  const paged = filtered.slice(offset, offset + limit);
20193
20373
  return reply.send({
20194
- snapshots: paged.map((s) => ({
20195
- id: s.id,
20196
- runId: s.runId,
20197
- queryId: s.queryId,
20198
- query: s.query,
20199
- provider: s.provider,
20200
- model: s.model,
20201
- citationState: s.citationState,
20202
- answerMentioned: resolveSnapshotAnswerMentioned(s, project),
20203
- visibilityState: resolveSnapshotVisibilityState(s, project),
20204
- mentionState: resolveSnapshotMentionState(s, project),
20205
- answerText: s.answerText,
20206
- citedDomains: s.citedDomains,
20207
- citedUrls: s.citedUrls,
20208
- captureStatus: s.captureStatus,
20209
- sourceCount: s.sourceCount,
20210
- resolvedCount: s.resolvedCount,
20211
- captureVersion: s.captureVersion,
20212
- competitorOverlap: s.competitorOverlap,
20213
- recommendedCompetitors: s.recommendedCompetitors,
20214
- location: s.location,
20215
- createdAt: s.createdAt
20216
- })),
20374
+ snapshots: paged.map((s) => {
20375
+ const rawResponse = parseJsonColumn(s.rawResponse, {});
20376
+ const rawGroundingSources = Array.isArray(rawResponse.groundingSources) ? rawResponse.groundingSources : [];
20377
+ const groundingSources = rawGroundingSources.filter(
20378
+ (source) => typeof source === "object" && source !== null && typeof source.uri === "string"
20379
+ );
20380
+ const competitiveSignals = competitiveSignalResolver.resolve({
20381
+ citedDomains: s.citedDomains,
20382
+ groundingSources,
20383
+ answerText: s.answerText
20384
+ });
20385
+ return {
20386
+ id: s.id,
20387
+ runId: s.runId,
20388
+ queryId: s.queryId,
20389
+ query: s.query,
20390
+ provider: s.provider,
20391
+ model: s.model,
20392
+ citationState: s.citationState,
20393
+ answerMentioned: resolveSnapshotAnswerMentioned(s, project),
20394
+ visibilityState: resolveSnapshotVisibilityState(s, project),
20395
+ mentionState: resolveSnapshotMentionState(s, project),
20396
+ answerText: s.answerText,
20397
+ citedDomains: s.citedDomains,
20398
+ citedUrls: s.citedUrls,
20399
+ captureStatus: s.captureStatus,
20400
+ sourceCount: s.sourceCount,
20401
+ resolvedCount: s.resolvedCount,
20402
+ captureVersion: s.captureVersion,
20403
+ ...competitiveSignals,
20404
+ // Legacy mixed signal retained for backwards compatibility.
20405
+ competitorOverlap: s.competitorOverlap,
20406
+ recommendedCompetitors: s.recommendedCompetitors,
20407
+ location: s.location,
20408
+ createdAt: s.createdAt
20409
+ };
20410
+ }),
20217
20411
  total
20218
20412
  });
20219
20413
  });
@@ -20452,6 +20646,61 @@ function parseOptionalPositiveInt(value, max2) {
20452
20646
  // ../api-routes/src/analytics.ts
20453
20647
  import { and as and17, desc as desc8, eq as eq23, gte as gte4, inArray as inArray9, lt as lt4 } from "drizzle-orm";
20454
20648
 
20649
+ // ../api-routes/src/mention-share-inputs.ts
20650
+ function mentionShareCompetitorsFromDomains(domains) {
20651
+ return domains.map((domain) => {
20652
+ const exactDomain = hostOf(domain);
20653
+ const domainLabel = brandLabelFromDomain(domain);
20654
+ return {
20655
+ domain,
20656
+ // A short registrable label is too noisy by itself (`AI`), but the full
20657
+ // written domain is operator-approved identity (`ai.com`). Feeding both
20658
+ // through the shared matcher keeps every mention-share surface aligned.
20659
+ brandTokens: usableBrandAliases([
20660
+ ...brandKeyFromText(domainLabel).length >= MIN_DOMAIN_BRAND_KEY_LENGTH ? [domainLabel] : [],
20661
+ ...exactDomain?.includes(".") ? [exactDomain] : []
20662
+ ])
20663
+ };
20664
+ });
20665
+ }
20666
+ function projectQueryClassifier(project) {
20667
+ const classifier = compileQueryClassifier(effectiveBrandNames({
20668
+ displayName: project.displayName ?? null,
20669
+ aliases: project.aliases ?? null,
20670
+ canonicalDomain: project.canonicalDomain ?? null,
20671
+ ownedDomains: project.ownedDomains ?? null
20672
+ }));
20673
+ return classifier ? (queryText) => classifier.classify(queryText) : null;
20674
+ }
20675
+ function buildMentionShareInputs(opts) {
20676
+ const classify = projectQueryClassifier(opts.project);
20677
+ const projectBrandNames = effectiveBrandNames({
20678
+ displayName: opts.project.displayName ?? null,
20679
+ aliases: opts.project.aliases ?? null,
20680
+ canonicalDomain: opts.project.canonicalDomain ?? null,
20681
+ ownedDomains: opts.project.ownedDomains ?? null
20682
+ });
20683
+ const projectDomains = effectiveDomains({
20684
+ canonicalDomain: opts.project.canonicalDomain ?? "",
20685
+ ownedDomains: opts.project.ownedDomains ?? []
20686
+ });
20687
+ return {
20688
+ classified: classify !== null,
20689
+ competitors: mentionShareCompetitorsFromDomains(opts.competitorDomains),
20690
+ snapshots: opts.snapshots.map((snap) => {
20691
+ const queryText = (snap.queryId ? opts.queryTextById?.get(snap.queryId) : void 0) ?? snap.queryText ?? null;
20692
+ return {
20693
+ // Mention share is a current-identity metric. Recompute stored answer
20694
+ // text after an alias/domain rename; only text-less legacy rows need
20695
+ // the persisted run-time boolean as a fallback.
20696
+ projectMentioned: snap.answerText ? determineAnswerMentioned(snap.answerText, projectBrandNames, projectDomains) : snap.answerMentioned === true,
20697
+ answerText: snap.answerText,
20698
+ queryClass: classify ? classify(queryText) : null
20699
+ };
20700
+ })
20701
+ };
20702
+ }
20703
+
20455
20704
  // ../api-routes/src/model-evidence.ts
20456
20705
  function classifyModelEvidence(values) {
20457
20706
  const models = /* @__PURE__ */ new Set();
@@ -20580,6 +20829,8 @@ function measuredWhole(db, run) {
20580
20829
  async function analyticsRoutes(app) {
20581
20830
  app.get("/projects/:name/analytics/metrics", async (request, reply) => {
20582
20831
  const project = resolveProject(app.db, request.params.name);
20832
+ const classifyQuery = projectQueryClassifier(project);
20833
+ const mentionShareScope = classifyQuery ? "non-brand" : "pooled";
20583
20834
  const window = parseWindow(request.query.window);
20584
20835
  const cutoff = windowCutoff(window);
20585
20836
  const projectRuns = app.db.select().from(runs).where(and17(
@@ -20592,6 +20843,7 @@ async function analyticsRoutes(app) {
20592
20843
  if (projectRuns.length === 0) {
20593
20844
  return reply.send({
20594
20845
  window,
20846
+ mentionShareScope,
20595
20847
  buckets: [],
20596
20848
  overall: { citationRate: 0, cited: 0, total: 0, mentionRate: 0, mentionedCount: 0 },
20597
20849
  byProvider: {},
@@ -20638,16 +20890,16 @@ async function analyticsRoutes(app) {
20638
20890
  ...s,
20639
20891
  runCreatedAt: runCreatedAt.get(s.runId),
20640
20892
  resolvedMentioned: resolveSnapshotAnswerMentioned(s, project),
20893
+ queryClass: classifyQuery ? classifyQuery((s.queryId ? queryTextById.get(s.queryId) : void 0) ?? s.queryText ?? null) : null,
20641
20894
  // Falls back to the id if no text resolves, so two distinct queries can
20642
20895
  // never collapse into one member on an empty key. (An orphan only gets
20643
20896
  // this far when its text matched the basket, so its key is never empty.)
20644
20897
  basketKey: normalizeQueryText(s.queryText ?? (s.queryId ? queryTextById.get(s.queryId) ?? "" : "")) || s.queryId || "",
20645
20898
  runBasketRevision: runBasketRevision.get(s.runId) ?? null
20646
20899
  }));
20647
- const mentionShareCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq23(competitors.projectId, project.id)).all().map((c) => ({
20648
- domain: c.domain,
20649
- brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
20650
- }));
20900
+ const mentionShareCompetitors = mentionShareCompetitorsFromDomains(
20901
+ app.db.select({ domain: competitors.domain }).from(competitors).where(eq23(competitors.projectId, project.id)).all().map((c) => c.domain)
20902
+ );
20651
20903
  const overall = computeProviderMetric(allSnapshots);
20652
20904
  const byProvider = {};
20653
20905
  const providers = new Set(allSnapshots.map((s) => s.provider));
@@ -20658,7 +20910,15 @@ async function analyticsRoutes(app) {
20658
20910
  const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
20659
20911
  const spanDays = Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 864e5));
20660
20912
  const bucketSize = bucketSizeForSpan(spanDays);
20661
- const buckets = computeBuckets(allSnapshots, projectRuns, bucketSize, queryCreatedAt, mentionShareCompetitors, referenceBasket);
20913
+ const buckets = computeBuckets(
20914
+ allSnapshots,
20915
+ projectRuns,
20916
+ bucketSize,
20917
+ queryCreatedAt,
20918
+ mentionShareCompetitors,
20919
+ referenceBasket,
20920
+ classifyQuery !== null
20921
+ );
20662
20922
  const anchors = {};
20663
20923
  const anchorObservedAt = {};
20664
20924
  const anchorUnavailable = /* @__PURE__ */ new Set();
@@ -20822,7 +21082,7 @@ async function analyticsRoutes(app) {
20822
21082
  }
20823
21083
  previousExecutionChecksum = identity.checksum;
20824
21084
  }
20825
- return reply.send({ window, buckets, overall, byProvider, trend, mentionTrend, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
21085
+ return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
20826
21086
  });
20827
21087
  app.get("/projects/:name/analytics/gaps", async (request, reply) => {
20828
21088
  const project = resolveProject(app.db, request.params.name);
@@ -20837,6 +21097,10 @@ async function analyticsRoutes(app) {
20837
21097
  }
20838
21098
  const windowRuns = app.db.select().from(runs).where(and17(eq23(runs.projectId, project.id), eq23(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => measuredWhole(app.db, r)).filter((r) => !cutoff || r.createdAt >= cutoff);
20839
21099
  const windowRunIds = windowRuns.map((r) => r.id);
21100
+ const competitorDomains = app.db.select({ domain: competitors.domain }).from(competitors).where(eq23(competitors.projectId, project.id)).all().map((c) => c.domain);
21101
+ const competitorMatchers = new Map(
21102
+ mentionShareCompetitorsFromDomains(competitorDomains).map((c) => [c.domain, compileBrandAliases([...c.brandTokens])])
21103
+ );
20840
21104
  const runIdToCreatedAt = new Map(windowRuns.map((r) => [r.id, r.createdAt]));
20841
21105
  const consistencyMap = /* @__PURE__ */ new Map();
20842
21106
  if (windowRunIds.length > 0) {
@@ -20866,7 +21130,7 @@ async function analyticsRoutes(app) {
20866
21130
  citationState: querySnapshots.citationState,
20867
21131
  answerMentioned: querySnapshots.answerMentioned,
20868
21132
  answerText: querySnapshots.answerText,
20869
- competitorOverlap: querySnapshots.competitorOverlap
21133
+ citedDomains: querySnapshots.citedDomains
20870
21134
  }).from(querySnapshots).leftJoin(queries, eq23(querySnapshots.queryId, queries.id)).where(inArray9(querySnapshots.runId, latestGroupRunIds)).all());
20871
21135
  const snapshots = rawSnapshots.map((s) => ({
20872
21136
  ...s,
@@ -20890,9 +21154,17 @@ async function analyticsRoutes(app) {
20890
21154
  const citedProviders = qSnapshots.filter((s) => s.citationState === CitationStates.cited).map((s) => s.provider);
20891
21155
  const mentionedProviders = qSnapshots.filter((s) => s.resolvedMentioned).map((s) => s.provider);
20892
21156
  const competitorsCiting = /* @__PURE__ */ new Set();
21157
+ const competitorsMentioned = /* @__PURE__ */ new Set();
20893
21158
  for (const s of qSnapshots) {
20894
- const overlap = s.competitorOverlap;
20895
- for (const c of overlap) competitorsCiting.add(c);
21159
+ for (const domain of s.citedDomains) {
21160
+ const match = competitorDomains.find((c) => hostMatchesDomain(domain, c));
21161
+ if (match) competitorsCiting.add(match);
21162
+ }
21163
+ if (!s.answerText) continue;
21164
+ for (const competitor of competitorDomains) {
21165
+ const matcher = competitorMatchers.get(competitor);
21166
+ if (matcher && matcherMatchesText(matcher, s.answerText)) competitorsMentioned.add(competitor);
21167
+ }
20896
21168
  }
20897
21169
  const cons = consistencyMap.get(queryId);
20898
21170
  const consistency = {
@@ -20914,6 +21186,7 @@ async function analyticsRoutes(app) {
20914
21186
  category,
20915
21187
  providers: citedProviders,
20916
21188
  competitorsCiting: [...competitorsCiting],
21189
+ competitorsMentioned: [...competitorsMentioned],
20917
21190
  consistency
20918
21191
  };
20919
21192
  if (category === "cited") cited.push(citationEntry);
@@ -20922,7 +21195,7 @@ async function analyticsRoutes(app) {
20922
21195
  let mentionCategory;
20923
21196
  if (mentionedProviders.length > 0) {
20924
21197
  mentionCategory = "cited";
20925
- } else if (competitorsCiting.size > 0) {
21198
+ } else if (competitorsMentioned.size > 0) {
20926
21199
  mentionCategory = "gap";
20927
21200
  } else {
20928
21201
  mentionCategory = "uncited";
@@ -20933,6 +21206,7 @@ async function analyticsRoutes(app) {
20933
21206
  category: mentionCategory,
20934
21207
  providers: mentionedProviders,
20935
21208
  competitorsCiting: [...competitorsCiting],
21209
+ competitorsMentioned: [...competitorsMentioned],
20936
21210
  consistency
20937
21211
  };
20938
21212
  if (mentionCategory === "cited") mentionedQueries.push(mentionEntry);
@@ -20942,7 +21216,7 @@ async function analyticsRoutes(app) {
20942
21216
  gap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
20943
21217
  cited.sort((a, b) => a.query.localeCompare(b.query));
20944
21218
  uncited.sort((a, b) => a.query.localeCompare(b.query));
20945
- mentionGap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
21219
+ mentionGap.sort((a, b) => b.competitorsMentioned.length - a.competitorsMentioned.length);
20946
21220
  mentionedQueries.sort((a, b) => a.query.localeCompare(b.query));
20947
21221
  notMentioned.sort((a, b) => a.query.localeCompare(b.query));
20948
21222
  return reply.send({ cited, gap, uncited, mentionedQueries, mentionGap, notMentioned, runId: latestRun.id, window });
@@ -21059,7 +21333,7 @@ function computeProviderMetric(snapshots) {
21059
21333
  mentionedCount
21060
21334
  };
21061
21335
  }
21062
- function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket) {
21336
+ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket, classificationAvailable = false) {
21063
21337
  if (projectRuns.length === 0) return [];
21064
21338
  const earliest = new Date(projectRuns[0].createdAt);
21065
21339
  const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
@@ -21108,7 +21382,7 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
21108
21382
  queryCount,
21109
21383
  mentionRate: metric2.mentionRate,
21110
21384
  mentionedCount: metric2.mentionedCount,
21111
- mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors),
21385
+ mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors, classificationAvailable),
21112
21386
  byProvider,
21113
21387
  modelEvidenceByProvider,
21114
21388
  basketRevision
@@ -21127,22 +21401,23 @@ function bucketStartDateFor(observedAt, earliest, bucketDays) {
21127
21401
  const offset = Math.max(0, Math.floor((observationDay.getTime() - firstBucketStart.getTime()) / bucketMilliseconds));
21128
21402
  return new Date(firstBucketStart.getTime() + offset * bucketMilliseconds).toISOString();
21129
21403
  }
21130
- function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors) {
21131
- if (mentionShareCompetitors.length === 0) {
21132
- return { rate: null, projectMentionSnapshots: 0, competitorMentionSnapshots: 0 };
21133
- }
21404
+ function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors, classificationAvailable) {
21134
21405
  const result = buildMentionShare(
21135
21406
  snapshots.map((s) => ({
21136
21407
  projectMentioned: s.resolvedMentioned,
21137
- answerText: s.answerText
21408
+ answerText: s.answerText,
21409
+ queryClass: s.queryClass
21138
21410
  })),
21139
- { competitors: mentionShareCompetitors }
21411
+ { competitors: mentionShareCompetitors, classificationAvailable }
21140
21412
  );
21141
21413
  const projectMentionSnapshots = result.breakdown.projectMentionSnapshots;
21142
21414
  const competitorMentionSnapshots = result.breakdown.competitorMentionSnapshots;
21143
21415
  const denominator = projectMentionSnapshots + competitorMentionSnapshots;
21144
21416
  return {
21145
- rate: denominator > 0 ? round4(projectMentionSnapshots / denominator) : null,
21417
+ scope: result.scope,
21418
+ // A project-only denominator is recognition evidence, not competitive
21419
+ // share. Preserve the count but leave the rate undefined without a frame.
21420
+ rate: mentionShareCompetitors.length > 0 && denominator > 0 ? round4(projectMentionSnapshots / denominator) : null,
21146
21421
  projectMentionSnapshots,
21147
21422
  competitorMentionSnapshots
21148
21423
  };
@@ -21726,20 +22001,27 @@ function aggregateCandidate(opts) {
21726
22001
  const ourCitedRate = citedCount / totalSnaps;
21727
22002
  const recentMissRate = 1 - ourCitedRate;
21728
22003
  const competitorTally = /* @__PURE__ */ new Map();
22004
+ const competitorMentionTally = /* @__PURE__ */ new Map();
22005
+ const competitiveSignalResolver = compileCompetitiveSignalResolver([...opts.competitorSet]);
21729
22006
  const competitorGroundingTally = /* @__PURE__ */ new Map();
21730
22007
  const ourGroundingTally = /* @__PURE__ */ new Map();
21731
22008
  const citedSurfaceTally = /* @__PURE__ */ new Map();
21732
22009
  let ourCitedInLatestRun = false;
21733
22010
  for (const snap of opts.snapshots) {
21734
22011
  const isLatestRun = snap.runId === opts.latestRunId;
21735
- const competitorOverlap = snap.competitorOverlap;
21736
- for (const domain of competitorOverlap) {
21737
- const normalized = hostOf(domain) ?? "";
21738
- const competitor = findMatchingDomain(normalized, opts.competitorSet);
21739
- if (!competitor) continue;
21740
- competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
21741
- }
21742
22012
  const grounding = extractGroundingSources(snap.rawResponse);
22013
+ const competitiveSignals = competitiveSignalResolver.resolve({
22014
+ citedDomains: snap.citedDomains,
22015
+ groundingSources: grounding,
22016
+ answerText: snap.answerText
22017
+ });
22018
+ const citedCompetitorsHere = /* @__PURE__ */ new Set();
22019
+ for (const competitor of competitiveSignals.citedCompetitorDomains) {
22020
+ citedCompetitorsHere.add(competitor);
22021
+ }
22022
+ for (const competitor of competitiveSignals.mentionedCompetitorDomains) {
22023
+ competitorMentionTally.set(competitor, (competitorMentionTally.get(competitor) ?? 0) + 1);
22024
+ }
21743
22025
  for (const g of grounding) {
21744
22026
  const domain = hostOf(g.uri) ?? "";
21745
22027
  if (!domain) continue;
@@ -21749,9 +22031,14 @@ function aggregateCandidate(opts) {
21749
22031
  continue;
21750
22032
  }
21751
22033
  citedSurfaceTally.set(domain, (citedSurfaceTally.get(domain) ?? 0) + 1);
21752
- if (!findMatchingDomain(domain, opts.competitorSet)) continue;
22034
+ const competitor = findMatchingDomain(domain, opts.competitorSet);
22035
+ if (!competitor) continue;
22036
+ citedCompetitorsHere.add(competitor);
21753
22037
  recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
21754
22038
  }
22039
+ for (const competitor of citedCompetitorsHere) {
22040
+ competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
22041
+ }
21755
22042
  }
21756
22043
  return {
21757
22044
  query: opts.query,
@@ -21762,8 +22049,10 @@ function aggregateCandidate(opts) {
21762
22049
  gscCtr: opts.gsc?.ctr ?? 0,
21763
22050
  ourCitedRate,
21764
22051
  ourCitedInLatestRun,
21765
- competitorDomains: Array.from(competitorTally.keys()),
22052
+ competitorCitedDomains: Array.from(competitorTally.keys()),
22053
+ competitorMentionedDomains: Array.from(competitorMentionTally.keys()),
21766
22054
  competitorCitationCount: Array.from(competitorTally.values()).reduce((a, b) => a + b, 0),
22055
+ competitorMentionCount: Array.from(competitorMentionTally.values()).reduce((a, b) => a + b, 0),
21767
22056
  recentMissRate,
21768
22057
  ourGroundingUrls: Array.from(ourGroundingTally.values()),
21769
22058
  competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
@@ -21801,8 +22090,10 @@ function emptyCandidate(query) {
21801
22090
  gscCtr: 0,
21802
22091
  ourCitedRate: 0,
21803
22092
  ourCitedInLatestRun: false,
21804
- competitorDomains: [],
22093
+ competitorCitedDomains: [],
22094
+ competitorMentionedDomains: [],
21805
22095
  competitorCitationCount: 0,
22096
+ competitorMentionCount: 0,
21806
22097
  recentMissRate: 0,
21807
22098
  ourGroundingUrls: [],
21808
22099
  competitorGroundingUrls: [],
@@ -22774,6 +23065,15 @@ table.report-table td .badge {
22774
23065
  grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
22775
23066
  gap: 16px;
22776
23067
  }
23068
+ .mention-branded-block {
23069
+ margin-top: 20px;
23070
+ }
23071
+ .chart-note {
23072
+ font-size: 12px;
23073
+ line-height: 1.6;
23074
+ color: #4b5563;
23075
+ margin: 0 0 12px;
23076
+ }
22777
23077
  .legend {
22778
23078
  display: flex;
22779
23079
  flex-wrap: wrap;
@@ -23579,18 +23879,24 @@ function renderCompetitorBars(landscape, canonical) {
23579
23879
  ];
23580
23880
  return renderLandscapeBars(data, "Citations per domain", "Citations per domain bar chart");
23581
23881
  }
23582
- function renderMentionBars(landscape, canonical) {
23882
+ function renderMentionBars(section2, canonical, title) {
23583
23883
  const data = [
23584
- { label: canonical, count: landscape.projectMentionCount, isProject: true },
23585
- ...landscape.competitors.map((c) => ({ label: c.domain, count: c.mentionCount, isProject: false }))
23884
+ { label: canonical, count: section2.projectMentionCount, isProject: true },
23885
+ ...section2.competitors.map((c) => ({ label: c.domain, count: c.mentionCount, isProject: false }))
23586
23886
  ];
23587
- return renderLandscapeBars(data, "Mentions per domain", "Mentions per domain bar chart");
23887
+ return renderLandscapeBars(data, title, `${title} bar chart`);
23588
23888
  }
23889
+ var MENTION_SCOPE_LABEL = {
23890
+ "non-brand": "non-brand queries",
23891
+ pooled: "pooled queries \xB7 classification unavailable"
23892
+ };
23589
23893
  function renderCompetitorLandscape(report) {
23590
23894
  const competitors2 = report.competitorLandscape.competitors;
23591
23895
  const mentionLandscape = report.mentionLandscape;
23592
23896
  const noCitationData = competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0;
23593
- const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0;
23897
+ const brandedMentions = mentionLandscape.branded;
23898
+ const hasBrandedMentions = (brandedMentions?.totalAnswerSnapshots ?? 0) > 0;
23899
+ const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0 && !hasBrandedMentions;
23594
23900
  if (noCitationData && noMentionData) {
23595
23901
  return section(
23596
23902
  { id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
@@ -23615,13 +23921,26 @@ function renderCompetitorLandscape(report) {
23615
23921
  <td>${escapeHtml(c.citedQueries.slice(0, 5).join(", "))}${c.citedQueries.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
23616
23922
  </tr>`;
23617
23923
  }).join("");
23924
+ const scopeLabel2 = MENTION_SCOPE_LABEL[mentionLandscape.scope] ?? MENTION_SCOPE_LABEL.pooled;
23925
+ const mentionScopeTooltip = mentionLandscape.scope === "non-brand" ? `Mentions on ${scopeLabel2}. Branded queries are counted separately \u2014 the client is named on nearly all of them and a competitor cannot be, so pooling the two would rank the client on its own brand recall.` : `Mentions on ${scopeLabel2}. The project has no usable brand identity for a branded/non-brand split, so all tracked queries remain pooled and this is not a competitive category read.`;
23618
23926
  const table = competitors2.length > 0 ? `<table class="report-table">
23619
- <thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric">Mentions</th><th class="numeric" title="Citation share \u2014 % of cited-source slots that went to this competitor across tracked queries. Distinct from Mention Share.">Citation share</th><th>Cited queries</th></tr></thead>
23927
+ <thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric" title="${escapeHtml(mentionScopeTooltip)}">Mentions (${escapeHtml(scopeLabel2)})</th><th class="numeric" title="Citation share \u2014 % of cited-source slots that went to this competitor across tracked queries. Distinct from Mention Share.">Citation share</th><th>Cited queries</th></tr></thead>
23620
23928
  <tbody>${rows}</tbody>
23621
23929
  </table>` : renderEmpty("No competitors configured.");
23622
23930
  const citationBars = renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain);
23623
- const mentionBars = renderMentionBars(mentionLandscape, report.meta.project.canonicalDomain);
23931
+ const mentionBars = renderMentionBars(
23932
+ mentionLandscape,
23933
+ report.meta.project.canonicalDomain,
23934
+ `Mentions per domain \xB7 ${scopeLabel2}`
23935
+ );
23624
23936
  const charts = citationBars && mentionBars ? `<div class="chart-grid">${citationBars}${mentionBars}</div>` : `${citationBars}${mentionBars}`;
23937
+ const mentionShareUnavailable = mentionLandscape.projectMentionCount === 0 && mentionLandscape.competitors.length > 0 && mentionLandscape.competitors.every((row) => row.sharePct === null);
23938
+ const mentionShareNote = mentionShareUnavailable ? `<p class="chart-note">Mention share unavailable for ${escapeHtml(scopeLabel2)}: no tracked brand was named, so the denominator is 0.</p>` : "";
23939
+ const brandedBars = hasBrandedMentions && brandedMentions ? renderMentionBars(brandedMentions, report.meta.project.canonicalDomain, "Mentions per domain \xB7 branded queries") : "";
23940
+ const brandedBlock = brandedBars ? `<div class="mention-branded-block">
23941
+ <p class="chart-note">Branded queries contain the client's own name. The client is named on nearly all of them and a competitor structurally cannot be, so these are kept out of the competitive figure above. Read them as brand recall: ${brandedMentions?.projectMentionCount ?? 0} of ${brandedMentions?.totalAnswerSnapshots ?? 0} branded answers named the client.</p>
23942
+ ${brandedBars}
23943
+ </div>` : "";
23625
23944
  return section(
23626
23945
  {
23627
23946
  id: "competitor-landscape",
@@ -23629,7 +23948,7 @@ function renderCompetitorLandscape(report) {
23629
23948
  title: "Competitor Landscape",
23630
23949
  intro: "Who AI engines cite and mention instead of the client."
23631
23950
  },
23632
- `${charts}${table}`
23951
+ `${mentionShareNote}${charts}${table}${brandedBlock}`
23633
23952
  );
23634
23953
  }
23635
23954
  var SOURCE_CATEGORY_TONE = {
@@ -25935,11 +26254,7 @@ function buildProjectReport(db, projectName, periodDays) {
25935
26254
  const competitorDomains = competitorRows.map((c) => c.domain);
25936
26255
  const ownedDomains = project.ownedDomains;
25937
26256
  const projectDomains = [project.canonicalDomain, ...ownedDomains];
25938
- const projectAliases = project.aliases;
25939
- const projectBrandNames = effectiveBrandNames({
25940
- displayName: project.displayName,
25941
- aliases: projectAliases
25942
- });
26257
+ const projectBrandNames = effectiveBrandNames(project);
25943
26258
  const citationScorecard = buildCitationScorecard(latestSnapshots, queryLookup);
25944
26259
  const competitorLandscape = buildCompetitorLandscape(
25945
26260
  latestSnapshots,
@@ -27231,10 +27546,8 @@ function computeCitationVisibility(input) {
27231
27546
  for (const snap of latestByPair.values()) {
27232
27547
  if (citationStateToCited(snap.citationState)) continue;
27233
27548
  if (competitorDomains.length === 0) continue;
27234
- const cited = snap.citedDomains;
27235
- const overlap = snap.competitorOverlap;
27236
27549
  const candidates = new Set(
27237
- [...cited, ...overlap].map((d) => hostOf(d) ?? "").filter((d) => d.length > 0)
27550
+ snap.citedDomains.map((d) => hostOf(d) ?? "").filter((d) => d.length > 0)
27238
27551
  );
27239
27552
  const citingCompetitors = competitorDomains.filter(
27240
27553
  (competitor) => [...candidates].some((candidate) => hostMatchesDomain(candidate, competitor))
@@ -27323,6 +27636,7 @@ function observedPairs(snapshots, attribution, queryIds) {
27323
27636
  function period(numerator, denominator) {
27324
27637
  const ci = wilsonInterval(numerator, denominator);
27325
27638
  return {
27639
+ availability: denominator > 0 ? "available" : "no-observations",
27326
27640
  point: denominator > 0 ? Math.round(numerator / denominator * 1e4) / 1e4 : null,
27327
27641
  ciLow: ci ? ci.low : null,
27328
27642
  ciHigh: ci ? ci.high : null,
@@ -27330,17 +27644,27 @@ function period(numerator, denominator) {
27330
27644
  denominator
27331
27645
  };
27332
27646
  }
27647
+ function unavailableCompetitivePeriod(numerator) {
27648
+ return {
27649
+ availability: "no-competitive-frame",
27650
+ point: null,
27651
+ ciLow: null,
27652
+ ciHigh: null,
27653
+ numerator,
27654
+ denominator: 0
27655
+ };
27656
+ }
27333
27657
  function ciOverlap(a, b) {
27334
27658
  if (a.ciLow === null || a.ciHigh === null || b.ciLow === null || b.ciHigh === null) return true;
27335
27659
  return a.ciLow <= b.ciHigh && b.ciLow <= a.ciHigh;
27336
27660
  }
27337
- function metric(key, label, driftRobust, from, to, continuityBlock) {
27661
+ function metric(key, label, queryClass, driftRobust, from, to, continuityBlock) {
27338
27662
  const verdict = continuityBlock ?? (from.denominator === 0 || to.denominator === 0 ? "insufficient-data" : ciOverlap(from, to) ? "within-noise" : "moved");
27339
27663
  const direction = from.point === null || to.point === null ? null : to.point > from.point ? "up" : to.point < from.point ? "down" : "flat";
27340
27664
  const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
27341
- return { key, label, driftRobust, from, to, rateRatio, direction, verdict };
27665
+ return { key, label, queryClass, driftRobust, from, to, rateRatio, direction, verdict };
27342
27666
  }
27343
- function countPeriod(snaps, competitors2) {
27667
+ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable, projectBrandNames) {
27344
27668
  let checked = 0;
27345
27669
  let mentioned = 0;
27346
27670
  let cited = 0;
@@ -27374,8 +27698,14 @@ function countPeriod(snaps, competitors2) {
27374
27698
  }
27375
27699
  }
27376
27700
  const mentionShare = buildMentionShare(
27377
- snaps.map((s) => ({ projectMentioned: s.answerMentioned === true, answerText: s.answerText })),
27378
- { competitors: competitors2 }
27701
+ snaps.map((s) => ({
27702
+ // Share uses current identity; named-rate counts above deliberately keep
27703
+ // their historical persisted-boolean semantics.
27704
+ projectMentioned: s.answerText ? determineAnswerMentioned(s.answerText, [...projectBrandNames], []) : s.answerMentioned === true,
27705
+ answerText: s.answerText,
27706
+ queryClass: queryClassOf(s)
27707
+ })),
27708
+ { competitors: competitors2, classificationAvailable }
27379
27709
  );
27380
27710
  return {
27381
27711
  checked,
@@ -27401,6 +27731,10 @@ function isUnknownModelEvidence(evidence) {
27401
27731
  }
27402
27732
  function computeVisibilityCompare(input) {
27403
27733
  const attribution = buildQueryAttribution(input.queries);
27734
+ const classifier = compileQueryClassifier(input.brandNames ?? []);
27735
+ const classificationAvailable = classifier !== null;
27736
+ const queryTextById = new Map(input.queries.map((q) => [q.id, q.query]));
27737
+ const queryClassOf = (snap) => classifier ? classifier.classify(queryTextById.get(snap.queryId) ?? snap.queryText) : null;
27404
27738
  const fromObs = observed(input.from.snapshots, attribution);
27405
27739
  const toObs = observed(input.to.snapshots, attribution);
27406
27740
  const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
@@ -27410,8 +27744,8 @@ function computeVisibilityCompare(input) {
27410
27744
  const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
27411
27745
  const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
27412
27746
  const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
27413
- const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors);
27414
- const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors);
27747
+ const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
27748
+ const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
27415
27749
  const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
27416
27750
  const fromEvidence = classifyModelEvidence(fromCandidateCounts.modelEvidence.get(provider) ?? []);
27417
27751
  const toEvidence = classifyModelEvidence(toCandidateCounts.modelEvidence.get(provider) ?? []);
@@ -27430,8 +27764,8 @@ function computeVisibilityCompare(input) {
27430
27764
  const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
27431
27765
  const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
27432
27766
  const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
27433
- const fromCounts = countPeriod(fromSnaps, input.competitors);
27434
- const toCounts = countPeriod(toSnaps, input.competitors);
27767
+ const fromCounts = countPeriod(fromSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
27768
+ const toCounts = countPeriod(toSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
27435
27769
  const shareCounts = (c) => ({
27436
27770
  proj: c.mentionShare.breakdown.projectMentionSnapshots,
27437
27771
  comp: c.mentionShare.breakdown.competitorMentionSnapshots
@@ -27442,9 +27776,10 @@ function computeVisibilityCompare(input) {
27442
27776
  metric(
27443
27777
  "mention-share-of-voice",
27444
27778
  "Named share of voice",
27779
+ classificationAvailable ? "non-brand" : "pooled",
27445
27780
  true,
27446
- period(fromShare.proj, fromShare.proj + fromShare.comp),
27447
- period(toShare.proj, toShare.proj + toShare.comp),
27781
+ input.competitors.length > 0 ? period(fromShare.proj, fromShare.proj + fromShare.comp) : unavailableCompetitivePeriod(fromShare.proj),
27782
+ input.competitors.length > 0 ? period(toShare.proj, toShare.proj + toShare.comp) : unavailableCompetitivePeriod(toShare.proj),
27448
27783
  continuityBlock
27449
27784
  ),
27450
27785
  // Share of voice is undefined without a competitive frame: with zero
@@ -27455,14 +27790,16 @@ function computeVisibilityCompare(input) {
27455
27790
  metric(
27456
27791
  "cited-share-of-voice",
27457
27792
  "Cited share of voice",
27793
+ "all",
27458
27794
  true,
27459
- input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : period(0, 0),
27460
- input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : period(0, 0),
27795
+ input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : unavailableCompetitivePeriod(fromCounts.projectCited),
27796
+ input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : unavailableCompetitivePeriod(toCounts.projectCited),
27461
27797
  continuityBlock
27462
27798
  ),
27463
27799
  metric(
27464
27800
  "mention-rate",
27465
27801
  "Named rate",
27802
+ "all",
27466
27803
  false,
27467
27804
  period(fromCounts.mentioned, fromCounts.checked),
27468
27805
  period(toCounts.mentioned, toCounts.checked),
@@ -27471,6 +27808,7 @@ function computeVisibilityCompare(input) {
27471
27808
  metric(
27472
27809
  "cited-rate",
27473
27810
  "Cited rate",
27811
+ "all",
27474
27812
  false,
27475
27813
  period(fromCounts.cited, fromCounts.total),
27476
27814
  period(toCounts.cited, toCounts.total),
@@ -27628,7 +27966,8 @@ async function visibilityStatsRoutes(app) {
27628
27966
  lastRuns: lastRunsRaw,
27629
27967
  groupBy: groupByRaw,
27630
27968
  month: monthRaw,
27631
- shareOfVoice: shareOfVoiceRaw
27969
+ shareOfVoice: shareOfVoiceRaw,
27970
+ queryClass: queryClassRaw
27632
27971
  } = request.query;
27633
27972
  let groupBy = null;
27634
27973
  if (groupByRaw !== void 0 && groupByRaw !== "") {
@@ -27640,6 +27979,9 @@ async function visibilityStatsRoutes(app) {
27640
27979
  const hasLastRuns = lastRunsRaw !== void 0 && lastRunsRaw !== "";
27641
27980
  const hasMonth = monthRaw !== void 0 && monthRaw !== "";
27642
27981
  const wantShareOfVoice = shareOfVoiceRaw === "1" || shareOfVoiceRaw === "true";
27982
+ const requestedQueryClass = queryClassRaw === void 0 || queryClassRaw === "" ? "non-brand" : queryClassRaw === "branded" || queryClassRaw === "non-brand" ? queryClassRaw : (() => {
27983
+ throw validationError('"queryClass" must be "branded" or "non-brand"');
27984
+ })();
27643
27985
  if (hasLastRuns && (hasSince || hasUntil)) {
27644
27986
  throw validationError('"lastRuns" cannot be combined with "since"/"until" \u2014 use one or the other');
27645
27987
  }
@@ -27699,10 +28041,6 @@ async function visibilityStatsRoutes(app) {
27699
28041
  let shareOfVoice;
27700
28042
  if (wantShareOfVoice) {
27701
28043
  const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
27702
- const mentionShareCompetitors = competitorRows.map((c) => ({
27703
- domain: c.domain,
27704
- brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
27705
- }));
27706
28044
  const sovSnapshots = runIds.length > 0 ? app.db.select({
27707
28045
  queryId: querySnapshots.queryId,
27708
28046
  queryText: querySnapshots.queryText,
@@ -27710,16 +28048,29 @@ async function visibilityStatsRoutes(app) {
27710
28048
  answerText: querySnapshots.answerText
27711
28049
  }).from(querySnapshots).where(inArray14(querySnapshots.runId, runIds)).all() : [];
27712
28050
  const attributedSovSnapshots = sovSnapshots.filter((s) => resolveCurrentQuery(queryAttribution, s) !== void 0);
27713
- const result = buildMentionShare(
27714
- attributedSovSnapshots.map((s) => ({ projectMentioned: s.answerMentioned === true, answerText: s.answerText })),
27715
- { competitors: mentionShareCompetitors }
27716
- );
27717
- const b = result.breakdown;
28051
+ const inputs = buildMentionShareInputs({
28052
+ project,
28053
+ competitorDomains: competitorRows.map((c) => c.domain),
28054
+ snapshots: attributedSovSnapshots,
28055
+ // Attribution prefers the current query row by id; classification must
28056
+ // use that same text or a rename can put the stats row and its SoV row
28057
+ // in different query classes.
28058
+ queryTextById: new Map(projectQueries.map((q) => [q.id, q.query]))
28059
+ });
28060
+ const result = buildMentionShare(inputs.snapshots, {
28061
+ competitors: inputs.competitors,
28062
+ classificationAvailable: inputs.classified
28063
+ });
28064
+ const servedQueryClass = result.scope === "pooled" ? "pooled" : requestedQueryClass;
28065
+ const b = servedQueryClass === "branded" ? result.branded : result.breakdown;
27718
28066
  const denom = b.projectMentionSnapshots + b.competitorMentionSnapshots;
27719
28067
  shareOfVoice = {
27720
- // `null` (not 0) when there is no competitive frame configured — a 0 here
27721
- // would read as "losing" when the head-to-head metric is simply undefined.
27722
- percent: competitorRows.length === 0 ? null : denom > 0 ? Math.round(b.projectMentionSnapshots / denom * 100) : 0,
28068
+ queryClass: servedQueryClass,
28069
+ // `null` (not 0) when there is no competitive frame or nothing in the
28070
+ // requested class was named. Both are undefined 0/0 proportions, not a
28071
+ // competitive loss.
28072
+ percent: competitorRows.length === 0 || denom === 0 ? null : Math.round(b.projectMentionSnapshots / denom * 100),
28073
+ competitorCount: competitorRows.length,
27723
28074
  projectMentions: b.projectMentionSnapshots,
27724
28075
  competitorMentions: b.competitorMentionSnapshots,
27725
28076
  snapshotsWithAnswerText: b.snapshotsWithAnswerText,
@@ -27769,10 +28120,7 @@ async function visibilityStatsRoutes(app) {
27769
28120
  }
27770
28121
  const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq32(queries.projectId, project.id)).all();
27771
28122
  const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
27772
- const competitorInputs = competitorRows.map((c) => ({
27773
- domain: c.domain,
27774
- brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
27775
- }));
28123
+ const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows.map((c) => c.domain));
27776
28124
  const loadMonth = (bounds) => {
27777
28125
  const sinceMs = Date.parse(bounds.since);
27778
28126
  const untilMs = Date.parse(bounds.until);
@@ -27798,6 +28146,7 @@ async function visibilityStatsRoutes(app) {
27798
28146
  project: project.name,
27799
28147
  queries: projectQueries,
27800
28148
  competitors: competitorInputs,
28149
+ brandNames: effectiveBrandNames(project),
27801
28150
  from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
27802
28151
  to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
27803
28152
  });
@@ -28111,25 +28460,35 @@ async function compositeRoutes(app) {
28111
28460
  }
28112
28461
  }
28113
28462
  const configuredApiProviders = project.providers.filter((p) => !p.startsWith("cdp:"));
28114
- const mentionShareCompetitors = competitorRows.map((c) => ({
28115
- domain: c.domain,
28116
- // Single brand token derived from the registrable domain (e.g.
28117
- // "offers.roofle.com" → "roofle"). Future PR can layer operator-curated
28118
- // aliases on top via a `competitor_aliases` column.
28119
- brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
28463
+ const mentionShareInputs = buildMentionShareInputs({
28464
+ project,
28465
+ competitorDomains: competitorRows.map((c) => c.domain),
28466
+ snapshots: trackedLatest,
28467
+ queryTextById: queryLookup.byId
28468
+ });
28469
+ const competitiveSignalResolver = compileCompetitiveSignalResolver(
28470
+ competitorRows.map((c) => c.domain)
28471
+ );
28472
+ const gapSignalSnapshots = trackedLatest.map((snapshot) => ({
28473
+ ...snapshot,
28474
+ ...competitiveSignalResolver.resolve({
28475
+ citedDomains: snapshot.citedDomains,
28476
+ groundingSources: snapshot.groundingSources,
28477
+ answerText: snapshot.answerText
28478
+ })
28120
28479
  }));
28121
28480
  const scores = {
28122
28481
  mention: buildMentionCoverage(trackedLatest, { configuredApiProviders }),
28123
28482
  visibility: buildVisibilityScore(trackedLatest, { configuredApiProviders }),
28124
28483
  mentionShare: buildMentionShare(
28125
- trackedLatest.map((s) => ({
28126
- projectMentioned: s.answerMentioned === true,
28127
- answerText: s.answerText
28128
- })),
28129
- { competitors: mentionShareCompetitors }
28484
+ mentionShareInputs.snapshots,
28485
+ {
28486
+ competitors: mentionShareInputs.competitors,
28487
+ classificationAvailable: mentionShareInputs.classified
28488
+ }
28130
28489
  ),
28131
- gapQueries: buildGapQueryScore(trackedLatest),
28132
- mentionGaps: buildMentionGapScore(trackedLatest),
28490
+ gapQueries: buildGapQueryScore(gapSignalSnapshots),
28491
+ mentionGaps: buildMentionGapScore(gapSignalSnapshots),
28133
28492
  indexCoverage: buildIndexCoverageScore(app, project.id),
28134
28493
  competitorPressure: buildCompetitorPressureScore(
28135
28494
  trackedLatest,
@@ -28311,6 +28670,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
28311
28670
  citationState: querySnapshots.citationState,
28312
28671
  answerMentioned: querySnapshots.answerMentioned,
28313
28672
  answerText: querySnapshots.answerText,
28673
+ rawResponse: querySnapshots.rawResponse,
28314
28674
  competitorOverlap: querySnapshots.competitorOverlap,
28315
28675
  citedDomains: querySnapshots.citedDomains
28316
28676
  }).from(querySnapshots).where(inArray15(querySnapshots.runId, [...runIds])).all();
@@ -28342,6 +28702,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
28342
28702
  citationState: row.citationState,
28343
28703
  answerMentioned: row.answerMentioned,
28344
28704
  answerText: row.answerText,
28705
+ groundingSources: parseOverviewGroundingSources(row.rawResponse),
28345
28706
  competitorOverlap: row.competitorOverlap,
28346
28707
  citedDomains: row.citedDomains
28347
28708
  });
@@ -28349,6 +28710,16 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
28349
28710
  }
28350
28711
  return result;
28351
28712
  }
28713
+ function parseOverviewGroundingSources(rawResponse) {
28714
+ const parsed = parseJsonColumn(rawResponse, {});
28715
+ const sources = parsed.groundingSources;
28716
+ if (!Array.isArray(sources)) return [];
28717
+ return sources.flatMap((source) => {
28718
+ if (!source || typeof source !== "object") return [];
28719
+ const uri = source.uri;
28720
+ return typeof uri === "string" ? [{ uri }] : [];
28721
+ });
28722
+ }
28352
28723
  function summarizeFromSnapshots(snapshots) {
28353
28724
  const empty = {
28354
28725
  queryCounts: {
@@ -29435,9 +29806,15 @@ var compareToQueryParameter = {
29435
29806
  var shareOfVoiceQueryParameter = {
29436
29807
  name: "shareOfVoice",
29437
29808
  in: "query",
29438
- description: 'Set to "1" to include pooled share of voice (project vs tracked-competitor brand mentions in answer text) across the window.',
29809
+ description: 'Set to "1" to include share of voice (project vs tracked-competitor brand mentions in answer text) across the window. Scoped by `queryClass`, which defaults to non-brand.',
29439
29810
  schema: { type: "string", enum: ["1"] }
29440
29811
  };
29812
+ var shareOfVoiceQueryClassParameter = {
29813
+ name: "queryClass",
29814
+ in: "query",
29815
+ description: "Which query class `shareOfVoice` covers. Defaults to `non-brand`: a branded query names the project, so it is mentioned on nearly all of them and a competitor structurally cannot be, and one shared denominator would report brand recall as category placement. Never pooled across classes.",
29816
+ schema: { type: "string", enum: ["branded", "non-brand"], default: "non-brand" }
29817
+ };
29441
29818
  var wordpressEnvQueryParameter = {
29442
29819
  name: "env",
29443
29820
  in: "query",
@@ -31180,7 +31557,7 @@ var routeCatalog = [
31180
31557
  method: "get",
31181
31558
  path: "/api/v1/projects/{name}/visibility-stats",
31182
31559
  summary: "Get aggregated mention/citation stats per query",
31183
- description: "Per-query mention (answer-text) and citation (source-list) counts with a sample size, pooled across many answer-visibility runs (probe-excluded). Tri-state aware: `checked` counts only snapshots where answerMentioned was recorded (null = not checked is excluded). Lets a consumer compute confidence-aware (e.g. Wilson) proportions without N+1 run fetches. With no since/until/lastRuns, EVERY completed/partial answer-visibility run is pooled \u2014 `window.runCount` reports how many; bound the window with lastRuns, since/until, or month=YYYY-MM for a recent sample. Set groupBy=provider for a per-provider breakdown whose counts sum to the pooled counts, or shareOfVoice=1 to add pooled share of voice vs tracked competitors.",
31560
+ description: "Per-query mention (answer-text) and citation (source-list) counts with a sample size, pooled across many answer-visibility runs (probe-excluded). Tri-state aware: `checked` counts only snapshots where answerMentioned was recorded (null = not checked is excluded). Lets a consumer compute confidence-aware (e.g. Wilson) proportions without N+1 run fetches. With no since/until/lastRuns, EVERY completed/partial answer-visibility run is pooled \u2014 `window.runCount` reports how many; bound the window with lastRuns, since/until, or month=YYYY-MM for a recent sample. Set groupBy=provider for a per-provider breakdown whose counts sum to the pooled counts, or shareOfVoice=1 to add share of voice vs tracked competitors (non-brand queries by default; set queryClass=branded for brand recall).",
31184
31561
  tags: ["analytics"],
31185
31562
  parameters: [
31186
31563
  nameParameter,
@@ -31189,7 +31566,8 @@ var routeCatalog = [
31189
31566
  lastRunsQueryParameter,
31190
31567
  groupByProviderQueryParameter,
31191
31568
  monthQueryParameter,
31192
- shareOfVoiceQueryParameter
31569
+ shareOfVoiceQueryParameter,
31570
+ shareOfVoiceQueryClassParameter
31193
31571
  ],
31194
31572
  responses: {
31195
31573
  200: jsonResponse("Aggregated visibility stats returned.", "VisibilityStatsDto"),
@@ -65376,6 +65754,16 @@ function matchesBrandAlias(candidate, aliases) {
65376
65754
  var RECURRENCE_LOOKBACK_RUNS = 5;
65377
65755
  var HISTORY_WINDOW_RUNS = Math.max(PERSISTENT_GAP_THRESHOLD, 5);
65378
65756
  var log = createLogger("IntelligenceService");
65757
+ function readStoredGroundingSources(rawResponse) {
65758
+ const parsed = parseJsonColumn(rawResponse, {});
65759
+ const sources = parsed.groundingSources;
65760
+ if (!Array.isArray(sources)) return [];
65761
+ return sources.flatMap((source) => {
65762
+ if (!source || typeof source !== "object") return [];
65763
+ const uri = source.uri;
65764
+ return typeof uri === "string" ? [{ uri }] : [];
65765
+ });
65766
+ }
65379
65767
  function gbpInsightSlot(typeOrSlot) {
65380
65768
  return typeOrSlot === "gbp-lodging-gap" || typeOrSlot === "gbp-listing-discrepancy" ? "lodging" : typeOrSlot;
65381
65769
  }
@@ -65418,11 +65806,13 @@ var IntelligenceService = class {
65418
65806
  log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
65419
65807
  return null;
65420
65808
  }
65809
+ const trackedCompetitors = this.loadTrackedCompetitors(projectId);
65421
65810
  const currentRun = this.buildRunData(
65422
65811
  runId,
65423
65812
  projectId,
65424
65813
  currentRunRecord.finishedAt ?? currentRunRecord.createdAt,
65425
- currentRunRecord.location ?? null
65814
+ currentRunRecord.location ?? null,
65815
+ trackedCompetitors
65426
65816
  );
65427
65817
  if (currentRun.snapshots.length === 0) {
65428
65818
  log.info("intelligence.skip", { runId, reason: "no snapshots" });
@@ -65437,10 +65827,16 @@ var IntelligenceService = class {
65437
65827
  previousRunRecord.id,
65438
65828
  projectId,
65439
65829
  previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
65440
- previousRunRecord.location ?? null
65830
+ previousRunRecord.location ?? null,
65831
+ trackedCompetitors
65441
65832
  ) : null;
65442
- const trackedCompetitors = this.loadTrackedCompetitors(projectId);
65443
- 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));
65833
+ const history = sameLocationOrdered.slice(0, currentLocIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(
65834
+ r.id,
65835
+ projectId,
65836
+ r.finishedAt ?? r.createdAt,
65837
+ r.location ?? null,
65838
+ trackedCompetitors
65839
+ ));
65444
65840
  if (!previousRun) {
65445
65841
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
65446
65842
  log.info("intelligence.analyzed", {
@@ -65644,11 +66040,13 @@ var IntelligenceService = class {
65644
66040
  * would have produced.
65645
66041
  */
65646
66042
  analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords, opts) {
66043
+ const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
65647
66044
  const currentRun = this.buildRunData(
65648
66045
  runRecord.id,
65649
66046
  runRecord.projectId,
65650
66047
  runRecord.finishedAt ?? runRecord.createdAt,
65651
- runRecord.location ?? null
66048
+ runRecord.location ?? null,
66049
+ trackedCompetitors
65652
66050
  );
65653
66051
  if (currentRun.snapshots.length === 0) {
65654
66052
  return null;
@@ -65657,10 +66055,16 @@ var IntelligenceService = class {
65657
66055
  previousRunRecord.id,
65658
66056
  previousRunRecord.projectId,
65659
66057
  previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
65660
- previousRunRecord.location ?? null
66058
+ previousRunRecord.location ?? null,
66059
+ trackedCompetitors
65661
66060
  ) : null;
65662
- const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
65663
- const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
66061
+ const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(
66062
+ r.id,
66063
+ r.projectId,
66064
+ r.finishedAt ?? r.createdAt,
66065
+ r.location ?? null,
66066
+ trackedCompetitors
66067
+ ));
65664
66068
  if (!previousRun) {
65665
66069
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
65666
66070
  const emptyResult = this.emptyAnalysisResult(result2);
@@ -65930,12 +66334,13 @@ var IntelligenceService = class {
65930
66334
  return { ...insight, severity };
65931
66335
  });
65932
66336
  }
65933
- buildRunData(runId, projectId, completedAt, location = null) {
66337
+ buildRunData(runId, projectId, completedAt, location = null, trackedCompetitors = []) {
65934
66338
  const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq60(projects.id, projectId)).get();
65935
66339
  const projectDomains = projectDomainRow ? effectiveDomains({
65936
66340
  canonicalDomain: projectDomainRow.canonicalDomain,
65937
66341
  ownedDomains: projectDomainRow.ownedDomains
65938
66342
  }) : [];
66343
+ const competitiveSignalResolver = compileCompetitiveSignalResolver(trackedCompetitors);
65939
66344
  const rows = this.db.select({
65940
66345
  query: queries.query,
65941
66346
  // Denormalized query text persisted by v58 — the fallback when the
@@ -65946,7 +66351,7 @@ var IntelligenceService = class {
65946
66351
  citationState: querySnapshots.citationState,
65947
66352
  answerMentioned: querySnapshots.answerMentioned,
65948
66353
  citedDomains: querySnapshots.citedDomains,
65949
- competitorOverlap: querySnapshots.competitorOverlap,
66354
+ rawResponse: querySnapshots.rawResponse,
65950
66355
  snapshotLocation: querySnapshots.location
65951
66356
  }).from(querySnapshots).leftJoin(queries, eq60(querySnapshots.queryId, queries.id)).where(eq60(querySnapshots.runId, runId)).all();
65952
66357
  const snapshots = [];
@@ -65958,7 +66363,10 @@ var IntelligenceService = class {
65958
66363
  continue;
65959
66364
  }
65960
66365
  const domains = r.citedDomains;
65961
- const competitors2 = r.competitorOverlap;
66366
+ const citedCompetitorDomains = competitiveSignalResolver.resolve({
66367
+ citedDomains: domains,
66368
+ groundingSources: readStoredGroundingSources(r.rawResponse)
66369
+ }).citedCompetitorDomains;
65962
66370
  snapshots.push({
65963
66371
  query: resolvedQuery,
65964
66372
  provider: r.provider,
@@ -65976,7 +66384,7 @@ var IntelligenceService = class {
65976
66384
  // we read it from the snapshot row so a stale runs.location can't
65977
66385
  // mask snapshot truth.
65978
66386
  location: r.snapshotLocation ?? location ?? null,
65979
- competitorDomains: competitors2,
66387
+ citedCompetitorDomains,
65980
66388
  // citedDomains is the FULL set (tracked competitors + third-party
65981
66389
  // sources). Cause analysis uses it to name the displacing source
65982
66390
  // when no tracked competitor appears in the response.