@anthusai/papyrus 5.56.1 → 5.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/amplify/data/resource.js +25 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +25 -3
  4. package/amplify/data/schema.ts +25 -3
  5. package/amplify/functions/category-action/handler.ts +51 -125
  6. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  7. package/components/conversation-context.tsx +52 -0
  8. package/components/conversation-provider.tsx +20 -0
  9. package/components/conversation-thread.tsx +188 -0
  10. package/components/conversations-memory-source.ts +46 -0
  11. package/components/conversations-network-source.ts +40 -0
  12. package/components/conversations-page.tsx +14 -0
  13. package/components/conversations-source.ts +24 -0
  14. package/components/conversations-view.tsx +319 -0
  15. package/components/decision-control.tsx +307 -0
  16. package/components/needs-me-bodies.tsx +4 -3
  17. package/components/needs-me-feed.tsx +8 -14
  18. package/components/needs-me-reference-detail.tsx +2 -0
  19. package/components/news-desk-taxonomy-client.ts +322 -37
  20. package/components/newsroom-organizer.css +24 -0
  21. package/components/newsroom-references-view.tsx +9 -0
  22. package/components/notification-cards.tsx +5 -4
  23. package/components/reference-conversation.tsx +43 -0
  24. package/components/reference-feed.tsx +69 -37
  25. package/components/reference-review-card.tsx +8 -134
  26. package/components/story-reporting-trail-client.ts +4 -3
  27. package/components/story-reporting-trail.tsx +8 -0
  28. package/components/topic-steering-workspace.tsx +43 -784
  29. package/components/use-conversation.ts +84 -0
  30. package/components/use-reference-ratings.ts +11 -7
  31. package/lib/category-repository.ts +7 -0
  32. package/lib/conversation-feed.ts +96 -0
  33. package/lib/conversation.ts +154 -0
  34. package/lib/cyclotron-suggestions.ts +18 -36
  35. package/lib/local-preview-seed.ts +16 -27
  36. package/lib/reference-feed-keys.ts +69 -0
  37. package/lib/reference-policy.ts +83 -11
  38. package/lib/reference-review-details.ts +21 -57
  39. package/lib/relevance-decisions.ts +6 -2
  40. package/lib/story-reporting-trail.ts +28 -2
  41. package/package.json +15 -2
  42. package/routes.manifest.json +1 -1
  43. package/components/review-rating-grid.tsx +0 -190
@@ -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
- export function referenceReasonCode(reference: Pick<ReferenceRecord, "metadata"> | null | undefined, messages: Array<Pick<MessageRecord, "metadata" | "createdAt">> = []): ReferenceRejectionReasonCode | null {
65
- const metadataReason = reasonCodeFromMetadata(reference?.metadata);
66
- if (metadataReason) return metadataReason;
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"> | null | undefined, messages: Array<Pick<MessageRecord, "metadata" | "createdAt">> = []): "positive" | "negative" | null {
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, messages);
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 { HydratedModelPayload, MessageRecord, SemanticRelationRecord } from "./category-repository";
1
+ import type { ReferenceRecord, SemanticRelationRecord } from "./category-repository";
2
2
  import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
3
3
 
