Pointf5ive commited on
Commit
31b2eee
·
verified ·
1 Parent(s): 30cacad

Update src/codex_extractor.py

Browse files
Files changed (1) hide show
  1. src/codex_extractor.py +749 -216
src/codex_extractor.py CHANGED
@@ -30,29 +30,45 @@ Use Prompt 2 (ChatGPT/Gemini) for those — see Codex Build Prompts document.
30
  Dependencies (add to requirements.txt):
31
  pdfplumber>=0.10
32
  nltk>=3.8
33
- pypdfium2>=4.30
34
- pytesseract>=0.3.10
35
-
36
- System dependency for OCR in Hugging Face Space (packages.txt):
37
- tesseract-ocr
38
- tesseract-ocr-eng
39
 
40
  NLTK data required (auto-downloaded on first run):
41
  punkt, punkt_tab, averaged_perceptron_tagger, cmudict, stopwords
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  Author: TOTEM Studio — Jamal Romeh
44
- Version: 1.0
45
  """
46
 
47
  from __future__ import annotations
48
 
 
 
49
  import math
 
50
  import re
51
  import string
52
- import os
53
  from collections import Counter
54
  from pathlib import Path
55
- from shutil import which
56
  from typing import Any
57
 
58
  # ── OPTIONAL IMPORTS WITH GRACEFUL FALLBACK ──────────────────────────────────
@@ -63,23 +79,8 @@ try:
63
  except ImportError:
64
  PDF_AVAILABLE = False
65
 
66
- try:
67
- import pypdfium2 as pdfium
68
- PDFIUM_AVAILABLE = True
69
- except Exception:
70
- PDFIUM_AVAILABLE = False
71
-
72
- try:
73
- import pytesseract
74
- from pytesseract import TesseractNotFoundError
75
- PYTESSERACT_AVAILABLE = True
76
- except Exception:
77
- PYTESSERACT_AVAILABLE = False
78
- TesseractNotFoundError = RuntimeError # type: ignore[assignment]
79
-
80
  try:
81
  import nltk
82
- # Auto-download required NLTK data if not present
83
  _NLTK_DATA = ["punkt", "punkt_tab", "averaged_perceptron_tagger", "cmudict", "stopwords"]
84
  for _pkg in _NLTK_DATA:
85
  try:
@@ -99,12 +100,9 @@ except Exception:
99
 
100
  # ── CONSTANTS ─────────────────────────────────────────────────────────────────
101
 
102
- MIN_WORD_COUNT = 200 # Below this: LOW CONFIDENCE flag
103
- TARGET_WORD_COUNT = 1000 # Above this: HIGH CONFIDENCE
104
 
105
- # Dolch sight words + Fry first 500 as a proxy for 4–7 age-band lexicon.
106
- # This is a representative subset — the full list should be loaded from a file
107
- # in production. Stored here for portability without external file dependency.
108
  DOLCH_FRY_PROXY = set("""
109
  a about after again all along also always am an and any are around as ask at away
110
  be been before big boy but by call came can come could day did do does down each
@@ -119,110 +117,232 @@ until up us use very want was way we well went were what when where which while
119
  who why will with word work world would write year you young your
120
  """.split())
121
 
122
- # Common English words unlikely to be in a children's 4-7 lexicon
123
- # Used as negative signal for VM-006
124
-
125
  SIMPLE_TOKENISE_PATTERN = re.compile(r"\b[a-z']+\b")
126
  SENTENCE_END_PATTERN = re.compile(r"[.!?]+")
127
- QUOTE_PATTERN = re.compile(r'"[^"]*"')
128
  EXCLAMATION_PATTERN = re.compile(r"!")
129
  QUESTION_PATTERN = re.compile(r"\?")
130
 
131
- # ── TEXT EXTRACTION ───────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
132
 
133
- def _word_count(text: str) -> int:
134
- """Count alpha-ish tokens quickly for extraction health checks."""
135
- return len(SIMPLE_TOKENISE_PATTERN.findall(text.lower()))
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
- def _ocr_runtime_ready() -> bool:
139
- """Return True when OCR fallback dependencies and binary are available."""
140
- if not PDFIUM_AVAILABLE or not PYTESSERACT_AVAILABLE:
 
 
 
 
 
141
  return False
142
- return which("tesseract") is not None
143
 
144
 
145
- def extract_text_from_pdf_ocr(pdf_path: str | Path) -> str:
146
  """
147
- OCR fallback for image-based PDFs.
148
- Renders pages to images, then runs Tesseract OCR.
149
  """
150
- if not PDFIUM_AVAILABLE:
151
- raise RuntimeError("OCR fallback unavailable: pypdfium2 is not installed.")
152
- if not PYTESSERACT_AVAILABLE:
153
- raise RuntimeError("OCR fallback unavailable: pytesseract is not installed.")
154
- if which("tesseract") is None:
155
- raise RuntimeError("OCR fallback unavailable: tesseract binary is not installed on this runtime.")
156
 
157
- render_scale = float(os.getenv("OCR_RENDER_SCALE", "2.0"))
158
- max_pages = int(os.getenv("OCR_MAX_PAGES", "200"))
159
- ocr_lang = os.getenv("OCR_LANG", "eng")
160
- ocr_config = os.getenv("OCR_CONFIG", "--oem 1 --psm 6")
161
 
162
- text_parts: list[str] = []
163
- pdf = pdfium.PdfDocument(str(pdf_path))
164
- page_count = min(len(pdf), max_pages)
 
165
 
166
- for page_index in range(page_count):
167
- page = pdf[page_index]
168
- bitmap = page.render(scale=render_scale)
169
- pil_image = bitmap.to_pil()
170
- page_text = pytesseract.image_to_string(pil_image, lang=ocr_lang, config=ocr_config)
171
- if page_text and page_text.strip():
172
- text_parts.append(page_text)
 
 
173
 
174
- return "\n".join(text_parts)
175
 
 
176
 
177
- def extract_text_from_pdf(pdf_path: str | Path) -> str:
 
 
 
 
178
  """
179
- Extract text from a PDF:
180
- 1) native selectable text layer
181
- 2) OCR fallback if little/no selectable text is present
 
 
 
 
 
 
 
 
 
 
 
 
182
  """
183
  if not PDF_AVAILABLE:
184
  raise RuntimeError("pdfplumber is not installed. Add it to requirements.txt.")
185
 
186
- text_parts = []
 
 
187
  with pdfplumber.open(str(pdf_path)) as pdf:
188
- for page in pdf.pages:
189
- page_text = page.extract_text()
190
- if page_text:
191
- text_parts.append(page_text)
192
- extracted = "\n".join(text_parts)
193
 
194
- # If selectable text exists, use it immediately.
195
- if _word_count(extracted) >= 20:
196
- return extracted
 
 
197
 
198
- # Scanned/image-only PDFs fall back to OCR.
199
- try:
200
- ocr_text = extract_text_from_pdf_ocr(pdf_path)
201
- except Exception as ocr_error:
202
- raise RuntimeError(
203
- "No selectable text detected in PDF and OCR fallback failed. "
204
- f"Reason: {ocr_error}"
205
- ) from ocr_error
 
206
 
207
- return ocr_text
 
 
 
 
 
208
 
 
209
 
210
- def extract_text_from_file(file_path: str | Path) -> str:
211
- """Extract text from PDF or plain text file."""
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  path = Path(file_path)
213
  if path.suffix.lower() == ".pdf":
214
- return extract_text_from_pdf(path)
215
  else:
216
- return path.read_text(encoding="utf-8", errors="replace")
 
 
217
 
 
218
 
219
  def clean_text(raw: str) -> str:
220
- """Basic cleaning — remove excessive whitespace, normalise line breaks."""
221
- text = re.sub(r"\r\n", "\n", raw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  text = re.sub(r"\r", "\n", text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  text = re.sub(r"\n{3,}", "\n\n", text)
224
- text = re.sub(r"[ \t]+", " ", text)
225
- return text.strip()
 
 
 
 
226
 
227
 
228
  # ── SYLLABLE COUNTING ─────────────────────────────────────────────────────────
@@ -231,7 +351,6 @@ def count_syllables_cmu(word: str) -> int | None:
231
  """Count syllables using CMU Pronouncing Dictionary. Returns None if not found."""
232
  word_lower = word.lower().strip(string.punctuation)
233
  if word_lower in CMU_DICT:
234
- # Take first pronunciation, count vowel phonemes
235
  pronunciation = CMU_DICT[word_lower][0]
236
  return sum(1 for ph in pronunciation if ph[-1].isdigit())
237
  return None
@@ -245,7 +364,6 @@ def count_syllables_fallback(word: str) -> int:
245
  word = word.lower().strip(string.punctuation)
246
  if not word:
247
  return 0
248
- # Remove trailing silent e
249
  if word.endswith("e") and len(word) > 2:
250
  word = word[:-1]
251
  vowels = "aeiouy"
@@ -268,14 +386,95 @@ def count_syllables(word: str) -> int:
268
  return count_syllables_fallback(word)
269
 
270
 
271
- def is_known_word(word: str) -> bool:
272
- """Return True if word is in CMU dict (proxy for standard English dictionary)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  word_lower = word.lower().strip(string.punctuation)
274
  if not word_lower or not word_lower.isalpha():
275
  return True # Don't flag numbers/punctuation as invented
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  if NLTK_AVAILABLE and CMU_DICT:
277
  return word_lower in CMU_DICT
278
- # Fallback: assume known if it looks like a real word
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  return True
280
 
281
 
