@anthusai/papyrus 5.41.1 → 5.42.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.
@@ -1015,6 +1015,26 @@ class DynamoReaderStore:
1015
1015
  raise
1016
1016
  return True
1017
1017
 
1018
+ def list_story_drafts(self, limit: int) -> list[dict[str, Any]]:
1019
+ """The story drafts of the table, only the fields ``listStoryDrafts`` may answer; at most ``limit`` (the newest if there are more)."""
1020
+ fields = proposal_rules.STORY_DRAFT_FIELDS
1021
+ names = {"#kind": "kind", **{f"#f{index}": field for index, field in enumerate(fields)}}
1022
+ request: dict[str, Any] = {
1023
+ "FilterExpression": "#kind = :kind",
1024
+ "ProjectionExpression": ", ".join(f"#f{index}" for index in range(len(fields))) + ", #kind",
1025
+ "ExpressionAttributeNames": names,
1026
+ "ExpressionAttributeValues": {":kind": "story_draft"},
1027
+ }
1028
+ rows: list[dict[str, Any]] = []
1029
+ while True:
1030
+ page = self._table("proposal").scan(**request)
1031
+ rows.extend(plain(item) for item in page.get("Items") or [])
1032
+ if not page.get("LastEvaluatedKey"):
1033
+ break
1034
+ request["ExclusiveStartKey"] = page["LastEvaluatedKey"]
1035
+ rows.sort(key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
1036
+ return [{field: row.get(field) for field in fields} for row in rows[:limit]]
1037
+
1018
1038
  def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
1019
1039
  """Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
1020
1040
  names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
@@ -1510,6 +1530,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
1510
1530
  """``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
1511
1531
  if proposal_rules.event_field_name(event) in proposal_rules.MUTATIONS:
1512
1532
  return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
1533
+ if proposal_rules.event_field_name(event) in proposal_rules.QUERIES:
1534
+ return proposal_rules.handle_proposal_query(event, store, account_id=account_id)
1513
1535
  identity = event.get("identity") or {}
1514
1536
  claims = identity.get("claims") or {}
1515
1537
  groups = claims.get("cognito:groups") or identity.get("groups") or []
@@ -57,6 +57,11 @@ TARGET_COUNTS: dict[str, tuple[int, int]] = {
57
57
  TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
58
58
 
59
59
  MUTATIONS = ("fileProposal", "transitionProposal", "editProposal")
60
+ #: The one read an operator has: ``listStoryDrafts`` answers the story drafts and nothing else of them (never the body, the evidence, the
61
+ #: history, the proposer, a vote, the editor log or a reason), so an unattended run can avoid filing a near-duplicate of an earlier draft.
62
+ QUERIES = ("listStoryDrafts",)
63
+ STORY_DRAFT_FIELDS = ("id", "title", "summary", "targets", "state", "createdAt")
64
+ MAXIMUM_STORY_DRAFTS = 500
60
65
 
61
66
  #: What ``editProposal`` does beyond moving the state. Editors and admins only: an operator is refused every one of them.
62
67
  PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION = "pin", "unpin", "hold", "release", "exclude_votes", "undo_exclusion"
@@ -128,6 +133,8 @@ class ProposalStore(Protocol):
128
133
 
129
134
  def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
130
135
 
136
+ def list_story_drafts(self, limit: int) -> list[dict[str, Any]]: ...
137
+
131
138
 
132
139
  def staff_subject(identity: dict[str, Any]) -> str | None:
133
140
  """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
@@ -456,3 +463,25 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
456
463
  by=BY_OPERATOR if operator else BY_STAFF,
457
464
  allowed=OPERATOR_TRANSITIONS if operator else None,
458
465
  ).to_dict()
466
+
467
+
468
+ def story_draft_summary(row: dict[str, Any]) -> dict[str, Any]:
469
+ """The six fields an operator may read of a story draft. ``targets`` stays the JSON text it is stored as."""
470
+ summary = {field: row.get(field) for field in STORY_DRAFT_FIELDS}
471
+ if summary["targets"] is not None and not isinstance(summary["targets"], str):
472
+ summary["targets"] = json.dumps(summary["targets"])
473
+ return summary
474
+
475
+
476
+ def handle_proposal_query(event: dict[str, Any], store: ProposalStore, *, account_id: str | None = None) -> dict[str, Any]:
477
+ """``listStoryDrafts`` from AppSync: the story drafts, newest first, with only ``STORY_DRAFT_FIELDS``. Read only.
478
+
479
+ The same caller check as :func:`handle_proposal_event`: an editor or admin, or an IAM principal of ``account_id`` (the function's own
480
+ account) acting as an operator. An identity-pool user and a principal of any other account are refused. Answers a receipt-shaped
481
+ object, never an exception.
482
+ """
483
+ identity = event.get("identity") or {}
484
+ if staff_subject(identity) is None and operator_actor(identity, account_id) is None:
485
+ return {**refused("not_staff", "Only editors, admins and operators of this account can list story drafts.").to_dict(), "drafts": []}
486
+ rows = sorted(store.list_story_drafts(MAXIMUM_STORY_DRAFTS), key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
487
+ return {"status": "ok", "id": None, "error": None, "message": None, "drafts": [story_draft_summary(row) for row in rows[:MAXIMUM_STORY_DRAFTS]]}
@@ -16,6 +16,7 @@ export const readerSteeringManifest = {
16
16
  contract: 1,
17
17
  models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
18
18
  mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
19
+ queries: ["listStoryDrafts"],
19
20
  guest: { mutations: [] },
20
21
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
21
22
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -23,6 +23,7 @@ export const readerSteeringManifest: PluginManifest = {
23
23
  contract: 1,
24
24
  models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
25
25
  mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
26
+ queries: ["listStoryDrafts"],
26
27
  guest: { mutations: [] },
27
28
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
28
29
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -165,6 +165,22 @@ export function readerSteeringSchemaDefinitions() {
165
165
  error: a.string(),
166
166
  message: a.string(),
167
167
  }),
168
+ // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
169
+ StoryDraftSummary: a.customType({
170
+ id: a.string().required(),
171
+ title: a.string().required(),
172
+ summary: a.string(),
173
+ targets: a.json(),
174
+ state: a.string().required(),
175
+ createdAt: a.string().required(),
176
+ }),
177
+ StoryDraftList: a.customType({
178
+ status: a.string().required(),
179
+ id: a.string(),
180
+ error: a.string(),
181
+ message: a.string(),
182
+ drafts: a.ref("StoryDraftSummary").array(),
183
+ }),
168
184
  // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
169
185
  // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
170
186
  // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
@@ -194,6 +210,13 @@ export function readerSteeringSchemaDefinitions() {
194
210
  .returns(a.ref("ProposalReceipt"))
195
211
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
196
212
  .handler(functionHandler("readerActions", readerActions)),
213
+ // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
214
+ // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
215
+ listStoryDrafts: a
216
+ .query()
217
+ .returns(a.ref("StoryDraftList"))
218
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
219
+ .handler(functionHandler("readerActions", readerActions)),
197
220
  // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
198
221
  // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
199
222
  editProposal: a
@@ -167,6 +167,22 @@ export function readerSteeringSchemaDefinitions() {
167
167
  error: a.string(),
168
168
  message: a.string(),
169
169
  }),
170
+ // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
171
+ StoryDraftSummary: a.customType({
172
+ id: a.string().required(),
173
+ title: a.string().required(),
174
+ summary: a.string(),
175
+ targets: a.json(),
176
+ state: a.string().required(),
177
+ createdAt: a.string().required(),
178
+ }),
179
+ StoryDraftList: a.customType({
180
+ status: a.string().required(),
181
+ id: a.string(),
182
+ error: a.string(),
183
+ message: a.string(),
184
+ drafts: a.ref("StoryDraftSummary").array(),
185
+ }),
170
186
  // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
171
187
  // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
172
188
  // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
@@ -196,6 +212,13 @@ export function readerSteeringSchemaDefinitions() {
196
212
  .returns(a.ref("ProposalReceipt"))
197
213
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
198
214
  .handler(functionHandler("readerActions", readerActions)),
215
+ // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
216
+ // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended trend run can dedupe; nothing else.
217
+ listStoryDrafts: a
218
+ .query()
219
+ .returns(a.ref("StoryDraftList"))
220
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
221
+ .handler(functionHandler("readerActions", readerActions)),
199
222
  // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
200
223
  // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
201
224
  editProposal: a
@@ -336,7 +336,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
336
336
  <div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
337
337
  {data.items.length ? (
338
338
  <ul className="news-desk-monitoring__queue">
339
- {data.items.map((item) => <QueueItem key={`${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
339
+ {data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
340
340
  </ul>
341
341
  ) : <p>No items in the queue.</p>}
342
342
  {data.hiddenNotRelevant > 0 ? (
@@ -345,6 +345,14 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
345
345
  </p>
346
346
  ) : null}
347
347
  </article>
348
+
349
+ {data.otherMonitors?.length ? (
350
+ <details className="news-desk-delegation-note" data-monitoring-other-monitors>
351
+ <summary>Not routed to this site ({data.otherMonitors.length})</summary>
352
+ <p>These monitors collect items, but none of them is routed to this site, so their items are not shown here.</p>
353
+ <ul>{data.otherMonitors.map((id) => <li key={id} data-monitoring-other-monitor={id}>{id}</li>)}</ul>
354
+ </details>
355
+ ) : null}
348
356
  </>
349
357
  );
350
358
  }
@@ -20,6 +20,7 @@ export type MonitoringRelevance = {
20
20
  };
21
21
 
22
22
  export type MonitoringItem = {
23
+ monitorId?: string;
23
24
  sourceId: string;
24
25
  revision: number;
25
26
  title: string;
@@ -128,6 +129,8 @@ export type MonitoringOverview = {
128
129
  historyComplete: boolean;
129
130
  } | null;
130
131
  items: MonitoringItem[];
132
+ /** Monitors that do not route to this site: named only, with no items. */
133
+ otherMonitors?: string[];
131
134
  emailSubmissions?: MonitoringEmailSummary[];
132
135
  hiddenNotRelevant: number;
133
136
  };
package/package.json CHANGED
@@ -456,7 +456,7 @@
456
456
  "optional": true
457
457
  }
458
458
  },
459
- "version": "5.41.1",
459
+ "version": "5.42.0",
460
460
  "dependencies": {
461
461
  "@aws-amplify/adapter-nextjs": "^1.7.3",
462
462
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.41.1",
2
+ "version": "5.42.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",