@canonry/canonry 5.18.0 → 5.19.1

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 (34) hide show
  1. package/assets/agent-workspace/skills/aero/SKILL.md +13 -8
  2. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +27 -10
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +4 -1
  4. package/assets/assets/{AuditHistoryPanel-Di5c6xSa.js → AuditHistoryPanel-ddq5tdlB.js} +1 -1
  5. package/assets/assets/{BacklinksPage-CZi3sZ-7.js → BacklinksPage-g57_xzTX.js} +1 -1
  6. package/assets/assets/{HistoryPage-BY7HiZWU.js → HistoryPage-C-TdCUQ2.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-D3AdBHpI.js → MeasurementPropertyPage-CAADmNfw.js} +1 -1
  8. package/assets/assets/{ProjectPage-NyUl1dkF.js → ProjectPage-CoBwpHmA.js} +1 -1
  9. package/assets/assets/{RunRow-cRjiHQbt.js → RunRow-BpG0Zy-x.js} +1 -1
  10. package/assets/assets/{RunsPage-DZP_kqJs.js → RunsPage-DK3pb1LH.js} +1 -1
  11. package/assets/assets/{SettingsPage-BqJXYCDc.js → SettingsPage-BruiY8Zh.js} +1 -1
  12. package/assets/assets/{SiteHealthSection-Brr0C56X.js → SiteHealthSection-De4U11Gv.js} +3 -3
  13. package/assets/assets/{TrafficPage-k5NPSDur.js → TrafficPage-BVolLXT2.js} +1 -1
  14. package/assets/assets/{TrafficSourceDetailPage-BKNzWnxA.js → TrafficSourceDetailPage-CFDb6lPD.js} +1 -1
  15. package/assets/assets/{extract-error-message-Cnvelll0.js → extract-error-message-Bk1RTjzo.js} +1 -1
  16. package/assets/assets/index-B--AA1mG.js +86 -0
  17. package/assets/assets/{react-sigma_core.esm.min-CthGjQKl.js → react-sigma_core.esm.min--QXVK1JS.js} +1 -1
  18. package/assets/assets/{v2-overview-adapter-q2o2MzmG.js → v2-overview-adapter-CpjJPZsC.js} +1 -1
  19. package/assets/index.html +1 -1
  20. package/dist/{chunk-Y5IDGH5B.js → chunk-3IAIW5KH.js} +534 -136
  21. package/dist/chunk-4WROKFN6.js +3991 -0
  22. package/dist/{chunk-TMTO2JHU.js → chunk-EJXDABGU.js} +21 -3970
  23. package/dist/{chunk-HIFBIDOW.js → chunk-EJZFTNZG.js} +444 -185
  24. package/dist/{chunk-X3GFCKUJ.js → chunk-J3JXJIST.js} +3 -3
  25. package/dist/{chunk-HAABFHXB.js → chunk-PGOID3LE.js} +2 -2
  26. package/dist/{chunk-77VHT3JD.js → chunk-SOCYTKTF.js} +223 -23
  27. package/dist/cli.js +84 -27
  28. package/dist/{demo-server-JBGYZKAI.js → demo-server-BLDYKCFS.js} +1071 -187
  29. package/dist/index.js +6 -6
  30. package/dist/{intelligence-service-EORTVFDF.js → intelligence-service-3MHYLKMS.js} +2 -2
  31. package/dist/mcp.js +4 -4
  32. package/package.json +9 -9
  33. package/assets/assets/index-Cd_1GknE.js +0 -86
  34. package/dist/chunk-AOJU5LKX.js +0 -8
