Pointf5ive commited on
Commit
cbba0ee
Β·
verified Β·
1 Parent(s): e833d42

Deploy Codex Extractor Gradio app

Browse files
Files changed (1) hide show
  1. src/codex_extractor.py +727 -0
src/codex_extractor.py ADDED
@@ -0,0 +1,727 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ codex_extractor.py β€” TOTEM Studio Codex Fingerprint Extractor
3
+ ==============================================================
4
+ Extracts Tier 1 (computed) voice metrics from text or PDF input.
5
+ Designed to run inside the Hugging Face Gradio Space (src/ directory).
6
+
7
+ Tier 1 metrics computed here (mathematically exact):
8
+ VM-001 Syllables per line (mean)
9
+ VM-002 Syllable variance (SD of per-line syllable counts)
10
+ VM-003 Rhyme scheme density (proportion of adjacent line-end pairs that rhyme)
11
+ VM-004 Rhyme scheme type (dominant pattern tag)
12
+ VM-005 Stressed syllable regularity (0–1, using CMU Pronouncing Dict)
13
+ VM-006 Vocabulary tier match 4–7 (proportion in Dolch/Fry word list proxy)
14
+ VM-007 Type-token ratio (unique words / total words)
15
+ VM-008 Invented word density (words not in WordNet/CMU dict)
16
+ VM-009 Average word length (mean character count per word)
17
+ VM-010 Sentence length mean (mean words per sentence)
18
+ VM-011 Sentence length variance (SD of sentence lengths)
19
+ VM-012 Cumulative structure score (repeated structural phrases, 0–1)
20
+ VM-013 Dialogue proportion (words in quotes / total words)
21
+ VM-024 Word count total
22
+ VM-025 Reading age estimate (Flesch-Kincaid grade level)
23
+ VM-026 Exclamation density (per 100 words)
24
+ VM-027 Question density (per 100 words)
25
+ VM-028 Repetition index (lines reusing prior phrase, 0–1)
26
+
27
+ Tier 2 metrics (VM-014 to VM-023) require human/AI qualitative judgment.
28
+ Use Prompt 2 (ChatGPT/Gemini) for those β€” see Codex Build Prompts document.
29
+
30
+ Dependencies (add to requirements.txt):
31
+ pdfplumber>=0.10
32
+ nltk>=3.8
33
+
34
+ NLTK data required (auto-downloaded on first run):
35
+ punkt, punkt_tab, averaged_perceptron_tagger, cmudict, stopwords
36
+
37
+ Author: TOTEM Studio β€” Jamal Romeh
38
+ Version: 1.0
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import math
44
+ import re
45
+ import string
46
+ import os
47
+ from collections import Counter
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+ # ── OPTIONAL IMPORTS WITH GRACEFUL FALLBACK ──────────────────────────────────
52
+
53
+ try:
54
+ import pdfplumber
55
+ PDF_AVAILABLE = True
56
+ except ImportError:
57
+ PDF_AVAILABLE = False
58
+
59
+ try:
60
+ import nltk
61
+ # Auto-download required NLTK data if not present
62
+ _NLTK_DATA = ["punkt", "punkt_tab", "averaged_perceptron_tagger", "cmudict", "stopwords"]
63
+ for _pkg in _NLTK_DATA:
64
+ try:
65
+ nltk.data.find(f"tokenizers/{_pkg}" if "punkt" in _pkg else f"corpora/{_pkg}")
66
+ except LookupError:
67
+ try:
68
+ nltk.download(_pkg, quiet=True)
69
+ except Exception:
70
+ pass
71
+ from nltk.tokenize import sent_tokenize, word_tokenize
72
+ from nltk.corpus import cmudict as _cmudict
73
+ CMU_DICT = _cmudict.dict()
74
+ NLTK_AVAILABLE = True
75
+ except Exception:
76
+ NLTK_AVAILABLE = False
77
+ CMU_DICT = {}
78
+
79
+ # ── CONSTANTS ─────────────────────────────────────────────────────────────────
80
+
81
+ MIN_WORD_COUNT = 200 # Below this: LOW CONFIDENCE flag
82
+ TARGET_WORD_COUNT = 1000 # Above this: HIGH CONFIDENCE
83
+
84
+ # Dolch sight words + Fry first 500 as a proxy for 4–7 age-band lexicon.
85
+ # This is a representative subset β€” the full list should be loaded from a file
86
+ # in production. Stored here for portability without external file dependency.
87
+ DOLCH_FRY_PROXY = set("""
88
+ a about after again all along also always am an and any are around as ask at away
89
+ be been before big boy but by call came can come could day did do does down each
90
+ end every few find first for from get girl give go good got had has have he help
91
+ her here him his home how i if in into is it its jump just keep kind know large
92
+ last left let like little long look made make man many may me more most mother
93
+ must my name new no not now of off old on once one only open or our out over own
94
+ part people place play put ran read right run said same saw say see she should
95
+ show small so some soon start still stop such take than that the their them then
96
+ there these they thing think this those three to together too try turn two under
97
+ until up us use very want was way we well went were what when where which while
98
+ who why will with word work world would write year you young your
99
+ """.split())
100
+
101
+ # Common English words unlikely to be in a children's 4-7 lexicon
102
+ # Used as negative signal for VM-006
103
+
104
+ SIMPLE_TOKENISE_PATTERN = re.compile(r"\b[a-z']+\b")
105
+ SENTENCE_END_PATTERN = re.compile(r"[.!?]+")
106
+ QUOTE_PATTERN = re.compile(r'"[^"]*"')
107
+ EXCLAMATION_PATTERN = re.compile(r"!")
108
+ QUESTION_PATTERN = re.compile(r"\?")
109
+
110
+ # ── TEXT EXTRACTION ───────────────────────────────────────────────────────────
111
+
112
+ def extract_text_from_pdf(pdf_path: str | Path) -> str:
113
+ """Extract all text from a PDF file using pdfplumber."""
114
+ if not PDF_AVAILABLE:
115
+ raise RuntimeError("pdfplumber is not installed. Add it to requirements.txt.")
116
+ text_parts = []
117
+ with pdfplumber.open(str(pdf_path)) as pdf:
118
+ for page in pdf.pages:
119
+ page_text = page.extract_text()
120
+ if page_text:
121
+ text_parts.append(page_text)
122
+ return "\n".join(text_parts)
123
+
124
+
125
+ def extract_text_from_file(file_path: str | Path) -> str:
126
+ """Extract text from PDF or plain text file."""
127
+ path = Path(file_path)
128
+ if path.suffix.lower() == ".pdf":
129
+ return extract_text_from_pdf(path)
130
+ else:
131
+ return path.read_text(encoding="utf-8", errors="replace")
132
+
133
+
134
+ def clean_text(raw: str) -> str:
135
+ """Basic cleaning β€” remove excessive whitespace, normalise line breaks."""
136
+ text = re.sub(r"\r\n", "\n", raw)
137
+ text = re.sub(r"\r", "\n", text)
138
+ text = re.sub(r"\n{3,}", "\n\n", text)
139
+ text = re.sub(r"[ \t]+", " ", text)
140
+ return text.strip()
141
+
142
+
143
+ # ── SYLLABLE COUNTING ─────────────────────────────────────────────────────────
144
+
145
+ def count_syllables_cmu(word: str) -> int | None:
146
+ """Count syllables using CMU Pronouncing Dictionary. Returns None if not found."""
147
+ word_lower = word.lower().strip(string.punctuation)
148
+ if word_lower in CMU_DICT:
149
+ # Take first pronunciation, count vowel phonemes
150
+ pronunciation = CMU_DICT[word_lower][0]
151
+ return sum(1 for ph in pronunciation if ph[-1].isdigit())
152
+ return None
153
+
154
+
155
+ def count_syllables_fallback(word: str) -> int:
156
+ """
157
+ Fallback syllable counter using vowel-group heuristic.
158
+ Less accurate than CMU but works for any word including invented ones.
159
+ """
160
+ word = word.lower().strip(string.punctuation)
161
+ if not word:
162
+ return 0
163
+ # Remove trailing silent e
164
+ if word.endswith("e") and len(word) > 2:
165
+ word = word[:-1]
166
+ vowels = "aeiouy"
167
+ count = 0
168
+ prev_vowel = False
169
+ for char in word:
170
+ is_vowel = char in vowels
171
+ if is_vowel and not prev_vowel:
172
+ count += 1
173
+ prev_vowel = is_vowel
174
+ return max(1, count)
175
+
176
+
177
+ def count_syllables(word: str) -> int:
178
+ """Count syllables, preferring CMU dict then falling back to heuristic."""
179
+ if NLTK_AVAILABLE and CMU_DICT:
180
+ result = count_syllables_cmu(word)
181
+ if result is not None:
182
+ return result
183
+ return count_syllables_fallback(word)
184
+
185
+
186
+ def is_known_word(word: str) -> bool:
187
+ """Return True if word is in CMU dict (proxy for standard English dictionary)."""
188
+ word_lower = word.lower().strip(string.punctuation)
189
+ if not word_lower or not word_lower.isalpha():
190
+ return True # Don't flag numbers/punctuation as invented
191
+ if NLTK_AVAILABLE and CMU_DICT:
192
+ return word_lower in CMU_DICT
193
+ # Fallback: assume known if it looks like a real word
194
+ return True
195
+
196
+
197
+ # ── RHYME DETECTION ───────────────────────────────────────────────────────────
198
+
199
+ def get_rhyme_signature(word: str) -> str | None:
200
+ """
201
+ Get the rhyme signature of a word using CMU dict (final vowel + consonants).
202
+ Returns None if word not in CMU dict.
203
+ """
204
+ word_lower = word.lower().strip(string.punctuation)
205
+ if not word_lower or not NLTK_AVAILABLE or word_lower not in CMU_DICT:
206
+ return None
207
+ pronunciation = CMU_DICT[word_lower][0]
208
+ # Find last stressed vowel and take everything from there
209
+ last_vowel_idx = None
210
+ for i, ph in enumerate(pronunciation):
211
+ if ph[-1].isdigit():
212
+ last_vowel_idx = i
213
+ if last_vowel_idx is None:
214
+ return None
215
+ return " ".join(pronunciation[last_vowel_idx:])
216
+
217
+
218
+ def words_rhyme(word1: str, word2: str) -> bool:
219
+ """Return True if two words rhyme based on CMU pronunciation."""
220
+ sig1 = get_rhyme_signature(word1)
221
+ sig2 = get_rhyme_signature(word2)
222
+ if sig1 and sig2 and sig1 == sig2 and word1.lower() != word2.lower():
223
+ return True
224
+ # Fallback: last 2 characters match (crude but works without NLTK)
225
+ w1 = word1.lower().strip(string.punctuation)
226
+ w2 = word2.lower().strip(string.punctuation)
227
+ if len(w1) >= 2 and len(w2) >= 2 and w1 != w2:
228
+ return w1[-2:] == w2[-2:]
229
+ return False
230
+
231
+
232
+ def get_line_end_words(text: str) -> list[str]:
233
+ """Extract the last word from each non-empty line."""
234
+ lines = [line.strip() for line in text.split("\n") if line.strip()]
235
+ end_words = []
236
+ for line in lines:
237
+ words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
238
+ if words:
239
+ end_words.append(words[-1])
240
+ return end_words
241
+
242
+
243
+ def compute_rhyme_density(end_words: list[str]) -> float:
244
+ """
245
+ Compute proportion of adjacent line-end pairs that rhyme.
246
+ Returns float 0–1.
247
+ """
248
+ if len(end_words) < 2:
249
+ return 0.0
250
+ pairs = [(end_words[i], end_words[i+1]) for i in range(len(end_words)-1)]
251
+ rhyming = sum(1 for w1, w2 in pairs if words_rhyme(w1, w2))
252
+ return round(rhyming / len(pairs), 3)
253
+
254
+
255
+ def detect_rhyme_scheme(end_words: list[str], window: int = 8) -> str:
256
+ """
257
+ Attempt to identify dominant rhyme scheme from first window lines.
258
+ Returns: AABB, ABAB, ABCB, free, mixed, or unknown.
259
+ """
260
+ if len(end_words) < 4:
261
+ return "insufficient data"
262
+
263
+ sample = end_words[:window]
264
+
265
+ # Test AABB: 0-1 rhyme, 2-3 rhyme
266
+ aabb_score = 0
267
+ for i in range(0, min(len(sample)-1, 8), 2):
268
+ if i+1 < len(sample) and words_rhyme(sample[i], sample[i+1]):
269
+ aabb_score += 1
270
+
271
+ # Test ABAB: 0-2 rhyme, 1-3 rhyme
272
+ abab_score = 0
273
+ for i in range(0, min(len(sample)-2, 6), 2):
274
+ if i+2 < len(sample) and words_rhyme(sample[i], sample[i+2]):
275
+ abab_score += 1
276
+
277
+ # Test ABCB: 1-3 rhyme only
278
+ abcb_score = 0
279
+ for i in range(1, min(len(sample)-2, 7), 4):
280
+ if i+2 < len(sample) and words_rhyme(sample[i], sample[i+2]):
281
+ abcb_score += 1
282
+
283
+ max_score = max(aabb_score, abab_score, abcb_score)
284
+ if max_score == 0:
285
+ density = compute_rhyme_density(end_words)
286
+ return "free" if density < 0.15 else "mixed"
287
+
288
+ if aabb_score >= abab_score and aabb_score >= abcb_score:
289
+ return "AABB"
290
+ elif abab_score >= aabb_score and abab_score >= abcb_score:
291
+ return "ABAB"
292
+ else:
293
+ return "ABCB"
294
+
295
+
296
+ # ── STRESS / METRE ────────────────────────────────────────────────────────────
297
+
298
+ def get_stress_pattern(line: str) -> list[int]:
299
+ """
300
+ Return a list of stress values (0=unstressed, 1=stressed) for each syllable in a line.
301
+ Uses CMU dict stress markers.
302
+ """
303
+ words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
304
+ pattern = []
305
+ for word in words:
306
+ if word in CMU_DICT:
307
+ pronunciation = CMU_DICT[word][0]
308
+ for ph in pronunciation:
309
+ if ph[-1] == "1":
310
+ pattern.append(1)
311
+ elif ph[-1] == "2":
312
+ pattern.append(1) # secondary stress counts
313
+ elif ph[-1] == "0":
314
+ pattern.append(0)
315
+ else:
316
+ # Fallback: assume alternating stress
317
+ syllables = count_syllables_fallback(word)
318
+ for i in range(syllables):
319
+ pattern.append(i % 2)
320
+ return pattern
321
+
322
+
323
+ def compute_stress_regularity(lines: list[str]) -> float:
324
+ """
325
+ Compute how regular the stress pattern is across lines.
326
+ Returns 0–1 where 1 = perfectly regular metre.
327
+ """
328
+ if not NLTK_AVAILABLE or not CMU_DICT:
329
+ return -1.0 # Cannot compute without CMU dict
330
+
331
+ patterns = [get_stress_pattern(line) for line in lines if line.strip()]
332
+ patterns = [p for p in patterns if len(p) >= 4]
333
+
334
+ if len(patterns) < 3:
335
+ return -1.0
336
+
337
+ # Measure consistency of stress at each position across lines
338
+ # Truncate to shortest pattern length
339
+ min_len = min(len(p) for p in patterns)
340
+ if min_len < 4:
341
+ return -1.0
342
+
343
+ truncated = [p[:min_len] for p in patterns]
344
+ position_agreement = []
345
+ for pos in range(min_len):
346
+ values = [p[pos] for p in truncated]
347
+ majority = max(set(values), key=values.count)
348
+ agreement = sum(1 for v in values if v == majority) / len(values)
349
+ position_agreement.append(agreement)
350
+
351
+ return round(sum(position_agreement) / len(position_agreement), 3)
352
+
353
+
354
+ # ── TOKENISATION ──────────────────────────────────────────────────────────────
355
+
356
+ def tokenise_words(text: str) -> list[str]:
357
+ """Return list of lowercase alphabetic word tokens."""
358
+ if NLTK_AVAILABLE:
359
+ try:
360
+ tokens = word_tokenize(text.lower())
361
+ return [t for t in tokens if t.isalpha()]
362
+ except Exception:
363
+ pass
364
+ return SIMPLE_TOKENISE_PATTERN.findall(text.lower())
365
+
366
+
367
+ def tokenise_sentences(text: str) -> list[str]:
368
+ """Return list of sentence strings."""
369
+ if NLTK_AVAILABLE:
370
+ try:
371
+ return sent_tokenize(text)
372
+ except Exception:
373
+ pass
374
+ # Fallback: split on sentence-ending punctuation
375
+ sentences = re.split(r"[.!?]+", text)
376
+ return [s.strip() for s in sentences if s.strip() and len(s.split()) > 1]
377
+
378
+
379
+ # ── FLESCH-KINCAID ────────────────────────────────────────────────────────────
380
+
381
+ def flesch_kincaid_grade(text: str, words: list[str], sentences: list[str]) -> float:
382
+ """
383
+ Compute Flesch-Kincaid Grade Level.
384
+ FK = 0.39 * (words/sentences) + 11.8 * (syllables/words) - 15.59
385
+ """
386
+ if not words or not sentences:
387
+ return -1.0
388
+ total_syllables = sum(count_syllables(w) for w in words)
389
+ asl = len(words) / len(sentences) # Average sentence length
390
+ asw = total_syllables / len(words) # Average syllables per word
391
+ fk = 0.39 * asl + 11.8 * asw - 15.59
392
+ return round(max(0.0, fk), 2)
393
+
394
+
395
+ # ── REPETITION INDEX ──────────────────────────────────────────────────────────
396
+
397
+ def compute_repetition_index(lines: list[str], ngram_size: int = 3) -> float:
398
+ """
399
+ Proportion of lines that reuse an n-gram from a prior line.
400
+ Returns float 0–1.
401
+ """
402
+ if len(lines) < 2:
403
+ return 0.0
404
+
405
+ seen_ngrams: set[tuple] = set()
406
+ repeat_count = 0
407
+
408
+ for line in lines:
409
+ words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
410
+ if len(words) < ngram_size:
411
+ continue
412
+ ngrams = [tuple(words[i:i+ngram_size]) for i in range(len(words)-ngram_size+1)]
413
+ line_has_repeat = any(ng in seen_ngrams for ng in ngrams)
414
+ if line_has_repeat:
415
+ repeat_count += 1
416
+ seen_ngrams.update(ngrams)
417
+
418
+ return round(repeat_count / len(lines), 3)
419
+
420
+
421
+ # ── CUMULATIVE STRUCTURE ──────────────────────────────────────────────────────
422
+
423
+ def compute_cumulative_structure(sentences: list[str]) -> float:
424
+ """
425
+ Proportion of sentences that open with a phrase used in a prior sentence.
426
+ Proxy for 'and then... and then...' accumulation pattern.
427
+ """
428
+ if len(sentences) < 3:
429
+ return 0.0
430
+
431
+ opening_phrases: list[str] = []
432
+ cumulative_count = 0
433
+
434
+ for sent in sentences:
435
+ words = SIMPLE_TOKENISE_PATTERN.findall(sent.lower())
436
+ if len(words) < 3:
437
+ continue
438
+ opening = " ".join(words[:3])
439
+ if opening in opening_phrases:
440
+ cumulative_count += 1
441
+ opening_phrases.append(opening)
442
+
443
+ return round(cumulative_count / len(sentences), 3)
444
+
445
+
446
+ # ── VOCABULARY TIER MATCH ─────────────────────────────────────────────────────
447
+
448
+ def compute_vocabulary_tier_match(words: list[str]) -> float:
449
+ """
450
+ Proportion of unique words that appear in the 4–7 age-band lexicon proxy.
451
+ Returns float 0–1.
452
+ """
453
+ unique_words = set(words)
454
+ if not unique_words:
455
+ return 0.0
456
+ matches = sum(1 for w in unique_words if w in DOLCH_FRY_PROXY)
457
+ return round(matches / len(unique_words), 3)
458
+
459
+
460
+ # ── DIALOGUE PROPORTION ───────────────────────────────────────────────────────
461
+
462
+ def compute_dialogue_proportion(text: str, total_words: int) -> float:
463
+ """Proportion of words inside quotation marks."""
464
+ if total_words == 0:
465
+ return 0.0
466
+ quoted_text = " ".join(QUOTE_PATTERN.findall(text))
467
+ quoted_words = len(SIMPLE_TOKENISE_PATTERN.findall(quoted_text.lower()))
468
+ return round(min(1.0, quoted_words / total_words), 3)
469
+
470
+
471
+ # ── MAIN EXTRACTION FUNCTION ──────────────────────────────────────────────────
472
+
473
+ def extract_fingerprint(
474
+ text: str,
475
+ author_name: str = "Unknown",
476
+ author_id: str = "CA-XXX",
477
+ works_sampled: str = "",
478
+ ) -> dict[str, Any]:
479
+ """
480
+ Extract all Tier 1 fingerprint metrics from text.
481
+
482
+ Args:
483
+ text: Raw text to analyse (already extracted from PDF or txt).
484
+ author_name: Author's full name for the output record.
485
+ author_id: Codex author ID (e.g. CA-001).
486
+ works_sampled: Comma-separated list of titles included in the text.
487
+
488
+ Returns:
489
+ Dictionary of metric values, confidence flags, and metadata.
490
+ Ready to paste into CODEX_03_FINGERPRINTS workbook row.
491
+ """
492
+ text = clean_text(text)
493
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
494
+ words = tokenise_words(text)
495
+ sentences = tokenise_sentences(text)
496
+
497
+ total_words = len(words)
498
+ total_sentences = len(sentences)
499
+ unique_words = set(words)
500
+
501
+ # ── CONFIDENCE FLAG ───────────────────────────────────────────────────────
502
+ if total_words < MIN_WORD_COUNT:
503
+ confidence = "LOW β€” sample under 200 words"
504
+ elif total_words < TARGET_WORD_COUNT:
505
+ confidence = f"MEDIUM β€” sample {total_words} words (target 1000+)"
506
+ else:
507
+ confidence = f"HIGH β€” sample {total_words} words"
508
+
509
+ # ── VM-001: Syllables per line ────────────────────────────────────────────
510
+ line_syllable_counts = []
511
+ for line in lines:
512
+ line_words = SIMPLE_TOKENISE_PATTERN.findall(line.lower())
513
+ if line_words:
514
+ syllables = sum(count_syllables(w) for w in line_words)
515
+ line_syllable_counts.append(syllables)
516
+
517
+ vm001 = round(sum(line_syllable_counts) / len(line_syllable_counts), 2) \
518
+ if line_syllable_counts else -1.0
519
+
520
+ # ── VM-002: Syllable variance ─────────────────────────────────────────────
521
+ if len(line_syllable_counts) >= 2:
522
+ mean_syl = sum(line_syllable_counts) / len(line_syllable_counts)
523
+ variance = sum((x - mean_syl) ** 2 for x in line_syllable_counts) / len(line_syllable_counts)
524
+ vm002 = round(math.sqrt(variance), 2)
525
+ else:
526
+ vm002 = -1.0
527
+
528
+ # ── VM-003: Rhyme scheme density ──────────────────────────────────────────
529
+ end_words = get_line_end_words(text)
530
+ vm003 = compute_rhyme_density(end_words)
531
+
532
+ # ── VM-004: Rhyme scheme type ─────────────────────────────────────────────
533
+ vm004 = detect_rhyme_scheme(end_words)
534
+
535
+ # ── VM-005: Stressed syllable regularity ──────────────────────────────────
536
+ vm005 = compute_stress_regularity(lines)
537
+
538
+ # ── VM-006: Vocabulary tier match 4-7 ────────────────────────────────────
539
+ vm006 = compute_vocabulary_tier_match(words)
540
+
541
+ # ── VM-007: Type-token ratio ──────────────────────────────────────────────
542
+ vm007 = round(len(unique_words) / total_words, 3) if total_words > 0 else -1.0
543
+
544
+ # ── VM-008: Invented word density ────────────────────────────────────────
545
+ unknown_words = [w for w in unique_words if len(w) > 2 and not is_known_word(w)]
546
+ vm008 = round(len(unknown_words) / len(unique_words), 3) if unique_words else 0.0
547
+
548
+ # ── VM-009: Average word length ───────────────────────────────────────────
549
+ vm009 = round(sum(len(w) for w in words) / total_words, 2) if total_words > 0 else -1.0
550
+
551
+ # ── VM-010: Sentence length mean ─────────────────────────────────────────
552
+ sent_lengths = [len(SIMPLE_TOKENISE_PATTERN.findall(s.lower())) for s in sentences if s.strip()]
553
+ vm010 = round(sum(sent_lengths) / len(sent_lengths), 2) if sent_lengths else -1.0
554
+
555
+ # ── VM-011: Sentence length variance ─────────────────────────────────────
556
+ if len(sent_lengths) >= 2:
557
+ mean_sent = sum(sent_lengths) / len(sent_lengths)
558
+ sent_var = sum((x - mean_sent) ** 2 for x in sent_lengths) / len(sent_lengths)
559
+ vm011 = round(math.sqrt(sent_var), 2)
560
+ else:
561
+ vm011 = -1.0
562
+
563
+ # ── VM-012: Cumulative structure score ────────────────────────────────────
564
+ vm012 = compute_cumulative_structure(sentences)
565
+
566
+ # ── VM-013: Dialogue proportion ───────────────────────────────────────────
567
+ vm013 = compute_dialogue_proportion(text, total_words)
568
+
569
+ # ── VM-024: Word count total ──────────────────────────────────────────────
570
+ vm024 = total_words
571
+
572
+ # ── VM-025: Reading age (Flesch-Kincaid) ─────────────────────────────────
573
+ vm025 = flesch_kincaid_grade(text, words, sentences)
574
+
575
+ # ── VM-026: Exclamation density ───────────────────────────────────────────
576
+ exclamations = len(EXCLAMATION_PATTERN.findall(text))
577
+ vm026 = round((exclamations / total_words) * 100, 2) if total_words > 0 else 0.0
578
+
579
+ # ── VM-027: Question density ──────────────────────────────────────────────
580
+ questions = len(QUESTION_PATTERN.findall(text))
581
+ vm027 = round((questions / total_words) * 100, 2) if total_words > 0 else 0.0
582
+
583
+ # ── VM-028: Repetition index ──────────────────────────────────────────────
584
+ vm028 = compute_repetition_index(lines)
585
+
586
+ # ── ASSEMBLE OUTPUT ──────��────────────────────────────────────────────────
587
+ result = {
588
+ # Metadata
589
+ "Author_ID": author_id,
590
+ "Author_Name": author_name,
591
+ "Works_Sampled": works_sampled,
592
+ "Sample_Words": total_words,
593
+ "Sample_Lines": len(lines),
594
+ "Sample_Sentences": total_sentences,
595
+ "Confidence_Level": confidence,
596
+ "NLTK_Available": NLTK_AVAILABLE,
597
+ "CMU_Dict_Available": bool(CMU_DICT),
598
+
599
+ # Tier 1 Metrics
600
+ "VM-001_Syllables_per_line": vm001,
601
+ "VM-002_Syllable_variance": vm002,
602
+ "VM-003_Rhyme_density": vm003,
603
+ "VM-004_Rhyme_type": vm004,
604
+ "VM-005_Stress_regularity": vm005 if vm005 != -1.0 else "REQUIRES_CMU_DICT",
605
+ "VM-006_Vocab_tier_match": vm006,
606
+ "VM-007_Type_token_ratio": vm007,
607
+ "VM-008_Invented_word_density": vm008,
608
+ "VM-009_Avg_word_length": vm009,
609
+ "VM-010_Sentence_length_mean": vm010,
610
+ "VM-011_Sentence_length_variance": vm011,
611
+ "VM-012_Cumulative_structure": vm012,
612
+ "VM-013_Dialogue_proportion": vm013,
613
+ "VM-024_Word_count": vm024,
614
+ "VM-025_Reading_age_FK": vm025,
615
+ "VM-026_Exclamation_density": vm026,
616
+ "VM-027_Question_density": vm027,
617
+ "VM-028_Repetition_index": vm028,
618
+
619
+ # Tier 2 reminder
620
+ "VM-014_to_VM-023": "TIER 2 β€” Use Codex Build Prompt 2 (ChatGPT/Gemini) for qualitative metrics",
621
+ }
622
+
623
+ return result
624
+
625
+
626
+ def format_fingerprint_report(fp: dict[str, Any]) -> str:
627
+ """
628
+ Format a fingerprint dict as a human-readable report string
629
+ suitable for display in the Gradio interface.
630
+ """
631
+ lines = [
632
+ f"╔══════════════════════════════════════════════════════╗",
633
+ f" TOTEM STUDIO CODEX β€” FINGERPRINT EXTRACTION REPORT",
634
+ f"β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•",
635
+ f"",
636
+ f" Author: {fp['Author_Name']}",
637
+ f" ID: {fp['Author_ID']}",
638
+ f" Works: {fp['Works_Sampled'] or 'Not specified'}",
639
+ f" Words: {fp['Sample_Words']}",
640
+ f" Lines: {fp['Sample_Lines']}",
641
+ f" Sentences: {fp['Sample_Sentences']}",
642
+ f" Confidence: {fp['Confidence_Level']}",
643
+ f" NLTK: {'Available' if fp['NLTK_Available'] else 'Not available β€” some metrics reduced accuracy'}",
644
+ f"",
645
+ f"── SONIC & RHYTHMIC ────────────────────────────────────",
646
+ f" VM-001 Syllables per line (mean): {fp['VM-001_Syllables_per_line']}",
647
+ f" VM-002 Syllable variance (SD): {fp['VM-002_Syllable_variance']}",
648
+ f" VM-003 Rhyme scheme density: {fp['VM-003_Rhyme_density']}",
649
+ f" VM-004 Rhyme scheme type: {fp['VM-004_Rhyme_type']}",
650
+ f" VM-005 Stress regularity (0–1): {fp['VM-005_Stress_regularity']}",
651
+ f"",
652
+ f"── VOCABULARY & LEXICON ────────────────────────────────",
653
+ f" VM-006 Vocab tier match 4–7 (0–1): {fp['VM-006_Vocab_tier_match']}",
654
+ f" VM-007 Type-token ratio (0–1): {fp['VM-007_Type_token_ratio']}",
655
+ f" VM-008 Invented word density (0–1): {fp['VM-008_Invented_word_density']}",
656
+ f" VM-009 Avg word length (chars): {fp['VM-009_Avg_word_length']}",
657
+ f"",
658
+ f"── NARRATIVE & STRUCTURE ───────────────────────────────",
659
+ f" VM-010 Sentence length mean (words): {fp['VM-010_Sentence_length_mean']}",
660
+ f" VM-011 Sentence length variance (SD): {fp['VM-011_Sentence_length_variance']}",
661
+ f" VM-012 Cumulative structure (0–1): {fp['VM-012_Cumulative_structure']}",
662
+ f" VM-013 Dialogue proportion (0–1): {fp['VM-013_Dialogue_proportion']}",
663
+ f"",
664
+ f"── AGE & DEMOGRAPHIC ───────────────────────────────────",
665
+ f" VM-024 Word count total: {fp['VM-024_Word_count']}",
666
+ f" VM-025 Reading age (FK grade): {fp['VM-025_Reading_age_FK']}",
667
+ f" VM-026 Exclamation density (per 100w): {fp['VM-026_Exclamation_density']}",
668
+ f" VM-027 Question density (per 100w): {fp['VM-027_Question_density']}",
669
+ f" VM-028 Repetition index (0–1): {fp['VM-028_Repetition_index']}",
670
+ f"",
671
+ f"── TIER 2 METRICS ──────────────────────────────────────",
672
+ f" VM-014 to VM-023 require qualitative extraction.",
673
+ f" Use Codex Build Prompt 2 (ChatGPT/Gemini) with the",
674
+ f" same text sample to complete these fields.",
675
+ f"",
676
+ f" Copy values above into CODEX_03_FINGERPRINTS row: {fp['Author_ID']}",
677
+ ]
678
+ return "\n".join(lines)
679
+
680
+
681
+ def process_upload(
682
+ file_path: str | Path,
683
+ author_name: str,
684
+ author_id: str,
685
+ works_sampled: str,
686
+ ) -> tuple[str, dict]:
687
+ """
688
+ Entry point for Gradio interface.
689
+ Accepts a file path, returns (formatted_report_string, raw_dict).
690
+ """
691
+ try:
692
+ raw_text = extract_text_from_file(file_path)
693
+ if not raw_text or len(raw_text.split()) < 20:
694
+ return "ERROR: No usable text extracted from file. Check the PDF contains selectable text (not scanned images).", {}
695
+ fp = extract_fingerprint(
696
+ text=raw_text,
697
+ author_name=author_name,
698
+ author_id=author_id,
699
+ works_sampled=works_sampled,
700
+ )
701
+ report = format_fingerprint_report(fp)
702
+ return report, fp
703
+ except Exception as e:
704
+ return f"ERROR: {type(e).__name__}: {str(e)}", {}
705
+
706
+
707
+ # ── STANDALONE TEST ───────────────────────────────────────────────────────────
708
+
709
+ if __name__ == "__main__":
710
+ # Quick test with a small sample β€” run: python3 codex_extractor.py
711
+ SAMPLE = """
712
+ The Gruffalo said that no gruffalo should
713
+ go near the snake who bakes chocolate cake.
714
+ The fox had a box full of socks by the dock,
715
+ and the mouse ran free from the clock and the clock.
716
+ He said to the owl, you're not like the rest,
717
+ your feathers are orange, your beak is the best.
718
+ She called to the bear in the cave far away,
719
+ come out come out on this bright sunny day.
720
+ """
721
+ fp = extract_fingerprint(
722
+ text=SAMPLE,
723
+ author_name="Test Author",
724
+ author_id="CA-TEST",
725
+ works_sampled="Test sample",
726
+ )
727
+ print(format_fingerprint_report(fp))