@ainyc/canonry 4.71.1 → 4.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/assets/agent-workspace/skills/aero/references/regression-playbook.md +1 -1
  2. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +7 -0
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +20 -2
  4. package/assets/assets/{BacklinksPage-CQNPYiDA.js → BacklinksPage-CjfpwZEH.js} +1 -1
  5. package/assets/assets/{ChartPrimitives-BShpLrpS.js → ChartPrimitives-Ckf2FrUy.js} +1 -1
  6. package/assets/assets/{ProjectPage-CJLw1m4O.js → ProjectPage-DZeplYeC.js} +6 -6
  7. package/assets/assets/{RunRow-Dq1vs1hA.js → RunRow-BuFyG0V_.js} +1 -1
  8. package/assets/assets/{RunsPage-CBMa2xWh.js → RunsPage-D-pr000K.js} +1 -1
  9. package/assets/assets/{SettingsPage-B_XeJDdg.js → SettingsPage-CiaapCYn.js} +1 -1
  10. package/assets/assets/{TrafficPage-vJv_Mf6f.js → TrafficPage-B40xytJD.js} +1 -1
  11. package/assets/assets/{TrafficSourceDetailPage-C3yFwVmQ.js → TrafficSourceDetailPage-7hHem-gM.js} +1 -1
  12. package/assets/assets/{extract-error-message-CIpeBFLl.js → extract-error-message-3GkDsu1h.js} +1 -1
  13. package/assets/assets/{index-BXLM3-cs.js → index-BVdH2O9w.js} +77 -77
  14. package/assets/assets/{server-traffic-Yt3jIi3g.js → server-traffic-CsgPsudZ.js} +1 -1
  15. package/assets/assets/{trash-2-xGvNHhEj.js → trash-2-B8Ipf9rI.js} +1 -1
  16. package/assets/index.html +1 -1
  17. package/dist/{chunk-ZNWMVYYU.js → chunk-NYZSY5QJ.js} +126 -7
  18. package/dist/{chunk-5FM7QRYD.js → chunk-SJI6JGPN.js} +1249 -1005
  19. package/dist/{chunk-ETJDAMGA.js → chunk-XYX447L2.js} +613 -87
  20. package/dist/{chunk-CWEV3YMZ.js → chunk-ZISXWFQA.js} +92 -4
  21. package/dist/cli.js +306 -84
  22. package/dist/index.js +4 -4
  23. package/dist/{intelligence-service-ISO4VGEC.js → intelligence-service-YOZOOYUI.js} +2 -2
  24. package/dist/mcp.js +2 -2
  25. package/package.json +8 -8
