Skill Tracer
Skills › Scientific Agent Skills › scientific-writing › validate_authorship.py

validate_authorship.py

324 lines · skills/scientific-writing/scripts/validate_authorship.py at 5f2bc03

1"""Validate human authorship, CRediT roles, accountability, and AI disclosure."""
2
3from __future__ import annotations
4
5import argparse
6import re
7from typing import Any
8
9from _common import (
10 InputError,
11 Issue,
12 emit_report,
13 is_nonempty_string,
14 is_iso_date,
15 is_placeholder,
16 issue,
17 read_json,
18 require_list,
19 require_object,
20 run,
23TOOL = "validate_authorship"
24AUTHOR_ID_RE = re.compile(r"^A[0-9]{3,8}$")
25CONTRIBUTOR_ID_RE = re.compile(r"^K[0-9]{3,8}$")
26SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
27CREDIT_ROLES = {
28 "conceptualization",
29 "data curation",
30 "formal analysis",
31 "funding acquisition",
32 "investigation",
33 "methodology",
34 "project administration",
35 "resources",
36 "software",
37 "supervision",
38 "validation",
39 "visualization",
40 "writing – original draft",
41 "writing – review & editing",
43AUTHORSHIP_CRITERIA = {
44 "substantial_contribution",
45 "drafted_or_critically_revised",
46 "final_approval",
47 "accountable_for_work",
49DISCLOSURE_LOCATIONS = {"cover_letter", "acknowledgments", "methods", "other"}
50MATERIAL_CLASSES = {
51 "none",
52 "public_text",
53 "unpublished_manuscript",
54 "peer_review_material",
55 "source_documents",
56 "sensitive_data",
57 "phi",
58 "proprietary_content",
60RESTRICTED_MATERIAL_CLASSES = MATERIAL_CLASSES - {"none", "public_text"}
63def _validate_roles(
64 roles_value: Any,
65 *,
66 item_id: str,
67 issues: list[Issue],
68) -> None:
69 roles = require_list(roles_value, f"{item_id}.credit_roles")
70 if not roles:
71 issues.append(issue("error", "NO_CREDIT_ROLE", item_id=item_id))
72 normalized: set[str] = set()
73 for role in roles:
74 if not isinstance(role, str) or role.casefold() not in CREDIT_ROLES:
75 issues.append(issue("error", "INVALID_CREDIT_ROLE", item_id=item_id))
76 elif role.casefold() in normalized:
77 issues.append(issue("error", "DUPLICATE_CREDIT_ROLE", item_id=item_id))
78 else:
79 normalized.add(role.casefold())
82def validate_people(data: dict[str, Any]) -> tuple[list[Issue], set[str]]:
83 issues: list[Issue] = []
84 author_ids: set[str] = set()
85 for index, raw_author in enumerate(require_list(data.get("authors"), "authors")):
86 author = require_object(raw_author, f"authors[{index}]")
87 author_id = author.get("author_id")
88 if not isinstance(author_id, str) or not AUTHOR_ID_RE.fullmatch(author_id):
89 issues.append(
90 issue("error", "INVALID_AUTHOR_ID", location=f"authors[{index}]")
91 )
92 continue
93 if author_id in author_ids:
94 issues.append(issue("error", "DUPLICATE_AUTHOR_ID", item_id=author_id))
95 continue
96 author_ids.add(author_id)
97 if author.get("is_human") is not True:
98 issues.append(
99 issue("error", "NONHUMAN_AUTHOR_PROHIBITED", item_id=author_id)
100 )
101 if not is_nonempty_string(author.get("name")) or is_placeholder(
102 author.get("name")
103 ):
104 issues.append(issue("error", "MISSING_AUTHOR_NAME", item_id=author_id))
105 criteria = require_object(
106 author.get("authorship_criteria"),
107 f"{author_id}.authorship_criteria",
108 )
109 for criterion in AUTHORSHIP_CRITERIA:
110 if criteria.get(criterion) is not True:
111 issues.append(
112 issue(
113 "error",
114 "AUTHORSHIP_CRITERION_NOT_MET",
115 location=criterion,
116 item_id=author_id,
117 )
118 )
119 _validate_roles(author.get("credit_roles"), item_id=author_id, issues=issues)
121 if not author_ids:
122 issues.append(issue("error", "NO_HUMAN_AUTHORS", location="authors"))
124 contributor_ids: set[str] = set()
125 for index, raw_contributor in enumerate(
126 require_list(data.get("contributors", []), "contributors")
127 ):
128 contributor = require_object(raw_contributor, f"contributors[{index}]")
129 contributor_id = contributor.get("contributor_id")
130 if (
131 not isinstance(contributor_id, str)
132 or not CONTRIBUTOR_ID_RE.fullmatch(contributor_id)
133 or contributor_id in contributor_ids
134 ):
135 issues.append(
136 issue(
137 "error",
138 "INVALID_OR_DUPLICATE_CONTRIBUTOR_ID",
139 location=f"contributors[{index}]",
140 )
141 )
142 continue
143 contributor_ids.add(contributor_id)
144 if contributor.get("is_human") is not True:
145 issues.append(
146 issue("error", "NONHUMAN_CONTRIBUTOR_RECORD", item_id=contributor_id)
147 )
148 _validate_roles(
149 contributor.get("credit_roles"),
150 item_id=contributor_id,
151 issues=issues,
152 )
153 return issues, author_ids
156def validate_accountability(
157 data: dict[str, Any],
158 author_ids: set[str],
159) -> list[Issue]:
160 issues: list[Issue] = []
161 corresponding = data.get("corresponding_author_id")
162 if corresponding not in author_ids:
163 issues.append(issue("error", "INVALID_CORRESPONDING_AUTHOR"))
164 accountability = require_object(data.get("accountability"), "accountability")
165 if accountability.get("all_authors_approved") is not True:
166 issues.append(
167 issue("error", "FINAL_APPROVAL_INCOMPLETE", location="accountability")
168 )
169 guarantors = require_list(
170 accountability.get("guarantor_author_ids"), "guarantor_author_ids"
171 )
172 if not guarantors:
173 issues.append(
174 issue("error", "NO_ACCOUNTABILITY_GUARANTOR", location="accountability")
175 )
176 for guarantor in guarantors:
177 if guarantor not in author_ids:
178 issues.append(
179 issue("error", "INVALID_GUARANTOR_AUTHOR", item_id=str(guarantor))
180 )
181 return issues
184def validate_ai_disclosure(data: dict[str, Any]) -> tuple[list[Issue], int]:
185 issues: list[Issue] = []
186 ai_use = require_object(data.get("ai_use"), "ai_use")
187 used = ai_use.get("used")
188 if not isinstance(used, bool):
189 issues.append(issue("error", "AI_USED_NOT_BOOLEAN", location="ai_use"))
190 used = False
191 tools = require_list(ai_use.get("tools"), "ai_use.tools")
192 if used and not tools:
193 issues.append(issue("error", "AI_USE_WITHOUT_TOOL_RECORD", location="ai_use"))
194 if not used and tools:
195 issues.append(issue("error", "AI_TOOL_RECORDED_WHEN_UNUSED", location="ai_use"))
197 if ai_use.get("human_verification_complete") is not True:
198 issues.append(issue("error", "AI_OUTPUT_NOT_HUMAN_VERIFIED", location="ai_use"))
199 if ai_use.get("journal_policy_checked") is not True:
200 issues.append(
201 issue("error", "JOURNAL_AI_POLICY_NOT_CHECKED", location="ai_use")
202 )
203 locations = require_list(ai_use.get("disclosed_in"), "ai_use.disclosed_in")
204 if used and not locations:
205 issues.append(issue("error", "AI_USE_NOT_DISCLOSED", location="ai_use"))
206 for location in locations:
207 if location not in DISCLOSURE_LOCATIONS:
208 issues.append(
209 issue("error", "INVALID_AI_DISCLOSURE_LOCATION", location="ai_use")
210 )
212 for index, raw_tool in enumerate(tools):
213 tool = require_object(raw_tool, f"ai_use.tools[{index}]")
214 tool_id = f"tool:{index + 1}"
215 for key in ("name", "version", "provider", "purpose"):
216 if not is_nonempty_string(tool.get(key)) or is_placeholder(tool.get(key)):
217 issues.append(
218 issue(
219 "error",
220 "INCOMPLETE_AI_TOOL_RECORD",
221 location=key,
222 item_id=tool_id,
223 )
224 )
225 material_class = tool.get("materials_sent")
226 if material_class not in MATERIAL_CLASSES:
227 issues.append(issue("error", "INVALID_AI_MATERIAL_CLASS", item_id=tool_id))
228 if not isinstance(tool.get("external_service"), bool):
229 issues.append(
230 issue("error", "EXTERNAL_SERVICE_NOT_BOOLEAN", item_id=tool_id)
231 )
232 if (
233 tool.get("external_service") is True
234 and material_class in RESTRICTED_MATERIAL_CLASSES
235 ):
236 if tool.get("explicit_authorization") is not True:
237 issues.append(
238 issue(
239 "error",
240 "RESTRICTED_EXTERNAL_TRANSFER_UNAUTHORIZED",
241 item_id=tool_id,
242 )
243 )
244 if tool.get("policy_reviewed") is not True:
245 issues.append(
246 issue(
247 "error",
248 "RESTRICTED_EXTERNAL_TRANSFER_POLICY_UNREVIEWED",
249 item_id=tool_id,
250 )
251 )
252 return issues, len(tools)
255def validate_declarations(data: dict[str, Any]) -> list[Issue]:
256 issues: list[Issue] = []
257 declarations = require_object(data.get("declarations"), "declarations")
258 for declaration_id in (
259 "ai_use",
260 "author_contributions",
261 "conflicts",
262 "funding",
263 ):
264 declaration = require_object(
265 declarations.get(declaration_id),
266 f"declarations.{declaration_id}",
267 )
268 if declaration.get("status") not in {"verified", "not_applicable"}:
269 issues.append(
270 issue("error", "DECLARATION_NOT_VERIFIED", item_id=declaration_id)
271 )
272 if not SHA256_RE.fullmatch(str(declaration.get("content_sha256", ""))):
273 issues.append(
274 issue("error", "INVALID_DECLARATION_HASH", item_id=declaration_id)
275 )
276 if not is_nonempty_string(declaration.get("verified_by")) or is_placeholder(
277 declaration.get("verified_by")
278 ):
279 issues.append(
280 issue("error", "DECLARATION_VERIFIER_MISSING", item_id=declaration_id)
281 )
282 if not is_iso_date(declaration.get("verified_on")):
283 issues.append(
284 issue("error", "INVALID_DECLARATION_DATE", item_id=declaration_id)
285 )
286 return issues
289def build_parser() -> argparse.ArgumentParser:
290 parser = argparse.ArgumentParser(
291 description=(
292 "Validate a local authorship JSON record against human accountability, "
293 "CRediT role names, AI disclosure, and restricted-material transfer gates."
294 )
295 )
296 parser.add_argument(
297 "authorship", help="UTF-8 JSON authorship and disclosure record"
298 )
299 return parser
302def cli() -> int:
303 args = build_parser().parse_args()
304 data = require_object(read_json(args.authorship), "authorship")
305 if data.get("schema_version") != "1.0":
306 raise InputError("unsupported authorship schema version")
307 issues, author_ids = validate_people(data)
308 issues.extend(validate_accountability(data, author_ids))
309 ai_issues, tool_count = validate_ai_disclosure(data)
310 issues.extend(ai_issues)
311 issues.extend(validate_declarations(data))
312 return emit_report(
313 TOOL,
314 issues,
315 summary={
316 "authors": len(author_ids),
317 "ai_tools": tool_count,
318 "credit_taxonomy": "ANSI/NISO Z39.104-2022",
319 },
320 )
323if __name__ == "__main__":
324 run(TOOL, cli)