artifice-draft 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.
- artifice_draft/__init__.py +3 -0
- artifice_draft/_diff.py +58 -0
- artifice_draft/_track_changes.py +170 -0
- artifice_draft/abbreviations.py +70 -0
- artifice_draft/accessibility.py +55 -0
- artifice_draft/archival_refs.py +113 -0
- artifice_draft/captions.py +68 -0
- artifice_draft/changelog.py +191 -0
- artifice_draft/citation_checker.py +159 -0
- artifice_draft/cli.py +237 -0
- artifice_draft/config.py +177 -0
- artifice_draft/consistency.py +112 -0
- artifice_draft/date_standardizer.py +159 -0
- artifice_draft/doc_parser.py +239 -0
- artifice_draft/doc_writer.py +113 -0
- artifice_draft/exporters.py +158 -0
- artifice_draft/foreign_phrases.py +135 -0
- artifice_draft/glossary.py +41 -0
- artifice_draft/llm_client.py +383 -0
- artifice_draft/llm_edit.py +60 -0
- artifice_draft/llm_utils.py +82 -0
- artifice_draft/log_setup.py +79 -0
- artifice_draft/metrics.py +59 -0
- artifice_draft/models.py +114 -0
- artifice_draft/prompts.py +163 -0
- artifice_draft/review.py +141 -0
- artifice_draft/style_guides/__init__.py +100 -0
- artifice_draft/style_guides/apa.py +72 -0
- artifice_draft/style_guides/base.py +72 -0
- artifice_draft/style_guides/chicago.py +80 -0
- artifice_draft/style_guides/mla.py +70 -0
- artifice_draft/style_guides/scraper.py +531 -0
- artifice_draft/web/__init__.py +3 -0
- artifice_draft/web/routers/__init__.py +5 -0
- artifice_draft/web/routers/byom.py +156 -0
- artifice_draft/web/runtime.py +418 -0
- artifice_draft/web/server.py +559 -0
- artifice_draft/web/static/css/app.css +744 -0
- artifice_draft/web/static/js/app.js +327 -0
- artifice_draft/web/static/js/guide-import.js +258 -0
- artifice_draft/web/static/js/review.js +103 -0
- artifice_draft/web/templates/about.html +76 -0
- artifice_draft/web/templates/base.html +44 -0
- artifice_draft/web/templates/index.html +221 -0
- artifice_draft/write_utils.py +126 -0
- artifice_draft-0.1.0.dist-info/METADATA +204 -0
- artifice_draft-0.1.0.dist-info/RECORD +51 -0
- artifice_draft-0.1.0.dist-info/WHEEL +5 -0
- artifice_draft-0.1.0.dist-info/entry_points.txt +2 -0
- artifice_draft-0.1.0.dist-info/licenses/LICENSE +235 -0
- artifice_draft-0.1.0.dist-info/top_level.txt +1 -0
artifice_draft/_diff.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Maurice Casey
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Word-level diff ranges between an original and an edited paragraph.
|
|
6
|
+
|
|
7
|
+
Used by the web review screen to highlight what an LLM edit actually changed,
|
|
8
|
+
the same way a track-changes view would, without waiting for Word to open the
|
|
9
|
+
file. Built on the standard library's ``difflib`` — no new dependency.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
_WORD_RE = re.compile(r"\S+|\s+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _tokenize(text: str) -> list[str]:
|
|
21
|
+
"""Split into words and whitespace runs, so ranges land on word boundaries."""
|
|
22
|
+
return _WORD_RE.findall(text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def diff_ranges(original: str, edited: str) -> tuple[list[tuple[int, int, str]], list[tuple[int, int, str]]]:
|
|
26
|
+
"""Return (original_ranges, edited_ranges), each a list of (start, end, tag)
|
|
27
|
+
character offsets into the respective string. ``tag`` is one of
|
|
28
|
+
"delete_" (present in original, removed), "insert_" (present in edited,
|
|
29
|
+
added), or "replace_" (changed on both sides) — matching the CSS class
|
|
30
|
+
names the review screen's ``mark.hl-*`` rules key off.
|
|
31
|
+
"""
|
|
32
|
+
orig_tokens = _tokenize(original)
|
|
33
|
+
edit_tokens = _tokenize(edited)
|
|
34
|
+
matcher = difflib.SequenceMatcher(a=orig_tokens, b=edit_tokens, autojunk=False)
|
|
35
|
+
|
|
36
|
+
orig_ranges: list[tuple[int, int, str]] = []
|
|
37
|
+
edit_ranges: list[tuple[int, int, str]] = []
|
|
38
|
+
orig_pos = 0
|
|
39
|
+
edit_pos = 0
|
|
40
|
+
|
|
41
|
+
for op, a0, a1, b0, b1 in matcher.get_opcodes():
|
|
42
|
+
orig_span = sum(len(t) for t in orig_tokens[a0:a1])
|
|
43
|
+
edit_span = sum(len(t) for t in edit_tokens[b0:b1])
|
|
44
|
+
|
|
45
|
+
if op == "equal":
|
|
46
|
+
pass
|
|
47
|
+
elif op == "delete":
|
|
48
|
+
orig_ranges.append((orig_pos, orig_pos + orig_span, "delete_"))
|
|
49
|
+
elif op == "insert":
|
|
50
|
+
edit_ranges.append((edit_pos, edit_pos + edit_span, "insert_"))
|
|
51
|
+
elif op == "replace":
|
|
52
|
+
orig_ranges.append((orig_pos, orig_pos + orig_span, "replace_"))
|
|
53
|
+
edit_ranges.append((edit_pos, edit_pos + edit_span, "replace_"))
|
|
54
|
+
|
|
55
|
+
orig_pos += orig_span
|
|
56
|
+
edit_pos += edit_span
|
|
57
|
+
|
|
58
|
+
return orig_ranges, edit_ranges
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Maurice Casey
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Apply tracked insertions/deletions to a .docx using docx-revisions.
|
|
6
|
+
|
|
7
|
+
Uses the document-level API from ``docx_revisions``: each edit is applied via
|
|
8
|
+
``RevisionDocument.find_and_replace_tracked``, producing real
|
|
9
|
+
<w:ins>/<w:del> revision elements and persisting them when the file is saved.
|
|
10
|
+
|
|
11
|
+
Each paragraph's edit is applied as a separate call so that different edits
|
|
12
|
+
can target different paragraphs independently, while still producing a single
|
|
13
|
+
revision document with all changes tracked under one author.
|
|
14
|
+
|
|
15
|
+
Paragraphs containing inline images (<w:drawing>) are handled by temporarily
|
|
16
|
+
removing drawings before the text replacement and re-injecting them into the
|
|
17
|
+
resulting <w:ins> element so that images survive the edit.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
from docx_revisions import RevisionDocument
|
|
27
|
+
|
|
28
|
+
from artifice_draft.write_utils import write_plain_docx
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
NSMAP = {
|
|
33
|
+
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
34
|
+
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
35
|
+
"pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
|
|
36
|
+
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _paragraph_has_drawing(para_elem) -> bool:
|
|
41
|
+
return len(para_elem.findall(".//w:drawing", NSMAP)) > 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _remove_drawings(para_elem):
|
|
45
|
+
"""Remove all <w:drawing> elements from a paragraph XML element.
|
|
46
|
+
|
|
47
|
+
Returns the list of extracted drawing LXML elements so they can be
|
|
48
|
+
re-injected later.
|
|
49
|
+
"""
|
|
50
|
+
drawings = para_elem.findall(".//w:drawing", NSMAP)
|
|
51
|
+
clones = []
|
|
52
|
+
for d in drawings:
|
|
53
|
+
parent = d.getparent()
|
|
54
|
+
if parent is not None:
|
|
55
|
+
clones.append(d)
|
|
56
|
+
parent.remove(d)
|
|
57
|
+
return clones
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _reinject_drawings_into_ins(para_elem, clones) -> None:
|
|
61
|
+
"""Re-inject drawing elements into the first <w:ins><w:r> found."""
|
|
62
|
+
if not clones:
|
|
63
|
+
return
|
|
64
|
+
ins_elements = para_elem.findall(".//w:ins", NSMAP)
|
|
65
|
+
for ins in ins_elements:
|
|
66
|
+
runs = ins.findall("w:r", NSMAP)
|
|
67
|
+
if runs:
|
|
68
|
+
target_run = runs[0]
|
|
69
|
+
for clone in clones:
|
|
70
|
+
target_run.append(clone)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _apply_edit_preserving_images(
|
|
75
|
+
rdoc,
|
|
76
|
+
original: str,
|
|
77
|
+
edited_text: str,
|
|
78
|
+
author: str,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Replace text in a paragraph while preserving inline drawings."""
|
|
81
|
+
for para in rdoc._iter_all_paragraphs():
|
|
82
|
+
if para.text.strip() != original:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
para_elem = para._element
|
|
86
|
+
if not _paragraph_has_drawing(para_elem):
|
|
87
|
+
rdoc.find_and_replace_tracked(
|
|
88
|
+
search_text=original,
|
|
89
|
+
replace_text=edited_text,
|
|
90
|
+
author=author,
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
clones = _remove_drawings(para_elem)
|
|
95
|
+
rdoc.find_and_replace_tracked(
|
|
96
|
+
search_text=original,
|
|
97
|
+
replace_text=edited_text,
|
|
98
|
+
author=author,
|
|
99
|
+
)
|
|
100
|
+
_reinject_drawings_into_ins(para_elem, clones)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def apply_track_changes_to_docx(
|
|
105
|
+
input_path: str | None,
|
|
106
|
+
paragraphs: list[dict],
|
|
107
|
+
changes: dict[int, str],
|
|
108
|
+
output_path: str,
|
|
109
|
+
author: str = "ArtificeDraft",
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Apply tracked changes to a .docx file using docx-revisions.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
input_path: path to the original .docx (loaded for find_and_replace_tracked).
|
|
115
|
+
If ``None``, reconstructs from paragraph data via a temp file.
|
|
116
|
+
paragraphs: List of paragraph dicts from doc_parser.parse_docx().
|
|
117
|
+
changes: Dict mapping paragraph index to the replacement text.
|
|
118
|
+
output_path: path for the resulting .docx
|
|
119
|
+
author: author name shown in tracked changes (default: "ArtificeDraft").
|
|
120
|
+
"""
|
|
121
|
+
if not changes:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
tmp_path = None
|
|
125
|
+
try:
|
|
126
|
+
if input_path:
|
|
127
|
+
logger.debug("Loading original document: %s", input_path)
|
|
128
|
+
rdoc = RevisionDocument(input_path)
|
|
129
|
+
else:
|
|
130
|
+
tmp_path = tempfile.mktemp(suffix=".docx")
|
|
131
|
+
logger.debug("No input_path; writing temp plain docx to %s", tmp_path)
|
|
132
|
+
write_plain_docx(paragraphs, tmp_path)
|
|
133
|
+
rdoc = RevisionDocument(tmp_path)
|
|
134
|
+
|
|
135
|
+
for i, entry in enumerate(paragraphs):
|
|
136
|
+
original = entry["text"]
|
|
137
|
+
edited_text = changes.get(i)
|
|
138
|
+
|
|
139
|
+
if edited_text is not None:
|
|
140
|
+
has_imgs = bool(entry.get("images"))
|
|
141
|
+
if has_imgs and input_path:
|
|
142
|
+
logger.debug(
|
|
143
|
+
"Applying edit with image preservation on paragraph %d", i
|
|
144
|
+
)
|
|
145
|
+
_apply_edit_preserving_images(
|
|
146
|
+
rdoc, original, edited_text, author,
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
logger.debug(
|
|
150
|
+
"Replacing paragraph %d: %r → %r",
|
|
151
|
+
i,
|
|
152
|
+
original[:50],
|
|
153
|
+
edited_text[:50],
|
|
154
|
+
)
|
|
155
|
+
rdoc.find_and_replace_tracked(
|
|
156
|
+
search_text=original,
|
|
157
|
+
replace_text=edited_text,
|
|
158
|
+
author=author,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
rdoc.save(output_path)
|
|
162
|
+
logger.info("Saved tracked-changes document to %s", output_path)
|
|
163
|
+
finally:
|
|
164
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
165
|
+
os.remove(tmp_path)
|
|
166
|
+
logger.debug("Cleaned up temp file: %s", tmp_path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
print("Track changes module — used internally by doc_writer.py")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Maurice Casey
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Abbreviation extraction and validation module for academic papers."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import TypedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AbbreviationIssue(TypedDict):
|
|
14
|
+
paragraph_index: int
|
|
15
|
+
abbreviation: str
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_abbreviations(paragraphs: list[dict]) -> list[AbbreviationIssue]:
|
|
20
|
+
"""Scan paragraphs for uppercase abbreviations (2-6 letters) and verify
|
|
21
|
+
|
|
22
|
+
whether they are introduced with a definition on first use.
|
|
23
|
+
"""
|
|
24
|
+
issues: list[AbbreviationIssue] = []
|
|
25
|
+
seen_defs: set[str] = set()
|
|
26
|
+
|
|
27
|
+
# Pattern for uppercase acronyms: 2 to 6 capital letters
|
|
28
|
+
acronym_pattern = re.compile(r'\b[A-Z]{2,6}\b')
|
|
29
|
+
|
|
30
|
+
for entry in paragraphs:
|
|
31
|
+
text = entry.get("text", "")
|
|
32
|
+
idx = entry.get("paragraph_index", 0)
|
|
33
|
+
|
|
34
|
+
matches = acronym_pattern.findall(text)
|
|
35
|
+
for acr in matches:
|
|
36
|
+
# Common false positives in academic writing
|
|
37
|
+
if acr in {"I", "A", "THE", "AND", "FOR", "BUT", "OR", "IN", "ON", "BY", "WITH"}:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# Check if defined in this paragraph or earlier
|
|
41
|
+
# Definition pattern: "Full Name (ACR)" or "ACR (Full Name)"
|
|
42
|
+
def_pattern1 = re.compile(rf'.*?\(({acr})\)')
|
|
43
|
+
def_pattern2 = re.compile(rf'({acr})\s+\(.*?\)')
|
|
44
|
+
|
|
45
|
+
if acr in seen_defs:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
if def_pattern1.search(text) or def_pattern2.search(text):
|
|
49
|
+
seen_defs.add(acr)
|
|
50
|
+
else:
|
|
51
|
+
# If not seen yet and not defined here, flag it as potentially undefined
|
|
52
|
+
# (unless it's a very standard abbreviation like US, UK, etc.)
|
|
53
|
+
if acr not in {"US", "UK", "EU", "UN", "BCE", "CE", "AD", "BC"}:
|
|
54
|
+
# Check if defined later or earlier across document
|
|
55
|
+
is_defined_elsewhere = False
|
|
56
|
+
for other in paragraphs:
|
|
57
|
+
otext = other.get("text", "")
|
|
58
|
+
if def_pattern1.search(otext) or def_pattern2.search(otext):
|
|
59
|
+
is_defined_elsewhere = True
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
if not is_defined_elsewhere and acr not in seen_defs:
|
|
63
|
+
seen_defs.add(acr) # Report once per document to avoid spam
|
|
64
|
+
issues.append({
|
|
65
|
+
"paragraph_index": idx,
|
|
66
|
+
"abbreviation": acr,
|
|
67
|
+
"message": f"Abbreviation '{acr}' used without explicit definition/expansion.",
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return issues
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Maurice Casey
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Accessibility and structural document hierarchy checker."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AccessibilityIssue(TypedDict):
|
|
13
|
+
paragraph_index: int
|
|
14
|
+
issue_type: str
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_accessibility(paragraphs: list[dict]) -> list[AccessibilityIssue]:
|
|
19
|
+
"""Check heading hierarchy (no skipped levels like Heading 1 to Heading 3)
|
|
20
|
+
|
|
21
|
+
and verify images have descriptions/alt text where applicable.
|
|
22
|
+
"""
|
|
23
|
+
issues: list[AccessibilityIssue] = []
|
|
24
|
+
last_heading_level = 0
|
|
25
|
+
|
|
26
|
+
for entry in paragraphs:
|
|
27
|
+
idx = entry.get("paragraph_index", 0)
|
|
28
|
+
style_name = entry.get("style_name", "")
|
|
29
|
+
images = entry.get("images", [])
|
|
30
|
+
|
|
31
|
+
# Check heading hierarchy
|
|
32
|
+
if style_name.startswith("Heading "):
|
|
33
|
+
try:
|
|
34
|
+
level = int(style_name.replace("Heading ", ""))
|
|
35
|
+
if last_heading_level > 0 and level > last_heading_level + 1:
|
|
36
|
+
issues.append({
|
|
37
|
+
"paragraph_index": idx,
|
|
38
|
+
"issue_type": "heading_hierarchy",
|
|
39
|
+
"message": f"Skipped heading level: Heading {level} follows Heading {last_heading_level}.",
|
|
40
|
+
})
|
|
41
|
+
last_heading_level = level
|
|
42
|
+
except ValueError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# Check images for alt text / descriptions
|
|
46
|
+
if images:
|
|
47
|
+
for img in images:
|
|
48
|
+
if not img.get("description"):
|
|
49
|
+
issues.append({
|
|
50
|
+
"paragraph_index": idx,
|
|
51
|
+
"issue_type": "missing_alt_text",
|
|
52
|
+
"message": f"Embedded image '{img.get('filename', 'unknown')}' lacks descriptive alt text for accessibility.",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return issues
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Maurice Casey
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Archival citation format validation."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from artifice_draft.style_guides import load_guide
|
|
13
|
+
|
|
14
|
+
# Patterns for common archival citation elements
|
|
15
|
+
_ARCHIVE_RE = re.compile(
|
|
16
|
+
r"\b(archive|archives|repository|fonds|collection|group|series)\b",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_BOX_FOLDER_RE = re.compile(
|
|
21
|
+
r"\b(Box|Folders?|File|Item|Folder)\s+[\d\w]",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_DATE_PATTERN_RE = re.compile(
|
|
26
|
+
r"\b\d{1,2}\s+(?:January|February|March|April|May|June|July|August|"
|
|
27
|
+
r"September|October|November|December)\s+\d{4}\b",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Patterns suggesting an incomplete archival reference
|
|
32
|
+
_INCOMPLETE_RE = re.compile(
|
|
33
|
+
r"(?:Box|Folders?|File)\s+\d+",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_REPOSITORY_ABBREVS = re.compile(
|
|
38
|
+
r"\b(NARA|TNA|PRO|LC|LOC|Yale|Harvard|Princeton|Columbia)\b"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ArchivalAdvisory:
|
|
44
|
+
"""A single archival reference advisory."""
|
|
45
|
+
|
|
46
|
+
paragraph_index: int
|
|
47
|
+
rule: str
|
|
48
|
+
message: str
|
|
49
|
+
severity: str # "warning" | "info"
|
|
50
|
+
suggested_fix: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_archival_refs(
|
|
54
|
+
paragraphs: list[dict],
|
|
55
|
+
guide_name: str = "",
|
|
56
|
+
) -> list[ArchivalAdvisory]:
|
|
57
|
+
"""Check archival citation formatting.
|
|
58
|
+
|
|
59
|
+
Validates that archival references include the essential components:
|
|
60
|
+
repository name, collection/fonds, box/folder, and date.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
paragraphs: Parsed paragraph dicts from doc_parser.
|
|
64
|
+
guide_name: Name of the active style guide.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of ArchivalAdvisory objects.
|
|
68
|
+
"""
|
|
69
|
+
advisories: list[ArchivalAdvisory] = []
|
|
70
|
+
|
|
71
|
+
for para in paragraphs:
|
|
72
|
+
idx = para["paragraph_index"]
|
|
73
|
+
text = para["text"]
|
|
74
|
+
|
|
75
|
+
has_box_folder = bool(_BOX_FOLDER_RE.search(text))
|
|
76
|
+
has_date = bool(_DATE_PATTERN_RE.search(text))
|
|
77
|
+
has_archive_word = bool(_ARCHIVE_RE.search(text))
|
|
78
|
+
has_repository = bool(_REPOSITORY_ABBREVS.search(text))
|
|
79
|
+
|
|
80
|
+
# If a box/folder reference exists but no date
|
|
81
|
+
if has_box_folder and not has_date:
|
|
82
|
+
advisories.append(ArchivalAdvisory(
|
|
83
|
+
paragraph_index=idx,
|
|
84
|
+
rule="archival_missing_date",
|
|
85
|
+
message="Archival reference includes box/folder but no date — add the date range of the materials.",
|
|
86
|
+
severity="warning",
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
# If archival language is used but no repository name
|
|
90
|
+
if has_archive_word and not has_repository and not has_box_folder:
|
|
91
|
+
advisories.append(ArchivalAdvisory(
|
|
92
|
+
paragraph_index=idx,
|
|
93
|
+
rule="archival_missing_repository",
|
|
94
|
+
message="Reference mentions archives but does not identify the repository — add the full repository name.",
|
|
95
|
+
severity="info",
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
# If box/folder is referenced without a collection name
|
|
99
|
+
if has_box_folder and not has_archive_word and not has_repository:
|
|
100
|
+
# Check if there's context that looks like a collection name nearby
|
|
101
|
+
box_match = _INCOMPLETE_RE.search(text)
|
|
102
|
+
if box_match:
|
|
103
|
+
advisories.append(ArchivalAdvisory(
|
|
104
|
+
paragraph_index=idx,
|
|
105
|
+
rule="archival_incomplete_reference",
|
|
106
|
+
message=(
|
|
107
|
+
f"Archival reference near '{box_match.group()}' may be incomplete — "
|
|
108
|
+
f"include repository name, collection name, and date."
|
|
109
|
+
),
|
|
110
|
+
severity="info",
|
|
111
|
+
))
|
|
112
|
+
|
|
113
|
+
return advisories
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Maurice Casey
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Figure and table caption normalization and validation module."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import TypedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CaptionIssue(TypedDict):
|
|
14
|
+
paragraph_index: int
|
|
15
|
+
caption_type: str
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_captions(paragraphs: list[dict]) -> list[CaptionIssue]:
|
|
20
|
+
"""Validate figure and table captions for consistent numbering and style."""
|
|
21
|
+
issues: list[CaptionIssue] = []
|
|
22
|
+
|
|
23
|
+
fig_pattern = re.compile(r'^(Figure|Fig\.?)\s+(\d+)', re.IGNORECASE)
|
|
24
|
+
table_pattern = re.compile(r'^(Table|Tab\.?)\s+(\d+)', re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
fig_numbers: list[int] = []
|
|
27
|
+
table_numbers: list[int] = []
|
|
28
|
+
|
|
29
|
+
for entry in paragraphs:
|
|
30
|
+
text = entry.get("text", "").strip()
|
|
31
|
+
idx = entry.get("paragraph_index", 0)
|
|
32
|
+
|
|
33
|
+
f_match = fig_pattern.match(text)
|
|
34
|
+
if f_match:
|
|
35
|
+
num = int(f_match.group(2))
|
|
36
|
+
fig_numbers.append(num)
|
|
37
|
+
# Check style prefix
|
|
38
|
+
prefix = f_match.group(1)
|
|
39
|
+
if prefix.lower() == "fig" and not prefix.endswith("."):
|
|
40
|
+
issues.append({
|
|
41
|
+
"paragraph_index": idx,
|
|
42
|
+
"caption_type": "figure",
|
|
43
|
+
"message": f"Abbreviated figure prefix '{prefix}' should include a period ('Fig.') or be spelled out ('Figure').",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
t_match = table_pattern.match(text)
|
|
47
|
+
if t_match:
|
|
48
|
+
num = int(t_match.group(2))
|
|
49
|
+
table_numbers.append(num)
|
|
50
|
+
|
|
51
|
+
# Check for sequential numbering
|
|
52
|
+
for i in range(1, len(fig_numbers)):
|
|
53
|
+
if fig_numbers[i] != fig_numbers[i - 1] + 1:
|
|
54
|
+
issues.append({
|
|
55
|
+
"paragraph_index": -1,
|
|
56
|
+
"caption_type": "figure",
|
|
57
|
+
"message": f"Figure numbers are not strictly sequential: found {fig_numbers[i-1]} followed by {fig_numbers[i]}.",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
for i in range(1, len(table_numbers)):
|
|
61
|
+
if table_numbers[i] != table_numbers[i - 1] + 1:
|
|
62
|
+
issues.append({
|
|
63
|
+
"paragraph_index": -1,
|
|
64
|
+
"caption_type": "table",
|
|
65
|
+
"message": f"Table numbers are not strictly sequential: found {table_numbers[i-1]} followed by {table_numbers[i]}.",
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return issues
|