@anthusai/papyrus 5.30.0 → 5.32.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.
Files changed (31) hide show
  1. package/amplify/data/resource.js +19 -0
  2. package/amplify/data/resource.ts +20 -0
  3. package/amplify/data/schema.js +21 -0
  4. package/amplify/data/schema.ts +22 -0
  5. package/amplify/functions/automation-view/handler.py +11 -0
  6. package/amplify/functions/automation-view/resource.js +24 -0
  7. package/amplify/functions/automation-view/resource.ts +34 -0
  8. package/amplify/functions/automation-view/schema.js +16 -0
  9. package/amplify/functions/automation-view/schema.ts +18 -0
  10. package/amplify/functions/automation-view/wire.js +32 -0
  11. package/amplify/functions/automation-view/wire.ts +46 -0
  12. package/amplify/plugins/reader-steering/config.js +8 -1
  13. package/amplify/plugins/reader-steering/config.ts +13 -1
  14. package/amplify/plugins/reader-steering/function/handler.py +209 -18
  15. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  16. package/amplify/plugins/reader-steering/papyrus.plugin.js +10 -2
  17. package/amplify/plugins/reader-steering/papyrus.plugin.ts +10 -2
  18. package/amplify/plugins/reader-steering/schema.js +65 -1
  19. package/amplify/plugins/reader-steering/schema.ts +65 -1
  20. package/amplify/site-backend.js +4 -0
  21. package/amplify/site-backend.ts +4 -0
  22. package/app/api/reader/flag/route.ts +9 -0
  23. package/components/automation-panel.tsx +39 -0
  24. package/components/automation-view.tsx +149 -0
  25. package/components/topic-steering-workspace.tsx +13 -2
  26. package/lib/automation-client.ts +29 -0
  27. package/lib/automation-fixture.ts +65 -0
  28. package/lib/automation-overview.ts +138 -0
  29. package/lib/reader-api.ts +1 -1
  30. package/package.json +7 -1
  31. package/routes.manifest.json +12 -1
@@ -30,6 +30,7 @@ if _FUNCTION_DIRECTORY not in sys.path: # a Lambda already has its code directo
30
30
  sys.path.insert(0, _FUNCTION_DIRECTORY)
31
31
  import ai_moderation # noqa: E402
32
32
  import moderation as moderation_rules # noqa: E402
33
+ import proposals as proposal_rules # noqa: E402
33
34
 
34
35
  SITE_SUBJECT = re.compile(r"ss1_[A-Za-z0-9_-]{43}")
35
36
  INTERIM_SUBJECT = re.compile(r"ssdev_[A-Za-z0-9_-]{43}")
@@ -54,6 +55,21 @@ ACTION_RATING = "rating"
54
55
  ACTION_SUBMIT = "submit"
55
56
  ACTION_COMMENT = "comment"
56
57
  ACTION_REPORT = "report"
58
+ ACTION_FLAG = "flag"
59
+ SUBJECT_TYPE_ITEM = "item"
60
+ SUBJECT_TYPE_SUBMISSION = "submission"
61
+ SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION, "story", "proposal", "concept")
62
+ DEFAULT_SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION)
63
+ SUBJECT_ID = re.compile(r"[A-Za-z0-9._:-]{1,190}")
64
+ VOTE_DIMENSION = "up"
65
+ VOTE_PAYLOAD_KEYS = frozenset({"subject", "value"})
66
+ FLAG_WRONG = "wrong"
67
+ FLAG_BELONGS_UNDER = "belongs_under"
68
+ FLAG_BETTER_NAME = "better_name"
69
+ FLAG_KINDS = (FLAG_WRONG, FLAG_BELONGS_UNDER, FLAG_BETTER_NAME)
70
+ FLAG_PAYLOAD_KEYS = frozenset({"subject", "kind", "reason", "target", "name"})
71
+ MAXIMUM_FLAG_REASON = 500
72
+ MAXIMUM_SUGGESTED_NAME = 120
57
73
  RATE_BUCKET_REPORT = "report"
58
74
  REPORT_ID_PREFIX = "rep-"
59
75
  MAXIMUM_REPORT_REASON = 500
@@ -127,6 +143,7 @@ class ReaderSettings:
127
143
  promote_section: str = "unsectioned"
128
144
  ai_model: str = ai_moderation.DEFAULT_MODEL
129
145
  ai_key_parameter: str = ""
146
+ subject_types: tuple[str, ...] = DEFAULT_SUBJECT_TYPES
130
147
 
131
148
 
132
149
  def settings_from_environment(environment: dict[str, str] | None = None) -> ReaderSettings:
