@anthusai/papyrus 5.57.0 → 5.59.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/message-actions.js +68 -0
- package/amplify/data/message-actions.ts +70 -0
- package/amplify/data/resolvers/delete-own-message.js +30 -0
- package/amplify/data/resolvers/edit-message.js +33 -0
- package/amplify/data/resolvers/moderate-message.js +32 -0
- package/amplify/data/resolvers/post-message.js +68 -0
- package/amplify/data/resolvers/rename-thread.js +28 -0
- package/amplify/data/resource.js +28 -3
- package/amplify/data/resource.ts +28 -3
- package/amplify/data/schema.js +34 -5
- package/amplify/data/schema.ts +34 -5
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +154 -25
- package/components/conversations-memory-source.ts +83 -0
- package/components/conversations-network-source.ts +52 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +36 -0
- package/components/conversations-view.tsx +680 -0
- package/components/decision-control.tsx +307 -0
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +18 -17
- package/components/needs-me-feed.tsx +16 -32
- package/components/news-desk-taxonomy-client.ts +216 -62
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +34 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +9 -8
- package/components/notification-center.tsx +5 -7
- package/components/proposals-view.tsx +4 -4
- package/components/reference-conversation.tsx +5 -0
- package/components/reference-feed.tsx +129 -127
- package/components/reference-review-card.tsx +9 -135
- package/components/references-page-loading.tsx +12 -6
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +44 -786
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +14 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +73 -2
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/needs-me-items.ts +4 -4
- package/lib/needs-me-ranking.ts +9 -22
- package/lib/newsroom-board.ts +10 -1
- package/lib/newsroom-demo-dashboard.ts +0 -7
- package/lib/newsroom-organizer-nav.ts +2 -2
- package/lib/post-message.ts +54 -0
- package/lib/proposals-admin.ts +2 -2
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-feed-ranking.ts +77 -160
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-drafts.ts +2 -2
- package/lib/review-fixture.ts +1 -1
- package/lib/review-requests.ts +4 -4
- package/lib/semantic-graph.ts +1 -1
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +12 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -1,40 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How the References feed is ordered,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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).
|
|
2
|
+
* How the References feed is ordered, in plain words (the "How this feed is ranked" popover says them too):
|
|
3
|
+
* - One list, two sorts. "Best bets" is the Cyclotron's confidence that you will Accept (P), highest first; a prediction of
|
|
4
|
+
* Accept at 80% is P = 0.8, a prediction of Reject at 80% is P = 0.2, no prediction is P = 0.5. Age does not change it.
|
|
5
|
+
* - "Newest" is reverse chronological by when the Reference arrived.
|
|
6
|
+
* - Ties go to the newest item, then to a stable id order.
|
|
7
|
+
* - "Unrated only" shows References nobody has answered; "Include rated" adds the answered ones to the same list.
|
|
8
|
+
* Nothing is cut off by age, and age never changes the order of Best bets: the original confidence is what ranks.
|
|
15
9
|
*/
|
|
16
10
|
import type { ReferenceRecord } from "./category-repository";
|
|
17
11
|
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
18
|
-
import {
|
|
12
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
19
13
|
import { referenceLineageId } from "./newsroom-references";
|
|
20
14
|
|
|
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
15
|
/** The probability that stands for "no prediction". */
|
|
26
16
|
export const UNDECIDED_PROBABILITY = 0.5;
|
|
27
17
|
/** A prediction that recorded a label but no confidence is a mild lean, not a coin toss and not a certainty. */
|
|
28
18
|
export const UNRECORDED_CONFIDENCE = 0.6;
|
|
29
19
|
|
|
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
20
|
|
|
39
21
|
function timeOf(value: string | null | undefined): number | null {
|
|
40
22
|
if (!value) return null;
|
|
@@ -52,35 +34,6 @@ export function referenceDisplayTime(reference: ReferenceRecord): number | null
|
|
|
52
34
|
return timeOf(reference.sourcePublishedAt) ?? timeOf(reference.sourceUpdatedAt) ?? referenceArrivalTime(reference);
|
|
53
35
|
}
|
|
54
36
|
|
|
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
|
-
if (isAutoAccepted(reference)) continue; // an automatic acceptance happens after the editors' usual time, so it says nothing about how fast they act
|
|
64
|
-
const arrived = referenceArrivalTime(reference);
|
|
65
|
-
const decided = timeOf(reference.curationStatusUpdatedAt);
|
|
66
|
-
if (arrived === null || decided === null) continue;
|
|
67
|
-
const age = (decided - arrived) / DAY_MILLISECONDS;
|
|
68
|
-
if (age > 0) ages.push(age);
|
|
69
|
-
}
|
|
70
|
-
if (ages.length < MINIMUM_FRESHNESS_EXAMPLES) return { tauDays: null, medianAgeDays: null, examples: ages.length };
|
|
71
|
-
ages.sort((left, right) => left - right);
|
|
72
|
-
const middle = Math.floor(ages.length / 2);
|
|
73
|
-
const median = ages.length % 2 ? ages[middle] : (ages[middle - 1] + ages[middle]) / 2;
|
|
74
|
-
return { tauDays: median / Math.LN2, medianAgeDays: median, examples: ages.length };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** exp(-age / tau) at `now`; 1 without a learned tau or an unreadable arrival. */
|
|
78
|
-
export function freshnessWeight(reference: ReferenceRecord, freshness: FeedFreshness, now: number): number {
|
|
79
|
-
const arrived = referenceArrivalTime(reference);
|
|
80
|
-
if (freshness.tauDays === null || arrived === null) return 1;
|
|
81
|
-
return Math.exp(-Math.max(now - arrived, 0) / DAY_MILLISECONDS / freshness.tauDays);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
37
|
/** P(the editor accepts), from the Cyclotron's decision: its confidence is in the label it chose. */
|
|
85
38
|
export function acceptProbability(decision: RelevanceDecisionView | undefined): number {
|
|
86
39
|
if (!decision) return UNDECIDED_PROBABILITY;
|
|
@@ -93,144 +46,108 @@ export type FeedItem = {
|
|
|
93
46
|
reference: ReferenceRecord;
|
|
94
47
|
lineageId: string;
|
|
95
48
|
decision: RelevanceDecisionView | undefined;
|
|
96
|
-
/** P(accept)
|
|
49
|
+
/** P(accept): the Cyclotron's confidence that the editor accepts. The Best bets sort key. */
|
|
97
50
|
probability: number;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
score: number;
|
|
101
|
-
/** True when this unrated item is older than the Earlier threshold. */
|
|
102
|
-
earlier: boolean;
|
|
51
|
+
/** True when a human has already answered (accepted or rejected). */
|
|
52
|
+
rated: boolean;
|
|
103
53
|
};
|
|
104
54
|
|
|
105
|
-
export
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
55
|
+
export type FeedSort = "best" | "newest";
|
|
56
|
+
export type FeedShow = "unrated" | "all";
|
|
57
|
+
export const DEFAULT_FEED_SORT: FeedSort = "best";
|
|
58
|
+
export const DEFAULT_FEED_SHOW: FeedShow = "unrated";
|
|
59
|
+
|
|
60
|
+
export function scoreFeedItem(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): FeedItem {
|
|
61
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
110
62
|
return {
|
|
111
63
|
reference,
|
|
112
64
|
lineageId: referenceLineageId(reference),
|
|
113
65
|
decision,
|
|
114
|
-
probability,
|
|
115
|
-
|
|
116
|
-
score: UNDECIDED_PROBABILITY + (probability - UNDECIDED_PROBABILITY) * weight,
|
|
117
|
-
earlier,
|
|
66
|
+
probability: acceptProbability(decision),
|
|
67
|
+
rated: status === "accepted" || status === "rejected",
|
|
118
68
|
};
|
|
119
69
|
}
|
|
120
70
|
|
|
121
|
-
/**
|
|
122
|
-
export function
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
71
|
+
/** Every canonical Reference that is not archived, scored. Unordered: use `orderFeedItems`. */
|
|
72
|
+
export function buildFeedItems(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined): FeedItem[] {
|
|
73
|
+
const items: FeedItem[] = [];
|
|
74
|
+
for (const reference of references) {
|
|
75
|
+
if (normalizeReferenceCurationStatus(reference.curationStatus) === "archived") continue;
|
|
76
|
+
items.push(scoreFeedItem(reference, decisions?.get(referenceLineageId(reference))));
|
|
77
|
+
}
|
|
78
|
+
return items;
|
|
126
79
|
}
|
|
127
80
|
|
|
128
|
-
|
|
81
|
+
/** Best bets: highest P(accept) first; ties go to the newest item, then to a stable id order. */
|
|
82
|
+
export function compareBestBets(left: FeedItem, right: FeedItem): number {
|
|
83
|
+
if (right.probability !== left.probability) return right.probability - left.probability;
|
|
84
|
+
return compareNewest(left, right);
|
|
85
|
+
}
|
|
129
86
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
earlier: FeedItem[];
|
|
138
|
-
};
|
|
87
|
+
/** Newest: latest arrival first; ties go to the higher P(accept), then to a stable id order. */
|
|
88
|
+
export function compareNewest(left: FeedItem, right: FeedItem): number {
|
|
89
|
+
const newest = (referenceArrivalTime(right.reference) ?? 0) - (referenceArrivalTime(left.reference) ?? 0);
|
|
90
|
+
if (newest) return newest;
|
|
91
|
+
if (right.probability !== left.probability) return right.probability - left.probability;
|
|
92
|
+
return left.lineageId.localeCompare(right.lineageId);
|
|
93
|
+
}
|
|
139
94
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
* `visitRated` holds lineage ids rated since the page opened: they stay in Feed, in place, showing the answer.
|
|
143
|
-
*/
|
|
144
|
-
export function partitionReferenceFeed(
|
|
145
|
-
references: ReferenceRecord[],
|
|
146
|
-
decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
|
|
147
|
-
options: { now: number; visitRated?: ReadonlySet<string>; tauDaysOverride?: number | null },
|
|
148
|
-
): ReferenceFeedPartition {
|
|
149
|
-
const visitRated = options.visitRated ?? new Set<string>();
|
|
150
|
-
const learned = learnFreshness(references, visitRated);
|
|
151
|
-
const freshness: FeedFreshness = options.tauDaysOverride && options.tauDaysOverride > 0
|
|
152
|
-
? { ...learned, tauDays: options.tauDaysOverride }
|
|
153
|
-
: learned;
|
|
154
|
-
const feed: FeedItem[] = [];
|
|
155
|
-
const rated: FeedItem[] = [];
|
|
156
|
-
const earlier: FeedItem[] = [];
|
|
157
|
-
for (const reference of references) {
|
|
158
|
-
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
159
|
-
const lineageId = referenceLineageId(reference);
|
|
160
|
-
const item = scoreFeedItem(reference, decisions?.get(lineageId), freshness, options.now);
|
|
161
|
-
if (status === "archived") continue;
|
|
162
|
-
if (status === "accepted" || status === "rejected") {
|
|
163
|
-
rated.push(item);
|
|
164
|
-
if (visitRated.has(lineageId)) (item.earlier ? earlier : feed).push(item);
|
|
165
|
-
} else if (item.earlier) earlier.push(item);
|
|
166
|
-
else feed.push(item);
|
|
167
|
-
}
|
|
168
|
-
feed.sort(compareFeedItems);
|
|
169
|
-
earlier.sort(compareFeedItems);
|
|
170
|
-
rated.sort((left, right) => (timeOf(right.reference.curationStatusUpdatedAt) ?? 0) - (timeOf(left.reference.curationStatusUpdatedAt) ?? 0)
|
|
171
|
-
|| left.lineageId.localeCompare(right.lineageId));
|
|
172
|
-
return { freshness, feed, rated, earlier };
|
|
95
|
+
export function orderFeedItems(items: FeedItem[], sort: FeedSort): FeedItem[] {
|
|
96
|
+
return [...items].sort(sort === "newest" ? compareNewest : compareBestBets);
|
|
173
97
|
}
|
|
174
98
|
|
|
175
|
-
|
|
99
|
+
/** The visible list: ordered by `sort`; "Unrated only" keeps the items answered during this visit, which stay in place. */
|
|
100
|
+
export function visibleFeedItems(items: FeedItem[], sort: FeedSort, show: FeedShow, visitRated: ReadonlySet<string> = new Set()): FeedItem[] {
|
|
101
|
+
return orderFeedItems(items.filter((item) => show === "all" || !item.rated || visitRated.has(item.lineageId)), sort);
|
|
102
|
+
}
|
|
176
103
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
* items with no prediction). A group with no members is absent.
|
|
181
|
-
*/
|
|
182
|
-
export function groupFeedItems(items: FeedItem[]): Array<{ key: FeedGroupKey; items: FeedItem[] }> {
|
|
183
|
-
const strongest = items.reduce((best, item) => Math.max(best, item.score - UNDECIDED_PROBABILITY), 0);
|
|
184
|
-
const groups: Record<FeedGroupKey, FeedItem[]> = { top: [], more: [], least: [] };
|
|
185
|
-
for (const item of items) {
|
|
186
|
-
const strength = item.score - UNDECIDED_PROBABILITY;
|
|
187
|
-
if (strength < 0) groups.least.push(item);
|
|
188
|
-
else if (strength > 0 && strength >= strongest / 2) groups.top.push(item);
|
|
189
|
-
else groups.more.push(item);
|
|
190
|
-
}
|
|
191
|
-
return (["top", "more", "least"] as const).filter((key) => groups[key].length).map((key) => ({ key, items: groups[key] }));
|
|
104
|
+
/** The lead card is the first card, unless it is a predicted Reject. */
|
|
105
|
+
export function feedLeadId(items: FeedItem[]): string | undefined {
|
|
106
|
+
return items[0] && items[0].probability >= UNDECIDED_PROBABILITY ? items[0].lineageId : undefined;
|
|
192
107
|
}
|
|
193
108
|
|
|
194
109
|
/**
|
|
195
110
|
* The order of the feed, taken once per visit when it loads and then left alone: selecting, rating, a background refresh and a cache
|
|
196
|
-
* revalidation never re-rank it. Items that arrive later are counted as new and wait behind a "N new items: show" pill.
|
|
111
|
+
* revalidation never re-rank it, in either sort. Items that arrive later are counted as new and wait behind a "N new items: show" pill.
|
|
197
112
|
*/
|
|
198
113
|
export type FeedOrderSnapshot = {
|
|
199
|
-
/** Every Reference lineage the snapshot knew about
|
|
114
|
+
/** Every Reference lineage the snapshot knew about: anything else is new. */
|
|
200
115
|
known: ReadonlySet<string>;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
116
|
+
/** The ones nobody had answered when the snapshot was taken: they stay listed under "Unrated only" after being rated this visit. */
|
|
117
|
+
unrated: ReadonlySet<string>;
|
|
118
|
+
best: string[];
|
|
119
|
+
newest: string[];
|
|
120
|
+
/** The lead card of each sort, taken with the order: it is never a predicted Reject when the snapshot is taken. */
|
|
121
|
+
lead: { best: string | undefined; newest: string | undefined };
|
|
205
122
|
};
|
|
206
123
|
|
|
207
|
-
export function takeFeedOrderSnapshot(
|
|
208
|
-
const
|
|
124
|
+
export function takeFeedOrderSnapshot(items: FeedItem[]): FeedOrderSnapshot {
|
|
125
|
+
const unrated = items.filter((item) => !item.rated);
|
|
126
|
+
const best = orderFeedItems(items, "best");
|
|
127
|
+
const newest = orderFeedItems(items, "newest");
|
|
209
128
|
return {
|
|
210
|
-
known: new Set(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
129
|
+
known: new Set(items.map((item) => item.lineageId)),
|
|
130
|
+
unrated: new Set(unrated.map((item) => item.lineageId)),
|
|
131
|
+
best: best.map((item) => item.lineageId),
|
|
132
|
+
newest: newest.map((item) => item.lineageId),
|
|
133
|
+
lead: { best: feedLeadId(orderFeedItems(unrated, "best")), newest: feedLeadId(orderFeedItems(unrated, "newest")) },
|
|
215
134
|
};
|
|
216
135
|
}
|
|
217
136
|
|
|
218
|
-
/** The
|
|
219
|
-
export function applyFeedOrderSnapshot(snapshot: FeedOrderSnapshot, live:
|
|
220
|
-
|
|
221
|
-
groups: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
|
|
137
|
+
/** The visible list in the snapshot's order; records and predictions stay current. */
|
|
138
|
+
export function applyFeedOrderSnapshot(snapshot: FeedOrderSnapshot, live: FeedItem[], sort: FeedSort, show: FeedShow): {
|
|
139
|
+
items: FeedItem[];
|
|
222
140
|
leadId: string | undefined;
|
|
223
141
|
newCount: number;
|
|
224
142
|
} {
|
|
225
|
-
const byId = new Map
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
143
|
+
const byId = new Map(live.map((item) => [item.lineageId, item] as const));
|
|
144
|
+
const items = (sort === "newest" ? snapshot.newest : snapshot.best)
|
|
145
|
+
.filter((id) => show === "all" || snapshot.unrated.has(id))
|
|
146
|
+
.flatMap((id) => { const item = byId.get(id); return item ? [item] : []; });
|
|
147
|
+
const lead = snapshot.lead[sort];
|
|
230
148
|
return {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
newCount,
|
|
149
|
+
items,
|
|
150
|
+
leadId: lead && items.some((item) => item.lineageId === lead) ? lead : undefined,
|
|
151
|
+
newCount: live.filter((item) => !item.rated && !snapshot.known.has(item.lineageId)).length,
|
|
235
152
|
};
|
|
236
153
|
}
|
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",
|
package/lib/review-drafts.ts
CHANGED
|
@@ -44,7 +44,7 @@ export type DraftView = {
|
|
|
44
44
|
|
|
45
45
|
export const MAXIMUM_SOURCES = 5;
|
|
46
46
|
const MAXIMUM_EVIDENCE = 3;
|
|
47
|
-
const KIND_WORDS: Record<string, string> = { story_draft: "Story
|
|
47
|
+
const KIND_WORDS: Record<string, string> = { story_draft: "Proposed Story", new_concept: "New concept", rename: "Rename", merge: "Merge", split: "Split", new_column: "New column", element_symbol: "Element symbol" };
|
|
48
48
|
|
|
49
49
|
/** JSON fields arrive as strings, and a field written through `JSON.stringify` of a string arrives encoded twice: unwrap until it is not text. */
|
|
50
50
|
function parseJson(value: unknown, depth = 0): unknown {
|
|
@@ -170,7 +170,7 @@ export function rejectPath(state: string): string[] | null {
|
|
|
170
170
|
/** The sentence for a draft that cannot move, or null when it can. A hold is named, never hidden. */
|
|
171
171
|
export function cannotMoveReason(view: Pick<DraftView, "state" | "held" | "decided">): string | null {
|
|
172
172
|
if (view.decided) return null;
|
|
173
|
-
if (view.held) return "This
|
|
173
|
+
if (view.held) return "This Proposed Story is on hold in Proposals. Release the hold there before you decide it.";
|
|
174
174
|
return null;
|
|
175
175
|
}
|
|
176
176
|
|
package/lib/review-fixture.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
|
18
18
|
history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
|
-
id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend
|
|
21
|
+
id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend Proposed Story is ready",
|
|
22
22
|
ask: null, raisedBy: "trend-pipeline", raisedAt: "2026-10-22T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "open", answer: null,
|
|
23
23
|
links: [], history: [], appliedIn: null, updatedAt: "2026-10-22T07:00:00Z",
|
|
24
24
|
},
|
package/lib/review-requests.ts
CHANGED
|
@@ -164,12 +164,12 @@ export function nextText(request: ReviewRequest): string {
|
|
|
164
164
|
if (request.state === "open") {
|
|
165
165
|
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you accept or reject.";
|
|
166
166
|
if (request.mode === "soft_review") {
|
|
167
|
-
if (drafts) return request.deadline ? `Closes on ${formatWhen(request.deadline)} if nobody answers. The
|
|
167
|
+
if (drafts) return request.deadline ? `Closes on ${formatWhen(request.deadline)} if nobody answers. The Proposed Stories stay in Proposals.` : "No deadline. The Proposed Stories stay in Proposals until an editor decides them.";
|
|
168
168
|
return request.deadline ? `If nobody answers by ${formatWhen(request.deadline)}, this closes and ${request.raisedBy} carries on without a review.` : "No deadline. Stays open until someone answers it.";
|
|
169
169
|
}
|
|
170
170
|
return "Nothing is waiting on this. It stays here until you acknowledge it.";
|
|
171
171
|
}
|
|
172
|
-
if (request.state === "timed_out") return drafts ? `Closed with no answer on ${formatWhen(request.updatedAt)}. The
|
|
172
|
+
if (request.state === "timed_out") return drafts ? `Closed with no answer on ${formatWhen(request.updatedAt)}. The Proposed Stories stay in Proposals.` : `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
173
173
|
if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
|
|
174
174
|
const answer = request.answer;
|
|
175
175
|
return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
@@ -295,7 +295,7 @@ export function sectionReviews(requests: ReviewRequest[]): NotificationSections
|
|
|
295
295
|
export type NotificationSummary = { waiting: number; drafts: number; otherReviews: number; decidedBySystem: number; unread: number };
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
|
-
* "N waiting for you · N
|
|
298
|
+
* "N waiting for you · N Proposed Stories to review · N other reviews open · N recently decided by the system".
|
|
299
299
|
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). `draftsToDecide` is the number of
|
|
300
300
|
* drafts in open draft reviews that nobody has decided yet.
|
|
301
301
|
*/
|
|
@@ -305,7 +305,7 @@ export function summarize(sections: NotificationSections, held: CyclotronEntry[]
|
|
|
305
305
|
}
|
|
306
306
|
|
|
307
307
|
export function summaryText(summary: NotificationSummary): string {
|
|
308
|
-
return `${summary.waiting} waiting for you · ${summary.drafts}
|
|
308
|
+
return `${summary.waiting} waiting for you · ${summary.drafts} ${summary.drafts === 1 ? "Proposed Story" : "Proposed Stories"} to review · ${summary.otherReviews} other ${summary.otherReviews === 1 ? "review" : "reviews"} open · ${summary.decidedBySystem} recently decided by the system`;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
|
package/lib/semantic-graph.ts
CHANGED
|
@@ -294,7 +294,7 @@ export class SemanticGraphSnapshot {
|
|
|
294
294
|
messagesFor(kind: string, lineageId: string): MessageRecord[] {
|
|
295
295
|
const incomingCommentMessages = this.incoming(kind, lineageId)
|
|
296
296
|
.filter((relation) => relation.subjectKind === "message")
|
|
297
|
-
.filter((relation) =>
|
|
297
|
+
.filter((relation) => relationTypeKey(relation) === "comment" || relation.predicate === "comment")
|
|
298
298
|
.map((relation) => this.messagesById.get(relation.subjectId))
|
|
299
299
|
.filter((message): message is MessageRecord => Boolean(message));
|
|
300
300
|
const producedMessages = this.outgoing(kind, lineageId)
|