@ainyc/canonry 4.2.2 → 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,7 +4,7 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-SR7TGHHG.js";
7
+ } from "./chunk-VDEMEI64.js";
8
8
  import {
9
9
  DEFAULT_RUN_HISTORY_LIMIT,
10
10
  IntelligenceService,
@@ -61,7 +61,7 @@ import {
61
61
  runs,
62
62
  schedules,
63
63
  usageCounters
64
- } from "./chunk-7YSI4GFA.js";
64
+ } from "./chunk-OOADR2Q5.js";
65
65
  import {
66
66
  AGENT_MEMORY_VALUE_MAX_BYTES,
67
67
  AGENT_PROVIDER_IDS,
@@ -95,6 +95,7 @@ import {
95
95
  emptyCitationVisibility,
96
96
  extractAnswerMentions,
97
97
  findDuplicateLocationLabels,
98
+ getProviderLocationHandling,
98
99
  hasLocationLabel,
99
100
  internalError,
100
101
  isAgentProviderId,
@@ -113,6 +114,7 @@ import {
113
114
  providerError,
114
115
  queryGenerateRequestSchema,
115
116
  registrableDomain,
117
+ reportActionTone,
116
118
  resolveConfigSpecQueries,
117
119
  resolveSnapshotRequestQueries,
118
120
  runInProgress,
@@ -127,7 +129,7 @@ import {
127
129
  visibilityStateFromAnswerMentioned,
128
130
  windowCutoff,
129
131
  wordpressEnvSchema
130
- } from "./chunk-T2I6AO7D.js";
132
+ } from "./chunk-XAW66QUX.js";
131
133
 
132
134
  // src/telemetry.ts
133
135
  import crypto from "crypto";
@@ -2746,6 +2748,21 @@ section.report-section .section-intro {
2746
2748
  .finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
2747
2749
  .finding strong { display: block; margin-bottom: 4px; }
2748
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; }
2749
2766
  table.report-table {
2750
2767
  width: 100%;
2751
2768
  border-collapse: collapse;
@@ -2866,6 +2883,77 @@ table.report-table td .badge {
2866
2883
  }
2867
2884
  .step .title { font-weight: 600; }
2868
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}; }
2869
2957
  .footer {
2870
2958
  margin-top: 96px;
2871
2959
  padding-top: 24px;
@@ -2890,15 +2978,68 @@ function section(opts, body) {
2890
2978
  function renderEmpty(message) {
2891
2979
  return `<div class="empty-state">${escapeHtml(message)}</div>`;
2892
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
+ }
2893
3026
  function renderExecutiveSummary(report) {
2894
3027
  const s = report.executiveSummary;
2895
3028
  const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
2896
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";
2897
3033
  const metrics = [
2898
3034
  {
2899
3035
  label: "Citation rate",
2900
3036
  value: `${s.citationRate}%`,
2901
- 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
2902
3043
  },
2903
3044
  {
2904
3045
  label: "Queries tracked",
@@ -2932,14 +3073,15 @@ function renderExecutiveSummary(report) {
2932
3073
  <strong>${escapeHtml(f.title)}</strong>
2933
3074
  <span>${escapeHtml(f.detail)}</span>
2934
3075
  </div>`).join("")}</div>` : "";
3076
+ const locationHtml = renderLocationCard(report);
2935
3077
  return section(
2936
3078
  {
2937
3079
  id: "executive-summary",
2938
3080
  eyebrow: "Section 1",
2939
3081
  title: "Executive Summary",
2940
- 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."
2941
3083
  },
2942
- metricsHtml + findingsHtml
3084
+ metricsHtml + findingsHtml + locationHtml
2943
3085
  );
2944
3086
  }
2945
3087
  function renderProviderBars(rates) {
@@ -2977,16 +3119,16 @@ function renderCitationMatrix(scorecard) {
2977
3119
  const cells = scorecard.providers.map((_, pi) => {
2978
3120
  const cell = scorecard.matrix[qi]?.[pi];
2979
3121
  if (!cell) {
2980
- return '<td><span class="cell-pending">\u2014</span></td>';
2981
- }
2982
- if (cell.citationState === CitationStates.cited) {
2983
- return '<td><span class="cell-cited">Cited</span></td>';
3122
+ return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
2984
3123
  }
2985
- 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>`;
2986
3127
  }).join("");
2987
3128
  return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
2988
3129
  }).join("");
2989
- 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">
2990
3132
  <thead><tr><th>Query</th>${headers}</tr></thead>
2991
3133
  <tbody>${rows}</tbody>
2992
3134
  </table>`;
@@ -2997,7 +3139,7 @@ function renderCitationScorecard(report) {
2997
3139
  ${renderCitationMatrix(report.citationScorecard)}
2998
3140
  `;
2999
3141
  return section(
3000
- { 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." },
3001
3143
  body
3002
3144
  );
3003
3145
  }
@@ -3084,44 +3226,40 @@ function renderCompetitorLandscape(report) {
3084
3226
  `${charts}${table}`
3085
3227
  );
3086
3228
  }
3087
- 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) {
3088
3243
  if (buckets.length === 0) return "";
3089
3244
  const total = buckets.reduce((s, b) => s + b.count, 0);
3090
3245
  if (total === 0) return "";
3091
- const cx = 110;
3092
- const cy = 110;
3093
- const r = 80;
3094
- const innerR = 48;
3095
- let cumulative = 0;
3096
- const slices = [];
3097
- const legend = [];
3098
- buckets.forEach((b, i) => {
3099
- const startAngle = cumulative / total * Math.PI * 2 - Math.PI / 2;
3100
- const endAngle = (cumulative + b.count) / total * Math.PI * 2 - Math.PI / 2;
3101
- cumulative += b.count;
3102
- const x1 = cx + Math.cos(startAngle) * r;
3103
- const y1 = cy + Math.sin(startAngle) * r;
3104
- const x2 = cx + Math.cos(endAngle) * r;
3105
- const y2 = cy + Math.sin(endAngle) * r;
3106
- const ix1 = cx + Math.cos(endAngle) * innerR;
3107
- const iy1 = cy + Math.sin(endAngle) * innerR;
3108
- const ix2 = cx + Math.cos(startAngle) * innerR;
3109
- const iy2 = cy + Math.sin(startAngle) * innerR;
3110
- const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
3111
- const color = COLORS.series[i % COLORS.series.length];
3112
- if (b.count > 0) {
3113
- 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}" />`);
3114
- legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
3115
- }
3116
- });
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("");
3117
3260
  return `<div class="chart-card">
3118
- <h3>AI source categories</h3>
3119
- <div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
3120
- <svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
3121
- ${slices.join("")}
3122
- </svg>
3123
- <div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
3124
- </div>
3261
+ <h3>By source type</h3>
3262
+ <div class="source-bars">${rows}</div>
3125
3263
  </div>`;
3126
3264
  }
3127
3265
  function renderAiSourceOrigin(report) {
@@ -3132,24 +3270,28 @@ function renderAiSourceOrigin(report) {
3132
3270
  renderEmpty("No source data yet. Run a visibility sweep first.")
3133
3271
  );
3134
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>` : "";
3135
3275
  const rows = origin.topDomains.map((d) => `
3136
3276
  <tr>
3137
3277
  <td>${escapeHtml(d.domain)}</td>
3138
3278
  <td class="numeric">${d.count}</td>
3139
- <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>
3140
3280
  </tr>`).join("");
3141
- const table = origin.topDomains.length > 0 ? `<table class="report-table">
3142
- <thead><tr><th>Domain</th><th>Citations</th><th>Tag</th></tr></thead>
3143
- <tbody>${rows}</tbody>
3144
- </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>` : "";
3145
3287
  return section(
3146
3288
  {
3147
3289
  id: "ai-source-origin",
3148
3290
  eyebrow: "Section 4",
3149
3291
  title: "AI Citation Sources",
3150
- 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."
3151
3293
  },
3152
- `${renderDonut(origin.categories)}${table}`
3294
+ `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
3153
3295
  );
3154
3296
  }
3155
3297
  function renderLineChart(points, color, title, height = 200) {
@@ -3442,15 +3584,15 @@ function renderCitationsTrend(report) {
3442
3584
  const rows = trend.map((t) => `
3443
3585
  <tr>
3444
3586
  <td>${formatDate(t.date)}</td>
3445
- <td class="numeric">${t.citationRate}%</td>
3587
+ <td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
3446
3588
  <td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
3447
3589
  </tr>`).join("");
3448
3590
  return section(
3449
- { 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." },
3450
3592
  `${chart}
3451
3593
  <div class="chart-card"><h3>Run-by-run breakdown</h3>
3452
3594
  <table class="report-table">
3453
- <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>
3454
3596
  <tbody>${rows}</tbody>
3455
3597
  </table>
3456
3598
  </div>`
@@ -3489,15 +3631,16 @@ function renderOpportunities(report) {
3489
3631
  if (opps.length === 0) return "";
3490
3632
  const canonical = report.meta.project.canonicalDomain;
3491
3633
  const rows = opps.slice(0, 10).map((o) => {
3492
- 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>';
3493
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>';
3494
3637
  return `<tr>
3495
3638
  <td>${escapeHtml(o.query)}</td>
3496
3639
  <td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
3497
3640
  <td class="numeric">${Math.round(o.score)}</td>
3641
+ <td>${drivers}</td>
3498
3642
  <td>${ourPage}</td>
3499
3643
  <td>${winning}</td>
3500
- <td><span class="badge tone-neutral">${escapeHtml(o.demandSource)}</span></td>
3501
3644
  <td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
3502
3645
  </tr>`;
3503
3646
  }).join("");
@@ -3506,10 +3649,36 @@ function renderOpportunities(report) {
3506
3649
  id: "content-opportunities",
3507
3650
  eyebrow: "Section 12",
3508
3651
  title: "Content Opportunities",
3509
- 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."
3510
3653
  },
3511
3654
  `<table class="report-table">
3512
- <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>
3513
3682
  <tbody>${rows}</tbody>
3514
3683
  </table>`
3515
3684
  );
@@ -3518,7 +3687,7 @@ function renderRecommendedNextSteps(report) {
3518
3687
  const steps = report.recommendedNextSteps;
3519
3688
  if (steps.length === 0) {
3520
3689
  return section(
3521
- { 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." },
3522
3691
  renderEmpty("No outstanding actions.")
3523
3692
  );
3524
3693
  }
@@ -3529,17 +3698,144 @@ function renderRecommendedNextSteps(report) {
3529
3698
  <span class="rationale">${escapeHtml(s.rationale)}</span>
3530
3699
  </div>`).join("");
3531
3700
  return section(
3532
- { 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." },
3533
3702
  `<div class="steps">${items}</div>`
3534
3703
  );
3535
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
+ }
3536
3825
  function escapeJsonForScript(json) {
3537
3826
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3538
3827
  }
3539
3828
  function renderReportHtml(report, opts = {}) {
3540
- const title = opts.title ?? `Canonry report \u2014 ${report.meta.project.displayName}`;
3541
- 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") : [
3542
3836
  renderExecutiveSummary(report),
3837
+ renderAudienceActionPlan(report, "agency"),
3838
+ renderAgencyDiagnostics(report),
3543
3839
  renderCitationScorecard(report),
3544
3840
  renderCompetitorLandscape(report),
3545
3841
  renderAiSourceOrigin(report),
@@ -3551,6 +3847,7 @@ function renderReportHtml(report, opts = {}) {
3551
3847
  renderCitationsTrend(report),
3552
3848
  renderInsights(report),
3553
3849
  renderOpportunities(report),
3850
+ renderContentGaps(report),
3554
3851
  renderRecommendedNextSteps(report)
3555
3852
  ].join("\n");
3556
3853
  const json = escapeJsonForScript(JSON.stringify(report));
@@ -3565,9 +3862,9 @@ function renderReportHtml(report, opts = {}) {
3565
3862
  <body>
3566
3863
  <div class="container">
3567
3864
  <header class="header">
3568
- <div class="eyebrow">AEO Report</div>
3865
+ <div class="eyebrow">${audience === "client" ? "AEO Client Summary" : "AEO Agency Report"}</div>
3569
3866
  <h1>${escapeHtml(report.meta.project.displayName)}</h1>
3570
- <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>
3571
3868
  </header>
3572
3869
  ${sections}
3573
3870
  <footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
@@ -3580,7 +3877,7 @@ function renderReportHtml(report, opts = {}) {
3580
3877
  // ../api-routes/src/content-data.ts
3581
3878
  import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
3582
3879
  var RECENT_RUNS_WINDOW = 5;
3583
- function loadOrchestratorInput(db, project) {
3880
+ function loadOrchestratorInput(db, project, locationFilter = void 0) {
3584
3881
  const projectId = project.id;
3585
3882
  const ownDomain = normalizeDomain(project.canonicalDomain);
3586
3883
  const ownedDomains = parseJsonColumn(project.ownedDomains, []);
@@ -3589,7 +3886,7 @@ function loadOrchestratorInput(db, project) {
3589
3886
  const candidateQueryStrings = trackedQueries.filter(isBlogShapedQuery);
3590
3887
  const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
3591
3888
  const competitorSet = new Set(trackedCompetitors);
3592
- const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
3889
+ const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW, locationFilter);
3593
3890
  const latestRunId = recentRunIds[0] ?? "";
3594
3891
  const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
3595
3892
  const candidateQueries = buildCandidateQueries({
@@ -3631,8 +3928,8 @@ function listCompetitorDomains(db, projectId) {
3631
3928
  const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq12(competitors.projectId, projectId)).all();
3632
3929
  return rows.map((r) => r.domain);
3633
3930
  }
3634
- function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
3635
- 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(
3636
3933
  and3(
3637
3934
  eq12(runs.projectId, projectId),
3638
3935
  eq12(runs.kind, RunKinds["answer-visibility"]),
@@ -3641,8 +3938,9 @@ function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
3641
3938
  // no usable evidence.
3642
3939
  inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
3643
3940
  )
3644
- ).orderBy(desc5(runs.createdAt)).limit(limit).all();
3645
- 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);
3646
3944
  }
3647
3945
  function lookupRunTimestamp(db, runId) {
3648
3946
  const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq12(runs.id, runId)).get();
@@ -4129,27 +4427,33 @@ function buildIndexingHealth(db, projectId) {
4129
4427
  }
4130
4428
  return null;
4131
4429
  }
4132
- function buildCitationsTrend(db, projectId, queryLookup) {
4133
- 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;
4134
4433
  const points = [];
4135
4434
  for (const run of visibilityRuns) {
4136
4435
  if (run.status !== RunStatuses.completed) continue;
4137
4436
  const snaps = loadSnapshotsForRun(db, run.id);
4138
4437
  if (snaps.length === 0) continue;
4139
- let cited = 0;
4438
+ const citedQueryIds = /* @__PURE__ */ new Set();
4439
+ const mentionedQueryIds = /* @__PURE__ */ new Set();
4140
4440
  let considered = 0;
4141
4441
  const providerCounts = /* @__PURE__ */ new Map();
4142
4442
  for (const snap of snaps) {
4143
4443
  if (!queryLookup.byId.has(snap.queryId)) continue;
4144
4444
  considered++;
4145
- if (snap.citationState === CitationStates.cited) cited++;
4445
+ if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
4446
+ if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
4146
4447
  const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
4147
4448
  counts.total++;
4148
4449
  if (snap.citationState === CitationStates.cited) counts.cited++;
4149
4450
  providerCounts.set(snap.provider, counts);
4150
4451
  }
4151
4452
  if (considered === 0) continue;
4152
- 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;
4153
4457
  const providerRates = [...providerCounts.entries()].map(([provider, counts]) => ({
4154
4458
  provider,
4155
4459
  citationRate: counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0
@@ -4158,20 +4462,24 @@ function buildCitationsTrend(db, projectId, queryLookup) {
4158
4462
  runId: run.id,
4159
4463
  date: run.finishedAt ?? run.createdAt,
4160
4464
  citationRate,
4465
+ citedQueryCount,
4466
+ totalQueryCount: totalQueries,
4467
+ mentionRate,
4468
+ mentionedQueryCount,
4161
4469
  providerRates
4162
4470
  });
4163
4471
  }
4164
4472
  points.sort((a, b) => a.date.localeCompare(b.date));
4165
4473
  return points;
4166
4474
  }
4167
- function buildInsightList(db, projectId) {
4168
- 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(
4169
4477
  and4(
4170
4478
  eq13(runs.projectId, projectId),
4171
4479
  eq13(runs.kind, RunKinds["answer-visibility"]),
4172
4480
  or2(eq13(runs.status, RunStatuses.completed), eq13(runs.status, RunStatuses.partial))
4173
4481
  )
4174
- ).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);
4175
4483
  if (recentRunIds.length === 0) return [];
4176
4484
  const rows = db.select().from(insights).where(and4(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
4177
4485
  const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -4243,7 +4551,7 @@ function buildRecommendedNextSteps(insightList) {
4243
4551
  }
4244
4552
  return steps;
4245
4553
  }
4246
- function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
4554
+ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
4247
4555
  const findings = [];
4248
4556
  if (trendsPoints.length > 0) {
4249
4557
  const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
@@ -4266,8 +4574,10 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
4266
4574
  break;
4267
4575
  }
4268
4576
  }
4577
+ const queryNoun = totalQueryCount === 1 ? "query" : "queries";
4578
+ const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
4269
4579
  findings.push({
4270
- title: `Citation rate at ${citationRate}%`,
4580
+ title: `Citation rate at ${citationRate}%${ratioFragment}`,
4271
4581
  detail,
4272
4582
  tone
4273
4583
  });
@@ -4290,6 +4600,328 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
4290
4600
  }
4291
4601
  return findings.slice(0, 5);
4292
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
+ }
4293
4925
  function buildProjectReport(db, projectName) {
4294
4926
  const project = resolveProject(db, projectName);
4295
4927
  const queryLookup = loadQueryLookup(db, project.id);
@@ -4299,6 +4931,7 @@ function buildProjectReport(db, projectName) {
4299
4931
  (r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
4300
4932
  ) ?? visibilityRuns[0];
4301
4933
  const latestSnapshots = latestRun ? loadSnapshotsForRun(db, latestRun.id) : [];
4934
+ const latestRunLocation = latestRun?.location ?? null;
4302
4935
  const competitorRows = db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
4303
4936
  const competitorDomains = competitorRows.map((c) => c.domain);
4304
4937
  const ownedDomains = parseJsonColumn(project.ownedDomains, []);
@@ -4330,9 +4963,9 @@ function buildProjectReport(db, projectName) {
4330
4963
  const socialSection = buildSocialReferrals(db, project.id);
4331
4964
  const aiReferralsSection = buildAiReferrals(db, project.id);
4332
4965
  const indexingHealthSection = buildIndexingHealth(db, project.id);
4333
- const citationsTrend = buildCitationsTrend(db, project.id, queryLookup);
4334
- const insightList = buildInsightList(db, project.id);
4335
- 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);
4336
4969
  const contentOpportunities = buildContentTargetRows(orchestratorInput);
4337
4970
  const contentGaps = buildContentGapRows(orchestratorInput);
4338
4971
  const groundingSources = buildContentSourceRows(orchestratorInput);
@@ -4341,14 +4974,18 @@ function buildProjectReport(db, projectName) {
4341
4974
  contentOpportunities,
4342
4975
  insightDerivedSteps
4343
4976
  );
4344
- let latestCited = 0;
4345
- let latestConsidered = 0;
4977
+ const totalQueryCount = queryLookup.byId.size;
4978
+ const citedQueryIds = /* @__PURE__ */ new Set();
4979
+ const mentionedQueryIds = /* @__PURE__ */ new Set();
4346
4980
  for (const snap of latestSnapshots) {
4347
4981
  if (!queryLookup.byId.has(snap.queryId)) continue;
4348
- latestConsidered++;
4349
- if (snap.citationState === CitationStates.cited) latestCited++;
4982
+ if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
4983
+ if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
4350
4984
  }
4351
- 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;
4352
4989
  const trendBaseline = isTrendBaseline(citationsTrend);
4353
4990
  const latestPoint = citationsTrend.at(-1);
4354
4991
  const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
@@ -4365,6 +5002,8 @@ function buildProjectReport(db, projectName) {
4365
5002
  }
4366
5003
  const findings = buildExecutiveFindings(
4367
5004
  citationRate,
5005
+ citedQueryCount,
5006
+ totalQueryCount,
4368
5007
  trend,
4369
5008
  citationsTrend,
4370
5009
  trendBaseline,
@@ -4373,6 +5012,66 @@ function buildProjectReport(db, projectName) {
4373
5012
  );
4374
5013
  const periodStart = citationsTrend[0]?.date ?? null;
4375
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
+ });
4376
5075
  return {
4377
5076
  meta: {
4378
5077
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -4384,29 +5083,12 @@ function buildProjectReport(db, projectName) {
4384
5083
  country: project.country,
4385
5084
  language: project.language
4386
5085
  },
5086
+ location: reportLocation,
5087
+ providerLocationHandling,
4387
5088
  periodStart,
4388
5089
  periodEnd
4389
5090
  },
4390
- executiveSummary: {
4391
- citationRate,
4392
- trend,
4393
- queryCount: queryLookup.byId.size,
4394
- competitorCount: competitorDomains.length,
4395
- providerCount: citationScorecard.providers.length,
4396
- gsc: gscSection ? {
4397
- clicks: gscSection.totalClicks,
4398
- impressions: gscSection.totalImpressions,
4399
- ctr: gscSection.ctr,
4400
- avgPosition: gscSection.avgPosition
4401
- } : null,
4402
- ga: gaSection ? {
4403
- sessions: gaSection.totalSessions,
4404
- users: gaSection.totalUsers,
4405
- periodStart: gaSection.periodStart,
4406
- periodEnd: gaSection.periodEnd
4407
- } : null,
4408
- findings
4409
- },
5091
+ executiveSummary,
4410
5092
  citationScorecard,
4411
5093
  competitorLandscape,
4412
5094
  mentionLandscape,
@@ -4419,14 +5101,22 @@ function buildProjectReport(db, projectName) {
4419
5101
  citationsTrend,
4420
5102
  insights: insightList,
4421
5103
  recommendedNextSteps,
5104
+ actionPlan,
5105
+ clientSummary,
5106
+ agencyDiagnostics,
4422
5107
  contentOpportunities,
4423
5108
  contentGaps,
4424
5109
  groundingSources
4425
5110
  };
4426
5111
  }
4427
- 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) {
4428
5118
  const date = generatedAt.slice(0, 10);
4429
- return `canonry-report-${project.name}-${date}.html`;
5119
+ return `canonry-report-${project.name}-${audience}-${date}.html`;
4430
5120
  }
4431
5121
  async function reportRoutes(app) {
4432
5122
  app.get("/projects/:name/report", async (request, reply) => {
@@ -4434,9 +5124,10 @@ async function reportRoutes(app) {
4434
5124
  return reply.send(dto);
4435
5125
  });
4436
5126
  app.get("/projects/:name/report.html", async (request, reply) => {
5127
+ const audience = parseReportAudience(request.query.audience);
4437
5128
  const dto = buildProjectReport(app.db, request.params.name);
4438
- const html = renderReportHtml(dto);
4439
- 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);
4440
5131
  reply.header("Content-Type", "text/html; charset=utf-8");
4441
5132
  reply.header("Content-Disposition", `attachment; filename="${filename}"`);
4442
5133
  return reply.send(html);
@@ -5294,6 +5985,12 @@ var locationQueryParameter = {
5294
5985
  description: "Filter by location label. Use an empty value to request locationless results.",
5295
5986
  schema: stringSchema
5296
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
+ };
5297
5994
  var analyticsWindowParameter = {
5298
5995
  name: "window",
5299
5996
  in: "query",
@@ -7514,9 +8211,9 @@ var routeCatalog = [
7514
8211
  {
7515
8212
  method: "get",
7516
8213
  path: "/api/v1/projects/{name}/report",
7517
- summary: "Aggregated client-facing AEO report",
8214
+ summary: "Aggregated canonical AEO report",
7518
8215
  tags: ["report"],
7519
- 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.",
7520
8217
  parameters: [nameParameter],
7521
8218
  responses: {
7522
8219
  200: { description: "Report returned." },
@@ -7528,8 +8225,8 @@ var routeCatalog = [
7528
8225
  path: "/api/v1/projects/{name}/report.html",
7529
8226
  summary: "Standalone HTML AEO report",
7530
8227
  tags: ["report"],
7531
- 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.",
7532
- 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],
7533
8230
  responses: {
7534
8231
  200: { description: "HTML report returned." },
7535
8232
  404: { description: "Project not found." }