@@ -154,6 +171,7 @@ def settings_from_environment(environment: dict[str, str] | None = None) -> Read
154
171
  reports_per_day=int((raw.get("moderation") or {}).get("reportsPerDay", defaults.reports_per_day)),
155
172
  ai_model=str(((raw.get("moderation") or {}).get("ai") or {}).get("model", defaults.ai_model)),
156
173
  ai_key_parameter=str(((raw.get("moderation") or {}).get("ai") or {}).get("apiKeyParameter", defaults.ai_key_parameter)),
174
+ subject_types=tuple((raw.get("subjects") or {}).get("types") or defaults.subject_types),
157
175
  promote_item_type=str((raw.get("submissions") or {}).get("itemType", defaults.promote_item_type)),
158
176
  promote_section=str((raw.get("submissions") or {}).get("section", defaults.promote_section)),
159
177
  )
@@ -172,6 +190,8 @@ class ReaderStore(Protocol):
172
190
 
173
191
  def delete_vote(self, vote_id: str) -> None: ...
174
192
 
193
+ def put_flag(self, flag: dict[str, Any], now_iso: str) -> None: ...
194
+
175
195
  def is_published(self, subject_key: str) -> bool: ...
176
196
 
177
197
  def get_submission(self, submission_id: str) -> dict[str, Any] | None: ...
@@ -229,6 +249,30 @@ def vote_id(subject_key: str, reader_id: str, dimension: str) -> str:
229
249
  return f"{subject_key}#{reader_id}#{dimension}"
230
250
 
231
251
 
252
+ def subject_key(subject_type: str, subject_id: str) -> str:
253
+ """The key a vote is stored under: items and submissions keep their bare id (so every vote written before subject types stays valid); other types are prefixed."""
254
+ return subject_id if subject_type in (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION) else f"{subject_type}:{subject_id}"
255
+
256
+
257
+ def legacy_subject_type(key: str) -> str:
258
+ """The type of a bare subject key: a submission id, otherwise an item."""
259
+ return SUBJECT_TYPE_SUBMISSION if SUBMISSION_ID.fullmatch(key) else SUBJECT_TYPE_ITEM
260
+
261
+
262
+ def parse_subject(raw: Any, settings: ReaderSettings) -> tuple[tuple[str, str] | None, dict[str, Any] | None]:
263
+ """``({type, id}) -> (type, id)``, or a refusal. A bare string is a subject written before subject types: its type is read from its shape."""
264
+ if isinstance(raw, str):
265
+ raw = {"type": legacy_subject_type(raw), "id": raw}
266
+ if not isinstance(raw, dict) or set(raw) != {"type", "id"} or not isinstance(raw["type"], str) or not isinstance(raw["id"], str):
267
+ return None, refusal(422, "invalid_subject", "Send the subject as {type, id}.")
268
+ subject_type, subject_id = raw["type"], raw["id"]
269
+ if subject_type not in SUBJECT_TYPES or subject_type not in settings.subject_types:
270
+ return None, refusal(422, "unknown_subject_type", "That kind of subject cannot be voted on here.")
271
+ if not SUBJECT_ID.fullmatch(subject_id):
272
+ return None, refusal(422, "invalid_subject", "Send the id of the subject.")
273
+ return (subject_type, subject_id), None
274
+
275
+
232
276
  def valid_site_subject(value: Any, settings: ReaderSettings) -> bool:
233
277
  if not isinstance(value, str):
234
278
  return False
@@ -321,13 +365,18 @@ def public_profile(profile: dict[str, Any]) -> dict[str, Any]:
321
365
 
322
366
 
323
367
  def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings) -> dict[str, Any]:
324
- subjects = payload.get("subjects", [])
325
- if (
326
- not isinstance(subjects, list)
327
- or len(subjects) > MAXIMUM_SUBJECTS
328
- or not all(isinstance(subject, str) and SUBJECT_KEY.fullmatch(subject) for subject in subjects)
329
- ):
330
- return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} item keys.")
368
+ raw_subjects = payload.get("subjects", [])
369
+ if not isinstance(raw_subjects, list) or len(raw_subjects) > MAXIMUM_SUBJECTS:
370
+ return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subjects.")
371
+ subjects: list[str] = []
372
+ for raw in raw_subjects:
373
+ if isinstance(raw, str) and SUBJECT_KEY.fullmatch(raw):
374
+ subjects.append(raw)
375
+ continue
376
+ parsed, _ = parse_subject(raw, settings) if isinstance(raw, dict) else (None, None)
377
+ if parsed is None:
378
+ return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subject keys or {{type, id}} subjects.")
379
+ subjects.append(subject_key(*parsed))
331
380
  dimensions = [*TOGGLE_DIMENSIONS, *settings.dimensions]
