@anthusai/papyrus 5.58.0 → 5.60.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/message-actions.js +72 -0
- package/amplify/data/message-actions.ts +74 -0
- package/amplify/data/resolvers/delete-own-message.js +30 -0
- package/amplify/data/resolvers/edit-message.js +33 -0
- package/amplify/data/resolvers/moderate-message.js +32 -0
- package/amplify/data/resolvers/post-message.js +68 -0
- package/amplify/data/resolvers/rename-thread.js +28 -0
- package/amplify/data/resource.js +8 -5
- package/amplify/data/resource.ts +8 -5
- package/amplify/data/schema.js +14 -7
- package/amplify/data/schema.ts +14 -7
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +133 -19
- package/components/conversations-memory-source.ts +43 -6
- package/components/conversations-network-source.ts +13 -1
- package/components/conversations-source.ts +15 -3
- package/components/conversations-view.tsx +437 -76
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +14 -14
- package/components/needs-me-feed.tsx +8 -18
- package/components/news-desk-taxonomy-client.ts +142 -57
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +10 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +4 -4
- package/components/notification-center.tsx +5 -7
- package/components/proposals-view.tsx +4 -4
- package/components/reference-conversation.tsx +5 -0
- package/components/reference-feed.tsx +69 -85
- package/components/reference-review-card.tsx +1 -1
- package/components/references-page-loading.tsx +12 -6
- package/components/topic-steering-workspace.tsx +8 -24
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/lib/category-repository.ts +7 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation.ts +73 -2
- package/lib/needs-me-items.ts +4 -4
- package/lib/needs-me-ranking.ts +9 -22
- package/lib/newsroom-board.ts +10 -1
- package/lib/newsroom-demo-dashboard.ts +0 -7
- package/lib/newsroom-organizer-nav.ts +2 -2
- package/lib/post-message.ts +54 -0
- package/lib/proposals-admin.ts +2 -2
- package/lib/reference-feed-ranking.ts +77 -160
- package/lib/reference-review-response.ts +19 -0
- package/lib/review-drafts.ts +2 -2
- package/lib/review-fixture.ts +1 -1
- package/lib/review-requests.ts +4 -4
- package/lib/semantic-graph.ts +1 -1
- package/package.json +5 -1
- package/routes.manifest.json +1 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import type { Schema } from "../amplify/data/resource";
|
|
5
5
|
import { configureAmplifyClient } from "../components/amplify-client-provider";
|
|
6
|
+
import { postMessageArguments, postMessageMutation } from "./post-message";
|
|
6
7
|
import { loadModelPayloadsForOwner, uploadModelPayloadForOwner } from "../components/news-desk-taxonomy-client";
|
|
7
8
|
|
|
8
9
|
const USER_POOL_AUTH_MODE = "userPool";
|
|
@@ -190,14 +191,6 @@ const UPDATE_CONSOLE_THREAD = `
|
|
|
190
191
|
}
|
|
191
192
|
`;
|
|
192
193
|
|
|
193
|
-
const CREATE_CONSOLE_MESSAGE_LEGACY = `
|
|
194
|
-
mutation CreateMessage($input: CreateMessageInput!) {
|
|
195
|
-
createMessage(input: $input) {
|
|
196
|
-
${CONSOLE_MESSAGE_FIELDS_LEGACY}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
`;
|
|
200
|
-
|
|
201
194
|
export type ConsoleChatThread = {
|
|
202
195
|
id: string;
|
|
203
196
|
threadKind: string;
|
|
@@ -383,79 +376,6 @@ function chooseMessageSelectionSet(schemaFields: Set<string> | null): string {
|
|
|
383
376
|
return selected.join("\n ");
|
|
384
377
|
}
|
|
385
378
|
|
|
386
|
-
function fieldValuesForCreateMessageInput(input: CreateConsoleMessageInput): Record<string, unknown> {
|
|
387
|
-
return {
|
|
388
|
-
id: input.id,
|
|
389
|
-
messageKind: input.messageKind,
|
|
390
|
-
messageDomain: input.messageDomain,
|
|
391
|
-
status: input.status,
|
|
392
|
-
summary: input.summary ?? truncateSummary(input.content ?? null),
|
|
393
|
-
source: input.source,
|
|
394
|
-
importRunId: null,
|
|
395
|
-
authorSub: null,
|
|
396
|
-
authorUserProfileId: input.authorUserProfileId ?? null,
|
|
397
|
-
authorLabel: input.authorLabel,
|
|
398
|
-
threadId: input.threadId ?? null,
|
|
399
|
-
parentMessageId: input.parentMessageId ?? null,
|
|
400
|
-
sequenceNumber: input.sequenceNumber ?? null,
|
|
401
|
-
role: input.role ?? null,
|
|
402
|
-
messageType: input.messageType ?? null,
|
|
403
|
-
semanticLayer: input.semanticLayer,
|
|
404
|
-
searchVisibility: input.searchVisibility,
|
|
405
|
-
responseTarget: input.responseTarget,
|
|
406
|
-
responseStatus: input.responseStatus ?? null,
|
|
407
|
-
responseOwner: input.responseOwner ?? null,
|
|
408
|
-
responseStartedAt: input.responseStartedAt ?? null,
|
|
409
|
-
responseCompletedAt: input.responseCompletedAt ?? null,
|
|
410
|
-
responseError: input.responseError ?? null,
|
|
411
|
-
metadata: input.metadata,
|
|
412
|
-
createdAt: input.createdAt,
|
|
413
|
-
updatedAt: input.updatedAt,
|
|
414
|
-
newsroomFeedKey: input.newsroomFeedKey,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function buildCreateMessageInputCandidates(
|
|
419
|
-
input: CreateConsoleMessageInput,
|
|
420
|
-
availableFields: Set<string>,
|
|
421
|
-
): Record<string, unknown>[] {
|
|
422
|
-
const values = fieldValuesForCreateMessageInput(input);
|
|
423
|
-
const candidates: Record<string, unknown>[] = [];
|
|
424
|
-
const seen = new Set<string>();
|
|
425
|
-
|
|
426
|
-
const pushCandidate = (candidate: Record<string, unknown>) => {
|
|
427
|
-
const cleaned: Record<string, unknown> = {};
|
|
428
|
-
for (const [key, value] of Object.entries(candidate)) {
|
|
429
|
-
if (value !== undefined) cleaned[key] = value;
|
|
430
|
-
}
|
|
431
|
-
const signature = JSON.stringify(Object.keys(cleaned).sort().map((key) => [key, cleaned[key]]));
|
|
432
|
-
if (!seen.has(signature)) {
|
|
433
|
-
seen.add(signature);
|
|
434
|
-
candidates.push(cleaned);
|
|
435
|
-
}
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
if (availableFields.size) {
|
|
439
|
-
pushCandidate(sanitizeCreateMessageInput(input, availableFields));
|
|
440
|
-
} else {
|
|
441
|
-
pushCandidate(values);
|
|
442
|
-
pushCandidate({
|
|
443
|
-
id: values.id,
|
|
444
|
-
messageKind: values.messageKind,
|
|
445
|
-
messageDomain: values.messageDomain,
|
|
446
|
-
status: values.status,
|
|
447
|
-
summary: values.summary,
|
|
448
|
-
source: values.source,
|
|
449
|
-
authorLabel: values.authorLabel,
|
|
450
|
-
createdAt: values.createdAt,
|
|
451
|
-
updatedAt: values.updatedAt,
|
|
452
|
-
newsroomFeedKey: values.newsroomFeedKey,
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
return candidates;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
379
|
async function loadConsoleSchemaSnapshot(): Promise<ConsoleSchemaSnapshot> {
|
|
460
380
|
if (consoleSchemaSnapshot) return consoleSchemaSnapshot;
|
|
461
381
|
if (consoleSchemaSnapshotPromise) return consoleSchemaSnapshotPromise;
|
|
@@ -540,70 +460,6 @@ function buildListMessagesQuery(
|
|
|
540
460
|
`;
|
|
541
461
|
}
|
|
542
462
|
|
|
543
|
-
function sanitizeCreateMessageInput(
|
|
544
|
-
input: CreateConsoleMessageInput,
|
|
545
|
-
availableFields: Set<string>,
|
|
546
|
-
): Record<string, unknown> {
|
|
547
|
-
const fieldValues: Record<string, unknown> = {
|
|
548
|
-
id: input.id,
|
|
549
|
-
messageKind: input.messageKind,
|
|
550
|
-
messageDomain: input.messageDomain,
|
|
551
|
-
status: input.status,
|
|
552
|
-
summary: input.summary ?? truncateSummary(input.content ?? null),
|
|
553
|
-
source: input.source,
|
|
554
|
-
importRunId: null,
|
|
555
|
-
authorSub: null,
|
|
556
|
-
authorUserProfileId: input.authorUserProfileId ?? null,
|
|
557
|
-
authorLabel: input.authorLabel,
|
|
558
|
-
threadId: input.threadId ?? null,
|
|
559
|
-
parentMessageId: input.parentMessageId ?? null,
|
|
560
|
-
sequenceNumber: input.sequenceNumber ?? null,
|
|
561
|
-
role: input.role ?? null,
|
|
562
|
-
messageType: input.messageType ?? null,
|
|
563
|
-
semanticLayer: input.semanticLayer,
|
|
564
|
-
searchVisibility: input.searchVisibility,
|
|
565
|
-
responseTarget: input.responseTarget,
|
|
566
|
-
responseStatus: input.responseStatus ?? null,
|
|
567
|
-
responseOwner: input.responseOwner ?? null,
|
|
568
|
-
responseStartedAt: input.responseStartedAt ?? null,
|
|
569
|
-
responseCompletedAt: input.responseCompletedAt ?? null,
|
|
570
|
-
responseError: input.responseError ?? null,
|
|
571
|
-
metadata: input.metadata,
|
|
572
|
-
createdAt: input.createdAt,
|
|
573
|
-
updatedAt: input.updatedAt,
|
|
574
|
-
newsroomFeedKey: input.newsroomFeedKey,
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
if (!availableFields.size) {
|
|
578
|
-
const conservativeFields = [
|
|
579
|
-
"id",
|
|
580
|
-
"messageKind",
|
|
581
|
-
"messageDomain",
|
|
582
|
-
"status",
|
|
583
|
-
"summary",
|
|
584
|
-
"source",
|
|
585
|
-
"authorLabel",
|
|
586
|
-
"createdAt",
|
|
587
|
-
"updatedAt",
|
|
588
|
-
"newsroomFeedKey",
|
|
589
|
-
] as const;
|
|
590
|
-
const conservative: Record<string, unknown> = {};
|
|
591
|
-
for (const key of conservativeFields) {
|
|
592
|
-
const value = fieldValues[key];
|
|
593
|
-
if (value !== undefined) conservative[key] = value;
|
|
594
|
-
}
|
|
595
|
-
return conservative;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const sanitized: Record<string, unknown> = {};
|
|
599
|
-
for (const [key, value] of Object.entries(fieldValues)) {
|
|
600
|
-
if (!availableFields.has(key)) continue;
|
|
601
|
-
if (value === undefined) continue;
|
|
602
|
-
sanitized[key] = value;
|
|
603
|
-
}
|
|
604
|
-
return sanitized;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
463
|
function truncateSummary(value: string | null): string | null {
|
|
608
464
|
if (!value) return null;
|
|
609
465
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
@@ -733,53 +589,27 @@ export async function createConsoleMessage(input: CreateConsoleMessageInput): Pr
|
|
|
733
589
|
const selectionSets = snapshot.messageFields.size
|
|
734
590
|
? [chooseMessageSelectionSet(snapshot.messageFields)]
|
|
735
591
|
: [CONSOLE_MESSAGE_FIELDS, CONSOLE_MESSAGE_FIELDS_LEGACY];
|
|
736
|
-
|
|
592
|
+
// The backend stamps the author from the signed-in identity (postMessage): the browser names none and sets no timestamps.
|
|
593
|
+
const args = postMessageArguments({ ...input, summary: input.summary ?? truncateSummary(input.content ?? null) } as unknown as Record<string, unknown>);
|
|
737
594
|
let lastError: unknown = null;
|
|
738
595
|
|
|
739
596
|
for (const selectionSet of selectionSets) {
|
|
740
|
-
const createMutation = `
|
|
741
|
-
mutation CreateMessage($input: CreateMessageInput!) {
|
|
742
|
-
createMessage(input: $input) {
|
|
743
|
-
${selectionSet}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
`;
|
|
747
|
-
for (const candidateInput of inputCandidates) {
|
|
748
|
-
try {
|
|
749
|
-
const response = await getGraphQLClient().graphql({
|
|
750
|
-
query: createMutation,
|
|
751
|
-
variables: { input: candidateInput },
|
|
752
|
-
authMode: USER_POOL_AUTH_MODE,
|
|
753
|
-
});
|
|
754
|
-
const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "createMessage"));
|
|
755
|
-
await maybeUploadConsoleMessageBody(created.id, input.content);
|
|
756
|
-
return input.content?.trim() ? { ...created, content: input.content } : created;
|
|
757
|
-
} catch (error) {
|
|
758
|
-
if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "createMessage");
|
|
759
|
-
lastError = error;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
if (lastError) {
|
|
765
597
|
try {
|
|
766
598
|
const response = await getGraphQLClient().graphql({
|
|
767
|
-
query:
|
|
768
|
-
variables:
|
|
769
|
-
input: inputCandidates[inputCandidates.length - 1] ?? {},
|
|
770
|
-
},
|
|
599
|
+
query: postMessageMutation(args, selectionSet),
|
|
600
|
+
variables: args,
|
|
771
601
|
authMode: USER_POOL_AUTH_MODE,
|
|
772
602
|
});
|
|
773
|
-
const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "
|
|
603
|
+
const created = normalizeConsoleMessageRecord(assertGraphQL<Record<string, unknown>>(response, "postMessage"));
|
|
774
604
|
await maybeUploadConsoleMessageBody(created.id, input.content);
|
|
775
605
|
return input.content?.trim() ? { ...created, content: input.content } : created;
|
|
776
606
|
} catch (error) {
|
|
777
|
-
if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "
|
|
607
|
+
if (!isUnavailableQueryError(error)) throw normalizeUnknownError(error, "postMessage");
|
|
778
608
|
lastError = error;
|
|
779
609
|
}
|
|
780
610
|
}
|
|
781
611
|
|
|
782
|
-
throw normalizeUnknownError(lastError, "
|
|
612
|
+
throw normalizeUnknownError(lastError, "postMessage");
|
|
783
613
|
}
|
|
784
614
|
|
|
785
615
|
export async function updateConsoleMessage(
|
package/lib/conversation.ts
CHANGED
|
@@ -21,10 +21,53 @@ export type ConversationMessage = {
|
|
|
21
21
|
content: string;
|
|
22
22
|
createdAt: string;
|
|
23
23
|
sequenceNumber: number;
|
|
24
|
+
/** The sub of the person who wrote it: only they can edit it. */
|
|
25
|
+
authorSub?: string | null;
|
|
26
|
+
/** Their profile, where their display name lives. */
|
|
27
|
+
authorUserProfileId?: string | null;
|
|
28
|
+
/** When the author last changed the text. */
|
|
29
|
+
editedAt?: string | null;
|
|
30
|
+
/** A deleted Message keeps its place (replies stay under it) but has no text. */
|
|
31
|
+
deleted?: boolean;
|
|
32
|
+
deletedAt?: string | null;
|
|
24
33
|
/** True while an optimistic Message waits for the save. */
|
|
25
34
|
pending?: boolean;
|
|
26
35
|
};
|
|
27
36
|
|
|
37
|
+
/** Who is looking: what they may change is decided from this (an author edits their own; an admin removes anyone's). */
|
|
38
|
+
export type ConversationViewer = { sub: string | null; isAdmin: boolean };
|
|
39
|
+
|
|
40
|
+
export const NO_VIEWER: ConversationViewer = { sub: null, isAdmin: false };
|
|
41
|
+
|
|
42
|
+
/** Only the author edits a Message, and not one that is deleted or still being saved. */
|
|
43
|
+
export function canEditMessage(viewer: ConversationViewer | undefined, message: ConversationMessage): boolean {
|
|
44
|
+
return Boolean(viewer?.sub && message.authorSub && message.authorSub === viewer.sub && !message.deleted && !message.pending);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The author deletes their own; an admin deletes anyone's. */
|
|
48
|
+
export function canDeleteMessage(viewer: ConversationViewer | undefined, message: ConversationMessage): boolean {
|
|
49
|
+
if (!viewer || message.deleted || message.pending) return false;
|
|
50
|
+
return viewer.isAdmin || Boolean(viewer.sub && message.authorSub && message.authorSub === viewer.sub);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Only the person who started a conversation, or an admin, renames it. */
|
|
54
|
+
export function canRenameThread(viewer: ConversationViewer | undefined, thread: { createdBySub?: string | null }): boolean {
|
|
55
|
+
if (!viewer) return false;
|
|
56
|
+
return viewer.isAdmin || Boolean(viewer.sub && thread.createdBySub && thread.createdBySub === viewer.sub);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The name to show. The label saved with the Message is the author's display name (it travels with the Message, because other
|
|
61
|
+
* editors cannot read profiles); an older Message saved an email there, so a profile name, when it can be read, comes before an
|
|
62
|
+
* email's first part.
|
|
63
|
+
*/
|
|
64
|
+
export function messageAuthorName(message: Pick<ConversationMessage, "authorLabel" | "authorUserProfileId">, names?: ReadonlyMap<string, string>): string {
|
|
65
|
+
const label = String(message.authorLabel ?? "").trim();
|
|
66
|
+
if (label && !label.includes("@")) return label;
|
|
67
|
+
const profileName = message.authorUserProfileId ? names?.get(message.authorUserProfileId)?.trim() : "";
|
|
68
|
+
return profileName || conversationAuthorName(label);
|
|
69
|
+
}
|
|
70
|
+
|
|
28
71
|
export type ConversationReply = { message: ConversationMessage; answers: string | null };
|
|
29
72
|
export type ConversationRoot = { message: ConversationMessage; replies: ConversationReply[] };
|
|
30
73
|
|
|
@@ -75,7 +118,7 @@ export function conversationAuthorName(label: string | null | undefined): string
|
|
|
75
118
|
* Top-level Messages in order, each with its replies. A reply to a reply stays flat under the same root and says who it answers.
|
|
76
119
|
* A reply whose parent is missing is shown as a top-level Message rather than lost.
|
|
77
120
|
*/
|
|
78
|
-
export function buildConversationTree(messages: ReadonlyArray<ConversationMessage
|
|
121
|
+
export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>, nameOf: (message: ConversationMessage) => string = (message) => conversationAuthorName(message.authorLabel)): ConversationRoot[] {
|
|
79
122
|
const sorted = [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber || a.createdAt.localeCompare(b.createdAt));
|
|
80
123
|
const byId = new Map(sorted.map((message) => [message.id, message]));
|
|
81
124
|
const rootOf = (message: ConversationMessage): string => {
|
|
@@ -95,7 +138,7 @@ export function buildConversationTree(messages: ReadonlyArray<ConversationMessag
|
|
|
95
138
|
const root = roots.get(rootOf(message));
|
|
96
139
|
if (!root || root.message.id === message.id) continue;
|
|
97
140
|
const parent = message.parentMessageId ? byId.get(message.parentMessageId) : undefined;
|
|
98
|
-
root.replies.push({ message, answers: parent && parent.id !== root.message.id ?
|
|
141
|
+
root.replies.push({ message, answers: parent && parent.id !== root.message.id ? nameOf(parent) : null });
|
|
99
142
|
}
|
|
100
143
|
return [...roots.values()];
|
|
101
144
|
}
|
|
@@ -112,6 +155,34 @@ export function conversationAge(createdAt: string, now: number): string {
|
|
|
112
155
|
return `${Math.round(hours / 24)}d`;
|
|
113
156
|
}
|
|
114
157
|
|
|
158
|
+
/** The first line of a message, trimmed to fit a card: what a thread shows as its latest message. */
|
|
159
|
+
export function conversationPreviewLine(content: string | null | undefined, max = 140): string {
|
|
160
|
+
const line = String(content ?? "").split("\n").map((part) => part.trim()).find(Boolean) ?? "";
|
|
161
|
+
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function threadMetadataObject(metadata: unknown): Record<string, unknown> {
|
|
165
|
+
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) return metadata as Record<string, unknown>;
|
|
166
|
+
if (typeof metadata !== "string" || !metadata.trim()) return {};
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(metadata);
|
|
169
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
170
|
+
} catch {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The thread's metadata (a JSON string) with the author of its latest message recorded, so a list can show who spoke last without reading messages. */
|
|
176
|
+
export function threadMetadataWithLastAuthor(metadata: unknown, authorLabel: string): string {
|
|
177
|
+
return JSON.stringify({ ...threadMetadataObject(metadata), lastAuthorLabel: authorLabel });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Who wrote the latest message, as recorded on the thread; null for a thread that predates the record. */
|
|
181
|
+
export function threadLastAuthorLabel(metadata: unknown): string | null {
|
|
182
|
+
const label = threadMetadataObject(metadata).lastAuthorLabel;
|
|
183
|
+
return typeof label === "string" && label.trim() ? label : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
115
186
|
export function messageCountLabel(count: number): string {
|
|
116
187
|
return `${count} ${count === 1 ? "message" : "messages"}`;
|
|
117
188
|
}
|
package/lib/needs-me-items.ts
CHANGED
|
@@ -127,9 +127,9 @@ export function draftItem(item: DraftItem, bySource: ReadonlyMap<string, Referen
|
|
|
127
127
|
type: "draft",
|
|
128
128
|
id: item.key,
|
|
129
129
|
title: view?.headline ?? link?.label ?? "Draft",
|
|
130
|
-
dek: view ? view.dek : "Loading the
|
|
130
|
+
dek: view ? view.dek : "Loading the Proposed Story…",
|
|
131
131
|
image: picture.imageUrl,
|
|
132
|
-
source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "
|
|
132
|
+
source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "Proposed Story"),
|
|
133
133
|
host: picture.host,
|
|
134
134
|
createdAt: timeOf(request.raisedAt),
|
|
135
135
|
importance: 1,
|
|
@@ -273,10 +273,10 @@ function plural(count: number, one: string, many = `${one}s`): string {
|
|
|
273
273
|
return `${count} ${count === 1 ? one : many}`;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/** "3
|
|
276
|
+
/** "3 Proposed Stories and 12 references waiting for you"; "All caught up" when nothing waits. */
|
|
277
277
|
export function waitingTextFor(counts: NeedsMeCounts): string {
|
|
278
278
|
const parts = [
|
|
279
|
-
counts.drafts ? plural(counts.drafts, "
|
|
279
|
+
counts.drafts ? plural(counts.drafts, "Proposed Story", "Proposed Stories") : null,
|
|
280
280
|
counts.references ? plural(counts.references, "reference") : null,
|
|
281
281
|
counts.stories ? plural(counts.stories, "story review") : null,
|
|
282
282
|
counts.email ? plural(counts.email, "held email", "held emails") : null,
|
package/lib/needs-me-ranking.ts
CHANGED
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
* What they share is one question: where would a person's answer change the most? In plain words, as the page says them:
|
|
6
6
|
* 1. Hard gates first. Nothing continues until a person decides one, so it leads, oldest first.
|
|
7
7
|
* 2. Then the interim "value of your input" score:
|
|
8
|
-
* value = uncertainty x importance
|
|
8
|
+
* value = uncertainty x importance
|
|
9
9
|
* - uncertainty is 1 - |2P - 1|, where P is the Cyclotron's probability that you would Accept. A coin toss (P = 0.5) is 1, a
|
|
10
10
|
* certainty is 0. An item with no prediction is 0.5: we do not know how unsure we are.
|
|
11
11
|
* - importance is the item's own priority (a relevance score, or a story or section priority). It is 1 until a source gives one.
|
|
12
|
-
* - freshness is exp(-age / tau), the weight the References feed already uses; tau is learned from how old the References you
|
|
13
|
-
* accepted were when you accepted them (reference-feed-ranking.ts). With too few examples there is no decay.
|
|
14
12
|
* 3. Types are interleaved, never more than two of one type in a row while another type is waiting, so a long run of References
|
|
15
13
|
* does not bury the drafts and emails behind it.
|
|
16
14
|
* 4. Confident items do not ask for you. An item whose uncertainty is below the threshold goes to "Decided automatically", where it
|
|
@@ -25,7 +23,7 @@ import type { ReferenceRecord } from "./category-repository";
|
|
|
25
23
|
import { referenceLineageId } from "./newsroom-references";
|
|
26
24
|
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
27
25
|
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
28
|
-
import { acceptProbability
|
|
26
|
+
import { acceptProbability } from "./reference-feed-ranking";
|
|
29
27
|
|
|
30
28
|
export type NeedsMeType = "reference" | "draft" | "stage_review" | "held_email" | "incomplete_capture" | "concept_proposal" | "flag";
|
|
31
29
|
|
|
@@ -35,7 +33,7 @@ export const NEEDS_ME_TYPES: readonly NeedsMeType[] = ["reference", "draft", "st
|
|
|
35
33
|
export type NeedsMeFilter = "all" | "references" | "drafts" | "stories" | "email" | "captures" | "other";
|
|
36
34
|
export const NEEDS_ME_FILTERS: readonly NeedsMeFilter[] = ["all", "references", "drafts", "stories", "email", "captures", "other"];
|
|
37
35
|
export const NEEDS_ME_FILTER_LABELS: Record<NeedsMeFilter, string> = {
|
|
38
|
-
all: "All", references: "References", drafts: "
|
|
36
|
+
all: "All", references: "References", drafts: "Proposed Stories", stories: "Stories", email: "Email", captures: "Captures", other: "Other",
|
|
39
37
|
};
|
|
40
38
|
|
|
41
39
|
const FILTER_OF: Record<NeedsMeType, Exclude<NeedsMeFilter, "all">> = {
|
|
@@ -52,7 +50,7 @@ export function matchesFilter(item: Pick<NeedsMeItem, "type">, filter: NeedsMeFi
|
|
|
52
50
|
|
|
53
51
|
/** The words for a type, used on the card and in the detail pane. */
|
|
54
52
|
export const NEEDS_ME_TYPE_LABELS: Record<NeedsMeType, string> = {
|
|
55
|
-
reference: "Reference", draft: "
|
|
53
|
+
reference: "Reference", draft: "Proposed Story", stage_review: "Story review", held_email: "Held email", incomplete_capture: "Capture check", concept_proposal: "Concept proposal", flag: "For your information",
|
|
56
54
|
};
|
|
57
55
|
|
|
58
56
|
export type NeedsMePrediction = {
|
|
@@ -109,33 +107,23 @@ export function predictionOf(decision: RelevanceDecisionView | undefined): Needs
|
|
|
109
107
|
return { label: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", pAccept, confidence: decision.confidence };
|
|
110
108
|
}
|
|
111
109
|
|
|
112
|
-
const DAY_MILLISECONDS = 86_400_000;
|
|
113
|
-
|
|
114
|
-
/** exp(-age / tau) at `now`; 1 without a learned tau or an unknown arrival. The References feed's own weight. */
|
|
115
|
-
export function freshnessAt(createdAt: number | null, freshness: FeedFreshness, now: number): number {
|
|
116
|
-
if (freshness.tauDays === null || createdAt === null) return 1;
|
|
117
|
-
return Math.exp(-Math.max(now - createdAt, 0) / DAY_MILLISECONDS / freshness.tauDays);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export type AttentionContext = { now: number; freshness: FeedFreshness };
|
|
121
|
-
|
|
122
110
|
/**
|
|
123
111
|
* The value of this editor's input on one item. Higher is earlier. The interim model below is a formula; a Cyclotron "attention"
|
|
124
112
|
* decision implements the same interface and is passed to `rankNeedsMe` in its place.
|
|
125
113
|
*/
|
|
126
114
|
export interface AttentionModel {
|
|
127
115
|
readonly id: string;
|
|
128
|
-
value(item: NeedsMeItem
|
|
116
|
+
value(item: NeedsMeItem): number;
|
|
129
117
|
}
|
|
130
118
|
|
|
131
119
|
export const interimAttention: AttentionModel = {
|
|
132
120
|
id: "interim-value-of-input",
|
|
133
|
-
value: (item
|
|
121
|
+
value: (item) => item.uncertainty * item.importance,
|
|
134
122
|
};
|
|
135
123
|
|
|
136
124
|
// --- confident items ----------------------------------------------------------------------------------------------------
|
|
137
125
|
|
|
138
|
-
export const MINIMUM_CONFIRMED_EXAMPLES =
|
|
126
|
+
export const MINIMUM_CONFIRMED_EXAMPLES = 10;
|
|
139
127
|
|
|
140
128
|
export type ConfidenceThreshold = {
|
|
141
129
|
/** Null with fewer than MINIMUM_CONFIRMED_EXAMPLES confirmed predictions: nothing is guessed, nothing is skipped. */
|
|
@@ -217,11 +205,10 @@ function newestFirst(left: NeedsMeItem, right: NeedsMeItem): number {
|
|
|
217
205
|
|
|
218
206
|
export function rankNeedsMe<P>(
|
|
219
207
|
items: readonly NeedsMeItem<P>[],
|
|
220
|
-
options: {
|
|
208
|
+
options: { threshold: ConfidenceThreshold; attention?: AttentionModel },
|
|
221
209
|
): NeedsMeRanking<P> {
|
|
222
210
|
const attention = options.attention ?? interimAttention;
|
|
223
|
-
const
|
|
224
|
-
const scores = new Map(items.map((item) => [item.id, attention.value(item, context)]));
|
|
211
|
+
const scores = new Map(items.map((item) => [item.id, attention.value(item)]));
|
|
225
212
|
const score = (item: NeedsMeItem) => scores.get(item.id) ?? 0;
|
|
226
213
|
const gates = items.filter((item) => item.hardGate).sort((left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0) || left.id.localeCompare(right.id));
|
|
227
214
|
const rest = items.filter((item) => !item.hardGate);
|
package/lib/newsroom-board.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
|
|
1
|
+
import type { KanbanConfig, KanbanIssue, KanbanSortFieldRule } from "@kanbus/ui";
|
|
2
2
|
import type { AssignmentRecord } from "./category-repository";
|
|
3
3
|
import {
|
|
4
4
|
ASSIGNMENT_PHASE_STAGE_KEYS,
|
|
@@ -118,11 +118,19 @@ export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[])
|
|
|
118
118
|
return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Kanbus's default column order is oldest-created first, and our content items carry no `created_at`, so without this every
|
|
123
|
+
* column listed its cards by id. Newest first by `updated_at`: it is the order the stage pages load in, and the only time a
|
|
124
|
+
* paged content item has (each save is a new version, so `createdAt` is the last edit, not the story's start).
|
|
125
|
+
*/
|
|
126
|
+
export const NEWEST_FIRST: KanbanSortFieldRule[] = [{ field: "updated_at", direction: "desc" }];
|
|
127
|
+
|
|
121
128
|
function boardConfigForType(type: string, columns: readonly { key: string; name: string; collapsed?: boolean }[]): KanbanConfig {
|
|
122
129
|
const statuses = columns.map(column => ({ ...column, category: "native" }));
|
|
123
130
|
return {
|
|
124
131
|
hierarchy: [], types: [type], statuses,
|
|
125
132
|
categories: [{ name: "native" }], priorities: {}, type_colors: {},
|
|
133
|
+
sort_order: Object.fromEntries(statuses.map(status => [status.key, [...NEWEST_FIRST]])),
|
|
126
134
|
workflows: { [type]: Object.fromEntries(statuses.map(status => [status.key, []])) },
|
|
127
135
|
};
|
|
128
136
|
}
|
|
@@ -165,6 +173,7 @@ export function deskBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConf
|
|
|
165
173
|
statuses: [...storiesConfig.statuses, ...assignmentsConfig.statuses],
|
|
166
174
|
categories: storiesConfig.categories,
|
|
167
175
|
workflows: { ...storiesConfig.workflows, ...assignmentsConfig.workflows },
|
|
176
|
+
sort_order: { ...assignmentsConfig.sort_order, ...storiesConfig.sort_order },
|
|
168
177
|
};
|
|
169
178
|
}
|
|
170
179
|
|
|
@@ -161,13 +161,6 @@ export function applyPilobolusDemoDashboardContent(
|
|
|
161
161
|
}));
|
|
162
162
|
|
|
163
163
|
localized.messages = localized.messages.map((message) => {
|
|
164
|
-
if (message.messageKind === "ingestion_rationale") {
|
|
165
|
-
return {
|
|
166
|
-
...message,
|
|
167
|
-
body: "Imported as a useful holdout against generic platform demo intake.",
|
|
168
|
-
summary: "Pilobolus intake rationale",
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
164
|
if (message.messageKind === "reporting_context_packet") {
|
|
172
165
|
const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
|
173
166
|
? message.metadata as Record<string, unknown>
|
|
@@ -8,10 +8,10 @@ export type OrganizerDestination = {
|
|
|
8
8
|
};
|
|
9
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
10
10
|
{ title: "Editorial work", items: [
|
|
11
|
-
{ key: "overview", label: "Needs me", description: "See what needs you:
|
|
11
|
+
{ key: "overview", label: "Needs me", description: "See what needs you: Proposed Stories, references and reviews.", path: "/newsroom", primary: true },
|
|
12
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
13
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
14
|
-
{ key: "drafts", label: "
|
|
14
|
+
{ key: "drafts", label: "Proposed Stories", description: "Read the Proposed Stories that AI suggests, then accept or reject them.", path: "/newsroom/administration/proposals" },
|
|
15
15
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
16
16
|
] },
|
|
17
17
|
{ title: "Research & knowledge", items: [
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages are created through the `postMessage` mutation, never through `createMessage`: the backend stamps the author from the
|
|
3
|
+
* signed-in identity, so a browser cannot name one. This is the one place that turns a Message record into that mutation and its
|
|
4
|
+
* arguments, shared by the conversation, forum and console chat writers. Authorship and timestamps are left out on purpose.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ARGUMENT_TYPES = {
|
|
8
|
+
id: "ID!",
|
|
9
|
+
threadId: "ID!",
|
|
10
|
+
content: "String",
|
|
11
|
+
messageKind: "String!",
|
|
12
|
+
messageDomain: "String!",
|
|
13
|
+
summary: "String",
|
|
14
|
+
parentMessageId: "ID",
|
|
15
|
+
sequenceNumber: "Int",
|
|
16
|
+
messageType: "String",
|
|
17
|
+
role: "String",
|
|
18
|
+
source: "String",
|
|
19
|
+
authorLabel: "String",
|
|
20
|
+
semanticLayer: "String",
|
|
21
|
+
searchVisibility: "String",
|
|
22
|
+
responseTarget: "String",
|
|
23
|
+
responseStatus: "String",
|
|
24
|
+
responseOwner: "String",
|
|
25
|
+
responseStartedAt: "AWSDateTime",
|
|
26
|
+
responseCompletedAt: "AWSDateTime",
|
|
27
|
+
metadata: "AWSJSON",
|
|
28
|
+
newsroomFeedKey: "String",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export type PostMessageArguments = Partial<Record<keyof typeof ARGUMENT_TYPES, string | number | null>>;
|
|
32
|
+
|
|
33
|
+
type MessageLike = Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
/** The arguments of `postMessage` from a Message record. Anything else on the record (authorSub, authorUserProfileId, createdAt, updatedAt) is dropped. */
|
|
36
|
+
export function postMessageArguments(record: MessageLike): PostMessageArguments {
|
|
37
|
+
const args: PostMessageArguments = {};
|
|
38
|
+
for (const key of Object.keys(ARGUMENT_TYPES) as Array<keyof typeof ARGUMENT_TYPES>) {
|
|
39
|
+
let value = record[key === "content" ? "content" : key];
|
|
40
|
+
if (key === "content") value = record.content ?? record.body;
|
|
41
|
+
if (value === undefined || value === null) continue;
|
|
42
|
+
if (key === "metadata") value = typeof value === "string" ? value : JSON.stringify(value);
|
|
43
|
+
args[key] = value as string | number;
|
|
44
|
+
}
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `mutation PostMessage($id: ID!, …) { postMessage(id: $id, …) { <selection> } }` declaring exactly the arguments given. */
|
|
49
|
+
export function postMessageMutation(args: PostMessageArguments, selection: string): string {
|
|
50
|
+
const keys = Object.keys(args) as Array<keyof typeof ARGUMENT_TYPES>;
|
|
51
|
+
const declared = keys.map((key) => `$${key}: ${ARGUMENT_TYPES[key]}`).join(", ");
|
|
52
|
+
const passed = keys.map((key) => `${key}: $${key}`).join(", ");
|
|
53
|
+
return `mutation PostMessage(${declared}) { postMessage(${passed}) { ${selection} } }`;
|
|
54
|
+
}
|
package/lib/proposals-admin.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type ProposalFilter = "open" | "draft" | "in_steering" | "editor_review"
|
|
|
22
22
|
export type ProposalMove = { label: string; path?: string[]; edit?: "pin" | "unpin" | "hold" | "release"; state?: string };
|
|
23
23
|
|
|
24
24
|
const STATE_LABELS: Record<string, string> = {
|
|
25
|
-
draft: "
|
|
25
|
+
draft: "Proposed",
|
|
26
26
|
in_steering: "In steering",
|
|
27
27
|
editor_review: "Editor review",
|
|
28
28
|
approved: "Approved",
|
|
@@ -31,7 +31,7 @@ const STATE_LABELS: Record<string, string> = {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
const KIND_LABELS: Record<string, string> = {
|
|
34
|
-
story_draft: "Story
|
|
34
|
+
story_draft: "Proposed Story",
|
|
35
35
|
new_concept: "New concept",
|
|
36
36
|
rename: "Rename",
|
|
37
37
|
merge: "Merge",
|