Skill Tracer
Skills › Scientific Agent Skills › peer-review › validate_review_intake.py

validate_review_intake.py

453 lines · skills/peer-review/scripts/validate_review_intake.py at 5f2bc03

1#!/usr/bin/env python3
2"""Validate peer-review scope, authorization, conflicts, and handling controls."""
3
4from __future__ import annotations
5
6import argparse
7from typing import Any
8
9from _common import (
10 ValidationError,
11 error_exit,
12 issue,
13 read_json,
14 require_bool,
15 require_enum,
16 require_exact_keys,
17 require_identifier,
18 require_identifier_list,
19 require_object,
20 require_text_list,
21 write_json_report,
24MATERIAL_STATUSES = {
25 "unpublished_confidential",
26 "public_preprint",
27 "published",
28 "synthetic",
30AUTHORIZATION_BASES = {
31 "journal_invitation",
32 "editor_assignment",
33 "author_request",
34 "public_material",
35 "synthetic_training",
37CAPACITIES = {
38 "assigned_reviewer",
39 "disclosed_co_reviewer",
40 "author_requested_reader",
41 "editorial_support",
42 "training",
44CONFLICT_STATUSES = {
45 "not_assessed",
46 "none_identified",
47 "disclosed_to_editor",
48 "editor_cleared",
49 "unresolved",
51PEER_REVIEW_MODELS = {
52 "single_anonymized",
53 "double_anonymized",
54 "open",
55 "transparent",
56 "post_publication",
57 "unknown",
59AI_POLICIES = {
60 "prohibited",
61 "permission_required",
62 "permitted_with_disclosure",
63 "not_stated",
65AI_PLANS = {"none", "local_deterministic_tools", "approved_ai_assistance"}
66RETENTION_RULES = {
67 "delete_after_review",
68 "delete_after_editor_confirmation",
69 "retain_per_venue_policy",
70 "public_material",
74def validate_intake(payload: Any) -> dict[str, Any]:
75 """Return a content-free intake report; never echo manuscript text."""
76 root = require_object(payload, "intake")
77 require_exact_keys(
78 root,
79 required={
80 "schema_version",
81 "review_id",
82 "material",
83 "authorization",
84 "reviewer",
85 "venue_policy",
86 "ai_use",
87 "handling",
88 "scope",
89 },
90 context="intake",
91 )
92 schema_version = require_enum(
93 root["schema_version"], {"2.0"}, "intake.schema_version"
94 )
95 review_id = require_identifier(root["review_id"], "intake.review_id")
97 material = require_object(root["material"], "intake.material")
98 require_exact_keys(
99 material,
100 required={"status", "contains_personal_or_sensitive_data"},
101 context="intake.material",
102 )
103 material_status = require_enum(
104 material["status"], MATERIAL_STATUSES, "intake.material.status"
105 )
106 sensitive = require_bool(
107 material["contains_personal_or_sensitive_data"],
108 "intake.material.contains_personal_or_sensitive_data",
109 )
111 authorization = require_object(root["authorization"], "intake.authorization")
112 require_exact_keys(
113 authorization,
114 required={
115 "basis",
116 "documented",
117 "local_processing_authorized",
118 "external_processing_authorized",
119 },
120 context="intake.authorization",
121 )
122 authorization_basis = require_enum(
123 authorization["basis"],
124 AUTHORIZATION_BASES,
125 "intake.authorization.basis",
126 )
127 authorization_documented = require_bool(
128 authorization["documented"], "intake.authorization.documented"
129 )
130 local_authorized = require_bool(
131 authorization["local_processing_authorized"],
132 "intake.authorization.local_processing_authorized",
133 )
134 external_authorized = require_bool(
135 authorization["external_processing_authorized"],
136 "intake.authorization.external_processing_authorized",
137 )
139 reviewer = require_object(root["reviewer"], "intake.reviewer")
140 require_exact_keys(
141 reviewer,
142 required={
143 "capacity",
144 "human_accountable",
145 "competence_areas",
146 "competence_limits",
147 "conflict_status",
148 "conflicts",
149 },
150 context="intake.reviewer",
151 )
152 capacity = require_enum(
153 reviewer["capacity"], CAPACITIES, "intake.reviewer.capacity"
154 )
155 human_accountable = require_bool(
156 reviewer["human_accountable"], "intake.reviewer.human_accountable"
157 )
158 competence_areas = require_identifier_list(
159 reviewer["competence_areas"],
160 "intake.reviewer.competence_areas",
161 minimum=1,
162 )
163 competence_limits = require_text_list(
164 reviewer["competence_limits"], "intake.reviewer.competence_limits"
165 )
166 conflict_status = require_enum(
167 reviewer["conflict_status"],
168 CONFLICT_STATUSES,
169 "intake.reviewer.conflict_status",
170 )
171 conflicts = require_text_list(
172 reviewer["conflicts"], "intake.reviewer.conflicts", maximum=50
173 )
175 venue = require_object(root["venue_policy"], "intake.venue_policy")
176 require_exact_keys(
177 venue,
178 required={
179 "checked",
180 "peer_review_model",
181 "confidential_editor_notes_supported",
182 },
183 context="intake.venue_policy",
184 )
185 venue_checked = require_bool(venue["checked"], "intake.venue_policy.checked")
186 peer_review_model = require_enum(
187 venue["peer_review_model"],
188 PEER_REVIEW_MODELS,
189 "intake.venue_policy.peer_review_model",
190 )
191 confidential_notes_supported = require_bool(
192 venue["confidential_editor_notes_supported"],
193 "intake.venue_policy.confidential_editor_notes_supported",
194 )
196 ai_use = require_object(root["ai_use"], "intake.ai_use")
197 require_exact_keys(
198 ai_use,
199 required={
200 "policy",
201 "planned",
202 "permission_confirmed",
203 "disclosure_planned",
204 },
205 context="intake.ai_use",
206 )
207 ai_policy = require_enum(
208 ai_use["policy"], AI_POLICIES, "intake.ai_use.policy"
209 )
210 ai_plan = require_enum(ai_use["planned"], AI_PLANS, "intake.ai_use.planned")
211 ai_permission = require_bool(
212 ai_use["permission_confirmed"], "intake.ai_use.permission_confirmed"
213 )
214 ai_disclosure = require_bool(
215 ai_use["disclosure_planned"], "intake.ai_use.disclosure_planned"
216 )
218 handling = require_object(root["handling"], "intake.handling")
219 require_exact_keys(
220 handling,
221 required={
222 "local_only",
223 "external_service_use",
224 "data_reuse_permitted",
225 "retention_rule",
226 "deletion_or_retention_record_planned",
227 },
228 context="intake.handling",
229 )
230 local_only = require_bool(handling["local_only"], "intake.handling.local_only")
231 external_service_use = require_bool(
232 handling["external_service_use"],
233 "intake.handling.external_service_use",
234 )
235 data_reuse = require_bool(
236 handling["data_reuse_permitted"],
237 "intake.handling.data_reuse_permitted",
238 )
239 retention_rule = require_enum(
240 handling["retention_rule"],
241 RETENTION_RULES,
242 "intake.handling.retention_rule",
243 )
244 handling_record = require_bool(
245 handling["deletion_or_retention_record_planned"],
246 "intake.handling.deletion_or_retention_record_planned",
247 )
249 scope = require_object(root["scope"], "intake.scope")
250 require_exact_keys(
251 scope,
252 required={
253 "manuscript_type",
254 "requested_focus",
255 "out_of_scope",
256 "specialist_review_needed",
257 },
258 context="intake.scope",
259 )
260 manuscript_type = require_identifier(
261 scope["manuscript_type"], "intake.scope.manuscript_type"
262 )
263 requested_focus = require_identifier_list(
264 scope["requested_focus"], "intake.scope.requested_focus", minimum=1
265 )
266 out_of_scope = require_identifier_list(
267 scope["out_of_scope"], "intake.scope.out_of_scope"
268 )
269 specialist_review_needed = require_identifier_list(
270 scope["specialist_review_needed"],
271 "intake.scope.specialist_review_needed",
272 )
274 errors: list[dict[str, str]] = []
275 warnings: list[dict[str, str]] = []
277 if not authorization_documented:
278 errors.append(issue("AUTHORIZATION_NOT_DOCUMENTED", "authorization.documented"))
279 if not local_authorized:
280 errors.append(
281 issue(
282 "LOCAL_PROCESSING_NOT_AUTHORIZED",
283 "authorization.local_processing_authorized",
284 )
285 )
286 if material_status == "unpublished_confidential" and authorization_basis in {
287 "public_material",
288 "synthetic_training",
289 }:
290 errors.append(
291 issue("AUTHORIZATION_BASIS_MISMATCH", "authorization.basis")
292 )
293 if material_status in {"public_preprint", "published"} and authorization_basis not in {
294 "public_material",
295 "author_request",
296 }:
297 warnings.append(
298 issue("PUBLIC_MATERIAL_BASIS_REVIEW", "authorization.basis")
299 )
300 if material_status == "synthetic" and authorization_basis != "synthetic_training":
301 warnings.append(
302 issue("SYNTHETIC_MATERIAL_BASIS_REVIEW", "authorization.basis")
303 )
305 if not human_accountable:
306 errors.append(
307 issue("HUMAN_ACCOUNTABILITY_REQUIRED", "reviewer.human_accountable")
308 )
309 if conflict_status == "not_assessed":
310 errors.append(issue("CONFLICTS_NOT_ASSESSED", "reviewer.conflict_status"))
311 elif conflict_status == "unresolved":
312 errors.append(issue("CONFLICT_UNRESOLVED", "reviewer.conflict_status"))
313 elif conflict_status == "none_identified" and conflicts:
314 errors.append(issue("CONFLICT_STATUS_MISMATCH", "reviewer.conflicts"))
315 elif conflict_status == "disclosed_to_editor":
316 errors.append(
317 issue("EDITOR_CONFLICT_CLEARANCE_REQUIRED", "reviewer.conflict_status")
318 )
320 if not venue_checked:
321 errors.append(issue("VENUE_POLICY_NOT_CHECKED", "venue_policy.checked"))
322 if peer_review_model == "unknown":
323 errors.append(
324 issue("PEER_REVIEW_MODEL_UNKNOWN", "venue_policy.peer_review_model")
325 )
326 if not confidential_notes_supported:
327 warnings.append(
328 issue(
329 "CONFIDENTIAL_NOTES_CHANNEL_UNAVAILABLE",
330 "venue_policy.confidential_editor_notes_supported",
331 )
332 )
334 if ai_plan == "approved_ai_assistance":
335 if ai_policy in {"prohibited", "not_stated"}:
336 errors.append(issue("AI_POLICY_DOES_NOT_PERMIT_USE", "ai_use.policy"))
337 if not ai_permission:
338 errors.append(
339 issue("AI_PERMISSION_NOT_CONFIRMED", "ai_use.permission_confirmed")
340 )
341 if not ai_disclosure:
342 errors.append(
343 issue("AI_DISCLOSURE_NOT_PLANNED", "ai_use.disclosure_planned")
344 )
345 elif ai_plan in {"none", "local_deterministic_tools"}:
346 if ai_permission or ai_disclosure:
347 warnings.append(issue("AI_FIELDS_REQUIRE_REVIEW", "ai_use"))
349 if not local_only:
350 errors.append(issue("LOCAL_ONLY_REQUIRED", "handling.local_only"))
351 if external_service_use:
352 errors.append(
353 issue("EXTERNAL_SERVICE_NOT_SUPPORTED", "handling.external_service_use")
354 )
355 if external_authorized and not external_service_use:
356 warnings.append(
357 issue(
358 "EXTERNAL_AUTHORIZATION_NOT_USED",
359 "authorization.external_processing_authorized",
360 )
361 )
362 if data_reuse:
363 errors.append(issue("DATA_REUSE_PROHIBITED", "handling.data_reuse_permitted"))
364 if not handling_record:
365 errors.append(
366 issue(
367 "DELETION_OR_RETENTION_RECORD_REQUIRED",
368 "handling.deletion_or_retention_record_planned",
369 )
370 )
371 if retention_rule == "public_material" and material_status not in {
372 "public_preprint",
373 "published",
374 "synthetic",
375 }:
376 errors.append(issue("RETENTION_RULE_MISMATCH", "handling.retention_rule"))
377 if sensitive:
378 warnings.append(
379 issue(
380 "MINIMUM_NECESSARY_HANDLING_REQUIRED",
381 "material.contains_personal_or_sensitive_data",
382 )
383 )
384 if competence_limits:
385 warnings.append(
386 issue("COMPETENCE_LIMITS_MUST_BE_DISCLOSED", "reviewer.competence_limits")
387 )
388 if specialist_review_needed:
389 warnings.append(
390 issue(
391 "SPECIALIST_REVIEW_NEEDED",
392 "scope.specialist_review_needed",
393 )
394 )
396 valid = not errors
397 return {
398 "schema_version": schema_version,
399 "review_id": review_id,
400 "valid": valid,
401 "status": "READY_FOR_LOCAL_REVIEW" if valid else "BLOCKED",
402 "errors": errors,
403 "warnings": warnings,
404 "normalized_scope": {
405 "capacity": capacity,
406 "competence_areas": competence_areas,
407 "manuscript_type": manuscript_type,
408 "requested_focus": requested_focus,
409 "out_of_scope": out_of_scope,
410 "peer_review_model": peer_review_model,
411 "ai_plan": ai_plan,
412 },
413 "handling_assertions": {
414 "bundled_tools_are_local_only": True,
415 "external_service_use_authorized_by_this_report": False,
416 "data_reuse_authorized": False,
417 "author_and_editor_channels_must_remain_separate": True,
418 },
419 "notice": (
420 "This validates declared process controls only. It does not establish "
421 "reviewer competence, resolve conflicts, authorize external processing, "
422 "or assess manuscript quality."
423 ),
424 }
427def build_parser() -> argparse.ArgumentParser:
428 parser = argparse.ArgumentParser(
429 description=(
430 "Validate a local peer-review intake JSON without reading or echoing "
431 "manuscript content."
432 )
433 )
434 parser.add_argument("intake", help="Local review intake JSON")
435 parser.add_argument("-o", "--output", help="Optional local JSON report")
436 parser.add_argument(
437 "--force", action="store_true", help="Replace an existing output file"
438 )
439 return parser
442def main() -> int:
443 args = build_parser().parse_args()
444 try:
445 report = validate_intake(read_json(args.intake))
446 write_json_report(report, args.output, force=args.force)
447 return 0 if report["valid"] else 1
448 except ValidationError as exc:
449 return error_exit(exc)
452if __name__ == "__main__":
453 raise SystemExit(main())