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.
Files changed (51) hide show
  1. artifice_draft/__init__.py +3 -0
  2. artifice_draft/_diff.py +58 -0
  3. artifice_draft/_track_changes.py +170 -0
  4. artifice_draft/abbreviations.py +70 -0
  5. artifice_draft/accessibility.py +55 -0
  6. artifice_draft/archival_refs.py +113 -0
  7. artifice_draft/captions.py +68 -0
  8. artifice_draft/changelog.py +191 -0
  9. artifice_draft/citation_checker.py +159 -0
  10. artifice_draft/cli.py +237 -0
  11. artifice_draft/config.py +177 -0
  12. artifice_draft/consistency.py +112 -0
  13. artifice_draft/date_standardizer.py +159 -0
  14. artifice_draft/doc_parser.py +239 -0
  15. artifice_draft/doc_writer.py +113 -0
  16. artifice_draft/exporters.py +158 -0
  17. artifice_draft/foreign_phrases.py +135 -0
  18. artifice_draft/glossary.py +41 -0
  19. artifice_draft/llm_client.py +383 -0
  20. artifice_draft/llm_edit.py +60 -0
  21. artifice_draft/llm_utils.py +82 -0
  22. artifice_draft/log_setup.py +79 -0
  23. artifice_draft/metrics.py +59 -0
  24. artifice_draft/models.py +114 -0
  25. artifice_draft/prompts.py +163 -0
  26. artifice_draft/review.py +141 -0
  27. artifice_draft/style_guides/__init__.py +100 -0
  28. artifice_draft/style_guides/apa.py +72 -0
  29. artifice_draft/style_guides/base.py +72 -0
  30. artifice_draft/style_guides/chicago.py +80 -0
  31. artifice_draft/style_guides/mla.py +70 -0
  32. artifice_draft/style_guides/scraper.py +531 -0
  33. artifice_draft/web/__init__.py +3 -0
  34. artifice_draft/web/routers/__init__.py +5 -0
  35. artifice_draft/web/routers/byom.py +156 -0
  36. artifice_draft/web/runtime.py +418 -0
  37. artifice_draft/web/server.py +559 -0
  38. artifice_draft/web/static/css/app.css +744 -0
  39. artifice_draft/web/static/js/app.js +327 -0
  40. artifice_draft/web/static/js/guide-import.js +258 -0
  41. artifice_draft/web/static/js/review.js +103 -0
  42. artifice_draft/web/templates/about.html +76 -0
  43. artifice_draft/web/templates/base.html +44 -0
  44. artifice_draft/web/templates/index.html +221 -0
  45. artifice_draft/write_utils.py +126 -0
  46. artifice_draft-0.1.0.dist-info/METADATA +204 -0
  47. artifice_draft-0.1.0.dist-info/RECORD +51 -0
  48. artifice_draft-0.1.0.dist-info/WHEEL +5 -0
  49. artifice_draft-0.1.0.dist-info/entry_points.txt +2 -0
  50. artifice_draft-0.1.0.dist-info/licenses/LICENSE +235 -0
  51. artifice_draft-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Maurice Casey
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
@@ -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