@canonry/canonry 5.20.3 → 5.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +1 -1
  2. package/assets/agent-workspace/skills/aero/references/reporting.md +9 -4
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +3 -0
  4. package/assets/assets/{AuditHistoryPanel-BSh3Oezj.js → AuditHistoryPanel-CjxWA3lx.js} +1 -1
  5. package/assets/assets/{BacklinksPage-DQ59dhCx.js → BacklinksPage-Bdd164Up.js} +1 -1
  6. package/assets/assets/{HistoryPage-BBvgid3o.js → HistoryPage-BvJmE-kP.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-Dmb18JbT.js → MeasurementPropertyPage-HuP6RMZ-.js} +1 -1
  8. package/assets/assets/{ProjectPage-CPg0q2Dy.js → ProjectPage-DOvXVbyy.js} +1 -1
  9. package/assets/assets/{RunRow-BK8sZCID.js → RunRow-Cnb3JOBw.js} +1 -1
  10. package/assets/assets/{RunsPage-CIcVBTdP.js → RunsPage-Dvz1JYIm.js} +1 -1
  11. package/assets/assets/{SettingsPage-Bc3aR-gz.js → SettingsPage-DYGvphkg.js} +1 -1
  12. package/assets/assets/{SiteHealthSection-FF14aIXT.js → SiteHealthSection-CUNiKaod.js} +3 -3
  13. package/assets/assets/{TrafficPage-C_qoeGDl.js → TrafficPage-Bf4Tq95q.js} +1 -1
  14. package/assets/assets/{TrafficSourceDetailPage-B3i_B2MI.js → TrafficSourceDetailPage-CIuLWZgT.js} +1 -1
  15. package/assets/assets/{extract-error-message-BisUn6nN.js → extract-error-message-DZex1e4H.js} +1 -1
  16. package/assets/assets/{index-Dht7JNX5.js → index-CeXW-lzj.js} +21 -21
  17. package/assets/assets/{react-sigma_core.esm.min-tWhS09SS.js → react-sigma_core.esm.min-CxvtwEkv.js} +1 -1
  18. package/assets/assets/{v2-overview-adapter-CAoeWKQJ.js → v2-overview-adapter-Bh4qIzLp.js} +1 -1
  19. package/assets/index.html +1 -1
  20. package/dist/{chunk-TOSPZ7I5.js → chunk-2LO7FLVD.js} +48 -5
  21. package/dist/{chunk-7YGOE4R2.js → chunk-FAVYLZQV.js} +341 -96
  22. package/dist/{chunk-PXVJ3P5G.js → chunk-FRWZWJB3.js} +4 -4
  23. package/dist/{chunk-KHNPBUUN.js → chunk-HDJHQPW3.js} +2 -2
  24. package/dist/{chunk-CVUR3O5G.js → chunk-MEAKE5W7.js} +3 -3
  25. package/dist/{chunk-H6XSHOS5.js → chunk-NFN5GSEJ.js} +7 -7
  26. package/dist/{chunk-JJPZ4OQ2.js → chunk-WUT3G2GM.js} +5 -3
  27. package/dist/cli.js +69 -12
  28. package/dist/{demo-server-JYDAUEP2.js → demo-server-7BVVVXPM.js} +4 -4
  29. package/dist/index.js +6 -6
  30. package/dist/{intelligence-service-H4LGPJLZ.js → intelligence-service-3UUKRAZ5.js} +2 -2
  31. package/dist/mcp.js +4 -4
  32. package/package.json +12 -12
