@ainyc/canonry 4.190.1 → 4.192.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/assets/agent-workspace/skills/canonry/SKILL.md +124 -216
  2. package/assets/assets/{AuditHistoryPanel-Dob_85ay.js → AuditHistoryPanel-C7MwfCl9.js} +1 -1
  3. package/assets/assets/{BacklinksPage-BDm4gQsa.js → BacklinksPage-1Nq1zbRk.js} +1 -1
  4. package/assets/assets/{ChartPrimitives-DLEICrrL.js → ChartPrimitives-Bxa_vFmq.js} +1 -1
  5. package/assets/assets/{HistoryPage-BUGJKNWJ.js → HistoryPage-BNACyEiz.js} +1 -1
  6. package/assets/assets/MeasurementPropertyPage-BqjpIYAw.js +1 -0
  7. package/assets/assets/ProjectPage-BhBzznlv.js +8 -0
  8. package/assets/assets/{RunRow-Cja3vahD.js → RunRow-DINPo5ES.js} +1 -1
  9. package/assets/assets/{RunsPage-BdVfLW9Z.js → RunsPage-BFuDLm7p.js} +1 -1
  10. package/assets/assets/{SettingsPage-Xw9rdDPd.js → SettingsPage-CkqmyTSK.js} +1 -1
  11. package/assets/assets/SiteHealthSection-DzFKj9lX.js +4 -0
  12. package/assets/assets/{TrafficPage-D5ZqJrJm.js → TrafficPage-CkIGVLET.js} +1 -1
  13. package/assets/assets/{TrafficSourceDetailPage-DB5V8cdH.js → TrafficSourceDetailPage-CTmn_FmZ.js} +1 -1
  14. package/assets/assets/{extract-error-message-lzuarNEg.js → extract-error-message-CC2kxd3e.js} +1 -1
  15. package/assets/assets/{index-BjjM151x.css → index-BtTEcbcB.css} +1 -1
  16. package/assets/assets/{index-YVHujfy1.js → index-CCPMPGar.js} +26 -26
  17. package/assets/assets/{react-sigma_core.esm.min-Ddu-c40B.js → react-sigma_core.esm.min-DkpQa1sf.js} +1 -1
  18. package/assets/assets/{vendor-lucide-dNHw1I5G.js → vendor-lucide-CW2qe0uH.js} +1 -1
  19. package/assets/assets/{vendor-markdown-9tTeP8v7.js → vendor-markdown-B-Oq2NYq.js} +1 -1
  20. package/assets/assets/{vendor-radix-BpmvLvpp.js → vendor-radix-twS1M3FF.js} +1 -1
  21. package/assets/assets/{vendor-recharts-FA0uySU7.js → vendor-recharts-D-cUuLZo.js} +1 -1
  22. package/assets/assets/{vendor-tanstack-CYBjbqVr.js → vendor-tanstack-ZXXkndVj.js} +1 -1
  23. package/assets/index.html +7 -7
  24. package/dist/{chunk-2II4ZMMH.js → chunk-32Q2NZ4S.js} +2 -2
  25. package/dist/{chunk-32QBVRMN.js → chunk-36RKISAI.js} +213 -32
  26. package/dist/{chunk-ROPJUNQN.js → chunk-BULKBROE.js} +1368 -1223
  27. package/dist/{chunk-ECPN4OUR.js → chunk-FTKZAOFD.js} +649 -249
  28. package/dist/{chunk-BE366UCM.js → chunk-JOSSB4QC.js} +73 -22
  29. package/dist/cli.js +65 -10
  30. package/dist/index.js +4 -4
  31. package/dist/{intelligence-service-QCKX2W4G.js → intelligence-service-MEQ5VX5L.js} +2 -2
  32. package/dist/mcp.js +13 -8
  33. package/package.json +11 -11
  34. package/assets/assets/MeasurementPropertyPage-H2g4VdcA.js +0 -1
  35. package/assets/assets/ProjectPage-d7iOSkrd.js +0 -8
  36. package/assets/assets/SiteHealthSection-CjioXMWy.js +0 -4
