@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41

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.
Files changed (118) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +16 -8
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/content-actions/requirements.txt +2 -0
  8. package/amplify/functions/content-actions/resource.js +1 -1
  9. package/amplify/functions/content-actions/resource.ts +1 -1
  10. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  11. package/amplify/functions/monitoring-view/schema.js +16 -0
  12. package/amplify/functions/monitoring-view/schema.ts +16 -0
  13. package/amplify/functions/procedure-action/handler.ts +2 -10
  14. package/amplify/functions/proposal-actions/proposals.py +20 -3
  15. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  16. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  17. package/app/api/newsroom/source-frameable/route.ts +12 -0
  18. package/app/api/newsroom/source-icon/route.ts +20 -0
  19. package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
  20. package/app/newsroom/[section]/page.tsx +1 -0
  21. package/bin/papyrus-app.mjs +12 -1
  22. package/bin/papyrus-infra.mjs +21 -1
  23. package/components/auto-accepted-badge.tsx +21 -0
  24. package/components/conversation-context.tsx +52 -0
  25. package/components/conversation-provider.tsx +20 -0
  26. package/components/conversation-thread.tsx +174 -0
  27. package/components/decision-control.tsx +306 -0
  28. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  29. package/components/markdown-context.tsx +412 -0
  30. package/components/monitoring-overview-view.tsx +143 -21
  31. package/components/monitoring-panel.tsx +64 -4
  32. package/components/needs-me-bodies.tsx +302 -0
  33. package/components/needs-me-detail.tsx +24 -0
  34. package/components/needs-me-feed.tsx +594 -0
  35. package/components/needs-me-reference-detail.tsx +79 -0
  36. package/components/needs-me-shared.tsx +48 -0
  37. package/components/news-desk-client-provider.tsx +24 -3
  38. package/components/news-desk-page.tsx +2 -1
  39. package/components/news-desk-taxonomy-client.ts +253 -31
  40. package/components/newsroom-access-gate.tsx +13 -20
  41. package/components/newsroom-board-skeleton.tsx +20 -0
  42. package/components/newsroom-board-view.tsx +2 -6
  43. package/components/newsroom-client-shell.tsx +2 -1
  44. package/components/newsroom-ops-overview.tsx +88 -73
  45. package/components/newsroom-ops-shell.tsx +8 -28
  46. package/components/newsroom-organizer.css +127 -3
  47. package/components/newsroom-references-view.tsx +334 -158
  48. package/components/notification-cards.tsx +370 -0
  49. package/components/notification-center.tsx +376 -213
  50. package/components/papyrus-console-shell.tsx +2 -1
  51. package/components/proposals-panel.tsx +21 -24
  52. package/components/proposals-view.tsx +4 -2
  53. package/components/reader-auth-control.tsx +3 -1
  54. package/components/reference-capture-client.ts +138 -0
  55. package/components/reference-capture-panel.tsx +268 -0
  56. package/components/reference-conversation.tsx +43 -0
  57. package/components/reference-feed.tsx +514 -0
  58. package/components/reference-review-card.tsx +62 -197
  59. package/components/reference-source-preview.tsx +27 -5
  60. package/components/reference-stories.tsx +34 -0
  61. package/components/references-page-loading.tsx +61 -0
  62. package/components/references-prefetch.ts +52 -0
  63. package/components/story-reporting-trail-client.ts +45 -8
  64. package/components/story-reporting-trail.tsx +24 -13
  65. package/components/topic-steering-workspace.tsx +50 -439
  66. package/components/ui/auto-grow-textarea.tsx +18 -0
  67. package/components/use-conversation.ts +84 -0
  68. package/components/use-media-query.ts +16 -0
  69. package/components/use-reference-preview-attachments.ts +101 -0
  70. package/components/use-reference-ratings.ts +189 -0
  71. package/infra/amplify-app-shell.js +18 -2
  72. package/infra/amplify-app-shell.ts +18 -2
  73. package/infra/chat-stack.js +222 -0
  74. package/infra/chat-stack.ts +250 -0
  75. package/infra/chat.test.js +142 -0
  76. package/infra/chat.test.ts +151 -0
  77. package/infra/index.js +1 -0
  78. package/infra/site-config.js +58 -1
  79. package/infra/site-config.ts +78 -1
  80. package/lib/category-repository.ts +6 -1
  81. package/lib/content-actions-client.ts +20 -0
  82. package/lib/conversation.ts +154 -0
  83. package/lib/local-preview-feed-fixture.ts +63 -0
  84. package/lib/local-preview-seed.ts +24 -2
  85. package/lib/local-preview-synthetic-rejects.ts +41 -0
  86. package/lib/monitoring-client.ts +29 -2
  87. package/lib/monitoring-overview.ts +52 -1
  88. package/lib/monitoring-paging.ts +58 -0
  89. package/lib/needs-me-items.ts +289 -0
  90. package/lib/needs-me-ranking.ts +315 -0
  91. package/lib/newsroom-all-pages.ts +21 -0
  92. package/lib/newsroom-chat-identity.ts +2 -1
  93. package/lib/newsroom-index-filters.ts +18 -0
  94. package/lib/newsroom-nav.ts +1 -1
  95. package/lib/newsroom-organizer-nav.ts +2 -1
  96. package/lib/newsroom-references.ts +25 -1
  97. package/lib/notification-feed.ts +212 -0
  98. package/lib/proposals-client.ts +29 -0
  99. package/lib/reader-subject.ts +3 -2
  100. package/lib/reference-capture.ts +294 -0
  101. package/lib/reference-feed-cache.ts +170 -0
  102. package/lib/reference-feed-ranking.ts +236 -0
  103. package/lib/reference-policy.ts +90 -0
  104. package/lib/reference-preview.ts +112 -0
  105. package/lib/relevance-decisions.ts +6 -2
  106. package/lib/review-client.ts +71 -5
  107. package/lib/review-drafts.ts +277 -0
  108. package/lib/review-fixture.ts +56 -2
  109. package/lib/review-requests.ts +89 -24
  110. package/lib/site-brand.ts +2 -0
  111. package/lib/source-frameable.ts +77 -0
  112. package/lib/source-icon.ts +104 -0
  113. package/lib/staff-groups.ts +23 -0
  114. package/lib/staging-gate.ts +2 -2
  115. package/lib/story-reporting-trail.ts +33 -2
  116. package/package.json +45 -2
  117. package/routes.manifest.json +23 -1
  118. package/components/newsroom-overview-skeleton.tsx +0 -25
