@anthusai/papyrus 5.43.0-next.40 → 5.43.0-next.42

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.
@@ -24,6 +24,7 @@ import {
24
24
  requestDelegation,
25
25
  type DelegationStore,
26
26
  } from "../shared/curation-delegation-flow";
27
+ import { rejectionReasonCodeForWrittenReason } from "../../../lib/reference-policy";
27
28
  import { claimGroups } from "../../../lib/staff-groups";
28
29
 
29
30
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
@@ -214,7 +215,15 @@ async function reviewReferenceCuration(
214
215
  const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
215
216
  const action = normalizeReferenceCurationAction(event.arguments.action);
216
217
  const nextStatus = referenceCurationStatusForAction(action);
217
- const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode, action === "reject");
218
+ const writtenReason = normalizeOptionalString(event.arguments.note);
219
+ // A rejection needs a reason, and the editor's own sentence is one: with no reason code sent, the
220
+ // words are read for the code they name, so the scope-training negatives keep arriving from the
221
+ // surfaces that ask for words instead of a chip. Words that name no code are filed under "other".
222
+ const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode)
223
+ ?? (action === "reject" ? rejectionReasonCodeForWrittenReason(writtenReason) : null);
224
+ if (action === "reject" && !reasonCode) {
225
+ throw new Error("A rejection needs a reason: say why in your own words, or send a reasonCode.");
226
+ }
218
227
  const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
219
228
  const requestedSectionId = normalizeOptionalString(event.arguments.sectionId);
220
229
  const sectionRecord = requestedSectionId
@@ -225,7 +234,7 @@ async function reviewReferenceCuration(
225
234
  const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
226
235
  const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
227
236
  const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
228
- const note = normalizeOptionalString(event.arguments.note);
237
+ const note = writtenReason;
229
238
  const referenceLineageId = normalizeOptionalString(reference.lineageId) ?? referenceId;
230
239
  const referenceVersionNumber = typeof reference.versionNumber === "number" ? reference.versionNumber : null;
231
240
  const referenceTitle = normalizeOptionalString(reference.title) ?? normalizeOptionalString(reference.externalItemId) ?? referenceId;
@@ -2095,12 +2104,10 @@ const REFERENCE_REJECTION_REASON_CODES = new Set([
2095
2104
  "other",
2096
2105
  ]);
2097
2106
 
2098
- function normalizeReferenceRejectionReasonCode(value: unknown, required: boolean): string | null {
2107
+ /** A supplied reason code, validated. Whether a rejection has a reason at all is the caller's rule. */
2108
+ function normalizeReferenceRejectionReasonCode(value: unknown): string | null {
2099
2109
  const normalized = normalizePolicyToken(value);
2100
- if (!normalized) {
2101
- if (required) throw new Error("reasonCode is required when rejecting a reference.");
2102
- return null;
2103
- }
2110
+ if (!normalized) return null;
2104
2111
  if (!REFERENCE_REJECTION_REASON_CODES.has(normalized)) {
2105
2112
  throw new Error(`Unsupported reference rejection reason code ${normalized}.`);
2106
2113
  }
@@ -4,6 +4,7 @@ import { useEffect, useId, useState } from "react";
4
4
  import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
5
5
  import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
6
6
  import { cn } from "../lib/utils";
7
+ import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
7
8
  import type { CardPrediction, ReviewChoice } from "./reference-review-card";
8
9
 
9
10
  /**
@@ -236,7 +237,7 @@ export function DecisionControl({
236
237
  </label>
237
238
  {/* Written reasons only. A row of canned answers collects a label the model mostly has already;
238
239
  what trains the next prediction is a sentence. */}
239
- <textarea
240
+ <AutoGrowTextarea
240
241
  className={cn(
241
242
  "w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-control)] px-3 py-2 text-sm leading-5 text-foreground outline-none transition-[min-height] placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring",
242
243
  field,
@@ -245,7 +246,7 @@ export function DecisionControl({
245
246
  id={fieldId}
246
247
  onBlur={() => { if (!disabled) onReasonCommit?.(); }}
247
248
  onChange={(event) => { setReasonDirty(true); onReasonChange?.(event.target.value); }}
248
- placeholder="Why? In your own words — this is what the next prediction learns from."
249
+ placeholder="Why? In your own words…"
249
250
  value={reason}
250
251
  />
251
252
  </div>
@@ -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 code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
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: chosenCode } = values(id);
114
- // A typed reason is a reason: a Reject with words but no reason button is filed under "other" rather than left waiting.
115
- const reasonCode = chosenCode ?? (choice === "reject" && note.trim() ? "other" : null);
116
- if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
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 {
@@ -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";
@@ -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/package.json CHANGED
@@ -510,7 +510,7 @@
510
510
  "optional": true
511
511
  }
512
512
  },
513
- "version": "5.43.0-next.40",
513
+ "version": "5.43.0-next.42",
514
514
  "dependencies": {
515
515
  "@aws-amplify/adapter-nextjs": "^1.7.3",
516
516
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.43.0-next.40",
2
+ "version": "5.43.0-next.42",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",