@canonry/canonry 4.172.0 → 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-DdZeCGyB.js → AuditHistoryPanel-DnqySUw3.js} +1 -1
  5. package/assets/assets/{BacklinksPage-C3cA1uZG.js → BacklinksPage-CbcHWD-G.js} +1 -1
  6. package/assets/assets/{HistoryPage-ILldZ0uo.js → HistoryPage-BYFSyxuB.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-DdGD-pSU.js → MeasurementPropertyPage-BBnXQPIv.js} +1 -1
  8. package/assets/assets/ProjectPage-Cq5bHpmk.js +9 -0
  9. package/assets/assets/{RunRow-BQACorl6.js → RunRow-lfyHktTf.js} +1 -1
  10. package/assets/assets/{RunsPage-BAt4nfFQ.js → RunsPage-ngnP0Y_D.js} +1 -1
  11. package/assets/assets/{SettingsPage-C8jJCoe8.js → SettingsPage-DEg74T20.js} +1 -1
  12. package/assets/assets/{SiteHealthSection-K8UY4Jnr.js → SiteHealthSection-C6ycXwbt.js} +3 -3
  13. package/assets/assets/{TrafficPage-GuuYfJor.js → TrafficPage-hrJ1IDqA.js} +1 -1
  14. package/assets/assets/{TrafficSourceDetailPage-BUpxbbyO.js → TrafficSourceDetailPage-fu7nPl3E.js} +1 -1
  15. package/assets/assets/{extract-error-message-DhnfPYL6.js → extract-error-message-dcdO9njP.js} +1 -1
  16. package/assets/assets/index-D1mXm81Z.css +1 -0
  17. package/assets/assets/{index-DgNO-ho5.js → index-DpQ_-QC_.js} +27 -27
  18. package/assets/assets/{react-sigma_core.esm.min-B_Td5ND2.js → react-sigma_core.esm.min-DZGxGBSe.js} +1 -1
  19. package/assets/index.html +2 -2
  20. package/dist/{chunk-HJ7W2LXI.js → chunk-EGGQSFQL.js} +4758 -4678
  21. package/dist/{chunk-O226BLC7.js → chunk-PXTSQ67Z.js} +712 -292
  22. package/dist/{chunk-5KGN224W.js → chunk-TI3SZAVI.js} +4 -4
  23. package/dist/{chunk-RR4KSDOD.js → chunk-U5JTZM66.js} +2 -2
  24. package/dist/{chunk-IIFDS3K2.js → chunk-YXDCVM5U.js} +8 -5
  25. package/dist/cli.js +72 -27
  26. package/dist/index.js +4 -4
  27. package/dist/{intelligence-service-KT6K4ODY.js → intelligence-service-H7756PBD.js} +2 -2
  28. package/dist/mcp.js +3 -3
  29. package/package.json +10 -10
  30. package/assets/assets/ProjectPage-BPbmNgA9.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,