@@ -417,6 +417,7 @@ import {
417
417
  isTemplateLinkRatio,
418
418
  isTrendBaseline,
419
419
  isVertexGroundingRedirect,
420
+ isVisibilityCompareClassMetric,
420
421
  isoDateDaysBeforeInTimeZone,
421
422
  keywordDtoSchema,
422
423
  keywordGenerateRequestSchema,
@@ -771,6 +772,7 @@ import {
771
772
  userListDtoSchema,
772
773
  validationError,
773
774
  visibilityCompareDtoSchema,
775
+ visibilityCompareSelectionSchema,
774
776
  visibilityReportQuerySchema,
775
777
  visibilityReportResponseSchema,
776
778
  visibilityStateFromAnswerMentioned,
@@ -792,7 +794,7 @@ import {
792
794
  wordpressSchemaDeployResultDtoSchema,
793
795
  wordpressSchemaStatusResultDtoSchema,
794
796
  wordpressStatusDtoSchema
795
- } from "./chunk-TOSPZ7I5.js";
797
+ } from "./chunk-2LO7FLVD.js";
796
798
 
797
799
  // src/intelligence-service.ts
798
800
  import { eq as eq71, desc as desc32, asc as asc12, and as and60, ne as ne11, or as or18, inArray as inArray27, gte as gte18, lte as lte15, isNull as isNull13, sql as sql28, exists } from "drizzle-orm";
@@ -9286,9 +9288,11 @@ function buildMentionShare(snapshots, options) {
9286
9288
  if (text2.length === 0) continue;
9287
9289
  tally.snapshotsWithAnswerText++;
9288
9290
  if (snap.projectMentioned) tally.projectMentionSnapshots++;
9291
+ if (options.competitors.length === 0) continue;
9292
+ const prepared = prepareBrandMatchText(text2);
9289
9293
  for (const competitor of options.competitors) {
9290
9294
  const matcher = competitorMatchers.get(competitor.domain);
9291
- if (matcher && matcherMatchesText(matcher, text2)) {
9295
+ if (matcher && matcherMatchesText(matcher, prepared)) {
9292
9296
  tally.competitorCounts.set(competitor.domain, (tally.competitorCounts.get(competitor.domain) ?? 0) + 1);
9293
9297
  }
9294
9298
  }
@@ -16044,6 +16048,17 @@ function modelEvidenceStatesEqual(a, b) {
16044
16048
  if (a.status !== "mixed" || b.status !== "mixed") return false;
16045
16049
  return a.includesUnknown === b.includesUnknown && a.models.length === b.models.length && a.models.every((model, index2) => model === b.models[index2]);
16046
16050
  }
16051
+ function compareModelContinuity(from, to) {
16052
+ const fromEvidence = classifyModelEvidence(from);
16053
+ const toEvidence = classifyModelEvidence(to);
16054
+ const ids = (evidence) => evidence.status === "known" ? [evidence.model] : evidence.status === "mixed" ? evidence.models : [];
16055
+ const unknown = (evidence) => evidence.status === "unknown" || evidence.status === "mixed" && evidence.includesUnknown;
16056
+ return {
16057
+ status: unknown(fromEvidence) || unknown(toEvidence) ? "model-unknown" : fromEvidence.status === "known" && toEvidence.status === "known" && fromEvidence.model === toEvidence.model ? "included" : "model-discontinuous",
16058
+ fromModels: ids(fromEvidence),
16059
+ toModels: ids(toEvidence)
16060
+ };
16061
+ }
16047
16062
 
16048
16063
  // ../api-routes/src/competitor-landscape.ts
16049
16064
  var COMPETITOR_LANDSCAPE_RANKED_ROW_LIMIT = 100;
@@ -24410,6 +24425,50 @@ function buildVisibilityReport(input) {
24410
24425
  populations
24411
24426
  });
24412
24427
  }
24428
+ function visibilityComparisonPopulation(run, selection) {
24429
+ const targets = targetMap(run.definition);
24430
+ const byClass = ALL_CLASSES.map((queryClass) => ({ queryClass, candidates: candidatesFor(run, selection, queryClass).candidates }));
24431
+ const pooled = /* @__PURE__ */ new Map();
24432
+ for (const { candidates } of byClass) {
24433
+ for (const candidate of candidates) {
24434
+ const existing = pooled.get(candidate.slot.id);
24435
+ if (existing) existing.edges.push(...candidate.edges);
24436
+ else pooled.set(candidate.slot.id, { ...candidate, edges: [...candidate.edges] });
24437
+ }
24438
+ }
24439
+ const cohortKeys = new Map([...pooled.values()].map((candidate) => [candidate.slot.id, JSON.stringify({
24440
+ definition: run.comparableDefinitionIds.at(-1) ?? run.definitionId,
24441
+ query: candidate.slot.query,
24442
+ execution: candidate.slot.executionId,
24443
+ location: candidate.slot.location,
24444
+ edges: candidate.edges.map((edge) => [edge.targetKey, edge.queryClass, [...edge.marketKeys].sort()]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
24445
+ })]));
24446
+ const row = (candidate, queryClass) => {
24447
+ const signals = targetValues(candidate, targets);
24448
+ return {
24449
+ queryId: candidate.slot.queryId ?? candidate.slot.queryKey,
24450
+ queryText: candidate.slot.query,
24451
+ provider: candidate.slot.provider,
24452
+ model: candidate.observation.model,
24453
+ answerMentioned: signals.mention,
24454
+ mentionApplicable: candidate.edges.some((edge) => targets.get(edge.targetKey)?.mentionEligible === true),
24455
+ citation: citationForCoverage(candidate, targets),
24456
+ answerText: candidate.observation.answerText,
24457
+ competitorDomains: [...new Set(candidate.edges.flatMap((edge) => edge.competitorDomains))],
24458
+ competitorMentions: candidate.observation.competitorMentionDomains.filter((domain) => candidate.edges.some((edge) => edge.competitorDomains.includes(domain))),
24459
+ competitorCitations: candidate.observation.competitorCitationDomains.filter((domain) => candidate.edges.some((edge) => edge.competitorDomains.includes(domain))),
24460
+ cohortKey: cohortKeys.get(candidate.slot.id),
24461
+ queryClass: queryClass === "unknown" ? null : queryClass
24462
+ };
24463
+ };
24464
+ return {
24465
+ snapshots: [...pooled.values()].filter((candidate) => candidate.observation !== null).map((candidate) => {
24466
+ const classes = new Set(candidate.edges.map((edge) => edge.queryClass));
24467
+ return row(candidate, classes.size === 1 ? candidate.edges[0].queryClass : null);
24468
+ }),
24469
+ classSnapshots: byClass.flatMap(({ queryClass, candidates }) => candidates.filter((candidate) => candidate.observation !== null).map((candidate) => row(candidate, queryClass)))
24470
+ };
24471
+ }
24413
24472
 
24414
24473
  // ../api-routes/src/visibility-report.ts
24415
24474
  function normalizedText3(value) {
@@ -24926,6 +24985,13 @@ function comparableVersionIds(rows, versionId) {
24926
24985
  }
24927
24986
  return ids;
24928
24987
  }
24988
+ function comparableChainHeads(rows, rowsById) {
24989
+ const heads = /* @__PURE__ */ new Map();
24990
+ for (const row of [...rows].sort((left, right) => left.revision - right.revision)) {
24991
+ for (const id of comparableVersionIds(rowsById, row.id)) heads.set(id, row.id);
24992
+ }
24993
+ return heads;
24994
+ }
24929
24995
  function assignmentSignature(plan, assignment) {
24930
24996
  const node = plan.executionNodes.find((candidate) => candidate.stableKey === assignment.executionNodeKey);
24931
24997
  if (!node) throw new Error(`Frozen assignment references missing execution node ${assignment.executionNodeKey}`);
@@ -25080,6 +25146,37 @@ async function visibilityReportRoutes(app) {
25080
25146
  async (request) => readVisibilityReport(app.db, resolveProject(app.db, request.params.name), request.query)
25081
25147
  );
25082
25148
  }
25149
+ function readVisibilityComparisonRuns(db, projectId, since, until) {
25150
+ const sourceRuns = db.select().from(runs).where(and21(
25151
+ eq26(runs.projectId, projectId),
25152
+ eq26(runs.kind, RunKinds["answer-visibility"]),
25153
+ inArray10(runs.status, [RunStatuses.completed, RunStatuses.partial]),
25154
+ notProbeRun(),
25155
+ isNotNull(runs.measurementPlanVersionId),
25156
+ isNull5(runs.measurementScope),
25157
+ gte4(runs.createdAt, since),
25158
+ lte4(runs.createdAt, until)
25159
+ )).all();
25160
+ const versionRows = db.select().from(measurementPlanVersions).where(eq26(measurementPlanVersions.projectId, projectId)).all();
25161
+ const rowsById = new Map(versionRows.map((version) => [version.id, version]));
25162
+ const versions = parseV2Versions(versionRows);
25163
+ const heads = comparableChainHeads(versionRows, rowsById);
25164
+ const byRun = loadVisibilitySnapshots(db, sourceRuns.map((run) => run.id));
25165
+ let unavailable5 = false;
25166
+ const selected = sourceRuns.flatMap((run) => {
25167
+ const source = versions.get(run.measurementPlanVersionId);
25168
+ if (!source) {
25169
+ unavailable5 = true;
25170
+ return [];
25171
+ }
25172
+ const presentation = versions.get(heads.get(source.row.id) ?? source.row.id) ?? source;
25173
+ const snapshots = byRun.get(run.id) ?? [];
25174
+ const models = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot.model]));
25175
+ const materialized = advancedRun(run, presentation.row, presentation.plan, snapshots, comparableVersionIds(rowsById, presentation.row.id));
25176
+ return [{ ...materialized, observations: materialized.observations.map((observation) => ({ ...observation, model: models.get(observation.answerId) ?? null })) }];
25177
+ });
25178
+ return { runs: selected, unavailable: unavailable5 };
25179
+ }
25083
25180
 
