@ainyc/canonry 4.190.0 → 4.191.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.
- package/README.md +1 -1
- package/assets/agent-workspace/skills/canonry/SKILL.md +124 -216
- package/assets/assets/{AuditHistoryPanel-wWLs088f.js → AuditHistoryPanel-C2dvjYNh.js} +1 -1
- package/assets/assets/{BacklinksPage-DJEO5o9c.js → BacklinksPage-D1yPQtfF.js} +1 -1
- package/assets/assets/{HistoryPage-CV5bTBU6.js → HistoryPage-DOEa7naA.js} +1 -1
- package/assets/assets/{MeasurementPropertyPage-C7KLIAad.js → MeasurementPropertyPage-D-O8vnMS.js} +1 -1
- package/assets/assets/ProjectPage-DqTCcimz.js +8 -0
- package/assets/assets/{RunRow-C3Ryj_9u.js → RunRow-BO7lyC1C.js} +1 -1
- package/assets/assets/{RunsPage-BxoEkZu3.js → RunsPage-DHSWfTav.js} +1 -1
- package/assets/assets/{SettingsPage-D_mB1JUZ.js → SettingsPage-oiJdhiq0.js} +1 -1
- package/assets/assets/SiteHealthSection-gVdylFY-.js +4 -0
- package/assets/assets/{TrafficPage-DgsWuDtv.js → TrafficPage-YIdwRpQh.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-BLWlQ8aT.js → TrafficSourceDetailPage-B4Cop6ox.js} +1 -1
- package/assets/assets/{extract-error-message-DHIHApUf.js → extract-error-message-XavUZNf3.js} +1 -1
- package/assets/assets/{index-BjjM151x.css → index-BtTEcbcB.css} +1 -1
- package/assets/assets/{index-nb8QHq99.js → index-njfYnO22.js} +25 -25
- package/assets/assets/{react-sigma_core.esm.min-DzTADS1A.js → react-sigma_core.esm.min-V7giUpEn.js} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-32QBVRMN.js → chunk-2A7E5TDU.js} +209 -29
- package/dist/{chunk-ROPJUNQN.js → chunk-KBUV3HGS.js} +55 -9
- package/dist/{chunk-2II4ZMMH.js → chunk-WSD5J6O4.js} +2 -2
- package/dist/{chunk-4NIQ3GMO.js → chunk-XI6FN52S.js} +33 -13
- package/dist/{chunk-6POEZ7GT.js → chunk-ZK4FJDCH.js} +291 -114
- package/dist/cli.js +56 -5
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-WNASQFN4.js → intelligence-service-EVRWZHDG.js} +2 -2
- package/dist/mcp.js +13 -8
- package/package.json +11 -11
- package/assets/assets/ProjectPage-DIW1h05b.js +0 -8
- package/assets/assets/SiteHealthSection-Dgqtz9J2.js +0 -4
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
MeasurementRunScopeError,
|
|
89
89
|
PROJECTS_WRITE_SCOPE,
|
|
90
90
|
READ_ONLY_SCOPE,
|
|
91
|
+
RESEARCH_RUN_SCOPE,
|
|
91
92
|
ResearchQueryStatuses,
|
|
92
93
|
ResearchRunStatuses,
|
|
93
94
|
RunKinds,
|
|
@@ -381,6 +382,7 @@ import {
|
|
|
381
382
|
inclusiveDayCount,
|
|
382
383
|
indexingRequestResponseDtoSchema,
|
|
383
384
|
internalError,
|
|
385
|
+
intersectScopes,
|
|
384
386
|
isBrowserProvider,
|
|
385
387
|
isLocationRedirectStatus,
|
|
386
388
|
isReadOnlyKey,
|
|
@@ -560,6 +562,8 @@ import {
|
|
|
560
562
|
reportConfidenceLabel,
|
|
561
563
|
reportHorizonLabel,
|
|
562
564
|
reportSeverityLabel,
|
|
565
|
+
researchBatchCreateSchema,
|
|
566
|
+
researchBatchSchema,
|
|
563
567
|
researchDailyLimitExceeded,
|
|
564
568
|
researchRunCreateSchema,
|
|
565
569
|
researchRunDetailSchema,
|
|
@@ -570,6 +574,7 @@ import {
|
|
|
570
574
|
resolveMeasurementRunQueryScope,
|
|
571
575
|
resolveMeasurementRunScope,
|
|
572
576
|
resolveSnapshotRequestQueries,
|
|
577
|
+
restrictedWriteScopes,
|
|
573
578
|
resultsClearRequestSchema,
|
|
574
579
|
resultsClearResponseSchema,
|
|
575
580
|
resultsExportDtoSchema,
|
|
@@ -676,7 +681,7 @@ import {
|
|
|
676
681
|
wordpressSchemaDeployResultDtoSchema,
|
|
677
682
|
wordpressSchemaStatusResultDtoSchema,
|
|
678
683
|
wordpressStatusDtoSchema
|
|
679
|
-
} from "./chunk-
|
|
684
|
+
} from "./chunk-KBUV3HGS.js";
|
|
680
685
|
|
|
681
686
|
// src/intelligence-service.ts
|
|
682
687
|
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";
|
|
@@ -1181,6 +1186,8 @@ var apiKeys = sqliteTable("api_keys", {
|
|
|
1181
1186
|
* project so a stale scoped key never outlives its project.
|
|
1182
1187
|
*/
|
|
1183
1188
|
projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
|
1189
|
+
/** Internal delegated session identity, never accepted by the key-create API. */
|
|
1190
|
+
delegatedUserId: text("delegated_user_id").references(() => users.id, { onDelete: "cascade" }),
|
|
1184
1191
|
createdAt: text("created_at").notNull(),
|
|
1185
1192
|
lastUsedAt: text("last_used_at"),
|
|
1186
1193
|
revokedAt: text("revoked_at")
|
|
@@ -7177,6 +7184,13 @@ var MIGRATION_VERSIONS = [
|
|
|
7177
7184
|
`ALTER TABLE research_runs ADD COLUMN template TEXT`,
|
|
7178
7185
|
`ALTER TABLE research_run_queries ADD COLUMN query_class TEXT`
|
|
7179
7186
|
]
|
|
7187
|
+
},
|
|
7188
|
+
{
|
|
7189
|
+
version: 154,
|
|
7190
|
+
name: "delegated-api-key-user",
|
|
7191
|
+
statements: [
|
|
7192
|
+
`ALTER TABLE api_keys ADD COLUMN delegated_user_id TEXT REFERENCES users(id) ON DELETE CASCADE`
|
|
7193
|
+
]
|
|
7180
7194
|
}
|
|
7181
7195
|
];
|
|
7182
7196
|
function addRunsMeasurementPlanVersionForeignKey(tx) {
|
|
@@ -10795,10 +10809,6 @@ var ADS_MUTATION_SCOPES = /* @__PURE__ */ new Set([
|
|
|
10795
10809
|
ADS_APPROVE_SCOPE,
|
|
10796
10810
|
ADS_ACTIVATE_SCOPE
|
|
10797
10811
|
]);
|
|
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
10812
|
function isAdsWriteRoute(url) {
|
|
10803
10813
|
const rest = projectRouteRest(url);
|
|
10804
10814
|
return rest !== null && rest.startsWith("ads/");
|
|
@@ -10829,8 +10839,8 @@ var VIEWER_DENIED_MESSAGE = "Your account has view-only access, so it cannot mak
|
|
|
10829
10839
|
var ADMIN_ONLY_MESSAGE = "Only an administrator account can use this.";
|
|
10830
10840
|
function requireAdminSession(request) {
|
|
10831
10841
|
const principal = request.principal;
|
|
10832
|
-
|
|
10833
|
-
if (
|
|
10842
|
+
const role = principal?.kind === "user" ? principal.role : principal?.delegatedUser?.role;
|
|
10843
|
+
if (!role || role === UserRoles.admin) return;
|
|
10834
10844
|
throw forbidden(ADMIN_ONLY_MESSAGE);
|
|
10835
10845
|
}
|
|
10836
10846
|
function requirePaidReadScope(request) {
|
|
@@ -10841,16 +10851,20 @@ function requirePaidReadScope(request) {
|
|
|
10841
10851
|
throw forbidden("This API key was not granted access to OpenAI Ads paid reads.");
|
|
10842
10852
|
}
|
|
10843
10853
|
}
|
|
10854
|
+
function canRunResearch(request, allowViewers) {
|
|
10855
|
+
const principal = request.principal;
|
|
10856
|
+
if (!principal) return true;
|
|
10857
|
+
if (principal.kind === "user" && principal.role === UserRoles.viewer && !allowViewers) return false;
|
|
10858
|
+
return principal.scopes.includes(WILDCARD_SCOPE) || principal.scopes.includes(RESEARCH_RUN_SCOPE);
|
|
10859
|
+
}
|
|
10844
10860
|
function requireResearchGrant(request, allowViewers) {
|
|
10845
10861
|
const principal = request.principal;
|
|
10846
10862
|
if (!principal) return;
|
|
10847
|
-
if (
|
|
10848
|
-
|
|
10849
|
-
throw forbidden("
|
|
10863
|
+
if (canRunResearch(request, allowViewers)) return;
|
|
10864
|
+
if (principal.kind === "user" && principal.role === UserRoles.viewer && !allowViewers) {
|
|
10865
|
+
throw forbidden("Research queries are not enabled for viewer accounts on this deployment.");
|
|
10850
10866
|
}
|
|
10851
|
-
|
|
10852
|
-
if (principal.role === UserRoles.viewer && allowViewers) return;
|
|
10853
|
-
throw forbidden("Research queries are not enabled for viewer accounts on this deployment.");
|
|
10867
|
+
throw forbidden('This credential was not granted the "research.run" capability.');
|
|
10854
10868
|
}
|
|
10855
10869
|
function requireBroadInstanceKey(request) {
|
|
10856
10870
|
const principal = request.principal;
|
|
@@ -10893,8 +10907,8 @@ function shouldSkipAuth(url) {
|
|
|
10893
10907
|
}
|
|
10894
10908
|
var USERS_READ_SCOPE = "users.read";
|
|
10895
10909
|
var USERS_WRITE_SCOPE = "users.write";
|
|
10896
|
-
function scopesForRole(role) {
|
|
10897
|
-
return role === UserRoles.admin ? [WILDCARD_SCOPE] : [READ_ONLY_SCOPE];
|
|
10910
|
+
function scopesForRole(role, researchAllowViewers = false) {
|
|
10911
|
+
return role === UserRoles.admin ? [WILDCARD_SCOPE] : researchAllowViewers ? [READ_ONLY_SCOPE, RESEARCH_RUN_SCOPE] : [READ_ONLY_SCOPE];
|
|
10898
10912
|
}
|
|
10899
10913
|
function queryValue(request, key) {
|
|
10900
10914
|
const raw = request.query?.[key];
|
|
@@ -10984,7 +10998,7 @@ function enforceEmbedProjectTabs(request, configuredTabs) {
|
|
|
10984
10998
|
if (tabs.includes("report") && isReportRead(url)) return;
|
|
10985
10999
|
throw forbidden("This endpoint is not available for the configured embed tabs.");
|
|
10986
11000
|
}
|
|
10987
|
-
function resolveSignedInPerson(app, request, reply, cookie) {
|
|
11001
|
+
function resolveSignedInPerson(app, request, reply, cookie, researchAllowViewers) {
|
|
10988
11002
|
const sessionId = parseCookieHeader(request.headers.cookie)[USER_SESSION_COOKIE_NAME];
|
|
10989
11003
|
if (!sessionId) return false;
|
|
10990
11004
|
const resolved = resolveUserSession(app.db, sessionId);
|
|
@@ -10993,7 +11007,7 @@ function resolveSignedInPerson(app, request, reply, cookie) {
|
|
|
10993
11007
|
kind: "user",
|
|
10994
11008
|
id: resolved.user.id,
|
|
10995
11009
|
name: resolved.user.name,
|
|
10996
|
-
scopes: scopesForRole(resolved.user.role),
|
|
11010
|
+
scopes: scopesForRole(resolved.user.role, researchAllowViewers),
|
|
10997
11011
|
projectId: null,
|
|
10998
11012
|
role: resolved.user.role,
|
|
10999
11013
|
viaCookie: true
|
|
@@ -11009,12 +11023,24 @@ function resolveSignedInPerson(app, request, reply, cookie) {
|
|
|
11009
11023
|
}
|
|
11010
11024
|
function applyRoleGates(request) {
|
|
11011
11025
|
const principal = request.principal;
|
|
11012
|
-
|
|
11013
|
-
|
|
11026
|
+
const role = principal?.kind === "user" ? principal.role : principal?.delegatedUser?.role;
|
|
11027
|
+
if (!principal || !role) return;
|
|
11028
|
+
request.readSemanticGrant = role === UserRoles.viewer && isReadSemanticRoute(request);
|
|
11029
|
+
applyScopedWriteGates(request);
|
|
11014
11030
|
if (isReadOnlyKey(principal.scopes) && WRITE_METHODS2.has(request.method) && !request.readSemanticGrant && !isPaidReadRoute(request) && !isTransportEnvelopeRoute(request)) {
|
|
11015
11031
|
throw forbidden(VIEWER_DENIED_MESSAGE);
|
|
11016
11032
|
}
|
|
11017
11033
|
}
|
|
11034
|
+
function applyScopedWriteGates(request) {
|
|
11035
|
+
if (!WRITE_METHODS2.has(request.method) || isTransportEnvelopeRoute(request) || request.readSemanticGrant) return;
|
|
11036
|
+
const scopes = principalScopes(request) ?? [];
|
|
11037
|
+
const restricted = restrictedWriteScopes(scopes);
|
|
11038
|
+
if (!restricted) return;
|
|
11039
|
+
const required2 = request.routeOptions.config.writeScope;
|
|
11040
|
+
if (required2 && restricted.includes(required2)) return;
|
|
11041
|
+
if (restricted.some((scope) => ADS_MUTATION_SCOPES.has(scope)) && isAdsWriteRoute(request.url.split("?")[0])) return;
|
|
11042
|
+
throw forbidden("This credential can only perform operations explicitly granted by its scopes.");
|
|
11043
|
+
}
|
|
11018
11044
|
async function authPlugin(app, opts = {}) {
|
|
11019
11045
|
app.addHook("onRequest", async (request, reply) => {
|
|
11020
11046
|
const url = request.url.split("?")[0];
|
|
@@ -11035,10 +11061,9 @@ async function authPlugin(app, opts = {}) {
|
|
|
11035
11061
|
if (granted) {
|
|
11036
11062
|
const account = app.db.select().from(users).where(eq2(users.id, granted.userId)).get();
|
|
11037
11063
|
if (!account) throw authInvalid();
|
|
11038
|
-
const roleScopes = scopesForRole(account.role);
|
|
11064
|
+
const roleScopes = scopesForRole(account.role, opts.researchAllowViewers);
|
|
11039
11065
|
const requested = (granted.scope ?? "").split(/\s+/).filter((part) => part.length > 0 && part !== "offline_access");
|
|
11040
|
-
const
|
|
11041
|
-
const effective = narrowed.length > 0 ? narrowed : [READ_ONLY_SCOPE];
|
|
11066
|
+
const effective = intersectScopes(roleScopes, requested);
|
|
11042
11067
|
request.principal = {
|
|
11043
11068
|
kind: "user",
|
|
11044
11069
|
id: account.id,
|
|
@@ -11055,7 +11080,7 @@ async function authPlugin(app, opts = {}) {
|
|
|
11055
11080
|
}
|
|
11056
11081
|
throw authInvalid();
|
|
11057
11082
|
}
|
|
11058
|
-
} else if (resolveSignedInPerson(app, request, reply, opts.userSessionCookie)) {
|
|
11083
|
+
} else if (resolveSignedInPerson(app, request, reply, opts.userSessionCookie, opts.researchAllowViewers ?? false)) {
|
|
11059
11084
|
applyRoleGates(request);
|
|
11060
11085
|
assertSameOriginWrite(request);
|
|
11061
11086
|
enforceEmbedProjectTabs(request, opts.embedProjectTabs);
|
|
@@ -11078,7 +11103,10 @@ async function authPlugin(app, opts = {}) {
|
|
|
11078
11103
|
throw authRequired();
|
|
11079
11104
|
}
|
|
11080
11105
|
app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq2(apiKeys.id, key.id)).run();
|
|
11081
|
-
|
|
11106
|
+
let scopes = Array.isArray(key.scopes) ? key.scopes : [];
|
|
11107
|
+
const delegatedUser = key.delegatedUserId ? app.db.select().from(users).where(eq2(users.id, key.delegatedUserId)).get() : void 0;
|
|
11108
|
+
if (key.delegatedUserId && !delegatedUser) throw authInvalid();
|
|
11109
|
+
if (delegatedUser) scopes = intersectScopes(scopesForRole(delegatedUser.role, opts.researchAllowViewers), scopes);
|
|
11082
11110
|
request.apiKey = { id: key.id, name: key.name, scopes, projectId: key.projectId ?? null };
|
|
11083
11111
|
request.principal = {
|
|
11084
11112
|
kind: "api-key",
|
|
@@ -11086,15 +11114,15 @@ async function authPlugin(app, opts = {}) {
|
|
|
11086
11114
|
name: key.name,
|
|
11087
11115
|
scopes,
|
|
11088
11116
|
projectId: key.projectId ?? null,
|
|
11089
|
-
viaCookie: keyArrivedInCookie
|
|
11117
|
+
viaCookie: keyArrivedInCookie,
|
|
11118
|
+
...delegatedUser ? { delegatedUser: { id: delegatedUser.id, name: delegatedUser.name, role: delegatedUser.role } } : {}
|
|
11090
11119
|
};
|
|
11091
11120
|
assertSameOriginWrite(request);
|
|
11092
|
-
|
|
11121
|
+
applyRoleGates(request);
|
|
11122
|
+
applyScopedWriteGates(request);
|
|
11123
|
+
if (isReadOnlyKey(scopes) && WRITE_METHODS2.has(request.method) && !request.readSemanticGrant && !isTransportEnvelopeRoute(request)) {
|
|
11093
11124
|
throw forbidden("This API key is read-only and cannot perform write operations.");
|
|
11094
11125
|
}
|
|
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
11126
|
enforceEmbedProjectTabs(request, opts.embedProjectTabs);
|
|
11099
11127
|
if (key.projectId) {
|
|
11100
11128
|
const match = url.match(/\/projects\/([^/?#]+)/);
|
|
@@ -11149,7 +11177,7 @@ function redirectUriAllowed(registered, presented) {
|
|
|
11149
11177
|
function isLoopbackHost(hostname) {
|
|
11150
11178
|
return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
|
|
11151
11179
|
}
|
|
11152
|
-
var SUPPORTED_SCOPES = ["read", "offline_access"];
|
|
11180
|
+
var SUPPORTED_SCOPES = ["read", RESEARCH_RUN_SCOPE, "offline_access"];
|
|
11153
11181
|
function resolveRequestedScope(requested) {
|
|
11154
11182
|
if (!requested || !requested.trim()) return "read";
|
|
11155
11183
|
const parts = requested.trim().split(/\s+/);
|
|
@@ -11183,6 +11211,7 @@ function consentPage(o) {
|
|
|
11183
11211
|
<dt>Permissions</dt><dd>${escapeHtml(o.scope)}</dd>
|
|
11184
11212
|
<dt>Redirects to</dt><dd class="uri">${escapeHtml(o.redirectUri)}</dd>
|
|
11185
11213
|
</dl>
|
|
11214
|
+
${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
11215
|
<input type="hidden" name="csrf" value="${escapeHtml(o.csrf)}">
|
|
11187
11216
|
<div class="row">
|
|
11188
11217
|
<button type="submit" name="approve" value="no" class="ghost">Deny</button>
|
|
@@ -13805,54 +13834,61 @@ function scopeNamedShareOfVoice(identities, answered2, prepared) {
|
|
|
13805
13834
|
entries: credited.map((row) => ({ ...row, share: row.credits / denominator }))
|
|
13806
13835
|
};
|
|
13807
13836
|
}
|
|
13808
|
-
function
|
|
13837
|
+
function createMeasurementOverviewEvaluator(input, options = {}) {
|
|
13809
13838
|
const prepared = prepareReport(input, options);
|
|
13810
13839
|
const indexes = buildMeasurementOverviewIndexes(input, prepared, options.onEvidenceIndexed);
|
|
13811
|
-
|
|
13812
|
-
|
|
13813
|
-
|
|
13814
|
-
|
|
13815
|
-
|
|
13816
|
-
|
|
13817
|
-
|
|
13818
|
-
|
|
13819
|
-
|
|
13820
|
-
|
|
13821
|
-
|
|
13822
|
-
mentionCoverage: targetMentionRate(ownTargets, ownSlots, ownAnswered, indexes),
|
|
13823
|
-
citationCoverage: indexedScopeCitationRate(ownSlots, ownEdges, indexes),
|
|
13824
|
-
// Each engine is measured over the slots it owns, using exactly the
|
|
13825
|
-
// functions the Property total uses. A per-engine reading is therefore
|
|
13826
|
-
// withheld for the same reasons the total is, never rounded down to zero.
|
|
13827
|
-
providers: providersFor(ownSlots).map((provider) => {
|
|
13828
|
-
const providerSlots = ownSlots.filter((slot) => slot.provider === provider);
|
|
13840
|
+
return {
|
|
13841
|
+
evaluate(scopeTargetIds, namedIdentities = input.namedIdentities) {
|
|
13842
|
+
const targetIds = new Set(scopeTargetIds);
|
|
13843
|
+
const edges = indexedScopeEdges(indexes, targetIds);
|
|
13844
|
+
const slots = indexedSlotsForEdges(edges, indexes);
|
|
13845
|
+
const answered2 = indexedAnsweredSlots(slots, indexes);
|
|
13846
|
+
const properties = sortedUnique([...targetIds]).map((targetId) => {
|
|
13847
|
+
const ownEdges = indexes.targetEdgesByTargetId.get(targetId) ?? [];
|
|
13848
|
+
const ownSlots = indexedSlotsForEdges(ownEdges, indexes);
|
|
13849
|
+
const ownAnswered = indexedAnsweredSlots(ownSlots, indexes);
|
|
13850
|
+
const ownTargets = indexes.targetsById.get(targetId);
|
|
13829
13851
|
return {
|
|
13830
|
-
|
|
13831
|
-
mentionCoverage: targetMentionRate(
|
|
13832
|
-
|
|
13833
|
-
|
|
13834
|
-
|
|
13835
|
-
|
|
13836
|
-
)
|
|
13837
|
-
|
|
13852
|
+
targetId,
|
|
13853
|
+
mentionCoverage: targetMentionRate(ownTargets, ownSlots, ownAnswered, indexes),
|
|
13854
|
+
citationCoverage: indexedScopeCitationRate(ownSlots, ownEdges, indexes),
|
|
13855
|
+
// Each engine is measured over the slots it owns, using exactly the
|
|
13856
|
+
// functions the Property total uses. A per-engine reading is therefore
|
|
13857
|
+
// withheld for the same reasons the total is, never rounded down to zero.
|
|
13858
|
+
providers: providersFor(ownSlots).map((provider) => {
|
|
13859
|
+
const providerSlots = ownSlots.filter((slot) => slot.provider === provider);
|
|
13860
|
+
return {
|
|
13861
|
+
provider,
|
|
13862
|
+
mentionCoverage: targetMentionRate(
|
|
13863
|
+
ownTargets,
|
|
13864
|
+
providerSlots,
|
|
13865
|
+
indexedAnsweredSlots(providerSlots, indexes),
|
|
13866
|
+
indexes
|
|
13867
|
+
),
|
|
13868
|
+
citationCoverage: indexedScopeCitationRate(providerSlots, ownEdges, indexes)
|
|
13869
|
+
};
|
|
13870
|
+
}),
|
|
13871
|
+
flags: indexes.ambiguousEvidenceKeysByTargetId.get(targetId)?.size ?? 0
|
|
13838
13872
|
};
|
|
13839
|
-
})
|
|
13840
|
-
|
|
13841
|
-
|
|
13842
|
-
|
|
13843
|
-
|
|
13844
|
-
|
|
13845
|
-
|
|
13846
|
-
|
|
13847
|
-
|
|
13848
|
-
|
|
13849
|
-
|
|
13850
|
-
|
|
13851
|
-
|
|
13852
|
-
|
|
13853
|
-
flags: properties.reduce((total, row) => total + row.flags, 0)
|
|
13873
|
+
});
|
|
13874
|
+
return {
|
|
13875
|
+
eligibleSlots: slots.length,
|
|
13876
|
+
answeredSlots: answered2.length,
|
|
13877
|
+
includesHistoricalData: prepared.diagnostics.bridgedObservationIds.length > 0 || prepared.diagnostics.historicalObservationIds.length > 0,
|
|
13878
|
+
propertiesMentioned: scopePropertiesMentioned(input, targetIds, slots, answered2, prepared),
|
|
13879
|
+
mentionCoverage: scopeMentionRate(input, targetIds, slots, answered2, prepared),
|
|
13880
|
+
citationCoverage: indexedScopeCitationRate(slots, edges, indexes),
|
|
13881
|
+
brandPresence: scopeBrandPresence(input, slots, answered2, prepared),
|
|
13882
|
+
namedShareOfVoice: scopeNamedShareOfVoice(namedIdentities ?? [], answered2, prepared),
|
|
13883
|
+
properties,
|
|
13884
|
+
flags: properties.reduce((total, row) => total + row.flags, 0)
|
|
13885
|
+
};
|
|
13886
|
+
}
|
|
13854
13887
|
};
|
|
13855
13888
|
}
|
|
13889
|
+
function buildMeasurementOverview(input, options = {}) {
|
|
13890
|
+
return createMeasurementOverviewEvaluator(input, options).evaluate(input.scopeTargetIds, input.namedIdentities);
|
|
13891
|
+
}
|
|
13856
13892
|
function buildMeasurementEvidence(input, options = {}) {
|
|
13857
13893
|
const prepared = prepareReport(input, options);
|
|
13858
13894
|
return { answers: prepared.answers, evidence: prepared.evidence, diagnostics: prepared.diagnostics };
|
|
@@ -21408,16 +21444,16 @@ function filteredOverviewWithDb(db, active, plan, run, filters, targetKeys) {
|
|
|
21408
21444
|
const location = filters.location === void 0 ? void 0 : normalizeMeasurementLocation(filters.location);
|
|
21409
21445
|
const expectedSlots = materialized.input.expectedSlots.filter((slot) => (provider === void 0 || normalizeText2(slot.provider) === provider) && (location === void 0 || normalizeMeasurementLocation(slot.location) === location));
|
|
21410
21446
|
const usageEdges = materialized.input.usageEdges.filter((edge) => filters.queryClass === "all" || materialized.edgeQueryClass.get(edge.id) === filters.queryClass);
|
|
21411
|
-
|
|
21412
|
-
materialized,
|
|
21447
|
+
const evaluator = createMeasurementOverviewEvaluator({
|
|
21448
|
+
...materialized.input,
|
|
21413
21449
|
expectedSlots,
|
|
21414
21450
|
usageEdges,
|
|
21415
|
-
|
|
21416
|
-
|
|
21417
|
-
|
|
21418
|
-
|
|
21419
|
-
|
|
21420
|
-
|
|
21451
|
+
scopeTargetIds: [...targetKeys]
|
|
21452
|
+
});
|
|
21453
|
+
return {
|
|
21454
|
+
materialized,
|
|
21455
|
+
evaluator,
|
|
21456
|
+
overview: evaluator.evaluate(targetKeys)
|
|
21421
21457
|
};
|
|
21422
21458
|
}
|
|
21423
21459
|
function targetAnswers(plan, target, materialized, filters) {
|
|
@@ -21537,16 +21573,11 @@ function targetKeysForRun(plan, run, requested, requireEntireScope) {
|
|
|
21537
21573
|
}
|
|
21538
21574
|
return selected;
|
|
21539
21575
|
}
|
|
21540
|
-
function marketRollup(plan, run,
|
|
21576
|
+
function marketRollup(plan, run, evaluator, scopedToOneGroup) {
|
|
21541
21577
|
if (scopedToOneGroup) return [];
|
|
21542
21578
|
return plan.groups.map((group) => {
|
|
21543
21579
|
const targetKeys = targetKeysForRun(plan, run, group.targetKeys, false);
|
|
21544
|
-
const overview =
|
|
21545
|
-
...materialized.input,
|
|
21546
|
-
expectedSlots,
|
|
21547
|
-
usageEdges,
|
|
21548
|
-
scopeTargetIds: targetKeys
|
|
21549
|
-
});
|
|
21580
|
+
const overview = evaluator.evaluate(targetKeys);
|
|
21550
21581
|
return {
|
|
21551
21582
|
groupKey: group.stableKey,
|
|
21552
21583
|
label: group.label,
|
|
@@ -21618,22 +21649,28 @@ function portfolioResponse(db, active, plan, query) {
|
|
|
21618
21649
|
truncated: rows2.length > limit2
|
|
21619
21650
|
});
|
|
21620
21651
|
}
|
|
21621
|
-
const { materialized, overview,
|
|
21652
|
+
const { materialized, overview, evaluator } = filteredOverviewWithDb(db, active, plan, run, filters, targetKeys);
|
|
21622
21653
|
const measured = new Map(overview.properties.map((row) => [row.targetId, row]));
|
|
21623
|
-
const
|
|
21654
|
+
const ranked = targets.map((target) => {
|
|
21624
21655
|
const row = measured.get(target.stableKey);
|
|
21625
|
-
const recommendations = recommendationRows(target, targetAnswers(plan, target, materialized, filters));
|
|
21626
21656
|
return {
|
|
21657
|
+
target,
|
|
21627
21658
|
...propertyDto2(target),
|
|
21628
21659
|
mentionCoverage: row ? coverageMetric2(row.mentionCoverage) : unavailable3("no_population"),
|
|
21629
21660
|
citationCoverage: row ? coverageMetric2(row.citationCoverage) : unavailable3("no_population"),
|
|
21630
|
-
flags: row?.flags ?? 0
|
|
21661
|
+
flags: row?.flags ?? 0
|
|
21662
|
+
};
|
|
21663
|
+
}).sort(compareWeakest);
|
|
21664
|
+
const limit = query.limit ?? DEFAULT_LIMIT3;
|
|
21665
|
+
const rows = ranked.slice(0, limit).map(({ target, ...row }) => {
|
|
21666
|
+
const recommendations = recommendationRows(target, targetAnswers(plan, target, materialized, filters));
|
|
21667
|
+
return {
|
|
21668
|
+
...row,
|
|
21631
21669
|
recommendedInstead: recommendations.slice(0, 5).map(({ name, occurrences }) => ({ name, occurrences })),
|
|
21632
21670
|
recommendedInsteadTotal: recommendations.length,
|
|
21633
21671
|
recommendedInsteadTruncated: recommendations.length > 5
|
|
21634
21672
|
};
|
|
21635
|
-
})
|
|
21636
|
-
const limit = query.limit ?? DEFAULT_LIMIT3;
|
|
21673
|
+
});
|
|
21637
21674
|
return measurementPortfolioSummaryResponseSchema.parse({
|
|
21638
21675
|
portfolio: {
|
|
21639
21676
|
groupKey: group?.stableKey ?? null,
|
|
@@ -21648,9 +21685,9 @@ function portfolioResponse(db, active, plan, query) {
|
|
|
21648
21685
|
citationCoverage: coverageMetric2(overview.citationCoverage)
|
|
21649
21686
|
},
|
|
21650
21687
|
weakestProperties: rows.slice(0, limit),
|
|
21651
|
-
markets: marketRollup(plan, run,
|
|
21652
|
-
totalProperties:
|
|
21653
|
-
truncated:
|
|
21688
|
+
markets: marketRollup(plan, run, evaluator, group !== void 0),
|
|
21689
|
+
totalProperties: ranked.length,
|
|
21690
|
+
truncated: ranked.length > limit
|
|
21654
21691
|
});
|
|
21655
21692
|
}
|
|
21656
21693
|
function propertyCompetitorsResponse(db, active, plan, query) {
|
|
@@ -34521,6 +34558,8 @@ var SCHEMA_TABLE = {
|
|
|
34521
34558
|
DiscoveryPromoteResult: discoveryPromoteResultSchema,
|
|
34522
34559
|
DiscoverySessionDetailDto: discoverySessionDetailDtoSchema,
|
|
34523
34560
|
DiscoverySessionDto: discoverySessionDtoSchema,
|
|
34561
|
+
ResearchBatchCreate: researchBatchCreateSchema,
|
|
34562
|
+
ResearchBatchDto: researchBatchSchema,
|
|
34524
34563
|
ResearchRunCreate: researchRunCreateSchema,
|
|
34525
34564
|
ResearchRunDetailDto: researchRunDetailSchema,
|
|
34526
34565
|
ResearchRunListDto: researchRunListSchema,
|
|
@@ -36222,6 +36261,16 @@ var routeCatalog = [
|
|
|
36222
36261
|
requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResearchRunCreate" } } } },
|
|
36223
36262
|
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.") }
|
|
36224
36263
|
},
|
|
36264
|
+
{
|
|
36265
|
+
method: "post",
|
|
36266
|
+
path: "/api/v1/projects/{name}/research/batches",
|
|
36267
|
+
summary: "Start bounded explicit research destinations",
|
|
36268
|
+
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.",
|
|
36269
|
+
tags: ["research"],
|
|
36270
|
+
parameters: [nameParameter],
|
|
36271
|
+
requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResearchBatchCreate" } } } },
|
|
36272
|
+
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.") }
|
|
36273
|
+
},
|
|
36225
36274
|
{
|
|
36226
36275
|
method: "get",
|
|
36227
36276
|
path: "/api/v1/projects/{name}/query-tracking",
|
|
@@ -41970,7 +42019,7 @@ async function keysRoutes(app) {
|
|
|
41970
42019
|
if (!row) {
|
|
41971
42020
|
throw notFound("API key", id);
|
|
41972
42021
|
}
|
|
41973
|
-
return toApiKeyDto(row, projectNameById(app.db));
|
|
42022
|
+
return toApiKeyDto({ ...row, scopes: request.apiKey?.scopes ?? row.scopes }, projectNameById(app.db));
|
|
41974
42023
|
});
|
|
41975
42024
|
app.post("/keys", async (request) => {
|
|
41976
42025
|
requireAdminSession(request);
|
|
@@ -83162,13 +83211,14 @@ async function technicalAeoRoutes(app, opts) {
|
|
|
83162
83211
|
import crypto49 from "crypto";
|
|
83163
83212
|
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";
|
|
83164
83213
|
var sameLocation = (a, b) => a.label === b.label && a.city === b.city && a.region === b.region && a.country === b.country && a.timezone === b.timezone;
|
|
83214
|
+
var BATCH_RECEIPT_PREFIX = "__canonry_research_batch__:";
|
|
83165
83215
|
async function researchRoutes(app, opts) {
|
|
83166
83216
|
const viewerDailyRunLimit = opts.viewerDailyRunLimit ?? DEFAULT_VIEWER_RESEARCH_DAILY_RUN_LIMIT;
|
|
83167
83217
|
if (!Number.isInteger(viewerDailyRunLimit) || viewerDailyRunLimit <= 0) {
|
|
83168
83218
|
throw new Error("viewerDailyRunLimit must be a positive integer");
|
|
83169
83219
|
}
|
|
83170
83220
|
app.post("/projects/:name/research/runs", {
|
|
83171
|
-
config: { paidRead: true }
|
|
83221
|
+
config: { paidRead: true, writeScope: RESEARCH_RUN_SCOPE }
|
|
83172
83222
|
}, async (request, reply) => {
|
|
83173
83223
|
requireResearchGrant(request, opts.allowViewers ?? false);
|
|
83174
83224
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -83176,6 +83226,7 @@ async function researchRoutes(app, opts) {
|
|
|
83176
83226
|
const parsed = researchRunCreateSchema.safeParse(request.body ?? {});
|
|
83177
83227
|
if (!parsed.success) throw validationError("Invalid research run request", { issues: parsed.error.issues });
|
|
83178
83228
|
const input = parsed.data;
|
|
83229
|
+
if (input.idempotencyKey?.startsWith(BATCH_RECEIPT_PREFIX)) throw validationError("This idempotency key prefix is reserved for internal research batch receipts.");
|
|
83179
83230
|
const adapters = opts.providerAdapters ?? [];
|
|
83180
83231
|
const configured = new Set(opts.configuredProviderNames ?? []);
|
|
83181
83232
|
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;
|
|
@@ -83188,9 +83239,9 @@ async function researchRoutes(app, opts) {
|
|
|
83188
83239
|
const existingReceipt = input.idempotencyKey ? app.db.select().from(researchRuns).where(and55(eq65(researchRuns.projectId, project.id), eq65(researchRuns.idempotencyKey, input.idempotencyKey))).get() : void 0;
|
|
83189
83240
|
const active = activeMeasurementPlan(app.db, project.id);
|
|
83190
83241
|
const scope = input.scope ? existingReceipt ? resolveStoredResearchScope(existingReceipt.scope, input.scope, input.idempotencyKey) : resolveResearchScope(active, input.scope) : null;
|
|
83191
|
-
const template = input.template ? existingReceipt ? resolveStoredResearchTemplate(existingReceipt.template, input.template, input.idempotencyKey) : resolveResearchTemplate(app, project.id, input.template, scope) : null;
|
|
83192
83242
|
const location = input.location === void 0 ? scope ? null : project.defaultLocation ? project.locations.find((item2) => item2.label === project.defaultLocation) ?? null : null : input.location;
|
|
83193
83243
|
if (location && !project.locations.some((item2) => sameLocation(item2, location))) throw validationError("Research location must exactly match a configured project location.", { location });
|
|
83244
|
+
const template = input.template ? existingReceipt ? resolveStoredResearchTemplate(existingReceipt.template, input.template, input.idempotencyKey) : resolveResearchTemplate(app, project.id, input.template, scope, location) : null;
|
|
83194
83245
|
const requestedModel2 = input.model ?? null;
|
|
83195
83246
|
const resolvedModel = requestedModel2 ?? (project.providerModels[providerName] || opts.getEffectiveProviderModels?.()[providerName] || adapter.defaultModel);
|
|
83196
83247
|
adapter.modelValidationPattern.lastIndex = 0;
|
|
@@ -83209,13 +83260,13 @@ async function researchRoutes(app, opts) {
|
|
|
83209
83260
|
return { reused: true, id: existing.id, shouldDispatch: existing.status === ResearchRunStatuses.queued };
|
|
83210
83261
|
}
|
|
83211
83262
|
}
|
|
83212
|
-
if (initiatedBy?.
|
|
83263
|
+
if (initiatedBy?.limited) {
|
|
83213
83264
|
const { start, end, date } = utcDayBounds(now);
|
|
83214
83265
|
const used = tx.select({ value: count3() }).from(researchRuns).where(and55(
|
|
83215
83266
|
eq65(researchRuns.projectId, project.id),
|
|
83216
83267
|
gte17(researchRuns.createdAt, start),
|
|
83217
83268
|
lt11(researchRuns.createdAt, end),
|
|
83218
|
-
sql25`json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer}`
|
|
83269
|
+
sql25`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
|
|
83219
83270
|
)).get()?.value ?? 0;
|
|
83220
83271
|
if (used >= viewerDailyRunLimit) {
|
|
83221
83272
|
throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
|
|
@@ -83232,6 +83283,90 @@ async function researchRoutes(app, opts) {
|
|
|
83232
83283
|
if (decision.reused) return reply.status(200).send(result);
|
|
83233
83284
|
return reply.status(202).send(result);
|
|
83234
83285
|
});
|
|
83286
|
+
app.post("/projects/:name/research/batches", {
|
|
83287
|
+
config: { paidRead: true, writeScope: RESEARCH_RUN_SCOPE }
|
|
83288
|
+
}, async (request, reply) => {
|
|
83289
|
+
requireResearchGrant(request, opts.allowViewers ?? false);
|
|
83290
|
+
const project = resolveProject(app.db, request.params.name);
|
|
83291
|
+
const parsed = researchBatchCreateSchema.safeParse(request.body ?? {});
|
|
83292
|
+
if (!parsed.success) throw validationError("Invalid research batch request", { issues: parsed.error.issues });
|
|
83293
|
+
const input = parsed.data;
|
|
83294
|
+
if (input.idempotencyKey.startsWith(BATCH_RECEIPT_PREFIX)) throw validationError("This idempotency key prefix is reserved for internal research batch receipts.");
|
|
83295
|
+
const requestHash2 = researchBatchRequestHash(input);
|
|
83296
|
+
const receiptKeys = input.runs.map((_run, index2) => batchChildReceiptKey(input.idempotencyKey, index2));
|
|
83297
|
+
const firstReceipt = app.db.select().from(researchRuns).where(and55(
|
|
83298
|
+
eq65(researchRuns.projectId, project.id),
|
|
83299
|
+
eq65(researchRuns.idempotencyKey, receiptKeys[0])
|
|
83300
|
+
)).get();
|
|
83301
|
+
if (firstReceipt) {
|
|
83302
|
+
if (firstReceipt.requestHash !== requestHash2) throw alreadyExists("Research batch idempotency key", input.idempotencyKey);
|
|
83303
|
+
const saved = receiptKeys.map((key) => app.db.select().from(researchRuns).where(and55(
|
|
83304
|
+
eq65(researchRuns.projectId, project.id),
|
|
83305
|
+
eq65(researchRuns.idempotencyKey, key)
|
|
83306
|
+
)).get());
|
|
83307
|
+
if (saved.some((row) => !row || row.requestHash !== requestHash2)) throw alreadyExists("Research batch idempotency key", input.idempotencyKey);
|
|
83308
|
+
const runs3 = saved.map((row) => getDetail(app, project.id, row.id));
|
|
83309
|
+
for (const run of runs3) if (run.status === ResearchRunStatuses.queued) opts.onResearchRunRequested?.(run.id, project.id);
|
|
83310
|
+
return reply.status(200).send({ runs: runs3 });
|
|
83311
|
+
}
|
|
83312
|
+
if (!opts.onResearchRunRequested) throw missingDependency("Research execution is not available on this deployment.", { reason: "no-research-handler" });
|
|
83313
|
+
const active = activeMeasurementPlan(app.db, project.id);
|
|
83314
|
+
const prepared = input.runs.map((run) => prepareBatchRun(app, opts, project, active, run));
|
|
83315
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
83316
|
+
const initiatedBy = researchPrincipal(request);
|
|
83317
|
+
const decision = app.db.transaction((tx) => {
|
|
83318
|
+
if (initiatedBy?.limited) {
|
|
83319
|
+
const { start, end, date } = utcDayBounds(now);
|
|
83320
|
+
const used = tx.select({ value: count3() }).from(researchRuns).where(and55(
|
|
83321
|
+
eq65(researchRuns.projectId, project.id),
|
|
83322
|
+
gte17(researchRuns.createdAt, start),
|
|
83323
|
+
lt11(researchRuns.createdAt, end),
|
|
83324
|
+
sql25`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
|
|
83325
|
+
)).get()?.value ?? 0;
|
|
83326
|
+
if (used + prepared.length > viewerDailyRunLimit) throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
|
|
83327
|
+
}
|
|
83328
|
+
const ids = [];
|
|
83329
|
+
for (const [index2, run] of prepared.entries()) {
|
|
83330
|
+
const id = crypto49.randomUUID();
|
|
83331
|
+
ids.push(id);
|
|
83332
|
+
tx.insert(researchRuns).values({
|
|
83333
|
+
id,
|
|
83334
|
+
projectId: project.id,
|
|
83335
|
+
status: ResearchRunStatuses.queued,
|
|
83336
|
+
provider: run.providerName,
|
|
83337
|
+
requestedModel: run.requestedModel,
|
|
83338
|
+
resolvedModel: run.resolvedModel,
|
|
83339
|
+
location: run.location,
|
|
83340
|
+
totalQueries: run.input.queries.length,
|
|
83341
|
+
scope: run.scope,
|
|
83342
|
+
template: run.template,
|
|
83343
|
+
idempotencyKey: receiptKeys[index2],
|
|
83344
|
+
requestHash: requestHash2,
|
|
83345
|
+
initiatedBy,
|
|
83346
|
+
createdAt: now
|
|
83347
|
+
}).run();
|
|
83348
|
+
for (const [position, query] of run.input.queries.entries()) tx.insert(researchRunQueries).values({
|
|
83349
|
+
id: crypto49.randomUUID(),
|
|
83350
|
+
researchRunId: id,
|
|
83351
|
+
position,
|
|
83352
|
+
queryText: query,
|
|
83353
|
+
queryClass: run.queryClasses[position] ?? null,
|
|
83354
|
+
status: ResearchQueryStatuses.queued,
|
|
83355
|
+
requestedModel: run.requestedModel,
|
|
83356
|
+
resolvedModel: run.resolvedModel,
|
|
83357
|
+
groundingSources: [],
|
|
83358
|
+
citedDomains: [],
|
|
83359
|
+
searchQueries: [],
|
|
83360
|
+
createdAt: now
|
|
83361
|
+
}).run();
|
|
83362
|
+
writeAuditLog(tx, { projectId: project.id, actor: initiatedBy ? `${initiatedBy.kind}:${initiatedBy.id}` : "api", action: "research.created", entityType: "research_run", entityId: id });
|
|
83363
|
+
}
|
|
83364
|
+
return { ids };
|
|
83365
|
+
});
|
|
83366
|
+
const runs2 = decision.ids.map((id) => getDetail(app, project.id, id));
|
|
83367
|
+
for (const id of decision.ids) opts.onResearchRunRequested(id, project.id);
|
|
83368
|
+
return reply.status(202).send({ runs: runs2 });
|
|
83369
|
+
});
|
|
83235
83370
|
app.get("/projects/:name/research/runs", async (request) => {
|
|
83236
83371
|
const project = resolveProject(app.db, request.params.name);
|
|
83237
83372
|
const requested = Number.parseInt(request.query.limit ?? "", 10);
|
|
@@ -83239,10 +83374,10 @@ async function researchRoutes(app, opts) {
|
|
|
83239
83374
|
const runs2 = app.db.select().from(researchRuns).where(eq65(researchRuns.projectId, project.id)).orderBy(desc28(researchRuns.createdAt)).limit(limit).all().map(serializeRun);
|
|
83240
83375
|
const configured = new Set(opts.configuredProviderNames ?? []);
|
|
83241
83376
|
const effectiveModels2 = opts.getEffectiveProviderModels?.() ?? {};
|
|
83242
|
-
const providers =
|
|
83377
|
+
const providers = (opts.providerAdapters ?? []).filter((adapter) => adapter.mode === "api" && !isBrowserProvider(adapter.name) && configured.has(adapter.name)).map((adapter) => {
|
|
83243
83378
|
const defaultModel = project.providerModels[adapter.name] || effectiveModels2[adapter.name] || adapter.defaultModel;
|
|
83244
|
-
const
|
|
83245
|
-
const models =
|
|
83379
|
+
const cached = opts.getCachedProviderModels?.(adapter.name) ?? [];
|
|
83380
|
+
const models = cached.length ? cached : adapter.knownModels;
|
|
83246
83381
|
return {
|
|
83247
83382
|
name: adapter.name,
|
|
83248
83383
|
displayName: adapter.displayName,
|
|
@@ -83250,8 +83385,10 @@ async function researchRoutes(app, opts) {
|
|
|
83250
83385
|
defaultModel,
|
|
83251
83386
|
knownModels: [...new Map([{ id: defaultModel, displayName: defaultModel }, ...models].map((model) => [model.id, { id: model.id, displayName: model.displayName }])).values()]
|
|
83252
83387
|
};
|
|
83253
|
-
})
|
|
83254
|
-
|
|
83388
|
+
});
|
|
83389
|
+
const canRun = canRunResearch(request, opts.allowViewers ?? false);
|
|
83390
|
+
const limited = request.principal !== void 0 && !request.principal.scopes.includes(WILDCARD_SCOPE);
|
|
83391
|
+
return { runs: runs2, providers, access: { canRun, dailyRunLimit: canRun && limited ? viewerDailyRunLimit : null } };
|
|
83255
83392
|
});
|
|
83256
83393
|
app.get("/projects/:name/research/runs/:runId", async (request) => {
|
|
83257
83394
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -83264,6 +83401,44 @@ function resolveStoredResearchScope(stored, selection, idempotencyKey) {
|
|
|
83264
83401
|
}
|
|
83265
83402
|
return stored;
|
|
83266
83403
|
}
|
|
83404
|
+
function batchChildReceiptKey(rootKey, index2) {
|
|
83405
|
+
const derived = crypto49.createHash("sha256").update(`${rootKey}\0${index2}`).digest("hex");
|
|
83406
|
+
return `${BATCH_RECEIPT_PREFIX}${derived}:${index2}`;
|
|
83407
|
+
}
|
|
83408
|
+
function researchBatchRequestHash(input) {
|
|
83409
|
+
return crypto49.createHash("sha256").update(JSON.stringify({ runs: input.runs })).digest("hex");
|
|
83410
|
+
}
|
|
83411
|
+
function prepareBatchRun(app, opts, project, active, input) {
|
|
83412
|
+
const adapters = opts.providerAdapters ?? [];
|
|
83413
|
+
const configured = new Set(opts.configuredProviderNames ?? []);
|
|
83414
|
+
const providerName = input.provider;
|
|
83415
|
+
const adapter = adapters.find((candidate) => candidate.name === providerName);
|
|
83416
|
+
if (!adapter || adapter.mode !== "api" || isBrowserProvider(providerName) || !configured.has(providerName)) {
|
|
83417
|
+
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) });
|
|
83418
|
+
}
|
|
83419
|
+
adapter.modelValidationPattern.lastIndex = 0;
|
|
83420
|
+
if (!adapter.modelValidationPattern.test(input.model) || !adapter.modelConfigurable && input.model !== adapter.defaultModel) {
|
|
83421
|
+
throw validationError(`Invalid model "${input.model}" for provider "${providerName}".`, { provider: providerName, model: input.model, hint: adapter.modelValidationHint });
|
|
83422
|
+
}
|
|
83423
|
+
const location = input.location;
|
|
83424
|
+
if (location && !project.locations.some((item2) => sameLocation(item2, location))) {
|
|
83425
|
+
throw validationError("Research location must exactly match a configured project location.", { location });
|
|
83426
|
+
}
|
|
83427
|
+
if (deduplicateResearchQueries(input.queries).length !== input.queries.length) throw validationError("Research queries must be unique within each batch destination.");
|
|
83428
|
+
const scope = input.scope ? resolveResearchScope(active, input.scope) : null;
|
|
83429
|
+
const template = input.template ? resolveResearchTemplate(app, project.id, input.template, scope, location) : null;
|
|
83430
|
+
return {
|
|
83431
|
+
input,
|
|
83432
|
+
providerName,
|
|
83433
|
+
requestedModel: input.model,
|
|
83434
|
+
// The batch contract requires the model so retries never consult a project or instance default.
|
|
83435
|
+
resolvedModel: input.model,
|
|
83436
|
+
location,
|
|
83437
|
+
scope,
|
|
83438
|
+
template,
|
|
83439
|
+
queryClasses: classifyResearchQueries(project, active?.plan ?? null, input.queries)
|
|
83440
|
+
};
|
|
83441
|
+
}
|
|
83267
83442
|
function resolveResearchScope(active, selection) {
|
|
83268
83443
|
if (!active || active.plan.schemaVersion !== 2) {
|
|
83269
83444
|
throw validationError("Research scope requires an active Advanced Measurement plan.");
|
|
@@ -83284,8 +83459,7 @@ function resolveStoredResearchTemplate(stored, selection, idempotencyKey) {
|
|
|
83284
83459
|
}
|
|
83285
83460
|
return stored;
|
|
83286
83461
|
}
|
|
83287
|
-
function resolveResearchTemplate(app, projectId, selection, scope) {
|
|
83288
|
-
if (!scope) throw validationError("Research templates require a selected market or property scope.");
|
|
83462
|
+
function resolveResearchTemplate(app, projectId, selection, scope, location) {
|
|
83289
83463
|
const savedTemplate = app.db.select().from(measurementQueryTemplates).where(and55(
|
|
83290
83464
|
eq65(measurementQueryTemplates.projectId, projectId),
|
|
83291
83465
|
eq65(measurementQueryTemplates.id, selection.templateId)
|
|
@@ -83295,7 +83469,7 @@ function resolveResearchTemplate(app, projectId, selection, scope) {
|
|
|
83295
83469
|
if (templateVersion !== selection.templateVersion) {
|
|
83296
83470
|
throw validationError("The selected research template is stale. Reload it and try again.");
|
|
83297
83471
|
}
|
|
83298
|
-
const { bindings, output } = expandResearchTemplate(savedTemplate, scope);
|
|
83472
|
+
const { bindings, output } = expandResearchTemplate(savedTemplate, scope, location);
|
|
83299
83473
|
if (!output || output.length > 4e3) throw validationError("Research template output must be between 1 and 4000 characters.");
|
|
83300
83474
|
return { templateId: selection.templateId, templateVersion, template: savedTemplate.pattern, bindings, output };
|
|
83301
83475
|
}
|
|
@@ -83319,11 +83493,13 @@ function serializeQuery(row) {
|
|
|
83319
83493
|
function researchPrincipal(request) {
|
|
83320
83494
|
const principal = request.principal;
|
|
83321
83495
|
if (!principal) return null;
|
|
83496
|
+
const actor = principal.delegatedUser ? { ...principal.delegatedUser, kind: "user" } : principal;
|
|
83322
83497
|
return {
|
|
83323
|
-
kind:
|
|
83324
|
-
id:
|
|
83325
|
-
name:
|
|
83326
|
-
role:
|
|
83498
|
+
kind: actor.kind,
|
|
83499
|
+
id: actor.id,
|
|
83500
|
+
name: actor.name,
|
|
83501
|
+
role: actor.kind === "user" ? actor.role ?? null : null,
|
|
83502
|
+
...!principal.scopes.includes(WILDCARD_SCOPE) ? { limited: true } : {}
|
|
83327
83503
|
};
|
|
83328
83504
|
}
|
|
83329
83505
|
function utcDayBounds(now) {
|
|
@@ -83447,6 +83623,7 @@ async function apiRoutes(app, opts) {
|
|
|
83447
83623
|
});
|
|
83448
83624
|
if (!opts.skipAuth) {
|
|
83449
83625
|
await authPlugin(api, {
|
|
83626
|
+
researchAllowViewers: opts.researchAllowViewers,
|
|
83450
83627
|
// A bearer that is not an api key is tried as an OAuth access token.
|
|
83451
83628
|
// Wired unconditionally: the table is empty until an operator registers
|
|
83452
83629
|
// a client, so an install with no OAuth in use pays one indexed miss.
|
|
@@ -83663,7 +83840,7 @@ async function apiRoutes(app, opts) {
|
|
|
83663
83840
|
embedQueries: opts.embedQueries
|
|
83664
83841
|
});
|
|
83665
83842
|
await api.register(researchRoutes, {
|
|
83666
|
-
|
|
83843
|
+
getCachedProviderModels: opts.getCachedProviderModels,
|
|
83667
83844
|
getEffectiveProviderModels: opts.getEffectiveProviderModels,
|
|
83668
83845
|
providerAdapters: opts.providerAdapters,
|
|
83669
83846
|
configuredProviderNames: opts.providerSummary?.filter((provider) => provider.configured).map((provider) => provider.name),
|