@@ -290,7 +489,6 @@ def get_rhyme_signature(word: str) -> str | None:
290
  if not word_lower or not NLTK_AVAILABLE or word_lower not in CMU_DICT:
291
  return None
292
  pronunciation = CMU_DICT[word_lower][0]
293
- # Find last stressed vowel and take everything from there
294
  last_vowel_idx = None
295
  for i, ph in enumerate(pronunciation):
296
  if ph[-1].isdigit():
@@ -306,7 +504,6 @@ def words_rhyme(word1: str, word2: str) -> bool:
306
  sig2 = get_rhyme_signature(word2)
307
  if sig1 and sig2 and sig1 == sig2 and word1.lower() != word2.lower():
308
  return True
309
- # Fallback: last 2 characters match (crude but works without NLTK)
310
  w1 = word1.lower().strip(string.punctuation)
311
  w2 = word2.lower().strip(string.punctuation)
312
  if len(w1) >= 2 and len(w2) >= 2 and w1 != w2:
@@ -314,15 +511,52 @@ def words_rhyme(word1: str, word2: str) -> bool:
314
  return False
315
 
316
 
317
- def get_line_end_words(text: str) -> list[str]:
318
- """Extract the last word from each non-empty line."""
319
- lines = [line.strip() for line in text.split("\n") if line.strip()]
320
- end_words = []
321
- for line in lines:
322
- words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
323
- if words:
324
- end_words.append(words[-1])
325
- return end_words
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
 
328
  def compute_rhyme_density(end_words: list[str]) -> float:
@@ -332,58 +566,89 @@ def compute_rhyme_density(end_words: list[str]) -> float:
332
  """
333
  if len(end_words) < 2:
334
  return 0.0
335
- pairs = [(end_words[i], end_words[i+1]) for i in range(len(end_words)-1)]
336
  rhyming = sum(1 for w1, w2 in pairs if words_rhyme(w1, w2))
337
  return round(rhyming / len(pairs), 3)
338
 
339
 
340
- def detect_rhyme_scheme(end_words: list[str], window: int = 8) -> str:
 
 
341
  """
342
- Attempt to identify dominant rhyme scheme from first window lines.
343
- Returns: AABB, ABAB, ABCB, free, mixed, or unknown.
 
 
 
 
 
344
  """
345
  if len(end_words) < 4:
346
  return "insufficient data"
347
 
348
- sample = end_words[:window]
349
-
350
- # Test AABB: 0-1 rhyme, 2-3 rhyme
351
- aabb_score = 0
352
- for i in range(0, min(len(sample)-1, 8), 2):
353
- if i+1 < len(sample) and words_rhyme(sample[i], sample[i+1]):
354
- aabb_score += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
- # Test ABAB: 0-2 rhyme, 1-3 rhyme
357
- abab_score = 0
358
- for i in range(0, min(len(sample)-2, 6), 2):
359
- if i+2 < len(sample) and words_rhyme(sample[i], sample[i+2]):
360
- abab_score += 1
361
 
362
- # Test ABCB: 1-3 rhyme only
363
- abcb_score = 0
364
- for i in range(1, min(len(sample)-2, 7), 4):
365
- if i+2 < len(sample) and words_rhyme(sample[i], sample[i+2]):
366
- abcb_score += 1
367
 
368
- max_score = max(aabb_score, abab_score, abcb_score)
369
- if max_score == 0:
370
  density = compute_rhyme_density(end_words)
371
- return "free" if density < 0.15 else "mixed"
372
 
373
- if aabb_score >= abab_score and aabb_score >= abcb_score:
 
 
 
 
374
  return "AABB"
375
- elif abab_score >= aabb_score and abab_score >= abcb_score:
376
  return "ABAB"
377
- else:
378
  return "ABCB"
 
 
379
 
380
 
381
  # ── STRESS / METRE ────────────────────────────────────────────────────────────
382
 
383
  def get_stress_pattern(line: str) -> list[int]:
384
  """
385
- Return a list of stress values (0=unstressed, 1=stressed) for each syllable in a line.
386
- Uses CMU dict stress markers.
387
  """
388
  words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
389
  pattern = []
@@ -394,11 +659,10 @@ def get_stress_pattern(line: str) -> list[int]:
394
  if ph[-1] == "1":
395
  pattern.append(1)
396
  elif ph[-1] == "2":
397
- pattern.append(1) # secondary stress counts
398
  elif ph[-1] == "0":
399
  pattern.append(0)
400
  else:
401
- # Fallback: assume alternating stress
402
  syllables = count_syllables_fallback(word)
403
  for i in range(syllables):
404
  pattern.append(i % 2)
@@ -411,16 +675,16 @@ def compute_stress_regularity(lines: list[str]) -> float:
411
  Returns 0–1 where 1 = perfectly regular metre.
412
  """
413
  if not NLTK_AVAILABLE or not CMU_DICT:
414
- return -1.0 # Cannot compute without CMU dict
415
 
416
- patterns = [get_stress_pattern(line) for line in lines if line.strip()]
 
 
417
  patterns = [p for p in patterns if len(p) >= 4]
418
 
419
  if len(patterns) < 3:
420
  return -1.0
421
 
422
- # Measure consistency of stress at each position across lines
423
- # Truncate to shortest pattern length
424
  min_len = min(len(p) for p in patterns)
425
  if min_len < 4:
426
  return -1.0
@@ -456,7 +720,6 @@ def tokenise_sentences(text: str) -> list[str]:
456
  return sent_tokenize(text)
457
  except Exception:
458
  pass
459
- # Fallback: split on sentence-ending punctuation
460
  sentences = re.split(r"[.!?]+", text)
461
  return [s.strip() for s in sentences if s.strip() and len(s.split()) > 1]
462
 
@@ -471,59 +734,103 @@ def flesch_kincaid_grade(text: str, words: list[str], sentences: list[str]) -> f
471
  if not words or not sentences:
472
  return -1.0
473
  total_syllables = sum(count_syllables(w) for w in words)
474
- asl = len(words) / len(sentences) # Average sentence length
475
- asw = total_syllables / len(words) # Average syllables per word
476
  fk = 0.39 * asl + 11.8 * asw - 15.59
477
  return round(max(0.0, fk), 2)
478
 
479
 
480
- # ── REPETITION INDEX ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
481
 
482
  def compute_repetition_index(lines: list[str], ngram_size: int = 3) -> float:
483
  """
484
- Proportion of lines that reuse an n-gram from a prior line.
 
 
 
 
485
  Returns float 0–1.
486
  """
487
- if len(lines) < 2:
 
 
 
 
 
 
488
  return 0.0
489
 
490
  seen_ngrams: set[tuple] = set()
491
  repeat_count = 0
492
 
493
- for line in lines:
494
- words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
495
  if len(words) < ngram_size:
496
- continue
497
- ngrams = [tuple(words[i:i+ngram_size]) for i in range(len(words)-ngram_size+1)]
 
 
 
 
 
 
 
498
  line_has_repeat = any(ng in seen_ngrams for ng in ngrams)
499
  if line_has_repeat:
500
  repeat_count += 1
501
  seen_ngrams.update(ngrams)
502
 
503
- return round(repeat_count / len(lines), 3)
504
 
505
 
506
  # ── CUMULATIVE STRUCTURE ──────────────────────────────────────────────────────
507
 
508
  def compute_cumulative_structure(sentences: list[str]) -> float:
509
  """
510
- Proportion of sentences that open with a phrase used in a prior sentence.
511
- Proxy for 'and then... and then...' accumulation pattern.
 
 
 
512
  """
513
  if len(sentences) < 3:
514
  return 0.0
515
 
516
- opening_phrases: list[str] = []
 
517
  cumulative_count = 0
518
 
519
  for sent in sentences:
520
  words = SIMPLE_TOKENISE_PATTERN.findall(sent.lower())
521
- if len(words) < 3:
522
  continue
523
- opening = " ".join(words[:3])
524
- if opening in opening_phrases:
 
 
 
 
 
 
 
 
 
525
  cumulative_count += 1
526
- opening_phrases.append(opening)
 
 
 
527
 
528
  return round(cumulative_count / len(sentences), 3)
529
 
@@ -545,37 +852,178 @@ def compute_vocabulary_tier_match(words: list[str]) -> float:
545
  # ── DIALOGUE PROPORTION ───────────────────────────────────────────────────────
546
 
547
  def compute_dialogue_proportion(text: str, total_words: int) -> float:
548
- """Proportion of words inside quotation marks."""
 
 
 
 
549
  if total_words == 0:
550
  return 0.0
551
- quoted_text = " ".join(QUOTE_PATTERN.findall(text))
552
  quoted_words = len(SIMPLE_TOKENISE_PATTERN.findall(quoted_text.lower()))
553
  return round(min(1.0, quoted_words / total_words), 3)
554
 
555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  # ── MAIN EXTRACTION FUNCTION ──────────────────────────────────────────────────
557
 
558
  def extract_fingerprint(
559
  text: str,
 
560
  author_name: str = "Unknown",
561
  author_id: str = "CA-XXX",
562
  works_sampled: str = "",
 
 
563
  ) -> dict[str, Any]:
564
  """
565
  Extract all Tier 1 fingerprint metrics from text.
566
 
567
  Args:
568
- text: Raw text to analyse (already extracted from PDF or txt).
569
- author_name: Author's full name for the output record.
570
- author_id: Codex author ID (e.g. CA-001).
571
- works_sampled: Comma-separated list of titles included in the text.
 
 
 
 
572
 
573
  Returns:
574
  Dictionary of metric values, confidence flags, and metadata.
575
  Ready to paste into CODEX_03_FINGERPRINTS workbook row.
576
  """
