Pointf5ive commited on
Commit
7064f8d
·
verified ·
1 Parent(s): 2dbd364

Update src/codex_extractor.py

Browse files
Files changed (1) hide show
  1. src/codex_extractor.py +782 -330
src/codex_extractor.py CHANGED
@@ -55,8 +55,28 @@ QA Fixes applied (v1.1 — engineering handoff 13/05/2026):
55
  with partial-match threshold
56
  Fix 10 — QA threshold flags: contradiction detection for known bad patterns
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  Author: TOTEM Studio — Jamal Romeh
59
- Version: 1.1
60
  """
61
 
62
  from __future__ import annotations
@@ -135,9 +155,61 @@ SENTENCE_END_PATTERN = re.compile(r"[.!?]+")
135
  EXCLAMATION_PATTERN = re.compile(r"!")
136
  QUESTION_PATTERN = re.compile(r"\?")
137
 
138
- # ── FIX 8: QUOTE NORMALISATION ────────────────────────────────────────────────
139
- # All quote variants normalised to straight double-quotes before any processing.
140
- # Handles: curly open/close, OCR ligature variants, backticks, guillemets.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  QUOTE_OPEN_PATTERN = re.compile(
143
  r'[\u201C\u201F\u00AB\u2039\u275D\u276E`\u201E]'
@@ -177,40 +249,30 @@ FRONT_MATTER_SIGNALS = re.compile(
177
  # OCR noise / watermark patterns to strip from individual lines.
178
  OCR_NOISE_PATTERNS = [
179
  re.compile(r'ppsbook\.com', re.IGNORECASE),
180
- re.compile(r'绘本在线论坛', re.UNICODE), # Chinese watermark visible in test PDF
181
  re.compile(r'\bisbn\b[\d\s\-]+', re.IGNORECASE),
182
- re.compile(r'^\s*\d{1,4}\s*$'), # Lone page numbers
183
- re.compile(r'^\s*[©®™]\s*.*$', re.MULTILINE), # Bare copyright symbol lines
184
- re.compile(r'^\s*[A-Z][a-z]+ [A-Z][a-z]+\s*$'), # "Firstname Lastname" bylines (2-word only)
185
  ]
186
 
187
- # Lines this short (in tokens) are almost certainly OCR artefacts when they
188
- # consist only of non-alphabetic characters or a single isolated symbol.
189
  MIN_LINE_TOKENS_FOR_METRICS = 2
190
 
191
 
192
  def is_front_matter_page(page_text: str) -> bool:
193
  """
194
- Fix 2: Return True if a page looks like front matter (title/copyright/
195
- dedication) rather than story text.
196
-
197
- Heuristic: page contains a front-matter signal keyword AND has fewer
198
- than 60 alphabetic words (story pages have more).
199
  """
200
  if not page_text:
201
  return False
202
  word_count = len(re.findall(r'[a-zA-Z]+', page_text))
203
  if word_count > 80:
204
- # A page with 80+ real words is almost certainly story content.
205
  return False
206
  return bool(FRONT_MATTER_SIGNALS.search(page_text))
207
 
208
 
209
  def strip_ocr_noise_from_line(line: str) -> str:
210
- """
211
- Fix 3: Remove known OCR noise patterns from a single line.
212
- Returns cleaned line; may return empty string if fully stripped.
213
- """
214
  for pattern in OCR_NOISE_PATTERNS:
215
  line = pattern.sub('', line)
216
  return line.strip()
@@ -218,13 +280,7 @@ def strip_ocr_noise_from_line(line: str) -> str:
218
 
219
  def is_artefact_line(line: str) -> bool:
220
  """
221
- Fix 3 + 4: Return True if a line is an OCR artefact or structural noise
222
- that should be excluded from verse-line metrics.
223
-
224
- Criteria:
225
- - Fewer than MIN_LINE_TOKENS_FOR_METRICS alphabetic tokens
226
- - Entirely non-alphabetic (numbers, punctuation, symbols)
227
- - Looks like a watermark or byline already stripped to a fragment
228
  """
229
  tokens = re.findall(r'[a-zA-Z]{2,}', line)
230
  if len(tokens) < MIN_LINE_TOKENS_FOR_METRICS:
@@ -246,10 +302,12 @@ def extract_text_from_pdf_ocr(
246
  pdf_path: str | Path,
247
  start_page: int | None = None,
248
  end_page: int | None = None,
249
- ) -> tuple[str, str]:
250
  """
251
  OCR fallback for scanned/image-only PDFs.
252
- Returns (story_text, raw_text) using the same page-filter logic as native extraction.
 
 
253
  """
254
  if not PDFIUM_AVAILABLE:
255
  raise RuntimeError("OCR fallback unavailable: pypdfium2 is not installed.")
@@ -265,6 +323,7 @@ def extract_text_from_pdf_ocr(
265
 
266
  raw_parts: list[str] = []
267
  story_parts: list[str] = []
 
268
 
269
  pdf = pdfium.PdfDocument(str(pdf_path))
270
  total_pages = min(len(pdf), max_pages)
@@ -280,53 +339,64 @@ def extract_text_from_pdf_ocr(
280
  pil_image = bitmap.to_pil()
281
  page_text = pytesseract.image_to_string(pil_image, lang=ocr_lang, config=ocr_config) or ""
282
  raw_parts.append(page_text)
 
 
 
 
 
283
 
284
  if start_page is not None or end_page is not None:
285
  if idx_start <= page_idx <= idx_end:
286
  story_parts.append(page_text)
287
- continue
288
-
289
- if total_pages > 1 and page_idx == 0:
290
- continue
291
- if is_front_matter_page(page_text):
292
- continue
293
- story_parts.append(page_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
- return "\n".join(story_parts), "\n".join(raw_parts)
296
 
297
 
298
  def extract_text_from_pdf(
299
  pdf_path: str | Path,
300
  start_page: int | None = None,
301
  end_page: int | None = None,
302
- ) -> tuple[str, str]:
303
  """
304
- Fix 2 + 3: Extract text from a PDF file.
305
-
306
- Applies page filtering (skips front matter pages by default) and OCR
307
- cleanup per page.
308
 
309
- Args:
310
- pdf_path: Path to the PDF.
311
- start_page: 1-based page index to start extraction (inclusive).
312
- If None, automatic front-matter detection is used.
313
- end_page: 1-based page index to end extraction (inclusive).
314
- If None, extraction runs to the last page.
315
-
316
- Returns:
317
- Tuple of (cleaned_story_text, raw_ocr_text).
318
- raw_ocr_text is the unmodified concatenation of all pages.
319
  """
320
  if not PDF_AVAILABLE:
321
  raise RuntimeError("pdfplumber is not installed. Add it to requirements.txt.")
322
 
323
  raw_parts: list[str] = []
324
  story_parts: list[str] = []
 
325
 
326
  with pdfplumber.open(str(pdf_path)) as pdf:
327
  total_pages = len(pdf.pages)
328
 
329
- # Resolve user-specified range (convert 1-based to 0-based indices)
330
  idx_start = (start_page - 1) if start_page is not None else 0
331
  idx_end = (end_page - 1) if end_page is not None else (total_pages - 1)
332
  idx_start = max(0, idx_start)
@@ -335,85 +405,138 @@ def extract_text_from_pdf(
335
  for page_idx, page in enumerate(pdf.pages):
336
  page_text = page.extract_text() or ""
337
  raw_parts.append(page_text)
 
 
 
 
 
338
 
339
- # If user supplied a range, honour it strictly.
340
  if start_page is not None or end_page is not None:
341
  if idx_start <= page_idx <= idx_end:
342
  story_parts.append(page_text)
343
- continue
344
-
345
- # Automatic front-matter detection (Fix 2).
346
- # Always skip the first page (cover).
347
- if total_pages > 1 and page_idx == 0:
348
- continue
349
- if is_front_matter_page(page_text):
350
- continue
351
-
352
- story_parts.append(page_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
 
354
  raw_text = "\n".join(raw_parts)
355
  story_text = "\n".join(story_parts)
356
 
357
- # If native text-layer extraction looks healthy, use it.
358
  if _word_count(story_text) >= 20:
359
- return story_text, raw_text
360
 
361
- # Otherwise, try OCR fallback for scanned/image-only pages.
362
  try:
363
- ocr_story_text, ocr_raw_text = extract_text_from_pdf_ocr(
364
- pdf_path,
365
- start_page=start_page,
366
- end_page=end_page,
367
  )
368
  if _word_count(ocr_story_text) >= max(20, _word_count(story_text)):
369
- return ocr_story_text, ocr_raw_text
 
 
 
 
 
370
  except Exception:
371
- # Fall through: process_upload will raise a clear error if text is still insufficient.
372
  pass
373
 
374
- return story_text, raw_text
375
 
376
 
377
  def extract_text_from_file(
378
  file_path: str | Path,
379
  start_page: int | None = None,
380
  end_page: int | None = None,
381
- ) -> tuple[str, str]:
382
  """
383
  Extract text from PDF or plain text file.
384
 
385
- Returns (story_text, raw_text). For plain text files, both are identical.
 
386
  """
387
  path = Path(file_path)
388
  if path.suffix.lower() == ".pdf":
389
  return extract_text_from_pdf(path, start_page=start_page, end_page=end_page)
390
  else:
391
  content = path.read_text(encoding="utf-8", errors="replace")
392
- return content, content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
- # ── FIX 3 + 4: TEXT CLEANING AND VERSE-LINE NORMALISATION ───────────────────
396
 
397
  def clean_text(raw: str) -> str:
398
  """
399
- Fix 3 + 4: Deep cleaning pipeline.
400
 
401
  Order of operations:
402
- 1. Quote normalisation (Fix 8) — must run before any other text work.
403
  2. Line-ending normalisation.
404
  3. Strip per-line OCR noise.
405
  4. Remove artefact lines.
406
- 5. Collapse excessive blank lines.
407
- 6. Normalise whitespace within lines.
 
408
  """
409
- # Step 1: normalise quotes before any other processing
410
  text = normalise_quotes(raw)
411
-
412
- # Step 2: normalise line endings
413
  text = re.sub(r"\r\n", "\n", text)
414
  text = re.sub(r"\r", "\n", text)
415
 
416
- # Step 3 + 4: clean each line
417
  cleaned_lines: list[str] = []
418
  for line in text.split("\n"):
419
  line = strip_ocr_noise_from_line(line)
@@ -425,12 +548,12 @@ def clean_text(raw: str) -> str:
425
  continue
426
  cleaned_lines.append(line)
427
 
428
- text = "\n".join(cleaned_lines)
 
429
 
430
- # Step 5: collapse multiple blank lines to a single separator
431
  text = re.sub(r"\n{3,}", "\n\n", text)
432
 
433
- # Step 6: normalise intra-line whitespace
434
  lines_out = []
435
  for line in text.split("\n"):
436
  lines_out.append(re.sub(r"[ \t]+", " ", line).strip())
@@ -440,7 +563,7 @@ def clean_text(raw: str) -> str:
440
  # ── SYLLABLE COUNTING ─────────────────────────────────────────────────────────
441
 
442
  def count_syllables_cmu(word: str) -> int | None:
443
- """Count syllables using CMU Pronouncing Dictionary. Returns None if not found."""
444
  word_lower = word.lower().strip(string.punctuation)
445
  if word_lower in CMU_DICT:
446
  pronunciation = CMU_DICT[word_lower][0]
@@ -451,12 +574,13 @@ def count_syllables_cmu(word: str) -> int | None:
451
  def count_syllables_fallback(word: str) -> int:
452
  """
453
  Fallback syllable counter using vowel-group heuristic.
454
- Less accurate than CMU but works for any word including invented ones.
455
  """
456
  word = word.lower().strip(string.punctuation)
457
  if not word:
458
  return 0
459
- if word.endswith("e") and len(word) > 2:
 
460
  word = word[:-1]
461
  vowels = "aeiouy"
462
  count = 0
@@ -478,136 +602,193 @@ def count_syllables(word: str) -> int:
478
  return count_syllables_fallback(word)
479
 
480
 
481
- # ── FIX 7: PROPER-NOUN / INVENTED-WORD WHITELIST ────────────────────────────
 
 
 
 
 
482
 
483
- # Default whitelist of known fantasy/proper terms common in children's
484
- # picture books that would otherwise be flagged as invented words.
485
- # Authors can extend this list via the `extra_whitelist` parameter.
486
  DEFAULT_INVENTED_WORD_WHITELIST: set[str] = {
487
- # The Gruffalo-specific terms
488
- "gruffalo", "gruffalos",
489
- # Common picture-book character name fragments and genre proper nouns
490
- # that appear frequently across children's texts but aren't in CMU dict
 
 
491
  "mummy", "daddy", "yummy", "tummy",
 
 
 
 
492
  }
493
 
494
 
495
  def is_ocr_gibberish(word: str) -> bool:
496
  """
