@anthusai/papyrus 1.4.0-next.5 → 1.4.0-next.7
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/amplify/data/resource.js +67 -0
- package/amplify/data/resource.ts +72 -0
- package/amplify/data/schema.js +67 -0
- package/amplify/data/schema.ts +72 -0
- package/amplify/functions/category-action/handler.ts +172 -2
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation.ts +67 -0
- package/amplify/functions/shared/reference-curation-metadata.ts +20 -0
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/components/administration-delegations-panel.tsx +204 -0
- package/components/board-card-excerpts.tsx +43 -27
- package/components/curation-delegation-client.ts +81 -0
- package/components/news-desk-taxonomy-client.ts +7 -0
- package/components/newsroom-newsprint-board.css +1 -1
- package/components/newsroom-organizer-shell.tsx +4 -1
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +56 -21
- package/components/prospectus-account-chooser.tsx +98 -0
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/topic-steering-workspace.tsx +49 -3
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +59 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/local-preview-seed.ts +141 -3
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/relevance-decisions.ts +29 -0
- package/lib/tenant-keys.ts +32 -0
- package/package.json +11 -1
- package/routes.manifest.json +1 -1
|
@@ -20,7 +20,9 @@ import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type
|
|
|
20
20
|
import { SITE_BRAND } from "./site-brand";
|
|
21
21
|
import { shouldFetchVideoScripts } from "./video-mode";
|
|
22
22
|
import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
|
|
23
|
-
import {
|
|
23
|
+
import { selectedTenantIdFromCookies } from "./prospectus-tenant";
|
|
24
|
+
import { currentContentSource, currentRequestClient, currentTenantId, runWithContentSource } from "./content-source-context";
|
|
25
|
+
import { rowBelongsToTenant, tenantScopedValue } from "./tenant-keys";
|
|
24
26
|
import { withRateLimitRetry } from "./rate-limit-retry";
|
|
25
27
|
import { getContentSource } from "./site-env";
|
|
26
28
|
|
|
@@ -70,6 +72,7 @@ type GraphQLEditionItem = {
|
|
|
70
72
|
|
|
71
73
|
type GraphQLItem = {
|
|
72
74
|
id: string;
|
|
75
|
+
tenantId?: string | null;
|
|
73
76
|
versionNumber?: number | null;
|
|
74
77
|
type: string;
|
|
75
78
|
status: string;
|
|
@@ -152,9 +155,10 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
|
|
|
152
155
|
// Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
|
|
153
156
|
// kept in the request's async context (never a module global) so one visitor's session cannot serve another.
|
|
154
157
|
const { cookies } = await import("next/headers");
|
|
155
|
-
await cookies();
|
|
158
|
+
const cookieStore = await cookies();
|
|
156
159
|
const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
|
|
157
|
-
|
|
160
|
+
const selectedTenantId = await selectedTenantIdFromCookies(cookieStore);
|
|
161
|
+
return runWithContentSource(source, operation, client, selectedTenantId);
|
|
158
162
|
}
|
|
159
163
|
return withRateLimitRetry(() =>
|
|
160
164
|
runWithGuestAmplifyContext(() =>
|
|
@@ -587,6 +591,10 @@ type ItemSourceModel = {
|
|
|
587
591
|
listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
588
592
|
};
|
|
589
593
|
|
|
594
|
+
type TenantSlugSourceModel = {
|
|
595
|
+
publishedItemByTenantSlug: (input: { tenantSlug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
596
|
+
};
|
|
597
|
+
|
|
590
598
|
type MediaAssetSourceModel = {
|
|
591
599
|
listPublishedMediaAssetsByItemAndSortKey: (
|
|
592
600
|
input: { publishedItemId: string },
|
|
@@ -671,20 +679,33 @@ function getPublishedEditionItemModel(): EditionItemSourceModel {
|
|
|
671
679
|
return model as unknown as EditionItemSourceModel;
|
|
672
680
|
}
|
|
673
681
|
|
|
682
|
+
async function getItemWithinTenant(
|
|
683
|
+
tenantId: string | undefined,
|
|
684
|
+
lookup: Promise<GraphQLGetResponse<GraphQLItem>>,
|
|
685
|
+
): Promise<GraphQLGetResponse<GraphQLItem>> {
|
|
686
|
+
const response = await lookup;
|
|
687
|
+
return rowBelongsToTenant(tenantId, response.data) ? response : { ...response, data: null };
|
|
688
|
+
}
|
|
689
|
+
|
|
674
690
|
function getPublishedItemModel(): ItemSourceModel {
|
|
691
|
+
const tenantId = currentTenantId();
|
|
675
692
|
if (currentContentSource() === "drafts") {
|
|
676
693
|
const model = getClient().models.Item;
|
|
677
694
|
if (!model) throw missingProjectionModelError("Item");
|
|
678
695
|
return {
|
|
679
|
-
get: (input, options) =>
|
|
680
|
-
|
|
696
|
+
get: (input, options) =>
|
|
697
|
+
getItemWithinTenant(tenantId, model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>),
|
|
698
|
+
publishedItemBySlug: ({ slug }, options) =>
|
|
699
|
+
(tenantId
|
|
700
|
+
? model.itemByTenantSlug({ tenantSlug: tenantScopedValue(tenantId, slug) }, options)
|
|
701
|
+
: model.itemBySlug({ slug }, options)) as ListResponse<GraphQLItem>,
|
|
681
702
|
listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
|
|
682
703
|
const itemType = typeStatus.split("#")[0];
|
|
683
704
|
return listMergedAcrossStatuses<GraphQLItem>(
|
|
684
705
|
[DRAFT_STATUS, PUBLISHED_STATUS],
|
|
685
706
|
(status, pageOptions) =>
|
|
686
707
|
model.listItemsByTypeStatusAndPublishedAt(
|
|
687
|
-
{ typeStatus: `${itemType}#${status}` },
|
|
708
|
+
{ typeStatus: tenantScopedValue(tenantId, `${itemType}#${status}`) },
|
|
688
709
|
pageOptions,
|
|
689
710
|
) as ListResponse<GraphQLItem>,
|
|
690
711
|
(item) => item.publishedAt ?? "",
|
|
@@ -695,7 +716,21 @@ function getPublishedItemModel(): ItemSourceModel {
|
|
|
695
716
|
}
|
|
696
717
|
const model = getClient().models.PublishedItem;
|
|
697
718
|
if (!model) throw missingProjectionModelError("PublishedItem");
|
|
698
|
-
|
|
719
|
+
const publishedModel = model as unknown as ItemSourceModel;
|
|
720
|
+
if (!tenantId) return publishedModel;
|
|
721
|
+
return {
|
|
722
|
+
get: (input, options) => getItemWithinTenant(tenantId, publishedModel.get(input, options)),
|
|
723
|
+
publishedItemBySlug: ({ slug }, options) =>
|
|
724
|
+
(model as unknown as TenantSlugSourceModel).publishedItemByTenantSlug(
|
|
725
|
+
{ tenantSlug: tenantScopedValue(tenantId, slug) },
|
|
726
|
+
options,
|
|
727
|
+
),
|
|
728
|
+
listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) =>
|
|
729
|
+
publishedModel.listPublishedItemsByTypeStatusAndPublishedAt(
|
|
730
|
+
{ typeStatus: tenantScopedValue(tenantId, typeStatus) },
|
|
731
|
+
options,
|
|
732
|
+
),
|
|
733
|
+
};
|
|
699
734
|
}
|
|
700
735
|
|
|
701
736
|
function getPublishedMediaAssetModel(): MediaAssetSourceModel {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
|
|
1
|
+
import type { AssignmentRecord, MessageRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
|
|
2
|
+
import { SHADOW_AGREEMENT_SCHEMA, type ShadowAgreement } from "./cyclotron-suggestions";
|
|
2
3
|
import type { EditableArticle } from "./newsroom-articles";
|
|
3
4
|
import type { MediaAssetRecord } from "./newsroom-media";
|
|
4
5
|
import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailVersion } from "./story-reporting-trail";
|
|
5
6
|
import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
|
|
6
7
|
import { getContentItemStageDefinitions } from "./site-brand";
|
|
8
|
+
import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId } from "./local-preview-tenant";
|
|
7
9
|
|
|
8
10
|
const SEED_TIMESTAMP_BASE = Date.parse("2026-10-01T12:00:00.000Z");
|
|
9
11
|
const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
|
|
10
12
|
|
|
11
|
-
const
|
|
13
|
+
const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO: readonly (readonly string[])[] = [
|
|
12
14
|
["Three sources confirm the zoning reversal", "Reporting packet: school board redistricting"],
|
|
13
15
|
["Draft in progress: the last ferry crossing", "Draft in progress: neighborhood bakery profile"],
|
|
14
16
|
["Illustration brief: flooded streets, aerial view", "Illustration brief: the market at dawn"],
|
|
@@ -18,6 +20,24 @@ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] =
|
|
|
18
20
|
["Spiked: the unconfirmed council rumor", "Spiked: the recycled press release"],
|
|
19
21
|
];
|
|
20
22
|
|
|
23
|
+
const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ONE: readonly (readonly string[])[] = [
|
|
24
|
+
["Field note: spore dispersal on a damp morning", "Field note: the dung beetle census"],
|
|
25
|
+
["Draft in progress: why fungi throw their caps", "Draft in progress: the compost experiment"],
|
|
26
|
+
["Illustration brief: a cap in flight, close up", "Illustration brief: the mycelium network"],
|
|
27
|
+
["Layout pass: the monthly field guide", "Layout pass: the reader mailbag"],
|
|
28
|
+
["Published: a season of mushroom sightings", "Published: how the forest floor recycles"],
|
|
29
|
+
["Held for spring: the truffle hunt", "Evergreen: a beginner's guide to identification"],
|
|
30
|
+
["Spiked: the unverified foraging claim", "Spiked: the duplicate field report"],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const SEED_CONTENT_ITEM_HEADLINES_BY_TENANT = [SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO, SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ONE] as const;
|
|
34
|
+
|
|
35
|
+
function headlinesForSelectedTenant(): readonly (readonly string[])[] {
|
|
36
|
+
const tenantId = getLocalPreviewTenantId();
|
|
37
|
+
const tenant = LOCAL_PREVIEW_TENANTS.find(candidate => candidate.accountId === tenantId) ?? LOCAL_PREVIEW_TENANTS[0];
|
|
38
|
+
return SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[tenant.fixtureHeadlineSet] ?? SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
function isoTimestampHoursBeforeBase(hours: number): string {
|
|
22
42
|
return new Date(SEED_TIMESTAMP_BASE - hours * ONE_HOUR_IN_MILLISECONDS).toISOString();
|
|
23
43
|
}
|
|
@@ -29,7 +49,8 @@ function slugFromHeadline(headline: string): string {
|
|
|
29
49
|
export function createLocalPreviewContentItemVersions(): ContentItemVersionRecord[] {
|
|
30
50
|
const stageKeys = contentItemStageKeys(getContentItemStageDefinitions());
|
|
31
51
|
return stageKeys.flatMap((stageKey, stageIndex) => {
|
|
32
|
-
const
|
|
52
|
+
const tenantHeadlines = headlinesForSelectedTenant();
|
|
53
|
+
const headlines = tenantHeadlines[stageIndex % tenantHeadlines.length];
|
|
33
54
|
return headlines.map((headline, headlineIndex) => {
|
|
34
55
|
const slug = slugFromHeadline(headline);
|
|
35
56
|
return {
|
|
@@ -146,3 +167,120 @@ export function createLocalPreviewReportingTrail(lineageId: string): StoryReport
|
|
|
146
167
|
export function createLocalPreviewMediaAssets(_itemId: string): MediaAssetRecord[] {
|
|
147
168
|
return [];
|
|
148
169
|
}
|
|
170
|
+
|
|
171
|
+
type PreviewSuggestionPlan = {
|
|
172
|
+
label: "include" | "exclude";
|
|
173
|
+
confidence: number;
|
|
174
|
+
curation: "none" | "human" | "service" | "delegated";
|
|
175
|
+
provenance: boolean;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const PREVIEW_SUGGESTION_PLANS: readonly PreviewSuggestionPlan[] = [
|
|
179
|
+
{ label: "include", confidence: 0.91, curation: "none", provenance: true },
|
|
180
|
+
{ label: "include", confidence: 0.86, curation: "human", provenance: true },
|
|
181
|
+
{ label: "exclude", confidence: 0.72, curation: "service", provenance: true },
|
|
182
|
+
{ label: "exclude", confidence: 0.88, curation: "delegated", provenance: true },
|
|
183
|
+
{ label: "exclude", confidence: 0.64, curation: "none", provenance: false },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const PREVIEW_CYCLOTRON_ID = "papyrus-relevance";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Fixture data for reviewing the cyclotron suggestion UI without a backend:
|
|
190
|
+
* suggestions with provenance on the first references, a mix of human and
|
|
191
|
+
* non-human curation messages, and a live-agreement payload.
|
|
192
|
+
*/
|
|
193
|
+
export function createLocalPreviewCyclotronFixtures(references: ReferenceRecord[]): {
|
|
194
|
+
relations: SemanticRelationRecord[];
|
|
195
|
+
messages: MessageRecord[];
|
|
196
|
+
shadowAgreement: ShadowAgreement;
|
|
197
|
+
} {
|
|
198
|
+
const relations: SemanticRelationRecord[] = [];
|
|
199
|
+
const messages: MessageRecord[] = [];
|
|
200
|
+
references.slice(0, PREVIEW_SUGGESTION_PLANS.length).forEach((reference, index) => {
|
|
201
|
+
const plan = PREVIEW_SUGGESTION_PLANS[index];
|
|
202
|
+
const lineageId = reference.lineageId ?? reference.id;
|
|
203
|
+
const decidedAt = isoTimestampHoursBeforeBase(40 - index);
|
|
204
|
+
relations.push({
|
|
205
|
+
id: `preview-relevance-relation-${index}`,
|
|
206
|
+
relationState: "current",
|
|
207
|
+
predicate: "relevance_decision_is",
|
|
208
|
+
subjectKind: "reference",
|
|
209
|
+
subjectId: reference.id,
|
|
210
|
+
subjectLineageId: lineageId,
|
|
211
|
+
objectKind: "semanticNode",
|
|
212
|
+
objectId: `semantic-node-relevance-${plan.label}-v1`,
|
|
213
|
+
objectLineageId: `semantic-node-relevance-${plan.label}`,
|
|
214
|
+
subjectStateKey: `reference#${lineageId}#current`,
|
|
215
|
+
objectStateKey: `semanticNode#semantic-node-relevance-${plan.label}#current`,
|
|
216
|
+
objectSubjectStateKey: `semanticNode#semantic-node-relevance-${plan.label}#current#reference`,
|
|
217
|
+
predicateObjectStateKey: `relevance_decision_is#semanticNode#semantic-node-relevance-${plan.label}#current`,
|
|
218
|
+
subjectVersionKey: `reference#${reference.id}`,
|
|
219
|
+
objectVersionKey: `semanticNode#semantic-node-relevance-${plan.label}-v1`,
|
|
220
|
+
confidence: plan.confidence,
|
|
221
|
+
modelVersion: "4",
|
|
222
|
+
reviewRecommended: false,
|
|
223
|
+
metadata: JSON.stringify({
|
|
224
|
+
decisionId: `preview-decision-${index}`,
|
|
225
|
+
label: plan.label,
|
|
226
|
+
probabilities: { include: plan.label === "include" ? plan.confidence : 1 - plan.confidence, exclude: plan.label === "exclude" ? plan.confidence : 1 - plan.confidence },
|
|
227
|
+
version: 4,
|
|
228
|
+
decidedAt,
|
|
229
|
+
labels: ["include", "exclude"],
|
|
230
|
+
positiveLabel: "include",
|
|
231
|
+
question: "Should this source be used as evidence for the publication?",
|
|
232
|
+
cyclotronId: PREVIEW_CYCLOTRON_ID,
|
|
233
|
+
...(plan.provenance ? {
|
|
234
|
+
provenance: {
|
|
235
|
+
labelSet: ["include", "exclude"],
|
|
236
|
+
doctrineFingerprint: "3f9c2a71d80b46e5",
|
|
237
|
+
teachingFingerprint: "b71e04c9aa5d2f13",
|
|
238
|
+
teachingLabels: 37,
|
|
239
|
+
primingExamples: 6,
|
|
240
|
+
},
|
|
241
|
+
} : {}),
|
|
242
|
+
}),
|
|
243
|
+
} as SemanticRelationRecord);
|
|
244
|
+
if (plan.curation !== "none") {
|
|
245
|
+
const createdAt = isoTimestampHoursBeforeBase(30 - index);
|
|
246
|
+
messages.push({
|
|
247
|
+
id: `preview-curation-message-${index}`,
|
|
248
|
+
messageKind: "reference_curation",
|
|
249
|
+
messageDomain: "commentary",
|
|
250
|
+
status: "active",
|
|
251
|
+
summary: `${reference.title ?? lineageId}: curated`,
|
|
252
|
+
authorLabel: plan.curation === "delegated" ? "Maya Okafor" : null,
|
|
253
|
+
source: "newsroom",
|
|
254
|
+
createdAt,
|
|
255
|
+
updatedAt: createdAt,
|
|
256
|
+
metadata: JSON.stringify({
|
|
257
|
+
action: reference.curationStatus === "rejected" ? "reject" : "accept",
|
|
258
|
+
curationStatus: reference.curationStatus,
|
|
259
|
+
referenceLineageId: lineageId,
|
|
260
|
+
actorKind: plan.curation === "service" ? "service" : "human",
|
|
261
|
+
...(plan.curation === "delegated" ? {
|
|
262
|
+
attestation: "delegated",
|
|
263
|
+
relayedBy: "newsroom-reporter",
|
|
264
|
+
delegationId: "curation-delegation-9b1f6c24-5d0e-4a7b-8c31-2e6f0a1d7b45",
|
|
265
|
+
instruction: "Reject that one, it duplicates the filing from Tuesday.",
|
|
266
|
+
} : {}),
|
|
267
|
+
}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
relations,
|
|
273
|
+
messages,
|
|
274
|
+
shadowAgreement: {
|
|
275
|
+
schema: SHADOW_AGREEMENT_SCHEMA,
|
|
276
|
+
comparisons: 23,
|
|
277
|
+
agreement: 0.87,
|
|
278
|
+
precision: 0.9,
|
|
279
|
+
recall: 0.82,
|
|
280
|
+
confusion: { truePositive: 9, falsePositive: 1, falseNegative: 2, trueNegative: 11 },
|
|
281
|
+
computedAt: isoTimestampHoursBeforeBase(3),
|
|
282
|
+
positiveLabel: "include",
|
|
283
|
+
loop: { enabled: true, stoppedBecause: "daily decision cap", lastSweepAt: isoTimestampHoursBeforeBase(3), maxDecisionsPerDay: 50 },
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local preview stands in for the multi-tenant newsroom: choosing a publishing account in the account menu
|
|
3
|
+
* switches which publication's content the preview shows. The choice is a cookie so server and browser agree.
|
|
4
|
+
*/
|
|
5
|
+
export const LOCAL_PREVIEW_TENANT_COOKIE = "papyrus-preview-tenant";
|
|
6
|
+
export const LOCAL_PREVIEW_DEFAULT_TENANT_ID = "preview-papyrus";
|
|
7
|
+
|
|
8
|
+
export type LocalPreviewTenant = { accountId: string; name: string; role: string; fixtureHeadlineSet: number };
|
|
9
|
+
|
|
10
|
+
export const LOCAL_PREVIEW_TENANTS: readonly LocalPreviewTenant[] = [
|
|
11
|
+
{ accountId: LOCAL_PREVIEW_DEFAULT_TENANT_ID, name: "Papyrus", role: "publisher", fixtureHeadlineSet: 0 },
|
|
12
|
+
{ accountId: "preview-pilobolus", name: "Pilobolus", role: "editor", fixtureHeadlineSet: 1 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function previewTenantIdFromCookieHeader(cookieHeader: string | null | undefined): string {
|
|
16
|
+
const match = cookieHeader?.split(";").map(part => part.trim()).find(part => part.startsWith(`${LOCAL_PREVIEW_TENANT_COOKIE}=`));
|
|
17
|
+
const value = match ? decodeURIComponent(match.slice(LOCAL_PREVIEW_TENANT_COOKIE.length + 1)) : "";
|
|
18
|
+
return LOCAL_PREVIEW_TENANTS.some(tenant => tenant.accountId === value) ? value : LOCAL_PREVIEW_DEFAULT_TENANT_ID;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getLocalPreviewTenantId(): string {
|
|
22
|
+
return typeof document === "undefined" ? LOCAL_PREVIEW_DEFAULT_TENANT_ID : previewTenantIdFromCookieHeader(document.cookie);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setLocalPreviewTenantId(accountId: string): void {
|
|
26
|
+
document.cookie = `${LOCAL_PREVIEW_TENANT_COOKIE}=${encodeURIComponent(accountId)}; path=/; max-age=31536000; samesite=lax`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROSPECTUS_SESSION_COOKIE,
|
|
3
|
+
loadProspectusSessionSealingSecret,
|
|
4
|
+
openProspectusCookie,
|
|
5
|
+
type ProspectusSignedInSession,
|
|
6
|
+
} from "./prospectus-session";
|
|
7
|
+
import { tenantIdForProspectusAccount } from "./tenant-keys";
|
|
8
|
+
|
|
9
|
+
type CookieReader = { get(name: string): { value: string } | undefined };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The tenant of the publishing account the signed-in person selected, read from the sealed Prospectus session
|
|
13
|
+
* cookie. Returns undefined on a site without Prospectus, with no session, or with no account selected, so the
|
|
14
|
+
* caller falls back to the site's own tenant (PAPYRUS_TENANT_ID).
|
|
15
|
+
*/
|
|
16
|
+
export async function selectedTenantIdFromCookies(cookies: CookieReader, loadSecret: () => Promise<string | null> = loadProspectusSessionSealingSecret): Promise<string | undefined> {
|
|
17
|
+
const sealingSecret = await loadSecret();
|
|
18
|
+
if (!sealingSecret) return undefined;
|
|
19
|
+
const session = openProspectusCookie<ProspectusSignedInSession>(sealingSecret, PROSPECTUS_SESSION_COOKIE, cookies.get(PROSPECTUS_SESSION_COOKIE)?.value);
|
|
20
|
+
if (!session || session.accessTokenExpiresAt * 1000 <= Date.now()) return undefined;
|
|
21
|
+
return tenantIdForProspectusAccount(session.selectedAccountId);
|
|
22
|
+
}
|
|
@@ -2,6 +2,15 @@ import type { SemanticRelationRecord } from "./category-repository";
|
|
|
2
2
|
|
|
3
3
|
export const RELEVANCE_RELATION = "relevance_decision_is";
|
|
4
4
|
|
|
5
|
+
/** What produced a suggestion beyond the model version, as the sweep recorded it. */
|
|
6
|
+
export type RelevanceProvenance = {
|
|
7
|
+
labelSet: string[];
|
|
8
|
+
doctrineFingerprint: string | null;
|
|
9
|
+
teachingFingerprint: string | null;
|
|
10
|
+
teachingLabels: number;
|
|
11
|
+
primingExamples: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
5
14
|
/** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
|
|
6
15
|
export type RelevanceDecisionView = {
|
|
7
16
|
decisionRelationId: string;
|
|
@@ -19,6 +28,10 @@ export type RelevanceDecisionView = {
|
|
|
19
28
|
/** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
|
|
20
29
|
reviewControl: "thumbs" | "labels";
|
|
21
30
|
cyclotronId: string | null;
|
|
31
|
+
/** When the cyclotron made the suggestion, if the decision recorded it. */
|
|
32
|
+
decidedAt: string | null;
|
|
33
|
+
/** Null for decisions made before provenance was recorded. */
|
|
34
|
+
provenance: RelevanceProvenance | null;
|
|
22
35
|
};
|
|
23
36
|
|
|
24
37
|
/** What an editor submits from the review control. */
|
|
@@ -49,6 +62,20 @@ function parseMetadata(value: unknown): Record<string, unknown> {
|
|
|
49
62
|
}
|
|
50
63
|
}
|
|
51
64
|
|
|
65
|
+
function parseProvenance(value: unknown): RelevanceProvenance | null {
|
|
66
|
+
const record = parseMetadata(value);
|
|
67
|
+
if (!Object.keys(record).length) return null;
|
|
68
|
+
const count = (entry: unknown) => (Number.isFinite(Number(entry)) ? Number(entry) : 0);
|
|
69
|
+
const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
|
|
70
|
+
return {
|
|
71
|
+
labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
|
|
72
|
+
doctrineFingerprint: text(record.doctrineFingerprint),
|
|
73
|
+
teachingFingerprint: text(record.teachingFingerprint),
|
|
74
|
+
teachingLabels: count(record.teachingLabels),
|
|
75
|
+
primingExamples: count(record.primingExamples),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
/**
|
|
53
80
|
* Current decisions keyed by reference lineage, from relations the Newsroom
|
|
54
81
|
* already loads: no extra request per row.
|
|
@@ -77,6 +104,8 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
|
|
|
77
104
|
question: typeof metadata.question === "string" ? metadata.question : null,
|
|
78
105
|
reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
|
|
79
106
|
cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
|
|
107
|
+
decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
|
|
108
|
+
provenance: parseProvenance(metadata.provenance),
|
|
80
109
|
});
|
|
81
110
|
}
|
|
82
111
|
return decisions;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const TENANT_ID_ENVIRONMENT_VARIABLE = "PAPYRUS_TENANT_ID";
|
|
2
|
+
const TENANT_KEY_SEPARATOR = "#";
|
|
3
|
+
const TENANT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
4
|
+
|
|
5
|
+
export function validateTenantId(tenantId: string): string {
|
|
6
|
+
if (!TENANT_ID_PATTERN.test(tenantId)) {
|
|
7
|
+
throw new Error(`Tenant id "${tenantId}" must be lowercase letters, digits and hyphens, starting with a letter or digit.`);
|
|
8
|
+
}
|
|
9
|
+
return tenantId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function tenantIdFromEnvironment(environment: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
13
|
+
const configured = environment[TENANT_ID_ENVIRONMENT_VARIABLE]?.trim();
|
|
14
|
+
return configured ? validateTenantId(configured) : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function tenantScopedValue(tenantId: string | undefined, value: string): string {
|
|
18
|
+
if (!tenantId) return value;
|
|
19
|
+
const prefix = `${tenantId}${TENANT_KEY_SEPARATOR}`;
|
|
20
|
+
return value.startsWith(prefix) ? value : `${prefix}${value}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function rowBelongsToTenant(tenantId: string | undefined, row: { tenantId?: string | null } | null | undefined): boolean {
|
|
24
|
+
if (!tenantId || !row) return true;
|
|
25
|
+
return row.tenantId === tenantId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The tenant a Prospectus publishing account maps to: its id as lowercase letters, digits and hyphens. */
|
|
29
|
+
export function tenantIdForProspectusAccount(accountId: string | null | undefined): string | undefined {
|
|
30
|
+
const normalized = (accountId ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
31
|
+
return normalized ? validateTenantId(normalized) : undefined;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"types": "./infra/amplify-app-shell.ts",
|
|
65
65
|
"default": "./infra/index.js"
|
|
66
66
|
},
|
|
67
|
+
"./components/administration-delegations-panel": "./components/administration-delegations-panel.tsx",
|
|
67
68
|
"./components/ai-elements/conversation": "./components/ai-elements/conversation.tsx",
|
|
68
69
|
"./components/ai-elements/model-selector": "./components/ai-elements/model-selector.tsx",
|
|
69
70
|
"./components/ai-elements/prompt-input": "./components/ai-elements/prompt-input.tsx",
|
|
@@ -76,6 +77,7 @@
|
|
|
76
77
|
"./components/article-video": "./components/article-video.tsx",
|
|
77
78
|
"./components/blog-page-background": "./components/blog-page-background.tsx",
|
|
78
79
|
"./components/board-card-excerpts": "./components/board-card-excerpts.tsx",
|
|
80
|
+
"./components/curation-delegation-client": "./components/curation-delegation-client.ts",
|
|
79
81
|
"./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
|
|
80
82
|
"./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
|
|
81
83
|
"./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
|
|
@@ -101,6 +103,7 @@
|
|
|
101
103
|
"./components/presentation-header": "./components/presentation-header.tsx",
|
|
102
104
|
"./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
|
|
103
105
|
"./components/priming-proof-page": "./components/priming-proof-page.tsx",
|
|
106
|
+
"./components/prospectus-account-chooser": "./components/prospectus-account-chooser.tsx",
|
|
104
107
|
"./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
|
|
105
108
|
"./components/reader-auth-control": "./components/reader-auth-control.tsx",
|
|
106
109
|
"./components/reader-auth-state": "./components/reader-auth-state.ts",
|
|
@@ -109,6 +112,8 @@
|
|
|
109
112
|
"./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
|
|
110
113
|
"./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
|
|
111
114
|
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
115
|
+
"./components/reference-cyclotron-suggestion": "./components/reference-cyclotron-suggestion.tsx",
|
|
116
|
+
"./components/reference-live-agreement": "./components/reference-live-agreement.tsx",
|
|
112
117
|
"./components/reference-priming": "./components/reference-priming.tsx",
|
|
113
118
|
"./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
|
|
114
119
|
"./components/reference-source-preview": "./components/reference-source-preview.tsx",
|
|
@@ -171,6 +176,8 @@
|
|
|
171
176
|
"./lib/content-source-context": "./lib/content-source-context.ts",
|
|
172
177
|
"./lib/content-types": "./lib/content-types.ts",
|
|
173
178
|
"./lib/curation-client": "./lib/curation-client.ts",
|
|
179
|
+
"./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
|
|
180
|
+
"./lib/cyclotron-suggestions": "./lib/cyclotron-suggestions.ts",
|
|
174
181
|
"./lib/define-site": "./lib/define-site.ts",
|
|
175
182
|
"./lib/demo-amplify-outputs": "./lib/demo-amplify-outputs.ts",
|
|
176
183
|
"./lib/dev-sandbox-editor-auth": "./lib/dev-sandbox-editor-auth.ts",
|
|
@@ -191,6 +198,7 @@
|
|
|
191
198
|
"./lib/layout-types": "./lib/layout-types.ts",
|
|
192
199
|
"./lib/layout-warnings": "./lib/layout-warnings.ts",
|
|
193
200
|
"./lib/local-preview-seed": "./lib/local-preview-seed.ts",
|
|
201
|
+
"./lib/local-preview-tenant": "./lib/local-preview-tenant.ts",
|
|
194
202
|
"./lib/local-preview": "./lib/local-preview.ts",
|
|
195
203
|
"./lib/markus-body": "./lib/markus-body.ts",
|
|
196
204
|
"./lib/markus-ir": "./lib/markus-ir.ts",
|
|
@@ -233,6 +241,7 @@
|
|
|
233
241
|
"./lib/prospectus-session": "./lib/prospectus-session.ts",
|
|
234
242
|
"./lib/prospectus-sign-in-routes": "./lib/prospectus-sign-in-routes.ts",
|
|
235
243
|
"./lib/prospectus-site-identity": "./lib/prospectus-site-identity.ts",
|
|
244
|
+
"./lib/prospectus-tenant": "./lib/prospectus-tenant.ts",
|
|
236
245
|
"./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
|
|
237
246
|
"./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
|
|
238
247
|
"./lib/publication-items": "./lib/publication-items.ts",
|
|
@@ -266,6 +275,7 @@
|
|
|
266
275
|
"./lib/staging-gated-path": "./lib/staging-gated-path.ts",
|
|
267
276
|
"./lib/staging-preview-object": "./lib/staging-preview-object.ts",
|
|
268
277
|
"./lib/story-reporting-trail": "./lib/story-reporting-trail.ts",
|
|
278
|
+
"./lib/tenant-keys": "./lib/tenant-keys.ts",
|
|
269
279
|
"./lib/test-editor-override": "./lib/test-editor-override.ts",
|
|
270
280
|
"./lib/text-columns": "./lib/text-columns.ts",
|
|
271
281
|
"./lib/themed-image": "./lib/themed-image.ts",
|
|
@@ -364,7 +374,7 @@
|
|
|
364
374
|
"optional": true
|
|
365
375
|
}
|
|
366
376
|
},
|
|
367
|
-
"version": "1.4.0-next.
|
|
377
|
+
"version": "1.4.0-next.7",
|
|
368
378
|
"dependencies": {
|
|
369
379
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
370
380
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED