@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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
|
42
|
-
* kept in the store until it is saved and is never dropped by a failed save.
|
|
41
|
+
* A Reject needs a reason (the backend refuses one without): words or a reason code, whichever the surface asks for. With neither it
|
|
42
|
+
* waits as `pending` until one arrives; a typed reason is kept in the store until it is saved and is never dropped by a failed save.
|
|
43
43
|
*/
|
|
44
44
|
export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
|
|
45
45
|
save: RatingSave;
|
|
@@ -110,13 +110,17 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
|
|
|
110
110
|
|
|
111
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
112
|
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
113
|
-
const { note, reasonCode
|
|
114
|
-
// A typed reason is a reason
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
const { note, reasonCode } = values(id);
|
|
114
|
+
// A typed reason is a reason, so a Reject with words goes at once rather than waiting. It carries no
|
|
115
|
+
// code the editor did not pick: the backend reads the words for the code they name, and files words
|
|
116
|
+
// that name none under "other". Only a Reject with neither words nor a code waits for one.
|
|
117
|
+
if (choice === "reject" && !reasonCode && !note.trim() && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
117
118
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
118
119
|
inFlight.current.add(id);
|
|
119
120
|
dirty.current.delete(id);
|
|
121
|
+
// The note goes out with this save, so its own pending autosave has nothing left to send.
|
|
122
|
+
dirty.current.delete(`note:${id}`);
|
|
123
|
+
clearTimeout(timers.current[id]);
|
|
120
124
|
const started = itemsRef.current[id] ?? EMPTY;
|
|
121
125
|
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
122
126
|
try {
|
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";
|
|
@@ -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
package/routes.manifest.json
CHANGED