497
- Fix 7: Return True if a word looks like OCR noise rather than a real
498
- or intentionally invented word.
499
 
500
- Heuristics:
501
- - Contains three or more consecutive consonants not in any known cluster
502
- - Mix of letters and digits
503
  - Very short with unusual character combination
504
- - All-caps fragment (likely header/watermark residue)
505
  """
506
  if not word or len(word) < 2:
507
  return True
508
- # Mixed alphanumeric that isn't a known abbreviation
509
  if re.search(r'[a-z]\d|\d[a-z]', word.lower()):
510
  return True
511
- # Runs of 4+ consonants (excluding common clusters like "str", "scr")
512
  if re.search(r'[bcdfghjklmnpqrstvwxyz]{5,}', word.lower()):
513
  return True
514
- # All-caps 2+ char fragments (likely OCR header noise)
515
  if word.isupper() and len(word) >= 3 and not word.isalpha():
516
  return True
 
 
 
517
  return False
518
 
519
 
520
- def is_known_word(word: str, extra_whitelist: set[str] | None = None) -> bool:
 
 
 
 
 
 
521
  """
522
- Fix 7: Return True if word is known (CMU dict) or whitelisted.
 
523
 
524
- Also returns True for OCR gibberish so those tokens don't inflate
525
- the invented-word count — they are separately handled in OCR cleanup.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  """
527
  word_lower = word.lower().strip(string.punctuation)
528
  if not word_lower or not word_lower.isalpha():
529
- return True # Don't flag numbers/punctuation as invented
 
 
 
530
 
531
- # OCR gibberish is not counted as an intentional invented word
532
  if is_ocr_gibberish(word_lower):
533
- return True
 
534
 
535
- # Whitelist check
536
  whitelist = DEFAULT_INVENTED_WORD_WHITELIST.copy()
537
  if extra_whitelist:
538
  whitelist.update(w.lower() for w in extra_whitelist)
539
  if word_lower in whitelist:
540
- return True
541
 
542
- # Proper nouns (title-cased in original, e.g. character names)
543
- # We treat any title-cased word > 3 chars as likely a proper noun
544
- if word[0].isupper() and len(word) > 3:
545
- return True
 
546
 
547
  if NLTK_AVAILABLE and CMU_DICT:
548
- return word_lower in CMU_DICT
 
 
549
 
550
- return True
 
 
551
 
552
 
553
  # ── FIX 5: VERSE-LINE CANDIDATE SELECTION ────────────────────────────────────
554
 
555
  def is_candidate_verse_line(line: str) -> bool:
556
  """
557
- Fix 5: Return True if a line is a plausible verse line for rhyme and
558
- syllable-per-line metrics.
559
-
560
- Excludes:
561
- - Lines fewer than 2 alphabetic tokens (artefacts already removed in
562
- clean_text, but belt-and-braces here)
563
- - Lines that look like speaker tags / dialogue attribution
564
- e.g. '"Fox said.' or 'said the mouse.'
565
- - Lines that are purely punctuation
566
  """
567
  tokens = re.findall(r'[a-zA-Z]{2,}', line)
568
- if len(tokens) < 2:
569
- return False
570
- return True
571
 
572
 
573
- # ── RHYME DETECTION ───────────────────────────────────────────────────────────
574
 
575
  def get_rhyme_signature(word: str) -> str | None:
576
  """
577
- Get the rhyme signature of a word using CMU dict (final vowel + consonants).
578
- Returns None if word not in CMU dict.
 
 
 
579
  """
580
  word_lower = word.lower().strip(string.punctuation)
581
- if not word_lower or not NLTK_AVAILABLE or word_lower not in CMU_DICT:
582
- return None
583
- pronunciation = CMU_DICT[word_lower][0]
584
- last_vowel_idx = None
585
- for i, ph in enumerate(pronunciation):
586
- if ph[-1].isdigit():
587
- last_vowel_idx = i
588
- if last_vowel_idx is None:
589
  return None
590
- return " ".join(pronunciation[last_vowel_idx:])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
 
592
 
593
  def words_rhyme(word1: str, word2: str) -> bool:
594
- """Return True if two words rhyme based on CMU pronunciation."""
595
- sig1 = get_rhyme_signature(word1)
596
- sig2 = get_rhyme_signature(word2)
597
- if sig1 and sig2 and sig1 == sig2 and word1.lower() != word2.lower():
598
- return True
 
599
  w1 = word1.lower().strip(string.punctuation)
600
  w2 = word2.lower().strip(string.punctuation)
601
- if len(w1) >= 2 and len(w2) >= 2 and w1 != w2:
602
- return w1[-2:] == w2[-2:]
 
 
 
 
603
  return False
604
 
605
 
 
 
 
 
 
 
 
 
 
 
 
606
  def get_candidate_verse_line_endings(
607
  text: str,
608
  ) -> tuple[list[str], list[dict]]:
609
  """
610
- Fix 5: Extract last words from candidate verse lines only.
 
 
 
611
 
612
  Returns:
613
  end_words: list of final words from candidate lines.