@@ -32,6 +32,7 @@ import {
32
32
  AppError,
33
33
  BacklinkSources,
34
34
  COMPETITOR_LANDSCAPE_MODEL_GROUP_LIMIT,
35
+ COMPETITOR_LANDSCAPE_OBSERVED_NAME_LIMIT,
35
36
  CcReleaseSyncStatuses,
36
37
  CheckCategories,
37
38
  CheckScopes,
@@ -79,6 +80,10 @@ import {
79
80
  MEASUREMENT_PAGE_DEFAULT_LIMIT,
80
81
  MEASUREMENT_PAGE_MAX_LIMIT,
81
82
  MEASUREMENT_PLAN_V2_SCHEMA_VERSION,
83
+ MEASUREMENT_PORTFOLIO_ANSWER_SOURCES_LIMIT,
84
+ MEASUREMENT_PORTFOLIO_DEFAULT_LIMIT,
85
+ MEASUREMENT_PORTFOLIO_ROW_EVIDENCE_LIMIT,
86
+ MEASUREMENT_PORTFOLIO_TIE_NOTE,
82
87
  MIN_DOMAIN_BRAND_KEY_LENGTH,
83
88
  MIN_TREND_POINTS,
84
89
  MODEL_ATTRIBUTION_EVENT_LIMIT,
@@ -733,6 +738,7 @@ import {
733
738
  snapshotRequestInputSchema,
734
739
  snapshotRequestSchema,
735
740
  sourceBreakdownDtoSchema,
741
+ sourceBreakdownQuerySchema,
736
742
  splitList,
737
743
  sumInfraHits,
738
744
  summarizeCheckResults,
@@ -785,7 +791,7 @@ import {
785
791
  wordpressSchemaDeployResultDtoSchema,
786
792
  wordpressSchemaStatusResultDtoSchema,
787
793
  wordpressStatusDtoSchema
788
- } from "./chunk-77VHT3JD.js";
794
+ } from "./chunk-SOCYTKTF.js";
789
795
 
790
796
  // src/intelligence-service.ts
791
797
  import { eq as eq71, desc as desc32, asc as asc12, and as and60, ne as ne11, or as or18, inArray as inArray27, gte as gte18, lte as lte15, isNull as isNull13, sql as sql28, exists } from "drizzle-orm";
@@ -9155,7 +9161,7 @@ function compileCompetitiveSignalResolver(competitorDomains) {
9155
9161
  ...evidence.citedDomains ?? [],
9156
9162
  ...(evidence.groundingSources ?? []).map((source) => source.uri)
9157
9163
  ];
9158
- const answerDomains = evidence.answerDomains ?? extractDomainsFromText(evidence.answerText);
9164
+ const answerDomains2 = evidence.answerDomains ?? extractDomainsFromText(evidence.answerText);
9159
9165
  const mentionedBrandKeys = matchedAliasKeys(domainBrandMatcher, evidence.answerText);
9160
9166
  const citedCompetitorDomains = [];
9161
9167
  const mentionedCompetitorDomains = [];
@@ -9163,7 +9169,7 @@ function compileCompetitiveSignalResolver(competitorDomains) {
9163
9169
  if (citationCandidates.some((candidate) => hostMatchesDomain(candidate, identity.domain))) {
9164
9170
  citedCompetitorDomains.push(identity.domain);
9165
9171
  }
9166
- if (answerDomains.some((candidate) => hostMatchesDomain(candidate, identity.domain)) || identity.domainBrandKey !== null && mentionedBrandKeys.has(identity.domainBrandKey)) {
9172
+ if (answerDomains2.some((candidate) => hostMatchesDomain(candidate, identity.domain)) || identity.domainBrandKey !== null && mentionedBrandKeys.has(identity.domainBrandKey)) {
9167
9173
  mentionedCompetitorDomains.push(identity.domain);
9168
9174
  }
9169
9175
  }
@@ -16052,7 +16058,8 @@ function readCompetitorLandscape(app, projectName, query, selection) {
16052
16058
  throw validationError("Invalid competitor landscape query", { issues: parsed.error.issues });
16053
16059
  }
16054
16060
  const filters = parsed.data;
16055
- if (selection?.autoAdvanced && activeMeasurementPlan(app.db, project.id)?.plan.schemaVersion === 2) filters.scope = "all-markets";
16061
+ const classSplitByDefault = filters.scope === void 0 && filters.groupKey === void 0 && filters.queryClass !== void 0 && filters.queryClass !== "all";
16062
+ if ((selection?.autoAdvanced || classSplitByDefault) && activeMeasurementPlan(app.db, project.id)?.plan.schemaVersion === 2) filters.scope = "all-markets";
16056
16063
  const window = parseWindow(filters.window);
16057
16064
  const cutoff = windowCutoff(window);
16058
16065
  const candidateRuns = app.db.select().from(runs).where(and10(
@@ -16125,8 +16132,10 @@ function readCompetitorLandscape(app, projectName, query, selection) {
16125
16132
  const pinned = mergePins(advanced?.pendingPins ?? [], advanced?.activePinned ?? [], projectPins);
16126
16133
  const buildHistory = (selectedSnapshots) => {
16127
16134
  const inputs = buildMentionShareInputs({ project, competitorDomains: [], snapshots: selectedSnapshots, queryTextById });
16135
+ const allObservedNames = observedCompetitorNames(selectedSnapshots);
16128
16136
  return {
16129
- observedNames: observedCompetitorNames(selectedSnapshots),
16137
+ observedNames: allObservedNames.slice(0, COMPETITOR_LANDSCAPE_OBSERVED_NAME_LIMIT),
16138
+ observedNamesTotal: allObservedNames.length,
16130
16139
  ...buildCompetitorLandscapeHistory({
16131
16140
  project: {
16132
16141
  domain: project.canonicalDomain,
@@ -16242,21 +16251,7 @@ function resolveAdvancedScope(app, projectId, kind, groupKey, candidateRuns) {
16242
16251
  if (kind === "group" && (!groupKey || !active.plan.groups.some((group) => group.stableKey === groupKey))) {
16243
16252
  throw validationError(`Unknown Advanced Measurement group "${groupKey ?? ""}".`);
16244
16253
  }
16245
- const planVersionIds = [...new Set(candidateRuns.map((run) => run.measurementPlanVersionId).filter((id) => typeof id === "string" && id.length > 0))];
16246
- const historicalVersions = planVersionIds.length === 0 ? [] : app.db.select().from(measurementPlanVersions).where(and10(
16247
- eq15(measurementPlanVersions.projectId, projectId),
16248
- inArray4(measurementPlanVersions.id, planVersionIds)
16249
- )).all();
16250
- const frozenByVersionId = new Map(historicalVersions.map((version) => [
16251
- version.id,
16252
- scopeForFrozenPlan(parseStoredMeasurementPlanAnyVersion(version.canonicalJson), kind, groupKey)
16253
- ]));
16254
- const runScopes = /* @__PURE__ */ new Map();
16255
- for (const run of candidateRuns) {
16256
- if (!run.measurementPlanVersionId) continue;
16257
- const frozen = frozenByVersionId.get(run.measurementPlanVersionId);
16258
- if (frozen) runScopes.set(run.id, frozen);
16259
- }
16254
+ const runScopes = frozenRunScopes(app.db, projectId, kind, groupKey, candidateRuns);
16260
16255
  const activeScope = scopeForFrozenPlan(active.plan, kind, groupKey);
16261
16256
  if (!activeScope) throw validationError("The active Advanced Measurement plan cannot resolve this market scope.");
16262
16257
  const draft = draftRow(app.db, projectId);
@@ -16274,25 +16269,58 @@ function resolveAdvancedScope(app, projectId, kind, groupKey, candidateRuns) {
16274
16269
  runScopes
16275
16270
  };
16276
16271
  }
16272
+ function frozenRunScopes(db, projectId, kind, groupKey, candidateRuns) {
16273
+ const planVersionIds = [...new Set(candidateRuns.map((run) => run.measurementPlanVersionId).filter((id) => typeof id === "string" && id.length > 0))];
16274
+ const historicalVersions = planVersionIds.length === 0 ? [] : db.select().from(measurementPlanVersions).where(and10(
16275
+ eq15(measurementPlanVersions.projectId, projectId),
16276
+ inArray4(measurementPlanVersions.id, planVersionIds)
16277
+ )).all();
16278
+ const frozenByVersionId = new Map(historicalVersions.map((version) => [
16279
+ version.id,
16280
+ scopeForFrozenPlan(parseStoredMeasurementPlanAnyVersion(version.canonicalJson), kind, groupKey)
16281
+ ]));
16282
+ const runScopes = /* @__PURE__ */ new Map();
16283
+ for (const run of candidateRuns) {
16284
+ if (!run.measurementPlanVersionId) continue;
16285
+ const frozen = frozenByVersionId.get(run.measurementPlanVersionId);
16286
+ if (frozen) runScopes.set(run.id, frozen);
16287
+ }
16288
+ return runScopes;
16289
+ }
16290
+ function planQueryClassesByRun(db, projectId, candidateRuns) {
16291
+ const byRun = /* @__PURE__ */ new Map();
16292
+ for (const [runId, scope] of frozenRunScopes(db, projectId, "all-markets", void 0, candidateRuns)) {
16293
+ byRun.set(runId, scope.queryClassesByExecution);
16294
+ }
16295
+ return byRun;
16296
+ }
16277
16297
  function scopeForFrozenPlan(plan, kind, groupKey) {
16278
16298
  if (plan.schemaVersion !== 2) return null;
16279
- const groups = kind === "group" ? plan.groups.filter((group) => group.stableKey === groupKey) : plan.groups;
16280
- if (groups.length === 0) return null;
16281
- const targetKeys = new Set(groups.flatMap((group) => group.targetKeys));
16282
- const scopedUsageEdges = plan.usageEdges.filter((edge) => targetKeys.has(edge.targetKey));
16283
- const executionNodeKeys = new Set(scopedUsageEdges.map((edge) => edge.executionNodeKey));
16284
- const scopedUsages = new Set(scopedUsageEdges.map((edge) => JSON.stringify([
16285
- edge.executionNodeKey,
16286
- edge.targetKey,
16287
- edge.queryId
16288
- ])));
16289
- const queryClassesByExecution = /* @__PURE__ */ new Map();
16290
- for (const assignment of plan.assignments) {
16291
- if (!scopedUsages.has(JSON.stringify([
16299
+ let groups = plan.groups;
16300
+ let scopedUsageEdges = plan.usageEdges;
16301
+ let scopedAssignments = plan.assignments;
16302
+ if (kind === "group") {
16303
+ groups = plan.groups.filter((group) => group.stableKey === groupKey);
16304
+ if (groups.length === 0) return null;
16305
+ const targetKeys = new Set(groups.flatMap((group) => group.targetKeys));
16306
+ scopedUsageEdges = plan.usageEdges.filter((edge) => targetKeys.has(edge.targetKey));
16307
+ const scopedUsages = new Set(scopedUsageEdges.map((edge) => JSON.stringify([
16308
+ edge.executionNodeKey,
16309
+ edge.targetKey,
16310
+ edge.queryId
16311
+ ])));
16312
+ scopedAssignments = plan.assignments.filter((assignment) => scopedUsages.has(JSON.stringify([
16292
16313
  assignment.executionNodeKey,
16293
16314
  assignment.targetKey,
16294
16315
  assignment.queryId
16295
- ]))) continue;
16316
+ ])));
16317
+ }
16318
+ const executionNodeKeys = /* @__PURE__ */ new Set([
16319
+ ...scopedUsageEdges.map((edge) => edge.executionNodeKey),
16320
+ ...scopedAssignments.map((assignment) => assignment.executionNodeKey)
16321
+ ]);
16322
+ const queryClassesByExecution = /* @__PURE__ */ new Map();
16323
+ for (const assignment of scopedAssignments) {
16296
16324
  const classes = queryClassesByExecution.get(assignment.executionNodeKey) ?? /* @__PURE__ */ new Set();
16297
16325
  classes.add(assignment.queryClass);
16298
16326
  queryClassesByExecution.set(assignment.executionNodeKey, classes);
@@ -22807,34 +22835,155 @@ function filteredOverviewWithDb(db, active, plan, run, filters, targetKeys) {
22807
22835
  overview: evaluator.evaluate(targetKeys)
22808
22836
  };
22809
22837
  }
22810
- function targetAnswers(plan, target, materialized, filters) {
22811
- const assignmentsByExecution = new Map(plan.assignments.filter((assignment) => assignment.targetKey === target.stableKey).filter((assignment) => filters.queryClass === "all" || assignment.queryClass === filters.queryClass).map((assignment) => [assignment.executionNodeKey, assignment]));
22812
- const edgesByExecution = new Map(materialized.input.usageEdges.filter((edge) => edge.type === "target" && edge.targetId === target.stableKey).filter((edge) => assignmentsByExecution.has(edge.executionId)).map((edge) => [edge.executionId, edge]));
22813
- const ownEdgeIds = new Set([...edgesByExecution.values()].map((edge) => edge.id));
22814
- const answerBySlot = new Map(materialized.evidence.answers.filter((answer) => ownEdgeIds.has(answer.usageEdgeId)).map((answer) => [answer.expectedSlotId, answer]));
22815
- const citedSlotIds = new Set(materialized.evidence.evidence.filter((row) => ownEdgeIds.has(row.usageEdgeId) && row.classification === "assigned").map((row) => row.expectedSlotId));
22838
+ function pushTo(map, key, value) {
22839
+ const existing = map.get(key);
22840
+ if (existing) existing.push(value);
22841
+ else map.set(key, [value]);
22842
+ }
22843
+ function innerMap(outer, key) {
22844
+ let inner = outer.get(key);
22845
+ if (!inner) {
22846
+ inner = /* @__PURE__ */ new Map();
22847
+ outer.set(key, inner);
22848
+ }
22849
+ return inner;
22850
+ }
22851
+ function createTargetAnswerReader(plan, materialized, filters) {
22852
+ const assignmentsByTarget = /* @__PURE__ */ new Map();
22853
+ for (const assignment of plan.assignments) {
22854
+ if (filters.queryClass !== "all" && assignment.queryClass !== filters.queryClass) continue;
22855
+ innerMap(assignmentsByTarget, assignment.targetKey).set(assignment.executionNodeKey, assignment);
22856
+ }
22857
+ const edgesByTarget = /* @__PURE__ */ new Map();
22858
+ for (const edge of materialized.input.usageEdges) {
22859
+ if (edge.type !== "target") continue;
22860
+ innerMap(edgesByTarget, edge.targetId).set(edge.executionId, edge);
22861
+ }
22862
+ const answersByEdge = /* @__PURE__ */ new Map();
22863
+ for (const answer of materialized.evidence.answers) {
22864
+ innerMap(answersByEdge, answer.usageEdgeId).set(answer.expectedSlotId, answer);
22865
+ }
22866
+ const citedSlotsByEdge = /* @__PURE__ */ new Map();
22867
+ for (const row of materialized.evidence.evidence) {
22868
+ if (row.classification !== "assigned") continue;
22869
+ const slots = citedSlotsByEdge.get(row.usageEdgeId);
22870
+ if (slots) slots.add(row.expectedSlotId);
22871
+ else citedSlotsByEdge.set(row.usageEdgeId, /* @__PURE__ */ new Set([row.expectedSlotId]));
22872
+ }
22816
22873
  const incompleteObservationIds = new Set(materialized.evidence.diagnostics.evidenceIncompleteObservationIds);
22817
22874
  const questionsById = new Map(plan.querySnapshots.map((question) => [question.queryId, question.queryText]));
22818
22875
  const provider = filters.provider === void 0 ? void 0 : normalizeText2(filters.provider);
22819
22876
  const location = filters.location === void 0 ? void 0 : normalizeMeasurementLocation(filters.location);
22820
- const slots = materialized.input.expectedSlots.filter((slot) => assignmentsByExecution.has(slot.executionId) && edgesByExecution.has(slot.executionId) && (provider === void 0 || normalizeText2(slot.provider) === provider) && (location === void 0 || normalizeMeasurementLocation(slot.location) === location));
22821
- const answers = [];
22822
- for (const slot of slots) {
22823
- const observation = materialized.observationsBySlot.get(slot.id);
22824
- const snapshot = observation === void 0 ? void 0 : materialized.snapshotsById.get(observation.id);
22825
- if (!snapshot || snapshot.answerText === null) continue;
22826
- const assignment = assignmentsByExecution.get(slot.executionId);
22827
- if (!assignment) throw new Error(`measurement assignment provenance is corrupt for execution ${slot.executionId}`);
22828
- answers.push({
22829
- slotId: slot.id,
22830
- provider: slot.provider,
22831
- question: questionsById.get(assignment.queryId) ?? slot.queryText,
22832
- snapshot,
22833
- mentioned: target.mentionNotApplicable ? null : answerBySlot.get(slot.id)?.mentioned ?? null,
22834
- cited: citedSlotIds.has(slot.id) ? true : incompleteObservationIds.has(snapshot.id) ? null : false
22835
- });
22877
+ const slotsByExecution = /* @__PURE__ */ new Map();
22878
+ const slotOrder = /* @__PURE__ */ new Map();
22879
+ materialized.input.expectedSlots.forEach((slot, index2) => {
22880
+ if (provider !== void 0 && normalizeText2(slot.provider) !== provider) return;
22881
+ if (location !== void 0 && normalizeMeasurementLocation(slot.location) !== location) return;
22882
+ pushTo(slotsByExecution, slot.executionId, slot);
22883
+ slotOrder.set(slot.id, index2);
22884
+ });
22885
+ return (target) => {
22886
+ const assignments = assignmentsByTarget.get(target.stableKey);
22887
+ const edges = edgesByTarget.get(target.stableKey);
22888
+ const answers = [];
22889
+ const sourceAnswers = [];
22890
+ const queries2 = /* @__PURE__ */ new Set();
22891
+ const providers = /* @__PURE__ */ new Set();
22892
+ let expected = 0;
22893
+ if (!assignments || !edges) return { expected, queries: 0, providers, answers, sourceAnswers };
22894
+ for (const [executionId, assignment] of assignments) {
22895
+ const edge = edges.get(executionId);
22896
+ const slots = slotsByExecution.get(executionId);
22897
+ if (!edge || !slots) continue;
22898
+ queries2.add(assignment.queryId);
22899
+ const answerBySlot = answersByEdge.get(edge.id);
22900
+ const citedSlotIds = citedSlotsByEdge.get(edge.id);
22901
+ for (const slot of slots) {
22902
+ expected++;
22903
+ providers.add(slot.provider);
22904
+ const observation = materialized.observationsBySlot.get(slot.id);
22905
+ const snapshot = observation === void 0 ? void 0 : materialized.snapshotsById.get(observation.id);
22906
+ if (!observation || !snapshot) continue;
22907
+ const sourced = {
22908
+ slotId: slot.id,
22909
+ snapshot,
22910
+ citedUrls: observation.citedUrls ?? observation.historicalCitedUrls ?? []
22911
+ };
22912
+ sourceAnswers.push(sourced);
22913
+ if (snapshot.answerText === null) continue;
22914
+ answers.push({
22915
+ ...sourced,
22916
+ provider: slot.provider,
22917
+ question: questionsById.get(assignment.queryId) ?? slot.queryText,
22918
+ mentioned: target.mentionNotApplicable ? null : answerBySlot?.get(slot.id)?.mentioned ?? null,
22919
+ cited: citedSlotIds?.has(slot.id) ? true : incompleteObservationIds.has(snapshot.id) ? null : false
22920
+ });
22921
+ }
22922
+ }
22923
+ const inRunOrder = (left, right) => slotOrder.get(left.slotId) - slotOrder.get(right.slotId);
22924
+ answers.sort(inRunOrder);
22925
+ sourceAnswers.sort(inRunOrder);
22926
+ return { expected, queries: queries2.size, providers, answers, sourceAnswers };
22927
+ };
22928
+ }
22929
+ function targetAnswers(plan, target, materialized, filters) {
22930
+ return createTargetAnswerReader(plan, materialized, filters)(target);
22931
+ }
22932
+ function plannedQueryCount(plan, targetKey, queryClass) {
22933
+ return new Set(plan.assignments.filter((assignment) => assignment.targetKey === targetKey).filter((assignment) => queryClass === "all" || assignment.queryClass === queryClass).map((assignment) => assignment.queryId)).size;
22934
+ }
22935
+ function propertyLocations(plan) {
22936
+ const groupsByKey = new Map(plan.groups.map((group) => [group.stableKey, group]));
22937
+ const depth = (group) => {
22938
+ let levels = 0;
22939
+ const seen2 = /* @__PURE__ */ new Set([group.stableKey]);
22940
+ let parentKey = group.parentGroupKey;
22941
+ while (parentKey !== void 0 && !seen2.has(parentKey)) {
22942
+ const parent = groupsByKey.get(parentKey);
22943
+ if (!parent) break;
22944
+ seen2.add(parentKey);
22945
+ levels++;
22946
+ parentKey = parent.parentGroupKey;
22947
+ }
22948
+ return levels;
22949
+ };
22950
+ const byLabel = (left, right) => compareText13(left.label, right.label) || compareText13(left.stableKey, right.stableKey);
22951
+ const metros = /* @__PURE__ */ new Map();
22952
+ const submarkets = /* @__PURE__ */ new Map();
22953
+ for (const group of plan.groups) {
22954
+ const level = depth(group);
22955
+ for (const targetKey of new Set(group.targetKeys)) {
22956
+ if (level === 0) pushTo(metros, targetKey, group);
22957
+ else pushTo(submarkets, targetKey, { group, depth: level });
22958
+ }
22959
+ }
22960
+ const metroDto = (group) => ({ groupKey: group.stableKey, label: group.label });
22961
+ return (targetKey) => {
22962
+ const own = [...metros.get(targetKey) ?? []].sort(byLabel);
22963
+ const nested = [...submarkets.get(targetKey) ?? []].sort((left, right) => left.depth - right.depth || byLabel(left.group, right.group));
22964
+ return {
22965
+ metro: own.length === 0 ? null : metroDto(own[0]),
22966
+ ...own.length > 1 ? { otherMetros: own.slice(1).map(metroDto) } : {},
22967
+ submarkets: nested.map((row) => row.group.label)
22968
+ };
22969
+ };
22970
+ }
22971
+ function answerDomains(answer) {
22972
+ const domains = /* @__PURE__ */ new Set();
22973
+ for (const raw of [...answer.snapshot.citedDomains, ...answer.citedUrls]) {
22974
+ const host = hostOf(raw);
22975
+ if (!host || hostMatchesAnyDomain(host, AI_PROVIDER_INFRA_DOMAINS)) continue;
22976
+ domains.add(host);
22977
+ }
22978
+ return domains;
22979
+ }
22980
+ function domainRows(answers, limit) {
22981
+ const counts2 = /* @__PURE__ */ new Map();
22982
+ for (const answer of answers) {
22983
+ for (const domain of answerDomains(answer)) counts2.set(domain, (counts2.get(domain) ?? 0) + 1);
22836
22984
  }
22837
- return { expected: slots.length, answers };
22985
+ const rows = [...counts2].map(([domain, count4]) => ({ domain, answers: count4 })).sort((left, right) => right.answers - left.answers || compareText13(left.domain, right.domain));
22986
+ return { rows: rows.slice(0, limit), total: rows.length };
22838
22987
  }
22839
22988
  function targetAliasKeys(target) {
22840
22989
  return new Set([target.label, ...target.aliases].map(brandKeyFromText).filter(Boolean));
@@ -22844,16 +22993,18 @@ function recommendationRows(target, population) {
22844
22993
  const grouped = /* @__PURE__ */ new Map();
22845
22994
  for (const answer of population.answers) {
22846
22995
  if (answer.mentioned !== false || answer.cited !== false) continue;
22996
+ const seenInAnswer = /* @__PURE__ */ new Set();
22847
22997
  for (const rawName of answer.snapshot.recommendedCompetitors) {
22848
22998
  const name = rawName.normalize("NFKC").trim().replace(/\s+/g, " ");
22849
22999
  const key = brandKeyFromText(name);
22850
23000
  if (!key || aliases.has(key)) continue;
22851
23001
  const existing = grouped.get(key);
22852
23002
  if (existing) {
23003
+ if (compareText13(name, existing.name) < 0) existing.name = name;
23004
+ if (seenInAnswer.has(key)) continue;
22853
23005
  existing.occurrences++;
22854
23006
  existing.providers.add(answer.provider);
22855
23007
  existing.questions.add(answer.question);
22856
- if (compareText13(name, existing.name) < 0) existing.name = name;
22857
23008
  } else {
22858
23009
  grouped.set(key, {
22859
23010
  name,
@@ -22862,6 +23013,7 @@ function recommendationRows(target, population) {
22862
23013
  questions: /* @__PURE__ */ new Set([answer.question])
22863
23014
  });
22864
23015
  }
23016
+ seenInAnswer.add(key);
22865
23017
  }
22866
23018
  }
22867
23019
  return [...grouped.values()].map((row) => {
@@ -22879,6 +23031,16 @@ function recommendationRows(target, population) {
22879
23031
  };
22880
23032
  }).sort((left, right) => right.occurrences - left.occurrences || compareText13(left.name, right.name));
22881
23033
  }
23034
+ function namedInsteadFields(names) {
23035
+ const returned = names.slice(0, MEASUREMENT_PORTFOLIO_ROW_EVIDENCE_LIMIT);
23036
+ return {
23037
+ namedInsteadInAnswerText: returned.map(({ name, occurrences }) => ({ name, answers: occurrences })),
23038
+ namedInsteadInAnswerTextTotal: names.length,
23039
+ recommendedInstead: returned.map(({ name, occurrences }) => ({ name, occurrences })),
23040
+ recommendedInsteadTotal: names.length,
23041
+ recommendedInsteadTruncated: names.length > returned.length
23042
+ };
23043
+ }
22882
23044
  function propertyDto2(target) {
22883
23045
  return { targetKey: target.stableKey, label: target.label };
22884
23046
  }
@@ -22925,20 +23087,60 @@ function targetKeysForRun(plan, run, requested, requireEntireScope) {
22925
23087
  }
22926
23088
  return selected;
22927
23089
  }
22928
- function marketRollup(plan, run, evaluator, scopedToOneGroup) {
22929
- if (scopedToOneGroup) return [];
22930
- return plan.groups.map((group) => {
22931
- const targetKeys = targetKeysForRun(plan, run, group.targetKeys, false);
22932
- const overview = evaluator.evaluate(targetKeys);
22933
- return {
23090
+ function marketRollup(plan, measured, scope, includeNested, limit) {
23091
+ const childCounts = /* @__PURE__ */ new Map();
23092
+ for (const group of plan.groups) {
23093
+ if (group.parentGroupKey !== void 0) childCounts.set(group.parentGroupKey, (childCounts.get(group.parentGroupKey) ?? 0) + 1);
23094
+ }
23095
+ const groupKeys = new Set(plan.groups.map((group) => group.stableKey));
23096
+ const isTopLevel = (group) => group.parentGroupKey === void 0 || !groupKeys.has(group.parentGroupKey);
23097
+ let candidates;
23098
+ if (scope === void 0) {
23099
+ candidates = includeNested ? plan.groups : plan.groups.filter(isTopLevel);
23100
+ } else if (!includeNested) {
23101
+ candidates = plan.groups.filter((group) => group.parentGroupKey === scope.stableKey);
23102
+ } else {
23103
+ const descendants = /* @__PURE__ */ new Set([scope.stableKey]);
23104
+ let grew = true;
23105
+ while (grew) {
23106
+ grew = false;
23107
+ for (const group of plan.groups) {
23108
+ if (group.parentGroupKey !== void 0 && descendants.has(group.parentGroupKey) && !descendants.has(group.stableKey)) {
23109
+ descendants.add(group.stableKey);
23110
+ grew = true;
23111
+ }
23112
+ }
23113
+ }
23114
+ candidates = plan.groups.filter((group) => group.stableKey !== scope.stableKey && descendants.has(group.stableKey));
23115
+ }
23116
+ const markets = candidates.map((group) => {
23117
+ const identity = {
22934
23118
  groupKey: group.stableKey,
22935
23119
  label: group.label,
23120
+ parentGroupKey: isTopLevel(group) ? null : group.parentGroupKey,
23121
+ childMarketCount: childCounts.get(group.stableKey) ?? 0
23122
+ };
23123
+ if (measured === void 0) {
23124
+ return {
23125
+ ...identity,
23126
+ propertyCount: group.targetKeys.length,
23127
+ propertiesMentioned: unavailable3("no_completed_run"),
23128
+ mentionCoverage: unavailable3("no_completed_run"),
23129
+ citationCoverage: unavailable3("no_completed_run")
23130
+ };
23131
+ }
23132
+ const targetKeys = targetKeysForRun(plan, measured.run, group.targetKeys, false);
23133
+ const overview = measured.evaluator.evaluate(targetKeys);
23134
+ return {
23135
+ ...identity,
22936
23136
  propertyCount: targetKeys.length,
22937
23137
  propertiesMentioned: countMetric2(overview.propertiesMentioned),
22938
23138
  mentionCoverage: coverageMetric2(overview.mentionCoverage),
22939
23139
  citationCoverage: coverageMetric2(overview.citationCoverage)
22940
23140
  };
22941
23141
  }).sort(compareWeakestMarket);
23142
+ const returned = includeNested ? markets : markets.slice(0, limit);
23143
+ return { markets: returned, totalMarkets: markets.length, marketsTruncated: returned.length < markets.length };
22942
23144
  }
22943
23145
  function compareWeakestMarket(a, b) {
22944
23146
  const aUnavailable = a.mentionCoverage.state === "unavailable" || a.citationCoverage.state === "unavailable";
@@ -22956,11 +23158,20 @@ function compareWeakestMarket(a, b) {
22956
23158
  function mentionRanking(rows, limit) {
22957
23159
  const eligible = [];
22958
23160
  const excluded = [];
22959
- for (const { targetKey, label, mentionCoverage, citationCoverage } of rows) {
23161
+ for (const { targetKey, label, mentionCoverage, citationCoverage, metro, otherMetros, submarkets, queries: queries2 } of rows) {
22960
23162
  if (mentionCoverage.state === "unavailable") {
22961
23163
  excluded.push({ targetKey, label, reason: mentionCoverage.reason });
22962
23164
  } else {
22963
- eligible.push({ targetKey, label, mentionCoverage, citationCoverage });
23165
+ eligible.push({
23166
+ targetKey,
23167
+ label,
23168
+ metro,
23169
+ ...otherMetros === void 0 ? {} : { otherMetros },
23170
+ submarkets,
23171
+ queries: queries2,
23172
+ mentionCoverage,
23173
+ citationCoverage
23174
+ });
22964
23175
  }
22965
23176
  }
22966
23177
  const byLabel = (a, b) => compareText13(a.label, b.label) || compareText13(a.targetKey, b.targetKey);
@@ -22985,65 +23196,74 @@ function portfolioResponse(db, active, plan, query) {
22985
23196
  provider: query.provider,
22986
23197
  location: query.location
22987
23198
  };
23199
+ const limit = query.limit ?? MEASUREMENT_PORTFOLIO_DEFAULT_LIMIT;
23200
+ const includeNestedMarkets = query.includeNestedMarkets ?? false;
23201
+ const locate = propertyLocations(plan);
22988
23202
  if (!run) {
22989
23203
  const rows2 = targets.map((target) => ({
22990
23204
  ...propertyDto2(target),
23205
+ ...locate(target.stableKey),
23206
+ queries: plannedQueryCount(plan, target.stableKey, query.queryClass),
22991
23207
  mentionCoverage: unavailable3("no_completed_run"),
22992
23208
  citationCoverage: unavailable3("no_completed_run"),
22993
23209
  flags: 0,
22994
- recommendedInstead: [],
22995
- recommendedInsteadTotal: 0,
22996
- recommendedInsteadTruncated: false
23210
+ ...namedInsteadFields([]),
23211
+ citedDomains: [],
23212
+ citedDomainsTotal: 0
22997
23213
  })).sort(compareWeakest);
22998
- const limit2 = query.limit ?? DEFAULT_LIMIT3;
22999
23214
  return measurementPortfolioSummaryResponseSchema.parse({
23000
23215
  portfolio: { groupKey: group?.stableKey ?? null, label: group?.label ?? null, measurementScope: null },
23001
23216
  measurement,
23002
23217
  queryClass: query.queryClass,
23218
+ engines: [],
23003
23219
  metrics: {
23004
23220
  propertiesMentioned: unavailable3("no_completed_run"),
23005
23221
  mentionCoverage: unavailable3("no_completed_run"),
23006
23222
  citationCoverage: unavailable3("no_completed_run")
23007
23223
  },
23008
- weakestProperties: rows2.slice(0, limit2),
23009
- mentionRanking: mentionRanking(rows2, limit2),
23224
+ weakestProperties: rows2.slice(0, limit),
23225
+ tiedAtWeakest: null,
23226
+ weakestAnswerSources: null,
23227
+ mentionRanking: mentionRanking(rows2, limit),
23010
23228
  // Sorted through the same comparator as the measured branch. Plan order
23011
23229
  // is `stableKey`, so emitting it raw put markets in an order the schema
23012
23230
  // documents as worst-first and that changes the moment a run lands.
23013
- markets: group !== void 0 ? [] : plan.groups.map((g) => ({
23014
- groupKey: g.stableKey,
23015
- label: g.label,
23016
- propertyCount: g.targetKeys.length,
23017
- propertiesMentioned: unavailable3("no_completed_run"),
23018
- mentionCoverage: unavailable3("no_completed_run"),
23019
- citationCoverage: unavailable3("no_completed_run")
23020
- })).sort(compareWeakestMarket),
23231
+ ...marketRollup(plan, void 0, group, includeNestedMarkets, limit),
23021
23232
  totalProperties: rows2.length,
23022
- truncated: rows2.length > limit2
23233
+ truncated: rows2.length > limit
23023
23234
  });
23024
23235
  }
23025
23236
  const { materialized, overview, evaluator } = filteredOverviewWithDb(db, active, plan, run, filters, targetKeys);
23237
+ const readTarget2 = createTargetAnswerReader(plan, materialized, filters);
23238
+ const populations = new Map(targets.map((target) => [target.stableKey, readTarget2(target)]));
23026
23239
  const measured = new Map(overview.properties.map((row) => [row.targetId, row]));
23027
23240
  const ranked = targets.map((target) => {
23028
23241
  const row = measured.get(target.stableKey);
23242
+ const population = populations.get(target.stableKey);
23029
23243
  return {
23030
23244
  target,
23245
+ population,
23031
23246
  ...propertyDto2(target),
23247
+ ...locate(target.stableKey),
23248
+ queries: population.queries,
23032
23249
  mentionCoverage: row ? coverageMetric2(row.mentionCoverage) : unavailable3("no_population"),
23033
23250
  citationCoverage: row ? coverageMetric2(row.citationCoverage) : unavailable3("no_population"),
23034
23251
  flags: row?.flags ?? 0
23035
23252
  };
23036
23253
  }).sort(compareWeakest);
23037
- const limit = query.limit ?? DEFAULT_LIMIT3;
23038
- const rows = ranked.slice(0, limit).map(({ target, ...row }) => {
23039
- const recommendations = recommendationRows(target, targetAnswers(plan, target, materialized, filters));
23254
+ const displayed = ranked.slice(0, limit);
23255
+ const rows = displayed.map(({ target, population, ...row }) => {
23256
+ const domains = domainRows(population.sourceAnswers, MEASUREMENT_PORTFOLIO_ROW_EVIDENCE_LIMIT);
23040
23257
  return {
23041
23258
  ...row,
23042
- recommendedInstead: recommendations.slice(0, 5).map(({ name, occurrences }) => ({ name, occurrences })),
23043
- recommendedInsteadTotal: recommendations.length,
23044
- recommendedInsteadTruncated: recommendations.length > 5
23259
+ ...namedInsteadFields(recommendationRows(target, population)),
23260
+ citedDomains: domains.rows,
23261
+ citedDomainsTotal: domains.total
23045
23262
  };
23046
23263
  });
23264
+ const tie = weakestTie(ranked);
23265
+ const engines = /* @__PURE__ */ new Set();
23266
+ for (const population of populations.values()) for (const provider of population.providers) engines.add(provider);
23047
23267
  return measurementPortfolioSummaryResponseSchema.parse({
23048
23268
  portfolio: {
23049
23269
  groupKey: group?.stableKey ?? null,
@@ -23052,18 +23272,46 @@ function portfolioResponse(db, active, plan, query) {
23052
23272
  },
23053
23273
  measurement,
23054
23274
  queryClass: query.queryClass,
23275
+ engines: [...engines].sort(compareText13),
23055
23276
  metrics: {
23056
23277
  propertiesMentioned: countMetric2(overview.propertiesMentioned),
23057
23278
  mentionCoverage: coverageMetric2(overview.mentionCoverage),
23058
23279
  citationCoverage: coverageMetric2(overview.citationCoverage)
23059
23280
  },
23060
- weakestProperties: rows.slice(0, limit),
23281
+ weakestProperties: rows,
23282
+ tiedAtWeakest: tie.summary,
23283
+ weakestAnswerSources: weakestAnswerSources([...displayed, ...tie.rows]),
23061
23284
  mentionRanking: mentionRanking(ranked, limit),
23062
- markets: marketRollup(plan, run, evaluator, group !== void 0),
23285
+ ...marketRollup(plan, { run, evaluator }, group, includeNestedMarkets, limit),
23063
23286
  totalProperties: ranked.length,
23064
23287
  truncated: ranked.length > limit
23065
23288
  });
23066
23289
  }
23290
+ function weakestTie(ranked) {
23291
+ if (ranked.length === 0) return { summary: null, rows: [] };
23292
+ const first = ranked[0];
23293
+ if (first.mentionCoverage.state !== "available" || first.citationCoverage.state !== "available") {
23294
+ return { summary: null, rows: [] };
23295
+ }
23296
+ const mentionRate3 = first.mentionCoverage.value;
23297
+ const citationRate = first.citationCoverage.value;
23298
+ const rows = ranked.filter((row) => row.mentionCoverage.state === "available" && row.mentionCoverage.value === mentionRate3 && row.citationCoverage.state === "available" && row.citationCoverage.value === citationRate);
23299
+ if (rows.length < 2) return { summary: null, rows: [] };
23300
+ return {
23301
+ summary: { count: rows.length, mentionRate: mentionRate3, citationRate, note: MEASUREMENT_PORTFOLIO_TIE_NOTE },
23302
+ rows
23303
+ };
23304
+ }
23305
+ function weakestAnswerSources(rows) {
23306
+ const properties = /* @__PURE__ */ new Set();
23307
+ const answers = /* @__PURE__ */ new Map();
23308
+ for (const row of rows) {
23309
+ properties.add(row.targetKey);
23310
+ for (const answer of row.population.sourceAnswers) answers.set(answer.snapshot.id, answer);
23311
+ }
23312
+ const domains = domainRows([...answers.values()], MEASUREMENT_PORTFOLIO_ANSWER_SOURCES_LIMIT);
23313
+ return { properties: properties.size, answers: answers.size, domains: domains.rows, domainTotal: domains.total };
23314
+ }
23067
23315
  function propertyCompetitorsResponse(db, active, plan, query) {
23068
23316
  const target = requireTarget3(plan, query.targetKey);
23069
23317
  const run = selectMeasurementQuestionRun(db, active.version.projectId, active, query.runId);
@@ -23398,7 +23646,9 @@ async function measurementPortfolioReadRoutes(app) {
23398
23646
  "/projects/:name/measurement-portfolio-summary",
23399
23647
  async (request) => {
23400
23648
  const project = resolveProject(app.db, request.params.name);
23401
- const query = parseLimitQuery(request.query, measurementPortfolioSummaryQuerySchema, "Invalid measurement portfolio summary query");
23649
+ const nested = request.query.includeNestedMarkets;
23650
+ const raw = nested === "true" || nested === "false" ? { ...request.query, includeNestedMarkets: nested === "true" } : request.query;
23651
+ const query = parseLimitQuery(raw, measurementPortfolioSummaryQuerySchema, "Invalid measurement portfolio summary query");
23402
23652
  const { active, plan } = activeV2Plan2(app.db, project.id, "Portfolio summary");
23403
23653
  return portfolioResponse(app.db, active, plan, query);
23404
23654
  }
@@ -27866,6 +28116,31 @@ async function analyticsRoutes(app) {
27866
28116
  if (!Number.isInteger(n) || n <= 0) throw validationError('"limit" must be a positive integer');
27867
28117
  limit = n;
27868
28118
  }
28119
+ const parsedFilters = sourceBreakdownQuerySchema.safeParse({
28120
+ runId: request.query.runId,
28121
+ queryClass: request.query.queryClass,
28122
+ includeByQuery: request.query.includeByQuery
28123
+ });
28124
+ if (!parsedFilters.success) {
28125
+ throw validationError("Invalid source breakdown query", { issues: parsedFilters.error.issues });
28126
+ }
28127
+ const requestedRunId = parsedFilters.data.runId ?? null;
28128
+ const queryClass = parsedFilters.data.queryClass ?? "all";
28129
+ const includeByQuery = parsedFilters.data.includeByQuery !== "false" && parsedFilters.data.includeByQuery !== "0";
28130
+ const splitByClass = queryClass !== "all";
28131
+ const planClassified = splitByClass && activeMeasurementPlan(app.db, project.id)?.plan.schemaVersion === 2;
28132
+ const classifyQueryText = splitByClass && !planClassified ? projectQueryClassifier(project) : null;
28133
+ if (splitByClass && !planClassified && !classifyQueryText) {
28134
+ throw validationError(
28135
+ `Query class "${queryClass}" needs a brand name or alias on this project. Add one, or omit queryClass for a pooled source list.`
28136
+ );
28137
+ }
28138
+ const filters = {
28139
+ runId: requestedRunId,
28140
+ queryClass,
28141
+ queryClassBasis: !splitByClass ? null : planClassified ? "measurement-plan" : "query-text",
28142
+ includeByQuery
28143
+ };
27869
28144
  const classifyCtx = {
27870
28145
  projectDomains: effectiveDomains(project),
27871
28146
  competitorDomains: app.db.select({ domain: competitors.domain }).from(competitors).where(eq30(competitors.projectId, project.id)).all().map((r) => r.domain)
@@ -27875,48 +28150,114 @@ async function analyticsRoutes(app) {
27875
28150
  const mapped = surfaceClassFromCompetitorType(row.competitorType);
27876
28151
  if (mapped) storedSurfaceClasses.set(normalizeProjectDomain(row.domain), mapped);
27877
28152
  }
27878
- const windowRuns = app.db.select().from(runs).where(and25(eq30(runs.projectId, project.id), eq30(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(desc12(runs.createdAt), desc12(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => measuredWhole(app.db, r)).filter((r) => !cutoff || r.createdAt >= cutoff);
28153
+ if (requestedRunId) {
28154
+ const requested = app.db.select().from(runs).where(and25(
28155
+ eq30(runs.id, requestedRunId),
28156
+ eq30(runs.projectId, project.id),
28157
+ eq30(runs.kind, RunKinds["answer-visibility"])
28158
+ )).get();
28159
+ if (!requested) throw notFound("Answer-visibility run", requestedRunId);
28160
+ if (requested.trigger === RunTriggers.probe) {
28161
+ throw validationError(`Run "${requestedRunId}" is a probe run; probes never enter source analytics.`);
28162
+ }
28163
+ if (requested.status !== RunStatuses.completed && requested.status !== RunStatuses.partial) {
28164
+ throw validationError(`Run "${requestedRunId}" is ${requested.status}; only completed or partial runs have source analytics.`);
28165
+ }
28166
+ if (!measuredWhole(app.db, requested)) {
28167
+ throw validationError(`Run "${requestedRunId}" did not fill every slot its measurement plan promised, so it is excluded from source analytics.`);
28168
+ }
28169
+ if (cutoff && requested.createdAt < cutoff) {
28170
+ throw validationError(`Run "${requestedRunId}" is older than the ${window} window. Omit window, or widen it, to read this run.`);
28171
+ }
28172
+ }
28173
+ const windowRuns = app.db.select().from(runs).where(and25(
28174
+ eq30(runs.projectId, project.id),
28175
+ eq30(runs.kind, RunKinds["answer-visibility"]),
28176
+ notProbeRun(),
28177
+ requestedRunId ? eq30(runs.id, requestedRunId) : void 0
28178
+ )).orderBy(desc12(runs.createdAt), desc12(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => measuredWhole(app.db, r)).filter((r) => !cutoff || r.createdAt >= cutoff);
27879
28179
  if (windowRuns.length === 0) {
27880
28180
  return reply.send({
27881
- overall: [],
27882
- byQuery: {},
27883
- ranked: buildRankedList(/* @__PURE__ */ new Map(), limit),
28181
+ ranked: buildRankedList(/* @__PURE__ */ new Map(), limit, 0, 0),
27884
28182
  byProvider: {},
28183
+ providersWithoutSources: [],
28184
+ answerTotal: 0,
28185
+ runCount: 0,
28186
+ unclassifiedAnswers: 0,
28187
+ filters,
27885
28188
  runId: "",
27886
28189
  window,
27887
- limit
28190
+ limit,
28191
+ overall: [],
28192
+ ...includeByQuery ? { byQuery: {} } : {}
27888
28193
  });
27889
28194
  }
27890
28195
  const latestGroup = groupRunsByCreatedAt(windowRuns)[0] ?? [];
27891
28196
  const latestRunId = pickGroupRepresentative(latestGroup)?.id ?? windowRuns[0].id;
27892
28197
  const windowRunIds = windowRuns.map((r) => r.id);
27893
- const snapshots = app.db.select({
28198
+ const loadedSnapshots = app.db.select({
28199
+ runId: querySnapshots.runId,
27894
28200
  queryId: querySnapshots.queryId,
28201
+ queryText: querySnapshots.queryText,
27895
28202
  query: queries.query,
27896
28203
  provider: querySnapshots.provider,
27897
- rawResponse: querySnapshots.rawResponse
28204
+ citedDomains: querySnapshots.citedDomains,
28205
+ citedUrls: querySnapshots.citedUrls,
28206
+ measurementExecutionId: querySnapshots.measurementExecutionId
27898
28207
  }).from(querySnapshots).leftJoin(queries, eq30(querySnapshots.queryId, queries.id)).where(inArray13(querySnapshots.runId, windowRunIds)).all();
28208
+ let unclassifiedAnswers = 0;
28209
+ let snapshots = loadedSnapshots;
28210
+ if (splitByClass) {
28211
+ const planClasses = planClassified ? planQueryClassesByRun(app.db, project.id, windowRuns) : null;
28212
+ snapshots = loadedSnapshots.filter((snap) => {
28213
+ let classes;
28214
+ if (planClasses) {
28215
+ classes = snap.measurementExecutionId ? planClasses.get(snap.runId)?.get(snap.measurementExecutionId) : void 0;
28216
+ } else {
28217
+ const text2 = snap.query ?? snap.queryText;
28218
+ classes = text2?.trim() ? /* @__PURE__ */ new Set([classifyQueryText(text2)]) : void 0;
28219
+ }
28220
+ if (!classes || classes.size === 0) {
28221
+ unclassifiedAnswers += 1;
28222
+ return false;
28223
+ }
28224
+ return classes.has(queryClass);
28225
+ });
28226
+ }
27899
28227
  const overallCounts = /* @__PURE__ */ new Map();
27900
- const byQuery = {};
28228
+ const queryCounts = /* @__PURE__ */ new Map();
27901
28229
  const overallDomains = /* @__PURE__ */ new Map();
27902
28230
  const providerDomains = /* @__PURE__ */ new Map();
28231
+ const answersByProvider = /* @__PURE__ */ new Map();
28232
+ let answersWithSources = 0;
27903
28233
  for (const snap of snapshots) {
27904
- const sources = parseGroundingSources(snap.rawResponse);
27905
- const qCounts = /* @__PURE__ */ new Map();
27906
- for (const source of sources) {
27907
- const { category, label, domain } = categorizeSource(source.uri);
28234
+ const domains = sourceDomainsOf(snap);
28235
+ const providerAnswers = answersByProvider.get(snap.provider) ?? { total: 0, withSources: 0 };
28236
+ providerAnswers.total += 1;
28237
+ if (domains.length > 0) {
28238
+ providerAnswers.withSources += 1;
28239
+ answersWithSources += 1;
28240
+ }
28241
+ answersByProvider.set(snap.provider, providerAnswers);
28242
+ const queryKey = includeByQuery && domains.length > 0 ? snap.query : null;
28243
+ let qCounts;
28244
+ if (queryKey) {
28245
+ qCounts = queryCounts.get(queryKey);
28246
+ if (!qCounts) {
28247
+ qCounts = /* @__PURE__ */ new Map();
28248
+ queryCounts.set(queryKey, qCounts);
28249
+ }
28250
+ }
28251
+ for (const host of domains) {
28252
+ const { category, label, domain } = categorizeSource(host);
27908
28253
  const surfaceClass = classifySurfaceFromCategory(
27909
28254
  domain,
27910
28255
  category,
27911
28256
  classifyCtx,
27912
28257
  storedSurfaceClasses.get(normalizeProjectDomain(domain))
27913
28258
  );
27914
- if (!overallCounts.has(category)) overallCounts.set(category, /* @__PURE__ */ new Map());
27915
- const oDomains = overallCounts.get(category);
27916
- oDomains.set(domain, (oDomains.get(domain) ?? 0) + 1);
27917
- if (!qCounts.has(category)) qCounts.set(category, /* @__PURE__ */ new Map());
27918
- const qDomains = qCounts.get(category);
27919
- qDomains.set(domain, (qDomains.get(domain) ?? 0) + 1);
28259
+ bumpCategory(overallCounts, category, domain);
28260
+ if (qCounts) bumpCategory(qCounts, category, domain);
27920
28261
  bumpDomain(overallDomains, domain, category, label, surfaceClass);
27921
28262
  let pm = providerDomains.get(snap.provider);
27922
28263
  if (!pm) {
@@ -27925,30 +28266,56 @@ async function analyticsRoutes(app) {
27925
28266
  }
27926
28267
  bumpDomain(pm, domain, category, label, surfaceClass);
27927
28268
  }
27928
- if (sources.length > 0 && snap.query) {
27929
- byQuery[snap.query] = buildCategoryCounts(qCounts);
27930
- }
27931
28269
  }
27932
- const overall = buildCategoryCounts(overallCounts);
27933
- const ranked = buildRankedList(overallDomains, limit);
28270
+ const ranked = buildRankedList(overallDomains, limit, snapshots.length, answersWithSources);
27934
28271
  const byProvider = {};
27935
28272
  for (const [provider, domains] of providerDomains) {
27936
- byProvider[provider] = buildRankedList(domains, limit);
28273
+ const answers = answersByProvider.get(provider);
28274
+ byProvider[provider] = buildRankedList(domains, limit, answers.total, answers.withSources);
28275
+ }
28276
+ const providersWithoutSources = [...answersByProvider].filter(([, answers]) => answers.withSources === 0).map(([provider]) => provider).sort();
28277
+ let byQuery;
28278
+ if (includeByQuery) {
28279
+ byQuery = {};
28280
+ for (const [query, counts2] of queryCounts) byQuery[query] = buildCategoryCounts(counts2);
27937
28281
  }
27938
- return reply.send({ overall, byQuery, ranked, byProvider, runId: latestRunId, window, limit });
28282
+ return reply.send({
28283
+ ranked,
28284
+ byProvider,
28285
+ providersWithoutSources,
28286
+ answerTotal: snapshots.length,
28287
+ runCount: windowRuns.length,
28288
+ unclassifiedAnswers,
28289
+ filters,
28290
+ runId: requestedRunId ?? latestRunId,
28291
+ window,
28292
+ limit,
28293
+ overall: buildCategoryCounts(overallCounts),
28294
+ ...byQuery ? { byQuery } : {}
28295
+ });
27939
28296
  });
27940
28297
  }
27941
28298
  var ANCHOR_SWEEP_SCAN_LIMIT = 60;
27942
28299
  function isProviderInfraDomain(uri) {
27943
28300
  return hostMatchesAnyDomain(uri, AI_PROVIDER_INFRA_DOMAINS);
27944
28301
  }
27945
- function parseGroundingSources(rawResponse) {
27946
- const parsed = parseJsonColumn(rawResponse, {});
27947
- const sources = parsed.groundingSources;
27948
- if (!Array.isArray(sources)) return [];
27949
- return sources.filter(
27950
- (s) => typeof s.uri === "string" && !isProviderInfraDomain(s.uri)
27951
- );
28302
+ function sourceDomainsOf(snapshot) {
28303
+ const hosts = /* @__PURE__ */ new Set();
28304
+ for (const value of [...snapshot.citedDomains ?? [], ...snapshot.citedUrls ?? []]) {
28305
+ if (typeof value !== "string") continue;
28306
+ const host = hostOf(value);
28307
+ if (!host || isProviderInfraDomain(host)) continue;
28308
+ hosts.add(host);
28309
+ }
28310
+ return [...hosts];
28311
+ }
28312
+ function bumpCategory(counts2, category, domain) {
28313
+ let domains = counts2.get(category);
28314
+ if (!domains) {
28315
+ domains = /* @__PURE__ */ new Map();
28316
+ counts2.set(category, domains);
28317
+ }
28318
+ domains.set(domain, (domains.get(domain) ?? 0) + 1);
27952
28319
  }
27953
28320
  function bucketSizeForSpan(spanDays) {
27954
28321
  if (spanDays <= 14) return 1;
@@ -28103,7 +28470,7 @@ function bumpDomain(map, domain, category, label, surfaceClass) {
28103
28470
  if (existing) existing.count += 1;
28104
28471
  else map.set(domain, { domain, count: 1, category, label, surfaceClass });
28105
28472
  }
28106
- function buildRankedList(domains, limit) {
28473
+ function buildRankedList(domains, limit, answerTotal, answersWithSources) {
28107
28474
  const all = [...domains.values()];
28108
28475
  const totalCitedSlots = all.reduce((sum, d) => sum + d.count, 0);
28109
28476
  const domainTotal = all.length;
@@ -28113,6 +28480,7 @@ function buildRankedList(domains, limit) {
28113
28480
  domain: d.domain,
28114
28481
  count: d.count,
28115
28482
  percentage: totalCitedSlots > 0 ? round4(d.count / totalCitedSlots) : 0,
28483
+ answerShare: answerTotal > 0 ? round4(d.count / answerTotal) : 0,
28116
28484
  category: d.category,
28117
28485
  label: d.label,
28118
28486
  surfaceClass: d.surfaceClass
@@ -28134,6 +28502,8 @@ function buildRankedList(domains, limit) {
28134
28502
  })).sort((a, b) => b.count - a.count || a.surfaceClass.localeCompare(b.surfaceClass));
28135
28503
  return {
28136
28504
  totalCitedSlots,
28505
+ answerTotal,
28506
+ answersWithSources,
28137
28507
  domainTotal,
28138
28508
  entries,
28139
28509
  truncatedDomainCount: domainTotal - shownEntries.length,
@@ -36589,7 +36959,7 @@ var competitorLandscapeGroupKeyParameter = {
36589
36959
  var competitorLandscapeScopeParameter = {
36590
36960
  name: "scope",
36591
36961
  in: "query",
36592
- description: 'Set to "all-markets" to aggregate raw stored evidence across every Advanced Measurement market. It cannot be combined with groupKey.',
36962
+ description: 'Set to "all-markets" to aggregate raw stored evidence across every Property in the Advanced Measurement plan, including Properties in no market. It cannot be combined with groupKey.',
36593
36963
  schema: { type: "string", enum: ["project", "all-markets"] }
36594
36964
  };
36595
36965
  var competitorLandscapeProviderParameter = {
@@ -36613,7 +36983,7 @@ var competitorLandscapeGroupByParameter = {
36613
36983
  var competitorLandscapeQueryClassParameter = {
36614
36984
  name: "queryClass",
36615
36985
  in: "query",
36616
- description: "Restrict evidence to a question class. Advanced groups use their frozen assignment classes; simple projects classify stored query text.",
36986
+ description: "Restrict evidence to a query class. Advanced groups use their frozen assignment classes; simple projects classify stored query text. On a project with an active v2 measurement plan, branded or non-brand without scope or groupKey defaults to scope=all-markets, so the class comes from the plan rather than the text classifier.",
36617
36987
  schema: { type: "string", enum: ["all", "branded", "non-brand"] }
36618
36988
  };
36619
36989
  var competitorLandscapeLocationParameter = {
@@ -36628,6 +36998,24 @@ var competitorLandscapeRunIdParameter = {
36628
36998
  description: "Restrict evidence to one stored answer-visibility run.",
36629
36999
  schema: stringSchema
36630
37000
  };
37001
+ var sourcesRunIdParameter = {
37002
+ name: "runId",
37003
+ in: "query",
37004
+ description: "Read one stored answer-visibility run instead of pooling every run in the window. An unknown id is 404; a probe, unfinished, partially measured, or out-of-window run is 400.",
37005
+ schema: stringSchema
37006
+ };
37007
+ var sourcesQueryClassParameter = {
37008
+ name: "queryClass",
37009
+ in: "query",
37010
+ description: "Restrict to branded or non-brand answers. With an active v2 measurement plan the class comes from each run's frozen plan assignments (the same answers as the competitor landscape at scope=all-markets); otherwise from the project's brand-name classifier over the query text. Answers neither basis can place are excluded and counted in unclassifiedAnswers. Defaults to all, which pools both classes.",
37011
+ schema: { type: "string", enum: ["all", "branded", "non-brand"] }
37012
+ };
37013
+ var sourcesIncludeByQueryParameter = {
37014
+ name: "includeByQuery",
37015
+ in: "query",
37016
+ description: "Set to false to omit the per-query breakdown (byQuery), which grows with every tracked query. Defaults to true.",
37017
+ schema: { type: "string", enum: ["true", "false", "1", "0"] }
37018
+ };
36631
37019
  var analyticsStartDateParameter = {
36632
37020
  name: "startDate",
36633
37021
  in: "query",
@@ -37435,7 +37823,7 @@ var routeCatalog = [
37435
37823
  method: "get",
37436
37824
  path: "/api/v1/projects/{name}/measurement-portfolio-summary",
37437
37825
  summary: "Get the weakest measured Properties",
37438
- description: "Returns a compact, revision-pinned portfolio ranking from stored results, plus a worst-first roll-up of every named market. It defaults to Non-brand questions, ranks measured mention coverage before citation coverage, and keeps unavailable rows separate from measured weakness. Every market is scoped to the displayed run, so a market row matches that market read with groupKey; markets may share Properties and never sum to the portfolio totals. The markets array is empty when the request already narrowed to one group. Replacement names come only from stored answer extraction. It never starts provider work.",
37826
+ description: "Returns a compact, revision-pinned portfolio ranking from stored results, plus a worst-first market roll-up. It defaults to non-brand queries, ranks measured mention coverage before citation coverage, and keeps unavailable rows separate from measured weakness. Every Property row carries its metro, submarkets and query count; weakest rows also carry namedInsteadInAnswerText (names written in the answer text of answers that neither named nor cited the Property, counted by answer) and citedDomains (domains cited by the Property's answers, counted by answer, every engine included). tiedAtWeakest reports Properties sharing the weakest rates, and weakestAnswerSources ranks the domains cited across the weakest and tied Properties, each answer once. markets holds one level by default (top-level markets, or the selected group's direct children), worst-first and capped at limit; includeNestedMarkets returns every level. Every market is scoped to the displayed run, so a market row matches that market read with groupKey; markets may share Properties and never sum to the portfolio totals. It never starts provider work.",
37439
37827
  tags: ["measurement-plans"],
37440
37828
  parameters: [
37441
37829
  nameParameter,
@@ -37444,7 +37832,8 @@ var routeCatalog = [
37444
37832
  { name: "provider", in: "query", description: "Restrict to one answer provider.", schema: stringSchema },
37445
37833
  { name: "location", in: "query", description: "Restrict to one execution location label.", schema: stringSchema },
37446
37834
  { name: "runId", in: "query", description: "Read this completed or partial active-revision run, including a named spot check.", schema: stringSchema },
37447
- { name: "limit", in: "query", description: "Maximum Property rows. Defaults to 10, maximum 50.", schema: { type: "integer", minimum: 1, maximum: 50 } }
37835
+ { name: "limit", in: "query", description: "Rows per list (weakest Properties, both mention rankings, markets). Defaults to 4, maximum 50.", schema: { type: "integer", minimum: 1, maximum: 50 } },
37836
+ { name: "includeNestedMarkets", in: "query", description: "Return every market in scope at every level, uncapped. Defaults to false: one level (top-level markets, or the selected group's direct children), capped at limit.", schema: { type: "boolean" } }
37448
37837
  ],
37449
37838
  responses: {
37450
37839
  200: jsonResponse("Compact portfolio summary returned.", "MeasurementPortfolioSummaryResponse"),
@@ -38591,11 +38980,20 @@ var routeCatalog = [
38591
38980
  method: "get",
38592
38981
  path: "/api/v1/projects/{name}/analytics/sources",
38593
38982
  summary: "Get source origin analytics",
38983
+ description: "Cited domains ranked by how many answers cite them, read from each answer's stored source list (citedDomains plus citedUrls), so every provider is counted, Gemini included. A domain counts at most once per answer. Without runId the response pools every run in the window (runCount says how many); without queryClass it pools branded and non-brand answers.",
38594
38984
  tags: ["analytics"],
38595
- parameters: [nameParameter, analyticsWindowParameter, limitQueryParameter],
38985
+ parameters: [
38986
+ nameParameter,
38987
+ analyticsWindowParameter,
38988
+ limitQueryParameter,
38989
+ sourcesRunIdParameter,
38990
+ sourcesQueryClassParameter,
38991
+ sourcesIncludeByQueryParameter
38992
+ ],
38596
38993
  responses: {
38597
38994
  200: jsonResponse("Source breakdown returned.", "SourceBreakdownDto"),
38598
- 404: errorResponse("Project not found.")
38995
+ 400: errorResponse("Invalid query parameters."),
38996
+ 404: errorResponse("Project or run not found.")
38599
38997
  }
38600
38998
  },
38601
38999
  {
@@ -55513,7 +55911,7 @@ async function cdpRoutes(app, opts) {
55513
55911
  browserOnlyCited++;
55514
55912
  disagreed++;
55515
55913
  }
55516
- const parseGroundingSources2 = (snap) => {
55914
+ const parseGroundingSources = (snap) => {
55517
55915
  if (!snap?.rawResponse) return [];
55518
55916
  try {
55519
55917
  const raw = JSON.parse(snap.rawResponse);
@@ -55528,13 +55926,13 @@ async function cdpRoutes(app, opts) {
55528
55926
  provider: api.provider,
55529
55927
  citationState: api.citationState,
55530
55928
  citedDomains: api.citedDomains,
55531
- groundingSources: parseGroundingSources2(api)
55929
+ groundingSources: parseGroundingSources(api)
55532
55930
  } : null,
55533
55931
  browser: browser ? {
55534
55932
  provider: browser.provider,
55535
55933
  citationState: browser.citationState,
55536
55934
  citedDomains: browser.citedDomains,
55537
- groundingSources: parseGroundingSources2(browser),
55935
+ groundingSources: parseGroundingSources(browser),
55538
55936
  screenshotUrl: browser.screenshotPath ? `${opts.routePrefix ?? "/api/v1"}/screenshots/${browser.id}` : void 0
55539
55937
  } : null,
55540
55938
  agreement
@@ -87546,6 +87944,10 @@ export {
87546
87944
  migrate,
87547
87945
  OperationalLogStore,
87548
87946
  groupRunsByCreatedAt,
87947
+ determineCitationState,
87948
+ computeCompetitorOverlap,
87949
+ computeCitedCompetitorDomains,
87950
+ extractRecommendedCompetitors,
87549
87951
  resolveTrustProxy,
87550
87952
  USER_SESSION_COOKIE_NAME,
87551
87953
  anyUsersExist,
@@ -87634,10 +88036,6 @@ export {
87634
88036
  addLogListener,
87635
88037
  createLogger,
87636
88038
  createFastifyLogger,
87637
- determineCitationState,
87638
- computeCompetitorOverlap,
87639
- computeCitedCompetitorDomains,
87640
- extractRecommendedCompetitors,
87641
88039
  HISTORY_WINDOW_RUNS,
87642
88040
  IntelligenceService
87643
88041
  };