@@ -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
403547def 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