@ainyc/canonry 4.71.1 → 4.72.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.
- package/assets/agent-workspace/skills/aero/references/regression-playbook.md +1 -1
- package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +7 -0
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +20 -2
- package/assets/assets/{BacklinksPage-CQNPYiDA.js → BacklinksPage-CjfpwZEH.js} +1 -1
- package/assets/assets/{ChartPrimitives-BShpLrpS.js → ChartPrimitives-Ckf2FrUy.js} +1 -1
- package/assets/assets/{ProjectPage-CJLw1m4O.js → ProjectPage-DZeplYeC.js} +6 -6
- package/assets/assets/{RunRow-Dq1vs1hA.js → RunRow-BuFyG0V_.js} +1 -1
- package/assets/assets/{RunsPage-CBMa2xWh.js → RunsPage-D-pr000K.js} +1 -1
- package/assets/assets/{SettingsPage-B_XeJDdg.js → SettingsPage-CiaapCYn.js} +1 -1
- package/assets/assets/{TrafficPage-vJv_Mf6f.js → TrafficPage-B40xytJD.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-C3yFwVmQ.js → TrafficSourceDetailPage-7hHem-gM.js} +1 -1
- package/assets/assets/{extract-error-message-CIpeBFLl.js → extract-error-message-3GkDsu1h.js} +1 -1
- package/assets/assets/{index-BXLM3-cs.js → index-BVdH2O9w.js} +77 -77
- package/assets/assets/{server-traffic-Yt3jIi3g.js → server-traffic-CsgPsudZ.js} +1 -1
- package/assets/assets/{trash-2-xGvNHhEj.js → trash-2-B8Ipf9rI.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-CWEV3YMZ.js → chunk-BRXQKUGY.js} +92 -4
- package/dist/{chunk-ETJDAMGA.js → chunk-J7SDOU2J.js} +616 -89
- package/dist/{chunk-ZNWMVYYU.js → chunk-NYZSY5QJ.js} +126 -7
- package/dist/{chunk-5FM7QRYD.js → chunk-SJI6JGPN.js} +1249 -1005
- package/dist/cli.js +306 -84
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-ISO4VGEC.js → intelligence-service-JNF3JRFR.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +9 -9
|
@@ -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-
|
|
225
|
+
} from "./chunk-SJI6JGPN.js";
|
|
216
226
|
|
|
217
227
|
// src/intelligence-service.ts
|
|
218
|
-
import { eq as
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
@@ -27279,6 +27676,7 @@ async function listVercelTrafficEvents(options) {
|
|
|
27279
27676
|
|
|
27280
27677
|
// ../integration-vercel/src/drain.ts
|
|
27281
27678
|
var MIN_SUB_WINDOW_MS = 1e3;
|
|
27679
|
+
var INITIAL_SUB_WINDOW_MS = 5 * 6e4;
|
|
27282
27680
|
var FLOOR_SLICE_MAX_PAGES = 1e3;
|
|
27283
27681
|
var FLOOR_CONGESTION_PROBE_INTERVAL = 60;
|
|
27284
27682
|
var RETENTION_PROBE_WINDOW_MS = 6e4;
|
|
@@ -27333,7 +27731,7 @@ async function drainVercelTrafficEvents(options) {
|
|
|
27333
27731
|
return { events, subWindowCount: 0, effectiveStartMs: startMs, retentionClamped: false, truncatedSliceCount: 0, truncatedSliceStartsMs: [], drainedThroughMs: startMs, deadlineReached: false };
|
|
27334
27732
|
}
|
|
27335
27733
|
let cursorMs = startMs;
|
|
27336
|
-
let spanMs = endMs - startMs;
|
|
27734
|
+
let spanMs = Math.min(endMs - startMs, INITIAL_SUB_WINDOW_MS);
|
|
27337
27735
|
let subWindowCount = 0;
|
|
27338
27736
|
let effectiveStartMs = startMs;
|
|
27339
27737
|
let retentionClamped = false;
|
|
@@ -27375,7 +27773,7 @@ async function drainVercelTrafficEvents(options) {
|
|
|
27375
27773
|
retentionClamped = retainedStartMs > cursorMs;
|
|
27376
27774
|
cursorMs = retainedStartMs;
|
|
27377
27775
|
effectiveStartMs = retainedStartMs;
|
|
27378
|
-
spanMs = Math.max(endMs - cursorMs, MIN_SUB_WINDOW_MS);
|
|
27776
|
+
spanMs = Math.max(Math.min(endMs - cursorMs, INITIAL_SUB_WINDOW_MS), MIN_SUB_WINDOW_MS);
|
|
27379
27777
|
continue;
|
|
27380
27778
|
}
|
|
27381
27779
|
throw error;
|
|
@@ -29214,6 +29612,104 @@ var BING_AUTH_CHECKS = [
|
|
|
29214
29612
|
}
|
|
29215
29613
|
];
|
|
29216
29614
|
|
|
29615
|
+
// ../api-routes/src/doctor/checks/content.ts
|
|
29616
|
+
import { eq as eq26 } from "drizzle-orm";
|
|
29617
|
+
var WINNABILITY_COVERAGE_WARN_THRESHOLD = 0.8;
|
|
29618
|
+
var UNCLASSIFIED_DOMAIN_SAMPLE_LIMIT = 10;
|
|
29619
|
+
function skippedNoProject() {
|
|
29620
|
+
return {
|
|
29621
|
+
status: CheckStatuses.skipped,
|
|
29622
|
+
code: "content.winnability.no-project",
|
|
29623
|
+
summary: "Project context required for content winnability checks.",
|
|
29624
|
+
remediation: "Run `canonry doctor --project <name>` to scope this check to a project."
|
|
29625
|
+
};
|
|
29626
|
+
}
|
|
29627
|
+
function loadProject(ctx) {
|
|
29628
|
+
if (!ctx.project) return null;
|
|
29629
|
+
return ctx.db.select().from(projects).where(eq26(projects.id, ctx.project.id)).get() ?? null;
|
|
29630
|
+
}
|
|
29631
|
+
function percent(value) {
|
|
29632
|
+
return Math.round(value * 100);
|
|
29633
|
+
}
|
|
29634
|
+
var winnabilityCoverageCheck = {
|
|
29635
|
+
id: "content.winnability.coverage",
|
|
29636
|
+
category: CheckCategories.integrations,
|
|
29637
|
+
scope: CheckScopes.project,
|
|
29638
|
+
title: "Content winnability classification coverage",
|
|
29639
|
+
run: (ctx) => {
|
|
29640
|
+
if (!ctx.project) return skippedNoProject();
|
|
29641
|
+
const project = loadProject(ctx);
|
|
29642
|
+
if (!project) {
|
|
29643
|
+
return {
|
|
29644
|
+
status: CheckStatuses.fail,
|
|
29645
|
+
code: "content.winnability.project-missing",
|
|
29646
|
+
summary: "Project row disappeared before the content winnability check could run.",
|
|
29647
|
+
remediation: "Re-run `canonry doctor --project <name>`; if this persists, inspect the local database."
|
|
29648
|
+
};
|
|
29649
|
+
}
|
|
29650
|
+
const input = loadOrchestratorInput(ctx.db, project);
|
|
29651
|
+
const citationCounts = /* @__PURE__ */ new Map();
|
|
29652
|
+
for (const candidate of input.candidateQueries) {
|
|
29653
|
+
for (const cited of candidate.citedSurfaceDomains) {
|
|
29654
|
+
citationCounts.set(cited.domain, (citationCounts.get(cited.domain) ?? 0) + cited.citationCount);
|
|
29655
|
+
}
|
|
29656
|
+
}
|
|
29657
|
+
const citedDomains = [...citationCounts.keys()].sort();
|
|
29658
|
+
if (citedDomains.length === 0) {
|
|
29659
|
+
return {
|
|
29660
|
+
status: CheckStatuses.skipped,
|
|
29661
|
+
code: "content.winnability.no-cited-surface",
|
|
29662
|
+
summary: "No non-owned cited-surface domains in recent content evidence, so the winnability gate has nothing to classify yet.",
|
|
29663
|
+
remediation: `Run \`canonry run ${ctx.project.name}\` to capture fresh answer-engine citations before checking discovery coverage.`,
|
|
29664
|
+
details: {
|
|
29665
|
+
citedSurfaceDomainCount: 0,
|
|
29666
|
+
classifiedDomainCount: input.domainClasses.size
|
|
29667
|
+
}
|
|
29668
|
+
};
|
|
29669
|
+
}
|
|
29670
|
+
const coveredDomains = citedDomains.filter((domain) => input.domainClasses.has(domain));
|
|
29671
|
+
const unclassifiedDomains = citedDomains.filter((domain) => !input.domainClasses.has(domain));
|
|
29672
|
+
const coverage = coveredDomains.length / citedDomains.length;
|
|
29673
|
+
const details = {
|
|
29674
|
+
citedSurfaceDomainCount: citedDomains.length,
|
|
29675
|
+
classifiedDomainCount: input.domainClasses.size,
|
|
29676
|
+
coveredDomainCount: coveredDomains.length,
|
|
29677
|
+
coverage,
|
|
29678
|
+
threshold: WINNABILITY_COVERAGE_WARN_THRESHOLD,
|
|
29679
|
+
unclassifiedDomains: unclassifiedDomains.slice(0, UNCLASSIFIED_DOMAIN_SAMPLE_LIMIT)
|
|
29680
|
+
};
|
|
29681
|
+
if (coveredDomains.length === 0) {
|
|
29682
|
+
return {
|
|
29683
|
+
status: CheckStatuses.warn,
|
|
29684
|
+
code: "content.winnability.no-classifications",
|
|
29685
|
+
summary: `0 of ${citedDomains.length} cited-surface domain(s) have discovery classifications; the winnability gate is failing open.`,
|
|
29686
|
+
remediation: `Run \`canonry discover run ${ctx.project.name} --wait\` to classify cited domains before trusting ownable/ceded content targets.`,
|
|
29687
|
+
details
|
|
29688
|
+
};
|
|
29689
|
+
}
|
|
29690
|
+
if (coverage < WINNABILITY_COVERAGE_WARN_THRESHOLD) {
|
|
29691
|
+
return {
|
|
29692
|
+
status: CheckStatuses.warn,
|
|
29693
|
+
code: "content.winnability.low-coverage",
|
|
29694
|
+
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) classified (${percent(coverage)}%); the winnability gate may miss ceded surfaces.`,
|
|
29695
|
+
remediation: `Run \`canonry discover run ${ctx.project.name} --wait\` to raise classification coverage before relying on ownable/ceded content targets.`,
|
|
29696
|
+
details
|
|
29697
|
+
};
|
|
29698
|
+
}
|
|
29699
|
+
return {
|
|
29700
|
+
status: CheckStatuses.ok,
|
|
29701
|
+
code: "content.winnability.covered",
|
|
29702
|
+
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) classified (${percent(coverage)}%); the winnability gate is active.`,
|
|
29703
|
+
remediation: null,
|
|
29704
|
+
details
|
|
29705
|
+
};
|
|
29706
|
+
}
|
|
29707
|
+
};
|
|
29708
|
+
var CONTENT_CHECKS = [winnabilityCoverageCheck];
|
|
29709
|
+
var CONTENT_CHECK_BY_ID = Object.fromEntries(
|
|
29710
|
+
CONTENT_CHECKS.map((check) => [check.id, check])
|
|
29711
|
+
);
|
|
29712
|
+
|
|
29217
29713
|
// ../api-routes/src/doctor/checks/ga-auth.ts
|
|
29218
29714
|
async function checkServiceAccount(conn) {
|
|
29219
29715
|
if (!conn.propertyId) {
|
|
@@ -29356,10 +29852,10 @@ var ga4ConnectionCheck = {
|
|
|
29356
29852
|
var GA_AUTH_CHECKS = [ga4ConnectionCheck];
|
|
29357
29853
|
|
|
29358
29854
|
// ../api-routes/src/doctor/checks/gbp-auth.ts
|
|
29359
|
-
import { and as and20, eq as
|
|
29855
|
+
import { and as and20, eq as eq27 } from "drizzle-orm";
|
|
29360
29856
|
var RECENT_SYNC_WARN_DAYS = 7;
|
|
29361
29857
|
var RECENT_SYNC_FAIL_DAYS = 30;
|
|
29362
|
-
function
|
|
29858
|
+
function skippedNoProject2() {
|
|
29363
29859
|
return {
|
|
29364
29860
|
status: CheckStatuses.skipped,
|
|
29365
29861
|
code: "gbp.auth.no-project",
|
|
@@ -29376,7 +29872,7 @@ function storeUnavailable() {
|
|
|
29376
29872
|
};
|
|
29377
29873
|
}
|
|
29378
29874
|
async function resolveGbpToken(ctx) {
|
|
29379
|
-
if (!ctx.project) return { ok: false, output:
|
|
29875
|
+
if (!ctx.project) return { ok: false, output: skippedNoProject2() };
|
|
29380
29876
|
const store = ctx.googleConnectionStore;
|
|
29381
29877
|
if (!store) return { ok: false, output: storeUnavailable() };
|
|
29382
29878
|
const auth = ctx.getGoogleAuthConfig?.() ?? {};
|
|
@@ -29454,7 +29950,7 @@ var scopesCheck = {
|
|
|
29454
29950
|
scope: CheckScopes.project,
|
|
29455
29951
|
title: "GBP granted scopes",
|
|
29456
29952
|
run: async (ctx) => {
|
|
29457
|
-
if (!ctx.project) return
|
|
29953
|
+
if (!ctx.project) return skippedNoProject2();
|
|
29458
29954
|
const store = ctx.googleConnectionStore;
|
|
29459
29955
|
if (!store) return storeUnavailable();
|
|
29460
29956
|
const conn = store.getConnection(ctx.project.canonicalDomain, "gbp");
|
|
@@ -29491,7 +29987,7 @@ var accountAccessCheck = {
|
|
|
29491
29987
|
scope: CheckScopes.project,
|
|
29492
29988
|
title: "GBP account access",
|
|
29493
29989
|
run: async (ctx) => {
|
|
29494
|
-
if (!ctx.project) return
|
|
29990
|
+
if (!ctx.project) return skippedNoProject2();
|
|
29495
29991
|
const store = ctx.googleConnectionStore;
|
|
29496
29992
|
if (!store) return storeUnavailable();
|
|
29497
29993
|
const conn = store.getConnection(ctx.project.canonicalDomain, "gbp");
|
|
@@ -29588,8 +30084,8 @@ var recentSyncCheck = {
|
|
|
29588
30084
|
scope: CheckScopes.project,
|
|
29589
30085
|
title: "GBP recent sync",
|
|
29590
30086
|
run: (ctx) => {
|
|
29591
|
-
if (!ctx.project) return
|
|
29592
|
-
const selected = ctx.db.select({ locationName: gbpLocations.locationName, syncedAt: gbpLocations.syncedAt }).from(gbpLocations).where(and20(
|
|
30087
|
+
if (!ctx.project) return skippedNoProject2();
|
|
30088
|
+
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
30089
|
if (selected.length === 0) {
|
|
29594
30090
|
return {
|
|
29595
30091
|
status: CheckStatuses.skipped,
|
|
@@ -29649,7 +30145,7 @@ var GBP_AUTH_CHECK_BY_ID = Object.fromEntries(
|
|
|
29649
30145
|
);
|
|
29650
30146
|
|
|
29651
30147
|
// ../api-routes/src/doctor/checks/places.ts
|
|
29652
|
-
import { eq as
|
|
30148
|
+
import { eq as eq28 } from "drizzle-orm";
|
|
29653
30149
|
var apiKeyCheck = {
|
|
29654
30150
|
id: "gbp.places.api-key",
|
|
29655
30151
|
category: CheckCategories.auth,
|
|
@@ -29694,7 +30190,7 @@ var apiKeyCheck = {
|
|
|
29694
30190
|
details: { tier: cfg.tier }
|
|
29695
30191
|
};
|
|
29696
30192
|
}
|
|
29697
|
-
const rows = ctx.db.select({ placeId: gbpLocations.placeId, selected: gbpLocations.selected }).from(gbpLocations).where(
|
|
30193
|
+
const rows = ctx.db.select({ placeId: gbpLocations.placeId, selected: gbpLocations.selected }).from(gbpLocations).where(eq28(gbpLocations.projectId, ctx.project.id)).all();
|
|
29698
30194
|
const selected = rows.filter((r) => r.selected);
|
|
29699
30195
|
const locationsWithPlaceId = selected.filter((r) => Boolean(r.placeId)).length;
|
|
29700
30196
|
const details = {
|
|
@@ -29730,7 +30226,7 @@ var PLACES_CHECK_BY_ID = Object.fromEntries(
|
|
|
29730
30226
|
var REQUIRED_GSC_SCOPES = [GSC_SCOPE, INDEXING_SCOPE];
|
|
29731
30227
|
async function resolveAccessToken(ctx) {
|
|
29732
30228
|
if (!ctx.project) {
|
|
29733
|
-
return { ok: false, output:
|
|
30229
|
+
return { ok: false, output: skippedNoProject3() };
|
|
29734
30230
|
}
|
|
29735
30231
|
const store = ctx.googleConnectionStore;
|
|
29736
30232
|
if (!store) {
|
|
@@ -29797,7 +30293,7 @@ async function resolveAccessToken(ctx) {
|
|
|
29797
30293
|
};
|
|
29798
30294
|
}
|
|
29799
30295
|
}
|
|
29800
|
-
function
|
|
30296
|
+
function skippedNoProject3() {
|
|
29801
30297
|
return {
|
|
29802
30298
|
status: CheckStatuses.skipped,
|
|
29803
30299
|
code: "google.auth.no-project",
|
|
@@ -29827,7 +30323,7 @@ var propertyAccessCheck = {
|
|
|
29827
30323
|
scope: CheckScopes.project,
|
|
29828
30324
|
title: "GSC property access",
|
|
29829
30325
|
run: async (ctx) => {
|
|
29830
|
-
if (!ctx.project) return
|
|
30326
|
+
if (!ctx.project) return skippedNoProject3();
|
|
29831
30327
|
const store = ctx.googleConnectionStore;
|
|
29832
30328
|
if (!store) {
|
|
29833
30329
|
return {
|
|
@@ -29928,7 +30424,7 @@ var redirectUriCheck = {
|
|
|
29928
30424
|
scope: CheckScopes.project,
|
|
29929
30425
|
title: "OAuth redirect URI",
|
|
29930
30426
|
run: async (ctx) => {
|
|
29931
|
-
if (!ctx.project) return
|
|
30427
|
+
if (!ctx.project) return skippedNoProject3();
|
|
29932
30428
|
const auth = ctx.getGoogleAuthConfig?.() ?? {};
|
|
29933
30429
|
if (!auth.clientId || !auth.clientSecret) {
|
|
29934
30430
|
return {
|
|
@@ -29982,7 +30478,7 @@ var scopesCheck2 = {
|
|
|
29982
30478
|
scope: CheckScopes.project,
|
|
29983
30479
|
title: "GSC granted scopes",
|
|
29984
30480
|
run: async (ctx) => {
|
|
29985
|
-
if (!ctx.project) return
|
|
30481
|
+
if (!ctx.project) return skippedNoProject3();
|
|
29986
30482
|
const store = ctx.googleConnectionStore;
|
|
29987
30483
|
if (!store) {
|
|
29988
30484
|
return {
|
|
@@ -30145,10 +30641,10 @@ var RUNTIME_STATE_CHECKS = [
|
|
|
30145
30641
|
];
|
|
30146
30642
|
|
|
30147
30643
|
// ../api-routes/src/doctor/checks/traffic-source.ts
|
|
30148
|
-
import { and as and21, eq as
|
|
30644
|
+
import { and as and21, eq as eq29, gte as gte4, ne as ne4, sql as sql12 } from "drizzle-orm";
|
|
30149
30645
|
var RECENT_DATA_WARN_DAYS = 7;
|
|
30150
30646
|
var RECENT_DATA_FAIL_DAYS = 30;
|
|
30151
|
-
function
|
|
30647
|
+
function skippedNoProject4() {
|
|
30152
30648
|
return {
|
|
30153
30649
|
status: CheckStatuses.skipped,
|
|
30154
30650
|
code: "traffic.no-project",
|
|
@@ -30160,7 +30656,7 @@ function loadProbes(ctx) {
|
|
|
30160
30656
|
if (!ctx.project) return [];
|
|
30161
30657
|
const rows = ctx.db.select().from(trafficSources).where(
|
|
30162
30658
|
and21(
|
|
30163
|
-
|
|
30659
|
+
eq29(trafficSources.projectId, ctx.project.id),
|
|
30164
30660
|
ne4(trafficSources.status, TrafficSourceStatuses.archived)
|
|
30165
30661
|
)
|
|
30166
30662
|
).all();
|
|
@@ -30182,7 +30678,7 @@ var sourceConnectedCheck = {
|
|
|
30182
30678
|
scope: CheckScopes.project,
|
|
30183
30679
|
title: "Traffic source connected",
|
|
30184
30680
|
run: (ctx) => {
|
|
30185
|
-
if (!ctx.project) return
|
|
30681
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30186
30682
|
const sources = loadProbes(ctx);
|
|
30187
30683
|
if (sources.length === 0) {
|
|
30188
30684
|
return {
|
|
@@ -30226,7 +30722,7 @@ var recentDataCheck = {
|
|
|
30226
30722
|
scope: CheckScopes.project,
|
|
30227
30723
|
title: "Traffic source recent data",
|
|
30228
30724
|
run: (ctx) => {
|
|
30229
|
-
if (!ctx.project) return
|
|
30725
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30230
30726
|
const sources = loadProbes(ctx);
|
|
30231
30727
|
if (sources.length === 0) {
|
|
30232
30728
|
return {
|
|
@@ -30241,7 +30737,7 @@ var recentDataCheck = {
|
|
|
30241
30737
|
const recentCrawlers = Number(
|
|
30242
30738
|
ctx.db.select({ total: sql12`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
|
|
30243
30739
|
and21(
|
|
30244
|
-
|
|
30740
|
+
eq29(crawlerEventsHourly.projectId, ctx.project.id),
|
|
30245
30741
|
gte4(crawlerEventsHourly.tsHour, warnCutoff)
|
|
30246
30742
|
)
|
|
30247
30743
|
).get()?.total ?? 0
|
|
@@ -30249,7 +30745,7 @@ var recentDataCheck = {
|
|
|
30249
30745
|
const recentReferrals = Number(
|
|
30250
30746
|
ctx.db.select({ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
|
|
30251
30747
|
and21(
|
|
30252
|
-
|
|
30748
|
+
eq29(aiReferralEventsHourly.projectId, ctx.project.id),
|
|
30253
30749
|
gte4(aiReferralEventsHourly.tsHour, warnCutoff)
|
|
30254
30750
|
)
|
|
30255
30751
|
).get()?.total ?? 0
|
|
@@ -30265,7 +30761,7 @@ var recentDataCheck = {
|
|
|
30265
30761
|
const olderCrawlers = Number(
|
|
30266
30762
|
ctx.db.select({ total: sql12`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
|
|
30267
30763
|
and21(
|
|
30268
|
-
|
|
30764
|
+
eq29(crawlerEventsHourly.projectId, ctx.project.id),
|
|
30269
30765
|
gte4(crawlerEventsHourly.tsHour, failCutoff)
|
|
30270
30766
|
)
|
|
30271
30767
|
).get()?.total ?? 0
|
|
@@ -30273,7 +30769,7 @@ var recentDataCheck = {
|
|
|
30273
30769
|
const olderReferrals = Number(
|
|
30274
30770
|
ctx.db.select({ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
|
|
30275
30771
|
and21(
|
|
30276
|
-
|
|
30772
|
+
eq29(aiReferralEventsHourly.projectId, ctx.project.id),
|
|
30277
30773
|
gte4(aiReferralEventsHourly.tsHour, failCutoff)
|
|
30278
30774
|
)
|
|
30279
30775
|
).get()?.total ?? 0
|
|
@@ -30388,7 +30884,7 @@ var credentialsCheck = {
|
|
|
30388
30884
|
scope: CheckScopes.project,
|
|
30389
30885
|
title: "Traffic source credentials",
|
|
30390
30886
|
run: async (ctx) => {
|
|
30391
|
-
if (!ctx.project) return
|
|
30887
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30392
30888
|
const sources = loadProbes(ctx);
|
|
30393
30889
|
if (sources.length === 0) {
|
|
30394
30890
|
return {
|
|
@@ -30417,7 +30913,7 @@ var scopesCheck3 = {
|
|
|
30417
30913
|
scope: CheckScopes.project,
|
|
30418
30914
|
title: "Traffic source scopes",
|
|
30419
30915
|
run: async (ctx) => {
|
|
30420
|
-
if (!ctx.project) return
|
|
30916
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30421
30917
|
const sources = loadProbes(ctx);
|
|
30422
30918
|
if (sources.length === 0) {
|
|
30423
30919
|
return {
|
|
@@ -30530,6 +31026,7 @@ var ALL_CHECKS = [
|
|
|
30530
31026
|
...GA_AUTH_CHECKS,
|
|
30531
31027
|
...PROVIDERS_CHECKS,
|
|
30532
31028
|
...TRAFFIC_SOURCE_CHECKS,
|
|
31029
|
+
...CONTENT_CHECKS,
|
|
30533
31030
|
...AGENT_CHECKS
|
|
30534
31031
|
];
|
|
30535
31032
|
|
|
@@ -30652,7 +31149,7 @@ async function doctorRoutes(app, opts) {
|
|
|
30652
31149
|
|
|
30653
31150
|
// ../api-routes/src/discovery/routes.ts
|
|
30654
31151
|
import crypto25 from "crypto";
|
|
30655
|
-
import { and as and22, desc as desc15, eq as
|
|
31152
|
+
import { and as and22, desc as desc15, eq as eq30, gte as gte5, inArray as inArray10 } from "drizzle-orm";
|
|
30656
31153
|
var MAX_INFLIGHT_DISCOVERY_AGE_MS = 2 * 60 * 60 * 1e3;
|
|
30657
31154
|
async function discoveryRoutes(app, opts) {
|
|
30658
31155
|
app.post("/projects/:name/discover/run", async (request, reply) => {
|
|
@@ -30685,8 +31182,8 @@ async function discoveryRoutes(app, opts) {
|
|
|
30685
31182
|
const ageFloorIso = new Date(Date.now() - MAX_INFLIGHT_DISCOVERY_AGE_MS).toISOString();
|
|
30686
31183
|
const decision = app.db.transaction((tx) => {
|
|
30687
31184
|
const existing = tx.select({ id: discoverySessions.id, runId: discoverySessions.runId }).from(discoverySessions).where(and22(
|
|
30688
|
-
|
|
30689
|
-
|
|
31185
|
+
eq30(discoverySessions.projectId, project.id),
|
|
31186
|
+
eq30(discoverySessions.icpDescription, icpDescription),
|
|
30690
31187
|
inArray10(discoverySessions.status, [
|
|
30691
31188
|
DiscoverySessionStatuses.queued,
|
|
30692
31189
|
DiscoverySessionStatuses.seeding,
|
|
@@ -30756,7 +31253,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30756
31253
|
const project = resolveProject(app.db, request.params.name);
|
|
30757
31254
|
const parsedLimit = parseInt(request.query.limit ?? "", 10);
|
|
30758
31255
|
const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? 50 : parsedLimit;
|
|
30759
|
-
const rows = app.db.select().from(discoverySessions).where(
|
|
31256
|
+
const rows = app.db.select().from(discoverySessions).where(eq30(discoverySessions.projectId, project.id)).orderBy(desc15(discoverySessions.createdAt)).limit(limit).all();
|
|
30760
31257
|
return reply.send(rows.map(serializeSession));
|
|
30761
31258
|
}
|
|
30762
31259
|
);
|
|
@@ -30764,11 +31261,11 @@ async function discoveryRoutes(app, opts) {
|
|
|
30764
31261
|
"/projects/:name/discover/sessions/:id",
|
|
30765
31262
|
async (request, reply) => {
|
|
30766
31263
|
const project = resolveProject(app.db, request.params.name);
|
|
30767
|
-
const session = app.db.select().from(discoverySessions).where(
|
|
31264
|
+
const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
|
|
30768
31265
|
if (!session || session.projectId !== project.id) {
|
|
30769
31266
|
throw notFound("Discovery session", request.params.id);
|
|
30770
31267
|
}
|
|
30771
|
-
const probeRows = app.db.select().from(discoveryProbes).where(
|
|
31268
|
+
const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
|
|
30772
31269
|
const detail = {
|
|
30773
31270
|
...serializeSession(session),
|
|
30774
31271
|
probes: probeRows.map(serializeProbe)
|
|
@@ -30780,12 +31277,12 @@ async function discoveryRoutes(app, opts) {
|
|
|
30780
31277
|
"/projects/:name/discover/sessions/:id/promote",
|
|
30781
31278
|
async (request, reply) => {
|
|
30782
31279
|
const project = resolveProject(app.db, request.params.name);
|
|
30783
|
-
const session = app.db.select().from(discoverySessions).where(
|
|
31280
|
+
const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
|
|
30784
31281
|
if (!session || session.projectId !== project.id) {
|
|
30785
31282
|
throw notFound("Discovery session", request.params.id);
|
|
30786
31283
|
}
|
|
30787
|
-
const probeRows = app.db.select().from(discoveryProbes).where(
|
|
30788
|
-
const existingCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(
|
|
31284
|
+
const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
|
|
31285
|
+
const existingCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq30(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase());
|
|
30789
31286
|
const seenCompetitors = new Set(existingCompetitors);
|
|
30790
31287
|
const cited = /* @__PURE__ */ new Set();
|
|
30791
31288
|
const aspirational = /* @__PURE__ */ new Set();
|
|
@@ -30814,7 +31311,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30814
31311
|
);
|
|
30815
31312
|
app.post("/projects/:name/discover/sessions/:id/promote", async (request, reply) => {
|
|
30816
31313
|
const project = resolveProject(app.db, request.params.name);
|
|
30817
|
-
const session = app.db.select().from(discoverySessions).where(
|
|
31314
|
+
const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
|
|
30818
31315
|
if (!session || session.projectId !== project.id) {
|
|
30819
31316
|
throw notFound("Discovery session", request.params.id);
|
|
30820
31317
|
}
|
|
@@ -30837,7 +31334,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30837
31334
|
const bucketSet = new Set(buckets);
|
|
30838
31335
|
const includeCompetitors = parsed.data.includeCompetitors ?? true;
|
|
30839
31336
|
const competitorTypes = parsed.data.competitorTypes ?? DEFAULT_DISCOVERY_PROMOTE_COMPETITOR_TYPES;
|
|
30840
|
-
const probeRows = app.db.select().from(discoveryProbes).where(
|
|
31337
|
+
const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
|
|
30841
31338
|
const candidateQueries = /* @__PURE__ */ new Set();
|
|
30842
31339
|
for (const probe of probeRows) {
|
|
30843
31340
|
if (!probe.bucket) continue;
|
|
@@ -30845,7 +31342,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30845
31342
|
if (bucket.success && bucketSet.has(bucket.data)) candidateQueries.add(probe.query);
|
|
30846
31343
|
}
|
|
30847
31344
|
const existingQueries = new Set(
|
|
30848
|
-
app.db.select({ query: queries.query }).from(queries).where(
|
|
31345
|
+
app.db.select({ query: queries.query }).from(queries).where(eq30(queries.projectId, project.id)).all().map((r) => r.query.toLowerCase())
|
|
30849
31346
|
);
|
|
30850
31347
|
const promotedQueries = [];
|
|
30851
31348
|
const skippedQueries = [];
|
|
@@ -30861,7 +31358,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30861
31358
|
const skippedCompetitors = [];
|
|
30862
31359
|
if (includeCompetitors) {
|
|
30863
31360
|
const existingCompetitors = new Set(
|
|
30864
|
-
app.db.select({ domain: competitors.domain }).from(competitors).where(
|
|
31361
|
+
app.db.select({ domain: competitors.domain }).from(competitors).where(eq30(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase())
|
|
30865
31362
|
);
|
|
30866
31363
|
const competitorMap = parseCompetitorMap(session.competitorMap);
|
|
30867
31364
|
for (const entry of selectEligibleCompetitors(competitorMap, competitorTypes)) {
|
|
@@ -30964,7 +31461,7 @@ function selectEligibleCompetitors(competitorMap, competitorTypes) {
|
|
|
30964
31461
|
|
|
30965
31462
|
// ../api-routes/src/discovery/orchestrate.ts
|
|
30966
31463
|
import crypto26 from "crypto";
|
|
30967
|
-
import { eq as
|
|
31464
|
+
import { eq as eq31 } from "drizzle-orm";
|
|
30968
31465
|
var DEFAULT_DEDUP_THRESHOLD = 0.85;
|
|
30969
31466
|
var DEFAULT_MAX_PROBES = 100;
|
|
30970
31467
|
var ABSOLUTE_MAX_PROBES = 500;
|
|
@@ -31019,7 +31516,7 @@ async function executeDiscovery(opts) {
|
|
|
31019
31516
|
status: DiscoverySessionStatuses.seeding,
|
|
31020
31517
|
dedupThreshold,
|
|
31021
31518
|
startedAt
|
|
31022
|
-
}).where(
|
|
31519
|
+
}).where(eq31(discoverySessions.id, opts.sessionId)).run();
|
|
31023
31520
|
const seedResult = await opts.deps.seed({
|
|
31024
31521
|
project: opts.project,
|
|
31025
31522
|
icpDescription: opts.icpDescription,
|
|
@@ -31039,7 +31536,7 @@ async function executeDiscovery(opts) {
|
|
|
31039
31536
|
seedProvider: seedResult.provider,
|
|
31040
31537
|
seedCountRaw,
|
|
31041
31538
|
seedCount
|
|
31042
|
-
}).where(
|
|
31539
|
+
}).where(eq31(discoverySessions.id, opts.sessionId)).run();
|
|
31043
31540
|
const probeRows = [];
|
|
31044
31541
|
const buckets = { cited: 0, aspirational: 0, "wasted-surface": 0 };
|
|
31045
31542
|
for (const query of probedCanonicals) {
|
|
@@ -31079,7 +31576,8 @@ async function executeDiscovery(opts) {
|
|
|
31079
31576
|
wastedCount: buckets["wasted-surface"],
|
|
31080
31577
|
competitorMap,
|
|
31081
31578
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
31082
|
-
}).where(
|
|
31579
|
+
}).where(eq31(discoverySessions.id, opts.sessionId)).run();
|
|
31580
|
+
upsertDomainClassifications(opts.db, opts.project.id, opts.sessionId, competitorMap);
|
|
31083
31581
|
return {
|
|
31084
31582
|
buckets,
|
|
31085
31583
|
competitorMap,
|
|
@@ -31088,12 +31586,37 @@ async function executeDiscovery(opts) {
|
|
|
31088
31586
|
seedProvider: seedResult.provider
|
|
31089
31587
|
};
|
|
31090
31588
|
}
|
|
31589
|
+
function upsertDomainClassifications(db, projectId, sessionId, competitorMap) {
|
|
31590
|
+
if (competitorMap.length === 0) return;
|
|
31591
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
31592
|
+
for (const entry of competitorMap) {
|
|
31593
|
+
const domain = normalizeDomain(entry.domain);
|
|
31594
|
+
if (!domain) continue;
|
|
31595
|
+
db.insert(domainClassifications).values({
|
|
31596
|
+
id: crypto26.randomUUID(),
|
|
31597
|
+
projectId,
|
|
31598
|
+
domain,
|
|
31599
|
+
competitorType: entry.competitorType,
|
|
31600
|
+
hits: entry.hits,
|
|
31601
|
+
sessionId,
|
|
31602
|
+
updatedAt: now
|
|
31603
|
+
}).onConflictDoUpdate({
|
|
31604
|
+
target: [domainClassifications.projectId, domainClassifications.domain],
|
|
31605
|
+
set: {
|
|
31606
|
+
competitorType: entry.competitorType,
|
|
31607
|
+
hits: entry.hits,
|
|
31608
|
+
sessionId,
|
|
31609
|
+
updatedAt: now
|
|
31610
|
+
}
|
|
31611
|
+
}).run();
|
|
31612
|
+
}
|
|
31613
|
+
}
|
|
31091
31614
|
function markSessionFailed(db, sessionId, error) {
|
|
31092
31615
|
db.update(discoverySessions).set({
|
|
31093
31616
|
status: DiscoverySessionStatuses.failed,
|
|
31094
31617
|
error,
|
|
31095
31618
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
31096
|
-
}).where(
|
|
31619
|
+
}).where(eq31(discoverySessions.id, sessionId)).run();
|
|
31097
31620
|
}
|
|
31098
31621
|
function dedupeStrings(input) {
|
|
31099
31622
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -31190,7 +31713,11 @@ async function apiRoutes(app, opts) {
|
|
|
31190
31713
|
await api.register(reportRoutes);
|
|
31191
31714
|
await api.register(citationRoutes);
|
|
31192
31715
|
await api.register(compositeRoutes);
|
|
31193
|
-
await api.register(contentRoutes, {
|
|
31716
|
+
await api.register(contentRoutes, {
|
|
31717
|
+
explainContentRecommendation: opts.explainContentRecommendation,
|
|
31718
|
+
briefContentRecommendation: opts.briefContentRecommendation,
|
|
31719
|
+
briefPromptVersion: opts.briefPromptVersion
|
|
31720
|
+
});
|
|
31194
31721
|
await api.register(settingsRoutes, {
|
|
31195
31722
|
providerSummary: opts.providerSummary,
|
|
31196
31723
|
providerAdapters: opts.providerAdapters,
|
|
@@ -31656,8 +32183,8 @@ var IntelligenceService = class {
|
|
|
31656
32183
|
analyzeAndPersist(runId, projectId) {
|
|
31657
32184
|
const recentRuns = this.db.select().from(runs).where(
|
|
31658
32185
|
and23(
|
|
31659
|
-
|
|
31660
|
-
or5(
|
|
32186
|
+
eq32(runs.projectId, projectId),
|
|
32187
|
+
or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
|
|
31661
32188
|
// Defensive: RunCoordinator already skips probes before this is
|
|
31662
32189
|
// called, but if a future call site invokes analyzeAndPersist
|
|
31663
32190
|
// directly for a probe, probes still must not pollute the
|
|
@@ -31739,7 +32266,7 @@ var IntelligenceService = class {
|
|
|
31739
32266
|
* Returns the persisted insights so the coordinator can count critical/high.
|
|
31740
32267
|
*/
|
|
31741
32268
|
analyzeAndPersistGbp(runId, projectId) {
|
|
31742
|
-
const runRow = this.db.select({ createdAt: runs.createdAt, startedAt: runs.startedAt, finishedAt: runs.finishedAt }).from(runs).where(
|
|
32269
|
+
const runRow = this.db.select({ createdAt: runs.createdAt, startedAt: runs.startedAt, finishedAt: runs.finishedAt }).from(runs).where(eq32(runs.id, runId)).get();
|
|
31743
32270
|
if (!runRow) {
|
|
31744
32271
|
log.info("gbp-intelligence.skip", { runId, reason: "run not found" });
|
|
31745
32272
|
this.persistGbpInsights(runId, projectId, [], []);
|
|
@@ -31748,8 +32275,8 @@ var IntelligenceService = class {
|
|
|
31748
32275
|
const windowStart = runRow.startedAt ?? runRow.createdAt;
|
|
31749
32276
|
const windowEnd = runRow.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
31750
32277
|
const selected = this.db.select().from(gbpLocations).where(and23(
|
|
31751
|
-
|
|
31752
|
-
|
|
32278
|
+
eq32(gbpLocations.projectId, projectId),
|
|
32279
|
+
eq32(gbpLocations.selected, true),
|
|
31753
32280
|
gte6(gbpLocations.syncedAt, windowStart),
|
|
31754
32281
|
lte3(gbpLocations.syncedAt, windowEnd)
|
|
31755
32282
|
)).all();
|
|
@@ -31784,10 +32311,10 @@ var IntelligenceService = class {
|
|
|
31784
32311
|
}
|
|
31785
32312
|
/** Build the per-location signal bundle the GBP analyzer consumes. */
|
|
31786
32313
|
buildGbpLocationSignals(projectId, locationName, displayName, fallbackDate) {
|
|
31787
|
-
const metricRows = this.db.select({ metric: gbpDailyMetrics.metric, date: gbpDailyMetrics.date, value: gbpDailyMetrics.value }).from(gbpDailyMetrics).where(and23(
|
|
31788
|
-
const placeActionRows = this.db.select({ placeActionType: gbpPlaceActions.placeActionType, providerType: gbpPlaceActions.providerType }).from(gbpPlaceActions).where(and23(
|
|
31789
|
-
const lodgingRow = this.db.select({ populatedGroupCount: gbpLodgingSnapshots.populatedGroupCount }).from(gbpLodgingSnapshots).where(and23(
|
|
31790
|
-
const placeRow = this.db.select({ attributes: gbpPlaceDetails.attributes }).from(gbpPlaceDetails).where(and23(
|
|
32314
|
+
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();
|
|
32315
|
+
const placeActionRows = this.db.select({ placeActionType: gbpPlaceActions.placeActionType, providerType: gbpPlaceActions.providerType }).from(gbpPlaceActions).where(and23(eq32(gbpPlaceActions.projectId, projectId), eq32(gbpPlaceActions.locationName, locationName))).all();
|
|
32316
|
+
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();
|
|
32317
|
+
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
32318
|
const placesAmenities = placeRow ? extractPlaceAmenities(placeRow.attributes) : [];
|
|
31792
32319
|
const summary = buildGbpSummary({
|
|
31793
32320
|
locationName,
|
|
@@ -31819,7 +32346,7 @@ var IntelligenceService = class {
|
|
|
31819
32346
|
/** Build the month-over-month keyword series for a location from the
|
|
31820
32347
|
* accumulating gbp_keyword_monthly table (latest complete month vs prior). */
|
|
31821
32348
|
buildGbpKeywordTrend(projectId, locationName) {
|
|
31822
|
-
const rows = this.db.select({ month: gbpKeywordMonthly.month, keyword: gbpKeywordMonthly.keyword, valueCount: gbpKeywordMonthly.valueCount }).from(gbpKeywordMonthly).where(and23(
|
|
32349
|
+
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
32350
|
if (rows.length === 0) return { recentMonth: null, priorMonth: null, points: [] };
|
|
31824
32351
|
const months = [...new Set(rows.map((r) => r.month))].sort().reverse();
|
|
31825
32352
|
const recentMonth = months[0] ?? null;
|
|
@@ -31850,7 +32377,7 @@ var IntelligenceService = class {
|
|
|
31850
32377
|
*/
|
|
31851
32378
|
persistGbpInsights(runId, projectId, gbpInsights, coveredLocationNames) {
|
|
31852
32379
|
const covered = new Set(coveredLocationNames);
|
|
31853
|
-
const existing = this.db.select({ id: insights.id, dismissed: insights.dismissed }).from(insights).where(and23(
|
|
32380
|
+
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
32381
|
const staleIds = [];
|
|
31855
32382
|
const dismissedSlots = /* @__PURE__ */ new Set();
|
|
31856
32383
|
for (const row of existing) {
|
|
@@ -31861,7 +32388,7 @@ var IntelligenceService = class {
|
|
|
31861
32388
|
}
|
|
31862
32389
|
this.db.transaction((tx) => {
|
|
31863
32390
|
for (const id of staleIds) {
|
|
31864
|
-
tx.delete(insights).where(
|
|
32391
|
+
tx.delete(insights).where(eq32(insights.id, id)).run();
|
|
31865
32392
|
}
|
|
31866
32393
|
for (const insight of gbpInsights) {
|
|
31867
32394
|
const parsed = parseGbpInsightId(insight.id);
|
|
@@ -31939,7 +32466,7 @@ var IntelligenceService = class {
|
|
|
31939
32466
|
* create per run + aggregate). DB is left untouched.
|
|
31940
32467
|
*/
|
|
31941
32468
|
backfill(projectName, opts, onProgress) {
|
|
31942
|
-
const project = this.db.select().from(projects).where(
|
|
32469
|
+
const project = this.db.select().from(projects).where(eq32(projects.name, projectName)).get();
|
|
31943
32470
|
if (!project) {
|
|
31944
32471
|
throw new Error(`Project "${projectName}" not found`);
|
|
31945
32472
|
}
|
|
@@ -31953,8 +32480,8 @@ var IntelligenceService = class {
|
|
|
31953
32480
|
}
|
|
31954
32481
|
const allRuns = this.db.select().from(runs).where(
|
|
31955
32482
|
and23(
|
|
31956
|
-
|
|
31957
|
-
or5(
|
|
32483
|
+
eq32(runs.projectId, project.id),
|
|
32484
|
+
or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
|
|
31958
32485
|
// Backfill must not replay probe runs as if they were real sweeps.
|
|
31959
32486
|
ne5(runs.trigger, RunTriggers.probe)
|
|
31960
32487
|
)
|
|
@@ -32033,7 +32560,7 @@ var IntelligenceService = class {
|
|
|
32033
32560
|
return { processed, skipped, totalInsights };
|
|
32034
32561
|
}
|
|
32035
32562
|
loadTrackedCompetitors(projectId) {
|
|
32036
|
-
return this.db.select({ domain: competitors.domain }).from(competitors).where(
|
|
32563
|
+
return this.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, projectId)).all().map((r) => r.domain);
|
|
32037
32564
|
}
|
|
32038
32565
|
/**
|
|
32039
32566
|
* Wipe transition signals from an analysis result while keeping health.
|
|
@@ -32054,15 +32581,15 @@ var IntelligenceService = class {
|
|
|
32054
32581
|
}
|
|
32055
32582
|
persistResult(result, runId, projectId) {
|
|
32056
32583
|
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(
|
|
32584
|
+
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
32585
|
for (const row of existingInsights) {
|
|
32059
32586
|
if (row.dismissed) {
|
|
32060
32587
|
previouslyDismissed.add(`${row.query}:${row.provider}:${row.type}`);
|
|
32061
32588
|
}
|
|
32062
32589
|
}
|
|
32063
32590
|
this.db.transaction((tx) => {
|
|
32064
|
-
tx.delete(insights).where(
|
|
32065
|
-
tx.delete(healthSnapshots).where(
|
|
32591
|
+
tx.delete(insights).where(eq32(insights.runId, runId)).run();
|
|
32592
|
+
tx.delete(healthSnapshots).where(eq32(healthSnapshots.runId, runId)).run();
|
|
32066
32593
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
32067
32594
|
for (const insight of result.insights) {
|
|
32068
32595
|
const wasDismissed = previouslyDismissed.has(`${insight.query}:${insight.provider}:${insight.type}`);
|
|
@@ -32113,14 +32640,14 @@ var IntelligenceService = class {
|
|
|
32113
32640
|
applySeverityTiering(rawInsights, excludeRunId, projectId) {
|
|
32114
32641
|
const regressions = rawInsights.filter((i) => i.type === "regression");
|
|
32115
32642
|
if (regressions.length === 0) return rawInsights;
|
|
32116
|
-
const gscRows = this.db.select({ query: gscSearchData.query, impressions: gscSearchData.impressions }).from(gscSearchData).where(
|
|
32643
|
+
const gscRows = this.db.select({ query: gscSearchData.query, impressions: gscSearchData.impressions }).from(gscSearchData).where(eq32(gscSearchData.projectId, projectId)).all();
|
|
32117
32644
|
const gscConnected = gscRows.length > 0;
|
|
32118
32645
|
const gscImpressionsByQuery = /* @__PURE__ */ new Map();
|
|
32119
32646
|
for (const row of gscRows) {
|
|
32120
32647
|
const key = row.query.toLowerCase();
|
|
32121
32648
|
gscImpressionsByQuery.set(key, (gscImpressionsByQuery.get(key) ?? 0) + row.impressions);
|
|
32122
32649
|
}
|
|
32123
|
-
const projectRow = this.db.select({ locations: projects.locations }).from(projects).where(
|
|
32650
|
+
const projectRow = this.db.select({ locations: projects.locations }).from(projects).where(eq32(projects.id, projectId)).get();
|
|
32124
32651
|
const locationCount = Math.max(
|
|
32125
32652
|
1,
|
|
32126
32653
|
(projectRow?.locations ?? []).length
|
|
@@ -32128,9 +32655,9 @@ var IntelligenceService = class {
|
|
|
32128
32655
|
const ROWS_PER_GROUP_BUDGET = Math.max(2, locationCount);
|
|
32129
32656
|
const recentRunRows = this.db.select({ id: runs.id, createdAt: runs.createdAt }).from(runs).where(
|
|
32130
32657
|
and23(
|
|
32131
|
-
|
|
32132
|
-
|
|
32133
|
-
or5(
|
|
32658
|
+
eq32(runs.projectId, projectId),
|
|
32659
|
+
eq32(runs.kind, RunKinds["answer-visibility"]),
|
|
32660
|
+
or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
|
|
32134
32661
|
// Defensive — see top of file.
|
|
32135
32662
|
ne5(runs.trigger, RunTriggers.probe)
|
|
32136
32663
|
)
|
|
@@ -32150,7 +32677,7 @@ var IntelligenceService = class {
|
|
|
32150
32677
|
const haveHistory = recentRunIds.length > 0;
|
|
32151
32678
|
const priorRegressionsByPair = /* @__PURE__ */ new Map();
|
|
32152
32679
|
if (haveHistory) {
|
|
32153
|
-
const priorRows = this.db.select({ query: insights.query, provider: insights.provider, runId: insights.runId }).from(insights).where(and23(
|
|
32680
|
+
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
32681
|
const regressionGroups = /* @__PURE__ */ new Map();
|
|
32155
32682
|
for (const row of priorRows) {
|
|
32156
32683
|
if (!row.runId) continue;
|
|
@@ -32179,7 +32706,7 @@ var IntelligenceService = class {
|
|
|
32179
32706
|
});
|
|
32180
32707
|
}
|
|
32181
32708
|
buildRunData(runId, projectId, completedAt, location = null) {
|
|
32182
|
-
const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(
|
|
32709
|
+
const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq32(projects.id, projectId)).get();
|
|
32183
32710
|
const projectDomains = projectDomainRow ? effectiveDomains({
|
|
32184
32711
|
canonicalDomain: projectDomainRow.canonicalDomain,
|
|
32185
32712
|
ownedDomains: projectDomainRow.ownedDomains
|
|
@@ -32195,7 +32722,7 @@ var IntelligenceService = class {
|
|
|
32195
32722
|
citedDomains: querySnapshots.citedDomains,
|
|
32196
32723
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
32197
32724
|
snapshotLocation: querySnapshots.location
|
|
32198
|
-
}).from(querySnapshots).leftJoin(queries,
|
|
32725
|
+
}).from(querySnapshots).leftJoin(queries, eq32(querySnapshots.queryId, queries.id)).where(eq32(querySnapshots.runId, runId)).all();
|
|
32199
32726
|
const snapshots = [];
|
|
32200
32727
|
let orphanCount = 0;
|
|
32201
32728
|
for (const r of rows) {
|