@@ -0,0 +1,236 @@
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 { isAutoAccepted, 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
+ 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
+ /** P(the editor accepts), from the Cyclotron's decision: its confidence is in the label it chose. */
85
+ export function acceptProbability(decision: RelevanceDecisionView | undefined): number {
86
+ if (!decision) return UNDECIDED_PROBABILITY;
87
+ const confidence = decision.confidence ?? UNRECORDED_CONFIDENCE;
88
+ const accepts = decision.label === (decision.positiveLabel ?? "include");
89
+ return accepts ? confidence : 1 - confidence;
90
+ }
91
+
92
+ export type FeedItem = {
93
+ reference: ReferenceRecord;
94
+ lineageId: string;
95
+ decision: RelevanceDecisionView | undefined;
96
+ /** P(accept) before freshness. */
97
+ probability: number;
98
+ weight: number;
99
+ /** 0.5 + (P - 0.5) * weight. Highest is first. */
100
+ score: number;
101
+ /** True when this unrated item is older than the Earlier threshold. */
102
+ earlier: boolean;
103
+ };
104
+
105
+ export function scoreFeedItem(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined, freshness: FeedFreshness, now: number): FeedItem {
106
+ const probability = acceptProbability(decision);
107
+ const weight = freshnessWeight(reference, freshness, now);
108
+ const arrived = referenceArrivalTime(reference);
109
+ const earlier = freshness.tauDays !== null && arrived !== null && (now - arrived) / DAY_MILLISECONDS > EARLIER_AFTER_TAUS * freshness.tauDays;
110
+ return {
111
+ reference,
112
+ lineageId: referenceLineageId(reference),
113
+ decision,
114
+ probability,
115
+ weight,
116
+ score: UNDECIDED_PROBABILITY + (probability - UNDECIDED_PROBABILITY) * weight,
117
+ earlier,
118
+ };
119
+ }
120
+
121
+ /** Highest score first; ties go to the newest item, then to a stable id order. */
122
+ export function compareFeedItems(left: FeedItem, right: FeedItem): number {
123
+ if (right.score !== left.score) return right.score - left.score;
124
+ const newest = (referenceDisplayTime(right.reference) ?? 0) - (referenceDisplayTime(left.reference) ?? 0);
125
+ return newest || left.lineageId.localeCompare(right.lineageId);
126
+ }
127
+
128
+ export type ReferenceFeedTabKey = "feed" | "rated" | "earlier";
129
+
130
+ export type ReferenceFeedPartition = {
131
+ freshness: FeedFreshness;
132
+ /** Pending items, plus the ones rated during this visit (they keep their place), best prediction first. */
133
+ feed: FeedItem[];
134
+ /** Everything an editor has answered, most recent answer first. */
135
+ rated: FeedItem[];
136
+ /** Unrated items past the freshness threshold, ranked like the feed. Still ratable. */
137
+ earlier: FeedItem[];
138
+ };
139
+
140
+ /**
141
+ * Splits canonical References into the three tabs.
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 };
173
+ }
174
+
175
+ export type FeedGroupKey = "top" | "more" | "least";
176
+
177
+ /**
178
+ * The feed's sections, from the ranked list (already best first). No fixed counts: Top predictions are the Accepts at least half as
179
+ * strong as the strongest one, Least likely are the predicted Rejects, and More for you is everything between (weaker Accepts and
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] }));
192
+ }
193
+
194
+ /**
195
+ * 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.
197
+ */
198
+ export type FeedOrderSnapshot = {
199
+ /** Every Reference lineage the snapshot knew about (pending, rated and earlier): anything else is new. */
200
+ known: ReadonlySet<string>;
201
+ feed: string[];
202
+ earlier: string[];
203
+ groups: Array<{ key: FeedGroupKey; ids: string[] }>;
204
+ leadId: string | undefined;
205
+ };
206
+
207
+ export function takeFeedOrderSnapshot(partition: ReferenceFeedPartition): FeedOrderSnapshot {
208
+ const groups = groupFeedItems(partition.feed);
209
+ return {
210
+ known: new Set([...partition.feed, ...partition.earlier, ...partition.rated].map((item) => item.lineageId)),
211
+ feed: partition.feed.map((item) => item.lineageId),
212
+ earlier: partition.earlier.map((item) => item.lineageId),
213
+ groups: groups.map((group) => ({ key: group.key, ids: group.items.map((item) => item.lineageId) })),
214
+ leadId: groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined,
215
+ };
216
+ }
217
+
218
+ /** The live partition with its feed order, sections and lead taken from the snapshot; records and scores stay current. */
219
+ export function applyFeedOrderSnapshot(snapshot: FeedOrderSnapshot, live: ReferenceFeedPartition): {
220
+ partition: ReferenceFeedPartition;
221
+ groups: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
222
+ leadId: string | undefined;
223
+ newCount: number;
224
+ } {
225
+ const byId = new Map<string, FeedItem>();
226
+ for (const item of [...live.rated, ...live.earlier, ...live.feed]) byId.set(item.lineageId, item);
227
+ const pick = (ids: string[]) => ids.flatMap((id) => { const item = byId.get(id); return item ? [item] : []; });
228
+ const groups = snapshot.groups.map((group) => ({ key: group.key, items: pick(group.ids) })).filter((group) => group.items.length);
229
+ const newCount = [...live.feed, ...live.earlier].filter((item) => !snapshot.known.has(item.lineageId)).length;
230
+ return {
231
+ partition: { ...live, feed: pick(snapshot.feed), earlier: pick(snapshot.earlier) },
232
+ groups,
233
+ leadId: snapshot.leadId && byId.has(snapshot.leadId) ? snapshot.leadId : undefined,
234
+ newCount,
235
+ };
236
+ }
@@ -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";
@@ -30,6 +108,18 @@ export function normalizeReferenceCurationStatus(value: unknown, defaultStatus:
30
108
  return "pending";
31
109
  }
32
110
 
111
+ /** The curation actor the router writes when the Cyclotron accepts a confident, unvoted Reference on its own (src/papyrus_content/auto_accept.py). Not a person. */
112
+ export const AUTO_ACCEPT_ACTOR = "auto:cyclotron";
113
+
114
+ /** The actor for a Reference a monitor with a fixed delivery rule accepted on delivery: the rule match is the acceptance. */
115
+ export const AUTO_ACCEPT_RULE_ACTOR = "auto:rule";
116
+
117
+ /** True while a Reference stands accepted automatically, by the Cyclotron's confidence or by a monitor's rule. An editor's later Accept or Reject replaces the actor, so this is false from then on. */
118
+ export function isAutoAccepted(reference: { curationStatus?: unknown; curationStatusUpdatedBy?: unknown }): boolean {
119
+ const actor = String(reference.curationStatusUpdatedBy ?? "").trim();
120
+ return normalizeReferenceCurationStatus(reference.curationStatus) === "accepted" && (actor === AUTO_ACCEPT_ACTOR || actor === AUTO_ACCEPT_RULE_ACTOR);
121
+ }
122
+
33
123
  export function referenceCurationStatusForAction(action: "accept" | "reject" | "reopen" | "archive"): ReferenceCurationStatus {
34
124
  if (action === "accept") return "accepted";
35
125
  if (action === "reject") return "rejected";
@@ -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 or the 1.91:1 Open Graph card (1200x630). 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 || Math.abs(naturalRatio - 1200 / 630) < 0.04);
112
+ }
@@ -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",
@@ -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
  /**
@@ -99,22 +100,87 @@ export async function markReviewRead(userKey: string, requestId: string, now: Da
99
100
  }
100
101
 
101
102
  const MAX_TITLE_LOOKUPS = 40;
103
+ /** The opening of a story, for a reviewer who has not opened it: the deck, then the first paragraphs of the body. */
104
+ const EXCERPT_CHARACTERS = 900;
105
+
106
+ export function storyExcerpt(deck: string | null | undefined, body: readonly (string | null)[] | null | undefined): string | null {
107
+ const text = [deck, ...(body ?? [])].map((part) => (part ?? "").trim()).filter(Boolean).join("\n\n");
108
+ if (!text) return null;
109
+ if (text.length <= EXCERPT_CHARACTERS) return text;
110
+ const cut = text.slice(0, EXCERPT_CHARACTERS);
111
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), EXCERPT_CHARACTERS - 80))}…`;
112
+ }
102
113
 
103
114
  /** Titles of the stories that review requests are about, by lineage id, and the ids of stories that no longer exist (no version of the lineage remains). A story that cannot be read simply has no entry (the card falls back to the id). */
104
- export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[] }> {
115
+ export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }> {
105
116
  const titles: Record<string, string> = {};
117
+ const excerpts: Record<string, string> = {};
106
118
  const deleted: string[] = [];
107
119
  await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
108
120
  const result = await run(
109
- `query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
121
+ `query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title deck body } } }`,
110
122
  { lineageId: id },
