Skip to content

Commit 2601c2c

Browse files
author
1
committed
feat: add v0.4 advanced text features to TextFeatures dataclass
1 parent 4a910be commit 2601c2c

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

src/lmscan/_types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ class TextFeatures:
1919
transition_word_ratio: float = 0.0
2020
slop_word_score: float = 0.0
2121
punctuation_entropy: float = 0.0
22+
# v0.4 advanced features
23+
passive_voice_ratio: float = 0.0
24+
sentence_opening_diversity: float = 0.0
25+
lexical_density: float = 0.0
26+
char_entropy: float = 0.0
27+
hedging_density: float = 0.0
28+
conjunction_start_ratio: float = 0.0
2229
avg_word_length: float = 0.0
2330
avg_sentence_length: float = 0.0
2431
paragraph_count: int = 0

src/lmscan/features.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,150 @@ def slop_word_score(text: str) -> float:
398398
return hits / total
399399

400400

401+
# ── v0.4 Advanced features ────────────────────────────────────────────────────
402+
403+
_PASSIVE_PATTERN = re.compile(
404+
r"\b(?:is|was|were|are|been|being|be|gets|got|gotten)\s+"
405+
r"(?:\w+\s+)*?"
406+
r"(?:\w+(?:ed|en|wn|nt|ht|pt|xt|lt|ft|ct|rn|rt))\b",
407+
re.IGNORECASE,
408+
)
409+
410+
_FUNCTION_WORDS: set[str] = {
411+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
412+
"have", "has", "had", "do", "does", "did", "will", "would", "shall",
413+
"should", "may", "might", "must", "can", "could",
414+
"i", "me", "my", "we", "us", "our", "you", "your", "he", "she",
415+
"him", "her", "his", "it", "its", "they", "them", "their",
416+
"this", "that", "these", "those", "which", "who", "whom", "whose",
417+
"what", "where", "when", "why", "how",
418+
"in", "on", "at", "to", "for", "of", "with", "by", "from", "up",
419+
"about", "into", "through", "during", "before", "after", "above",
420+
"below", "between", "under", "over", "out",
421+
"and", "but", "or", "nor", "not", "so", "yet", "both", "either",
422+
"neither", "if", "then", "than", "as", "while", "although", "because",
423+
"since", "until", "unless", "whether", "though",
424+
"very", "also", "just", "even", "still", "already", "too", "quite",
425+
}
426+
427+
_HEDGING_PHRASES: list[str] = [
428+
"it is important to note", "it is worth noting", "it's important to note",
429+
"it's worth noting", "it should be noted", "one could argue",
430+
"it is essential to", "it is crucial to", "it is worth mentioning",
431+
"it bears mentioning", "it must be emphasized", "it cannot be overstated",
432+
"needless to say", "it goes without saying",
433+
"in this context", "in this regard", "to this end",
434+
"from a broader perspective", "taking into account",
435+
"it is important to consider", "it is imperative to",
436+
]
437+
438+
_CONJUNCTION_STARTERS: set[str] = {
439+
"furthermore", "moreover", "additionally", "consequently", "nevertheless",
440+
"however", "therefore", "thus", "hence", "accordingly", "similarly",
441+
"meanwhile", "subsequently", "conversely", "alternatively",
442+
"specifically", "notably", "importantly", "significantly",
443+
"ultimately", "essentially", "fundamentally", "interestingly",
444+
}
445+
446+
447+
def passive_voice_ratio(text: str) -> float:
448+
"""Estimate the fraction of sentences using passive voice constructions."""
449+
sentences = _split_sentences(text)
450+
if not sentences:
451+
return 0.0
452+
passive_count = sum(1 for s in sentences if _PASSIVE_PATTERN.search(s))
453+
return passive_count / len(sentences)
454+
455+
456+
def sentence_opening_diversity(text: str) -> float:
457+
"""Measure how diverse sentence openings are (0=all same, 1=all unique).
458+
459+
AI text tends to start sentences with "The", "This", "It" repetitively.
460+
Returns the ratio of unique first-word-pairs to total sentences.
461+
"""
462+
sentences = _split_sentences(text)
463+
if len(sentences) < 3:
464+
return 1.0
465+
# Use first two words as the opening pattern
466+
openings: list[str] = []
467+
for s in sentences:
468+
words = _tokenize(s)
469+
if len(words) >= 2:
470+
openings.append(f"{words[0]} {words[1]}")
471+
elif words:
472+
openings.append(words[0])
473+
if not openings:
474+
return 1.0
475+
unique = len(set(openings))
476+
return unique / len(openings)
477+
478+
479+
def lexical_density(text: str) -> float:
480+
"""Ratio of content words to total words (0-1).
481+
482+
AI-generated text often has lower lexical density due to filler
483+
and function word padding.
484+
"""
485+
words = _tokenize(text)
486+
if not words:
487+
return 0.0
488+
content_words = sum(1 for w in words if w not in _FUNCTION_WORDS)
489+
return content_words / len(words)
490+
491+
492+
def char_entropy(text: str) -> float:
493+
"""Shannon entropy at the character level (more robust for short text)."""
494+
if not text:
495+
return 0.0
496+
# Only count printable characters
497+
chars = [c for c in text.lower() if c.isprintable()]
498+
if not chars:
499+
return 0.0
500+
counts = Counter(chars)
501+
n = len(chars)
502+
entropy = 0.0
503+
for c in counts.values():
504+
p = c / n
505+
if p > 0:
506+
entropy -= p * math.log2(p)
507+
return entropy
508+
509+
510+
def hedging_density(text: str) -> float:
511+
"""Count hedging/qualifying phrases as fraction of total words."""
512+
words = _tokenize(text)
513+
if not words:
514+
return 0.0
515+
text_lower = text.lower()
516+
total = len(words)
517+
hits = 0
518+
for phrase in _HEDGING_PHRASES:
519+
start = 0
520+
while True:
521+
idx = text_lower.find(phrase, start)
522+
if idx == -1:
523+
break
524+
hits += len(phrase.split())
525+
start = idx + 1
526+
return hits / total
527+
528+
529+
def conjunction_start_ratio(text: str) -> float:
530+
"""Fraction of sentences beginning with a conjunction/transition adverb.
531+
532+
AI text heavily opens sentences with "Furthermore,", "Moreover,", etc.
533+
"""
534+
sentences = _split_sentences(text)
535+
if not sentences:
536+
return 0.0
537+
count = 0
538+
for s in sentences:
539+
words = _tokenize(s)
540+
if words and words[0] in _CONJUNCTION_STARTERS:
541+
count += 1
542+
return count / len(sentences)
543+
544+
401545
# ── Master extraction function ────────────────────────────────────────────────
402546

403547
def extract_features(text: str) -> TextFeatures:
@@ -425,6 +569,12 @@ def extract_features(text: str) -> TextFeatures:
425569
transition_word_ratio=round(transition_word_ratio(text), 6),
426570
slop_word_score=round(slop_word_score(text), 6),
427571
punctuation_entropy=round(punctuation_entropy(text), 6),
572+
passive_voice_ratio=round(passive_voice_ratio(text), 6),
573+
sentence_opening_diversity=round(sentence_opening_diversity(text), 6),
574+
lexical_density=round(lexical_density(text), 6),
575+
char_entropy=round(char_entropy(text), 6),
576+
hedging_density=round(hedging_density(text), 6),
577+
conjunction_start_ratio=round(conjunction_start_ratio(text), 6),
428578
avg_word_length=round(avg_wl, 6),
429579
avg_sentence_length=round(avg_sl, 6),
430580
paragraph_count=len(paragraphs),

0 commit comments

Comments
 (0)