25084
25181
  // ../api-routes/src/query-tracking.ts
25085
25182
  import crypto21 from "crypto";
@@ -34820,15 +34917,15 @@ import { and as and35, desc as desc18, eq as eq39, inArray as inArray18 } from "
34820
34917
 
34821
34918
  // ../api-routes/src/visibility-compare.ts
34822
34919
  var VISIBILITY_COMPARE_MIN_RUNS = 5;
34823
- function basketPairKey(queryId, provider) {
34824
- return JSON.stringify([queryId, provider]);
34920
+ function basketPairKey(queryId, provider, cohortKey) {
34921
+ return JSON.stringify([queryId, provider, cohortKey ?? null]);
34825
34922
  }
34826
34923
  function restrict(snapshots, attribution, pairs) {
34827
34924
  const out = [];
34828
34925
  for (const snap of snapshots) {
34829
34926
  const resolved = resolveCurrentQuery(attribution, snap);
34830
34927
  if (!resolved) continue;
34831
- if (!pairs.has(basketPairKey(resolved.id, snap.provider))) continue;
34928
+ if (!pairs.has(basketPairKey(resolved.id, snap.provider, snap.cohortKey))) continue;
34832
34929
  out.push({ ...snap, queryId: resolved.id });
34833
34930
  }
34834
34931
  return out;
@@ -34849,8 +34946,8 @@ function observedPairs(snapshots, attribution, queryIds) {
34849
34946
  for (const snap of snapshots) {
34850
34947
  const resolved = resolveCurrentQuery(attribution, snap);
34851
34948
  if (!resolved || !queryIds.has(resolved.id)) continue;
34852
- const pair = { queryId: resolved.id, provider: snap.provider };
34853
- pairs.set(basketPairKey(pair.queryId, pair.provider), pair);
34949
+ const pair = { queryId: resolved.id, provider: snap.provider, cohortKey: snap.cohortKey };
34950
+ pairs.set(basketPairKey(pair.queryId, pair.provider, pair.cohortKey), pair);
34854
34951
  }
34855
34952
  return pairs;
34856
34953
  }
@@ -34885,13 +34982,23 @@ function metric(key, label, queryClass, driftRobust, from, to, continuityBlock)
34885
34982
  const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
34886
34983
  return { key, label, queryClass, driftRobust, from, to, rateRatio, direction, verdict };
34887
34984
  }
34888
- function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable, projectBrandNames) {
34985
+ function modelEvidenceOf(snaps) {
34986
+ const evidence = /* @__PURE__ */ new Map();
34987
+ for (const snap of snaps) {
34988
+ const models = evidence.get(snap.provider) ?? [];
34989
+ models.push(snap.model);
34990
+ evidence.set(snap.provider, models);
34991
+ }
34992
+ return evidence;
34993
+ }
34994
+ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable, projectMatcher) {
34889
34995
  let checked = 0;
34890
34996
  let mentioned = 0;
34997
+ let total = 0;
34891
34998
  let cited = 0;
34999
+ let citationUnknown = 0;
34892
35000
  let competitorCited = 0;
34893
35001
  const perProvider = /* @__PURE__ */ new Map();
34894
- const modelEvidence = /* @__PURE__ */ new Map();
34895
35002
  const mentionedQueries = /* @__PURE__ */ new Set();
34896
35003
  const competitorHosts = competitors2.map((c) => hostOf(c.domain)).filter((h) => h !== null && h.length > 0);
34897
35004
  for (const snap of snaps) {
@@ -34901,43 +35008,59 @@ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable,
34901
35008
  mentioned += 1;
34902
35009
  mentionedQueries.add(snap.queryId);
34903
35010
  }
34904
- const isCited = snap.citationState === CitationStates.cited;
35011
+ const citationKnown = snap.citationChecked !== false;
35012
+ if (citationKnown) total += 1;
35013
+ else citationUnknown += 1;
35014
+ const isCited = citationKnown && snap.citationState === CitationStates.cited;
34905
35015
  if (isCited) cited += 1;
34906
35016
  const pp = perProvider.get(snap.provider) ?? { checked: 0, mentioned: 0, cited: 0 };
34907
35017
  if (snap.answerMentioned === true || snap.answerMentioned === false) pp.checked += 1;
34908
35018
  if (isMentioned) pp.mentioned += 1;
34909
35019
  if (isCited) pp.cited += 1;
34910
35020
  perProvider.set(snap.provider, pp);
34911
- const models = modelEvidence.get(snap.provider) ?? [];
34912
- models.push(snap.model);
34913
- modelEvidence.set(snap.provider, models);
34914
- if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
35021
+ if (!citationKnown) continue;
35022
+ if (snap.competitorCitations !== void 0) competitorCited += snap.competitorCitations.length;
35023
+ else if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
34915
35024
  const citedHosts = snap.citedDomains.map((d) => hostOf(d)).filter((h) => h !== null && h.length > 0);
34916
35025
  for (const compHost of competitorHosts) {
34917
35026
  if (citedHosts.some((ch) => hostMatchesDomain(ch, compHost))) competitorCited += 1;
34918
35027
  }
34919
35028
  }