@@ -616,34 +797,39 @@ def get_candidate_verse_line_endings(
616
  end_words: list[str] = []
617
  trace: list[dict] = []
618
 
619
- for line_num, line in enumerate(text.split("\n"), start=1):
620
- line = line.strip()
621
- if not line:
622
  continue
623
 
624
  excluded = False
625
  exclusion_reason = ""
626
 
627
- if not is_candidate_verse_line(line):
628
  excluded = True
629
  exclusion_reason = "too_short_or_artefact"
630
 
 
631
  final_word = ""
 
 
632
  if not excluded:
633
- tokens = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
634
  if tokens:
635
  final_word = tokens[-1]
 
 
636
  end_words.append(final_word)
637
  else:
638
  excluded = True
639
- exclusion_reason = "no_alpha_tokens"
640
 
641
- rhyme_key = get_rhyme_signature(final_word) if final_word else ""
642
  trace.append({
643
  "line_number": line_num,
644
- "line": line,
 
645
  "final_word": final_word,
646
- "rhyme_key": rhyme_key or "",
647
  "excluded": excluded,
648
  "exclusion_reason": exclusion_reason,
649
  })
@@ -651,37 +837,92 @@ def get_candidate_verse_line_endings(
651
  return end_words, trace
652
 
653
 
654
- def compute_rhyme_density(end_words: list[str]) -> float:
 
 
655
  """
656
- Compute proportion of adjacent line-end pairs that rhyme.
657
- Returns float 0–1.
 
 
 
 
 
 
 
658
  """
659
  if len(end_words) < 2:
660
- return 0.0
661
- pairs = [(end_words[i], end_words[i + 1]) for i in range(len(end_words) - 1)]
662
- rhyming = sum(1 for w1, w2 in pairs if words_rhyme(w1, w2))
663
- return round(rhyming / len(pairs), 3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
 
665
 
666
- # ── FIX 6: STANZA-WINDOW RHYME SCHEME CLASSIFICATION ────────────────────────
667
 
668
- def detect_rhyme_scheme(end_words: list[str]) -> str:
669
  """
670
- Fix 6: Classify rhyme scheme using stanza windows rather than a single
671
- global adjacent-pair pass.
672
 
673
- Splits end_words into groups of 4 (one stanza), scores each stanza
674
- for AABB / ABAB / ABCB pattern, then reports the dominant pattern
675
- across all stanzas. Falls back to density-based free/mixed only when
676
- no pattern wins.
 
 
 
 
 
677
  """
678
- if len(end_words) < 4:
679
- return "insufficient data"
680
 
681
  aabb_votes = 0
682
  abab_votes = 0
683
  abcb_votes = 0
684
- free_stanzas = 0
685
  total_stanzas = 0
686
 
687
  stanza_size = 4
@@ -690,15 +931,11 @@ def detect_rhyme_scheme(end_words: list[str]) -> str:
690
  if len(stanza) < 4:
691
  break
692
  total_stanzas += 1
693
-
694
  a, b, c, d = stanza
695
 
696
- # AABB: lines 0-1 rhyme AND lines 2-3 rhyme
697
- aabb = (words_rhyme(a, b) and words_rhyme(c, d))
698
- # ABAB: lines 0-2 rhyme AND lines 1-3 rhyme
699
- abab = (words_rhyme(a, c) and words_rhyme(b, d))
700
- # ABCB: lines 1-3 rhyme only
701
- abcb = (not words_rhyme(a, b) and words_rhyme(b, d))
702
 
703
  if aabb:
704
  aabb_votes += 1
@@ -706,33 +943,30 @@ def detect_rhyme_scheme(end_words: list[str]) -> str:
706
  abab_votes += 1
707
  elif abcb:
708
  abcb_votes += 1
709
- else:
710
- free_stanzas += 1
711
 
712
  if total_stanzas == 0:
713
- # Fewer than 4 complete stanzas — fall back to adjacent-pair density
714
- density = compute_rhyme_density(end_words)
715
- return "free" if density < 0.20 else "mixed"
716
-
717
- max_votes = max(aabb_votes, abab_votes, abcb_votes)
718
-
719
- if max_votes == 0:
720
- # No stanza matched a named scheme — use density to decide
721
- density = compute_rhyme_density(end_words)
722
- return "free" if density < 0.20 else "mixed"
723
-
724
- # Require that the winner accounts for at least 30% of stanzas,
725
- # otherwise classify as mixed.
726
- threshold = total_stanzas * 0.30
727
-
728
- if aabb_votes >= threshold and aabb_votes >= abab_votes and aabb_votes >= abcb_votes:
729
- return "AABB"
730
- elif abab_votes >= threshold and abab_votes >= aabb_votes and abab_votes >= abcb_votes:
731
- return "ABAB"
732
- elif abcb_votes >= threshold:
733
- return "ABCB"
734
- else:
735
- return "mixed"
736
 
737
 
738
  # ── STRESS / METRE ────────────────────────────────────────────────────────────
@@ -769,7 +1003,6 @@ def compute_stress_regularity(lines: list[str]) -> float:
769
  if not NLTK_AVAILABLE or not CMU_DICT:
770
  return -1.0
771
 
772
- # Fix 5: only use candidate verse lines for stress calculation
773
  candidate_lines = [l for l in lines if l.strip() and is_candidate_verse_line(l)]
774
  patterns = [get_stress_pattern(line) for line in candidate_lines]
775
  patterns = [p for p in patterns if len(p) >= 4]
@@ -819,10 +1052,7 @@ def tokenise_sentences(text: str) -> list[str]:
819
  # ── FLESCH-KINCAID ────────────────────────────────────────────────────────────
820
 
821
  def flesch_kincaid_grade(text: str, words: list[str], sentences: list[str]) -> float:
822
- """
823
- Compute Flesch-Kincaid Grade Level.
824
- FK = 0.39 * (words/sentences) + 11.8 * (syllables/words) - 15.59
825
- """
826
  if not words or not sentences:
827
  return -1.0
828
  total_syllables = sum(count_syllables(w) for w in words)
@@ -832,11 +1062,11 @@ def flesch_kincaid_grade(text: str, words: list[str], sentences: list[str]) -> f
832
  return round(max(0.0, fk), 2)
833
 
834
 
835
- # ── FIX 9: FUZZY REPETITION MATCHING ─────────────────────────────────────────
836
 
837
  def _normalise_line_for_repetition(line: str) -> str:
838
  """
839
- Fix 9: Normalise a line for fuzzy repetition matching.
840
  Lowercases, strips punctuation, collapses whitespace.
841
  """
842
  line = line.lower()
@@ -845,14 +1075,39 @@ def _normalise_line_for_repetition(line: str) -> str:
845
  return line
846
 
847
 
848
- def compute_repetition_index(lines: list[str], ngram_size: int = 3) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  """
850
- Fix 9: Proportion of lines that reuse an n-gram from a prior line,
851
- using normalised (lowercased, punctuation-stripped) line text.
 
 
 
852
 
853
- Also accepts partial matches: if any n-gram from the current line
854
- appeared in any prior line, the line counts as a repeat.
855
- Returns float 0–1.
856
  """
857
  candidate_lines = [
858
  _normalise_line_for_repetition(l)
@@ -861,49 +1116,69 @@ def compute_repetition_index(lines: list[str], ngram_size: int = 3) -> float:
861
  ]
862
 
863
  if len(candidate_lines) < 2:
864
- return 0.0
865
 
866
  seen_ngrams: set[tuple] = set()
 
867
  repeat_count = 0
 
868
 
869
- for line in candidate_lines:
870
  words = SIMPLE_TOKENISE_PATTERN.findall(line)
871
- if len(words) < ngram_size:
872
- # For very short lines, use bigrams instead
873
- ngram_size_local = max(2, len(words) - 1)
874
- else:
875
- ngram_size_local = ngram_size
876
 
 
 
877
  ngrams = [
878
  tuple(words[i: i + ngram_size_local])
879
  for i in range(len(words) - ngram_size_local + 1)
880
  ]
881
- line_has_repeat = any(ng in seen_ngrams for ng in ngrams)
882
- if line_has_repeat:
 
 
 
 
 
883
  repeat_count += 1
884
- seen_ngrams.update(ngrams)
 
 
 
 
 
 
 
 
 
 
885
 
886
- return round(repeat_count / len(candidate_lines), 3)
 
887
 
 
 
888
 
889
- # ── CUMULATIVE STRUCTURE ──────────────────────────────────────────────────────
890
 
891
- def compute_cumulative_structure(sentences: list[str]) -> float:
 
 
892
  """
893
- Fix 9: Proportion of sentences that open with a phrase used in a prior
894
- sentence. Uses normalised (lowercased, stripped) text.
895
 
896
- Now also checks 2-word openings (in addition to 3-word) to catch
897
- repeated structural frames like "On went" / "A mouse" in picture books.
898
  """
899
  if len(sentences) < 3:
900
- return 0.0
901
 
902
  opening_phrases_2: list[str] = []
903
  opening_phrases_3: list[str] = []
904
  cumulative_count = 0
 
905
 
906
- for sent in sentences:
907
  words = SIMPLE_TOKENISE_PATTERN.findall(sent.lower())
908
  if len(words) < 2:
909
  continue
@@ -912,28 +1187,35 @@ def compute_cumulative_structure(sentences: list[str]) -> float:
912
  opening_3 = " ".join(words[:3]) if len(words) >= 3 else ""
913
 
914
  matched = False
 
915
  if opening_2 in opening_phrases_2:
916
  matched = True
 
917
  if opening_3 and opening_3 in opening_phrases_3:
918
  matched = True
 
919
 
920
  if matched:
921
  cumulative_count += 1
 
 
 
 
 
 
 
922
 
923
  opening_phrases_2.append(opening_2)
924
  if opening_3:
925
  opening_phrases_3.append(opening_3)
926
 
927
- return round(cumulative_count / len(sentences), 3)
928
 
929
 
930
  # ── VOCABULARY TIER MATCH ─────────────────────────────────────────────────────
931
 
932
  def compute_vocabulary_tier_match(words: list[str]) -> float:
933
- """
934
- Proportion of unique words that appear in the 4–7 age-band lexicon proxy.
935
- Returns float 0–1.
936
- """
937
  unique_words = set(words)
938
  if not unique_words:
939
  return 0.0
@@ -946,8 +1228,7 @@ def compute_vocabulary_tier_match(words: list[str]) -> float:
946
  def compute_dialogue_proportion(text: str, total_words: int) -> float:
947
  """
948
  Fix 8: Proportion of words inside quotation marks.
949
- Quote normalisation is applied upstream in clean_text(), so this
950
- function can use straight double-quotes reliably.
951
  """
952
  if total_words == 0:
953
  return 0.0
@@ -960,18 +1241,7 @@ def compute_dialogue_proportion(text: str, total_words: int) -> float:
960
 
961
  def compute_qa_flags(fp: dict[str, Any]) -> list[str]:
962
  """
963
- Fix 10: Return a list of QA warning strings for known contradiction
964
- patterns. These prevent bad fingerprints from silently entering
965
- CODEX_03 without review.
966
-
967
- Flags raised:
968
- - RHYME_CONTRADICTION: rhyme density > 0.3 but scheme is 'free'
969
- - HIGH_INVENTED_WORD_DENSITY: VM-008 > 0.10 (likely OCR noise)
970
- - POSSIBLE_FRONT_MATTER_INCLUDED: word count unusually high for
971
- a standard picture book (>= 1200) with low rhyme density
972
- - LOW_DIALOGUE_WITH_HIGH_PUNCTUATION: high ? or ! density but
973
- dialogue proportion < 0.05 (quote marks likely lost)
974
- - LOW_CONFIDENCE_SAMPLE: fewer than MIN_WORD_COUNT words
975
  """
976
  flags: list[str] = []
977
 
@@ -983,27 +1253,34 @@ def compute_qa_flags(fp: dict[str, Any]) -> list[str]:
983
  excl = fp.get("VM-026_Exclamation_density", 0)
984
  ques = fp.get("VM-027_Question_density", 0)
985
 
986
- if rhyme_density > 0.3 and rhyme_type in ("free",):
 
 
 
 
 
 
987
  flags.append(
988
- "RHYME_CONTRADICTION: rhyme density is high but scheme classified as free — "
989
- "check verse-line normalisation."
990
  )
991
 
992
  if isinstance(invented, float) and invented > 0.10:
993
  flags.append(
994
- f"HIGH_INVENTED_WORD_DENSITY: {invented:.3f} — likely OCR noise or missing whitelist entries."
 
995
  )
996
 
997
  if word_count >= 1200 and rhyme_density < 0.15:
998
  flags.append(
999
  "POSSIBLE_FRONT_MATTER_INCLUDED: high word count with low rhyme density — "
1000
- "check page filtering and story boundary."
1001
  )
1002
 
1003
  if (excl + ques) > 3.0 and dialogue < 0.05:
1004
  flags.append(
1005
- "LOW_DIALOGUE_WITH_HIGH_PUNCTUATION: high exclamation/question density but very "
1006
- "low dialogue proportion — quote normalisation may have failed."
1007
  )
1008
 
1009
  if word_count < MIN_WORD_COUNT:
@@ -1011,10 +1288,38 @@ def compute_qa_flags(fp: dict[str, Any]) -> list[str]:
1011
  f"LOW_CONFIDENCE_SAMPLE: only {word_count} words — metrics are unreliable."
1012
  )
1013
 
 
 
 
 
 
 
 
 
1014
  return flags
1015
 
1016
 
1017
- # ── FIX 1: DEBUG ARTEFACT EXPORTS ────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
 
1019
  def export_debug_artefacts(
1020
  output_dir: str | Path,
@@ -1023,22 +1328,29 @@ def export_debug_artefacts(
1023
  line_endings_trace: list[dict],
1024
  metric_trace: dict,
1025
  qa_flags: list[str],
 
 
 
 
1026
  ) -> dict[str, str]:
1027
  """
1028
- Fix 1: Write debug artefacts for human QA inspection.
1029
 
1030
  Files written:
1031
- - cleaned_text.txt : the story text after OCR cleanup and page filtering
1032
- - raw_ocr_text.txt : unmodified OCR output
1033
- - line_endings.csv : per-candidate-line trace (line, final word, rhyme key, excluded)
1034
- - metric_trace.json : per-metric source counts
1035
- - qa_flags.json : automatic contradiction warnings
 
 
 
 
1036
 
1037
  Returns dict mapping artefact name -> file path written.
1038
  """
1039
  out = Path(output_dir)
1040
  out.mkdir(parents=True, exist_ok=True)
1041
-
1042
  paths: dict[str, str] = {}
1043
 
1044
  # cleaned_text.txt
@@ -1051,17 +1363,22 @@ def export_debug_artefacts(
1051
  p.write_text(raw_text, encoding="utf-8")
1052
  paths["raw_ocr_text"] = str(p)
1053
 
1054
- # line_endings.csv
1055
  p = out / "line_endings.csv"
1056
  if line_endings_trace:
1057
  with p.open("w", newline="", encoding="utf-8") as f:
1058
  writer = csv.DictWriter(
1059
  f,
1060
- fieldnames=["line_number", "line", "final_word",
1061
- "rhyme_key", "excluded", "exclusion_reason"],
 
 
1062
  )
1063
  writer.writeheader()
1064
  writer.writerows(line_endings_trace)
 
 
 
1065
  paths["line_endings"] = str(p)
1066
 
1067
  # metric_trace.json
@@ -1077,6 +1394,76 @@ def export_debug_artefacts(
1077
  )
1078
  paths["qa_flags"] = str(p)
1079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1080
  return paths
1081
 
1082
 
@@ -1090,6 +1477,8 @@ def extract_fingerprint(
1090
  works_sampled: str = "",
1091
  extra_whitelist: set[str] | None = None,
1092
  debug_output_dir: str | Path | None = None,
 
 
1093
  ) -> dict[str, Any]:
1094
  """
1095
  Extract all Tier 1 fingerprint metrics from text.
@@ -1099,21 +1488,24 @@ def extract_fingerprint(
1099
  raw_text: Unmodified OCR output, for debug export.
1100
  author_name: Author's full name for the output record.
1101
  author_id: Codex author ID (e.g. CA-001).
1102
- works_sampled: Comma-separated list of titles included in the text.
1103
- extra_whitelist: Set of additional proper nouns / invented terms to
1104
- whitelist from the invented-word density count.
1105
- debug_output_dir: If set, write Fix 1 debug artefacts to this directory.
 
 
 
 
1106
 
1107
  Returns:
1108
  Dictionary of metric values, confidence flags, and metadata.
1109
- Ready to paste into CODEX_03_FINGERPRINTS workbook row.
1110
  """
 
 
 
1111
  text = clean_text(text)
1112
 
1113
- # All lines (for metrics that use raw line structure)
1114
  all_lines = [l.strip() for l in text.split("\n") if l.strip()]
1115
-
1116
- # Candidate verse lines only (Fix 5): used for syllable, rhyme, stress metrics
1117
  candidate_lines = [l for l in all_lines if is_candidate_verse_line(l)]
1118
 
1119
  words = tokenise_words(text)
@@ -1131,7 +1523,7 @@ def extract_fingerprint(
1131
  else:
1132
  confidence = f"HIGH — sample {total_words} words"
1133
 
1134
- # ── VM-001: Syllables per line (candidate verse lines only) ───────────────
1135
  line_syllable_counts = []
1136
  for line in candidate_lines:
1137
  line_words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
@@ -1150,10 +1542,10 @@ def extract_fingerprint(
1150
  else:
1151
  vm002 = -1.0
1152
 
1153
- # ── VM-003 + 004: Rhyme density and scheme (Fix 5 + 6) ───────────────────
1154
  end_words, line_endings_trace = get_candidate_verse_line_endings(text)
1155
- vm003 = compute_rhyme_density(end_words)
1156
- vm004 = detect_rhyme_scheme(end_words)
1157
 
1158
  # ── VM-005: Stressed syllable regularity ──────────────────────────────────
1159
  vm005 = compute_stress_regularity(candidate_lines)
@@ -1164,12 +1556,23 @@ def extract_fingerprint(
1164
  # ── VM-007: Type-token ratio ──────────────────────────────────────────────
1165
  vm007 = round(len(unique_words) / total_words, 3) if total_words > 0 else -1.0
1166
 
1167
- # ── VM-008: Invented word density (Fix 7) ───────────────────────────────
1168
- unknown_words = [
1169
- w for w in unique_words
1170
- if len(w) > 2 and not is_known_word(w, extra_whitelist=extra_whitelist)
1171
- ]
1172
- vm008 = round(len(unknown_words) / len(unique_words), 3) if unique_words else 0.0
 
 
 
 
 
 
 
 
 
 
 
1173
 
1174
  # ── VM-009: Average word length ───────────────────────────────────────────
1175
  vm009 = round(sum(len(w) for w in words) / total_words, 2) if total_words > 0 else -1.0
@@ -1189,10 +1592,17 @@ def extract_fingerprint(
1189
  else:
1190
  vm011 = -1.0
1191
 
1192
- # ── VM-012: Cumulative structure score (Fix 9) ────────────────────────────
1193
- vm012 = compute_cumulative_structure(sentences)
 
 
 
 
 
 
 
1194
 
1195
- # ── VM-013: Dialogue proportion (Fix 8) ──────────────────────────────────
1196
  vm013 = compute_dialogue_proportion(text, total_words)
1197
 
1198
  # ── VM-024: Word count total ──────────────────────────────────────────────
@@ -1209,15 +1619,25 @@ def extract_fingerprint(
1209
  questions = len(QUESTION_PATTERN.findall(text))
1210
  vm027 = round((questions / total_words) * 100, 2) if total_words > 0 else 0.0
1211
 
1212
- # ── VM-028: Repetition index (Fix 9) ─────────────────────────────────────
1213
- vm028 = compute_repetition_index(all_lines)
 
 
 
 
 
 
 
 
 
 
1214
 
1215
  # ── ASSEMBLE OUTPUT ───────────────────────────────────────────────────────
1216
  result: dict[str, Any] = {
1217
- # Metadata
1218
  "Author_ID": author_id,
1219
  "Author_Name": author_name,
1220
- "Works_Sampled": works_sampled,
1221
  "Sample_Words": total_words,
1222
  "Sample_Lines": len(all_lines),
1223
  "Sample_Candidate_Verse_Lines": len(candidate_lines),
@@ -1258,20 +1678,24 @@ def extract_fingerprint(
1258
  result["QA_Flags"] = qa_flags
1259
  result["QA_Flag_Count"] = len(qa_flags)
1260
 
1261
- # ── FIX 1: DEBUG ARTEFACT EXPORTS ────────────────────────────────────────
1262
  if debug_output_dir is not None:
1263
  metric_trace = {
1264
  "total_words": total_words,
1265
  "unique_words": len(unique_words),
1266
  "total_lines": len(all_lines),
1267
  "candidate_verse_lines": len(candidate_lines),
1268
- "candidate_rhyme_pairs": len(end_words),
1269
- "rhyming_adjacent_pairs": int(round(vm003 * max(len(end_words) - 1, 1))),
 
 
 
 
1270
  "dialogue_tokens": int(round(vm013 * total_words)),
1271
  "exclamation_count": exclamations,
1272
  "question_count": questions,
1273
- "unknown_words_for_vm008": unknown_words,
1274
  "sentence_count": total_sentences,
 
1275
  }
1276
  artefact_paths = export_debug_artefacts(
1277
  output_dir=debug_output_dir,
@@ -1280,6 +1704,10 @@ def extract_fingerprint(
1280
  line_endings_trace=line_endings_trace,
1281
  metric_trace=metric_trace,
1282
  qa_flags=qa_flags,
 
 
 
 
1283
  )
1284
  result["Debug_Artefacts"] = artefact_paths
1285
 
@@ -1310,7 +1738,7 @@ def format_fingerprint_report(fp: dict[str, Any]) -> str:
1310
  f"",
1311
  f" Author: {fp['Author_Name']}",
1312
  f" ID: {fp['Author_ID']}",
1313
- f" Works: {fp['Works_Sampled'] or 'Not specified'}",
1314
  f" Words: {fp['Sample_Words']}",
1315
  f" Lines (total): {fp['Sample_Lines']}",
1316
  f" Lines (verse cands): {fp.get('Sample_Candidate_Verse_Lines', 'n/a')}",
@@ -1376,19 +1804,19 @@ def process_upload(
1376
  file_path: Path to uploaded PDF or text file.
1377
  author_name: Author's full name.
1378
  author_id: Codex author ID.
1379
- works_sampled: Comma-separated list of titles.
 
1380
  start_page: Optional 1-based start page for story extraction.
1381
  end_page: Optional 1-based end page for story extraction.
1382
  extra_whitelist_str: Comma-separated proper nouns / invented terms
1383
  to whitelist from VM-008 (e.g. "Gruffalo,Zog").
1384
- debug_output_dir: Directory to write debug artefacts. If None,
1385
- no artefacts are written.
1386
 
1387
  Returns:
1388
  Tuple of (formatted_report_string, raw_dict).
1389
  """
1390
  try:
1391
- story_text, raw_text = extract_text_from_file(
1392
  file_path,
1393
  start_page=start_page,
1394
  end_page=end_page,
@@ -1425,6 +1853,8 @@ def process_upload(
1425
  works_sampled=works_sampled,
1426
  extra_whitelist=extra_whitelist,
1427
  debug_output_dir=debug_output_dir,
 
 
1428
  )
1429
  report = format_fingerprint_report(fp)
1430
  return report, fp
@@ -1436,23 +1866,45 @@ def process_upload(
1436
  # ── STANDALONE TEST ───────────────────────────────────────────────────────────
1437
 
1438
  if __name__ == "__main__":
1439
- # Quick test with a small sample — run: python3 codex_extractor.py
1440
  SAMPLE = """
1441
- The Gruffalo said that no gruffalo should
1442
- go near the snake who bakes chocolate cake.
1443
- The fox had a box full of socks by the dock,
1444
- and the mouse ran free from the clock and the clock.
1445
- He said to the owl, you're not like the rest,
1446
- your feathers are orange, your beak is the best.
1447
- She called to the bear in the cave far away,
1448
- come out come out on this bright sunny day.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1449
  """
1450
  fp = extract_fingerprint(
1451
  text=SAMPLE,
1452
  raw_text=SAMPLE,
1453
- author_name="Test Author",
1454
- author_id="CA-TEST",
1455
- works_sampled="Test sample",
1456
  extra_whitelist={"gruffalo"},
1457
  debug_output_dir="/tmp/codex_debug",
1458
  )
 
55
  with partial-match threshold
56
  Fix 10 — QA threshold flags: contradiction detection for known bad patterns
57
 
58
+ Protocol tightenings applied (v1.2 — 14/05/2026):
59
+ Fix P1 — Metadata provenance lock: Works_Sampled reflects only user-supplied
60
+ input or actual filenames processed — no inferred bibliography.
61
+ Fix P2 — Page classification export: page_trace.csv with page number,
62
+ raw word count, cleaned word count, classification, skip reason.
63
+ Fix P3 — cleaned_text.txt export (already present; now always written).
64
+ Fix P4 — line_endings.csv export (already present; now always written).
65
+ Fix P5 — rhyme_pairs_debug.csv: every evaluated rhyme pair with pass/fail.
66
+ Fix P6 — unknown_tokens.csv: every word flagged for VM-008, with reason.
67
+ Fix P7 — repetition_matches.csv: every matched repetition event for
68
+ VM-012 and VM-028.
69
+ Fix P8 — Rhyme detection upgrade: OCR-fragment merging, couplet + alternating
70
+ window evaluation, CMUdict primary with suffix fallback.
71
+ Fix P9 — Rhyme type labels tightened: couplet-dominant / alternating-dominant /
72
+ mixed-rhymed / free / prose / unknown-low-confidence.
73
+ Fix P10 — Invented word cleanup: proper noun / title-character whitelist,
74
+ British spelling support, OCR artefact pre-filter.
75
+ Fix P11 — Repetition upgrade: exact n-gram repetition + structural template
76
+ repetition combined score.
77
+
78
  Author: TOTEM Studio — Jamal Romeh
79
+ Version: 1.2
80
  """
81
 
82
  from __future__ import annotations
 
155
  EXCLAMATION_PATTERN = re.compile(r"!")
156
  QUESTION_PATTERN = re.compile(r"\?")
157
 
158
+ # ── FIX P10 / Fix 8: BRITISH SPELLING DICTIONARY ─────────────────────────────
159
+ # Common British spellings not in CMU dict (which is American-English biased).
160
+ # These should never be flagged as invented words.
161
+ BRITISH_SPELLINGS: set[str] = {
162
+ "colour", "colours", "coloured", "colouring",
163
+ "favour", "favours", "favourite", "favourites",
164
+ "honour", "honours", "honourable",
165
+ "labour", "labours",
166
+ "neighbour", "neighbours",
167
+ "rumour", "rumours",
168
+ "behaviour", "behaviours",
169
+ "armour",
170
+ "flavour", "flavours",
171
+ "glamour",
172
+ "humour", "humours",
173
+ "odour",
174
+ "savour",
175
+ "vigour",
176
+ "centre", "centres",
177
+ "metre", "metres",
178
+ "theatre", "theatres",
179
+ "litre", "litres",
180
+ "fibre", "fibres",
181
+ "realise", "realised", "realising",
182
+ "recognise", "recognised",
183
+ "organise", "organised",
184
+ "analyse", "analysed",
185
+ "travelling", "traveller", "travellers",
186
+ "marvellous",
187
+ "cancelled", "cancelling",
188
+ "jewellery",
189
+ "woollen",
190
+ "programme", "programmes",
191
+ "grey", "greys",
192
+ "plough", "ploughs",
193
+ "defence", "defences",
194
+ "offence", "offences",
195
+ "licence", "licences",
196
+ "practise", # verb form in British English
197
+ "mum", "mums",
198
+ "whilst",
199
+ "amongst",
200
+ "learnt", "spelt", "smelt", "dreamt", "leapt", "knelt",
201
+ "shan't", "mayn't", "oughtn't",
202
+ "tyre", "tyres",
203
+ "pyjamas",
204
+ "cosy",
205
+ "marvellous",
206
+ "fulfil", "fulfils", "fulfilled",
207
+ "enrol", "enrols", "enrolled",
208
+ "skilful",
209
+ "wilful",
210
+ }
211
+
212
+ # ── FIX 8 / P8: QUOTE NORMALISATION ──────────────────────────────────────────
213
 
214
  QUOTE_OPEN_PATTERN = re.compile(
215
  r'[\u201C\u201F\u00AB\u2039\u275D\u276E`\u201E]'
 
249
  # OCR noise / watermark patterns to strip from individual lines.
250
  OCR_NOISE_PATTERNS = [
251
  re.compile(r'ppsbook\.com', re.IGNORECASE),
252
+ re.compile(r'绘本在线论坛', re.UNICODE),
253
  re.compile(r'\bisbn\b[\d\s\-]+', re.IGNORECASE),
254
+ re.compile(r'^\s*\d{1,4}\s*$'),
255
+ re.compile(r'^\s*[©®™]\s*.*$', re.MULTILINE),
256
+ re.compile(r'^\s*[A-Z][a-z]+ [A-Z][a-z]+\s*$'),
257
  ]
258
 
 
 
259
  MIN_LINE_TOKENS_FOR_METRICS = 2
260
 
261
 
262
  def is_front_matter_page(page_text: str) -> bool:
263
  """
264
+ Fix 2: Return True if a page looks like front matter.
 
 
 
 
265
  """
266
  if not page_text:
267
  return False
268
  word_count = len(re.findall(r'[a-zA-Z]+', page_text))
269
  if word_count > 80:
 
270
  return False
271
  return bool(FRONT_MATTER_SIGNALS.search(page_text))
272
 
273
 
274
  def strip_ocr_noise_from_line(line: str) -> str:
275
+ """Fix 3: Remove known OCR noise patterns from a single line."""
 
 
 
276
  for pattern in OCR_NOISE_PATTERNS:
277
  line = pattern.sub('', line)
278
  return line.strip()
 
280
 
281
  def is_artefact_line(line: str) -> bool:
282
  """
283
+ Fix 3 + 4: Return True if a line is an OCR artefact or structural noise.
 
 
 
 
 
 
284
  """
285
  tokens = re.findall(r'[a-zA-Z]{2,}', line)
286
  if len(tokens) < MIN_LINE_TOKENS_FOR_METRICS:
 
302
  pdf_path: str | Path,
303
  start_page: int | None = None,
304
  end_page: int | None = None,
305
+ ) -> tuple[str, str, list[dict]]:
306
  """
307
  OCR fallback for scanned/image-only PDFs.
308
+
309
+ Returns (story_text, raw_text, page_trace).
310
+ page_trace is a list of per-page classification dicts for Fix P2.
311
  """
312
  if not PDFIUM_AVAILABLE:
313
  raise RuntimeError("OCR fallback unavailable: pypdfium2 is not installed.")
 
323
 
324
  raw_parts: list[str] = []
325
  story_parts: list[str] = []
326
+ page_trace: list[dict] = []
327
 
328
  pdf = pdfium.PdfDocument(str(pdf_path))
329
  total_pages = min(len(pdf), max_pages)
 
339
  pil_image = bitmap.to_pil()
340
  page_text = pytesseract.image_to_string(pil_image, lang=ocr_lang, config=ocr_config) or ""
341
  raw_parts.append(page_text)
342
+ raw_wc = _word_count(page_text)
343
+
344
+ classification = "story"
345
+ skip_reason = ""
346
+ included = False
347
 
348
  if start_page is not None or end_page is not None:
349
  if idx_start <= page_idx <= idx_end:
350
  story_parts.append(page_text)
351
+ included = True
352
+ else:
353
+ classification = "out_of_range"
354
+ skip_reason = f"outside user range {start_page}–{end_page}"
355
+ else:
356
+ if total_pages > 1 and page_idx == 0:
357
+ classification = "cover"
358
+ skip_reason = "first page auto-skipped as cover"
359
+ elif is_front_matter_page(page_text):
360
+ classification = "front_matter"
361
+ skip_reason = "front-matter signals detected"
362
+ else:
363
+ story_parts.append(page_text)
364
+ included = True
365
+
366
+ cleaned_wc = _word_count(page_text) if included else 0
367
+ page_trace.append({
368
+ "page_number": page_idx + 1,
369
+ "raw_word_count": raw_wc,
370
+ "cleaned_word_count": cleaned_wc,
371
+ "classification": classification,
372
+ "included": included,
373
+ "skip_reason": skip_reason,
374
+ })
375
 
376
+ return "\n".join(story_parts), "\n".join(raw_parts), page_trace
377
 
378
 
379
  def extract_text_from_pdf(
380
  pdf_path: str | Path,
381
  start_page: int | None = None,
382
  end_page: int | None = None,
383
+ ) -> tuple[str, str, list[dict]]:
384
  """
385
+ Fix 2 + 3 + P2: Extract text from a PDF file.
 
 
 
386
 
387
+ Returns (cleaned_story_text, raw_ocr_text, page_trace).
388
+ page_trace provides per-page word counts and classification for Fix P2.
 
 
 
 
 
 
 
 
389
  """
390
  if not PDF_AVAILABLE:
391
  raise RuntimeError("pdfplumber is not installed. Add it to requirements.txt.")
392
 
393
  raw_parts: list[str] = []
394
  story_parts: list[str] = []
395
+ page_trace: list[dict] = []
396
 
397
  with pdfplumber.open(str(pdf_path)) as pdf:
398
  total_pages = len(pdf.pages)
399
 
 
400
  idx_start = (start_page - 1) if start_page is not None else 0
401
  idx_end = (end_page - 1) if end_page is not None else (total_pages - 1)
402
  idx_start = max(0, idx_start)
 
405
  for page_idx, page in enumerate(pdf.pages):
406
  page_text = page.extract_text() or ""
407
  raw_parts.append(page_text)
408
+ raw_wc = _word_count(page_text)
409
+
410
+ classification = "story"
411
+ skip_reason = ""
412
+ included = False
413
 
 
414
  if start_page is not None or end_page is not None:
415
  if idx_start <= page_idx <= idx_end:
416
  story_parts.append(page_text)
417
+ included = True
418
+ else:
419
+ classification = "out_of_range"
420
+ skip_reason = f"outside user range {start_page}–{end_page}"
421
+ else:
422
+ if total_pages > 1 and page_idx == 0:
423
+ classification = "cover"
424
+ skip_reason = "first page auto-skipped as cover"
425
+ elif is_front_matter_page(page_text):
426
+ classification = "front_matter"
427
+ skip_reason = "front-matter signals detected"
428
+ else:
429
+ story_parts.append(page_text)
430
+ included = True
431
+
432
+ # cleaned_word_count measured after story_parts inclusion decision
433
+ cleaned_wc = _word_count(page_text) if included else 0
434
+ page_trace.append({
435
+ "page_number": page_idx + 1,
436
+ "raw_word_count": raw_wc,
437
+ "cleaned_word_count": cleaned_wc,
438
+ "classification": classification,
439
+ "included": included,
440
+ "skip_reason": skip_reason,
441
+ })
442
 
443
  raw_text = "\n".join(raw_parts)
444
  story_text = "\n".join(story_parts)
445
 
 
446
  if _word_count(story_text) >= 20:
447
+ return story_text, raw_text, page_trace
448
 
 
449
  try:
450
+ ocr_story_text, ocr_raw_text, ocr_trace = extract_text_from_pdf_ocr(
451
+ pdf_path, start_page=start_page, end_page=end_page,
 
 
452
  )
453
  if _word_count(ocr_story_text) >= max(20, _word_count(story_text)):
454
+ # Mark all original pages as ocr_fallback and merge OCR trace
455
+ for entry in page_trace:
456
+ if entry["classification"] == "story":
457
+ entry["classification"] = "ocr_fallback"
458
+ entry["skip_reason"] = "native text layer empty; OCR used"
459
+ return ocr_story_text, ocr_raw_text, ocr_trace
460
  except Exception:
 
461
  pass
462
 
463
+ return story_text, raw_text, page_trace
464
 
465
 
466
  def extract_text_from_file(
467
  file_path: str | Path,
468
  start_page: int | None = None,
469
  end_page: int | None = None,
470
+ ) -> tuple[str, str, list[dict]]:
471
  """
472
  Extract text from PDF or plain text file.
473
 
474
+ Returns (story_text, raw_text, page_trace).
475
+ For plain text files page_trace contains a single synthetic entry.
476
  """
477
  path = Path(file_path)
478
  if path.suffix.lower() == ".pdf":
479
  return extract_text_from_pdf(path, start_page=start_page, end_page=end_page)
480
  else:
481
  content = path.read_text(encoding="utf-8", errors="replace")
482
+ wc = _word_count(content)
483
+ page_trace = [{
484
+ "page_number": 1,
485
+ "raw_word_count": wc,
486
+ "cleaned_word_count": wc,
487
+ "classification": "story",
488
+ "included": True,
489
+ "skip_reason": "",
490
+ }]
491
+ return content, content, page_trace
492
+
493
+
494
+ # ── FIX 3 + 4 + P8: TEXT CLEANING AND VERSE-LINE NORMALISATION ──────────────
495
+
496
+ def _merge_ocr_fragments(lines: list[str]) -> list[str]:
497
+ """
498
+ Fix P8: Merge short OCR line fragments that appear to continue the
499
+ previous line rather than start a new verse line.
500
 
501
+ Heuristic: if a line has fewer than 4 alphabetic tokens AND begins with
502
+ a lowercase letter AND the previous line is non-empty, append it to the
503
+ previous line.
504
+ """
505
+ if not lines:
506
+ return lines
507
+ merged: list[str] = []
508
+ for line in lines:
509
+ if not line:
510
+ merged.append(line)
511
+ continue
512
+ tokens = re.findall(r'[a-zA-Z]{2,}', line)
513
+ is_short = len(tokens) < 4
514
+ starts_lower = bool(line) and line[0].islower()
515
+ prev_non_empty = merged and merged[-1].strip()
516
+ if is_short and starts_lower and prev_non_empty:
517
+ merged[-1] = merged[-1].rstrip() + " " + line
518
+ else:
519
+ merged.append(line)
520
+ return merged
521
 
 
522
 
523
  def clean_text(raw: str) -> str:
524
  """
525
+ Fix 3 + 4 + P8: Deep cleaning pipeline.
526
 
527
  Order of operations:
528
+ 1. Quote normalisation (Fix 8).
529
  2. Line-ending normalisation.
530
  3. Strip per-line OCR noise.
531
  4. Remove artefact lines.
532
+ 5. Merge OCR continuation fragments (Fix P8).
533
+ 6. Collapse excessive blank lines.
534
+ 7. Normalise whitespace within lines.
535
  """
 
536
  text = normalise_quotes(raw)
 
 
537
  text = re.sub(r"\r\n", "\n", text)
538
  text = re.sub(r"\r", "\n", text)
539
 
 
540
  cleaned_lines: list[str] = []
541
  for line in text.split("\n"):
542
  line = strip_ocr_noise_from_line(line)
 
548
  continue
549
  cleaned_lines.append(line)
550
 
551
+ # Fix P8: merge continuation fragments
552
+ cleaned_lines = _merge_ocr_fragments(cleaned_lines)
553
 
554
+ text = "\n".join(cleaned_lines)
555
  text = re.sub(r"\n{3,}", "\n\n", text)
556
 
 
557
  lines_out = []
558
  for line in text.split("\n"):
559
  lines_out.append(re.sub(r"[ \t]+", " ", line).strip())
 
563
  # ── SYLLABLE COUNTING ─────────────────────────────────────────────────────────
564
 
565
  def count_syllables_cmu(word: str) -> int | None:
566
+ """Count syllables using CMU Pronouncing Dictionary."""
567
  word_lower = word.lower().strip(string.punctuation)
568
  if word_lower in CMU_DICT:
569
  pronunciation = CMU_DICT[word_lower][0]
 
574
  def count_syllables_fallback(word: str) -> int:
575
  """
576
  Fallback syllable counter using vowel-group heuristic.
577
+ Also handles common British suffixes (-our, -re, -ise).
578
  """
579
  word = word.lower().strip(string.punctuation)
580
  if not word:
581
  return 0
582
+ # Silent trailing -e but preserve -le, -re which are syllabic
583
+ if word.endswith("e") and not word.endswith(("le", "re")) and len(word) > 2:
584
  word = word[:-1]
585
  vowels = "aeiouy"
586
  count = 0
 
602
  return count_syllables_fallback(word)
603
 
604
 
605
+ # ── FIX P10 / Fix 7: INVENTED-WORD WHITELIST AND DETECTION ──────────────────
606
+
607
+ # Fix P1: Works_Sampled metadata is passed through verbatim from user input.
608
+ # The whitelist here is for VM-008 invented-word detection only.
609
+ # It does not imply any bibliography — it is a technical filter for known
610
+ # proper nouns and invented terms that would otherwise inflate VM-008.
611
 
 
 
 
612
  DEFAULT_INVENTED_WORD_WHITELIST: set[str] = {
613
+ # Donaldson-specific invented / proper nouns
614
+ "gruffalo", "gruffalos", "gruffalo's",
615
+ "zog", "zogs",
616
+ "smeds", "smed", "gruffalochild",
617
+ "tiddler",
618
+ # Generic picture-book terms
619
  "mummy", "daddy", "yummy", "tummy",
620
+ "gonna", "wanna", "lemme",
621
+ # Onomatopoeia common in picture books
622
+ "whoosh", "splat", "squeak", "eek", "ooh", "aah", "boo",
623
+ "whee", "yay", "wow",
624
  }
625
 
626
 
627
  def is_ocr_gibberish(word: str) -> bool:
628
  """
629
+ Fix P10: Return True if a word looks like OCR noise.
 
630
 
631
+ Heuristics (in order):
632
+ - Mixed alphanumeric
633
+ - 5+ consecutive consonants
634
  - Very short with unusual character combination
635
+ - Runs of repeated characters
636
  """
637
  if not word or len(word) < 2:
638
  return True
 
639
  if re.search(r'[a-z]\d|\d[a-z]', word.lower()):
640
  return True
 
641
  if re.search(r'[bcdfghjklmnpqrstvwxyz]{5,}', word.lower()):
642
  return True
 
643
  if word.isupper() and len(word) >= 3 and not word.isalpha():
644
  return True
645
+ # Runs of 3+ identical consecutive characters are almost always OCR noise
646
+ if re.search(r'(.)\1{2,}', word.lower()):
647
+ return True
648
  return False
649
 
650
 
651
+ def _is_proper_noun_in_context(word: str) -> bool:
652
+ """
653
+ Fix P10: Return True if the word's capitalisation suggests a proper noun
654
+ that is legitimately not in CMU dict.
655
+
656
+ Criterion: title-cased AND length >= 4.
657
+ Single-capital letters (I, A) and short fragments are excluded.
658
  """
659
+ return word[0].isupper() and len(word) >= 4
660
+
661
 
662
+ def is_known_word(
663
+ word: str,
664
+ extra_whitelist: set[str] | None = None,
665
+ ) -> tuple[bool, str]:
666
+ """
667
+ Fix P10: Return (is_known, reason_if_unknown).
668
+
669
+ Returns True (known) under any of the following conditions:
670
+ - Is OCR gibberish (excluded from invented count; logged separately)
671
+ - Is in the default or user whitelist
672
+ - Is a British spelling
673
+ - Is a proper noun (title-cased, length >= 4)
674
+ - Is in the CMU pronouncing dictionary
675
+ - Is very short (<= 2 chars): numbers, initials, punctuation residue
676
+
677
+ Returns False (unknown) only when none of the above apply.
678
+ The second element gives the reason for logging in unknown_tokens.csv.
679
  """
680
  word_lower = word.lower().strip(string.punctuation)
681
  if not word_lower or not word_lower.isalpha():
682
+ return True, ""
683
+
684
+ if len(word_lower) <= 2:
685
+ return True, ""
686
 
 
687
  if is_ocr_gibberish(word_lower):
688
+ # OCR gibberish is NOT counted as invented — it's noise.
689
+ return True, ""
690
 
 
691
  whitelist = DEFAULT_INVENTED_WORD_WHITELIST.copy()
692
  if extra_whitelist:
693
  whitelist.update(w.lower() for w in extra_whitelist)
694
  if word_lower in whitelist:
695
+ return True, ""
696
 
697
+ if word_lower in BRITISH_SPELLINGS:
698
+ return True, ""
699
+
700
+ if _is_proper_noun_in_context(word):
701
+ return True, ""
702
 
703
  if NLTK_AVAILABLE and CMU_DICT:
704
+ if word_lower in CMU_DICT:
705
+ return True, ""
706
+ return False, "not_in_cmudict"
707
 
708
+ # Without CMU dict we cannot distinguish unknown from known, so return known
709
+ # to avoid inflating VM-008 without evidence.
710
+ return True, ""
711
 
712
 
713
  # ── FIX 5: VERSE-LINE CANDIDATE SELECTION ────────────────────────────────────
714
 
715
  def is_candidate_verse_line(line: str) -> bool:
716
  """
717
+ Fix 5: Return True if a line is a plausible verse line.
718
+ Excludes lines with fewer than 2 alphabetic tokens.
 
 
 
 
 
 
 
719
  """
720
  tokens = re.findall(r'[a-zA-Z]{2,}', line)
721
+ return len(tokens) >= 2
 
 
722
 
723
 
724
+ # ── FIX P8: RHYME DETECTION UPGRADE ──────────────────────────────────────────
725
 
726
  def get_rhyme_signature(word: str) -> str | None:
727
  """
728
+ Fix P8: Get the rhyme signature of a word.
729
+
730
+ Primary: CMU dict (final stressed vowel + all following phonemes).
731
+ Fallback: last 2 characters of the word (after punctuation strip).
732
+ Returns a non-None string in all cases so callers can always compare.
733
  """
734
  word_lower = word.lower().strip(string.punctuation)
735
+ if not word_lower:
 
 
 
 
 
 
 
736
  return None
737
+
738
+ if NLTK_AVAILABLE and word_lower in CMU_DICT:
739
+ pronunciation = CMU_DICT[word_lower][0]
740
+ last_vowel_idx = None
741
+ for i, ph in enumerate(pronunciation):
742
+ if ph[-1].isdigit():
743
+ last_vowel_idx = i
744
+ if last_vowel_idx is not None:
745
+ return "CMU:" + " ".join(pronunciation[last_vowel_idx:])
746
+
747
+ # Suffix fallback: use last 3 chars if length >= 4, else last 2.
748
+ if len(word_lower) >= 4:
749
+ return "SFX:" + word_lower[-3:]
750
+ if len(word_lower) >= 2:
751
+ return "SFX:" + word_lower[-2:]
752
+ return None
753
 
754
 
755
  def words_rhyme(word1: str, word2: str) -> bool:
756
+ """
757
+ Fix P8: Return True if two words rhyme.
758
+
759
+ Uses CMU signature when available; falls back to suffix comparison.
760
+ Words that are identical do NOT count as rhymes.
761
+ """
762
  w1 = word1.lower().strip(string.punctuation)
763
  w2 = word2.lower().strip(string.punctuation)
764
+ if not w1 or not w2 or w1 == w2:
765
+ return False
766
+ sig1 = get_rhyme_signature(w1)
767
+ sig2 = get_rhyme_signature(w2)
768
+ if sig1 and sig2 and sig1 == sig2:
769
+ return True
770
  return False
771
 
772
 
773
+ def _normalise_verse_line(line: str) -> str:
774
+ """
775
+ Fix P8: Normalise a verse line for rhyme detection.
776
+ Strips leading punctuation, lowercases, collapses spaces.
777
+ """
778
+ line = line.lower().strip()
779
+ line = re.sub(r"^[^a-z]+", "", line)
780
+ line = re.sub(r"\s+", " ", line)
781
+ return line
782
+
783
+
784
  def get_candidate_verse_line_endings(
785
  text: str,
786
  ) -> tuple[list[str], list[dict]]:
787
  """
788
+ Fix P8: Extract last words from candidate verse lines only.
789
+
790
+ Applies verse-line normalisation and OCR fragment filtering before
791
+ extracting final words. Produces a trace for line_endings.csv.
792
 
793
  Returns:
794
  end_words: list of final words from candidate lines.
 
797
  end_words: list[str] = []
798
  trace: list[dict] = []
799
 
800
+ for line_num, raw_line in enumerate(text.split("\n"), start=1):
801
+ raw_line_stripped = raw_line.strip()
802
+ if not raw_line_stripped:
803
  continue
804
 
805
  excluded = False
806
  exclusion_reason = ""
807
 
808
+ if not is_candidate_verse_line(raw_line_stripped):
809
  excluded = True
810
  exclusion_reason = "too_short_or_artefact"
811
 
812
+ norm_line = _normalise_verse_line(raw_line_stripped) if not excluded else ""
813
  final_word = ""
814
+ rhyme_key = ""
815
+
816
  if not excluded:
817
+ tokens = SIMPLE_TOKENISE_PATTERN.findall(norm_line)
818
  if tokens:
819
  final_word = tokens[-1]
820
+ sig = get_rhyme_signature(final_word)
821
+ rhyme_key = sig if sig else ""
822
  end_words.append(final_word)
823
  else:
824
  excluded = True
825
+ exclusion_reason = "no_alpha_tokens_after_normalise"
826
 
 
827
  trace.append({
828
  "line_number": line_num,
829
+ "line": raw_line_stripped,
830
+ "normalised_line": norm_line,
831
  "final_word": final_word,
832
+ "rhyme_key": rhyme_key,
833
  "excluded": excluded,
834
  "exclusion_reason": exclusion_reason,
835
  })
 
837
  return end_words, trace
838
 
839
 
840
+ def compute_rhyme_density(
841
+ end_words: list[str],
842
+ ) -> tuple[float, list[dict]]:
843
  """
844
+ Fix P8: Compute rhyme density using BOTH couplet (adjacent) and alternating
845
+ windows, returning the higher of the two scores.
846
+
847
+ Also returns a list of pair dicts for rhyme_pairs_debug.csv.
848
+
849
+ Couplet window: pairs (0,1), (1,2), (2,3), …
850
+ Alternating window: pairs (0,2), (1,3), (2,4), …
851
+
852
+ Returns (density_float, debug_pairs_list).
853
  """
854
  if len(end_words) < 2:
855
+ return 0.0, []
856
+
857
+ debug_pairs: list[dict] = []
858
+
859
+ # Couplet (adjacent) pairs
860
+ couplet_pairs = [(end_words[i], end_words[i + 1]) for i in range(len(end_words) - 1)]
861
+ couplet_rhyming = 0
862
+ for w1, w2 in couplet_pairs:
863
+ rhymes = words_rhyme(w1, w2)
864
+ if rhymes:
865
+ couplet_rhyming += 1
866
+ debug_pairs.append({
867
+ "window": "couplet",
868
+ "word_a": w1,
869
+ "word_b": w2,
870
+ "rhymes": rhymes,
871
+ "sig_a": get_rhyme_signature(w1) or "",
872
+ "sig_b": get_rhyme_signature(w2) or "",
873
+ })
874
+
875
+ # Alternating pairs
876
+ alternating_rhyming = 0
877
+ alternating_total = 0
878
+ if len(end_words) >= 3:
879
+ for i in range(len(end_words) - 2):
880
+ w1, w2 = end_words[i], end_words[i + 2]
881
+ rhymes = words_rhyme(w1, w2)
882
+ if rhymes:
883
+ alternating_rhyming += 1
884
+ alternating_total += 1
885
+ debug_pairs.append({
886
+ "window": "alternating",
887
+ "word_a": w1,
888
+ "word_b": w2,
889
+ "rhymes": rhymes,
890
+ "sig_a": get_rhyme_signature(w1) or "",
891
+ "sig_b": get_rhyme_signature(w2) or "",
892
+ })
893
+
894
+ couplet_density = couplet_rhyming / len(couplet_pairs)
895
+ alternating_density = (
896
+ alternating_rhyming / alternating_total if alternating_total > 0 else 0.0
897
+ )
898
+
899
+ # Use the higher window density as the reported value.
900
+ density = round(max(couplet_density, alternating_density), 3)
901
+ return density, debug_pairs
902
 
903
 
904
+ # ── FIX P9: RHYME TYPE LABELS ─────────────────────────────────────────────────
905
 
906
+ def detect_rhyme_scheme(end_words: list[str], density: float) -> str:
907
  """
908
+ Fix P9: Classify rhyme scheme with tightened labels.
 
909
 
910
+ Labels:
911
+ couplet-dominant AABB pattern wins in >= 40% of stanzas
912
+ alternating-dominant ABAB pattern wins in >= 40% of stanzas
913
+ mixed-rhymed — density >= 0.25 but no single pattern dominates
914
+ free — density < 0.15 (discernible structure absent)
915
+ prose — density < 0.05 (essentially no rhyme)
916
+ unknown-low-confidence — fewer than 8 end words to evaluate
917
+
918
+ Stanza-window scoring uses groups of 4 consecutive end words.
919
  """
920
+ if len(end_words) < 8:
921
+ return "unknown-low-confidence"
922
 
923
  aabb_votes = 0
924
  abab_votes = 0
925
  abcb_votes = 0
 
926
  total_stanzas = 0
927
 
928
  stanza_size = 4
 
931
  if len(stanza) < 4:
932
  break
933
  total_stanzas += 1
 
934
  a, b, c, d = stanza
935
 
936
+ aabb = words_rhyme(a, b) and words_rhyme(c, d)
937
+ abab = words_rhyme(a, c) and words_rhyme(b, d)
938
+ abcb = (not words_rhyme(a, b)) and words_rhyme(b, d)
 
 
 
939
 
940
  if aabb:
941
  aabb_votes += 1
 
943
  abab_votes += 1
944
  elif abcb:
945
  abcb_votes += 1
 
 
946
 
947
  if total_stanzas == 0:
948
+ if density < 0.05:
949
+ return "prose"
950
+ if density < 0.15:
951
+ return "free"
952
+ return "mixed-rhymed"
953
+
954
+ dominance_threshold = total_stanzas * 0.40
955
+
956
+ if aabb_votes >= dominance_threshold and aabb_votes >= abab_votes and aabb_votes >= abcb_votes:
957
+ return "couplet-dominant"
958
+ if abab_votes >= dominance_threshold and abab_votes >= aabb_votes and abab_votes >= abcb_votes:
959
+ return "alternating-dominant"
960
+ if abcb_votes >= dominance_threshold:
961
+ return "mixed-rhymed"
962
+
963
+ if density < 0.05:
964
+ return "prose"
965
+ if density < 0.15:
966
+ return "free"
967
+ if density >= 0.25:
968
+ return "mixed-rhymed"
969
+ return "free"
 
970
 
971
 
972
  # ── STRESS / METRE ────────────────────────────────────────────────────────────
 
1003
  if not NLTK_AVAILABLE or not CMU_DICT:
1004
  return -1.0
1005
 
 
1006
  candidate_lines = [l for l in lines if l.strip() and is_candidate_verse_line(l)]
1007
  patterns = [get_stress_pattern(line) for line in candidate_lines]
1008
  patterns = [p for p in patterns if len(p) >= 4]
 
1052
  # ── FLESCH-KINCAID ────────────────────────────────────────────────────────────
1053
 
1054
  def flesch_kincaid_grade(text: str, words: list[str], sentences: list[str]) -> float:
1055
+ """Compute Flesch-Kincaid Grade Level."""
 
 
 
1056
  if not words or not sentences:
1057
  return -1.0
1058
  total_syllables = sum(count_syllables(w) for w in words)
 
1062
  return round(max(0.0, fk), 2)
1063
 
1064
 
1065
+ # ── FIX P11 / Fix 9: REPETITION UPGRADE ──────────────────────────────────────
1066
 
1067
  def _normalise_line_for_repetition(line: str) -> str:
1068
  """
1069
+ Fix P11: Normalise a line for repetition matching.
1070
  Lowercases, strips punctuation, collapses whitespace.
1071
  """
1072
  line = line.lower()
 
1075
  return line
1076
 
1077
 
1078
+ def _structural_template(words: list[str]) -> str:
1079
+ """
1080
+ Fix P11: Produce a structural template from a word list by replacing
1081
+ content words with a placeholder, keeping function words intact.
1082
+
1083
+ This catches patterns like:
1084
+ "said the mouse" / "said the fox" / "said the owl"
1085
+ where only the final content word varies.
1086
+
1087
+ Function-word set is the DOLCH_FRY_PROXY (already loaded).
1088
+ """
1089
+ template_parts = []
1090
+ for w in words:
1091
+ if w in DOLCH_FRY_PROXY:
1092
+ template_parts.append(w)
1093
+ else:
1094
+ template_parts.append("__X__")
1095
+ return " ".join(template_parts)
1096
+
1097
+
1098
+ def compute_repetition_index(
1099
+ lines: list[str],
1100
+ ngram_size: int = 3,
1101
+ ) -> tuple[float, list[dict]]:
1102
  """
1103
+ Fix P11: Combined exact n-gram + structural template repetition.
1104
+
1105
+ A line is counted as a repetition event if:
1106
+ (a) Any n-gram from the current line appeared in a prior line, OR
1107
+ (b) The structural template of the current line matches a prior template.
1108
 
1109
+ Returns (repetition_index_float, repetition_matches_list).
1110
+ repetition_matches_list is used for repetition_matches.csv.
 
1111
  """
1112
  candidate_lines = [
1113
  _normalise_line_for_repetition(l)
 
1116
  ]
1117
 
1118
  if len(candidate_lines) < 2:
1119
+ return 0.0, []
1120
 
1121
  seen_ngrams: set[tuple] = set()
1122
+ seen_templates: set[str] = set()
1123
  repeat_count = 0
1124
+ matches: list[dict] = []
1125
 
1126
+ for line_idx, line in enumerate(candidate_lines):
1127
  words = SIMPLE_TOKENISE_PATTERN.findall(line)
1128
+ if not words:
1129
+ continue
 
 
 
1130
 
1131
+ # N-gram check
1132
+ ngram_size_local = ngram_size if len(words) >= ngram_size else max(2, len(words) - 1)
1133
  ngrams = [
1134
  tuple(words[i: i + ngram_size_local])
1135
  for i in range(len(words) - ngram_size_local + 1)
1136
  ]
1137
+ matched_ngram = next((ng for ng in ngrams if ng in seen_ngrams), None)
1138
+
1139
+ # Template check
1140
+ template = _structural_template(words)
1141
+ template_matched = template in seen_templates
1142
+
1143
+ if matched_ngram or template_matched:
1144
  repeat_count += 1
1145
+ matches.append({
1146
+ "line_index": line_idx,
1147
+ "line": line,
1148
+ "match_type": (
1149
+ "ngram+template" if matched_ngram and template_matched
1150
+ else "ngram" if matched_ngram
1151
+ else "template"
1152
+ ),
1153
+ "matched_ngram": " ".join(matched_ngram) if matched_ngram else "",
1154
+ "template": template,
1155
+ })
1156
 
1157
+ seen_ngrams.update(ngrams)
1158
+ seen_templates.add(template)
1159
 
1160
+ index = round(repeat_count / len(candidate_lines), 3)
1161
+ return index, matches
1162
 
 
1163
 
1164
+ def compute_cumulative_structure(
1165
+ sentences: list[str],
1166
+ ) -> tuple[float, list[dict]]:
1167
  """
1168
+ Fix P11: Proportion of sentences that open with a phrase used in a prior
1169
+ sentence, using 2-word and 3-word opening frames.
1170
 
1171
+ Also returns match list for repetition_matches.csv (VM-012 section).
 
1172
  """
1173
  if len(sentences) < 3:
1174
+ return 0.0, []
1175
 
1176
  opening_phrases_2: list[str] = []
1177
  opening_phrases_3: list[str] = []
1178
  cumulative_count = 0
1179
+ matches: list[dict] = []
1180
 
1181
+ for sent_idx, sent in enumerate(sentences):
1182
  words = SIMPLE_TOKENISE_PATTERN.findall(sent.lower())
1183
  if len(words) < 2:
1184
  continue
 
1187
  opening_3 = " ".join(words[:3]) if len(words) >= 3 else ""
1188
 
1189
  matched = False
1190
+ match_phrase = ""
1191
  if opening_2 in opening_phrases_2:
1192
  matched = True
1193
+ match_phrase = opening_2
1194
  if opening_3 and opening_3 in opening_phrases_3:
1195
  matched = True
1196
+ match_phrase = opening_3
1197
 
1198
  if matched:
1199
  cumulative_count += 1
1200
+ matches.append({
1201
+ "sentence_index": sent_idx,
1202
+ "sentence": sent.strip(),
1203
+ "match_type": "cumulative_opening",
1204
+ "matched_phrase": match_phrase,
1205
+ "template": "",
1206
+ })
1207
 
1208
  opening_phrases_2.append(opening_2)
1209
  if opening_3:
1210
  opening_phrases_3.append(opening_3)
1211
 
1212
+ return round(cumulative_count / len(sentences), 3), matches
1213
 
1214
 
1215
  # ── VOCABULARY TIER MATCH ─────────────────────────────────────────────────────
1216
 
1217
  def compute_vocabulary_tier_match(words: list[str]) -> float:
1218
+ """Proportion of unique words in the 4–7 age-band lexicon proxy."""
 
 
 
1219
  unique_words = set(words)
1220
  if not unique_words:
1221
  return 0.0
 
1228
  def compute_dialogue_proportion(text: str, total_words: int) -> float:
1229
  """
1230
  Fix 8: Proportion of words inside quotation marks.
1231
+ Quote normalisation is applied upstream in clean_text().
 
1232
  """
1233
  if total_words == 0:
1234
  return 0.0
 
1241
 
1242
  def compute_qa_flags(fp: dict[str, Any]) -> list[str]:
1243
  """
1244
+ Fix 10 + P9: Return QA warning strings for known contradiction patterns.
 
 
 
 
 
 
 
 
 
 
 
1245
  """
1246
  flags: list[str] = []
1247
 
 
1253
  excl = fp.get("VM-026_Exclamation_density", 0)
1254
  ques = fp.get("VM-027_Question_density", 0)
1255
 
1256
+ if rhyme_density > 0.3 and rhyme_type in ("free", "prose"):
1257
+ flags.append(
1258
+ "RHYME_CONTRADICTION: density is high but scheme is free/prose — "
1259
+ "check verse-line normalisation and rhyme_pairs_debug.csv."
1260
+ )
1261
+
1262
+ if rhyme_density < 0.15 and rhyme_type in ("couplet-dominant", "alternating-dominant", "mixed-rhymed"):
1263
  flags.append(
1264
+ "RHYME_LABEL_INCONSISTENCY: scheme label implies rhyme but density is low — "
1265
+ "check stanza window size and end-word count."
1266
  )
1267
 
1268
  if isinstance(invented, float) and invented > 0.10:
1269
  flags.append(
1270
+ f"HIGH_INVENTED_WORD_DENSITY: {invented:.3f} — check unknown_tokens.csv; "
1271
+ "add proper nouns / invented terms to extra_whitelist if warranted."
1272
  )
1273
 
1274
  if word_count >= 1200 and rhyme_density < 0.15:
1275
  flags.append(
1276
  "POSSIBLE_FRONT_MATTER_INCLUDED: high word count with low rhyme density — "
1277
+ "check page_trace.csv for misclassified pages."
1278
  )
1279
 
1280
  if (excl + ques) > 3.0 and dialogue < 0.05:
1281
  flags.append(
1282
+ "LOW_DIALOGUE_WITH_HIGH_PUNCTUATION: high ! or ? density but very low dialogue "
1283
+ "proportion — quote normalisation may have failed."
1284
  )
1285
 
1286
  if word_count < MIN_WORD_COUNT:
 
1288
  f"LOW_CONFIDENCE_SAMPLE: only {word_count} words — metrics are unreliable."
1289
  )
1290
 
1291
+ # Fix P1: Warn if Works_Sampled is blank
1292
+ works = fp.get("Works_Sampled", "")
1293
+ if not works or not works.strip():
1294
+ flags.append(
1295
+ "WORKS_SAMPLED_EMPTY: Works_Sampled field is blank. "
1296
+ "Set it to the actual title(s) of the uploaded file(s)."
1297
+ )
1298
+
1299
  return flags
1300
 
1301
 
1302
+ # ── FIX P1: METADATA PROVENANCE LOCK ─────────────────────────────────────────
1303
+
1304
+ def _lock_works_sampled(works_sampled: str, file_path: str | Path | None = None) -> str:
1305
+ """
1306
+ Fix P1: Return a clean Works_Sampled string that reflects only:
1307
+ (a) The value explicitly provided by the user, or
1308
+ (b) The filename of the uploaded file if no title was provided.
1309
+
1310
+ No bibliography is inferred. No additional Donaldson or other titles
1311
+ are inserted. If works_sampled is blank, derive from filename only.
1312
+ """
1313
+ if works_sampled and works_sampled.strip():
1314
+ # Return user-supplied value verbatim (trimmed).
1315
+ return works_sampled.strip()
1316
+ if file_path is not None:
1317
+ stem = Path(file_path).stem
1318
+ return f"[derived from filename: {stem}]"
1319
+ return "[not specified]"
1320
+
1321
+
1322
+ # ── FIX 1 + P2–P7: DEBUG ARTEFACT EXPORTS ────────────────────────────────────
1323
 
1324
  def export_debug_artefacts(
1325
  output_dir: str | Path,
 
1328
  line_endings_trace: list[dict],
1329
  metric_trace: dict,
1330
  qa_flags: list[str],
1331
+ page_trace: list[dict] | None = None,
1332
+ rhyme_pairs_debug: list[dict] | None = None,
1333
+ unknown_tokens: list[dict] | None = None,
1334
+ repetition_matches: list[dict] | None = None,
1335
  ) -> dict[str, str]:
1336
  """
1337
+ Fix 1 + P2–P7: Write all debug artefacts for human QA inspection.
1338
 
1339
  Files written:
1340
+ - cleaned_text.txt (Fix P3 always written)
1341
+ - raw_ocr_text.txt (Fix 1)
1342
+ - line_endings.csv (Fix P4 always written)
1343
+ - metric_trace.json (Fix 1)
1344
+ - qa_flags.json (Fix 1)
1345
+ - page_trace.csv (Fix P2 — per-page word counts + classification)
1346
+ - rhyme_pairs_debug.csv (Fix P5 — every evaluated rhyme pair)
1347
+ - unknown_tokens.csv (Fix P6 — VM-008 flagged tokens)
1348
+ - repetition_matches.csv (Fix P7 — VM-012 + VM-028 matches)
1349
 
1350
  Returns dict mapping artefact name -> file path written.
1351
  """
1352
  out = Path(output_dir)
1353
  out.mkdir(parents=True, exist_ok=True)
 
1354
  paths: dict[str, str] = {}
1355
 
1356
  # cleaned_text.txt
 
1363
  p.write_text(raw_text, encoding="utf-8")
1364
  paths["raw_ocr_text"] = str(p)
1365
 
1366
+ # line_endings.csv (Fix P4)
1367
  p = out / "line_endings.csv"
1368
  if line_endings_trace:
1369
  with p.open("w", newline="", encoding="utf-8") as f:
1370
  writer = csv.DictWriter(
1371
  f,
1372
+ fieldnames=[
1373
+ "line_number", "line", "normalised_line",
1374
+ "final_word", "rhyme_key", "excluded", "exclusion_reason",
1375
+ ],
1376
  )
1377
  writer.writeheader()
1378
  writer.writerows(line_endings_trace)
1379
+ else:
1380
+ p.write_text("line_number,line,normalised_line,final_word,rhyme_key,excluded,exclusion_reason\n",
1381
+ encoding="utf-8")
1382
  paths["line_endings"] = str(p)
1383
 
1384
  # metric_trace.json
 
1394
  )
1395
  paths["qa_flags"] = str(p)
1396
 
1397
+ # page_trace.csv (Fix P2)
1398
+ p = out / "page_trace.csv"
1399
+ if page_trace:
1400
+ with p.open("w", newline="", encoding="utf-8") as f:
1401
+ writer = csv.DictWriter(
1402
+ f,
1403
+ fieldnames=[
1404
+ "page_number", "raw_word_count", "cleaned_word_count",
1405
+ "classification", "included", "skip_reason",
1406
+ ],
1407
+ )
1408
+ writer.writeheader()
1409
+ writer.writerows(page_trace)
1410
+ else:
1411
+ p.write_text(
1412
+ "page_number,raw_word_count,cleaned_word_count,classification,included,skip_reason\n",
1413
+ encoding="utf-8",
1414
+ )
1415
+ paths["page_trace"] = str(p)
1416
+
1417
+ # rhyme_pairs_debug.csv (Fix P5)
1418
+ p = out / "rhyme_pairs_debug.csv"
1419
+ if rhyme_pairs_debug:
1420
+ with p.open("w", newline="", encoding="utf-8") as f:
1421
+ writer = csv.DictWriter(
1422
+ f,
1423
+ fieldnames=["window", "word_a", "word_b", "rhymes", "sig_a", "sig_b"],
1424
+ )
1425
+ writer.writeheader()
1426
+ writer.writerows(rhyme_pairs_debug)
1427
+ else:
1428
+ p.write_text("window,word_a,word_b,rhymes,sig_a,sig_b\n", encoding="utf-8")
1429
+ paths["rhyme_pairs_debug"] = str(p)
1430
+
1431
+ # unknown_tokens.csv (Fix P6)
1432
+ p = out / "unknown_tokens.csv"
1433
+ if unknown_tokens:
1434
+ with p.open("w", newline="", encoding="utf-8") as f:
1435
+ writer = csv.DictWriter(
1436
+ f,
1437
+ fieldnames=["word", "reason", "is_ocr_gibberish", "is_proper_noun"],
1438
+ )
1439
+ writer.writeheader()
1440
+ writer.writerows(unknown_tokens)
1441
+ else:
1442
+ p.write_text("word,reason,is_ocr_gibberish,is_proper_noun\n", encoding="utf-8")
1443
+ paths["unknown_tokens"] = str(p)
1444
+
1445
+ # repetition_matches.csv (Fix P7)
1446
+ p = out / "repetition_matches.csv"
1447
+ if repetition_matches:
1448
+ with p.open("w", newline="", encoding="utf-8") as f:
1449
+ writer = csv.DictWriter(
1450
+ f,
1451
+ fieldnames=[
1452
+ "source", "line_index", "sentence_index",
1453
+ "line", "sentence", "match_type", "matched_ngram",
1454
+ "matched_phrase", "template",
1455
+ ],
1456
+ )
1457
+ writer.writeheader()
1458
+ writer.writerows(repetition_matches)
1459
+ else:
1460
+ p.write_text(
1461
+ "source,line_index,sentence_index,line,sentence,match_type,"
1462
+ "matched_ngram,matched_phrase,template\n",
1463
+ encoding="utf-8",
1464
+ )
1465
+ paths["repetition_matches"] = str(p)
1466
+
1467
  return paths
1468
 
1469
 
 
1477
  works_sampled: str = "",
1478
  extra_whitelist: set[str] | None = None,
1479
  debug_output_dir: str | Path | None = None,
1480
+ page_trace: list[dict] | None = None,
1481
+ source_file_path: str | Path | None = None,
1482
  ) -> dict[str, Any]:
1483
  """
1484
  Extract all Tier 1 fingerprint metrics from text.
 
1488
  raw_text: Unmodified OCR output, for debug export.
1489
  author_name: Author's full name for the output record.
1490
  author_id: Codex author ID (e.g. CA-001).
1491
+ works_sampled: Title(s) of the uploaded work(s). ONLY user-supplied
1492
+ values are used. No bibliography is inferred.
1493
+ extra_whitelist: Set of proper nouns / invented terms to whitelist from
1494
+ the invented-word density count.
1495
+ debug_output_dir: If set, write debug artefacts to this directory.
1496
+ page_trace: Per-page classification list from extraction phase.
1497
+ source_file_path: Original file path, used only if works_sampled is blank
1498
+ and a filename-derived fallback is needed.
1499
 
1500
  Returns:
1501
  Dictionary of metric values, confidence flags, and metadata.
 
1502
  """
1503
+ # Fix P1: Provenance lock — Works_Sampled is never inferred from context.
1504
+ locked_works = _lock_works_sampled(works_sampled, file_path=source_file_path)
1505
+
1506
  text = clean_text(text)
1507
 
 
1508
  all_lines = [l.strip() for l in text.split("\n") if l.strip()]
 
 
1509
  candidate_lines = [l for l in all_lines if is_candidate_verse_line(l)]
1510
 
1511
  words = tokenise_words(text)
 
1523
  else:
1524
  confidence = f"HIGH — sample {total_words} words"
1525
 
1526
+ # ── VM-001: Syllables per line ────────────────────────────────────────────
1527
  line_syllable_counts = []
1528
  for line in candidate_lines:
1529
  line_words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
 
1542
  else:
1543
  vm002 = -1.0
1544
 
1545
+ # ── VM-003 + 004: Rhyme density and scheme (Fix P8 + P9) ─────────────────
1546
  end_words, line_endings_trace = get_candidate_verse_line_endings(text)
1547
+ vm003, rhyme_pairs_debug = compute_rhyme_density(end_words)
1548
+ vm004 = detect_rhyme_scheme(end_words, vm003)
1549
 
1550
  # ── VM-005: Stressed syllable regularity ──────────────────────────────────
1551
  vm005 = compute_stress_regularity(candidate_lines)
 
1556
  # ── VM-007: Type-token ratio ──────────────────────────────────────────────
1557
  vm007 = round(len(unique_words) / total_words, 3) if total_words > 0 else -1.0
1558
 
1559
+ # ── VM-008: Invented word density (Fix P10) ───────────────────────────────
1560
+ unknown_token_records: list[dict] = []
1561
+ unknown_count = 0
1562
+ for w in unique_words:
1563
+ if len(w) <= 2:
1564
+ continue
1565
+ known, reason = is_known_word(w, extra_whitelist=extra_whitelist)
1566
+ if not known:
1567
+ unknown_count += 1
1568
+ unknown_token_records.append({
1569
+ "word": w,
1570
+ "reason": reason,
1571
+ "is_ocr_gibberish": is_ocr_gibberish(w),
1572
+ "is_proper_noun": _is_proper_noun_in_context(w),
1573
+ })
1574
+
1575
+ vm008 = round(unknown_count / len(unique_words), 3) if unique_words else 0.0
1576
 
1577
  # ── VM-009: Average word length ───────────────────────────────────────────
1578
  vm009 = round(sum(len(w) for w in words) / total_words, 2) if total_words > 0 else -1.0
 
1592
  else:
1593
  vm011 = -1.0
1594
 
1595
+ # ── VM-012: Cumulative structure score (Fix P11) ──────────────────────────
1596
+ vm012, cumulative_matches = compute_cumulative_structure(sentences)
1597
+
1598
+ # Tag cumulative matches with source for repetition_matches.csv
1599
+ for m in cumulative_matches:
1600
+ m.setdefault("source", "VM-012")
1601
+ m.setdefault("line_index", "")
1602
+ m.setdefault("line", "")
1603
+ m.setdefault("matched_ngram", "")
1604
 
1605
+ # ── VM-013: Dialogue proportion (Fix 8) ──────────────────────────────────
1606
  vm013 = compute_dialogue_proportion(text, total_words)
1607
 
1608
  # ── VM-024: Word count total ──────────────────────────────────────────────
 
1619
  questions = len(QUESTION_PATTERN.findall(text))
1620
  vm027 = round((questions / total_words) * 100, 2) if total_words > 0 else 0.0
1621
 
1622
+ # ── VM-028: Repetition index (Fix P11) ───────────────────────────────────
1623
+ vm028, repetition_line_matches = compute_repetition_index(all_lines)
1624
+
1625
+ # Tag VM-028 matches with source for repetition_matches.csv
1626
+ for m in repetition_line_matches:
1627
+ m.setdefault("source", "VM-028")
1628
+ m.setdefault("sentence_index", "")
1629
+ m.setdefault("sentence", "")
1630
+ m.setdefault("matched_phrase", "")
1631
+
1632
+ # Combine repetition matches
1633
+ all_repetition_matches = cumulative_matches + repetition_line_matches
1634
 
1635
  # ── ASSEMBLE OUTPUT ───────────────────────────────────────────────────────
1636
  result: dict[str, Any] = {
1637
+ # Metadata — Fix P1: Works_Sampled is provenance-locked.
1638
  "Author_ID": author_id,
1639
  "Author_Name": author_name,
1640
+ "Works_Sampled": locked_works,
1641
  "Sample_Words": total_words,
1642
  "Sample_Lines": len(all_lines),
1643
  "Sample_Candidate_Verse_Lines": len(candidate_lines),
 
1678
  result["QA_Flags"] = qa_flags
1679
  result["QA_Flag_Count"] = len(qa_flags)
1680
 
1681
+ # ── DEBUG ARTEFACT EXPORTS ────────────────────────────────────────────────
1682
  if debug_output_dir is not None:
1683
  metric_trace = {
1684
  "total_words": total_words,
1685
  "unique_words": len(unique_words),
1686
  "total_lines": len(all_lines),
1687
  "candidate_verse_lines": len(candidate_lines),
1688
+ "candidate_rhyme_end_words": len(end_words),
1689
+ "rhyme_density_couplet_or_alternating": vm003,
1690
+ "rhyme_type": vm004,
1691
+ "unknown_token_count": unknown_count,
1692
+ "cumulative_structure_matches": len(cumulative_matches),
1693
+ "repetition_line_matches": len(repetition_line_matches),
1694
  "dialogue_tokens": int(round(vm013 * total_words)),
1695
  "exclamation_count": exclamations,
1696
  "question_count": questions,
 
1697
  "sentence_count": total_sentences,
1698
+ "works_sampled_locked": locked_works,
1699
  }
1700
  artefact_paths = export_debug_artefacts(
1701
  output_dir=debug_output_dir,
 
1704
  line_endings_trace=line_endings_trace,
1705
  metric_trace=metric_trace,
1706
  qa_flags=qa_flags,
1707
+ page_trace=page_trace,
1708
+ rhyme_pairs_debug=rhyme_pairs_debug,
1709
+ unknown_tokens=unknown_token_records,
1710
+ repetition_matches=all_repetition_matches,
1711
  )
1712
  result["Debug_Artefacts"] = artefact_paths
1713
 
 
1738
  f"",
1739
  f" Author: {fp['Author_Name']}",
1740
  f" ID: {fp['Author_ID']}",
1741
+ f" Works: {fp['Works_Sampled']}",
1742
  f" Words: {fp['Sample_Words']}",
1743
  f" Lines (total): {fp['Sample_Lines']}",
1744
  f" Lines (verse cands): {fp.get('Sample_Candidate_Verse_Lines', 'n/a')}",
 
1804
  file_path: Path to uploaded PDF or text file.
1805
  author_name: Author's full name.
1806
  author_id: Codex author ID.
1807
+ works_sampled: Title of the uploaded work (user-supplied only;
1808
+ no bibliography is inferred from this field).
1809
  start_page: Optional 1-based start page for story extraction.
1810
  end_page: Optional 1-based end page for story extraction.
1811
  extra_whitelist_str: Comma-separated proper nouns / invented terms
1812
  to whitelist from VM-008 (e.g. "Gruffalo,Zog").
1813
+ debug_output_dir: Directory to write debug artefacts.
 
1814
 
1815
  Returns:
1816
  Tuple of (formatted_report_string, raw_dict).
1817
  """
1818
  try:
1819
+ story_text, raw_text, page_trace = extract_text_from_file(
1820
  file_path,
1821
  start_page=start_page,
1822
  end_page=end_page,
 
1853
  works_sampled=works_sampled,
1854
  extra_whitelist=extra_whitelist,
1855
  debug_output_dir=debug_output_dir,
1856
+ page_trace=page_trace,
1857
+ source_file_path=file_path,
1858
  )
1859
  report = format_fingerprint_report(fp)
1860
  return report, fp
 
1866
  # ── STANDALONE TEST ───────────────────────────────────────────────────────────
1867
 
1868
  if __name__ == "__main__":
1869
+ # Quick test with Donaldson-style rhymed couplets — run: python3 codex_extractor.py
1870
  SAMPLE = """
1871
+ A mouse took a stroll through the deep dark wood.
1872
+ A fox saw the mouse and the mouse looked good.
1873
+ "Where are you going to, little brown mouse?
1874
+ Come and have lunch in my underground house."
1875
+ "It's terribly kind of you, Fox, but no—
1876
+ I'm going to have lunch with a gruffalo."
1877
+ "A gruffalo? What's a gruffalo?"
1878
+ "A gruffalo! Why, didn't you know?
1879
+ He has terrible tusks, and terrible claws,
1880
+ And terrible teeth in his terrible jaws."
1881
+ "Where are you meeting him?" "Here, by these rocks,
1882
+ And his favourite food is roasted fox."
1883
+ "Roasted fox! I'm off!" Fox said. "Goodbye,
1884
+ Little brown mouse." And away he did fly.
1885
+ "Silly old Fox! Doesn't he know,
1886
+ There's no such thing as a gruffalo?"
1887
+ On went the mouse through the deep dark wood.
1888
+ An owl saw the mouse and the mouse looked good.
1889
+ "Where are you going to, little brown mouse?
1890
+ Come and have tea in my treetop house."
1891
+ "It's frightfully nice of you, Owl, but no—
1892
+ I'm going to have tea with a gruffalo."
1893
+ "A gruffalo? What's a gruffalo?"
1894
+ "A gruffalo! Why, didn't you know?
1895
+ He has knobbly knees, and turned-out toes,
1896
+ And a poisonous wart at the end of his nose."
1897
+ "Where are you meeting him?" "Here, by this stream,
1898
+ And his favourite food is owl ice cream."
1899
+ "Owl ice cream? Toowhit toowhoo,
1900
+ Goodbye, little mouse." And away Owl flew.
1901
  """
1902
  fp = extract_fingerprint(
1903
  text=SAMPLE,
1904
  raw_text=SAMPLE,
1905
+ author_name="Julia Donaldson",
1906
+ author_id="CA-001",
1907
+ works_sampled="The Gruffalo",
1908
  extra_whitelist={"gruffalo"},
1909
  debug_output_dir="/tmp/codex_debug",
1910
  )