@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.
@@ -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["info"]["fieldName"], event.get("arguments") or {}, Services(store=GraphQLEditorialStore()))
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
- const from = compiled.indexOf(NEGOTIATE_START);
54
- const to = compiled.indexOf(NEGOTIATE_END);
55
- if (from === -1 || to === -1 || to < from) {
56
- throw new Error("redirect function source has no markdown negotiation markers");
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
- export type EdgeConfig = { rules: EdgeRedirectRule[]; negotiateMarkdown: boolean };
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
- const from = compiled.indexOf(NEGOTIATE_START);
72
- const to = compiled.indexOf(NEGOTIATE_END);
73
- if (from === -1 || to === -1 || to < from) {
74
- throw new Error("redirect function source has no markdown negotiation markers");
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"), readEdgeConfig(props.staticDir).rules, {
76
- negotiateMarkdown: readEdgeConfig(props.staticDir).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"), readEdgeConfig(props.staticDir).rules, {
119
- negotiateMarkdown: readEdgeConfig(props.staticDir).negotiateMarkdown,
119
+ compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), edgeConfig.rules, {
120
+ negotiateMarkdown: edgeConfig.negotiateMarkdown,
121
+ directoryTwins: edgeConfig.directoryTwins,
120
122
  }),
121
123
  ),
122
124
  keyValueStore,
@@ -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?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Test a rule change</button>:null}
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&&snapshot.status.features?.steering&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
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
@@ -407,7 +407,7 @@
407
407
  "optional": true
408
408
  }
409
409
  },
410
- "version": "5.18.0",
410
+ "version": "5.19.1",
411
411
  "dependencies": {
412
412
  "@aws-amplify/adapter-nextjs": "^1.7.3",
413
413
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.18.0",
2
+ "version": "5.19.1",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",