paperpush 0.1.0__py3-none-any.whl

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 (93) hide show
  1. paperpush/__init__.py +47 -0
  2. paperpush/_logging.py +84 -0
  3. paperpush/autofill.py +533 -0
  4. paperpush/cli.py +1031 -0
  5. paperpush/credentials.py +312 -0
  6. paperpush/database.py +566 -0
  7. paperpush/manuscript.py +408 -0
  8. paperpush/orcid.py +442 -0
  9. paperpush/schema_models.py +206 -0
  10. paperpush/sensitive.py +785 -0
  11. paperpush/subfile.py +312 -0
  12. paperpush/subfile_templates/arxiv.sub +80 -0
  13. paperpush/subfile_templates/bioinformatics.sub +211 -0
  14. paperpush/subfile_templates/biorxiv.sub +100 -0
  15. paperpush/subfile_templates/bmc_bioinformatics.sub +75 -0
  16. paperpush/subfile_templates/cell.sub +89 -0
  17. paperpush/subfile_templates/cell_genomics.sub +84 -0
  18. paperpush/subfile_templates/cell_systems.sub +89 -0
  19. paperpush/subfile_templates/discrete_mathematics.sub +92 -0
  20. paperpush/subfile_templates/genome_biology.sub +75 -0
  21. paperpush/subfile_templates/medrxiv.sub +115 -0
  22. paperpush/subfile_templates/nature.sub +114 -0
  23. paperpush/subfile_templates/nature_biotech.sub +114 -0
  24. paperpush/subfile_templates/nature_methods.sub +108 -0
  25. paperpush/subfile_templates/nucleic_acids_research.sub +146 -0
  26. paperpush/subfile_templates/plos_compbio.sub +137 -0
  27. paperpush/subfile_templates/science.sub +92 -0
  28. paperpush/subfile_templates/science_advances.sub +92 -0
  29. paperpush/subfile_templates/science_immunology.sub +92 -0
  30. paperpush/subfile_templates/science_robotics.sub +92 -0
  31. paperpush/subfile_templates/science_signaling.sub +92 -0
  32. paperpush/subfile_templates/science_translational_medicine.sub +92 -0
  33. paperpush/validate.py +971 -0
  34. paperpush/venues/__init__.py +233 -0
  35. paperpush/venues/_assets/aaai_2027_countries.txt +196 -0
  36. paperpush/venues/_assets/aaai_2027_topics.txt +203 -0
  37. paperpush/venues/_assets/arxiv_categories.txt +155 -0
  38. paperpush/venues/_assets/bioinformatics_keywords.txt +192 -0
  39. paperpush/venues/_assets/genome_biology_institution_countries.txt +249 -0
  40. paperpush/venues/_assets/nature_biotech_categories.json +18659 -0
  41. paperpush/venues/_assets/nature_categories.json +22740 -0
  42. paperpush/venues/_assets/nature_methods_categories.json +22740 -0
  43. paperpush/venues/_assets/plos_compbio_classifications.txt +257 -0
  44. paperpush/venues/_assets/plos_compbio_funding_countries.txt +197 -0
  45. paperpush/venues/_assets/science_advances_subjects.txt +82 -0
  46. paperpush/venues/_assets/science_immunology_subjects.txt +51 -0
  47. paperpush/venues/_assets/science_robotics_subjects.txt +50 -0
  48. paperpush/venues/_assets/science_signaling_subjects.txt +116 -0
  49. paperpush/venues/_assets/science_subjects.txt +58 -0
  50. paperpush/venues/_assets/science_translational_medicine_subjects.txt +62 -0
  51. paperpush/venues/arxiv/__init__.py +0 -0
  52. paperpush/venues/arxiv/arxiv.py +166 -0
  53. paperpush/venues/base.py +273 -0
  54. paperpush/venues/common.py +249 -0
  55. paperpush/venues/discrete_mathematics/__init__.py +0 -0
  56. paperpush/venues/discrete_mathematics/discrete_mathematics.py +438 -0
  57. paperpush/venues/editorialmanager/__init__.py +0 -0
  58. paperpush/venues/editorialmanager/cell.py +22 -0
  59. paperpush/venues/editorialmanager/cell_genomics.py +22 -0
  60. paperpush/venues/editorialmanager/cell_systems.py +22 -0
  61. paperpush/venues/editorialmanager/main.py +1119 -0
  62. paperpush/venues/editorialmanager/plos_compbio.py +22 -0
  63. paperpush/venues/login.py +202 -0
  64. paperpush/venues/nature/__init__.py +0 -0
  65. paperpush/venues/nature/main.py +1413 -0
  66. paperpush/venues/nature/nature.py +39 -0
  67. paperpush/venues/nature/nature_biotech.py +39 -0
  68. paperpush/venues/nature/nature_methods.py +40 -0
  69. paperpush/venues/openreview/__init__.py +6 -0
  70. paperpush/venues/openreview/aaai_2027.py +417 -0
  71. paperpush/venues/openrxiv/__init__.py +0 -0
  72. paperpush/venues/openrxiv/biorxiv.py +28 -0
  73. paperpush/venues/openrxiv/main.py +891 -0
  74. paperpush/venues/openrxiv/medrxiv.py +32 -0
  75. paperpush/venues/scholarone/__init__.py +0 -0
  76. paperpush/venues/scholarone/bioinformatics.py +357 -0
  77. paperpush/venues/scholarone/main.py +372 -0
  78. paperpush/venues/scholarone/nucleic_acids_research.py +515 -0
  79. paperpush/venues/science/__init__.py +0 -0
  80. paperpush/venues/science/science.py +794 -0
  81. paperpush/venues/snapp/__init__.py +0 -0
  82. paperpush/venues/snapp/bmc_bioinformatics.py +29 -0
  83. paperpush/venues/snapp/genome_biology.py +29 -0
  84. paperpush/venues/snapp/main.py +683 -0
  85. paperpush/venues/template/template.py +73 -0
  86. paperpush/venues.json +3082 -0
  87. paperpush/venues.schema.json +856 -0
  88. paperpush-0.1.0.dist-info/METADATA +113 -0
  89. paperpush-0.1.0.dist-info/RECORD +93 -0
  90. paperpush-0.1.0.dist-info/WHEEL +5 -0
  91. paperpush-0.1.0.dist-info/entry_points.txt +2 -0
  92. paperpush-0.1.0.dist-info/licenses/LICENSE +24 -0
  93. paperpush-0.1.0.dist-info/top_level.txt +1 -0
