@anthusai/papyrus 2.0.0-next.69 → 2.0.0-next.70
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/plugins/reader-steering/function/handler.py +41 -1
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +6 -2
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +6 -2
- package/amplify/plugins/reader-steering/schema.js +48 -0
- package/amplify/plugins/reader-steering/schema.ts +48 -0
- package/package.json +1 -1
- package/routes.manifest.json +1 -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}")
|
|
@@ -383,7 +384,9 @@ def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any
|
|
|
383
384
|
for stored_id, value in found.items():
|
|
384
385
|
subject, dimension = wanted[stored_id]
|
|
385
386
|
votes.setdefault(subject, {})[dimension] = value
|
|
386
|
-
|
|
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})
|
|
387
390
|
|
|
388
391
|
|
|
389
392
|
def set_handle(
|
|
@@ -976,6 +979,7 @@ class DynamoReaderStore:
|
|
|
976
979
|
report_table: str = "",
|
|
977
980
|
action_table: str = "",
|
|
978
981
|
item_table: str = "",
|
|
982
|
+
proposal_table: str = "",
|
|
979
983
|
flag_table: str = "",
|
|
980
984
|
) -> None:
|
|
981
985
|
self._resource = resource
|
|
@@ -990,12 +994,45 @@ class DynamoReaderStore:
|
|
|
990
994
|
"report": report_table,
|
|
991
995
|
"action": action_table,
|
|
992
996
|
"item": item_table,
|
|
997
|
+
"proposal": proposal_table,
|
|
993
998
|
"flag": flag_table,
|
|
994
999
|
}
|
|
995
1000
|
|
|
996
1001
|
def _table(self, key: str) -> Any:
|
|
997
1002
|
return self._resource.Table(self._names[key])
|
|
998
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
|
+
|
|
999
1036
|
def get_profile(self, site_subject: str) -> dict[str, Any] | None:
|
|
1000
1037
|
item = self._table("profile").get_item(Key={"id": site_subject}, ConsistentRead=True).get("Item")
|
|
1001
1038
|
return plain(item) if item else None
|
|
@@ -1409,6 +1446,7 @@ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoR
|
|
|
1409
1446
|
report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
|
|
1410
1447
|
action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
|
|
1411
1448
|
item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
|
|
1449
|
+
proposal_table=environment.get("PAPYRUS_READER_PROPOSAL_TABLE", ""),
|
|
1412
1450
|
flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
|
|
1413
1451
|
)
|
|
1414
1452
|
|
|
@@ -1463,6 +1501,8 @@ def run_ai_worker(
|
|
|
1463
1501
|
|
|
1464
1502
|
def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
|
|
1465
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))
|
|
1466
1506
|
identity = event.get("identity") or {}
|
|
1467
1507
|
claims = identity.get("claims") or {}
|
|
1468
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()
|
|
@@ -14,8 +14,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
|
|
|
14
14
|
export const readerSteeringManifest = {
|
|
15
15
|
name: "reader-steering",
|
|
16
16
|
contract: 1,
|
|
17
|
-
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
|
|
18
|
-
mutations: ["moderateReader"],
|
|
17
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
18
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
19
19
|
guest: { mutations: [] },
|
|
20
20
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
21
21
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -102,6 +102,7 @@ export const readerSteeringPlugin = {
|
|
|
102
102
|
publicSettings: (config) => ({
|
|
103
103
|
contract: readerSteeringManifest.contract,
|
|
104
104
|
dimensions: config.dimensions,
|
|
105
|
+
subjects: { types: config.subjects.types },
|
|
105
106
|
moderation: { mode: config.moderation.mode },
|
|
106
107
|
}),
|
|
107
108
|
wire(backend, config, context) {
|
|
@@ -117,6 +118,7 @@ export const readerSteeringPlugin = {
|
|
|
117
118
|
["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
|
|
118
119
|
["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
|
|
119
120
|
["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
|
|
121
|
+
["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
|
|
120
122
|
]) {
|
|
121
123
|
tables[model].grantReadWriteData(lambda);
|
|
122
124
|
lambda.addEnvironment(variable, tables[model].tableName);
|
|
@@ -133,6 +135,8 @@ export const readerSteeringPlugin = {
|
|
|
133
135
|
// The snapshot reads submissions, and the review step writes their signals and moderation state.
|
|
134
136
|
tables.ReaderSubmission.grantReadWriteData(contentActions);
|
|
135
137
|
tables.ReaderComment.grantReadWriteData(contentActions);
|
|
138
|
+
// The snapshot reads proposals and freezes the community result of one that reaches editor review.
|
|
139
|
+
tables.Proposal.grantReadWriteData(contentActions);
|
|
136
140
|
lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
|
|
137
141
|
if (config.reservedConcurrency !== undefined) {
|
|
138
142
|
lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
|
|
@@ -21,8 +21,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
|
|
|
21
21
|
export const readerSteeringManifest: PluginManifest = {
|
|
22
22
|
name: "reader-steering",
|
|
23
23
|
contract: 1,
|
|
24
|
-
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
|
|
25
|
-
mutations: ["moderateReader"],
|
|
24
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
|
|
25
|
+
mutations: ["moderateReader", "fileProposal", "transitionProposal"],
|
|
26
26
|
guest: { mutations: [] },
|
|
27
27
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
28
28
|
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
@@ -120,6 +120,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
120
120
|
publicSettings: (config) => ({
|
|
121
121
|
contract: readerSteeringManifest.contract,
|
|
122
122
|
dimensions: config.dimensions,
|
|
123
|
+
subjects: { types: config.subjects.types },
|
|
123
124
|
moderation: { mode: config.moderation.mode },
|
|
124
125
|
}),
|
|
125
126
|
wire(backend, config, context: PluginWiringContext) {
|
|
@@ -136,6 +137,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
136
137
|
["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
|
|
137
138
|
["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
|
|
138
139
|
["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
|
|
140
|
+
["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
|
|
139
141
|
] as const) {
|
|
140
142
|
tables[model].grantReadWriteData(lambda);
|
|
141
143
|
lambda.addEnvironment(variable, tables[model].tableName);
|
|
@@ -152,6 +154,8 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
152
154
|
// The snapshot reads submissions, and the review step writes their signals and moderation state.
|
|
153
155
|
tables.ReaderSubmission.grantReadWriteData(contentActions);
|
|
154
156
|
tables.ReaderComment.grantReadWriteData(contentActions);
|
|
157
|
+
// The snapshot reads proposals and freezes the community result of one that reaches editor review.
|
|
158
|
+
tables.Proposal.grantReadWriteData(contentActions);
|
|
155
159
|
|
|
156
160
|
lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
|
|
157
161
|
if (config.reservedConcurrency !== undefined) {
|
|
@@ -134,6 +134,54 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
134
134
|
.disableOperations(["subscriptions"])
|
|
135
135
|
.secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
|
|
136
136
|
.authorization(staffOnly),
|
|
137
|
+
Proposal: a
|
|
138
|
+
.model({
|
|
139
|
+
id: a.id().required(),
|
|
140
|
+
kind: a.string().required(),
|
|
141
|
+
title: a.string().required(),
|
|
142
|
+
summary: a.string(),
|
|
143
|
+
body: a.string().required(),
|
|
144
|
+
targets: a.json().required(),
|
|
145
|
+
evidence: a.json(),
|
|
146
|
+
proposer: a.string().required(),
|
|
147
|
+
state: a.string().required(),
|
|
148
|
+
history: a.json().required(),
|
|
149
|
+
communityResult: a.json(),
|
|
150
|
+
createdAt: a.datetime().required(),
|
|
151
|
+
})
|
|
152
|
+
.disableOperations(["subscriptions"])
|
|
153
|
+
.secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
|
|
154
|
+
.authorization(staffOnly),
|
|
155
|
+
ProposalReceipt: a.customType({
|
|
156
|
+
status: a.string().required(),
|
|
157
|
+
id: a.string(),
|
|
158
|
+
error: a.string(),
|
|
159
|
+
message: a.string(),
|
|
160
|
+
}),
|
|
161
|
+
fileProposal: a
|
|
162
|
+
.mutation()
|
|
163
|
+
.arguments({
|
|
164
|
+
kind: a.string().required(),
|
|
165
|
+
title: a.string().required(),
|
|
166
|
+
body: a.string().required(),
|
|
167
|
+
summary: a.string(),
|
|
168
|
+
targets: a.json(),
|
|
169
|
+
evidence: a.json(),
|
|
170
|
+
})
|
|
171
|
+
.returns(a.ref("ProposalReceipt"))
|
|
172
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
173
|
+
.handler(a.handler.function(readerActions)),
|
|
174
|
+
transitionProposal: a
|
|
175
|
+
.mutation()
|
|
176
|
+
.arguments({
|
|
177
|
+
proposalId: a.string().required(),
|
|
178
|
+
state: a.string().required(),
|
|
179
|
+
reason: a.string(),
|
|
180
|
+
result: a.string(),
|
|
181
|
+
})
|
|
182
|
+
.returns(a.ref("ProposalReceipt"))
|
|
183
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
184
|
+
.handler(a.handler.function(readerActions)),
|
|
137
185
|
ModerationReceipt: a.customType({
|
|
138
186
|
status: a.string().required(),
|
|
139
187
|
id: a.string(),
|
|
@@ -136,6 +136,54 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
136
136
|
.disableOperations(["subscriptions"])
|
|
137
137
|
.secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
|
|
138
138
|
.authorization(staffOnly),
|
|
139
|
+
Proposal: a
|
|
140
|
+
.model({
|
|
141
|
+
id: a.id().required(),
|
|
142
|
+
kind: a.string().required(),
|
|
143
|
+
title: a.string().required(),
|
|
144
|
+
summary: a.string(),
|
|
145
|
+
body: a.string().required(),
|
|
146
|
+
targets: a.json().required(),
|
|
147
|
+
evidence: a.json(),
|
|
148
|
+
proposer: a.string().required(),
|
|
149
|
+
state: a.string().required(),
|
|
150
|
+
history: a.json().required(),
|
|
151
|
+
communityResult: a.json(),
|
|
152
|
+
createdAt: a.datetime().required(),
|
|
153
|
+
})
|
|
154
|
+
.disableOperations(["subscriptions"])
|
|
155
|
+
.secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
|
|
156
|
+
.authorization(staffOnly),
|
|
157
|
+
ProposalReceipt: a.customType({
|
|
158
|
+
status: a.string().required(),
|
|
159
|
+
id: a.string(),
|
|
160
|
+
error: a.string(),
|
|
161
|
+
message: a.string(),
|
|
162
|
+
}),
|
|
163
|
+
fileProposal: a
|
|
164
|
+
.mutation()
|
|
165
|
+
.arguments({
|
|
166
|
+
kind: a.string().required(),
|
|
167
|
+
title: a.string().required(),
|
|
168
|
+
body: a.string().required(),
|
|
169
|
+
summary: a.string(),
|
|
170
|
+
targets: a.json(),
|
|
171
|
+
evidence: a.json(),
|
|
172
|
+
})
|
|
173
|
+
.returns(a.ref("ProposalReceipt"))
|
|
174
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
175
|
+
.handler(a.handler.function(readerActions)),
|
|
176
|
+
transitionProposal: a
|
|
177
|
+
.mutation()
|
|
178
|
+
.arguments({
|
|
179
|
+
proposalId: a.string().required(),
|
|
180
|
+
state: a.string().required(),
|
|
181
|
+
reason: a.string(),
|
|
182
|
+
result: a.string(),
|
|
183
|
+
})
|
|
184
|
+
.returns(a.ref("ProposalReceipt"))
|
|
185
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
186
|
+
.handler(a.handler.function(readerActions)),
|
|
139
187
|
ModerationReceipt: a.customType({
|
|
140
188
|
status: a.string().required(),
|
|
141
189
|
id: a.string(),
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED