@demig0d2/skills 1.0.2 → 1.1.2

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 (43) hide show
  1. package/README.md +58 -67
  2. package/bin/cli.js +7 -3
  3. package/package.json +2 -5
  4. package/skills/book-creator/SKILL.md +848 -0
  5. package/skills/book-creator/references/kdp_specs.md +139 -0
  6. package/skills/book-creator/references/log_schema.md +149 -0
  7. package/skills/book-creator/references/patterns_quick_ref.md +71 -0
  8. package/skills/book-creator/references/thinkers_reference.md +104 -0
  9. package/skills/book-creator/scripts/__pycache__/bank_formatter.cpython-312.pyc +0 -0
  10. package/skills/book-creator/scripts/__pycache__/conflict_check.cpython-312.pyc +0 -0
  11. package/skills/book-creator/scripts/__pycache__/dna_scan.cpython-312.pyc +0 -0
  12. package/skills/book-creator/scripts/__pycache__/kdp_check.cpython-312.pyc +0 -0
  13. package/skills/book-creator/scripts/__pycache__/log_manager.cpython-312.pyc +0 -0
  14. package/skills/book-creator/scripts/__pycache__/scan_ai_patterns.cpython-312.pyc +0 -0
  15. package/skills/book-creator/scripts/__pycache__/score_report.cpython-312.pyc +0 -0
  16. package/skills/book-creator/scripts/__pycache__/toc_extract.cpython-312.pyc +0 -0
  17. package/skills/book-creator/scripts/__pycache__/validate_concept.cpython-312.pyc +0 -0
  18. package/skills/book-creator/scripts/__pycache__/word_count.cpython-312.pyc +0 -0
  19. package/skills/book-creator/scripts/bank_formatter.py +206 -0
  20. package/skills/book-creator/scripts/conflict_check.py +179 -0
  21. package/skills/book-creator/scripts/dna_scan.py +168 -0
  22. package/skills/book-creator/scripts/kdp_check.py +255 -0
  23. package/skills/book-creator/scripts/log_manager.py +258 -0
  24. package/skills/book-creator/scripts/scan_ai_patterns.py +279 -0
  25. package/skills/book-creator/scripts/score_report.py +237 -0
  26. package/skills/book-creator/scripts/toc_extract.py +151 -0
  27. package/skills/book-creator/scripts/validate_concept.py +255 -0
  28. package/skills/book-creator/scripts/word_count.py +196 -0
  29. package/skills/book-writer/scripts/__pycache__/kdp_check.cpython-312.pyc +0 -0
  30. package/skills/book-writer/scripts/__pycache__/toc_extract.cpython-312.pyc +0 -0
  31. package/skills/book-writer/scripts/__pycache__/word_count.cpython-312.pyc +0 -0
  32. package/skills/book-writer.zip +0 -0
  33. package/skills/chapter-auditor/scripts/__pycache__/score_report.cpython-312.pyc +0 -0
  34. package/skills/concept-expander/scripts/__pycache__/validate_concept.cpython-312.pyc +0 -0
  35. package/skills/continuity-tracker/scripts/__pycache__/conflict_check.cpython-312.pyc +0 -0
  36. package/skills/continuity-tracker/scripts/__pycache__/log_manager.cpython-312.pyc +0 -0
  37. package/skills/humanizer/scripts/__pycache__/dna_scan.cpython-312.pyc +0 -0
  38. package/skills/humanizer/scripts/__pycache__/scan_ai_patterns.cpython-312.pyc +0 -0
  39. package/skills/overhaul/scripts/__pycache__/changelog_gen.cpython-312.pyc +0 -0
  40. package/skills/overhaul/scripts/__pycache__/skill_parser.cpython-312.pyc +0 -0
  41. package/skills/overhaul/scripts/__pycache__/version_bump.cpython-312.pyc +0 -0
  42. package/skills/overhaul.zip +0 -0
  43. package/skills/research-aggregator/scripts/__pycache__/bank_formatter.cpython-312.pyc +0 -0
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ scan_ai_patterns.py — Scan text for AI writing patterns
4
+
5
+ Usage:
6
+ python scan_ai_patterns.py <input_file>
7
+ python scan_ai_patterns.py <input_file> --json
8
+ python scan_ai_patterns.py <input_file> --summary
9
+
10
+ Output:
11
+ Human-readable report of all AI patterns found, with line numbers.
12
+ Use --json for machine-readable output (for audit scripts).
13
+ Use --summary for a count-only view.
14
+ """
15
+
16
+ import sys
17
+ import re
18
+ import json
19
+ from pathlib import Path
20
+
21
+ # ─── Pattern Definitions ─────────────────────────────────────────────────────
22
+
23
+ PATTERNS = {
24
+ "significance_inflation": {
25
+ "label": "Pattern 1 — Significance Inflation",
26
+ "terms": [
27
+ r"\bstands as\b", r"\bserves as\b", r"\bmarks a\b", r"\btestament to\b",
28
+ r"\ba reminder that\b", r"\bpivotal moment\b", r"\bcrucial role\b",
29
+ r"\bvital role\b", r"\bunderscores\b", r"\bhighlights its importance\b",
30
+ r"\breflects broader\b", r"\bsetting the stage for\b", r"\bevolving landscape\b",
31
+ r"\bindel[i]ble mark\b", r"\bdeeply rooted\b", r"\bkey turning point\b",
32
+ r"\bfocal point\b", r"\bmarks a shift\b", r"\bshaping the\b",
33
+ ],
34
+ },
35
+ "promotional_language": {
36
+ "label": "Pattern 4 — Promotional Language",
37
+ "terms": [
38
+ r"\bboasts\b", r"\bvibrant\b", r"\bnestled\b", r"\bbreathtaking\b",
39
+ r"\bgroundbreaking\b", r"\brenowned\b", r"\bstunning\b", r"\bmust-visit\b",
40
+ r"\bmust-read\b", r"\bshowcasing\b", r"\bexemplifies\b",
41
+ ],
42
+ },
43
+ "ing_tack_ons": {
44
+ "label": "Pattern 3 — Superficial -ing Endings",
45
+ "terms": [
46
+ r"\bhighlighting\b", r"\bunderscoring\b", r"\bemphasizing\b",
47
+ r"\bensuring\b", r"\breflecting\b", r"\bsymbolizing\b",
48
+ r"\bcontributing to\b", r"\bcultivating\b", r"\bfostering\b",
49
+ r"\bshowcasing\b", r"\bencompassing\b",
50
+ ],
51
+ },
52
+ "ai_vocabulary": {
53
+ "label": "Pattern 7 — AI Vocabulary Words",
54
+ "terms": [
55
+ r"\bdelve\b", r"\btapestry\b", r"\bmultifaceted\b", r"\bnuanced\b",
56
+ r"\bembark\b", r"\brealm\b", r"\bfoster\b", r"\belevate\b",
57
+ r"\bleverage\b", r"\bnavigate\b", r"\bunpack\b", r"\bholistic\b",
58
+ r"\bsynergy\b", r"\btransformative\b", r"\bimpactful\b", r"\brobust\b",
59
+ r"\bpivotal\b", r"\btestament\b", r"\blandscape\b", r"\bgarner\b",
60
+ r"\bintricate\b", r"\binterplay\b",
61
+ ],
62
+ },
63
+ "copula_avoidance": {
64
+ "label": "Pattern 8 — Copula Avoidance",
65
+ "terms": [
66
+ r"\bserves as\b", r"\bstands as\b", r"\brepresents a\b", r"\bboasts\b",
67
+ r"\bfeatures\b", r"\boffers a\b",
68
+ ],
69
+ },
70
+ "vague_attributions": {
71
+ "label": "Pattern 5 — Vague Attributions",
72
+ "terms": [
73
+ r"\bexperts (say|argue|believe|suggest)\b",
74
+ r"\bindustry (reports|observers)\b",
75
+ r"\bmany believe\b", r"\bsome critics argue\b",
76
+ r"\bit is widely believed\b", r"\bobservers (have|say)\b",
77
+ ],
78
+ },
79
+ "filler_phrases": {
80
+ "label": "Pattern 22 — Filler Phrases",
81
+ "terms": [
82
+ r"\bin order to\b", r"\bdue to the fact that\b", r"\bat this point in time\b",
83
+ r"\bin the event that\b", r"\bhas the ability to\b",
84
+ r"\bit is important to note\b", r"\bat its core\b",
85
+ r"\bin today's world\b", r"\bin conclusion\b",
86
+ r"\bto summarize\b", r"\bit goes without saying\b",
87
+ r"\bneedless to say\b",
88
+ ],
89
+ },
90
+ "hedging_overload": {
91
+ "label": "Pattern 23 — Hedging Overload",
92
+ "terms": [
93
+ r"\bcould potentially\b", r"\bmight possibly\b",
94
+ r"\bit could be argued\b", r"\bpotentially possibly\b",
95
+ ],
96
+ },
97
+ "generic_conclusions": {
98
+ "label": "Pattern 24 — Generic Positive Conclusions",
99
+ "terms": [
100
+ r"\bthe future (looks|is) bright\b", r"\bexciting times (lie |)ahead\b",
101
+ r"\bthis is just the beginning\b", r"\bcontinue this journey\b",
102
+ r"\bthe possibilities are endless\b", r"\btoward excellence\b",
103
+ ],
104
+ },
105
+ "chatbot_artifacts": {
106
+ "label": "Pattern 19 — Chatbot Artifacts",
107
+ "terms": [
108
+ r"\bgreat question\b", r"\bi hope this helps\b",
109
+ r"\blet me know if\b", r"\bfeel free to\b",
110
+ r"\bof course!\b", r"\bcertainly!\b",
111
+ r"\byou'?re absolutely right\b",
112
+ ],
113
+ },
114
+ "em_dash_overuse": {
115
+ "label": "Pattern 13 — Em Dash Overuse",
116
+ "terms": [r"—"],
117
+ "count_threshold": 3, # flag only if more than N per paragraph
118
+ },
119
+ "negative_parallelism": {
120
+ "label": "Pattern 9 — Negative Parallelism",
121
+ "terms": [
122
+ r"\bit'?s not just (about |)\b.*?it'?s\b",
123
+ r"\bnot (only|merely|just)\b.*?\bbut\b",
124
+ ],
125
+ },
126
+ "false_ranges": {
127
+ "label": "Pattern 12 — False Ranges",
128
+ "terms": [
129
+ r"\bfrom .{5,40} to .{5,40}, from\b",
130
+ ],
131
+ },
132
+ "hyphen_overuse": {
133
+ "label": "Pattern 25 — Hyphenated Word Pair Overuse",
134
+ "terms": [
135
+ r"\bcross-functional\b", r"\bdata-driven\b", r"\bclient-facing\b",
136
+ r"\bdecision-making\b", r"\bwell-known\b", r"\bhigh-quality\b",
137
+ r"\breal-time\b", r"\blong-term\b", r"\bend-to-end\b",
138
+ r"\bthird-party\b",
139
+ ],
140
+ },
141
+ }
142
+
143
+ # ─── DNA Protection Terms (never flag these) ─────────────────────────────────
144
+
145
+ DNA_PROTECTED_PHRASES = [
146
+ "but here's",
147
+ "the hardest part wasn't",
148
+ "that is what",
149
+ "did to me from the inside",
150
+ "and you start to see",
151
+ ]
152
+
153
+ # ─── Scanner ──────────────────────────────────────────────────────────────────
154
+
155
+ def is_dna_protected(line: str) -> bool:
156
+ line_lower = line.lower()
157
+ return any(phrase in line_lower for phrase in DNA_PROTECTED_PHRASES)
158
+
159
+
160
+ def scan_file(filepath: str) -> dict:
161
+ path = Path(filepath)
162
+ if not path.exists():
163
+ print(f"Error: File not found: {filepath}", file=sys.stderr)
164
+ sys.exit(1)
165
+
166
+ text = path.read_text(encoding="utf-8")
167
+ lines = text.splitlines()
168
+
169
+ results = {
170
+ "file": str(path),
171
+ "total_lines": len(lines),
172
+ "total_words": len(text.split()),
173
+ "findings": [],
174
+ "pattern_counts": {},
175
+ "total_flags": 0,
176
+ "clean": True,
177
+ }
178
+
179
+ for pattern_key, pattern_data in PATTERNS.items():
180
+ label = pattern_data["label"]
181
+ terms = pattern_data["terms"]
182
+ threshold = pattern_data.get("count_threshold", 1)
183
+ pattern_findings = []
184
+
185
+ for line_num, line in enumerate(lines, start=1):
186
+ if is_dna_protected(line):
187
+ continue
188
+
189
+ line_lower = line.lower()
190
+ matched_terms = []
191
+
192
+ for term in terms:
193
+ matches = re.findall(term, line_lower)
194
+ if matches:
195
+ matched_terms.extend(matches)
196
+
197
+ if matched_terms:
198
+ # For em dash, only flag if count exceeds threshold in paragraph
199
+ if pattern_key == "em_dash_overuse":
200
+ count = line.count("—")
201
+ if count < threshold:
202
+ continue
203
+
204
+ pattern_findings.append({
205
+ "line": line_num,
206
+ "text": line.strip()[:120],
207
+ "matched": list(set(matched_terms)),
208
+ })
209
+
210
+ if pattern_findings:
211
+ results["findings"].append({
212
+ "pattern": label,
213
+ "key": pattern_key,
214
+ "count": len(pattern_findings),
215
+ "instances": pattern_findings,
216
+ })
217
+ results["pattern_counts"][pattern_key] = len(pattern_findings)
218
+ results["total_flags"] += len(pattern_findings)
219
+ results["clean"] = False
220
+
221
+ return results
222
+
223
+
224
+ def print_report(results: dict):
225
+ print(f"\n{'═' * 60}")
226
+ print(f" AI PATTERN SCAN REPORT")
227
+ print(f" File: {results['file']}")
228
+ print(f" Words: {results['total_words']:,} | Lines: {results['total_lines']:,}")
229
+ print(f"{'═' * 60}")
230
+
231
+ if results["clean"]:
232
+ print("\n ✓ No AI patterns detected. Text is clean.\n")
233
+ return
234
+
235
+ print(f"\n Total flags: {results['total_flags']}")
236
+ print(f" Patterns triggered: {len(results['findings'])}\n")
237
+
238
+ for finding in results["findings"]:
239
+ print(f" {'─' * 56}")
240
+ print(f" {finding['pattern']} ({finding['count']} instance{'s' if finding['count'] > 1 else ''})")
241
+ for instance in finding["instances"][:5]: # cap at 5 per pattern
242
+ print(f" Line {instance['line']:>4}: {instance['text'][:90]}")
243
+ print(f" Matched: {', '.join(instance['matched'][:3])}")
244
+ if len(finding["instances"]) > 5:
245
+ print(f" ... and {len(finding['instances']) - 5} more")
246
+
247
+ print(f"\n{'═' * 60}")
248
+ print(f" Run humanizer pass to fix {results['total_flags']} flag(s).\n")
249
+
250
+
251
+ def print_summary(results: dict):
252
+ print(f"\n {results['file']} — {results['total_words']:,} words")
253
+ if results["clean"]:
254
+ print(" ✓ Clean — no AI patterns found\n")
255
+ return
256
+ print(f" ✗ {results['total_flags']} flags across {len(results['findings'])} patterns\n")
257
+ for finding in results["findings"]:
258
+ print(f" {str(finding['count']).rjust(3)}x {finding['pattern']}")
259
+ print()
260
+
261
+
262
+ # ─── Main ─────────────────────────────────────────────────────────────────────
263
+
264
+ if __name__ == "__main__":
265
+ if len(sys.argv) < 2:
266
+ print("Usage: python scan_ai_patterns.py <input_file> [--json|--summary]")
267
+ sys.exit(1)
268
+
269
+ filepath = sys.argv[1]
270
+ mode = sys.argv[2] if len(sys.argv) > 2 else "--report"
271
+
272
+ results = scan_file(filepath)
273
+
274
+ if mode == "--json":
275
+ print(json.dumps(results, indent=2))
276
+ elif mode == "--summary":
277
+ print_summary(results)
278
+ else:
279
+ print_report(results)
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ score_report.py — Generate and save a structured chapter audit report
4
+
5
+ Takes audit scores as input and produces a formatted report.
6
+ Can also read a JSON audit file and re-format it.
7
+
8
+ Usage:
9
+ # Interactive scoring
10
+ python score_report.py --chapter "Chapter 1: Why It Hurts So Much"
11
+
12
+ # From JSON input
13
+ python score_report.py --input audit.json
14
+
15
+ # Output formats
16
+ python score_report.py --chapter "Title" --output report.md
17
+ python score_report.py --input audit.json --json
18
+ """
19
+
20
+ import sys
21
+ import json
22
+ import argparse
23
+ from pathlib import Path
24
+ from datetime import datetime
25
+
26
+ # ─── Scoring Constants ────────────────────────────────────────────────────────
27
+
28
+ DIMENSIONS = [
29
+ {
30
+ "key": "voice_authenticity",
31
+ "label": "Voice Authenticity",
32
+ "description": "Opens with scene/confession, immersive My Story, oscillates I/you in Reflection, no LinkedIn sentences",
33
+ "weight": 1,
34
+ },
35
+ {
36
+ "key": "arc_quality",
37
+ "label": "Pain-to-Transformation Arc",
38
+ "description": "Pain entered fully, wrong attempts shown, realization from within, perspective shift not tidy fix, closing reframes",
39
+ "weight": 1,
40
+ },
41
+ {
42
+ "key": "sentence_rhythm",
43
+ "label": "Sentence Rhythm",
44
+ "description": "Mix of short punchy and long flowing, varied paragraph length, rhetorical questions as pivots",
45
+ "weight": 1,
46
+ },
47
+ {
48
+ "key": "imagery_quality",
49
+ "label": "Metaphor & Imagery",
50
+ "description": "Physical/grounded images, specific enough to visualize, no recycled metaphors, min 2 strong images",
51
+ "weight": 1,
52
+ },
53
+ {
54
+ "key": "structural_integrity",
55
+ "label": "Structural Integrity",
56
+ "description": "Sections demarcated, each paragraph has one beat, no filler, within word count target",
57
+ "weight": 1,
58
+ },
59
+ {
60
+ "key": "ai_contamination",
61
+ "label": "AI Pattern Contamination",
62
+ "description": "Clean of AI vocabulary, significance inflation, -ing tack-ons, chatbot residue (5=clean, 1=heavy)",
63
+ "weight": 1,
64
+ },
65
+ {
66
+ "key": "reader_resonance",
67
+ "label": "Reader Resonance",
68
+ "description": "Reader's pain named accurately, 'how did he know' moment present, no condescension, author in-process",
69
+ "weight": 1,
70
+ },
71
+ ]
72
+
73
+ VERDICTS = {
74
+ (28, 35): ("PASS", "✓", "Proceed to Humanizer (Module 6D)"),
75
+ (21, 27): ("CONDITIONAL PASS", "~", "Proceed to Humanizer, address Required Fixes after"),
76
+ (0, 20): ("REVISE", "✗", "Return to Chapter Writer (Module 6B) with fix list"),
77
+ }
78
+
79
+
80
+ def get_verdict(total: int) -> tuple:
81
+ for (lo, hi), (label, icon, action) in VERDICTS.items():
82
+ if lo <= total <= hi:
83
+ return label, icon, action
84
+ return "REVISE", "✗", "Return to Chapter Writer"
85
+
86
+
87
+ def score_to_label(score: int) -> str:
88
+ labels = {5: "Excellent", 4: "Good", 3: "Needs work", 2: "Significant issues", 1: "Failing"}
89
+ return labels.get(score, "Unknown")
90
+
91
+
92
+ def build_report(chapter_title: str, scores: dict, word_count: int = 0,
93
+ target_words: int = 0, notes: dict = None) -> dict:
94
+ notes = notes or {}
95
+ total = sum(scores.get(d["key"], 0) for d in DIMENSIONS)
96
+ max_score = len(DIMENSIONS) * 5
97
+ verdict, icon, action = get_verdict(total)
98
+
99
+ required_fixes = []
100
+ recommended = []
101
+
102
+ for dim in DIMENSIONS:
103
+ key = dim["key"]
104
+ score = scores.get(key, 0)
105
+ note = notes.get(key, "")
106
+ if score <= 3:
107
+ required_fixes.append({
108
+ "dimension": dim["label"],
109
+ "score": score,
110
+ "note": note or f"Score {score}/5 — {score_to_label(score)}",
111
+ })
112
+ elif score == 4 and note:
113
+ recommended.append({
114
+ "dimension": dim["label"],
115
+ "score": score,
116
+ "note": note,
117
+ })
118
+
119
+ return {
120
+ "chapter": chapter_title,
121
+ "timestamp": datetime.now().isoformat(),
122
+ "word_count": word_count,
123
+ "target_words": target_words,
124
+ "scores": {d["key"]: scores.get(d["key"], 0) for d in DIMENSIONS},
125
+ "total": total,
126
+ "max_score": max_score,
127
+ "verdict": verdict,
128
+ "icon": icon,
129
+ "action": action,
130
+ "required_fixes": required_fixes,
131
+ "recommended": recommended,
132
+ }
133
+
134
+
135
+ def format_report(report: dict) -> str:
136
+ lines = []
137
+ lines.append(f"\n{'═' * 60}")
138
+ lines.append(f" CHAPTER AUDIT — {report['chapter']}")
139
+ if report.get("word_count"):
140
+ wc_line = f" Words: {report['word_count']:,}"
141
+ if report.get("target_words"):
142
+ wc_line += f" / Target: {report['target_words']:,}"
143
+ lines.append(wc_line)
144
+ lines.append(f"{'═' * 60}")
145
+ lines.append(f"\n SCORES")
146
+ lines.append(f" {'─' * 46}")
147
+
148
+ for dim in DIMENSIONS:
149
+ key = dim["key"]
150
+ score = report["scores"].get(key, 0)
151
+ bar = "█" * score + "░" * (5 - score)
152
+ lines.append(f" {dim['label']:<30} {bar} {score}/5")
153
+
154
+ lines.append(f" {'─' * 46}")
155
+ lines.append(f" {'OVERALL':<30} {report['total']}/{report['max_score']}")
156
+ lines.append(f"\n VERDICT: {report['icon']} {report['verdict']}")
157
+ lines.append(f" Action: {report['action']}")
158
+
159
+ if report["required_fixes"]:
160
+ lines.append(f"\n {'─' * 46}")
161
+ lines.append(f" REQUIRED FIXES ({len(report['required_fixes'])}):")
162
+ for i, fix in enumerate(report["required_fixes"], 1):
163
+ lines.append(f"\n {i}. {fix['dimension']} [{fix['score']}/5]")
164
+ if fix["note"]:
165
+ lines.append(f" {fix['note']}")
166
+
167
+ if report["recommended"]:
168
+ lines.append(f"\n RECOMMENDED IMPROVEMENTS:")
169
+ for rec in report["recommended"]:
170
+ lines.append(f" · {rec['dimension']}: {rec['note']}")
171
+
172
+ lines.append(f"\n{'═' * 60}\n")
173
+ return "\n".join(lines)
174
+
175
+
176
+ def interactive_score(chapter_title: str) -> dict:
177
+ """Prompt for scores interactively."""
178
+ print(f"\n Scoring: {chapter_title}\n")
179
+ scores = {}
180
+ notes = {}
181
+
182
+ for dim in DIMENSIONS:
183
+ while True:
184
+ try:
185
+ val = input(f" {dim['label']} [1-5]: ").strip()
186
+ score = int(val)
187
+ if 1 <= score <= 5:
188
+ scores[dim["key"]] = score
189
+ if score <= 3:
190
+ note = input(f" Issue note (enter to skip): ").strip()
191
+ if note:
192
+ notes[dim["key"]] = note
193
+ break
194
+ else:
195
+ print(" Enter a number between 1 and 5")
196
+ except (ValueError, EOFError):
197
+ print(" Invalid input")
198
+ break
199
+
200
+ wc = input("\n Chapter word count (enter to skip): ").strip()
201
+ target = input(" Target word count (enter to skip): ").strip()
202
+
203
+ return build_report(
204
+ chapter_title,
205
+ scores,
206
+ int(wc) if wc.isdigit() else 0,
207
+ int(target) if target.isdigit() else 0,
208
+ notes,
209
+ )
210
+
211
+
212
+ if __name__ == "__main__":
213
+ parser = argparse.ArgumentParser(description="Generate chapter audit report")
214
+ parser.add_argument("--chapter", help="Chapter title")
215
+ parser.add_argument("--input", help="Load scores from JSON file")
216
+ parser.add_argument("--output", help="Save report to file")
217
+ parser.add_argument("--json", action="store_true", help="Output JSON")
218
+ args = parser.parse_args()
219
+
220
+ if args.input:
221
+ report = json.loads(Path(args.input).read_text())
222
+ elif args.chapter:
223
+ report = interactive_score(args.chapter)
224
+ else:
225
+ print("Error: provide --chapter or --input", file=sys.stderr)
226
+ sys.exit(1)
227
+
228
+ if args.json:
229
+ output = json.dumps(report, indent=2)
230
+ print(output)
231
+ else:
232
+ output = format_report(report)
233
+ print(output)
234
+
235
+ if args.output:
236
+ Path(args.output).write_text(output if not args.json else json.dumps(report, indent=2))
237
+ print(f" Saved to: {args.output}")
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ toc_extract.py — Extract table of contents from a manuscript
4
+
5
+ Detects chapter headings, part dividers, and section structure.
6
+ Outputs formatted TOC or JSON.
7
+
8
+ Usage:
9
+ python toc_extract.py <manuscript_file>
10
+ python toc_extract.py <manuscript_file> --json
11
+ python toc_extract.py <manuscript_file> --markdown
12
+ """
13
+
14
+ import sys
15
+ import re
16
+ import json
17
+ from pathlib import Path
18
+
19
+ PART_PATTERN = re.compile(r"^(Part\s+(I{1,4}|[0-9]+)|PART\s+\w+)[:\s]?", re.IGNORECASE)
20
+ CHAPTER_PATTERN = re.compile(r"^(Chapter\s+\d+|CHAPTER\s+\d+|#{1,2}\s+Chapter)", re.IGNORECASE)
21
+ INTRO_PATTERN = re.compile(r"^(Introduction|Foreword|Preface|Prologue|How to Use)", re.IGNORECASE)
22
+ OUTRO_PATTERN = re.compile(r"^(Conclusion|Epilogue|Afterword|About the Author|Acknowledgments)", re.IGNORECASE)
23
+ HEADING_PATTERN = re.compile(r"^#{1,3}\s+(.+)")
24
+
25
+
26
+ def extract_toc(filepath: str) -> dict:
27
+ path = Path(filepath)
28
+ if not path.exists():
29
+ print(f"Error: File not found: {filepath}", file=sys.stderr)
30
+ sys.exit(1)
31
+
32
+ lines = path.read_text(encoding="utf-8").splitlines()
33
+
34
+ toc = {
35
+ "front_matter": [],
36
+ "parts": [],
37
+ "back_matter": [],
38
+ "ungrouped_chapters": [],
39
+ }
40
+
41
+ current_part = None
42
+ chapter_num = 0
43
+
44
+ for line_num, line in enumerate(lines, 1):
45
+ stripped = line.strip()
46
+ if not stripped:
47
+ continue
48
+
49
+ if INTRO_PATTERN.match(stripped):
50
+ toc["front_matter"].append({
51
+ "title": stripped.lstrip("#").strip(),
52
+ "line": line_num,
53
+ "type": "front",
54
+ })
55
+
56
+ elif OUTRO_PATTERN.match(stripped):
57
+ toc["back_matter"].append({
58
+ "title": stripped.lstrip("#").strip(),
59
+ "line": line_num,
60
+ "type": "back",
61
+ })
62
+
63
+ elif PART_PATTERN.match(stripped):
64
+ current_part = {
65
+ "title": stripped.lstrip("#").strip(),
66
+ "line": line_num,
67
+ "chapters": [],
68
+ }
69
+ toc["parts"].append(current_part)
70
+
71
+ elif CHAPTER_PATTERN.match(stripped) or (HEADING_PATTERN.match(stripped) and stripped.startswith("#")):
72
+ chapter_num += 1
73
+ title = stripped.lstrip("#").strip()
74
+ chapter = {
75
+ "number": chapter_num,
76
+ "title": title,
77
+ "line": line_num,
78
+ }
79
+ if current_part:
80
+ current_part["chapters"].append(chapter)
81
+ else:
82
+ toc["ungrouped_chapters"].append(chapter)
83
+
84
+ return toc
85
+
86
+
87
+ def print_toc(toc: dict):
88
+ print(f"\n{'═' * 60}")
89
+ print(f" TABLE OF CONTENTS")
90
+ print(f"{'═' * 60}\n")
91
+
92
+ if toc["front_matter"]:
93
+ for item in toc["front_matter"]:
94
+ print(f" {item['title']}")
95
+ print()
96
+
97
+ if toc["parts"]:
98
+ for part in toc["parts"]:
99
+ print(f" {part['title'].upper()}")
100
+ for ch in part["chapters"]:
101
+ print(f" Chapter {ch['number']}: {ch['title']}")
102
+ print()
103
+ elif toc["ungrouped_chapters"]:
104
+ for ch in toc["ungrouped_chapters"]:
105
+ print(f" Chapter {ch['number']}: {ch['title']}")
106
+ print()
107
+
108
+ if toc["back_matter"]:
109
+ for item in toc["back_matter"]:
110
+ print(f" {item['title']}")
111
+ print()
112
+
113
+ total = sum(len(p["chapters"]) for p in toc["parts"]) + len(toc["ungrouped_chapters"])
114
+ print(f" Total chapters: {total}")
115
+ print(f"{'═' * 60}\n")
116
+
117
+
118
+ def print_markdown_toc(toc: dict):
119
+ print("## Table of Contents\n")
120
+ for item in toc["front_matter"]:
121
+ print(f"- {item['title']}")
122
+ if toc["parts"]:
123
+ for part in toc["parts"]:
124
+ print(f"\n### {part['title']}")
125
+ for ch in part["chapters"]:
126
+ print(f"- Chapter {ch['number']}: {ch['title']}")
127
+ else:
128
+ for ch in toc["ungrouped_chapters"]:
129
+ print(f"- Chapter {ch['number']}: {ch['title']}")
130
+ if toc["back_matter"]:
131
+ print()
132
+ for item in toc["back_matter"]:
133
+ print(f"- {item['title']}")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ if len(sys.argv) < 2:
138
+ print("Usage: python toc_extract.py <file> [--json|--markdown]")
139
+ sys.exit(1)
140
+
141
+ filepath = sys.argv[1]
142
+ mode = sys.argv[2] if len(sys.argv) > 2 else "--report"
143
+
144
+ toc = extract_toc(filepath)
145
+
146
+ if mode == "--json":
147
+ print(json.dumps(toc, indent=2))
148
+ elif mode == "--markdown":
149
+ print_markdown_toc(toc)
150
+ else:
151
+ print_toc(toc)