@@ -28,6 +28,7 @@ import {
28
28
  TrafficSourceStatuses,
29
29
  TrafficSourceTypes,
30
30
  VerificationStatuses,
31
+ WinnabilityClasses,
31
32
  __export,
32
33
  absolutizeProjectUrl,
33
34
  actionConfidenceLabel,
@@ -65,6 +66,7 @@ import {
65
66
  citationStateToCited,
66
67
  citationVisibilityResponseSchema,
67
68
  classifySkillFile,
69
+ classifySurfaceFromCategory,
68
70
  clusterByCosine,
69
71
  coerceSkillManifest,
70
72
  competitorBatchRequestSchema,
@@ -83,6 +85,7 @@ import {
83
85
  deliveryFailed,
84
86
  deltaPercent,
85
87
  deltaTone,
88
+ deriveWinnabilityClass,
86
89
  determineAnswerMentioned,
87
90
  discoveryBucketSchema,
88
91
  discoveryPromotePreviewSchema,
@@ -92,6 +95,7 @@ import {
92
95
  discoverySessionDetailDtoSchema,
93
96
  discoverySessionDtoSchema,
94
97
  doctorReportSchema,
98
+ domainClassificationsResponseDtoSchema,
95
99
  effectiveBrandNames,
96
100
  effectiveDomains,
97
101
  emptyCitationVisibility,
@@ -158,6 +162,7 @@ import {
158
162
  queryDtoSchema,
159
163
  queryGenerateRequestSchema,
160
164
  quotaExceeded,
165
+ recommendationBriefDtoSchema,
161
166
  recommendationExplainRequestSchema,
162
167
  recommendationExplanationDtoSchema,
163
168
  registrableDomain,
@@ -184,7 +189,10 @@ import {
184
189
  snapshotListResponseSchema,
185
190
  snapshotReportSchema,
186
191
  snapshotRequestSchema,
192
+ sourceBreakdownDtoSchema,
187
193
  summarizeCheckResults,
194
+ surfaceClassFromCompetitorType,
195
+ surfaceClassLabel,
188
196
  trafficBackfillResponseSchema,
189
197
  trafficConnectVercelRequestSchema,
190
198
  trafficConnectWordpressRequestSchema,
@@ -199,6 +207,8 @@ import {
199
207
  validationError,
200
208
  visibilityStateFromAnswerMentioned,
201
209
  windowCutoff,
210
+ winnabilityClassLabel,
211
+ winnabilityClassSchema,
202
212
  withRetry,
203
213
  wordpressAuditPageDtoSchema,
204
214
  wordpressBulkMetaResultDtoSchema,
@@ -212,10 +222,10 @@ import {
212
222
  wordpressSchemaDeployResultDtoSchema,
213
223
  wordpressSchemaStatusResultDtoSchema,
214
224
  wordpressStatusDtoSchema
215
- } from "./chunk-5FM7QRYD.js";
225
+ } from "./chunk-SJI6JGPN.js";
216
226
 
217
227
  // src/intelligence-service.ts
218
- import { eq as eq31, desc as desc16, asc as asc3, and as and23, ne as ne5, or as or5, inArray as inArray11, gte as gte6, lte as lte3 } from "drizzle-orm";
228
+ import { eq as eq32, desc as desc16, asc as asc3, and as and23, ne as ne5, or as or5, inArray as inArray11, gte as gte6, lte as lte3 } from "drizzle-orm";
219
229
 
220
230
  // ../db/src/client.ts
221
231
  import { mkdirSync } from "fs";
@@ -244,6 +254,7 @@ __export(schema_exports, {
244
254
  crawlerEventsHourly: () => crawlerEventsHourly,
245
255
  discoveryProbes: () => discoveryProbes,
246
256
  discoverySessions: () => discoverySessions,
257
+ domainClassifications: () => domainClassifications,
247
258
  gaAiReferrals: () => gaAiReferrals,
248
259
  gaConnections: () => gaConnections,
249
260
  gaSocialReferrals: () => gaSocialReferrals,
@@ -269,6 +280,7 @@ __export(schema_exports, {
269
280
  queries: () => queries,
270
281
  querySnapshots: () => querySnapshots,
271
282
  rawEventSamples: () => rawEventSamples,
283
+ recommendationBriefs: () => recommendationBriefs,
272
284
  recommendationExplanations: () => recommendationExplanations,
273
285
  runs: () => runs,
274
286
  schedules: () => schedules,
@@ -981,6 +993,20 @@ var discoveryProbes = sqliteTable("discovery_probes", {
981
993
  index("idx_discovery_probes_session").on(table.sessionId),
982
994
  index("idx_discovery_probes_project").on(table.projectId)
983
995
  ]);
996
+ var domainClassifications = sqliteTable("domain_classifications", {
997
+ id: text("id").primaryKey(),
998
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
999
+ domain: text("domain").notNull(),
1000
+ competitorType: text("competitor_type").$type().notNull(),
1001
+ /** Recurrence count from the latest classifying session; informational. */
1002
+ hits: integer("hits").notNull().default(0),
1003
+ /** Discovery session that produced the latest classification. */
1004
+ sessionId: text("session_id"),
1005
+ updatedAt: text("updated_at").notNull()
1006
+ }, (table) => [
1007
+ uniqueIndex("idx_domain_classifications_project_domain").on(table.projectId, table.domain),
1008
+ index("idx_domain_classifications_project").on(table.projectId)
1009
+ ]);
984
1010
  var contentTargetDismissals = sqliteTable("content_target_dismissals", {
985
1011
  id: text("id").primaryKey(),
986
1012
  projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
@@ -1011,6 +1037,26 @@ var recommendationExplanations = sqliteTable("recommendation_explanations", {
1011
1037
  ),
1012
1038
  index("idx_recommendation_explanations_project").on(table.projectId)
1013
1039
  ]);
1040
+ var recommendationBriefs = sqliteTable("recommendation_briefs", {
1041
+ id: text("id").primaryKey(),
1042
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
1043
+ targetRef: text("target_ref").notNull(),
1044
+ promptVersion: text("prompt_version").notNull(),
1045
+ provider: text("provider").notNull(),
1046
+ model: text("model").notNull(),
1047
+ /** The structured brief payload (angle, why-winnable, schema hookup, etc.). */
1048
+ brief: text("brief", { mode: "json" }).$type().notNull(),
1049
+ /** Estimated cost in millicents (1/100 of a cent) for audit; 0 if unknown. */
1050
+ costMillicents: integer("cost_millicents").notNull().default(0),
1051
+ generatedAt: text("generated_at").notNull()
1052
+ }, (table) => [
1053
+ uniqueIndex("idx_recommendation_briefs_unique").on(
1054
+ table.projectId,
1055
+ table.targetRef,
1056
+ table.promptVersion
1057
+ ),
1058
+ index("idx_recommendation_briefs_project").on(table.projectId)
1059
+ ]);
1014
1060
  var migrationsTable = sqliteTable("_migrations", {
1015
1061
  version: integer("version").primaryKey(),
1016
1062
  name: text("name").notNull(),
@@ -2690,6 +2736,49 @@ var MIGRATION_VERSIONS = [
2690
2736
  )`,
2691
2737
  `CREATE INDEX IF NOT EXISTS idx_gbp_place_details_loc ON gbp_place_details(project_id, location_name, synced_at)`
2692
2738
  ]
2739
+ },
2740
+ {
2741
+ // Durable per-domain classification of cited surfaces, upserted on each
2742
+ // discovery completion. Powers the content-targets winnabilityClass winnability
2743
+ // gate without re-running a discovery probe. Keyed (project_id, domain).
2744
+ version: 73,
2745
+ name: "domain-classifications",
2746
+ statements: [
2747
+ `CREATE TABLE IF NOT EXISTS domain_classifications (
2748
+ id TEXT PRIMARY KEY,
2749
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
2750
+ domain TEXT NOT NULL,
2751
+ competitor_type TEXT NOT NULL,
2752
+ hits INTEGER NOT NULL DEFAULT 0,
2753
+ session_id TEXT,
2754
+ updated_at TEXT NOT NULL
2755
+ )`,
2756
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_classifications_project_domain ON domain_classifications(project_id, domain)`,
2757
+ `CREATE INDEX IF NOT EXISTS idx_domain_classifications_project ON domain_classifications(project_id)`
2758
+ ]
2759
+ },
2760
+ {
2761
+ // Structured LLM content briefs, cached per (project, target_ref,
2762
+ // prompt_version). Separate from recommendation_explanations so the
2763
+ // structured brief payload and its version-keyed cache never collide with
2764
+ // the prompt-version-blind explanation lookup.
2765
+ version: 74,
2766
+ name: "recommendation-briefs",
2767
+ statements: [
2768
+ `CREATE TABLE IF NOT EXISTS recommendation_briefs (
2769
+ id TEXT PRIMARY KEY,
2770
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
2771
+ target_ref TEXT NOT NULL,
2772
+ prompt_version TEXT NOT NULL,
2773
+ provider TEXT NOT NULL,
2774
+ model TEXT NOT NULL,
2775
+ brief TEXT NOT NULL,
2776
+ cost_millicents INTEGER NOT NULL DEFAULT 0,
2777
+ generated_at TEXT NOT NULL
2778
+ )`,
2779
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_recommendation_briefs_unique ON recommendation_briefs(project_id, target_ref, prompt_version)`,
2780
+ `CREATE INDEX IF NOT EXISTS idx_recommendation_briefs_project ON recommendation_briefs(project_id)`
2781
+ ]
2693
2782
  }
2694
2783
  ];
2695
2784
  function isDuplicateColumnError(err) {
@@ -3547,6 +3636,7 @@ function buildContentTargetRows(input) {
3547
3636
  gscAvgPosition: cq.gscPosition,
3548
3637
  organicSessions: input.gaTrafficByPage.get(ourPage.url) ?? 0
3549
3638
  } : null;
3639
+ const { winnabilityClass, winnability } = deriveWinnabilityClass(cq.citedSurfaceDomains, input.domainClasses);
3550
3640
  rows.push({
3551
3641
  targetRef,
3552
3642
  query: cq.query,
@@ -3558,7 +3648,9 @@ function buildContentTargetRows(input) {
3558
3648
  drivers: scoring.drivers,
3559
3649
  demandSource: scoring.demandSource,
3560
3650
  actionConfidence,
3561
- existingAction: input.inProgressActions.get(targetRef) ?? null
3651
+ existingAction: input.inProgressActions.get(targetRef) ?? null,
3652
+ winnabilityClass,
3653
+ winnability
3562
3654
  });
3563
3655
  }
3564
3656
  return dedupeByIntent(
@@ -7173,9 +7265,32 @@ async function analyticsRoutes(app) {
7173
7265
  const project = resolveProject(app.db, request.params.name);
7174
7266
  const window = parseWindow(request.query.window);
7175
7267
  const cutoff = windowCutoff(window);
7268
+ let limit = null;
7269
+ if (request.query.limit !== void 0) {
7270
+ const n = Number(request.query.limit);
7271
+ if (!Number.isInteger(n) || n <= 0) throw validationError('"limit" must be a positive integer');
7272
+ limit = n;
7273
+ }
7274
+ const classifyCtx = {
7275
+ projectDomains: effectiveDomains(project),
7276
+ competitorDomains: app.db.select({ domain: competitors.domain }).from(competitors).where(eq10(competitors.projectId, project.id)).all().map((r) => r.domain)
7277
+ };
7278
+ const storedSurfaceClasses = /* @__PURE__ */ new Map();
7279
+ for (const row of app.db.select({ domain: domainClassifications.domain, competitorType: domainClassifications.competitorType }).from(domainClassifications).where(eq10(domainClassifications.projectId, project.id)).all()) {
7280
+ const mapped = surfaceClassFromCompetitorType(row.competitorType);
7281
+ if (mapped) storedSurfaceClasses.set(normalizeProjectDomain(row.domain), mapped);
7282
+ }
7176
7283
  const windowRuns = app.db.select().from(runs).where(and5(eq10(runs.projectId, project.id), notProbeRun())).orderBy(desc3(runs.createdAt), desc3(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
7177
7284
  if (windowRuns.length === 0) {
7178
- return reply.send({ overall: [], byQuery: {}, runId: "", window });
7285
+ return reply.send({
7286
+ overall: [],
7287
+ byQuery: {},
7288
+ ranked: buildRankedList(/* @__PURE__ */ new Map(), limit),
7289
+ byProvider: {},
7290
+ runId: "",
7291
+ window,
7292
+ limit
7293
+ });
7179
7294
  }
7180
7295
  const latestGroup = groupRunsByCreatedAt(windowRuns)[0] ?? [];
7181
7296
  const latestRunId = pickGroupRepresentative(latestGroup)?.id ?? windowRuns[0].id;
@@ -7183,28 +7298,49 @@ async function analyticsRoutes(app) {
7183
7298
  const snapshots = app.db.select({
7184
7299
  queryId: querySnapshots.queryId,
7185
7300
  query: queries.query,
7301
+ provider: querySnapshots.provider,
7186
7302
  rawResponse: querySnapshots.rawResponse
7187
7303
  }).from(querySnapshots).leftJoin(queries, eq10(querySnapshots.queryId, queries.id)).where(inArray3(querySnapshots.runId, windowRunIds)).all();
7188
7304
  const overallCounts = /* @__PURE__ */ new Map();
7189
7305
  const byQuery = {};
7306
+ const overallDomains = /* @__PURE__ */ new Map();
7307
+ const providerDomains = /* @__PURE__ */ new Map();
7190
7308
  for (const snap of snapshots) {
7191
7309
  const sources = parseGroundingSources(snap.rawResponse);
7192
7310
  const qCounts = /* @__PURE__ */ new Map();
7193
7311
  for (const source of sources) {
7194
- const { category, domain } = categorizeSource(source.uri);
7312
+ const { category, label, domain } = categorizeSource(source.uri);
7313
+ const surfaceClass = classifySurfaceFromCategory(
7314
+ domain,
7315
+ category,
7316
+ classifyCtx,
7317
+ storedSurfaceClasses.get(normalizeProjectDomain(domain))
7318
+ );
7195
7319
  if (!overallCounts.has(category)) overallCounts.set(category, /* @__PURE__ */ new Map());
7196
7320
  const oDomains = overallCounts.get(category);
7197
7321
  oDomains.set(domain, (oDomains.get(domain) ?? 0) + 1);
7198
7322
  if (!qCounts.has(category)) qCounts.set(category, /* @__PURE__ */ new Map());
7199
7323
  const qDomains = qCounts.get(category);
7200
7324
  qDomains.set(domain, (qDomains.get(domain) ?? 0) + 1);
7325
+ bumpDomain(overallDomains, domain, category, label, surfaceClass);
7326
+ let pm = providerDomains.get(snap.provider);
7327
+ if (!pm) {
7328
+ pm = /* @__PURE__ */ new Map();
7329
+ providerDomains.set(snap.provider, pm);
7330
+ }
7331
+ bumpDomain(pm, domain, category, label, surfaceClass);
7201
7332
  }
7202
7333
  if (sources.length > 0 && snap.query) {
7203
7334
  byQuery[snap.query] = buildCategoryCounts(qCounts);
7204
7335
  }
7205
7336
  }
7206
7337
  const overall = buildCategoryCounts(overallCounts);
7207
- return reply.send({ overall, byQuery, runId: latestRunId, window });
7338
+ const ranked = buildRankedList(overallDomains, limit);
7339
+ const byProvider = {};
7340
+ for (const [provider, domains] of providerDomains) {
7341
+ byProvider[provider] = buildRankedList(domains, limit);
7342
+ }
7343
+ return reply.send({ overall, byQuery, ranked, byProvider, runId: latestRunId, window, limit });
7208
7344
  });
7209
7345
  }
7210
7346
  function isProviderInfraDomain(uri) {
@@ -7315,6 +7451,52 @@ function computeTrend(buckets, rateKey) {
7315
7451
  if (diff < -0.05) return "declining";
7316
7452
  return "stable";
7317
7453
  }
7454
+ function round4(ratio) {
7455
+ return Math.round(ratio * 1e4) / 1e4;
7456
+ }
7457
+ function bumpDomain(map, domain, category, label, surfaceClass) {
7458
+ const existing = map.get(domain);
7459
+ if (existing) existing.count += 1;
7460
+ else map.set(domain, { domain, count: 1, category, label, surfaceClass });
7461
+ }
7462
+ function buildRankedList(domains, limit) {
7463
+ const all = [...domains.values()];
7464
+ const totalCitedSlots = all.reduce((sum, d) => sum + d.count, 0);
7465
+ const domainTotal = all.length;
7466
+ all.sort((a, b) => b.count - a.count || a.domain.localeCompare(b.domain));
7467
+ const shownEntries = limit != null && limit < all.length ? all.slice(0, limit) : all;
7468
+ const entries = shownEntries.map((d) => ({
7469
+ domain: d.domain,
7470
+ count: d.count,
7471
+ percentage: totalCitedSlots > 0 ? round4(d.count / totalCitedSlots) : 0,
7472
+ category: d.category,
7473
+ label: d.label,
7474
+ surfaceClass: d.surfaceClass
7475
+ }));
7476
+ const shownSlots = shownEntries.reduce((sum, d) => sum + d.count, 0);
7477
+ const classAgg = /* @__PURE__ */ new Map();
7478
+ for (const d of all) {
7479
+ const entry = classAgg.get(d.surfaceClass) ?? { count: 0, domainCount: 0 };
7480
+ entry.count += d.count;
7481
+ entry.domainCount += 1;
7482
+ classAgg.set(d.surfaceClass, entry);
7483
+ }
7484
+ const bySurfaceClass = [...classAgg.entries()].map(([surfaceClass, v]) => ({
7485
+ surfaceClass,
7486
+ label: surfaceClassLabel(surfaceClass),
7487
+ count: v.count,
7488
+ percentage: totalCitedSlots > 0 ? round4(v.count / totalCitedSlots) : 0,
7489
+ domainCount: v.domainCount
7490
+ })).sort((a, b) => b.count - a.count || a.surfaceClass.localeCompare(b.surfaceClass));
7491
+ return {
7492
+ totalCitedSlots,
7493
+ domainTotal,
7494
+ entries,
7495
+ truncatedDomainCount: domainTotal - shownEntries.length,
7496
+ truncatedCitedSlots: totalCitedSlots - shownSlots,
7497
+ bySurfaceClass
7498
+ };
7499
+ }
7318
7500
  function buildCategoryCounts(counts) {
7319
7501
  let grandTotal = 0;
7320
7502
  for (const domains of counts.values()) {
@@ -7528,6 +7710,7 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
7528
7710
  });
7529
7711
  const gaTrafficByPage = buildGaTrafficByPage(db, projectId);
7530
7712
  const totalAiReferralSessions = sumAiReferralSessions(db, projectId);
7713
+ const domainClasses = loadDomainClasses(db, projectId);
7531
7714
  return {
7532
7715
  projectId,
7533
7716
  ownDomain,
@@ -7540,9 +7723,14 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
7540
7723
  totalAiReferralSessions,
7541
7724
  latestRunId,
7542
7725
  latestRunTimestamp,
7543
- inProgressActions: /* @__PURE__ */ new Map()
7726
+ inProgressActions: /* @__PURE__ */ new Map(),
7727
+ domainClasses
7544
7728
  };
7545
7729
  }
7730
+ function loadDomainClasses(db, projectId) {
7731
+ const rows = db.select({ domain: domainClassifications.domain, competitorType: domainClassifications.competitorType }).from(domainClassifications).where(eq12(domainClassifications.projectId, projectId)).all();
7732
+ return new Map(rows.map((r) => [normalizeDomain(r.domain), r.competitorType]));
7733
+ }
7546
7734
  function buildQueryIntentModifiers(project, locationFilter) {
7547
7735
  if (locationFilter === void 0 || locationFilter === null) return [];
7548
7736
  const locations = project.locations ?? [];
@@ -7749,6 +7937,7 @@ function aggregateCandidate(opts) {
7749
7937
  const competitorTally = /* @__PURE__ */ new Map();
7750
7938
  const competitorGroundingTally = /* @__PURE__ */ new Map();
7751
7939
  const ourGroundingTally = /* @__PURE__ */ new Map();
7940
+ const citedSurfaceTally = /* @__PURE__ */ new Map();
7752
7941
  let ourCitedInLatestRun = false;
7753
7942
  for (const snap of opts.snapshots) {
7754
7943
  const isLatestRun = snap.runId === opts.latestRunId;
@@ -7767,6 +7956,7 @@ function aggregateCandidate(opts) {
7767
7956
  recordGroundingHit(ourGroundingTally, g, domain, snap.provider);
7768
7957
  continue;
7769
7958
  }
7959
+ citedSurfaceTally.set(domain, (citedSurfaceTally.get(domain) ?? 0) + 1);
7770
7960
  if (!opts.competitorSet.has(domain)) continue;
7771
7961
  recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
7772
7962
  }
@@ -7785,6 +7975,7 @@ function aggregateCandidate(opts) {
7785
7975
  recentMissRate,
7786
7976
  ourGroundingUrls: Array.from(ourGroundingTally.values()),
7787
7977
  competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
7978
+ citedSurfaceDomains: Array.from(citedSurfaceTally.entries()).map(([domain, citationCount]) => ({ domain, citationCount })),
7788
7979
  runsOfHistory: new Set(opts.snapshots.map((s) => s.runId)).size
7789
7980
  };
7790
7981
  }
@@ -7820,6 +8011,7 @@ function emptyCandidate(query) {
7820
8011
  recentMissRate: 0,
7821
8012
  ourGroundingUrls: [],
7822
8013
  competitorGroundingUrls: [],
8014
+ citedSurfaceDomains: [],
7823
8015
  runsOfHistory: 0
7824
8016
  };
7825
8017
  }
@@ -7886,6 +8078,17 @@ function formatExplanationRow(row) {
7886
8078
  generatedAt: row.generatedAt
7887
8079
  };
7888
8080
  }
8081
+ function formatBriefRow(row) {
8082
+ return {
8083
+ targetRef: row.targetRef,
8084
+ promptVersion: row.promptVersion,
8085
+ provider: row.provider,
8086
+ model: row.model,
8087
+ brief: row.brief,
8088
+ costMillicents: row.costMillicents,
8089
+ generatedAt: row.generatedAt
8090
+ };
8091
+ }
7889
8092
  function findRecommendationByRef(db, project, targetRef) {
7890
8093
  const input = loadOrchestratorInput(db, project);
7891
8094
  const rows = buildContentTargetRows(input);
@@ -7896,6 +8099,10 @@ async function contentRoutes(app, opts = {}) {
7896
8099
  const project = resolveProject(app.db, request.params.name);
7897
8100
  const includeInProgress = request.query["include-in-progress"] === "true";
7898
8101
  const limit = parseLimitParam(request.query.limit);
8102
+ if (request.query["surface-class"] !== void 0) {
8103
+ throw validationError('"surface-class" was renamed to "winnability-class"');
8104
+ }
8105
+ const winnabilityClassFilter = parseWinnabilityClassFilter(request.query["winnability-class"], request.query.ownable);
7899
8106
  const input = loadOrchestratorInput(app.db, project);
7900
8107
  let rows = buildContentTargetRows(input);
7901
8108
  if (!includeInProgress) {
@@ -7905,6 +8112,10 @@ async function contentRoutes(app, opts = {}) {
7905
8112
  if (dismissed.size > 0) {
7906
8113
  rows = rows.filter((r) => !dismissed.has(r.targetRef));
7907
8114
  }
8115
+ if (winnabilityClassFilter) {
8116
+ rows = rows.filter((r) => r.winnabilityClass === winnabilityClassFilter);
8117
+ }
8118
+ rows = [...rows].sort((a, b) => winnabilityClassRank(a.winnabilityClass) - winnabilityClassRank(b.winnabilityClass));
7908
8119
  if (limit !== void 0) {
7909
8120
  rows = rows.slice(0, limit);
7910
8121
  }
@@ -8065,6 +8276,113 @@ async function contentRoutes(app, opts = {}) {
8065
8276
  if (!row) throw notFound("recommendationExplanation", targetRef);
8066
8277
  return reply.send(formatExplanationRow(row));
8067
8278
  });
8279
+ app.get("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
8280
+ const project = resolveProject(app.db, request.params.name);
8281
+ const { targetRef } = request.params;
8282
+ const recommendation = findRecommendationByRef(app.db, project, targetRef);
8283
+ if (!recommendation) {
8284
+ throw notFound("contentRecommendation", targetRef);
8285
+ }
8286
+ if (recommendation.winnabilityClass === WinnabilityClasses.ceded) {
8287
+ throw validationError(
8288
+ `Cannot return a brief for "${recommendation.query}": its cited surface is now ceded (dominated by aggregators/editorial).`
8289
+ );
8290
+ }
8291
+ const row = lookupCachedBrief(app.db, project.id, targetRef, opts.briefPromptVersion);
8292
+ if (!row) throw notFound("recommendationBrief", request.params.targetRef);
8293
+ return reply.send(formatBriefRow(row));
8294
+ });
8295
+ app.post("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
8296
+ const project = resolveProject(app.db, request.params.name);
8297
+ const synthesizer = opts.briefContentRecommendation;
8298
+ if (!synthesizer) {
8299
+ throw providerError(
8300
+ "No AI provider configured for content briefs. Configure a provider via `canonry settings` or set an API key env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, ZAI_API_KEY)."
8301
+ );
8302
+ }
8303
+ const parsed = recommendationExplainRequestSchema.safeParse(request.body ?? {});
8304
+ if (!parsed.success) {
8305
+ throw validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
8306
+ }
8307
+ const body = parsed.data;
8308
+ const { targetRef } = request.params;
8309
+ const recommendation = findRecommendationByRef(app.db, project, targetRef);
8310
+ if (!recommendation) {
8311
+ throw notFound("contentRecommendation", targetRef);
8312
+ }
8313
+ if (recommendation.winnabilityClass === WinnabilityClasses.ceded) {
8314
+ throw validationError(
8315
+ `Cannot synthesize a brief for "${recommendation.query}": its cited surface is ceded (dominated by aggregators/editorial). This is not a query first-party content can realistically win.`
8316
+ );
8317
+ }
8318
+ if (!body.forceRefresh) {
8319
+ const cached = lookupCachedBrief(app.db, project.id, targetRef, opts.briefPromptVersion);
8320
+ if (cached) return reply.send(formatBriefRow(cached));
8321
+ }
8322
+ const result = await synthesizer({
8323
+ projectId: project.id,
8324
+ projectName: project.name,
8325
+ canonicalDomain: project.canonicalDomain,
8326
+ recommendation,
8327
+ providerOverride: body.provider,
8328
+ modelOverride: body.model
8329
+ });
8330
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8331
+ app.db.insert(recommendationBriefs).values({
8332
+ id: crypto11.randomUUID(),
8333
+ projectId: project.id,
8334
+ targetRef,
8335
+ promptVersion: result.promptVersion,
8336
+ provider: result.provider,
8337
+ model: result.model,
8338
+ brief: result.brief,
8339
+ costMillicents: result.costMillicents,
8340
+ generatedAt: now
8341
+ }).onConflictDoUpdate({
8342
+ target: [
8343
+ recommendationBriefs.projectId,
8344
+ recommendationBriefs.targetRef,
8345
+ recommendationBriefs.promptVersion
8346
+ ],
8347
+ set: {
8348
+ provider: result.provider,
8349
+ model: result.model,
8350
+ brief: result.brief,
8351
+ costMillicents: result.costMillicents,
8352
+ generatedAt: now
8353
+ }
8354
+ }).run();
8355
+ const row = app.db.select().from(recommendationBriefs).where(and8(
8356
+ eq13(recommendationBriefs.projectId, project.id),
8357
+ eq13(recommendationBriefs.targetRef, targetRef),
8358
+ eq13(recommendationBriefs.promptVersion, result.promptVersion)
8359
+ )).get();
8360
+ if (!row) throw notFound("recommendationBrief", targetRef);
8361
+ return reply.send(formatBriefRow(row));
8362
+ });
8363
+ app.get("/projects/:name/content/domain-classifications", async (request) => {
8364
+ const project = resolveProject(app.db, request.params.name);
8365
+ const rows = app.db.select().from(domainClassifications).where(eq13(domainClassifications.projectId, project.id)).orderBy(desc6(domainClassifications.hits)).all();
8366
+ const response = {
8367
+ classifications: rows.map((r) => ({
8368
+ domain: r.domain,
8369
+ competitorType: r.competitorType,
8370
+ hits: r.hits,
8371
+ updatedAt: r.updatedAt
8372
+ }))
8373
+ };
8374
+ return response;
8375
+ });
8376
+ }
8377
+ function lookupCachedBrief(db, projectId, targetRef, promptVersion) {
8378
+ const conditions = [
8379
+ eq13(recommendationBriefs.projectId, projectId),
8380
+ eq13(recommendationBriefs.targetRef, targetRef)
8381
+ ];
8382
+ if (promptVersion !== void 0) {
8383
+ conditions.push(eq13(recommendationBriefs.promptVersion, promptVersion));
8384
+ }
8385
+ return db.select().from(recommendationBriefs).where(and8(...conditions)).orderBy(desc6(recommendationBriefs.generatedAt)).limit(1).get();
8068
8386
  }
8069
8387
  function parseLimitParam(raw) {
8070
8388
  if (raw === void 0) return void 0;
@@ -8074,6 +8392,20 @@ function parseLimitParam(raw) {
8074
8392
  }
8075
8393
  return parsed;
8076
8394
  }
8395
+ function parseWinnabilityClassFilter(raw, ownable) {
8396
+ if (raw !== void 0) {
8397
+ const parsed = winnabilityClassSchema.safeParse(raw);
8398
+ if (!parsed.success) {
8399
+ throw validationError('"winnability-class" must be "ownable" or "ceded"');
8400
+ }
8401
+ return parsed.data;
8402
+ }
8403
+ if (ownable === "true") return WinnabilityClasses.ownable;
8404
+ return void 0;
8405
+ }
8406
+ function winnabilityClassRank(winnabilityClass) {
8407
+ return winnabilityClass === WinnabilityClasses.ownable ? 0 : 1;
8408
+ }
8077
8409
 
8078
8410
  // ../api-routes/src/report-renderer.ts
8079
8411
  var COLORS = {
@@ -9995,9 +10327,11 @@ function renderOpportunities(report) {
9995
10327
  const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
9996
10328
  const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
9997
10329
  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>';
10330
+ const surfaceTone = o.winnabilityClass === "ceded" ? "tone-caution" : "tone-neutral";
9998
10331
  return `<tr>
9999
10332
  <td>${escapeHtml(o.query)}</td>
10000
10333
  <td><span class="badge tone-neutral">${escapeHtml(contentActionLabel(o.action))}</span></td>
10334
+ <td><span class="badge ${surfaceTone}">${escapeHtml(winnabilityClassLabel(o.winnabilityClass))}</span></td>
10001
10335
  <td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
10002
10336
  <td>${drivers}</td>
10003
10337
  <td>${ourPage}</td>
@@ -10010,10 +10344,10 @@ function renderOpportunities(report) {
10010
10344
  id: "content-opportunities",
10011
10345
  eyebrow: "Section 14",
10012
10346
  title: "Content Opportunities",
10013
- intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger."
10347
+ intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger. Winnability flags whether the cited surface is ownable or ceded to aggregators/editorial."
10014
10348
  },
10015
10349
  `${highlights}<table class="report-table">
10016
- <thead><tr><th>Query</th><th>Action</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
10350
+ <thead><tr><th>Query</th><th>Action</th><th>Winnability</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
10017
10351
  <tbody>${rows}</tbody>
10018
10352
  </table>`
10019
10353
  );
@@ -10241,7 +10575,7 @@ function renderClientEvidenceSummary(report) {
10241
10575
  <ul class="client-opportunity-list">
10242
10576
  ${opportunities.map((o) => `<li>
10243
10577
  <div class="op-query">${escapeHtml(o.query)}</div>
10244
- <div class="op-action">${escapeHtml(contentActionLabel(o.action))}</div>
10578
+ <div class="op-action">${escapeHtml(contentActionLabel(o.action))}${o.winnabilityClass === "ceded" ? ' <span class="badge tone-caution">Ceded surface</span>' : ""}</div>
10245
10579
  </li>`).join("")}
10246
10580
  </ul>
10247
10581
  </div>`);
@@ -12618,6 +12952,8 @@ var SCHEMA_TABLE = {
12618
12952
  ContentTargetsResponseDto: contentTargetsResponseDtoSchema,
12619
12953
  CreateApiKeyRequest: createApiKeyRequestSchema,
12620
12954
  CreatedApiKeyDto: createdApiKeyDtoSchema,
12955
+ DomainClassificationsResponseDto: domainClassificationsResponseDtoSchema,
12956
+ RecommendationBriefDto: recommendationBriefDtoSchema,
12621
12957
  RecommendationExplanationDto: recommendationExplanationDtoSchema,
12622
12958
  DiscoveryPromotePreview: discoveryPromotePreviewSchema,
12623
12959
  DiscoveryPromoteResult: discoveryPromoteResultSchema,
@@ -12664,6 +13000,7 @@ var SCHEMA_TABLE = {
12664
13000
  SnapshotDiffResponse: snapshotDiffResponseSchema,
12665
13001
  SnapshotListResponse: snapshotListResponseSchema,
12666
13002
  SnapshotReportDto: snapshotReportSchema,
13003
+ SourceBreakdownDto: sourceBreakdownDtoSchema,
12667
13004
  TrafficBackfillResponse: trafficBackfillResponseSchema,
12668
13005
  TrafficEventsResponse: trafficEventsResponseSchema,
12669
13006
  TrafficSourceDetailDto: trafficSourceDetailDtoSchema,
@@ -13616,10 +13953,9 @@ var routeCatalog = [
13616
13953
  path: "/api/v1/projects/{name}/analytics/sources",
13617
13954
  summary: "Get source origin analytics",
13618
13955
  tags: ["analytics"],
13619
- parameters: [nameParameter, analyticsWindowParameter],
13956
+ parameters: [nameParameter, analyticsWindowParameter, limitQueryParameter],
13620
13957
  responses: {
13621
- // TODO: Add `SourceBreakdownDto` Zod schema in contracts.
13622
- 200: rawJsonResponse("Source breakdown returned.", looseObjectSchema),
13958
+ 200: jsonResponse("Source breakdown returned.", "SourceBreakdownDto"),
13623
13959
  404: errorResponse("Project not found.")
13624
13960
  }
13625
13961
  },
@@ -15529,16 +15865,18 @@ var routeCatalog = [
15529
15865
  method: "get",
15530
15866
  path: "/api/v1/projects/{name}/content/targets",
15531
15867
  summary: "Ranked, action-typed content opportunities",
15532
- description: "Returns the canonical opportunity list. Each row is `{query, action, ourBestPage?, winningCompetitor?, score, scoreBreakdown, drivers[], demandSource, actionConfidence, existingAction?}`. Hides rows with in-progress actions by default; pass `?include-in-progress=true` to include them annotated.",
15868
+ description: "Returns the canonical opportunity list. Each row is `{query, action, ourBestPage?, winningCompetitor?, score, scoreBreakdown, drivers[], demandSource, actionConfidence, existingAction?, winnabilityClass, winnability?}`. `winnabilityClass` is the deterministic winnability gate (`ownable` worth a brief, `ceded` an aggregator/editorial head term to skip). Ownable rows sort first. Hides rows with in-progress actions by default; pass `?include-in-progress=true` to include them annotated.",
15533
15869
  tags: ["content"],
15534
15870
  parameters: [
15535
15871
  nameParameter,
15536
15872
  { name: "limit", in: "query", description: "Max rows returned.", schema: stringSchema },
15537
- { name: "include-in-progress", in: "query", description: "Include rows with in-flight tracked actions.", schema: stringSchema }
15873
+ { name: "include-in-progress", in: "query", description: "Include rows with in-flight tracked actions.", schema: stringSchema },
15874
+ { name: "winnability-class", in: "query", description: 'Filter by winnability: "ownable" or "ceded".', schema: stringSchema },
15875
+ { name: "ownable", in: "query", description: 'Convenience alias for winnability-class=ownable when "true".', schema: stringSchema }
15538
15876
  ],
15539
15877
  responses: {
15540
15878
  200: jsonResponse("Targets returned.", "ContentTargetsResponseDto"),
15541
- 400: errorResponse("Invalid limit."),
15879
+ 400: errorResponse("Invalid limit or winnability-class."),
15542
15880
  404: errorResponse("Project not found.")
15543
15881
  }
15544
15882
  },
@@ -15645,6 +15983,65 @@ var routeCatalog = [
15645
15983
  503: errorResponse("No AI provider configured for this project.")
15646
15984
  }
15647
15985
  },
15986
+ {
15987
+ method: "get",
15988
+ path: "/api/v1/projects/{name}/content/recommendations/{targetRef}/brief",
15989
+ summary: "Get cached structured content brief for a recommendation",
15990
+ description: "Returns the cached structured brief (`{targetQuery, winnabilityClass, angle, whyWinnable, schemaHookup, controllableSurfaceRationale}`) for one content recommendation at the current prompt version, or 404 if none exists. Cache-only read from the dedicated recommendation_briefs table \u2014 never collides with the prose explanation. Use `POST /brief` to synthesize one.",
15991
+ tags: ["content"],
15992
+ parameters: [
15993
+ nameParameter,
15994
+ { name: "targetRef", in: "path", required: true, description: "Stable hash from ContentTargetRowDto.targetRef.", schema: stringSchema }
15995
+ ],
15996
+ responses: {
15997
+ 200: jsonResponse("Cached brief.", "RecommendationBriefDto"),
15998
+ 404: errorResponse("No cached brief for this targetRef yet.")
15999
+ }
16000
+ },
16001
+ {
16002
+ method: "post",
16003
+ path: "/api/v1/projects/{name}/content/recommendations/{targetRef}/brief",
16004
+ summary: "Synthesize (or fetch cached) a structured content brief",
16005
+ description: "Synthesizes a STRUCTURED content brief for one recommendation, reusing the `analyze` capability tier. GATED to `ownable` targets \u2014 a `ceded` head term (cited surface dominated by aggregators/editorial) is rejected with 400 before any LLM call. Cached per (project, targetRef, promptVersion) in a dedicated table; repeat calls without `forceRefresh` return the cached row free. Pass `provider`/`model` to override.",
16006
+ tags: ["content"],
16007
+ parameters: [
16008
+ nameParameter,
16009
+ { name: "targetRef", in: "path", required: true, description: "Stable hash from ContentTargetRowDto.targetRef.", schema: stringSchema }
16010
+ ],
16011
+ requestBody: {
16012
+ required: false,
16013
+ content: {
16014
+ "application/json": {
16015
+ schema: {
16016
+ type: "object",
16017
+ properties: {
16018
+ provider: stringSchema,
16019
+ model: stringSchema,
16020
+ forceRefresh: { type: "boolean" }
16021
+ }
16022
+ }
16023
+ }
16024
+ }
16025
+ },
16026
+ responses: {
16027
+ 200: jsonResponse("Brief synthesized or returned from cache.", "RecommendationBriefDto"),
16028
+ 400: errorResponse("Invalid request body, unknown provider, or target is ceded (not winnable)."),
16029
+ 404: errorResponse("Project not found or targetRef does not match any current recommendation."),
16030
+ 503: errorResponse("No AI provider configured for this project.")
16031
+ }
16032
+ },
16033
+ {
16034
+ method: "get",
16035
+ path: "/api/v1/projects/{name}/content/domain-classifications",
16036
+ summary: "List per-domain cited-surface classifications",
16037
+ description: "Returns every cited-surface domain classification discovery has produced for the project (`{domain, competitorType, hits, updatedAt}`), ranked by recurrence. This is the read surface behind the winnabilityClass winnability gate; running discovery improves coverage.",
16038
+ tags: ["content"],
16039
+ parameters: [nameParameter],
16040
+ responses: {
16041
+ 200: jsonResponse("Classifications returned.", "DomainClassificationsResponseDto"),
16042
+ 404: errorResponse("Project not found.")
16043
+ }
16044
+ },
15648
16045
  {
15649
16046
  method: "get",
15650
16047
  path: "/api/v1/projects/{name}/content/sources",
@@ -29214,6 +29611,104 @@ var BING_AUTH_CHECKS = [
29214
29611
  }
29215
29612
  ];
29216
29613
 
29614
+ // ../api-routes/src/doctor/checks/content.ts
29615
+ import { eq as eq26 } from "drizzle-orm";
29616
+ var WINNABILITY_COVERAGE_WARN_THRESHOLD = 0.8;
29617
+ var UNCLASSIFIED_DOMAIN_SAMPLE_LIMIT = 10;
29618
+ function skippedNoProject() {
29619
+ return {
29620
+ status: CheckStatuses.skipped,
29621
+ code: "content.winnability.no-project",
29622
+ summary: "Project context required for content winnability checks.",
29623
+ remediation: "Run `canonry doctor --project <name>` to scope this check to a project."
29624
+ };
29625
+ }
29626
+ function loadProject(ctx) {
29627
+ if (!ctx.project) return null;
29628
+ return ctx.db.select().from(projects).where(eq26(projects.id, ctx.project.id)).get() ?? null;
29629
+ }
29630
+ function percent(value) {
29631
+ return Math.round(value * 100);
29632
+ }
29633
+ var winnabilityCoverageCheck = {
29634
+ id: "content.winnability.coverage",
29635
+ category: CheckCategories.integrations,
29636
+ scope: CheckScopes.project,
29637
+ title: "Content winnability classification coverage",
29638
+ run: (ctx) => {
29639
+ if (!ctx.project) return skippedNoProject();
29640
+ const project = loadProject(ctx);
29641
+ if (!project) {
29642
+ return {
29643
+ status: CheckStatuses.fail,
29644
+ code: "content.winnability.project-missing",
29645
+ summary: "Project row disappeared before the content winnability check could run.",
29646
+ remediation: "Re-run `canonry doctor --project <name>`; if this persists, inspect the local database."
29647
+ };
29648
+ }
29649
+ const input = loadOrchestratorInput(ctx.db, project);
29650
+ const citationCounts = /* @__PURE__ */ new Map();
29651
+ for (const candidate of input.candidateQueries) {
29652
+ for (const cited of candidate.citedSurfaceDomains) {
29653
+ citationCounts.set(cited.domain, (citationCounts.get(cited.domain) ?? 0) + cited.citationCount);
29654
+ }
29655
+ }
29656
+ const citedDomains = [...citationCounts.keys()].sort();
29657
+ if (citedDomains.length === 0) {
29658
+ return {
29659
+ status: CheckStatuses.skipped,
29660
+ code: "content.winnability.no-cited-surface",
29661
+ summary: "No non-owned cited-surface domains in recent content evidence, so the winnability gate has nothing to classify yet.",
29662
+ remediation: `Run \`canonry run ${ctx.project.name}\` to capture fresh answer-engine citations before checking discovery coverage.`,
29663
+ details: {
29664
+ citedSurfaceDomainCount: 0,
29665
+ classifiedDomainCount: input.domainClasses.size
29666
+ }
29667
+ };
29668
+ }
29669
+ const coveredDomains = citedDomains.filter((domain) => input.domainClasses.has(domain));
29670
+ const unclassifiedDomains = citedDomains.filter((domain) => !input.domainClasses.has(domain));
29671
+ const coverage = coveredDomains.length / citedDomains.length;
29672
+ const details = {
29673
+ citedSurfaceDomainCount: citedDomains.length,
29674
+ classifiedDomainCount: input.domainClasses.size,
29675
+ coveredDomainCount: coveredDomains.length,
29676
+ coverage,
29677
+ threshold: WINNABILITY_COVERAGE_WARN_THRESHOLD,
29678
+ unclassifiedDomains: unclassifiedDomains.slice(0, UNCLASSIFIED_DOMAIN_SAMPLE_LIMIT)
29679
+ };
29680
+ if (coveredDomains.length === 0) {
29681
+ return {
29682
+ status: CheckStatuses.warn,
29683
+ code: "content.winnability.no-classifications",
29684
+ summary: `0 of ${citedDomains.length} cited-surface domain(s) have discovery classifications; the winnability gate is failing open.`,
29685
+ remediation: `Run \`canonry discover run ${ctx.project.name} --wait\` to classify cited domains before trusting ownable/ceded content targets.`,
29686
+ details
29687
+ };
29688
+ }
29689
+ if (coverage < WINNABILITY_COVERAGE_WARN_THRESHOLD) {
29690
+ return {
29691
+ status: CheckStatuses.warn,
29692
+ code: "content.winnability.low-coverage",
29693
+ summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) classified (${percent(coverage)}%); the winnability gate may miss ceded surfaces.`,
29694
+ remediation: `Run \`canonry discover run ${ctx.project.name} --wait\` to raise classification coverage before relying on ownable/ceded content targets.`,
29695
+ details
29696
+ };
29697
+ }
29698
+ return {
29699
+ status: CheckStatuses.ok,
29700
+ code: "content.winnability.covered",
29701
+ summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) classified (${percent(coverage)}%); the winnability gate is active.`,
29702
+ remediation: null,
29703
+ details
29704
+ };
29705
+ }
29706
+ };
29707
+ var CONTENT_CHECKS = [winnabilityCoverageCheck];
29708
+ var CONTENT_CHECK_BY_ID = Object.fromEntries(
29709
+ CONTENT_CHECKS.map((check) => [check.id, check])
29710
+ );
29711
+
29217
29712
  // ../api-routes/src/doctor/checks/ga-auth.ts
29218
29713
  async function checkServiceAccount(conn) {
29219
29714
  if (!conn.propertyId) {
@@ -29356,10 +29851,10 @@ var ga4ConnectionCheck = {
29356
29851
  var GA_AUTH_CHECKS = [ga4ConnectionCheck];
29357
29852
 
29358
29853
  // ../api-routes/src/doctor/checks/gbp-auth.ts
29359
- import { and as and20, eq as eq26 } from "drizzle-orm";
29854
+ import { and as and20, eq as eq27 } from "drizzle-orm";
29360
29855
  var RECENT_SYNC_WARN_DAYS = 7;
29361
29856
  var RECENT_SYNC_FAIL_DAYS = 30;
29362
- function skippedNoProject() {
29857
+ function skippedNoProject2() {
29363
29858
  return {
29364
29859
  status: CheckStatuses.skipped,
29365
29860
  code: "gbp.auth.no-project",
@@ -29376,7 +29871,7 @@ function storeUnavailable() {
29376
29871
  };
29377
29872
  }
29378
29873
  async function resolveGbpToken(ctx) {
29379
- if (!ctx.project) return { ok: false, output: skippedNoProject() };
29874
+ if (!ctx.project) return { ok: false, output: skippedNoProject2() };
29380
29875
  const store = ctx.googleConnectionStore;
29381
29876
  if (!store) return { ok: false, output: storeUnavailable() };
29382
29877
  const auth = ctx.getGoogleAuthConfig?.() ?? {};
@@ -29454,7 +29949,7 @@ var scopesCheck = {
29454
29949
  scope: CheckScopes.project,
29455
29950
  title: "GBP granted scopes",
29456
29951
  run: async (ctx) => {
29457
- if (!ctx.project) return skippedNoProject();
29952
+ if (!ctx.project) return skippedNoProject2();
29458
29953
  const store = ctx.googleConnectionStore;
29459
29954
  if (!store) return storeUnavailable();
29460
29955
  const conn = store.getConnection(ctx.project.canonicalDomain, "gbp");
@@ -29491,7 +29986,7 @@ var accountAccessCheck = {
29491
29986
  scope: CheckScopes.project,
29492
29987
  title: "GBP account access",
29493
29988
  run: async (ctx) => {
29494
- if (!ctx.project) return skippedNoProject();
29989
+ if (!ctx.project) return skippedNoProject2();
29495
29990
  const store = ctx.googleConnectionStore;
29496
29991
  if (!store) return storeUnavailable();
29497
29992
  const conn = store.getConnection(ctx.project.canonicalDomain, "gbp");
@@ -29588,8 +30083,8 @@ var recentSyncCheck = {
29588
30083
  scope: CheckScopes.project,
29589
30084
  title: "GBP recent sync",
29590
30085
  run: (ctx) => {
29591
- if (!ctx.project) return skippedNoProject();
29592
- const selected = ctx.db.select({ locationName: gbpLocations.locationName, syncedAt: gbpLocations.syncedAt }).from(gbpLocations).where(and20(eq26(gbpLocations.projectId, ctx.project.id), eq26(gbpLocations.selected, true))).all();
30086
+ if (!ctx.project) return skippedNoProject2();
30087
+ const selected = ctx.db.select({ locationName: gbpLocations.locationName, syncedAt: gbpLocations.syncedAt }).from(gbpLocations).where(and20(eq27(gbpLocations.projectId, ctx.project.id), eq27(gbpLocations.selected, true))).all();
29593
30088
  if (selected.length === 0) {
29594
30089
  return {
29595
30090
  status: CheckStatuses.skipped,
@@ -29649,7 +30144,7 @@ var GBP_AUTH_CHECK_BY_ID = Object.fromEntries(
29649
30144
  );
29650
30145
 
29651
30146
  // ../api-routes/src/doctor/checks/places.ts
29652
- import { eq as eq27 } from "drizzle-orm";
30147
+ import { eq as eq28 } from "drizzle-orm";
29653
30148
  var apiKeyCheck = {
29654
30149
  id: "gbp.places.api-key",
29655
30150
  category: CheckCategories.auth,
@@ -29694,7 +30189,7 @@ var apiKeyCheck = {
29694
30189
  details: { tier: cfg.tier }
29695
30190
  };
29696
30191
  }
29697
- const rows = ctx.db.select({ placeId: gbpLocations.placeId, selected: gbpLocations.selected }).from(gbpLocations).where(eq27(gbpLocations.projectId, ctx.project.id)).all();
30192
+ const rows = ctx.db.select({ placeId: gbpLocations.placeId, selected: gbpLocations.selected }).from(gbpLocations).where(eq28(gbpLocations.projectId, ctx.project.id)).all();
29698
30193
  const selected = rows.filter((r) => r.selected);
29699
30194
  const locationsWithPlaceId = selected.filter((r) => Boolean(r.placeId)).length;
29700
30195
  const details = {
@@ -29730,7 +30225,7 @@ var PLACES_CHECK_BY_ID = Object.fromEntries(
29730
30225
  var REQUIRED_GSC_SCOPES = [GSC_SCOPE, INDEXING_SCOPE];
29731
30226
  async function resolveAccessToken(ctx) {
29732
30227
  if (!ctx.project) {
29733
- return { ok: false, output: skippedNoProject2() };
30228
+ return { ok: false, output: skippedNoProject3() };
29734
30229
  }
29735
30230
  const store = ctx.googleConnectionStore;
29736
30231
  if (!store) {
@@ -29797,7 +30292,7 @@ async function resolveAccessToken(ctx) {
29797
30292
  };
29798
30293
  }
29799
30294
  }
29800
- function skippedNoProject2() {
30295
+ function skippedNoProject3() {
29801
30296
  return {
29802
30297
  status: CheckStatuses.skipped,
29803
30298
  code: "google.auth.no-project",
@@ -29827,7 +30322,7 @@ var propertyAccessCheck = {
29827
30322
  scope: CheckScopes.project,
29828
30323
  title: "GSC property access",
29829
30324
  run: async (ctx) => {
29830
- if (!ctx.project) return skippedNoProject2();
30325
+ if (!ctx.project) return skippedNoProject3();
29831
30326
  const store = ctx.googleConnectionStore;
29832
30327
  if (!store) {
29833
30328
  return {
@@ -29928,7 +30423,7 @@ var redirectUriCheck = {
29928
30423
  scope: CheckScopes.project,
29929
30424
  title: "OAuth redirect URI",
29930
30425
  run: async (ctx) => {
29931
- if (!ctx.project) return skippedNoProject2();
30426
+ if (!ctx.project) return skippedNoProject3();
29932
30427
  const auth = ctx.getGoogleAuthConfig?.() ?? {};
29933
30428
  if (!auth.clientId || !auth.clientSecret) {
29934
30429
  return {
@@ -29982,7 +30477,7 @@ var scopesCheck2 = {
29982
30477
  scope: CheckScopes.project,
29983
30478
  title: "GSC granted scopes",
29984
30479
  run: async (ctx) => {
29985
- if (!ctx.project) return skippedNoProject2();
30480
+ if (!ctx.project) return skippedNoProject3();
29986
30481
  const store = ctx.googleConnectionStore;
29987
30482
  if (!store) {
29988
30483
  return {
@@ -30145,10 +30640,10 @@ var RUNTIME_STATE_CHECKS = [
30145
30640
  ];
30146
30641
 
30147
30642
  // ../api-routes/src/doctor/checks/traffic-source.ts
30148
- import { and as and21, eq as eq28, gte as gte4, ne as ne4, sql as sql12 } from "drizzle-orm";
30643
+ import { and as and21, eq as eq29, gte as gte4, ne as ne4, sql as sql12 } from "drizzle-orm";
30149
30644
  var RECENT_DATA_WARN_DAYS = 7;
30150
30645
  var RECENT_DATA_FAIL_DAYS = 30;
30151
- function skippedNoProject3() {
30646
+ function skippedNoProject4() {
30152
30647
  return {
30153
30648
  status: CheckStatuses.skipped,
30154
30649
  code: "traffic.no-project",
@@ -30160,7 +30655,7 @@ function loadProbes(ctx) {
30160
30655
  if (!ctx.project) return [];
30161
30656
  const rows = ctx.db.select().from(trafficSources).where(
30162
30657
  and21(
30163
- eq28(trafficSources.projectId, ctx.project.id),
30658
+ eq29(trafficSources.projectId, ctx.project.id),
30164
30659
  ne4(trafficSources.status, TrafficSourceStatuses.archived)
30165
30660
  )
30166
30661
  ).all();
@@ -30182,7 +30677,7 @@ var sourceConnectedCheck = {
30182
30677
  scope: CheckScopes.project,
30183
30678
  title: "Traffic source connected",
30184
30679
  run: (ctx) => {
30185
- if (!ctx.project) return skippedNoProject3();
30680
+ if (!ctx.project) return skippedNoProject4();
30186
30681
  const sources = loadProbes(ctx);
30187
30682
  if (sources.length === 0) {
30188
30683
  return {
@@ -30226,7 +30721,7 @@ var recentDataCheck = {
30226
30721
  scope: CheckScopes.project,
30227
30722
  title: "Traffic source recent data",
30228
30723
  run: (ctx) => {
30229
- if (!ctx.project) return skippedNoProject3();
30724
+ if (!ctx.project) return skippedNoProject4();
30230
30725
  const sources = loadProbes(ctx);
30231
30726
  if (sources.length === 0) {
30232
30727
  return {
@@ -30241,7 +30736,7 @@ var recentDataCheck = {
30241
30736
  const recentCrawlers = Number(
30242
30737
  ctx.db.select({ total: sql12`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
30243
30738
  and21(
30244
- eq28(crawlerEventsHourly.projectId, ctx.project.id),
30739
+ eq29(crawlerEventsHourly.projectId, ctx.project.id),
30245
30740
  gte4(crawlerEventsHourly.tsHour, warnCutoff)
30246
30741
  )
30247
30742
  ).get()?.total ?? 0
@@ -30249,7 +30744,7 @@ var recentDataCheck = {
30249
30744
  const recentReferrals = Number(
30250
30745
  ctx.db.select({ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
30251
30746
  and21(
30252
- eq28(aiReferralEventsHourly.projectId, ctx.project.id),
30747
+ eq29(aiReferralEventsHourly.projectId, ctx.project.id),
30253
30748
  gte4(aiReferralEventsHourly.tsHour, warnCutoff)
30254
30749
  )
30255
30750
  ).get()?.total ?? 0
@@ -30265,7 +30760,7 @@ var recentDataCheck = {
30265
30760
  const olderCrawlers = Number(
30266
30761
  ctx.db.select({ total: sql12`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
30267
30762
  and21(
30268
- eq28(crawlerEventsHourly.projectId, ctx.project.id),
30763
+ eq29(crawlerEventsHourly.projectId, ctx.project.id),
30269
30764
  gte4(crawlerEventsHourly.tsHour, failCutoff)
30270
30765
  )
30271
30766
  ).get()?.total ?? 0
@@ -30273,7 +30768,7 @@ var recentDataCheck = {
30273
30768
  const olderReferrals = Number(
30274
30769
  ctx.db.select({ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
30275
30770
  and21(
30276
- eq28(aiReferralEventsHourly.projectId, ctx.project.id),
30771
+ eq29(aiReferralEventsHourly.projectId, ctx.project.id),
30277
30772
  gte4(aiReferralEventsHourly.tsHour, failCutoff)
30278
30773
  )
30279
30774
  ).get()?.total ?? 0
@@ -30388,7 +30883,7 @@ var credentialsCheck = {
30388
30883
  scope: CheckScopes.project,
30389
30884
  title: "Traffic source credentials",
30390
30885
  run: async (ctx) => {
30391
- if (!ctx.project) return skippedNoProject3();
30886
+ if (!ctx.project) return skippedNoProject4();
30392
30887
  const sources = loadProbes(ctx);
30393
30888
  if (sources.length === 0) {
30394
30889
  return {
@@ -30417,7 +30912,7 @@ var scopesCheck3 = {
30417
30912
  scope: CheckScopes.project,
30418
30913
  title: "Traffic source scopes",
30419
30914
  run: async (ctx) => {
30420
- if (!ctx.project) return skippedNoProject3();
30915
+ if (!ctx.project) return skippedNoProject4();
30421
30916
  const sources = loadProbes(ctx);
30422
30917
  if (sources.length === 0) {
30423
30918
  return {
@@ -30530,6 +31025,7 @@ var ALL_CHECKS = [
30530
31025
  ...GA_AUTH_CHECKS,
30531
31026
  ...PROVIDERS_CHECKS,
30532
31027
  ...TRAFFIC_SOURCE_CHECKS,
31028
+ ...CONTENT_CHECKS,
30533
31029
  ...AGENT_CHECKS
30534
31030
  ];
30535
31031
 
@@ -30652,7 +31148,7 @@ async function doctorRoutes(app, opts) {
30652
31148
 
30653
31149
  // ../api-routes/src/discovery/routes.ts
30654
31150
  import crypto25 from "crypto";
30655
- import { and as and22, desc as desc15, eq as eq29, gte as gte5, inArray as inArray10 } from "drizzle-orm";
31151
+ import { and as and22, desc as desc15, eq as eq30, gte as gte5, inArray as inArray10 } from "drizzle-orm";
30656
31152
  var MAX_INFLIGHT_DISCOVERY_AGE_MS = 2 * 60 * 60 * 1e3;
30657
31153
  async function discoveryRoutes(app, opts) {
30658
31154
  app.post("/projects/:name/discover/run", async (request, reply) => {
@@ -30685,8 +31181,8 @@ async function discoveryRoutes(app, opts) {
30685
31181
  const ageFloorIso = new Date(Date.now() - MAX_INFLIGHT_DISCOVERY_AGE_MS).toISOString();
30686
31182
  const decision = app.db.transaction((tx) => {
30687
31183
  const existing = tx.select({ id: discoverySessions.id, runId: discoverySessions.runId }).from(discoverySessions).where(and22(
30688
- eq29(discoverySessions.projectId, project.id),
30689
- eq29(discoverySessions.icpDescription, icpDescription),
31184
+ eq30(discoverySessions.projectId, project.id),
31185
+ eq30(discoverySessions.icpDescription, icpDescription),
30690
31186
  inArray10(discoverySessions.status, [
30691
31187
  DiscoverySessionStatuses.queued,
30692
31188
  DiscoverySessionStatuses.seeding,
@@ -30756,7 +31252,7 @@ async function discoveryRoutes(app, opts) {
30756
31252
  const project = resolveProject(app.db, request.params.name);
30757
31253
  const parsedLimit = parseInt(request.query.limit ?? "", 10);
30758
31254
  const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? 50 : parsedLimit;
30759
- const rows = app.db.select().from(discoverySessions).where(eq29(discoverySessions.projectId, project.id)).orderBy(desc15(discoverySessions.createdAt)).limit(limit).all();
31255
+ const rows = app.db.select().from(discoverySessions).where(eq30(discoverySessions.projectId, project.id)).orderBy(desc15(discoverySessions.createdAt)).limit(limit).all();
30760
31256
  return reply.send(rows.map(serializeSession));
30761
31257
  }
30762
31258
  );
@@ -30764,11 +31260,11 @@ async function discoveryRoutes(app, opts) {
30764
31260
  "/projects/:name/discover/sessions/:id",
30765
31261
  async (request, reply) => {
30766
31262
  const project = resolveProject(app.db, request.params.name);
30767
- const session = app.db.select().from(discoverySessions).where(eq29(discoverySessions.id, request.params.id)).get();
31263
+ const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
30768
31264
  if (!session || session.projectId !== project.id) {
30769
31265
  throw notFound("Discovery session", request.params.id);
30770
31266
  }
30771
- const probeRows = app.db.select().from(discoveryProbes).where(eq29(discoveryProbes.sessionId, session.id)).all();
31267
+ const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
30772
31268
  const detail = {
30773
31269
  ...serializeSession(session),
30774
31270
  probes: probeRows.map(serializeProbe)
@@ -30780,12 +31276,12 @@ async function discoveryRoutes(app, opts) {
30780
31276
  "/projects/:name/discover/sessions/:id/promote",
30781
31277
  async (request, reply) => {
30782
31278
  const project = resolveProject(app.db, request.params.name);
30783
- const session = app.db.select().from(discoverySessions).where(eq29(discoverySessions.id, request.params.id)).get();
31279
+ const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
30784
31280
  if (!session || session.projectId !== project.id) {
30785
31281
  throw notFound("Discovery session", request.params.id);
30786
31282
  }
30787
- const probeRows = app.db.select().from(discoveryProbes).where(eq29(discoveryProbes.sessionId, session.id)).all();
30788
- const existingCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq29(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase());
31283
+ const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
31284
+ const existingCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq30(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase());
30789
31285
  const seenCompetitors = new Set(existingCompetitors);
30790
31286
  const cited = /* @__PURE__ */ new Set();
30791
31287
  const aspirational = /* @__PURE__ */ new Set();
@@ -30814,7 +31310,7 @@ async function discoveryRoutes(app, opts) {
30814
31310
  );
30815
31311
  app.post("/projects/:name/discover/sessions/:id/promote", async (request, reply) => {
30816
31312
  const project = resolveProject(app.db, request.params.name);
30817
- const session = app.db.select().from(discoverySessions).where(eq29(discoverySessions.id, request.params.id)).get();
31313
+ const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
30818
31314
  if (!session || session.projectId !== project.id) {
30819
31315
  throw notFound("Discovery session", request.params.id);
30820
31316
  }
@@ -30837,7 +31333,7 @@ async function discoveryRoutes(app, opts) {
30837
31333
  const bucketSet = new Set(buckets);
30838
31334
  const includeCompetitors = parsed.data.includeCompetitors ?? true;
30839
31335
  const competitorTypes = parsed.data.competitorTypes ?? DEFAULT_DISCOVERY_PROMOTE_COMPETITOR_TYPES;
30840
- const probeRows = app.db.select().from(discoveryProbes).where(eq29(discoveryProbes.sessionId, session.id)).all();
31336
+ const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
30841
31337
  const candidateQueries = /* @__PURE__ */ new Set();
30842
31338
  for (const probe of probeRows) {
30843
31339
  if (!probe.bucket) continue;
@@ -30845,7 +31341,7 @@ async function discoveryRoutes(app, opts) {
30845
31341
  if (bucket.success && bucketSet.has(bucket.data)) candidateQueries.add(probe.query);
30846
31342
  }
30847
31343
  const existingQueries = new Set(
30848
- app.db.select({ query: queries.query }).from(queries).where(eq29(queries.projectId, project.id)).all().map((r) => r.query.toLowerCase())
31344
+ app.db.select({ query: queries.query }).from(queries).where(eq30(queries.projectId, project.id)).all().map((r) => r.query.toLowerCase())
30849
31345
  );
30850
31346
  const promotedQueries = [];
30851
31347
  const skippedQueries = [];
@@ -30861,7 +31357,7 @@ async function discoveryRoutes(app, opts) {
30861
31357
  const skippedCompetitors = [];
30862
31358
  if (includeCompetitors) {
30863
31359
  const existingCompetitors = new Set(
30864
- app.db.select({ domain: competitors.domain }).from(competitors).where(eq29(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase())
31360
+ app.db.select({ domain: competitors.domain }).from(competitors).where(eq30(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase())
30865
31361
  );
30866
31362
  const competitorMap = parseCompetitorMap(session.competitorMap);
30867
31363
  for (const entry of selectEligibleCompetitors(competitorMap, competitorTypes)) {
@@ -30964,7 +31460,7 @@ function selectEligibleCompetitors(competitorMap, competitorTypes) {
30964
31460
 
30965
31461
  // ../api-routes/src/discovery/orchestrate.ts
30966
31462
  import crypto26 from "crypto";
30967
- import { eq as eq30 } from "drizzle-orm";
31463
+ import { eq as eq31 } from "drizzle-orm";
30968
31464
  var DEFAULT_DEDUP_THRESHOLD = 0.85;
30969
31465
  var DEFAULT_MAX_PROBES = 100;
30970
31466
  var ABSOLUTE_MAX_PROBES = 500;
@@ -31019,7 +31515,7 @@ async function executeDiscovery(opts) {
31019
31515
  status: DiscoverySessionStatuses.seeding,
31020
31516
  dedupThreshold,
31021
31517
  startedAt
31022
- }).where(eq30(discoverySessions.id, opts.sessionId)).run();
31518
+ }).where(eq31(discoverySessions.id, opts.sessionId)).run();
31023
31519
  const seedResult = await opts.deps.seed({
31024
31520
  project: opts.project,
31025
31521
  icpDescription: opts.icpDescription,
@@ -31039,7 +31535,7 @@ async function executeDiscovery(opts) {
31039
31535
  seedProvider: seedResult.provider,
31040
31536
  seedCountRaw,
31041
31537
  seedCount
31042
- }).where(eq30(discoverySessions.id, opts.sessionId)).run();
31538
+ }).where(eq31(discoverySessions.id, opts.sessionId)).run();
31043
31539
  const probeRows = [];
31044
31540
  const buckets = { cited: 0, aspirational: 0, "wasted-surface": 0 };
31045
31541
  for (const query of probedCanonicals) {
@@ -31079,7 +31575,8 @@ async function executeDiscovery(opts) {
31079
31575
  wastedCount: buckets["wasted-surface"],
31080
31576
  competitorMap,
31081
31577
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
31082
- }).where(eq30(discoverySessions.id, opts.sessionId)).run();
31578
+ }).where(eq31(discoverySessions.id, opts.sessionId)).run();
31579
+ upsertDomainClassifications(opts.db, opts.project.id, opts.sessionId, competitorMap);
31083
31580
  return {
31084
31581
  buckets,
31085
31582
  competitorMap,
@@ -31088,12 +31585,37 @@ async function executeDiscovery(opts) {
31088
31585
  seedProvider: seedResult.provider
31089
31586
  };
31090
31587
  }
31588
+ function upsertDomainClassifications(db, projectId, sessionId, competitorMap) {
31589
+ if (competitorMap.length === 0) return;
31590
+ const now = (/* @__PURE__ */ new Date()).toISOString();
31591
+ for (const entry of competitorMap) {
31592
+ const domain = normalizeDomain(entry.domain);
31593
+ if (!domain) continue;
31594
+ db.insert(domainClassifications).values({
31595
+ id: crypto26.randomUUID(),
31596
+ projectId,
31597
+ domain,
31598
+ competitorType: entry.competitorType,
31599
+ hits: entry.hits,
31600
+ sessionId,
31601
+ updatedAt: now
31602
+ }).onConflictDoUpdate({
31603
+ target: [domainClassifications.projectId, domainClassifications.domain],
31604
+ set: {
31605
+ competitorType: entry.competitorType,
31606
+ hits: entry.hits,
31607
+ sessionId,
31608
+ updatedAt: now
31609
+ }
31610
+ }).run();
31611
+ }
31612
+ }
31091
31613
  function markSessionFailed(db, sessionId, error) {
31092
31614
  db.update(discoverySessions).set({
31093
31615
  status: DiscoverySessionStatuses.failed,
31094
31616
  error,
31095
31617
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
31096
- }).where(eq30(discoverySessions.id, sessionId)).run();
31618
+ }).where(eq31(discoverySessions.id, sessionId)).run();
31097
31619
  }
31098
31620
  function dedupeStrings(input) {
31099
31621
  const seen = /* @__PURE__ */ new Set();
@@ -31190,7 +31712,11 @@ async function apiRoutes(app, opts) {
31190
31712
  await api.register(reportRoutes);
31191
31713
  await api.register(citationRoutes);
31192
31714
  await api.register(compositeRoutes);
31193
- await api.register(contentRoutes, { explainContentRecommendation: opts.explainContentRecommendation });
31715
+ await api.register(contentRoutes, {
31716
+ explainContentRecommendation: opts.explainContentRecommendation,
31717
+ briefContentRecommendation: opts.briefContentRecommendation,
31718
+ briefPromptVersion: opts.briefPromptVersion
31719
+ });
31194
31720
  await api.register(settingsRoutes, {
31195
31721
  providerSummary: opts.providerSummary,
31196
31722
  providerAdapters: opts.providerAdapters,
@@ -31656,8 +32182,8 @@ var IntelligenceService = class {
31656
32182
  analyzeAndPersist(runId, projectId) {
31657
32183
  const recentRuns = this.db.select().from(runs).where(
31658
32184
  and23(
31659
- eq31(runs.projectId, projectId),
31660
- or5(eq31(runs.status, "completed"), eq31(runs.status, "partial")),
32185
+ eq32(runs.projectId, projectId),
32186
+ or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
31661
32187
  // Defensive: RunCoordinator already skips probes before this is
31662
32188
  // called, but if a future call site invokes analyzeAndPersist
31663
32189
  // directly for a probe, probes still must not pollute the
@@ -31739,7 +32265,7 @@ var IntelligenceService = class {
31739
32265
  * Returns the persisted insights so the coordinator can count critical/high.
31740
32266
  */
31741
32267
  analyzeAndPersistGbp(runId, projectId) {
31742
- const runRow = this.db.select({ createdAt: runs.createdAt, startedAt: runs.startedAt, finishedAt: runs.finishedAt }).from(runs).where(eq31(runs.id, runId)).get();
32268
+ const runRow = this.db.select({ createdAt: runs.createdAt, startedAt: runs.startedAt, finishedAt: runs.finishedAt }).from(runs).where(eq32(runs.id, runId)).get();
31743
32269
  if (!runRow) {
31744
32270
  log.info("gbp-intelligence.skip", { runId, reason: "run not found" });
31745
32271
  this.persistGbpInsights(runId, projectId, [], []);
@@ -31748,8 +32274,8 @@ var IntelligenceService = class {
31748
32274
  const windowStart = runRow.startedAt ?? runRow.createdAt;
31749
32275
  const windowEnd = runRow.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
31750
32276
  const selected = this.db.select().from(gbpLocations).where(and23(
31751
- eq31(gbpLocations.projectId, projectId),
31752
- eq31(gbpLocations.selected, true),
32277
+ eq32(gbpLocations.projectId, projectId),
32278
+ eq32(gbpLocations.selected, true),
31753
32279
  gte6(gbpLocations.syncedAt, windowStart),
31754
32280
  lte3(gbpLocations.syncedAt, windowEnd)
31755
32281
  )).all();
@@ -31784,10 +32310,10 @@ var IntelligenceService = class {
31784
32310
  }
31785
32311
  /** Build the per-location signal bundle the GBP analyzer consumes. */
31786
32312
  buildGbpLocationSignals(projectId, locationName, displayName, fallbackDate) {
31787
- const metricRows = this.db.select({ metric: gbpDailyMetrics.metric, date: gbpDailyMetrics.date, value: gbpDailyMetrics.value }).from(gbpDailyMetrics).where(and23(eq31(gbpDailyMetrics.projectId, projectId), eq31(gbpDailyMetrics.locationName, locationName))).all();
31788
- const placeActionRows = this.db.select({ placeActionType: gbpPlaceActions.placeActionType, providerType: gbpPlaceActions.providerType }).from(gbpPlaceActions).where(and23(eq31(gbpPlaceActions.projectId, projectId), eq31(gbpPlaceActions.locationName, locationName))).all();
31789
- const lodgingRow = this.db.select({ populatedGroupCount: gbpLodgingSnapshots.populatedGroupCount }).from(gbpLodgingSnapshots).where(and23(eq31(gbpLodgingSnapshots.projectId, projectId), eq31(gbpLodgingSnapshots.locationName, locationName))).orderBy(desc16(gbpLodgingSnapshots.syncedAt)).limit(1).get();
31790
- const placeRow = this.db.select({ attributes: gbpPlaceDetails.attributes }).from(gbpPlaceDetails).where(and23(eq31(gbpPlaceDetails.projectId, projectId), eq31(gbpPlaceDetails.locationName, locationName))).orderBy(desc16(gbpPlaceDetails.syncedAt)).limit(1).get();
32313
+ const metricRows = this.db.select({ metric: gbpDailyMetrics.metric, date: gbpDailyMetrics.date, value: gbpDailyMetrics.value }).from(gbpDailyMetrics).where(and23(eq32(gbpDailyMetrics.projectId, projectId), eq32(gbpDailyMetrics.locationName, locationName))).all();
32314
+ const placeActionRows = this.db.select({ placeActionType: gbpPlaceActions.placeActionType, providerType: gbpPlaceActions.providerType }).from(gbpPlaceActions).where(and23(eq32(gbpPlaceActions.projectId, projectId), eq32(gbpPlaceActions.locationName, locationName))).all();
32315
+ const lodgingRow = this.db.select({ populatedGroupCount: gbpLodgingSnapshots.populatedGroupCount }).from(gbpLodgingSnapshots).where(and23(eq32(gbpLodgingSnapshots.projectId, projectId), eq32(gbpLodgingSnapshots.locationName, locationName))).orderBy(desc16(gbpLodgingSnapshots.syncedAt)).limit(1).get();
32316
+ const placeRow = this.db.select({ attributes: gbpPlaceDetails.attributes }).from(gbpPlaceDetails).where(and23(eq32(gbpPlaceDetails.projectId, projectId), eq32(gbpPlaceDetails.locationName, locationName))).orderBy(desc16(gbpPlaceDetails.syncedAt)).limit(1).get();
31791
32317
  const placesAmenities = placeRow ? extractPlaceAmenities(placeRow.attributes) : [];
31792
32318
  const summary = buildGbpSummary({
31793
32319
  locationName,
@@ -31819,7 +32345,7 @@ var IntelligenceService = class {
31819
32345
  /** Build the month-over-month keyword series for a location from the
31820
32346
  * accumulating gbp_keyword_monthly table (latest complete month vs prior). */
31821
32347
  buildGbpKeywordTrend(projectId, locationName) {
31822
- const rows = this.db.select({ month: gbpKeywordMonthly.month, keyword: gbpKeywordMonthly.keyword, valueCount: gbpKeywordMonthly.valueCount }).from(gbpKeywordMonthly).where(and23(eq31(gbpKeywordMonthly.projectId, projectId), eq31(gbpKeywordMonthly.locationName, locationName))).all();
32348
+ const rows = this.db.select({ month: gbpKeywordMonthly.month, keyword: gbpKeywordMonthly.keyword, valueCount: gbpKeywordMonthly.valueCount }).from(gbpKeywordMonthly).where(and23(eq32(gbpKeywordMonthly.projectId, projectId), eq32(gbpKeywordMonthly.locationName, locationName))).all();
31823
32349
  if (rows.length === 0) return { recentMonth: null, priorMonth: null, points: [] };
31824
32350
  const months = [...new Set(rows.map((r) => r.month))].sort().reverse();
31825
32351
  const recentMonth = months[0] ?? null;
@@ -31850,7 +32376,7 @@ var IntelligenceService = class {
31850
32376
  */
31851
32377
  persistGbpInsights(runId, projectId, gbpInsights, coveredLocationNames) {
31852
32378
  const covered = new Set(coveredLocationNames);
31853
- const existing = this.db.select({ id: insights.id, dismissed: insights.dismissed }).from(insights).where(and23(eq31(insights.projectId, projectId), eq31(insights.provider, GBP_INSIGHT_PROVIDER))).all();
32379
+ const existing = this.db.select({ id: insights.id, dismissed: insights.dismissed }).from(insights).where(and23(eq32(insights.projectId, projectId), eq32(insights.provider, GBP_INSIGHT_PROVIDER))).all();
31854
32380
  const staleIds = [];
31855
32381
  const dismissedSlots = /* @__PURE__ */ new Set();
31856
32382
  for (const row of existing) {
@@ -31861,7 +32387,7 @@ var IntelligenceService = class {
31861
32387
  }
31862
32388
  this.db.transaction((tx) => {
31863
32389
  for (const id of staleIds) {
31864
- tx.delete(insights).where(eq31(insights.id, id)).run();
32390
+ tx.delete(insights).where(eq32(insights.id, id)).run();
31865
32391
  }
31866
32392
  for (const insight of gbpInsights) {
31867
32393
  const parsed = parseGbpInsightId(insight.id);
@@ -31939,7 +32465,7 @@ var IntelligenceService = class {
31939
32465
  * create per run + aggregate). DB is left untouched.
31940
32466
  */
31941
32467
  backfill(projectName, opts, onProgress) {
31942
- const project = this.db.select().from(projects).where(eq31(projects.name, projectName)).get();
32468
+ const project = this.db.select().from(projects).where(eq32(projects.name, projectName)).get();
31943
32469
  if (!project) {
31944
32470
  throw new Error(`Project "${projectName}" not found`);
31945
32471
  }
@@ -31953,8 +32479,8 @@ var IntelligenceService = class {
31953
32479
  }
31954
32480
  const allRuns = this.db.select().from(runs).where(
31955
32481
  and23(
31956
- eq31(runs.projectId, project.id),
31957
- or5(eq31(runs.status, "completed"), eq31(runs.status, "partial")),
32482
+ eq32(runs.projectId, project.id),
32483
+ or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
31958
32484
  // Backfill must not replay probe runs as if they were real sweeps.
31959
32485
  ne5(runs.trigger, RunTriggers.probe)
31960
32486
  )
@@ -32033,7 +32559,7 @@ var IntelligenceService = class {
32033
32559
  return { processed, skipped, totalInsights };
32034
32560
  }
32035
32561
  loadTrackedCompetitors(projectId) {
32036
- return this.db.select({ domain: competitors.domain }).from(competitors).where(eq31(competitors.projectId, projectId)).all().map((r) => r.domain);
32562
+ return this.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, projectId)).all().map((r) => r.domain);
32037
32563
  }
32038
32564
  /**
32039
32565
  * Wipe transition signals from an analysis result while keeping health.
@@ -32054,15 +32580,15 @@ var IntelligenceService = class {
32054
32580
  }
32055
32581
  persistResult(result, runId, projectId) {
32056
32582
  const previouslyDismissed = /* @__PURE__ */ new Set();
32057
- const existingInsights = this.db.select({ query: insights.query, provider: insights.provider, type: insights.type, dismissed: insights.dismissed }).from(insights).where(eq31(insights.runId, runId)).all();
32583
+ const existingInsights = this.db.select({ query: insights.query, provider: insights.provider, type: insights.type, dismissed: insights.dismissed }).from(insights).where(eq32(insights.runId, runId)).all();
32058
32584
  for (const row of existingInsights) {
32059
32585
  if (row.dismissed) {
32060
32586
  previouslyDismissed.add(`${row.query}:${row.provider}:${row.type}`);
32061
32587
  }
32062
32588
  }
32063
32589
  this.db.transaction((tx) => {
32064
- tx.delete(insights).where(eq31(insights.runId, runId)).run();
32065
- tx.delete(healthSnapshots).where(eq31(healthSnapshots.runId, runId)).run();
32590
+ tx.delete(insights).where(eq32(insights.runId, runId)).run();
32591
+ tx.delete(healthSnapshots).where(eq32(healthSnapshots.runId, runId)).run();
32066
32592
  const now = (/* @__PURE__ */ new Date()).toISOString();
32067
32593
  for (const insight of result.insights) {
32068
32594
  const wasDismissed = previouslyDismissed.has(`${insight.query}:${insight.provider}:${insight.type}`);
@@ -32113,14 +32639,14 @@ var IntelligenceService = class {
32113
32639
  applySeverityTiering(rawInsights, excludeRunId, projectId) {
32114
32640
  const regressions = rawInsights.filter((i) => i.type === "regression");
32115
32641
  if (regressions.length === 0) return rawInsights;
32116
- const gscRows = this.db.select({ query: gscSearchData.query, impressions: gscSearchData.impressions }).from(gscSearchData).where(eq31(gscSearchData.projectId, projectId)).all();
32642
+ const gscRows = this.db.select({ query: gscSearchData.query, impressions: gscSearchData.impressions }).from(gscSearchData).where(eq32(gscSearchData.projectId, projectId)).all();
32117
32643
  const gscConnected = gscRows.length > 0;
32118
32644
  const gscImpressionsByQuery = /* @__PURE__ */ new Map();
32119
32645
  for (const row of gscRows) {
32120
32646
  const key = row.query.toLowerCase();
32121
32647
  gscImpressionsByQuery.set(key, (gscImpressionsByQuery.get(key) ?? 0) + row.impressions);
32122
32648
  }
32123
- const projectRow = this.db.select({ locations: projects.locations }).from(projects).where(eq31(projects.id, projectId)).get();
32649
+ const projectRow = this.db.select({ locations: projects.locations }).from(projects).where(eq32(projects.id, projectId)).get();
32124
32650
  const locationCount = Math.max(
32125
32651
  1,
32126
32652
  (projectRow?.locations ?? []).length
@@ -32128,9 +32654,9 @@ var IntelligenceService = class {
32128
32654
  const ROWS_PER_GROUP_BUDGET = Math.max(2, locationCount);
32129
32655
  const recentRunRows = this.db.select({ id: runs.id, createdAt: runs.createdAt }).from(runs).where(
32130
32656
  and23(
32131
- eq31(runs.projectId, projectId),
32132
- eq31(runs.kind, RunKinds["answer-visibility"]),
32133
- or5(eq31(runs.status, "completed"), eq31(runs.status, "partial")),
32657
+ eq32(runs.projectId, projectId),
32658
+ eq32(runs.kind, RunKinds["answer-visibility"]),
32659
+ or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
32134
32660
  // Defensive — see top of file.
32135
32661
  ne5(runs.trigger, RunTriggers.probe)
32136
32662
  )
@@ -32150,7 +32676,7 @@ var IntelligenceService = class {
32150
32676
  const haveHistory = recentRunIds.length > 0;
32151
32677
  const priorRegressionsByPair = /* @__PURE__ */ new Map();
32152
32678
  if (haveHistory) {
32153
- const priorRows = this.db.select({ query: insights.query, provider: insights.provider, runId: insights.runId }).from(insights).where(and23(eq31(insights.type, "regression"), inArray11(insights.runId, recentRunIds))).all();
32679
+ const priorRows = this.db.select({ query: insights.query, provider: insights.provider, runId: insights.runId }).from(insights).where(and23(eq32(insights.type, "regression"), inArray11(insights.runId, recentRunIds))).all();
32154
32680
  const regressionGroups = /* @__PURE__ */ new Map();
32155
32681
  for (const row of priorRows) {
32156
32682
  if (!row.runId) continue;
@@ -32179,7 +32705,7 @@ var IntelligenceService = class {
32179
32705
  });
32180
32706
  }
32181
32707
  buildRunData(runId, projectId, completedAt, location = null) {
32182
- const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq31(projects.id, projectId)).get();
32708
+ const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq32(projects.id, projectId)).get();
32183
32709
  const projectDomains = projectDomainRow ? effectiveDomains({
32184
32710
  canonicalDomain: projectDomainRow.canonicalDomain,
32185
32711
  ownedDomains: projectDomainRow.ownedDomains
@@ -32195,7 +32721,7 @@ var IntelligenceService = class {
32195
32721
  citedDomains: querySnapshots.citedDomains,
32196
32722
  competitorOverlap: querySnapshots.competitorOverlap,
32197
32723
  snapshotLocation: querySnapshots.location
32198
- }).from(querySnapshots).leftJoin(queries, eq31(querySnapshots.queryId, queries.id)).where(eq31(querySnapshots.runId, runId)).all();
32724
+ }).from(querySnapshots).leftJoin(queries, eq32(querySnapshots.queryId, queries.id)).where(eq32(querySnapshots.runId, runId)).all();
32199
32725
  const snapshots = [];
32200
32726
  let orphanCount = 0;
32201
32727
  for (const r of rows) {