@anthusai/papyrus 5.43.0 → 5.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +1 -1
- package/components/newsroom-organizer.css +48 -0
- package/components/newsroom-references-view.tsx +183 -24
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-feed.tsx +469 -0
- package/components/reference-review-card.tsx +90 -76
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +47 -0
- package/components/review-rating-grid.tsx +164 -0
- package/components/topic-steering-workspace.tsx +32 -8
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +4 -1
- package/lib/local-preview-feed-fixture.ts +43 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-feed-cache.ts +133 -0
- package/lib/reference-feed-ranking.ts +191 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +64 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +14 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Merging loaded pages into the Monitoring lists. Kept free of other imports so the panel's tests can load it alone. */
|
|
2
|
+
import type {
|
|
3
|
+
MonitoringCursors,
|
|
4
|
+
MonitoringItem,
|
|
5
|
+
MonitoringListName,
|
|
6
|
+
MonitoringListRows,
|
|
7
|
+
MonitoringOverview,
|
|
8
|
+
} from "./monitoring-overview";
|
|
9
|
+
|
|
10
|
+
/** Rows the editor has loaded beyond the first page of each list, and where each list continues. */
|
|
11
|
+
export type MonitoringLoadedPages = Partial<{ [L in MonitoringListName]: { rows: MonitoringListRows[L][]; cursor: string | null } }>;
|
|
12
|
+
|
|
13
|
+
const ROW_KEYS: { [L in MonitoringListName]: (row: MonitoringListRows[L]) => string } = {
|
|
14
|
+
runs: (row) => `${row.runId}`,
|
|
15
|
+
routerRuns: (row) => `${row.runId}`,
|
|
16
|
+
items: (row) => `${row.monitorId ?? ""}|${row.sourceId}`,
|
|
17
|
+
emailSubmissions: (row) => row.receiptId,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The first page followed by loaded pages, with a row seen twice kept once. The refresh re-reads the first page, so
|
|
22
|
+
* a row that moved onto it from a later page must not be listed again; for items the highest revision wins.
|
|
23
|
+
*/
|
|
24
|
+
export function mergeListPages<L extends MonitoringListName>(list: L, first: MonitoringListRows[L][], loaded: MonitoringListRows[L][]): MonitoringListRows[L][] {
|
|
25
|
+
const keyOf = ROW_KEYS[list] as (row: MonitoringListRows[L]) => string;
|
|
26
|
+
const seen = new Map<string, number>();
|
|
27
|
+
const merged: MonitoringListRows[L][] = [];
|
|
28
|
+
for (const row of [...first, ...loaded]) {
|
|
29
|
+
const key = keyOf(row);
|
|
30
|
+
const at = seen.get(key);
|
|
31
|
+
if (at === undefined) {
|
|
32
|
+
seen.set(key, merged.length);
|
|
33
|
+
merged.push(row);
|
|
34
|
+
} else if (list === "items" && (row as MonitoringItem).revision > (merged[at] as MonitoringItem).revision) {
|
|
35
|
+
merged[at] = row;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return merged;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the page shows: the refreshed first page of every list plus whatever the editor loaded, with each list's
|
|
43
|
+
* cursor. With `held` the email list is replaced by the held-for-review list (its own pages).
|
|
44
|
+
*/
|
|
45
|
+
export function withLoadedPages(overview: MonitoringOverview, loaded: MonitoringLoadedPages, held: MonitoringLoadedPages["emailSubmissions"] | null): MonitoringOverview {
|
|
46
|
+
const cursors: MonitoringCursors = {};
|
|
47
|
+
const lists: Partial<Record<MonitoringListName, unknown[]>> = {};
|
|
48
|
+
for (const list of ["runs", "routerRuns", "items", "emailSubmissions"] as const) {
|
|
49
|
+
const extra = loaded[list];
|
|
50
|
+
lists[list] = mergeListPages(list, (overview[list] ?? []) as never[], (extra?.rows ?? []) as never[]) as unknown[];
|
|
51
|
+
cursors[list] = extra ? extra.cursor : overview.cursors?.[list] ?? null;
|
|
52
|
+
}
|
|
53
|
+
if (held) {
|
|
54
|
+
lists.emailSubmissions = held.rows;
|
|
55
|
+
cursors.emailSubmissions = held.cursor;
|
|
56
|
+
}
|
|
57
|
+
return { ...overview, ...(lists as Pick<MonitoringOverview, MonitoringListName>), cursors };
|
|
58
|
+
}
|
|
@@ -2,6 +2,7 @@ import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
|
2
2
|
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
3
3
|
import type { NextRequest } from "next/server";
|
|
4
4
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
5
|
+
import { STAFF_GROUPS } from "./staff-groups";
|
|
5
6
|
|
|
6
7
|
export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
|
|
7
8
|
export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
|
|
@@ -14,7 +15,7 @@ export function newsroomChatIdentityResolver(
|
|
|
14
15
|
userPoolId: auth.user_pool_id,
|
|
15
16
|
clientId: auth.user_pool_client_id,
|
|
16
17
|
tokenUse: "access",
|
|
17
|
-
groups: [
|
|
18
|
+
groups: [...STAFF_GROUPS],
|
|
18
19
|
}, jwksCache ? { jwksCache } : undefined);
|
|
19
20
|
return async (request) => {
|
|
20
21
|
const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
|
|
@@ -11,6 +11,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
11
11
|
{ key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
|
|
12
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
13
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
14
|
+
{ key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
|
|
14
15
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
15
16
|
] },
|
|
16
17
|
{ title: "Research & knowledge", items: [
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import { EDIT_PROPOSAL, TRANSITION_PROPOSAL, type ProposalMove, type ProposalReceipt } from "./proposals-admin";
|
|
5
|
+
|
|
6
|
+
type GraphResult = { data?: Record<string, ProposalReceipt | null>; errors?: { message?: string }[] };
|
|
7
|
+
|
|
8
|
+
async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
|
|
9
|
+
const client = generateClient({ authMode: "userPool" });
|
|
10
|
+
const result = (await client.graphql({ query, variables })) as GraphResult;
|
|
11
|
+
if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
|
|
12
|
+
return result.data?.[field] ?? { status: "refused", message: "The action did not answer." };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Moves a proposal through `path`, one `transitionProposal` call per state, stopping at the first refusal. */
|
|
16
|
+
export async function transitionProposalPath(proposalId: string, path: readonly string[], reason = ""): Promise<ProposalReceipt> {
|
|
17
|
+
let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
|
|
18
|
+
for (const state of path) {
|
|
19
|
+
receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
|
|
20
|
+
if (receipt.status !== "ok") break;
|
|
21
|
+
}
|
|
22
|
+
return receipt;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One button of the Proposals panel: a path of states, or `editProposal` for pin and hold. */
|
|
26
|
+
export async function performProposalMove(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
|
|
27
|
+
if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
|
|
28
|
+
return transitionProposalPath(proposalId, move.path ?? [], reason);
|
|
29
|
+
}
|
package/lib/reader-subject.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
2
|
import type { ProspectusSignedInSession } from "./prospectus-session";
|
|
3
|
+
import { hasEditorRights, STAFF_GROUPS } from "./staff-groups";
|
|
3
4
|
|
|
4
5
|
export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
|
|
5
6
|
export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
|
|
6
7
|
export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
|
|
7
|
-
export const READER_STAFF_GROUPS =
|
|
8
|
+
export const READER_STAFF_GROUPS = STAFF_GROUPS;
|
|
8
9
|
|
|
9
10
|
const MINIMUM_DEV_SECRET_LENGTH = 16;
|
|
10
11
|
const INTERIM_SUBJECT_PREFIX = "ssdev_";
|
|
@@ -46,7 +47,7 @@ export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessTo
|
|
|
46
47
|
try {
|
|
47
48
|
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
|
|
48
49
|
const groups = claims["cognito:groups"];
|
|
49
|
-
return Array.isArray(groups) && groups
|
|
50
|
+
return Array.isArray(groups) && hasEditorRights(groups);
|
|
50
51
|
} catch {
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-while-revalidate copies of what the editor last saw, so a return visit paints cards (and the signed-in name) at once
|
|
3
|
+
* while the real reads run in the background. Display state only: the backend still authorizes every call, a cached copy never
|
|
4
|
+
* enables an action by itself, and everything here is dropped on sign-out. Every storage touch is guarded: the page renders
|
|
5
|
+
* the same, only slower, when storage is blocked or full.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
8
|
+
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
9
|
+
import type { ReferencePreview } from "./reference-preview";
|
|
10
|
+
|
|
11
|
+
const FEED_PREFIX = "papyrus.references.feed.v1.";
|
|
12
|
+
const SESSION_PREFIX = "papyrus.newsroom.session.v1.";
|
|
13
|
+
/** A saved session summary is trusted for display only this long; the live check always still runs. */
|
|
14
|
+
export const SESSION_DISPLAY_TTL_MS = 15 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export type FeedSnapshot = {
|
|
17
|
+
savedAt: number;
|
|
18
|
+
references: ReferenceRecord[];
|
|
19
|
+
previews: Array<[string, ReferencePreview]>;
|
|
20
|
+
decisions: Array<[string, RelevanceDecisionView]>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SessionDisplay = {
|
|
24
|
+
savedAt: number;
|
|
25
|
+
label: string;
|
|
26
|
+
isAdmin: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function storageOf(kind: "local" | "session"): Storage | null {
|
|
30
|
+
try {
|
|
31
|
+
if (typeof window === "undefined") return null;
|
|
32
|
+
return kind === "local" ? window.localStorage : window.sessionStorage;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function read<T>(kind: "local" | "session", key: string): T | null {
|
|
39
|
+
try {
|
|
40
|
+
const raw = storageOf(kind)?.getItem(key);
|
|
41
|
+
return raw ? JSON.parse(raw) as T : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function write(kind: "local" | "session", key: string, value: unknown): boolean {
|
|
48
|
+
try {
|
|
49
|
+
const storage = storageOf(kind);
|
|
50
|
+
if (!storage) return false;
|
|
51
|
+
storage.setItem(key, JSON.stringify(value));
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function remove(kind: "local" | "session", key: string) {
|
|
59
|
+
try { storageOf(kind)?.removeItem(key); } catch { /* nothing to clear */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The fields a card needs, and nothing else: a smaller copy that fits, and less private text at rest. */
|
|
63
|
+
export function slimReference(reference: ReferenceRecord): ReferenceRecord {
|
|
64
|
+
return {
|
|
65
|
+
id: reference.id,
|
|
66
|
+
lineageId: reference.lineageId,
|
|
67
|
+
versionNumber: reference.versionNumber,
|
|
68
|
+
versionState: reference.versionState,
|
|
69
|
+
corpusId: reference.corpusId,
|
|
70
|
+
externalItemId: reference.externalItemId,
|
|
71
|
+
title: reference.title,
|
|
72
|
+
sourceUri: reference.sourceUri,
|
|
73
|
+
mediaType: reference.mediaType,
|
|
74
|
+
sourcePublishedAt: reference.sourcePublishedAt,
|
|
75
|
+
retrievedAt: reference.retrievedAt,
|
|
76
|
+
importedAt: reference.importedAt,
|
|
77
|
+
createdAt: reference.createdAt,
|
|
78
|
+
curationStatus: reference.curationStatus,
|
|
79
|
+
curationStatusUpdatedAt: reference.curationStatusUpdatedAt,
|
|
80
|
+
curationStatusReason: reference.curationStatusReason,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function feedCacheKey(siteId: string): string {
|
|
85
|
+
return `${FEED_PREFIX}${siteId}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function loadFeedSnapshot(siteId: string): FeedSnapshot | null {
|
|
89
|
+
const snapshot = read<FeedSnapshot>("local", feedCacheKey(siteId));
|
|
90
|
+
if (!snapshot || !Array.isArray(snapshot.references) || !snapshot.references.length) return null;
|
|
91
|
+
return snapshot;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Saves the newest copy. On a full store it keeps the newest half, then gives up quietly. */
|
|
95
|
+
export function saveFeedSnapshot(
|
|
96
|
+
siteId: string,
|
|
97
|
+
parts: { references: ReferenceRecord[]; previews: ReadonlyMap<string, ReferencePreview>; decisions: ReadonlyMap<string, RelevanceDecisionView> },
|
|
98
|
+
now = Date.now(),
|
|
99
|
+
): boolean {
|
|
100
|
+
let references = parts.references.map(slimReference);
|
|
101
|
+
for (let attempt = 0; attempt < 4 && references.length; attempt += 1) {
|
|
102
|
+
const kept = new Set(references.map((reference) => reference.lineageId ?? reference.id));
|
|
103
|
+
const snapshot: FeedSnapshot = {
|
|
104
|
+
savedAt: now,
|
|
105
|
+
references,
|
|
106
|
+
previews: [...parts.previews].filter(([lineage]) => kept.has(lineage)),
|
|
107
|
+
decisions: [...parts.decisions].filter(([lineage]) => kept.has(lineage)).map(([lineage, decision]) => [lineage, { ...decision, provenance: null }]),
|
|
108
|
+
};
|
|
109
|
+
if (write("local", feedCacheKey(siteId), snapshot)) return true;
|
|
110
|
+
references = references.slice(0, Math.ceil(references.length / 2));
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function clearFeedSnapshot(siteId: string) {
|
|
116
|
+
remove("local", feedCacheKey(siteId));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function loadSessionDisplay(siteId: string, now = Date.now()): SessionDisplay | null {
|
|
120
|
+
const saved = read<SessionDisplay>("session", `${SESSION_PREFIX}${siteId}`);
|
|
121
|
+
if (!saved || typeof saved.label !== "string" || typeof saved.savedAt !== "number") return null;
|
|
122
|
+
return now - saved.savedAt <= SESSION_DISPLAY_TTL_MS ? saved : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function saveSessionDisplay(siteId: string, display: Omit<SessionDisplay, "savedAt">, now = Date.now()) {
|
|
126
|
+
write("session", `${SESSION_PREFIX}${siteId}`, { ...display, savedAt: now });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Sign-out: nothing of the signed-in view survives in this browser. */
|
|
130
|
+
export function clearSavedNewsroomDisplay(siteId: string) {
|
|
131
|
+
remove("session", `${SESSION_PREFIX}${siteId}`);
|
|
132
|
+
clearFeedSnapshot(siteId);
|
|
133
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the References feed is ordered, and which cards belong in which tab.
|
|
3
|
+
*
|
|
4
|
+
* Plain words, as the "How this feed is ranked" popover says them:
|
|
5
|
+
* 1. The Cyclotron's prediction becomes a probability that you will Accept (P). A prediction of Accept at 80% is
|
|
6
|
+
* P = 0.8; a prediction of Reject at 80% is P = 0.2; no prediction is P = 0.5.
|
|
7
|
+
* 2. Old items lose their say. The score is 0.5 + (P - 0.5) * w, where w = exp(-age / tau) is a freshness weight.
|
|
8
|
+
* Age pulls an item toward "uncertain" (0.5), never to the bottom.
|
|
9
|
+
* 3. tau is learned from how old the References you accepted were when you accepted them
|
|
10
|
+
* (tau = median age / ln 2). With fewer than 10 accepted examples nothing is guessed: no decay.
|
|
11
|
+
* 4. Highest score first. The strongest Reject predictions end up last. Ties go to the newest item.
|
|
12
|
+
* 5. Unrated items older than 3 tau move to "Earlier" at the bottom. Nothing is deleted.
|
|
13
|
+
*
|
|
14
|
+
* The same recipe ranks monitor stories (`learn_freshness` in src/papyrus_content/ideas/monitor_stories.py).
|
|
15
|
+
*/
|
|
16
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
17
|
+
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
18
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
19
|
+
import { referenceLineageId } from "./newsroom-references";
|
|
20
|
+
|
|
21
|
+
/** Fewer accepted examples than this and there is no freshness weight: a constant is never guessed. */
|
|
22
|
+
export const MINIMUM_FRESHNESS_EXAMPLES = 10;
|
|
23
|
+
/** An unrated item whose age passes this many tau has a weight below exp(-3), about 5%, and moves to Earlier. */
|
|
24
|
+
export const EARLIER_AFTER_TAUS = 3;
|
|
25
|
+
/** The probability that stands for "no prediction". */
|
|
26
|
+
export const UNDECIDED_PROBABILITY = 0.5;
|
|
27
|
+
/** A prediction that recorded a label but no confidence is a mild lean, not a coin toss and not a certainty. */
|
|
28
|
+
export const UNRECORDED_CONFIDENCE = 0.6;
|
|
29
|
+
|
|
30
|
+
const DAY_MILLISECONDS = 86_400_000;
|
|
31
|
+
|
|
32
|
+
export type FeedFreshness = {
|
|
33
|
+
/** Null when there were too few accepted examples to learn from: no decay at all. */
|
|
34
|
+
tauDays: number | null;
|
|
35
|
+
medianAgeDays: number | null;
|
|
36
|
+
examples: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function timeOf(value: string | null | undefined): number | null {
|
|
40
|
+
if (!value) return null;
|
|
41
|
+
const parsed = Date.parse(value);
|
|
42
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** When the item reached the newsroom. The same arrival the monitor stories use. */
|
|
46
|
+
export function referenceArrivalTime(reference: ReferenceRecord): number | null {
|
|
47
|
+
return timeOf(reference.createdAt) ?? timeOf(reference.importedAt) ?? timeOf(reference.retrievedAt) ?? timeOf(reference.sourcePublishedAt);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The date a card shows: when the source says it was published, else when it arrived. */
|
|
51
|
+
export function referenceDisplayTime(reference: ReferenceRecord): number | null {
|
|
52
|
+
return timeOf(reference.sourcePublishedAt) ?? timeOf(reference.sourceUpdatedAt) ?? referenceArrivalTime(reference);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* tau from the age at acceptance of accepted References: median / ln 2, since the median age of an exponential
|
|
57
|
+
* exp(-age / tau) is tau * ln 2. `ignore` keeps the item an editor just rated from moving the very ranking they are looking at.
|
|
58
|
+
*/
|
|
59
|
+
export function learnFreshness(references: ReferenceRecord[], ignore: ReadonlySet<string> = new Set()): FeedFreshness {
|
|
60
|
+
const ages: number[] = [];
|
|
61
|
+
for (const reference of references) {
|
|
62
|
+
if (normalizeReferenceCurationStatus(reference.curationStatus) !== "accepted" || ignore.has(referenceLineageId(reference))) continue;
|
|
63
|
+
const arrived = referenceArrivalTime(reference);
|
|
64
|
+
const decided = timeOf(reference.curationStatusUpdatedAt);
|
|
65
|
+
if (arrived === null || decided === null) continue;
|
|
66
|
+
const age = (decided - arrived) / DAY_MILLISECONDS;
|
|
67
|
+
if (age > 0) ages.push(age);
|
|
68
|
+
}
|
|
69
|
+
if (ages.length < MINIMUM_FRESHNESS_EXAMPLES) return { tauDays: null, medianAgeDays: null, examples: ages.length };
|
|
70
|
+
ages.sort((left, right) => left - right);
|
|
71
|
+
const middle = Math.floor(ages.length / 2);
|
|
72
|
+
const median = ages.length % 2 ? ages[middle] : (ages[middle - 1] + ages[middle]) / 2;
|
|
73
|
+
return { tauDays: median / Math.LN2, medianAgeDays: median, examples: ages.length };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** exp(-age / tau) at `now`; 1 without a learned tau or an unreadable arrival. */
|
|
77
|
+
export function freshnessWeight(reference: ReferenceRecord, freshness: FeedFreshness, now: number): number {
|
|
78
|
+
const arrived = referenceArrivalTime(reference);
|
|
79
|
+
if (freshness.tauDays === null || arrived === null) return 1;
|
|
80
|
+
return Math.exp(-Math.max(now - arrived, 0) / DAY_MILLISECONDS / freshness.tauDays);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** P(the editor accepts), from the Cyclotron's decision: its confidence is in the label it chose. */
|
|
84
|
+
export function acceptProbability(decision: RelevanceDecisionView | undefined): number {
|
|
85
|
+
if (!decision) return UNDECIDED_PROBABILITY;
|
|
86
|
+
const confidence = decision.confidence ?? UNRECORDED_CONFIDENCE;
|
|
87
|
+
const accepts = decision.label === (decision.positiveLabel ?? "include");
|
|
88
|
+
return accepts ? confidence : 1 - confidence;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type FeedItem = {
|
|
92
|
+
reference: ReferenceRecord;
|
|
93
|
+
lineageId: string;
|
|
94
|
+
decision: RelevanceDecisionView | undefined;
|
|
95
|
+
/** P(accept) before freshness. */
|
|
96
|
+
probability: number;
|
|
97
|
+
weight: number;
|
|
98
|
+
/** 0.5 + (P - 0.5) * weight. Highest is first. */
|
|
99
|
+
score: number;
|
|
100
|
+
/** True when this unrated item is older than the Earlier threshold. */
|
|
101
|
+
earlier: boolean;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function scoreFeedItem(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined, freshness: FeedFreshness, now: number): FeedItem {
|
|
105
|
+
const probability = acceptProbability(decision);
|
|
106
|
+
const weight = freshnessWeight(reference, freshness, now);
|
|
107
|
+
const arrived = referenceArrivalTime(reference);
|
|
108
|
+
const earlier = freshness.tauDays !== null && arrived !== null && (now - arrived) / DAY_MILLISECONDS > EARLIER_AFTER_TAUS * freshness.tauDays;
|
|
109
|
+
return {
|
|
110
|
+
reference,
|
|
111
|
+
lineageId: referenceLineageId(reference),
|
|
112
|
+
decision,
|
|
113
|
+
probability,
|
|
114
|
+
weight,
|
|
115
|
+
score: UNDECIDED_PROBABILITY + (probability - UNDECIDED_PROBABILITY) * weight,
|
|
116
|
+
earlier,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Highest score first; ties go to the newest item, then to a stable id order. */
|
|
121
|
+
export function compareFeedItems(left: FeedItem, right: FeedItem): number {
|
|
122
|
+
if (right.score !== left.score) return right.score - left.score;
|
|
123
|
+
const newest = (referenceDisplayTime(right.reference) ?? 0) - (referenceDisplayTime(left.reference) ?? 0);
|
|
124
|
+
return newest || left.lineageId.localeCompare(right.lineageId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type ReferenceFeedTabKey = "feed" | "rated" | "earlier";
|
|
128
|
+
|
|
129
|
+
export type ReferenceFeedPartition = {
|
|
130
|
+
freshness: FeedFreshness;
|
|
131
|
+
/** Pending items, plus the ones rated during this visit (they keep their place), best prediction first. */
|
|
132
|
+
feed: FeedItem[];
|
|
133
|
+
/** Everything an editor has answered, most recent answer first. */
|
|
134
|
+
rated: FeedItem[];
|
|
135
|
+
/** Unrated items past the freshness threshold, ranked like the feed. Still ratable. */
|
|
136
|
+
earlier: FeedItem[];
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Splits canonical References into the three tabs.
|
|
141
|
+
* `visitRated` holds lineage ids rated since the page opened: they stay in Feed, in place, showing the answer.
|
|
142
|
+
*/
|
|
143
|
+
export function partitionReferenceFeed(
|
|
144
|
+
references: ReferenceRecord[],
|
|
145
|
+
decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
|
|
146
|
+
options: { now: number; visitRated?: ReadonlySet<string>; tauDaysOverride?: number | null },
|
|
147
|
+
): ReferenceFeedPartition {
|
|
148
|
+
const visitRated = options.visitRated ?? new Set<string>();
|
|
149
|
+
const learned = learnFreshness(references, visitRated);
|
|
150
|
+
const freshness: FeedFreshness = options.tauDaysOverride && options.tauDaysOverride > 0
|
|
151
|
+
? { ...learned, tauDays: options.tauDaysOverride }
|
|
152
|
+
: learned;
|
|
153
|
+
const feed: FeedItem[] = [];
|
|
154
|
+
const rated: FeedItem[] = [];
|
|
155
|
+
const earlier: FeedItem[] = [];
|
|
156
|
+
for (const reference of references) {
|
|
157
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
158
|
+
const lineageId = referenceLineageId(reference);
|
|
159
|
+
const item = scoreFeedItem(reference, decisions?.get(lineageId), freshness, options.now);
|
|
160
|
+
if (status === "archived") continue;
|
|
161
|
+
if (status === "accepted" || status === "rejected") {
|
|
162
|
+
rated.push(item);
|
|
163
|
+
if (visitRated.has(lineageId)) (item.earlier ? earlier : feed).push(item);
|
|
164
|
+
} else if (item.earlier) earlier.push(item);
|
|
165
|
+
else feed.push(item);
|
|
166
|
+
}
|
|
167
|
+
feed.sort(compareFeedItems);
|
|
168
|
+
earlier.sort(compareFeedItems);
|
|
169
|
+
rated.sort((left, right) => (timeOf(right.reference.curationStatusUpdatedAt) ?? 0) - (timeOf(left.reference.curationStatusUpdatedAt) ?? 0)
|
|
170
|
+
|| left.lineageId.localeCompare(right.lineageId));
|
|
171
|
+
return { freshness, feed, rated, earlier };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type FeedGroupKey = "top" | "more" | "least";
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The feed's sections, from the ranked list (already best first). No fixed counts: Top predictions are the Accepts at least half as
|
|
178
|
+
* strong as the strongest one, Least likely are the predicted Rejects, and More for you is everything between (weaker Accepts and
|
|
179
|
+
* items with no prediction). A group with no members is absent.
|
|
180
|
+
*/
|
|
181
|
+
export function groupFeedItems(items: FeedItem[]): Array<{ key: FeedGroupKey; items: FeedItem[] }> {
|
|
182
|
+
const strongest = items.reduce((best, item) => Math.max(best, item.score - UNDECIDED_PROBABILITY), 0);
|
|
183
|
+
const groups: Record<FeedGroupKey, FeedItem[]> = { top: [], more: [], least: [] };
|
|
184
|
+
for (const item of items) {
|
|
185
|
+
const strength = item.score - UNDECIDED_PROBABILITY;
|
|
186
|
+
if (strength < 0) groups.least.push(item);
|
|
187
|
+
else if (strength > 0 && strength >= strongest / 2) groups.top.push(item);
|
|
188
|
+
else groups.more.push(item);
|
|
189
|
+
}
|
|
190
|
+
return (["top", "more", "least"] as const).filter((key) => groups[key].length).map((key) => ({ key, items: groups[key] }));
|
|
191
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Reference card shows besides its title: the source page's preview image, a one-line dek and the site name.
|
|
3
|
+
*
|
|
4
|
+
* Storage: a `ReferenceAttachment` with role "preview" (sort key "002-preview"), no schema change. `sourceUri` is the
|
|
5
|
+
* page's og:image / twitter:image address and `metadata` holds `{ description, siteName, title, fetchedAt }`. The newsroom
|
|
6
|
+
* already loads every attachment with the References, so the feed needs no extra request. `papyrus references enrich-images`
|
|
7
|
+
* (src/papyrus_content/reference_images.py) writes them.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReferenceAttachmentRecord, ReferenceRecord } from "./category-repository";
|
|
10
|
+
|
|
11
|
+
export const REFERENCE_PREVIEW_ROLE = "preview";
|
|
12
|
+
|
|
13
|
+
export type ReferencePreview = {
|
|
14
|
+
imageUrl: string | null;
|
|
15
|
+
description: string | null;
|
|
16
|
+
siteName: string | null;
|
|
17
|
+
title: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function record(value: unknown): Record<string, unknown> {
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
try { return record(JSON.parse(value)); } catch { return {}; }
|
|
23
|
+
}
|
|
24
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function text(value: unknown): string | null {
|
|
28
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function httpAddress(value: unknown): string | null {
|
|
32
|
+
const candidate = text(value);
|
|
33
|
+
return candidate && /^https?:\/\//i.test(candidate) ? candidate : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The preview of each Reference lineage, from its "preview" attachment. A lineage with none has no entry. */
|
|
37
|
+
export function referencePreviewsByLineage(attachments: ReferenceAttachmentRecord[] | undefined): Map<string, ReferencePreview> {
|
|
38
|
+
const previews = new Map<string, ReferencePreview>();
|
|
39
|
+
for (const attachment of attachments ?? []) {
|
|
40
|
+
if (attachment.role !== REFERENCE_PREVIEW_ROLE || !attachment.referenceLineageId) continue;
|
|
41
|
+
const metadata = record(attachment.metadata);
|
|
42
|
+
previews.set(attachment.referenceLineageId, {
|
|
43
|
+
imageUrl: httpAddress(attachment.sourceUri) ?? httpAddress(metadata.imageUrl),
|
|
44
|
+
description: text(metadata.description),
|
|
45
|
+
siteName: text(metadata.siteName),
|
|
46
|
+
title: text(metadata.title),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return previews;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The one-line dek: the source page's description, else a summary recorded on the Reference. */
|
|
53
|
+
export function referenceDek(reference: ReferenceRecord, preview: ReferencePreview | undefined): string | null {
|
|
54
|
+
if (preview?.description) return preview.description;
|
|
55
|
+
const metadata = record(reference.metadata);
|
|
56
|
+
for (const key of ["subtitle", "summary", "abstract", "description"]) {
|
|
57
|
+
const value = text(metadata[key]);
|
|
58
|
+
if (value) return value;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The source's host without "www.", or null when the Reference has no web address. */
|
|
64
|
+
export function referenceSourceHost(reference: ReferenceRecord): string | null {
|
|
65
|
+
const candidate = httpAddress(reference.sourceUri);
|
|
66
|
+
if (!candidate) return null;
|
|
67
|
+
try { return new URL(candidate).hostname.replace(/^www\./i, "") || null; } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Short age for a card: "5m", "3h", "2d", "6w", "4mo". */
|
|
71
|
+
export function formatFeedAge(time: number | null, now: number): string | null {
|
|
72
|
+
if (time === null) return null;
|
|
73
|
+
const seconds = Math.max(0, Math.round((now - time) / 1000));
|
|
74
|
+
if (seconds < 60) return "now";
|
|
75
|
+
const minutes = Math.round(seconds / 60);
|
|
76
|
+
if (minutes < 60) return `${minutes}m`;
|
|
77
|
+
const hours = Math.round(minutes / 60);
|
|
78
|
+
if (hours < 24) return `${hours}h`;
|
|
79
|
+
const days = Math.round(hours / 24);
|
|
80
|
+
if (days < 14) return `${days}d`;
|
|
81
|
+
if (days < 60) return `${Math.round(days / 7)}w`;
|
|
82
|
+
return `${Math.round(days / 30)}mo`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function words(text: string): string[] {
|
|
86
|
+
return text.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, " ").split(" ").filter((word) => word.length > 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether the dek tells the reader something the headline and picture do not. A dek that repeats the headline, or that is mostly
|
|
91
|
+
* the headline's own words, is dropped; so is the description of a GitHub repository, whose social-card picture already prints it.
|
|
92
|
+
*/
|
|
93
|
+
export function dekAddsInformation(title: string, dek: string | null, imageUrl: string | null): boolean {
|
|
94
|
+
if (!dek) return false;
|
|
95
|
+
let imageHost = "";
|
|
96
|
+
try { imageHost = imageUrl ? new URL(imageUrl).hostname : ""; } catch { /* no host */ }
|
|
97
|
+
if (/(^|\.)opengraph\.githubassets\.com$/i.test(imageHost)) return false;
|
|
98
|
+
const titleWords = new Set(words(title));
|
|
99
|
+
const dekWords = words(dek);
|
|
100
|
+
if (!dekWords.length) return false;
|
|
101
|
+
const shared = dekWords.filter((word) => titleWords.has(word)).length;
|
|
102
|
+
const covered = titleWords.size ? [...titleWords].filter((word) => dekWords.includes(word)).length / titleWords.size : 0;
|
|
103
|
+
return shared / dekWords.length < 0.6 && covered < 0.8;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Pictures that are a text card, not a photograph: a repository social card, or any picture whose ratio is almost exactly 2:1. Cropping cuts the words off. */
|
|
107
|
+
export function isCardStyleImage(url: string | null, naturalRatio: number | null): boolean {
|
|
108
|
+
let host = "";
|
|
109
|
+
try { host = url ? new URL(url).hostname : ""; } catch { /* no host */ }
|
|
110
|
+
if (/(^|\.)(opengraph\.githubassets\.com|repository-images\.githubusercontent\.com|github\.com)$/i.test(host)) return true;
|
|
111
|
+
return naturalRatio !== null && Math.abs(naturalRatio - 2) < 0.04;
|
|
112
|
+
}
|
package/lib/review-client.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import { getCurrentUser } from "aws-amplify/auth";
|
|
5
|
+
import { draftDecisionUpdate, draftView, type DraftRow, type DraftView } from "./review-drafts";
|
|
5
6
|
import {
|
|
6
7
|
answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
|
|
7
|
-
type AnswerInput, type ReviewRequest,
|
|
8
|
+
type AnswerInput, type DraftDecision, type ReviewRequest,
|
|
8
9
|
} from "./review-requests";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -118,3 +119,39 @@ export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<s
|
|
|
118
119
|
}));
|
|
119
120
|
return { titles, deleted };
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
const MAX_DRAFT_LOOKUPS = 60;
|
|
124
|
+
const DRAFT_FIELDS = "id kind title summary body state held evidence history override createdAt";
|
|
125
|
+
|
|
126
|
+
/** Records one draft's decision on the request while it stays open. Same condition as an answer: state = open. */
|
|
127
|
+
export async function saveDraftDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): Promise<ReviewRequest> {
|
|
128
|
+
const result = await run(
|
|
129
|
+
`mutation Decide($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
130
|
+
{ input: draftDecisionUpdate(request, draftId, decision), condition: { state: { eq: "open" } } },
|
|
131
|
+
);
|
|
132
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
133
|
+
throw new Error("Someone else answered this, or it closed at its deadline. Refresh to see the result.");
|
|
134
|
+
}
|
|
135
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("The draft was decided, but the request could not record it. Try again.");
|
|
136
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type DraftLoad = { drafts: Record<string, DraftView>; missing: string[]; failed: string[] };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The Proposals that review requests are about, by id (editors and admins may read them). `missing` ids have no proposal (an idea kept
|
|
143
|
+
* only as JSON has no proposal to open); `failed` ids could not be read right now, which says nothing about whether they exist.
|
|
144
|
+
*/
|
|
145
|
+
export async function loadDrafts(ids: string[], demo = false): Promise<DraftLoad> {
|
|
146
|
+
const drafts: Record<string, DraftView> = {};
|
|
147
|
+
const missing: string[] = [];
|
|
148
|
+
const failed: string[] = [];
|
|
149
|
+
await Promise.all([...new Set(ids)].slice(0, MAX_DRAFT_LOOKUPS).map(async (id) => {
|
|
150
|
+
const result = await run(`query Draft($id: ID!) { getProposal(id: $id) { ${DRAFT_FIELDS} } }`, { id });
|
|
151
|
+
const row = result.data?.getProposal as DraftRow | null | undefined;
|
|
152
|
+
if (row) drafts[id] = draftView(row, demo);
|
|
153
|
+
else if (result.errors?.length || !result.data) failed.push(id);
|
|
154
|
+
else missing.push(id);
|
|
155
|
+
}));
|
|
156
|
+
return { drafts, missing, failed };
|
|
157
|
+
}
|