111
123
  );
112
124
  // Only a clean, empty answer means the story is gone; a failed lookup says nothing.
113
125
  if (!result.errors?.length && result.data?.listItemsByLineageAndVersion && !(result.data.listItemsByLineageAndVersion.items ?? []).filter(Boolean).length) deleted.push(id);
114
- const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
126
+ const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string; deck?: string | null; body?: (string | null)[] | null }[];
115
127
  const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
116
128
  const title = newest?.headline || newest?.title;
117
129
  if (title) titles[id] = title;
130
+ const excerpt = storyExcerpt(newest?.deck, newest?.body);
131
+ if (excerpt) excerpts[id] = excerpt;
132
+ }));
133
+ return { titles, deleted, excerpts };
134
+ }
135
+
136
+ const MAX_DRAFT_LOOKUPS = 60;
137
+ const DRAFT_FIELDS = "id kind title summary body state held evidence history override createdAt";
138
+
139
+ /** Records one draft's decision on the request while it stays open. Same condition as an answer: state = open. */
140
+ export async function saveDraftDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): Promise<ReviewRequest> {
141
+ const result = await run(
142
+ `mutation Decide($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
143
+ { input: draftDecisionUpdate(request, draftId, decision), condition: { state: { eq: "open" } } },
144
+ );
145
+ if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
146
+ throw new Error("Someone else answered this, or it closed at its deadline. Refresh to see the result.");
147
+ }
148
+ if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("The draft was decided, but the request could not record it. Try again.");
149
+ return normalizeReviewRow(result.data.updateReviewRequest);
150
+ }
151
+
152
+ /**
153
+ * Changes only the Why of an already decided draft or answered request (see `draftReasonUpdate` and `answerReasonUpdate`). The condition is that the
154
+ * request is still in the state it was read in, so a change cannot overwrite a request that closed or timed out meanwhile.
155
+ */
156
+ export async function saveReviewReason(request: ReviewRequest, update: Record<string, unknown>): Promise<ReviewRequest> {
157
+ const result = await run(
158
+ `mutation Reason($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
159
+ { input: update, condition: { state: { eq: request.state } } },
160
+ );
161
+ if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
162
+ throw new Error("This request changed while you were writing. Your note is kept here; refresh, then save it again.");
163
+ }
164
+ if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("Your note could not be saved. It is kept here; keep typing or tap away to try again.");
165
+ return normalizeReviewRow(result.data.updateReviewRequest);
166
+ }
167
+
168
+ export type DraftLoad = { drafts: Record<string, DraftView>; missing: string[]; failed: string[] };
169
+
170
+ /**
171
+ * The Proposals that review requests are about, by id (editors and admins may read them). `missing` ids have no proposal (an idea kept
172
+ * only as JSON has no proposal to open); `failed` ids could not be read right now, which says nothing about whether they exist.
173
+ */
174
+ export async function loadDrafts(ids: string[], demo = false): Promise<DraftLoad> {
175
+ const drafts: Record<string, DraftView> = {};
176
+ const missing: string[] = [];
177
+ const failed: string[] = [];
178
+ await Promise.all([...new Set(ids)].slice(0, MAX_DRAFT_LOOKUPS).map(async (id) => {
179
+ const result = await run(`query Draft($id: ID!) { getProposal(id: $id) { ${DRAFT_FIELDS} } }`, { id });
180
+ const row = result.data?.getProposal as DraftRow | null | undefined;
181
+ if (row) drafts[id] = draftView(row, demo);
182
+ else if (result.errors?.length || !result.data) failed.push(id);
183
+ else missing.push(id);
118
184
  }));
119
- return { titles, deleted };
185
+ return { drafts, missing, failed };
120
186
  }