@anthusai/papyrus 1.0.0-next.32 → 1.0.0-next.34
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/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +3 -1
- package/amplify/auth/resource.ts +6 -1
- package/amplify/data/resource.js +2 -0
- package/amplify/data/resource.ts +2 -0
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/site-backend-config.ts +6 -0
- package/amplify/site-backend.js +6 -1
- package/amplify/site-backend.ts +6 -1
- package/app/newsroom/layout.tsx +2 -0
- package/components/amplify-client-provider.tsx +4 -22
- package/components/news-desk-taxonomy-client.ts +51 -0
- package/components/newsroom-references-view.tsx +83 -8
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/topic-steering-workspace.tsx +48 -1
- package/infra/site-config.js +14 -5
- package/infra/site-config.ts +14 -5
- package/lib/category-repository.ts +41 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/semantic-graph.ts +2 -0
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const DEFAULT_NEWSROOM_BASE_PATH = "/newsroom";
|
|
2
|
+
function parseRedirectUrl(value) {
|
|
3
|
+
try {
|
|
4
|
+
const parsed = new URL(value);
|
|
5
|
+
return { original: value, origin: parsed.origin, path: parsed.pathname.replace(/\/+$/, "") };
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Expands every origin in an OAuth redirect list to both the newsroom return
|
|
13
|
+
* path and the site root, newsroom first. Amplify signs in with the first
|
|
14
|
+
* entry that matches the current origin, so a list that names only
|
|
15
|
+
* `https://host/` would land a signed-in editor on the public reader.
|
|
16
|
+
*
|
|
17
|
+
* With `newsroomBasePath` of `""` (a CMS-only host that mounts the newsroom at
|
|
18
|
+
* `/`), the root already is the newsroom and nothing is added. Explicit
|
|
19
|
+
* entries are kept; unparseable entries pass through unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export function expandNewsroomRedirectUrls(redirectUrls, newsroomBasePath = DEFAULT_NEWSROOM_BASE_PATH) {
|
|
22
|
+
const newsroomPath = newsroomBasePath.replace(/\/+$/, "");
|
|
23
|
+
const originOrder = [];
|
|
24
|
+
const explicitByOrigin = new Map();
|
|
25
|
+
const passthrough = [];
|
|
26
|
+
for (const value of redirectUrls) {
|
|
27
|
+
const parsed = parseRedirectUrl(value);
|
|
28
|
+
if (!parsed) {
|
|
29
|
+
passthrough.push(value);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!explicitByOrigin.has(parsed.origin)) {
|
|
33
|
+
originOrder.push(parsed.origin);
|
|
34
|
+
explicitByOrigin.set(parsed.origin, []);
|
|
35
|
+
}
|
|
36
|
+
explicitByOrigin.get(parsed.origin)?.push(value);
|
|
37
|
+
}
|
|
38
|
+
const expanded = [];
|
|
39
|
+
for (const origin of originOrder) {
|
|
40
|
+
const explicit = explicitByOrigin.get(origin) ?? [];
|
|
41
|
+
if (newsroomPath === "") {
|
|
42
|
+
expanded.push(...explicit);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const newsroomEntries = explicit.filter((value) => parseRedirectUrl(value)?.path === newsroomPath);
|
|
46
|
+
const otherEntries = explicit.filter((value) => {
|
|
47
|
+
const path = parseRedirectUrl(value)?.path;
|
|
48
|
+
return path !== newsroomPath && path !== "";
|
|
49
|
+
});
|
|
50
|
+
expanded.push(...(newsroomEntries.length > 0 ? newsroomEntries : [`${origin}${newsroomPath}`]), `${origin}/`, ...otherEntries);
|
|
51
|
+
}
|
|
52
|
+
expanded.push(...passthrough);
|
|
53
|
+
return [...new Set(expanded)];
|
|
54
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const DEFAULT_NEWSROOM_BASE_PATH = "/newsroom";
|
|
2
|
+
|
|
3
|
+
type ParsedRedirectUrl = { original: string; origin: string; path: string } | null;
|
|
4
|
+
|
|
5
|
+
function parseRedirectUrl(value: string): ParsedRedirectUrl {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = new URL(value);
|
|
8
|
+
return { original: value, origin: parsed.origin, path: parsed.pathname.replace(/\/+$/, "") };
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Expands every origin in an OAuth redirect list to both the newsroom return
|
|
16
|
+
* path and the site root, newsroom first. Amplify signs in with the first
|
|
17
|
+
* entry that matches the current origin, so a list that names only
|
|
18
|
+
* `https://host/` would land a signed-in editor on the public reader.
|
|
19
|
+
*
|
|
20
|
+
* With `newsroomBasePath` of `""` (a CMS-only host that mounts the newsroom at
|
|
21
|
+
* `/`), the root already is the newsroom and nothing is added. Explicit
|
|
22
|
+
* entries are kept; unparseable entries pass through unchanged.
|
|
23
|
+
*/
|
|
24
|
+
export function expandNewsroomRedirectUrls(
|
|
25
|
+
redirectUrls: readonly string[],
|
|
26
|
+
newsroomBasePath: string = DEFAULT_NEWSROOM_BASE_PATH,
|
|
27
|
+
): string[] {
|
|
28
|
+
const newsroomPath = newsroomBasePath.replace(/\/+$/, "");
|
|
29
|
+
const originOrder: string[] = [];
|
|
30
|
+
const explicitByOrigin = new Map<string, string[]>();
|
|
31
|
+
const passthrough: string[] = [];
|
|
32
|
+
|
|
33
|
+
for (const value of redirectUrls) {
|
|
34
|
+
const parsed = parseRedirectUrl(value);
|
|
35
|
+
if (!parsed) {
|
|
36
|
+
passthrough.push(value);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!explicitByOrigin.has(parsed.origin)) {
|
|
40
|
+
originOrder.push(parsed.origin);
|
|
41
|
+
explicitByOrigin.set(parsed.origin, []);
|
|
42
|
+
}
|
|
43
|
+
explicitByOrigin.get(parsed.origin)?.push(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const expanded: string[] = [];
|
|
47
|
+
for (const origin of originOrder) {
|
|
48
|
+
const explicit = explicitByOrigin.get(origin) ?? [];
|
|
49
|
+
if (newsroomPath === "") {
|
|
50
|
+
expanded.push(...explicit);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const newsroomEntries = explicit.filter((value) => parseRedirectUrl(value)?.path === newsroomPath);
|
|
54
|
+
const otherEntries = explicit.filter((value) => {
|
|
55
|
+
const path = parseRedirectUrl(value)?.path;
|
|
56
|
+
return path !== newsroomPath && path !== "";
|
|
57
|
+
});
|
|
58
|
+
expanded.push(
|
|
59
|
+
...(newsroomEntries.length > 0 ? newsroomEntries : [`${origin}${newsroomPath}`]),
|
|
60
|
+
`${origin}/`,
|
|
61
|
+
...otherEntries,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
expanded.push(...passthrough);
|
|
65
|
+
return [...new Set(expanded)];
|
|
66
|
+
}
|
package/amplify/auth/resource.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { defineAuth, secret } from "@aws-amplify/backend";
|
|
2
2
|
import { manageUserRole } from "../functions/manage-user-role/resource.js";
|
|
3
3
|
import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
|
|
4
|
+
import { expandNewsroomRedirectUrls } from "./redirect-urls.js";
|
|
4
5
|
// Default OAuth redirect URLs for the p.apyr.us app. Other publications pass
|
|
5
6
|
// `auth.redirectUrls` to `defineSiteBackend` (or, for Papyrus's own backend,
|
|
6
7
|
// the PAPYRUS_OAUTH_REDIRECT_URLS branch env var, comma-separated).
|
|
8
|
+
// `defineSiteAuth` adds the newsroom and root variants of every origin listed.
|
|
7
9
|
// See docs/google-oauth-setup.md.
|
|
8
10
|
const DEFAULT_AUTH_REDIRECT_URLS = [
|
|
9
11
|
"http://localhost:3001/",
|
|
@@ -31,7 +33,7 @@ export function authConfigFromEnv() {
|
|
|
31
33
|
};
|
|
32
34
|
}
|
|
33
35
|
export function defineSiteAuth(config) {
|
|
34
|
-
const authRedirectUrls = config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
|
|
36
|
+
const authRedirectUrls = expandNewsroomRedirectUrls(config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS, config.newsroomBasePath);
|
|
35
37
|
const disableGoogleOAuth = config.disableGoogleOAuth === true;
|
|
36
38
|
return defineAuth({
|
|
37
39
|
loginWith: {
|
package/amplify/auth/resource.ts
CHANGED
|
@@ -3,10 +3,12 @@ import { manageUserRole } from "../functions/manage-user-role/resource";
|
|
|
3
3
|
import { Stack } from "aws-cdk-lib";
|
|
4
4
|
import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
|
|
5
5
|
import type { PapyrusAuthConfig } from "../site-backend-config";
|
|
6
|
+
import { expandNewsroomRedirectUrls } from "./redirect-urls";
|
|
6
7
|
|
|
7
8
|
// Default OAuth redirect URLs for the p.apyr.us app. Other publications pass
|
|
8
9
|
// `auth.redirectUrls` to `defineSiteBackend` (or, for Papyrus's own backend,
|
|
9
10
|
// the PAPYRUS_OAUTH_REDIRECT_URLS branch env var, comma-separated).
|
|
11
|
+
// `defineSiteAuth` adds the newsroom and root variants of every origin listed.
|
|
10
12
|
// See docs/google-oauth-setup.md.
|
|
11
13
|
const DEFAULT_AUTH_REDIRECT_URLS = [
|
|
12
14
|
"http://localhost:3001/",
|
|
@@ -36,7 +38,10 @@ export function authConfigFromEnv(): PapyrusAuthConfig {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export function defineSiteAuth(config: PapyrusAuthConfig) {
|
|
39
|
-
const authRedirectUrls =
|
|
41
|
+
const authRedirectUrls = expandNewsroomRedirectUrls(
|
|
42
|
+
config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS,
|
|
43
|
+
config.newsroomBasePath,
|
|
44
|
+
);
|
|
40
45
|
const disableGoogleOAuth = config.disableGoogleOAuth === true;
|
|
41
46
|
return defineAuth({
|
|
42
47
|
loginWith: {
|
package/amplify/data/resource.js
CHANGED
|
@@ -287,6 +287,8 @@ const schema = a.schema({
|
|
|
287
287
|
actorLabel: a.string(),
|
|
288
288
|
note: a.string(),
|
|
289
289
|
reasonCode: a.string(),
|
|
290
|
+
decisionRelationId: a.id(),
|
|
291
|
+
shareable: a.boolean(),
|
|
290
292
|
})
|
|
291
293
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
292
294
|
.authorization((allow) => [
|
package/amplify/data/resource.ts
CHANGED
|
@@ -312,6 +312,8 @@ const schema = a.schema({
|
|
|
312
312
|
actorLabel: a.string(),
|
|
313
313
|
note: a.string(),
|
|
314
314
|
reasonCode: a.string(),
|
|
315
|
+
decisionRelationId: a.id(),
|
|
316
|
+
shareable: a.boolean(),
|
|
315
317
|
})
|
|
316
318
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
317
319
|
.authorization((allow) => [
|
package/amplify/data/schema.js
CHANGED
|
@@ -245,6 +245,8 @@ export const schema = a.schema({
|
|
|
245
245
|
actorLabel: a.string(),
|
|
246
246
|
note: a.string(),
|
|
247
247
|
reasonCode: a.string(),
|
|
248
|
+
decisionRelationId: a.id(),
|
|
249
|
+
shareable: a.boolean(),
|
|
248
250
|
})
|
|
249
251
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
250
252
|
.authorization((allow) => [
|
package/amplify/data/schema.ts
CHANGED
|
@@ -268,6 +268,8 @@ export const schema = a.schema({
|
|
|
268
268
|
actorLabel: a.string(),
|
|
269
269
|
note: a.string(),
|
|
270
270
|
reasonCode: a.string(),
|
|
271
|
+
decisionRelationId: a.id(),
|
|
272
|
+
shareable: a.boolean(),
|
|
271
273
|
})
|
|
272
274
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
273
275
|
.authorization((allow) => [
|
|
@@ -4,6 +4,7 @@ import { Amplify } from "aws-amplify";
|
|
|
4
4
|
import { generateClient } from "aws-amplify/data";
|
|
5
5
|
import type { Schema } from "../../data/resource";
|
|
6
6
|
import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
|
|
7
|
+
import { referenceCurationMetadata } from "../shared/reference-curation-metadata";
|
|
7
8
|
|
|
8
9
|
type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
|
|
9
10
|
type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
|
|
@@ -225,14 +226,15 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
|
|
|
225
226
|
{ ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
|
|
226
227
|
"metadata",
|
|
227
228
|
"metadata",
|
|
228
|
-
{
|
|
229
|
+
referenceCurationMetadata({
|
|
229
230
|
action,
|
|
230
231
|
curationStatus: nextStatus,
|
|
231
232
|
reasonCode,
|
|
232
|
-
curationReasonCode: reasonCode,
|
|
233
233
|
referenceId,
|
|
234
234
|
referenceLineageId,
|
|
235
|
-
|
|
235
|
+
decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
|
|
236
|
+
shareable: event.arguments.shareable ?? null,
|
|
237
|
+
}),
|
|
236
238
|
{ filename: "metadata.json", now },
|
|
237
239
|
);
|
|
238
240
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata stored with an editor's reference curation Message. When the
|
|
3
|
+
* relevance cyclotron decided the reference, the review records which
|
|
4
|
+
* decision it answered and whether its explanation may be shared (for the
|
|
5
|
+
* Cyclotron marketing recording). Both are optional.
|
|
6
|
+
*/
|
|
7
|
+
export type ReferenceCurationMetadataInput = {
|
|
8
|
+
action: string
|
|
9
|
+
curationStatus: string
|
|
10
|
+
reasonCode: string | null
|
|
11
|
+
referenceId: string
|
|
12
|
+
referenceLineageId: string
|
|
13
|
+
decisionRelationId?: string | null
|
|
14
|
+
shareable?: boolean | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
|
|
18
|
+
const decisionRelationId = input.decisionRelationId?.trim() || null
|
|
19
|
+
return {
|
|
20
|
+
action: input.action,
|
|
21
|
+
curationStatus: input.curationStatus,
|
|
22
|
+
reasonCode: input.reasonCode,
|
|
23
|
+
curationReasonCode: input.reasonCode,
|
|
24
|
+
referenceId: input.referenceId,
|
|
25
|
+
referenceLineageId: input.referenceLineageId,
|
|
26
|
+
...(decisionRelationId ? {decisionRelationId} : {}),
|
|
27
|
+
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -7,6 +7,12 @@ export type PapyrusAuthConfig = {
|
|
|
7
7
|
/** OAuth callback/logout URLs for Google sign-in (ignored when Google is disabled). */
|
|
8
8
|
redirectUrls?: string[];
|
|
9
9
|
disableGoogleOAuth?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Public newsroom path prefix for sign-in return URLs. Default `/newsroom`;
|
|
12
|
+
* `""` on a CMS-only host where the newsroom is the root. Filled from the
|
|
13
|
+
* active brand's `newsroomBasePath` by `defineSiteBackend` when unset.
|
|
14
|
+
*/
|
|
15
|
+
newsroomBasePath?: string;
|
|
10
16
|
/** Desired Cognito hosted-UI domain prefix. Only applied when `applyCognitoDomainPrefix` is true. */
|
|
11
17
|
cognitoDomainPrefix?: string;
|
|
12
18
|
/**
|
package/amplify/site-backend.js
CHANGED
|
@@ -54,7 +54,12 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
54
54
|
const enableInboundEmail = featureFlags.inboundEmail;
|
|
55
55
|
const enableSlackAgent = featureFlags.slack;
|
|
56
56
|
const enableStorageBackups = featureFlags.storageBackups;
|
|
57
|
-
const
|
|
57
|
+
const baseAuthConfig = site.auth ?? authConfigFromEnv();
|
|
58
|
+
const activeBrand = (papyrusSite.brands ?? []).find((brand) => brand.id.toLowerCase() === siteBackendIdentity.brandId);
|
|
59
|
+
const authConfig = {
|
|
60
|
+
...baseAuthConfig,
|
|
61
|
+
newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
|
|
62
|
+
};
|
|
58
63
|
const backend = defineBackend({
|
|
59
64
|
assignmentAction,
|
|
60
65
|
auth: defineSiteAuth(authConfig),
|
package/amplify/site-backend.ts
CHANGED
|
@@ -71,7 +71,12 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
71
71
|
const enableSlackAgent = featureFlags.slack;
|
|
72
72
|
const enableStorageBackups = featureFlags.storageBackups;
|
|
73
73
|
|
|
74
|
-
const
|
|
74
|
+
const baseAuthConfig = site.auth ?? authConfigFromEnv();
|
|
75
|
+
const activeBrand = (papyrusSite.brands ?? []).find((brand) => brand.id.toLowerCase() === siteBackendIdentity.brandId);
|
|
76
|
+
const authConfig = {
|
|
77
|
+
...baseAuthConfig,
|
|
78
|
+
newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
|
|
79
|
+
};
|
|
75
80
|
const backend = defineBackend({
|
|
76
81
|
assignmentAction,
|
|
77
82
|
auth: defineSiteAuth(authConfig),
|
package/app/newsroom/layout.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Component rules only (no reset, no theme): the Cyclotron review control and status widget.
|
|
2
|
+
import "cyclotron/styles/components.css";
|
|
1
3
|
import { NewsroomClientShell } from "../../components/newsroom-client-shell";
|
|
2
4
|
|
|
3
5
|
export default function NewsroomLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
@@ -8,6 +8,8 @@ import { CookieStorage, Hub } from "aws-amplify/utils";
|
|
|
8
8
|
import { useEffect } from "react";
|
|
9
9
|
import { assertSandboxAmplifyOutputsForDev } from "../lib/amplify-outputs-guard";
|
|
10
10
|
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
11
|
+
import { getPublicNewsroomBasePath } from "../lib/newsroom-base-path";
|
|
12
|
+
import { prioritizeRedirectUrlsForOrigin } from "../lib/oauth-redirect-priority";
|
|
11
13
|
|
|
12
14
|
let configured = false;
|
|
13
15
|
|
|
@@ -72,14 +74,10 @@ function prioritizeCurrentOrigin(config: ResourcesConfig): ResourcesConfig {
|
|
|
72
74
|
const auth = (config as { auth?: { oauth?: Record<string, unknown> } }).auth;
|
|
73
75
|
const oauth = auth?.oauth;
|
|
74
76
|
if (!auth || !oauth) return config;
|
|
75
|
-
const origin = normalizeUrlOrigin(window.location.origin);
|
|
76
77
|
const reorder = (urls: unknown) => {
|
|
77
78
|
if (!Array.isArray(urls)) return urls;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
if (!head.length) return normalized;
|
|
81
|
-
const tail = normalized.filter((value) => normalizeUrlOrigin(value) !== origin);
|
|
82
|
-
return [...head, ...tail];
|
|
79
|
+
const strings = urls.filter((value): value is string => typeof value === "string");
|
|
80
|
+
return prioritizeRedirectUrlsForOrigin(strings, window.location.origin, getPublicNewsroomBasePath());
|
|
83
81
|
};
|
|
84
82
|
return {
|
|
85
83
|
...config,
|
|
@@ -111,22 +109,6 @@ function redirectLoopbackToLocalhost(): void {
|
|
|
111
109
|
window.location.replace(target);
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
function normalizeUrlOrigin(value: string): string {
|
|
115
|
-
const trimmed = value.replace(/\/+$/, "");
|
|
116
|
-
try {
|
|
117
|
-
const parsed = new URL(trimmed);
|
|
118
|
-
const hostname = normalizeLoopbackHostname(parsed.hostname);
|
|
119
|
-
return `${parsed.protocol}//${hostname}${parsed.port ? `:${parsed.port}` : ""}`;
|
|
120
|
-
} catch {
|
|
121
|
-
return trimmed;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function normalizeLoopbackHostname(hostname: string): string {
|
|
126
|
-
if (hostname === "127.0.0.1" || hostname === "::1") return "localhost";
|
|
127
|
-
return hostname;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
112
|
function extractAuthFailureDetail(error: unknown): string | null {
|
|
131
113
|
if (!error) return null;
|
|
132
114
|
if (typeof error === "string") return error;
|
|
@@ -3682,3 +3682,54 @@ function normalizeUnknownErrorMessage(error: unknown, fallback: string): string
|
|
|
3682
3682
|
return fallback;
|
|
3683
3683
|
}
|
|
3684
3684
|
}
|
|
3685
|
+
|
|
3686
|
+
const CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION = `
|
|
3687
|
+
mutation CreateKnowledgeRawPayload($input: CreateKnowledgeRawPayloadInput!) {
|
|
3688
|
+
createKnowledgeRawPayload(input: $input) { id }
|
|
3689
|
+
}
|
|
3690
|
+
`;
|
|
3691
|
+
|
|
3692
|
+
const GET_KNOWLEDGE_RAW_PAYLOAD_QUERY = `
|
|
3693
|
+
query GetKnowledgeRawPayload($id: ID!) {
|
|
3694
|
+
getKnowledgeRawPayload(id: $id) { id }
|
|
3695
|
+
}
|
|
3696
|
+
`;
|
|
3697
|
+
|
|
3698
|
+
/** The relevance cyclotron's latest cyclotron-status/v1 snapshot, as the sweep recorded it. */
|
|
3699
|
+
export async function loadCyclotronStatus(cyclotronId: string): Promise<import("cyclotron/cyclotron-status").CyclotronStatus | null> {
|
|
3700
|
+
const { cyclotronStatusPayloadId, latestCyclotronStatus } = await import("../lib/relevance-status");
|
|
3701
|
+
const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", cyclotronStatusPayloadId(cyclotronId), ["raw_payload"]);
|
|
3702
|
+
return latestCyclotronStatus(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/** Ask the next decide-relevance sweep to set or clear a manual review rate. */
|
|
3706
|
+
export async function requestCyclotronReviewRate(
|
|
3707
|
+
cyclotronId: string,
|
|
3708
|
+
request: import("../lib/relevance-status").ReviewRateRequest,
|
|
3709
|
+
): Promise<void> {
|
|
3710
|
+
const { reviewRateRequestPayloadId, safeId } = await import("../lib/relevance-status");
|
|
3711
|
+
const id = reviewRateRequestPayloadId(cyclotronId);
|
|
3712
|
+
const existing = await runGraphql<{ getKnowledgeRawPayload?: { id: string } | null }>(GET_KNOWLEDGE_RAW_PAYLOAD_QUERY, { id });
|
|
3713
|
+
if (!existing.getKnowledgeRawPayload) {
|
|
3714
|
+
await runGraphql(CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION, {
|
|
3715
|
+
input: {
|
|
3716
|
+
id,
|
|
3717
|
+
ownerType: "cyclotron",
|
|
3718
|
+
ownerId: safeId(cyclotronId),
|
|
3719
|
+
payloadKind: "cyclotron-review-rate-request",
|
|
3720
|
+
createdAt: request.requestedAt,
|
|
3721
|
+
updatedAt: request.requestedAt,
|
|
3722
|
+
},
|
|
3723
|
+
});
|
|
3724
|
+
}
|
|
3725
|
+
await uploadModelPayloadForOwner({
|
|
3726
|
+
ownerKind: "knowledgeRawPayload",
|
|
3727
|
+
ownerId: id,
|
|
3728
|
+
ownerLineageId: id,
|
|
3729
|
+
role: "raw_payload",
|
|
3730
|
+
sortKey: `review-rate-request-${request.requestedAt}`,
|
|
3731
|
+
filename: "review-rate-request.json",
|
|
3732
|
+
mediaType: "application/json",
|
|
3733
|
+
content: JSON.stringify(request),
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
@@ -44,21 +44,36 @@ import {
|
|
|
44
44
|
import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
45
45
|
import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
46
46
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
47
|
+
import { ReferenceRelevanceReview } from "./reference-relevance-review";
|
|
48
|
+
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
49
|
+
import type { CyclotronStatus } from "cyclotron/cyclotron-status";
|
|
50
|
+
import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
47
51
|
import {
|
|
48
52
|
loadReferenceAttachmentsForLineageId,
|
|
49
53
|
loadStoragePathUrl,
|
|
50
54
|
} from "./news-desk-taxonomy-client";
|
|
51
55
|
|
|
52
56
|
type NewsroomReferencesViewProps = {
|
|
57
|
+
/** The relevance cyclotron's status snapshot, shown above the list. */
|
|
58
|
+
cyclotronStatus?: CyclotronStatus | null;
|
|
59
|
+
/** Present for editors who may ask for a manual review rate. */
|
|
60
|
+
onRequestReviewRate?: (choice: ReviewRateChoice) => Promise<void> | void;
|
|
53
61
|
demo?: boolean;
|
|
62
|
+
/** The relevance cyclotron's current decisions, keyed by reference lineage. */
|
|
63
|
+
decisions?: Map<string, RelevanceDecisionView>;
|
|
54
64
|
disabled?: boolean;
|
|
65
|
+
onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
|
|
55
66
|
initialReferenceLineageId?: string | null;
|
|
56
67
|
onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
|
|
57
68
|
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
58
69
|
references: ReferenceRecord[];
|
|
59
70
|
};
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
|
|
73
|
+
|
|
74
|
+
const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
|
|
75
|
+
|
|
76
|
+
const STATUS_FILTERS: Array<{ key: ReferenceListFilter; label: string }> = [
|
|
62
77
|
{ key: "all", label: "All" },
|
|
63
78
|
{ key: "pending", label: "Pending" },
|
|
64
79
|
{ key: "accepted", label: "Accepted" },
|
|
@@ -183,16 +198,30 @@ function useReferencePreviewAttachments(
|
|
|
183
198
|
return previewAttachments;
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
function decisionSummary(decision: RelevanceDecisionView): string {
|
|
202
|
+
const sure = decision.confidence == null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
|
|
203
|
+
return `${decision.label} · ${sure}${decision.version != null ? ` · version ${decision.version}` : ""}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
|
|
207
|
+
return Boolean(decision?.reviewRecommended)
|
|
208
|
+
&& normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
function ReferenceDetailPanel({
|
|
212
|
+
decision,
|
|
187
213
|
demo,
|
|
188
214
|
disabled,
|
|
215
|
+
onRelevanceReview,
|
|
189
216
|
onReview,
|
|
190
217
|
previewAttachments,
|
|
191
218
|
reference,
|
|
192
219
|
onClose,
|
|
193
220
|
}: {
|
|
221
|
+
decision?: RelevanceDecisionView;
|
|
194
222
|
demo?: boolean;
|
|
195
223
|
disabled?: boolean;
|
|
224
|
+
onRelevanceReview?: (curation: RelevanceCuration) => void;
|
|
196
225
|
onReview: (action: ReferenceCurationAction) => void;
|
|
197
226
|
previewAttachments: ReferenceAttachmentRecord[];
|
|
198
227
|
reference: ReferenceRecord;
|
|
@@ -224,11 +253,25 @@ function ReferenceDetailPanel({
|
|
|
224
253
|
) : null}
|
|
225
254
|
</div>
|
|
226
255
|
|
|
256
|
+
{decision && status === "pending" && onRelevanceReview ? (
|
|
257
|
+
<ReferenceRelevanceReview
|
|
258
|
+
decision={decision}
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
onCurate={onRelevanceReview}
|
|
261
|
+
referenceLineageId={lineageId}
|
|
262
|
+
/>
|
|
263
|
+
) : decision ? (
|
|
264
|
+
<p className="text-sm text-muted-foreground" data-reference-relevance-decision={lineageId}>
|
|
265
|
+
Cyclotron decision: {decisionSummary(decision)}
|
|
266
|
+
</p>
|
|
267
|
+
) : null}
|
|
268
|
+
|
|
227
269
|
<div
|
|
228
270
|
className="flex flex-wrap items-center gap-2"
|
|
229
271
|
data-news-desk-reference-curation-cluster
|
|
230
272
|
data-reference-curation-status={status}
|
|
231
273
|
>
|
|
274
|
+
{decision && status === "pending" && onRelevanceReview ? null : <>
|
|
232
275
|
<Button
|
|
233
276
|
aria-label="Accept reference"
|
|
234
277
|
aria-pressed={status === "accepted"}
|
|
@@ -255,6 +298,7 @@ function ReferenceDetailPanel({
|
|
|
255
298
|
<ThumbsDownIcon className="size-4" />
|
|
256
299
|
Reject
|
|
257
300
|
</Button>
|
|
301
|
+
</>}
|
|
258
302
|
<Button
|
|
259
303
|
aria-label="Archive reference"
|
|
260
304
|
disabled={archiveDisabled}
|
|
@@ -347,8 +391,12 @@ function ReferenceDetailPanel({
|
|
|
347
391
|
}
|
|
348
392
|
|
|
349
393
|
export function NewsroomReferencesView({
|
|
394
|
+
cyclotronStatus,
|
|
395
|
+
decisions,
|
|
396
|
+
onRequestReviewRate,
|
|
350
397
|
demo = false,
|
|
351
398
|
disabled = false,
|
|
399
|
+
onRelevanceReview,
|
|
352
400
|
initialReferenceLineageId = null,
|
|
353
401
|
onReview,
|
|
354
402
|
referenceAttachments = [],
|
|
@@ -365,13 +413,20 @@ export function NewsroomReferencesView({
|
|
|
365
413
|
const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
|
|
366
414
|
const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
|
|
367
415
|
|
|
368
|
-
const [statusFilter, setStatusFilter] = useState<
|
|
416
|
+
const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
|
|
417
|
+
const needsReviewCount = useMemo(
|
|
418
|
+
() => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
|
|
419
|
+
[canonicalReferences, decisions],
|
|
420
|
+
);
|
|
421
|
+
const filters = decisions?.size ? [NEEDS_REVIEW_FILTER, ...STATUS_FILTERS] : STATUS_FILTERS;
|
|
369
422
|
const [selectedLineageId, setSelectedLineageId] = useState(routeLineageId);
|
|
370
423
|
const [mobileDetailOpen, setMobileDetailOpen] = useState(Boolean(routeLineageId) && isMobileDetail);
|
|
371
424
|
|
|
372
425
|
const filteredReferences = useMemo(
|
|
373
|
-
() =>
|
|
374
|
-
|
|
426
|
+
() => statusFilter === "needs_review"
|
|
427
|
+
? canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference))))
|
|
428
|
+
: filterReferencesByStatus(canonicalReferences, statusFilter),
|
|
429
|
+
[canonicalReferences, decisions, statusFilter],
|
|
375
430
|
);
|
|
376
431
|
|
|
377
432
|
const selectedReference = useMemo(
|
|
@@ -390,7 +445,7 @@ export function NewsroomReferencesView({
|
|
|
390
445
|
? filteredReferences[selectedIndex + 1]
|
|
391
446
|
: null;
|
|
392
447
|
|
|
393
|
-
const syncStatusUrl = useCallback((nextStatus:
|
|
448
|
+
const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
|
|
394
449
|
if (demo) return;
|
|
395
450
|
syncBrowserNewsroomIndexUrl("references", {
|
|
396
451
|
status: nextStatus === "all" ? "" : nextStatus,
|
|
@@ -452,22 +507,29 @@ export function NewsroomReferencesView({
|
|
|
452
507
|
Review source intake before it becomes accepted evidence.
|
|
453
508
|
</p>
|
|
454
509
|
</div>
|
|
510
|
+
{cyclotronStatus ? (
|
|
511
|
+
<div className="shrink-0">
|
|
512
|
+
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|
|
513
|
+
</div>
|
|
514
|
+
) : null}
|
|
455
515
|
|
|
456
516
|
<Tabs
|
|
457
517
|
className="shrink-0"
|
|
458
518
|
defaultValue="all"
|
|
459
519
|
onValueChange={(value) => {
|
|
460
|
-
const next = value as
|
|
520
|
+
const next = value as ReferenceListFilter;
|
|
461
521
|
setStatusFilter(next);
|
|
462
522
|
syncStatusUrl(next);
|
|
463
523
|
}}
|
|
464
524
|
value={statusFilter}
|
|
465
525
|
>
|
|
466
526
|
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 bg-muted/40 p-1">
|
|
467
|
-
{
|
|
527
|
+
{filters.map((filter) => {
|
|
468
528
|
const count = filter.key === "all"
|
|
469
529
|
? canonicalReferences.length
|
|
470
|
-
:
|
|
530
|
+
: filter.key === "needs_review"
|
|
531
|
+
? needsReviewCount
|
|
532
|
+
: statusCounts[filter.key] ?? 0;
|
|
471
533
|
return (
|
|
472
534
|
<TabsTrigger className="text-xs sm:text-sm" key={filter.key} value={filter.key}>
|
|
473
535
|
{filter.label}
|
|
@@ -484,6 +546,7 @@ export function NewsroomReferencesView({
|
|
|
484
546
|
const lineageId = referenceLineageId(reference);
|
|
485
547
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
486
548
|
const active = selectedLineageId === lineageId;
|
|
549
|
+
const decision = decisions?.get(lineageId);
|
|
487
550
|
return (
|
|
488
551
|
<button
|
|
489
552
|
aria-current={active ? "true" : undefined}
|
|
@@ -504,6 +567,14 @@ export function NewsroomReferencesView({
|
|
|
504
567
|
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
505
568
|
</div>
|
|
506
569
|
<p className="mt-2 text-xs text-muted-foreground">{formatReferenceListDate(reference)}</p>
|
|
570
|
+
{decision ? (
|
|
571
|
+
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" data-reference-relevance-row={lineageId}>
|
|
572
|
+
<span>Decision: {decisionSummary(decision)}</span>
|
|
573
|
+
{needsReview(reference, decision) ? (
|
|
574
|
+
<Badge variant="outline"><span aria-hidden="true">●</span> Needs review</Badge>
|
|
575
|
+
) : null}
|
|
576
|
+
</p>
|
|
577
|
+
) : null}
|
|
507
578
|
</button>
|
|
508
579
|
);
|
|
509
580
|
}) : (
|
|
@@ -552,8 +623,10 @@ export function NewsroomReferencesView({
|
|
|
552
623
|
<ScrollArea className="min-h-0 flex-1">
|
|
553
624
|
{selectedReference ? (
|
|
554
625
|
<ReferenceDetailPanel
|
|
626
|
+
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
555
627
|
demo={demo}
|
|
556
628
|
disabled={disabled}
|
|
629
|
+
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
557
630
|
onReview={runReview}
|
|
558
631
|
previewAttachments={previewAttachments}
|
|
559
632
|
reference={selectedReference}
|
|
@@ -581,9 +654,11 @@ export function NewsroomReferencesView({
|
|
|
581
654
|
{selectedReference ? (
|
|
582
655
|
<ScrollArea className="max-h-[75dvh]">
|
|
583
656
|
<ReferenceDetailPanel
|
|
657
|
+
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
584
658
|
demo={demo}
|
|
585
659
|
disabled={disabled}
|
|
586
660
|
onClose={closeDetail}
|
|
661
|
+
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
587
662
|
onReview={runReview}
|
|
588
663
|
previewAttachments={previewAttachments}
|
|
589
664
|
reference={selectedReference}
|