@ainyc/canonry 4.1.3 → 4.7.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.
@@ -4,8 +4,9 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-KCETXLDF.js";
7
+ } from "./chunk-VDEMEI64.js";
8
8
  import {
9
+ DEFAULT_RUN_HISTORY_LIMIT,
9
10
  IntelligenceService,
10
11
  MIN_TREND_POINTS,
11
12
  agentMemory,
@@ -16,11 +17,22 @@ import {
16
17
  backlinkSummaries,
17
18
  bingCoverageSnapshots,
18
19
  bingUrlInspections,
20
+ buildAiSourceOrigin,
19
21
  buildBrandTokens,
22
+ buildCitationScorecard,
23
+ buildCompetitorLandscape,
24
+ buildCompetitorPressureScore,
20
25
  buildContentGapRows,
21
26
  buildContentSourceRows,
22
27
  buildContentTargetRows,
28
+ buildGapQueryScore,
23
29
  buildInventory,
30
+ buildMentionLandscape,
31
+ buildMovementSummary,
32
+ buildOverviewCompetitors,
33
+ buildProviderScores,
34
+ buildRunHistory,
35
+ buildVisibilityScore,
24
36
  categorizeQueryByIntent,
25
37
  ccReleaseSyncs,
26
38
  competitors,
@@ -49,7 +61,7 @@ import {
49
61
  runs,
50
62
  schedules,
51
63
  usageCounters
52
- } from "./chunk-AXMSAMKN.js";
64
+ } from "./chunk-OOADR2Q5.js";
53
65
  import {
54
66
  AGENT_MEMORY_VALUE_MAX_BYTES,
55
67
  AGENT_PROVIDER_IDS,
@@ -83,6 +95,7 @@ import {
83
95
  emptyCitationVisibility,
84
96
  extractAnswerMentions,
85
97
  findDuplicateLocationLabels,
98
+ getProviderLocationHandling,
86
99
  hasLocationLabel,
87
100
  internalError,
88
101
  isAgentProviderId,
@@ -101,6 +114,7 @@ import {
101
114
  providerError,
102
115
  queryGenerateRequestSchema,
103
116
  registrableDomain,
117
+ reportActionTone,
104
118
  resolveConfigSpecQueries,
105
119
  resolveSnapshotRequestQueries,
106
120
  runInProgress,
@@ -115,7 +129,7 @@ import {
115
129
  visibilityStateFromAnswerMentioned,
116
130
  windowCutoff,
117
131
  wordpressEnvSchema
118
- } from "./chunk-O5JZQUPX.js";
132
+ } from "./chunk-XAW66QUX.js";
119
133
 
120
134
  // src/telemetry.ts
121
135
  import crypto from "crypto";
@@ -2734,6 +2748,21 @@ section.report-section .section-intro {
2734
2748
  .finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
2735
2749
  .finding strong { display: block; margin-bottom: 4px; }
2736
2750
  .finding span { color: ${COLORS.textMuted}; font-size: 13px; }
2751
+ .location-card { margin-top: 16px; }
2752
+ .location-card .location-line { margin: 0 0 12px; font-size: 13px; color: ${COLORS.text}; }
2753
+ .location-card .location-line strong { color: ${COLORS.text}; }
2754
+ .location-card .location-line .cell-pending { font-size: 12px; }
2755
+ .source-origin-headline { margin: 0 0 12px; font-size: 14px; color: ${COLORS.text}; }
2756
+ .source-origin-headline strong { color: ${COLORS.text}; }
2757
+ .source-bars { display: flex; flex-direction: column; gap: 6px; }
2758
+ .source-bar-row { display: grid; grid-template-columns: 220px 1fr 90px; align-items: center; gap: 12px; font-size: 13px; }
2759
+ .source-bar-label { color: ${COLORS.textMuted}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2760
+ .source-bar-track { height: 14px; background: ${COLORS.border}; border-radius: 3px; overflow: hidden; }
2761
+ .source-bar-fill { height: 100%; border-radius: 3px; }
2762
+ .source-bar-value { color: ${COLORS.text}; text-align: right; font-variant-numeric: tabular-nums; }
2763
+ .source-bar-pct { color: ${COLORS.textFaint}; font-size: 11px; }
2764
+ .driver-list { margin: 0; padding-left: 16px; font-size: 12px; color: ${COLORS.textMuted}; }
2765
+ .driver-list li { margin: 2px 0; }
2737
2766
  table.report-table {
2738
2767
  width: 100%;
2739
2768
  border-collapse: collapse;
@@ -2854,6 +2883,77 @@ table.report-table td .badge {
2854
2883
  }
2855
2884
  .step .title { font-weight: 600; }
2856
2885
  .step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
2886
+ .action-card-grid {
2887
+ display: grid;
2888
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
2889
+ gap: 16px;
2890
+ }
2891
+ .action-card {
2892
+ background: ${COLORS.surface};
2893
+ border: 1px solid ${COLORS.border};
2894
+ border-radius: 8px;
2895
+ padding: 18px 20px;
2896
+ }
2897
+ .action-card .action-meta {
2898
+ display: flex;
2899
+ flex-wrap: wrap;
2900
+ gap: 8px;
2901
+ margin-bottom: 10px;
2902
+ }
2903
+ .action-card h3 {
2904
+ font-size: 16px;
2905
+ margin: 0 0 8px;
2906
+ }
2907
+ .action-card p {
2908
+ margin: 0 0 12px;
2909
+ color: ${COLORS.textMuted};
2910
+ }
2911
+ .action-card ul {
2912
+ margin: 0 0 12px;
2913
+ padding-left: 18px;
2914
+ color: ${COLORS.textMuted};
2915
+ font-size: 13px;
2916
+ }
2917
+ .action-card li { margin: 4px 0; }
2918
+ .action-card .success-metric {
2919
+ color: ${COLORS.text};
2920
+ font-size: 13px;
2921
+ border-top: 1px solid ${COLORS.border};
2922
+ padding-top: 10px;
2923
+ margin-top: 12px;
2924
+ }
2925
+ .client-notes {
2926
+ margin-top: 18px;
2927
+ display: grid;
2928
+ gap: 8px;
2929
+ }
2930
+ .client-note {
2931
+ color: ${COLORS.textMuted};
2932
+ font-size: 13px;
2933
+ background: ${COLORS.surface};
2934
+ border: 1px solid ${COLORS.border};
2935
+ border-radius: 8px;
2936
+ padding: 10px 12px;
2937
+ }
2938
+ .diagnostics-grid {
2939
+ display: grid;
2940
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
2941
+ gap: 12px;
2942
+ }
2943
+ .diagnostic-card {
2944
+ background: ${COLORS.surface};
2945
+ border: 1px solid ${COLORS.border};
2946
+ border-left-width: 3px;
2947
+ border-radius: 8px;
2948
+ padding: 14px 16px;
2949
+ }
2950
+ .diagnostic-card h3 { font-size: 14px; margin: 0 0 6px; }
2951
+ .diagnostic-card p { margin: 0 0 8px; color: ${COLORS.textMuted}; font-size: 13px; }
2952
+ .diagnostic-card ul { margin: 0; padding-left: 16px; color: ${COLORS.textMuted}; font-size: 12px; }
2953
+ .diagnostic-card.tone-positive { border-left-color: ${COLORS.positive}; }
2954
+ .diagnostic-card.tone-caution { border-left-color: ${COLORS.caution}; }
2955
+ .diagnostic-card.tone-negative { border-left-color: ${COLORS.negative}; }
2956
+ .diagnostic-card.tone-neutral { border-left-color: ${COLORS.neutral}; }
2857
2957
  .footer {
2858
2958
  margin-top: 96px;
2859
2959
  padding-top: 24px;
@@ -2878,15 +2978,68 @@ function section(opts, body) {
2878
2978
  function renderEmpty(message) {
2879
2979
  return `<div class="empty-state">${escapeHtml(message)}</div>`;
2880
2980
  }
2981
+ function locationDisplay(location) {
2982
+ if (!location) return "";
2983
+ const place = [location.city, location.region, location.country].filter(Boolean).join(", ");
2984
+ return place ? `${location.label} (${place})` : location.label;
2985
+ }
2986
+ function renderHeaderLocationFragment(location) {
2987
+ if (!location) return " \xB7 No location set";
2988
+ return ` \xB7 Location: ${escapeHtml(locationDisplay(location))}`;
2989
+ }
2990
+ function renderLocationCard(report) {
2991
+ const location = report.meta.location;
2992
+ const handling = report.meta.providerLocationHandling;
2993
+ if (!location && handling.length === 0) return "";
2994
+ const treatmentTone = {
2995
+ "request-param": "positive",
2996
+ prompt: "positive",
2997
+ "browser-geo": "caution",
2998
+ ignored: "negative"
2999
+ };
3000
+ const treatmentLabel = {
3001
+ "request-param": "Request parameter",
3002
+ prompt: "Prompt-injected",
3003
+ "browser-geo": "Browser geo",
3004
+ ignored: "Ignored"
3005
+ };
3006
+ const locationLine = location ? `<p class="location-line"><strong>Location for this run:</strong> ${escapeHtml(locationDisplay(location))}${location.otherConfiguredLabels.length > 0 ? ` <span class="cell-pending">\u2014 other configured locations (${location.otherConfiguredLabels.map(escapeHtml).join(", ")}) need their own sweep to compare</span>` : ""}</p>` : `<p class="location-line"><strong>Location for this run:</strong> none \u2014 providers received the queries verbatim with no geographic hint.</p>`;
3007
+ const handlingRows = handling.length > 0 ? handling.map((h) => {
3008
+ const tone = treatmentTone[h.treatment] ?? "neutral";
3009
+ const label = treatmentLabel[h.treatment] ?? h.treatment;
3010
+ return `<tr>
3011
+ <td>${escapeHtml(h.provider)}</td>
3012
+ <td><span class="badge tone-${tone}">${escapeHtml(label)}</span></td>
3013
+ <td>${escapeHtml(h.description)}</td>
3014
+ </tr>`;
3015
+ }).join("") : "";
3016
+ const handlingTable = handlingRows ? `<table class="report-table">
3017
+ <thead><tr><th>Provider</th><th>Treatment</th><th>How the location reached the model</th></tr></thead>
3018
+ <tbody>${handlingRows}</tbody>
3019
+ </table>` : "";
3020
+ return `<div class="chart-card location-card">
3021
+ <h3>Location handling</h3>
3022
+ ${locationLine}
3023
+ ${handlingTable}
3024
+ </div>`;
3025
+ }
2881
3026
  function renderExecutiveSummary(report) {
2882
3027
  const s = report.executiveSummary;
2883
3028
  const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
2884
3029
  const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
3030
+ const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
3031
+ const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
3032
+ const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
2885
3033
  const metrics = [
2886
3034
  {
2887
3035
  label: "Citation rate",
2888
3036
  value: `${s.citationRate}%`,
2889
- delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
3037
+ delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${citedFragment} \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
3038
+ },
3039
+ {
3040
+ label: "Mention rate",
3041
+ value: `${s.mentionRate}%`,
3042
+ delta: mentionedFragment
2890
3043
  },
2891
3044
  {
2892
3045
  label: "Queries tracked",
@@ -2920,14 +3073,15 @@ function renderExecutiveSummary(report) {
2920
3073
  <strong>${escapeHtml(f.title)}</strong>
2921
3074
  <span>${escapeHtml(f.detail)}</span>
2922
3075
  </div>`).join("")}</div>` : "";
3076
+ const locationHtml = renderLocationCard(report);
2923
3077
  return section(
2924
3078
  {
2925
3079
  id: "executive-summary",
2926
3080
  eyebrow: "Section 1",
2927
3081
  title: "Executive Summary",
2928
- intro: "Top-line citation rate with trend versus the prior run, plus the most actionable findings from the latest visibility sweep."
3082
+ intro: "Two independent signals: Citation rate = share of tracked queries where your domain appeared in the source list the AI used. Mention rate = share of tracked queries where your brand or domain appeared in the answer text itself. A model can mention you without citing your domain, or cite your domain without naming you in the prose. Both are computed per-query so they stay comparable when provider count changes."
2929
3083
  },
2930
- metricsHtml + findingsHtml
3084
+ metricsHtml + findingsHtml + locationHtml
2931
3085
  );
2932
3086
  }
2933
3087
  function renderProviderBars(rates) {
@@ -2965,16 +3119,16 @@ function renderCitationMatrix(scorecard) {
2965
3119
  const cells = scorecard.providers.map((_, pi) => {
2966
3120
  const cell = scorecard.matrix[qi]?.[pi];
2967
3121
  if (!cell) {
2968
- return '<td><span class="cell-pending">\u2014</span></td>';
3122
+ return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
2969
3123
  }
2970
- if (cell.citationState === CitationStates.cited) {
2971
- return '<td><span class="cell-cited">Cited</span></td>';
2972
- }
2973
- return '<td><span class="cell-not-cited">Not cited</span></td>';
3124
+ const citedGlyph = cell.citationState === CitationStates.cited ? '<span class="cell-cited">C</span>' : '<span class="cell-not-cited">c</span>';
3125
+ const mentionedGlyph = cell.answerMentioned === true ? '<span class="cell-cited">M</span>' : cell.answerMentioned === false ? '<span class="cell-not-cited">m</span>' : '<span class="cell-pending">\u2013</span>';
3126
+ return `<td>${citedGlyph} ${mentionedGlyph}</td>`;
2974
3127
  }).join("");
2975
3128
  return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
2976
3129
  }).join("");
2977
- return `<table class="report-table">
3130
+ const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">Each cell shows two flags \u2014 <span class="cell-cited">C</span>/<span class="cell-not-cited">c</span> = cited / not cited (your domain in the source list), <span class="cell-cited">M</span>/<span class="cell-not-cited">m</span> = mentioned / not mentioned (your brand in the answer text), <span class="cell-pending">\u2013</span> = no data.</p>';
3131
+ return `${legend}<table class="report-table">
2978
3132
  <thead><tr><th>Query</th>${headers}</tr></thead>
2979
3133
  <tbody>${rows}</tbody>
2980
3134
  </table>`;
@@ -2985,7 +3139,7 @@ function renderCitationScorecard(report) {
2985
3139
  ${renderCitationMatrix(report.citationScorecard)}
2986
3140
  `;
2987
3141
  return section(
2988
- { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Whether your domain appeared in each AI engine\u2019s source list for every tracked query in the latest sweep \u2014 a cell turns green when your domain was cited, red when it was not, and gray when no snapshot exists for that pair." },
3142
+ { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per (query \xD7 provider) view of both signals \u2014 citations (your domain in the source list) and mentions (your brand in the answer text) \u2014 for every tracked query in the latest sweep." },
2989
3143
  body
2990
3144
  );
2991
3145
  }
@@ -3072,44 +3226,40 @@ function renderCompetitorLandscape(report) {
3072
3226
  `${charts}${table}`
3073
3227
  );
3074
3228
  }
3075
- function renderDonut(buckets) {
3229
+ var SOURCE_CATEGORY_TONE = {
3230
+ competitor: "negative",
3231
+ directory: "caution",
3232
+ forum: "caution",
3233
+ news: "neutral",
3234
+ reference: "neutral",
3235
+ blog: "neutral",
3236
+ social: "neutral",
3237
+ video: "neutral",
3238
+ ecommerce: "neutral",
3239
+ academic: "neutral",
3240
+ other: "neutral"
3241
+ };
3242
+ function renderCategoryBars(buckets) {
3076
3243
  if (buckets.length === 0) return "";
3077
3244
  const total = buckets.reduce((s, b) => s + b.count, 0);
3078
3245
  if (total === 0) return "";
3079
- const cx = 110;
3080
- const cy = 110;
3081
- const r = 80;
3082
- const innerR = 48;
3083
- let cumulative = 0;
3084
- const slices = [];
3085
- const legend = [];
3086
- buckets.forEach((b, i) => {
3087
- const startAngle = cumulative / total * Math.PI * 2 - Math.PI / 2;
3088
- const endAngle = (cumulative + b.count) / total * Math.PI * 2 - Math.PI / 2;
3089
- cumulative += b.count;
3090
- const x1 = cx + Math.cos(startAngle) * r;
3091
- const y1 = cy + Math.sin(startAngle) * r;
3092
- const x2 = cx + Math.cos(endAngle) * r;
3093
- const y2 = cy + Math.sin(endAngle) * r;
3094
- const ix1 = cx + Math.cos(endAngle) * innerR;
3095
- const iy1 = cy + Math.sin(endAngle) * innerR;
3096
- const ix2 = cx + Math.cos(startAngle) * innerR;
3097
- const iy2 = cy + Math.sin(startAngle) * innerR;
3098
- const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
3099
- const color = COLORS.series[i % COLORS.series.length];
3100
- if (b.count > 0) {
3101
- slices.push(`<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z" fill="${color}" />`);
3102
- legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
3103
- }
3104
- });
3246
+ const max = Math.max(...buckets.map((b) => b.count), 1);
3247
+ const rows = buckets.map((b) => {
3248
+ const pct = b.count / max * 100;
3249
+ const tone = SOURCE_CATEGORY_TONE[b.category] ?? "neutral";
3250
+ const color = tone === "negative" ? COLORS.negative : tone === "caution" ? COLORS.caution : COLORS.accent;
3251
+ return `
3252
+ <div class="source-bar-row">
3253
+ <div class="source-bar-label">${escapeHtml(b.label)}</div>
3254
+ <div class="source-bar-track">
3255
+ <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
3256
+ </div>
3257
+ <div class="source-bar-value">${b.count} <span class="source-bar-pct">(${b.sharePct}%)</span></div>
3258
+ </div>`;
3259
+ }).join("");
3105
3260
  return `<div class="chart-card">
3106
- <h3>AI source categories</h3>
3107
- <div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
3108
- <svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
3109
- ${slices.join("")}
3110
- </svg>
3111
- <div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
3112
- </div>
3261
+ <h3>By source type</h3>
3262
+ <div class="source-bars">${rows}</div>
3113
3263
  </div>`;
3114
3264
  }
3115
3265
  function renderAiSourceOrigin(report) {
@@ -3120,24 +3270,28 @@ function renderAiSourceOrigin(report) {
3120
3270
  renderEmpty("No source data yet. Run a visibility sweep first.")
3121
3271
  );
3122
3272
  }
3273
+ const competitorBucket = origin.categories.find((c) => c.category === "competitor");
3274
+ const headlineFragment = competitorBucket ? `<p class="source-origin-headline"><strong>${competitorBucket.sharePct}%</strong> of citations went to tracked competitors (${competitorBucket.count} of ${origin.categories.reduce((s, c) => s + c.count, 0)}).</p>` : "";
3123
3275
  const rows = origin.topDomains.map((d) => `
3124
3276
  <tr>
3125
3277
  <td>${escapeHtml(d.domain)}</td>
3126
3278
  <td class="numeric">${d.count}</td>
3127
- <td>${d.isCompetitor ? '<span class="badge tone-negative">Competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
3279
+ <td>${d.isCompetitor ? '<span class="badge tone-negative">Tracked competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
3128
3280
  </tr>`).join("");
3129
- const table = origin.topDomains.length > 0 ? `<table class="report-table">
3130
- <thead><tr><th>Domain</th><th>Citations</th><th>Tag</th></tr></thead>
3131
- <tbody>${rows}</tbody>
3132
- </table>` : "";
3281
+ const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>Top sources</h3>
3282
+ <table class="report-table">
3283
+ <thead><tr><th>Domain</th><th class="numeric">Citations</th><th>Tag</th></tr></thead>
3284
+ <tbody>${rows}</tbody>
3285
+ </table>
3286
+ </div>` : "";
3133
3287
  return section(
3134
3288
  {
3135
3289
  id: "ai-source-origin",
3136
3290
  eyebrow: "Section 4",
3137
3291
  title: "AI Citation Sources",
3138
- intro: "Every external website AI engines cited as a source for your tracked keywords in the latest sweep \u2014 categorized by site type (Reddit, YouTube, news, etc.) on the left and ranked by citation count on the right. Your own domains are excluded; tracked competitors are flagged."
3292
+ intro: "Every external website AI engines cited as a source for your tracked queries in the latest sweep, ranked by citation count. Tracked competitors are pulled into their own bucket so you can see how much of the AI\u2019s answer came from rivals; the remaining buckets cover directories, forums, news, and other site types. Your own domains are excluded."
3139
3293
  },
3140
- `${renderDonut(origin.categories)}${table}`
3294
+ `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
3141
3295
  );
3142
3296
  }
3143
3297
  function renderLineChart(points, color, title, height = 200) {
@@ -3430,15 +3584,15 @@ function renderCitationsTrend(report) {
3430
3584
  const rows = trend.map((t) => `
3431
3585
  <tr>
3432
3586
  <td>${formatDate(t.date)}</td>
3433
- <td class="numeric">${t.citationRate}%</td>
3587
+ <td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
3434
3588
  <td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
3435
3589
  </tr>`).join("");
3436
3590
  return section(
3437
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation rate across every visibility sweep \u2014 the share of (keyword \xD7 provider) pairs in each run where your domain appeared in the source list, with a per-provider breakdown beneath." },
3591
+ { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation rate across every visibility sweep \u2014 the share of tracked queries cited by at least one provider, with a per-provider breakdown beneath. Computed per-query so the headline stays comparable across runs that ran a different mix of providers." },
3438
3592
  `${chart}
3439
3593
  <div class="chart-card"><h3>Run-by-run breakdown</h3>
3440
3594
  <table class="report-table">
3441
- <thead><tr><th>Run</th><th class="numeric">Overall rate</th><th>Per-provider rates</th></tr></thead>
3595
+ <thead><tr><th>Run</th><th class="numeric">Cited queries</th><th>Per-provider rates</th></tr></thead>
3442
3596
  <tbody>${rows}</tbody>
3443
3597
  </table>
3444
3598
  </div>`
@@ -3477,15 +3631,16 @@ function renderOpportunities(report) {
3477
3631
  if (opps.length === 0) return "";
3478
3632
  const canonical = report.meta.project.canonicalDomain;
3479
3633
  const rows = opps.slice(0, 10).map((o) => {
3480
- const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3634
+ const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
3481
3635
  const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3636
+ const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
3482
3637
  return `<tr>
3483
3638
  <td>${escapeHtml(o.query)}</td>
3484
3639
  <td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
3485
3640
  <td class="numeric">${Math.round(o.score)}</td>
3641
+ <td>${drivers}</td>
3486
3642
  <td>${ourPage}</td>
3487
3643
  <td>${winning}</td>
3488
- <td><span class="badge tone-neutral">${escapeHtml(o.demandSource)}</span></td>
3489
3644
  <td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
3490
3645
  </tr>`;
3491
3646
  }).join("");
@@ -3494,10 +3649,36 @@ function renderOpportunities(report) {
3494
3649
  id: "content-opportunities",
3495
3650
  eyebrow: "Section 12",
3496
3651
  title: "Content Opportunities",
3497
- intro: "Queries where you have search demand or competitor citation pressure but aren\u2019t winning AI citations. Each row carries a suggested action (create / refresh / expand / add-schema). Top 10 shown."
3652
+ intro: "Queries where you have search demand or competitor citation pressure but aren\u2019t winning AI citations. Each row pairs a suggested action (create / refresh / expand / add-schema) with the signals driving the score, the best matching page on your domain, and the competitor URL the AI most often cites. Top 10 shown."
3498
3653
  },
3499
3654
  `<table class="report-table">
3500
- <thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Our page</th><th>Winning competitor</th><th>Demand</th><th>Confidence</th></tr></thead>
3655
+ <thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
3656
+ <tbody>${rows}</tbody>
3657
+ </table>`
3658
+ );
3659
+ }
3660
+ function renderContentGaps(report) {
3661
+ const gaps = report.contentGaps;
3662
+ if (gaps.length === 0) return "";
3663
+ const rows = gaps.slice(0, 10).map((g) => {
3664
+ const competitorList = g.competitorDomains.slice(0, 5).map(escapeHtml).join(", ");
3665
+ const more = g.competitorDomains.length > 5 ? `, +${g.competitorDomains.length - 5} more` : "";
3666
+ return `<tr>
3667
+ <td>${escapeHtml(g.query)}</td>
3668
+ <td class="numeric">${g.competitorCount}</td>
3669
+ <td>${competitorList}${more}</td>
3670
+ <td class="numeric">${Math.round(g.missRate * 100)}%</td>
3671
+ </tr>`;
3672
+ }).join("");
3673
+ return section(
3674
+ {
3675
+ id: "content-gaps",
3676
+ eyebrow: "Section 13",
3677
+ title: "Content Gaps",
3678
+ intro: 'Tracked queries where multiple competitors are cited by AI engines but you are not \u2014 explicit "they are answering, you are missing" signal. Sorted by recent miss rate, then by number of competitors cited. Top 10 shown.'
3679
+ },
3680
+ `<table class="report-table">
3681
+ <thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
3501
3682
  <tbody>${rows}</tbody>
3502
3683
  </table>`
3503
3684
  );
@@ -3506,7 +3687,7 @@ function renderRecommendedNextSteps(report) {
3506
3687
  const steps = report.recommendedNextSteps;
3507
3688
  if (steps.length === 0) {
3508
3689
  return section(
3509
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
3690
+ { id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
3510
3691
  renderEmpty("No outstanding actions.")
3511
3692
  );
3512
3693
  }
@@ -3517,17 +3698,144 @@ function renderRecommendedNextSteps(report) {
3517
3698
  <span class="rationale">${escapeHtml(s.rationale)}</span>
3518
3699
  </div>`).join("");
3519
3700
  return section(
3520
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
3701
+ { id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
3521
3702
  `<div class="steps">${items}</div>`
3522
3703
  );
3523
3704
  }
3705
+ function actionAudienceMatches(action, audience) {
3706
+ return action.audience === "both" || action.audience === audience;
3707
+ }
3708
+ function renderActionCards(actions) {
3709
+ if (actions.length === 0) return renderEmpty("No prioritized actions yet.");
3710
+ return `<div class="action-card-grid">
3711
+ ${actions.map((action) => {
3712
+ const tone = reportActionTone(action);
3713
+ const why = action.why.length > 0 ? `<ul>${action.why.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
3714
+ const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
3715
+ return `<article class="action-card">
3716
+ <div class="action-meta">
3717
+ <span class="badge tone-${tone}">${escapeHtml(action.horizon)}</span>
3718
+ <span class="badge tone-neutral">${escapeHtml(action.category)}</span>
3719
+ <span class="badge tone-neutral">${escapeHtml(action.confidence)} confidence</span>
3720
+ </div>
3721
+ <h3>${escapeHtml(action.title)}</h3>
3722
+ <p>${escapeHtml(action.action)}</p>
3723
+ ${why ? `<div><strong>Why</strong>${why}</div>` : ""}
3724
+ ${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
3725
+ <div class="success-metric"><strong>Success metric:</strong> ${escapeHtml(action.successMetric)}</div>
3726
+ </article>`;
3727
+ }).join("")}
3728
+ </div>`;
3729
+ }
3730
+ function renderAudienceActionPlan(report, audience) {
3731
+ const actions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
3732
+ return section(
3733
+ {
3734
+ id: audience === "client" ? "client-action-plan" : "agency-action-plan",
3735
+ eyebrow: audience === "client" ? "Client actions" : "Agency actions",
3736
+ title: audience === "client" ? "What We Recommend Next" : "Agency Action Plan",
3737
+ intro: audience === "client" ? "Polished next steps the client can understand, backed by concise evidence from the report." : "Technical priorities pulled from the canonical action plan, sorted by urgency and evidence strength."
3738
+ },
3739
+ renderActionCards(actions)
3740
+ );
3741
+ }
3742
+ function renderClientSummary(report) {
3743
+ const s = report.executiveSummary;
3744
+ const metrics = `<div class="metric-grid">
3745
+ <div class="metric"><div class="label">Citation coverage</div><div class="value">${s.citationRate}%</div><div class="delta">${s.citedQueryCount}/${s.totalQueryCount} tracked queries cited</div></div>
3746
+ <div class="metric"><div class="label">Mention coverage</div><div class="value">${s.mentionRate}%</div><div class="delta">${s.mentionedQueryCount}/${s.totalQueryCount} tracked queries mentioned</div></div>
3747
+ <div class="metric"><div class="label">Providers checked</div><div class="value">${formatNumber(s.providerCount)}</div><div class="delta">${formatNumber(s.queryCount)} tracked queries</div></div>
3748
+ </div>`;
3749
+ const notes = report.clientSummary.confidenceNotes.length > 0 ? `<div class="client-notes">${report.clientSummary.confidenceNotes.map((note) => `<div class="client-note">${escapeHtml(note)}</div>`).join("")}</div>` : "";
3750
+ return section(
3751
+ {
3752
+ id: "client-summary",
3753
+ eyebrow: "Client summary",
3754
+ title: "How You're Appearing",
3755
+ intro: report.clientSummary.overview
3756
+ },
3757
+ `<div class="chart-card">
3758
+ <h3>${escapeHtml(report.clientSummary.headline)}</h3>
3759
+ <p class="source-origin-headline">${escapeHtml(report.clientSummary.overview)}</p>
3760
+ </div>
3761
+ ${metrics}
3762
+ ${notes}`
3763
+ );
3764
+ }
3765
+ function renderClientEvidenceSummary(report) {
3766
+ const evidenceCards = [];
3767
+ if (report.aiSourceOrigin.topDomains.length > 0) {
3768
+ evidenceCards.push(`<div class="diagnostic-card tone-neutral">
3769
+ <h3>Sources AI engines trust</h3>
3770
+ <p>These domains appeared most often as cited sources outside your owned domain.</p>
3771
+ <ul>${report.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `<li>${escapeHtml(d.domain)}: ${formatNumber(d.count)} citation${d.count === 1 ? "" : "s"}</li>`).join("")}</ul>
3772
+ </div>`);
3773
+ }
3774
+ if (report.gsc) {
3775
+ evidenceCards.push(`<div class="diagnostic-card tone-neutral">
3776
+ <h3>Search demand</h3>
3777
+ <p>Search Console shows ${formatNumber(report.gsc.totalImpressions)} impressions and ${formatNumber(report.gsc.totalClicks)} clicks in the report window.</p>
3778
+ <ul>${report.gsc.topQueries.slice(0, 5).map((q) => `<li>${escapeHtml(q.query)}: ${formatNumber(q.impressions)} impressions</li>`).join("")}</ul>
3779
+ </div>`);
3780
+ }
3781
+ if (report.indexingHealth) {
3782
+ const tone = report.indexingHealth.indexedPct >= 90 ? "positive" : report.indexingHealth.indexedPct >= 70 ? "caution" : "negative";
3783
+ evidenceCards.push(`<div class="diagnostic-card tone-${tone}">
3784
+ <h3>Indexing readiness</h3>
3785
+ <p>${report.indexingHealth.indexedPct}% of inspected URLs are indexed.</p>
3786
+ <ul><li>${formatNumber(report.indexingHealth.indexed)} indexed</li><li>${formatNumber(report.indexingHealth.notIndexed)} not indexed</li></ul>
3787
+ </div>`);
3788
+ }
3789
+ if (report.contentOpportunities.length > 0) {
3790
+ evidenceCards.push(`<div class="diagnostic-card tone-caution">
3791
+ <h3>Content opportunities</h3>
3792
+ <p>Canonry found topics where better content could improve AI citations.</p>
3793
+ <ul>${report.contentOpportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
3794
+ </div>`);
3795
+ }
3796
+ return section(
3797
+ {
3798
+ id: "client-evidence-summary",
3799
+ eyebrow: "Evidence",
3800
+ title: "Why This Is The Plan",
3801
+ intro: "A concise evidence view for the client summary. The agency report keeps the full matrices and detailed tables."
3802
+ },
3803
+ evidenceCards.length > 0 ? `<div class="diagnostics-grid">${evidenceCards.join("")}</div>` : renderEmpty("No supporting evidence sections are populated yet.")
3804
+ );
3805
+ }
3806
+ function renderAgencyDiagnostics(report) {
3807
+ const diagnostics = report.agencyDiagnostics.diagnostics;
3808
+ const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
3809
+ ${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
3810
+ <h3>${escapeHtml(d.title)}</h3>
3811
+ <p>${escapeHtml(d.detail)}</p>
3812
+ ${d.evidence.length > 0 ? `<ul>${d.evidence.map((e) => `<li>${escapeHtml(e)}</li>`).join("")}</ul>` : ""}
3813
+ </div>`).join("")}
3814
+ </div>` : renderEmpty("No agency diagnostics available yet.");
3815
+ return section(
3816
+ {
3817
+ id: "agency-diagnostics",
3818
+ eyebrow: "Agency diagnostics",
3819
+ title: "Technical Diagnostics",
3820
+ intro: "Operator-facing diagnostics for content, provider, source-domain, search-demand, indexing, and location follow-up."
3821
+ },
3822
+ body
3823
+ );
3824
+ }
3524
3825
  function escapeJsonForScript(json) {
3525
3826
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3526
3827
  }
3527
3828
  function renderReportHtml(report, opts = {}) {
3528
- const title = opts.title ?? `Canonry report \u2014 ${report.meta.project.displayName}`;
3529
- const sections = [
3829
+ const audience = opts.audience ?? "agency";
3830
+ const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
3831
+ const sections = audience === "client" ? [
3832
+ renderClientSummary(report),
3833
+ renderAudienceActionPlan(report, "client"),
3834
+ renderClientEvidenceSummary(report)
3835
+ ].join("\n") : [
3530
3836
  renderExecutiveSummary(report),
3837
+ renderAudienceActionPlan(report, "agency"),
3838
+ renderAgencyDiagnostics(report),
3531
3839
  renderCitationScorecard(report),
3532
3840
  renderCompetitorLandscape(report),
3533
3841
  renderAiSourceOrigin(report),
@@ -3539,6 +3847,7 @@ function renderReportHtml(report, opts = {}) {
3539
3847
  renderCitationsTrend(report),
3540
3848
  renderInsights(report),
3541
3849
  renderOpportunities(report),
3850
+ renderContentGaps(report),
3542
3851
  renderRecommendedNextSteps(report)
3543
3852
  ].join("\n");
3544
3853
  const json = escapeJsonForScript(JSON.stringify(report));
@@ -3553,9 +3862,9 @@ function renderReportHtml(report, opts = {}) {
3553
3862
  <body>
3554
3863
  <div class="container">
3555
3864
  <header class="header">
3556
- <div class="eyebrow">AEO Report</div>
3865
+ <div class="eyebrow">${audience === "client" ? "AEO Client Summary" : "AEO Agency Report"}</div>
3557
3866
  <h1>${escapeHtml(report.meta.project.displayName)}</h1>
3558
- <div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
3867
+ <div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())}${renderHeaderLocationFragment(report.meta.location)} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
3559
3868
  </header>
3560
3869
  ${sections}
3561
3870
  <footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
@@ -3568,7 +3877,7 @@ function renderReportHtml(report, opts = {}) {
3568
3877
  // ../api-routes/src/content-data.ts
3569
3878
  import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
3570
3879
  var RECENT_RUNS_WINDOW = 5;
3571
- function loadOrchestratorInput(db, project) {
3880
+ function loadOrchestratorInput(db, project, locationFilter = void 0) {
3572
3881
  const projectId = project.id;
3573
3882
  const ownDomain = normalizeDomain(project.canonicalDomain);
3574
3883
  const ownedDomains = parseJsonColumn(project.ownedDomains, []);
@@ -3577,7 +3886,7 @@ function loadOrchestratorInput(db, project) {
3577
3886
  const candidateQueryStrings = trackedQueries.filter(isBlogShapedQuery);
3578
3887
  const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
3579
3888
  const competitorSet = new Set(trackedCompetitors);
3580
- const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
3889
+ const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW, locationFilter);
3581
3890
  const latestRunId = recentRunIds[0] ?? "";
3582
3891
  const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
3583
3892
  const candidateQueries = buildCandidateQueries({
@@ -3619,8 +3928,8 @@ function listCompetitorDomains(db, projectId) {
3619
3928
  const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq12(competitors.projectId, projectId)).all();
3620
3929
  return rows.map((r) => r.domain);
3621
3930
  }
3622
- function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
3623
- const rows = db.select({ id: runs.id }).from(runs).where(
3931
+ function listRecentAnswerVisibilityRunIds(db, projectId, limit, locationFilter) {
3932
+ const rows = db.select({ id: runs.id, location: runs.location }).from(runs).where(
3624
3933
  and3(
3625
3934
  eq12(runs.projectId, projectId),
3626
3935
  eq12(runs.kind, RunKinds["answer-visibility"]),
@@ -3629,8 +3938,9 @@ function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
3629
3938
  // no usable evidence.
3630
3939
  inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
3631
3940
  )
3632
- ).orderBy(desc5(runs.createdAt)).limit(limit).all();
3633
- return rows.map((r) => r.id);
3941
+ ).orderBy(desc5(runs.createdAt)).all();
3942
+ const filtered = locationFilter === void 0 ? rows : rows.filter((r) => (r.location ?? null) === locationFilter);
3943
+ return filtered.slice(0, limit).map((r) => r.id);
3634
3944
  }
3635
3945
  function lookupRunTimestamp(db, runId) {
3636
3946
  const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq12(runs.id, runId)).get();
@@ -3846,7 +4156,6 @@ function extractPath(url) {
3846
4156
  var TOP_QUERIES_LIMIT = 20;
3847
4157
  var TOP_LANDING_PAGES_LIMIT = 20;
3848
4158
  var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
3849
- var TOP_SOURCE_DOMAINS_LIMIT = 20;
3850
4159
  var TOP_CAMPAIGN_LIMIT = 10;
3851
4160
  var INSIGHT_LOOKBACK_RUNS = 5;
3852
4161
  function safeNum(value) {
@@ -3857,14 +4166,6 @@ function safeNum(value) {
3857
4166
  }
3858
4167
  return 0;
3859
4168
  }
3860
- function citedDomainBelongsToProject(citedDomain, projectDomains) {
3861
- const candidate = normalizeProjectDomain(citedDomain);
3862
- for (const domain of projectDomains) {
3863
- const normalized = normalizeProjectDomain(domain);
3864
- if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
3865
- }
3866
- return false;
3867
- }
3868
4169
  function categorizeQuery(query, projectDisplayName, canonicalDomain) {
3869
4170
  return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectDisplayName));
3870
4171
  }
@@ -3891,186 +4192,6 @@ function loadQueryLookup(db, projectId) {
3891
4192
  for (const row of rows) byId.set(row.id, row.query);
3892
4193
  return { byId };
3893
4194
  }
3894
- function buildCitationScorecard(snapshots, queryLookup) {
3895
- if (snapshots.length === 0) {
3896
- return { queries: [], providers: [], matrix: [], providerRates: [] };
3897
- }
3898
- const querySet = /* @__PURE__ */ new Set();
3899
- const providerSet = /* @__PURE__ */ new Set();
3900
- for (const snap of snapshots) {
3901
- const q = queryLookup.byId.get(snap.queryId);
3902
- if (!q) continue;
3903
- querySet.add(q);
3904
- providerSet.add(snap.provider);
3905
- }
3906
- const queryList = [...querySet].sort();
3907
- const providerList = [...providerSet].sort();
3908
- const matrix = queryList.map(
3909
- () => providerList.map(() => null)
3910
- );
3911
- const providerCounts = /* @__PURE__ */ new Map();
3912
- for (const snap of snapshots) {
3913
- const q = queryLookup.byId.get(snap.queryId);
3914
- if (!q) continue;
3915
- const qi = queryList.indexOf(q);
3916
- const pi = providerList.indexOf(snap.provider);
3917
- if (qi < 0 || pi < 0) continue;
3918
- matrix[qi][pi] = {
3919
- citationState: snap.citationState === CitationStates.cited ? CitationStates.cited : CitationStates["not-cited"],
3920
- answerMentioned: snap.answerMentioned ?? null,
3921
- model: snap.model
3922
- };
3923
- const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
3924
- counts.total++;
3925
- if (snap.citationState === CitationStates.cited) counts.cited++;
3926
- providerCounts.set(snap.provider, counts);
3927
- }
3928
- const providerRates = providerList.map((provider) => {
3929
- const counts = providerCounts.get(provider) ?? { cited: 0, total: 0 };
3930
- const citationRate = counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0;
3931
- return {
3932
- provider,
3933
- citedCount: counts.cited,
3934
- totalCount: counts.total,
3935
- citationRate
3936
- };
3937
- });
3938
- return { queries: queryList, providers: providerList, matrix, providerRates };
3939
- }
3940
- function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
3941
- let projectCitationCount = 0;
3942
- const competitorMap = /* @__PURE__ */ new Map();
3943
- for (const c of competitorDomains) {
3944
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
3945
- }
3946
- for (const snap of snapshots) {
3947
- const q = queryLookup.byId.get(snap.queryId);
3948
- const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
3949
- if (allDomains.some((d) => citedDomainBelongsToProject(d, projectDomains))) {
3950
- projectCitationCount++;
3951
- }
3952
- for (const competitor of competitorDomains) {
3953
- if (allDomains.some((d) => citedDomainBelongsToProject(d, [competitor]))) {
3954
- const entry = competitorMap.get(competitor);
3955
- entry.count++;
3956
- if (q) entry.queries.add(q);
3957
- }
3958
- const competitorNorm = normalizeDomain(competitor);
3959
- for (const gs of snap.groundingSources) {
3960
- const host = normalizeDomain(extractHostFromUri(gs.uri));
3961
- if (!host) continue;
3962
- if (host === competitorNorm || host.endsWith(`.${competitorNorm}`)) {
3963
- const entry = competitorMap.get(competitor);
3964
- const pageQueries = entry.pages.get(gs.uri) ?? /* @__PURE__ */ new Set();
3965
- if (q) pageQueries.add(q);
3966
- entry.pages.set(gs.uri, pageQueries);
3967
- }
3968
- }
3969
- }
3970
- }
3971
- const totalCitedSlots = projectCitationCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
3972
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
3973
- const total = snapshots.length;
3974
- const ratio = total > 0 ? data.count / total : 0;
3975
- let pressureLabel = "None";
3976
- if (data.count > 0) {
3977
- if (ratio >= 0.5) pressureLabel = "High";
3978
- else if (ratio >= 0.2) pressureLabel = "Moderate";
3979
- else pressureLabel = "Low";
3980
- }
3981
- const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
3982
- const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
3983
- return {
3984
- domain,
3985
- citationCount: data.count,
3986
- totalCount: total,
3987
- pressureLabel,
3988
- citedQueries: [...data.queries].sort(),
3989
- sharePct,
3990
- theirCitedPages
3991
- };
3992
- });
3993
- competitorRows.sort((a, b) => b.citationCount - a.citationCount);
3994
- return { projectCitationCount, competitors: competitorRows };
3995
- }
3996
- function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName, projectDomains, queryLookup) {
3997
- let projectMentionCount = 0;
3998
- let totalAnswerSnapshots = 0;
3999
- const competitorMap = /* @__PURE__ */ new Map();
4000
- for (const c of competitorDomains) {
4001
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set() });
4002
- }
4003
- for (const snap of snapshots) {
4004
- const text = snap.answerText;
4005
- if (!text) continue;
4006
- totalAnswerSnapshots++;
4007
- const q = queryLookup.byId.get(snap.queryId);
4008
- const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
4009
- text,
4010
- projectDisplayName,
4011
- projectDomains
4012
- );
4013
- if (projectMentioned) projectMentionCount++;
4014
- for (const competitor of competitorDomains) {
4015
- const brand = brandLabelFromDomain(competitor);
4016
- const mentioned = determineAnswerMentioned(text, brand, [competitor]);
4017
- if (mentioned) {
4018
- const entry = competitorMap.get(competitor);
4019
- entry.count++;
4020
- if (q) entry.queries.add(q);
4021
- }
4022
- }
4023
- }
4024
- const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
4025
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
4026
- const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
4027
- let pressureLabel = "None";
4028
- if (data.count > 0) {
4029
- if (ratio >= 0.5) pressureLabel = "High";
4030
- else if (ratio >= 0.2) pressureLabel = "Moderate";
4031
- else pressureLabel = "Low";
4032
- }
4033
- const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
4034
- return {
4035
- domain,
4036
- mentionCount: data.count,
4037
- totalCount: totalAnswerSnapshots,
4038
- pressureLabel,
4039
- mentionedQueries: [...data.queries].sort(),
4040
- sharePct
4041
- };
4042
- });
4043
- competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
4044
- return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
4045
- }
4046
- function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains) {
4047
- const categoryCounts = /* @__PURE__ */ new Map();
4048
- const domainCounts = /* @__PURE__ */ new Map();
4049
- let totalCitations = 0;
4050
- for (const snap of snapshots) {
4051
- for (const raw of snap.citedDomains) {
4052
- if (citedDomainBelongsToProject(raw, projectDomains)) continue;
4053
- const { category, label, domain } = categorizeSource(raw);
4054
- const cat = categoryCounts.get(category) ?? { label, count: 0 };
4055
- cat.count++;
4056
- categoryCounts.set(category, cat);
4057
- domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
4058
- totalCitations++;
4059
- }
4060
- }
4061
- const categories = [...categoryCounts.entries()].map(([category, { label, count }]) => ({
4062
- category,
4063
- label,
4064
- count,
4065
- sharePct: totalCitations > 0 ? Math.round(count / totalCitations * 100) : 0
4066
- })).sort((a, b) => b.count - a.count);
4067
- const topDomains = [...domainCounts.entries()].map(([domain, count]) => ({
4068
- domain,
4069
- count,
4070
- isCompetitor: citedDomainBelongsToProject(domain, competitorDomains)
4071
- })).sort((a, b) => b.count - a.count).slice(0, TOP_SOURCE_DOMAINS_LIMIT);
4072
- return { categories, topDomains };
4073
- }
4074
4195
  function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
4075
4196
  const rows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
4076
4197
  if (rows.length === 0) return null;
@@ -4306,27 +4427,33 @@ function buildIndexingHealth(db, projectId) {
4306
4427
  }
4307
4428
  return null;
4308
4429
  }
4309
- function buildCitationsTrend(db, projectId, queryLookup) {
4310
- const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all();
4430
+ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
4431
+ const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter);
4432
+ const totalQueries = queryLookup.byId.size;
4311
4433
  const points = [];
4312
4434
  for (const run of visibilityRuns) {
4313
4435
  if (run.status !== RunStatuses.completed) continue;
4314
4436
  const snaps = loadSnapshotsForRun(db, run.id);
4315
4437
  if (snaps.length === 0) continue;
4316
- let cited = 0;
4438
+ const citedQueryIds = /* @__PURE__ */ new Set();
4439
+ const mentionedQueryIds = /* @__PURE__ */ new Set();
4317
4440
  let considered = 0;
4318
4441
  const providerCounts = /* @__PURE__ */ new Map();
4319
4442
  for (const snap of snaps) {
4320
4443
  if (!queryLookup.byId.has(snap.queryId)) continue;
4321
4444
  considered++;
4322
- if (snap.citationState === CitationStates.cited) cited++;
4445
+ if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
4446
+ if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
4323
4447
  const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
4324
4448
  counts.total++;
4325
4449
  if (snap.citationState === CitationStates.cited) counts.cited++;
4326
4450
  providerCounts.set(snap.provider, counts);
4327
4451
  }
4328
4452
  if (considered === 0) continue;
4329
- const citationRate = Math.round(cited / considered * 100);
4453
+ const citedQueryCount = citedQueryIds.size;
4454
+ const mentionedQueryCount = mentionedQueryIds.size;
4455
+ const citationRate = totalQueries > 0 ? Math.round(citedQueryCount / totalQueries * 100) : 0;
4456
+ const mentionRate = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
4330
4457
  const providerRates = [...providerCounts.entries()].map(([provider, counts]) => ({
4331
4458
  provider,
4332
4459
  citationRate: counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0
@@ -4335,20 +4462,24 @@ function buildCitationsTrend(db, projectId, queryLookup) {
4335
4462
  runId: run.id,
4336
4463
  date: run.finishedAt ?? run.createdAt,
4337
4464
  citationRate,
4465
+ citedQueryCount,
4466
+ totalQueryCount: totalQueries,
4467
+ mentionRate,
4468
+ mentionedQueryCount,
4338
4469
  providerRates
4339
4470
  });
4340
4471
  }
4341
4472
  points.sort((a, b) => a.date.localeCompare(b.date));
4342
4473
  return points;
4343
4474
  }
4344
- function buildInsightList(db, projectId) {
4345
- const recentRunIds = db.select({ id: runs.id }).from(runs).where(
4475
+ function buildInsightList(db, projectId, locationFilter) {
4476
+ const recentRunIds = db.select({ id: runs.id, location: runs.location }).from(runs).where(
4346
4477
  and4(
4347
4478
  eq13(runs.projectId, projectId),
4348
4479
  eq13(runs.kind, RunKinds["answer-visibility"]),
4349
4480
  or2(eq13(runs.status, RunStatuses.completed), eq13(runs.status, RunStatuses.partial))
4350
4481
  )
4351
- ).orderBy(desc6(runs.createdAt)).limit(INSIGHT_LOOKBACK_RUNS).all().map((r) => r.id);
4482
+ ).orderBy(desc6(runs.createdAt)).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter).slice(0, INSIGHT_LOOKBACK_RUNS).map((r) => r.id);
4352
4483
  if (recentRunIds.length === 0) return [];
4353
4484
  const rows = db.select().from(insights).where(and4(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
4354
4485
  const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -4420,7 +4551,7 @@ function buildRecommendedNextSteps(insightList) {
4420
4551
  }
4421
4552
  return steps;
4422
4553
  }
4423
- function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
4554
+ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
4424
4555
  const findings = [];
4425
4556
  if (trendsPoints.length > 0) {
4426
4557
  const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
@@ -4443,8 +4574,10 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
4443
4574
  break;
4444
4575
  }
4445
4576
  }
4577
+ const queryNoun = totalQueryCount === 1 ? "query" : "queries";
4578
+ const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
4446
4579
  findings.push({
4447
- title: `Citation rate at ${citationRate}%`,
4580
+ title: `Citation rate at ${citationRate}%${ratioFragment}`,
4448
4581
  detail,
4449
4582
  tone
4450
4583
  });
@@ -4467,6 +4600,328 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
4467
4600
  }
4468
4601
  return findings.slice(0, 5);
4469
4602
  }
4603
+ function buildLocationMeta(runLocationLabel, configuredLocations) {
4604
+ if (!runLocationLabel) return null;
4605
+ const match = configuredLocations.find((loc) => loc.label === runLocationLabel);
4606
+ const others = configuredLocations.map((loc) => loc.label).filter((label) => label !== runLocationLabel);
4607
+ return {
4608
+ label: runLocationLabel,
4609
+ city: match?.city ?? "",
4610
+ region: match?.region ?? "",
4611
+ country: match?.country ?? "",
4612
+ otherConfiguredLabels: others
4613
+ };
4614
+ }
4615
+ function buildProviderLocationHandling(providersInRun) {
4616
+ return [...providersInRun].sort().map((provider) => {
4617
+ const handling = getProviderLocationHandling(provider);
4618
+ return {
4619
+ provider,
4620
+ treatment: handling.treatment,
4621
+ description: handling.description
4622
+ };
4623
+ });
4624
+ }
4625
+ function compactList(items, limit = 3) {
4626
+ const visible = items.slice(0, limit);
4627
+ const extra = items.length - visible.length;
4628
+ return extra > 0 ? `${visible.join(", ")}, +${extra} more` : visible.join(", ");
4629
+ }
4630
+ function contentActionVerb(action) {
4631
+ switch (action) {
4632
+ case "create":
4633
+ return "Create";
4634
+ case "expand":
4635
+ return "Expand";
4636
+ case "refresh":
4637
+ return "Refresh";
4638
+ case "add-schema":
4639
+ return "Add schema to";
4640
+ }
4641
+ }
4642
+ function confidenceFromEvidence(count) {
4643
+ if (count >= 3) return "high";
4644
+ if (count >= 1) return "medium";
4645
+ return "low";
4646
+ }
4647
+ function actionAudienceMatches2(action, audience) {
4648
+ return action.audience === "both" || action.audience === audience;
4649
+ }
4650
+ function buildReportActionPlan(input) {
4651
+ const actions = [];
4652
+ if (input.competitorDomains.length === 0 && input.aiSourceOrigin.topDomains.length > 0) {
4653
+ const topDomains = input.aiSourceOrigin.topDomains.slice(0, 5);
4654
+ actions.push({
4655
+ audience: "both",
4656
+ priority: 10,
4657
+ horizon: "immediate",
4658
+ category: "competitors",
4659
+ title: "Define the competitor set Canonry should benchmark against",
4660
+ action: "Review the recurring external source domains and add the true competitors before the next sweep.",
4661
+ why: [
4662
+ "The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.",
4663
+ "A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients."
4664
+ ],
4665
+ evidence: topDomains.map((d) => `${d.domain} appeared in ${d.count} cited source${d.count === 1 ? "" : "s"}`),
4666
+ successMetric: "Next report separates tracked competitors from independent source domains in the competitor landscape.",
4667
+ confidence: confidenceFromEvidence(topDomains.length)
4668
+ });
4669
+ }
4670
+ for (const [index, opportunity] of input.contentOpportunities.slice(0, 2).entries()) {
4671
+ const verb = contentActionVerb(opportunity.action);
4672
+ const target = opportunity.ourBestPage?.url ?? `a new page for "${opportunity.query}"`;
4673
+ const evidence = [
4674
+ `Opportunity score ${Math.round(opportunity.score)} with ${opportunity.actionConfidence} confidence`,
4675
+ `Demand source: ${opportunity.demandSource}`
4676
+ ];
4677
+ if (opportunity.winningCompetitor) {
4678
+ evidence.push(`${opportunity.winningCompetitor.domain} is the current winning cited source`);
4679
+ }
4680
+ if (opportunity.ourBestPage) {
4681
+ evidence.push(`Best matching owned page: ${opportunity.ourBestPage.url}`);
4682
+ } else {
4683
+ evidence.push("No matching owned page was found");
4684
+ }
4685
+ actions.push({
4686
+ audience: "both",
4687
+ priority: 20 + index,
4688
+ horizon: opportunity.actionConfidence === "high" ? "short-term" : "medium-term",
4689
+ category: "content",
4690
+ title: `${verb} content for "${opportunity.query}"`,
4691
+ action: opportunity.ourBestPage ? `${verb} ${target} so it directly answers the tracked query and cites the strongest supporting evidence.` : `${verb} ${target} that directly answers the query and earns citations from AI answer engines.`,
4692
+ why: opportunity.drivers.length > 0 ? opportunity.drivers : ["Canonry ranked this as a content opportunity from search-demand and citation evidence."],
4693
+ evidence,
4694
+ successMetric: `A future sweep cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
4695
+ confidence: opportunity.actionConfidence
4696
+ });
4697
+ }
4698
+ if (input.indexingHealth && input.indexingHealth.total > 0 && input.indexingHealth.indexedPct < 70) {
4699
+ const ih = input.indexingHealth;
4700
+ const evidence = [
4701
+ `${ih.indexedPct}% indexed (${ih.indexed}/${ih.total})`,
4702
+ `${ih.notIndexed} not indexed${ih.deindexed > 0 ? `, ${ih.deindexed} deindexed` : ""}`
4703
+ ];
4704
+ actions.push({
4705
+ audience: "both",
4706
+ priority: 30,
4707
+ horizon: "immediate",
4708
+ category: "indexing",
4709
+ title: "Fix indexing coverage before expanding the content plan",
4710
+ action: "Audit the not-indexed tracked URLs, resolve crawl/index blockers, and resubmit priority pages.",
4711
+ why: [
4712
+ "Pages missing from the search index are less likely to be retrieved or cited by AI answer engines.",
4713
+ "Indexing issues can hide otherwise strong content from both search and AI systems."
4714
+ ],
4715
+ evidence,
4716
+ successMetric: "Indexed share moves above 80% for tracked URLs and priority pages are eligible for retrieval.",
4717
+ confidence: ih.total >= 5 ? "high" : "medium"
4718
+ });
4719
+ }
4720
+ const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
4721
+ if (zeroCitationProviders.length > 0) {
4722
+ actions.push({
4723
+ audience: "agency",
4724
+ priority: 40,
4725
+ horizon: "short-term",
4726
+ category: "provider",
4727
+ title: "Diagnose providers with zero citations",
4728
+ action: "Inspect zero-citation provider answers and compare their cited domains against the pages currently available on the client site.",
4729
+ why: [
4730
+ "Provider-level misses show where one model family is not retrieving the client even when others might.",
4731
+ "This points the agency toward provider-specific evidence gaps instead of a generic content recommendation."
4732
+ ],
4733
+ evidence: zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`),
4734
+ successMetric: "At least one zero-citation provider cites the client on a priority query in a later sweep.",
4735
+ confidence: "high"
4736
+ });
4737
+ }
4738
+ if (input.gsc && (input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0)) {
4739
+ const evidence = [];
4740
+ if (input.gsc.trackedButNoGsc.length > 0) {
4741
+ evidence.push(`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`);
4742
+ }
4743
+ if (input.gsc.gscButNotTracked.length > 0) {
4744
+ evidence.push(`Search demand not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`);
4745
+ }
4746
+ actions.push({
4747
+ audience: "agency",
4748
+ priority: 50,
4749
+ horizon: "short-term",
4750
+ category: "search-demand",
4751
+ title: "Align tracked AEO queries with search demand",
4752
+ action: "Prune or relabel tracked queries with no search demand and add high-impression non-brand GSC queries to the AEO tracking set.",
4753
+ why: [
4754
+ "The strongest report actions come from overlap between real search demand and AI citation gaps.",
4755
+ "Mismatch here can make the client report feel interesting but hard to act on."
4756
+ ],
4757
+ evidence,
4758
+ successMetric: "Next report has fewer no-demand tracked queries and includes the highest-impression non-brand GSC candidates.",
4759
+ confidence: evidence.length > 1 ? "high" : "medium"
4760
+ });
4761
+ }
4762
+ if (input.contentGaps.length > 0) {
4763
+ const topGap = input.contentGaps[0];
4764
+ actions.push({
4765
+ audience: "agency",
4766
+ priority: 60,
4767
+ horizon: "medium-term",
4768
+ category: "content",
4769
+ title: "Close competitor-cited content gaps",
4770
+ action: "Map the top missing queries to owned pages or new briefs, starting with the gaps where multiple competitors are already cited.",
4771
+ why: [
4772
+ "These are explicit places where AI engines found competitor sources but not the client.",
4773
+ "They are stronger evidence than a generic topic list because the model is already retrieving competing content."
4774
+ ],
4775
+ evidence: [
4776
+ `"${topGap.query}" missed at ${Math.round(topGap.missRate * 100)}% with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
4777
+ `Cited competitors: ${compactList(topGap.competitorDomains)}`
4778
+ ],
4779
+ successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
4780
+ confidence: topGap.competitorCount >= 2 ? "high" : "medium"
4781
+ });
4782
+ }
4783
+ if (input.reportLocation && input.reportLocation.otherConfiguredLabels.length > 0) {
4784
+ const ignoredProviders = input.providerLocationHandling.filter((p) => p.treatment === "ignored" || p.treatment === "browser-geo").map((p) => p.provider);
4785
+ const evidence = [
4786
+ `Current report location: ${input.reportLocation.label}`,
4787
+ `Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`
4788
+ ];
4789
+ if (ignoredProviders.length > 0) {
4790
+ evidence.push(`Providers with weak/indirect location handling: ${compactList(ignoredProviders)}`);
4791
+ }
4792
+ actions.push({
4793
+ audience: "agency",
4794
+ priority: 70,
4795
+ horizon: "medium-term",
4796
+ category: "location",
4797
+ title: "Keep location-scoped reporting separate by market",
4798
+ action: "Run and compare separate sweeps for each configured location before making market-level recommendations.",
4799
+ why: [
4800
+ "A multi-location client can appear differently by market.",
4801
+ "Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story."
4802
+ ],
4803
+ evidence,
4804
+ successMetric: "Each configured market has its own current sweep and trend before cross-market decisions are made.",
4805
+ confidence: "high"
4806
+ });
4807
+ }
4808
+ if (actions.length === 0) {
4809
+ actions.push({
4810
+ audience: "both",
4811
+ priority: 90,
4812
+ horizon: "short-term",
4813
+ category: "monitoring",
4814
+ title: "Keep monitoring citation and mention coverage",
4815
+ action: "Run the next scheduled visibility sweep and watch for citation gains, losses, and provider-specific misses.",
4816
+ why: [
4817
+ "No urgent corrective action surfaced from the current evidence.",
4818
+ "AEO performance is directional; repeated sweeps are needed before overreacting to a single sample."
4819
+ ],
4820
+ evidence: ["No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report."],
4821
+ successMetric: "Coverage stays stable or improves across the next trend window.",
4822
+ confidence: "medium"
4823
+ });
4824
+ }
4825
+ return actions.sort((a, b) => a.priority - b.priority).slice(0, 10);
4826
+ }
4827
+ function trendSentence(trend) {
4828
+ switch (trend) {
4829
+ case "up":
4830
+ return "Citation coverage improved versus the prior comparable sweep.";
4831
+ case "down":
4832
+ return "Citation coverage declined versus the prior comparable sweep.";
4833
+ case "flat":
4834
+ return "Citation coverage is flat versus the prior comparable sweep.";
4835
+ case "unknown":
4836
+ return "There is not enough comparable run history yet to call a trend.";
4837
+ }
4838
+ }
4839
+ function buildClientSummary(reportLike) {
4840
+ const s = reportLike.executiveSummary;
4841
+ const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
4842
+ const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a visibility sweep yet";
4843
+ const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "Canonry needs at least one completed visibility sweep before it can summarize how the brand appears in AI answers.";
4844
+ const confidenceNotes = [];
4845
+ if (s.totalQueryCount === 0) {
4846
+ confidenceNotes.push("Confidence is low until the first tracked query sweep completes.");
4847
+ } else if (s.totalQueryCount < 5) {
4848
+ confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
4849
+ }
4850
+ if (isTrendBaseline(reportLike.citationsTrend)) {
4851
+ confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable sweeps are needed for a stable trend.`);
4852
+ }
4853
+ if (!reportLike.gsc) {
4854
+ confidenceNotes.push("Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.");
4855
+ }
4856
+ if (reportLike.reportLocation) {
4857
+ confidenceNotes.push(`This summary is scoped to the ${reportLike.reportLocation.label} run location.`);
4858
+ }
4859
+ return {
4860
+ headline,
4861
+ overview,
4862
+ actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches2(a, "client")).slice(0, 3),
4863
+ confidenceNotes
4864
+ };
4865
+ }
4866
+ function buildAgencyDiagnostics(input) {
4867
+ const diagnostics = [];
4868
+ const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
4869
+ diagnostics.push({
4870
+ title: "Provider citation coverage",
4871
+ detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length} provider${zeroCitationProviders.length === 1 ? "" : "s"} returned zero client citations in the latest sweep.` : "Every provider with completed snapshots produced at least one client citation or no provider data is available yet.",
4872
+ severity: zeroCitationProviders.length > 0 ? "negative" : "positive",
4873
+ evidence: zeroCitationProviders.length > 0 ? zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount}`) : input.citationScorecard.providerRates.map((p) => `${p.provider}: ${p.citedCount}/${p.totalCount}`)
4874
+ });
4875
+ diagnostics.push({
4876
+ title: "AI source domains",
4877
+ detail: input.aiSourceOrigin.topDomains.length > 0 ? "Repeated external source domains show what AI engines are currently trusting for this topic set." : "No external source-domain evidence is available from the latest sweep yet.",
4878
+ severity: input.aiSourceOrigin.topDomains.length > 0 ? "neutral" : "caution",
4879
+ evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `${d.domain}: ${d.count}`)
4880
+ });
4881
+ if (input.gsc) {
4882
+ diagnostics.push({
4883
+ title: "GSC query mismatch",
4884
+ detail: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "The tracked AEO query set and real search demand are not fully aligned." : "Tracked AEO queries and high-impression non-brand GSC queries are aligned for the current window.",
4885
+ severity: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "caution" : "positive",
4886
+ evidence: [
4887
+ ...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`] : [],
4888
+ ...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`] : []
4889
+ ]
4890
+ });
4891
+ }
4892
+ if (input.indexingHealth) {
4893
+ diagnostics.push({
4894
+ title: "Indexing health",
4895
+ detail: `${input.indexingHealth.indexedPct}% of inspected URLs are indexed in ${input.indexingHealth.provider ?? "the connected provider"}.`,
4896
+ severity: input.indexingHealth.indexedPct >= 90 ? "positive" : input.indexingHealth.indexedPct >= 70 ? "caution" : "negative",
4897
+ evidence: [
4898
+ `${input.indexingHealth.indexed}/${input.indexingHealth.total} indexed`,
4899
+ `${input.indexingHealth.notIndexed} not indexed`
4900
+ ]
4901
+ });
4902
+ }
4903
+ diagnostics.push({
4904
+ title: "Content opportunity pipeline",
4905
+ detail: input.contentOpportunities.length > 0 ? `${input.contentOpportunities.length} ranked content opportunit${input.contentOpportunities.length === 1 ? "y" : "ies"} and ${input.contentGaps.length} content gap${input.contentGaps.length === 1 ? "" : "s"} are available.` : "No ranked content opportunities are available from the current evidence.",
4906
+ severity: input.contentOpportunities.length > 0 ? "caution" : "neutral",
4907
+ evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
4908
+ });
4909
+ if (input.reportLocation) {
4910
+ diagnostics.push({
4911
+ title: "Location caveat",
4912
+ detail: input.reportLocation.otherConfiguredLabels.length > 0 ? "This report is scoped to the latest run location; other configured locations need separate interpretation." : "This report is scoped to one configured location.",
4913
+ severity: input.reportLocation.otherConfiguredLabels.length > 0 ? "caution" : "neutral",
4914
+ evidence: [
4915
+ `Current location: ${input.reportLocation.label}`,
4916
+ ...input.reportLocation.otherConfiguredLabels.length > 0 ? [`Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`] : []
4917
+ ]
4918
+ });
4919
+ }
4920
+ return {
4921
+ priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
4922
+ diagnostics
4923
+ };
4924
+ }
4470
4925
  function buildProjectReport(db, projectName) {
4471
4926
  const project = resolveProject(db, projectName);
4472
4927
  const queryLookup = loadQueryLookup(db, project.id);
@@ -4476,6 +4931,7 @@ function buildProjectReport(db, projectName) {
4476
4931
  (r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
4477
4932
  ) ?? visibilityRuns[0];
4478
4933
  const latestSnapshots = latestRun ? loadSnapshotsForRun(db, latestRun.id) : [];
4934
+ const latestRunLocation = latestRun?.location ?? null;
4479
4935
  const competitorRows = db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
4480
4936
  const competitorDomains = competitorRows.map((c) => c.domain);
4481
4937
  const ownedDomains = parseJsonColumn(project.ownedDomains, []);
@@ -4507,9 +4963,9 @@ function buildProjectReport(db, projectName) {
4507
4963
  const socialSection = buildSocialReferrals(db, project.id);
4508
4964
  const aiReferralsSection = buildAiReferrals(db, project.id);
4509
4965
  const indexingHealthSection = buildIndexingHealth(db, project.id);
4510
- const citationsTrend = buildCitationsTrend(db, project.id, queryLookup);
4511
- const insightList = buildInsightList(db, project.id);
4512
- const orchestratorInput = loadOrchestratorInput(db, project);
4966
+ const citationsTrend = buildCitationsTrend(db, project.id, queryLookup, latestRunLocation);
4967
+ const insightList = buildInsightList(db, project.id, latestRunLocation);
4968
+ const orchestratorInput = loadOrchestratorInput(db, project, latestRunLocation);
4513
4969
  const contentOpportunities = buildContentTargetRows(orchestratorInput);
4514
4970
  const contentGaps = buildContentGapRows(orchestratorInput);
4515
4971
  const groundingSources = buildContentSourceRows(orchestratorInput);
@@ -4518,14 +4974,18 @@ function buildProjectReport(db, projectName) {
4518
4974
  contentOpportunities,
4519
4975
  insightDerivedSteps
4520
4976
  );
4521
- let latestCited = 0;
4522
- let latestConsidered = 0;
4977
+ const totalQueryCount = queryLookup.byId.size;
4978
+ const citedQueryIds = /* @__PURE__ */ new Set();
4979
+ const mentionedQueryIds = /* @__PURE__ */ new Set();
4523
4980
  for (const snap of latestSnapshots) {
4524
4981
  if (!queryLookup.byId.has(snap.queryId)) continue;
4525
- latestConsidered++;
4526
- if (snap.citationState === CitationStates.cited) latestCited++;
4982
+ if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
4983
+ if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
4527
4984
  }
4528
- const citationRate = latestConsidered > 0 ? Math.round(latestCited / latestConsidered * 100) : 0;
4985
+ const citedQueryCount = citedQueryIds.size;
4986
+ const mentionedQueryCount = mentionedQueryIds.size;
4987
+ const citationRate = totalQueryCount > 0 ? Math.round(citedQueryCount / totalQueryCount * 100) : 0;
4988
+ const mentionRate = totalQueryCount > 0 ? Math.round(mentionedQueryCount / totalQueryCount * 100) : 0;
4529
4989
  const trendBaseline = isTrendBaseline(citationsTrend);
4530
4990
  const latestPoint = citationsTrend.at(-1);
4531
4991
  const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
@@ -4542,6 +5002,8 @@ function buildProjectReport(db, projectName) {
4542
5002
  }
4543
5003
  const findings = buildExecutiveFindings(
4544
5004
  citationRate,
5005
+ citedQueryCount,
5006
+ totalQueryCount,
4545
5007
  trend,
4546
5008
  citationsTrend,
4547
5009
  trendBaseline,
@@ -4550,6 +5012,66 @@ function buildProjectReport(db, projectName) {
4550
5012
  );
4551
5013
  const periodStart = citationsTrend[0]?.date ?? null;
4552
5014
  const periodEnd = citationsTrend.at(-1)?.date ?? null;
5015
+ const configuredLocations = parseJsonColumn(project.locations, []);
5016
+ const reportLocation = buildLocationMeta(latestRun?.location ?? null, configuredLocations);
5017
+ const providerLocationHandling = reportLocation ? buildProviderLocationHandling(citationScorecard.providers) : [];
5018
+ const executiveSummary = {
5019
+ citationRate,
5020
+ citedQueryCount,
5021
+ totalQueryCount,
5022
+ mentionRate,
5023
+ mentionedQueryCount,
5024
+ trend,
5025
+ queryCount: queryLookup.byId.size,
5026
+ competitorCount: competitorDomains.length,
5027
+ providerCount: citationScorecard.providers.length,
5028
+ gsc: gscSection ? {
5029
+ clicks: gscSection.totalClicks,
5030
+ impressions: gscSection.totalImpressions,
5031
+ ctr: gscSection.ctr,
5032
+ avgPosition: gscSection.avgPosition
5033
+ } : null,
5034
+ ga: gaSection ? {
5035
+ sessions: gaSection.totalSessions,
5036
+ users: gaSection.totalUsers,
5037
+ periodStart: gaSection.periodStart,
5038
+ periodEnd: gaSection.periodEnd
5039
+ } : null,
5040
+ findings
5041
+ };
5042
+ const actionPlan = buildReportActionPlan({
5043
+ canonicalDomain: project.canonicalDomain,
5044
+ competitorDomains,
5045
+ citationScorecard,
5046
+ aiSourceOrigin,
5047
+ gsc: gscSection,
5048
+ indexingHealth: indexingHealthSection,
5049
+ contentOpportunities,
5050
+ contentGaps,
5051
+ reportLocation,
5052
+ providerLocationHandling
5053
+ });
5054
+ const clientSummary = buildClientSummary({
5055
+ canonicalDomain: project.canonicalDomain,
5056
+ reportLocation,
5057
+ executiveSummary,
5058
+ citationsTrend,
5059
+ gsc: gscSection,
5060
+ actionPlan
5061
+ });
5062
+ const agencyDiagnostics = buildAgencyDiagnostics({
5063
+ canonicalDomain: project.canonicalDomain,
5064
+ competitorDomains,
5065
+ citationScorecard,
5066
+ aiSourceOrigin,
5067
+ gsc: gscSection,
5068
+ indexingHealth: indexingHealthSection,
5069
+ contentOpportunities,
5070
+ contentGaps,
5071
+ reportLocation,
5072
+ providerLocationHandling,
5073
+ actionPlan
5074
+ });
4553
5075
  return {
4554
5076
  meta: {
4555
5077
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -4561,29 +5083,12 @@ function buildProjectReport(db, projectName) {
4561
5083
  country: project.country,
4562
5084
  language: project.language
4563
5085
  },
5086
+ location: reportLocation,
5087
+ providerLocationHandling,
4564
5088
  periodStart,
4565
5089
  periodEnd
4566
5090
  },
4567
- executiveSummary: {
4568
- citationRate,
4569
- trend,
4570
- queryCount: queryLookup.byId.size,
4571
- competitorCount: competitorDomains.length,
4572
- providerCount: citationScorecard.providers.length,
4573
- gsc: gscSection ? {
4574
- clicks: gscSection.totalClicks,
4575
- impressions: gscSection.totalImpressions,
4576
- ctr: gscSection.ctr,
4577
- avgPosition: gscSection.avgPosition
4578
- } : null,
4579
- ga: gaSection ? {
4580
- sessions: gaSection.totalSessions,
4581
- users: gaSection.totalUsers,
4582
- periodStart: gaSection.periodStart,
4583
- periodEnd: gaSection.periodEnd
4584
- } : null,
4585
- findings
4586
- },
5091
+ executiveSummary,
4587
5092
  citationScorecard,
4588
5093
  competitorLandscape,
4589
5094
  mentionLandscape,
@@ -4596,14 +5101,22 @@ function buildProjectReport(db, projectName) {
4596
5101
  citationsTrend,
4597
5102
  insights: insightList,
4598
5103
  recommendedNextSteps,
5104
+ actionPlan,
5105
+ clientSummary,
5106
+ agencyDiagnostics,
4599
5107
  contentOpportunities,
4600
5108
  contentGaps,
4601
5109
  groundingSources
4602
5110
  };
4603
5111
  }
4604
- function reportFilenameFor(project, generatedAt) {
5112
+ function parseReportAudience(value) {
5113
+ if (value === void 0 || value === "agency") return "agency";
5114
+ if (value === "client") return "client";
5115
+ throw validationError('"audience" must be "agency" or "client"');
5116
+ }
5117
+ function reportFilenameFor(project, generatedAt, audience) {
4605
5118
  const date = generatedAt.slice(0, 10);
4606
- return `canonry-report-${project.name}-${date}.html`;
5119
+ return `canonry-report-${project.name}-${audience}-${date}.html`;
4607
5120
  }
4608
5121
  async function reportRoutes(app) {
4609
5122
  app.get("/projects/:name/report", async (request, reply) => {
@@ -4611,9 +5124,10 @@ async function reportRoutes(app) {
4611
5124
  return reply.send(dto);
4612
5125
  });
4613
5126
  app.get("/projects/:name/report.html", async (request, reply) => {
5127
+ const audience = parseReportAudience(request.query.audience);
4614
5128
  const dto = buildProjectReport(app.db, request.params.name);
4615
- const html = renderReportHtml(dto);
4616
- const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt);
5129
+ const html = renderReportHtml(dto, { audience });
5130
+ const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt, audience);
4617
5131
  reply.header("Content-Type", "text/html; charset=utf-8");
4618
5132
  reply.header("Content-Disposition", `attachment; filename="${filename}"`);
4619
5133
  return reply.send(html);
@@ -4785,24 +5299,68 @@ function normalizeDomain2(domain) {
4785
5299
  }
4786
5300
 
4787
5301
  // ../api-routes/src/composites.ts
4788
- import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3 } from "drizzle-orm";
5302
+ import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3, inArray as inArray6 } from "drizzle-orm";
4789
5303
  var TOP_INSIGHT_LIMIT = 5;
4790
5304
  var SEARCH_HIT_HARD_LIMIT = 50;
4791
5305
  var SEARCH_SNIPPET_RADIUS = 80;
4792
5306
  async function compositeRoutes(app) {
4793
5307
  app.get("/projects/:name/overview", async (request, reply) => {
4794
5308
  const project = resolveProject(app.db, request.params.name);
4795
- const totalRunsRow = app.db.select({ count: sql3`count(*)` }).from(runs).where(eq15(runs.projectId, project.id)).get();
4796
- const totalRuns = totalRunsRow?.count ?? 0;
4797
- const recentRuns = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).limit(2).all();
4798
- const [latestRunRow, previousRunRow] = recentRuns;
5309
+ const filterLocation = (request.query.location ?? "").trim() || null;
5310
+ const sinceIso = parseSinceFilter(request.query.since);
5311
+ const allRunsRaw = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).all();
5312
+ const allRuns = allRunsRaw.filter((r) => runMatchesFilters(r, filterLocation, sinceIso));
5313
+ const totalRuns = allRuns.length;
5314
+ const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
5315
+ const completedVisRuns = visibilityRuns.filter(
5316
+ (r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
5317
+ );
5318
+ const latestVisibilityRun = completedVisRuns[0] ?? null;
5319
+ const previousVisibilityRun = completedVisRuns[1] ?? null;
5320
+ const latestRunRow = allRuns[0] ?? null;
4799
5321
  const latestRun = latestRunRow ? { totalRuns, run: summarizeRun(latestRunRow) } : { totalRuns: 0, run: null };
4800
5322
  const healthRow = app.db.select().from(healthSnapshots).where(eq15(healthSnapshots.projectId, project.id)).orderBy(desc7(healthSnapshots.createdAt)).limit(1).get();
4801
5323
  const health = healthRow ? mapHealthRow2(healthRow) : null;
4802
5324
  const insightRows = app.db.select().from(insights).where(eq15(insights.projectId, project.id)).orderBy(desc7(insights.createdAt)).all();
4803
5325
  const topInsights = insightRows.filter((row) => !row.dismissed).slice(0, TOP_INSIGHT_LIMIT).map(mapInsightRow2);
4804
- const { queryCounts, providers } = summarizeLatestRun(app, latestRunRow ?? null);
4805
- const transitions = summarizeTransitions(app, latestRunRow ?? null, previousRunRow ?? null);
5326
+ const sparklineRunIds = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => r.id);
5327
+ const snapshotRunIds = new Set(sparklineRunIds);
5328
+ if (latestVisibilityRun) snapshotRunIds.add(latestVisibilityRun.id);
5329
+ if (previousVisibilityRun) snapshotRunIds.add(previousVisibilityRun.id);
5330
+ const snapshotsByRun = loadSnapshotsByRunIds(app, [...snapshotRunIds]);
5331
+ const latestSnapshots = latestVisibilityRun ? snapshotsByRun.get(latestVisibilityRun.id) ?? [] : [];
5332
+ const previousSnapshots = previousVisibilityRun ? snapshotsByRun.get(previousVisibilityRun.id) ?? [] : [];
5333
+ const { queryCounts, providers } = summarizeFromSnapshots(latestSnapshots);
5334
+ const transitions = summarizeTransitionsFromSnapshots(
5335
+ latestSnapshots,
5336
+ previousSnapshots,
5337
+ previousVisibilityRun?.createdAt ?? null
5338
+ );
5339
+ const competitorRows = app.db.select().from(competitors).where(eq15(competitors.projectId, project.id)).all();
5340
+ const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq15(queries.projectId, project.id)).all();
5341
+ const queryLookup = { byId: new Map(projectQueries.map((q) => [q.id, q.query])) };
5342
+ const configuredApiProviders = parseJsonColumn(project.providers, []).filter((p) => !p.startsWith("cdp:"));
5343
+ const scores = {
5344
+ visibility: buildVisibilityScore(latestSnapshots, { configuredApiProviders }),
5345
+ gapQueries: buildGapQueryScore(latestSnapshots),
5346
+ indexCoverage: buildIndexCoverageScore(app, project.id),
5347
+ competitorPressure: buildCompetitorPressureScore(
5348
+ latestSnapshots,
5349
+ competitorRows.map((c) => c.domain),
5350
+ competitorRows.length
5351
+ ),
5352
+ runStatus: buildRunStatusScore(allRuns)
5353
+ };
5354
+ const movementSummary = buildMovementSummary(latestSnapshots, previousSnapshots);
5355
+ const providerScores = buildProviderScores(latestSnapshots);
5356
+ const overviewCompetitors = buildOverviewCompetitors(
5357
+ latestSnapshots,
5358
+ competitorRows.map((c) => ({ id: c.id, domain: c.domain })),
5359
+ queryLookup
5360
+ );
5361
+ const attentionItems = buildAttentionItems(insightRows, allRuns);
5362
+ const sparklineRuns = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => ({ id: r.id, createdAt: r.createdAt, status: r.status }));
5363
+ const runHistory = buildRunHistory(sparklineRuns, snapshotsByRun);
4806
5364
  const result = {
4807
5365
  project: formatProject2(project),
4808
5366
  latestRun,
@@ -4810,7 +5368,15 @@ async function compositeRoutes(app) {
4810
5368
  topInsights,
4811
5369
  queryCounts,
4812
5370
  providers,
4813
- transitions
5371
+ transitions,
5372
+ scores,
5373
+ movementSummary,
5374
+ competitors: overviewCompetitors,
5375
+ providerScores,
5376
+ attentionItems,
5377
+ runHistory,
5378
+ dateRangeLabel: "All time",
5379
+ contextLabel: `${project.country} / ${project.language.toUpperCase()}`
4814
5380
  };
4815
5381
  return reply.send(result);
4816
5382
  });
@@ -4876,6 +5442,21 @@ async function compositeRoutes(app) {
4876
5442
  return reply.send(response);
4877
5443
  });
4878
5444
  }
5445
+ function parseSinceFilter(raw) {
5446
+ if (!raw) return null;
5447
+ const trimmed = raw.trim();
5448
+ if (!trimmed) return null;
5449
+ const parsed = Date.parse(trimmed);
5450
+ if (Number.isNaN(parsed)) {
5451
+ throw validationError('"since" must be an ISO 8601 datetime');
5452
+ }
5453
+ return new Date(parsed).toISOString();
5454
+ }
5455
+ function runMatchesFilters(run, location, sinceIso) {
5456
+ if (location !== null && (run.location ?? "") !== location) return false;
5457
+ if (sinceIso !== null && run.createdAt < sinceIso) return false;
5458
+ return true;
5459
+ }
4879
5460
  function clampSearchLimit(raw) {
4880
5461
  if (!raw) return 25;
4881
5462
  const parsed = Number.parseInt(raw, 10);
@@ -4901,29 +5482,49 @@ function summarizeRun(run) {
4901
5482
  createdAt: run.createdAt
4902
5483
  };
4903
5484
  }
4904
- function summarizeLatestRun(app, run) {
5485
+ function loadSnapshotsByRunIds(app, runIds) {
5486
+ const result = /* @__PURE__ */ new Map();
5487
+ if (runIds.length === 0) return result;
5488
+ const rows = app.db.select({
5489
+ runId: querySnapshots.runId,
5490
+ queryId: querySnapshots.queryId,
5491
+ provider: querySnapshots.provider,
5492
+ model: querySnapshots.model,
5493
+ citationState: querySnapshots.citationState,
5494
+ competitorOverlap: querySnapshots.competitorOverlap,
5495
+ citedDomains: querySnapshots.citedDomains
5496
+ }).from(querySnapshots).where(inArray6(querySnapshots.runId, [...runIds])).all();
5497
+ for (const row of rows) {
5498
+ const list = result.get(row.runId) ?? [];
5499
+ list.push({
5500
+ queryId: row.queryId,
5501
+ provider: row.provider,
5502
+ model: row.model,
5503
+ citationState: row.citationState,
5504
+ competitorOverlap: parseJsonColumn(row.competitorOverlap, []),
5505
+ citedDomains: parseJsonColumn(row.citedDomains, [])
5506
+ });
5507
+ result.set(row.runId, list);
5508
+ }
5509
+ return result;
5510
+ }
5511
+ function summarizeFromSnapshots(snapshots) {
4905
5512
  const empty = {
4906
5513
  queryCounts: { totalQueries: 0, citedQueries: 0, notCitedQueries: 0, citedRate: 0 },
4907
5514
  providers: []
4908
5515
  };
4909
- if (!run) return empty;
4910
- const rows = app.db.select({
4911
- queryId: querySnapshots.queryId,
4912
- provider: querySnapshots.provider,
4913
- citationState: querySnapshots.citationState
4914
- }).from(querySnapshots).where(eq15(querySnapshots.runId, run.id)).all();
4915
- if (rows.length === 0) return empty;
5516
+ if (snapshots.length === 0) return empty;
4916
5517
  const perQuery = /* @__PURE__ */ new Map();
4917
5518
  const perProvider = /* @__PURE__ */ new Map();
4918
- for (const row of rows) {
4919
- const cited = row.citationState === CitationStates.cited;
4920
- if (!perQuery.has(row.queryId) || cited) {
4921
- perQuery.set(row.queryId, cited);
5519
+ for (const snap of snapshots) {
5520
+ const cited = snap.citationState === CitationStates.cited;
5521
+ if (!perQuery.has(snap.queryId) || cited) {
5522
+ perQuery.set(snap.queryId, cited);
4922
5523
  }
4923
- const bucket = perProvider.get(row.provider) ?? { cited: 0, total: 0 };
5524
+ const bucket = perProvider.get(snap.provider) ?? { cited: 0, total: 0 };
4924
5525
  bucket.total += 1;
4925
5526
  if (cited) bucket.cited += 1;
4926
- perProvider.set(row.provider, bucket);
5527
+ perProvider.set(snap.provider, bucket);
4927
5528
  }
4928
5529
  const totalQueries = perQuery.size;
4929
5530
  let citedQueries = 0;
@@ -4943,23 +5544,20 @@ function summarizeLatestRun(app, run) {
4943
5544
  providers
4944
5545
  };
4945
5546
  }
4946
- function summarizeTransitions(app, latest, previous) {
4947
- const empty = { since: null, gained: 0, lost: 0, emerging: 0 };
4948
- if (!latest || !previous) return empty;
4949
- const fetchCited = (runId) => {
4950
- const rows = app.db.select({
4951
- queryId: querySnapshots.queryId,
4952
- citationState: querySnapshots.citationState
4953
- }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
4954
- const map = /* @__PURE__ */ new Map();
4955
- for (const row of rows) {
4956
- const cited = row.citationState === CitationStates.cited;
4957
- if (!map.has(row.queryId) || cited) map.set(row.queryId, cited);
5547
+ function summarizeTransitionsFromSnapshots(latest, previous, since) {
5548
+ if (!since || previous.length === 0) {
5549
+ return { since: null, gained: 0, lost: 0, emerging: 0 };
5550
+ }
5551
+ const buildMap = (snaps) => {
5552
+ const m = /* @__PURE__ */ new Map();
5553
+ for (const s of snaps) {
5554
+ const cited = s.citationState === CitationStates.cited;
5555
+ if (!m.has(s.queryId) || cited) m.set(s.queryId, cited);
4958
5556
  }
4959
- return map;
5557
+ return m;
4960
5558
  };
4961
- const latestMap = fetchCited(latest.id);
4962
- const previousMap = fetchCited(previous.id);
5559
+ const latestMap = buildMap(latest);
5560
+ const previousMap = buildMap(previous);
4963
5561
  let gained = 0;
4964
5562
  let lost = 0;
4965
5563
  let emerging = 0;
@@ -4972,7 +5570,142 @@ function summarizeTransitions(app, latest, previous) {
4972
5570
  if (latestCited && !previousCited) gained += 1;
4973
5571
  else if (!latestCited && previousCited) lost += 1;
4974
5572
  }
4975
- return { since: previous.createdAt, gained, lost, emerging };
5573
+ return { since, gained, lost, emerging };
5574
+ }
5575
+ function buildIndexCoverageScore(app, projectId) {
5576
+ const tooltip = "Percentage of inspected URLs currently indexed. Google Search Console is preferred when available, otherwise Bing Webmaster Tools is used.";
5577
+ const empty = {
5578
+ label: "Index Coverage",
5579
+ value: "No data",
5580
+ delta: "Connect GSC or Bing",
5581
+ tone: "neutral",
5582
+ description: "Connect Google Search Console or Bing Webmaster Tools and inspect your sitemap to populate coverage.",
5583
+ tooltip,
5584
+ trend: []
5585
+ };
5586
+ const gscRow = app.db.select().from(gscCoverageSnapshots).where(eq15(gscCoverageSnapshots.projectId, projectId)).orderBy(desc7(gscCoverageSnapshots.date)).limit(1).get();
5587
+ const bingRow = app.db.select().from(bingCoverageSnapshots).where(eq15(bingCoverageSnapshots.projectId, projectId)).orderBy(desc7(bingCoverageSnapshots.date)).limit(1).get();
5588
+ const chosen = pickIndexCoverageRow(gscRow, bingRow);
5589
+ if (!chosen) return empty;
5590
+ const total = chosen.indexed + chosen.notIndexed;
5591
+ if (total === 0) return empty;
5592
+ const deindexed = chosen.provider === "Google" ? countGoogleDeindexedUrls(app, projectId) : 0;
5593
+ const percentage = chosen.indexed / total * 100;
5594
+ const tone = deindexed > 0 ? "negative" : percentage >= 90 ? "positive" : percentage >= 70 ? "caution" : "negative";
5595
+ const notIndexedLabel = chosen.notIndexed === 1 ? "URL is" : "URLs are";
5596
+ const deindexedLabel = deindexed === 1 ? "URL" : "URLs";
5597
+ return {
5598
+ label: "Index Coverage",
5599
+ value: `${Math.round(percentage)}`,
5600
+ delta: `${chosen.provider} \xB7 ${chosen.indexed} of ${total} indexed`,
5601
+ tone,
5602
+ description: deindexed > 0 ? `${deindexed} deindexed ${deindexedLabel} detected in the latest Google Search Console inspection.` : `${chosen.notIndexed} ${notIndexedLabel} not indexed in ${chosen.provider === "Google" ? "Google Search Console" : "Bing Webmaster Tools"}.`,
5603
+ tooltip,
5604
+ trend: [],
5605
+ progress: Math.round(percentage)
5606
+ };
5607
+ }
5608
+ function countGoogleDeindexedUrls(app, projectId) {
5609
+ const rows = app.db.select({
5610
+ url: gscUrlInspections.url,
5611
+ indexingState: gscUrlInspections.indexingState,
5612
+ inspectedAt: gscUrlInspections.inspectedAt
5613
+ }).from(gscUrlInspections).where(eq15(gscUrlInspections.projectId, projectId)).orderBy(desc7(gscUrlInspections.inspectedAt)).all();
5614
+ if (rows.length === 0) return 0;
5615
+ const canonicalUrl = (url) => url.replace(/^http:\/\//, "https://");
5616
+ const historyByUrl = /* @__PURE__ */ new Map();
5617
+ for (const row of rows) {
5618
+ const key = canonicalUrl(row.url);
5619
+ const list = historyByUrl.get(key);
5620
+ if (list) list.push(row);
5621
+ else historyByUrl.set(key, [row]);
5622
+ }
5623
+ let deindexed = 0;
5624
+ for (const history of historyByUrl.values()) {
5625
+ if (history.length < 2) continue;
5626
+ const latest = history[0];
5627
+ const previous = history[1];
5628
+ if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
5629
+ deindexed++;
5630
+ }
5631
+ }
5632
+ return deindexed;
5633
+ }
5634
+ function pickIndexCoverageRow(gsc, bing) {
5635
+ if (gsc && gsc.indexed + gsc.notIndexed > 0) {
5636
+ return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
5637
+ }
5638
+ if (bing && bing.indexed + bing.notIndexed > 0) {
5639
+ return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
5640
+ }
5641
+ if (gsc) return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
5642
+ if (bing) return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
5643
+ return null;
5644
+ }
5645
+ function buildRunStatusScore(allRuns) {
5646
+ const tooltip = "Current execution state of visibility sweeps. Shows the status of the most recent run and total run count.";
5647
+ if (allRuns.length === 0) {
5648
+ return {
5649
+ label: "Run Status",
5650
+ value: "None",
5651
+ delta: "No runs yet",
5652
+ tone: "neutral",
5653
+ description: "Trigger a visibility sweep to start tracking.",
5654
+ tooltip,
5655
+ trend: []
5656
+ };
5657
+ }
5658
+ const latestVisibility = allRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
5659
+ const latest = latestVisibility ?? allRuns[0];
5660
+ const value = latest.status === RunStatuses.completed ? "Healthy" : latest.status === RunStatuses.running ? "Running" : latest.status === RunStatuses.queued ? "Queued" : latest.status === RunStatuses.partial ? "Partial" : "Failed";
5661
+ const tone = latest.status === RunStatuses.completed ? "positive" : latest.status === RunStatuses.failed ? "negative" : latest.status === RunStatuses.partial ? "caution" : "neutral";
5662
+ const visibilityRunCount = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]).length;
5663
+ const syncRunCount = allRuns.length - visibilityRunCount;
5664
+ const delta = syncRunCount > 0 ? `${visibilityRunCount} visibility \xB7 ${syncRunCount} sync` : `${visibilityRunCount} visibility run${visibilityRunCount === 1 ? "" : "s"}`;
5665
+ return {
5666
+ label: "Run Status",
5667
+ value,
5668
+ delta,
5669
+ tone,
5670
+ description: `Latest run ${value.toLowerCase()}. ${allRuns.length} total run${allRuns.length === 1 ? "" : "s"}.`,
5671
+ tooltip,
5672
+ trend: []
5673
+ };
5674
+ }
5675
+ var ATTENTION_INSIGHT_LIMIT = 5;
5676
+ function buildAttentionItems(insightRows, allRuns) {
5677
+ const items = [];
5678
+ for (const row of insightRows) {
5679
+ if (row.dismissed) continue;
5680
+ if (row.severity !== "critical" && row.severity !== "high") continue;
5681
+ if (items.length >= ATTENTION_INSIGHT_LIMIT) break;
5682
+ items.push({
5683
+ id: `insight_${row.id}`,
5684
+ tone: row.severity === "critical" ? "negative" : "caution",
5685
+ title: row.title,
5686
+ detail: row.query ? `On query: ${row.query}` : "",
5687
+ actionLabel: row.severity === "critical" ? "Critical" : "High",
5688
+ href: `#insight-${row.id}`
5689
+ });
5690
+ }
5691
+ const sortedRuns = [...allRuns].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
5692
+ const latestVisRun = sortedRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
5693
+ const latestSyncRun = sortedRuns.find((r) => r.kind !== RunKinds["answer-visibility"]);
5694
+ if (latestVisRun && latestSyncRun) {
5695
+ const visibilityAge = new Date(latestSyncRun.createdAt).getTime() - new Date(latestVisRun.createdAt).getTime();
5696
+ const ONE_DAY = 24 * 60 * 60 * 1e3;
5697
+ if (visibilityAge > ONE_DAY) {
5698
+ items.push({
5699
+ id: "stale_visibility",
5700
+ tone: "caution",
5701
+ title: "Stale visibility data",
5702
+ detail: `Last visibility sweep was ${latestVisRun.createdAt}; integration syncs have run since.`,
5703
+ actionLabel: "Stale",
5704
+ href: "#runs"
5705
+ });
5706
+ }
5707
+ }
5708
+ return items;
4976
5709
  }
4977
5710
  function mapInsightRow2(r) {
4978
5711
  return {
@@ -5252,6 +5985,12 @@ var locationQueryParameter = {
5252
5985
  description: "Filter by location label. Use an empty value to request locationless results.",
5253
5986
  schema: stringSchema
5254
5987
  };
5988
+ var reportAudienceQueryParameter = {
5989
+ name: "audience",
5990
+ in: "query",
5991
+ description: "HTML report audience mode. Defaults to agency.",
5992
+ schema: { type: "string", enum: ["agency", "client"] }
5993
+ };
5255
5994
  var analyticsWindowParameter = {
5256
5995
  name: "window",
5257
5996
  in: "query",
@@ -7472,9 +8211,9 @@ var routeCatalog = [
7472
8211
  {
7473
8212
  method: "get",
7474
8213
  path: "/api/v1/projects/{name}/report",
7475
- summary: "Aggregated client-facing AEO report",
8214
+ summary: "Aggregated canonical AEO report",
7476
8215
  tags: ["report"],
7477
- description: "Bundles every section the canonry-report HTML output needs (executive summary, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single JSON payload. Backs `canonry report <project>`.",
8216
+ description: "Bundles every section the canonry-report HTML output needs (executive summary, client summary, agency diagnostics, action plan, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single canonical JSON payload. Backs `canonry report <project>` and MCP report reads.",
7478
8217
  parameters: [nameParameter],
7479
8218
  responses: {
7480
8219
  200: { description: "Report returned." },
@@ -7486,8 +8225,8 @@ var routeCatalog = [
7486
8225
  path: "/api/v1/projects/{name}/report.html",
7487
8226
  summary: "Standalone HTML AEO report",
7488
8227
  tags: ["report"],
7489
- description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
7490
- parameters: [nameParameter],
8228
+ description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer in agency or client mode. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-<audience>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
8229
+ parameters: [nameParameter, reportAudienceQueryParameter],
7491
8230
  responses: {
7492
8231
  200: { description: "HTML report returned." },
7493
8232
  404: { description: "Project not found." }
@@ -17299,7 +18038,7 @@ import crypto19 from "crypto";
17299
18038
  import fs7 from "fs";
17300
18039
  import path9 from "path";
17301
18040
  import os4 from "os";
17302
- import { and as and12, eq as eq23, inArray as inArray6, sql as sql7 } from "drizzle-orm";
18041
+ import { and as and12, eq as eq23, inArray as inArray7, sql as sql7 } from "drizzle-orm";
17303
18042
 
17304
18043
  // src/citation-utils.ts
17305
18044
  function domainMatches(domain, canonicalDomain) {
@@ -17556,7 +18295,7 @@ var JobRunner = class {
17556
18295
  this.registry = registry;
17557
18296
  }
17558
18297
  recoverStaleRuns() {
17559
- const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray6(runs.status, ["running", "queued"])).all();
18298
+ const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray7(runs.status, ["running", "queued"])).all();
17560
18299
  if (stale.length === 0) return;
17561
18300
  const now = (/* @__PURE__ */ new Date()).toISOString();
17562
18301
  for (const run of stale) {