@@ -88,6 +88,8 @@ import {
88
88
  MeasurementRunScopeError,
89
89
  PROJECTS_WRITE_SCOPE,
90
90
  READ_ONLY_SCOPE,
91
+ REPORT_VISIBILITY_COPY,
92
+ RESEARCH_RUN_SCOPE,
91
93
  ResearchQueryStatuses,
92
94
  ResearchRunStatuses,
93
95
  RunKinds,
@@ -381,6 +383,7 @@ import {
381
383
  inclusiveDayCount,
382
384
  indexingRequestResponseDtoSchema,
383
385
  internalError,
386
+ intersectScopes,
384
387
  isBrowserProvider,
385
388
  isLocationRedirectStatus,
386
389
  isReadOnlyKey,
@@ -559,7 +562,16 @@ import {
559
562
  reportComparisonWindowDays,
560
563
  reportConfidenceLabel,
561
564
  reportHorizonLabel,
565
+ reportQueryClassLabel,
562
566
  reportSeverityLabel,
567
+ reportVisibilityComparison,
568
+ reportVisibilityEvidence,
569
+ reportVisibilityHistoryLabel,
570
+ reportVisibilityLocationLabel,
571
+ reportVisibilityMeasurementLabel,
572
+ reportVisibilityRate,
573
+ researchBatchCreateSchema,
574
+ researchBatchSchema,
563
575
  researchDailyLimitExceeded,
564
576
  researchRunCreateSchema,
565
577
  researchRunDetailSchema,
@@ -570,6 +582,7 @@ import {
570
582
  resolveMeasurementRunQueryScope,
571
583
  resolveMeasurementRunScope,
572
584
  resolveSnapshotRequestQueries,
585
+ restrictedWriteScopes,
573
586
  resultsClearRequestSchema,
574
587
  resultsClearResponseSchema,
575
588
  resultsExportDtoSchema,
@@ -676,10 +689,10 @@ import {
676
689
  wordpressSchemaDeployResultDtoSchema,
677
690
  wordpressSchemaStatusResultDtoSchema,
678
691
  wordpressStatusDtoSchema
679
- } from "./chunk-ROPJUNQN.js";
692
+ } from "./chunk-BULKBROE.js";
680
693
 
681
694
  // src/intelligence-service.ts
682
- import { eq as eq67, desc as desc29, asc as asc12, and as and57, ne as ne10, or as or14, inArray as inArray25, gte as gte18, lte as lte15, isNull as isNull12, sql as sql26, exists } from "drizzle-orm";
695
+ import { eq as eq67, desc as desc29, asc as asc12, and as and57, ne as ne10, or as or15, inArray as inArray25, gte as gte18, lte as lte15, isNull as isNull12, sql as sql26, exists } from "drizzle-orm";
683
696
 
684
697
  // ../db/src/client.ts
685
698
  import { mkdirSync } from "fs";
@@ -1181,6 +1194,8 @@ var apiKeys = sqliteTable("api_keys", {
1181
1194
  * project so a stale scoped key never outlives its project.
1182
1195
  */
1183
1196
  projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
1197
+ /** Internal delegated session identity, never accepted by the key-create API. */
1198
+ delegatedUserId: text("delegated_user_id").references(() => users.id, { onDelete: "cascade" }),
1184
1199
  createdAt: text("created_at").notNull(),
1185
1200
  lastUsedAt: text("last_used_at"),
1186
1201
  revokedAt: text("revoked_at")
@@ -7177,6 +7192,13 @@ var MIGRATION_VERSIONS = [
7177
7192
  `ALTER TABLE research_runs ADD COLUMN template TEXT`,
7178
7193
  `ALTER TABLE research_run_queries ADD COLUMN query_class TEXT`
7179
7194
  ]
7195
+ },
7196
+ {
7197
+ version: 154,
7198
+ name: "delegated-api-key-user",
7199
+ statements: [
7200
+ `ALTER TABLE api_keys ADD COLUMN delegated_user_id TEXT REFERENCES users(id) ON DELETE CASCADE`
7201
+ ]
7180
7202
  }
7181
7203
  ];
7182
7204
  function addRunsMeasurementPlanVersionForeignKey(tx) {
@@ -8471,14 +8493,14 @@ function buildCitationScorecard(snapshots, queryLookup) {
8471
8493
  const providerRates = providerList.map((provider) => {
8472
8494
  const counts2 = providerCounts.get(provider) ?? { cited: 0, mentioned: 0, total: 0 };
8473
8495
  const citationRate = counts2.total > 0 ? Math.round(counts2.cited / counts2.total * 100) : 0;
8474
- const mentionRate2 = counts2.total > 0 ? Math.round(counts2.mentioned / counts2.total * 100) : 0;
8496
+ const mentionRate3 = counts2.total > 0 ? Math.round(counts2.mentioned / counts2.total * 100) : 0;
8475
8497
  return {
8476
8498
  provider,
8477
8499
  citedCount: counts2.cited,
8478
8500
  mentionedCount: counts2.mentioned,
8479
8501
  totalCount: counts2.total,
8480
8502
  citationRate,
8481
- mentionRate: mentionRate2
8503
+ mentionRate: mentionRate3
8482
8504
  };
8483
8505
  });
8484
8506
  return { queries: queryList, providers: providerList, matrix, providerRates };
@@ -9635,7 +9657,7 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
9635
9657
  const citedCount = [...queryCited.values()].filter(Boolean).length;
9636
9658
  const citationRate = totalCount > 0 ? Math.round(citedCount / totalCount * 100) : 0;
9637
9659
  const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
9638
- const mentionRate2 = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
9660
+ const mentionRate3 = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
9639
9661
  return {
9640
9662
  runId: run.id,
9641
9663
  createdAt: run.createdAt,
@@ -9643,7 +9665,7 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
9643
9665
  totalCount,
9644
9666
  citationRate,
9645
9667
  mentionedCount,
9646
- mentionRate: mentionRate2,
9668
+ mentionRate: mentionRate3,
9647
9669
  status: run.status
9648
9670
  };
9649
9671
  });
@@ -10795,10 +10817,6 @@ var ADS_MUTATION_SCOPES = /* @__PURE__ */ new Set([
10795
10817
  ADS_APPROVE_SCOPE,
10796
10818
  ADS_ACTIVATE_SCOPE
10797
10819
  ]);
10798
- function isAdsMutationOnlyKey(scopes) {
10799
- const writeGrants = scopes.filter((scope) => scope === "*" || scope === "write" || scope.endsWith(".write") || scope === ADS_APPROVE_SCOPE || scope === ADS_ACTIVATE_SCOPE);
10800
- return writeGrants.length > 0 && writeGrants.every((scope) => ADS_MUTATION_SCOPES.has(scope));
10801
- }
10802
10820
  function isAdsWriteRoute(url) {
10803
10821
  const rest = projectRouteRest(url);
10804
10822
  return rest !== null && rest.startsWith("ads/");
@@ -10829,8 +10847,8 @@ var VIEWER_DENIED_MESSAGE = "Your account has view-only access, so it cannot mak
10829
10847
  var ADMIN_ONLY_MESSAGE = "Only an administrator account can use this.";
10830
10848
  function requireAdminSession(request) {
10831
10849
  const principal = request.principal;
10832
- if (!principal || principal.kind !== "user") return;
10833
- if (principal.role === UserRoles.admin) return;
10850
+ const role = principal?.kind === "user" ? principal.role : principal?.delegatedUser?.role;
10851
+ if (!role || role === UserRoles.admin) return;
10834
10852
  throw forbidden(ADMIN_ONLY_MESSAGE);
10835
10853
  }
10836
10854
  function requirePaidReadScope(request) {
@@ -10841,16 +10859,20 @@ function requirePaidReadScope(request) {
10841
10859
  throw forbidden("This API key was not granted access to OpenAI Ads paid reads.");
10842
10860
  }
10843
10861
  }
10862
+ function canRunResearch(request, allowViewers) {
10863
+ const principal = request.principal;
10864
+ if (!principal) return true;
10865
+ if (principal.kind === "user" && principal.role === UserRoles.viewer && !allowViewers) return false;
10866
+ return principal.scopes.includes(WILDCARD_SCOPE) || principal.scopes.includes(RESEARCH_RUN_SCOPE);
10867
+ }
10844
10868
  function requireResearchGrant(request, allowViewers) {
10845
10869
  const principal = request.principal;
10846
10870
  if (!principal) return;
10847
- if (principal.kind === "api-key") {
10848
- if (principal.scopes.includes(WILDCARD_SCOPE)) return;
10849
- throw forbidden("This API key was not granted access to paid research queries.");
10871
+ if (canRunResearch(request, allowViewers)) return;
10872
+ if (principal.kind === "user" && principal.role === UserRoles.viewer && !allowViewers) {
10873
+ throw forbidden("Research queries are not enabled for viewer accounts on this deployment.");
10850
10874
  }
10851
- if (principal.role === UserRoles.admin) return;
10852
- if (principal.role === UserRoles.viewer && allowViewers) return;
10853
- throw forbidden("Research queries are not enabled for viewer accounts on this deployment.");
10875
+ throw forbidden('This credential was not granted the "research.run" capability.');
10854
10876
  }
10855
10877
  function requireBroadInstanceKey(request) {
10856
10878
  const principal = request.principal;
@@ -10893,8 +10915,8 @@ function shouldSkipAuth(url) {
10893
10915
  }
10894
10916
  var USERS_READ_SCOPE = "users.read";
10895
10917
  var USERS_WRITE_SCOPE = "users.write";
10896
- function scopesForRole(role) {
10897
- return role === UserRoles.admin ? [WILDCARD_SCOPE] : [READ_ONLY_SCOPE];
10918
+ function scopesForRole(role, researchAllowViewers = false) {
10919
+ return role === UserRoles.admin ? [WILDCARD_SCOPE] : researchAllowViewers ? [READ_ONLY_SCOPE, RESEARCH_RUN_SCOPE] : [READ_ONLY_SCOPE];
10898
10920
  }
10899
10921
  function queryValue(request, key) {
10900
10922
  const raw = request.query?.[key];
@@ -10984,7 +11006,7 @@ function enforceEmbedProjectTabs(request, configuredTabs) {
10984
11006
  if (tabs.includes("report") && isReportRead(url)) return;
10985
11007
  throw forbidden("This endpoint is not available for the configured embed tabs.");
10986
11008
  }
10987
- function resolveSignedInPerson(app, request, reply, cookie) {
11009
+ function resolveSignedInPerson(app, request, reply, cookie, researchAllowViewers) {
10988
11010
  const sessionId = parseCookieHeader(request.headers.cookie)[USER_SESSION_COOKIE_NAME];
10989
11011
  if (!sessionId) return false;
10990
11012
  const resolved = resolveUserSession(app.db, sessionId);
@@ -10993,7 +11015,7 @@ function resolveSignedInPerson(app, request, reply, cookie) {
10993
11015
  kind: "user",
10994
11016
  id: resolved.user.id,
10995
11017
  name: resolved.user.name,
10996
- scopes: scopesForRole(resolved.user.role),
11018
+ scopes: scopesForRole(resolved.user.role, researchAllowViewers),
10997
11019
  projectId: null,
10998
11020
  role: resolved.user.role,
10999
11021
  viaCookie: true
@@ -11009,12 +11031,24 @@ function resolveSignedInPerson(app, request, reply, cookie) {
11009
11031
  }
11010
11032
  function applyRoleGates(request) {
11011
11033
  const principal = request.principal;
11012
- if (!principal || principal.kind !== "user") return;
11013
- request.readSemanticGrant = principal.role === UserRoles.viewer && isReadSemanticRoute(request);
11034
+ const role = principal?.kind === "user" ? principal.role : principal?.delegatedUser?.role;
11035
+ if (!principal || !role) return;
11036
+ request.readSemanticGrant = role === UserRoles.viewer && isReadSemanticRoute(request);
11037
+ applyScopedWriteGates(request);
11014
11038
  if (isReadOnlyKey(principal.scopes) && WRITE_METHODS2.has(request.method) && !request.readSemanticGrant && !isPaidReadRoute(request) && !isTransportEnvelopeRoute(request)) {
11015
11039
  throw forbidden(VIEWER_DENIED_MESSAGE);
11016
11040
  }
11017
11041
  }
11042
+ function applyScopedWriteGates(request) {
11043
+ if (!WRITE_METHODS2.has(request.method) || isTransportEnvelopeRoute(request) || request.readSemanticGrant) return;
11044
+ const scopes = principalScopes(request) ?? [];
11045
+ const restricted = restrictedWriteScopes(scopes);
11046
+ if (!restricted) return;
11047
+ const required2 = request.routeOptions.config.writeScope;
11048
+ if (required2 && restricted.includes(required2)) return;
11049
+ if (restricted.some((scope) => ADS_MUTATION_SCOPES.has(scope)) && isAdsWriteRoute(request.url.split("?")[0])) return;
11050
+ throw forbidden("This credential can only perform operations explicitly granted by its scopes.");
11051
+ }
11018
11052
  async function authPlugin(app, opts = {}) {
11019
11053
  app.addHook("onRequest", async (request, reply) => {
11020
11054
  const url = request.url.split("?")[0];
@@ -11035,10 +11069,9 @@ async function authPlugin(app, opts = {}) {
11035
11069
  if (granted) {
11036
11070
  const account = app.db.select().from(users).where(eq2(users.id, granted.userId)).get();
11037
11071
  if (!account) throw authInvalid();
11038
- const roleScopes = scopesForRole(account.role);
11072
+ const roleScopes = scopesForRole(account.role, opts.researchAllowViewers);
11039
11073
  const requested = (granted.scope ?? "").split(/\s+/).filter((part) => part.length > 0 && part !== "offline_access");
11040
- const narrowed = roleScopes.includes(WILDCARD_SCOPE) ? requested.length > 0 ? requested : [READ_ONLY_SCOPE] : roleScopes.filter((scope) => requested.includes(scope) || requested.includes(WILDCARD_SCOPE));
11041
- const effective = narrowed.length > 0 ? narrowed : [READ_ONLY_SCOPE];
11074
+ const effective = intersectScopes(roleScopes, requested);
11042
11075
  request.principal = {
11043
11076
  kind: "user",
11044
11077
  id: account.id,
@@ -11055,7 +11088,7 @@ async function authPlugin(app, opts = {}) {
11055
11088
  }
11056
11089
  throw authInvalid();
11057
11090
  }
11058
- } else if (resolveSignedInPerson(app, request, reply, opts.userSessionCookie)) {
11091
+ } else if (resolveSignedInPerson(app, request, reply, opts.userSessionCookie, opts.researchAllowViewers ?? false)) {
11059
11092
  applyRoleGates(request);
11060
11093
  assertSameOriginWrite(request);
11061
11094
  enforceEmbedProjectTabs(request, opts.embedProjectTabs);
@@ -11078,7 +11111,10 @@ async function authPlugin(app, opts = {}) {
11078
11111
  throw authRequired();
11079
11112
  }
11080
11113
  app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq2(apiKeys.id, key.id)).run();
11081
- const scopes = Array.isArray(key.scopes) ? key.scopes : [];
11114
+ let scopes = Array.isArray(key.scopes) ? key.scopes : [];
11115
+ const delegatedUser = key.delegatedUserId ? app.db.select().from(users).where(eq2(users.id, key.delegatedUserId)).get() : void 0;
11116
+ if (key.delegatedUserId && !delegatedUser) throw authInvalid();
11117
+ if (delegatedUser) scopes = intersectScopes(scopesForRole(delegatedUser.role, opts.researchAllowViewers), scopes);
11082
11118
  request.apiKey = { id: key.id, name: key.name, scopes, projectId: key.projectId ?? null };
11083
11119
  request.principal = {
11084
11120
  kind: "api-key",
@@ -11086,15 +11122,15 @@ async function authPlugin(app, opts = {}) {
11086
11122
  name: key.name,
11087
11123
  scopes,
11088
11124
  projectId: key.projectId ?? null,
11089
- viaCookie: keyArrivedInCookie
11125
+ viaCookie: keyArrivedInCookie,
11126
+ ...delegatedUser ? { delegatedUser: { id: delegatedUser.id, name: delegatedUser.name, role: delegatedUser.role } } : {}
11090
11127
  };
11091
11128
  assertSameOriginWrite(request);
11092
- if (isReadOnlyKey(scopes) && WRITE_METHODS2.has(request.method) && !isTransportEnvelopeRoute(request)) {
11129
+ applyRoleGates(request);
11130
+ applyScopedWriteGates(request);
11131
+ if (isReadOnlyKey(scopes) && WRITE_METHODS2.has(request.method) && !request.readSemanticGrant && !isTransportEnvelopeRoute(request)) {
11093
11132
  throw forbidden("This API key is read-only and cannot perform write operations.");
11094
11133
  }
11095
- if (isAdsMutationOnlyKey(scopes) && WRITE_METHODS2.has(request.method) && !isAdsWriteRoute(url)) {
11096
- throw forbidden("This API key can only perform OpenAI Ads write operations.");
11097
- }
11098
11134
  enforceEmbedProjectTabs(request, opts.embedProjectTabs);
11099
11135
  if (key.projectId) {
11100
11136
  const match = url.match(/\/projects\/([^/?#]+)/);
@@ -11149,7 +11185,7 @@ function redirectUriAllowed(registered, presented) {
11149
11185
  function isLoopbackHost(hostname) {
11150
11186
  return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
11151
11187
  }
11152
- var SUPPORTED_SCOPES = ["read", "offline_access"];
11188
+ var SUPPORTED_SCOPES = ["read", RESEARCH_RUN_SCOPE, "offline_access"];
11153
11189
  function resolveRequestedScope(requested) {
11154
11190
  if (!requested || !requested.trim()) return "read";
11155
11191
  const parts = requested.trim().split(/\s+/);
@@ -11183,6 +11219,7 @@ function consentPage(o) {
11183
11219
  <dt>Permissions</dt><dd>${escapeHtml(o.scope)}</dd>
11184
11220
  <dt>Redirects to</dt><dd class="uri">${escapeHtml(o.redirectUri)}</dd>
11185
11221
  </dl>
11222
+ ${o.scope.split(/\s+/).includes(RESEARCH_RUN_SCOPE) ? `<p class="warn">Run research queries using the operator's provider quota, subject to the shared daily research limit. This does not grant permission to change tracking or settings.</p>` : ""}
11186
11223
  <input type="hidden" name="csrf" value="${escapeHtml(o.csrf)}">
11187
11224
  <div class="row">
11188
11225
  <button type="submit" name="approve" value="no" class="ghost">Deny</button>
@@ -13293,7 +13330,7 @@ function normalizeMeasurementLocation(value) {
13293
13330
  return normalized || null;
13294
13331
  }
13295
13332
  function words(value) {
13296
- return value.normalize("NFKC").toLocaleLowerCase("en").match(/[\p{L}\p{N}]+/gu) ?? [];
13333
+ return value.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
13297
13334
  }
13298
13335
  function measurementMentionAliasKey(value) {
13299
13336
  return words(value).join("\0");
@@ -13326,10 +13363,11 @@ function indexMentionAliases(aliases) {
13326
13363
  }
13327
13364
  return byFirstWord;
13328
13365
  }
13329
- function mentionedTargetsForAliases(answerText, aliases) {
13366
+ function mentionedTargetsForAliases(answerText, aliases, preparedWords) {
13330
13367
  const result = /* @__PURE__ */ new Set();
13331
- if (answerText === null) return result;
13332
- const textWords = words(answerText);
13368
+ const unknown = /* @__PURE__ */ new Set();
13369
+ if (answerText === null) return { mentioned: result, unknown };
13370
+ const textWords = preparedWords ?? words(answerText);
13333
13371
  for (let start = 0; start < textWords.length; ) {
13334
13372
  const matches = (aliases.get(textWords[start]) ?? []).filter((alias2) => aliasMatchesAt(textWords, alias2.words, start));
13335
13373
  if (matches.length === 0) {
@@ -13339,16 +13377,46 @@ function mentionedTargetsForAliases(answerText, aliases) {
13339
13377
  const longest = Math.max(...matches.map((match) => match.words.length));
13340
13378
  const owners = sortedUnique(matches.filter((match) => match.words.length === longest).map((match) => match.targetId));
13341
13379
  if (owners.length === 1) result.add(owners[0]);
13380
+ else for (const owner of owners) unknown.add(owner);
13342
13381
  start += longest;
13343
13382
  }
13344
- return result;
13383
+ for (const owner of result) unknown.delete(owner);
13384
+ return { mentioned: result, unknown };
13345
13385
  }
13346
- function mentionedTargets(answerText, targets) {
13347
- return mentionedTargetsForAliases(answerText, indexMentionAliases(compiledMentionAliases(targets)));
13386
+ function identityAmbiguityPatterns(aliases) {
13387
+ const patterns = aliases.flatMap((alias2) => {
13388
+ const name = words(alias2).join(" ").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13389
+ if (!name) return [];
13390
+ return [{
13391
+ clarification: new RegExp(`(?:^| )which (?:specific )?${name} (?:(?:(?:do|did) )?you mean|(?:(?:are|were) you|you(?: re| are| were)?) (?:asking about|referring to|talking about))(?= |$)`, "u"),
13392
+ multipleEntities: [
13393
+ new RegExp(`(?:^| )${name} (?:turns out to |can |could |may )?refers? to (?:a )?(?:few |several |multiple |different )`, "u"),
13394
+ new RegExp(`(?:^| )${name} is (?:a |the )?name shared by(?= |$)`, "u"),
13395
+ new RegExp(`(?:^| )(?:several|multiple|different) (?:places|properties|businesses|communities|entities) (?:called|named) ${name}(?= |$)`, "u")
13396
+ ]
13397
+ }];
13398
+ });
13399
+ return {
13400
+ clarification: patterns.map((pattern) => pattern.clarification),
13401
+ multipleEntities: patterns.flatMap((pattern) => pattern.multipleEntities)
13402
+ };
13348
13403
  }
13349
- function targetMentionedInAnswer(answerText, targetId, targets) {
13350
- if (answerText === null) return null;
13351
- return mentionedTargets(answerText, targets).has(targetId);
13404
+ function resolveMentionIdentity(text2, targets, aliases, citedTargetIds, ambiguityPatterns = new Map(targets.map((target) => [target.id, identityAmbiguityPatterns(target.aliases)]))) {
13405
+ const textWords = text2 === null ? [] : words(text2);
13406
+ const state = mentionedTargetsForAliases(text2, aliases, textWords);
13407
+ if (text2 === null) return state;
13408
+ const normalized = textWords.join(" ");
13409
+ for (const target of targets) {
13410
+ if (!state.mentioned.has(target.id)) continue;
13411
+ const patterns = ambiguityPatterns.get(target.id);
13412
+ const qualified = citedTargetIds.has(target.id) || (target.identityAliases?.length ?? 0) > 0 && containsAnyAlias(text2, target.identityAliases);
13413
+ const unresolved = patterns?.clarification.some((pattern) => pattern.test(normalized)) || !qualified && (patterns?.multipleEntities.some((pattern) => pattern.test(normalized)) || (target.identityAliases?.length ?? 0) > 0);
13414
+ if (unresolved) {
13415
+ state.mentioned.delete(target.id);
13416
+ state.unknown.add(target.id);
13417
+ }
13418
+ }
13419
+ return state;
13352
13420
  }
13353
13421
  function observationSource(observation) {
13354
13422
  if (observation.citedUrls !== null) {
@@ -13364,15 +13432,28 @@ function observationSource(observation) {
13364
13432
  complete: observation.historicalCitedUrlsComplete === true
13365
13433
  };
13366
13434
  }
13367
- function prepareReport(input, options = {}) {
13435
+ function prepareReport(input, options = {}, detailedEvidence = true) {
13368
13436
  const ambiguous = /* @__PURE__ */ new Set();
13369
13437
  const unmatched = /* @__PURE__ */ new Set();
13370
13438
  const bridged = /* @__PURE__ */ new Set();
13371
13439
  const candidates = /* @__PURE__ */ new Map();
13372
13440
  const mentionAliases = compiledMentionAliases(input.targets);
13373
13441
  const aliasesByFirstWord = indexMentionAliases(mentionAliases);
13442
+ const ambiguityPatterns = new Map(input.targets.map((target) => [target.id, identityAmbiguityPatterns(target.aliases)]));
13443
+ const slotsByIdentity = /* @__PURE__ */ new Map();
13444
+ const slotsByLegacyIdentity = /* @__PURE__ */ new Map();
13445
+ for (const slot of input.expectedSlots) {
13446
+ for (const [index2, key] of [
13447
+ [slotsByIdentity, JSON.stringify([slot.executionId, slot.provider])],
13448
+ [slotsByLegacyIdentity, JSON.stringify([slot.queryText, slot.provider, normalizeMeasurementLocation(slot.location)])]
13449
+ ]) {
13450
+ const slots = index2.get(key) ?? [];
13451
+ slots.push(slot);
13452
+ index2.set(key, slots);
13453
+ }
13454
+ }
13374
13455
  for (const observation of input.observations) {
13375
- const slots = observation.executionId !== null ? input.expectedSlots.filter((slot2) => slot2.executionId === observation.executionId && slot2.provider === observation.provider) : input.expectedSlots.filter((slot2) => slot2.queryText === observation.queryText && slot2.provider === observation.provider && normalizeMeasurementLocation(slot2.location) === normalizeMeasurementLocation(observation.location));
13456
+ const slots = (observation.executionId !== null ? slotsByIdentity.get(JSON.stringify([observation.executionId, observation.provider])) : slotsByLegacyIdentity.get(JSON.stringify([observation.queryText, observation.provider, normalizeMeasurementLocation(observation.location)]))) ?? [];
13376
13457
  if (slots.length === 0) {
13377
13458
  unmatched.add(observation.id);
13378
13459
  continue;
@@ -13409,7 +13490,9 @@ function prepareReport(input, options = {}) {
13409
13490
  historical: source.historical,
13410
13491
  sourceComplete: source.complete,
13411
13492
  sourceUrls: source.urls,
13412
- mentionedTargetIds: mentionedTargetsForAliases(observation.answerText, aliasesByFirstWord)
13493
+ mentionedTargetIds: /* @__PURE__ */ new Set(),
13494
+ unknownMentionTargetIds: /* @__PURE__ */ new Set(),
13495
+ citedTargetIds: /* @__PURE__ */ new Set()
13413
13496
  });
13414
13497
  }
13415
13498
  const edgesByExecution = /* @__PURE__ */ new Map();
@@ -13433,6 +13516,15 @@ function prepareReport(input, options = {}) {
13433
13516
  mentionAliases.map((alias2) => alias2.targetId)
13434
13517
  );
13435
13518
  for (const observation of observationsBySlot.values()) {
13519
+ const citedTargetIds = new Set(observation.sourceUrls.flatMap((url) => {
13520
+ const source = sourceAttribution(url);
13521
+ return source.classification === "matched" ? source.matchedTargetIds : [];
13522
+ }));
13523
+ const mentions = resolveMentionIdentity(observation.input.answerText, input.targets, aliasesByFirstWord, citedTargetIds, ambiguityPatterns);
13524
+ observation.mentionedTargetIds = mentions.mentioned;
13525
+ observation.unknownMentionTargetIds = mentions.unknown;
13526
+ observation.citedTargetIds = citedTargetIds;
13527
+ if (!detailedEvidence) continue;
13436
13528
  const edges = [...edgesByExecution.get(observation.slot.executionId) ?? []].sort((left, right) => compareText2(left.id, right.id));
13437
13529
  for (const edge of edges) {
13438
13530
  const sources = observation.sourceUrls.map((sourceUrl) => {
@@ -13502,6 +13594,17 @@ function prepareReport(input, options = {}) {
13502
13594
  }
13503
13595
  };
13504
13596
  }
13597
+ function buildMeasurementObservationSignals(input) {
13598
+ const prepared = prepareReport(input, {}, false);
13599
+ return [...prepared.observationsBySlot.values()].map((observation) => ({
13600
+ observationId: observation.input.id,
13601
+ mentionedTargetIds: [...observation.mentionedTargetIds],
13602
+ unknownMentionTargetIds: [...observation.unknownMentionTargetIds],
13603
+ citedTargetIds: [...observation.citedTargetIds],
13604
+ sourceComplete: observation.sourceComplete,
13605
+ sourceUrls: observation.sourceUrls
13606
+ }));
13607
+ }
13505
13608
  function slotsForEdges(expectedSlots, edges) {
13506
13609
  const executionIds = new Set(edges.map((edge) => edge.executionId));
13507
13610
  const slots = /* @__PURE__ */ new Map();
@@ -13562,6 +13665,9 @@ function mentionRate(target, slots, prepared) {
13562
13665
  if (!status.complete || !status.answerComplete) {
13563
13666
  return { numerator: null, denominator: null, rate: null, reason: "incomplete" };
13564
13667
  }
13668
+ if (slots.some((slot) => prepared.observationsBySlot.get(slot.id)?.unknownMentionTargetIds.has(target.id))) {
13669
+ return unavailable("identity-ambiguous");
13670
+ }
13565
13671
  return { numerator, denominator: slots.length, rate: numerator / slots.length };
13566
13672
  }
13567
13673
  function providersFor(slots) {
@@ -13679,6 +13785,7 @@ function buildMeasurementOverviewIndexes(input, prepared, onEvidenceIndexed) {
13679
13785
  const answeredSlotIds = /* @__PURE__ */ new Set();
13680
13786
  const sourceCompleteSlotIds = /* @__PURE__ */ new Set();
13681
13787
  const mentionedSlotIdsByTargetId = /* @__PURE__ */ new Map();
13788
+ const unknownMentionSlotIdsByTargetId = /* @__PURE__ */ new Map();
13682
13789
  for (const [slotId, observation] of prepared.observationsBySlot) {
13683
13790
  if (observation.sourceComplete) sourceCompleteSlotIds.add(slotId);
13684
13791
  if (observation.input.answerText === null) continue;
@@ -13686,6 +13793,9 @@ function buildMeasurementOverviewIndexes(input, prepared, onEvidenceIndexed) {
13686
13793
  for (const targetId of observation.mentionedTargetIds) {
13687
13794
  addToSet(mentionedSlotIdsByTargetId, targetId, slotId);
13688
13795
  }
13796
+ for (const targetId of observation.unknownMentionTargetIds) {
13797
+ addToSet(unknownMentionSlotIdsByTargetId, targetId, slotId);
13798
+ }
13689
13799
  }
13690
13800
  const assignedSlotIdsByEdgeId = /* @__PURE__ */ new Map();
13691
13801
  const ambiguousEvidenceKeysByTargetId = /* @__PURE__ */ new Map();
@@ -13710,6 +13820,7 @@ function buildMeasurementOverviewIndexes(input, prepared, onEvidenceIndexed) {
13710
13820
  answeredSlotIds,
13711
13821
  sourceCompleteSlotIds,
13712
13822
  mentionedSlotIdsByTargetId,
13823
+ unknownMentionSlotIdsByTargetId,
13713
13824
  assignedSlotIdsByEdgeId,
13714
13825
  ambiguousEvidenceKeysByTargetId
13715
13826
  };
@@ -13733,27 +13844,34 @@ function mentionedForEdge(observation, edge, mentionableIds) {
13733
13844
  if (edge.type !== "target") return null;
13734
13845
  if (observation.input.answerText === null) return null;
13735
13846
  if (!mentionableIds.has(edge.targetId)) return null;
13847
+ if (observation.unknownMentionTargetIds.has(edge.targetId)) return null;
13736
13848
  return observation.mentionedTargetIds.has(edge.targetId);
13737
13849
  }
13738
13850
  function mentionableTargets(input, targetIds) {
13739
13851
  return input.targets.filter((target) => targetIds.has(target.id) && target.aliases.some((alias2) => words(alias2).length > 0));
13740
13852
  }
13741
- function scopeMentionRate(input, targetIds, slots, answered2, prepared) {
13853
+ function scopeMentionRate(input, targetIds, slots, answered2, prepared, edges) {
13742
13854
  const mentionable = mentionableTargets(input, targetIds);
13743
13855
  if (mentionable.length === 0) return unavailable("aliasless");
13744
13856
  if (slots.length === 0) return unavailable("no-population");
13745
- if (answered2.length === 0) return unavailable("evidence-incomplete");
13746
- const ids = mentionable.map((target) => target.id);
13747
- const numerator = answered2.filter((slot) => {
13748
- const mentioned = prepared.observationsBySlot.get(slot.id)?.mentionedTargetIds;
13749
- return mentioned !== void 0 && ids.some((id) => mentioned.has(id));
13750
- }).length;
13857
+ if (answered2.length !== slots.length) return unavailable("evidence-incomplete");
13858
+ const mentionableIds = new Set(mentionable.map((target) => target.id));
13859
+ const idsByExecution = /* @__PURE__ */ new Map();
13860
+ for (const edge of edges) if (mentionableIds.has(edge.targetId)) addToSet(idsByExecution, edge.executionId, edge.targetId);
13861
+ let numerator = 0;
13862
+ for (const slot of answered2) {
13863
+ const observation = prepared.observationsBySlot.get(slot.id);
13864
+ const ids = [...idsByExecution.get(slot.executionId) ?? []];
13865
+ if (ids.length === 0) return unavailable("aliasless");
13866
+ if (ids.some((id) => observation.mentionedTargetIds.has(id))) numerator++;
13867
+ else if (ids.some((id) => observation.unknownMentionTargetIds.has(id))) return unavailable("identity-ambiguous");
13868
+ }
13751
13869
  return { numerator, denominator: answered2.length, rate: numerator / answered2.length };
13752
13870
  }
13753
13871
  function indexedScopeCitationRate(slots, edges, indexes) {
13754
13872
  if (slots.length === 0) return unavailable("no-population");
13755
13873
  const basis = slots.filter((slot) => indexes.sourceCompleteSlotIds.has(slot.id));
13756
- if (basis.length === 0) return unavailable("evidence-incomplete");
13874
+ if (basis.length !== slots.length) return unavailable("evidence-incomplete");
13757
13875
  const assignedSlots = /* @__PURE__ */ new Set();
13758
13876
  for (const edge of edges) {
13759
13877
  for (const slotId of indexes.assignedSlotIdsByEdgeId.get(edge.id) ?? []) assignedSlots.add(slotId);
@@ -13765,8 +13883,10 @@ function targetMentionRate(targets, slots, answered2, indexes) {
13765
13883
  const mentionable = targets?.filter((target) => target.aliases.some((alias2) => words(alias2).length > 0)) ?? [];
13766
13884
  if (mentionable.length === 0) return unavailable("aliasless");
13767
13885
  if (slots.length === 0) return unavailable("no-population");
13768
- if (answered2.length === 0) return unavailable("evidence-incomplete");
13886
+ if (answered2.length !== slots.length) return unavailable("evidence-incomplete");
13769
13887
  const mentioned = indexes.mentionedSlotIdsByTargetId.get(mentionable[0].id) ?? /* @__PURE__ */ new Set();
13888
+ const unknown = indexes.unknownMentionSlotIdsByTargetId.get(mentionable[0].id);
13889
+ if (unknown && answered2.some((slot) => unknown.has(slot.id))) return unavailable("identity-ambiguous");
13770
13890
  const numerator = answered2.filter((slot) => mentioned.has(slot.id)).length;
13771
13891
  return { numerator, denominator: answered2.length, rate: numerator / answered2.length };
13772
13892
  }
@@ -13783,13 +13903,23 @@ function scopeBrandPresence(input, slots, answered2, prepared) {
13783
13903
  const numerator = presenceIn(answered2, input.projectBrandNames, prepared);
13784
13904
  return { numerator, denominator: answered2.length, rate: numerator / answered2.length };
13785
13905
  }
13786
- function scopePropertiesMentioned(input, targetIds, slots, answered2, prepared) {
13906
+ function scopePropertiesMentioned(input, targetIds, slots, answered2, indexes) {
13787
13907
  const mentionable = mentionableTargets(input, targetIds);
13788
13908
  if (mentionable.length === 0) return unavailable("aliasless");
13789
13909
  if (slots.length === 0) return unavailable("no-population");
13790
13910
  if (answered2.length === 0) return unavailable("evidence-incomplete");
13791
- const mentioned = new Set(answered2.flatMap((slot) => [...prepared.observationsBySlot.get(slot.id)?.mentionedTargetIds ?? []]));
13792
- const numerator = mentionable.filter((target) => mentioned.has(target.id)).length;
13911
+ let numerator = 0;
13912
+ for (const target of mentionable) {
13913
+ const own = indexedSlotsForEdges(indexes.targetEdgesByTargetId.get(target.id) ?? [], indexes);
13914
+ const mentioned = indexes.mentionedSlotIdsByTargetId.get(target.id);
13915
+ if (mentioned && own.some((slot) => mentioned.has(slot.id))) {
13916
+ numerator++;
13917
+ continue;
13918
+ }
13919
+ const unknown = indexes.unknownMentionSlotIdsByTargetId.get(target.id);
13920
+ if (unknown && own.some((slot) => unknown.has(slot.id))) return unavailable("identity-ambiguous");
13921
+ if (own.length === 0 || own.some((slot) => !indexes.answeredSlotIds.has(slot.id))) return unavailable("evidence-incomplete");
13922
+ }
13793
13923
  return { numerator, denominator: mentionable.length, rate: numerator / mentionable.length };
13794
13924
  }
13795
13925
  function scopeNamedShareOfVoice(identities, answered2, prepared) {
@@ -13846,8 +13976,8 @@ function createMeasurementOverviewEvaluator(input, options = {}) {
13846
13976
  eligibleSlots: slots.length,
13847
13977
  answeredSlots: answered2.length,
13848
13978
  includesHistoricalData: prepared.diagnostics.bridgedObservationIds.length > 0 || prepared.diagnostics.historicalObservationIds.length > 0,
13849
- propertiesMentioned: scopePropertiesMentioned(input, targetIds, slots, answered2, prepared),
13850
- mentionCoverage: scopeMentionRate(input, targetIds, slots, answered2, prepared),
13979
+ propertiesMentioned: scopePropertiesMentioned(input, targetIds, slots, answered2, indexes),
13980
+ mentionCoverage: scopeMentionRate(input, targetIds, slots, answered2, prepared, edges),
13851
13981
  citationCoverage: indexedScopeCitationRate(slots, edges, indexes),
13852
13982
  brandPresence: scopeBrandPresence(input, slots, answered2, prepared),
13853
13983
  namedShareOfVoice: scopeNamedShareOfVoice(namedIdentities ?? [], answered2, prepared),
@@ -14127,6 +14257,7 @@ function buildMeasurementPlanV2ReportInput(revision, plan, manifest, snapshots)
14127
14257
  // The revision already decided this Property cannot be mentioned. Feeding
14128
14258
  // its aliases in anyway would turn "not applicable" into a 0% reading.
14129
14259
  aliases: target.mentionNotApplicable ? [] : target.aliases,
14260
+ ...target.identityAliases === void 0 ? {} : { identityAliases: target.identityAliases },
14130
14261
  urls: target.urlMatchers.map((matcher, index2) => matcherInput(target.stableKey, matcher, index2))
14131
14262
  })),
14132
14263
  groups: plan.groups.map((group) => ({
@@ -14406,6 +14537,8 @@ function activeMeasurementPlan(db, projectId) {
14406
14537
  }
14407
14538
  function metricReason(reason) {
14408
14539
  switch (reason) {
14540
+ case "identity-ambiguous":
14541
+ return "identity_ambiguous";
14409
14542
  case "no-population":
14410
14543
  return "no_population";
14411
14544
  case "incomplete":
@@ -17537,6 +17670,14 @@ function compileMeasurementDraft(authoring, context) {
17537
17670
  aliasClaims.set(identity, target.stableKey);
17538
17671
  });
17539
17672
  const aliases = canonicalStrings(target.aliases);
17673
+ const aliasKeys = aliases.map(measurementMentionAliasKey).filter(Boolean);
17674
+ target.identityAliases?.forEach((identityAlias, index2) => {
17675
+ const identityKey = measurementMentionAliasKey(identityAlias);
17676
+ const qualifiesAlias = aliasKeys.some((aliasKey) => identityKey !== aliasKey && `\0${identityKey}\0`.includes(`\0${aliasKey}\0`));
17677
+ if (!qualifiesAlias) {
17678
+ sink.fail("target-identity-alias-unqualified", "An identity phrase must include a Property alias and additional identifying context.", ["targets", targetIndex, "identityAliases", index2]);
17679
+ }
17680
+ });
17540
17681
  if (aliases.length === 0) {
17541
17682
  sink.warn("target-without-aliases", `Target "${target.label}" has no aliases, so it can be cited but never mentioned.`, ["targets", targetIndex, "aliases"]);
17542
17683
  }
@@ -17545,6 +17686,7 @@ function compileMeasurementDraft(authoring, context) {
17545
17686
  stableKey: target.stableKey,
17546
17687
  label: target.label,
17547
17688
  aliases,
17689
+ ...target.identityAliases === void 0 ? {} : { identityAliases: canonicalStrings(target.identityAliases) },
17548
17690
  urlMatchers: matchers,
17549
17691
  mentionNotApplicable: aliases.length === 0,
17550
17692
  discoveryIdentity: target.discoveryIdentity ?? null
@@ -18080,6 +18222,7 @@ function mergeTargets(authoring, body) {
18080
18222
  const merged = {
18081
18223
  ...survivor,
18082
18224
  aliases: unique([...survivor.aliases, ...authoring.targets.filter((target) => absorbedSet.has(target.stableKey)).flatMap((target) => target.aliases)]),
18225
+ ...[survivor, ...authoring.targets.filter((target) => absorbedSet.has(target.stableKey))].some((target) => target.identityAliases !== void 0) ? { identityAliases: unique([...survivor.identityAliases ?? [], ...authoring.targets.filter((target) => absorbedSet.has(target.stableKey)).flatMap((target) => target.identityAliases ?? [])]) } : {},
18083
18226
  urlMatchers: unique([...survivor.urlMatchers, ...authoring.targets.filter((target) => absorbedSet.has(target.stableKey)).flatMap((target) => target.urlMatchers)])
18084
18227
  };
18085
18228
  const assignments = [];
@@ -19243,6 +19386,7 @@ function seedAuthoring(project, active, context, opts) {
19243
19386
  label: target.label,
19244
19387
  status: "included",
19245
19388
  aliases: [...target.aliases],
19389
+ ...target.identityAliases === void 0 ? {} : { identityAliases: [...target.identityAliases] },
19246
19390
  urlMatchers: target.urlMatchers.map(formatDraftMatcher),
19247
19391
  source: target.discoveryIdentity ? "sitemap" : "manual",
19248
19392
  ...target.discoveryIdentity ? { discoveryIdentity: target.discoveryIdentity } : {}
@@ -21169,6 +21313,7 @@ function questionRows(plan, target, materialized, filters = {}) {
21169
21313
  }
21170
21314
  const ownEdgesByExecution = new Map(materialized.input.usageEdges.filter((edge) => edge.type === "target" && edge.targetId === targetKey).filter((edge) => assignmentsByExecution.has(edge.executionId)).map((edge) => [edge.executionId, edge]));
21171
21315
  const ownEdgeIds = new Set([...ownEdgesByExecution.values()].map((edge) => edge.id));
21316
+ const answerBySlot = new Map(materialized.evidence.answers.filter((answer) => ownEdgeIds.has(answer.usageEdgeId)).map((answer) => [answer.expectedSlotId, answer]));
21172
21317
  const citedSlotIds = new Set(materialized.evidence.evidence.filter((row) => ownEdgeIds.has(row.usageEdgeId) && row.classification === "assigned").map((row) => row.expectedSlotId));
21173
21318
  const incompleteObservationIds = new Set(materialized.evidence.diagnostics.evidenceIncompleteObservationIds);
21174
21319
  const rows = [];
@@ -21200,8 +21345,8 @@ function questionRows(plan, target, materialized, filters = {}) {
21200
21345
  servedModel: snapshot?.servedModel ?? null,
21201
21346
  location: slot.location,
21202
21347
  status: answered2 ? "answered" : "missing",
21203
- mentioned: answered2 && !target.mentionNotApplicable ? targetMentionedInAnswer(answer, targetKey, materialized.input.targets) : null,
21204
- cited: answered2 ? incompleteObservationIds.has(snapshot.id) ? null : citedSlotIds.has(slot.id) : null,
21348
+ mentioned: answered2 && !target.mentionNotApplicable ? answerBySlot.get(slot.id)?.mentioned ?? null : null,
21349
+ cited: answered2 ? citedSlotIds.has(slot.id) ? true : incompleteObservationIds.has(snapshot.id) ? null : false : null,
21205
21350
  recommendedInstead: answered2 ? recommendedInstead(snapshot.recommendedCompetitors, target) : [],
21206
21351
  answerExcerpt: answered2 ? excerptOf(answer) : null,
21207
21352
  edgeId: edge.id
@@ -21382,6 +21527,8 @@ function runIdentity(run) {
21382
21527
  }
21383
21528
  function metricReason2(reason) {
21384
21529
  switch (reason) {
21530
+ case "identity-ambiguous":
21531
+ return "identity_ambiguous";
21385
21532
  case "no-population":
21386
21533
  return "no_population";
21387
21534
  case "incomplete":
@@ -21431,6 +21578,7 @@ function targetAnswers(plan, target, materialized, filters) {
21431
21578
  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]));
21432
21579
  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]));
21433
21580
  const ownEdgeIds = new Set([...edgesByExecution.values()].map((edge) => edge.id));
21581
+ const answerBySlot = new Map(materialized.evidence.answers.filter((answer) => ownEdgeIds.has(answer.usageEdgeId)).map((answer) => [answer.expectedSlotId, answer]));
21434
21582
  const citedSlotIds = new Set(materialized.evidence.evidence.filter((row) => ownEdgeIds.has(row.usageEdgeId) && row.classification === "assigned").map((row) => row.expectedSlotId));
21435
21583
  const incompleteObservationIds = new Set(materialized.evidence.diagnostics.evidenceIncompleteObservationIds);
21436
21584
  const questionsById = new Map(plan.querySnapshots.map((question) => [question.queryId, question.queryText]));
@@ -21449,8 +21597,8 @@ function targetAnswers(plan, target, materialized, filters) {
21449
21597
  provider: slot.provider,
21450
21598
  question: questionsById.get(assignment.queryId) ?? slot.queryText,
21451
21599
  snapshot,
21452
- mentioned: target.mentionNotApplicable ? null : targetMentionedInAnswer(snapshot.answerText, target.stableKey, materialized.input.targets),
21453
- cited: incompleteObservationIds.has(snapshot.id) ? null : citedSlotIds.has(slot.id)
21600
+ mentioned: target.mentionNotApplicable ? null : answerBySlot.get(slot.id)?.mentioned ?? null,
21601
+ cited: citedSlotIds.has(slot.id) ? true : incompleteObservationIds.has(snapshot.id) ? null : false
21454
21602
  });
21455
21603
  }
21456
21604
  return { expected: slots.length, answers };
@@ -22065,10 +22213,10 @@ function selectedClasses(value) {
22065
22213
  function unavailable4(reason) {
22066
22214
  return { numerator: null, denominator: null, rate: null, reason };
22067
22215
  }
22068
- function rate(values, expected) {
22216
+ function rate(values, expected, unknownReason = "evidence-incomplete") {
22069
22217
  if (expected === 0) return unavailable4("no-population");
22070
22218
  if (values.length !== expected) return unavailable4("incomplete");
22071
- if (values.some((value) => value === null)) return unavailable4("evidence-incomplete");
22219
+ if (values.some((value) => value === null)) return unavailable4(unknownReason);
22072
22220
  const denominator = values.length;
22073
22221
  if (denominator === 0) return unavailable4("no-population");
22074
22222
  const numerator = values.filter(Boolean).length;
@@ -22170,17 +22318,30 @@ function targetValues(candidate, targets, targetKey) {
22170
22318
  const targetKeys = targetKey === void 0 ? [...new Set(candidate.edges.map((edge) => edge.targetKey))] : [targetKey];
22171
22319
  const eligible = targetKeys.filter((key) => targets.get(key)?.mentionEligible === true);
22172
22320
  const observation = candidate.observation;
22321
+ const citation = observation && targetKeys.some((key) => observation.citedTargetKeys.includes(key)) ? true : observation?.citationComplete && targetKeys.length > 0 ? false : null;
22173
22322
  const mentionComplete = observation !== null && (observation.mentionComplete ?? observation.answerText !== null);
22174
22323
  if (!observation || !mentionComplete || eligible.length === 0) {
22175
22324
  return {
22176
22325
  mention: null,
22177
- citation: observation?.citationComplete === true && targetKeys.length > 0 ? targetKeys.some((key) => observation.citedTargetKeys.includes(key)) : null
22326
+ citation
22178
22327
  };
22179
22328
  }
22180
- return {
22181
- mention: eligible.some((key) => observation.mentionedTargetKeys.includes(key)),
22182
- citation: observation.citationComplete ? targetKeys.some((key) => observation.citedTargetKeys.includes(key)) : null
22183
- };
22329
+ if (eligible.some((key) => observation.mentionedTargetKeys.includes(key))) return { mention: true, citation };
22330
+ if (eligible.some((key) => observation.unknownMentionTargetKeys?.includes(key))) {
22331
+ return { mention: null, citation, mentionUnavailableReason: "identity-ambiguous" };
22332
+ }
22333
+ return { mention: false, citation };
22334
+ }
22335
+ function citationForCoverage(candidate, targets, targetKey) {
22336
+ return candidate.observation?.citationComplete ? targetValues(candidate, targets, targetKey).citation : null;
22337
+ }
22338
+ function mentionRate2(candidates, targets, targetKey) {
22339
+ const values = candidates.map((candidate) => targetValues(candidate, targets, targetKey));
22340
+ return rate(
22341
+ values.map((value) => value.mention),
22342
+ values.length,
22343
+ values.some((value) => value.mentionUnavailableReason === "identity-ambiguous") ? "identity-ambiguous" : "evidence-incomplete"
22344
+ );
22184
22345
  }
22185
22346
  function answered(candidate) {
22186
22347
  return candidate.observation !== null;
@@ -22194,21 +22355,30 @@ function selectedTargetKeys(candidates) {
22194
22355
  function targetMetrics(candidates, targets, targetKey) {
22195
22356
  const own = candidates.filter((candidate) => candidate.edges.some((edge) => edge.targetKey === targetKey));
22196
22357
  return {
22197
- mentionCoverage: rate(own.map((candidate) => targetValues(candidate, targets, targetKey).mention), own.length),
22198
- citationCoverage: rate(own.map((candidate) => targetValues(candidate, targets, targetKey).citation), own.length)
22358
+ mentionCoverage: mentionRate2(own, targets, targetKey),
22359
+ citationCoverage: rate(own.map((candidate) => citationForCoverage(candidate, targets, targetKey)), own.length)
22360
+ };
22361
+ }
22362
+ function targetPresence(candidates, targets, targetKey) {
22363
+ const values = candidates.filter((candidate) => candidate.edges.some((edge) => edge.targetKey === targetKey)).map((candidate) => targetValues(candidate, targets, targetKey));
22364
+ const anyKnown = (signals) => signals.includes(true) ? true : signals.length === 0 || signals.includes(null) ? null : false;
22365
+ return {
22366
+ targetKey,
22367
+ mention: anyKnown(values.map((value) => value.mention)),
22368
+ citation: anyKnown(values.map((value) => value.citation)),
22369
+ identityAmbiguous: values.some((value) => value.mentionUnavailableReason === "identity-ambiguous")
22199
22370
  };
22200
22371
  }
22201
- function outcomeCounts(candidates, targets, targetKeys) {
22202
- const counts2 = { bothSignals: 0, mentionedOnly: 0, citedOnly: 0, neither: 0, notMeasured: 0, total: targetKeys.length };
22203
- for (const targetKey of targetKeys) {
22204
- const metrics = targetMetrics(candidates, targets, targetKey);
22205
- if (metrics.mentionCoverage.numerator === null || metrics.citationCoverage.numerator === null) {
22372
+ function outcomeCounts(rows) {
22373
+ const counts2 = { bothSignals: 0, mentionedOnly: 0, citedOnly: 0, neither: 0, notMeasured: 0, total: rows.length };
22374
+ for (const row of rows) {
22375
+ if (row.mention === null || row.citation === null) {
22206
22376
  counts2.notMeasured++;
22207
- } else if (metrics.mentionCoverage.numerator > 0 && metrics.citationCoverage.numerator > 0) {
22377
+ } else if (row.mention && row.citation) {
22208
22378
  counts2.bothSignals++;
22209
- } else if (metrics.mentionCoverage.numerator > 0) {
22379
+ } else if (row.mention) {
22210
22380
  counts2.mentionedOnly++;
22211
- } else if (metrics.citationCoverage.numerator > 0) {
22381
+ } else if (row.citation) {
22212
22382
  counts2.citedOnly++;
22213
22383
  } else {
22214
22384
  counts2.neither++;
@@ -22223,19 +22393,20 @@ function coverageSummary(candidates, definition, targets = targetMap(definition)
22223
22393
  rows,
22224
22394
  queryCount: new Set(rows.map((candidate) => candidate.slot.queryKey)).size,
22225
22395
  answerCount: rows.filter(answered).length,
22226
- mentionCoverage: rate(rows.map((candidate) => targetValues(candidate, targets).mention), rows.length),
22227
- citationCoverage: rate(rows.map((candidate) => targetValues(candidate, targets).citation), rows.length)
22396
+ mentionCoverage: mentionRate2(rows, targets),
22397
+ citationCoverage: rate(rows.map((candidate) => citationForCoverage(candidate, targets)), rows.length)
22228
22398
  };
22229
22399
  }
22230
22400
  function summary(candidates, definition, populationTargetKeys = selectedTargetKeys(candidates)) {
22231
22401
  const targets = targetMap(definition);
22232
22402
  const base = coverageSummary(candidates, definition, targets);
22233
22403
  const { rows } = base;
22234
- const targetRows = populationTargetKeys.map((targetKey) => ({ targetKey, ...targetMetrics(rows, targets, targetKey) }));
22404
+ const targetRows = populationTargetKeys.map((targetKey) => targetPresence(rows, targets, targetKey));
22235
22405
  const eligibleTargets = targetRows.filter((row) => targets.get(row.targetKey)?.mentionEligible === true);
22236
22406
  const propertyReach = rate(
22237
- eligibleTargets.map((row) => row.mentionCoverage.numerator === null ? null : row.mentionCoverage.numerator > 0),
22238
- eligibleTargets.length
22407
+ eligibleTargets.map((row) => row.mention),
22408
+ eligibleTargets.length,
22409
+ eligibleTargets.some((row) => row.mention === null && row.identityAmbiguous) ? "identity-ambiguous" : "evidence-incomplete"
22239
22410
  );
22240
22411
  return {
22241
22412
  queryCount: base.queryCount,
@@ -22243,7 +22414,7 @@ function summary(candidates, definition, populationTargetKeys = selectedTargetKe
22243
22414
  mentionCoverage: base.mentionCoverage,
22244
22415
  citationCoverage: base.citationCoverage,
22245
22416
  propertyReach,
22246
- outcomes: outcomeCounts(rows, targets, populationTargetKeys)
22417
+ outcomes: outcomeCounts(targetRows)
22247
22418
  };
22248
22419
  }
22249
22420
  function queryRows(candidates, definition, selection) {
@@ -22290,6 +22461,7 @@ function evidenceRows(candidates, definition, selection) {
22290
22461
  location: candidate.slot.location,
22291
22462
  targetKeys: selectedTargetKeys([candidate]),
22292
22463
  mentioned: signals.mention,
22464
+ ...signals.mentionUnavailableReason === void 0 ? {} : { mentionUnavailableReason: signals.mentionUnavailableReason },
22293
22465
  cited: signals.citation,
22294
22466
  // Answer bodies are intentionally limited to the stable query-key
22295
22467
  // drill-in. A query-id is enough to locate an evidence page, but it
@@ -22360,8 +22532,8 @@ function breakdown(candidates, definition, populationTargetKeys = selectedTarget
22360
22532
  const groups = definition.groups.map((group) => ({ group, targetKeys: group.targetKeys.filter((targetKey) => populationTargetSet.has(targetKey)) })).filter(({ targetKeys }) => targetKeys.length > 0).map(({ group, targetKeys }) => {
22361
22533
  const groupTargetKeys = new Set(targetKeys);
22362
22534
  const own = candidates.map((candidate) => narrowedToTargetKeys(candidate, groupTargetKeys)).filter((candidate) => candidate !== null);
22363
- const metrics = rate(own.map((candidate) => targetValues(candidate, targets).mention), own.length);
22364
- const citations = rate(own.map((candidate) => targetValues(candidate, targets).citation), own.length);
22535
+ const metrics = mentionRate2(own, targets);
22536
+ const citations = rate(own.map((candidate) => citationForCoverage(candidate, targets)), own.length);
22365
22537
  return {
22366
22538
  id: group.id,
22367
22539
  label: group.label,
@@ -22756,52 +22928,42 @@ function v2Definition(plan, revision, manifest, run) {
22756
22928
  edges
22757
22929
  };
22758
22930
  }
22759
- function v2Observations(plan, revision, run, snapshots) {
22931
+ function v2Observations(plan, revision, run, snapshots, includeEvidence = true) {
22760
22932
  const manifest = measurementRunExpectedSlots(run, plan);
22761
22933
  const materialized = buildMeasurementPlanV2ReportInput(revision, plan, manifest, adapterSnapshots(snapshots));
22762
- const evidence = buildMeasurementEvidence(materialized.input);
22934
+ const signalsByObservation = new Map(buildMeasurementObservationSignals(materialized.input).map((signal) => [signal.observationId, signal]));
22763
22935
  const rawById = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot]));
22764
- const answersByObservation = /* @__PURE__ */ new Map();
22765
- for (const answer of evidence.answers) {
22766
- const rows = answersByObservation.get(answer.observationId) ?? [];
22767
- rows.push(answer);
22768
- answersByObservation.set(answer.observationId, rows);
22769
- }
22770
- const edgeById = new Map(materialized.input.usageEdges.map((edge) => [edge.id, edge]));
22771
22936
  const competitors2 = exactCompetitorMatchers(plan);
22772
22937
  const output = [];
22773
22938
  for (const observation of materialized.input.observations) {
22774
22939
  const raw = rawById.get(observation.id);
22775
22940
  if (!raw || observation.executionId === null) continue;
22776
- const answers = answersByObservation.get(observation.id) ?? [];
22777
- const targetFor = (answer) => {
22778
- const edge = edgeById.get(answer.usageEdgeId);
22779
- return edge?.type === "target" ? edge.targetId : null;
22780
- };
22781
- const signals = competitorSignals(raw, competitors2);
22941
+ const attribution = signalsByObservation.get(observation.id);
22942
+ const signals = includeEvidence ? competitorSignals(raw, competitors2) : { mentioned: [], cited: [] };
22782
22943
  output.push({
22783
22944
  slotId: `slot:${observation.executionId}:${observation.provider}`,
22784
22945
  answerId: raw.id,
22785
22946
  model: raw.servedModel?.trim() || null,
22786
- answerText: raw.answerText,
22947
+ answerText: includeEvidence ? raw.answerText : null,
22787
22948
  // `answerMentioned` is the legacy project-level boolean. It cannot say
22788
22949
  // WHICH Property an answer named, so a portfolio may use it neither as
22789
22950
  // a positive nor as a measured negative when the answer body is absent.
22790
22951
  mentionComplete: raw.answerText !== null,
22791
- mentionedTargetKeys: answers.filter((answer) => answer.mentioned === true).map(targetFor).filter((value) => value !== null),
22792
- citedTargetKeys: answers.filter((answer) => answer.cited === true).map(targetFor).filter((value) => value !== null),
22793
- citationComplete: answers.length > 0 && answers.every((answer) => answer.evidenceComplete),
22952
+ mentionedTargetKeys: attribution?.mentionedTargetIds ?? [],
22953
+ unknownMentionTargetKeys: attribution?.unknownMentionTargetIds ?? [],
22954
+ citedTargetKeys: attribution?.citedTargetIds ?? [],
22955
+ citationComplete: attribution?.sourceComplete ?? false,
22794
22956
  competitorMentionDomains: signals.mentioned,
22795
22957
  competitorCitationDomains: signals.cited,
22796
- observedCompetitorNames: observedCompetitorNames2(raw.recommendedCompetitors),
22797
- sources: [...new Set(answers.flatMap((answer) => answer.sources.map((source) => source.sourceUrl)))],
22958
+ observedCompetitorNames: includeEvidence ? observedCompetitorNames2(raw.recommendedCompetitors) : [],
22959
+ sources: includeEvidence ? attribution?.sourceUrls ?? [] : [],
22798
22960
  createdAt: raw.createdAt
22799
22961
  });
22800
22962
  }
22801
22963
  return { manifest, observations: output };
22802
22964
  }
22803
- function advancedRun(run, version, plan, snapshots, comparableDefinitionIds) {
22804
- const populated = v2Observations(plan, version.revision, run, snapshots);
22965
+ function advancedRun(run, version, plan, snapshots, comparableDefinitionIds, includeEvidence = true) {
22966
+ const populated = v2Observations(plan, version.revision, run, snapshots, includeEvidence);
22805
22967
  return {
22806
22968
  id: run.id,
22807
22969
  createdAt: run.createdAt,
@@ -22848,7 +23010,7 @@ function simpleModelFingerprint(definition) {
22848
23010
  const engines = [...definition.engines].map((engine) => ({ provider: normalizedText3(engine.provider), requestedModel: engine.requestedModel })).sort((left, right) => left.provider.localeCompare(right.provider, "en"));
22849
23011
  return `simple-model:${createHash4("sha256").update(JSON.stringify(engines)).digest("hex")}`;
22850
23012
  }
22851
- function frozenSimpleRun(run, definition, snapshots) {
23013
+ function frozenSimpleRun(run, definition, snapshots, includeEvidence = true) {
22852
23014
  const matcher = compileBrandAliases(effectiveBrandNames({
22853
23015
  displayName: definition.identity.displayName,
22854
23016
  aliases: definition.identity.aliases,
@@ -22896,20 +23058,20 @@ function frozenSimpleRun(run, definition, snapshots) {
22896
23058
  if (!slotKeys.has(slotId)) throw new Error(`Frozen simple definition has no slot for stored snapshot ${snapshot.id}`);
22897
23059
  const preparedAnswerText = prepareBrandMatchText(snapshot.answerText);
22898
23060
  const mentioned = snapshot.answerText !== null ? matcherMatchesText(matcher, preparedAnswerText) : snapshot.answerMentioned === true;
22899
- const competitorSignalsForSnapshot = competitors2 === null ? { mentioned: [], cited: [] } : competitorSignals(snapshot, competitors2, preparedAnswerText);
23061
+ const competitorSignalsForSnapshot = !includeEvidence || competitors2 === null ? { mentioned: [], cited: [] } : competitorSignals(snapshot, competitors2, preparedAnswerText);
22900
23062
  observations.push({
22901
23063
  slotId,
22902
23064
  answerId: snapshot.id,
22903
23065
  model: snapshot.servedModel?.trim() || null,
22904
- answerText: snapshot.answerText,
23066
+ answerText: includeEvidence ? snapshot.answerText : null,
22905
23067
  mentionComplete: snapshot.answerText !== null || snapshot.answerMentioned !== null,
22906
23068
  mentionedTargetKeys: mentioned ? ["project"] : [],
22907
23069
  citedTargetKeys: snapshot.citationState === "cited" ? ["project"] : [],
22908
23070
  citationComplete: true,
22909
23071
  competitorMentionDomains: competitorSignalsForSnapshot.mentioned,
22910
23072
  competitorCitationDomains: competitorSignalsForSnapshot.cited,
22911
- observedCompetitorNames: observedCompetitorNames2(snapshot.recommendedCompetitors),
22912
- sources: snapshot.citedUrls ?? [],
23073
+ observedCompetitorNames: includeEvidence ? observedCompetitorNames2(snapshot.recommendedCompetitors) : [],
23074
+ sources: includeEvidence ? snapshot.citedUrls ?? [] : [],
22913
23075
  createdAt: snapshot.createdAt
22914
23076
  });
22915
23077
  }
@@ -22941,7 +23103,7 @@ function frozenSimpleRun(run, definition, snapshots) {
22941
23103
  observations
22942
23104
  };
22943
23105
  }
22944
- function legacySimpleRun(project, run, snapshots) {
23106
+ function legacySimpleRun(project, run, snapshots, includeEvidence = true) {
22945
23107
  const slots = snapshots.map((snapshot) => ({
22946
23108
  id: `slot:legacy:${snapshot.id}`,
22947
23109
  executionId: `legacy:${snapshot.id}`,
@@ -22979,7 +23141,7 @@ function legacySimpleRun(project, run, snapshots) {
22979
23141
  slotId: `slot:legacy:${snapshot.id}`,
22980
23142
  answerId: snapshot.id,
22981
23143
  model: snapshot.servedModel?.trim() || null,
22982
- answerText: snapshot.answerText,
23144
+ answerText: includeEvidence ? snapshot.answerText : null,
22983
23145
  // A legacy answer body cannot be matched against today's identity. Only
22984
23146
  // the stored boolean is a frozen mention fact; null stays incomplete.
22985
23147
  mentionComplete: snapshot.answerMentioned !== null,
@@ -22988,8 +23150,8 @@ function legacySimpleRun(project, run, snapshots) {
22988
23150
  citationComplete: true,
22989
23151
  competitorMentionDomains: [],
22990
23152
  competitorCitationDomains: [],
22991
- observedCompetitorNames: observedCompetitorNames2(snapshot.recommendedCompetitors),
22992
- sources: snapshot.citedUrls ?? [],
23153
+ observedCompetitorNames: includeEvidence ? observedCompetitorNames2(snapshot.recommendedCompetitors) : [],
23154
+ sources: includeEvidence ? snapshot.citedUrls ?? [] : [],
22993
23155
  createdAt: snapshot.createdAt
22994
23156
  }))
22995
23157
  };
@@ -23127,41 +23289,37 @@ function advancedReaderInput(db, projectId, active, query) {
23127
23289
  const presentationComparableIds = new Set(comparableVersionIds(allVersionRowsById, presentationVersion.id));
23128
23290
  const activeComparableIds = new Set(comparableVersionIds(allVersionRowsById, active.version.id));
23129
23291
  const sourceRuns = completedVisibilityRuns(db, projectId, false, query);
23130
- const snapshotsByRun = loadVisibilitySnapshots(db, sourceRuns.map((run) => run.id));
23131
23292
  const sourceCandidates = sourceRuns.flatMap((run) => {
23132
23293
  if (run.measurementPlanVersionId === null) return [];
23133
23294
  const source = versions.get(run.measurementPlanVersionId);
23134
- if (!source) return [];
23135
- const own = advancedRun(
23136
- run,
23137
- source.row,
23138
- source.plan,
23139
- snapshotsByRun.get(run.id) ?? [],
23140
- comparableVersionIds(allVersionRowsById, source.row.id)
23141
- );
23142
- return [{ run, source, own }];
23143
- }).filter((candidate) => query.revision === void 0 || presentationComparableIds.has(candidate.source.row.id));
23295
+ if (!source || query.revision !== void 0 && !presentationComparableIds.has(source.row.id)) return [];
23296
+ return [{ run, source }];
23297
+ });
23144
23298
  if (query.runId !== void 0 && !sourceCandidates.some((candidate) => candidate.run.id === query.runId)) {
23145
23299
  throw validationError(`Measurement run "${query.runId}" is not an eligible advanced result.`);
23146
23300
  }
23301
+ const compatibleSourceCandidates = sourceCandidates.filter((candidate) => activeComparableIds.has(candidate.source.row.id));
23302
+ const preferredSource = query.runId === void 0 ? compatibleSourceCandidates.at(0) ?? sourceCandidates.at(0) : void 0;
23303
+ const selectedSource = query.runId === void 0 ? preferredSource : sourceCandidates.find((candidate) => candidate.run.id === query.runId);
23147
23304
  const candidates = sourceCandidates.map((candidate) => {
23148
- if (candidate.source.row.id === presentationVersion.id) return candidate.own;
23149
- if (!presentationComparableIds.has(candidate.source.row.id)) return candidate.own;
23305
+ const usePresentation = presentationComparableIds.has(candidate.source.row.id);
23306
+ const version = usePresentation ? presentationVersion : candidate.source.row;
23307
+ const plan = usePresentation ? presentationPlan : candidate.source.plan;
23308
+ const snapshots = loadVisibilitySnapshots(db, [candidate.run.id]).get(candidate.run.id) ?? [];
23150
23309
  return advancedRun(
23151
23310
  candidate.run,
23152
- presentationVersion,
23153
- presentationPlan,
23154
- snapshotsByRun.get(candidate.run.id) ?? [],
23155
- [...presentationComparableIds]
23311
+ version,
23312
+ plan,
23313
+ snapshots,
23314
+ usePresentation ? [...presentationComparableIds] : comparableVersionIds(allVersionRowsById, candidate.source.row.id),
23315
+ candidate.run.id === selectedSource?.run.id
23156
23316
  );
23157
23317
  });
23158
- const compatibleSourceCandidates = sourceCandidates.filter((candidate) => activeComparableIds.has(candidate.source.row.id));
23159
- const preferredSource = query.runId === void 0 ? compatibleSourceCandidates.at(0) ?? sourceCandidates.at(0) : void 0;
23160
- const selectedSource = query.runId === void 0 ? preferredSource : sourceCandidates.find((candidate) => candidate.run.id === query.runId);
23318
+ const selectedDefinition = candidates.find((candidate) => candidate.id === selectedSource?.run.id)?.definition;
23161
23319
  return {
23162
23320
  mode: "advanced",
23163
23321
  activeRevision: active.version.revision,
23164
- pendingAssignmentCount: selectedSource !== void 0 && activeComparableIds.has(selectedSource.source.row.id) ? 0 : pendingAssignments(active.plan, selectedSource?.source.plan, selectedSource?.own.definition),
23322
+ pendingAssignmentCount: selectedSource !== void 0 && activeComparableIds.has(selectedSource.source.row.id) ? 0 : pendingAssignments(active.plan, selectedSource?.source.plan, selectedDefinition),
23165
23323
  selection: query,
23166
23324
  activeDefinition: activeV2Definition(presentationPlan, presentationVersion.revision),
23167
23325
  ...preferredSource === void 0 ? {} : { preferredRunId: preferredSource.run.id },
@@ -23174,11 +23332,12 @@ function simpleReaderInput(db, project, query) {
23174
23332
  eq24(simpleMeasurementDefinitions.projectId, project.id),
23175
23333
  inArray9(simpleMeasurementDefinitions.runId, sourceRuns.map((run) => run.id))
23176
23334
  )).all().map((row) => [row.runId, row.definition]));
23177
- const snapshotsByRun = loadVisibilitySnapshots(db, sourceRuns.map((run) => run.id));
23335
+ const selectedRunId = sourceRuns[0]?.id;
23178
23336
  const candidates = sourceRuns.map((run) => {
23179
- const snapshots = snapshotsByRun.get(run.id) ?? [];
23337
+ const snapshots = loadVisibilitySnapshots(db, [run.id]).get(run.id) ?? [];
23180
23338
  const definition = frozen.get(run.id);
23181
- return definition ? frozenSimpleRun(run, definition, snapshots) : legacySimpleRun(project, run, snapshots);
23339
+ const includeEvidence = run.id === selectedRunId;
23340
+ return definition ? frozenSimpleRun(run, definition, snapshots, includeEvidence) : legacySimpleRun(project, run, snapshots, includeEvidence);
23182
23341
  });
23183
23342
  if (query.runId !== void 0 && !candidates.some((run) => run.id === query.runId)) {
23184
23343
  throw validationError(`Measurement run "${query.runId}" is not an eligible simple result.`);
@@ -23192,37 +23351,28 @@ function simpleReaderInput(db, project, query) {
23192
23351
  runs: candidates
23193
23352
  };
23194
23353
  }
23354
+ function readVisibilityReport(db, project, rawQuery) {
23355
+ const query = parseQuery(rawQuery);
23356
+ const active = activeMeasurementPlan(db, project.id);
23357
+ const mode = query.mode === "auto" ? active === null ? "simple" : "advanced" : query.mode;
23358
+ try {
23359
+ if (mode === "advanced") {
23360
+ if (active === null) throw validationError("This project has no advanced measurement plan.");
23361
+ if (active.plan.schemaVersion !== MEASUREMENT_PLAN_V2_SCHEMA_VERSION) return unsupportedAdvancedResponse(query, active);
23362
+ return buildVisibilityReport(advancedReaderInput(db, project.id, active, query));
23363
+ }
23364
+ return buildVisibilityReport(simpleReaderInput(db, project, query));
23365
+ } catch (error) {
23366
+ if (error instanceof VisibilityReportCursorError || error instanceof VisibilityReportScopeError) {
23367
+ throw validationError(error.message);
23368
+ }
23369
+ throw error;
23370
+ }
23371
+ }
23195
23372
  async function visibilityReportRoutes(app) {
23196
23373
  app.get(
23197
23374
  "/projects/:name/visibility-report",
23198
- async (request) => {
23199
- const project = resolveProject(app.db, request.params.name);
23200
- const query = parseQuery(request.query);
23201
- const active = activeMeasurementPlan(app.db, project.id);
23202
- const mode = query.mode === "auto" ? active === null ? "simple" : "advanced" : query.mode;
23203
- if (mode === "advanced") {
23204
- if (active === null) throw validationError("This project has no advanced measurement plan.");
23205
- if (active.plan.schemaVersion !== MEASUREMENT_PLAN_V2_SCHEMA_VERSION) {
23206
- return unsupportedAdvancedResponse(query, active);
23207
- }
23208
- try {
23209
- return buildVisibilityReport(advancedReaderInput(app.db, project.id, active, query));
23210
- } catch (error) {
23211
- if (error instanceof VisibilityReportCursorError || error instanceof VisibilityReportScopeError) {
23212
- throw validationError(error.message);
23213
- }
23214
- throw error;
23215
- }
23216
- }
23217
- try {
23218
- return buildVisibilityReport(simpleReaderInput(app.db, project, query));
23219
- } catch (error) {
23220
- if (error instanceof VisibilityReportCursorError || error instanceof VisibilityReportScopeError) {
23221
- throw validationError(error.message);
23222
- }
23223
- throw error;
23224
- }
23225
- }
23375
+ async (request) => readVisibilityReport(app.db, resolveProject(app.db, request.params.name), request.query)
23226
23376
  );
23227
23377
  }
23228
23378
 
@@ -23471,6 +23621,7 @@ function frozenAssignmentSignature(plan, assignment) {
23471
23621
  target: {
23472
23622
  stableKey: target.stableKey,
23473
23623
  aliases: target.aliases,
23624
+ ...target.identityAliases === void 0 ? {} : { identityAliases: target.identityAliases },
23474
23625
  urlMatchers: target.urlMatchers,
23475
23626
  mentionNotApplicable: target.mentionNotApplicable,
23476
23627
  discoveryIdentity: target.discoveryIdentity
@@ -23972,6 +24123,7 @@ function compileExecution(state, target, queryId, queryText, queryClass, classif
23972
24123
  label: target.label,
23973
24124
  status: "included",
23974
24125
  aliases: [...target.aliases],
24126
+ ...target.identityAliases === void 0 ? {} : { identityAliases: [...target.identityAliases] },
23975
24127
  urlMatchers: target.urlMatchers.map(draftMatcher),
23976
24128
  source: target.discoveryIdentity ? "sitemap" : "manual",
23977
24129
  ...target.discoveryIdentity ? { discoveryIdentity: target.discoveryIdentity } : {}
@@ -28735,10 +28887,10 @@ function renderExecutiveSummary(report) {
28735
28887
  const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
28736
28888
  const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
28737
28889
  const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
28738
- const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
28739
- const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
28740
- const headlineTitle = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
28741
- const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a check to populate the first citation and mention baseline.";
28890
+ const citedFragment = (s.totalQueryCount ?? 0) > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
28891
+ const mentionedFragment = (s.totalQueryCount ?? 0) > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
28892
+ const headlineTitle = (s.totalQueryCount ?? 0) > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
28893
+ const headlineSubtitle = (s.totalQueryCount ?? 0) > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a check to populate the first citation and mention baseline.";
28742
28894
  const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
28743
28895
  const actionCount = dedupeReportActions(report, priorityActions).length;
28744
28896
  const heroHtml = `<div class="executive-hero">
@@ -29835,9 +29987,9 @@ function renderAudienceActionPlan(report, audience) {
29835
29987
  function renderClientSummary(report) {
29836
29988
  const s = report.executiveSummary;
29837
29989
  const sc = report.citationScorecard;
29838
- const totalQ = s.totalQueryCount;
29990
+ const totalQ = s.totalQueryCount ?? 0;
29839
29991
  const heroNumber = totalQ > 0 ? `${s.mentionRate}%` : "\u2014";
29840
- const heroSentence = totalQ > 0 ? `When customers asked AI ${totalQ} ${pluralize(totalQ, "query", "queries")} about your industry, AI mentioned you in ${s.mentionedQueryCount} of ${totalQ === 1 ? "them" : "those answers"}.` : "No AI check has been run yet. Run a check to see how AI tools answer customer queries about your business.";
29992
+ const heroSentence = totalQ > 0 ? `When customers asked AI ${totalQ} ${pluralize(totalQ, "query", "queries")} about your industry, AI mentioned you in ${s.mentionedQueryCount} of ${totalQ === 1 ? "them" : "those queries"}.` : "No AI check has been run yet. Run a check to see how AI tools answer customer queries about your business.";
29841
29993
  const trend = clientTrendCopy(report.whatsChanged.mentionRate);
29842
29994
  const heroTrend = trend ? `<p class="client-hero-trend tone-${trend.tone}"><span style="margin-right:6px;">${trend.arrow}</span>${escapeHtml2(trend.text)}</p>` : "";
29843
29995
  const hero = `<div class="client-hero">
@@ -29851,12 +30003,12 @@ function renderClientSummary(report) {
29851
30003
  <div class="client-metric-tile">
29852
30004
  <div class="label">AI mentions your name</div>
29853
30005
  <div class="value">${s.mentionRate}%</div>
29854
- <div class="subtitle">${totalQ > 0 ? `Says your name in ${s.mentionedQueryCount} of ${totalQ} ${pluralize(totalQ, "answer")}` : "No data yet"}</div>
30006
+ <div class="subtitle">${totalQ > 0 ? `Says your name in ${s.mentionedQueryCount} of ${totalQ} ${pluralize(totalQ, "query", "queries")}` : "No data yet"}</div>
29855
30007
  </div>
29856
30008
  <div class="client-metric-tile">
29857
30009
  <div class="label">AI links to your website</div>
29858
30010
  <div class="value">${s.citationRate}%</div>
29859
- <div class="subtitle">${totalQ > 0 ? `Cites your site as a source in ${s.citedQueryCount} of ${totalQ} ${pluralize(totalQ, "answer")}` : "No data yet"}</div>
30011
+ <div class="subtitle">${totalQ > 0 ? `Cites your site as a source in ${s.citedQueryCount} of ${totalQ} ${pluralize(totalQ, "query", "queries")}` : "No data yet"}</div>
29860
30012
  </div>
29861
30013
  <div class="client-metric-tile">
29862
30014
  <div class="label">AI tools tested</div>
@@ -29997,9 +30149,9 @@ function renderReportHtml(report, opts = {}) {
29997
30149
  const audience = opts.audience ?? "agency";
29998
30150
  const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
29999
30151
  const sections = audience === "client" ? [
30000
- renderClientSummary(report),
30001
- renderReportShareOfVoice(report),
30002
- renderWhatsChanged(report, "client"),
30152
+ report.visibility ? renderReportVisibility(report.visibility) : renderClientSummary(report),
30153
+ report.visibility?.selection.mode === "advanced" ? "" : renderReportShareOfVoice(report),
30154
+ report.visibility ? "" : renderWhatsChanged(report, "client"),
30003
30155
  // Server-side AI visibility runs between WhatsChanged and the action
30004
30156
  // plan in BOTH the SPA and HTML so clients see the same ordered set
30005
30157
  // of sections in either surface (per the report-parity rule).
@@ -30007,13 +30159,13 @@ function renderReportHtml(report, opts = {}) {
30007
30159
  renderAudienceActionPlan(report, "client"),
30008
30160
  renderClientEvidenceSummary(report)
30009
30161
  ].join("\n") : [
30010
- renderExecutiveSummary(report),
30011
- renderReportShareOfVoice(report),
30012
- renderWhatsChanged(report, "agency"),
30162
+ report.visibility ? renderReportVisibility(report.visibility) : renderExecutiveSummary(report),
30163
+ report.visibility?.selection.mode === "advanced" ? "" : renderReportShareOfVoice(report),
30164
+ report.visibility ? "" : renderWhatsChanged(report, "agency"),
30013
30165
  renderAudienceActionPlan(report, "agency"),
30014
- renderAgencyDiagnostics(report),
30015
- renderCitationScorecard(report),
30016
- renderCompetitorLandscape(report),
30166
+ report.visibility?.selection.mode === "advanced" ? "" : renderAgencyDiagnostics(report),
30167
+ report.visibility?.selection.mode === "advanced" ? "" : renderCitationScorecard(report),
30168
+ report.visibility?.selection.mode === "advanced" ? "" : renderCompetitorLandscape(report),
30017
30169
  renderAiSourceOrigin(report),
30018
30170
  renderGsc(report),
30019
30171
  renderGa(report),
@@ -30021,7 +30173,7 @@ function renderReportHtml(report, opts = {}) {
30021
30173
  renderAiReferrals(report),
30022
30174
  renderServerActivity(report, "agency"),
30023
30175
  renderIndexingHealth(report),
30024
- renderCitationsTrend(report),
30176
+ report.visibility?.selection.mode === "advanced" ? "" : renderCitationsTrend(report),
30025
30177
  renderInsights(report),
30026
30178
  renderOpportunities(report),
30027
30179
  renderContentGaps(report),
@@ -30043,7 +30195,7 @@ function renderReportHtml(report, opts = {}) {
30043
30195
  <header class="header">
30044
30196
  <div class="eyebrow">AI Visibility Report</div>
30045
30197
  <h1>${escapeHtml2(report.meta.project.displayName)}</h1>
30046
- <div class="subtitle">${escapeHtml2(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml2(report.meta.project.country)} / ${escapeHtml2(report.meta.project.language.toUpperCase())}${renderHeaderLocationFragment(report.meta.location)} \xB7 Last ${report.meta.periodDays} days \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
30198
+ <div class="subtitle">${escapeHtml2(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml2(report.meta.project.country)} / ${escapeHtml2(report.meta.project.language.toUpperCase())}${report.visibility?.selection.mode === "advanced" ? ` \xB7 ${escapeHtml2(reportVisibilityLocationLabel(report.visibility))}` : renderHeaderLocationFragment(report.meta.location)} \xB7 Last ${report.meta.periodDays} days \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
30047
30199
  </header>
30048
30200
  ${sections}
30049
30201
  <footer class="footer">Generated by <a href="https://canonry.ai">canonry</a> \xB7 ${escapeHtml2(formatIsoDate(report.meta.generatedAt))}</footer>
@@ -30052,6 +30204,15 @@ function renderReportHtml(report, opts = {}) {
30052
30204
  </body>
30053
30205
  </html>`;
30054
30206
  }
30207
+ function renderReportVisibility(visibility) {
30208
+ const populations = visibility.populations.filter((population) => population.queryClass !== "unknown" || population.summary.answerCount > 0);
30209
+ const historyPopulations = visibility.populations.filter((population) => population.queryClass !== "unknown" || population.trend.some((point) => point.answerCount > 0));
30210
+ const rateCell = (rate2) => `<td><strong>${escapeHtml2(reportVisibilityRate(rate2))}</strong><p class="muted">${escapeHtml2(reportVisibilityEvidence(rate2))}</p></td>`;
30211
+ const headers = (labels) => `<thead><tr>${labels.map((label) => `<th>${escapeHtml2(label)}</th>`).join("")}</tr></thead>`;
30212
+ const summary2 = `<table>${headers([REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.queries, REPORT_VISIBILITY_COPY.answers, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited])}<tbody>${populations.map((population) => `<tr><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td><td>${population.summary.queryCount}</td><td>${population.summary.answerCount}</td>${rateCell(population.summary.mentionCoverage)}${rateCell(population.summary.citationCoverage)}</tr>`).join("")}</tbody></table>`;
30213
+ const trend = `<details><summary>${escapeHtml2(reportVisibilityHistoryLabel(visibility))}</summary><div class="table-scroll"><table>${headers([REPORT_VISIBILITY_COPY.date, REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited, REPORT_VISIBILITY_COPY.comparison])}<tbody>${historyPopulations.flatMap((population) => population.trend.map((point) => `<tr><td><time datetime="${escapeHtml2(point.createdAt)}">${escapeHtml2(point.createdAt.slice(0, 10))}</time></td><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td>${rateCell(point.mentionCoverage)}${rateCell(point.citationCoverage)}<td>${escapeHtml2(reportVisibilityComparison(point.continuity.state, point.continuity.comparedRunId !== null && !population.trend.some((previous) => previous.runId === point.continuity.comparedRunId)))}</td></tr>`)).join("")}</tbody></table></div></details>`;
30214
+ return `<section id="client-summary" class="report-section" aria-label="${escapeHtml2(REPORT_VISIBILITY_COPY.title)}"><h2>${escapeHtml2(REPORT_VISIBILITY_COPY.title)}</h2><p>${escapeHtml2(visibility.selection.mode === "advanced" ? REPORT_VISIBILITY_COPY.description : REPORT_VISIBILITY_COPY.simpleDescription)}</p><p>${escapeHtml2(reportVisibilityMeasurementLabel(visibility))}</p><div class="table-scroll">${summary2}</div>${trend}</section>`;
30215
+ }
30055
30216
 
30056
30217
  // ../api-routes/src/ga-ai-referral-aggregation.ts
30057
30218
  function normalizeAiTrafficClass(value) {
@@ -30913,7 +31074,6 @@ function buildIndexingHealth(db, projectId) {
30913
31074
  }
30914
31075
  function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
30915
31076
  const visibilityRuns = db.select().from(runs).where(and29(eq33(runs.projectId, projectId), eq33(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter);
30916
- const totalQueries = queryLookup.byId.size;
30917
31077
  const points = [];
30918
31078
  for (const run of visibilityRuns) {
30919
31079
  if (run.status !== RunStatuses.completed) continue;
@@ -30935,10 +31095,11 @@ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
30935
31095
  providerCounts.set(snap.provider, counts2);
30936
31096
  }
30937
31097
  if (considered === 0) continue;
31098
+ const totalQueries = new Set(snaps.map((snapshot) => snapshot.queryId)).size;
30938
31099
  const citedQueryCount = citedQueryIds.size;
30939
31100
  const mentionedQueryCount = mentionedQueryIds.size;
30940
31101
  const citationRate = totalQueries > 0 ? Math.round(citedQueryCount / totalQueries * 100) : 0;
30941
- const mentionRate2 = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
31102
+ const mentionRate3 = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
30942
31103
  const providerRates = [...providerCounts.entries()].map(([provider, counts2]) => ({
30943
31104
  provider,
30944
31105
  citationRate: counts2.total > 0 ? Math.round(counts2.cited / counts2.total * 100) : 0,
@@ -30950,7 +31111,7 @@ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
30950
31111
  citationRate,
30951
31112
  citedQueryCount,
30952
31113
  totalQueryCount: totalQueries,
30953
- mentionRate: mentionRate2,
31114
+ mentionRate: mentionRate3,
30954
31115
  mentionedQueryCount,
30955
31116
  providerRates
30956
31117
  });
@@ -31310,7 +31471,7 @@ function buildReportActionPlan(input) {
31310
31471
  });
31311
31472
  }
31312
31473
  if (actions.length === 0) {
31313
- const neverMeasured = input.citationScorecard.providers.length === 0;
31474
+ const neverMeasured = !(input.hasVisibilityMeasurement ?? input.citationScorecard.providers.length > 0);
31314
31475
  actions.push({
31315
31476
  audience: "both",
31316
31477
  priority: 90,
@@ -31343,12 +31504,12 @@ function mentionTrendSentence(delta) {
31343
31504
  function buildClientSummary(reportLike) {
31344
31505
  const s = reportLike.executiveSummary;
31345
31506
  const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
31346
- const headline = s.totalQueryCount > 0 ? `${s.mentionedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} mention the brand in AI answers` : "No tracked queries have completed a check yet";
31347
- const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is mentioned on ${s.mentionRate}% of tracked queries and cited on ${s.citationRate}% of tracked queries. ${mentionTrendSentence(reportLike.whatsChanged.mentionRate)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
31507
+ const headline = (s.totalQueryCount ?? 0) > 0 ? `${s.mentionedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} mention the brand in AI answers` : "No tracked queries have completed a check yet";
31508
+ const overview = (s.totalQueryCount ?? 0) > 0 ? `${reportLike.canonicalDomain} is mentioned on ${s.mentionRate}% of tracked queries and cited on ${s.citationRate}% of tracked queries. ${mentionTrendSentence(reportLike.whatsChanged.mentionRate)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
31348
31509
  const confidenceNotes = [];
31349
31510
  if (s.totalQueryCount === 0) {
31350
31511
  confidenceNotes.push("Confidence is low until the first tracked query check completes.");
31351
- } else if (s.totalQueryCount < 5) {
31512
+ } else if ((s.totalQueryCount ?? 0) < 5) {
31352
31513
  confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
31353
31514
  }
31354
31515
  if (isTrendBaseline(reportLike.citationsTrend)) {
@@ -31473,7 +31634,7 @@ function buildWhatsChanged(input) {
31473
31634
  direction: rateDirection(citationRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
31474
31635
  } : null;
31475
31636
  const mentionRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionRate);
31476
- const mentionRate2 = enoughHistory && mentionRateSmoothed ? {
31637
+ const mentionRate3 = enoughHistory && mentionRateSmoothed ? {
31477
31638
  ...mentionRateSmoothed,
31478
31639
  direction: rateDirection(mentionRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
31479
31640
  } : null;
@@ -31520,7 +31681,7 @@ function buildWhatsChanged(input) {
31520
31681
  enoughHistory,
31521
31682
  headline,
31522
31683
  citationRate,
31523
- mentionRate: mentionRate2,
31684
+ mentionRate: mentionRate3,
31524
31685
  citedQueryCount,
31525
31686
  mentionedQueryCount,
31526
31687
  gscClicksDelta,
@@ -31534,14 +31695,30 @@ function buildWhatsChanged(input) {
31534
31695
  function buildProjectReport(db, projectName, periodDays) {
31535
31696
  const project = resolveProject(db, projectName);
31536
31697
  const queryLookup = loadQueryLookup(db, project.id);
31698
+ const canonicalVisibility = readVisibilityReport(db, project, { queryClass: "all", scope: "project" });
31699
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
31700
+ const historyWindow = { from: new Date(Date.parse(generatedAt) - periodDays * 864e5).toISOString(), to: generatedAt };
31701
+ const visibility = {
31702
+ selection: canonicalVisibility.selection,
31703
+ historyWindow,
31704
+ populations: canonicalVisibility.populations.map(({ queryClass, summary: summary2, trend: trend2 }) => ({
31705
+ queryClass,
31706
+ summary: summary2,
31707
+ // Keep each point's actual comparison. Renderers disclose when the prior
31708
+ // measurement is outside this window rather than inventing a baseline.
31709
+ trend: trend2.filter((point) => point.createdAt >= historyWindow.from && point.createdAt <= historyWindow.to)
31710
+ }))
31711
+ };
31712
+ const canonicalVisibilitySupported = visibility.selection.availability.state === "available";
31713
+ const advancedVisibility = canonicalVisibilitySupported && visibility.selection.mode === "advanced";
31537
31714
  const comparisonWindowDays = reportComparisonWindowDays(periodDays);
31538
31715
  const allRuns = db.select().from(runs).where(and29(eq33(runs.projectId, project.id), notProbeRun())).orderBy(desc14(runs.createdAt), desc14(runs.id)).all();
31539
31716
  const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
31540
31717
  const completedVisRunGroups = groupRunsByCreatedAt(
31541
31718
  visibilityRuns.filter((r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial)
31542
31719
  );
31543
- const latestVisRunGroup = completedVisRunGroups[0] ?? [];
31544
- const representativeLatestRun = pickGroupRepresentative(latestVisRunGroup) ?? visibilityRuns[0] ?? null;
31720
+ const latestVisRunGroup = advancedVisibility ? visibilityRuns.filter((run) => run.id === visibility.selection.run.id) : completedVisRunGroups[0] ?? [];
31721
+ const representativeLatestRun = pickGroupRepresentative(latestVisRunGroup) ?? (advancedVisibility ? null : visibilityRuns[0] ?? null);
31545
31722
  const latestSnapshots = loadSnapshotsForRunIds(db, latestVisRunGroup.map((r) => r.id));
31546
31723
  const latestRunLocation = representativeLatestRun?.location ?? null;
31547
31724
  const competitorRows2 = db.select().from(competitors).where(eq33(competitors.projectId, project.id)).all();
@@ -31594,7 +31771,8 @@ function buildProjectReport(db, projectName, periodDays) {
31594
31771
  }));
31595
31772
  }
31596
31773
  Object.assign(mentionLandscape, mentionLandscape.nonBrand);
31597
- const aiSourceOrigin = buildAiSourceOrigin(latestSnapshots, projectDomains, competitorDomains);
31774
+ const advancedSourceSnapshots = advancedVisibility && visibility.selection.run.id !== null ? db.select({ citedDomains: querySnapshots.citedDomains, provider: querySnapshots.provider, location: querySnapshots.location }).from(querySnapshots).where(eq33(querySnapshots.runId, visibility.selection.run.id)).all() : [];
31775
+ const aiSourceOrigin = buildAiSourceOrigin(advancedVisibility ? advancedSourceSnapshots : latestSnapshots, projectDomains, competitorDomains);
31598
31776
  const trackedQueries = [...queryLookup.byId.values()];
31599
31777
  const gscSection = buildGscSection(
31600
31778
  db,
@@ -31629,7 +31807,7 @@ function buildProjectReport(db, projectName, periodDays) {
31629
31807
  insights: insightList,
31630
31808
  comparisonWindowDays
31631
31809
  });
31632
- const totalQueryCount = queryLookup.byId.size;
31810
+ const totalQueryCount = new Set(latestSnapshots.map((snapshot) => snapshot.queryId)).size;
31633
31811
  const citedQueryIds = /* @__PURE__ */ new Set();
31634
31812
  const mentionedQueryIds = /* @__PURE__ */ new Set();
31635
31813
  for (const snap of latestSnapshots) {
@@ -31640,7 +31818,7 @@ function buildProjectReport(db, projectName, periodDays) {
31640
31818
  const citedQueryCount = citedQueryIds.size;
31641
31819
  const mentionedQueryCount = mentionedQueryIds.size;
31642
31820
  const citationRate = totalQueryCount > 0 ? Math.round(citedQueryCount / totalQueryCount * 100) : 0;
31643
- const mentionRate2 = totalQueryCount > 0 ? Math.round(mentionedQueryCount / totalQueryCount * 100) : 0;
31821
+ const mentionRate3 = totalQueryCount > 0 ? Math.round(mentionedQueryCount / totalQueryCount * 100) : 0;
31644
31822
  const trendBaseline = isTrendBaseline(citationsTrend);
31645
31823
  const latestPoint = citationsTrend.at(-1);
31646
31824
  const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
@@ -31665,21 +31843,22 @@ function buildProjectReport(db, projectName, periodDays) {
31665
31843
  insightList,
31666
31844
  competitorLandscape.competitors
31667
31845
  );
31668
- const periodStart = citationsTrend[0]?.date ?? null;
31669
- const periodEnd = citationsTrend.at(-1)?.date ?? null;
31846
+ const visibilityDates = visibility.populations.flatMap((population) => population.trend.map((point) => point.createdAt)).sort();
31847
+ const periodStart = advancedVisibility ? visibilityDates[0] ?? visibility.selection.measurement.completedAt : citationsTrend[0]?.date ?? null;
31848
+ const periodEnd = advancedVisibility ? visibility.selection.measurement.completedAt : citationsTrend.at(-1)?.date ?? null;
31670
31849
  const configuredLocations = project.locations;
31671
31850
  const reportLocation = buildLocationMeta(representativeLatestRun?.location ?? null, configuredLocations);
31672
- const providerLocationHandling = reportLocation ? buildProviderLocationHandling(citationScorecard.providers) : [];
31851
+ const providerLocationHandling = advancedVisibility ? buildProviderLocationHandling([...new Set(advancedSourceSnapshots.filter((snapshot) => snapshot.location !== null).map((snapshot) => snapshot.provider))]) : reportLocation ? buildProviderLocationHandling(citationScorecard.providers) : [];
31673
31852
  const executiveSummary = {
31674
31853
  citationRate,
31675
31854
  citedQueryCount,
31676
31855
  totalQueryCount,
31677
- mentionRate: mentionRate2,
31856
+ mentionRate: mentionRate3,
31678
31857
  mentionedQueryCount,
31679
31858
  trend,
31680
31859
  queryCount: queryLookup.byId.size,
31681
31860
  competitorCount: competitorDomains.length,
31682
- providerCount: citationScorecard.providers.length,
31861
+ providerCount: advancedVisibility ? new Set(advancedSourceSnapshots.map((snapshot) => snapshot.provider)).size : citationScorecard.providers.length,
31683
31862
  gsc: gscSection ? {
31684
31863
  clicks: gscSection.totalClicks,
31685
31864
  impressions: gscSection.totalImpressions,
@@ -31700,6 +31879,7 @@ function buildProjectReport(db, projectName, periodDays) {
31700
31879
  canonicalDomain: project.canonicalDomain,
31701
31880
  competitorDomains: actionPlanCompetitorDomains,
31702
31881
  citationScorecard,
31882
+ hasVisibilityMeasurement: advancedVisibility ? visibility.selection.run.id !== null : void 0,
31703
31883
  aiSourceOrigin,
31704
31884
  gsc: gscSection,
31705
31885
  indexingHealth: indexingHealthSection,
@@ -31721,6 +31901,7 @@ function buildProjectReport(db, projectName, periodDays) {
31721
31901
  canonicalDomain: project.canonicalDomain,
31722
31902
  competitorDomains,
31723
31903
  citationScorecard,
31904
+ hasVisibilityMeasurement: advancedVisibility ? visibility.selection.run.id !== null : void 0,
31724
31905
  aiSourceOrigin,
31725
31906
  gsc: gscSection,
31726
31907
  indexingHealth: indexingHealthSection,
@@ -31732,7 +31913,7 @@ function buildProjectReport(db, projectName, periodDays) {
31732
31913
  });
31733
31914
  return {
31734
31915
  meta: {
31735
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
31916
+ generatedAt,
31736
31917
  project: {
31737
31918
  id: project.id,
31738
31919
  name: project.name,
@@ -31747,7 +31928,18 @@ function buildProjectReport(db, projectName, periodDays) {
31747
31928
  periodEnd,
31748
31929
  periodDays
31749
31930
  },
31750
- executiveSummary,
31931
+ ...canonicalVisibilitySupported ? { visibility } : {},
31932
+ executiveSummary: advancedVisibility ? {
31933
+ ...executiveSummary,
31934
+ visibilityBasis: "frozen-populations",
31935
+ citationRate: null,
31936
+ citedQueryCount: null,
31937
+ totalQueryCount: null,
31938
+ mentionRate: null,
31939
+ mentionedQueryCount: null,
31940
+ trend: "unknown",
31941
+ findings: []
31942
+ } : { ...executiveSummary, visibilityBasis: "legacy-project-queries" },
31751
31943
  citationScorecard,
31752
31944
  competitorLandscape,
31753
31945
  mentionLandscape,
@@ -31758,12 +31950,28 @@ function buildProjectReport(db, projectName, periodDays) {
31758
31950
  aiReferrals: aiReferralsSection,
31759
31951
  serverActivity: serverActivitySection,
31760
31952
  indexingHealth: indexingHealthSection,
31761
- citationsTrend,
31762
- whatsChanged,
31953
+ citationsTrend: advancedVisibility ? [] : citationsTrend,
31954
+ whatsChanged: advancedVisibility ? {
31955
+ ...whatsChanged,
31956
+ enoughHistory: false,
31957
+ headline: "See measurement history for each query type.",
31958
+ citationRate: null,
31959
+ mentionRate: null,
31960
+ citedQueryCount: null,
31961
+ mentionedQueryCount: null,
31962
+ providerMovements: [],
31963
+ wins: [],
31964
+ regressions: []
31965
+ } : whatsChanged,
31763
31966
  insights: insightList,
31764
31967
  recommendedNextSteps,
31765
31968
  actionPlan,
31766
- clientSummary,
31969
+ clientSummary: advancedVisibility ? {
31970
+ ...clientSummary,
31971
+ headline: "AI visibility by query type",
31972
+ overview: "Mention and citation coverage use the properties assigned to each measured query.",
31973
+ confidenceNotes: []
31974
+ } : clientSummary,
31767
31975
  agencyDiagnostics,
31768
31976
  contentOpportunities,
31769
31977
  contentGaps,
@@ -34078,7 +34286,7 @@ function summarizeFromSnapshots(snapshots) {
34078
34286
  if (wasMentioned) mentionedQueries += 1;
34079
34287
  }
34080
34288
  const notMentionedQueries = totalQueries - mentionedQueries;
34081
- const mentionRate2 = totalQueries === 0 ? 0 : Number((mentionedQueries / totalQueries).toFixed(4));
34289
+ const mentionRate3 = totalQueries === 0 ? 0 : Number((mentionedQueries / totalQueries).toFixed(4));
34082
34290
  const providers = [...perProvider.entries()].map(([provider, { cited, total }]) => ({
34083
34291
  provider,
34084
34292
  cited,
@@ -34093,7 +34301,7 @@ function summarizeFromSnapshots(snapshots) {
34093
34301
  citedRate,
34094
34302
  mentionedQueries,
34095
34303
  notMentionedQueries,
34096
- mentionRate: mentionRate2
34304
+ mentionRate: mentionRate3
34097
34305
  },
34098
34306
  providers
34099
34307
  };
@@ -34529,6 +34737,8 @@ var SCHEMA_TABLE = {
34529
34737
  DiscoveryPromoteResult: discoveryPromoteResultSchema,
34530
34738
  DiscoverySessionDetailDto: discoverySessionDetailDtoSchema,
34531
34739
  DiscoverySessionDto: discoverySessionDtoSchema,
34740
+ ResearchBatchCreate: researchBatchCreateSchema,
34741
+ ResearchBatchDto: researchBatchSchema,
34532
34742
  ResearchRunCreate: researchRunCreateSchema,
34533
34743
  ResearchRunDetailDto: researchRunDetailSchema,
34534
34744
  ResearchRunListDto: researchRunListSchema,
@@ -36230,6 +36440,16 @@ var routeCatalog = [
36230
36440
  requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResearchRunCreate" } } } },
36231
36441
  responses: { 200: jsonResponse("Idempotent request returned its existing research run.", "ResearchRunDetailDto"), 202: jsonResponse("Research batch queued.", "ResearchRunDetailDto"), 400: errorResponse("Invalid provider, model, location, or request."), 403: errorResponse("Paid research was not granted to this principal."), 404: errorResponse("Project not found."), 409: errorResponse("Idempotency key was reused with a different payload."), 422: errorResponse("Research executor is unavailable on this deployment."), 429: errorResponse("The viewer reached the per-project UTC-day research limit.") }
36232
36442
  },
36443
+ {
36444
+ method: "post",
36445
+ path: "/api/v1/projects/{name}/research/batches",
36446
+ summary: "Start bounded explicit research destinations",
36447
+ description: "Queues one to twenty saved research runs atomically, with no more than fifty exact editor questions in total. Every destination explicitly pins its configured API provider, model, and configured location (or null); a scoped destination also pins the published plan revision. The root idempotency key covers the complete ordered destination list: a retry returns the same saved runs even if plans, templates, or provider defaults later change. A changed, reordered, added, or removed destination conflicts. Research never creates tracked queries, shared runs, snapshots, insights, or notifications.",
36448
+ tags: ["research"],
36449
+ parameters: [nameParameter],
36450
+ requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResearchBatchCreate" } } } },
36451
+ responses: { 200: jsonResponse("Idempotent request returned its existing saved runs.", "ResearchBatchDto"), 202: jsonResponse("All research destinations were queued.", "ResearchBatchDto"), 400: errorResponse("Each destination must explicitly supply a configured provider, model, location or null, and expected plan revision when scoped. The request permits at most 20 destinations and 50 total concrete queries."), 403: errorResponse("Paid research was not granted to this principal."), 404: errorResponse("Project not found."), 409: errorResponse("The root idempotency key was reused with different ordered destinations."), 422: errorResponse("Research execution is not available on this deployment."), 429: errorResponse("The viewer reached the per-project UTC-day research limit.") }
36452
+ },
36233
36453
  {
36234
36454
  method: "get",
36235
36455
  path: "/api/v1/projects/{name}/query-tracking",
@@ -36273,7 +36493,7 @@ var routeCatalog = [
36273
36493
  path: "/api/v1/projects/{name}/research/runs",
36274
36494
  summary: "List saved research query batches",
36275
36495
  tags: ["research"],
36276
- parameters: [nameParameter, { name: "limit", in: "query", description: "Max runs, default 20 and maximum 100.", schema: integerSchema }],
36496
+ parameters: [nameParameter, { name: "limit", in: "query", description: "Max runs, default 20 and maximum 100.", schema: integerSchema }, { name: "cursor", in: "query", description: "Opaque cursor from the previous page.", schema: stringSchema }],
36277
36497
  responses: { 200: jsonResponse("Research runs returned newest first.", "ResearchRunListDto"), 404: errorResponse("Project not found.") }
36278
36498
  },
36279
36499
  {
@@ -41978,7 +42198,7 @@ async function keysRoutes(app) {
41978
42198
  if (!row) {
41979
42199
  throw notFound("API key", id);
41980
42200
  }
41981
- return toApiKeyDto(row, projectNameById(app.db));
42201
+ return toApiKeyDto({ ...row, scopes: request.apiKey?.scopes ?? row.scopes }, projectNameById(app.db));
41982
42202
  });
41983
42203
  app.post("/keys", async (request) => {
41984
42204
  requireAdminSession(request);
@@ -83168,22 +83388,33 @@ async function technicalAeoRoutes(app, opts) {
83168
83388
 
83169
83389
  // ../api-routes/src/research.ts
83170
83390
  import crypto49 from "crypto";
83171
- import { and as and55, count as count3, desc as desc28, eq as eq65, gte as gte17, lt as lt11, sql as sql25 } from "drizzle-orm";
83391
+ import { z as z5 } from "zod";
83392
+ import { and as and55, count as count3, desc as desc28, eq as eq65, gte as gte17, lt as lt11, or as or14, sql as sql25 } from "drizzle-orm";
83172
83393
  var sameLocation = (a, b) => a.label === b.label && a.city === b.city && a.region === b.region && a.country === b.country && a.timezone === b.timezone;
83394
+ var BATCH_RECEIPT_PREFIX = "__canonry_research_batch__:";
83173
83395
  async function researchRoutes(app, opts) {
83174
83396
  const viewerDailyRunLimit = opts.viewerDailyRunLimit ?? DEFAULT_VIEWER_RESEARCH_DAILY_RUN_LIMIT;
83175
83397
  if (!Number.isInteger(viewerDailyRunLimit) || viewerDailyRunLimit <= 0) {
83176
83398
  throw new Error("viewerDailyRunLimit must be a positive integer");
83177
83399
  }
83178
83400
  app.post("/projects/:name/research/runs", {
83179
- config: { paidRead: true }
83401
+ config: { paidRead: true, writeScope: RESEARCH_RUN_SCOPE }
83180
83402
  }, async (request, reply) => {
83181
83403
  requireResearchGrant(request, opts.allowViewers ?? false);
83182
83404
  const project = resolveProject(app.db, request.params.name);
83183
- if (!opts.onResearchRunRequested) throw missingDependency("Research execution is not available on this deployment.", { reason: "no-research-handler" });
83184
83405
  const parsed = researchRunCreateSchema.safeParse(request.body ?? {});
83185
83406
  if (!parsed.success) throw validationError("Invalid research run request", { issues: parsed.error.issues });
83186
83407
  const input = parsed.data;
83408
+ if (input.idempotencyKey?.startsWith(BATCH_RECEIPT_PREFIX)) throw validationError("This idempotency key prefix is reserved for internal research batch receipts.");
83409
+ const requestHash2 = directResearchRequestHash(input);
83410
+ const existingReceipt = input.idempotencyKey ? app.db.select().from(researchRuns).where(and55(eq65(researchRuns.projectId, project.id), eq65(researchRuns.idempotencyKey, input.idempotencyKey))).get() : void 0;
83411
+ if (existingReceipt) {
83412
+ assertSameDirectResearchRequest(existingReceipt, input, requestHash2);
83413
+ const result2 = getDetail(app, project.id, existingReceipt.id);
83414
+ if (existingReceipt.status === ResearchRunStatuses.queued) opts.onResearchRunRequested?.(existingReceipt.id, project.id);
83415
+ return reply.status(200).send(result2);
83416
+ }
83417
+ if (!opts.onResearchRunRequested) throw missingDependency("Research execution is not available on this deployment.", { reason: "no-research-handler" });
83187
83418
  const adapters = opts.providerAdapters ?? [];
83188
83419
  const configured = new Set(opts.configuredProviderNames ?? []);
83189
83420
  const providerName = input.provider ?? project.providers.find((name) => configured.has(name) && adapters.some((adapter2) => adapter2.name === name && adapter2.mode === "api")) ?? adapters.find((adapter2) => adapter2.mode === "api" && configured.has(adapter2.name))?.name;
@@ -83193,37 +83424,34 @@ async function researchRoutes(app, opts) {
83193
83424
  adapter.modelValidationPattern.lastIndex = 0;
83194
83425
  if (!adapter.modelConfigurable || !adapter.modelValidationPattern.test(input.model)) throw validationError(`Invalid model "${input.model}" for provider "${providerName}".`, { provider: providerName, model: input.model, hint: adapter.modelValidationHint });
83195
83426
  }
83196
- const existingReceipt = input.idempotencyKey ? app.db.select().from(researchRuns).where(and55(eq65(researchRuns.projectId, project.id), eq65(researchRuns.idempotencyKey, input.idempotencyKey))).get() : void 0;
83197
83427
  const active = activeMeasurementPlan(app.db, project.id);
83198
- const scope = input.scope ? existingReceipt ? resolveStoredResearchScope(existingReceipt.scope, input.scope, input.idempotencyKey) : resolveResearchScope(active, input.scope) : null;
83199
- const template = input.template ? existingReceipt ? resolveStoredResearchTemplate(existingReceipt.template, input.template, input.idempotencyKey) : resolveResearchTemplate(app, project.id, input.template, scope) : null;
83428
+ const scope = input.scope ? resolveResearchScope(active, input.scope) : null;
83200
83429
  const location = input.location === void 0 ? scope ? null : project.defaultLocation ? project.locations.find((item2) => item2.label === project.defaultLocation) ?? null : null : input.location;
83201
83430
  if (location && !project.locations.some((item2) => sameLocation(item2, location))) throw validationError("Research location must exactly match a configured project location.", { location });
83431
+ const template = input.template ? resolveResearchTemplate(app, project.id, input.template, scope, location) : null;
83202
83432
  const requestedModel2 = input.model ?? null;
83203
83433
  const resolvedModel = requestedModel2 ?? (project.providerModels[providerName] || opts.getEffectiveProviderModels?.()[providerName] || adapter.defaultModel);
83204
83434
  adapter.modelValidationPattern.lastIndex = 0;
83205
83435
  if (!adapter.modelValidationPattern.test(resolvedModel)) throw validationError('Invalid resolved model "' + resolvedModel + '" for provider "' + providerName + '".', { provider: providerName, model: resolvedModel, hint: adapter.modelValidationHint });
83206
83436
  if (deduplicateResearchQueries(input.queries).length !== input.queries.length) throw validationError("Research queries must be unique within a batch.");
83207
83437
  const queryClasses = classifyResearchQueries(project, active?.plan ?? null, input.queries);
83208
- const normalized = { queries: input.queries, provider: providerName, model: requestedModel2, location: location ?? null, ...scope ? { scope } : {}, ...template ? { template } : {} };
83209
- const requestHash2 = crypto49.createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
83210
83438
  const now = (/* @__PURE__ */ new Date()).toISOString();
83211
83439
  const initiatedBy = researchPrincipal(request);
83212
83440
  const decision = app.db.transaction((tx) => {
83213
83441
  if (input.idempotencyKey) {
83214
83442
  const existing = tx.select().from(researchRuns).where(and55(eq65(researchRuns.projectId, project.id), eq65(researchRuns.idempotencyKey, input.idempotencyKey))).get();
83215
83443
  if (existing) {
83216
- if (existing.requestHash !== requestHash2) throw alreadyExists("Research idempotency key", input.idempotencyKey);
83444
+ assertSameDirectResearchRequest(existing, input, requestHash2);
83217
83445
  return { reused: true, id: existing.id, shouldDispatch: existing.status === ResearchRunStatuses.queued };
83218
83446
  }
83219
83447
  }
83220
- if (initiatedBy?.kind === "user" && initiatedBy.role === UserRoles.viewer) {
83448
+ if (initiatedBy?.limited) {
83221
83449
  const { start, end, date } = utcDayBounds(now);
83222
83450
  const used = tx.select({ value: count3() }).from(researchRuns).where(and55(
83223
83451
  eq65(researchRuns.projectId, project.id),
83224
83452
  gte17(researchRuns.createdAt, start),
83225
83453
  lt11(researchRuns.createdAt, end),
83226
- sql25`json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer}`
83454
+ sql25`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
83227
83455
  )).get()?.value ?? 0;
83228
83456
  if (used >= viewerDailyRunLimit) {
83229
83457
  throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
@@ -83240,17 +83468,108 @@ async function researchRoutes(app, opts) {
83240
83468
  if (decision.reused) return reply.status(200).send(result);
83241
83469
  return reply.status(202).send(result);
83242
83470
  });
83471
+ app.post("/projects/:name/research/batches", {
83472
+ config: { paidRead: true, writeScope: RESEARCH_RUN_SCOPE }
83473
+ }, async (request, reply) => {
83474
+ requireResearchGrant(request, opts.allowViewers ?? false);
83475
+ const project = resolveProject(app.db, request.params.name);
83476
+ const parsed = researchBatchCreateSchema.safeParse(request.body ?? {});
83477
+ if (!parsed.success) throw validationError("Invalid research batch request", { issues: parsed.error.issues });
83478
+ const input = parsed.data;
83479
+ if (input.idempotencyKey.startsWith(BATCH_RECEIPT_PREFIX)) throw validationError("This idempotency key prefix is reserved for internal research batch receipts.");
83480
+ const requestHash2 = researchBatchRequestHash(input);
83481
+ const receiptKeys = input.runs.map((_run, index2) => batchChildReceiptKey(input.idempotencyKey, index2));
83482
+ const firstReceipt = app.db.select().from(researchRuns).where(and55(
83483
+ eq65(researchRuns.projectId, project.id),
83484
+ eq65(researchRuns.idempotencyKey, receiptKeys[0])
83485
+ )).get();
83486
+ if (firstReceipt) {
83487
+ if (firstReceipt.requestHash !== requestHash2) throw alreadyExists("Research batch idempotency key", input.idempotencyKey);
83488
+ const saved = receiptKeys.map((key) => app.db.select().from(researchRuns).where(and55(
83489
+ eq65(researchRuns.projectId, project.id),
83490
+ eq65(researchRuns.idempotencyKey, key)
83491
+ )).get());
83492
+ if (saved.some((row) => !row || row.requestHash !== requestHash2)) throw alreadyExists("Research batch idempotency key", input.idempotencyKey);
83493
+ const runs3 = saved.map((row) => getDetail(app, project.id, row.id));
83494
+ for (const run of runs3) if (run.status === ResearchRunStatuses.queued) opts.onResearchRunRequested?.(run.id, project.id);
83495
+ return reply.status(200).send({ runs: runs3 });
83496
+ }
83497
+ if (!opts.onResearchRunRequested) throw missingDependency("Research execution is not available on this deployment.", { reason: "no-research-handler" });
83498
+ const active = activeMeasurementPlan(app.db, project.id);
83499
+ const prepared = input.runs.map((run) => prepareBatchRun(app, opts, project, active, run));
83500
+ const now = (/* @__PURE__ */ new Date()).toISOString();
83501
+ const initiatedBy = researchPrincipal(request);
83502
+ const decision = app.db.transaction((tx) => {
83503
+ if (initiatedBy?.limited) {
83504
+ const { start, end, date } = utcDayBounds(now);
83505
+ const used = tx.select({ value: count3() }).from(researchRuns).where(and55(
83506
+ eq65(researchRuns.projectId, project.id),
83507
+ gte17(researchRuns.createdAt, start),
83508
+ lt11(researchRuns.createdAt, end),
83509
+ sql25`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
83510
+ )).get()?.value ?? 0;
83511
+ if (used + prepared.length > viewerDailyRunLimit) throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
83512
+ }
83513
+ const ids = [];
83514
+ for (const [index2, run] of prepared.entries()) {
83515
+ const id = crypto49.randomUUID();
83516
+ ids.push(id);
83517
+ tx.insert(researchRuns).values({
83518
+ id,
83519
+ projectId: project.id,
83520
+ status: ResearchRunStatuses.queued,
83521
+ provider: run.providerName,
83522
+ requestedModel: run.requestedModel,
83523
+ resolvedModel: run.resolvedModel,
83524
+ location: run.location,
83525
+ totalQueries: run.input.queries.length,
83526
+ scope: run.scope,
83527
+ template: run.template,
83528
+ idempotencyKey: receiptKeys[index2],
83529
+ requestHash: requestHash2,
83530
+ initiatedBy,
83531
+ createdAt: now
83532
+ }).run();
83533
+ for (const [position, query] of run.input.queries.entries()) tx.insert(researchRunQueries).values({
83534
+ id: crypto49.randomUUID(),
83535
+ researchRunId: id,
83536
+ position,
83537
+ queryText: query,
83538
+ queryClass: run.queryClasses[position] ?? null,
83539
+ status: ResearchQueryStatuses.queued,
83540
+ requestedModel: run.requestedModel,
83541
+ resolvedModel: run.resolvedModel,
83542
+ groundingSources: [],
83543
+ citedDomains: [],
83544
+ searchQueries: [],
83545
+ createdAt: now
83546
+ }).run();
83547
+ writeAuditLog(tx, { projectId: project.id, actor: initiatedBy ? `${initiatedBy.kind}:${initiatedBy.id}` : "api", action: "research.created", entityType: "research_run", entityId: id });
83548
+ }
83549
+ return { ids };
83550
+ });
83551
+ const runs2 = decision.ids.map((id) => getDetail(app, project.id, id));
83552
+ for (const id of decision.ids) opts.onResearchRunRequested(id, project.id);
83553
+ return reply.status(202).send({ runs: runs2 });
83554
+ });
83243
83555
  app.get("/projects/:name/research/runs", async (request) => {
83244
83556
  const project = resolveProject(app.db, request.params.name);
83245
83557
  const requested = Number.parseInt(request.query.limit ?? "", 10);
83246
83558
  const limit = Number.isInteger(requested) && requested > 0 ? Math.min(requested, 100) : 20;
83247
- const runs2 = app.db.select().from(researchRuns).where(eq65(researchRuns.projectId, project.id)).orderBy(desc28(researchRuns.createdAt)).limit(limit).all().map(serializeRun);
83559
+ const cursor = request.query.cursor ? parseResearchCursor(request.query.cursor, project.id) : null;
83560
+ const rows = app.db.select().from(researchRuns).where(and55(
83561
+ eq65(researchRuns.projectId, project.id),
83562
+ cursor ? or14(lt11(researchRuns.createdAt, cursor.createdAt), and55(eq65(researchRuns.createdAt, cursor.createdAt), lt11(researchRuns.id, cursor.id))) : void 0
83563
+ )).orderBy(desc28(researchRuns.createdAt), desc28(researchRuns.id)).limit(limit + 1).all();
83564
+ const runs2 = rows.slice(0, limit).map(serializeRun);
83565
+ const last = runs2.at(-1);
83566
+ const nextCursor = rows.length > limit && last ? Buffer.from(JSON.stringify({ projectId: project.id, createdAt: last.createdAt, id: last.id })).toString("base64url") : null;
83248
83567
  const configured = new Set(opts.configuredProviderNames ?? []);
83249
83568
  const effectiveModels2 = opts.getEffectiveProviderModels?.() ?? {};
83250
- const providers = await Promise.all((opts.providerAdapters ?? []).filter((adapter) => adapter.mode === "api" && !isBrowserProvider(adapter.name) && configured.has(adapter.name)).map(async (adapter) => {
83569
+ const providers = (opts.providerAdapters ?? []).filter((adapter) => adapter.mode === "api" && !isBrowserProvider(adapter.name) && configured.has(adapter.name)).map((adapter) => {
83251
83570
  const defaultModel = project.providerModels[adapter.name] || effectiveModels2[adapter.name] || adapter.defaultModel;
83252
- const discovered = opts.getProviderModels ? await opts.getProviderModels(adapter.name) : [];
83253
- const models = discovered.length ? discovered : adapter.knownModels;
83571
+ const cached = opts.getCachedProviderModels?.(adapter.name) ?? [];
83572
+ const models = cached.length ? cached : adapter.knownModels;
83254
83573
  return {
83255
83574
  name: adapter.name,
83256
83575
  displayName: adapter.displayName,
@@ -83258,20 +83577,94 @@ async function researchRoutes(app, opts) {
83258
83577
  defaultModel,
83259
83578
  knownModels: [...new Map([{ id: defaultModel, displayName: defaultModel }, ...models].map((model) => [model.id, { id: model.id, displayName: model.displayName }])).values()]
83260
83579
  };
83261
- }));
83262
- return { runs: runs2, providers };
83580
+ });
83581
+ const canRun = canRunResearch(request, opts.allowViewers ?? false);
83582
+ const limited = request.principal !== void 0 && !request.principal.scopes.includes(WILDCARD_SCOPE);
83583
+ return { runs: runs2, nextCursor, providers, access: { canRun, dailyRunLimit: canRun && limited ? viewerDailyRunLimit : null } };
83263
83584
  });
83264
83585
  app.get("/projects/:name/research/runs/:runId", async (request) => {
83265
83586
  const project = resolveProject(app.db, request.params.name);
83266
83587
  return getDetail(app, project.id, request.params.runId);
83267
83588
  });
83268
83589
  }
83590
+ var DIRECT_REQUEST_HASH_VERSION = "direct-v2:";
83591
+ function directResearchRequestHash(input) {
83592
+ const { idempotencyKey: _key, ...request } = input;
83593
+ return DIRECT_REQUEST_HASH_VERSION + crypto49.createHash("sha256").update(JSON.stringify(request)).digest("hex");
83594
+ }
83595
+ function assertSameDirectResearchRequest(existing, input, hash) {
83596
+ if (existing.requestHash?.startsWith(DIRECT_REQUEST_HASH_VERSION)) {
83597
+ if (existing.requestHash !== hash) throw alreadyExists("Research idempotency key", input.idempotencyKey);
83598
+ return;
83599
+ }
83600
+ const scope = input.scope ? resolveStoredResearchScope(existing.scope, input.scope, input.idempotencyKey) : null;
83601
+ const template = input.template ? resolveStoredResearchTemplate(existing.template, input.template, input.idempotencyKey) : null;
83602
+ if (input.template?.bindingLocation !== void 0) throw alreadyExists("Research idempotency key", input.idempotencyKey);
83603
+ const normalized = {
83604
+ queries: input.queries,
83605
+ provider: input.provider ?? existing.provider,
83606
+ model: input.model ?? null,
83607
+ location: input.location === void 0 ? existing.location ?? null : input.location,
83608
+ ...scope ? { scope } : {},
83609
+ ...template ? { template } : {}
83610
+ };
83611
+ if (crypto49.createHash("sha256").update(JSON.stringify(normalized)).digest("hex") !== existing.requestHash) throw alreadyExists("Research idempotency key", input.idempotencyKey);
83612
+ }
83613
+ var researchCursorSchema = z5.object({ projectId: z5.string().min(1), createdAt: z5.string().datetime({ offset: true }), id: z5.string().min(1).max(256) }).strict();
83614
+ function parseResearchCursor(value, projectId) {
83615
+ try {
83616
+ if (value.length > 2048 || !/^[\w-]+$/.test(value)) throw new Error("Invalid encoding");
83617
+ const cursor = researchCursorSchema.parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
83618
+ if (cursor.projectId !== projectId) throw new Error("Different project");
83619
+ return cursor;
83620
+ } catch {
83621
+ throw validationError("Invalid research history cursor. Reload the first page and try again.");
83622
+ }
83623
+ }
83269
83624
  function resolveStoredResearchScope(stored, selection, idempotencyKey) {
83270
83625
  if (!stored || stored.kind !== selection.kind || stored.key !== selection.key || selection.expectedPlanRevision !== void 0 && selection.expectedPlanRevision !== stored.planRevision) {
83271
83626
  throw alreadyExists("Research idempotency key", idempotencyKey);
83272
83627
  }
83273
83628
  return stored;
83274
83629
  }
83630
+ function batchChildReceiptKey(rootKey, index2) {
83631
+ const derived = crypto49.createHash("sha256").update(`${rootKey}\0${index2}`).digest("hex");
83632
+ return `${BATCH_RECEIPT_PREFIX}${derived}:${index2}`;
83633
+ }
83634
+ function researchBatchRequestHash(input) {
83635
+ return crypto49.createHash("sha256").update(JSON.stringify({ runs: input.runs })).digest("hex");
83636
+ }
83637
+ function prepareBatchRun(app, opts, project, active, input) {
83638
+ const adapters = opts.providerAdapters ?? [];
83639
+ const configured = new Set(opts.configuredProviderNames ?? []);
83640
+ const providerName = input.provider;
83641
+ const adapter = adapters.find((candidate) => candidate.name === providerName);
83642
+ if (!adapter || adapter.mode !== "api" || isBrowserProvider(providerName) || !configured.has(providerName)) {
83643
+ throw validationError("Research requires a configured API provider.", { provider: providerName, validProviders: adapters.filter((a) => a.mode === "api" && configured.has(a.name)).map((a) => a.name) });
83644
+ }
83645
+ adapter.modelValidationPattern.lastIndex = 0;
83646
+ if (!adapter.modelValidationPattern.test(input.model) || !adapter.modelConfigurable && input.model !== adapter.defaultModel) {
83647
+ throw validationError(`Invalid model "${input.model}" for provider "${providerName}".`, { provider: providerName, model: input.model, hint: adapter.modelValidationHint });
83648
+ }
83649
+ const location = input.location;
83650
+ if (location && !project.locations.some((item2) => sameLocation(item2, location))) {
83651
+ throw validationError("Research location must exactly match a configured project location.", { location });
83652
+ }
83653
+ if (deduplicateResearchQueries(input.queries).length !== input.queries.length) throw validationError("Research queries must be unique within each batch destination.");
83654
+ const scope = input.scope ? resolveResearchScope(active, input.scope) : null;
83655
+ const template = input.template ? resolveResearchTemplate(app, project.id, input.template, scope, location) : null;
83656
+ return {
83657
+ input,
83658
+ providerName,
83659
+ requestedModel: input.model,
83660
+ // The batch contract requires the model so retries never consult a project or instance default.
83661
+ resolvedModel: input.model,
83662
+ location,
83663
+ scope,
83664
+ template,
83665
+ queryClasses: classifyResearchQueries(project, active?.plan ?? null, input.queries)
83666
+ };
83667
+ }
83275
83668
  function resolveResearchScope(active, selection) {
83276
83669
  if (!active || active.plan.schemaVersion !== 2) {
83277
83670
  throw validationError("Research scope requires an active Advanced Measurement plan.");
@@ -83292,8 +83685,7 @@ function resolveStoredResearchTemplate(stored, selection, idempotencyKey) {
83292
83685
  }
83293
83686
  return stored;
83294
83687
  }
83295
- function resolveResearchTemplate(app, projectId, selection, scope) {
83296
- if (!scope) throw validationError("Research templates require a selected market or property scope.");
83688
+ function resolveResearchTemplate(app, projectId, selection, scope, location) {
83297
83689
  const savedTemplate = app.db.select().from(measurementQueryTemplates).where(and55(
83298
83690
  eq65(measurementQueryTemplates.projectId, projectId),
83299
83691
  eq65(measurementQueryTemplates.id, selection.templateId)
@@ -83303,7 +83695,12 @@ function resolveResearchTemplate(app, projectId, selection, scope) {
83303
83695
  if (templateVersion !== selection.templateVersion) {
83304
83696
  throw validationError("The selected research template is stale. Reload it and try again.");
83305
83697
  }
83306
- const { bindings, output } = expandResearchTemplate(savedTemplate, scope);
83698
+ const bindingLocation = selection.bindingLocation === void 0 ? location : selection.bindingLocation;
83699
+ if (bindingLocation) {
83700
+ const project = app.db.select({ locations: projects.locations }).from(projects).where(eq65(projects.id, projectId)).get();
83701
+ if (!project.locations.some((item2) => sameLocation(item2, bindingLocation))) throw validationError("Research template binding location must match a configured project location.");
83702
+ }
83703
+ const { bindings, output } = expandResearchTemplate(savedTemplate, scope, bindingLocation);
83307
83704
  if (!output || output.length > 4e3) throw validationError("Research template output must be between 1 and 4000 characters.");
83308
83705
  return { templateId: selection.templateId, templateVersion, template: savedTemplate.pattern, bindings, output };
83309
83706
  }
@@ -83327,11 +83724,13 @@ function serializeQuery(row) {
83327
83724
  function researchPrincipal(request) {
83328
83725
  const principal = request.principal;
83329
83726
  if (!principal) return null;
83727
+ const actor = principal.delegatedUser ? { ...principal.delegatedUser, kind: "user" } : principal;
83330
83728
  return {
83331
- kind: principal.kind,
83332
- id: principal.id,
83333
- name: principal.name,
83334
- role: principal.kind === "user" ? principal.role ?? null : null
83729
+ kind: actor.kind,
83730
+ id: actor.id,
83731
+ name: actor.name,
83732
+ role: actor.kind === "user" ? actor.role ?? null : null,
83733
+ ...!principal.scopes.includes(WILDCARD_SCOPE) ? { limited: true } : {}
83335
83734
  };
83336
83735
  }
83337
83736
  function utcDayBounds(now) {
@@ -83455,6 +83854,7 @@ async function apiRoutes(app, opts) {
83455
83854
  });
83456
83855
  if (!opts.skipAuth) {
83457
83856
  await authPlugin(api, {
83857
+ researchAllowViewers: opts.researchAllowViewers,
83458
83858
  // A bearer that is not an api key is tried as an OAuth access token.
83459
83859
  // Wired unconditionally: the table is empty until an operator registers
83460
83860
  // a client, so an install with no OAuth in use pays one indexed miss.
@@ -83671,7 +84071,7 @@ async function apiRoutes(app, opts) {
83671
84071
  embedQueries: opts.embedQueries
83672
84072
  });
83673
84073
  await api.register(researchRoutes, {
83674
- getProviderModels: opts.getProviderModels,
84074
+ getCachedProviderModels: opts.getCachedProviderModels,
83675
84075
  getEffectiveProviderModels: opts.getEffectiveProviderModels,
83676
84076
  providerAdapters: opts.providerAdapters,
83677
84077
  configuredProviderNames: opts.providerSummary?.filter((provider) => provider.configured).map((provider) => provider.name),
@@ -84114,7 +84514,7 @@ function analyzableRunPredicates(projectId) {
84114
84514
  eq67(runs.projectId, projectId),
84115
84515
  // Only this kind writes query_snapshots.
84116
84516
  eq67(runs.kind, RunKinds["answer-visibility"]),
84117
- or14(eq67(runs.status, RunStatuses.completed), eq67(runs.status, RunStatuses.partial)),
84517
+ or15(eq67(runs.status, RunStatuses.completed), eq67(runs.status, RunStatuses.partial)),
84118
84518
  // Defensive: RunCoordinator already skips probes before analyzeAndPersist
84119
84519
  // is called, but a future call site invoking it directly for a probe must
84120
84520
  // still not pollute the intelligence window.
@@ -84704,7 +85104,7 @@ var IntelligenceService = class {
84704
85104
  and57(
84705
85105
  eq67(runs.projectId, projectId),
84706
85106
  eq67(runs.kind, RunKinds["answer-visibility"]),
84707
- or14(eq67(runs.status, "completed"), eq67(runs.status, "partial")),
85107
+ or15(eq67(runs.status, "completed"), eq67(runs.status, "partial")),
84708
85108
  // Defensive — see top of file.
84709
85109
  ne10(runs.trigger, RunTriggers.probe)
84710
85110
  )