577
  text = clean_text(text)
578
- lines = [l.strip() for l in text.split("\n") if l.strip()]
 
 
 
 
 
 
579
  words = tokenise_words(text)
580
  sentences = tokenise_sentences(text)
581
 
@@ -591,9 +1039,9 @@ def extract_fingerprint(
591
  else:
592
  confidence = f"HIGH — sample {total_words} words"
593
 
594
- # ── VM-001: Syllables per line ────────────────────────────────────────────
595
  line_syllable_counts = []
596
- for line in lines:
597
  line_words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
598
  if line_words:
599
  syllables = sum(count_syllables(w) for w in line_words)
@@ -610,15 +1058,13 @@ def extract_fingerprint(
610
  else:
611
  vm002 = -1.0
612
 
613
- # ── VM-003: Rhyme scheme density ──────────────────────────────────────────
614
- end_words = get_line_end_words(text)
615
  vm003 = compute_rhyme_density(end_words)
616
-
617
- # ── VM-004: Rhyme scheme type ─────────────────────────────────────────────
618
  vm004 = detect_rhyme_scheme(end_words)
619
 
620
  # ── VM-005: Stressed syllable regularity ──────────────────────────────────
621
- vm005 = compute_stress_regularity(lines)
622
 
623
  # ── VM-006: Vocabulary tier match 4-7 ────────────────────────────────────
624
  vm006 = compute_vocabulary_tier_match(words)
@@ -626,15 +1072,21 @@ def extract_fingerprint(
626
  # ── VM-007: Type-token ratio ──────────────────────────────────────────────
627
  vm007 = round(len(unique_words) / total_words, 3) if total_words > 0 else -1.0
628
 
629
- # ── VM-008: Invented word density ────────────────────────────────────────
630
- unknown_words = [w for w in unique_words if len(w) > 2 and not is_known_word(w)]
 
 
 
631
  vm008 = round(len(unknown_words) / len(unique_words), 3) if unique_words else 0.0
632
 
633
  # ── VM-009: Average word length ───────────────────────────────────────────
634
  vm009 = round(sum(len(w) for w in words) / total_words, 2) if total_words > 0 else -1.0
635
 
636
  # ── VM-010: Sentence length mean ─────────────────────────────────────────
637
- sent_lengths = [len(SIMPLE_TOKENISE_PATTERN.findall(s.lower())) for s in sentences if s.strip()]
 
 
 
638
  vm010 = round(sum(sent_lengths) / len(sent_lengths), 2) if sent_lengths else -1.0
639
 
640
  # ── VM-011: Sentence length variance ─────────────────────────────────────
@@ -645,10 +1097,10 @@ def extract_fingerprint(
645
  else:
646
  vm011 = -1.0
647
 
648
- # ── VM-012: Cumulative structure score ────────────────────────────────────
649
  vm012 = compute_cumulative_structure(sentences)
650
 
651
- # ─ VM-013: Dialogue proportion ───────────────────────────────────────────
652
  vm013 = compute_dialogue_proportion(text, total_words)
653
 
654
  # ── VM-024: Word count total ──────────────────────────────────────────────
@@ -665,17 +1117,18 @@ def extract_fingerprint(
665
  questions = len(QUESTION_PATTERN.findall(text))
666
  vm027 = round((questions / total_words) * 100, 2) if total_words > 0 else 0.0
667
 
668
- # ── VM-028: Repetition index ──────────────────────────────────────────────
669
- vm028 = compute_repetition_index(lines)
670
 
671
  # ── ASSEMBLE OUTPUT ───────────────────────────────────────────────────────
672
- result = {
673
  # Metadata
674
  "Author_ID": author_id,
675
  "Author_Name": author_name,
676
  "Works_Sampled": works_sampled,
677
  "Sample_Words": total_words,
678
- "Sample_Lines": len(lines),
 
679
  "Sample_Sentences": total_sentences,
680
  "Confidence_Level": confidence,
681
  "NLTK_Available": NLTK_AVAILABLE,
@@ -702,9 +1155,42 @@ def extract_fingerprint(
702
  "VM-028_Repetition_index": vm028,
703
 
704
  # Tier 2 reminder
705
- "VM-014_to_VM-023": "TIER 2 — Use Codex Build Prompt 2 (ChatGPT/Gemini) for qualitative metrics",
 
 
 
706
  }
707
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  return result
709
 
710
 
@@ -713,19 +1199,32 @@ def format_fingerprint_report(fp: dict[str, Any]) -> str:
713
  Format a fingerprint dict as a human-readable report string
714
  suitable for display in the Gradio interface.
715
  """
 
 
 
 
 
 
 
 
 
 
 
 
716
  lines = [
717
  f"╔══════════════════════════════════════════════════════╗",
718
  f" TOTEM STUDIO CODEX — FINGERPRINT EXTRACTION REPORT",
719
  f"╚══════════════════════════════════════════════════════╝",
720
  f"",
721
- f" Author: {fp['Author_Name']}",
722
- f" ID: {fp['Author_ID']}",
723
- f" Works: {fp['Works_Sampled'] or 'Not specified'}",
724
- f" Words: {fp['Sample_Words']}",
725
- f" Lines: {fp['Sample_Lines']}",
726
- f" Sentences: {fp['Sample_Sentences']}",
727
- f" Confidence: {fp['Confidence_Level']}",
728
- f" NLTK: {'Available' if fp['NLTK_Available'] else 'Not available — some metrics reduced accuracy'}",
 
729
  f"",
730
  f"── SONIC & RHYTHMIC ────────────────────────────────────",
731
  f" VM-001 Syllables per line (mean): {fp['VM-001_Syllables_per_line']}",
@@ -757,10 +1256,15 @@ def format_fingerprint_report(fp: dict[str, Any]) -> str:
757
  f" VM-014 to VM-023 require qualitative extraction.",
758
  f" Use Codex Build Prompt 2 (ChatGPT/Gemini) with the",
759
  f" same text sample to complete these fields.",
760
- f"",
761
- f" Copy values above into CODEX_03_FINGERPRINTS row: {fp['Author_ID']}",
762
  ]
763
- return "\n".join(lines)
 
 
 
 
 
 
 
764
 
765
 
766
  def process_upload(
@@ -768,39 +1272,64 @@ def process_upload(
768
  author_name: str,
769
  author_id: str,
770
  works_sampled: str,
 
 
 
 
771
  ) -> tuple[str, dict]:
772
  """
773
  Entry point for Gradio interface.
774
- Accepts a file path, returns (formatted_report_string, raw_dict).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
  """
776
  try:
777
- raw_text = extract_text_from_file(file_path)
778
- if not raw_text or len(raw_text.split()) < 20:
 
 
 
 
 
779
  return (
780
  "ERROR: No usable text extracted from file. "
781
- "If this is a scanned/image PDF, OCR could not recover enough text.",
 
782
  {},
783
  )
 
 
 
 
 
 
 
 
 
784
  fp = extract_fingerprint(
785
- text=raw_text,
 
786
  author_name=author_name,
787
  author_id=author_id,
788
  works_sampled=works_sampled,
 
 
789
  )
790
  report = format_fingerprint_report(fp)
791
  return report, fp
792
- except RuntimeError as e:
793
- msg = str(e)
794
- if "OCR fallback failed" in msg:
795
- if not _ocr_runtime_ready():
796
- return (
797
- "ERROR: This PDF appears image-based, but OCR runtime is not available yet. "
798
- "Install OCR dependencies (pytesseract, pypdfium2) and system package "
799
- "`tesseract-ocr` in the Space, then retry.",
800
- {},
801
- )
802
- return f"ERROR: OCR was attempted but failed: {msg}", {}
803
- return f"ERROR: {msg}", {}
804
  except Exception as e:
805
  return f"ERROR: {type(e).__name__}: {str(e)}", {}
806
 
@@ -821,8 +1350,12 @@ if __name__ == "__main__":
821
  """
822
  fp = extract_fingerprint(
823
  text=SAMPLE,
 
824
  author_name="Test Author",
825
  author_id="CA-TEST",
826
  works_sampled="Test sample",
 
 
827
  )
828
  print(format_fingerprint_report(fp))
 
 
30
  Dependencies (add to requirements.txt):
31
  pdfplumber>=0.10
32
  nltk>=3.8
 
 
 
 
 
 
33
 
34
  NLTK data required (auto-downloaded on first run):
35
  punkt, punkt_tab, averaged_perceptron_tagger, cmudict, stopwords
36
 
37
+ QA Fixes applied (v1.1 — engineering handoff 13/05/2026):
38
+ Fix 1 — Debug artefact exports (cleaned_text.txt, raw_ocr_text.txt,
39
+ line_endings.csv, metric_trace.json, qa_flags.json)
40
+ Fix 2 — Page filtering: skip cover/title/copyright/dedication pages;
41
+ support user-specified story page range
42
+ Fix 3 — OCR cleanup: strip watermarks, ISBN fragments, page numbers,
43
+ author/illustrator bylines, repeated title noise, isolated symbols
44
+ Fix 4 — Verse-line normalisation: collapse broken OCR fragments, remove
45
+ blank/artefact lines, preserve real verse breaks
46
+ Fix 5 — Rhyme detector rewrite: compute over candidate verse line endings
47
+ only, exclude dialogue tags, artefact lines, very short lines
48
+ Fix 6 — Rhyme scheme classification: stanza-window approach replaces
49
+ global adjacent-pair noise
50
+ Fix 7 — Proper-noun / invented-word whitelist: separates OCR gibberish
51
+ from intentional invented words
52
+ Fix 8 — Quote normalisation: curly/straight/OCR quote variants all
53
+ normalised before dialogue counting
54
+ Fix 9 — Fuzzy repetition matching: lowercased lemmatized n-gram windows
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
63
 
64
+ import csv
65
+ import json
66
  import math
67
+ import os
68
  import re
69
  import string
 
70
  from collections import Counter
71
  from pathlib import Path
 
72
  from typing import Any
73
 
74
  # ── OPTIONAL IMPORTS WITH GRACEFUL FALLBACK ──────────────────────────────────
 
79
  except ImportError:
80
  PDF_AVAILABLE = False
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  try:
83
  import nltk
 
84
  _NLTK_DATA = ["punkt", "punkt_tab", "averaged_perceptron_tagger", "cmudict", "stopwords"]
85
  for _pkg in _NLTK_DATA:
86
  try:
 
100
 
101
  # ── CONSTANTS ─────────────────────────────────────────────────────────────────
102
 
103
+ MIN_WORD_COUNT = 200
104
+ TARGET_WORD_COUNT = 1000
105
 
 
 
 
106
  DOLCH_FRY_PROXY = set("""
107
  a about after again all along also always am an and any are around as ask at away
108
  be been before big boy but by call came can come could day did do does down each
 
117
  who why will with word work world would write year you young your
118
  """.split())
119
 
 
 
 
120
  SIMPLE_TOKENISE_PATTERN = re.compile(r"\b[a-z']+\b")
121
  SENTENCE_END_PATTERN = re.compile(r"[.!?]+")
 
122
  EXCLAMATION_PATTERN = re.compile(r"!")
123
  QUESTION_PATTERN = re.compile(r"\?")
124
 
125
+ # ── FIX 8: QUOTE NORMALISATION ────────────────────────────────────────────────
126
+ # All quote variants normalised to straight double-quotes before any processing.
127
+ # Handles: curly open/close, OCR ligature variants, backticks, guillemets.
128
+
129
+ QUOTE_OPEN_PATTERN = re.compile(
130
+ r'[\u201C\u201F\u00AB\u2039\u275D\u276E`\u201E]'
131
+ )
132
+ QUOTE_CLOSE_PATTERN = re.compile(
133
+ r'[\u201D\u201E\u00BB\u203A\u275E\u276F\u201C]'
134
+ )
135
+ NORMALISED_QUOTE_PATTERN = re.compile(r'"[^"]*"')
136
 
 
 
 
137
 
138
+ def normalise_quotes(text: str) -> str:
139
+ """
140
+ Fix 8: Convert all quotation mark variants to straight double-quotes.
141
+ Runs early in the pipeline so dialogue detection works consistently
142
+ regardless of OCR or encoding.
143
+ """
144
+ text = QUOTE_OPEN_PATTERN.sub('"', text)
145
+ text = QUOTE_CLOSE_PATTERN.sub('"', text)
146
+ # Single curly quotes used as speech marks (common in UK publishers)
147
+ text = text.replace('\u2018', '"').replace('\u2019s', "'s")
148
+ text = re.sub(r'\u2018([^\']*)\u2019', r'"\1"', text)
149
+ return text
150
+
151
+
152
+ # ── FIX 2 + 3: PAGE FILTERING AND OCR CLEANUP ───────────────────────────────
153
+
154
+ # Signals that a page is front matter, not story text.
155
+ FRONT_MATTER_SIGNALS = re.compile(
156
+ r'(?:isbn|copyright|all rights reserved|first published|printed in'
157
+ r'|macmillan|publishers limited|catalogue record|british library'
158
+ r'|illustrated by|text copyright|illustrations copyright'
159
+ r'|for all at|in accordance with|designs and patents act'
160
+ r'|associated companies|basingstoke)',
161
+ re.IGNORECASE,
162
+ )
163
+
164
+ # OCR noise / watermark patterns to strip from individual lines.
165
+ OCR_NOISE_PATTERNS = [
166
+ re.compile(r'ppsbook\.com', re.IGNORECASE),
167
+ re.compile(r'绘本在线论坛', re.UNICODE), # Chinese watermark visible in test PDF
168
+ re.compile(r'\bisbn\b[\d\s\-]+', re.IGNORECASE),
169
+ re.compile(r'^\s*\d{1,4}\s*$'), # Lone page numbers
170
+ re.compile(r'^\s*[©®™]\s*.*$', re.MULTILINE), # Bare copyright symbol lines
171
+ re.compile(r'^\s*[A-Z][a-z]+ [A-Z][a-z]+\s*$'), # "Firstname Lastname" bylines (2-word only)
172
+ ]
173
+
174
+ # Lines this short (in tokens) are almost certainly OCR artefacts when they
175
+ # consist only of non-alphabetic characters or a single isolated symbol.
176
+ MIN_LINE_TOKENS_FOR_METRICS = 2
177
+
178
+
179
+ def is_front_matter_page(page_text: str) -> bool:
180
+ """
181
+ Fix 2: Return True if a page looks like front matter (title/copyright/
182
+ dedication) rather than story text.
183
 
184
+ Heuristic: page contains a front-matter signal keyword AND has fewer
185
+ than 60 alphabetic words (story pages have more).
186
+ """
187
+ if not page_text:
188
+ return False
189
+ word_count = len(re.findall(r'[a-zA-Z]+', page_text))
190
+ if word_count > 80:
191
+ # A page with 80+ real words is almost certainly story content.
192
  return False
193
+ return bool(FRONT_MATTER_SIGNALS.search(page_text))
194
 
195
 
196
+ def strip_ocr_noise_from_line(line: str) -> str:
197
  """
198
+ Fix 3: Remove known OCR noise patterns from a single line.
199
+ Returns cleaned line; may return empty string if fully stripped.
200
  """
201
+ for pattern in OCR_NOISE_PATTERNS:
202
+ line = pattern.sub('', line)
203
+ return line.strip()
 
 
 
204
 
 
 
 
 
205
 
206
+ def is_artefact_line(line: str) -> bool:
207
+ """
208
+ Fix 3 + 4: Return True if a line is an OCR artefact or structural noise
209
+ that should be excluded from verse-line metrics.
210
 
211
+ Criteria:
212
+ - Fewer than MIN_LINE_TOKENS_FOR_METRICS alphabetic tokens
213
+ - Entirely non-alphabetic (numbers, punctuation, symbols)
214
+ - Looks like a watermark or byline already stripped to a fragment
215
+ """
216
+ tokens = re.findall(r'[a-zA-Z]{2,}', line)
217
+ if len(tokens) < MIN_LINE_TOKENS_FOR_METRICS:
218
+ return True
219
+ return False
220
 
 
221
 
222
+ # ── TEXT EXTRACTION ───────────────────────────────────────────────────────────
223
 
224
+ def extract_text_from_pdf(
225
+ pdf_path: str | Path,
226
+ start_page: int | None = None,
227
+ end_page: int | None = None,
228
+ ) -> tuple[str, str]:
229
  """
230
+ Fix 2 + 3: Extract text from a PDF file.
231
+
232
+ Applies page filtering (skips front matter pages by default) and OCR
233
+ cleanup per page.
234
+
235
+ Args:
236
+ pdf_path: Path to the PDF.
237
+ start_page: 1-based page index to start extraction (inclusive).
238
+ If None, automatic front-matter detection is used.
239
+ end_page: 1-based page index to end extraction (inclusive).
240
+ If None, extraction runs to the last page.
241
+
242
+ Returns:
243
+ Tuple of (cleaned_story_text, raw_ocr_text).
244
+ raw_ocr_text is the unmodified concatenation of all pages.
245
  """
246
  if not PDF_AVAILABLE:
247
  raise RuntimeError("pdfplumber is not installed. Add it to requirements.txt.")
248
 
249
+ raw_parts: list[str] = []
250
+ story_parts: list[str] = []
251
+
252
  with pdfplumber.open(str(pdf_path)) as pdf:
253
+ total_pages = len(pdf.pages)
 
 
 
 
254
 
255
+ # Resolve user-specified range (convert 1-based to 0-based indices)
256
+ idx_start = (start_page - 1) if start_page is not None else 0
257
+ idx_end = (end_page - 1) if end_page is not None else (total_pages - 1)
258
+ idx_start = max(0, idx_start)
259
+ idx_end = min(total_pages - 1, idx_end)
260
 
261
+ for page_idx, page in enumerate(pdf.pages):
262
+ page_text = page.extract_text() or ""
263
+ raw_parts.append(page_text)
264
+
265
+ # If user supplied a range, honour it strictly.
266
+ if start_page is not None or end_page is not None:
267
+ if idx_start <= page_idx <= idx_end:
268
+ story_parts.append(page_text)
269
+ continue
270
 
271
+ # Automatic front-matter detection (Fix 2).
272
+ # Always skip the first page (cover).
273
+ if page_idx == 0:
274
+ continue
275
+ if is_front_matter_page(page_text):
276
+ continue
277
 
278
+ story_parts.append(page_text)
279
 
280
+ raw_text = "\n".join(raw_parts)
281
+ story_text = "\n".join(story_parts)
282
+ return story_text, raw_text
283
+
284
+
285
+ def extract_text_from_file(
286
+ file_path: str | Path,
287
+ start_page: int | None = None,
288
+ end_page: int | None = None,
289
+ ) -> tuple[str, str]:
290
+ """
291
+ Extract text from PDF or plain text file.
292
+
293
+ Returns (story_text, raw_text). For plain text files, both are identical.
294
+ """
295
  path = Path(file_path)
296
  if path.suffix.lower() == ".pdf":
297
+ return extract_text_from_pdf(path, start_page=start_page, end_page=end_page)
298
  else:
299
+ content = path.read_text(encoding="utf-8", errors="replace")
300
+ return content, content
301
+
302
 
303
+ # ── FIX 3 + 4: TEXT CLEANING AND VERSE-LINE NORMALISATION ───────────────────
304
 
305
  def clean_text(raw: str) -> str:
306
+ """
307
+ Fix 3 + 4: Deep cleaning pipeline.
308
+
309
+ Order of operations:
310
+ 1. Quote normalisation (Fix 8) — must run before any other text work.
311
+ 2. Line-ending normalisation.
312
+ 3. Strip per-line OCR noise.
313
+ 4. Remove artefact lines.
314
+ 5. Collapse excessive blank lines.
315
+ 6. Normalise whitespace within lines.
316
+ """
317
+ # Step 1: normalise quotes before any other processing
318
+ text = normalise_quotes(raw)
319
+
320
+ # Step 2: normalise line endings
321
+ text = re.sub(r"\r\n", "\n", text)
322
  text = re.sub(r"\r", "\n", text)
323
+
324
+ # Step 3 + 4: clean each line
325
+ cleaned_lines: list[str] = []
326
+ for line in text.split("\n"):
327
+ line = strip_ocr_noise_from_line(line)
328
+ if not line:
329
+ cleaned_lines.append("")
330
+ continue
331
+ if is_artefact_line(line):
332
+ cleaned_lines.append("")
333
+ continue
334
+ cleaned_lines.append(line)
335
+
336
+ text = "\n".join(cleaned_lines)
337
+
338
+ # Step 5: collapse multiple blank lines to a single separator
339
  text = re.sub(r"\n{3,}", "\n\n", text)
340
+
341
+ # Step 6: normalise intra-line whitespace
342
+ lines_out = []
343
+ for line in text.split("\n"):
344
+ lines_out.append(re.sub(r"[ \t]+", " ", line).strip())
345
+ return "\n".join(lines_out).strip()
346
 
347
 
348
  # ── SYLLABLE COUNTING ─────────────────────────────────────────────────────────
 
351
  """Count syllables using CMU Pronouncing Dictionary. Returns None if not found."""
352
  word_lower = word.lower().strip(string.punctuation)
353
  if word_lower in CMU_DICT:
 
354
  pronunciation = CMU_DICT[word_lower][0]
355
  return sum(1 for ph in pronunciation if ph[-1].isdigit())
356
  return None
 
364
  word = word.lower().strip(string.punctuation)
365
  if not word:
366
  return 0
 
367
  if word.endswith("e") and len(word) > 2:
368
  word = word[:-1]
369
  vowels = "aeiouy"
 
386
  return count_syllables_fallback(word)
387
 
388
 
389
+ # ── FIX 7: PROPER-NOUN / INVENTED-WORD WHITELIST ────────────────────────────
390
+
391
+ # Default whitelist of known fantasy/proper terms common in children's
392
+ # picture books that would otherwise be flagged as invented words.
393
+ # Authors can extend this list via the `extra_whitelist` parameter.
394
+ DEFAULT_INVENTED_WORD_WHITELIST: set[str] = {
395
+ # The Gruffalo-specific terms
396
+ "gruffalo", "gruffalos",
397
+ # Common picture-book character name fragments and genre proper nouns
398
+ # that appear frequently across children's texts but aren't in CMU dict
399
+ "mummy", "daddy", "yummy", "tummy",
400
+ }
401
+
402
+
403
+ def is_ocr_gibberish(word: str) -> bool:
404
+ """
405
+ Fix 7: Return True if a word looks like OCR noise rather than a real
406
+ or intentionally invented word.
407
+
408
+ Heuristics:
409
+ - Contains three or more consecutive consonants not in any known cluster
410
+ - Mix of letters and digits
411
+ - Very short with unusual character combination
412
+ - All-caps fragment (likely header/watermark residue)
413
+ """
414
+ if not word or len(word) < 2:
415
+ return True
416
+ # Mixed alphanumeric that isn't a known abbreviation
417
+ if re.search(r'[a-z]\d|\d[a-z]', word.lower()):
418
+ return True
419
+ # Runs of 4+ consonants (excluding common clusters like "str", "scr")
420
+ if re.search(r'[bcdfghjklmnpqrstvwxyz]{5,}', word.lower()):
421
+ return True
422
+ # All-caps 2+ char fragments (likely OCR header noise)
423
+ if word.isupper() and len(word) >= 3 and not word.isalpha():
424
+ return True
425
+ return False
426
+
427
+
428
+ def is_known_word(word: str, extra_whitelist: set[str] | None = None) -> bool:
429
+ """
430
+ Fix 7: Return True if word is known (CMU dict) or whitelisted.
431
+
432
+ Also returns True for OCR gibberish so those tokens don't inflate
433
+ the invented-word count — they are separately handled in OCR cleanup.
434
+ """
435
  word_lower = word.lower().strip(string.punctuation)
436
  if not word_lower or not word_lower.isalpha():
437
  return True # Don't flag numbers/punctuation as invented
438
+
439
+ # OCR gibberish is not counted as an intentional invented word
440
+ if is_ocr_gibberish(word_lower):
441
+ return True
442
+
443
+ # Whitelist check
444
+ whitelist = DEFAULT_INVENTED_WORD_WHITELIST.copy()
445
+ if extra_whitelist:
446
+ whitelist.update(w.lower() for w in extra_whitelist)
447
+ if word_lower in whitelist:
448
+ return True
449
+
450
+ # Proper nouns (title-cased in original, e.g. character names)
451
+ # We treat any title-cased word > 3 chars as likely a proper noun
452
+ if word[0].isupper() and len(word) > 3:
453
+ return True
454
+
455
  if NLTK_AVAILABLE and CMU_DICT:
456
  return word_lower in CMU_DICT
457
+
458
+ return True
459
+
460
+
461
+ # ── FIX 5: VERSE-LINE CANDIDATE SELECTION ────────────────────────────────────
462
+
463
+ def is_candidate_verse_line(line: str) -> bool:
464
+ """
465
+ Fix 5: Return True if a line is a plausible verse line for rhyme and
466
+ syllable-per-line metrics.
467
+
468
+ Excludes:
469
+ - Lines fewer than 2 alphabetic tokens (artefacts already removed in
470
+ clean_text, but belt-and-braces here)
471
+ - Lines that look like speaker tags / dialogue attribution
472
+ e.g. '"Fox said.' or 'said the mouse.'
473
+ - Lines that are purely punctuation
474
+ """
475
+ tokens = re.findall(r'[a-zA-Z]{2,}', line)
476
+ if len(tokens) < 2:
477
+ return False
478
  return True
479
 
480
 
 
489
  if not word_lower or not NLTK_AVAILABLE or word_lower not in CMU_DICT:
490
  return None
491
  pronunciation = CMU_DICT[word_lower][0]
 
492
  last_vowel_idx = None
493
  for i, ph in enumerate(pronunciation):
494
  if ph[-1].isdigit():
 
504
  sig2 = get_rhyme_signature(word2)
505
  if sig1 and sig2 and sig1 == sig2 and word1.lower() != word2.lower():
506
  return True
 
507
  w1 = word1.lower().strip(string.punctuation)
508
  w2 = word2.lower().strip(string.punctuation)
509
  if len(w1) >= 2 and len(w2) >= 2 and w1 != w2:
 
511
  return False
512
 
513
 
514
+ def get_candidate_verse_line_endings(
515
+ text: str,
516
+ ) -> tuple[list[str], list[dict]]:
517
+ """
518
+ Fix 5: Extract last words from candidate verse lines only.
519
+
520
+ Returns:
521
+ end_words: list of final words from candidate lines.
522
+ trace: list of dicts for line_endings.csv debug export.
523
+ """
524
+ end_words: list[str] = []
525
+ trace: list[dict] = []
526
+
527
+ for line_num, line in enumerate(text.split("\n"), start=1):
528
+ line = line.strip()
529
+ if not line:
530
+ continue
531
+
532
+ excluded = False
533
+ exclusion_reason = ""
534
+
535
+ if not is_candidate_verse_line(line):
536
+ excluded = True
537
+ exclusion_reason = "too_short_or_artefact"
538
+
539
+ final_word = ""
540
+ if not excluded:
541
+ tokens = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
542
+ if tokens:
543
+ final_word = tokens[-1]
544
+ end_words.append(final_word)
545
+ else:
546
+ excluded = True
547
+ exclusion_reason = "no_alpha_tokens"
548
+
549
+ rhyme_key = get_rhyme_signature(final_word) if final_word else ""
550
+ trace.append({
551
+ "line_number": line_num,
552
+ "line": line,
553
+ "final_word": final_word,
554
+ "rhyme_key": rhyme_key or "",
555
+ "excluded": excluded,
556
+ "exclusion_reason": exclusion_reason,
557
+ })
558
+
559
+ return end_words, trace
560
 
561
 
562
  def compute_rhyme_density(end_words: list[str]) -> float:
 
566
  """
567
  if len(end_words) < 2:
568
  return 0.0
569
+ pairs = [(end_words[i], end_words[i + 1]) for i in range(len(end_words) - 1)]
570
  rhyming = sum(1 for w1, w2 in pairs if words_rhyme(w1, w2))
571
  return round(rhyming / len(pairs), 3)
572
 
573
 
574
+ # ── FIX 6: STANZA-WINDOW RHYME SCHEME CLASSIFICATION ────────────────────────
575
+
576
+ def detect_rhyme_scheme(end_words: list[str]) -> str:
577
  """
578
+ Fix 6: Classify rhyme scheme using stanza windows rather than a single
579
+ global adjacent-pair pass.
580
+
581
+ Splits end_words into groups of 4 (one stanza), scores each stanza
582
+ for AABB / ABAB / ABCB pattern, then reports the dominant pattern
583
+ across all stanzas. Falls back to density-based free/mixed only when
584
+ no pattern wins.
585
  """
586
  if len(end_words) < 4:
587
  return "insufficient data"
588
 
589
+ aabb_votes = 0
590
+ abab_votes = 0
591
+ abcb_votes = 0
592
+ free_stanzas = 0
593
+ total_stanzas = 0
594
+
595
+ stanza_size = 4
596
+ for i in range(0, len(end_words) - stanza_size + 1, stanza_size):
597
+ stanza = end_words[i: i + stanza_size]
598
+ if len(stanza) < 4:
599
+ break
600
+ total_stanzas += 1
601
+
602
+ a, b, c, d = stanza
603
+
604
+ # AABB: lines 0-1 rhyme AND lines 2-3 rhyme
605
+ aabb = (words_rhyme(a, b) and words_rhyme(c, d))
606
+ # ABAB: lines 0-2 rhyme AND lines 1-3 rhyme
607
+ abab = (words_rhyme(a, c) and words_rhyme(b, d))
608
+ # ABCB: lines 1-3 rhyme only
609
+ abcb = (not words_rhyme(a, b) and words_rhyme(b, d))
610
+
611
+ if aabb:
612
+ aabb_votes += 1
613
+ elif abab:
614
+ abab_votes += 1
615
+ elif abcb:
616
+ abcb_votes += 1
617
+ else:
618
+ free_stanzas += 1
619
 
620
+ if total_stanzas == 0:
621
+ # Fewer than 4 complete stanzas — fall back to adjacent-pair density
622
+ density = compute_rhyme_density(end_words)
623
+ return "free" if density < 0.20 else "mixed"
 
624
 
625
+ max_votes = max(aabb_votes, abab_votes, abcb_votes)
 
 
 
 
626
 
627
+ if max_votes == 0:
628
+ # No stanza matched a named scheme — use density to decide
629
  density = compute_rhyme_density(end_words)
630
+ return "free" if density < 0.20 else "mixed"
631
 
632
+ # Require that the winner accounts for at least 30% of stanzas,
633
+ # otherwise classify as mixed.
634
+ threshold = total_stanzas * 0.30
635
+
636
+ if aabb_votes >= threshold and aabb_votes >= abab_votes and aabb_votes >= abcb_votes:
637
  return "AABB"
638
+ elif abab_votes >= threshold and abab_votes >= aabb_votes and abab_votes >= abcb_votes:
639
  return "ABAB"
640
+ elif abcb_votes >= threshold:
641
  return "ABCB"
642
+ else:
643
+ return "mixed"
644
 
645
 
646
  # ── STRESS / METRE ────────────────────────────────────────────────────────────
647
 
648
  def get_stress_pattern(line: str) -> list[int]:
649
  """
650
+ Return a list of stress values (0=unstressed, 1=stressed) for each
651
+ syllable in a line. Uses CMU dict stress markers.
652
  """
653
  words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
654
  pattern = []
 
659
  if ph[-1] == "1":
660
  pattern.append(1)
661
  elif ph[-1] == "2":
662
+ pattern.append(1)
663
  elif ph[-1] == "0":
664
  pattern.append(0)
665
  else:
 
666
  syllables = count_syllables_fallback(word)
667
  for i in range(syllables):
668
  pattern.append(i % 2)
 
675
  Returns 0–1 where 1 = perfectly regular metre.
676
  """
677
  if not NLTK_AVAILABLE or not CMU_DICT:
678
+ return -1.0
679
 
680
+ # Fix 5: only use candidate verse lines for stress calculation
681
+ candidate_lines = [l for l in lines if l.strip() and is_candidate_verse_line(l)]
682
+ patterns = [get_stress_pattern(line) for line in candidate_lines]
683
  patterns = [p for p in patterns if len(p) >= 4]
684
 
685
  if len(patterns) < 3:
686
  return -1.0
687
 
 
 
688
  min_len = min(len(p) for p in patterns)
689
  if min_len < 4:
690
  return -1.0
 
720
  return sent_tokenize(text)
721
  except Exception:
722
  pass
 
723
  sentences = re.split(r"[.!?]+", text)
724
  return [s.strip() for s in sentences if s.strip() and len(s.split()) > 1]
725
 
 
734
  if not words or not sentences:
735
  return -1.0
736
  total_syllables = sum(count_syllables(w) for w in words)
737
+ asl = len(words) / len(sentences)
738
+ asw = total_syllables / len(words)
739
  fk = 0.39 * asl + 11.8 * asw - 15.59
740
  return round(max(0.0, fk), 2)
741
 
742
 
743
+ # ── FIX 9: FUZZY REPETITION MATCHING ─────────────────────────────────────────
744
+
745
+ def _normalise_line_for_repetition(line: str) -> str:
746
+ """
747
+ Fix 9: Normalise a line for fuzzy repetition matching.
748
+ Lowercases, strips punctuation, collapses whitespace.
749
+ """
750
+ line = line.lower()
751
+ line = re.sub(r"[^\w\s']", " ", line)
752
+ line = re.sub(r"\s+", " ", line).strip()
753
+ return line
754
+
755
 
756
  def compute_repetition_index(lines: list[str], ngram_size: int = 3) -> float:
757
  """
758
+ Fix 9: Proportion of lines that reuse an n-gram from a prior line,
759
+ using normalised (lowercased, punctuation-stripped) line text.
760
+
761
+ Also accepts partial matches: if any n-gram from the current line
762
+ appeared in any prior line, the line counts as a repeat.
763
  Returns float 0–1.
764
  """
765
+ candidate_lines = [
766
+ _normalise_line_for_repetition(l)
767
+ for l in lines
768
+ if l.strip() and is_candidate_verse_line(l)
769
+ ]
770
+
771
+ if len(candidate_lines) < 2:
772
  return 0.0
773
 
774
  seen_ngrams: set[tuple] = set()
775
  repeat_count = 0
776
 
777
+ for line in candidate_lines:
778
+ words = SIMPLE_TOKENISE_PATTERN.findall(line)
779
  if len(words) < ngram_size:
780
+ # For very short lines, use bigrams instead
781
+ ngram_size_local = max(2, len(words) - 1)
782
+ else:
783
+ ngram_size_local = ngram_size
784
+
785
+ ngrams = [
786
+ tuple(words[i: i + ngram_size_local])
787
+ for i in range(len(words) - ngram_size_local + 1)
788
+ ]
789
  line_has_repeat = any(ng in seen_ngrams for ng in ngrams)
790
  if line_has_repeat:
791
  repeat_count += 1
792
  seen_ngrams.update(ngrams)
793
 
794
+ return round(repeat_count / len(candidate_lines), 3)
795
 
796
 
797
  # ── CUMULATIVE STRUCTURE ──────────────────────────────────────────────────────
798
 
799
  def compute_cumulative_structure(sentences: list[str]) -> float:
800
  """
801
+ Fix 9: Proportion of sentences that open with a phrase used in a prior
802
+ sentence. Uses normalised (lowercased, stripped) text.
803
+
804
+ Now also checks 2-word openings (in addition to 3-word) to catch
805
+ repeated structural frames like "On went" / "A mouse" in picture books.
806
  """
807
  if len(sentences) < 3:
808
  return 0.0
809
 
810
+ opening_phrases_2: list[str] = []
811
+ opening_phrases_3: list[str] = []
812
  cumulative_count = 0
813
 
814
  for sent in sentences:
815
  words = SIMPLE_TOKENISE_PATTERN.findall(sent.lower())
816
+ if len(words) < 2:
817
  continue
818
+
819
+ opening_2 = " ".join(words[:2])
820
+ opening_3 = " ".join(words[:3]) if len(words) >= 3 else ""
821
+
822
+ matched = False
823
+ if opening_2 in opening_phrases_2:
824
+ matched = True
825
+ if opening_3 and opening_3 in opening_phrases_3:
826
+ matched = True
827
+
828
+ if matched:
829
  cumulative_count += 1
830
+
831
+ opening_phrases_2.append(opening_2)
832
+ if opening_3:
833
+ opening_phrases_3.append(opening_3)
834
 
835
  return round(cumulative_count / len(sentences), 3)
836
 
 
852
  # ── DIALOGUE PROPORTION ───────────────────────────────────────────────────────
853
 
854
  def compute_dialogue_proportion(text: str, total_words: int) -> float:
855
+ """
856
+ Fix 8: Proportion of words inside quotation marks.
857
+ Quote normalisation is applied upstream in clean_text(), so this
858
+ function can use straight double-quotes reliably.
859
+ """
860
  if total_words == 0:
861
  return 0.0
862
+ quoted_text = " ".join(NORMALISED_QUOTE_PATTERN.findall(text))
863
  quoted_words = len(SIMPLE_TOKENISE_PATTERN.findall(quoted_text.lower()))
864
  return round(min(1.0, quoted_words / total_words), 3)
865
 
866
 
867
+ # ── FIX 10: QA THRESHOLD FLAGS ───────────────────────────────────────────────
868
+
869
+ def compute_qa_flags(fp: dict[str, Any]) -> list[str]:
870
+ """
871
+ Fix 10: Return a list of QA warning strings for known contradiction
872
+ patterns. These prevent bad fingerprints from silently entering
873
+ CODEX_03 without review.
874
+
875
+ Flags raised:
876
+ - RHYME_CONTRADICTION: rhyme density > 0.3 but scheme is 'free'
877
+ - HIGH_INVENTED_WORD_DENSITY: VM-008 > 0.10 (likely OCR noise)
878
+ - POSSIBLE_FRONT_MATTER_INCLUDED: word count unusually high for
879
+ a standard picture book (>= 1200) with low rhyme density
880
+ - LOW_DIALOGUE_WITH_HIGH_PUNCTUATION: high ? or ! density but
881
+ dialogue proportion < 0.05 (quote marks likely lost)
882
+ - LOW_CONFIDENCE_SAMPLE: fewer than MIN_WORD_COUNT words
883
+ """
884
+ flags: list[str] = []
885
+
886
+ rhyme_density = fp.get("VM-003_Rhyme_density", 0)
887
+ rhyme_type = fp.get("VM-004_Rhyme_type", "")
888
+ invented = fp.get("VM-008_Invented_word_density", 0)
889
+ word_count = fp.get("VM-024_Word_count", 0)
890
+ dialogue = fp.get("VM-013_Dialogue_proportion", 0)
891
+ excl = fp.get("VM-026_Exclamation_density", 0)
892
+ ques = fp.get("VM-027_Question_density", 0)
893
+
894
+ if rhyme_density > 0.3 and rhyme_type in ("free",):
895
+ flags.append(
896
+ "RHYME_CONTRADICTION: rhyme density is high but scheme classified as free — "
897
+ "check verse-line normalisation."
898
+ )
899
+
900
+ if isinstance(invented, float) and invented > 0.10:
901
+ flags.append(
902
+ f"HIGH_INVENTED_WORD_DENSITY: {invented:.3f} — likely OCR noise or missing whitelist entries."
903
+ )
904
+
905
+ if word_count >= 1200 and rhyme_density < 0.15:
906
+ flags.append(
907
+ "POSSIBLE_FRONT_MATTER_INCLUDED: high word count with low rhyme density — "
908
+ "check page filtering and story boundary."
909
+ )
910
+
911
+ if (excl + ques) > 3.0 and dialogue < 0.05:
912
+ flags.append(
913
+ "LOW_DIALOGUE_WITH_HIGH_PUNCTUATION: high exclamation/question density but very "
914
+ "low dialogue proportion — quote normalisation may have failed."
915
+ )
916
+
917
+ if word_count < MIN_WORD_COUNT:
918
+ flags.append(
919
+ f"LOW_CONFIDENCE_SAMPLE: only {word_count} words — metrics are unreliable."
920
+ )
921
+
922
+ return flags
923
+
924
+
925
+ # ── FIX 1: DEBUG ARTEFACT EXPORTS ────────────────────────────────────────────
926
+
927
+ def export_debug_artefacts(
928
+ output_dir: str | Path,
929
+ cleaned_text: str,
930
+ raw_text: str,
931
+ line_endings_trace: list[dict],
932
+ metric_trace: dict,
933
+ qa_flags: list[str],
934
+ ) -> dict[str, str]:
935
+ """
936
+ Fix 1: Write debug artefacts for human QA inspection.
937
+
938
+ Files written:
939
+ - cleaned_text.txt : the story text after OCR cleanup and page filtering
940
+ - raw_ocr_text.txt : unmodified OCR output
941
+ - line_endings.csv : per-candidate-line trace (line, final word, rhyme key, excluded)
942
+ - metric_trace.json : per-metric source counts
943
+ - qa_flags.json : automatic contradiction warnings
944
+
945
+ Returns dict mapping artefact name -> file path written.
946
+ """
947
+ out = Path(output_dir)
948
+ out.mkdir(parents=True, exist_ok=True)
949
+
950
+ paths: dict[str, str] = {}
951
+
952
+ # cleaned_text.txt
953
+ p = out / "cleaned_text.txt"
954
+ p.write_text(cleaned_text, encoding="utf-8")
955
+ paths["cleaned_text"] = str(p)
956
+
957
+ # raw_ocr_text.txt
958
+ p = out / "raw_ocr_text.txt"
959
+ p.write_text(raw_text, encoding="utf-8")
960
+ paths["raw_ocr_text"] = str(p)
961
+
962
+ # line_endings.csv
963
+ p = out / "line_endings.csv"
964
+ if line_endings_trace:
965
+ with p.open("w", newline="", encoding="utf-8") as f:
966
+ writer = csv.DictWriter(
967
+ f,
968
+ fieldnames=["line_number", "line", "final_word",
969
+ "rhyme_key", "excluded", "exclusion_reason"],
970
+ )
971
+ writer.writeheader()
972
+ writer.writerows(line_endings_trace)
973
+ paths["line_endings"] = str(p)
974
+
975
+ # metric_trace.json
976
+ p = out / "metric_trace.json"
977
+ p.write_text(json.dumps(metric_trace, indent=2, ensure_ascii=False), encoding="utf-8")
978
+ paths["metric_trace"] = str(p)
979
+
980
+ # qa_flags.json
981
+ p = out / "qa_flags.json"
982
+ p.write_text(
983
+ json.dumps({"flags": qa_flags, "flag_count": len(qa_flags)}, indent=2, ensure_ascii=False),
984
+ encoding="utf-8",
985
+ )
986
+ paths["qa_flags"] = str(p)
987
+
988
+ return paths
989
+
990
+
991
  # ── MAIN EXTRACTION FUNCTION ──────────────────────────────────────────────────
992
 
993
  def extract_fingerprint(
994
  text: str,
995
+ raw_text: str = "",
996
  author_name: str = "Unknown",
997
  author_id: str = "CA-XXX",
998
  works_sampled: str = "",
999
+ extra_whitelist: set[str] | None = None,
1000
+ debug_output_dir: str | Path | None = None,
1001
  ) -> dict[str, Any]:
1002
  """
1003
  Extract all Tier 1 fingerprint metrics from text.
1004
 
1005
  Args:
1006
+ text: Cleaned story text (post page-filtering + OCR cleanup).
1007
+ raw_text: Unmodified OCR output, for debug export.
1008
+ author_name: Author's full name for the output record.
1009
+ author_id: Codex author ID (e.g. CA-001).
1010
+ works_sampled: Comma-separated list of titles included in the text.
1011
+ extra_whitelist: Set of additional proper nouns / invented terms to
1012
+ whitelist from the invented-word density count.
1013
+ debug_output_dir: If set, write Fix 1 debug artefacts to this directory.
1014
 
1015
  Returns:
1016
  Dictionary of metric values, confidence flags, and metadata.
1017
  Ready to paste into CODEX_03_FINGERPRINTS workbook row.
1018
  """
1019
  text = clean_text(text)
1020
+
1021
+ # All lines (for metrics that use raw line structure)
1022
+ all_lines = [l.strip() for l in text.split("\n") if l.strip()]
1023
+
1024
+ # Candidate verse lines only (Fix 5): used for syllable, rhyme, stress metrics
1025
+ candidate_lines = [l for l in all_lines if is_candidate_verse_line(l)]
1026
+
1027
  words = tokenise_words(text)
1028
  sentences = tokenise_sentences(text)
1029
 
 
1039
  else:
1040
  confidence = f"HIGH — sample {total_words} words"
1041
 
1042
+ # ── VM-001: Syllables per line (candidate verse lines only) ───────────────
1043
  line_syllable_counts = []
1044
+ for line in candidate_lines:
1045
  line_words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
1046
  if line_words:
1047
  syllables = sum(count_syllables(w) for w in line_words)
 
1058
  else:
1059
  vm002 = -1.0
1060
 
1061
+ # ── VM-003 + 004: Rhyme density and scheme (Fix 5 + 6) ───────────────────
1062
+ end_words, line_endings_trace = get_candidate_verse_line_endings(text)
1063
  vm003 = compute_rhyme_density(end_words)
 
 
1064
  vm004 = detect_rhyme_scheme(end_words)
1065
 
1066
  # ── VM-005: Stressed syllable regularity ──────────────────────────────────
1067
+ vm005 = compute_stress_regularity(candidate_lines)
1068
 
1069
  # ── VM-006: Vocabulary tier match 4-7 ────────────────────────────────────
1070
  vm006 = compute_vocabulary_tier_match(words)
 
1072
  # ── VM-007: Type-token ratio ──────────────────────────────────────────────
1073
  vm007 = round(len(unique_words) / total_words, 3) if total_words > 0 else -1.0
1074
 
1075
+ # ── VM-008: Invented word density (Fix 7) ────────────────────────────────
1076
+ unknown_words = [
1077
+ w for w in unique_words
1078
+ if len(w) > 2 and not is_known_word(w, extra_whitelist=extra_whitelist)
1079
+ ]
1080
  vm008 = round(len(unknown_words) / len(unique_words), 3) if unique_words else 0.0
1081
 
1082
  # ── VM-009: Average word length ───────────────────────────────────────────
1083
  vm009 = round(sum(len(w) for w in words) / total_words, 2) if total_words > 0 else -1.0
1084
 
1085
  # ── VM-010: Sentence length mean ─────────────────────────────────────────
1086
+ sent_lengths = [
1087
+ len(SIMPLE_TOKENISE_PATTERN.findall(s.lower()))
1088
+ for s in sentences if s.strip()
1089
+ ]
1090
  vm010 = round(sum(sent_lengths) / len(sent_lengths), 2) if sent_lengths else -1.0
1091
 
1092
  # ── VM-011: Sentence length variance ─────────────────────────────────────
 
1097
  else:
1098
  vm011 = -1.0
1099
 
1100
+ # ── VM-012: Cumulative structure score (Fix 9) ────────────────────────────
1101
  vm012 = compute_cumulative_structure(sentences)
1102
 
1103
+ # ─��� VM-013: Dialogue proportion (Fix 8) ───────────────────────────────────
1104
  vm013 = compute_dialogue_proportion(text, total_words)
1105
 
1106
  # ── VM-024: Word count total ──────────────────────────────────────────────
 
1117
  questions = len(QUESTION_PATTERN.findall(text))
1118
  vm027 = round((questions / total_words) * 100, 2) if total_words > 0 else 0.0
1119
 
1120
+ # ── VM-028: Repetition index (Fix 9) ─────────────────────────────────────
1121
+ vm028 = compute_repetition_index(all_lines)
1122
 
1123
  # ── ASSEMBLE OUTPUT ───────────────────────────────────────────────────────
1124
+ result: dict[str, Any] = {
1125
  # Metadata
1126
  "Author_ID": author_id,
1127
  "Author_Name": author_name,
1128
  "Works_Sampled": works_sampled,
1129
  "Sample_Words": total_words,
1130
+ "Sample_Lines": len(all_lines),
1131
+ "Sample_Candidate_Verse_Lines": len(candidate_lines),
1132
  "Sample_Sentences": total_sentences,
1133
  "Confidence_Level": confidence,
1134
  "NLTK_Available": NLTK_AVAILABLE,
 
1155
  "VM-028_Repetition_index": vm028,
1156
 
1157
  # Tier 2 reminder
1158
+ "VM-014_to_VM-023": (
1159
+ "TIER 2 — Use Codex Build Prompt 2 (ChatGPT/Gemini) "
1160
+ "for qualitative metrics"
1161
+ ),
1162
  }
1163
 
1164
+ # ── FIX 10: QA FLAGS ─────────────────────────────────────────────────────
1165
+ qa_flags = compute_qa_flags(result)
1166
+ result["QA_Flags"] = qa_flags
1167
+ result["QA_Flag_Count"] = len(qa_flags)
1168
+
1169
+ # ── FIX 1: DEBUG ARTEFACT EXPORTS ────────────────────────────────────────
1170
+ if debug_output_dir is not None:
1171
+ metric_trace = {
1172
+ "total_words": total_words,
1173
+ "unique_words": len(unique_words),
1174
+ "total_lines": len(all_lines),
1175
+ "candidate_verse_lines": len(candidate_lines),
1176
+ "candidate_rhyme_pairs": len(end_words),
1177
+ "rhyming_adjacent_pairs": int(round(vm003 * max(len(end_words) - 1, 1))),
1178
+ "dialogue_tokens": int(round(vm013 * total_words)),
1179
+ "exclamation_count": exclamations,
1180
+ "question_count": questions,
1181
+ "unknown_words_for_vm008": unknown_words,
1182
+ "sentence_count": total_sentences,
1183
+ }
1184
+ artefact_paths = export_debug_artefacts(
1185
+ output_dir=debug_output_dir,
1186
+ cleaned_text=text,
1187
+ raw_text=raw_text or text,
1188
+ line_endings_trace=line_endings_trace,
1189
+ metric_trace=metric_trace,
1190
+ qa_flags=qa_flags,
1191
+ )
1192
+ result["Debug_Artefacts"] = artefact_paths
1193
+
1194
  return result
1195
 
1196
 
 
1199
  Format a fingerprint dict as a human-readable report string
1200
  suitable for display in the Gradio interface.
1201
  """
1202
+ qa_flags = fp.get("QA_Flags", [])
1203
+ flag_section = ""
1204
+ if qa_flags:
1205
+ flag_lines = "\n".join(f" ⚠ {f}" for f in qa_flags)
1206
+ flag_section = f"\n── QA FLAGS ({len(qa_flags)}) ────────────────────────────────────\n{flag_lines}\n"
1207
+
1208
+ debug_section = ""
1209
+ if "Debug_Artefacts" in fp:
1210
+ paths = fp["Debug_Artefacts"]
1211
+ debug_lines = "\n".join(f" {k}: {v}" for k, v in paths.items())
1212
+ debug_section = f"\n── DEBUG ARTEFACTS ─────────────────────────────────────\n{debug_lines}\n"
1213
+
1214
  lines = [
1215
  f"╔══════════════════════════════════════════════════════╗",
1216
  f" TOTEM STUDIO CODEX — FINGERPRINT EXTRACTION REPORT",
1217
  f"╚══════════════════════════════════════════════════════╝",
1218
  f"",
1219
+ f" Author: {fp['Author_Name']}",
1220
+ f" ID: {fp['Author_ID']}",
1221
+ f" Works: {fp['Works_Sampled'] or 'Not specified'}",
1222
+ f" Words: {fp['Sample_Words']}",
1223
+ f" Lines (total): {fp['Sample_Lines']}",
1224
+ f" Lines (verse cands): {fp.get('Sample_Candidate_Verse_Lines', 'n/a')}",
1225
+ f" Sentences: {fp['Sample_Sentences']}",
1226
+ f" Confidence: {fp['Confidence_Level']}",
1227
+ f" NLTK: {'Available' if fp['NLTK_Available'] else 'Not available — some metrics reduced accuracy'}",
1228
  f"",
1229
  f"── SONIC & RHYTHMIC ────────────────────────────────────",
1230
  f" VM-001 Syllables per line (mean): {fp['VM-001_Syllables_per_line']}",
 
1256
  f" VM-014 to VM-023 require qualitative extraction.",
1257
  f" Use Codex Build Prompt 2 (ChatGPT/Gemini) with the",
1258
  f" same text sample to complete these fields.",
 
 
1259
  ]
1260
+
1261
+ report = "\n".join(lines)
1262
+ if flag_section:
1263
+ report += "\n" + flag_section
1264
+ if debug_section:
1265
+ report += "\n" + debug_section
1266
+ report += f"\n Copy values above into CODEX_03_FINGERPRINTS row: {fp['Author_ID']}"
1267
+ return report
1268
 
1269
 
1270
  def process_upload(
 
1272
  author_name: str,
1273
  author_id: str,
1274
  works_sampled: str,
1275
+ start_page: int | None = None,
1276
+ end_page: int | None = None,
1277
+ extra_whitelist_str: str = "",
1278
+ debug_output_dir: str | Path | None = None,
1279
  ) -> tuple[str, dict]:
1280
  """
1281
  Entry point for Gradio interface.
1282
+
1283
+ Args:
1284
+ file_path: Path to uploaded PDF or text file.
1285
+ author_name: Author's full name.
1286
+ author_id: Codex author ID.
1287
+ works_sampled: Comma-separated list of titles.
1288
+ start_page: Optional 1-based start page for story extraction.
1289
+ end_page: Optional 1-based end page for story extraction.
1290
+ extra_whitelist_str: Comma-separated proper nouns / invented terms
1291
+ to whitelist from VM-008 (e.g. "Gruffalo,Zog").
1292
+ debug_output_dir: Directory to write debug artefacts. If None,
1293
+ no artefacts are written.
1294
+
1295
+ Returns:
1296
+ Tuple of (formatted_report_string, raw_dict).
1297
  """
1298
  try:
1299
+ story_text, raw_text = extract_text_from_file(
1300
+ file_path,
1301
+ start_page=start_page,
1302
+ end_page=end_page,
1303
+ )
1304
+
1305
+ if not story_text or len(story_text.split()) < 20:
1306
  return (
1307
  "ERROR: No usable text extracted from file. "
1308
+ "Check the PDF contains selectable text (not scanned images), "
1309
+ "or try specifying a story page range.",
1310
  {},
1311
  )
1312
+
1313
+ extra_whitelist: set[str] | None = None
1314
+ if extra_whitelist_str.strip():
1315
+ extra_whitelist = {
1316
+ w.strip().lower()
1317
+ for w in extra_whitelist_str.split(",")
1318
+ if w.strip()
1319
+ }
1320
+
1321
  fp = extract_fingerprint(
1322
+ text=story_text,
1323
+ raw_text=raw_text,
1324
  author_name=author_name,
1325
  author_id=author_id,
1326
  works_sampled=works_sampled,
1327
+ extra_whitelist=extra_whitelist,
1328
+ debug_output_dir=debug_output_dir,
1329
  )
1330
  report = format_fingerprint_report(fp)
1331
  return report, fp
1332
+
 
 
 
 
 
 
 
 
 
 
 
1333
  except Exception as e:
1334
  return f"ERROR: {type(e).__name__}: {str(e)}", {}
1335
 
 
1350
  """
1351
  fp = extract_fingerprint(
1352
  text=SAMPLE,
1353
+ raw_text=SAMPLE,
1354
  author_name="Test Author",
1355
  author_id="CA-TEST",
1356
  works_sampled="Test sample",
1357
+ extra_whitelist={"gruffalo"},
1358
+ debug_output_dir="/tmp/codex_debug",
1359
  )
1360
  print(format_fingerprint_report(fp))
1361
+ print("\nDebug artefacts written to:", fp.get("Debug_Artefacts", {}))