4
- function metadataRecord(value: unknown): Record<string, unknown> {
5
- if (typeof value === "string") {
6
- try { return metadataRecord(JSON.parse(value)); } catch { return {}; }
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: "incoming" | "outgoing") => Promise<SemanticRelationRecord[]>;
14
- message: (id: string) => Promise<MessageRecord | null>;
15
- payloads: (id: string) => Promise<HydratedModelPayload[]>;
16
- }): Promise<{ relations: SemanticRelationRecord[]; messages: MessageRecord[] }> {
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 clears any earlier rejection reason. */
52
- export function latestReferenceReviewReasons(messages: MessageRecord[]): Map<string, ReferenceRejectionReasonCode> {
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 [lineage, message] of latestReferenceReviews(messages)) {
55
- const metadata = metadataRecord(message.metadata);
56
- const reason = metadata.action === "reject" ? normalizeReferenceRejectionReasonCode(metadata.reasonCode) : null;
57
- if (reason) reasons.set(lineage, reason);
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
- /** Preserve only a Section explicitly recorded on the newest acceptance, never a prediction. */
63
- export function latestReferenceReviewSections(messages: MessageRecord[]): Map<string, string> {
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 [lineage, message] of latestReferenceReviews(messages)) {
66
- const metadata = metadataRecord(message.metadata);
67
- const sectionId = metadata.action === "accept" && typeof metadata.sectionId === "string" ? metadata.sectionId.trim() : "";
68
- if (sectionId) sections.set(lineage, sectionId);
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",
@@ -17,6 +17,15 @@ export type TrailVersion = {
17
17
  updatedAt: string | null;
18
18
  };
19
19
 
20
+ /** One stage move of the story, read from the latest Item version's `metadata.stageMoves` (not from Messages). */
21
+ export type TrailStageMove = {
22
+ at: string;
23
+ from: string | null;
24
+ to: string;
25
+ by: string | null;
26
+ reason: string | null;
27
+ };
28
+
20
29
  export type TrailAssignmentEvent = {
21
30
  id: string;
22
31
  eventType: string;
@@ -89,12 +98,29 @@ export type StoryReportingTrail = {
89
98
  threads: TrailThread[];
90
99
  references: TrailReference[];
91
100
  attachments: TrailAttachment[];
101
+ stageMoves: TrailStageMove[];
92
102
  };
93
103
 
94
- export const EMPTY_STORY_REPORTING_TRAIL: StoryReportingTrail = { versions: [], assignments: [], threads: [], references: [], attachments: [] };
104
+ export const EMPTY_STORY_REPORTING_TRAIL: StoryReportingTrail = { versions: [], assignments: [], threads: [], references: [], attachments: [], stageMoves: [] };
105
+
106
+ /** The stage moves recorded on an Item version's metadata, oldest first. Anything that is not a well-formed move is skipped. */
107
+ export function stageMovesOf(metadata: unknown): TrailStageMove[] {
108
+ let parsed: unknown = metadata;
109
+ if (typeof metadata === "string") {
110
+ try { parsed = JSON.parse(metadata); } catch { return []; }
111
+ }
112
+ const moves = typeof parsed === "object" && parsed ? (parsed as Record<string, unknown>).stageMoves : null;
113
+ if (!Array.isArray(moves)) return [];
114
+ return moves.flatMap(entry => {
115
+ if (typeof entry !== "object" || !entry) return [];
116
+ const move = entry as Record<string, unknown>;
117
+ if (typeof move.at !== "string" || typeof move.to !== "string") return [];
118
+ return [{ at: move.at, from: typeof move.from === "string" ? move.from : null, to: move.to, by: typeof move.by === "string" ? move.by : null, reason: typeof move.reason === "string" ? move.reason : null }];
119
+ });
120
+ }
95
121
 
96
122
  export function storyReportingTrailIsEmpty(trail: StoryReportingTrail): boolean {
97
- return trail.assignments.length === 0 && trail.threads.length === 0 && trail.references.length === 0 && trail.attachments.length === 0 && trail.versions.length <= 1;
123
+ return trail.assignments.length === 0 && trail.threads.length === 0 && trail.stageMoves.length === 0 && trail.references.length === 0 && trail.attachments.length === 0 && trail.versions.length <= 1;
98
124
  }
99
125
 
100
126
  export type StoryFeedEntry =
package/package.json CHANGED
@@ -80,7 +80,16 @@
80
80
  "./components/automation-run-now": "./components/automation-run-now.tsx",
81
81
  "./components/automation-view": "./components/automation-view.tsx",
82
82
  "./components/blog-page-background": "./components/blog-page-background.tsx",
83
+ "./components/conversation-context": "./components/conversation-context.tsx",
84
+ "./components/conversation-provider": "./components/conversation-provider.tsx",
85
+ "./components/conversation-thread": "./components/conversation-thread.tsx",
86
+ "./components/conversations-memory-source": "./components/conversations-memory-source.ts",
87
+ "./components/conversations-network-source": "./components/conversations-network-source.ts",
88
+ "./components/conversations-page": "./components/conversations-page.tsx",
89
+ "./components/conversations-source": "./components/conversations-source.ts",
90
+ "./components/conversations-view": "./components/conversations-view.tsx",
83
91
  "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
92
+ "./components/decision-control": "./components/decision-control.tsx",
84
93
  "./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
85
94
  "./components/editorial-checks-admin-panel": "./components/editorial-checks-admin-panel.tsx",
86
95
  "./components/editorial-finding-card": "./components/editorial-finding-card.tsx",
@@ -146,6 +155,7 @@
146
155
  "./components/reference-add-entry": "./components/reference-add-entry.tsx",
147
156
  "./components/reference-capture-client": "./components/reference-capture-client.ts",
148
157
  "./components/reference-capture-panel": "./components/reference-capture-panel.tsx",
158
+ "./components/reference-conversation": "./components/reference-conversation.tsx",
149
159
  "./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
150
160
  "./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
151
161
  "./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
@@ -160,7 +170,6 @@
160
170
  "./components/reference-stories": "./components/reference-stories.tsx",
161
171
  "./components/references-page-loading": "./components/references-page-loading.tsx",
162
172
  "./components/references-prefetch": "./components/references-prefetch.ts",
163
- "./components/review-rating-grid": "./components/review-rating-grid.tsx",
164
173
  "./components/settings-page": "./components/settings-page.tsx",
165
174
  "./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
166
175
  "./components/staging-banner": "./components/staging-banner.tsx",
@@ -189,6 +198,7 @@
189
198
  "./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
190
199
  "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
191
200
  "./components/use-console-visibility": "./components/use-console-visibility.ts",
201
+ "./components/use-conversation": "./components/use-conversation.ts",
192
202
  "./components/use-editorial-checks": "./components/use-editorial-checks.ts",
193
203
  "./components/use-media-query": "./components/use-media-query.ts",
194
204
  "./components/use-near-viewport": "./components/use-near-viewport.ts",
@@ -229,6 +239,8 @@
229
239
  "./lib/content-repository": "./lib/content-repository.ts",
230
240
  "./lib/content-source-context": "./lib/content-source-context.ts",
231
241
  "./lib/content-types": "./lib/content-types.ts",
242
+ "./lib/conversation-feed": "./lib/conversation-feed.ts",
243
+ "./lib/conversation": "./lib/conversation.ts",
232
244
  "./lib/curation-client": "./lib/curation-client.ts",
233
245
  "./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
234
246
  "./lib/curation-routes": "./lib/curation-routes.ts",
@@ -342,6 +354,7 @@
342
354
  "./lib/reference-capture": "./lib/reference-capture.ts",
343
355
  "./lib/reference-display": "./lib/reference-display.ts",
344
356
  "./lib/reference-feed-cache": "./lib/reference-feed-cache.ts",
357
+ "./lib/reference-feed-keys": "./lib/reference-feed-keys.ts",
345
358
  "./lib/reference-feed-ranking": "./lib/reference-feed-ranking.ts",
346
359
  "./lib/reference-policy": "./lib/reference-policy.ts",
347
360
  "./lib/reference-preview": "./lib/reference-preview.ts",
@@ -504,7 +517,7 @@
504
517
  "optional": true
505
518
  }
506
519
  },
507
- "version": "5.56.1",
520
+ "version": "5.58.0",
508
521
  "dependencies": {
509
522
  "@aws-amplify/adapter-nextjs": "^1.7.3",
510
523
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.56.1",
2
+ "version": "5.58.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -1,190 +0,0 @@
1
- "use client";
2
-
3
- import { CheckIcon, CircleCheckIcon, CircleXIcon, Loader2Icon, XIcon } from "lucide-react";
4
- import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
5
- import { cn } from "../lib/utils";
6
- import type { CardPrediction, ReviewChoice } from "./reference-review-card";
7
-
8
- /**
9
- * The rating area of a card: one grid, three columns (label | Accept | Reject), and the two rows share its columns exactly.
10
- * Cyclotron [ Accept 96% ] [ Reject ] read only; the predicted side is highlighted with its confidence
11
- * [====== confidence bar, centred at 50% ======]
12
- * You [ Accept ] [ Reject ] the real buttons, each directly under its matching cell
13
- * Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
14
- * green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
15
- * filled. Flat tones and rounded shapes only, no borders.
16
- * The grid sizes itself to its own width (a container query), not the window's: on a narrow card the label column shrinks to its
17
- * word, the padding tightens, and the confidence wraps under "Accept" instead of spilling out of its cell.
18
- */
19
-
20
- export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
21
-
22
- /** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
23
- export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
24
- const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
25
- return prediction.choice === "accept" ? confidence : 1 - confidence;
26
- }
27
-
28
- /** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
29
- export function confidenceFillPercent(probability: number): number {
30
- return Math.min(100, Math.abs(probability - 0.5) * 2 * 100);
31
- }
32
-
33
- const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
34
-
35
- const TONE = {
36
- match: "bg-emerald-600/20 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100",
37
- miss: "bg-red-600/20 text-red-900 dark:bg-red-400/20 dark:text-red-100",
38
- } as const;
39
- const FILL = { match: "bg-emerald-600 dark:bg-emerald-400", miss: "bg-red-600 dark:bg-red-400", neutral: "bg-[var(--primary)]" } as const;
40
-
41
- export function RatingGrid({
42
- answer,
43
- decided = answer,
44
- disabled = false,
45
- disabledReason = null,
46
- feedback = { phase: "idle" },
47
- note = null,
48
- onChoose,
49
- prediction,
50
- showPrediction = true,
51
- size = "compact",
52
- statusOverlay = false,
53
- surface = "footer",
54
- }: {
55
- /** The side the buttons show as chosen (it may be a pending choice that is still saving). */
56
- answer: ReviewChoice | null;
57
- /** The saved human answer, which the Match / Miss verdict compares against. */
58
- decided?: ReviewChoice | null;
59
- disabled?: boolean;
60
- disabledReason?: string | null;
61
- feedback?: RatingFeedback;
62
- /** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
63
- note?: string | null;
64
- onChoose: (choice: ReviewChoice) => void;
65
- prediction: CardPrediction;
66
- /** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
67
- showPrediction?: boolean;
68
- size?: "compact" | "large";
69
- /** Lay the Saved / note line over the bottom padding of the nearest positioned ancestor, so it adds no height under the buttons. */
70
- statusOverlay?: boolean;
71
- /** The tone the grid sits on, so the bar's centre notch matches it. */
72
- surface?: "footer" | "card";
73
- }) {
74
- const ready = prediction.state === "ready" ? prediction : null;
75
- const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
76
- const probability = ready ? predictionAcceptProbability(ready) : 0.5;
77
- const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
78
- const leansAccept = probability >= 0.5;
79
- const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
80
- const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
81
- const label = size === "large" ? "text-sm" : "text-xs";
82
-
83
- return (
84
- <div
85
- className={cn(
86
- "@container/rating grid gap-x-2 gap-y-2",
87
- showPrediction ? "grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)] @max-[17rem]/rating:gap-x-1.5" : "grid-cols-2",
88
- )}
89
- data-rating-grid
90
- >
91
- {showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
92
- <span className={cn("self-center font-semibold", label)}>Cyclotron</span>
93
- {(["accept", "reject"] as const).map((choice) => {
94
- const predicted = ready?.choice === choice;
95
- const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
96
- return (
97
- <span
98
- className={cn(
99
- "flex min-w-0 flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center @max-[17rem]/rating:px-1",
100
- cell,
101
- !predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
102
- predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
103
- predicted && verdict && cn("font-semibold", TONE[verdict]),
104
- )}
105
- data-cyclotron-choice={choice}
106
- data-predicted={predicted || undefined}
107
- data-verdict={predicted && verdict ? verdict : undefined}
108
- key={choice}
109
- >
110
- {/* Wraps rather than overflows: when the cell is narrow, the confidence moves onto its own line. */}
111
- <span className="flex max-w-full flex-wrap items-center justify-center gap-x-1.5 leading-tight">
112
- <span className="inline-flex items-center gap-1">
113
- {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
114
- {WORD[choice]}
115
- </span>
116
- {/* The confidence and the verdict share one line, so a vote never makes the cell (or the card) taller. */}
117
- {predicted && (confidenceText || verdict) ? (
118
- <small className="inline-flex items-center gap-1 whitespace-nowrap text-xs">
119
- {confidenceText ? <span className="font-normal opacity-80" data-cyclotron-confidence>{confidenceText}</span> : null}
120
- {confidenceText && verdict ? <span aria-hidden="true" className="opacity-60">·</span> : null}
121
- {verdict ? <span className="font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</span> : null}
122
- </small>
123
- ) : null}
124
- </span>
125
- {predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
126
- </span>
127
- );
128
- })}
129
- <div className="col-span-2 col-start-2 flex flex-col justify-center" data-confidence>
130
- <div
131
- aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
132
- className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
133
- data-confidence-bar
134
- data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
135
- role="img"
136
- >
137
- {ready ? (
138
- <span
139
- className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
140
- data-confidence-fill
141
- data-fill-percent={Math.round(half * 2)}
142
- style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
143
- />
144
- ) : null}
145
- <span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
146
- </div>
147
- {/* Screen-reader status only: a visible line under the bar would push it off the middle between the Cyclotron and You rows. */}
148
- <small className="sr-only" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
149
- {prediction.state === "ready" ? "" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
150
- </small>
151
- </div>
152
- </div> : null}
153
-
154
- <div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
155
- {showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
156
- {(["accept", "reject"] as const).map((choice) => {
157
- const on = answer === choice;
158
- const Icon = choice === "accept" ? CheckIcon : XIcon;
159
- return (
160
- <button
161
- aria-disabled={disabled ? true : undefined}
162
- aria-pressed={on}
163
- className={cn(
164
- "inline-flex min-w-0 items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring @max-[17rem]/rating:gap-1 @max-[17rem]/rating:px-1",
165
- cell,
166
- on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
167
- disabled && !on && "opacity-60",
168
- )}
169
- data-review-choice={choice}
170
- key={choice}
171
- onClick={() => onChoose(choice)}
172
- type="button"
173
- >
174
- <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
175
- {WORD[choice]}
176
- </button>
177
- );
178
- })}
179
- </div>
180
-
181
- <div className={cn("flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground", statusOverlay ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : cn("min-h-5", showPrediction ? "col-span-3" : "col-span-2"))} data-feed-saved role="status">
182
- {note ? <span data-feed-reason>{note}</span> : null}
183
- {feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
184
- {feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
185
- </div>
186
- {disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
187
- {feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
188
- </div>
189
- );
190
- }