@anthusai/papyrus 1.3.0 → 1.4.0-next.10
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 +134 -3
- package/amplify/data/resource.ts +145 -3
- package/amplify/data/schema.js +131 -0
- package/amplify/data/schema.ts +142 -0
- package/amplify/functions/category-action/handler.ts +272 -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/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +115 -0
- package/amplify/functions/shared/python-bundle.js +3 -3
- package/amplify/functions/shared/python-bundle.ts +3 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +51 -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/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/page.tsx +3 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
- package/app/api/archive/editions/route.ts +2 -0
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/archive/page.tsx +3 -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 +8 -1
- package/app/newsroom/articles/page.tsx +10 -0
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
- package/app/page.tsx +30 -1
- package/components/administration-delegations-panel.tsx +156 -0
- package/components/curation-delegation-client.ts +104 -0
- package/components/item-stream-view.tsx +63 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +154 -240
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-article-editor.tsx +138 -40
- package/components/newsroom-assignment-detail.tsx +79 -0
- package/components/newsroom-assignments-view.tsx +1 -1
- package/components/newsroom-board-view.tsx +153 -43
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-newsprint-board.css +5 -46
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +36 -13
- package/components/newsroom-organizer.css +269 -13
- package/components/newsroom-references-view.tsx +118 -23
- package/components/newsroom-topics-view.tsx +1 -1
- package/components/priming-proof-page.tsx +17 -8
- package/components/prospectus-account-chooser.tsx +131 -0
- package/components/reference-cyclotron-priming.tsx +85 -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/reference-section-suggestion.tsx +104 -0
- package/components/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +280 -679
- package/components/ui/sheet.tsx +10 -4
- package/components/use-paged-content-item-versions.ts +151 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/infra/amplify-app-shell.js +17 -3
- package/infra/amplify-app-shell.ts +18 -3
- package/infra/site-config.js +6 -1
- package/infra/site-config.ts +7 -1
- package/lib/approved-priming-proof-reports.ts +6 -3
- package/lib/approved-priming-proofs/papyrus.json +90 -0
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/blog-feature-solver.ts +1 -109
- package/lib/category-repository.ts +1 -1
- package/lib/chrome-text.ts +504 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -0
- package/lib/content-item-lifecycle.ts +34 -5
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +69 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/front-matter-fields.ts +42 -0
- package/lib/front-mosaic.ts +1133 -0
- package/lib/furniture.ts +385 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/item-stream.ts +47 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +40 -11
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/layout-warnings.ts +8 -0
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/local-preview-seed.ts +286 -0
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-assignments.ts +14 -1
- package/lib/newsroom-board.ts +99 -47
- package/lib/newsroom-demo-dashboard.ts +1 -1
- 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 +9 -16
- 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/pretext-layout.ts +22 -0
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-account-client.ts +90 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/reader-static-params.ts +2 -1
- package/lib/relevance-decisions.ts +29 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/reporting-story-budget.ts +2 -2
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +28 -1
- package/lib/site-env.ts +5 -0
- package/lib/site-section-suggestions.ts +120 -0
- package/lib/site-stack.ts +1 -1
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/text-columns.ts +251 -0
- package/lib/wiki.ts +56 -0
- package/package.json +46 -3
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/renderers/pretext/rhythm-article-page.tsx +1 -18
- package/routes.manifest.json +34 -15
- package/app/newsroom/work/page.tsx +0 -10
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ContentActionFailure } from "./content-actions-client";
|
|
2
|
+
import { isLocalPreviewEnabled } from "./local-preview";
|
|
2
3
|
import type {
|
|
3
4
|
DeriveMarkusInput,
|
|
4
5
|
DeriveMarkusResult,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
demoContentHash,
|
|
13
14
|
type DemoArticleRecord,
|
|
14
15
|
} from "./newsroom-demo-articles";
|
|
16
|
+
import { createLocalPreviewEditableArticle, lineageForPreviewVersion, recordLocalPreviewDesk, recordLocalPreviewVersion } from "./local-preview-seed";
|
|
15
17
|
import { sourceLineOffset, type ArticlesBackend, type EditableArticle, type NewsroomArticleRow } from "./newsroom-articles";
|
|
16
18
|
|
|
17
19
|
function titleFromYaml(frontMatterYaml: string | null, fallback: string): string {
|
|
@@ -29,6 +31,18 @@ function demoAttribute(attributes: string, name: string): string {
|
|
|
29
31
|
return match ? match[1] : "";
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
async function deriveThroughPreviewMarkusEngine(input: DeriveMarkusInput): Promise<DeriveMarkusResult | null> {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch("/api/local-preview/derive", { method: "POST", body: JSON.stringify({ frontMatterYaml: input.frontMatterYaml, bodyMarkus: input.bodyMarkus }) });
|
|
37
|
+
if (!response.ok) return null;
|
|
38
|
+
const result = await response.json() as DeriveMarkusResult & { error?: string };
|
|
39
|
+
if (result.error) return null;
|
|
40
|
+
return input.includeIr ? result : { ...result, bodyIr: undefined };
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
function demoEnvelope(bodyMarkus: string) {
|
|
33
47
|
const images: Record<string, Record<string, unknown>> = {};
|
|
34
48
|
const children = bodyMarkus
|
|
@@ -73,6 +87,10 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
73
87
|
return {
|
|
74
88
|
actions: {
|
|
75
89
|
async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
|
|
90
|
+
if (isLocalPreviewEnabled()) {
|
|
91
|
+
const derived = await deriveThroughPreviewMarkusEngine(input);
|
|
92
|
+
if (derived) return derived;
|
|
93
|
+
}
|
|
76
94
|
const lines = input.bodyMarkus.split("\n");
|
|
77
95
|
const invalidIndex = lines.findIndex((line) => line.includes(":::nope"));
|
|
78
96
|
if (invalidIndex >= 0) {
|
|
@@ -90,6 +108,28 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
90
108
|
},
|
|
91
109
|
async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
|
|
92
110
|
const existing = input.id ? requireRecord(input.id) : null;
|
|
111
|
+
if (input.newVersion && existing) {
|
|
112
|
+
const lineageId = createLocalPreviewEditableArticle(existing.id)?.lineageId ?? existing.id;
|
|
113
|
+
const versionNumber = (existing.versionNumber ?? 1) + 1;
|
|
114
|
+
const id = `${lineageId}-v${versionNumber}`;
|
|
115
|
+
const frontMatterYaml = input.frontMatterYaml ?? "";
|
|
116
|
+
const record: DemoArticleRecord = {
|
|
117
|
+
...existing,
|
|
118
|
+
id,
|
|
119
|
+
slug: existing.slug,
|
|
120
|
+
section: input.section,
|
|
121
|
+
title: titleFromYaml(input.frontMatterYaml, existing.slug),
|
|
122
|
+
status: "draft",
|
|
123
|
+
frontMatterYaml,
|
|
124
|
+
bodyMarkus: input.bodyMarkus,
|
|
125
|
+
contentHash: demoContentHash(frontMatterYaml, input.bodyMarkus),
|
|
126
|
+
versionNumber,
|
|
127
|
+
updatedAt: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
records.set(id, record);
|
|
130
|
+
recordLocalPreviewVersion(lineageId, { id, versionNumber, versionState: "current", status: "draft", stage: null, headline: record.title, changeReason: input.changeReason ?? null, versionCreatedBy: "You", versionCreatedAt: record.updatedAt, updatedAt: record.updatedAt });
|
|
131
|
+
return { ok: true, item: { id, contentHash: record.contentHash, status: "draft", slug: record.slug, versionNumber } };
|
|
132
|
+
}
|
|
93
133
|
if (existing && input.expectedContentHash !== null && input.expectedContentHash !== existing.contentHash) {
|
|
94
134
|
throw new ContentActionFailure([
|
|
95
135
|
{ code: "conflict", message: "The item changed since it was loaded.", line: null },
|
|
@@ -119,6 +159,7 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
119
159
|
updatedAt: new Date().toISOString(),
|
|
120
160
|
};
|
|
121
161
|
records.set(id, record);
|
|
162
|
+
recordLocalPreviewDesk(id, record.section);
|
|
122
163
|
return {
|
|
123
164
|
ok: true,
|
|
124
165
|
item: {
|
|
@@ -171,9 +212,23 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
171
212
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
172
213
|
},
|
|
173
214
|
async loadArticle(id: string): Promise<EditableArticle> {
|
|
215
|
+
if (!records.has(id)) {
|
|
216
|
+
const previewRecord = createLocalPreviewEditableArticle(id);
|
|
217
|
+
if (previewRecord) {
|
|
218
|
+
records.set(id, {
|
|
219
|
+
...previewRecord,
|
|
220
|
+
title: titleFromYaml(previewRecord.frontMatterYaml, previewRecord.slug),
|
|
221
|
+
status: previewRecord.status === "published" ? "published" : "draft",
|
|
222
|
+
contentHash: demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus),
|
|
223
|
+
publishedContentHash: previewRecord.status === "published" ? demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus) : null,
|
|
224
|
+
updatedAt: new Date().toISOString(),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
174
228
|
const record = requireRecord(id);
|
|
175
229
|
return {
|
|
176
230
|
id: record.id,
|
|
231
|
+
lineageId: createLocalPreviewEditableArticle(record.id)?.lineageId ?? lineageForPreviewVersion(record.id),
|
|
177
232
|
type: record.type,
|
|
178
233
|
section: record.section,
|
|
179
234
|
slug: record.slug,
|
|
@@ -7,17 +7,29 @@
|
|
|
7
7
|
export type ContentItemStageDefinition = {
|
|
8
8
|
key: string;
|
|
9
9
|
label: string;
|
|
10
|
+
/** Closed or rarely visited stage: its board column starts collapsed and its cards load only when it is opened. */
|
|
11
|
+
collapsedUntilOpened?: boolean;
|
|
10
12
|
};
|
|
11
13
|
|
|
14
|
+
export const PUBLISHED_STAGE_KEY = "published";
|
|
15
|
+
|
|
16
|
+
/** Stage keys written before the story stages were simplified, read as the stage that replaced them. */
|
|
17
|
+
export const LEGACY_CONTENT_ITEM_STAGE_ALIASES: Readonly<Record<string, string>> = {
|
|
18
|
+
research: "reporting",
|
|
19
|
+
report: "reporting",
|
|
20
|
+
editor_select: "reporting",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Stages for work that has not become a story yet; they appear on the assignments board only. */
|
|
24
|
+
export const ASSIGNMENT_PHASE_STAGE_KEYS: readonly string[] = ["idea", "assignment"];
|
|
25
|
+
|
|
12
26
|
export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDefinition[] = [
|
|
13
|
-
{ key: "
|
|
14
|
-
{ key: "assignment", label: "Assignment" },
|
|
15
|
-
{ key: "research", label: "Research" },
|
|
16
|
-
{ key: "report", label: "Report" },
|
|
17
|
-
{ key: "editor_select", label: "Editor select" },
|
|
27
|
+
{ key: "reporting", label: "Reporting" },
|
|
18
28
|
{ key: "copywriting", label: "Copywriting" },
|
|
19
29
|
{ key: "illustration", label: "Illustration" },
|
|
20
30
|
{ key: "typesetting", label: "Typesetting" },
|
|
31
|
+
{ key: "spiked", label: "Spiked", collapsedUntilOpened: true },
|
|
32
|
+
{ key: "held", label: "Held" },
|
|
21
33
|
{ key: "published", label: "Published" },
|
|
22
34
|
];
|
|
23
35
|
|
|
@@ -25,6 +37,21 @@ export function contentItemStageKeys(stageDefinitions: readonly ContentItemStage
|
|
|
25
37
|
return stageDefinitions.map(definition => definition.key);
|
|
26
38
|
}
|
|
27
39
|
|
|
40
|
+
export function canonicalContentItemStage(
|
|
41
|
+
stage: string,
|
|
42
|
+
stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
|
|
43
|
+
): string {
|
|
44
|
+
const isDefined = stageDefinitions.some(definition => definition.key === stage);
|
|
45
|
+
return isDefined ? stage : LEGACY_CONTENT_ITEM_STAGE_ALIASES[stage] ?? stage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function legacyStageKeysToLoad(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
|
|
49
|
+
const definedKeys = contentItemStageKeys(stageDefinitions);
|
|
50
|
+
return Object.entries(LEGACY_CONTENT_ITEM_STAGE_ALIASES)
|
|
51
|
+
.filter(([legacyKey, replacementKey]) => !definedKeys.includes(legacyKey) && definedKeys.includes(replacementKey))
|
|
52
|
+
.map(([legacyKey]) => legacyKey);
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
export function contentItemStageLabel(
|
|
29
56
|
stage: string,
|
|
30
57
|
stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
|
|
@@ -41,6 +68,8 @@ export type ContentItemVersionRecord = {
|
|
|
41
68
|
status: string;
|
|
42
69
|
title?: string | null;
|
|
43
70
|
headline?: string | null;
|
|
71
|
+
deck?: string | null;
|
|
72
|
+
metadata?: unknown;
|
|
44
73
|
slug: string;
|
|
45
74
|
section?: string | null;
|
|
46
75
|
updatedAt?: string | null;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { getContentSource, type ContentSource } from "./site-env";
|
|
3
|
+
import { tenantIdFromEnvironment } from "./tenant-keys";
|
|
3
4
|
|
|
4
5
|
export type ContentSourceContext = {
|
|
5
6
|
source: ContentSource;
|
|
6
7
|
/** Request-scoped data client (drafts mode: carries the visitor's session cookies). */
|
|
7
8
|
client?: unknown;
|
|
9
|
+
/** Tenant of the selected Prospectus account; falls back to PAPYRUS_TENANT_ID. */
|
|
10
|
+
tenantId?: string;
|
|
8
11
|
};
|
|
9
12
|
|
|
10
13
|
const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
|
|
@@ -13,8 +16,13 @@ export function runWithContentSource<T>(
|
|
|
13
16
|
source: ContentSource,
|
|
14
17
|
operation: () => Promise<T>,
|
|
15
18
|
client?: unknown,
|
|
19
|
+
tenantId?: string,
|
|
16
20
|
): Promise<T> {
|
|
17
|
-
return contentSourceStorage.run({ source, client }, operation);
|
|
21
|
+
return contentSourceStorage.run({ source, client, tenantId }, operation);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function currentTenantId(): string | undefined {
|
|
25
|
+
return contentSourceStorage.getStore()?.tenantId ?? tenantIdFromEnvironment();
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export function currentRequestClient<T>(): T | undefined {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type CurationDelegationState = "pending" | "approved" | "denied" | "expired" | "revoked";
|
|
2
|
+
|
|
3
|
+
export type CurationDelegationRecord = {
|
|
4
|
+
delegationId: string;
|
|
5
|
+
agentLabel: string;
|
|
6
|
+
grantorLabel: string | null;
|
|
7
|
+
expiresAt: string;
|
|
8
|
+
revokedAt: string | null;
|
|
9
|
+
state?: CurationDelegationState | null;
|
|
10
|
+
/** XXXX-XXXX, present only while the request is pending. */
|
|
11
|
+
userCode?: string | null;
|
|
12
|
+
requestedHours?: number | null;
|
|
13
|
+
pendingExpiresAt?: string | null;
|
|
14
|
+
approvedAt?: string | null;
|
|
15
|
+
deniedAt?: string | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function delegationExpiryText(expiresAt: string, revokedAt: string | null, nowMilliseconds: number): string {
|
|
19
|
+
if (revokedAt) return "Revoked";
|
|
20
|
+
const remaining = Date.parse(expiresAt) - nowMilliseconds;
|
|
21
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return "Expired";
|
|
22
|
+
const minutes = Math.ceil(remaining / 60_000);
|
|
23
|
+
if (minutes < 60) return `Expires in ${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
24
|
+
const hours = Math.round(remaining / 3_600_000);
|
|
25
|
+
return `Expires in ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isDelegationActive(record: CurationDelegationRecord, nowMilliseconds: number): boolean {
|
|
29
|
+
return !record.revokedAt && Date.parse(record.expiresAt) > nowMilliseconds;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function describeDelegationError(error: unknown): string {
|
|
33
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
34
|
+
const graphql = (error as { errors?: Array<{ message?: string }> } | null)?.errors?.[0]?.message ?? "";
|
|
35
|
+
const text = `${raw} ${graphql}`.toLowerCase();
|
|
36
|
+
if (text.includes("not authorized") || text.includes("unauthorized")) return "Your account is not allowed to manage delegations. Only editors and administrators can.";
|
|
37
|
+
if (text.includes("not found")) return "That delegation no longer exists.";
|
|
38
|
+
if (text.includes("only the person who granted")) return "Only the person who granted a delegation, or an administrator, can revoke it.";
|
|
39
|
+
if (text.includes("network") || text.includes("failed to fetch")) return "Could not reach the server. Check your connection and try again.";
|
|
40
|
+
const detail = (graphql || raw).trim();
|
|
41
|
+
return detail ? `The server refused the request: ${detail}` : "Something went wrong. Nothing was changed.";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A pending request that an agent started and nobody has approved, denied or let lapse. */
|
|
45
|
+
export function isPendingDelegation(record: CurationDelegationRecord, nowMilliseconds: number): boolean {
|
|
46
|
+
return record.state === "pending" && Boolean(record.userCode) && Date.parse(record.pendingExpiresAt ?? record.expiresAt) > nowMilliseconds;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** "9:41 left" for a request that is still open, "Expired" once its window has passed. */
|
|
50
|
+
export function pendingCountdownText(pendingExpiresAt: string | null | undefined, nowMilliseconds: number): string {
|
|
51
|
+
const remaining = Date.parse(pendingExpiresAt ?? "") - nowMilliseconds;
|
|
52
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return "Expired";
|
|
53
|
+
const totalSeconds = Math.ceil(remaining / 1000);
|
|
54
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
55
|
+
const seconds = totalSeconds % 60;
|
|
56
|
+
return `${minutes}:${String(seconds).padStart(2, "0")} left`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The state of a decided or lapsed delegation in words an editor reads in the list. */
|
|
60
|
+
export function delegationStateText(record: CurationDelegationRecord, nowMilliseconds: number): string {
|
|
61
|
+
if (record.state === "denied") return "Denied";
|
|
62
|
+
if (record.state === "pending") return pendingCountdownText(record.pendingExpiresAt ?? record.expiresAt, nowMilliseconds) === "Expired" ? "Request expired" : "Waiting for a decision";
|
|
63
|
+
return delegationExpiryText(record.expiresAt, record.revokedAt, nowMilliseconds);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function requestedHoursText(hours: number | null | undefined): string {
|
|
67
|
+
if (!hours || hours <= 0) return "Duration not stated";
|
|
68
|
+
return `Asks for ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|