Spaces:
Sleeping
Sleeping
Update src/codex_extractor.py
Browse files- 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.
|
| 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:
|
| 139 |
-
#
|
| 140 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
| 181 |
re.compile(r'\bisbn\b[\d\s\-]+', re.IGNORECASE),
|
| 182 |
-
re.compile(r'^\s*\d{1,4}\s*$'),
|
| 183 |
-
re.compile(r'^\s*[©®™]\s*.*$', re.MULTILINE),
|
| 184 |
-
re.compile(r'^\s*[A-Z][a-z]+ [A-Z][a-z]+\s*$'),
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 310 |
-
|
| 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 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 403 |
2. Line-ending normalisation.
|
| 404 |
3. Strip per-line OCR noise.
|
| 405 |
4. Remove artefact lines.
|
| 406 |
-
5.
|
| 407 |
-
6.
|
|
|
|
| 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 |
-
|
|
|
|
| 429 |
|
| 430 |
-
|
| 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.
|
| 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 |
-
|
| 455 |
"""
|
| 456 |
word = word.lower().strip(string.punctuation)
|
| 457 |
if not word:
|
| 458 |
return 0
|
| 459 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 488 |
-
"gruffalo", "gruffalos",
|
| 489 |
-
|
| 490 |
-
|
|
|
|
|
|
|
| 491 |
"mummy", "daddy", "yummy", "tummy",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
}
|
| 493 |
|
| 494 |
|
| 495 |
def is_ocr_gibberish(word: str) -> bool:
|
| 496 |
"""
|
| 497 |
-
Fix
|
| 498 |
-
or intentionally invented word.
|
| 499 |
|
| 500 |
-
Heuristics:
|
| 501 |
-
-
|
| 502 |
-
-
|
| 503 |
- Very short with unusual character combination
|
| 504 |
-
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
"""
|
| 522 |
-
|
|
|
|
| 523 |
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
"""
|
| 527 |
word_lower = word.lower().strip(string.punctuation)
|
| 528 |
if not word_lower or not word_lower.isalpha():
|
| 529 |
-
return True
|
|
|
|
|
|
|
|
|
|
| 530 |
|
| 531 |
-
# OCR gibberish is not counted as an intentional invented word
|
| 532 |
if is_ocr_gibberish(word_lower):
|
| 533 |
-
|
|
|
|
| 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 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
| 546 |
|
| 547 |
if NLTK_AVAILABLE and CMU_DICT:
|
| 548 |
-
|
|
|
|
|
|
|
| 549 |
|
| 550 |
-
return
|
|
|
|
|
|
|
| 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
|
| 558 |
-
|
| 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 |
-
|
| 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
|
| 578 |
-
|
|
|
|
|
|
|
|
|
|
| 579 |
"""
|
| 580 |
word_lower = word.lower().strip(string.punctuation)
|
| 581 |
-
if not word_lower
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
|
| 592 |
|
| 593 |
def words_rhyme(word1: str, word2: str) -> bool:
|
| 594 |
-
"""
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
|
|
|
| 599 |
w1 = word1.lower().strip(string.punctuation)
|
| 600 |
w2 = word2.lower().strip(string.punctuation)
|
| 601 |
-
if
|
| 602 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
return False
|
| 604 |
|
| 605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
def get_candidate_verse_line_endings(
|
| 607 |
text: str,
|
| 608 |
) -> tuple[list[str], list[dict]]:
|
| 609 |
"""
|
| 610 |
-
Fix
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 620 |
-
|
| 621 |
-
if not
|
| 622 |
continue
|
| 623 |
|
| 624 |
excluded = False
|
| 625 |
exclusion_reason = ""
|
| 626 |
|
| 627 |
-
if not is_candidate_verse_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(
|
| 634 |
if tokens:
|
| 635 |
final_word = tokens[-1]
|
|
|
|
|
|
|
| 636 |
end_words.append(final_word)
|
| 637 |
else:
|
| 638 |
excluded = True
|
| 639 |
-
exclusion_reason = "
|
| 640 |
|
| 641 |
-
rhyme_key = get_rhyme_signature(final_word) if final_word else ""
|
| 642 |
trace.append({
|
| 643 |
"line_number": line_num,
|
| 644 |
-
"line":
|
|
|
|
| 645 |
"final_word": final_word,
|
| 646 |
-
"rhyme_key": rhyme_key
|
| 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(
|
|
|
|
|
|
|
| 655 |
"""
|
| 656 |
-
Compute
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
"""
|
| 659 |
if len(end_words) < 2:
|
| 660 |
-
return 0.0
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
|
| 665 |
|
| 666 |
-
# ── FIX
|
| 667 |
|
| 668 |
-
def detect_rhyme_scheme(end_words: list[str]) -> str:
|
| 669 |
"""
|
| 670 |
-
Fix
|
| 671 |
-
global adjacent-pair pass.
|
| 672 |
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
"""
|
| 678 |
-
if len(end_words) <
|
| 679 |
-
return "
|
| 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 |
-
|
| 697 |
-
|
| 698 |
-
|
| 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 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
return "
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
if
|
| 729 |
-
return "
|
| 730 |
-
|
| 731 |
-
return "
|
| 732 |
-
|
| 733 |
-
return "
|
| 734 |
-
|
| 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:
|
| 836 |
|
| 837 |
def _normalise_line_for_repetition(line: str) -> str:
|
| 838 |
"""
|
| 839 |
-
Fix
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
"""
|
| 850 |
-
Fix
|
| 851 |
-
|
|
|
|
|
|
|
|
|
|
| 852 |
|
| 853 |
-
|
| 854 |
-
|
| 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
|
| 872 |
-
|
| 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 |
-
|
| 882 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
repeat_count += 1
|
| 884 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
|
| 886 |
-
|
|
|
|
| 887 |
|
|
|
|
|
|
|
| 888 |
|
| 889 |
-
# ── CUMULATIVE STRUCTURE ──────────────────────────────────────────────────────
|
| 890 |
|
| 891 |
-
def compute_cumulative_structure(
|
|
|
|
|
|
|
| 892 |
"""
|
| 893 |
-
Fix
|
| 894 |
-
sentence
|
| 895 |
|
| 896 |
-
|
| 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()
|
| 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
|
| 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 |
-
"
|
| 989 |
-
"check
|
| 990 |
)
|
| 991 |
|
| 992 |
if isinstance(invented, float) and invented > 0.10:
|
| 993 |
flags.append(
|
| 994 |
-
f"HIGH_INVENTED_WORD_DENSITY: {invented:.3f} —
|
|
|
|
| 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
|
| 1001 |
)
|
| 1002 |
|
| 1003 |
if (excl + ques) > 3.0 and dialogue < 0.05:
|
| 1004 |
flags.append(
|
| 1005 |
-
"LOW_DIALOGUE_WITH_HIGH_PUNCTUATION: high
|
| 1006 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1032 |
-
- raw_ocr_text.txt
|
| 1033 |
-
- line_endings.csv
|
| 1034 |
-
- metric_trace.json
|
| 1035 |
-
- qa_flags.json
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=[
|
| 1061 |
-
|
|
|
|
|
|
|
| 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:
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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":
|
| 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 |
-
# ──
|
| 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 |
-
"
|
| 1269 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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']
|
| 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:
|
|
|
|
| 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.
|
| 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
|
| 1440 |
SAMPLE = """
|
| 1441 |
-
|
| 1442 |
-
|
| 1443 |
-
|
| 1444 |
-
and
|
| 1445 |
-
|
| 1446 |
-
|
| 1447 |
-
|
| 1448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1449 |
"""
|
| 1450 |
fp = extract_fingerprint(
|
| 1451 |
text=SAMPLE,
|
| 1452 |
raw_text=SAMPLE,
|
| 1453 |
-
author_name="
|
| 1454 |
-
author_id="CA-
|
| 1455 |
-
works_sampled="
|
| 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 |
)
|