34920
35029
  }
34921
- const mentionShare = buildMentionShare(
35030
+ let mentionShare = buildMentionShare(
34922
35031
  snaps.map((s) => ({
34923
35032
  // Share uses current identity; named-rate counts above deliberately keep
34924
35033
  // their historical persisted-boolean semantics.
34925
- projectMentioned: s.answerText ? determineAnswerMentioned(s.answerText, [...projectBrandNames], []) : s.answerMentioned === true,
35034
+ projectMentioned: s.answerText ? matcherMatchesText(projectMatcher, s.answerText) : s.answerMentioned === true,
34926
35035
  answerText: s.answerText,
34927
35036
  queryClass: queryClassOf(s)
34928
35037
  })),
34929
35038
  { competitors: competitors2, classificationAvailable }
34930
35039
  );
35040
+ if (snaps.some((snapshot) => snapshot.competitorMentions !== void 0)) {
35041
+ const selected = snaps.filter((snapshot) => (!classificationAvailable || queryClassOf(snapshot) === "non-brand") && snapshot.answerMentioned !== null);
35042
+ const perCompetitor = competitors2.map((competitor) => ({
35043
+ domain: competitor.domain,
35044
+ mentionSnapshots: selected.filter((snapshot) => snapshot.competitorMentions?.includes(competitor.domain)).length
35045
+ })).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1));
35046
+ mentionShare = { ...mentionShare, breakdown: {
35047
+ ...mentionShare.breakdown,
35048
+ projectMentionSnapshots: selected.filter((snapshot) => snapshot.answerMentioned === true).length,
35049
+ competitorMentionSnapshots: perCompetitor.reduce((total2, competitor) => total2 + competitor.mentionSnapshots, 0),
35050
+ perCompetitor
35051
+ } };
35052
+ }
34931
35053
  return {
34932
35054
  checked,
34933
35055
  mentioned,
34934
- total: snaps.length,
35056
+ total,
34935
35057
  cited,
35058
+ citationUnknown,
35059
+ citationEvidence: snaps.some((snapshot) => snapshot.citationChecked !== void 0),
34936
35060
  projectCited: cited,
34937
35061
  competitorCited,
34938
35062
  queriesMentioned: mentionedQueries.size,
34939
35063
  perProvider,
34940
- modelEvidence,
34941
35064
  mentionShare,
34942
35065
  competitors: mentionShare.breakdown.perCompetitor.map((c) => ({ domain: c.domain, mentions: c.mentionSnapshots }))
34943
35066
  };
@@ -34947,15 +35070,13 @@ function modelIds(evidence) {
34947
35070
  if (evidence.status === "mixed") return evidence.models;
34948
35071
  return [];
34949
35072
  }
34950
- function isUnknownModelEvidence(evidence) {
34951
- return evidence.status === "unknown" || evidence.status === "mixed" && evidence.includesUnknown;
34952
- }
34953
35073
  function computeVisibilityCompare(input) {
34954
35074
  const attribution = buildQueryAttribution(input.queries);
34955
35075
  const classifier = compileQueryClassifier(input.brandNames ?? []);
34956
- const classificationAvailable = classifier !== null;
35076
+ const classificationAvailable = input.frozenClassification === true || classifier !== null;
34957
35077
  const queryTextById = new Map(input.queries.map((q) => [q.id, q.query]));
34958
- const queryClassOf = (snap) => classifier ? classifier.classify(queryTextById.get(snap.queryId) ?? snap.queryText) : null;
35078
+ const queryClassOf = (snap) => input.frozenClassification ? snap.queryClass ?? null : classifier ? classifier.classify(queryTextById.get(snap.queryId) ?? snap.queryText) : null;
35079
+ const projectMatcher = compileBrandAliases(input.brandNames ?? []);
34959
35080
  const fromObs = observed(input.from.snapshots, attribution);
34960
35081
  const toObs = observed(input.to.snapshots, attribution);
34961
35082
  const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
@@ -34963,17 +35084,13 @@ function computeVisibilityCompare(input) {
34963
35084
  const toPairs = observedPairs(input.to.snapshots, attribution, queriesObservedBoth);
34964
35085
  const pairsBoth = new Map([...fromPairs].filter(([key]) => toPairs.has(key)));
34965
35086
  const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
34966
- const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
34967
- const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
34968
- const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
34969
- const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
35087
+ const fromCandidateModels = modelEvidenceOf(restrict(input.from.snapshots, attribution, pairsBoth));
35088
+ const toCandidateModels = modelEvidenceOf(restrict(input.to.snapshots, attribution, pairsBoth));
34970
35089
  const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
34971
- const fromEvidence = classifyModelEvidence(fromCandidateCounts.modelEvidence.get(provider) ?? []);
34972
- const toEvidence = classifyModelEvidence(toCandidateCounts.modelEvidence.get(provider) ?? []);
34973
- const fromModels = modelIds(fromEvidence);
34974
- const toModels = modelIds(toEvidence);
34975
- const status = isUnknownModelEvidence(fromEvidence) || isUnknownModelEvidence(toEvidence) ? "model-unknown" : fromEvidence.status === "known" && toEvidence.status === "known" && fromEvidence.model === toEvidence.model ? "included" : "model-discontinuous";
34976
- return { provider, status, fromModels, toModels };
35090
+ return { provider, ...compareModelContinuity(
35091
+ fromCandidateModels.get(provider) ?? [],
35092
+ toCandidateModels.get(provider) ?? []
35093
+ ) };
34977
35094
  });