332
381
  wanted = {vote_id(subject, profile["readerId"], dimension): (subject, dimension) for subject in subjects for dimension in dimensions}
333
382
  found = store.get_vote_values(list(wanted))
@@ -335,7 +384,9 @@ def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any
335
384
  for stored_id, value in found.items():
336
385
  subject, dimension = wanted[stored_id]
337
386
  votes.setdefault(subject, {})[dimension] = value
338
- return reply(200, {**public_profile(profile), "votes": votes})
387
+ # The reader's own up (1) or down (-1) vote per requested subject, by subject key (the key format of items.json).
388
+ subject_votes = {subject: held["up"] for subject, held in votes.items() if held.get("up") in (1, -1)}
389
+ return reply(200, {**public_profile(profile), "votes": votes, "subjectVotes": subject_votes})
339
390
 
340
391
 
341
392
  def set_handle(
@@ -375,8 +426,11 @@ def react(
375
426
  now_seconds: float,
376
427
  schedule_snapshot: Callable[[], None],
377
428
  ) -> dict[str, Any]:
378
- if not isinstance(subject, str) or not SUBJECT_KEY.fullmatch(subject):
379
- return refusal(422, "invalid_subject", "Send the key of a published item.")
429
+ parsed, refused = parse_subject(subject, settings)
430
+ if refused:
431
+ return refused
432
+ subject_type, subject_id = parsed
433
+ subject = subject_key(subject_type, subject_id)
380
434
  if not isinstance(dimension, str) or dimension not in allowed_dimensions:
381
435
  return refusal(422, "invalid_dimension", "That dimension is not available.")
382
436
  if profile.get("state") == STATE_BANNED:
@@ -386,7 +440,7 @@ def react(
386
440
  limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
387
441
  if limited:
388
442
  return limited
389
- if not subject_is_open(store, subject):
443
+ if not subject_is_open(store, subject_type, subject_id):
390
444
  return refusal(404, "unknown_subject", "That item is not published.")
391
445
  stored_id = vote_id(subject, profile["readerId"], dimension)
392
446
  if value is None:
@@ -396,6 +450,7 @@ def react(
396
450
  {
397
451
  "id": stored_id,
398
452
  "subjectKey": subject,
453
+ "subjectType": subject_type,
399
454
  "readerId": profile["readerId"],
400
455
  "dimension": dimension,
401
456
  "value": value,
@@ -407,12 +462,17 @@ def react(
407
462
  return reply(200, {"subject": subject, "dimension": dimension, "value": value})
408
463
 
409
464
 
410
- def subject_is_open(store: ReaderStore, subject: str) -> bool:
411
- """A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on."""
412
- if SUBMISSION_ID.fullmatch(subject):
413
- submission = store.get_submission(subject)
465
+ def subject_is_open(store: ReaderStore, subject_type: str, subject_id: str) -> bool:
466
+ """A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on.
467
+
468
+ The other subject types have no record in this plugin (stories, proposals and concepts live in the site's own data), so any well-formed id is open.
469
+ """
470
+ if subject_type == SUBJECT_TYPE_SUBMISSION:
471
+ submission = store.get_submission(subject_id)
414
472
  return submission is not None and submission.get("state") in VISIBLE_SUBMISSION_STATES
415
- return store.is_published(subject)
473
+ if subject_type == SUBJECT_TYPE_ITEM:
474
+ return store.is_published(subject_id)
475
+ return True
416
476
 
417
477
 
418
478
  def clean_text(value: Any, label: str, maximum: int, *, required: bool) -> tuple[str | None, dict[str, Any] | None]:
@@ -606,7 +666,7 @@ def comment_action(
606
666
  limited = within_rate_limit(store, profile, RATE_BUCKET_COMMENT, new_account_limit(profile, settings, now_seconds, settings.comments_per_hour), now_seconds)
607
667
  if limited:
608
668
  return limited
609
- if not subject_is_open(store, subject):
669
+ if not subject_is_open(store, legacy_subject_type(subject), subject):
610
670
  return refusal(404, "unknown_subject", "That item is not published.")
611
671
  if parent_id is not None:
612
672
  parent = store.get_comment(parent_id)
@@ -677,6 +737,75 @@ def report_action(
677
737
  return reply(200, {"reported": True})
678
738
 
679
739
 
740
+ def flag_action(
741
+ store: ReaderStore,
742
+ profile: dict[str, Any],
743
+ payload: dict[str, Any],
744
+ settings: ReaderSettings,
745
+ is_staff: bool,
746
+ now_seconds: float,
747
+ schedule_snapshot: Callable[[], None],
748
+ ) -> dict[str, Any]:
749
+ """A reader flags a subject as wrong, as belonging under another subject, or as needing a better name; a reason is required.
750
+
751
+ One flag per reader, subject and kind: flagging again replaces the reason. Flags are stored apart from votes and reach the
752
+ snapshot as counts only. The stored row holds the readerId, never a siteSubject.
753
+ """
754
+ if set(payload) - FLAG_PAYLOAD_KEYS:
755
+ return refusal(422, "invalid_flag", "Send subject, kind, reason, and target or name for the kind.")
756
+ kind = payload.get("kind")
757
+ if kind not in FLAG_KINDS:
758
+ return refusal(422, "invalid_flag_kind", f"Send kind as one of {', '.join(FLAG_KINDS)}.")
759
+ parsed, refused = parse_subject(payload.get("subject"), settings)
760
+ if refused:
761
+ return refused
762
+ reason = payload.get("reason")
763
+ if not isinstance(reason, str) or not reason.strip() or len(reason.strip()) > MAXIMUM_FLAG_REASON or CONTROL_CHARACTERS.search(reason):
764
+ return refusal(422, "invalid_reason", f"Give a reason in plain text of at most {MAXIMUM_FLAG_REASON} characters.")
765
+ if EMAIL_ADDRESS.search(reason):
766
+ return refusal(422, "invalid_reason", "A reason cannot contain an email address.")
767
+ target_key: str | None = None
768
+ suggested: str | None = None
769
+ if kind == FLAG_BELONGS_UNDER:
770
+ target, refused = parse_subject(payload.get("target"), settings) if isinstance(payload.get("target"), dict) else (None, True)
771
+ if refused or "name" in payload:
772
+ return refusal(422, "invalid_target", "Send target as {type, id} for belongs_under.")
773
+ target_key = subject_key(*target)
774
+ if target_key == subject_key(*parsed):
775
+ return refusal(422, "invalid_target", "A subject cannot belong under itself.")
776
+ elif kind == FLAG_BETTER_NAME:
777
+ suggested, refused = clean_text(payload.get("name"), "name", MAXIMUM_SUGGESTED_NAME, required=True)
778
+ if refused or "target" in payload:
779
+ return refused or refusal(422, "invalid_name", "Send name for better_name.")
780
+ elif "target" in payload or "name" in payload:
781
+ return refusal(422, "invalid_flag", "wrong takes only a reason.")
782
+ if profile.get("state") == STATE_LIMITED:
783
+ return refusal(403, "reader_limited", "This reader cannot flag right now.")
784
+ limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
785
+ if limited:
786
+ return limited
787
+ subject_type, subject_id = parsed
788
+ if not subject_is_open(store, subject_type, subject_id):
789
+ return refusal(404, "unknown_subject", "That subject cannot be flagged.")
790
+ key = subject_key(subject_type, subject_id)
791
+ store.put_flag(
792
+ {
793
+ "id": f"{key}#{profile['readerId']}#{kind}",
794
+ "subjectKey": key,
795
+ "subjectType": subject_type,
796
+ "readerId": profile["readerId"],
797
+ "kind": kind,
798
+ "reason": reason.strip(),
799
+ "targetKey": target_key,
800
+ "suggestedName": suggested,
801
+ "voterRole": ROLE_EDITOR if is_staff else ROLE_READER,
802
+ },
803
+ iso(now_seconds),
804
+ )
805
+ schedule_snapshot()
806
+ return reply(200, {"subject": payload["subject"], "kind": kind})
807
+
808
+
680
809
  def own_comment(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], allowed: frozenset[str]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
681
810
  if set(payload) - allowed:
682
811
  return None, refusal(422, "invalid_comment", "Send only " + ", ".join(sorted(allowed)) + ".")
@@ -740,7 +869,23 @@ def delete_comment_action(
740
869
  return reply(200, {"id": comment["id"], "state": COMMENT_DELETED})
741
870
 
742
871
 
872
+ def typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
873
+ """``{subject: {type, id}, value: 1 | -1 | null}``: an up or down vote on any enabled subject type; ``null`` removes the reader's vote."""
874
+ value = payload.get("value")
875
+ if set(payload) - VOTE_PAYLOAD_KEYS or isinstance(value, bool) or value not in (1, -1, None):
876
+ return refusal(422, "invalid_vote", "Send only subject and value, and value as 1, -1 or null.")
877
+ answer = react(
878
+ store, profile, subject=payload.get("subject"), dimension=VOTE_DIMENSION, allowed_dimensions=(VOTE_DIMENSION,),
879
+ value=value, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
880
+ )
881
+ if answer["statusCode"] == 200:
882
+ answer = reply(200, {"subject": payload["subject"], "value": value})
883
+ return answer
884
+
885
+
743
886
  def vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
887
+ if isinstance(payload.get("subject"), dict):
888
+ return typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot)
744
889
  on = payload.get("on")
745
890
  if not isinstance(on, bool):
746
891
  return refusal(422, "invalid_vote", "Send on as true or false.")
@@ -794,6 +939,8 @@ def handle_event(
794
939
  return delete_comment_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
795
940
  if action == ACTION_REPORT:
796
941
  return report_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
942
+ if action == ACTION_FLAG:
943
+ return flag_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
797
944
  return refusal(400, "unknown_action", "Unknown action.")
798
945
 
799
946
 
@@ -832,6 +979,8 @@ class DynamoReaderStore:
832
979
  report_table: str = "",
833
980
  action_table: str = "",
834
981
  item_table: str = "",
982
+ proposal_table: str = "",
983
+ flag_table: str = "",
835
984
  ) -> None:
836
985
  self._resource = resource
837
986
  self._names = {
@@ -845,11 +994,45 @@ class DynamoReaderStore:
845
994
  "report": report_table,
846
995
  "action": action_table,
847
996
  "item": item_table,
997
+ "proposal": proposal_table,
998
+ "flag": flag_table,
848
999
  }
849
1000
 
850
1001
  def _table(self, key: str) -> Any:
851
1002
  return self._resource.Table(self._names[key])
852
1003
 
1004
+ def get_proposal(self, proposal_id: str) -> dict[str, Any] | None:
1005
+ item = self._table("proposal").get_item(Key={"id": proposal_id}, ConsistentRead=True).get("Item")
1006
+ return plain(item) if item else None
1007
+
1008
+ def create_proposal(self, row: dict[str, Any]) -> bool:
1009
+ item = {**_without_none(row), "__typename": "Proposal", "updatedAt": row["createdAt"]}
1010
+ try:
1011
+ self._table("proposal").put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
1012
+ except Exception as error:
1013
+ if is_conditional_failure(error):
1014
+ return False
1015
+ raise
1016
+ return True
1017
+
1018
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool:
1019
+ names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
1020
+ assignments = ", ".join(f"#c{index} = :c{index}" for index in range(len(changes)))
1021
+ values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values())}}
1022
+ try:
1023
+ self._table("proposal").update_item(
1024
+ Key={"id": proposal_id},
1025
+ UpdateExpression=f"SET {assignments}, #u = :now",
1026
+ ConditionExpression="#st = :expected",
1027
+ ExpressionAttributeNames=names,
1028
+ ExpressionAttributeValues=values,
1029
+ )
1030
+ except Exception as error:
1031
+ if is_conditional_failure(error):
1032
+ return False
1033
+ raise
1034
+ return True
1035
+
853
1036
  def get_profile(self, site_subject: str) -> dict[str, Any] | None:
854
1037
  item = self._table("profile").get_item(Key={"id": site_subject}, ConsistentRead=True).get("Item")
855
1038
  return plain(item) if item else None
@@ -914,13 +1097,14 @@ class DynamoReaderStore:
914
1097
  self._table("vote").update_item(
915
1098
  Key={"id": vote["id"]},
916
1099
  UpdateExpression=(
917
- "SET #t = :t, subjectKey = :s, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
1100
+ "SET #t = :t, subjectKey = :s, subjectType = :st, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
918
1101
  "createdAt = if_not_exists(createdAt, :now), updatedAt = :now"
919
1102
  ),
920
1103
  ExpressionAttributeNames={"#t": "__typename", "#d": "dimension", "#v": "value"},
921
1104
  ExpressionAttributeValues={
922
1105
  ":t": "ReaderVote",
923
1106
  ":s": vote["subjectKey"],
1107
+ ":st": vote.get("subjectType") or legacy_subject_type(vote["subjectKey"]),
924
1108
  ":r": vote["readerId"],
925
1109
  ":d": vote["dimension"],
926
1110
  ":v": vote["value"],
@@ -932,6 +1116,9 @@ class DynamoReaderStore:
932
1116
  def delete_vote(self, vote_id_: str) -> None:
933
1117
  self._table("vote").delete_item(Key={"id": vote_id_})
934
1118
 
1119
+ def put_flag(self, flag: dict[str, Any], now_iso: str) -> None:
1120
+ self._table("flag").put_item(Item=_without_none({**flag, "__typename": "ReaderFlag", "createdAt": now_iso, "updatedAt": now_iso}))
1121
+
935
1122
  def is_published(self, subject_key: str) -> bool:
936
1123
  item = self._table("published").get_item(Key={"id": PUBLISHED_ID_PREFIX + subject_key}, ProjectionExpression="#s", ExpressionAttributeNames={"#s": "status"}).get("Item")
937
1124
  return bool(item) and item.get("status") == "published"
@@ -1259,6 +1446,8 @@ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoR
1259
1446
  report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
1260
1447
  action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
1261
1448
  item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
1449
+ proposal_table=environment.get("PAPYRUS_READER_PROPOSAL_TABLE", ""),
1450
+ flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
1262
1451
  )
1263
1452
 
1264
1453
 
@@ -1312,6 +1501,8 @@ def run_ai_worker(
1312
1501
 
1313
1502
  def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
1314
1503
  """``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
1504
+ if (event.get("info") or {}).get("fieldName") in proposal_rules.MUTATIONS:
1505
+ return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds))
1315
1506
  identity = event.get("identity") or {}
1316
1507
  claims = identity.get("claims") or {}
1317
1508
  groups = claims.get("cognito:groups") or identity.get("groups") or []
@@ -0,0 +1,255 @@
1
+ """The proposal record: what may be filed, which state changes are allowed, and who may make them.
2
+
3
+ A proposal is a change to the map written down where readers can see it: a story draft, a new concept, a rename, a merge,
4
+ a split, a new column or an element symbol. It is filed by AI (a reader proposer is part of the record but has no route yet)
5
+ and steered by votes; an editor decides. Staff reach this through the ``fileProposal`` and ``transitionProposal``
6
+ mutations (editors and admins only; the function checks the groups again). Every state change is appended to the proposal's
7
+ ``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
8
+
9
+ Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
10
+ actor of a change is the staff member's Cognito subject, which is stored and never published either (the public file shows
11
+ "AI" or "Editor"). Votes are not counted here: they belong to the vote model and the snapshot joins them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from typing import Any, Protocol
21
+
22
+ KINDS = ("story_draft", "new_concept", "rename", "merge", "split", "new_column", "element_symbol")
23
+ DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR = (
24
+ "draft",
25
+ "in_steering",
26
+ "editor_review",
27
+ "approved",
28
+ "declined",
29
+ "overridden_by_editor",
30
+ )
31
+ STATES = (DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
32
+
33
+ #: Every allowed state change. Anything not listed is refused. Approved, declined and overridden are final.
34
+ TRANSITIONS: dict[str, tuple[str, ...]] = {
35
+ DRAFT: (IN_STEERING, DECLINED),
36
+ IN_STEERING: (EDITOR_REVIEW,),
37
+ EDITOR_REVIEW: (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR),
38
+ }
39
+ #: The changes that need a reason. Every reason is public; this one is also required, because it explains a decision against the community.
40
+ REASON_REQUIRED = frozenset({OVERRIDDEN_BY_EDITOR})
41
+ #: An override says what the editor decided against the community's view: approved or declined. Approve and decline are their own result.
42
+ OVERRIDE_RESULTS = (APPROVED, DECLINED)
43
+
44
+ #: How many target subjects each kind names, as (minimum, maximum), and the types a target may have.
45
+ TARGET_COUNTS: dict[str, tuple[int, int]] = {
46
+ "story_draft": (0, 10),
47
+ "new_concept": (0, 10),
48
+ "rename": (1, 1),
49
+ "merge": (2, 10),
50
+ "split": (1, 1),
51
+ "new_column": (0, 10),
52
+ "element_symbol": (1, 1),
53
+ }
54
+ TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
55
+
56
+ MUTATIONS = ("fileProposal", "transitionProposal")
57
+ PROPOSER_AI = "ai"
58
+ STAFF_GROUPS = frozenset({"editor", "admin"})
59
+ PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
60
+ TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
61
+ MAXIMUM_TITLE = 200
62
+ MAXIMUM_SUMMARY = 300
63
+ MAXIMUM_BODY = 50_000
64
+ MAXIMUM_REASON = 500
65
+ MAXIMUM_EVIDENCE_SOURCE = 200
66
+ MAXIMUM_EVIDENCE_SIGNALS = 1_000_000
67
+
68
+
69
+ @dataclass
70
+ class Outcome:
71
+ status: str # "ok" or "refused"
72
+ id: str | None = None
73
+ error: str | None = None
74
+ message: str | None = None
75
+
76
+ def to_dict(self) -> dict[str, Any]:
77
+ return {"status": self.status, "id": self.id, "error": self.error, "message": self.message}
78
+
79
+
80
+ def refused(error: str, message: str) -> Outcome:
81
+ return Outcome("refused", error=error, message=message)
82
+
83
+
84
+ class ProposalStore(Protocol):
85
+ def get_proposal(self, proposal_id: str) -> dict[str, Any] | None: ...
86
+
87
+ def create_proposal(self, row: dict[str, Any]) -> bool: ...
88
+
89
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool: ...
90
+
91
+
92
+ def staff_subject(identity: dict[str, Any]) -> str | None:
93
+ """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
94
+ claims = identity.get("claims") or {}
95
+ groups = claims.get("cognito:groups") or identity.get("groups") or []
96
+ if isinstance(groups, str):
97
+ groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
98
+ subject = identity.get("sub")
99
+ if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
100
+ return None
101
+ return subject
102
+
103
+
104
+ def clean_text(value: Any, limit: int) -> str | None:
105
+ if not isinstance(value, str):
106
+ return None
107
+ text = value.strip()
108
+ return text if text and len(text) <= limit else None
109
+
110
+
111
+ def parse_json(value: Any) -> Any:
112
+ """AppSync delivers AWSJSON arguments as strings; the CLI and tests may pass the parsed value."""
113
+ if isinstance(value, str):
114
+ try:
115
+ return json.loads(value)
116
+ except ValueError:
117
+ return None
118
+ return value
119
+
120
+
121
+ def clean_targets(kind: str, raw: Any) -> list[dict[str, str]] | Outcome:
122
+ parsed = parse_json(raw) if raw is not None else []
123
+ if not isinstance(parsed, list):
124
+ return refused("invalid_targets", "targets must be a list of {type, id}.")
125
+ minimum, maximum = TARGET_COUNTS[kind]
126
+ targets: list[dict[str, str]] = []
127
+ for entry in parsed:
128
+ if not isinstance(entry, dict) or entry.get("type") not in TARGET_TYPES or not isinstance(entry.get("id"), str) or not TARGET_ID.fullmatch(entry["id"]):
129
+ return refused("invalid_targets", f"Each target is {{type, id}} with a type of {', '.join(TARGET_TYPES)}.")
130
+ target = {"type": entry["type"], "id": entry["id"]}
131
+ if target in targets:
132
+ return refused("invalid_targets", "A target is named twice.")
133
+ targets.append(target)
134
+ if not minimum <= len(targets) <= maximum:
135
+ wanted = f"{minimum}" if minimum == maximum else f"{minimum} to {maximum}"
136
+ return refused("invalid_targets", f"A {kind} proposal names {wanted} target(s).")
137
+ return targets
138
+
139
+
140
+ def clean_evidence(raw: Any) -> dict[str, Any] | None | Outcome:
141
+ parsed = parse_json(raw) if raw is not None else None
142
+ if parsed is None:
143
+ return None
144
+ if not isinstance(parsed, dict):
145
+ return refused("invalid_evidence", "evidence must be {signals, source}.")
146
+ signals, source = parsed.get("signals"), parsed.get("source")
147
+ if not isinstance(signals, int) or isinstance(signals, bool) or not 0 <= signals <= MAXIMUM_EVIDENCE_SIGNALS:
148
+ return refused("invalid_evidence", "evidence.signals must be a whole number of signals.")
149
+ if source is not None and clean_text(source, MAXIMUM_EVIDENCE_SOURCE) is None:
150
+ return refused("invalid_evidence", "evidence.source must be short text.")
151
+ return {"signals": signals, "source": source.strip() if isinstance(source, str) else None}
152
+
153
+
154
+ def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, proposal_id: str | None = None) -> tuple[dict[str, Any] | None, Outcome | None]:
155
+ """The new row for a proposal in ``draft``, or the refusal. The proposer is AI: reader filing has no route yet."""
156
+ kind = arguments.get("kind")
157
+ if kind not in KINDS:
158
+ return None, refused("invalid_kind", f"kind must be one of {', '.join(KINDS)}.")
159
+ title = clean_text(arguments.get("title"), MAXIMUM_TITLE)
160
+ if title is None:
161
+ return None, refused("invalid_title", f"A proposal needs a title of at most {MAXIMUM_TITLE} characters.")
162
+ body = arguments.get("body") # Markus source, kept byte for byte
163
+ if not isinstance(body, str) or not body.strip() or len(body) > MAXIMUM_BODY:
164
+ return None, refused("invalid_body", "A proposal needs a body (Markus), at most 50,000 characters.")
165
+ summary = arguments.get("summary")
166
+ if summary is not None and clean_text(summary, MAXIMUM_SUMMARY) is None:
167
+ return None, refused("invalid_summary", f"summary is one line of at most {MAXIMUM_SUMMARY} characters.")
168
+ targets = clean_targets(kind, arguments.get("targets"))
169
+ if isinstance(targets, Outcome):
170
+ return None, targets
171
+ evidence = clean_evidence(arguments.get("evidence"))
172
+ if isinstance(evidence, Outcome):
173
+ return None, evidence
174
+ identifier = proposal_id or "prop-" + uuid.uuid4().hex
175
+ row: dict[str, Any] = {
176
+ "id": identifier,
177
+ "kind": kind,
178
+ "title": title,
179
+ "body": body,
180
+ "targets": json.dumps(targets),
181
+ "proposer": PROPOSER_AI,
182
+ "state": DRAFT,
183
+ "history": json.dumps([{"from": None, "to": DRAFT, "by": "ai", "actor": actor_sub, "reason": None, "at": now_iso}]),
184
+ "createdAt": now_iso,
185
+ }
186
+ if summary is not None:
187
+ row["summary"] = summary.strip()
188
+ if evidence is not None:
189
+ row["evidence"] = json.dumps(evidence)
190
+ return row, None
191
+
192
+
193
+ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_sub: str, now_iso: str, result: Any = None) -> dict[str, Any] | Outcome:
194
+ """The changes that move ``row`` to ``to_state``, or why it may not move."""
195
+ state = row.get("state")
196
+ if to_state not in STATES:
197
+ return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
198
+ if to_state not in TRANSITIONS.get(state, ()):
199
+ return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
200
+ text = None
201
+ if reason is not None and str(reason).strip():
202
+ text = clean_text(reason, MAXIMUM_REASON)
203
+ if text is None:
204
+ return refused("invalid_reason", f"A reason is plain text of at most {MAXIMUM_REASON} characters.")
205
+ if to_state in REASON_REQUIRED and text is None:
206
+ return refused("reason_required", "Overriding the community needs a public reason.")
207
+ if to_state == OVERRIDDEN_BY_EDITOR:
208
+ if result not in OVERRIDE_RESULTS:
209
+ return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
210
+ elif result is not None:
211
+ return refused("result_not_allowed", "Only an override carries a result; approve and decline are their own.")
212
+ entry = {"from": state, "to": to_state, "by": "staff", "actor": actor_sub, "reason": text, "at": now_iso}
213
+ if result is not None:
214
+ entry["result"] = result
215
+ history = [*(parse_json(row.get("history")) or []), entry]
216
+ return {"state": to_state, "history": json.dumps(history)}
217
+
218
+
219
+ def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, reason: Any, actor_sub: str, now_iso: str, result: Any = None) -> Outcome:
220
+ if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
221
+ return refused("invalid_proposal", "That is not a proposal id.")
222
+ row = store.get_proposal(proposal_id)
223
+ if row is None:
224
+ return refused("not_found", "There is no such proposal.")
225
+ plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result)
226
+ if isinstance(plan, Outcome):
227
+ return plan
228
+ if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
229
+ return refused("target_changed", "Someone else changed that proposal first.")
230
+ return Outcome("ok", id=proposal_id)
231
+
232
+
233
+ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str) -> dict[str, Any]:
234
+ """``fileProposal`` or ``transitionProposal`` from AppSync. Answers a receipt, never an exception."""
235
+ actor = staff_subject(event.get("identity") or {})
236
+ if actor is None:
237
+ return refused("not_staff", "Only editors and admins can file or decide proposals.").to_dict()
238
+ arguments = event.get("arguments") or {}
239
+ field = (event.get("info") or {}).get("fieldName")
240
+ if field == "fileProposal":
241
+ row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
242
+ if problem is not None:
243
+ return problem.to_dict()
244
+ if not store.create_proposal(row):
245
+ return refused("exists", "That proposal already exists.").to_dict()
246
+ return Outcome("ok", id=row["id"]).to_dict()
247
+ return apply_transition(
248
+ store,
249
+ proposal_id=arguments.get("proposalId"),
250
+ to_state=arguments.get("state"),
251
+ reason=arguments.get("reason"),
252
+ result=arguments.get("result"),
253
+ actor_sub=actor,
254
+ now_iso=now_iso,
255
+ ).to_dict()