@@ -355,6 +357,7 @@ import {
355
357
  hostMatchesAnyDomain,
356
358
  hostMatchesDomain,
357
359
  hostOf,
360
+ inclusiveDayCount,
358
361
  indexingRequestResponseDtoSchema,
359
362
  internalError,
360
363
  isBrowserProvider,
@@ -371,6 +374,7 @@ import {
371
374
  locationContextSchema,
372
375
  loginRequestSchema,
373
376
  mapWithConcurrency,
377
+ matchedAliasKeys,
374
378
  matcherMatchesText,
375
379
  measurementChangesQuerySchema,
376
380
  measurementChangesResponseSchema,
@@ -624,7 +628,7 @@ import {
624
628
  wordpressSchemaDeployResultDtoSchema,
625
629
  wordpressSchemaStatusResultDtoSchema,
626
630
  wordpressStatusDtoSchema
627
- } from "./chunk-HJ7W2LXI.js";
631
+ } from "./chunk-EGGQSFQL.js";
628
632
 
629
633
  // src/intelligence-service.ts
630
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";
@@ -1019,6 +1023,21 @@ var querySnapshots = sqliteTable("query_snapshots", {
1019
1023
  // observation predates the field, NOT that retrieval did not happen.
1020
1024
  retrievalStatus: text("retrieval_status").$type(),
1021
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
+ */
1022
1041
  competitorOverlap: text("competitor_overlap", { mode: "json" }).$type().notNull().default([]),
1023
1042
  recommendedCompetitors: text("recommended_competitors", { mode: "json" }).$type().notNull().default([]),
1024
1043
  location: text("location"),
@@ -7247,9 +7266,9 @@ function analyzeCause(regression, currentSnapshots) {
7247
7266
  const matchingSnaps = currentSnapshots.filter(
7248
7267
  (s) => s.query === regression.query && s.provider === regression.provider && !s.cited
7249
7268
  );
7250
- const withCompetitor = matchingSnaps.find((s) => s.competitorDomains?.length);
7269
+ const withCompetitor = matchingSnaps.find((s) => s.citedCompetitorDomains?.length);
7251
7270
  if (withCompetitor) {
7252
- const competitor = withCompetitor.competitorDomains[0];
7271
+ const competitor = withCompetitor.citedCompetitorDomains[0];
7253
7272
  return {
7254
7273
  cause: "competitor_gain",
7255
7274
  competitorDomain: competitor,
@@ -7487,8 +7506,8 @@ function detectPersistentGaps(runs2, threshold = PERSISTENT_GAP_THRESHOLD) {
7487
7506
  function buildCompetitorQueryMap(run, tracked) {
7488
7507
  const result = /* @__PURE__ */ new Map();
7489
7508
  for (const snap of run.snapshots) {
7490
- if (!snap.query || !snap.competitorDomains || snap.competitorDomains.length === 0) continue;
7491
- for (const domain of snap.competitorDomains) {
7509
+ if (!snap.query || !snap.citedCompetitorDomains || snap.citedCompetitorDomains.length === 0) continue;
7510
+ for (const domain of snap.citedCompetitorDomains) {
7492
7511
  if (!tracked.has(domain)) continue;
7493
7512
  const existing = result.get(domain) ?? /* @__PURE__ */ new Set();
7494
7513
  existing.add(snap.query);
@@ -7753,6 +7772,7 @@ function calculateActionConfidence(input) {
7753
7772
  function buildContentTargetRows(input) {
7754
7773
  const rows = [];
7755
7774
  for (const cq of input.candidateQueries) {
7775
+ const competitiveDomains = competitivePresenceDomains(cq);
7756
7776
  const ourPage = resolveOurPage(cq, input.inventory);
7757
7777
  const ourPageInGroundingSources = cq.ourCitedInLatestRun;
7758
7778
  const ourPageHasSchema = ourPage ? input.wpSchemaAudit.get(ourPage.url) ?? null : null;
@@ -7763,7 +7783,7 @@ function buildContentTargetRows(input) {
7763
7783
  });
7764
7784
  if (!action) continue;
7765
7785
  const hasGsc = cq.gscImpressions > 0;
7766
- const hasCompetitor = cq.competitorDomains.length > 0;
7786
+ const hasCompetitor = competitiveDomains.length > 0;
7767
7787
  if (!hasGsc && !hasCompetitor && !cq.ourCitedInLatestRun) continue;
7768
7788
  const aiReferralFactor = computeAiReferralFactor(
7769
7789
  input.totalAiReferralSessions,
@@ -7772,7 +7792,7 @@ function buildContentTargetRows(input) {
7772
7792
  const scoring = scoreContentTarget({
7773
7793
  gscImpressions: cq.gscImpressions,
7774
7794
  aiReferralFactor,
7775
- competitorCount: cq.competitorDomains.length,
7795
+ competitorCount: competitiveDomains.length,
7776
7796
  recentMissRate: cq.recentMissRate,
7777
7797
  citationCount: cq.competitorCitationCount,
7778
7798
  ourCitedRate: cq.ourCitedRate,
@@ -7783,7 +7803,7 @@ function buildContentTargetRows(input) {
7783
7803
  hasGsc: cq.gscPage !== null,
7784
7804
  gscImpressions: cq.gscImpressions,
7785
7805
  runsOfHistory: cq.runsOfHistory,
7786
- hasCompetitorEvidence: cq.competitorDomains.length > 0,
7806
+ hasCompetitorEvidence: competitiveDomains.length > 0,
7787
7807
  hasInventoryMatch: ourPage?.source === "inventory"
7788
7808
  });
7789
7809
  const targetRef = computeTargetRef({
@@ -7854,12 +7874,12 @@ function buildContentSourceRows(input) {
7854
7874
  function buildContentGapRows(input) {
7855
7875
  const gaps = [];
7856
7876
  for (const cq of input.candidateQueries) {
7857
- if (cq.competitorDomains.length === 0) continue;
7877
+ if (cq.competitorCitedDomains.length === 0) continue;
7858
7878
  if (cq.ourCitedRate >= 1) continue;
7859
7879
  gaps.push({
7860
7880
  query: cq.query,
7861
- competitorDomains: cq.competitorDomains,
7862
- competitorCount: cq.competitorDomains.length,
7881
+ competitorDomains: cq.competitorCitedDomains,
7882
+ competitorCount: cq.competitorCitedDomains.length,
7863
7883
  missRate: clamp012(cq.recentMissRate),
7864
7884
  lastSeenInRunId: input.latestRunId
7865
7885
  });
@@ -7869,6 +7889,12 @@ function buildContentGapRows(input) {
7869
7889
  return b.competitorCount - a.competitorCount;
7870
7890
  });
7871
7891
  }
7892
+ function competitivePresenceDomains(cq) {
7893
+ return [.../* @__PURE__ */ new Set([
7894
+ ...cq.competitorCitedDomains,
7895
+ ...cq.competitorMentionedDomains
7896
+ ])];
7897
+ }
7872
7898
  function resolveOurPage(cq, inventory) {
7873
7899
  if (cq.gscPage && cq.gscPosition !== null) {
7874
7900
  return { url: cq.gscPage, position: cq.gscPosition, source: "gsc" };
@@ -8106,6 +8132,46 @@ function citedDomainBelongsToProject(citedDomain, projectDomains) {
8106
8132
  return hostMatchesAnyDomain(citedDomain, projectDomains);
8107
8133
  }
8108
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
+
8109
8175
  // ../intelligence/src/competitor-landscape.ts
8110
8176
  function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
8111
8177
  let projectCitationCount = 0;
@@ -8113,14 +8179,22 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
8113
8179
  for (const c of competitorDomains) {
8114
8180
  competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
8115
8181
  }
8182
+ const competitorResolver = compileCompetitiveSignalResolver(competitorDomains);
8183
+ const projectResolver = compileCompetitiveSignalResolver(projectDomains);
8116
8184
  for (const snap of snapshots) {
8117
8185
  const q = queryLookup.byId.get(snap.queryId);
8118
- const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
8119
- 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) {
8120
8191
  projectCitationCount++;
8121
8192
  }
8193
+ const citedCompetitors = new Set(
8194
+ competitorResolver.resolve(evidence).citedCompetitorDomains
8195
+ );
8122
8196
  for (const competitor of competitorDomains) {
8123
- if (allDomains.some((d) => citedDomainBelongsToProject(d, [competitor]))) {
8197
+ if (citedCompetitors.has(competitor)) {
8124
8198
  const entry = competitorMap.get(competitor);
8125
8199
  entry.count++;
8126
8200
  if (q) entry.queries.add(q);
@@ -8162,55 +8236,84 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
8162
8236
  }
8163
8237
 
8164
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
+ }
8165
8273
  function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
8166
- let projectMentionCount = 0;
8167
- let totalAnswerSnapshots = 0;
8168
- const competitorMap = /* @__PURE__ */ new Map();
8169
- for (const c of competitorDomains) {
8170
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set() });
8171
- }
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
+ );
8172
8284
  for (const snap of snapshots) {
8173
8285
  const text2 = snap.answerText;
8174
8286
  if (!text2) continue;
8175
- totalAnswerSnapshots++;
8176
8287
  const q = queryLookup.byId.get(snap.queryId);
8177
- 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(
8178
8292
  text2,
8179
8293
  [...projectBrandNames],
8180
8294
  [...projectDomains]
8181
8295
  );
8182
- if (projectMentioned) projectMentionCount++;
8296
+ if (projectMentioned) tally.projectMentionCount++;
8183
8297
  for (const competitor of competitorDomains) {
8184
- const brand = brandLabelFromDomain(competitor);
8185
- const mentioned = determineAnswerMentioned(text2, brand ? [brand] : [], [competitor]);
8298
+ const aliases = competitorAliases.get(competitor) ?? [];
8299
+ const mentioned = determineAnswerMentioned(text2, aliases, [competitor]);
8186
8300
  if (mentioned) {
8187
- const entry = competitorMap.get(competitor);
8301
+ const entry = tally.competitorMap.get(competitor);
8188
8302
  entry.count++;
8189
8303
  if (q) entry.queries.add(q);
8190
8304
  }
8191
8305
  }
8192
8306
  }
8193
- const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
8194
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
8195
- const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
8196
- let pressureLabel = "None";
8197
- if (data.count > 0) {
8198
- if (ratio >= 0.5) pressureLabel = "High";
8199
- else if (ratio >= 0.2) pressureLabel = "Moderate";
8200
- else pressureLabel = "Low";
8201
- }
8202
- const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
8203
- return {
8204
- domain,
8205
- mentionCount: data.count,
8206
- totalCount: totalAnswerSnapshots,
8207
- pressureLabel,
8208
- mentionedQueries: [...data.queries].sort(),
8209
- sharePct
8210
- };
8211
- });
8212
- competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
8213
- 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
+ };
8214
8317
  }
8215
8318
 
8216
8319
  // ../intelligence/src/ai-source-origin.ts
@@ -8503,14 +8606,14 @@ function buildGapQueryScore(snapshots) {
8503
8606
  const byQuery = /* @__PURE__ */ new Map();
8504
8607
  for (const snap of snapshots) {
8505
8608
  const key = snap.queryId;
8506
- const current = byQuery.get(key) ?? { cited: false, competitorOverlap: /* @__PURE__ */ new Set() };
8609
+ const current = byQuery.get(key) ?? { cited: false, citedCompetitors: /* @__PURE__ */ new Set() };
8507
8610
  if (snap.citationState === CitationStates.cited) current.cited = true;
8508
- for (const domain of snap.competitorOverlap) current.competitorOverlap.add(domain);
8611
+ for (const domain of snap.citedCompetitorDomains) current.citedCompetitors.add(domain);
8509
8612
  byQuery.set(key, current);
8510
8613
  }
8511
8614
  const totalCount = byQuery.size;
8512
8615
  const gapCount = [...byQuery.values()].filter(
8513
- (entry) => !entry.cited && entry.competitorOverlap.size > 0
8616
+ (entry) => !entry.cited && entry.citedCompetitors.size > 0
8514
8617
  ).length;
8515
8618
  const gapQueryLabel = gapCount === 1 ? "query" : "queries";
8516
8619
  return {
@@ -8540,14 +8643,14 @@ function buildMentionGapScore(snapshots) {
8540
8643
  const byQuery = /* @__PURE__ */ new Map();
8541
8644
  for (const snap of snapshots) {
8542
8645
  const key = snap.queryId;
8543
- const current = byQuery.get(key) ?? { mentioned: false, competitorOverlap: /* @__PURE__ */ new Set() };
8646
+ const current = byQuery.get(key) ?? { mentioned: false, mentionedCompetitors: /* @__PURE__ */ new Set() };
8544
8647
  if (snap.answerMentioned === true) current.mentioned = true;
8545
- for (const domain of snap.competitorOverlap) current.competitorOverlap.add(domain);
8648
+ for (const domain of snap.mentionedCompetitorDomains) current.mentionedCompetitors.add(domain);
8546
8649
  byQuery.set(key, current);
8547
8650
  }
8548
8651
  const totalCount = byQuery.size;
8549
8652
  const gapCount = [...byQuery.values()].filter(
8550
- (entry) => !entry.mentioned && entry.competitorOverlap.size > 0
8653
+ (entry) => !entry.mentioned && entry.mentionedCompetitors.size > 0
8551
8654
  ).length;
8552
8655
  const gapQueryLabel = gapCount === 1 ? "query" : "queries";
8553
8656
  return {
@@ -8579,7 +8682,7 @@ function buildCompetitorPressureScore(snapshots, competitorDomains, totalTracked
8579
8682
  }
8580
8683
  let overlapCount = 0;
8581
8684
  for (const snap of snapshots) {
8582
- if (snap.competitorOverlap.some((domain) => hostMatchesAnyDomain(domain, competitorDomains))) {
8685
+ if (snap.citedDomains.some((domain) => hostMatchesAnyDomain(domain, competitorDomains))) {
8583
8686
  overlapCount++;
8584
8687
  }
8585
8688
  }
@@ -8604,7 +8707,7 @@ function buildOverviewCompetitors(snapshots, competitors2, queryLookup) {
8604
8707
  return competitors2.map((competitor, index2) => {
8605
8708
  const citedQuerySet = /* @__PURE__ */ new Set();
8606
8709
  for (const snap of snapshots) {
8607
- 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))) {
8608
8711
  if (snap.queryId) citedQuerySet.add(snap.queryId);
8609
8712
  }
8610
8713
  }
@@ -8715,15 +8818,62 @@ function collectProviderKeys(perRun) {
8715
8818
  }
8716
8819
 
8717
8820
  // ../intelligence/src/mention-share.ts
8718
- function buildMentionShare(snapshots, options) {
8719
- 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".';
8720
- 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 {
8721
8827
  projectMentionSnapshots: 0,
8722
- competitorMentionSnapshots: 0,
8723
- perCompetitor: [],
8724
8828
  snapshotsWithAnswerText: 0,
8725
- 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
8726
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.";
8727
8877
  if (snapshots.length === 0) {
8728
8878
  return {
8729
8879
  label: "Mention Share",
@@ -8733,10 +8883,14 @@ function buildMentionShare(snapshots, options) {
8733
8883
  description: "No mention share data yet. Trigger a run to start tracking.",
8734
8884
  tooltip,
8735
8885
  trend: [],
8736
- breakdown: emptyBreakdown
8886
+ scope,
8887
+ breakdown: headline,
8888
+ branded: brandedBreakdown
8737
8889
  };
8738
8890
  }
8739
8891
  if (options.competitors.length === 0) {
8892
+ headline = { ...headline, score: null };
8893
+ brandedBreakdown = { ...brandedBreakdown, score: null };
8740
8894
  return {
8741
8895
  label: "Mention Share",
8742
8896
  value: "Add competitors",
@@ -8745,83 +8899,95 @@ function buildMentionShare(snapshots, options) {
8745
8899
  description: "Mention Share is a head-to-head competitive metric \u2014 add tracked competitors to compare brand mention rates.",
8746
8900
  tooltip,
8747
8901
  trend: [],
8748
- breakdown: emptyBreakdown
8902
+ scope,
8903
+ breakdown: headline,
8904
+ branded: brandedBreakdown
8749
8905
  };
8750
8906
  }
8751
- let projectMentionSnapshots = 0;
8752
- let snapshotsWithAnswerText = 0;
8753
- const competitorCounts = /* @__PURE__ */ new Map();
8754
- const competitorAliases = /* @__PURE__ */ new Map();
8755
- for (const competitor of options.competitors) {
8756
- competitorCounts.set(competitor.domain, 0);
8757
- competitorAliases.set(
8758
- competitor.domain,
8759
- competitor.brandTokens.filter((alias2) => brandKeyFromText(alias2).length >= 3)
8760
- );
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
+ };
8761
8922
  }
8762
- for (const snap of snapshots) {
8763
- const text2 = snap.answerText ?? "";
8764
- if (text2.length === 0) continue;
8765
- snapshotsWithAnswerText++;
8766
- if (snap.projectMentioned) projectMentionSnapshots++;
8767
- for (const competitor of options.competitors) {
8768
- const aliases = competitorAliases.get(competitor.domain) ?? [];
8769
- if (textContainsAnyBrandAlias(text2, aliases)) {
8770
- competitorCounts.set(competitor.domain, (competitorCounts.get(competitor.domain) ?? 0) + 1);
8771
- }
8772
- }
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
+ };
8773
8951
  }
8774
- const competitorMentionSnapshots = [...competitorCounts.values()].reduce((a, b) => a + b, 0);
8775
- const denom = projectMentionSnapshots + competitorMentionSnapshots;
8776
- const score = denom > 0 ? Math.round(projectMentionSnapshots / denom * 100) : 0;
8777
- const perCompetitor = options.competitors.map((c) => ({
8778
- domain: c.domain,
8779
- mentionSnapshots: competitorCounts.get(c.domain) ?? 0,
8780
- shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
8781
- })).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots);
8782
- const breakdown = {
8783
- projectMentionSnapshots,
8784
- competitorMentionSnapshots,
8785
- perCompetitor,
8786
- snapshotsWithAnswerText,
8787
- snapshotsTotal: snapshots.length
8788
- };
8789
- const description = describe({
8790
- score,
8791
- projectMentionSnapshots,
8792
- competitorMentionSnapshots,
8793
- perCompetitor
8794
- });
8795
8952
  return {
8796
8953
  label: "Mention Share",
8797
- value: denom > 0 ? `${score}` : "0",
8798
- delta: denom > 0 ? `${projectMentionSnapshots} of ${denom} brand mentions` : "No brand mentions in this run",
8799
- tone: denom > 0 ? mentionShareTone(score) : "neutral",
8800
- 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 }),
8801
8958
  tooltip,
8802
8959
  trend: [],
8803
- progress: denom > 0 ? score : 0,
8804
- breakdown
8960
+ progress: score,
8961
+ scope,
8962
+ breakdown: headline,
8963
+ branded: brandedBreakdown
8805
8964
  };
8806
8965
  }
8966
+ function scopeLabel(scope, body) {
8967
+ return scope === "non-brand" ? `${body} \xB7 non-brand queries` : `${body} \xB7 pooled queries \xB7 classification unavailable`;
8968
+ }
8807
8969
  function mentionShareTone(score) {
8808
8970
  if (score >= 50) return "positive";
8809
8971
  if (score >= 25) return "caution";
8810
8972
  return "negative";
8811
8973
  }
8812
8974
  function describe(parts) {
8813
- 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.` : "";
8814
8979
  if (projectMentionSnapshots === 0 && competitorMentionSnapshots === 0) {
8815
- 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}`;
8816
8981
  }
8817
8982
  if (competitorMentionSnapshots === 0) {
8818
- 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}`;
8819
8984
  }
8820
8985
  const top = perCompetitor[0];
8986
+ const total = projectMentionSnapshots + competitorMentionSnapshots;
8821
8987
  if (!top) {
8822
- return `${score}% of brand mentions are you (${projectMentionSnapshots} of ${projectMentionSnapshots + competitorMentionSnapshots}).`;
8988
+ return `${score}% of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
8823
8989
  }
8824
- 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}`;
8825
8991
  }
8826
8992
 
8827
8993
  // ../intelligence/src/suggested-queries.ts
@@ -13283,6 +13449,8 @@ function loadRunDetail(app, run) {
13283
13449
  ownedDomains: projects.ownedDomains,
13284
13450
  aliases: projects.aliases
13285
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);
13286
13454
  const snapshots = app.db.select({
13287
13455
  id: querySnapshots.id,
13288
13456
  runId: querySnapshots.runId,
@@ -13316,6 +13484,14 @@ function loadRunDetail(app, run) {
13316
13484
  ...formatRun(run),
13317
13485
  snapshots: snapshots.map((s) => {
13318
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
+ });
13319
13495
  const answerMentioned = project ? resolveSnapshotAnswerMentioned(s, project) : s.answerMentioned ?? false;
13320
13496
  return {
13321
13497
  id: s.id,
@@ -13336,6 +13512,8 @@ function loadRunDetail(app, run) {
13336
13512
  sourceCount: s.sourceCount,
13337
13513
  resolvedCount: s.resolvedCount,
13338
13514
  captureVersion: s.captureVersion,
13515
+ ...competitiveSignals,
13516
+ // Legacy mixed signal retained for backwards compatibility.
13339
13517
  competitorOverlap: s.competitorOverlap,
13340
13518
  recommendedCompetitors: s.recommendedCompetitors,
13341
13519
  matchedTerms: project ? resolveSnapshotMatchedTerms(s, project) : [],
@@ -20164,6 +20342,8 @@ async function historyRoutes(app) {
20164
20342
  if (projectRuns.length === 0) {
20165
20343
  return reply.send({ snapshots: [], total: 0 });
20166
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);
20167
20347
  const allSnapshots = app.db.select({
20168
20348
  id: querySnapshots.id,
20169
20349
  runId: querySnapshots.runId,
@@ -20183,6 +20363,7 @@ async function historyRoutes(app) {
20183
20363
  competitorOverlap: querySnapshots.competitorOverlap,
20184
20364
  recommendedCompetitors: querySnapshots.recommendedCompetitors,
20185
20365
  location: querySnapshots.location,
20366
+ rawResponse: querySnapshots.rawResponse,
20186
20367
  createdAt: querySnapshots.createdAt
20187
20368
  }).from(querySnapshots).leftJoin(queries, eq22(querySnapshots.queryId, queries.id)).where(inArray8(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc7(querySnapshots.createdAt)).all();
20188
20369
  const locationFilter = request.query.location;
@@ -20190,29 +20371,43 @@ async function historyRoutes(app) {
20190
20371
  const total = filtered.length;
20191
20372
  const paged = filtered.slice(offset, offset + limit);
20192
20373
  return reply.send({
20193
- snapshots: paged.map((s) => ({
20194
- id: s.id,
20195
- runId: s.runId,
20196
- queryId: s.queryId,
20197
- query: s.query,
20198
- provider: s.provider,
20199
- model: s.model,
20200
- citationState: s.citationState,
20201
- answerMentioned: resolveSnapshotAnswerMentioned(s, project),
20202
- visibilityState: resolveSnapshotVisibilityState(s, project),
20203
- mentionState: resolveSnapshotMentionState(s, project),
20204
- answerText: s.answerText,
20205
- citedDomains: s.citedDomains,
20206
- citedUrls: s.citedUrls,
20207
- captureStatus: s.captureStatus,
20208
- sourceCount: s.sourceCount,
20209
- resolvedCount: s.resolvedCount,
20210
- captureVersion: s.captureVersion,
20211
- competitorOverlap: s.competitorOverlap,
20212
- recommendedCompetitors: s.recommendedCompetitors,
20213
- location: s.location,
20214
- createdAt: s.createdAt
20215
- })),
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
+ }),
20216
20411
  total
20217
20412
  });
20218
20413
  });
@@ -20451,6 +20646,61 @@ function parseOptionalPositiveInt(value, max2) {
20451
20646
  // ../api-routes/src/analytics.ts
20452
20647
  import { and as and17, desc as desc8, eq as eq23, gte as gte4, inArray as inArray9, lt as lt4 } from "drizzle-orm";
20453
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
+
20454
20704
  // ../api-routes/src/model-evidence.ts
20455
20705
  function classifyModelEvidence(values) {
20456
20706
  const models = /* @__PURE__ */ new Set();
@@ -20579,6 +20829,8 @@ function measuredWhole(db, run) {
20579
20829
  async function analyticsRoutes(app) {
20580
20830
  app.get("/projects/:name/analytics/metrics", async (request, reply) => {
20581
20831
  const project = resolveProject(app.db, request.params.name);
20832
+ const classifyQuery = projectQueryClassifier(project);
20833
+ const mentionShareScope = classifyQuery ? "non-brand" : "pooled";
20582
20834
  const window = parseWindow(request.query.window);
20583
20835
  const cutoff = windowCutoff(window);
20584
20836
  const projectRuns = app.db.select().from(runs).where(and17(
@@ -20591,6 +20843,7 @@ async function analyticsRoutes(app) {
20591
20843
  if (projectRuns.length === 0) {
20592
20844
  return reply.send({
20593
20845
  window,
20846
+ mentionShareScope,
20594
20847
  buckets: [],
20595
20848
  overall: { citationRate: 0, cited: 0, total: 0, mentionRate: 0, mentionedCount: 0 },
20596
20849
  byProvider: {},
@@ -20637,16 +20890,16 @@ async function analyticsRoutes(app) {
20637
20890
  ...s,
20638
20891
  runCreatedAt: runCreatedAt.get(s.runId),
20639
20892
  resolvedMentioned: resolveSnapshotAnswerMentioned(s, project),
20893
+ queryClass: classifyQuery ? classifyQuery((s.queryId ? queryTextById.get(s.queryId) : void 0) ?? s.queryText ?? null) : null,
20640
20894
  // Falls back to the id if no text resolves, so two distinct queries can
20641
20895
  // never collapse into one member on an empty key. (An orphan only gets
20642
20896
  // this far when its text matched the basket, so its key is never empty.)
20643
20897
  basketKey: normalizeQueryText(s.queryText ?? (s.queryId ? queryTextById.get(s.queryId) ?? "" : "")) || s.queryId || "",
20644
20898
  runBasketRevision: runBasketRevision.get(s.runId) ?? null
20645
20899
  }));
20646
- const mentionShareCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq23(competitors.projectId, project.id)).all().map((c) => ({
20647
- domain: c.domain,
20648
- brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
20649
- }));
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
+ );
20650
20903
  const overall = computeProviderMetric(allSnapshots);
20651
20904
  const byProvider = {};
20652
20905
  const providers = new Set(allSnapshots.map((s) => s.provider));
@@ -20657,7 +20910,15 @@ async function analyticsRoutes(app) {
20657
20910
  const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
20658
20911
  const spanDays = Math.max(1, Math.ceil((latest.getTime() - earliest.getTime()) / 864e5));
20659
20912
  const bucketSize = bucketSizeForSpan(spanDays);
20660
- 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
+ );
20661
20922
  const anchors = {};
20662
20923
  const anchorObservedAt = {};
20663
20924
  const anchorUnavailable = /* @__PURE__ */ new Set();
@@ -20821,7 +21082,7 @@ async function analyticsRoutes(app) {
20821
21082
  }
20822
21083
  previousExecutionChecksum = identity.checksum;
20823
21084
  }
20824
- 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 });
20825
21086
  });
20826
21087
  app.get("/projects/:name/analytics/gaps", async (request, reply) => {
20827
21088
  const project = resolveProject(app.db, request.params.name);
@@ -20836,6 +21097,10 @@ async function analyticsRoutes(app) {
20836
21097
  }
20837
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);
20838
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
+ );
20839
21104
  const runIdToCreatedAt = new Map(windowRuns.map((r) => [r.id, r.createdAt]));
20840
21105
  const consistencyMap = /* @__PURE__ */ new Map();
20841
21106
  if (windowRunIds.length > 0) {
@@ -20865,7 +21130,7 @@ async function analyticsRoutes(app) {
20865
21130
  citationState: querySnapshots.citationState,
20866
21131
  answerMentioned: querySnapshots.answerMentioned,
20867
21132
  answerText: querySnapshots.answerText,
20868
- competitorOverlap: querySnapshots.competitorOverlap
21133
+ citedDomains: querySnapshots.citedDomains
20869
21134
  }).from(querySnapshots).leftJoin(queries, eq23(querySnapshots.queryId, queries.id)).where(inArray9(querySnapshots.runId, latestGroupRunIds)).all());
20870
21135
  const snapshots = rawSnapshots.map((s) => ({
20871
21136
  ...s,
@@ -20889,9 +21154,17 @@ async function analyticsRoutes(app) {
20889
21154
  const citedProviders = qSnapshots.filter((s) => s.citationState === CitationStates.cited).map((s) => s.provider);
20890
21155
  const mentionedProviders = qSnapshots.filter((s) => s.resolvedMentioned).map((s) => s.provider);
20891
21156
  const competitorsCiting = /* @__PURE__ */ new Set();
21157
+ const competitorsMentioned = /* @__PURE__ */ new Set();
20892
21158
  for (const s of qSnapshots) {
20893
- const overlap = s.competitorOverlap;
20894
- 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
+ }
20895
21168
  }
20896
21169
  const cons = consistencyMap.get(queryId);
20897
21170
  const consistency = {
@@ -20913,6 +21186,7 @@ async function analyticsRoutes(app) {
20913
21186
  category,
20914
21187
  providers: citedProviders,
20915
21188
  competitorsCiting: [...competitorsCiting],
21189
+ competitorsMentioned: [...competitorsMentioned],
20916
21190
  consistency
20917
21191
  };
20918
21192
  if (category === "cited") cited.push(citationEntry);
@@ -20921,7 +21195,7 @@ async function analyticsRoutes(app) {
20921
21195
  let mentionCategory;
20922
21196
  if (mentionedProviders.length > 0) {
20923
21197
  mentionCategory = "cited";
20924
- } else if (competitorsCiting.size > 0) {
21198
+ } else if (competitorsMentioned.size > 0) {
20925
21199
  mentionCategory = "gap";
20926
21200
  } else {
20927
21201
  mentionCategory = "uncited";
@@ -20932,6 +21206,7 @@ async function analyticsRoutes(app) {
20932
21206
  category: mentionCategory,
20933
21207
  providers: mentionedProviders,
20934
21208
  competitorsCiting: [...competitorsCiting],
21209
+ competitorsMentioned: [...competitorsMentioned],
20935
21210
  consistency
20936
21211
  };
20937
21212
  if (mentionCategory === "cited") mentionedQueries.push(mentionEntry);
@@ -20941,7 +21216,7 @@ async function analyticsRoutes(app) {
20941
21216
  gap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
20942
21217
  cited.sort((a, b) => a.query.localeCompare(b.query));
20943
21218
  uncited.sort((a, b) => a.query.localeCompare(b.query));
20944
- mentionGap.sort((a, b) => b.competitorsCiting.length - a.competitorsCiting.length);
21219
+ mentionGap.sort((a, b) => b.competitorsMentioned.length - a.competitorsMentioned.length);
20945
21220
  mentionedQueries.sort((a, b) => a.query.localeCompare(b.query));
20946
21221
  notMentioned.sort((a, b) => a.query.localeCompare(b.query));
20947
21222
  return reply.send({ cited, gap, uncited, mentionedQueries, mentionGap, notMentioned, runId: latestRun.id, window });
@@ -21058,7 +21333,7 @@ function computeProviderMetric(snapshots) {
21058
21333
  mentionedCount
21059
21334
  };
21060
21335
  }
21061
- function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket) {
21336
+ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, mentionShareCompetitors = [], referenceBasket, classificationAvailable = false) {
21062
21337
  if (projectRuns.length === 0) return [];
21063
21338
  const earliest = new Date(projectRuns[0].createdAt);
21064
21339
  const latest = new Date(projectRuns[projectRuns.length - 1].createdAt);
@@ -21107,7 +21382,7 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
21107
21382
  queryCount,
21108
21383
  mentionRate: metric2.mentionRate,
21109
21384
  mentionedCount: metric2.mentionedCount,
21110
- mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors),
21385
+ mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors, classificationAvailable),
21111
21386
  byProvider,
21112
21387
  modelEvidenceByProvider,
21113
21388
  basketRevision
@@ -21126,22 +21401,23 @@ function bucketStartDateFor(observedAt, earliest, bucketDays) {
21126
21401
  const offset = Math.max(0, Math.floor((observationDay.getTime() - firstBucketStart.getTime()) / bucketMilliseconds));
21127
21402
  return new Date(firstBucketStart.getTime() + offset * bucketMilliseconds).toISOString();
21128
21403
  }
21129
- function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors) {
21130
- if (mentionShareCompetitors.length === 0) {
21131
- return { rate: null, projectMentionSnapshots: 0, competitorMentionSnapshots: 0 };
21132
- }
21404
+ function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors, classificationAvailable) {
21133
21405
  const result = buildMentionShare(
21134
21406
  snapshots.map((s) => ({
21135
21407
  projectMentioned: s.resolvedMentioned,
21136
- answerText: s.answerText
21408
+ answerText: s.answerText,
21409
+ queryClass: s.queryClass
21137
21410
  })),
21138
- { competitors: mentionShareCompetitors }
21411
+ { competitors: mentionShareCompetitors, classificationAvailable }
21139
21412
  );
21140
21413
  const projectMentionSnapshots = result.breakdown.projectMentionSnapshots;
21141
21414
  const competitorMentionSnapshots = result.breakdown.competitorMentionSnapshots;
21142
21415
  const denominator = projectMentionSnapshots + competitorMentionSnapshots;
21143
21416
  return {
21144
- 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,
21145
21421
  projectMentionSnapshots,
21146
21422
  competitorMentionSnapshots
21147
21423
  };
@@ -21725,20 +22001,27 @@ function aggregateCandidate(opts) {
21725
22001
  const ourCitedRate = citedCount / totalSnaps;
21726
22002
  const recentMissRate = 1 - ourCitedRate;
21727
22003
  const competitorTally = /* @__PURE__ */ new Map();
22004
+ const competitorMentionTally = /* @__PURE__ */ new Map();
22005
+ const competitiveSignalResolver = compileCompetitiveSignalResolver([...opts.competitorSet]);
21728
22006
  const competitorGroundingTally = /* @__PURE__ */ new Map();
21729
22007
  const ourGroundingTally = /* @__PURE__ */ new Map();
21730
22008
  const citedSurfaceTally = /* @__PURE__ */ new Map();
21731
22009
  let ourCitedInLatestRun = false;
21732
22010
  for (const snap of opts.snapshots) {
21733
22011
  const isLatestRun = snap.runId === opts.latestRunId;
21734
- const competitorOverlap = snap.competitorOverlap;
21735
- for (const domain of competitorOverlap) {
21736
- const normalized = hostOf(domain) ?? "";
21737
- const competitor = findMatchingDomain(normalized, opts.competitorSet);
21738
- if (!competitor) continue;
21739
- competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
21740
- }
21741
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
+ }
21742
22025
  for (const g of grounding) {
21743
22026
  const domain = hostOf(g.uri) ?? "";
21744
22027
  if (!domain) continue;
@@ -21748,9 +22031,14 @@ function aggregateCandidate(opts) {
21748
22031
  continue;
21749
22032
  }
21750
22033
  citedSurfaceTally.set(domain, (citedSurfaceTally.get(domain) ?? 0) + 1);
21751
- if (!findMatchingDomain(domain, opts.competitorSet)) continue;
22034
+ const competitor = findMatchingDomain(domain, opts.competitorSet);
22035
+ if (!competitor) continue;
22036
+ citedCompetitorsHere.add(competitor);
21752
22037
  recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
21753
22038
  }
22039
+ for (const competitor of citedCompetitorsHere) {
22040
+ competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
22041
+ }
21754
22042
  }
21755
22043
  return {
21756
22044
  query: opts.query,
@@ -21761,8 +22049,10 @@ function aggregateCandidate(opts) {
21761
22049
  gscCtr: opts.gsc?.ctr ?? 0,
21762
22050
  ourCitedRate,
21763
22051
  ourCitedInLatestRun,
21764
- competitorDomains: Array.from(competitorTally.keys()),
22052
+ competitorCitedDomains: Array.from(competitorTally.keys()),
22053
+ competitorMentionedDomains: Array.from(competitorMentionTally.keys()),
21765
22054
  competitorCitationCount: Array.from(competitorTally.values()).reduce((a, b) => a + b, 0),
22055
+ competitorMentionCount: Array.from(competitorMentionTally.values()).reduce((a, b) => a + b, 0),
21766
22056
  recentMissRate,
21767
22057
  ourGroundingUrls: Array.from(ourGroundingTally.values()),
21768
22058
  competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
@@ -21800,8 +22090,10 @@ function emptyCandidate(query) {
21800
22090
  gscCtr: 0,
21801
22091
  ourCitedRate: 0,
21802
22092
  ourCitedInLatestRun: false,
21803
- competitorDomains: [],
22093
+ competitorCitedDomains: [],
22094
+ competitorMentionedDomains: [],
21804
22095
  competitorCitationCount: 0,
22096
+ competitorMentionCount: 0,
21805
22097
  recentMissRate: 0,
21806
22098
  ourGroundingUrls: [],
21807
22099
  competitorGroundingUrls: [],
@@ -22773,6 +23065,15 @@ table.report-table td .badge {
22773
23065
  grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
22774
23066
  gap: 16px;
22775
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
+ }
22776
23077
  .legend {
22777
23078
  display: flex;
22778
23079
  flex-wrap: wrap;
@@ -23578,18 +23879,24 @@ function renderCompetitorBars(landscape, canonical) {
23578
23879
  ];
23579
23880
  return renderLandscapeBars(data, "Citations per domain", "Citations per domain bar chart");
23580
23881
  }
23581
- function renderMentionBars(landscape, canonical) {
23882
+ function renderMentionBars(section2, canonical, title) {
23582
23883
  const data = [
23583
- { label: canonical, count: landscape.projectMentionCount, isProject: true },
23584
- ...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 }))
23585
23886
  ];
23586
- return renderLandscapeBars(data, "Mentions per domain", "Mentions per domain bar chart");
23887
+ return renderLandscapeBars(data, title, `${title} bar chart`);
23587
23888
  }
23889
+ var MENTION_SCOPE_LABEL = {
23890
+ "non-brand": "non-brand queries",
23891
+ pooled: "pooled queries \xB7 classification unavailable"
23892
+ };
23588
23893
  function renderCompetitorLandscape(report) {
23589
23894
  const competitors2 = report.competitorLandscape.competitors;
23590
23895
  const mentionLandscape = report.mentionLandscape;
23591
23896
  const noCitationData = competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0;
23592
- 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;
23593
23900
  if (noCitationData && noMentionData) {
23594
23901
  return section(
23595
23902
  { id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
@@ -23614,13 +23921,26 @@ function renderCompetitorLandscape(report) {
23614
23921
  <td>${escapeHtml(c.citedQueries.slice(0, 5).join(", "))}${c.citedQueries.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
23615
23922
  </tr>`;
23616
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.`;
23617
23926
  const table = competitors2.length > 0 ? `<table class="report-table">
23618
- <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>
23619
23928
  <tbody>${rows}</tbody>
23620
23929
  </table>` : renderEmpty("No competitors configured.");
23621
23930
  const citationBars = renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain);
23622
- 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
+ );
23623
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>` : "";
23624
23944
  return section(
23625
23945
  {
23626
23946
  id: "competitor-landscape",
@@ -23628,7 +23948,7 @@ function renderCompetitorLandscape(report) {
23628
23948
  title: "Competitor Landscape",
23629
23949
  intro: "Who AI engines cite and mention instead of the client."
23630
23950
  },
23631
- `${charts}${table}`
23951
+ `${mentionShareNote}${charts}${table}${brandedBlock}`
23632
23952
  );
23633
23953
  }
23634
23954
  var SOURCE_CATEGORY_TONE = {
@@ -24752,19 +25072,22 @@ function buildGaSection(db, projectId, windowDays2) {
24752
25072
  )
24753
25073
  ).limit(1).get() : void 0;
24754
25074
  const fallbackSummary = windowSummary ? null : db.select().from(gaTrafficSummaries).where(eq28(gaTrafficSummaries.projectId, projectId)).orderBy(desc12(gaTrafficSummaries.syncedAt)).limit(1).get();
25075
+ const matchingFallbackSummary = fallbackSummary && inclusiveDayCount(fallbackSummary.periodStart, fallbackSummary.periodEnd) === windowDays2 ? fallbackSummary : null;
24755
25076
  const snapshotMaxDate = db.select({ m: sql9`MAX(${gaTrafficSnapshots.date})` }).from(gaTrafficSnapshots).where(eq28(gaTrafficSnapshots.projectId, projectId)).get()?.m ?? "";
24756
- if (!windowSummary && !fallbackSummary && !snapshotMaxDate) return null;
24757
- const snapshotStartDate = snapshotMaxDate ? windowStartDate(snapshotMaxDate, windowDays2) : "";
25077
+ if (!windowSummary && !matchingFallbackSummary && !snapshotMaxDate) return null;
25078
+ const summaryPeriod = windowSummary ?? matchingFallbackSummary;
25079
+ const snapshotMaxInWindow = summaryPeriod?.periodEnd || snapshotMaxDate;
25080
+ const snapshotStartDate = summaryPeriod?.periodStart || (snapshotMaxDate ? windowStartDate(snapshotMaxDate, windowDays2) : "");
24758
25081
  const snapshotRows = snapshotStartDate ? db.select().from(gaTrafficSnapshots).where(
24759
25082
  and22(
24760
25083
  eq28(gaTrafficSnapshots.projectId, projectId),
24761
25084
  gte5(gaTrafficSnapshots.date, snapshotStartDate),
24762
- lte3(gaTrafficSnapshots.date, snapshotMaxDate)
25085
+ lte3(gaTrafficSnapshots.date, snapshotMaxInWindow)
24763
25086
  )
24764
25087
  ).all() : db.select().from(gaTrafficSnapshots).where(eq28(gaTrafficSnapshots.projectId, projectId)).all();
24765
- const totalSessions = windowSummary?.totalSessions ?? fallbackSummary?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
24766
- const totalUsers = windowSummary?.totalUsers ?? fallbackSummary?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
24767
- const totalOrganicSessions = windowSummary?.totalOrganicSessions ?? fallbackSummary?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
25088
+ const totalSessions = windowSummary?.totalSessions ?? matchingFallbackSummary?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
25089
+ const totalUsers = windowSummary?.totalUsers ?? matchingFallbackSummary?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
25090
+ const totalOrganicSessions = windowSummary?.totalOrganicSessions ?? matchingFallbackSummary?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
24768
25091
  const pageAgg = /* @__PURE__ */ new Map();
24769
25092
  let directSessions = windowSummary?.totalDirectSessions ?? 0;
24770
25093
  for (const r of snapshotRows) {
@@ -24786,9 +25109,9 @@ function buildGaSection(db, projectId, windowDays2) {
24786
25109
  eq28(gaAiReferrals.projectId, projectId),
24787
25110
  eq28(gaAiReferrals.sourceDimension, "session")
24788
25111
  ];
24789
- if (snapshotStartDate && snapshotMaxDate) {
25112
+ if (snapshotStartDate && snapshotMaxInWindow) {
24790
25113
  aiConditions.push(gte5(gaAiReferrals.date, snapshotStartDate));
24791
- aiConditions.push(lte3(gaAiReferrals.date, snapshotMaxDate));
25114
+ aiConditions.push(lte3(gaAiReferrals.date, snapshotMaxInWindow));
24792
25115
  }
24793
25116
  const aiSessionRows = db.select({
24794
25117
  trafficClass: gaAiReferrals.trafficClass,
@@ -24828,8 +25151,8 @@ function buildGaSection(db, projectId, windowDays2) {
24828
25151
  }
24829
25152
  }
24830
25153
  }
24831
- const periodStart = windowSummary?.periodStart ?? (snapshotStartDate || fallbackSummary?.periodStart || "");
24832
- const periodEnd = windowSummary?.periodEnd ?? (snapshotMaxDate || fallbackSummary?.periodEnd || "");
25154
+ const periodStart = snapshotStartDate;
25155
+ const periodEnd = snapshotMaxInWindow;
24833
25156
  return {
24834
25157
  totalSessions,
24835
25158
  totalUsers,
@@ -25931,11 +26254,7 @@ function buildProjectReport(db, projectName, periodDays) {
25931
26254
  const competitorDomains = competitorRows.map((c) => c.domain);
25932
26255
  const ownedDomains = project.ownedDomains;
25933
26256
  const projectDomains = [project.canonicalDomain, ...ownedDomains];
25934
- const projectAliases = project.aliases;
25935
- const projectBrandNames = effectiveBrandNames({
25936
- displayName: project.displayName,
25937
- aliases: projectAliases
25938
- });
26257
+ const projectBrandNames = effectiveBrandNames(project);
25939
26258
  const citationScorecard = buildCitationScorecard(latestSnapshots, queryLookup);
25940
26259
  const competitorLandscape = buildCompetitorLandscape(
25941
26260
  latestSnapshots,
@@ -27227,10 +27546,8 @@ function computeCitationVisibility(input) {
27227
27546
  for (const snap of latestByPair.values()) {
27228
27547
  if (citationStateToCited(snap.citationState)) continue;
27229
27548
  if (competitorDomains.length === 0) continue;
27230
- const cited = snap.citedDomains;
27231
- const overlap = snap.competitorOverlap;
27232
27549
  const candidates = new Set(
27233
- [...cited, ...overlap].map((d) => hostOf(d) ?? "").filter((d) => d.length > 0)
27550
+ snap.citedDomains.map((d) => hostOf(d) ?? "").filter((d) => d.length > 0)
27234
27551
  );
27235
27552
  const citingCompetitors = competitorDomains.filter(
27236
27553
  (competitor) => [...candidates].some((candidate) => hostMatchesDomain(candidate, competitor))
@@ -27319,6 +27636,7 @@ function observedPairs(snapshots, attribution, queryIds) {
27319
27636
  function period(numerator, denominator) {
27320
27637
  const ci = wilsonInterval(numerator, denominator);
27321
27638
  return {
27639
+ availability: denominator > 0 ? "available" : "no-observations",
27322
27640
  point: denominator > 0 ? Math.round(numerator / denominator * 1e4) / 1e4 : null,
27323
27641
  ciLow: ci ? ci.low : null,
27324
27642
  ciHigh: ci ? ci.high : null,
@@ -27326,17 +27644,27 @@ function period(numerator, denominator) {
27326
27644
  denominator
27327
27645
  };
27328
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
+ }
27329
27657
  function ciOverlap(a, b) {
27330
27658
  if (a.ciLow === null || a.ciHigh === null || b.ciLow === null || b.ciHigh === null) return true;
27331
27659
  return a.ciLow <= b.ciHigh && b.ciLow <= a.ciHigh;
27332
27660
  }
27333
- function metric(key, label, driftRobust, from, to, continuityBlock) {
27661
+ function metric(key, label, queryClass, driftRobust, from, to, continuityBlock) {
27334
27662
  const verdict = continuityBlock ?? (from.denominator === 0 || to.denominator === 0 ? "insufficient-data" : ciOverlap(from, to) ? "within-noise" : "moved");
27335
27663
  const direction = from.point === null || to.point === null ? null : to.point > from.point ? "up" : to.point < from.point ? "down" : "flat";
27336
27664
  const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
27337
- return { key, label, driftRobust, from, to, rateRatio, direction, verdict };
27665
+ return { key, label, queryClass, driftRobust, from, to, rateRatio, direction, verdict };
27338
27666
  }
27339
- function countPeriod(snaps, competitors2) {
27667
+ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable, projectBrandNames) {
27340
27668
  let checked = 0;
27341
27669
  let mentioned = 0;
27342
27670
  let cited = 0;
@@ -27370,8 +27698,14 @@ function countPeriod(snaps, competitors2) {
27370
27698
  }
27371
27699
  }
27372
27700
  const mentionShare = buildMentionShare(
27373
- snaps.map((s) => ({ projectMentioned: s.answerMentioned === true, answerText: s.answerText })),
27374
- { 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 }
27375
27709
  );
27376
27710
  return {
27377
27711
  checked,
@@ -27397,6 +27731,10 @@ function isUnknownModelEvidence(evidence) {
27397
27731
  }
27398
27732
  function computeVisibilityCompare(input) {
27399
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;
27400
27738
  const fromObs = observed(input.from.snapshots, attribution);
27401
27739
  const toObs = observed(input.to.snapshots, attribution);
27402
27740
  const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
@@ -27406,8 +27744,8 @@ function computeVisibilityCompare(input) {
27406
27744
  const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
27407
27745
  const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
27408
27746
  const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
27409
- const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors);
27410
- 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 ?? []);
27411
27749
  const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
27412
27750
  const fromEvidence = classifyModelEvidence(fromCandidateCounts.modelEvidence.get(provider) ?? []);
27413
27751
  const toEvidence = classifyModelEvidence(toCandidateCounts.modelEvidence.get(provider) ?? []);
@@ -27426,8 +27764,8 @@ function computeVisibilityCompare(input) {
27426
27764
  const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
27427
27765
  const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
27428
27766
  const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
27429
- const fromCounts = countPeriod(fromSnaps, input.competitors);
27430
- 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 ?? []);
27431
27769
  const shareCounts = (c) => ({
27432
27770
  proj: c.mentionShare.breakdown.projectMentionSnapshots,
27433
27771
  comp: c.mentionShare.breakdown.competitorMentionSnapshots
@@ -27438,9 +27776,10 @@ function computeVisibilityCompare(input) {
27438
27776
  metric(
27439
27777
  "mention-share-of-voice",
27440
27778
  "Named share of voice",
27779
+ classificationAvailable ? "non-brand" : "pooled",
27441
27780
  true,
27442
- period(fromShare.proj, fromShare.proj + fromShare.comp),
27443
- 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),
27444
27783
  continuityBlock
27445
27784
  ),
27446
27785
  // Share of voice is undefined without a competitive frame: with zero
@@ -27451,14 +27790,16 @@ function computeVisibilityCompare(input) {
27451
27790
  metric(
27452
27791
  "cited-share-of-voice",
27453
27792
  "Cited share of voice",
27793
+ "all",
27454
27794
  true,
27455
- input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : period(0, 0),
27456
- 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),
27457
27797
  continuityBlock
27458
27798
  ),
27459
27799
  metric(
27460
27800
  "mention-rate",
27461
27801
  "Named rate",
27802
+ "all",
27462
27803
  false,
27463
27804
  period(fromCounts.mentioned, fromCounts.checked),
27464
27805
  period(toCounts.mentioned, toCounts.checked),
@@ -27467,6 +27808,7 @@ function computeVisibilityCompare(input) {
27467
27808
  metric(
27468
27809
  "cited-rate",
27469
27810
  "Cited rate",
27811
+ "all",
27470
27812
  false,
27471
27813
  period(fromCounts.cited, fromCounts.total),
27472
27814
  period(toCounts.cited, toCounts.total),
@@ -27624,7 +27966,8 @@ async function visibilityStatsRoutes(app) {
27624
27966
  lastRuns: lastRunsRaw,
27625
27967
  groupBy: groupByRaw,
27626
27968
  month: monthRaw,
27627
- shareOfVoice: shareOfVoiceRaw
27969
+ shareOfVoice: shareOfVoiceRaw,
27970
+ queryClass: queryClassRaw
27628
27971
  } = request.query;
27629
27972
  let groupBy = null;
27630
27973
  if (groupByRaw !== void 0 && groupByRaw !== "") {
@@ -27636,6 +27979,9 @@ async function visibilityStatsRoutes(app) {
27636
27979
  const hasLastRuns = lastRunsRaw !== void 0 && lastRunsRaw !== "";
27637
27980
  const hasMonth = monthRaw !== void 0 && monthRaw !== "";
27638
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
+ })();
27639
27985
  if (hasLastRuns && (hasSince || hasUntil)) {
27640
27986
  throw validationError('"lastRuns" cannot be combined with "since"/"until" \u2014 use one or the other');
27641
27987
  }
@@ -27695,10 +28041,6 @@ async function visibilityStatsRoutes(app) {
27695
28041
  let shareOfVoice;
27696
28042
  if (wantShareOfVoice) {
27697
28043
  const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
27698
- const mentionShareCompetitors = competitorRows.map((c) => ({
27699
- domain: c.domain,
27700
- brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
27701
- }));
27702
28044
  const sovSnapshots = runIds.length > 0 ? app.db.select({
27703
28045
  queryId: querySnapshots.queryId,
27704
28046
  queryText: querySnapshots.queryText,
@@ -27706,16 +28048,29 @@ async function visibilityStatsRoutes(app) {
27706
28048
  answerText: querySnapshots.answerText
27707
28049
  }).from(querySnapshots).where(inArray14(querySnapshots.runId, runIds)).all() : [];
27708
28050
  const attributedSovSnapshots = sovSnapshots.filter((s) => resolveCurrentQuery(queryAttribution, s) !== void 0);
27709
- const result = buildMentionShare(
27710
- attributedSovSnapshots.map((s) => ({ projectMentioned: s.answerMentioned === true, answerText: s.answerText })),
27711
- { competitors: mentionShareCompetitors }
27712
- );
27713
- 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;
27714
28066
  const denom = b.projectMentionSnapshots + b.competitorMentionSnapshots;
27715
28067
  shareOfVoice = {
27716
- // `null` (not 0) when there is no competitive frame configured — a 0 here
27717
- // would read as "losing" when the head-to-head metric is simply undefined.
27718
- 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,
27719
28074
  projectMentions: b.projectMentionSnapshots,
27720
28075
  competitorMentions: b.competitorMentionSnapshots,
27721
28076
  snapshotsWithAnswerText: b.snapshotsWithAnswerText,
@@ -27765,10 +28120,7 @@ async function visibilityStatsRoutes(app) {
27765
28120
  }
27766
28121
  const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq32(queries.projectId, project.id)).all();
27767
28122
  const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, project.id)).all();
27768
- const competitorInputs = competitorRows.map((c) => ({
27769
- domain: c.domain,
27770
- brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
27771
- }));
28123
+ const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows.map((c) => c.domain));
27772
28124
  const loadMonth = (bounds) => {
27773
28125
  const sinceMs = Date.parse(bounds.since);
27774
28126
  const untilMs = Date.parse(bounds.until);
@@ -27794,6 +28146,7 @@ async function visibilityStatsRoutes(app) {
27794
28146
  project: project.name,
27795
28147
  queries: projectQueries,
27796
28148
  competitors: competitorInputs,
28149
+ brandNames: effectiveBrandNames(project),
27797
28150
  from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
27798
28151
  to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
27799
28152
  });
@@ -28107,25 +28460,35 @@ async function compositeRoutes(app) {
28107
28460
  }
28108
28461
  }
28109
28462
  const configuredApiProviders = project.providers.filter((p) => !p.startsWith("cdp:"));
28110
- const mentionShareCompetitors = competitorRows.map((c) => ({
28111
- domain: c.domain,
28112
- // Single brand token derived from the registrable domain (e.g.
28113
- // "offers.roofle.com" → "roofle"). Future PR can layer operator-curated
28114
- // aliases on top via a `competitor_aliases` column.
28115
- 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
+ })
28116
28479
  }));
28117
28480
  const scores = {
28118
28481
  mention: buildMentionCoverage(trackedLatest, { configuredApiProviders }),
28119
28482
  visibility: buildVisibilityScore(trackedLatest, { configuredApiProviders }),
28120
28483
  mentionShare: buildMentionShare(
28121
- trackedLatest.map((s) => ({
28122
- projectMentioned: s.answerMentioned === true,
28123
- answerText: s.answerText
28124
- })),
28125
- { competitors: mentionShareCompetitors }
28484
+ mentionShareInputs.snapshots,
28485
+ {
28486
+ competitors: mentionShareInputs.competitors,
28487
+ classificationAvailable: mentionShareInputs.classified
28488
+ }
28126
28489
  ),
28127
- gapQueries: buildGapQueryScore(trackedLatest),
28128
- mentionGaps: buildMentionGapScore(trackedLatest),
28490
+ gapQueries: buildGapQueryScore(gapSignalSnapshots),
28491
+ mentionGaps: buildMentionGapScore(gapSignalSnapshots),
28129
28492
  indexCoverage: buildIndexCoverageScore(app, project.id),
28130
28493
  competitorPressure: buildCompetitorPressureScore(
28131
28494
  trackedLatest,
@@ -28307,6 +28670,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
28307
28670
  citationState: querySnapshots.citationState,
28308
28671
  answerMentioned: querySnapshots.answerMentioned,
28309
28672
  answerText: querySnapshots.answerText,
28673
+ rawResponse: querySnapshots.rawResponse,
28310
28674
  competitorOverlap: querySnapshots.competitorOverlap,
28311
28675
  citedDomains: querySnapshots.citedDomains
28312
28676
  }).from(querySnapshots).where(inArray15(querySnapshots.runId, [...runIds])).all();
@@ -28338,6 +28702,7 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
28338
28702
  citationState: row.citationState,
28339
28703
  answerMentioned: row.answerMentioned,
28340
28704
  answerText: row.answerText,
28705
+ groundingSources: parseOverviewGroundingSources(row.rawResponse),
28341
28706
  competitorOverlap: row.competitorOverlap,
28342
28707
  citedDomains: row.citedDomains
28343
28708
  });
@@ -28345,6 +28710,16 @@ function loadSnapshotsByRunIds(app, runIds, queryIdByText) {
28345
28710
  }
28346
28711
  return result;
28347
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
+ }
28348
28723
  function summarizeFromSnapshots(snapshots) {
28349
28724
  const empty = {
28350
28725
  queryCounts: {
@@ -29431,9 +29806,15 @@ var compareToQueryParameter = {
29431
29806
  var shareOfVoiceQueryParameter = {
29432
29807
  name: "shareOfVoice",
29433
29808
  in: "query",
29434
- 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.',
29435
29810
  schema: { type: "string", enum: ["1"] }
29436
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
+ };
29437
29818
  var wordpressEnvQueryParameter = {
29438
29819
  name: "env",
29439
29820
  in: "query",
@@ -31176,7 +31557,7 @@ var routeCatalog = [
31176
31557
  method: "get",
31177
31558
  path: "/api/v1/projects/{name}/visibility-stats",
31178
31559
  summary: "Get aggregated mention/citation stats per query",
31179
- 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).",
31180
31561
  tags: ["analytics"],
31181
31562
  parameters: [
31182
31563
  nameParameter,
@@ -31185,7 +31566,8 @@ var routeCatalog = [
31185
31566
  lastRunsQueryParameter,
31186
31567
  groupByProviderQueryParameter,
31187
31568
  monthQueryParameter,
31188
- shareOfVoiceQueryParameter
31569
+ shareOfVoiceQueryParameter,
31570
+ shareOfVoiceQueryClassParameter
31189
31571
  ],
31190
31572
  responses: {
31191
31573
  200: jsonResponse("Aggregated visibility stats returned.", "VisibilityStatsDto"),
@@ -48195,11 +48577,9 @@ async function ga4Routes(app, opts) {
48195
48577
  requireGa4Connection(opts, project.name, project.canonicalDomain);
48196
48578
  const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
48197
48579
  const range = resolveDateRange(request.query);
48198
- const dateFiltered = range.startDate !== null || range.endDate !== null;
48199
- const snapshotConditions = [eq45(gaTrafficSnapshots.projectId, project.id), ...dateRangeConditions(gaTrafficSnapshots.date, range)];
48200
- const aiConditions = [eq45(gaAiReferrals.projectId, project.id), ...dateRangeConditions(gaAiReferrals.date, range)];
48201
- const socialConditions = [eq45(gaSocialReferrals.projectId, project.id), ...dateRangeConditions(gaSocialReferrals.date, range)];
48202
48580
  const windowSummaryRow = range.startDate && !range.explicitDates ? app.db.select({
48581
+ periodStart: gaTrafficWindowSummaries.periodStart,
48582
+ periodEnd: gaTrafficWindowSummaries.periodEnd,
48203
48583
  totalSessions: gaTrafficWindowSummaries.totalSessions,
48204
48584
  totalOrganicSessions: gaTrafficWindowSummaries.totalOrganicSessions,
48205
48585
  totalDirectSessions: gaTrafficWindowSummaries.totalDirectSessions,
@@ -48210,7 +48590,29 @@ async function ga4Routes(app, opts) {
48210
48590
  eq45(gaTrafficWindowSummaries.windowKey, range.window)
48211
48591
  )
48212
48592
  ).get() : null;
48213
- const snapshotTotalsRow = dateFiltered && !windowSummaryRow ? (() => {
48593
+ const projectSummaryRow = app.db.select({
48594
+ periodStart: gaTrafficSummaries.periodStart,
48595
+ periodEnd: gaTrafficSummaries.periodEnd,
48596
+ totalSessions: gaTrafficSummaries.totalSessions,
48597
+ totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
48598
+ totalUsers: gaTrafficSummaries.totalUsers
48599
+ }).from(gaTrafficSummaries).where(eq45(gaTrafficSummaries.projectId, project.id)).get();
48600
+ const retainedBounds = [
48601
+ app.db.select({ min: sql17`MIN(${gaTrafficSnapshots.date})`, max: sql17`MAX(${gaTrafficSnapshots.date})` }).from(gaTrafficSnapshots).where(eq45(gaTrafficSnapshots.projectId, project.id)).get(),
48602
+ app.db.select({ min: sql17`MIN(${gaAiReferrals.date})`, max: sql17`MAX(${gaAiReferrals.date})` }).from(gaAiReferrals).where(eq45(gaAiReferrals.projectId, project.id)).get(),
48603
+ app.db.select({ min: sql17`MIN(${gaSocialReferrals.date})`, max: sql17`MAX(${gaSocialReferrals.date})` }).from(gaSocialReferrals).where(eq45(gaSocialReferrals.projectId, project.id)).get()
48604
+ ];
48605
+ const retainedDates = retainedBounds.flatMap((bound) => [bound?.min, bound?.max]).filter((date) => Boolean(date));
48606
+ const projectSummaryCoversAll = Boolean(
48607
+ projectSummaryRow?.periodStart && projectSummaryRow.periodEnd && retainedDates.every((date) => date >= projectSummaryRow.periodStart && date <= projectSummaryRow.periodEnd)
48608
+ );
48609
+ const denominatorRow = windowSummaryRow ?? (range.window === "all" && projectSummaryCoversAll ? projectSummaryRow : null);
48610
+ const denominatorPeriod = denominatorRow?.periodStart && denominatorRow.periodEnd ? { startDate: denominatorRow.periodStart, endDate: denominatorRow.periodEnd } : null;
48611
+ const measuredRange = denominatorPeriod ? { ...range, ...denominatorPeriod } : range;
48612
+ const snapshotConditions = [eq45(gaTrafficSnapshots.projectId, project.id), ...dateRangeConditions(gaTrafficSnapshots.date, measuredRange)];
48613
+ const aiConditions = [eq45(gaAiReferrals.projectId, project.id), ...dateRangeConditions(gaAiReferrals.date, measuredRange)];
48614
+ const socialConditions = [eq45(gaSocialReferrals.projectId, project.id), ...dateRangeConditions(gaSocialReferrals.date, measuredRange)];
48615
+ const snapshotTotalsRow = !denominatorRow ? (() => {
48214
48616
  const summed = app.db.select({
48215
48617
  totalSessions: sql17`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)`,
48216
48618
  totalOrganicSessions: sql17`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)`,
@@ -48219,25 +48621,16 @@ async function ga4Routes(app, opts) {
48219
48621
  return {
48220
48622
  totalSessions: summed?.totalSessions ?? 0,
48221
48623
  totalOrganicSessions: summed?.totalOrganicSessions ?? 0,
48222
- // Unavailable for an EXPLICIT calendar range. The rolling-window
48223
- // fallback keeps its historical summed value: it is wrong for the
48224
- // same reason, but it predates this branch and correcting it is a
48225
- // separate, visible behaviour change rather than one bundled here.
48226
- totalUsers: range.explicitDates ? null : summed?.totalUsers ?? 0
48624
+ // Unavailable for an explicit or all-history range. The rolling-
48625
+ // window fallback keeps its historical summed value for projects
48626
+ // that have not yet backfilled window summaries.
48627
+ totalUsers: range.explicitDates || range.window === "all" ? null : summed?.totalUsers ?? 0
48227
48628
  };
48228
48629
  })() : null;
48229
- const summaryRow = dateFiltered ? windowSummaryRow ?? snapshotTotalsRow : app.db.select({
48230
- totalSessions: gaTrafficSummaries.totalSessions,
48231
- totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
48232
- totalUsers: gaTrafficSummaries.totalUsers
48233
- }).from(gaTrafficSummaries).where(eq45(gaTrafficSummaries.projectId, project.id)).get();
48630
+ const summaryRow = denominatorRow ?? snapshotTotalsRow;
48234
48631
  const directTotalRow = windowSummaryRow ? { totalDirectSessions: windowSummaryRow.totalDirectSessions } : app.db.select({
48235
48632
  totalDirectSessions: sql17`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)`
48236
48633
  }).from(gaTrafficSnapshots).where(and36(...snapshotConditions)).get();
48237
- const summaryMeta = app.db.select({
48238
- periodStart: gaTrafficSummaries.periodStart,
48239
- periodEnd: gaTrafficSummaries.periodEnd
48240
- }).from(gaTrafficSummaries).where(eq45(gaTrafficSummaries.projectId, project.id)).get();
48241
48634
  const rows = app.db.select({
48242
48635
  landingPage: sql17`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`,
48243
48636
  sessions: sql17`SUM(${gaTrafficSnapshots.sessions})`,
@@ -48300,6 +48693,9 @@ async function ga4Routes(app, opts) {
48300
48693
  const totalDirectSessions = directTotalRow?.totalDirectSessions ?? 0;
48301
48694
  const totalOrganicSessions = summaryRow?.totalOrganicSessions ?? 0;
48302
48695
  const socialSessions = socialTotals?.sessions ?? 0;
48696
+ const windowStart = measuredRange.startDate;
48697
+ const windowEnd = measuredRange.endDate;
48698
+ const windowDays2 = windowStart && windowEnd ? inclusiveDayCount(windowStart, windowEnd) : null;
48303
48699
  const channelBreakdown = buildChannelBreakdown({
48304
48700
  totalSessions: total,
48305
48701
  organicSessions: totalOrganicSessions,
@@ -48315,7 +48711,7 @@ async function ga4Routes(app, opts) {
48315
48711
  // it. GA counts users as a COUNT DISTINCT at the grain requested, so the
48316
48712
  // landing-page dimensioned sum is inflated, and a 0 would read as
48317
48713
  // "nobody visited" rather than "not measurable for this range".
48318
- totalUsers: summaryRow ? summaryRow.totalUsers : 0,
48714
+ totalUsers: summaryRow ? summaryRow.totalUsers : null,
48319
48715
  topPages: rows.map((r) => ({
48320
48716
  landingPage: r.landingPage,
48321
48717
  sessions: r.sessions ?? 0,
@@ -48382,25 +48778,19 @@ async function ga4Routes(app, opts) {
48382
48778
  directSharePctDisplay: formatSharePct(totalDirectSessions, total),
48383
48779
  socialSharePctDisplay: formatSharePct(socialSessions, total),
48384
48780
  lastSyncedAt: latestSync?.syncedAt ?? null,
48385
- // Report the range that was actually measured, so a caller can tell which
48386
- // period the totals above belong to.
48387
- // An EXPLICIT calendar range is reported back verbatim, even when it
48388
- // covers nothing. Substituting the synced period there labelled an empty
48389
- // future range with real past dates, so the numbers said "no data" while
48390
- // the labels named a period that did have data.
48391
- //
48392
- // A rolling window still clamps: "last 30 days" is a relative ask, and a
48393
- // cutoff computed from today can legitimately land after the last synced
48394
- // date on a stale project. Reporting a start after the end would be its
48395
- // own nonsense.
48396
- periodStart: (() => {
48397
- if (range.explicitDates) return range.startDate ?? summaryMeta?.periodStart ?? null;
48398
- const start = range.startDate ?? summaryMeta?.periodStart ?? null;
48399
- const end = range.endDate ?? summaryMeta?.periodEnd ?? null;
48400
- if (start && end && start > end) return summaryMeta?.periodStart ?? null;
48401
- return start;
48402
- })(),
48403
- periodEnd: range.endDate ?? summaryMeta?.periodEnd ?? null
48781
+ // The window every figure above was measured over never a different
48782
+ // table's period, and never a fallback that names dates the queries did
48783
+ // not use. A `null` bound is genuinely open (all retained history on that
48784
+ // side), which is why `windowDays` is null too rather than guessing a
48785
+ // span. Read a share against these dates or not at all.
48786
+ windowStart,
48787
+ windowEnd,
48788
+ windowDays: windowDays2,
48789
+ // Retained aliases of `windowStart` / `windowEnd`. They previously mixed
48790
+ // the requested range with the synced range, which is what let a 30-day
48791
+ // total sit under a 90-day channel count with a label that named neither.
48792
+ periodStart: windowStart,
48793
+ periodEnd: windowEnd
48404
48794
  };
48405
48795
  });
48406
48796
  app.get("/projects/:name/ga/ai-referral-history", async (request, _reply) => {
@@ -65364,6 +65754,16 @@ function matchesBrandAlias(candidate, aliases) {
65364
65754
  var RECURRENCE_LOOKBACK_RUNS = 5;
65365
65755
  var HISTORY_WINDOW_RUNS = Math.max(PERSISTENT_GAP_THRESHOLD, 5);
65366
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
+ }
65367
65767
  function gbpInsightSlot(typeOrSlot) {
65368
65768
  return typeOrSlot === "gbp-lodging-gap" || typeOrSlot === "gbp-listing-discrepancy" ? "lodging" : typeOrSlot;
65369
65769
  }
@@ -65406,11 +65806,13 @@ var IntelligenceService = class {
65406
65806
  log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
65407
65807
  return null;
65408
65808
  }
65809
+ const trackedCompetitors = this.loadTrackedCompetitors(projectId);
65409
65810
  const currentRun = this.buildRunData(
65410
65811
  runId,
65411
65812
  projectId,
65412
65813
  currentRunRecord.finishedAt ?? currentRunRecord.createdAt,
65413
- currentRunRecord.location ?? null
65814
+ currentRunRecord.location ?? null,
65815
+ trackedCompetitors
65414
65816
  );
65415
65817
  if (currentRun.snapshots.length === 0) {
65416
65818
  log.info("intelligence.skip", { runId, reason: "no snapshots" });
@@ -65425,10 +65827,16 @@ var IntelligenceService = class {
65425
65827
  previousRunRecord.id,
65426
65828
  projectId,
65427
65829
  previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
65428
- previousRunRecord.location ?? null
65830
+ previousRunRecord.location ?? null,
65831
+ trackedCompetitors
65429
65832
  ) : null;
65430
- const trackedCompetitors = this.loadTrackedCompetitors(projectId);
65431
- 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
+ ));
65432
65840
  if (!previousRun) {
65433
65841
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
65434
65842
  log.info("intelligence.analyzed", {
@@ -65632,11 +66040,13 @@ var IntelligenceService = class {
65632
66040
  * would have produced.
65633
66041
  */
65634
66042
  analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords, opts) {
66043
+ const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
65635
66044
  const currentRun = this.buildRunData(
65636
66045
  runRecord.id,
65637
66046
  runRecord.projectId,
65638
66047
  runRecord.finishedAt ?? runRecord.createdAt,
65639
- runRecord.location ?? null
66048
+ runRecord.location ?? null,
66049
+ trackedCompetitors
65640
66050
  );
65641
66051
  if (currentRun.snapshots.length === 0) {
65642
66052
  return null;
@@ -65645,10 +66055,16 @@ var IntelligenceService = class {
65645
66055
  previousRunRecord.id,
65646
66056
  previousRunRecord.projectId,
65647
66057
  previousRunRecord.finishedAt ?? previousRunRecord.createdAt,
65648
- previousRunRecord.location ?? null
66058
+ previousRunRecord.location ?? null,
66059
+ trackedCompetitors
65649
66060
  ) : null;
65650
- const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
65651
- 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
+ ));
65652
66068
  if (!previousRun) {
65653
66069
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
65654
66070
  const emptyResult = this.emptyAnalysisResult(result2);
@@ -65918,12 +66334,13 @@ var IntelligenceService = class {
65918
66334
  return { ...insight, severity };
65919
66335
  });
65920
66336
  }
65921
- buildRunData(runId, projectId, completedAt, location = null) {
66337
+ buildRunData(runId, projectId, completedAt, location = null, trackedCompetitors = []) {
65922
66338
  const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq60(projects.id, projectId)).get();
65923
66339
  const projectDomains = projectDomainRow ? effectiveDomains({
65924
66340
  canonicalDomain: projectDomainRow.canonicalDomain,
65925
66341
  ownedDomains: projectDomainRow.ownedDomains
65926
66342
  }) : [];
66343
+ const competitiveSignalResolver = compileCompetitiveSignalResolver(trackedCompetitors);
65927
66344
  const rows = this.db.select({
65928
66345
  query: queries.query,
65929
66346
  // Denormalized query text persisted by v58 — the fallback when the
@@ -65934,7 +66351,7 @@ var IntelligenceService = class {
65934
66351
  citationState: querySnapshots.citationState,
65935
66352
  answerMentioned: querySnapshots.answerMentioned,
65936
66353
  citedDomains: querySnapshots.citedDomains,
65937
- competitorOverlap: querySnapshots.competitorOverlap,
66354
+ rawResponse: querySnapshots.rawResponse,
65938
66355
  snapshotLocation: querySnapshots.location
65939
66356
  }).from(querySnapshots).leftJoin(queries, eq60(querySnapshots.queryId, queries.id)).where(eq60(querySnapshots.runId, runId)).all();
65940
66357
  const snapshots = [];
@@ -65946,7 +66363,10 @@ var IntelligenceService = class {
65946
66363
  continue;
65947
66364
  }
65948
66365
  const domains = r.citedDomains;
65949
- const competitors2 = r.competitorOverlap;
66366
+ const citedCompetitorDomains = competitiveSignalResolver.resolve({
66367
+ citedDomains: domains,
66368
+ groundingSources: readStoredGroundingSources(r.rawResponse)
66369
+ }).citedCompetitorDomains;
65950
66370
  snapshots.push({
65951
66371
  query: resolvedQuery,
65952
66372
  provider: r.provider,
@@ -65964,7 +66384,7 @@ var IntelligenceService = class {
65964
66384
  // we read it from the snapshot row so a stale runs.location can't
65965
66385
  // mask snapshot truth.
65966
66386
  location: r.snapshotLocation ?? location ?? null,
65967
- competitorDomains: competitors2,
66387
+ citedCompetitorDomains,
65968
66388
  // citedDomains is the FULL set (tracked competitors + third-party
65969
66389
  // sources). Cause analysis uses it to name the displacing source
65970
66390
  // when no tracked competitor appears in the response.