File size: 11,672 Bytes
470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 4f41d03 470bcea 98ce58d 4f41d03 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea 98ce58d 470bcea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 | """Apply the curator's verdict to a queued ICSAC submission.
Invoked via decide.sh (or the curator-reply-channel responder) after
the curator confirms or overrides the panel's recommendation:
decide.sh ICSAC-SUB-NNNNN <accept|revise|scope_reject> [optional note]
Runs publications registration (DOI route only), Zenodo deposit staging
(PDF route + deposit consent only), drafts the author email, appends
audit-log, clears the awaiting-decision marker, finalises state.json.
Test-tier routing (T2/T3): submissions whose sub_id matches
ICSAC-SUB-TEST-<unix-ts> are looked up under ~/icsac-submissions/test/,
their audit entries land in audit-log-test.jsonl, the email step takes
the tier kwarg from submission.json (T2 -> outbox .eml, T3 -> Gmail
draft with `[T3 TEST]` prefix), Zenodo staging passes sandbox=True for
T3 and skips entirely for T2, and the curator-ready Telegram ping
routes to TELEGRAM_TEST_CHAT_ID. Publications registration is skipped
in test mode regardless of tier (no writes to icsacinstitute.org repo).
"""
from __future__ import annotations
import datetime
import json
import os
import re
import sys
from pathlib import Path
# Parent-package imports (editorial-system modules live at repo root, one
# level above this intake/ subpackage).
_REPO_ROOT = Path(__file__).resolve().parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import config # noqa: E402
import notify # noqa: E402
import publications # noqa: E402
from . import notify_author # local
from . import submission_worker as worker # local β reuse _scrubbed_report_pair etc.
SUBMISSIONS_ROOT = Path.home() / "icsac-submissions"
TEST_SUBMISSIONS_ROOT = SUBMISSIONS_ROOT / "test"
AUDIT_LOG = Path(config.REVIEWS_DIR) / "audit-log.jsonl"
TEST_AUDIT_LOG = Path(config.REVIEWS_DIR) / "audit-log-test.jsonl"
SUB_ID_RE = re.compile(r"^ICSAC-SUB-(?:TEST-\d+|\d{5})$")
TEST_SUB_ID_RE = re.compile(r"^ICSAC-SUB-TEST-\d+$")
VERDICT_OK = {"accept", "revise", "scope_reject"}
def _now_iso() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
def _audit(event: dict, *, test_mode: bool = False) -> None:
target = TEST_AUDIT_LOG if test_mode else AUDIT_LOG
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("a") as f:
payload = {"ts": _now_iso(), **event}
if test_mode:
payload.setdefault("test", True)
f.write(json.dumps(payload) + "\n")
def _curator_routing(test_mode: bool, tier: int) -> dict:
"""Return kwargs for notify.send_to_curator routing.
Production: empty dict (default chat, ntfy on). Test mode at T3:
route to TELEGRAM_TEST_CHAT_ID (if configured) and suppress ntfy
so test runs do not trip pain alerts. Test mode at T2: suppress
both curator pings (skip Telegram too) since T2 stays IMAP-less.
"""
if not test_mode:
return {}
if tier == 3:
chat = getattr(config, "TELEGRAM_TEST_CHAT_ID", "")
thread = getattr(config, "TELEGRAM_TEST_THREAD_ID", "")
if not chat:
# No test chat configured; suppress entirely.
return {"chat_override": "__suppress__", "ntfy": False}
return {"chat_override": chat, "thread_override": thread,
"ntfy": False}
# T2 (or T1 dry-run): no curator ping.
return {"chat_override": "__suppress__", "ntfy": False}
def main(argv: list[str]) -> int:
if len(argv) < 3:
print("usage: apply_decision.py <sub-id> <accept|revise|scope_reject> [note]",
file=sys.stderr)
return 2
sub_id = argv[1].strip()
verdict = argv[2].strip().lower()
note = " ".join(argv[3:]).strip() if len(argv) > 3 else ""
if not SUB_ID_RE.match(sub_id):
print(f"bad sub id: {sub_id!r}", file=sys.stderr)
return 2
if verdict not in VERDICT_OK:
print(f"bad verdict: {verdict!r} (want accept|revise|scope_reject)",
file=sys.stderr)
return 2
is_test = bool(TEST_SUB_ID_RE.match(sub_id))
sub_root = TEST_SUBMISSIONS_ROOT if is_test else SUBMISSIONS_ROOT
sub_dir = sub_root / sub_id
if not sub_dir.is_dir():
print(f"no such submission: {sub_dir}", file=sys.stderr)
return 1
submission = json.loads((sub_dir / "submission.json").read_text())
# tier is intake-asserted on the submission record; test_mode is
# the canonical isolation flag. Re-derive both from the sub_id
# prefix too as a belt-and-suspenders check against a forged
# record file.
tier = int(submission.get("tier") or 1)
test_mode = bool(submission.get("test_mode")) or is_test
if test_mode and tier == 1:
tier = 1 # T1 short-circuits earlier in the pipeline; we should
# not see T1 records arrive here, but if one does treat
# it as a no-side-effect dry run.
form = submission.get("form", {})
title = submission.get("title") or "Untitled"
source = submission.get("source") or "upload"
source_ref = submission.get("doi") or submission.get("source_ref") or ""
panel_md, rqc_md = worker._scrubbed_report_pair(sub_id, title, tier=tier)
state_path = sub_dir / "state.json"
state_pre = json.loads(state_path.read_text()) if state_path.exists() else {}
deposit_doi = state_pre.get("deposit_doi")
deposit_url = state_pre.get("deposit_url")
# DOI-route accept: register to /publications immediately (mirrors
# submission_worker.process()). PDF-route accept defers to
# publish_watcher after the curator publishes the draft.
publications_url_str: str | None = None
if verdict == "accept" and source == "doi" and not test_mode:
try:
publications_url_str = worker._register_doi_accept(sub_id, sub_dir, submission)
_audit({"sub_id": sub_id, "event": "publications_registered",
"publications_url": publications_url_str, "by": "curator"}, test_mode=test_mode)
print(f" publications: registered at {publications_url_str}",
file=sys.stderr)
except Exception as exc:
print(f" publications register failed for {sub_id}: {exc}",
file=sys.stderr)
_audit({"sub_id": sub_id, "event": "publications_register_failed",
"reason": f"{type(exc).__name__}: {exc}"[:500],
"by": "curator"}, test_mode=test_mode)
# Author email still sends β placeholder reads as empty string.
# Curator-driven accept on the upload route stages a draft deposit
# if one hasn't been staged yet (auto path catches PAUSED-then-
# recovered cases too). DRAFT-ONLY semantics: no DOI is minted, the
# draft waits for the curator to publish from Zenodo's UI (or
# zenodo_deposit.publish_draft). Failure is graceful β _pending
# template either way. Mirrors submission_worker.process().
deposit_record_id = state_pre.get("deposit_record_id")
skip_zenodo = test_mode and tier == 2
if (verdict == "accept"
and source == "upload"
and not deposit_record_id
and form.get("deposit_consent")
and not skip_zenodo):
try:
import repository_deposit as zenodo_deposit
sandbox = test_mode and tier == 3
label = "sandbox " if sandbox else ""
print(f" deposit-draft: staging {label}for {sub_id}...", file=sys.stderr)
paper_pdf = sub_dir / "paper.pdf"
draft = zenodo_deposit.stage_deposit_draft(
submission, paper_pdf,
log=lambda m: print(m, file=sys.stderr),
sandbox=sandbox,
)
state_pre["deposit_record_id"] = draft["record_id"]
state_pre["deposit_draft_url"] = draft["draft_url"]
_audit({"sub_id": sub_id, "event": "deposit_draft_completed",
"deposit_record_id": draft["record_id"],
"deposit_draft_url": draft["draft_url"],
"by": "curator"}, test_mode=test_mode)
except Exception as exc:
print(f" deposit-draft failed for {sub_id}: {exc}", file=sys.stderr)
_audit({"sub_id": sub_id, "event": "deposit_draft_failed",
"reason": f"{type(exc).__name__}: {exc}"[:500],
"by": "curator"}, test_mode=test_mode)
# deposit_doi stays None either way β template falls back to _pending.
# Load the blind-review compaction manifest (worker persisted it
# alongside the submission). Missing manifest = compaction did not run
# for this submission (older sub, or worker crash before write); the
# disclosure block prints a graceful fallback in that case.
compaction_manifest = None
manifest_path = sub_dir / "compaction_manifest.json"
if manifest_path.exists():
try:
compaction_manifest = json.loads(manifest_path.read_text())
except Exception as exc:
print(f" manifest load failed: {exc}", file=sys.stderr)
ok, info = notify_author.send_decision(
to=form["email"], sub_id=sub_id, title=title,
author_name=form["name"], verdict=verdict,
source=source, source_ref=source_ref,
panel_report_md=panel_md, rqc_md=rqc_md,
deposit_doi=deposit_doi, deposit_url=deposit_url,
publications_url=publications_url_str,
tier=tier,
compaction_manifest=compaction_manifest,
)
if ok:
# Decision emails go to Gmail Drafts (curator-applied decision path).
try:
curator_kwargs = _curator_routing(test_mode, tier)
notify.send_to_curator(
f"*Draft ready*: `{sub_id}` β verdict *{verdict}* (curator)\n"
f"To: `{form['email']}`\n"
f"Open Gmail β Drafts to review and send.",
parse_mode="Markdown",
**curator_kwargs,
)
except Exception as exc:
print(f"curator draft-ready ping failed: {exc}", file=sys.stderr)
state = dict(state_pre)
state.update({
"state": "completed" if ok else "completed_email_failed",
"completed_at": _now_iso(),
"decision": verdict,
"decided_by": "curator",
"decision_note": note or None,
})
state_path.write_text(json.dumps(state, indent=2))
awaiting = sub_dir / "awaiting-decision.json"
if awaiting.exists():
awaiting.unlink()
_audit({
"sub_id": sub_id, "event": "decision_emailed",
"verdict": verdict, "by": "curator", "note": note or None,
"email_sent": ok,
}, test_mode=test_mode)
# DOI lazy-rehydration: replace local paper.pdf with a stub now that
# the review lifecycle is closed. Bytes remain retrievable from the
# resolver via rehydrate.sh; sha256 in the stub guards verification.
# Upload submissions are skipped by stub_pdf_if_doi (they're the
# archive of record).
if worker.stub_pdf_if_doi(sub_dir, submission):
_audit({"sub_id": sub_id, "event": "pdf_stubbed",
"doi": submission.get("doi", ""), "by": "curator"}, test_mode=test_mode)
notify.send_to_curator(
f"ICSAC decision applied\n\n"
f"ID: {sub_id}\n"
f"Verdict: {verdict.upper()}\n"
f"Author email: {'sent' if ok else 'FAILED β ' + str(info)[:120]}",
parse_mode=None,
**_curator_routing(test_mode, tier),
)
print(f"applied {verdict} for {sub_id}; email_sent={ok}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
|