@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110
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/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +152 -62
- package/amplify/data/resource.ts +158 -62
- package/amplify/data/schema.js +2101 -1987
- package/amplify/data/schema.ts +197 -70
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +64 -3
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +2 -2
- package/amplify/functions/editorial-checks/requirements.txt +1 -1
- package/amplify/functions/editorial-checks/resource.js +3 -3
- package/amplify/functions/editorial-checks/resource.ts +3 -3
- package/amplify/functions/editorial-checks/schema.js +38 -8
- package/amplify/functions/editorial-checks/schema.ts +38 -8
- package/amplify/functions/editorial-checks/wire.js +4 -0
- package/amplify/functions/editorial-checks/wire.ts +4 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +29 -0
- package/amplify/functions/monitoring-view/schema.ts +31 -0
- package/amplify/functions/monitoring-view/wire.js +64 -0
- package/amplify/functions/monitoring-view/wire.ts +81 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1589 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +487 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
- package/amplify/plugins/reader-steering/schema.js +252 -0
- package/amplify/plugins/reader-steering/schema.ts +254 -0
- package/amplify/plugins/registry.js +4 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.js +19 -0
- package/amplify/site-backend-config.ts +45 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +42 -4
- package/amplify/site-backend.ts +45 -4
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/app/layout.tsx +5 -1
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +195 -0
- package/components/editorial-checks-admin-panel.tsx +32 -17
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +422 -0
- package/components/monitoring-panel.tsx +60 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +16 -2
- package/components/newsroom-article-editor.tsx +9 -7
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +26 -96
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +281 -0
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +230 -144
- package/infra/amplify-app-shell.js +96 -16
- package/infra/amplify-app-shell.ts +108 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +92 -6
- package/infra/site-config.ts +138 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +252 -0
- package/lib/category-repository.ts +14 -12
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/console-web-context.ts +1 -1
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-client.ts +18 -0
- package/lib/editorial-checks-demo.ts +1 -1
- package/lib/editorial-checks.ts +8 -4
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +83 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +191 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +30 -14
- package/lib/prospectus-sign-in-routes.ts +106 -31
- package/lib/prospectus-site-identity.ts +46 -6
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +120 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +246 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +8 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +67 -3
- package/routes.manifest.json +238 -2
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +17 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { HydratedModelPayload, MessageRecord, SemanticRelationRecord } from "./category-repository";
|
|
2
|
+
import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
|
|
3
|
+
|
|
4
|
+
function metadataRecord(value: unknown): Record<string, unknown> {
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
try { return metadataRecord(JSON.parse(value)); } catch { return {}; }
|
|
7
|
+
}
|
|
8
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The selected Reference's index is authoritative even when the global feed is truncated. */
|
|
12
|
+
export async function loadReferenceReviewDetails(lineageId: string, read: {
|
|
13
|
+
relations: (direction: "incoming" | "outgoing") => Promise<SemanticRelationRecord[]>;
|
|
14
|
+
message: (id: string) => Promise<MessageRecord | null>;
|
|
15
|
+
payloads: (id: string) => Promise<HydratedModelPayload[]>;
|
|
16
|
+
}): Promise<{ relations: SemanticRelationRecord[]; messages: MessageRecord[] }> {
|
|
17
|
+
const [outgoing, incoming] = await Promise.all([read.relations("outgoing"), read.relations("incoming")]);
|
|
18
|
+
const relations = outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId);
|
|
19
|
+
const latest = incoming.filter((relation) => relation.predicate === "comment" && relation.relationState === "current"
|
|
20
|
+
&& relation.subjectKind === "message" && relation.objectKind === "reference" && relation.objectLineageId === lineageId
|
|
21
|
+
&& metadataRecord(relation.metadata).messageKind === "reference_curation")
|
|
22
|
+
.sort((a, b) => String(b.createdAt ?? b.importedAt ?? "").localeCompare(String(a.createdAt ?? a.importedAt ?? "")))[0];
|
|
23
|
+
if (!latest) return { relations, messages: [] };
|
|
24
|
+
const message = await read.message(latest.subjectId);
|
|
25
|
+
if (!message || message.messageKind !== "reference_curation") throw new Error("The latest reference review could not be loaded.");
|
|
26
|
+
const payload = (await read.payloads(message.id))
|
|
27
|
+
.filter((entry) => entry.attachment.role === "metadata" && entry.attachment.ownerKind === "message" && entry.attachment.ownerId === message.id)
|
|
28
|
+
.sort((a, b) => String(b.attachment.updatedAt ?? "").localeCompare(String(a.attachment.updatedAt ?? "")))[0];
|
|
29
|
+
if (!payload) throw new Error("The latest reference review metadata could not be loaded.");
|
|
30
|
+
if (payload.error) throw new Error(payload.error);
|
|
31
|
+
if (!payload.json || typeof payload.json !== "object" || Array.isArray(payload.json)) {
|
|
32
|
+
throw new Error("The latest reference review metadata is invalid.");
|
|
33
|
+
}
|
|
34
|
+
const hydrated = { ...message, metadata: payload.json };
|
|
35
|
+
const recordedLineage = metadataRecord(hydrated.metadata).referenceLineageId;
|
|
36
|
+
if (recordedLineage !== lineageId) throw new Error("The latest review metadata does not match this reference lineage.");
|
|
37
|
+
return { relations, messages: [hydrated] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function latestReferenceReviews(messages: MessageRecord[]): Map<string, MessageRecord> {
|
|
41
|
+
const latest = new Map<string, MessageRecord>();
|
|
42
|
+
for (const message of messages) {
|
|
43
|
+
if (message.messageKind !== "reference_curation") continue;
|
|
44
|
+
const lineage = metadataRecord(message.metadata).referenceLineageId;
|
|
45
|
+
if (typeof lineage !== "string" || !lineage) continue;
|
|
46
|
+
if (!latest.has(lineage) || String(message.createdAt).localeCompare(String(latest.get(lineage)!.createdAt)) > 0) latest.set(lineage, message);
|
|
47
|
+
}
|
|
48
|
+
return latest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A later acceptance or archive clears any earlier rejection reason. */
|
|
52
|
+
export function latestReferenceReviewReasons(messages: MessageRecord[]): Map<string, ReferenceRejectionReasonCode> {
|
|
53
|
+
const reasons = new Map<string, ReferenceRejectionReasonCode>();
|
|
54
|
+
for (const [lineage, message] of latestReferenceReviews(messages)) {
|
|
55
|
+
const metadata = metadataRecord(message.metadata);
|
|
56
|
+
const reason = metadata.action === "reject" ? normalizeReferenceRejectionReasonCode(metadata.reasonCode) : null;
|
|
57
|
+
if (reason) reasons.set(lineage, reason);
|
|
58
|
+
}
|
|
59
|
+
return reasons;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Preserve only a Section explicitly recorded on the newest acceptance, never a prediction. */
|
|
63
|
+
export function latestReferenceReviewSections(messages: MessageRecord[]): Map<string, string> {
|
|
64
|
+
const sections = new Map<string, string>();
|
|
65
|
+
for (const [lineage, message] of latestReferenceReviews(messages)) {
|
|
66
|
+
const metadata = metadataRecord(message.metadata);
|
|
67
|
+
const sectionId = metadata.action === "accept" && typeof metadata.sectionId === "string" ? metadata.sectionId.trim() : "";
|
|
68
|
+
if (sectionId) sections.set(lineage, sectionId);
|
|
69
|
+
}
|
|
70
|
+
return sections;
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Hold the lock before invoking a save, including before React can rerender. */
|
|
2
|
+
export function runReferenceReviewRequest(
|
|
3
|
+
pending: Map<string, Promise<void>>,
|
|
4
|
+
lineageId: string,
|
|
5
|
+
save: () => Promise<void>,
|
|
6
|
+
): Promise<void> {
|
|
7
|
+
const existing = pending.get(lineageId);
|
|
8
|
+
if (existing) return existing;
|
|
9
|
+
const request = Promise.resolve().then(save).finally(() => {
|
|
10
|
+
if (pending.get(lineageId) === request) pending.delete(lineageId);
|
|
11
|
+
});
|
|
12
|
+
pending.set(lineageId, request);
|
|
13
|
+
return request;
|
|
14
|
+
}
|
|
@@ -7,8 +7,12 @@ export type RelevanceProvenance = {
|
|
|
7
7
|
labelSet: string[];
|
|
8
8
|
doctrineFingerprint: string | null;
|
|
9
9
|
teachingFingerprint: string | null;
|
|
10
|
-
teachingLabels: number;
|
|
11
|
-
primingExamples: number;
|
|
10
|
+
teachingLabels: number | null;
|
|
11
|
+
primingExamples: number | null;
|
|
12
|
+
/** Recorded by the shared monitoring router, absent on local sweep provenance. */
|
|
13
|
+
system?: string;
|
|
14
|
+
publicationId?: string;
|
|
15
|
+
output?: string;
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
/** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
|
|
@@ -28,6 +32,7 @@ export type RelevanceDecisionView = {
|
|
|
28
32
|
/** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
|
|
29
33
|
reviewControl: "thumbs" | "labels";
|
|
30
34
|
cyclotronId: string | null;
|
|
35
|
+
classifierId?: string;
|
|
31
36
|
/** When the cyclotron made the suggestion, if the decision recorded it. */
|
|
32
37
|
decidedAt: string | null;
|
|
33
38
|
/** Null for decisions made before provenance was recorded. */
|
|
@@ -44,6 +49,7 @@ export type RelevanceReview = {
|
|
|
44
49
|
|
|
45
50
|
/** The existing curation action an editor's review becomes. */
|
|
46
51
|
export type RelevanceCuration = {
|
|
52
|
+
sectionId?: string | null;
|
|
47
53
|
action: "accept" | "reject";
|
|
48
54
|
reasonCode: string | null;
|
|
49
55
|
note: string | null;
|
|
@@ -65,7 +71,7 @@ function parseMetadata(value: unknown): Record<string, unknown> {
|
|
|
65
71
|
function parseProvenance(value: unknown): RelevanceProvenance | null {
|
|
66
72
|
const record = parseMetadata(value);
|
|
67
73
|
if (!Object.keys(record).length) return null;
|
|
68
|
-
const count = (entry: unknown) =>
|
|
74
|
+
const count = (entry: unknown) => typeof entry === "number" && Number.isInteger(entry) && entry >= 0 ? entry : null;
|
|
69
75
|
const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
|
|
70
76
|
return {
|
|
71
77
|
labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
|
|
@@ -73,27 +79,52 @@ function parseProvenance(value: unknown): RelevanceProvenance | null {
|
|
|
73
79
|
teachingFingerprint: text(record.teachingFingerprint),
|
|
74
80
|
teachingLabels: count(record.teachingLabels),
|
|
75
81
|
primingExamples: count(record.primingExamples),
|
|
82
|
+
...(text(record.system) ? { system: text(record.system)! } : {}),
|
|
83
|
+
...(text(record.publicationId) ? { publicationId: text(record.publicationId)! } : {}),
|
|
84
|
+
...(text(record.output) ? { output: text(record.output)! } : {}),
|
|
76
85
|
};
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
function recordedVersion(value: unknown): number | null {
|
|
89
|
+
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
|
|
90
|
+
const version = Number(value);
|
|
91
|
+
return Number.isInteger(version) && version >= 0 ? version : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decisionTime(relation: SemanticRelationRecord): number {
|
|
95
|
+
const metadata = parseMetadata(relation.metadata);
|
|
96
|
+
for (const value of [metadata.decidedAt, relation.importedAt, relation.createdAt]) {
|
|
97
|
+
if (typeof value !== "string") continue;
|
|
98
|
+
const timestamp = Date.parse(value);
|
|
99
|
+
if (Number.isFinite(timestamp)) return timestamp;
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/**
|
|
80
105
|
* Current decisions keyed by reference lineage, from relations the Newsroom
|
|
81
106
|
* already loads: no extra request per row.
|
|
82
107
|
*/
|
|
83
108
|
export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
|
|
84
109
|
const decisions = new Map<string, RelevanceDecisionView>();
|
|
85
|
-
|
|
86
|
-
|
|
110
|
+
// Realtime events can briefly expose both sides of a superseding write. The
|
|
111
|
+
// result must not depend on list pagination or subscription arrival order.
|
|
112
|
+
const ordered = [...relations].sort((left, right) => decisionTime(left) - decisionTime(right) || left.id.localeCompare(right.id));
|
|
113
|
+
for (const relation of ordered) {
|
|
114
|
+
if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current" || relation.subjectKind !== "reference") continue;
|
|
87
115
|
const metadata = parseMetadata(relation.metadata);
|
|
116
|
+
if (!relation.subjectLineageId || typeof metadata.label !== "string" || !metadata.label.trim()
|
|
117
|
+
|| typeof metadata.decisionId !== "string" || !metadata.decisionId.trim()) continue;
|
|
88
118
|
const probabilities = parseMetadata(metadata.probabilities);
|
|
89
119
|
const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
|
|
90
|
-
const version =
|
|
120
|
+
const version = recordedVersion(relation.modelVersion ?? metadata.version);
|
|
91
121
|
decisions.set(relation.subjectLineageId, {
|
|
92
122
|
decisionRelationId: relation.id,
|
|
93
|
-
decisionId:
|
|
94
|
-
label:
|
|
95
|
-
confidence: typeof relation.confidence === "number"
|
|
96
|
-
|
|
123
|
+
decisionId: metadata.decisionId,
|
|
124
|
+
label: metadata.label,
|
|
125
|
+
confidence: typeof relation.confidence === "number" && Number.isFinite(relation.confidence)
|
|
126
|
+
&& relation.confidence >= 0 && relation.confidence <= 1 ? relation.confidence : null,
|
|
127
|
+
version,
|
|
97
128
|
reviewRecommended: Boolean(relation.reviewRecommended),
|
|
98
129
|
reviewReason: reason,
|
|
99
130
|
reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
|
|
@@ -104,6 +135,7 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
|
|
|
104
135
|
question: typeof metadata.question === "string" ? metadata.question : null,
|
|
105
136
|
reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
|
|
106
137
|
cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
|
|
138
|
+
...(relation.classifierId ? { classifierId: relation.classifierId } : {}),
|
|
107
139
|
decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
|
|
108
140
|
provenance: parseProvenance(metadata.provenance),
|
|
109
141
|
});
|
package/lib/relevance-status.ts
CHANGED
|
@@ -52,10 +52,10 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
|
|
|
52
52
|
},
|
|
53
53
|
calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
|
|
54
54
|
reviewRate: {
|
|
55
|
-
state: "onboarding", rate: 1, reason: "
|
|
55
|
+
state: "onboarding", rate: 1, reason: "Getting started: every suggestion is reviewed until suggestions earn a lower review rate.",
|
|
56
56
|
auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
|
|
57
57
|
nextCheckAfterDecisions: 99,
|
|
58
|
-
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a
|
|
58
|
+
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a gap under 5 points between how sure suggestions say they are and how often they are right. Next check in 99 decisions.",
|
|
59
59
|
expectedReviewsPerWeek: 1,
|
|
60
60
|
},
|
|
61
61
|
lastChange: null,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import { getCurrentUser } from "aws-amplify/auth";
|
|
5
|
+
import {
|
|
6
|
+
answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
|
|
7
|
+
type AnswerInput, type ReviewRequest,
|
|
8
|
+
} from "./review-requests";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Review requests through the site's own authenticated API (user pool; editors and admins). The condition `state = open`
|
|
12
|
+
* on every change means two people answering at once, or an answer racing the deadline, cannot both win.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FIELDS = "id subject mode reason ask raisedBy raisedAt deadline state answer links history appliedIn updatedAt";
|
|
16
|
+
const MAX_PAGES = 10;
|
|
17
|
+
|
|
18
|
+
type GraphQLResult = { data?: Record<string, any>; errors?: { message?: string; errorType?: string }[] };
|
|
19
|
+
|
|
20
|
+
async function run(query: string, variables: Record<string, unknown>): Promise<GraphQLResult> {
|
|
21
|
+
const client = generateClient({ authMode: "userPool" });
|
|
22
|
+
try {
|
|
23
|
+
return (await client.graphql({ query, variables })) as GraphQLResult;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// Amplify throws the response when AppSync returns errors.
|
|
26
|
+
return (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ReviewLoad = { state: "ready"; requests: ReviewRequest[]; readIds: string[]; userKey: string } | { state: "forbidden" } | { state: "unavailable" };
|
|
31
|
+
|
|
32
|
+
export async function currentUserKey(): Promise<string> {
|
|
33
|
+
return (await getCurrentUser()).userId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Loads every request (newest pages first), marks due soft reviews as timed out ("proceeded without review"), and reads this user's marks. */
|
|
37
|
+
export async function loadReviews(now: Date = new Date()): Promise<ReviewLoad> {
|
|
38
|
+
let userKey: string;
|
|
39
|
+
try {
|
|
40
|
+
userKey = await currentUserKey();
|
|
41
|
+
} catch {
|
|
42
|
+
return { state: "unavailable" };
|
|
43
|
+
}
|
|
44
|
+
const rows: Record<string, unknown>[] = [];
|
|
45
|
+
let nextToken: string | null = null;
|
|
46
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
47
|
+
const result = await run(
|
|
48
|
+
`query ListReviews($limit: Int, $nextToken: String) { listReviewRequests(limit: $limit, nextToken: $nextToken) { items { ${FIELDS} } nextToken } }`,
|
|
49
|
+
{ limit: 500, nextToken },
|
|
50
|
+
);
|
|
51
|
+
if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) return { state: "forbidden" };
|
|
52
|
+
const connection = result.data?.listReviewRequests;
|
|
53
|
+
if (result.errors?.length || !connection) return { state: "unavailable" };
|
|
54
|
+
rows.push(...connection.items.filter(Boolean));
|
|
55
|
+
nextToken = connection.nextToken ?? null;
|
|
56
|
+
if (!nextToken) break;
|
|
57
|
+
}
|
|
58
|
+
const requests = rows.map(normalizeReviewRow);
|
|
59
|
+
for (const [index, request] of requests.entries()) {
|
|
60
|
+
if (!isDue(request, now)) continue;
|
|
61
|
+
const update = timeoutUpdate(request, now);
|
|
62
|
+
const written = await run(
|
|
63
|
+
`mutation Expire($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
64
|
+
{ input: update, condition: { state: { eq: "open" } } },
|
|
65
|
+
);
|
|
66
|
+
const row = written.data?.updateReviewRequest;
|
|
67
|
+
requests[index] = row ? normalizeReviewRow(row) : { ...request, state: "timed_out", updatedAt: String(update.updatedAt), history: JSON.parse(String(update.history)) };
|
|
68
|
+
}
|
|
69
|
+
const marks = await run(
|
|
70
|
+
`query Marks($filter: ModelReviewReadMarkFilterInput, $limit: Int) { listReviewReadMarks(filter: $filter, limit: $limit) { items { requestId } } }`,
|
|
71
|
+
{ filter: { userKey: { eq: userKey } }, limit: 5000 },
|
|
72
|
+
);
|
|
73
|
+
const readIds = (marks.data?.listReviewReadMarks?.items ?? []).filter(Boolean).map((mark: { requestId: string }) => mark.requestId);
|
|
74
|
+
return { state: "ready", requests, readIds, userKey };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Records an answer. Throws an Error with a sentence for the page when the request is closed or the write loses a race. */
|
|
78
|
+
export async function answerReviewRequest(request: ReviewRequest, input: AnswerInput, by: string, now: Date = new Date()): Promise<ReviewRequest> {
|
|
79
|
+
const update = answerUpdate(request, input, by, now);
|
|
80
|
+
const result = await run(
|
|
81
|
+
`mutation Answer($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
82
|
+
{ input: update, condition: { state: { eq: "open" } } },
|
|
83
|
+
);
|
|
84
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
85
|
+
throw new Error("Someone else answered this, or it proceeded at its deadline. Refresh to see the result.");
|
|
86
|
+
}
|
|
87
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("Could not save the answer. Nothing was changed.");
|
|
88
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** This user has seen the request. Create the mark; if it exists, refresh it. */
|
|
92
|
+
export async function markReviewRead(userKey: string, requestId: string, now: Date = new Date()): Promise<void> {
|
|
93
|
+
const input = { id: `${userKey}#${requestId}`, requestId, userKey, readAt: now.toISOString() };
|
|
94
|
+
const created = await run(
|
|
95
|
+
`mutation Mark($input: CreateReviewReadMarkInput!) { createReviewReadMark(input: $input) { id } }`, { input });
|
|
96
|
+
if (created.errors?.length) {
|
|
97
|
+
await run(`mutation Mark($input: UpdateReviewReadMarkInput!) { updateReviewReadMark(input: $input) { id } }`, { input });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const MAX_TITLE_LOOKUPS = 40;
|
|
102
|
+
|
|
103
|
+
/** Titles of the stories that review requests are about, by lineage id, and the ids of stories that no longer exist (no version of the lineage remains). A story that cannot be read simply has no entry (the card falls back to the id). */
|
|
104
|
+
export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[] }> {
|
|
105
|
+
const titles: Record<string, string> = {};
|
|
106
|
+
const deleted: string[] = [];
|
|
107
|
+
await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
|
|
108
|
+
const result = await run(
|
|
109
|
+
`query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
|
|
110
|
+
{ lineageId: id },
|
|
111
|
+
);
|
|
112
|
+
// Only a clean, empty answer means the story is gone; a failed lookup says nothing.
|
|
113
|
+
if (!result.errors?.length && result.data?.listItemsByLineageAndVersion && !(result.data.listItemsByLineageAndVersion.items ?? []).filter(Boolean).length) deleted.push(id);
|
|
114
|
+
const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
|
|
115
|
+
const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
|
|
116
|
+
const title = newest?.headline || newest?.title;
|
|
117
|
+
if (title) titles[id] = title;
|
|
118
|
+
}));
|
|
119
|
+
return { titles, deleted };
|
|
120
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReviewRequest } from "./review-requests";
|
|
2
|
+
|
|
3
|
+
/** Demo and test data: one of each mode, a timed-out soft review, and an answered one whose steering was applied. */
|
|
4
|
+
export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
5
|
+
{
|
|
6
|
+
id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Approve this ruling summary before it is published",
|
|
7
|
+
ask: "Check the holding and the quoted passages.", raisedBy: "distinctive.ttab.ruling-analysis", raisedAt: "2026-10-23T08:00:00Z", deadline: null,
|
|
8
|
+
state: "open", answer: null, links: [], history: [{ at: "2026-10-23T08:00:00Z", event: "raised", by: "distinctive.ttab.ruling-analysis", note: "hard_gate" }],
|
|
9
|
+
appliedIn: null, updatedAt: "2026-10-23T08:00:00Z",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "rr-soft-2", subject: { type: "batch", id: "2026-W43" }, mode: "soft_review", reason: "3 new story ideas to review",
|
|
13
|
+
ask: "Approve to keep them all, reject to drop the batch, or steer with a note.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
|
|
14
|
+
deadline: "2026-10-30T07:00:00Z", state: "open", answer: null,
|
|
15
|
+
links: [{ type: "proposal", id: "proposal-1", label: "story idea 1" }, { type: "proposal", id: "proposal-2", label: "story idea 2" }, { type: "proposal", id: "proposal-3", label: "story idea 3" }],
|
|
16
|
+
history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend story draft is ready",
|
|
20
|
+
ask: null, raisedBy: "trend-pipeline", raisedAt: "2026-10-22T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "open", answer: null,
|
|
21
|
+
links: [], history: [], appliedIn: null, updatedAt: "2026-10-22T07:00:00Z",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "rr-flag-1", subject: { type: "reference", id: "ref-1" }, mode: "flag", reason: "Two sources disagree about the filing date",
|
|
25
|
+
ask: null, raisedBy: "reference-intake", raisedAt: "2026-10-22T10:00:00Z", deadline: null, state: "open", answer: null, links: [], history: [],
|
|
26
|
+
appliedIn: null, updatedAt: "2026-10-22T10:00:00Z",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "rr-late-1", subject: { type: "story", id: "story-old" }, mode: "soft_review", reason: "Check the headline", ask: null, raisedBy: "copy-editor",
|
|
30
|
+
raisedAt: "2026-10-10T07:00:00Z", deadline: "2026-10-17T07:00:00Z", state: "timed_out", answer: null, links: [],
|
|
31
|
+
history: [{ at: "2026-10-17T07:00:00Z", event: "timed_out", by: "system", note: "proceeded without review" }], appliedIn: null, updatedAt: "2026-10-17T07:00:00Z",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "rr-done-1", subject: { type: "batch", id: "2026-W42" }, mode: "soft_review", reason: "4 new story ideas to review", ask: null, raisedBy: "idea-pipeline",
|
|
35
|
+
raisedAt: "2026-10-16T07:00:00Z", deadline: "2026-10-23T07:00:00Z", state: "answered",
|
|
36
|
+
answer: { decision: "feedback", feedback: "Fewer listicles", steer: "not_this_kind", by: "Ed Itor", at: "2026-10-17T09:00:00Z" },
|
|
37
|
+
links: [], history: [], appliedIn: { runId: "run-w43", by: "idea-pipeline", at: "2026-10-20T07:00:00Z" }, updatedAt: "2026-10-17T09:00:00Z",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "rr-done-2", subject: { type: "proposal", id: "proposal-4" }, mode: "soft_review", reason: "A concept rename was proposed", ask: null, raisedBy: "concept-bot",
|
|
41
|
+
raisedAt: "2026-10-18T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "answered",
|
|
42
|
+
answer: { decision: "feedback", feedback: "Keep the old name for sports", steer: "adjust", by: "Ed Itor", at: "2026-10-19T09:00:00Z" },
|
|
43
|
+
links: [], history: [], appliedIn: null, updatedAt: "2026-10-19T09:00:00Z",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Notification Center's model (PPY-1cd261, PPY-ab6a50): review requests, the Cyclotron items that wait for a person,
|
|
3
|
+
* the auto-decided stream, and the words the page says about each. Pure functions only, so the page and its tests share them.
|
|
4
|
+
*
|
|
5
|
+
* Principle (docs/notification-center.md): everything except a hard gate keeps moving if nobody acts. Each entry says in words
|
|
6
|
+
* what happens next without the reader, and only a hard gate says "Waiting for you".
|
|
7
|
+
*/
|
|
8
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
9
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
10
|
+
import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
|
|
11
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
12
|
+
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
13
|
+
|
|
14
|
+
export type ReviewMode = "flag" | "soft_review" | "hard_gate";
|
|
15
|
+
export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
|
|
16
|
+
export type ReviewDecision = "approve" | "acknowledge" | "reject" | "feedback";
|
|
17
|
+
export type ReviewSteer = "adjust" | "not_this_kind";
|
|
18
|
+
|
|
19
|
+
export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string };
|
|
20
|
+
export type ReviewHistoryEntry = { at: string; event: string; by?: string; note?: string };
|
|
21
|
+
export type ReviewLink = { label?: string; url?: string; type?: string; id?: string };
|
|
22
|
+
|
|
23
|
+
export type ReviewRequest = {
|
|
24
|
+
id: string;
|
|
25
|
+
subject: { type: string; id: string };
|
|
26
|
+
mode: ReviewMode;
|
|
27
|
+
reason: string;
|
|
28
|
+
ask: string | null;
|
|
29
|
+
raisedBy: string;
|
|
30
|
+
raisedAt: string;
|
|
31
|
+
deadline: string | null;
|
|
32
|
+
state: ReviewState;
|
|
33
|
+
answer: ReviewAnswer | null;
|
|
34
|
+
links: ReviewLink[];
|
|
35
|
+
history: ReviewHistoryEntry[];
|
|
36
|
+
appliedIn: { runId: string; by: string; at: string; note?: string | null } | null;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const PROCEEDED_WITHOUT_REVIEW = "proceeded without review";
|
|
41
|
+
const MAX_HISTORY = 50;
|
|
42
|
+
|
|
43
|
+
function parseJson<T>(value: unknown, fallback: T): T {
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(value) as T;
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return value === null || value === undefined ? fallback : (value as T);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A row from the API (JSON fields arrive as strings) as a request. */
|
|
55
|
+
export function normalizeReviewRow(row: Record<string, unknown>): ReviewRequest {
|
|
56
|
+
return {
|
|
57
|
+
id: String(row.id),
|
|
58
|
+
subject: parseJson(row.subject, { type: "unknown", id: "" }),
|
|
59
|
+
mode: row.mode as ReviewMode,
|
|
60
|
+
reason: String(row.reason ?? ""),
|
|
61
|
+
ask: typeof row.ask === "string" ? row.ask : null,
|
|
62
|
+
raisedBy: String(row.raisedBy ?? ""),
|
|
63
|
+
raisedAt: String(row.raisedAt ?? ""),
|
|
64
|
+
deadline: typeof row.deadline === "string" ? row.deadline : null,
|
|
65
|
+
state: row.state as ReviewState,
|
|
66
|
+
answer: parseJson<ReviewAnswer | null>(row.answer, null),
|
|
67
|
+
links: parseJson<ReviewLink[]>(row.links, []),
|
|
68
|
+
history: parseJson<ReviewHistoryEntry[]>(row.history, []),
|
|
69
|
+
appliedIn: parseJson(row.appliedIn, null),
|
|
70
|
+
updatedAt: String(row.updatedAt ?? row.raisedAt ?? ""),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- the deadline (checked on read) ---------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export function isDue(request: ReviewRequest, now: Date): boolean {
|
|
77
|
+
return request.mode === "soft_review" && request.state === "open" && request.deadline !== null && new Date(request.deadline).getTime() <= now.getTime();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The update that marks a due soft review as timed out, "proceeded without review". Applied with the condition state = open. */
|
|
81
|
+
export function timeoutUpdate(request: ReviewRequest, now: Date): Record<string, unknown> {
|
|
82
|
+
const at = now.toISOString();
|
|
83
|
+
const history = [...request.history, { at, event: "timed_out", by: "system", note: PROCEEDED_WITHOUT_REVIEW }].slice(-MAX_HISTORY);
|
|
84
|
+
return { id: request.id, state: "timed_out", updatedAt: at, history: JSON.stringify(history) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type AnswerInput = { decision: ReviewDecision; feedback?: string | null; steer?: ReviewSteer | null };
|
|
88
|
+
|
|
89
|
+
/** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
|
|
90
|
+
export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
|
|
91
|
+
if (request.state !== "open") throw new Error(`This request is ${request.state.replace("_", " ")}${request.state === "timed_out" ? ` (${PROCEEDED_WITHOUT_REVIEW})` : ""} and can no longer be answered.`);
|
|
92
|
+
if (input.decision === "acknowledge" && request.mode !== "flag") throw new Error("Only a flag is acknowledged; approve or reject this one.");
|
|
93
|
+
const feedback = input.feedback?.trim() || null;
|
|
94
|
+
const steer = input.steer ?? null;
|
|
95
|
+
if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
|
|
96
|
+
const at = now.toISOString();
|
|
97
|
+
const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at };
|
|
98
|
+
const history = [...request.history, { at, event: "answered", by, note: input.decision }].slice(-MAX_HISTORY);
|
|
99
|
+
return { id: request.id, state: "answered", answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- words ------------------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export function formatWhen(iso: string | null | undefined): string {
|
|
105
|
+
if (!iso) return "";
|
|
106
|
+
const date = new Date(iso);
|
|
107
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
108
|
+
return `${date.toISOString().slice(0, 10)} ${date.toISOString().slice(11, 16)} UTC`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function modeLabel(mode: ReviewMode): string {
|
|
112
|
+
return mode === "hard_gate" ? "Hard gate" : mode === "soft_review" ? "Soft review" : "Flag";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A mark and the words, never colour alone. */
|
|
116
|
+
export function modeMark(mode: ReviewMode): string {
|
|
117
|
+
return mode === "hard_gate" ? "■" : mode === "soft_review" ? "◐" : "⚑";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What happens next without the reader. Only a hard gate says "Waiting for you". */
|
|
121
|
+
export function nextText(request: ReviewRequest): string {
|
|
122
|
+
if (request.state === "open") {
|
|
123
|
+
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you approve or reject.";
|
|
124
|
+
if (request.mode === "soft_review") return `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.`;
|
|
125
|
+
return "Nothing is waiting on this. It stays here until you acknowledge it.";
|
|
126
|
+
}
|
|
127
|
+
if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
128
|
+
if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
|
|
129
|
+
const answer = request.answer;
|
|
130
|
+
return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function answerWords(answer: ReviewAnswer, request?: Pick<ReviewRequest, "mode">): string {
|
|
134
|
+
if (answer.steer === "not_this_kind") return "Steered: not this kind of thing";
|
|
135
|
+
if (answer.decision === "acknowledge") return "Acknowledged";
|
|
136
|
+
if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Approved";
|
|
137
|
+
if (answer.decision === "reject") return "Rejected";
|
|
138
|
+
return "Steered with a note";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "You steered X, applied in run Y": what became of a person's input. */
|
|
142
|
+
export function steeringText(request: ReviewRequest): string | null {
|
|
143
|
+
const answer = request.answer;
|
|
144
|
+
if (request.state !== "answered" || !answer || !(answer.feedback || answer.steer || answer.decision === "reject")) return null;
|
|
145
|
+
const said = answer.feedback ? `"${answer.feedback}"` : answerWords(answer).toLowerCase();
|
|
146
|
+
if (request.appliedIn) return `You steered ${said}. Applied in run ${request.appliedIn.runId} on ${formatWhen(request.appliedIn.at)}.`;
|
|
147
|
+
return `You steered ${said}. ${request.raisedBy} has not used it yet; it will on its next run.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What a request is about, in words: a story shows its title (the id when the title is not known), anything else its type and id. */
|
|
151
|
+
export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}, deleted: ReadonlySet<string> = new Set()): string {
|
|
152
|
+
if (subject.type === "story") return deleted.has(subject.id) ? `story ${subject.id} (story deleted)` : titles[subject.id] ?? `story ${subject.id}`;
|
|
153
|
+
return `${subject.type.replace("_", " ")} ${subject.id}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
|
|
157
|
+
const id = encodeURIComponent(subject.id);
|
|
158
|
+
if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
|
|
159
|
+
if (subject.type === "reference" || subject.type === "cyclotron_decision") return getNewsroomNavHref(`/newsroom/references/${id}`, demo);
|
|
160
|
+
if (subject.type === "proposal") return getNewsroomNavHref(`/newsroom/topics?proposal=${id}`, demo);
|
|
161
|
+
if (subject.type === "automation_run") return getNewsroomNavHref("/newsroom/administration/automations", demo);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- Cyclotron items ---------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export type CyclotronEntry = {
|
|
168
|
+
lineageId: string;
|
|
169
|
+
title: string;
|
|
170
|
+
label: string;
|
|
171
|
+
confidence: number | null;
|
|
172
|
+
decidedAt: string | null;
|
|
173
|
+
reviewDetail: string | null;
|
|
174
|
+
reviewedByPerson: boolean;
|
|
175
|
+
href: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
function cyclotronEntry(reference: ReferenceRecord, decision: RelevanceDecisionView, reviewed: ReadonlySet<string>, demo: boolean): CyclotronEntry {
|
|
179
|
+
const lineageId = referenceLineageId(reference);
|
|
180
|
+
return {
|
|
181
|
+
lineageId, title: referenceDisplayTitle(reference), label: decision.label, confidence: decision.confidence,
|
|
182
|
+
decidedAt: decision.decidedAt, reviewDetail: decision.reviewDetail, reviewedByPerson: reviewed.has(lineageId),
|
|
183
|
+
href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Items the Cyclotron routed to a person (the References tab's own rule: review recommended and still pending). Least confident first. */
|
|
188
|
+
export function cyclotronHeldForReview(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, demo = false): CyclotronEntry[] {
|
|
189
|
+
const entries: CyclotronEntry[] = [];
|
|
190
|
+
for (const reference of references) {
|
|
191
|
+
const decision = decisions.get(referenceLineageId(reference));
|
|
192
|
+
if (decision?.reviewRecommended && normalizeReferenceCurationStatus(reference.curationStatus) === "pending") {
|
|
193
|
+
entries.push(cyclotronEntry(reference, decision, new Set(), demo));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return entries.sort((a, b) => (a.confidence ?? 1) - (b.confidence ?? 1));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Recent classifications the Cyclotron made on its own confidence. Read only here; correcting happens on the reference. Newest first. */
|
|
200
|
+
export function cyclotronAutoDecided(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, reviewed: ReadonlySet<string>, demo = false, limit = 20): CyclotronEntry[] {
|
|
201
|
+
const entries: CyclotronEntry[] = [];
|
|
202
|
+
for (const reference of references) {
|
|
203
|
+
const decision = decisions.get(referenceLineageId(reference));
|
|
204
|
+
if (decision && !decision.reviewRecommended) entries.push(cyclotronEntry(reference, decision, reviewed, demo));
|
|
205
|
+
}
|
|
206
|
+
return entries.sort((a, b) => (b.decidedAt ?? "").localeCompare(a.decidedAt ?? "")).slice(0, limit);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- the page's shape -------------------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
export type NotificationSections = {
|
|
212
|
+
hardGates: ReviewRequest[];
|
|
213
|
+
softReviews: ReviewRequest[];
|
|
214
|
+
flags: ReviewRequest[];
|
|
215
|
+
/** Answered, timed out or withdrawn, newest first: what people said and what became of it. */
|
|
216
|
+
decided: ReviewRequest[];
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export function sectionReviews(requests: ReviewRequest[]): NotificationSections {
|
|
220
|
+
const open = requests.filter((request) => request.state === "open");
|
|
221
|
+
const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
|
|
222
|
+
return {
|
|
223
|
+
hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
|
|
224
|
+
softReviews: open.filter((request) => request.mode === "soft_review").sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? "")),
|
|
225
|
+
flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
|
|
226
|
+
decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type NotificationSummary = { waiting: number; proceedingSoon: number; decidedBySystem: number; unread: number };
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* "N waiting for you · N proceeding soon · N recently decided by the system".
|
|
234
|
+
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). Proceeding soon = open soft reviews.
|
|
235
|
+
*/
|
|
236
|
+
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
|
|
237
|
+
return { waiting: sections.hardGates.length + held.length, proceedingSoon: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function summaryText(summary: NotificationSummary): string {
|
|
241
|
+
return `${summary.waiting} waiting for you · ${summary.proceedingSoon} proceeding soon · ${summary.decidedBySystem} recently decided by the system`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
|
|
245
|
+
return !readIds.has(request.id);
|
|
246
|
+
}
|
package/lib/semantic-graph.ts
CHANGED
|
@@ -57,6 +57,7 @@ export type SemanticPredicateId =
|
|
|
57
57
|
| "related_to"
|
|
58
58
|
| "broader_than"
|
|
59
59
|
| "narrower_than"
|
|
60
|
+
| "member_of"
|
|
60
61
|
| "supports"
|
|
61
62
|
| "contradicts";
|
|
62
63
|
|
|
@@ -96,6 +97,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
|
|
|
96
97
|
{ id: "related_to", label: "related to", group: "generic", inverseLabel: "related from" },
|
|
97
98
|
{ id: "broader_than", label: "broader than", group: "ontology", inverseLabel: "narrower concept" },
|
|
98
99
|
{ id: "narrower_than", label: "narrower than", group: "ontology", inverseLabel: "broader concept" },
|
|
100
|
+
{ id: "member_of", label: "member of", group: "ontology", inverseLabel: "members" },
|
|
99
101
|
{ id: "supports", label: "supports", group: "evidence", inverseLabel: "supported by" },
|
|
100
102
|
{ id: "contradicts", label: "contradicts", group: "evidence", inverseLabel: "contradicted by" },
|
|
101
103
|
];
|