@anthusai/papyrus 1.10.0 → 1.12.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/amplify/data/resource.js +70 -3
- package/amplify/data/resource.ts +75 -3
- package/amplify/data/schema.js +67 -0
- package/amplify/data/schema.ts +72 -0
- package/amplify/functions/category-action/handler.ts +190 -20
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/content-actions/handler.py +23 -0
- 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/functions/shared/slack-events.ts +2 -2
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/globals.css +28 -28
- package/app/layout.tsx +8 -5
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
- package/app/newsroom/[section]/page.tsx +0 -1
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/components/administration-delegations-panel.tsx +204 -0
- package/components/board-card-excerpts.tsx +45 -0
- package/components/curation-delegation-client.ts +81 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +32 -220
- package/components/newsroom-article-editor.tsx +135 -39
- package/components/newsroom-assignment-detail.tsx +35 -1
- package/components/newsroom-board-view.tsx +29 -7
- package/components/newsroom-newsprint-board.css +9 -2
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +25 -7
- package/components/newsroom-organizer.css +218 -4
- package/components/newsroom-references-view.tsx +56 -21
- package/components/priming-proof-page.tsx +17 -8
- 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/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +203 -674
- package/components/ui/sheet.tsx +1 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -3
- package/lib/content-item-lifecycle.ts +4 -2
- 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/front-matter-fields.ts +42 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/local-preview-seed.ts +174 -3
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-board.ts +4 -0
- package/lib/newsroom-forum-routes.ts +2 -43
- package/lib/newsroom-index-filters.ts +3 -40
- package/lib/newsroom-media-backend.ts +5 -0
- package/lib/newsroom-nav.ts +1 -3
- package/lib/newsroom-organizer-nav.ts +5 -12
- package/lib/newsroom-section-routing.ts +1 -1
- package/lib/newsroom-sections.ts +12 -0
- package/lib/papyrus-web-locations.ts +3 -24
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/relevance-decisions.ts +29 -0
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +9 -0
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/wiki.ts +56 -0
- package/package.json +21 -1
- package/routes.manifest.json +23 -7
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { MessageRecord } from "./category-repository";
|
|
2
|
+
import type { RelevanceCuration, RelevanceDecisionView } from "./relevance-decisions";
|
|
3
|
+
import { safeId } from "./relevance-status";
|
|
4
|
+
|
|
5
|
+
export const SHADOW_AGREEMENT_SCHEMA = "cyclotron-shadow-agreement/v1";
|
|
6
|
+
export const MINIMUM_COMPARISONS_FOR_AGREEMENT = 10;
|
|
7
|
+
|
|
8
|
+
/** What the sweep recorded about the loop itself: whether it is on, when it last ran, why it stopped. */
|
|
9
|
+
export type CyclotronLoopState = {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
stoppedBecause: string | null;
|
|
12
|
+
lastSweepAt: string | null;
|
|
13
|
+
maxDecisionsPerDay: number | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The live-agreement payload the sweep writes: suggestions compared with later human decisions. */
|
|
17
|
+
export type ShadowAgreement = {
|
|
18
|
+
schema: typeof SHADOW_AGREEMENT_SCHEMA;
|
|
19
|
+
comparisons: number;
|
|
20
|
+
agreement: number | null;
|
|
21
|
+
precision: number | null;
|
|
22
|
+
recall: number | null;
|
|
23
|
+
confusion: Record<string, unknown> | null;
|
|
24
|
+
computedAt: string | null;
|
|
25
|
+
positiveLabel: string | null;
|
|
26
|
+
loop: CyclotronLoopState | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type AgreementStripMeasurement = {
|
|
30
|
+
comparisons: number;
|
|
31
|
+
agreement: number | null;
|
|
32
|
+
precision: number | null;
|
|
33
|
+
recall: number | null;
|
|
34
|
+
computedAt: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AgreementStripState =
|
|
38
|
+
| { kind: "hidden" }
|
|
39
|
+
| {
|
|
40
|
+
kind: "not_enough" | "measured";
|
|
41
|
+
measurement: AgreementStripMeasurement;
|
|
42
|
+
needed: number;
|
|
43
|
+
loop: CyclotronLoopState | null;
|
|
44
|
+
stopReasonSentence: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function shadowAgreementPayloadId(cyclotronId: string): string {
|
|
48
|
+
return `knowledge-raw-payload-cyclotron-shadow-agreement-${safeId(cyclotronId)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function numberOrNull(value: unknown): number | null {
|
|
52
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseLoop(value: unknown): CyclotronLoopState | null {
|
|
56
|
+
if (!value || typeof value !== "object") return null;
|
|
57
|
+
const loop = value as Record<string, unknown>;
|
|
58
|
+
if (typeof loop.enabled !== "boolean") return null;
|
|
59
|
+
return {
|
|
60
|
+
enabled: loop.enabled,
|
|
61
|
+
stoppedBecause: typeof loop.stoppedBecause === "string" && loop.stoppedBecause ? loop.stoppedBecause : null,
|
|
62
|
+
lastSweepAt: typeof loop.lastSweepAt === "string" ? loop.lastSweepAt : null,
|
|
63
|
+
maxDecisionsPerDay: numberOrNull(loop.maxDecisionsPerDay),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A shadow-agreement payload, or null for any other JSON. */
|
|
68
|
+
export function parseShadowAgreement(json: unknown): ShadowAgreement | null {
|
|
69
|
+
if (!json || typeof json !== "object") return null;
|
|
70
|
+
const record = json as Record<string, unknown>;
|
|
71
|
+
if (record.schema !== SHADOW_AGREEMENT_SCHEMA) return null;
|
|
72
|
+
return {
|
|
73
|
+
schema: SHADOW_AGREEMENT_SCHEMA,
|
|
74
|
+
comparisons: numberOrNull(record.comparisons) ?? 0,
|
|
75
|
+
agreement: numberOrNull(record.agreement),
|
|
76
|
+
precision: numberOrNull(record.precision),
|
|
77
|
+
recall: numberOrNull(record.recall),
|
|
78
|
+
confusion: record.confusion && typeof record.confusion === "object" ? (record.confusion as Record<string, unknown>) : null,
|
|
79
|
+
computedAt: typeof record.computedAt === "string" ? record.computedAt : null,
|
|
80
|
+
positiveLabel: typeof record.positiveLabel === "string" ? record.positiveLabel : null,
|
|
81
|
+
loop: parseLoop(record.loop),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The newest valid payload among a payload owner's attachments. */
|
|
86
|
+
export function latestShadowAgreement(payloads: Array<{ json: unknown; updatedAt?: string | null }>): ShadowAgreement | null {
|
|
87
|
+
const valid = payloads
|
|
88
|
+
.map((payload) => ({ agreement: parseShadowAgreement(payload.json), updatedAt: payload.updatedAt }))
|
|
89
|
+
.filter((entry): entry is { agreement: ShadowAgreement; updatedAt: string | null | undefined } => entry.agreement !== null)
|
|
90
|
+
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
|
|
91
|
+
return valid.length ? valid[0].agreement : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const STOP_REASON_WORDS: Record<string, string> = {
|
|
95
|
+
disabled: "The relevance cyclotron is switched off in the steering config, so it is making no suggestions.",
|
|
96
|
+
"daily decision cap": "The last sweep stopped at the daily decision cap, so some references have no suggestion yet.",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The sentence an editor reads for a stop reason; unknown reasons are shown as given. */
|
|
100
|
+
export function stopReasonSentence(reason: string): string {
|
|
101
|
+
return STOP_REASON_WORDS[reason] ?? `The last sweep stopped early: ${reason}.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What the Live agreement strip shows. No payload hides it; under ten
|
|
106
|
+
* comparisons the figures are withheld; a stop reason is always put in words.
|
|
107
|
+
*/
|
|
108
|
+
export function agreementStripState(agreement: ShadowAgreement | null): AgreementStripState {
|
|
109
|
+
if (!agreement) return { kind: "hidden" };
|
|
110
|
+
const loop = agreement.loop;
|
|
111
|
+
return {
|
|
112
|
+
kind: agreement.comparisons < MINIMUM_COMPARISONS_FOR_AGREEMENT ? "not_enough" : "measured",
|
|
113
|
+
measurement: {
|
|
114
|
+
comparisons: agreement.comparisons,
|
|
115
|
+
agreement: agreement.agreement,
|
|
116
|
+
precision: agreement.precision,
|
|
117
|
+
recall: agreement.recall,
|
|
118
|
+
computedAt: agreement.computedAt,
|
|
119
|
+
},
|
|
120
|
+
needed: MINIMUM_COMPARISONS_FOR_AGREEMENT,
|
|
121
|
+
loop,
|
|
122
|
+
stopReasonSentence: loop?.stoppedBecause ? stopReasonSentence(loop.stoppedBecause) : null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** "Loop on" or "Loop off", the word the strip leads with. */
|
|
127
|
+
export function loopStateWords(loop: CyclotronLoopState): string {
|
|
128
|
+
return loop.enabled ? "Loop on" : "Loop off";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The daily cap in words; no cap is said plainly. */
|
|
132
|
+
export function dailyCapWords(loop: CyclotronLoopState): string {
|
|
133
|
+
return loop.maxDecisionsPerDay === null ? "No daily decision cap" : `Daily cap ${loop.maxDecisionsPerDay} decisions`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatPercentage(fraction: number | null): string {
|
|
137
|
+
return fraction === null ? "not measured" : `${Math.round(fraction * 100)}%`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseMetadata(value: unknown): Record<string, unknown> {
|
|
141
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
142
|
+
if (typeof value !== "string") return {};
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(value);
|
|
145
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
146
|
+
} catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reference lineage ids whose latest curation Message was made by a signed-in
|
|
153
|
+
* person. Agent and classifier status changes never appear here, and neither
|
|
154
|
+
* does a reference a person decided before a later non-human change.
|
|
155
|
+
*/
|
|
156
|
+
export function referencesWithHumanLatestCuration(messages: MessageRecord[]): Set<string> {
|
|
157
|
+
const latestByReference = new Map<string, { createdAt: string; human: boolean }>();
|
|
158
|
+
for (const message of messages) {
|
|
159
|
+
if (message.messageKind !== "reference_curation") continue;
|
|
160
|
+
const metadata = parseMetadata(message.metadata);
|
|
161
|
+
const lineageId = typeof metadata.referenceLineageId === "string" ? metadata.referenceLineageId : "";
|
|
162
|
+
if (!lineageId) continue;
|
|
163
|
+
const seen = latestByReference.get(lineageId);
|
|
164
|
+
if (seen && seen.createdAt >= message.createdAt) continue;
|
|
165
|
+
latestByReference.set(lineageId, { createdAt: message.createdAt, human: metadata.actorKind === "human" });
|
|
166
|
+
}
|
|
167
|
+
return new Set([...latestByReference].filter(([, entry]) => entry.human).map(([lineageId]) => lineageId));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type SuggestionChoice = "agree" | "overrule";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The accept or reject an editor's click becomes. Agreeing does what the
|
|
174
|
+
* suggestion implies; overruling does the opposite. Both are the ordinary
|
|
175
|
+
* curation actions, tied to the suggestion they answered.
|
|
176
|
+
*/
|
|
177
|
+
export function curationForSuggestionChoice(decision: RelevanceDecisionView, choice: SuggestionChoice): RelevanceCuration {
|
|
178
|
+
const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
|
|
179
|
+
const accept = choice === "agree" ? suggestsAccept : !suggestsAccept;
|
|
180
|
+
return {
|
|
181
|
+
action: accept ? "accept" : "reject",
|
|
182
|
+
reasonCode: null,
|
|
183
|
+
note: null,
|
|
184
|
+
decisionRelationId: decision.decisionRelationId,
|
|
185
|
+
shareable: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type ProvenanceSummary = {
|
|
190
|
+
teachingLabelsSentence: string;
|
|
191
|
+
primingExamplesSentence: string;
|
|
192
|
+
doctrineShortHash: string | null;
|
|
193
|
+
modelVersion: string | null;
|
|
194
|
+
decidedAt: string | null;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const SHORT_HASH_LENGTH = 8;
|
|
198
|
+
|
|
199
|
+
/** The facts the "How this suggestion was made" block lists; null when the decision recorded no provenance. */
|
|
200
|
+
export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSummary | null {
|
|
201
|
+
const provenance = decision.provenance;
|
|
202
|
+
if (!provenance) return null;
|
|
203
|
+
const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
204
|
+
return {
|
|
205
|
+
teachingLabelsSentence: `Taught by ${plural(provenance.teachingLabels, "label")} that editors set themselves`,
|
|
206
|
+
primingExamplesSentence: `${plural(provenance.primingExamples, "priming example")} supplied as starting examples`,
|
|
207
|
+
doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
|
|
208
|
+
modelVersion: decision.version === null ? null : `version ${decision.version}`,
|
|
209
|
+
decidedAt: decision.decidedAt,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function suggestionConfidenceWords(decision: RelevanceDecisionView): string {
|
|
214
|
+
return decision.confidence === null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A curation recorded by an agent using a delegation a person granted. */
|
|
218
|
+
export type DelegatedCuration = {
|
|
219
|
+
agent: string;
|
|
220
|
+
person: string | null;
|
|
221
|
+
instruction: string;
|
|
222
|
+
delegationId: string;
|
|
223
|
+
delegationShortId: string;
|
|
224
|
+
/** What the backend does: a delegated curation is stamped human, so the person's label trains the cyclotron. */
|
|
225
|
+
countsAsTrainingLabel: boolean;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const DELEGATION_ID_PREFIX = "curation-delegation-";
|
|
229
|
+
const DELEGATION_SHORT_ID_LENGTH = 8;
|
|
230
|
+
|
|
231
|
+
export function delegationShortId(delegationId: string): string {
|
|
232
|
+
return delegationId.replace(DELEGATION_ID_PREFIX, "").slice(0, DELEGATION_SHORT_ID_LENGTH);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Delegated curations by reference lineage, from each reference's latest
|
|
237
|
+
* curation Message only. The person is the Message author; the agent is relayedBy.
|
|
238
|
+
*/
|
|
239
|
+
export function delegatedCurationsByReference(messages: MessageRecord[]): Map<string, DelegatedCuration> {
|
|
240
|
+
const latest = new Map<string, MessageRecord>();
|
|
241
|
+
for (const message of messages) {
|
|
242
|
+
if (message.messageKind !== "reference_curation") continue;
|
|
243
|
+
const lineageId = parseMetadata(message.metadata).referenceLineageId;
|
|
244
|
+
if (typeof lineageId !== "string" || !lineageId) continue;
|
|
245
|
+
const seen = latest.get(lineageId);
|
|
246
|
+
if (!seen || seen.createdAt < message.createdAt) latest.set(lineageId, message);
|
|
247
|
+
}
|
|
248
|
+
const delegated = new Map<string, DelegatedCuration>();
|
|
249
|
+
for (const [lineageId, message] of latest) {
|
|
250
|
+
const metadata = parseMetadata(message.metadata);
|
|
251
|
+
if (metadata.attestation !== "delegated") continue;
|
|
252
|
+
const delegationId = typeof metadata.delegationId === "string" ? metadata.delegationId : "";
|
|
253
|
+
delegated.set(lineageId, {
|
|
254
|
+
agent: typeof metadata.relayedBy === "string" && metadata.relayedBy ? metadata.relayedBy : "an agent",
|
|
255
|
+
person: message.authorLabel?.trim() || null,
|
|
256
|
+
instruction: typeof metadata.instruction === "string" ? metadata.instruction : "",
|
|
257
|
+
delegationId,
|
|
258
|
+
delegationShortId: delegationShortId(delegationId),
|
|
259
|
+
countsAsTrainingLabel: metadata.actorKind === "human",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return delegated;
|
|
263
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseDocument } from "yaml";
|
|
2
|
+
import type { ContentItemVersionRecord } from "./content-item-lifecycle";
|
|
3
|
+
|
|
4
|
+
/** Front matter keys the editor shows as form fields instead of raw YAML. */
|
|
5
|
+
export const FRONT_MATTER_TITLE_KEY = "title";
|
|
6
|
+
export const FRONT_MATTER_SUBTITLE_KEY = "deck";
|
|
7
|
+
export const FRONT_MATTER_BLURB_KEY = "blurb";
|
|
8
|
+
|
|
9
|
+
/** Reads one text field from front matter YAML; invalid YAML or a non-text value reads as empty. */
|
|
10
|
+
export function readFrontMatterText(frontMatterYaml: string | null | undefined, key: string): string {
|
|
11
|
+
if (!frontMatterYaml?.trim()) return "";
|
|
12
|
+
const document = parseDocument(frontMatterYaml);
|
|
13
|
+
if (document.errors.length) return "";
|
|
14
|
+
const value = document.get(key);
|
|
15
|
+
return typeof value === "string" ? value : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Sets or removes one text field in front matter YAML, keeping every other line and comment as written. */
|
|
19
|
+
export function writeFrontMatterText(frontMatterYaml: string | null | undefined, key: string, value: string): string {
|
|
20
|
+
const document = parseDocument(frontMatterYaml?.trim() ? frontMatterYaml : "{}\n");
|
|
21
|
+
if (document.errors.length) return frontMatterYaml ?? "";
|
|
22
|
+
if (value.trim()) document.set(key, value);
|
|
23
|
+
else document.delete(key);
|
|
24
|
+
const text = document.toString();
|
|
25
|
+
return text.trim() === "{}" ? "" : text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function metadataFrontMatterYaml(metadata: unknown): string {
|
|
29
|
+
const parsed = typeof metadata === "string" ? safeJson(metadata) : metadata;
|
|
30
|
+
const text = (parsed as { frontMatterYaml?: unknown } | null)?.frontMatterYaml;
|
|
31
|
+
return typeof text === "string" ? text : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeJson(text: string): unknown {
|
|
35
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A few lines for a board card: the blurb when one is recorded, else the subtitle. */
|
|
39
|
+
export function contentItemExcerpt(version: Pick<ContentItemVersionRecord, "deck" | "metadata">): string | undefined {
|
|
40
|
+
const blurb = readFrontMatterText(metadataFrontMatterYaml(version.metadata), FRONT_MATTER_BLURB_KEY).trim();
|
|
41
|
+
return blurb || version.deck?.trim() || undefined;
|
|
42
|
+
}
|
|
@@ -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,12 +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";
|
|
4
|
+
import type { MediaAssetRecord } from "./newsroom-media";
|
|
5
|
+
import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailVersion } from "./story-reporting-trail";
|
|
3
6
|
import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
|
|
4
7
|
import { getContentItemStageDefinitions } from "./site-brand";
|
|
8
|
+
import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId } from "./local-preview-tenant";
|
|
5
9
|
|
|
6
10
|
const SEED_TIMESTAMP_BASE = Date.parse("2026-10-01T12:00:00.000Z");
|
|
7
11
|
const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
|
|
8
12
|
|
|
9
|
-
const
|
|
13
|
+
const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO: readonly (readonly string[])[] = [
|
|
10
14
|
["Three sources confirm the zoning reversal", "Reporting packet: school board redistricting"],
|
|
11
15
|
["Draft in progress: the last ferry crossing", "Draft in progress: neighborhood bakery profile"],
|
|
12
16
|
["Illustration brief: flooded streets, aerial view", "Illustration brief: the market at dawn"],
|
|
@@ -16,6 +20,24 @@ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] =
|
|
|
16
20
|
["Spiked: the unconfirmed council rumor", "Spiked: the recycled press release"],
|
|
17
21
|
];
|
|
18
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
|
+
|
|
19
41
|
function isoTimestampHoursBeforeBase(hours: number): string {
|
|
20
42
|
return new Date(SEED_TIMESTAMP_BASE - hours * ONE_HOUR_IN_MILLISECONDS).toISOString();
|
|
21
43
|
}
|
|
@@ -27,7 +49,8 @@ function slugFromHeadline(headline: string): string {
|
|
|
27
49
|
export function createLocalPreviewContentItemVersions(): ContentItemVersionRecord[] {
|
|
28
50
|
const stageKeys = contentItemStageKeys(getContentItemStageDefinitions());
|
|
29
51
|
return stageKeys.flatMap((stageKey, stageIndex) => {
|
|
30
|
-
const
|
|
52
|
+
const tenantHeadlines = headlinesForSelectedTenant();
|
|
53
|
+
const headlines = tenantHeadlines[stageIndex % tenantHeadlines.length];
|
|
31
54
|
return headlines.map((headline, headlineIndex) => {
|
|
32
55
|
const slug = slugFromHeadline(headline);
|
|
33
56
|
return {
|
|
@@ -95,12 +118,17 @@ export function createLocalPreviewAssignments(): AssignmentRecord[] {
|
|
|
95
118
|
return [...assignmentsOnContentItems, ...assignmentsWithoutContentItem];
|
|
96
119
|
}
|
|
97
120
|
|
|
121
|
+
export function createLocalPreviewRealPublishedVersions(): ContentItemVersionRecord[] {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
export function createLocalPreviewEditableArticle(id: string): EditableArticle | null {
|
|
99
126
|
const version = createLocalPreviewContentItemVersions().find(candidate => candidate.id === id);
|
|
100
127
|
if (!version) return null;
|
|
101
128
|
const headline = version.headline || version.title || version.slug;
|
|
102
129
|
return {
|
|
103
130
|
id: version.id,
|
|
131
|
+
lineageId: version.lineageId,
|
|
104
132
|
type: version.type,
|
|
105
133
|
section: version.section ?? null,
|
|
106
134
|
slug: version.slug,
|
|
@@ -113,3 +141,146 @@ export function createLocalPreviewEditableArticle(id: string): EditableArticle |
|
|
|
113
141
|
everPublished: version.status === "published",
|
|
114
142
|
};
|
|
115
143
|
}
|
|
144
|
+
|
|
145
|
+
export function recordLocalPreviewDesk(articleId: string, deskId: string | null): void {
|
|
146
|
+
const version = createLocalPreviewContentItemVersions().find(candidate => candidate.id === articleId);
|
|
147
|
+
if (version) version.section = deskId;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const previewSavedVersions = new Map<string, TrailVersion[]>();
|
|
151
|
+
const previewLineageByVersionId = new Map<string, string>();
|
|
152
|
+
|
|
153
|
+
export function recordLocalPreviewVersion(lineageId: string, version: TrailVersion): void {
|
|
154
|
+
previewSavedVersions.set(lineageId, [version, ...(previewSavedVersions.get(lineageId) ?? [])]);
|
|
155
|
+
previewLineageByVersionId.set(version.id, lineageId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function lineageForPreviewVersion(versionId: string): string | null {
|
|
159
|
+
return previewLineageByVersionId.get(versionId) ?? null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createLocalPreviewReportingTrail(lineageId: string): StoryReportingTrail {
|
|
163
|
+
const saved = previewSavedVersions.get(lineageId) ?? [];
|
|
164
|
+
return { ...EMPTY_STORY_REPORTING_TRAIL, versions: saved };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createLocalPreviewMediaAssets(_itemId: string): MediaAssetRecord[] {
|
|
168
|
+
return [];
|
|
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
|
+
}
|
package/lib/newsroom-articles.ts
CHANGED
|
@@ -59,6 +59,7 @@ export type NewsroomArticleEditorState = {
|
|
|
59
59
|
|
|
60
60
|
export type EditableArticle = {
|
|
61
61
|
id: string;
|
|
62
|
+
lineageId?: string | null;
|
|
62
63
|
type: string;
|
|
63
64
|
section: string | null;
|
|
64
65
|
slug: string;
|
|
@@ -267,6 +268,7 @@ export async function loadEditableArticle(client: ArticlesDataClient, id: string
|
|
|
267
268
|
const status: ArticleStatus = record.status === "published" ? "published" : "draft";
|
|
268
269
|
return {
|
|
269
270
|
id: String(record.id),
|
|
271
|
+
lineageId: typeof record.lineageId === "string" ? record.lineageId : null,
|
|
270
272
|
type: String(record.type),
|
|
271
273
|
section: typeof record.section === "string" ? record.section : null,
|
|
272
274
|
slug: String(record.slug),
|