@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.
- package/amplify/data/resource.js +25 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +25 -3
- package/amplify/data/schema.ts +25 -3
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +188 -0
- package/components/conversations-memory-source.ts +46 -0
- package/components/conversations-network-source.ts +40 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +24 -0
- package/components/conversations-view.tsx +319 -0
- package/components/decision-control.tsx +307 -0
- package/components/needs-me-bodies.tsx +4 -3
- package/components/needs-me-feed.tsx +8 -14
- package/components/needs-me-reference-detail.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +322 -37
- package/components/newsroom-organizer.css +24 -0
- package/components/newsroom-references-view.tsx +9 -0
- package/components/notification-cards.tsx +5 -4
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +69 -37
- package/components/reference-review-card.tsx +8 -134
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +43 -784
- package/components/use-conversation.ts +84 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +7 -0
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +154 -0
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +15 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -9,7 +9,7 @@ import { cannotMoveReason, type DraftChoice } from "../lib/review-drafts";
|
|
|
9
9
|
import { answerWords, formatWhen, modeLabel, nextText, steeringText, subjectHref, subjectLabel, type ReviewRequest } from "../lib/review-requests";
|
|
10
10
|
import type { ReferencePreview } from "../lib/reference-preview";
|
|
11
11
|
import { FEED_SURFACE_PROPS, FeedImage, PILL, SourceMark } from "./reference-feed";
|
|
12
|
-
import {
|
|
12
|
+
import { DecisionControl } from "./decision-control";
|
|
13
13
|
|
|
14
14
|
/** What the editor typed with an answer. */
|
|
15
15
|
export type DecisionExtras = { why: string; notThisKind: boolean };
|
|
@@ -109,9 +109,9 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
109
109
|
{locked ? (
|
|
110
110
|
<>
|
|
111
111
|
<DecidedLine choice={decision.choice} notThisKind={decision.notThisKind} why={decision.why} />
|
|
112
|
-
<
|
|
112
|
+
<DecisionControl
|
|
113
113
|
answer={decision.choice} disabled disabledReason={null} feedback={{ phase: "saved" }} onChoose={() => undefined}
|
|
114
|
-
prediction={{ state: "none" }} showPrediction={false} surface="footer"
|
|
114
|
+
prediction={{ state: "none" }} showPrediction={false} showReason={false} surface="footer"
|
|
115
115
|
/>
|
|
116
116
|
<p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p>
|
|
117
117
|
</>
|
|
@@ -132,7 +132,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
132
132
|
</button>
|
|
133
133
|
<span className="text-xs text-muted-foreground">Optional. Counts if you reject.</span>
|
|
134
134
|
</div>
|
|
135
|
-
<
|
|
135
|
+
<DecisionControl
|
|
136
136
|
answer={busy ? pending : null}
|
|
137
137
|
disabled={Boolean(disabledReason)}
|
|
138
138
|
disabledReason={disabledReason}
|
|
@@ -140,6 +140,7 @@ function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNot
|
|
|
140
140
|
onChoose={choose}
|
|
141
141
|
prediction={{ state: "none" }}
|
|
142
142
|
showPrediction={false}
|
|
143
|
+
showReason={false}
|
|
143
144
|
surface="footer"
|
|
144
145
|
/>
|
|
145
146
|
</>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ConversationThread } from "./conversation-thread";
|
|
5
|
+
import { useConversation } from "./use-conversation";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Messages about one Reference, under its review card. Shows nothing where no conversation store is provided.
|
|
9
|
+
* `onCount` tells the feed how many Messages there are, so its card count follows what is written here.
|
|
10
|
+
*/
|
|
11
|
+
export function ReferenceConversation({ disabledReason = null, lineageId, onCount, referenceId, title }: {
|
|
12
|
+
disabledReason?: string | null;
|
|
13
|
+
lineageId: string;
|
|
14
|
+
onCount?: (lineageId: string, count: number) => void;
|
|
15
|
+
/** The Reference version's id, when it differs from the lineage id. */
|
|
16
|
+
referenceId?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const conversation = useConversation({ kind: "reference", lineageId, title, ...(referenceId ? { id: referenceId } : {}) });
|
|
20
|
+
const [now, setNow] = useState(() => Date.now());
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const timer = window.setInterval(() => setNow(Date.now()), 60000);
|
|
23
|
+
return () => window.clearInterval(timer);
|
|
24
|
+
}, []);
|
|
25
|
+
const settled = conversation.messages.filter((message) => !message.pending).length;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (conversation.state === "ready") onCount?.(lineageId, settled);
|
|
28
|
+
}, [conversation.state, lineageId, onCount, settled]);
|
|
29
|
+
if (!conversation.available) return null;
|
|
30
|
+
return (
|
|
31
|
+
<div data-reference-conversation={lineageId}>
|
|
32
|
+
<ConversationThread
|
|
33
|
+
disabledReason={disabledReason}
|
|
34
|
+
error={conversation.error}
|
|
35
|
+
messages={conversation.messages}
|
|
36
|
+
now={now}
|
|
37
|
+
onPost={conversation.post}
|
|
38
|
+
onRetry={conversation.retry}
|
|
39
|
+
state={conversation.state}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { InfoIcon, Loader2Icon } from "lucide-react";
|
|
3
|
+
import { InfoIcon, Loader2Icon, MessageSquareIcon } from "lucide-react";
|
|
4
4
|
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
5
5
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
6
6
|
import { REFERENCE_REJECTION_REASON_CODES, isAutoAccepted, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
@@ -20,11 +20,12 @@ import { referenceDisplayTitle } from "../lib/newsroom-references";
|
|
|
20
20
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
21
21
|
import { cn } from "../lib/utils";
|
|
22
22
|
import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
|
|
23
|
-
import {
|
|
23
|
+
import { DecisionControl } from "./decision-control";
|
|
24
24
|
import { CaptureBadge } from "./reference-capture-panel";
|
|
25
25
|
import { AutoAcceptedBadge } from "./auto-accepted-badge";
|
|
26
26
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
27
27
|
import type { CaptureStatus } from "../lib/reference-capture";
|
|
28
|
+
import { messageCountLabel } from "../lib/conversation";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
|
|
@@ -56,6 +57,8 @@ export type ReferenceFeedProps = {
|
|
|
56
57
|
disabledReason?: string | null;
|
|
57
58
|
/** True while the first read is in flight: cards are skeletons. */
|
|
58
59
|
loading?: boolean;
|
|
60
|
+
/** How many Messages each Reference has, by lineage id. A card with none shows no count. */
|
|
61
|
+
messageCounts?: ReadonlyMap<string, number>;
|
|
59
62
|
now: number;
|
|
60
63
|
onOpen: (lineageId: string) => void;
|
|
61
64
|
onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
|
|
@@ -199,8 +202,61 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
|
|
|
199
202
|
);
|
|
200
203
|
}
|
|
201
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The standard Reference header: its picture, source, headline, dek and age. The feed card wraps it in the button that opens the
|
|
207
|
+
* Reference, and the Messages page shows it under a conversation as the thing the conversation is about. No decision controls.
|
|
208
|
+
*/
|
|
209
|
+
export function ReferenceCardHeader({ capture, lead = false, messageCount = 0, now, preview, reference, titleId }: {
|
|
210
|
+
capture?: CaptureStatus | undefined;
|
|
211
|
+
lead?: boolean;
|
|
212
|
+
messageCount?: number;
|
|
213
|
+
now: number;
|
|
214
|
+
preview: ReferencePreview | undefined;
|
|
215
|
+
reference: ReferenceRecord;
|
|
216
|
+
titleId?: string;
|
|
217
|
+
}) {
|
|
218
|
+
const host = referenceSourceHost(reference);
|
|
219
|
+
const site = preview?.siteName ?? host ?? "Unknown source";
|
|
220
|
+
const title = referenceDisplayTitle(reference);
|
|
221
|
+
const dek = referenceDek(reference, preview);
|
|
222
|
+
const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
|
|
223
|
+
const time = referenceDisplayTime(reference);
|
|
224
|
+
const age = formatFeedAge(time, now);
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
<FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
|
|
228
|
+
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
|
|
229
|
+
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
230
|
+
<SourceMark host={host} />
|
|
231
|
+
<span className="truncate">{site}</span>
|
|
232
|
+
</span>
|
|
233
|
+
<span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
|
|
234
|
+
{title}
|
|
235
|
+
</span>
|
|
236
|
+
{showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
|
|
237
|
+
<span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
|
|
238
|
+
<span>
|
|
239
|
+
{age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
|
|
240
|
+
{age && host ? <span aria-hidden="true"> · </span> : null}
|
|
241
|
+
{host}
|
|
242
|
+
</span>
|
|
243
|
+
{capture ? <CaptureBadge status={capture} /> : null}
|
|
244
|
+
{messageCount > 0 ? (
|
|
245
|
+
<span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
|
|
246
|
+
<MessageSquareIcon aria-hidden="true" className="size-3.5" />
|
|
247
|
+
{messageCount}
|
|
248
|
+
</span>
|
|
249
|
+
) : null}
|
|
250
|
+
{isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
|
|
251
|
+
</span>
|
|
252
|
+
</span>
|
|
253
|
+
</>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
202
257
|
export function FeedCard({
|
|
203
258
|
capture,
|
|
259
|
+
messageCount = 0,
|
|
204
260
|
decision,
|
|
205
261
|
disabledReason,
|
|
206
262
|
item,
|
|
@@ -216,6 +272,8 @@ export function FeedCard({
|
|
|
216
272
|
}: {
|
|
217
273
|
/** Optional: the capture badge. */
|
|
218
274
|
capture?: CaptureStatus | undefined;
|
|
275
|
+
/** Optional: how many Messages this Reference has. */
|
|
276
|
+
messageCount?: number;
|
|
219
277
|
decision: RelevanceDecisionView | undefined;
|
|
220
278
|
disabledReason: string | null;
|
|
221
279
|
item: FeedItem;
|
|
@@ -277,54 +335,26 @@ export function FeedCard({
|
|
|
277
335
|
onClick={onOpen}
|
|
278
336
|
type="button"
|
|
279
337
|
>
|
|
280
|
-
<
|
|
281
|
-
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
|
|
282
|
-
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
283
|
-
<SourceMark host={host} />
|
|
284
|
-
<span className="truncate">{site}</span>
|
|
285
|
-
</span>
|
|
286
|
-
<span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
|
|
287
|
-
{title}
|
|
288
|
-
</span>
|
|
289
|
-
{showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
|
|
290
|
-
<span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
|
|
291
|
-
<span>
|
|
292
|
-
{age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
|
|
293
|
-
{age && host ? <span aria-hidden="true"> · </span> : null}
|
|
294
|
-
{host}
|
|
295
|
-
</span>
|
|
296
|
-
{capture ? <CaptureBadge status={capture} /> : null}
|
|
297
|
-
{isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
|
|
298
|
-
</span>
|
|
299
|
-
</span>
|
|
338
|
+
<ReferenceCardHeader capture={capture} lead={lead} messageCount={messageCount} now={now} preview={preview} reference={reference} titleId={titleId} />
|
|
300
339
|
</button>
|
|
301
340
|
|
|
302
341
|
<div className="relative bg-[var(--feed-footer)] px-4 pb-4 pt-3" data-feed-actions>
|
|
303
|
-
<
|
|
342
|
+
<DecisionControl
|
|
304
343
|
answer={chosen}
|
|
305
344
|
decided={answer}
|
|
306
345
|
disabled={Boolean(disabledReason)}
|
|
307
346
|
disabledReason={disabledReason}
|
|
308
347
|
feedback={feedback}
|
|
348
|
+
needsReason={rating.askReason}
|
|
309
349
|
note={reasonLabel ? `Reason: ${reasonLabel}` : null}
|
|
310
350
|
onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
|
|
351
|
+
onReasonChange={(text) => store.setNote(item.lineageId, text)}
|
|
352
|
+
onReasonCommit={() => { if (!disabledReason) store.commitNote(item.lineageId); }}
|
|
311
353
|
prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
|
|
354
|
+
reason={rating.note ?? ""}
|
|
355
|
+
reasonRequiredFor="reject"
|
|
312
356
|
statusOverlay
|
|
313
357
|
/>
|
|
314
|
-
{rating.askReason ? (
|
|
315
|
-
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
316
|
-
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
317
|
-
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
318
|
-
<div className="flex flex-wrap gap-1.5">
|
|
319
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
320
|
-
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
|
|
321
|
-
{REASON_LABELS[code]}
|
|
322
|
-
</button>
|
|
323
|
-
))}
|
|
324
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
|
|
325
|
-
</div>
|
|
326
|
-
</div>
|
|
327
|
-
) : null}
|
|
328
358
|
</div>
|
|
329
359
|
</article>
|
|
330
360
|
);
|
|
@@ -398,6 +428,7 @@ export function SectionHeading({ children, id }: { children: ReactNode; id: stri
|
|
|
398
428
|
export function ReferenceFeed({
|
|
399
429
|
disabledReason = null,
|
|
400
430
|
loading = false,
|
|
431
|
+
messageCounts,
|
|
401
432
|
now,
|
|
402
433
|
onOpen,
|
|
403
434
|
onRate,
|
|
@@ -426,6 +457,7 @@ export function ReferenceFeed({
|
|
|
426
457
|
item={item}
|
|
427
458
|
key={item.lineageId}
|
|
428
459
|
lead={lead}
|
|
460
|
+
messageCount={messageCounts?.get(item.lineageId)}
|
|
429
461
|
now={now}
|
|
430
462
|
onOpen={() => onOpen(item.lineageId)}
|
|
431
463
|
onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
|
|
@@ -2,20 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ArchiveIcon,
|
|
5
|
-
CheckIcon,
|
|
6
5
|
ChevronLeftIcon,
|
|
7
6
|
ChevronRightIcon,
|
|
8
|
-
CircleCheckIcon,
|
|
9
|
-
CircleXIcon,
|
|
10
7
|
ExternalLinkIcon,
|
|
11
8
|
} from "lucide-react";
|
|
12
9
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
10
|
import { Button } from "./ui/button";
|
|
14
|
-
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
15
11
|
import { cn } from "../lib/utils";
|
|
16
|
-
import {
|
|
12
|
+
import { DecisionControl } from "./decision-control";
|
|
17
13
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
18
|
-
import {
|
|
14
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
19
15
|
|
|
20
16
|
export type ReviewChoice = "accept" | "reject";
|
|
21
17
|
|
|
@@ -31,96 +27,6 @@ export type ReviewCardDecision = {
|
|
|
31
27
|
reasonCode: ReferenceRejectionReasonCode | null;
|
|
32
28
|
};
|
|
33
29
|
|
|
34
|
-
const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
35
|
-
out_of_scope: "Out of scope",
|
|
36
|
-
policy_exclusion: "Policy exclusion",
|
|
37
|
-
duplicate: "Duplicate",
|
|
38
|
-
low_quality: "Low quality",
|
|
39
|
-
unavailable: "Unavailable",
|
|
40
|
-
provenance: "Provenance",
|
|
41
|
-
other: "Other",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const CHOICE_LABEL: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
45
|
-
|
|
46
|
-
function percent(confidence: number | null): string | null {
|
|
47
|
-
return confidence === null ? null : `${Math.round(confidence * 100)}%`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
|
|
52
|
-
* Shared by the review card and by every list that shows a suggestion before a decision.
|
|
53
|
-
*/
|
|
54
|
-
export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
|
|
55
|
-
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
56
|
-
return (
|
|
57
|
-
<div className="space-y-2" data-review-row="cyclotron">
|
|
58
|
-
<div className="flex items-center gap-3">
|
|
59
|
-
<span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
|
|
60
|
-
{prediction.state === "ready" ? (
|
|
61
|
-
<div className="flex flex-1 gap-2">
|
|
62
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
63
|
-
const predicted = prediction.state === "ready" && prediction.choice === choice;
|
|
64
|
-
const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
|
|
65
|
-
return (
|
|
66
|
-
<span
|
|
67
|
-
className={cn(
|
|
68
|
-
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
69
|
-
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
70
|
-
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
71
|
-
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
72
|
-
)}
|
|
73
|
-
data-cyclotron-choice={choice}
|
|
74
|
-
data-predicted={predicted || undefined}
|
|
75
|
-
data-verdict={verdict ?? undefined}
|
|
76
|
-
key={choice}
|
|
77
|
-
>
|
|
78
|
-
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
79
|
-
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
80
|
-
{CHOICE_LABEL[choice]}
|
|
81
|
-
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
82
|
-
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
83
|
-
</span>
|
|
84
|
-
);
|
|
85
|
-
})}
|
|
86
|
-
</div>
|
|
87
|
-
) : (
|
|
88
|
-
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
89
|
-
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
90
|
-
</p>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
|
|
98
|
-
export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
|
|
99
|
-
return (
|
|
100
|
-
<div className="flex flex-1 gap-2">
|
|
101
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
102
|
-
const on = chosen === choice;
|
|
103
|
-
return (
|
|
104
|
-
<Button
|
|
105
|
-
aria-disabled={disabled ? true : undefined}
|
|
106
|
-
aria-pressed={on}
|
|
107
|
-
className="min-h-14 flex-1 text-base"
|
|
108
|
-
data-review-choice={choice}
|
|
109
|
-
key={choice}
|
|
110
|
-
onClick={() => onChoose(choice)}
|
|
111
|
-
type="button"
|
|
112
|
-
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
113
|
-
>
|
|
114
|
-
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
115
|
-
{CHOICE_LABEL[choice]}
|
|
116
|
-
</Button>
|
|
117
|
-
);
|
|
118
|
-
})}
|
|
119
|
-
</div>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** A reference's relevance decision in the card's own vocabulary. */
|
|
124
30
|
export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
|
|
125
31
|
return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
|
|
126
32
|
}
|
|
@@ -181,10 +87,8 @@ export function ReviewCard({
|
|
|
181
87
|
const store = ratings ?? local;
|
|
182
88
|
const item = store.get(itemId);
|
|
183
89
|
const note = item.note ?? initialNote;
|
|
184
|
-
const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
|
|
185
90
|
const chosen = item.pending ?? decided;
|
|
186
91
|
const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
|
|
187
|
-
const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
|
|
188
92
|
const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
|
|
189
93
|
|
|
190
94
|
// Desktop shortcuts: A accepts, R rejects. Never while typing.
|
|
@@ -222,53 +126,23 @@ export function ReviewCard({
|
|
|
222
126
|
) : null}
|
|
223
127
|
</header>
|
|
224
128
|
|
|
225
|
-
<
|
|
129
|
+
<DecisionControl
|
|
226
130
|
answer={chosen}
|
|
227
131
|
decided={decided}
|
|
228
132
|
disabled={Boolean(disabledReason)}
|
|
229
133
|
disabledReason={disabledReason}
|
|
230
134
|
feedback={item.feedback}
|
|
135
|
+
needsReason={item.askReason}
|
|
231
136
|
onChoose={choose}
|
|
137
|
+
onReasonChange={(text) => store.setNote(itemId, text)}
|
|
138
|
+
onReasonCommit={commitNote}
|
|
232
139
|
prediction={prediction}
|
|
140
|
+
reason={note}
|
|
141
|
+
reasonRequiredFor="reject"
|
|
233
142
|
size="large"
|
|
234
143
|
surface="card"
|
|
235
144
|
/>
|
|
236
145
|
|
|
237
|
-
<label className="block text-sm font-medium">
|
|
238
|
-
Why? (optional)
|
|
239
|
-
<AutoGrowTextarea
|
|
240
|
-
aria-label="Why? (optional)"
|
|
241
|
-
className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
242
|
-
onBlur={commitNote}
|
|
243
|
-
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
244
|
-
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
245
|
-
value={note}
|
|
246
|
-
/>
|
|
247
|
-
</label>
|
|
248
|
-
|
|
249
|
-
{chosen === "reject" ? (
|
|
250
|
-
<div className="space-y-2" data-review-reasons>
|
|
251
|
-
<p className="text-sm font-medium">Reason for rejecting</p>
|
|
252
|
-
<div className="flex flex-wrap gap-2">
|
|
253
|
-
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
254
|
-
<button
|
|
255
|
-
aria-disabled={disabledReason ? true : undefined}
|
|
256
|
-
aria-pressed={reason === code}
|
|
257
|
-
className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
|
|
258
|
-
data-review-reason={code}
|
|
259
|
-
key={code}
|
|
260
|
-
onClick={() => chooseReason(code)}
|
|
261
|
-
type="button"
|
|
262
|
-
>
|
|
263
|
-
{reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
264
|
-
{REASON_LABELS[code]}
|
|
265
|
-
</button>
|
|
266
|
-
))}
|
|
267
|
-
</div>
|
|
268
|
-
{!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
|
|
269
|
-
</div>
|
|
270
|
-
) : null}
|
|
271
|
-
|
|
272
146
|
{children}
|
|
273
147
|
|
|
274
148
|
{onArchive ? (
|
|
@@ -4,7 +4,7 @@ import { generateClient } from "aws-amplify/data";
|
|
|
4
4
|
import type { Schema } from "../amplify/data/resource";
|
|
5
5
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
6
6
|
import { createLocalPreviewReportingTrail, createLocalPreviewStoriesForReference } from "../lib/local-preview-seed";
|
|
7
|
-
import { EMPTY_STORY_REPORTING_TRAIL, referencesByRole, siteNameOf, type ReferenceStory, type StoryReportingTrail, type TrailAssignment, type TrailAttachment, type TrailReference, type TrailThread, type TrailVersion } from "../lib/story-reporting-trail";
|
|
7
|
+
import { EMPTY_STORY_REPORTING_TRAIL, referencesByRole, siteNameOf, stageMovesOf, type ReferenceStory, type StoryReportingTrail, type TrailAssignment, type TrailAttachment, type TrailReference, type TrailThread, type TrailVersion } from "../lib/story-reporting-trail";
|
|
8
8
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
9
9
|
|
|
10
10
|
type GraphqlResponse = { data?: Record<string, { items?: Record<string, unknown>[] | null; nextToken?: string | null } | null> | null; errors?: { message?: string }[] | null };
|
|
@@ -89,7 +89,7 @@ export async function loadStoryReportingTrail(lineageId: string, itemId: string,
|
|
|
89
89
|
configureAmplifyClient();
|
|
90
90
|
const client = generateClient<Schema>() as unknown as GraphqlClient;
|
|
91
91
|
const [versionRows, assignmentRows, attachmentRows, relationRows] = await Promise.all([
|
|
92
|
-
listAll(client, "listItemsByLineageAndVersion", "id versionNumber versionState status stage headline title changeReason versionCreatedBy versionCreatedAt updatedAt", ", $lineageId: ID!", ", lineageId: $lineageId", { lineageId }).catch(() => []),
|
|
92
|
+
listAll(client, "listItemsByLineageAndVersion", "id versionNumber versionState status stage headline title changeReason versionCreatedBy versionCreatedAt updatedAt metadata", ", $lineageId: ID!", ", lineageId: $lineageId", { lineageId }).catch(() => []),
|
|
93
93
|
listAll(client, "listAssignmentsByItemLineageAndCreatedAt", "id title assignmentTypeKey status assigneeKey summary createdAt", ", $itemLineageId: ID!", ", itemLineageId: $itemLineageId", { itemLineageId: lineageId }).catch(() => []),
|
|
94
94
|
listAll(client, "listModelAttachmentsByOwnerRoleAndSortKey", "id role filename mediaType byteSize", ", $ownerId: ID!", ", ownerId: $ownerId", { ownerId: itemId }).catch(() => []),
|
|
95
95
|
listAll(client, "listSemanticRelations", "id predicate subjectKind subjectLineageId objectKind objectId objectLineageId metadata", ", $filter: ModelSemanticRelationFilterInput", ", filter: $filter", { filter: { or: [{ subjectLineageId: { eq: lineageId } }, { objectLineageId: { eq: lineageId } }] } }).catch(() => []),
|
|
@@ -124,5 +124,6 @@ export async function loadStoryReportingTrail(lineageId: string, itemId: string,
|
|
|
124
124
|
};
|
|
125
125
|
})));
|
|
126
126
|
const attachments: TrailAttachment[] = attachmentRows.map(row => ({ id: String(row.id), role: text(row.role), filename: text(row.filename), mediaType: text(row.mediaType), byteSize: typeof row.byteSize === "number" ? row.byteSize : null }));
|
|
127
|
-
|
|
127
|
+
const latestRow = [...versionRows].sort((left, right) => Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0))[0];
|
|
128
|
+
return { ...EMPTY_STORY_REPORTING_TRAIL, versions, assignments, threads, references, attachments, stageMoves: stageMovesOf(latestRow?.metadata) };
|
|
128
129
|
}
|
|
@@ -91,6 +91,14 @@ export function StoryReportingTrailView({ citedKeys = [], demo = false, focusedR
|
|
|
91
91
|
</li>)}
|
|
92
92
|
</ol>
|
|
93
93
|
</TrailSection>
|
|
94
|
+
{trail.stageMoves.length > 0 && <TrailSection count={trail.stageMoves.length} title="Stage moves">
|
|
95
|
+
<ol className="story-trail-list" data-story-trail-stage-moves>
|
|
96
|
+
{[...trail.stageMoves].reverse().map(move => <li key={`${move.at}-${move.to}`}>
|
|
97
|
+
<div className="story-trail-row"><strong>{move.from ?? "start"} → {move.to}</strong>{move.by && <span className="story-trail-chip">{move.by}</span>}<span className="story-trail-meta">{formatTimestamp(move.at)}</span></div>
|
|
98
|
+
{move.reason && <p className="story-trail-meta">{move.reason}</p>}
|
|
99
|
+
</li>)}
|
|
100
|
+
</ol>
|
|
101
|
+
</TrailSection>}
|
|
94
102
|
<TrailSection count={trail.versions.length} title="Draft versions">
|
|
95
103
|
<ol className="story-trail-list">
|
|
96
104
|
{trail.versions.map((version, versionIndex) => <li key={version.id}>
|