34978
35095
  const providersBoth = new Set(
34979
35096
  continuityProviders.filter((provider) => provider.status === "included").map((provider) => provider.provider)
@@ -34985,14 +35102,20 @@ function computeVisibilityCompare(input) {
34985
35102
  const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
34986
35103
  const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
34987
35104
  const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
34988
- const fromCounts = countPeriod(fromSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
34989
- const toCounts = countPeriod(toSnaps, input.competitors, queryClassOf, classificationAvailable, input.brandNames ?? []);
35105
+ const fromCounts = countPeriod(fromSnaps, input.competitors, queryClassOf, classificationAvailable, projectMatcher);
35106
+ const toCounts = countPeriod(toSnaps, input.competitors, queryClassOf, classificationAvailable, projectMatcher);
35107
+ const fromClassSnaps = input.from.classSnapshots === void 0 ? fromSnaps : restrict(input.from.classSnapshots, attribution, comparedPairs);
35108
+ const toClassSnaps = input.to.classSnapshots === void 0 ? toSnaps : restrict(input.to.classSnapshots, attribution, comparedPairs);
35109
+ const noProjectMatcher = compileBrandAliases([]);
35110
+ const fromShareCounts = input.frozenClassification ? countPeriod(fromClassSnaps, input.competitors, queryClassOf, true, noProjectMatcher) : fromCounts;
35111
+ const toShareCounts = input.frozenClassification ? countPeriod(toClassSnaps, input.competitors, queryClassOf, true, noProjectMatcher) : toCounts;
34990
35112
  const shareCounts = (c) => ({
34991
35113
  proj: c.mentionShare.breakdown.projectMentionSnapshots,
34992
35114
  comp: c.mentionShare.breakdown.competitorMentionSnapshots
34993
35115
  });
34994
- const fromShare = shareCounts(fromCounts);
34995
- const toShare = shareCounts(toCounts);
35116
+ const fromShare = shareCounts(fromShareCounts);
35117
+ const toShare = shareCounts(toShareCounts);
35118
+ const citationPeriod = (c, value) => c.citationEvidence ? { ...value, excludedUnknown: c.citationUnknown } : value;
34996
35119
  const metrics = [
34997
35120
  metric(
34998
35121
  "mention-share-of-voice",
@@ -35013,8 +35136,8 @@ function computeVisibilityCompare(input) {
35013
35136
  "Cited share of voice",
35014
35137
  "all",
35015
35138
  true,
35016
- input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : unavailableCompetitivePeriod(fromCounts.projectCited),
35017
- input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : unavailableCompetitivePeriod(toCounts.projectCited),
35139
+ citationPeriod(fromCounts, input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : unavailableCompetitivePeriod(fromCounts.projectCited)),
35140
+ citationPeriod(toCounts, input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : unavailableCompetitivePeriod(toCounts.projectCited)),
35018
35141
  continuityBlock
35019
35142
  ),
35020
35143
  metric(
@@ -35031,14 +35154,34 @@ function computeVisibilityCompare(input) {
35031
35154
  "Cited rate",
35032
35155
  "all",
35033
35156
  false,
35034
- period(fromCounts.cited, fromCounts.total),
35035
- period(toCounts.cited, toCounts.total),
35157
+ citationPeriod(fromCounts, period(fromCounts.cited, fromCounts.total)),
35158
+ citationPeriod(toCounts, period(toCounts.cited, toCounts.total)),
35036
35159
  continuityBlock
35037
35160
  )
35038
35161
  ];
35162
+ const classPeriod = (snapshots, queryClass, signal) => {
35163
+ if (!classificationAvailable) return { ...period(0, 0), availability: "classification-unavailable" };
35164
+ const selected = snapshots.filter((snapshot) => queryClassOf(snapshot) === queryClass && (signal === "cited" || snapshot.mentionApplicable !== false));
35165
+ const checked = selected.filter((snapshot) => signal === "mention" ? typeof snapshot.answerMentioned === "boolean" : snapshot.citationChecked !== false);
35166
+ const numerator = checked.filter((snapshot) => signal === "mention" ? snapshot.answerMentioned === true : snapshot.citationState === CitationStates.cited).length;
35167
+ return { ...period(numerator, checked.length), excludedUnknown: selected.length - checked.length };
35168
+ };
35169
+ for (const queryClass of ["branded", "non-brand"]) {
35170
+ for (const signal of ["mention", "cited"]) {
35171
+ metrics.push(metric(
35172
+ `${signal}-rate-${queryClass}`,
35173
+ signal === "mention" ? "Mention rate" : "Cited rate",
35174
+ queryClass,
35175
+ false,
35176
+ classPeriod(fromClassSnaps, queryClass, signal),
35177
+ classPeriod(toClassSnaps, queryClass, signal),
35178
+ continuityBlock
35179
+ ));
35180
+ }
35181
+ }
35039
35182
  const modelChanges = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
35040
- const fromModels = modelIds(classifyModelEvidence(fromCandidateCounts.modelEvidence.get(provider) ?? []));
35041
- const toModels = modelIds(classifyModelEvidence(toCandidateCounts.modelEvidence.get(provider) ?? []));
35183
+ const fromModels = modelIds(classifyModelEvidence(fromCandidateModels.get(provider) ?? []));
35184
+ const toModels = modelIds(classifyModelEvidence(toCandidateModels.get(provider) ?? []));
35042
35185
  return { provider, fromModels, toModels };
35043
35186
  }).filter(
35044
35187
  (c) => c.fromModels.length > 0 && c.toModels.length > 0 && JSON.stringify(c.fromModels) !== JSON.stringify(c.toModels)
@@ -35083,7 +35226,7 @@ function computeVisibilityCompare(input) {
35083
35226
  comparedProviders: [...providersBoth].sort((a, b) => a.localeCompare(b)),
35084
35227
  providers: continuityProviders
35085
35228
  },
35086
- competitors: { from: fromCounts.competitors, to: toCounts.competitors }
35229
+ competitors: { from: fromShareCounts.competitors, to: toShareCounts.competitors }
35087
35230
  };
35088
35231
  }
35089
35232
 
@@ -35301,59 +35444,150 @@ async function visibilityStatsRoutes(app) {
35301
35444
  return reply.send(response);
35302
35445
  });
35303
35446
  app.get("/projects/:name/visibility-compare", async (request, reply) => {
35304
- const project = resolveProject(app.db, request.params.name);
35305
- const { from: fromRaw, to: toRaw } = request.query;
35306
- if (fromRaw === void 0 || fromRaw === "") throw validationError('"from" (YYYY-MM) is required');
35307
- if (toRaw === void 0 || toRaw === "") throw validationError('"to" (YYYY-MM) is required');
35308
- let fromBounds;
35309
- let toBounds;
35310
- try {
35311
- fromBounds = calendarMonthBounds(fromRaw);
35312
- } catch (err) {
35313
- throw validationError(err instanceof RangeError ? `"from": ${err.message}` : '"from" must be in YYYY-MM format');
35314
- }
35315
- try {
35316
- toBounds = calendarMonthBounds(toRaw);
35317
- } catch (err) {
35318
- throw validationError(err instanceof RangeError ? `"to": ${err.message}` : '"to" must be in YYYY-MM format');
35319
- }
35320
- if (Date.parse(fromBounds.since) >= Date.parse(toBounds.since)) {
35321
- throw validationError('"from" must be a month strictly before "to"');
35322
- }
35323
- const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq39(queries.projectId, project.id)).all();
35324
- const competitorRows2 = app.db.select({ domain: competitors.domain }).from(competitors).where(eq39(competitors.projectId, project.id)).all();
35325
- const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows2.map((c) => c.domain));
35326
- const loadMonth = (bounds) => {
35327
- const sinceMs = Date.parse(bounds.since);
35328
- const untilMs = Date.parse(bounds.until);
35329
- const monthRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt, status: runs.status }).from(runs).where(and35(eq39(runs.projectId, project.id), eq39(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter(
35330
- (r) => (r.status === RunStatuses.completed || r.status === RunStatuses.partial) && Date.parse(r.createdAt) >= sinceMs && Date.parse(r.createdAt) <= untilMs
35331
- );
35332
- const runIds = monthRuns.map((r) => r.id);
35333
- const snapshots = runIds.length > 0 && projectQueries.length > 0 ? app.db.select({
35334
- queryId: querySnapshots.queryId,
35335
- queryText: querySnapshots.queryText,
35336
- provider: querySnapshots.provider,
35337
- model: querySnapshots.model,
35338
- citationState: querySnapshots.citationState,
35339
- answerMentioned: querySnapshots.answerMentioned,
35340
- answerText: querySnapshots.answerText,
35341
- citedDomains: querySnapshots.citedDomains
35342
- }).from(querySnapshots).where(inArray18(querySnapshots.runId, runIds)).all() : [];
35343
- return { runCount: monthRuns.length, snapshots };
35447
+ const dto = readVisibilityCompare(app.db, request.params.name, request.query);
35448
+ return reply.send(dto);
35449
+ });
35450
+ }
35451
+ function readVisibilityCompare(db, projectName, query) {
35452
+ const project = resolveProject(db, projectName);
35453
+ const selection = visibilityCompareSelectionSchema.safeParse(query);
35454
+ if (!selection.success) throw validationError("Invalid comparison selection", { issues: selection.error.issues });
35455
+ const filters = selection.data;
35456
+ const scope = filters.scope ?? "project";
35457
+ if (scope !== "project" && filters.scopeKey === void 0) throw validationError("A non-project comparison scope requires scopeKey.");
35458
+ if (scope === "project" && filters.scopeKey !== void 0) throw validationError("scopeKey is not valid for project scope.");
35459
+ if (scope === "market" && filters.marketKey !== void 0) throw validationError("marketKey is not valid for market scope.");
35460
+ const { from: fromRaw, to: toRaw } = query;
35461
+ if (fromRaw === void 0 || fromRaw === "") throw validationError('"from" (YYYY-MM) is required');
35462
+ if (toRaw === void 0 || toRaw === "") throw validationError('"to" (YYYY-MM) is required');
35463
+ let fromBounds;
35464
+ let toBounds;
35465
+ try {
35466
+ fromBounds = calendarMonthBounds(fromRaw);
35467
+ } catch (err) {
35468
+ throw validationError(err instanceof RangeError ? `"from": ${err.message}` : '"from" must be in YYYY-MM format');
35469
+ }
35470
+ try {
35471
+ toBounds = calendarMonthBounds(toRaw);
35472
+ } catch (err) {
35473
+ throw validationError(err instanceof RangeError ? `"to": ${err.message}` : '"to" must be in YYYY-MM format');
35474
+ }
35475
+ if (Date.parse(fromBounds.since) >= Date.parse(toBounds.since)) {
35476
+ throw validationError('"from" must be a month strictly before "to"');
35477
+ }
35478
+ const providerKey2 = filters.provider === void 0 ? void 0 : normalizeText3(filters.provider);
35479
+ const locationKey2 = filters.location === void 0 ? void 0 : normalizeText3(filters.location);
35480
+ const scoped = scope !== "project" || filters.marketKey !== void 0;
35481
+ let advancedComparison;
35482
+ if (activeMeasurementPlan(db, project.id) !== null) {
35483
+ let missingScope;
35484
+ let measuredScope = 0;
35485
+ const loadAdvancedMonth = (bounds) => {
35486
+ const selected = readVisibilityComparisonRuns(db, project.id, bounds.since, bounds.until);
35487
+ const snapshots = [];
35488
+ const classSnapshots = [];
35489
+ let runCount = 0;
35490
+ for (const run of selected.runs) {
35491
+ let population;
35492
+ try {
35493
+ population = visibilityComparisonPopulation(run, {
35494
+ ...filters,
35495
+ scope,
35496
+ queryClass: "all",
35497
+ location: locationKey2 === void 0 ? { kind: "all" } : locationKey2 === "none" ? { kind: "none" } : { kind: "exact", value: filters.location },
35498
+ limit: 100
35499
+ });
35500
+ } catch (error) {
35501
+ if (error instanceof VisibilityReportScopeError && error.details !== void 0) {
35502
+ missingScope ??= error;
35503
+ continue;
35504
+ }
35505
+ if (error instanceof VisibilityReportScopeError) throw validationError(error.message, error.details);
35506
+ throw error;
35507
+ }
35508
+ runCount += 1;
35509
+ const adapt = (snapshot) => ({
35510
+ ...snapshot,
35511
+ citationState: snapshot.citation === true ? CitationStates.cited : CitationStates["not-cited"],
35512
+ citationChecked: snapshot.citation !== null,
35513
+ citedDomains: []
35514
+ });
35515
+ snapshots.push(...population.snapshots.map(adapt));
35516
+ classSnapshots.push(...population.classSnapshots.map(adapt));
35517
+ }
35518
+ measuredScope += runCount;
35519
+ return { runCount, snapshots, classSnapshots, classificationAvailable: !selected.unavailable };
35344
35520
  };
35345
- const fromMonth = loadMonth(fromBounds);
35346
- const toMonth = loadMonth(toBounds);
35347
- const dto = computeVisibilityCompare({
35521
+ const fromMonth2 = loadAdvancedMonth(fromBounds);
35522
+ const toMonth2 = loadAdvancedMonth(toBounds);
35523
+ if (missingScope !== void 0 && measuredScope === 0) throw validationError(missingScope.message, missingScope.details);
35524
+ const queries2 = [...new Map([...fromMonth2.snapshots, ...toMonth2.snapshots].map((snapshot) => [snapshot.queryId, { id: snapshot.queryId, query: snapshot.queryText }])).values()];
35525
+ advancedComparison = { ...computeVisibilityCompare({
35348
35526
  project: project.name,
35349
- queries: projectQueries,
35350
- competitors: competitorInputs,
35351
- brandNames: effectiveBrandNames(project),
35352
- from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
35353
- to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
35354
- });
35355
- return reply.send(dto);
35527
+ queries: queries2,
35528
+ competitors: [...new Set([...fromMonth2.snapshots, ...toMonth2.snapshots].flatMap((snapshot) => snapshot.competitorDomains ?? []))].map((domain) => ({ domain, brandTokens: [] })),
35529
+ frozenClassification: fromMonth2.classificationAvailable && toMonth2.classificationAvailable,
35530
+ from: { ...fromMonth2, month: fromRaw, ...fromBounds },
35531
+ to: { ...toMonth2, month: toRaw, ...toBounds }
35532
+ }), selection: filters };
35533
+ if (scoped) {
35534
+ const unreadable = [fromMonth2.classificationAvailable ? null : fromRaw, toMonth2.classificationAvailable ? null : toRaw].filter((month) => month !== null);
35535
+ if (unreadable.length > 0) {
35536
+ throw validationError(`Property, group and market comparisons need frozen schema-v2 plan history; ${unreadable.join(" and ")} include runs whose plan cannot be reconstructed.`, { months: unreadable });
35537
+ }
35538
+ return advancedComparison;
35539
+ }
35540
+ }
35541
+ if (advancedComparison === void 0 && scoped) throw validationError("Property, group and market comparison scopes require an Advanced portfolio.");
35542
+ const projectQueries = db.select({ id: queries.id, query: queries.query }).from(queries).where(eq39(queries.projectId, project.id)).all();
35543
+ const competitorRows2 = db.select({ domain: competitors.domain }).from(competitors).where(eq39(competitors.projectId, project.id)).all();
35544
+ const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows2.map((c) => c.domain));
35545
+ const loadMonth = (bounds) => {
35546
+ const sinceMs = Date.parse(bounds.since);
35547
+ const untilMs = Date.parse(bounds.until);
35548
+ const monthRuns = db.select({ id: runs.id, createdAt: runs.createdAt, status: runs.status }).from(runs).where(and35(eq39(runs.projectId, project.id), eq39(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter(
35549
+ (r) => (r.status === RunStatuses.completed || r.status === RunStatuses.partial) && Date.parse(r.createdAt) >= sinceMs && Date.parse(r.createdAt) <= untilMs
35550
+ );
35551
+ const runIds = monthRuns.map((r) => r.id);
35552
+ const snapshots = runIds.length > 0 && projectQueries.length > 0 ? db.select({
35553
+ queryId: querySnapshots.queryId,
35554
+ queryText: querySnapshots.queryText,
35555
+ provider: querySnapshots.provider,
35556
+ model: querySnapshots.model,
35557
+ citationState: querySnapshots.citationState,
35558
+ answerMentioned: querySnapshots.answerMentioned,
35559
+ answerText: querySnapshots.answerText,
35560
+ citedDomains: querySnapshots.citedDomains,
35561
+ location: querySnapshots.location
35562
+ }).from(querySnapshots).where(inArray18(querySnapshots.runId, runIds)).all() : [];
35563
+ return {
35564
+ runCount: monthRuns.length,
35565
+ snapshots: snapshots.filter((snapshot) => (providerKey2 === void 0 || normalizeText3(snapshot.provider) === providerKey2) && (locationKey2 === void 0 || (locationKey2 === "none" ? snapshot.location === null : snapshot.location !== null && normalizeText3(snapshot.location) === locationKey2)))
35566
+ };
35567
+ };
35568
+ const fromMonth = loadMonth(fromBounds);
35569
+ const toMonth = loadMonth(toBounds);
35570
+ const dto = computeVisibilityCompare({
35571
+ project: project.name,
35572
+ queries: projectQueries,
35573
+ competitors: competitorInputs,
35574
+ brandNames: effectiveBrandNames(project),
35575
+ from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
35576
+ to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
35356
35577
  });
35578
+ if (advancedComparison !== void 0) {
35579
+ const { from, to, basket, continuity: continuity2, modelChanges } = advancedComparison;
35580
+ return {
35581
+ ...dto,
35582
+ selection: filters,
35583
+ metrics: [
35584
+ ...dto.metrics.filter((metric2) => !isVisibilityCompareClassMetric(metric2.key)),
35585
+ ...advancedComparison.metrics.filter((metric2) => isVisibilityCompareClassMetric(metric2.key))
35586
+ ],
35587
+ classComparison: { from, to, basket, continuity: continuity2, modelChanges }
35588
+ };
35589
+ }
35590
+ return { ...dto, selection: filters };
35357
35591
  }
35358
35592
 
35359
35593
  // ../api-routes/src/results-export.ts
@@ -39050,9 +39284,18 @@ var routeCatalog = [
39050
39284
  method: "get",
39051
39285
  path: "/api/v1/projects/{name}/visibility-compare",
39052
39286
  summary: "Compare AEO visibility month over month",
39053
- description: "Statistically honest month-over-month AEO comparison in one call. PRIMARY metric is share of voice (brand vs competitor mentions in the same answers), which is less exposed to broad model-wide naming propensity than an absolute rate but never bypasses model continuity. Rates are pooled per-snapshot over each month (invariant to sweep count), restricted to query/provider pairs present in BOTH months, and then restricted again to providers with exactly one known, identical configured model id in both months. `continuity` reports every provider and its model evidence; changed, mixed mid-month, or legacy-unknown models are excluded. When no provider remains, metrics return a continuity-blocked verdict rather than a directional call. `from` must be a month strictly before `to`. A silent upstream version bump under an unchanged configured id remains undetectable.",
39287
+ description: "Statistically honest month-over-month AEO comparison in one call. PRIMARY metric is share of voice (brand vs competitor mentions in the same answers), which is less exposed to broad model-wide naming propensity than an absolute rate but never bypasses model continuity. Rates are pooled per-snapshot over each month (invariant to sweep count), restricted to query/provider pairs present in BOTH months, and then restricted again to providers with exactly one known, identical configured model id in both months. `continuity` reports every provider and its model evidence; changed, mixed mid-month, or legacy-unknown models are excluded. When no provider remains, metrics return a continuity-blocked verdict rather than a directional call. `from` must be a month strictly before `to`. Adds separate branded/non-brand mention and cited rates with classification-unavailable rather than pooled fallback. Advanced class metrics read frozen Property/market/execution assignments and reuse each answer once per class. Without a scope or marketKey the four original metrics keep the project frame (provider/location narrow it the same way for Simple and Advanced) and Advanced class metrics carry their own `classComparison` cohort. A scope or marketKey answers entirely from the frozen frame and is refused when either month holds runs whose plan cannot be reconstructed (schema v1). On frozen frames, answers with incomplete source capture leave every citation figure and are counted in `excludedUnknown`. A silent upstream version bump under an unchanged configured id remains undetectable.",
39054
39288
  tags: ["analytics"],
39055
- parameters: [nameParameter, compareFromQueryParameter, compareToQueryParameter],
39289
+ parameters: [
39290
+ nameParameter,
39291
+ compareFromQueryParameter,
39292
+ compareToQueryParameter,
39293
+ { name: "scope", in: "query", description: "Advanced frozen population scope; default project.", schema: { type: "string", enum: ["project", "group", "market", "property"] } },
39294
+ { name: "scopeKey", in: "query", description: "Stable Property Target, group, or market key; required for non-project scope.", schema: stringSchema },
39295
+ { name: "marketKey", in: "query", description: "Intersect project/group/property selection with exact frozen market edges.", schema: stringSchema },
39296
+ { name: "provider", in: "query", description: "Restrict both frames to one provider (case-insensitive).", schema: stringSchema },
39297
+ { name: "location", in: "query", description: "Execution location label (case-insensitive), or none for no location.", schema: stringSchema }
39298
+ ],
39056
39299
  responses: {
39057
39300
  200: jsonResponse("Month-over-month visibility comparison returned.", "VisibilityCompareDto"),
39058
39301
  400: errorResponse("Invalid or missing from/to months."),
@@ -48037,6 +48280,8 @@ async function googleRoutes(app, opts) {
48037
48280
  tx.delete(gbpKeywordMonthly).where(eq46(gbpKeywordMonthly.projectId, projectId)).run();
48038
48281
  tx.delete(gbpPlaceActions).where(eq46(gbpPlaceActions.projectId, projectId)).run();
48039
48282
  tx.delete(gbpLodgingSnapshots).where(eq46(gbpLodgingSnapshots.projectId, projectId)).run();
48283
+ tx.delete(gbpAttributesSnapshots).where(eq46(gbpAttributesSnapshots.projectId, projectId)).run();
48284
+ tx.delete(gbpPlaceDetails).where(eq46(gbpPlaceDetails.projectId, projectId)).run();
48040
48285
  tx.delete(gbpLocations).where(eq46(gbpLocations.projectId, projectId)).run();
48041
48286
  }
48042
48287
  function currentProjectAccount(projectId) {