paperpush/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """paperpush: one-click manuscript submission to academic venues."""
2
+
3
+ import logging
4
+
5
+ __version__ = "0.1.0"
6
+ __url__ = "https://github.com/pachterlab/paperpush"
7
+
8
+ # Standard-library convention: a library should not configure logging output.
9
+ # The NullHandler keeps importing paperpush silent until the embedding
10
+ # application (or our own CLI, via paperpush._logging.configure_logging)
11
+ # installs a real handler.
12
+ #
13
+ # Import stdlib logging under its own name -- NOT ``as _logging`` -- so it does
14
+ # not shadow the ``paperpush._logging`` submodule as a package attribute, which
15
+ # would make ``from paperpush import _logging`` hand back the stdlib module.
16
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
17
+
18
+ # Public API. Kept deliberately small: the two data models (with their
19
+ # accessors), the subfile toolkit, and the two workflow operations that have a
20
+ # library-level entry point. The login and submit steps are exposed only as CLI
21
+ # commands (`paperpush login` / `paperpush submit`), not as importable
22
+ # functions. Lower-level helpers (autofill extraction internals, credential
23
+ # storage, logging setup) remain importable from their submodules
24
+ # (e.g. `from paperpush import credentials`) but are not part of the public API.
25
+ from .database import Field, Venue, get_venue, list_venues
26
+ from .subfile import SubFile, load, parse, render_template, write_template
27
+ from .autofill import autofill
28
+ from .validate import validate
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "__url__",
33
+ # Data models
34
+ "Field",
35
+ "Venue",
36
+ "get_venue",
37
+ "list_venues",
38
+ # Submission files
39
+ "SubFile",
40
+ "load",
41
+ "parse",
42
+ "render_template",
43
+ "write_template",
44
+ # Workflow operations
45
+ "autofill",
46
+ "validate",
47
+ ]
paperpush/_logging.py ADDED
@@ -0,0 +1,84 @@
1
+ """Logging configuration for the paperpush command-line interface.
2
+
3
+ Library modules obtain a logger with ``logging.getLogger(__name__)`` and never
4
+ install handlers themselves. Following the standard-library convention, the
5
+ package's top-level logger carries a :class:`logging.NullHandler` (attached in
6
+ :mod:`paperpush.__init__`), so importing paperpush as a library stays
7
+ silent unless the embedding application configures logging.
8
+
9
+ The command-line entry point calls :func:`configure_logging` once to attach a
10
+ single stderr handler whose level reflects the ``-v``/``-q`` flags, or the
11
+ ``PAPERPUSH_LOG_LEVEL`` environment variable when set. Diagnostics go to
12
+ stderr so they never mix with command output on stdout.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import sys
20
+
21
+ PACKAGE_LOGGER = "paperpush"
22
+
23
+ # Each additional ``-v`` raises the verbosity one step from the WARNING default:
24
+ # -v -> INFO, -vv -> DEBUG.
25
+ _VERBOSITY_LEVELS = [logging.WARNING, logging.INFO, logging.DEBUG]
26
+
27
+ _LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
28
+ _DATE_FORMAT = "%H:%M:%S"
29
+
30
+ # Marks the handler this module installs so a later call can replace it rather
31
+ # than stack a second one.
32
+ _HANDLER_FLAG = "_paperpush_cli_handler"
33
+
34
+
35
+ def _level_from_env() -> int | None:
36
+ """Return the level named by ``PAPERPUSH_LOG_LEVEL``, or None if unset/bad."""
37
+ name = os.environ.get("PAPERPUSH_LOG_LEVEL")
38
+ if not name:
39
+ return None
40
+ resolved = logging.getLevelName(name.strip().upper())
41
+ # getLevelName returns the int for a known name, or a "Level X" string for
42
+ # an unknown one; only accept a real level.
43
+ return resolved if isinstance(resolved, int) else None
44
+
45
+
46
+ def resolve_level(verbosity: int = 0, quiet: bool = False) -> int:
47
+ """Pick a logging level from the CLI flags and the environment.
48
+
49
+ ``PAPERPUSH_LOG_LEVEL`` (e.g. ``DEBUG``) wins when set to a valid level;
50
+ otherwise ``quiet`` forces ``ERROR`` and each ``-v`` raises the verbosity one
51
+ step from the ``WARNING`` default.
52
+ """
53
+ env_level = _level_from_env()
54
+ if env_level is not None:
55
+ return env_level
56
+ if quiet:
57
+ return logging.ERROR
58
+ index = min(max(verbosity, 0), len(_VERBOSITY_LEVELS) - 1)
59
+ return _VERBOSITY_LEVELS[index]
60
+
61
+
62
+ def configure_logging(verbosity: int = 0, quiet: bool = False) -> logging.Logger:
63
+ """Attach a single stderr handler to the package logger and set its level.
64
+
65
+ Safe to call more than once: a handler installed by an earlier call is
66
+ replaced rather than stacked, so repeated CLI invocations within one process
67
+ (notably the test suite) do not emit duplicate lines.
68
+ """
69
+ level = resolve_level(verbosity, quiet)
70
+ logger = logging.getLogger(PACKAGE_LOGGER)
71
+
72
+ for handler in list(logger.handlers):
73
+ if getattr(handler, _HANDLER_FLAG, False):
74
+ logger.removeHandler(handler)
75
+
76
+ handler = logging.StreamHandler(sys.stderr)
77
+ handler.setFormatter(logging.Formatter(_LOG_FORMAT, _DATE_FORMAT))
78
+ setattr(handler, _HANDLER_FLAG, True)
79
+ logger.addHandler(handler)
80
+ logger.setLevel(level)
81
+ # Diagnostics flow only through our handler, not whatever the root logger has.
82
+ logger.propagate = False
83
+ logger.debug("Logging configured at level %s", logging.getLevelName(level))
84
+ return logger
paperpush/autofill.py ADDED
@@ -0,0 +1,533 @@
1
+ """Populate a ``.sub`` file from values extracted from a manuscript directory.
2
+
3
+ This module is the deterministic core shared by both autofill front-ends: the
4
+ ``paperpush autofill`` command (which extracts values with the Anthropic
5
+ API) and the Claude Code skill (where Claude reads the files and supplies the
6
+ values). Neither front-end writes the ``.sub`` itself; both hand a set of
7
+ proposed ``{field id -> value}`` extractions to :func:`autofill`, which decides
8
+ what may be written and writes it surgically through the same
9
+ :mod:`paperpush.subfile` helpers a hand edit would use.
10
+
11
+ Two gates protect the file from a careless or over-reaching extractor:
12
+
13
+ * **Role gate.** Every field has an autofill *role* (``extract``, ``classify``,
14
+ ``filemap``, or ``never``; see :func:`effective_role`). A ``never`` field --
15
+ licenses, consent attestations, workflow flags -- is left at its template
16
+ default and reported, never written. This is the single most important
17
+ safety rule and it lives here in Python, not in any prompt.
18
+ * **Confidence gate.** Each proposal carries a confidence (``high`` / ``medium``
19
+ / ``low``). Anything below ``min_confidence`` is skipped. A written value that
20
+ is a ``classify`` field, or is below ``high`` confidence, is flagged for the
21
+ author to review rather than presented as settled.
22
+
23
+ Every written field is run back through :func:`paperpush.validate.validate`
24
+ so an autofilled file carries the same guarantees as one filled in by hand.
25
+
26
+ The API extraction engine (``paperpush autofill --engine api``) lives in the
27
+ "API extraction engine" section at the bottom of this module: it reads the
28
+ manuscript files and asks the Anthropic API to propose values, returning the same
29
+ :class:`Extraction` the manual engine produces so both flow through the identical
30
+ gates above. ``anthropic`` is an optional dependency, imported lazily, so the
31
+ deterministic core works without it.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import json
38
+ import logging
39
+ import os
40
+ from dataclasses import dataclass
41
+ from dataclasses import field as _dc_field
42
+ from pathlib import Path
43
+
44
+ from .database import Field, Venue
45
+ from .subfile import _MULTILINE_TYPES, parse, replace_block, replace_scalar
46
+ from .validate import Issue, validate
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Autofill roles. A field's role is read from its ``autofill`` attribute, or
51
+ # inferred from its ``type`` when that is blank (see :func:`effective_role`).
52
+ EXTRACT = "extract"
53
+ CLASSIFY = "classify"
54
+ FILEMAP = "filemap"
55
+ NEVER = "never"
56
+ _ROLES = {EXTRACT, CLASSIFY, FILEMAP, NEVER}
57
+
58
+ # Fallback role per field type when a field declares no explicit ``autofill``.
59
+ _ROLE_BY_TYPE = {
60
+ "file": FILEMAP,
61
+ "filelist": FILEMAP,
62
+ "choice": CLASSIFY,
63
+ "multichoice": CLASSIFY,
64
+ "boolean": NEVER,
65
+ }
66
+
67
+ # Confidence levels, ordered low -> high.
68
+ _CONFIDENCE_ORDER = {"low": 0, "medium": 1, "high": 2}
69
+ DEFAULT_CONFIDENCE = "medium"
70
+
71
+ # What happened to each field, for the summary the front-ends print.
72
+ FILLED = "filled" # written, high confidence, not a judgment call
73
+ REVIEW = "review" # written but the author should confirm it
74
+ SKIPPED_POLICY = "skipped_policy" # a ``never`` field; left at its default
75
+ SKIPPED_LOW = "skipped_low" # below the confidence threshold; not written
76
+ SKIPPED_EMPTY = "skipped_empty" # proposal had no value to write
77
+ UNKNOWN = "unknown" # not a field in this venue's template
78
+
79
+
80
+ def effective_role(field: Field) -> str:
81
+ """Return the autofill role for ``field``.
82
+
83
+ Uses the field's explicit ``autofill`` value when set and recognized,
84
+ otherwise infers a role from its ``type`` (files map, choices classify,
85
+ booleans are never touched, everything else is extracted).
86
+ """
87
+ if field.autofill in _ROLES:
88
+ return field.autofill
89
+ if field.autofill:
90
+ logger.warning("Field %r has unknown autofill role %r; inferring from " "type %r", field.id, field.autofill, field.type)
91
+ return _ROLE_BY_TYPE.get(field.type, EXTRACT)
92
+
93
+
94
+ def field_schema(venue: Venue) -> list[dict]:
95
+ """Return each field's id, role, and constraints as plain dicts.
96
+
97
+ This is the authoritative description an extractor (the API engine or the
98
+ Claude skill) needs to know what to fill and how: which fields to extract
99
+ versus classify versus map to files versus leave alone, plus the closed
100
+ option sets and accepted file types. Built straight from the venue
101
+ definition so the roles never drift from ``venues.json``.
102
+ """
103
+ out: list[dict] = []
104
+ for f in venue.fields:
105
+ out.append(
106
+ {
107
+ "id": f.id,
108
+ "label": f.label,
109
+ "type": f.type,
110
+ "role": effective_role(f),
111
+ "required": f.required,
112
+ "help": f.help,
113
+ "options": f.options,
114
+ "type_options": f.type_options,
115
+ "accept": f.accept,
116
+ "min_count": f.min_count,
117
+ "max_count": f.max_count,
118
+ "word_count": f.word_count,
119
+ "character_count": f.character_count,
120
+ }
121
+ )
122
+ return out
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Proposal:
127
+ """One extracted value an engine proposes for a field."""
128
+
129
+ id: str
130
+ value: str
131
+ confidence: str = DEFAULT_CONFIDENCE
132
+ source: str = ""
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class Extraction:
137
+ """The full set of proposals an engine produced for a manuscript."""
138
+
139
+ fields: list[Proposal] = _dc_field(default_factory=list)
140
+ # Fields the engine deliberately left for the author, with a reason.
141
+ unfilled: list[tuple[str, str]] = _dc_field(default_factory=list)
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class Outcome:
146
+ """What :func:`autofill` decided to do with one field."""
147
+
148
+ id: str
149
+ label: str
150
+ action: str # one of FILLED / REVIEW / SKIPPED_* / UNKNOWN
151
+ value: str = ""
152
+ confidence: str = ""
153
+ source: str = ""
154
+ note: str = ""
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class AutofillResult:
159
+ """The rewritten ``.sub`` text plus a record of every decision."""
160
+
161
+ text: str
162
+ outcomes: list[Outcome]
163
+ issues: list[Issue]
164
+
165
+ def _by_action(self, *actions: str) -> list[Outcome]:
166
+ wanted = set(actions)
167
+ return [o for o in self.outcomes if o.action in wanted]
168
+
169
+ @property
170
+ def filled(self) -> list[Outcome]:
171
+ return self._by_action(FILLED)
172
+
173
+ @property
174
+ def review(self) -> list[Outcome]:
175
+ return self._by_action(REVIEW)
176
+
177
+ @property
178
+ def skipped(self) -> list[Outcome]:
179
+ return self._by_action(SKIPPED_POLICY, SKIPPED_LOW, SKIPPED_EMPTY, UNKNOWN)
180
+
181
+ @property
182
+ def errors(self) -> list[Issue]:
183
+ return [i for i in self.issues if i.is_error]
184
+
185
+ @property
186
+ def warnings(self) -> list[Issue]:
187
+ return [i for i in self.issues if not i.is_error]
188
+
189
+
190
+ def parse_extraction(data: dict) -> Extraction:
191
+ """Build an :class:`Extraction` from the shared JSON schema.
192
+
193
+ The schema is::
194
+
195
+ {"fields": [{"id", "value", "confidence"?, "source"?}, ...],
196
+ "unfilled": [{"id", "reason"?}, ...]}
197
+
198
+ Both front-ends emit this shape, so the same loader serves the API engine
199
+ and the skill's ``--values`` file.
200
+ """
201
+ proposals: list[Proposal] = []
202
+ for item in data.get("fields", []) or []:
203
+ proposals.append(
204
+ Proposal(
205
+ id=str(item["id"]),
206
+ value="" if item.get("value") is None else str(item.get("value")),
207
+ confidence=str(item.get("confidence") or DEFAULT_CONFIDENCE).lower(),
208
+ source=str(item.get("source") or ""),
209
+ )
210
+ )
211
+ unfilled = [(str(u["id"]), str(u.get("reason") or "")) for u in (data.get("unfilled", []) or [])]
212
+ return Extraction(fields=proposals, unfilled=unfilled)
213
+
214
+
215
+ def _resolve_one_path(path_str: str, manuscript_dir: Path | None) -> str:
216
+ """Make a single file path resolvable from the current directory.
217
+
218
+ If the path as given does not exist but the same name does inside
219
+ ``manuscript_dir``, rewrite it to that location so the file checks in
220
+ :mod:`paperpush.validate` (which resolves paths against the working
221
+ directory) find it. Otherwise the value is left untouched for validation
222
+ to flag.
223
+ """
224
+ path_str = path_str.strip()
225
+ if not path_str or manuscript_dir is None:
226
+ return path_str
227
+ if Path(path_str).expanduser().exists():
228
+ return path_str
229
+ candidate = manuscript_dir / path_str
230
+ if candidate.exists():
231
+ return str(candidate)
232
+ # Also try just the basename, in case the engine quoted a longer path.
233
+ candidate = manuscript_dir / Path(path_str).name
234
+ if candidate.exists():
235
+ return str(candidate)
236
+ return path_str
237
+
238
+
239
+ def _resolve_filemap(field: Field, value: str, manuscript_dir: Path | None) -> str:
240
+ """Resolve the file path(s) in a ``filemap`` field's value.
241
+
242
+ For a ``file`` field the whole value is a path. For a ``filelist`` each
243
+ non-blank line is ``path | extra | ...``; only the leading path segment is
244
+ resolved, the remaining ``| ...`` columns (label, type, link text) are
245
+ preserved as written.
246
+ """
247
+ if field.type == "file":
248
+ return _resolve_one_path(value, manuscript_dir)
249
+
250
+ out_lines: list[str] = []
251
+ for line in value.splitlines():
252
+ if not line.strip():
253
+ continue
254
+ parts = [p.strip() for p in line.split("|")]
255
+ parts[0] = _resolve_one_path(parts[0], manuscript_dir)
256
+ out_lines.append(" | ".join(parts) if len(parts) > 1 else parts[0])
257
+ return "\n".join(out_lines)
258
+
259
+
260
+ def _write_field(text: str, field: Field, value: str) -> str:
261
+ """Write ``value`` into ``field`` using the right surgical helper."""
262
+ if field.type in _MULTILINE_TYPES:
263
+ return replace_block(text, field.id, value)
264
+ return replace_scalar(text, field.id, value)
265
+
266
+
267
+ def autofill(
268
+ text: str,
269
+ venue: Venue,
270
+ extraction: Extraction,
271
+ manuscript_dir: str | Path | None = None,
272
+ min_confidence: str = "low",
273
+ ) -> AutofillResult:
274
+ """Apply ``extraction`` to the ``.sub`` ``text`` for ``venue``.
275
+
276
+ Returns an :class:`AutofillResult` holding the rewritten text and an
277
+ :class:`Outcome` for every proposed field. The original ``text`` is never
278
+ mutated; fields that are skipped keep whatever value they already carried
279
+ (typically the template default).
280
+
281
+ ``min_confidence`` is the floor below which a proposal is not written at all
282
+ (``"low"`` writes everything proposed; ``"high"`` writes only sure things).
283
+ ``manuscript_dir`` is used to resolve relative file paths in ``filemap``
284
+ fields against the directory the files actually live in.
285
+ """
286
+ by_id = {f.id: f for f in venue.fields}
287
+ floor = _CONFIDENCE_ORDER.get(min_confidence.lower(), 0)
288
+ mdir = Path(manuscript_dir).expanduser() if manuscript_dir is not None else None
289
+
290
+ result = text
291
+ outcomes: list[Outcome] = []
292
+
293
+ for prop in extraction.fields:
294
+ field = by_id.get(prop.id)
295
+ if field is None:
296
+ outcomes.append(Outcome(prop.id, prop.id, UNKNOWN, value=prop.value, confidence=prop.confidence, source=prop.source, note=f"not a field in the {venue.slug} template"))
297
+ continue
298
+
299
+ role = effective_role(field)
300
+ if role == NEVER:
301
+ outcomes.append(Outcome(field.id, field.label, SKIPPED_POLICY, source=prop.source, note="policy/consent field -- left at its default for you to set"))
302
+ continue
303
+
304
+ confidence = prop.confidence if prop.confidence in _CONFIDENCE_ORDER else DEFAULT_CONFIDENCE
305
+
306
+ value = prop.value
307
+ if role == FILEMAP:
308
+ value = _resolve_filemap(field, value, mdir)
309
+
310
+ if not value.strip():
311
+ outcomes.append(Outcome(field.id, field.label, SKIPPED_EMPTY, confidence=confidence, source=prop.source, note="no value to write"))
312
+ continue
313
+
314
+ if _CONFIDENCE_ORDER[confidence] < floor:
315
+ outcomes.append(Outcome(field.id, field.label, SKIPPED_LOW, value=value, confidence=confidence, source=prop.source, note=f"{confidence} confidence is below the {min_confidence} " "threshold"))
316
+ continue
317
+
318
+ result = _write_field(result, field, value)
319
+
320
+ needs_review = role == CLASSIFY or confidence != "high"
321
+ if role == CLASSIFY:
322
+ note = "classified from the manuscript -- confirm it is correct"
323
+ elif confidence != "high":
324
+ note = f"{confidence} confidence -- please verify"
325
+ else:
326
+ note = ""
327
+ outcomes.append(Outcome(field.id, field.label, REVIEW if needs_review else FILLED, value=value, confidence=confidence, source=prop.source, note=note))
328
+
329
+ issues = validate(parse(result), venue)
330
+ logger.info("autofill %s: %d filled, %d to review, %d skipped, %d " "validation issue(s)", venue.slug, sum(1 for o in outcomes if o.action == FILLED), sum(1 for o in outcomes if o.action == REVIEW), sum(1 for o in outcomes if o.action.startswith("skipped") or o.action == UNKNOWN), len(issues))
331
+ return AutofillResult(text=result, outcomes=outcomes, issues=issues)
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # API extraction engine (``paperpush autofill --engine api``)
336
+ #
337
+ # The second autofill front-end (the Claude Code skill is the first): it reads
338
+ # the manuscript files and asks the Anthropic API to propose field values,
339
+ # returning the same ``Extraction`` the manual engine produces so both flow
340
+ # through the deterministic gates above. ``anthropic`` is an optional dependency
341
+ # (``pip install paperpush[autofill]``), imported lazily below.
342
+ # ---------------------------------------------------------------------------
343
+
344
+
345
+ DEFAULT_MODEL = "claude-opus-4-8"
346
+
347
+
348
+ class AutofillApiError(RuntimeError):
349
+ """A problem preparing or running the API extraction (not a model refusal)."""
350
+
351
+
352
+ @dataclass(frozen=True)
353
+ class DocumentInput:
354
+ """One manuscript document to show the model, with a role label."""
355
+
356
+ label: str # e.g. "manuscript", "title_page", "supplement"
357
+ path: Path
358
+
359
+
360
+ def _docx_text(path: Path) -> str:
361
+ """Extract visible paragraph text from a ``.docx``, raising on failure.
362
+
363
+ Delegates to the standard-library extractor in :mod:`paperpush.manuscript`
364
+ (the single implementation, which also handles tabs and XML entities) and
365
+ turns its ``None`` failure into an :class:`AutofillApiError` so the API engine
366
+ surfaces an unreadable document instead of sending the model empty text.
367
+ """
368
+ from .manuscript import docx_to_text
369
+
370
+ text = docx_to_text(path)
371
+ if text is None:
372
+ raise AutofillApiError(f"could not read {path.name}")
373
+ return text.strip()
374
+
375
+
376
+ def _read_text(path: Path) -> str:
377
+ if path.suffix.lower() == ".docx":
378
+ return _docx_text(path)
379
+ try:
380
+ return path.read_text(encoding="utf-8", errors="replace").strip()
381
+ except OSError as exc:
382
+ raise AutofillApiError(f"could not read {path.name}: {exc}") from exc
383
+
384
+
385
+ def _document_blocks(documents: list[DocumentInput]) -> list[dict]:
386
+ """Build the user-content blocks for the manuscript documents.
387
+
388
+ PDFs become base64 ``document`` blocks; other formats are extracted to text
389
+ and wrapped in a labelled text block.
390
+ """
391
+ blocks: list[dict] = []
392
+ for doc in documents:
393
+ if not doc.path.is_file():
394
+ raise AutofillApiError(f"{doc.label} file not found: {doc.path}")
395
+ if doc.path.suffix.lower() == ".pdf":
396
+ data = base64.standard_b64encode(doc.path.read_bytes()).decode("ascii")
397
+ blocks.append(
398
+ {
399
+ "type": "document",
400
+ "title": f"{doc.label}: {doc.path.name}",
401
+ "source": {
402
+ "type": "base64",
403
+ "media_type": "application/pdf",
404
+ "data": data,
405
+ },
406
+ }
407
+ )
408
+ else:
409
+ text = _read_text(doc.path)
410
+ blocks.append(
411
+ {
412
+ "type": "text",
413
+ "text": f"=== {doc.label} ({doc.path.name}) ===\n{text}",
414
+ }
415
+ )
416
+ return blocks
417
+
418
+
419
+ def _extraction_schema(field_ids: list[str]) -> dict:
420
+ """JSON schema forcing the model's output into the shared extraction shape."""
421
+ return {
422
+ "type": "object",
423
+ "properties": {
424
+ "fields": {
425
+ "type": "array",
426
+ "items": {
427
+ "type": "object",
428
+ "properties": {
429
+ "id": {"type": "string", "enum": field_ids},
430
+ "value": {"type": "string"},
431
+ "confidence": {"type": "string", "enum": ["high", "medium", "low"]},
432
+ "source": {"type": "string"},
433
+ },
434
+ "required": ["id", "value", "confidence", "source"],
435
+ "additionalProperties": False,
436
+ },
437
+ },
438
+ "unfilled": {
439
+ "type": "array",
440
+ "items": {
441
+ "type": "object",
442
+ "properties": {
443
+ "id": {"type": "string", "enum": field_ids},
444
+ "reason": {"type": "string"},
445
+ },
446
+ "required": ["id", "reason"],
447
+ "additionalProperties": False,
448
+ },
449
+ },
450
+ },
451
+ "required": ["fields", "unfilled"],
452
+ "additionalProperties": False,
453
+ }
454
+
455
+
456
+ def _field_brief(venue: Venue) -> tuple[list[dict], list[str]]:
457
+ """The requested (non-``never``) fields as a brief, plus their ids."""
458
+ brief: list[dict] = []
459
+ for f in field_schema(venue):
460
+ if f["role"] == NEVER:
461
+ continue
462
+ entry = {k: f[k] for k in ("id", "label", "type", "role", "required", "help")}
463
+ if f["options"]:
464
+ entry["options"] = f["options"]
465
+ if f["type_options"]:
466
+ entry["file_types"] = f["type_options"]
467
+ if f["accept"]:
468
+ entry["accepts"] = f["accept"]
469
+ for key in ("min_count", "max_count", "word_count", "character_count"):
470
+ if f.get(key) is not None:
471
+ entry[key] = f[key]
472
+ brief.append(entry)
473
+ return brief, [e["id"] for e in brief]
474
+
475
+
476
+ _SYSTEM = "You prepare academic venue submissions. Read the attached manuscript " "documents and propose values for the listed submission fields. Rules: " "(1) Extract values that appear in the text verbatim where possible. " "(2) For a 'classify' field, choose exactly one of its options. " "(3) For a 'filemap' field, assign one or more file paths from the directory " "listing, given relative to the manuscript directory; one per line, using " "the column format described in the field's help. " "(4) Never invent emails, ORCID iDs, DOIs, funders, or licenses that are not " "present in the documents -- leave a subfield blank instead. " "(5) Set confidence honestly: 'high' only for verbatim copies or unambiguous " "file matches, 'medium' for inference or classification, 'low' for guesses. " "(6) Put any field you cannot fill in 'unfilled' with a brief reason. " "Authors use the format 'Name | email | affiliation | ORCID | corresponding' " "with exactly one corresponding author marked 'yes'."
477
+
478
+
479
+ def _build_prompt(venue: Venue, documents: list[DocumentInput], file_listing: list[str]) -> tuple[str, list[dict], dict, list[str]]:
480
+ brief, field_ids = _field_brief(venue)
481
+ content = _document_blocks(documents)
482
+ instructions = f"Target venue: {venue.full_name or venue.name} " f"(slug: {venue.slug}).\n\n" "Files available in the manuscript directory (use these relative paths " "for 'filemap' fields):\n" + "\n".join(f" {p}" for p in file_listing) + "\n\nFields to fill (JSON):\n" + json.dumps(brief, indent=2) + "\n\nReturn the extraction now."
483
+ content.append({"type": "text", "text": instructions})
484
+ schema = _extraction_schema(field_ids)
485
+ return _SYSTEM, content, schema, field_ids
486
+
487
+
488
+ def extract_via_api(
489
+ venue: Venue,
490
+ documents: list[DocumentInput],
491
+ file_listing: list[str],
492
+ model: str = DEFAULT_MODEL,
493
+ max_tokens: int = 16000,
494
+ ) -> Extraction:
495
+ """Ask the Anthropic API to propose field values; return an Extraction.
496
+
497
+ Raises :class:`AutofillApiError` for setup problems (missing dependency, no
498
+ API key, unreadable file) and for a model refusal.
499
+ """
500
+ try:
501
+ import anthropic
502
+ except ImportError as exc:
503
+ raise AutofillApiError("the 'api' engine needs the anthropic package; install it with " "'pip install paperpush[autofill]'.") from exc
504
+
505
+ if not (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")):
506
+ raise AutofillApiError("no API key found; set ANTHROPIC_API_KEY (or use '--engine manual' " "with the Claude skill).")
507
+
508
+ system, content, schema, _ = _build_prompt(venue, documents, file_listing)
509
+ logger.info("autofill api: calling %s for %s (%d document(s), %d field(s))", model, venue.slug, len(documents), len(schema["properties"]["fields"]["items"]["properties"]["id"]["enum"]))
510
+
511
+ client = anthropic.Anthropic()
512
+ try:
513
+ response = client.messages.create(
514
+ model=model,
515
+ max_tokens=max_tokens,
516
+ system=system,
517
+ messages=[{"role": "user", "content": content}],
518
+ output_config={"format": {"type": "json_schema", "schema": schema}},
519
+ )
520
+ except anthropic.APIError as exc:
521
+ raise AutofillApiError(f"the API request failed: {exc}") from exc
522
+
523
+ if response.stop_reason == "refusal":
524
+ raise AutofillApiError("the model declined to process this request " f"({getattr(response.stop_details, 'category', None) or 'refusal'}).")
525
+
526
+ text = next((b.text for b in response.content if b.type == "text"), "")
527
+ try:
528
+ data = json.loads(text)
529
+ except json.JSONDecodeError as exc:
530
+ raise AutofillApiError(f"the model returned output that was not valid JSON: {exc}") from exc
531
+
532
+ logger.info("autofill api: %s proposed %d field(s), %d unfilled (usage: " "%s in / %s out)", venue.slug, len(data.get("fields", [])), len(data.get("unfilled", [])), response.usage.input_tokens, response.usage.output_tokens)
533
+ return parse_extraction(data)