@ainyc/canonry 4.36.0 → 4.40.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.
@@ -3256,24 +3256,51 @@ function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains, topDo
3256
3256
  }
3257
3257
 
3258
3258
  // ../intelligence/src/movement-summary.ts
3259
- function buildMovementSummary(currentSnapshots, previousSnapshots) {
3259
+ function buildMovementSummary(currentSnapshots, previousSnapshots, options = {}) {
3260
3260
  if (previousSnapshots.length === 0) {
3261
- const citedCount = collectCitedQueryIds(currentSnapshots).size;
3261
+ const citedIds = collectCitedQueryIds(currentSnapshots);
3262
+ const citedCount = citedIds.size;
3262
3263
  const tone2 = citedCount > 0 ? "positive" : "neutral";
3263
- return { gained: citedCount, lost: 0, tone: tone2, hasPreviousRun: false };
3264
+ return withQueryLists(
3265
+ { gained: citedCount, lost: 0, tone: tone2, hasPreviousRun: false },
3266
+ citedIds,
3267
+ /* @__PURE__ */ new Set(),
3268
+ options.queryLookup
3269
+ );
3264
3270
  }
3265
3271
  const latestCited = collectCitedQueryIds(currentSnapshots);
3266
3272
  const previousCited = collectCitedQueryIds(previousSnapshots);
3267
- let gained = 0;
3268
- let lost = 0;
3273
+ const gainedIds = /* @__PURE__ */ new Set();
3274
+ const lostIds = /* @__PURE__ */ new Set();
3269
3275
  for (const id of latestCited) {
3270
- if (!previousCited.has(id)) gained++;
3276
+ if (!previousCited.has(id)) gainedIds.add(id);
3271
3277
  }
3272
3278
  for (const id of previousCited) {
3273
- if (!latestCited.has(id)) lost++;
3279
+ if (!latestCited.has(id)) lostIds.add(id);
3274
3280
  }
3275
- const tone = lost > gained ? "negative" : gained > lost ? "positive" : "neutral";
3276
- return { gained, lost, tone, hasPreviousRun: true };
3281
+ const tone = lostIds.size > gainedIds.size ? "negative" : gainedIds.size > lostIds.size ? "positive" : "neutral";
3282
+ return withQueryLists(
3283
+ { gained: gainedIds.size, lost: lostIds.size, tone, hasPreviousRun: true },
3284
+ gainedIds,
3285
+ lostIds,
3286
+ options.queryLookup
3287
+ );
3288
+ }
3289
+ function withQueryLists(base, gainedIds, lostIds, lookup) {
3290
+ if (!lookup) return base;
3291
+ return {
3292
+ ...base,
3293
+ gainedQueries: resolveQueryTexts(gainedIds, lookup),
3294
+ lostQueries: resolveQueryTexts(lostIds, lookup)
3295
+ };
3296
+ }
3297
+ function resolveQueryTexts(ids, lookup) {
3298
+ const out = [];
3299
+ for (const id of ids) {
3300
+ const text2 = lookup.get(id);
3301
+ if (text2) out.push(text2);
3302
+ }
3303
+ return out.sort();
3277
3304
  }
3278
3305
  function collectCitedQueryIds(snapshots) {
3279
3306
  const cited = /* @__PURE__ */ new Set();
@@ -3405,11 +3432,48 @@ function buildGapQueryScore(snapshots) {
3405
3432
  ).length;
3406
3433
  const gapQueryLabel = gapCount === 1 ? "query" : "queries";
3407
3434
  return {
3408
- label: "Gap Queries",
3435
+ label: "Citation Gaps",
3409
3436
  value: `${gapCount}`,
3410
3437
  delta: `${gapCount} of ${totalCount} queries at risk`,
3411
3438
  tone: gapTone(gapCount, totalCount),
3412
- description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive query gaps detected in the latest visibility run.",
3439
+ description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive citation gaps detected in the latest visibility run.",
3440
+ tooltip,
3441
+ trend: [],
3442
+ progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
3443
+ };
3444
+ }
3445
+ function buildMentionGapScore(snapshots) {
3446
+ const tooltip = "Tracked queries where a competitor surfaces in the latest run but your brand / domain is not mentioned in the answer text.";
3447
+ if (snapshots.length === 0) {
3448
+ return {
3449
+ label: "Mention Gaps",
3450
+ value: "No data",
3451
+ delta: "Run a sweep first",
3452
+ tone: "neutral",
3453
+ description: "Run a visibility sweep to identify queries where competitors are mentioned and your brand is not.",
3454
+ tooltip,
3455
+ trend: []
3456
+ };
3457
+ }
3458
+ const byQuery = /* @__PURE__ */ new Map();
3459
+ for (const snap of snapshots) {
3460
+ const key = snap.queryId;
3461
+ const current = byQuery.get(key) ?? { mentioned: false, competitorOverlap: /* @__PURE__ */ new Set() };
3462
+ if (snap.answerMentioned === true) current.mentioned = true;
3463
+ for (const domain of snap.competitorOverlap) current.competitorOverlap.add(domain);
3464
+ byQuery.set(key, current);
3465
+ }
3466
+ const totalCount = byQuery.size;
3467
+ const gapCount = [...byQuery.values()].filter(
3468
+ (entry) => !entry.mentioned && entry.competitorOverlap.size > 0
3469
+ ).length;
3470
+ const gapQueryLabel = gapCount === 1 ? "query" : "queries";
3471
+ return {
3472
+ label: "Mention Gaps",
3473
+ value: `${gapCount}`,
3474
+ delta: `${gapCount} of ${totalCount} queries at risk`,
3475
+ tone: gapTone(gapCount, totalCount),
3476
+ description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} mention competitors but never your brand.` : "No competitive mention gaps detected in the latest visibility run.",
3413
3477
  tooltip,
3414
3478
  trend: [],
3415
3479
  progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
@@ -3525,6 +3589,129 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
3525
3589
  });
3526
3590
  }
3527
3591
 
3592
+ // ../intelligence/src/share-of-voice.ts
3593
+ function sovTone(score) {
3594
+ if (score >= 30) return "positive";
3595
+ if (score >= 10) return "caution";
3596
+ return "negative";
3597
+ }
3598
+ function buildShareOfVoice(snapshots, options) {
3599
+ const tooltip = "Your domain's share of every distinct cited-source slot across the latest run. Subdomain-aware (cited docs.you.com counts for you.com). Distinct from Citation Coverage \u2014 SoV measures how much of the answer real-estate you own, not just whether you appear.";
3600
+ if (snapshots.length === 0) {
3601
+ return {
3602
+ label: "Share of Voice",
3603
+ value: "No data",
3604
+ delta: "Run a sweep first",
3605
+ tone: "neutral",
3606
+ description: "No SoV data yet. Trigger a run to start tracking.",
3607
+ tooltip,
3608
+ trend: []
3609
+ };
3610
+ }
3611
+ const breakdown = computeShareOfVoiceBreakdown(snapshots, options);
3612
+ if (breakdown.totalSlots === 0) {
3613
+ return {
3614
+ label: "Share of Voice",
3615
+ value: "0",
3616
+ delta: "No citations in this run",
3617
+ tone: "neutral",
3618
+ description: "The latest run produced no source-list citations across any provider, so SoV cannot be measured. (Mention Coverage may still be non-zero \u2014 answers can mention you without grounding to a URL.)",
3619
+ tooltip,
3620
+ trend: [],
3621
+ progress: 0
3622
+ };
3623
+ }
3624
+ const { projectSlots, competitorSlots, otherSlots, totalSlots } = breakdown;
3625
+ const score = Math.round(projectSlots / totalSlots * 100);
3626
+ const competitorShare = Math.round(competitorSlots / totalSlots * 100);
3627
+ const otherShare = Math.max(0, 100 - score - competitorShare);
3628
+ const hasCompetitorsConfigured = options.competitorDomains.length > 0;
3629
+ const description = describeBreakdown({
3630
+ projectSlots,
3631
+ competitorSlots,
3632
+ otherSlots,
3633
+ totalSlots,
3634
+ score,
3635
+ competitorShare,
3636
+ otherShare,
3637
+ hasCompetitorsConfigured
3638
+ });
3639
+ return {
3640
+ label: "Share of Voice",
3641
+ value: `${score}`,
3642
+ delta: `${projectSlots} of ${totalSlots} cited slots`,
3643
+ tone: sovTone(score),
3644
+ description,
3645
+ tooltip,
3646
+ trend: [],
3647
+ progress: score
3648
+ };
3649
+ }
3650
+ function computeShareOfVoiceBreakdown(snapshots, options) {
3651
+ let totalSlots = 0;
3652
+ let projectSlots = 0;
3653
+ let competitorSlots = 0;
3654
+ for (const snap of snapshots) {
3655
+ for (const domain of snap.citedDomains) {
3656
+ totalSlots++;
3657
+ if (citedDomainBelongsToProject(domain, options.projectDomains)) {
3658
+ projectSlots++;
3659
+ } else if (citedDomainBelongsToProject(domain, options.competitorDomains)) {
3660
+ competitorSlots++;
3661
+ }
3662
+ }
3663
+ }
3664
+ const otherSlots = totalSlots - projectSlots - competitorSlots;
3665
+ return { projectSlots, competitorSlots, otherSlots, totalSlots };
3666
+ }
3667
+ function describeBreakdown(parts) {
3668
+ const { projectSlots, competitorSlots, totalSlots, score, competitorShare, otherShare, hasCompetitorsConfigured } = parts;
3669
+ if (!hasCompetitorsConfigured) {
3670
+ return `${projectSlots} of ${totalSlots} cited slots were yours (${score}%). Add tracked competitors to break out the rest.`;
3671
+ }
3672
+ if (competitorSlots === 0) {
3673
+ return `${projectSlots} of ${totalSlots} cited slots were yours (${score}%); no tracked competitors surfaced in the run. The remaining ${otherShare}% goes to unrelated sources.`;
3674
+ }
3675
+ return `You own ${score}% of cited slots; tracked competitors hold ${competitorShare}%; the remaining ${otherShare}% goes to non-competitive sources.`;
3676
+ }
3677
+
3678
+ // ../intelligence/src/provider-trends.ts
3679
+ function buildProviderTrends(runs2, snapshotsByRunId, limit = 12) {
3680
+ const recent = [...runs2].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3681
+ const keys = collectProviderKeys(snapshotsByRunId.values());
3682
+ const result = /* @__PURE__ */ new Map();
3683
+ for (const key of keys) result.set(key, []);
3684
+ for (const run of recent) {
3685
+ const snaps = snapshotsByRunId.get(run.id) ?? [];
3686
+ const perKey = /* @__PURE__ */ new Map();
3687
+ for (const snap of snaps) {
3688
+ const key = providerKey(snap.provider, snap.model);
3689
+ const queryMap = perKey.get(key) ?? /* @__PURE__ */ new Map();
3690
+ if (!queryMap.has(snap.queryId)) queryMap.set(snap.queryId, false);
3691
+ if (snap.citationState === CitationStates.cited) queryMap.set(snap.queryId, true);
3692
+ perKey.set(key, queryMap);
3693
+ }
3694
+ for (const key of keys) {
3695
+ const queryMap = perKey.get(key);
3696
+ const rate = queryMap && queryMap.size > 0 ? Math.round([...queryMap.values()].filter(Boolean).length / queryMap.size * 100) : 0;
3697
+ result.get(key).push({ rate, createdAt: run.createdAt });
3698
+ }
3699
+ }
3700
+ return result;
3701
+ }
3702
+ function providerKey(provider, model) {
3703
+ return `${provider}::${model ?? "unknown"}`;
3704
+ }
3705
+ function collectProviderKeys(perRun) {
3706
+ const keys = /* @__PURE__ */ new Set();
3707
+ for (const snaps of perRun) {
3708
+ for (const snap of snaps) {
3709
+ keys.add(providerKey(snap.provider, snap.model));
3710
+ }
3711
+ }
3712
+ return keys;
3713
+ }
3714
+
3528
3715
  // src/intelligence-service.ts
3529
3716
  import crypto from "crypto";
3530
3717
 
@@ -4045,11 +4232,15 @@ export {
4045
4232
  buildVisibilityScore,
4046
4233
  buildMentionCoverage,
4047
4234
  buildGapQueryScore,
4235
+ buildMentionGapScore,
4048
4236
  buildCompetitorPressureScore,
4049
4237
  buildOverviewCompetitors,
4050
4238
  buildProviderScores,
4051
4239
  DEFAULT_RUN_HISTORY_LIMIT,
4052
4240
  buildRunHistory,
4241
+ buildShareOfVoice,
4242
+ buildProviderTrends,
4243
+ providerKey,
4053
4244
  createLogger,
4054
4245
  IntelligenceService
4055
4246
  };