@anthusai/papyrus 5.57.0 → 5.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +25 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +25 -3
- package/amplify/data/schema.ts +25 -3
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-thread.tsx +27 -12
- package/components/conversations-memory-source.ts +46 -0
- package/components/conversations-network-source.ts +40 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +24 -0
- package/components/conversations-view.tsx +319 -0
- package/components/decision-control.tsx +307 -0
- package/components/needs-me-bodies.tsx +4 -3
- package/components/needs-me-feed.tsx +8 -14
- package/components/news-desk-taxonomy-client.ts +83 -14
- package/components/newsroom-organizer.css +24 -0
- package/components/notification-cards.tsx +5 -4
- package/components/reference-feed.tsx +60 -42
- package/components/reference-review-card.tsx +8 -134
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +40 -782
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +7 -0
- package/lib/conversation-feed.ts +96 -0
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +9 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
5
|
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
-
import type { RatingFeedback } from "./
|
|
6
|
+
import type { RatingFeedback } from "./decision-control";
|
|
7
7
|
|
|
8
8
|
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
9
|
export const NOTE_AUTOSAVE_MS = 1200;
|
|
@@ -38,8 +38,8 @@ export type RatingController = {
|
|
|
38
38
|
* shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
|
|
39
39
|
* vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
|
|
40
40
|
* reason, then vote -> the vote carries the reason
|
|
41
|
-
* A Reject needs a reason
|
|
42
|
-
* kept in the store until it is saved and is never dropped by a failed save.
|
|
41
|
+
* A Reject needs a reason (the backend refuses one without): words or a reason code, whichever the surface asks for. With neither it
|
|
42
|
+
* waits as `pending` until one arrives; a typed reason is kept in the store until it is saved and is never dropped by a failed save.
|
|
43
43
|
*/
|
|
44
44
|
export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
|
|
45
45
|
save: RatingSave;
|
|
@@ -110,13 +110,17 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
|
|
|
110
110
|
|
|
111
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
112
|
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
113
|
-
const { note, reasonCode
|
|
114
|
-
// A typed reason is a reason
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
const { note, reasonCode } = values(id);
|
|
114
|
+
// A typed reason is a reason, so a Reject with words goes at once rather than waiting. It carries no
|
|
115
|
+
// code the editor did not pick: the backend reads the words for the code they name, and files words
|
|
116
|
+
// that name none under "other". Only a Reject with neither words nor a code waits for one.
|
|
117
|
+
if (choice === "reject" && !reasonCode && !note.trim() && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
117
118
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
118
119
|
inFlight.current.add(id);
|
|
119
120
|
dirty.current.delete(id);
|
|
121
|
+
// The note goes out with this save, so its own pending autosave has nothing left to send.
|
|
122
|
+
dirty.current.delete(`note:${id}`);
|
|
123
|
+
clearTimeout(timers.current[id]);
|
|
120
124
|
const started = itemsRef.current[id] ?? EMPTY;
|
|
121
125
|
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
122
126
|
try {
|
|
@@ -280,6 +280,13 @@ export type ReferenceRecord = {
|
|
|
280
280
|
curationStatusUpdatedAt?: string | null;
|
|
281
281
|
curationStatusUpdatedBy?: string | null;
|
|
282
282
|
curationStatusReason?: string | null;
|
|
283
|
+
/** The latest vote's details; a Reference keeps only its latest vote (earlier votes live in the Cyclotron's feedback). */
|
|
284
|
+
curationReasonCode?: string | null;
|
|
285
|
+
curationActorKind?: string | null;
|
|
286
|
+
curationSectionId?: string | null;
|
|
287
|
+
curationShareable?: boolean | null;
|
|
288
|
+
curationDelegation?: unknown;
|
|
289
|
+
curationDecisionRelationId?: string | null;
|
|
283
290
|
newsroomFeedKey?: string | null;
|
|
284
291
|
reviewedFeedKey?: string | null;
|
|
285
292
|
metadata?: unknown;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Messages page: which MessageThreads are conversations, which section each belongs to, and how several newest-first streams
|
|
3
|
+
* (one per thread kind) merge into one page of conversations ordered by last activity. Pure helpers, shared by the browser and the tests.
|
|
4
|
+
*/
|
|
5
|
+
import { GENERAL_CONVERSATION_KIND, REFERENCE_CONVERSATION_KIND, STORY_CONVERSATION_KIND } from "./conversation";
|
|
6
|
+
|
|
7
|
+
export type ConversationSection = "all" | "references" | "stories" | "chats" | "other";
|
|
8
|
+
export const CONVERSATION_SECTIONS: ReadonlyArray<{ key: ConversationSection; label: string }> = [
|
|
9
|
+
{ key: "all", label: "All" },
|
|
10
|
+
{ key: "references", label: "References" },
|
|
11
|
+
{ key: "stories", label: "Stories" },
|
|
12
|
+
{ key: "chats", label: "Chats" },
|
|
13
|
+
{ key: "other", label: "Other" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const CHAT_THREAD_KIND = "console";
|
|
17
|
+
export const FORUM_THREAD_KINDS = ["edition_forum", "section_forum"] as const;
|
|
18
|
+
|
|
19
|
+
/** The thread kinds that are conversations, by section. Machine records (provenance, curation, ingestion rationale) are in none of them. */
|
|
20
|
+
const KINDS_BY_SECTION: Record<Exclude<ConversationSection, "all">, ReadonlyArray<string>> = {
|
|
21
|
+
references: [REFERENCE_CONVERSATION_KIND],
|
|
22
|
+
stories: [STORY_CONVERSATION_KIND],
|
|
23
|
+
chats: [CHAT_THREAD_KIND],
|
|
24
|
+
other: [GENERAL_CONVERSATION_KIND, ...FORUM_THREAD_KINDS],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function threadKindsForSection(section: ConversationSection): string[] {
|
|
28
|
+
if (section === "all") return Object.values(KINDS_BY_SECTION).flat();
|
|
29
|
+
return [...KINDS_BY_SECTION[section]];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The section a thread kind is listed under, or null for a kind that is not a conversation. */
|
|
33
|
+
export function sectionOfThreadKind(kind: string): Exclude<ConversationSection, "all"> | null {
|
|
34
|
+
for (const [section, kinds] of Object.entries(KINDS_BY_SECTION)) if (kinds.includes(kind)) return section as Exclude<ConversationSection, "all">;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SECTION_EMPTY_COPY: Record<ConversationSection, string> = {
|
|
39
|
+
all: "No conversations yet. Write a message above, or on a Reference, to start one.",
|
|
40
|
+
references: "No Reference has a message yet. Open a Reference and write down what it made you think.",
|
|
41
|
+
stories: "No Story has a message yet.",
|
|
42
|
+
chats: "No chats yet.",
|
|
43
|
+
other: "No other conversations yet. Write a message above to start one.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type PagedThreads<Thread> = { items: Thread[]; nextToken: string | null };
|
|
47
|
+
|
|
48
|
+
type Streamable = { id: string; updatedAt: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One page at a time out of several newest-first streams, merged by `updatedAt` (the order of every thread index). A stream is only
|
|
52
|
+
* asked for another page when its buffered threads have all been shown, so the order across streams is exact, never approximate.
|
|
53
|
+
*/
|
|
54
|
+
export function createMergedThreadStream<Thread extends Streamable>(
|
|
55
|
+
kinds: ReadonlyArray<string>,
|
|
56
|
+
loadPage: (kind: string, nextToken: string | null) => Promise<PagedThreads<Thread>>,
|
|
57
|
+
) {
|
|
58
|
+
const lanes = kinds.map((kind) => ({ kind, buffer: [] as Thread[], nextToken: null as string | null, started: false, done: false }));
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
|
|
61
|
+
async function fill() {
|
|
62
|
+
await Promise.all(lanes.filter((lane) => !lane.done && lane.buffer.length === 0).map(async (lane) => {
|
|
63
|
+
const page = await loadPage(lane.kind, lane.started ? lane.nextToken : null);
|
|
64
|
+
lane.started = true;
|
|
65
|
+
lane.nextToken = page.nextToken;
|
|
66
|
+
lane.done = !page.nextToken;
|
|
67
|
+
lane.buffer = [...page.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
/** The next `count` conversations, newest first, and whether more remain. */
|
|
73
|
+
async next(count: number): Promise<{ items: Thread[]; hasMore: boolean }> {
|
|
74
|
+
const items: Thread[] = [];
|
|
75
|
+
while (items.length < count) {
|
|
76
|
+
await fill();
|
|
77
|
+
const live = lanes.filter((lane) => lane.buffer.length > 0);
|
|
78
|
+
if (!live.length) break;
|
|
79
|
+
// A lane that is not finished and has an empty buffer after a fill returned an empty page with more behind it: ask again.
|
|
80
|
+
if (lanes.some((lane) => !lane.done && lane.buffer.length === 0)) continue;
|
|
81
|
+
const best = live.reduce((winner, lane) => (lane.buffer[0].updatedAt.localeCompare(winner.buffer[0].updatedAt) > 0 ? lane : winner));
|
|
82
|
+
const thread = best.buffer.shift()!;
|
|
83
|
+
if (seen.has(thread.id)) continue;
|
|
84
|
+
seen.add(thread.id);
|
|
85
|
+
items.push(thread);
|
|
86
|
+
}
|
|
87
|
+
return { items, hasMore: lanes.some((lane) => lane.buffer.length > 0 || !lane.done) };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The newest `limit` messages, oldest first, and how many are held back behind "Show all". */
|
|
93
|
+
export function latestMessages<Message>(messages: ReadonlyArray<Message>, limit: number, showAll: boolean): { shown: Message[]; hidden: number } {
|
|
94
|
+
if (showAll || messages.length <= limit) return { shown: [...messages], hidden: 0 };
|
|
95
|
+
return { shown: messages.slice(messages.length - limit), hidden: messages.length - limit };
|
|
96
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
2
2
|
import type { RelevanceCuration, RelevanceDecisionView } from "./relevance-decisions";
|
|
3
3
|
import { safeId } from "./relevance-status";
|
|
4
4
|
|
|
@@ -149,22 +149,12 @@ function parseMetadata(value: unknown): Record<string, unknown> {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* Reference lineage ids whose latest
|
|
153
|
-
*
|
|
154
|
-
* does a reference a person decided before a later non-human change.
|
|
152
|
+
* Reference lineage ids whose latest vote was made by a signed-in person
|
|
153
|
+
* (`curationActorKind` is `human`). Agent, Cyclotron and rule votes never appear
|
|
154
|
+
* here, and neither does a reference a person decided before a later non-human change.
|
|
155
155
|
*/
|
|
156
|
-
export function referencesWithHumanLatestCuration(
|
|
157
|
-
|
|
158
|
-
for (const message of messages) {
|
|
159
|
-
if (message.messageKind !== "reference_curation") continue;
|
|
160
|
-
const metadata = parseMetadata(message.metadata);
|
|
161
|
-
const lineageId = typeof metadata.referenceLineageId === "string" ? metadata.referenceLineageId : "";
|
|
162
|
-
if (!lineageId) continue;
|
|
163
|
-
const seen = latestByReference.get(lineageId);
|
|
164
|
-
if (seen && seen.createdAt >= message.createdAt) continue;
|
|
165
|
-
latestByReference.set(lineageId, { createdAt: message.createdAt, human: metadata.actorKind === "human" });
|
|
166
|
-
}
|
|
167
|
-
return new Set([...latestByReference].filter(([, entry]) => entry.human).map(([lineageId]) => lineageId));
|
|
156
|
+
export function referencesWithHumanLatestCuration(references: readonly ReferenceRecord[]): Set<string> {
|
|
157
|
+
return new Set(references.filter((reference) => reference.curationActorKind === "human").map((reference) => reference.lineageId ?? reference.id));
|
|
168
158
|
}
|
|
169
159
|
|
|
170
160
|
export type SuggestionChoice = "agree" | "overrule";
|
|
@@ -233,30 +223,22 @@ export function delegationShortId(delegationId: string): string {
|
|
|
233
223
|
}
|
|
234
224
|
|
|
235
225
|
/**
|
|
236
|
-
* Delegated curations by reference lineage, from each
|
|
237
|
-
*
|
|
226
|
+
* Delegated curations by reference lineage, from each Reference's latest vote only.
|
|
227
|
+
* The person is who set the status (`curationStatusUpdatedBy`); the agent is the delegation's relayedBy.
|
|
238
228
|
*/
|
|
239
|
-
export function delegatedCurationsByReference(
|
|
240
|
-
const latest = new Map<string, MessageRecord>();
|
|
241
|
-
for (const message of messages) {
|
|
242
|
-
if (message.messageKind !== "reference_curation") continue;
|
|
243
|
-
const lineageId = parseMetadata(message.metadata).referenceLineageId;
|
|
244
|
-
if (typeof lineageId !== "string" || !lineageId) continue;
|
|
245
|
-
const seen = latest.get(lineageId);
|
|
246
|
-
if (!seen || seen.createdAt < message.createdAt) latest.set(lineageId, message);
|
|
247
|
-
}
|
|
229
|
+
export function delegatedCurationsByReference(references: readonly ReferenceRecord[]): Map<string, DelegatedCuration> {
|
|
248
230
|
const delegated = new Map<string, DelegatedCuration>();
|
|
249
|
-
for (const
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
252
|
-
const delegationId = typeof
|
|
253
|
-
delegated.set(lineageId, {
|
|
254
|
-
agent: typeof
|
|
255
|
-
person:
|
|
256
|
-
instruction: typeof
|
|
231
|
+
for (const reference of references) {
|
|
232
|
+
const delegation = parseMetadata(reference.curationDelegation);
|
|
233
|
+
if (!Object.keys(delegation).length) continue;
|
|
234
|
+
const delegationId = typeof delegation.delegationId === "string" ? delegation.delegationId : "";
|
|
235
|
+
delegated.set(reference.lineageId ?? reference.id, {
|
|
236
|
+
agent: typeof delegation.relayedBy === "string" && delegation.relayedBy ? delegation.relayedBy : "an agent",
|
|
237
|
+
person: reference.curationStatusUpdatedBy?.trim() || null,
|
|
238
|
+
instruction: typeof delegation.instruction === "string" ? delegation.instruction : "",
|
|
257
239
|
delegationId,
|
|
258
240
|
delegationShortId: delegationShortId(delegationId),
|
|
259
|
-
countsAsTrainingLabel:
|
|
241
|
+
countsAsTrainingLabel: reference.curationActorKind === "human",
|
|
260
242
|
});
|
|
261
243
|
}
|
|
262
244
|
return delegated;
|
|
@@ -210,15 +210,16 @@ const PREVIEW_CYCLOTRON_ID = "papyrus-relevance";
|
|
|
210
210
|
/**
|
|
211
211
|
* Fixture data for reviewing the cyclotron suggestion UI without a backend:
|
|
212
212
|
* suggestions with provenance on the first references, a mix of human and
|
|
213
|
-
* non-human
|
|
213
|
+
* non-human votes recorded on the References, and a live-agreement payload.
|
|
214
214
|
*/
|
|
215
215
|
export function createLocalPreviewCyclotronFixtures(references: ReferenceRecord[]): {
|
|
216
216
|
relations: SemanticRelationRecord[];
|
|
217
|
-
|
|
217
|
+
/** The first few References with a latest vote recorded on them, by Reference id. */
|
|
218
|
+
votedReferences: Map<string, ReferenceRecord>;
|
|
218
219
|
shadowAgreement: ShadowAgreement;
|
|
219
220
|
} {
|
|
220
221
|
const relations: SemanticRelationRecord[] = [];
|
|
221
|
-
const
|
|
222
|
+
const votedReferences = new Map<string, ReferenceRecord>();
|
|
222
223
|
references.slice(0, PREVIEW_SUGGESTION_PLANS.length).forEach((reference, index) => {
|
|
223
224
|
const plan = PREVIEW_SUGGESTION_PLANS[index];
|
|
224
225
|
const lineageId = reference.lineageId ?? reference.id;
|
|
@@ -264,35 +265,23 @@ export function createLocalPreviewCyclotronFixtures(references: ReferenceRecord[
|
|
|
264
265
|
}),
|
|
265
266
|
} as SemanticRelationRecord);
|
|
266
267
|
if (plan.curation !== "none") {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
metadata: JSON.stringify({
|
|
279
|
-
action: reference.curationStatus === "rejected" ? "reject" : "accept",
|
|
280
|
-
curationStatus: reference.curationStatus,
|
|
281
|
-
referenceLineageId: lineageId,
|
|
282
|
-
actorKind: plan.curation === "service" ? "service" : "human",
|
|
283
|
-
...(plan.curation === "delegated" ? {
|
|
284
|
-
attestation: "delegated",
|
|
285
|
-
relayedBy: "newsroom-reporter",
|
|
286
|
-
delegationId: "curation-delegation-9b1f6c24-5d0e-4a7b-8c31-2e6f0a1d7b45",
|
|
287
|
-
instruction: "Reject that one, it duplicates the filing from Tuesday.",
|
|
288
|
-
} : {}),
|
|
289
|
-
}),
|
|
268
|
+
// A Reference keeps its latest vote on itself (no Message): the details sit in its curation* fields.
|
|
269
|
+
votedReferences.set(reference.id, {
|
|
270
|
+
...reference,
|
|
271
|
+
curationStatusUpdatedAt: isoTimestampHoursBeforeBase(30 - index),
|
|
272
|
+
curationStatusUpdatedBy: plan.curation === "delegated" ? "Maya Okafor" : reference.curationStatusUpdatedBy,
|
|
273
|
+
curationActorKind: plan.curation === "service" ? "agent" : "human",
|
|
274
|
+
curationDelegation: plan.curation === "delegated" ? {
|
|
275
|
+
delegationId: "curation-delegation-9b1f6c24",
|
|
276
|
+
relayedBy: "newsroom-reporter",
|
|
277
|
+
instruction: "Reject that one, it duplicates the filing from Tuesday.",
|
|
278
|
+
} : null,
|
|
290
279
|
});
|
|
291
280
|
}
|
|
292
281
|
});
|
|
293
282
|
return {
|
|
294
283
|
relations,
|
|
295
|
-
|
|
284
|
+
votedReferences,
|
|
296
285
|
shadowAgreement: {
|
|
297
286
|
schema: SHADOW_AGREEMENT_SCHEMA,
|
|
298
287
|
comparisons: 23,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys the References page reads through (docs/reference-feed-indexes.md). The Python twin is
|
|
3
|
+
* src/papyrus_content/reference_feed_keys.py; scripts/fixtures/reference-feed-key-vectors.json holds the vectors both test.
|
|
4
|
+
*
|
|
5
|
+
* `feedStateKey` is "<corpusId>#unrated" (pending) or "<corpusId>#rated" (accepted or rejected); a Reference that is not the current
|
|
6
|
+
* version, or is archived, has none and is in neither feed. `predictionSortKey` is the Cyclotron's ORIGINAL P(accept), then arrival, then
|
|
7
|
+
* id; a Reference nobody predicted sorts as 0.5. A vote changes `feedStateKey` and nothing else here.
|
|
8
|
+
*/
|
|
9
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
10
|
+
|
|
11
|
+
export const UNDECIDED_PROBABILITY = 0.5;
|
|
12
|
+
const EPOCH = "1970-01-01T00:00:00.000Z";
|
|
13
|
+
const ARRIVAL_FIELDS = ["createdAt", "importedAt", "retrievedAt", "sourcePublishedAt", "versionCreatedAt"] as const;
|
|
14
|
+
|
|
15
|
+
export type ReferenceFeedKeyInput = {
|
|
16
|
+
id: string;
|
|
17
|
+
corpusId?: string | null;
|
|
18
|
+
curationStatus?: string | null;
|
|
19
|
+
versionState?: string | null;
|
|
20
|
+
predictedAcceptProbability?: number | null;
|
|
21
|
+
createdAt?: string | null;
|
|
22
|
+
importedAt?: string | null;
|
|
23
|
+
retrievedAt?: string | null;
|
|
24
|
+
sourcePublishedAt?: string | null;
|
|
25
|
+
versionCreatedAt?: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ReferenceFeedKeys = {
|
|
29
|
+
feedStateKey: string | null;
|
|
30
|
+
feedArrivalAt: string;
|
|
31
|
+
predictionSortKey: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function instant(value: string | null | undefined): string | null {
|
|
35
|
+
if (!value) return null;
|
|
36
|
+
const parsed = Date.parse(value);
|
|
37
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function referenceFeedArrivalAt(reference: ReferenceFeedKeyInput): string {
|
|
41
|
+
for (const field of ARRIVAL_FIELDS) {
|
|
42
|
+
const found = instant(reference[field]);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
}
|
|
45
|
+
return EPOCH;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function referenceFeedStateKey(reference: ReferenceFeedKeyInput): string | null {
|
|
49
|
+
if (reference.versionState && reference.versionState !== "current") return null;
|
|
50
|
+
if (!reference.corpusId) return null;
|
|
51
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
52
|
+
if (status === "archived") return null;
|
|
53
|
+
return `${reference.corpusId}#${status === "pending" ? "unrated" : "rated"}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function predictionSortKeyFor(probability: number | null | undefined, arrival: string, referenceId: string): string {
|
|
57
|
+
const chosen = probability === null || probability === undefined ? UNDECIDED_PROBABILITY : Math.min(1, Math.max(0, probability));
|
|
58
|
+
return `${chosen.toFixed(6)}#${arrival}#${referenceId}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The three feed keys for a Reference as it stands. `probability` overrides the one stored on it. */
|
|
62
|
+
export function referenceFeedKeys(reference: ReferenceFeedKeyInput, probability?: number | null): ReferenceFeedKeys {
|
|
63
|
+
const arrival = referenceFeedArrivalAt(reference);
|
|
64
|
+
return {
|
|
65
|
+
feedStateKey: referenceFeedStateKey(reference),
|
|
66
|
+
feedArrivalAt: arrival,
|
|
67
|
+
predictionSortKey: predictionSortKeyFor(probability ?? reference.predictedAcceptProbability, arrival, reference.id),
|
|
68
|
+
};
|
|
69
|
+
}
|
package/lib/reference-policy.ts
CHANGED
|
@@ -22,6 +22,84 @@ export function isScopeTrainingNegativeReason(code: string): boolean {
|
|
|
22
22
|
return SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(code);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The phrases an editor writes when they mean one reason code and little else.
|
|
27
|
+
*
|
|
28
|
+
* The order matters, and the two scope-training codes come LAST: a sentence
|
|
29
|
+
* that also names a mechanical reason ("already have it, and off our beat") is
|
|
30
|
+
* filed under that reason instead. A false scope negative teaches the relevance
|
|
31
|
+
* classifier something wrong; a missed one only leaves it untaught.
|
|
32
|
+
*
|
|
33
|
+
* Each phrase is a whole word or wording, never a fragment, so "slopes" is not
|
|
34
|
+
* read as "slop". Words that say none of this yield no code at all rather than
|
|
35
|
+
* the nearest guess.
|
|
36
|
+
*/
|
|
37
|
+
const WRITTEN_REASON_PHRASES: ReadonlyArray<readonly [ReferenceRejectionReasonCode, readonly string[]]> = [
|
|
38
|
+
["duplicate", [
|
|
39
|
+
"duplicate", "duplicates", "duplicated", "duplication", "dupe", "reprint", "reprinted",
|
|
40
|
+
"already have (it|this|that|the)", "already (got|covered|accepted|registered|ingested|indexed)",
|
|
41
|
+
"already in (our|the) (corpus|list|feed|knowledge base|library)", "same (source|piece|story) as",
|
|
42
|
+
]],
|
|
43
|
+
["unavailable", [
|
|
44
|
+
"paywall", "paywalled", "unavailable", "not available", "no longer (available|online|up|there|reachable)",
|
|
45
|
+
"behind a (paywall|login|subscription|sign in)", "(dead|broken|404) link", "link is (dead|broken)",
|
|
46
|
+
"(cannot|cant) (open|read|reach|access|download|retrieve)", "requires a (subscription|login|account|sign in)",
|
|
47
|
+
"taken down", "timed out", "page is gone",
|
|
48
|
+
]],
|
|
49
|
+
["provenance", [
|
|
50
|
+
"provenance", "unattributed", "unverified", "unverifiable", "untraceable",
|
|
51
|
+
"(cannot|cant) (verify|attribute)", "no (byline|author|citation|attribution|provenance)",
|
|
52
|
+
"(unknown|unclear|anonymous|missing) (author|source|origin|provenance)", "who wrote it",
|
|
53
|
+
]],
|
|
54
|
+
["low_quality", [
|
|
55
|
+
"(low|poor) quality", "poorly (written|sourced|argued|researched|edited)", "badly written", "sloppy",
|
|
56
|
+
"content farm", "clickbait", "seo (spam|bait|filler)", "slop", "junk", "spam", "no substance", "churnalism",
|
|
57
|
+
]],
|
|
58
|
+
["policy_exclusion", [
|
|
59
|
+
"policy exclusion", "(our|house|editorial) policy", "against (our )?policy",
|
|
60
|
+
"(policy|policies) (excludes?|forbids?|bars?|rules? out|says?)",
|
|
61
|
+
"(excluded|barred|ruled out|forbidden) by (our )?policy",
|
|
62
|
+
"we (dont|do not) (cover|run|publish|cite|take)", "not allowed", "disallowed", "prohibited",
|
|
63
|
+
"blocklist", "blocklisted", "blocked source",
|
|
64
|
+
]],
|
|
65
|
+
["out_of_scope", [
|
|
66
|
+
"out of scope", "outside (our|the) (scope|remit|coverage|beat|lane)", "not (in|within) (our|the) scope",
|
|
67
|
+
"off topic", "nothing to do with", "not (our|the) (beat|subject|topic|area|coverage)",
|
|
68
|
+
"not relevant", "no longer relevant", "irrelevant", "unrelated", "wrong (beat|subject|topic)",
|
|
69
|
+
]],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const WRITTEN_REASON_PATTERNS: ReadonlyArray<readonly [ReferenceRejectionReasonCode, RegExp]> =
|
|
73
|
+
WRITTEN_REASON_PHRASES.map(([code, phrases]) => [code, new RegExp(`\\b(?:${phrases.join("|")})\\b`)] as const);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The reason code a rejection is filed under when the editor gave none: what
|
|
77
|
+
* their own words plainly say, else "other".
|
|
78
|
+
*
|
|
79
|
+
* Words are a reason — a written sentence is what the editor actually gives
|
|
80
|
+
* now that there are no reason chips — but "other" is the honest answer for
|
|
81
|
+
* words that name no policy code, rather than a guess the sentence cannot
|
|
82
|
+
* support. Blank words are no reason at all, so this yields null and the
|
|
83
|
+
* caller refuses the rejection.
|
|
84
|
+
*/
|
|
85
|
+
export function rejectionReasonCodeForWrittenReason(writtenReason: unknown): ReferenceRejectionReasonCode | null {
|
|
86
|
+
const words = normalizeWrittenReason(writtenReason);
|
|
87
|
+
if (!words) return null;
|
|
88
|
+
for (const [code, pattern] of WRITTEN_REASON_PATTERNS) {
|
|
89
|
+
if (pattern.test(words)) return code;
|
|
90
|
+
}
|
|
91
|
+
return "other";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Lower case, apostrophes dropped ("don't" -> "dont") and every other gap a single space, so phrases match plain prose. */
|
|
95
|
+
function normalizeWrittenReason(value: unknown): string {
|
|
96
|
+
return String(value ?? "")
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.replace(/['\u2018\u2019]/g, "")
|
|
99
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
100
|
+
.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
25
103
|
export function normalizeReferenceCurationStatus(value: unknown, defaultStatus: ReferenceCurationStatus = "pending"): ReferenceCurationStatus {
|
|
26
104
|
const normalized = normalizePolicyToken(value ?? defaultStatus);
|
|
27
105
|
if (normalized === "accepted" || normalized === "accept" || normalized === "ready" || normalized === "trusted") return "accepted";
|
|
@@ -61,23 +139,17 @@ export function isEvidenceEligibleReference(reference: Pick<ReferenceRecord, "ve
|
|
|
61
139
|
&& normalizeReferenceCurationStatus(reference?.curationStatus) === "accepted";
|
|
62
140
|
}
|
|
63
141
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const sortedMessages = [...messages].sort((left, right) => String(right.createdAt ?? "").localeCompare(String(left.createdAt ?? "")));
|
|
68
|
-
for (const message of sortedMessages) {
|
|
69
|
-
const reason = reasonCodeFromMetadata(message.metadata);
|
|
70
|
-
if (reason) return reason;
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
142
|
+
/** The reason code of the Reference's latest vote (`curationReasonCode`), else one recorded in its metadata. */
|
|
143
|
+
export function referenceReasonCode(reference: Pick<ReferenceRecord, "metadata" | "curationReasonCode"> | null | undefined): ReferenceRejectionReasonCode | null {
|
|
144
|
+
return normalizeReferenceRejectionReasonCode(reference?.curationReasonCode) ?? reasonCodeFromMetadata(reference?.metadata);
|
|
73
145
|
}
|
|
74
146
|
|
|
75
|
-
export function scopeTrainingLabelForReference(reference: Pick<ReferenceRecord, "versionState" | "curationStatus" | "metadata"
|
|
147
|
+
export function scopeTrainingLabelForReference(reference: Pick<ReferenceRecord, "versionState" | "curationStatus" | "metadata" | "curationReasonCode"> | null | undefined): "positive" | "negative" | null {
|
|
76
148
|
if (!reference || reference.versionState !== "current") return null;
|
|
77
149
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
78
150
|
if (status === "accepted") return "positive";
|
|
79
151
|
if (status !== "rejected") return null;
|
|
80
|
-
const reasonCode = referenceReasonCode(reference
|
|
152
|
+
const reasonCode = referenceReasonCode(reference);
|
|
81
153
|
return reasonCode && SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(reasonCode) ? "negative" : null;
|
|
82
154
|
}
|
|
83
155
|
|
|
@@ -1,71 +1,35 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReferenceRecord, SemanticRelationRecord } from "./category-repository";
|
|
2
2
|
import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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. */
|
|
4
|
+
/**
|
|
5
|
+
* The selected Reference's indexed predictions (the Cyclotron's relevance decisions). The Reference's own latest vote
|
|
6
|
+
* (`curationActorKind`, `curationReasonCode`, ...) is already on the Reference, so no Message is read.
|
|
7
|
+
*/
|
|
12
8
|
export async function loadReferenceReviewDetails(lineageId: string, read: {
|
|
13
|
-
relations: (direction: "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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;
|
|
9
|
+
relations: (direction: "outgoing") => Promise<SemanticRelationRecord[]>;
|
|
10
|
+
}): Promise<{ relations: SemanticRelationRecord[] }> {
|
|
11
|
+
const outgoing = await read.relations("outgoing");
|
|
12
|
+
return { relations: outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId) };
|
|
49
13
|
}
|
|
50
14
|
|
|
51
|
-
/** A later acceptance or archive
|
|
52
|
-
export function latestReferenceReviewReasons(
|
|
15
|
+
/** The reason code of each rejected Reference's latest vote. A later acceptance or archive replaced the vote, so it clears the reason. */
|
|
16
|
+
export function latestReferenceReviewReasons(references: readonly ReferenceRecord[]): Map<string, ReferenceRejectionReasonCode> {
|
|
53
17
|
const reasons = new Map<string, ReferenceRejectionReasonCode>();
|
|
54
|
-
for (const
|
|
55
|
-
|
|
56
|
-
const reason =
|
|
57
|
-
if (reason) reasons.set(
|
|
18
|
+
for (const reference of references) {
|
|
19
|
+
if (!reference.curationActorKind || reference.curationStatus !== "rejected") continue;
|
|
20
|
+
const reason = normalizeReferenceRejectionReasonCode(reference.curationReasonCode);
|
|
21
|
+
if (reason) reasons.set(reference.lineageId ?? reference.id, reason);
|
|
58
22
|
}
|
|
59
23
|
return reasons;
|
|
60
24
|
}
|
|
61
25
|
|
|
62
|
-
/**
|
|
63
|
-
export function latestReferenceReviewSections(
|
|
26
|
+
/** The Section a person explicitly recorded on a Reference's latest vote when accepting it, never a prediction. */
|
|
27
|
+
export function latestReferenceReviewSections(references: readonly ReferenceRecord[]): Map<string, string> {
|
|
64
28
|
const sections = new Map<string, string>();
|
|
65
|
-
for (const
|
|
66
|
-
|
|
67
|
-
const sectionId =
|
|
68
|
-
if (sectionId) sections.set(
|
|
29
|
+
for (const reference of references) {
|
|
30
|
+
if (!reference.curationActorKind || reference.curationStatus !== "accepted") continue;
|
|
31
|
+
const sectionId = reference.curationSectionId?.trim() ?? "";
|
|
32
|
+
if (sectionId) sections.set(reference.lineageId ?? reference.id, sectionId);
|
|
69
33
|
}
|
|
70
34
|
return sections;
|
|
71
35
|
}
|
|
@@ -149,11 +149,15 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
|
|
|
149
149
|
* any other label, or a review without a label, rejects with the reason code,
|
|
150
150
|
* which the existing scope-training rule turns back into a cyclotron label
|
|
151
151
|
* (out_of_scope and policy_exclusion) or none.
|
|
152
|
+
*
|
|
153
|
+
* A rejection must say why, but either way of saying it will do: a reason code,
|
|
154
|
+
* or the editor's own words, which the curation Lambda reads for the code they
|
|
155
|
+
* name. Only a rejection with neither is refused.
|
|
152
156
|
*/
|
|
153
157
|
export function curationForRelevanceReview(decision: RelevanceDecisionView, review: RelevanceReview): RelevanceCuration {
|
|
154
158
|
const accepted = review.label !== null && review.label === decision.positiveLabel;
|
|
155
|
-
if (!accepted && !review.reasonCode) {
|
|
156
|
-
throw new Error("A review that does not accept the reference needs a reason code.");
|
|
159
|
+
if (!accepted && !review.reasonCode && !review.explanation?.trim()) {
|
|
160
|
+
throw new Error("A review that does not accept the reference needs a reason code, or a written reason.");
|
|
157
161
|
}
|
|
158
162
|
return {
|
|
159
163
|
action: accepted ? "accept" : "reject",
|