@anthusai/papyrus 5.18.0 → 5.19.1
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/functions/editorial-checks/handler.py +2 -2
- package/amplify/publication/redirect-and-index.js +22 -0
- package/amplify/publication/redirect-rules.js +21 -9
- package/amplify/publication/redirect-rules.ts +25 -11
- package/amplify/publication/s3-projection.js +4 -2
- package/amplify/publication/s3-projection.ts +4 -2
- package/bin/papyrus-infra.mjs +1 -1
- package/components/publication-cyclotron-panel.tsx +6 -1
- package/components/reference-curation-controls.tsx +1 -1
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -4,11 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from papyrus_content.editorial_checks import GraphQLEditorialStore, Services, handle, load_provider_keys
|
|
7
|
+
from papyrus_content.editorial_checks import GraphQLEditorialStore, Services, event_field_name, handle, load_provider_keys
|
|
8
8
|
|
|
9
9
|
# Amplify passes secrets to Python functions as placeholders; resolve them once per cold start.
|
|
10
10
|
load_provider_keys()
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
|
|
14
|
-
return handle(event
|
|
14
|
+
return handle(event_field_name(event), event.get("arguments") or {}, Services(store=GraphQLEditorialStore()))
|
|
@@ -91,6 +91,22 @@ function markdownTwin(uri) {
|
|
|
91
91
|
return uri.substring(0, uri.length - 5) + ".md";
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Directory-style sites keep a page's twin at /x/index.md, so a bare /x.md is rewritten there. The edge cannot test for
|
|
95
|
+
// existence, hence the rule: a last segment <name>.md with a name other than index. A path prefix (a base path) is just
|
|
96
|
+
// part of <path>. The flag is spliced in by redirect-rules.ts at deploy time from urls.style.
|
|
97
|
+
const directoryTwins = /*PAPYRUS_DIRECTORY_TWINS*/false/*END_PAPYRUS_DIRECTORY_TWINS*/;
|
|
98
|
+
|
|
99
|
+
function directoryTwin(uri) {
|
|
100
|
+
if (uri.length < 5 || uri.substring(uri.length - 3) !== ".md") {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
|
|
104
|
+
if (lastSegment === "index.md" || lastSegment === ".md") {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return uri.substring(0, uri.length - 3) + "/index.md";
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
async function handler(event) {
|
|
95
111
|
const request = event.request;
|
|
96
112
|
const redirectTarget = await lookUpRedirectTarget(request.uri);
|
|
@@ -110,6 +126,12 @@ async function handler(event) {
|
|
|
110
126
|
};
|
|
111
127
|
}
|
|
112
128
|
request.uri = rewriteToIndexDocument(request.uri);
|
|
129
|
+
if (directoryTwins) {
|
|
130
|
+
const bareTwin = directoryTwin(request.uri);
|
|
131
|
+
if (bareTwin !== null) {
|
|
132
|
+
request.uri = bareTwin;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
113
135
|
if (negotiateMarkdown && prefersMarkdown(request)) {
|
|
114
136
|
const twin = markdownTwin(request.uri);
|
|
115
137
|
if (twin !== null) {
|
|
@@ -27,20 +27,34 @@ export function edgeRulesFromSettings(settings) {
|
|
|
27
27
|
export function negotiatesMarkdown(settings) {
|
|
28
28
|
return settings.site !== undefined && settings.agents?.markdown !== false;
|
|
29
29
|
}
|
|
30
|
+
/** A directory-style site keeps a page's Markdown twin at `/x/index.md`; the edge then serves a bare `/x.md` from there. */
|
|
31
|
+
export function usesDirectoryTwins(settings) {
|
|
32
|
+
return negotiatesMarkdown(settings) && settings.urls?.style === "directory";
|
|
33
|
+
}
|
|
30
34
|
export function readEdgeConfig(staticDir, baseDirectory = process.cwd()) {
|
|
31
35
|
if (staticDir === undefined)
|
|
32
|
-
return { rules: [], negotiateMarkdown: false };
|
|
36
|
+
return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
33
37
|
const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
|
|
34
38
|
if (!existsSync(settingsPath))
|
|
35
|
-
return { rules: [], negotiateMarkdown: false };
|
|
39
|
+
return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
36
40
|
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
37
|
-
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings) };
|
|
41
|
+
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings), directoryTwins: usesDirectoryTwins(settings) };
|
|
38
42
|
}
|
|
39
43
|
export function readEdgeRules(staticDir, baseDirectory = process.cwd()) {
|
|
40
44
|
return readEdgeConfig(staticDir, baseDirectory).rules;
|
|
41
45
|
}
|
|
42
46
|
const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
43
47
|
const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
48
|
+
const TWINS_START = "/*PAPYRUS_DIRECTORY_TWINS*/";
|
|
49
|
+
const TWINS_END = "/*END_PAPYRUS_DIRECTORY_TWINS*/";
|
|
50
|
+
function spliceFlag(source, start, end, label) {
|
|
51
|
+
const from = source.indexOf(start);
|
|
52
|
+
const to = source.indexOf(end);
|
|
53
|
+
if (from === -1 || to === -1 || to < from) {
|
|
54
|
+
throw new Error(`redirect function source has no ${label} markers`);
|
|
55
|
+
}
|
|
56
|
+
return source.slice(0, from + start.length) + "true" + source.slice(to);
|
|
57
|
+
}
|
|
44
58
|
/** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
|
|
45
59
|
export function compileRedirectFunctionSource(source, rules, options = {}) {
|
|
46
60
|
const start = source.indexOf(START_MARKER);
|
|
@@ -50,12 +64,10 @@ export function compileRedirectFunctionSource(source, rules, options = {}) {
|
|
|
50
64
|
}
|
|
51
65
|
let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
|
|
52
66
|
if (options.negotiateMarkdown === true) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
compiled = compiled.slice(0, from + NEGOTIATE_START.length) + "true" + compiled.slice(to);
|
|
67
|
+
compiled = spliceFlag(compiled, NEGOTIATE_START, NEGOTIATE_END, "markdown negotiation");
|
|
68
|
+
}
|
|
69
|
+
if (options.directoryTwins === true) {
|
|
70
|
+
compiled = spliceFlag(compiled, TWINS_START, TWINS_END, "directory twin");
|
|
59
71
|
}
|
|
60
72
|
return compiled;
|
|
61
73
|
}
|
|
@@ -38,14 +38,19 @@ export function negotiatesMarkdown(settings: ProjectionSettings): boolean {
|
|
|
38
38
|
return settings.site !== undefined && settings.agents?.markdown !== false;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/** A directory-style site keeps a page's Markdown twin at `/x/index.md`; the edge then serves a bare `/x.md` from there. */
|
|
42
|
+
export function usesDirectoryTwins(settings: ProjectionSettings): boolean {
|
|
43
|
+
return negotiatesMarkdown(settings) && settings.urls?.style === "directory";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type EdgeConfig = { rules: EdgeRedirectRule[]; negotiateMarkdown: boolean; directoryTwins: boolean };
|
|
42
47
|
|
|
43
48
|
export function readEdgeConfig(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeConfig {
|
|
44
|
-
if (staticDir === undefined) return { rules: [], negotiateMarkdown: false };
|
|
49
|
+
if (staticDir === undefined) return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
45
50
|
const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
|
|
46
|
-
if (!existsSync(settingsPath)) return { rules: [], negotiateMarkdown: false };
|
|
51
|
+
if (!existsSync(settingsPath)) return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
47
52
|
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ProjectionSettings;
|
|
48
|
-
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings) };
|
|
53
|
+
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings), directoryTwins: usesDirectoryTwins(settings) };
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export function readEdgeRules(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeRedirectRule[] {
|
|
@@ -54,12 +59,23 @@ export function readEdgeRules(staticDir: string | undefined, baseDirectory: stri
|
|
|
54
59
|
|
|
55
60
|
const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
56
61
|
const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
62
|
+
const TWINS_START = "/*PAPYRUS_DIRECTORY_TWINS*/";
|
|
63
|
+
const TWINS_END = "/*END_PAPYRUS_DIRECTORY_TWINS*/";
|
|
64
|
+
|
|
65
|
+
function spliceFlag(source: string, start: string, end: string, label: string): string {
|
|
66
|
+
const from = source.indexOf(start);
|
|
67
|
+
const to = source.indexOf(end);
|
|
68
|
+
if (from === -1 || to === -1 || to < from) {
|
|
69
|
+
throw new Error(`redirect function source has no ${label} markers`);
|
|
70
|
+
}
|
|
71
|
+
return source.slice(0, from + start.length) + "true" + source.slice(to);
|
|
72
|
+
}
|
|
57
73
|
|
|
58
74
|
/** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
|
|
59
75
|
export function compileRedirectFunctionSource(
|
|
60
76
|
source: string,
|
|
61
77
|
rules: EdgeRedirectRule[],
|
|
62
|
-
options: { negotiateMarkdown?: boolean } = {},
|
|
78
|
+
options: { negotiateMarkdown?: boolean; directoryTwins?: boolean } = {},
|
|
63
79
|
): string {
|
|
64
80
|
const start = source.indexOf(START_MARKER);
|
|
65
81
|
const end = source.indexOf(END_MARKER);
|
|
@@ -68,12 +84,10 @@ export function compileRedirectFunctionSource(
|
|
|
68
84
|
}
|
|
69
85
|
let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
|
|
70
86
|
if (options.negotiateMarkdown === true) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
compiled = compiled.slice(0, from + NEGOTIATE_START.length) + "true" + compiled.slice(to);
|
|
87
|
+
compiled = spliceFlag(compiled, NEGOTIATE_START, NEGOTIATE_END, "markdown negotiation");
|
|
88
|
+
}
|
|
89
|
+
if (options.directoryTwins === true) {
|
|
90
|
+
compiled = spliceFlag(compiled, TWINS_START, TWINS_END, "directory twin");
|
|
77
91
|
}
|
|
78
92
|
return compiled;
|
|
79
93
|
}
|
|
@@ -70,10 +70,12 @@ export function addS3ProjectionPublication(scope, props, media) {
|
|
|
70
70
|
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
71
71
|
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
72
72
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
73
|
+
const edgeConfig = readEdgeConfig(props.staticDir);
|
|
73
74
|
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
74
75
|
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
75
|
-
code: cloudfront.FunctionCode.fromInline(compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"),
|
|
76
|
-
negotiateMarkdown:
|
|
76
|
+
code: cloudfront.FunctionCode.fromInline(compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), edgeConfig.rules, {
|
|
77
|
+
negotiateMarkdown: edgeConfig.negotiateMarkdown,
|
|
78
|
+
directoryTwins: edgeConfig.directoryTwins,
|
|
77
79
|
})),
|
|
78
80
|
keyValueStore,
|
|
79
81
|
});
|
|
@@ -112,11 +112,13 @@ export function addS3ProjectionPublication(
|
|
|
112
112
|
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
113
113
|
|
|
114
114
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
115
|
+
const edgeConfig = readEdgeConfig(props.staticDir);
|
|
115
116
|
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
116
117
|
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
117
118
|
code: cloudfront.FunctionCode.fromInline(
|
|
118
|
-
compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"),
|
|
119
|
-
negotiateMarkdown:
|
|
119
|
+
compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), edgeConfig.rules, {
|
|
120
|
+
negotiateMarkdown: edgeConfig.negotiateMarkdown,
|
|
121
|
+
directoryTwins: edgeConfig.directoryTwins,
|
|
120
122
|
}),
|
|
121
123
|
),
|
|
122
124
|
keyValueStore,
|
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -53,7 +53,7 @@ if (command === "compile-edge-function") {
|
|
|
53
53
|
const { compileRedirectFunctionSource, readEdgeConfig } = await import("../amplify/publication/redirect-rules.js");
|
|
54
54
|
const functionSource = fs.readFileSync(new URL("../amplify/publication/redirect-and-index.js", import.meta.url), "utf8");
|
|
55
55
|
const config = readEdgeConfig(staticDir);
|
|
56
|
-
const compiled = compileRedirectFunctionSource(functionSource, config.rules, { negotiateMarkdown: config.negotiateMarkdown });
|
|
56
|
+
const compiled = compileRedirectFunctionSource(functionSource, config.rules, { negotiateMarkdown: config.negotiateMarkdown, directoryTwins: config.directoryTwins });
|
|
57
57
|
const out = opt("out");
|
|
58
58
|
if (out) fs.writeFileSync(path.resolve(out), compiled);
|
|
59
59
|
else process.stdout.write(compiled);
|
|
@@ -5,6 +5,9 @@ import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/revie
|
|
|
5
5
|
import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
|
|
6
6
|
import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
|
|
7
7
|
|
|
8
|
+
/** Steering is offered only when the Cyclotron contract says so; absent means unknown, so off. */
|
|
9
|
+
export function steeringAvailable(status:PublicationStatus):boolean{return status.features?.steering===true;}
|
|
10
|
+
|
|
8
11
|
function requestId(){
|
|
9
12
|
const bytes=crypto.getRandomValues(new Uint8Array(16));
|
|
10
13
|
return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
|
|
@@ -38,7 +41,9 @@ function PublicationControls({mode,status,history,onReview,onSteer}:PublicationP
|
|
|
38
41
|
<header><h2 className="text-lg font-semibold">Suggestions · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Suggestions and reviews are kept by this site.':'Suggestions and reviews are kept by the shared review service.'}</p></header>
|
|
39
42
|
{status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
|
|
40
43
|
<CyclotronStatusView status={status.classifiers[output.id]} classifierName={output.question} variant="card"/>
|
|
41
|
-
{onSteer
|
|
44
|
+
{onSteer?(steeringAvailable(status)
|
|
45
|
+
?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Test a rule change</button>
|
|
46
|
+
:<div><button type="button" disabled aria-describedby={`steer-unavailable:${output.id}`}>Test a rule change · Not available yet</button><p id={`steer-unavailable:${output.id}`} className="text-sm text-muted-foreground">Rule changes cannot be tested on this service yet.</p></div>):null}
|
|
42
47
|
{history.history.map(row=>{
|
|
43
48
|
const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
|
|
44
49
|
const key=`${context}:${row.decision.decisionId}:${output.id}:${row.context?.contextId??'legacy'}`;const saved=row.reviews[output.id];const historical=Boolean(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired);
|
|
@@ -73,7 +73,7 @@ function Controls({session,disabled,refreshSignal}:{session:CurationSession;disa
|
|
|
73
73
|
{error?<p role="alert">{error}</p>:null}
|
|
74
74
|
{snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
|
|
75
75
|
onReview={canReview?async payload=>{await session.publication.review(payload);await refresh();}:undefined}
|
|
76
|
-
onSteer={canSteer&&
|
|
76
|
+
onSteer={canSteer&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
|
|
77
77
|
{steering?<form aria-label="Rule change" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
|
|
78
78
|
<h4 className="font-semibold">Test a rule change</h4>
|
|
79
79
|
<p className="text-sm">A change is tested before it is adopted. Opening this form does not change any existing suggestion.</p>
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED