diff --git a/scripts/cli.py b/scripts/cli.py index 51daac5..5a1c75b 100644 --- a/scripts/cli.py +++ b/scripts/cli.py @@ -170,6 +170,34 @@ def format_text_output(analysis, verbose=False): results_table.add_row("Verdict", verdict) + # Add supplementary AI indicators (new toolkit heuristics) + results_table.add_row("", "") # Separator + results_table.add_row("[dim]Supplementary Indicators:[/dim]", "") + + # Transition smoothness + trans_color = "green" if analysis.transition_score < 0.3 else "yellow" if analysis.transition_score < 0.6 else "red" + results_table.add_row( + "Transition Smoothness", + f"[{trans_color}]{analysis.transition_rate:.2f}/sent (score: {analysis.transition_score:.2f})[/{trans_color}]" + ) + + # Average comparative clustering + if analysis.echo_scores: + avg_comparative = sum(s.comparative_cluster_score for s in analysis.echo_scores) / len(analysis.echo_scores) + comp_color = "green" if avg_comparative < 0.3 else "yellow" if avg_comparative < 0.6 else "red" + results_table.add_row( + "Comparative Clustering", + f"[{comp_color}]{avg_comparative:.2f} avg[/{comp_color}]" + ) + + # Average em-dash frequency + avg_em_dash = sum(s.em_dash_score for s in analysis.echo_scores) / len(analysis.echo_scores) + em_color = "green" if avg_em_dash < 0.3 else "yellow" if avg_em_dash < 0.6 else "red" + results_table.add_row( + "Em-dash Frequency", + f"[{em_color}]{avg_em_dash:.2f} avg[/{em_color}]" + ) + console.print(results_table) console.print() @@ -190,6 +218,8 @@ def format_text_output(analysis, verbose=False): echo_table.add_column("Phonetic", justify="right") echo_table.add_column("Structural", justify="right") echo_table.add_column("Semantic", justify="right") + echo_table.add_column("Comparative", justify="right", style="cyan") + echo_table.add_column("Em-dash", justify="right", style="cyan") echo_table.add_column("Combined", justify="right", style="bold") for i, score in enumerate(analysis.echo_scores[:10], 1): @@ -198,6 +228,8 @@ def format_text_output(analysis, verbose=False): f"{score.phonetic_score:.3f}", f"{score.structural_score:.3f}", f"{score.semantic_score:.3f}", + f"{score.comparative_cluster_score:.3f}", + f"{score.em_dash_score:.3f}", f"{score.combined_score:.3f}" ) @@ -245,6 +277,10 @@ def format_json_output(analysis): else "possibly_watermarked" if analysis.final_score > 0.5 else "likely_human" ), + "supplementary_indicators": { + "transition_rate": float(analysis.transition_rate), + "transition_score": float(analysis.transition_score), + }, "metadata": { "clause_pairs": len(analysis.clause_pairs), "echo_scores_count": len(analysis.echo_scores), @@ -254,6 +290,9 @@ def format_json_output(analysis): # Add echo scores if available if analysis.echo_scores: combined_scores = [float(s.combined_score) for s in analysis.echo_scores] + comparative_scores = [float(s.comparative_cluster_score) for s in analysis.echo_scores] + em_dash_scores = [float(s.em_dash_score) for s in analysis.echo_scores] + result["echo_scores"] = { "mean": sum(combined_scores) / len(combined_scores), "max": max(combined_scores), @@ -263,11 +302,21 @@ def format_json_output(analysis): "phonetic": float(s.phonetic_score), "structural": float(s.structural_score), "semantic": float(s.semantic_score), - "combined": float(s.combined_score) + "combined": float(s.combined_score), + "comparative_cluster": float(s.comparative_cluster_score), + "em_dash": float(s.em_dash_score) } for s in analysis.echo_scores[:10] # First 10 samples ] } + + # Add supplementary indicator averages + result["supplementary_indicators"]["avg_comparative_cluster"] = ( + sum(comparative_scores) / len(comparative_scores) + ) + result["supplementary_indicators"]["avg_em_dash"] = ( + sum(em_dash_scores) / len(em_dash_scores) + ) return json.dumps(result, indent=2) diff --git a/specHO/detector.py b/specHO/detector.py index b5eacf4..fb4ae67 100644 --- a/specHO/detector.py +++ b/specHO/detector.py @@ -25,6 +25,7 @@ from .clause_identifier.pipeline import ClauseIdentifier from .echo_engine.pipeline import EchoAnalysisEngine from .scoring.pipeline import ScoringModule +from .scoring.transition_analyzer import TransitionSmoothnessAnalyzer from .validator.pipeline import StatisticalValidator @@ -110,6 +111,9 @@ def __init__(self, baseline_path: str = "data/baseline/baseline_stats.pkl"): self.echo_engine = EchoAnalysisEngine() self.scoring_module = ScoringModule() self.validator = StatisticalValidator(baseline_path) + + # Initialize supplementary analyzer for transition smoothness + self.transition_analyzer = TransitionSmoothnessAnalyzer() self.baseline_path = baseline_path @@ -222,6 +226,11 @@ def analyze(self, text: str) -> DocumentAnalysis: z_score, confidence = self.validator.validate(final_score) logging.debug(f" → Z-score: {z_score:.4f}, Confidence: {confidence:.4f}") + # Stage 6: Supplementary Analysis - Transition Smoothness + logging.debug("Stage 6: Analyzing transition smoothness...") + _, _, transition_rate, transition_score = self.transition_analyzer.analyze_text(text) + logging.debug(f" → Transition rate: {transition_rate:.4f}, Score: {transition_score:.4f}") + # Package complete analysis analysis = DocumentAnalysis( text=text, @@ -229,12 +238,14 @@ def analyze(self, text: str) -> DocumentAnalysis: echo_scores=echo_scores, final_score=final_score, z_score=z_score, - confidence=confidence + confidence=confidence, + transition_rate=transition_rate, + transition_score=transition_score ) logging.info( f"Analysis complete: score={final_score:.3f}, z={z_score:.2f}, " - f"conf={confidence:.1%}" + f"conf={confidence:.1%}, trans_rate={transition_rate:.2f}" ) return analysis @@ -260,7 +271,9 @@ def _create_empty_analysis(self, text: str) -> DocumentAnalysis: echo_scores=[], final_score=0.0, z_score=0.0, - confidence=0.5 # Neutral confidence for empty input + confidence=0.5, # Neutral confidence for empty input + transition_rate=0.0, + transition_score=0.0 ) def get_pipeline_info(self) -> dict: diff --git a/specHO/echo_engine/__init__.py b/specHO/echo_engine/__init__.py index 3f635e1..b0344ee 100644 --- a/specHO/echo_engine/__init__.py +++ b/specHO/echo_engine/__init__.py @@ -2,5 +2,8 @@ Echo Engine Component Analyzes phonetic, structural, and semantic echoes between clause pairs. +Also includes supplementary AI watermark indicators (comparative clustering, +em-dash frequency). + Part of the SpecHO watermark detection system. """ diff --git a/specHO/echo_engine/comparative_analyzer.py b/specHO/echo_engine/comparative_analyzer.py new file mode 100644 index 0000000..23d04c6 --- /dev/null +++ b/specHO/echo_engine/comparative_analyzer.py @@ -0,0 +1,171 @@ +""" +Comparative Clustering Analyzer for SpecHO Watermark Detector. + +Analyzes clustering of comparative terms within clause zones as an AI watermark +indicator. Based on toolkit analysis showing that AI (especially GPT-4) tends to +cluster multiple comparative terms in single sentences, creating a "harmonic +oscillation" pattern. + +Part of Component 3: Echo Engine (Supplementary analyzer). +""" + +from typing import List, Set +from specHO.models import Token + + +class ComparativeClusterAnalyzer: + """ + Analyzes comparative term clustering between clause zones. + + AI-generated text often contains clusters of comparative and superlative + terms (less/more/shorter/longer/better/worse) that create a rhythmic pattern. + This analyzer detects such clustering as a watermark indicator. + + Key AI Tell: + - 5+ comparatives in a sentence pair = EXTREME suspicion + - 3-4 comparatives = HIGH suspicion + - 2 comparatives = MODERATE suspicion + - 0-1 comparatives = LOW suspicion + + Examples from toolkit analysis: + "learned less, invested less effort, wrote advice that was shorter, + less factual and more generic" → 5 comparatives (strong AI tell) + """ + + # Comprehensive list of comparative terms typical in AI writing + COMPARATIVE_TERMS: Set[str] = { + # Basic comparatives + 'less', 'more', 'fewer', 'greater', + 'smaller', 'larger', 'shorter', 'longer', + 'better', 'worse', 'deeper', 'shallower', + 'higher', 'lower', 'stronger', 'weaker', + 'faster', 'slower', 'easier', 'harder', + + # Superlatives + 'least', 'most', 'fewest', 'greatest', + 'smallest', 'largest', 'shortest', 'longest', + 'best', 'worst', 'deepest', 'shallowest', + 'highest', 'lowest', 'strongest', 'weakest', + 'fastest', 'slowest', 'easiest', 'hardest', + + # Adjective comparatives (common in AI) + 'simpler', 'clearer', 'broader', 'narrower', + 'richer', 'poorer', 'newer', 'older', + 'younger', 'earlier', 'later', 'nearer', + 'farther', 'further', 'closer', 'tighter', + 'looser', 'wider', 'thinner', 'thicker' + } + + def analyze(self, zone_a: List[Token], zone_b: List[Token]) -> float: + """ + Calculate comparative clustering score for a clause pair. + + Args: + zone_a: First zone (terminal content words from clause A) + zone_b: Second zone (initial content words from clause B) + + Returns: + Float in [0, 1] representing comparative clustering intensity. + - 0.0-0.2: No/minimal clustering (0-1 comparatives) + - 0.2-0.4: Mild clustering (2 comparatives) + - 0.4-0.7: Moderate clustering (3 comparatives) + - 0.7-0.9: High clustering (4 comparatives) + - 0.9-1.0: Extreme clustering (5+ comparatives) + + Algorithm: + 1. Extract all tokens from both zones + 2. Count comparative terms (case-insensitive) + 3. Map count to [0,1] score using threshold function + 4. Return clustering score + """ + if not zone_a and not zone_b: + return 0.0 + + # Extract all text tokens from both zones + all_tokens = zone_a + zone_b + token_texts = [token.text.lower() for token in all_tokens if token.text] + + # Count comparative terms + comparative_count = sum( + 1 for text in token_texts if text in self.COMPARATIVE_TERMS + ) + + # Map count to [0,1] score using threshold function + # Based on toolkit analysis thresholds + score = self._count_to_score(comparative_count) + + return score + + def _count_to_score(self, count: int) -> float: + """ + Convert comparative count to normalized score. + + Scoring function based on toolkit analysis: + - 0-1 comparatives: 0.0-0.2 (minimal) + - 2 comparatives: 0.3 (mild) + - 3 comparatives: 0.5 (moderate) + - 4 comparatives: 0.8 (high) + - 5+ comparatives: 0.95-1.0 (extreme) + + Args: + count: Number of comparative terms found + + Returns: + Float in [0, 1] + """ + if count == 0: + return 0.0 + elif count == 1: + return 0.15 + elif count == 2: + return 0.3 + elif count == 3: + return 0.5 + elif count == 4: + return 0.8 + elif count >= 5: + # Scale beyond 5: 0.95 for 5, approach 1.0 asymptotically + return min(1.0, 0.9 + (count - 5) * 0.02) + + return 0.0 + + def get_comparatives_in_zones(self, zone_a: List[Token], zone_b: List[Token]) -> List[str]: + """ + Get list of comparative terms found in the zones (for debugging/display). + + Args: + zone_a: First zone + zone_b: Second zone + + Returns: + List of comparative terms found (lowercase) + + Example: + >>> analyzer = ComparativeClusterAnalyzer() + >>> comparatives = analyzer.get_comparatives_in_zones(zone_a, zone_b) + >>> print(comparatives) + ['less', 'more', 'shorter', 'better'] + """ + all_tokens = zone_a + zone_b + token_texts = [token.text.lower() for token in all_tokens if token.text] + + found_comparatives = [ + text for text in token_texts if text in self.COMPARATIVE_TERMS + ] + + return found_comparatives + + +def quick_comparative_analysis(zone_a: List[Token], zone_b: List[Token]) -> float: + """ + Convenience function for quick comparative clustering analysis. + + Args: + zone_a: First zone + zone_b: Second zone + + Returns: + Comparative clustering score in [0, 1] + """ + analyzer = ComparativeClusterAnalyzer() + return analyzer.analyze(zone_a, zone_b) diff --git a/specHO/echo_engine/pipeline.py b/specHO/echo_engine/pipeline.py index c5c14c6..8e71f12 100644 --- a/specHO/echo_engine/pipeline.py +++ b/specHO/echo_engine/pipeline.py @@ -4,6 +4,10 @@ phonetic, structural, and semantic dimensions. It coordinates the three specialized analyzers and returns a consolidated EchoScore. +Also integrates supplementary AI watermark indicators: +- Comparative clustering (comparative terms like less/more/shorter) +- Em-dash frequency (structural indicator) + Tier: 1 (MVP) Task: 4.4 Dependencies: Tasks 4.1, 4.2, 4.3 @@ -14,6 +18,7 @@ from specHO.echo_engine.phonetic_analyzer import PhoneticEchoAnalyzer from specHO.echo_engine.structural_analyzer import StructuralEchoAnalyzer from specHO.echo_engine.semantic_analyzer import SemanticEchoAnalyzer +from specHO.echo_engine.comparative_analyzer import ComparativeClusterAnalyzer class EchoAnalysisEngine: @@ -23,9 +28,14 @@ class EchoAnalysisEngine: specialized analyzers and combines their results into a consolidated EchoScore dataclass. + Also includes supplementary AI watermark indicators: + - Comparative clustering analysis + - Em-dash frequency detection + Tier 1 Implementation: - Simple orchestration (no complex logic) - Run all three analyzers sequentially + - Add supplementary indicators - Combine results into EchoScore - No error recovery (let exceptions propagate) @@ -33,13 +43,15 @@ class EchoAnalysisEngine: phonetic_analyzer: Analyzes phonetic similarity (ARPAbet/Levenshtein) structural_analyzer: Analyzes POS patterns and syllable counts semantic_analyzer: Analyzes semantic similarity (embeddings) + comparative_analyzer: Analyzes comparative term clustering (supplementary) """ def __init__( self, phonetic_analyzer: Optional[PhoneticEchoAnalyzer] = None, structural_analyzer: Optional[StructuralEchoAnalyzer] = None, - semantic_analyzer: Optional[SemanticEchoAnalyzer] = None + semantic_analyzer: Optional[SemanticEchoAnalyzer] = None, + comparative_analyzer: Optional[ComparativeClusterAnalyzer] = None ): """Initialize the echo analysis engine with specialized analyzers. @@ -51,27 +63,35 @@ def __init__( semantic_analyzer: Semantic similarity analyzer. If None, creates default instance (operates in fallback mode if no embeddings available). + comparative_analyzer: Comparative clustering analyzer. If None, + creates default instance. """ # Initialize analyzers (use defaults if not provided) self.phonetic_analyzer = phonetic_analyzer or PhoneticEchoAnalyzer() self.structural_analyzer = structural_analyzer or StructuralEchoAnalyzer() self.semantic_analyzer = semantic_analyzer or SemanticEchoAnalyzer() + self.comparative_analyzer = comparative_analyzer or ComparativeClusterAnalyzer() def analyze_pair(self, clause_pair: ClausePair) -> EchoScore: """Analyze a clause pair across all three similarity dimensions. Runs phonetic, structural, and semantic analysis on the clause pair's - zones and returns a consolidated EchoScore. The combined_score is - calculated by the scoring module (Task 5.x), so we leave it as 0.0. + zones and returns a consolidated EchoScore. Also includes supplementary + AI watermark indicators (comparative clustering, em-dash frequency). + + The combined_score is calculated by the scoring module (Task 5.x), so + we leave it as 0.0. Args: clause_pair: ClausePair with zone_a_tokens and zone_b_tokens populated Returns: - EchoScore with all three dimension scores: + EchoScore with all three dimension scores plus supplementary indicators: - phonetic_score: Float in [0.0, 1.0] - structural_score: Float in [0.0, 1.0] - semantic_score: Float in [0.0, 1.0] + - comparative_cluster_score: Float in [0.0, 1.0] (supplementary) + - em_dash_score: Float in [0.0, 1.0] (supplementary) - combined_score: 0.0 (calculated by scoring module) Edge Cases: @@ -85,8 +105,10 @@ def analyze_pair(self, clause_pair: ClausePair) -> EchoScore: >>> print(f"Phonetic: {score.phonetic_score:.3f}") >>> print(f"Structural: {score.structural_score:.3f}") >>> print(f"Semantic: {score.semantic_score:.3f}") + >>> print(f"Comparative: {score.comparative_cluster_score:.3f}") + >>> print(f"Em-dash: {score.em_dash_score:.3f}") """ - # Run all three analyzers on the clause pair zones + # Run all three primary analyzers on the clause pair zones phonetic_score = self.phonetic_analyzer.analyze( clause_pair.zone_a_tokens, clause_pair.zone_b_tokens @@ -102,12 +124,26 @@ def analyze_pair(self, clause_pair: ClausePair) -> EchoScore: clause_pair.zone_b_tokens ) - # Return consolidated EchoScore + # Run supplementary AI watermark indicators + comparative_score = self.comparative_analyzer.analyze( + clause_pair.zone_a_tokens, + clause_pair.zone_b_tokens + ) + + # Get em-dash score from structural analyzer + _, em_dash_score = self.structural_analyzer.detect_em_dashes( + clause_pair.zone_a_tokens, + clause_pair.zone_b_tokens + ) + + # Return consolidated EchoScore with supplementary metrics # Note: combined_score is 0.0 here - it will be calculated by the # scoring module (Task 5.1) which applies weighted combination return EchoScore( phonetic_score=phonetic_score, structural_score=structural_score, semantic_score=semantic_score, - combined_score=0.0 # Calculated by scoring module + combined_score=0.0, # Calculated by scoring module + comparative_cluster_score=comparative_score, + em_dash_score=em_dash_score ) diff --git a/specHO/echo_engine/structural_analyzer.py b/specHO/echo_engine/structural_analyzer.py index 5df4448..dcde8b0 100644 --- a/specHO/echo_engine/structural_analyzer.py +++ b/specHO/echo_engine/structural_analyzer.py @@ -2,12 +2,13 @@ Structural Echo Analyzer for SpecHO Watermark Detector. Analyzes structural similarity between clause zones based on POS patterns and syllable counts. +Also tracks em-dash frequency as an AI watermark indicator. Part of Component 3: Echo Engine (Task 4.2). Tier 1: Simple exact matching of POS patterns and syllable count comparison. """ -from typing import List +from typing import List, Tuple from specHO.models import Token @@ -135,6 +136,58 @@ def compare_syllable_counts(self, zone_a: List[Token], zone_b: List[Token]) -> f # Ensure output is in [0, 1] range return max(0.0, min(1.0, similarity)) + def detect_em_dashes(self, zone_a: List[Token], zone_b: List[Token]) -> Tuple[int, float]: + """ + Detect em-dash usage in zones as an AI watermark indicator. + + AI models (especially GPT-4) overuse em-dashes in their writing. + This method counts em-dashes and calculates a suspicion score. + + Args: + zone_a: First zone + zone_b: Second zone + + Returns: + Tuple of (count, score): + - count: Number of em-dashes found + - score: Float in [0, 1] representing em-dash suspicion + 0.0-0.2: Low (0 em-dashes, human-typical) + 0.3-0.5: Moderate (1 em-dash) + 0.6-1.0: High (2+ em-dashes, AI-typical) + + Based on toolkit analysis: + - Human writing: typically <0.3 em-dashes per sentence + - AI writing (GPT-4): typically 0.5-1.0+ em-dashes per sentence + + Note: Em-dashes include both – (en-dash) and — (em-dash) Unicode chars + """ + if not zone_a and not zone_b: + return 0, 0.0 + + # Combine zones for counting + all_tokens = zone_a + zone_b + + # Count em-dashes (both en-dash – and em-dash —) + em_dash_count = 0 + for token in all_tokens: + if token.text in ('–', '—', '--'): + em_dash_count += 1 + + # Map count to suspicion score + # 0 em-dashes: 0.0 (typical) + # 1 em-dash: 0.4 (moderate) + # 2+ em-dashes: 0.7-1.0 (high suspicion) + if em_dash_count == 0: + score = 0.0 + elif em_dash_count == 1: + score = 0.4 + elif em_dash_count == 2: + score = 0.7 + else: # 3+ + score = min(1.0, 0.7 + (em_dash_count - 2) * 0.1) + + return em_dash_count, score + def quick_structural_analysis(zone_a: List[Token], zone_b: List[Token]) -> float: """ diff --git a/specHO/models.py b/specHO/models.py index 8df2345..f997b27 100644 --- a/specHO/models.py +++ b/specHO/models.py @@ -97,6 +97,10 @@ class EchoScore: between zone A and zone B. The combined_score represents the weighted aggregation of the three dimensions. + Also includes supplementary AI watermark indicators from toolkit analysis: + - comparative_cluster_score: Clustering of comparative terms (less/more/shorter) + - em_dash_score: Em-dash frequency indicator + All scores are normalized to the range [0.0, 1.0] where: - 0.0 indicates no similarity - 1.0 indicates perfect similarity @@ -106,11 +110,15 @@ class EchoScore: structural_score: Structural similarity (POS patterns, syllable counts) semantic_score: Semantic similarity (meaning relationships) combined_score: Weighted combination of the three scores + comparative_cluster_score: Comparative term clustering indicator (supplementary) + em_dash_score: Em-dash frequency indicator (supplementary) """ phonetic_score: float # 0.0-1.0 structural_score: float # 0.0-1.0 semantic_score: float # 0.0-1.0 combined_score: float # 0.0-1.0 + comparative_cluster_score: float = 0.0 # 0.0-1.0 (supplementary) + em_dash_score: float = 0.0 # 0.0-1.0 (supplementary) @dataclass @@ -125,6 +133,10 @@ class DocumentAnalysis: - z_score: Standard deviations above human baseline mean - confidence: Probability that this score is inconsistent with human writing + Also includes supplementary AI watermark indicators from toolkit analysis: + - transition_rate: Smooth transition words per sentence + - transition_score: Transition smoothness suspicion score + Attributes: text: Original input text that was analyzed tokens: All tokens from preprocessing (for debugging/display) @@ -133,6 +145,8 @@ class DocumentAnalysis: final_score: Aggregated document-level echo score (0.0-1.0) z_score: Statistical significance relative to human baseline confidence: Confidence level that watermark is present (0.0-1.0) + transition_rate: Smooth transitions per sentence (supplementary) + transition_score: Transition smoothness AI suspicion score (supplementary) """ text: str clause_pairs: List[ClausePair] @@ -141,6 +155,8 @@ class DocumentAnalysis: z_score: float confidence: float tokens: List[Token] = None # Optional, added for UI display + transition_rate: float = 0.0 # Supplementary metric + transition_score: float = 0.0 # Supplementary metric @property def document_score(self) -> float: diff --git a/specHO/scoring/transition_analyzer.py b/specHO/scoring/transition_analyzer.py new file mode 100644 index 0000000..06009bb --- /dev/null +++ b/specHO/scoring/transition_analyzer.py @@ -0,0 +1,211 @@ +""" +Transition Smoothness Analyzer for SpecHO Watermark Detector. + +Analyzes the frequency of smooth transition words/phrases as an AI watermark +indicator. AI models (especially GPT-4) tend to overuse smooth, formal transitions +to create cohesive-sounding text. + +Part of Component 4: Scoring Module (Supplementary analyzer). +""" + +from typing import List, Set, Tuple + + +class TransitionSmoothnessAnalyzer: + """ + Analyzes transition word/phrase frequency as an AI tell. + + AI-generated text often contains an unnaturally high rate of smooth, + formal transition words and phrases. This creates overly polished, + academic-sounding prose that lacks the natural roughness of human writing. + + Key AI Tell: + - Transition rate >0.25 per sentence = HIGH suspicion (AI-typical) + - Transition rate 0.15-0.25 per sentence = MODERATE suspicion + - Transition rate <0.15 per sentence = LOW suspicion (human-typical) + + Based on toolkit analysis of GPT-4 generated academic text. + """ + + # Comprehensive list of AI-typical smooth transitions + SMOOTH_TRANSITIONS: Set[str] = { + # Contrast transitions + 'however', 'nevertheless', 'nonetheless', 'conversely', + 'on the other hand', 'in contrast', 'rather', 'alternatively', + + # Addition transitions + 'moreover', 'furthermore', 'additionally', 'likewise', + 'similarly', 'in addition', 'also', 'besides', + + # Clarification transitions + 'to be clear', 'in other words', 'specifically', 'namely', + 'that is', 'in particular', 'more precisely', + + # Sequential transitions + 'first', 'second', 'third', 'next', 'then', 'finally', + 'subsequently', 'meanwhile', 'in turn', + + # Causal transitions + 'therefore', 'thus', 'consequently', 'accordingly', + 'as a result', 'hence', 'for this reason', + + # Elaboration transitions + 'indeed', 'in fact', 'notably', 'significantly', + 'importantly', 'as part of', 'building on this', + + # Experimentation/research transitions + 'in another experiment', 'in a follow-up study', + 'to test this', 'to examine this further', + + # Summary transitions + 'in summary', 'in conclusion', 'overall', 'ultimately', + 'in essence', 'to sum up' + } + + def analyze_text(self, text: str) -> Tuple[int, int, float, float]: + """ + Analyze transition smoothness for entire document. + + Args: + text: Full document text to analyze + + Returns: + Tuple of (transition_count, sentence_count, rate, score): + - transition_count: Number of smooth transitions found + - sentence_count: Number of sentences in text + - rate: Transitions per sentence (float) + - score: Float in [0, 1] representing AI suspicion based on rate + 0.0-0.3: Low suspicion (human-typical) + 0.3-0.6: Moderate suspicion + 0.6-1.0: High suspicion (AI-typical) + + Algorithm: + 1. Split text into sentences (simple split on .!?) + 2. Search for transition words/phrases at sentence start or after commas + 3. Calculate rate = transition_count / sentence_count + 4. Map rate to [0,1] suspicion score + """ + if not text or not text.strip(): + return 0, 0, 0.0, 0.0 + + # Split into sentences (simple approach for Tier 1) + # Split on periods, exclamation marks, question marks + import re + sentences = re.split(r'[.!?]+', text) + sentences = [s.strip() for s in sentences if s.strip()] + sentence_count = len(sentences) + + if sentence_count == 0: + return 0, 0, 0.0, 0.0 + + # Count transitions + transition_count = 0 + for sentence in sentences: + sentence_lower = sentence.lower() + + # Check if sentence starts with a transition + for transition in self.SMOOTH_TRANSITIONS: + # Check at start of sentence + if sentence_lower.startswith(transition): + transition_count += 1 + break # Count only one transition per sentence + # Check after comma (common AI pattern: ", however, ...") + elif f', {transition}' in sentence_lower: + transition_count += 1 + break + + # Calculate rate + rate = transition_count / sentence_count + + # Map rate to suspicion score based on toolkit thresholds + score = self._rate_to_score(rate) + + return transition_count, sentence_count, rate, score + + def _rate_to_score(self, rate: float) -> float: + """ + Convert transition rate to normalized suspicion score. + + Scoring function based on toolkit analysis: + - rate < 0.15: 0.0-0.3 (low suspicion, human-typical) + - rate 0.15-0.25: 0.3-0.6 (moderate suspicion) + - rate > 0.25: 0.6-1.0 (high suspicion, AI-typical) + + Args: + rate: Transitions per sentence (float) + + Returns: + Float in [0, 1] representing AI suspicion level + """ + if rate < 0.15: + # Linear mapping: 0.0 -> 0.0, 0.15 -> 0.3 + return rate * 2.0 + elif rate < 0.25: + # Linear mapping: 0.15 -> 0.3, 0.25 -> 0.6 + return 0.3 + ((rate - 0.15) * 3.0) + else: + # Asymptotic approach to 1.0 for high rates + # 0.25 -> 0.6, 0.5 -> 0.85, 1.0 -> 0.95 + normalized = min((rate - 0.25) / 0.75, 1.0) + return 0.6 + (normalized * 0.4) + + def get_transitions_in_text(self, text: str) -> List[Tuple[str, str]]: + """ + Get list of transitions found in text (for debugging/display). + + Args: + text: Text to analyze + + Returns: + List of (transition_phrase, sentence_snippet) tuples + + Example: + >>> analyzer = TransitionSmoothnessAnalyzer() + >>> transitions = analyzer.get_transitions_in_text(text) + >>> for phrase, snippet in transitions: + ... print(f"'{phrase}' in: {snippet}") + 'however' in: However, the results showed... + 'moreover' in: Moreover, we found that... + """ + if not text or not text.strip(): + return [] + + import re + sentences = re.split(r'[.!?]+', text) + sentences = [s.strip() for s in sentences if s.strip()] + + found_transitions = [] + for sentence in sentences: + sentence_lower = sentence.lower() + + for transition in self.SMOOTH_TRANSITIONS: + if sentence_lower.startswith(transition): + snippet = sentence[:80] + ('...' if len(sentence) > 80 else '') + found_transitions.append((transition, snippet)) + break + elif f', {transition}' in sentence_lower: + # Find the context around the transition + idx = sentence_lower.find(f', {transition}') + start = max(0, idx - 20) + end = min(len(sentence), idx + 60) + snippet = ('...' if start > 0 else '') + sentence[start:end] + \ + ('...' if end < len(sentence) else '') + found_transitions.append((transition, snippet)) + break + + return found_transitions + + +def quick_transition_analysis(text: str) -> float: + """ + Convenience function for quick transition smoothness analysis. + + Args: + text: Document text + + Returns: + Transition smoothness score in [0, 1] + """ + analyzer = TransitionSmoothnessAnalyzer() + _, _, _, score = analyzer.analyze_text(text) + return score diff --git a/tests/test_comparative_analyzer.py b/tests/test_comparative_analyzer.py new file mode 100644 index 0000000..3fcbbe0 --- /dev/null +++ b/tests/test_comparative_analyzer.py @@ -0,0 +1,192 @@ +""" +Tests for ComparativeClusterAnalyzer. + +Tests the comparative term clustering detection feature from toolkit analysis. +""" + +import pytest +from specHO.echo_engine.comparative_analyzer import ( + ComparativeClusterAnalyzer, + quick_comparative_analysis +) +from specHO.models import Token + + +@pytest.fixture +def analyzer(): + """Create a ComparativeClusterAnalyzer instance.""" + return ComparativeClusterAnalyzer() + + +@pytest.fixture +def sample_tokens(): + """Create sample tokens for testing.""" + return [ + Token('less', 'ADJ', 'L EH S', True, 1), + Token('effort', 'NOUN', 'EH F ER T', True, 2), + Token('more', 'ADJ', 'M AO R', True, 1), + Token('generic', 'ADJ', 'JH AH N EH R IH K', True, 3), + Token('shorter', 'ADJ', 'SH AO R T ER', True, 2), + Token('text', 'NOUN', 'T EH K S T', True, 1), + ] + + +class TestComparativeClusterAnalyzer: + """Test suite for ComparativeClusterAnalyzer.""" + + def test_initialization(self, analyzer): + """Test analyzer initializes correctly.""" + assert analyzer is not None + assert isinstance(analyzer.COMPARATIVE_TERMS, set) + assert 'less' in analyzer.COMPARATIVE_TERMS + assert 'more' in analyzer.COMPARATIVE_TERMS + assert 'shorter' in analyzer.COMPARATIVE_TERMS + + def test_no_comparatives(self, analyzer): + """Test with no comparative terms.""" + tokens = [ + Token('the', 'DET', 'DH AH', False, 1), + Token('cat', 'NOUN', 'K AE T', True, 1), + Token('sat', 'VERB', 'S AE T', True, 1), + ] + score = analyzer.analyze(tokens, []) + assert score == 0.0 + + def test_one_comparative(self, analyzer): + """Test with one comparative term.""" + tokens = [ + Token('less', 'ADJ', 'L EH S', True, 1), + Token('effort', 'NOUN', 'EH F ER T', True, 2), + ] + score = analyzer.analyze(tokens, []) + assert 0.1 <= score <= 0.2 + + def test_two_comparatives(self, analyzer): + """Test with two comparative terms.""" + zone_a = [Token('less', 'ADJ', 'L EH S', True, 1)] + zone_b = [Token('more', 'ADJ', 'M AO R', True, 1)] + score = analyzer.analyze(zone_a, zone_b) + assert score == 0.3 + + def test_three_comparatives(self, analyzer, sample_tokens): + """Test with three comparative terms - moderate suspicion.""" + zone_a = [sample_tokens[0], sample_tokens[1]] # less, effort + zone_b = [sample_tokens[2], sample_tokens[4]] # more, shorter + score = analyzer.analyze(zone_a, zone_b) + # Should have 3 comparatives: less, more, shorter + assert score == 0.5 + + def test_four_comparatives(self, analyzer): + """Test with four comparative terms - high suspicion.""" + zone_a = [ + Token('less', 'ADJ', 'L EH S', True, 1), + Token('more', 'ADJ', 'M AO R', True, 1), + ] + zone_b = [ + Token('shorter', 'ADJ', 'SH AO R T ER', True, 2), + Token('better', 'ADJ', 'B EH T ER', True, 2), + ] + score = analyzer.analyze(zone_a, zone_b) + assert score == 0.8 + + def test_five_comparatives_extreme(self, analyzer): + """Test with five comparative terms - extreme suspicion (AI tell).""" + zone_a = [ + Token('less', 'ADJ', 'L EH S', True, 1), + Token('more', 'ADJ', 'M AO R', True, 1), + Token('shorter', 'ADJ', 'SH AO R T ER', True, 2), + ] + zone_b = [ + Token('better', 'ADJ', 'B EH T ER', True, 2), + Token('fewer', 'ADJ', 'F Y UW ER', True, 2), + ] + score = analyzer.analyze(zone_a, zone_b) + assert score >= 0.9 + + def test_case_insensitive(self, analyzer): + """Test that comparison is case-insensitive.""" + tokens = [ + Token('LESS', 'ADJ', 'L EH S', True, 1), + Token('More', 'ADJ', 'M AO R', True, 1), + ] + score = analyzer.analyze(tokens, []) + assert score == 0.3 + + def test_empty_zones(self, analyzer): + """Test with empty zones.""" + score = analyzer.analyze([], []) + assert score == 0.0 + + def test_one_empty_zone(self, analyzer): + """Test with one empty zone.""" + tokens = [Token('less', 'ADJ', 'L EH S', True, 1)] + score = analyzer.analyze(tokens, []) + assert 0.1 <= score <= 0.2 + + def test_get_comparatives_in_zones(self, analyzer, sample_tokens): + """Test getting list of comparative terms found.""" + zone_a = [sample_tokens[0], sample_tokens[1]] # less, effort + zone_b = [sample_tokens[2]] # more + + comparatives = analyzer.get_comparatives_in_zones(zone_a, zone_b) + assert 'less' in comparatives + assert 'more' in comparatives + assert len(comparatives) == 2 + + def test_superlatives(self, analyzer): + """Test that superlatives are also detected.""" + tokens = [ + Token('best', 'ADJ', 'B EH S T', True, 1), + Token('worst', 'ADJ', 'W ER S T', True, 1), + Token('least', 'ADJ', 'L IY S T', True, 1), + ] + score = analyzer.analyze(tokens, []) + assert score == 0.5 # 3 comparatives + + def test_diverse_comparatives(self, analyzer): + """Test with diverse comparative forms.""" + tokens = [ + Token('higher', 'ADJ', 'HH AY ER', True, 2), + Token('lower', 'ADJ', 'L OW ER', True, 2), + Token('faster', 'ADJ', 'F AE S T ER', True, 2), + Token('slower', 'ADJ', 'S L OW ER', True, 2), + ] + score = analyzer.analyze(tokens, []) + assert score == 0.8 # 4 comparatives + + +class TestQuickComparativeAnalysis: + """Test convenience function.""" + + def test_quick_analysis(self): + """Test quick_comparative_analysis convenience function.""" + zone_a = [Token('less', 'ADJ', 'L EH S', True, 1)] + zone_b = [Token('more', 'ADJ', 'M AO R', True, 1)] + + score = quick_comparative_analysis(zone_a, zone_b) + assert score == 0.3 + + +class TestComparativeTermsCoverage: + """Test that comprehensive set of comparative terms is covered.""" + + def test_basic_comparatives_present(self, analyzer): + """Test basic comparative terms are in the set.""" + basic_terms = ['less', 'more', 'fewer', 'greater', 'smaller', + 'larger', 'shorter', 'longer', 'better', 'worse'] + for term in basic_terms: + assert term in analyzer.COMPARATIVE_TERMS + + def test_superlatives_present(self, analyzer): + """Test superlative terms are in the set.""" + superlatives = ['least', 'most', 'best', 'worst', 'smallest', + 'largest', 'shortest', 'longest'] + for term in superlatives: + assert term in analyzer.COMPARATIVE_TERMS + + def test_adjective_comparatives_present(self, analyzer): + """Test adjective comparative forms are in the set.""" + adjectives = ['simpler', 'clearer', 'broader', 'narrower', + 'richer', 'poorer', 'newer', 'older'] + for term in adjectives: + assert term in analyzer.COMPARATIVE_TERMS diff --git a/tests/test_structural_analyzer.py b/tests/test_structural_analyzer.py index 09b9c69..bd28659 100644 --- a/tests/test_structural_analyzer.py +++ b/tests/test_structural_analyzer.py @@ -494,3 +494,96 @@ def test_reusable_analyzer(self): assert score1 == 1.0 assert score2 == 1.0 + + +# ============================================================================ +# EM-DASH DETECTION TESTS +# ============================================================================ + +class TestEmDashDetection: + """Test em-dash frequency detection feature.""" + + def test_no_em_dashes(self, analyzer): + """Test with no em-dashes.""" + zone_a = [Token(text="word", pos_tag="NOUN", phonetic="W ER D", is_content_word=True, syllable_count=1)] + zone_b = [Token(text="another", pos_tag="DET", phonetic="AH N AH DH ER", is_content_word=False, syllable_count=3)] + + count, score = analyzer.detect_em_dashes(zone_a, zone_b) + + assert count == 0 + assert score == 0.0 + + def test_one_em_dash(self, analyzer): + """Test with one em-dash.""" + zone_a = [ + Token(text="word", pos_tag="NOUN", phonetic="W ER D", is_content_word=True, syllable_count=1), + Token(text="–", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0), + ] + zone_b = [Token(text="another", pos_tag="DET", phonetic="AH N AH DH ER", is_content_word=False, syllable_count=3)] + + count, score = analyzer.detect_em_dashes(zone_a, zone_b) + + assert count == 1 + assert score == 0.4 + + def test_two_em_dashes(self, analyzer): + """Test with two em-dashes.""" + zone_a = [ + Token(text="word", pos_tag="NOUN", phonetic="W ER D", is_content_word=True, syllable_count=1), + Token(text="–", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0), + ] + zone_b = [ + Token(text="another", pos_tag="DET", phonetic="AH N AH DH ER", is_content_word=False, syllable_count=3), + Token(text="—", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0), + ] + + count, score = analyzer.detect_em_dashes(zone_a, zone_b) + + assert count == 2 + assert score == 0.7 + + def test_three_or_more_em_dashes(self, analyzer): + """Test with three or more em-dashes (high suspicion).""" + zone_a = [ + Token(text="–", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0), + Token(text="—", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0), + ] + zone_b = [ + Token(text="–", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0), + Token(text="another", pos_tag="DET", phonetic="AH N AH DH ER", is_content_word=False, syllable_count=3), + ] + + count, score = analyzer.detect_em_dashes(zone_a, zone_b) + + assert count == 3 + assert score >= 0.79 # Allow for floating point precision + + def test_double_hyphen_as_em_dash(self, analyzer): + """Test that double hyphen -- is counted as em-dash.""" + zone_a = [ + Token(text="word", pos_tag="NOUN", phonetic="W ER D", is_content_word=True, syllable_count=1), + Token(text="--", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0), + ] + zone_b = [] + + count, score = analyzer.detect_em_dashes(zone_a, zone_b) + + assert count == 1 + assert score == 0.4 + + def test_empty_zones_em_dash(self, analyzer): + """Test em-dash detection with empty zones.""" + count, score = analyzer.detect_em_dashes([], []) + + assert count == 0 + assert score == 0.0 + + def test_en_dash_vs_em_dash(self, analyzer): + """Test that both en-dash and em-dash are detected.""" + zone_a = [Token(text="–", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0)] + zone_b = [Token(text="—", pos_tag="PUNCT", phonetic="", is_content_word=False, syllable_count=0)] + + count, score = analyzer.detect_em_dashes(zone_a, zone_b) + + assert count == 2 + assert score == 0.7 diff --git a/tests/test_transition_analyzer.py b/tests/test_transition_analyzer.py new file mode 100644 index 0000000..e51e9be --- /dev/null +++ b/tests/test_transition_analyzer.py @@ -0,0 +1,265 @@ +""" +Tests for TransitionSmoothnessAnalyzer. + +Tests the transition word frequency detection feature from toolkit analysis. +""" + +import pytest +from specHO.scoring.transition_analyzer import ( + TransitionSmoothnessAnalyzer, + quick_transition_analysis +) + + +@pytest.fixture +def analyzer(): + """Create a TransitionSmoothnessAnalyzer instance.""" + return TransitionSmoothnessAnalyzer() + + +class TestTransitionSmoothnessAnalyzer: + """Test suite for TransitionSmoothnessAnalyzer.""" + + def test_initialization(self, analyzer): + """Test analyzer initializes correctly.""" + assert analyzer is not None + assert isinstance(analyzer.SMOOTH_TRANSITIONS, set) + assert 'however' in analyzer.SMOOTH_TRANSITIONS + assert 'moreover' in analyzer.SMOOTH_TRANSITIONS + assert 'furthermore' in analyzer.SMOOTH_TRANSITIONS + + def test_no_transitions(self, analyzer): + """Test with text containing no smooth transitions.""" + text = "The cat sat on the mat. The dog ran in the park." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 0 + assert sents == 2 + assert rate == 0.0 + assert score < 0.3 + + def test_one_transition(self, analyzer): + """Test with one transition word.""" + text = "The study was important. However, results were mixed." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 1 + assert sents == 2 + assert rate == 0.5 + assert 0.3 < score < 0.8 + + def test_multiple_transitions(self, analyzer): + """Test with multiple transition words.""" + text = "First, we examined the data. Moreover, we conducted interviews. Finally, we synthesized the findings." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 3 + assert sents == 3 + assert rate == 1.0 + assert score > 0.8 + + def test_high_transition_rate(self, analyzer): + """Test with high transition rate (AI-typical).""" + text = """ + However, the results were surprising. Moreover, the data supported this. + Furthermore, additional studies confirmed it. Nevertheless, questions remained. + """ + count, sents, rate, score = analyzer.analyze_text(text) + + assert count >= 4 + assert rate > 0.25 + assert score > 0.6 + + def test_comma_separated_transition(self, analyzer): + """Test detection of transitions after commas.""" + text = "The experiment succeeded, however, with some caveats." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 1 + assert sents == 1 + assert rate == 1.0 + + def test_case_insensitive(self, analyzer): + """Test that detection is case-insensitive.""" + text = "HOWEVER, the results differed. Moreover, this was unexpected." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 2 + + def test_empty_text(self, analyzer): + """Test with empty text.""" + count, sents, rate, score = analyzer.analyze_text("") + + assert count == 0 + assert sents == 0 + assert rate == 0.0 + assert score == 0.0 + + def test_whitespace_only(self, analyzer): + """Test with whitespace-only text.""" + count, sents, rate, score = analyzer.analyze_text(" \n\n ") + + assert count == 0 + assert sents == 0 + assert rate == 0.0 + assert score == 0.0 + + def test_rate_to_score_thresholds(self, analyzer): + """Test rate to score mapping thresholds.""" + # Low rate (human-typical) + score_low = analyzer._rate_to_score(0.1) + assert score_low < 0.3 + + # Moderate rate + score_mid = analyzer._rate_to_score(0.2) + assert 0.3 <= score_mid <= 0.6 + + # High rate (AI-typical) + score_high = analyzer._rate_to_score(0.3) + assert score_high > 0.6 + + def test_get_transitions_in_text(self, analyzer): + """Test getting list of transitions found.""" + text = "However, the study succeeded. Moreover, results were clear." + transitions = analyzer.get_transitions_in_text(text) + + assert len(transitions) == 2 + assert any('however' in t[0] for t in transitions) + assert any('moreover' in t[0] for t in transitions) + + def test_various_sentence_endings(self, analyzer): + """Test with different sentence ending punctuation.""" + text = "What happened? However, we continued! Moreover, we succeeded." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 2 + assert sents == 3 + + def test_contrast_transitions(self, analyzer): + """Test contrast transition detection.""" + text = "The plan was solid. Nevertheless, issues emerged. Conversely, success followed." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 2 + assert 'nevertheless' in analyzer.SMOOTH_TRANSITIONS + assert 'conversely' in analyzer.SMOOTH_TRANSITIONS + + def test_addition_transitions(self, analyzer): + """Test addition transition detection.""" + text = "The method worked. Additionally, it was efficient. Furthermore, it was scalable." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 2 + assert 'additionally' in analyzer.SMOOTH_TRANSITIONS + assert 'furthermore' in analyzer.SMOOTH_TRANSITIONS + + def test_causal_transitions(self, analyzer): + """Test causal transition detection.""" + text = "The evidence was clear. Therefore, we proceeded. Consequently, results improved." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 2 + assert 'therefore' in analyzer.SMOOTH_TRANSITIONS + assert 'consequently' in analyzer.SMOOTH_TRANSITIONS + + def test_multi_word_transitions(self, analyzer): + """Test multi-word transition phrases.""" + text = "The data was analyzed. On the other hand, concerns remained. In addition, validation was needed." + count, sents, rate, score = analyzer.analyze_text(text) + + assert count == 2 + assert 'on the other hand' in analyzer.SMOOTH_TRANSITIONS + assert 'in addition' in analyzer.SMOOTH_TRANSITIONS + + +class TestQuickTransitionAnalysis: + """Test convenience function.""" + + def test_quick_analysis(self): + """Test quick_transition_analysis convenience function.""" + text = "However, the study succeeded. Moreover, results were clear." + score = quick_transition_analysis(text) + + assert score > 0.6 + + +class TestTransitionCoverage: + """Test that comprehensive set of transition words is covered.""" + + def test_contrast_transitions_present(self, analyzer): + """Test contrast transitions are in the set.""" + contrast = ['however', 'nevertheless', 'nonetheless', 'conversely', + 'on the other hand', 'in contrast', 'rather'] + for term in contrast: + assert term in analyzer.SMOOTH_TRANSITIONS + + def test_addition_transitions_present(self, analyzer): + """Test addition transitions are in the set.""" + addition = ['moreover', 'furthermore', 'additionally', 'likewise', + 'similarly', 'in addition', 'also'] + for term in addition: + assert term in analyzer.SMOOTH_TRANSITIONS + + def test_clarification_transitions_present(self, analyzer): + """Test clarification transitions are in the set.""" + clarification = ['to be clear', 'in other words', 'specifically', + 'namely', 'that is', 'in particular'] + for term in clarification: + assert term in analyzer.SMOOTH_TRANSITIONS + + def test_sequential_transitions_present(self, analyzer): + """Test sequential transitions are in the set.""" + sequential = ['first', 'second', 'third', 'next', 'then', + 'finally', 'subsequently', 'in turn'] + for term in sequential: + assert term in analyzer.SMOOTH_TRANSITIONS + + def test_causal_transitions_present(self, analyzer): + """Test causal transitions are in the set.""" + causal = ['therefore', 'thus', 'consequently', 'accordingly', + 'as a result', 'hence', 'for this reason'] + for term in causal: + assert term in analyzer.SMOOTH_TRANSITIONS + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_transition_at_start_of_text(self, analyzer): + """Test transition word at very start of text.""" + text = "However, the experiment succeeded." + count, _, _, _ = analyzer.analyze_text(text) + assert count == 1 + + def test_transition_only_sentence(self, analyzer): + """Test sentence that is only a transition word.""" + text = "The plan worked. However." + count, sents, rate, score = analyzer.analyze_text(text) + assert count == 1 + assert sents == 2 + + def test_very_long_text(self, analyzer): + """Test with longer text to ensure performance.""" + sentences = [] + for i in range(100): + if i % 3 == 0: + sentences.append("However, the process continued.") + else: + sentences.append("The work was important.") + + text = " ".join(sentences) + count, sents, rate, score = analyzer.analyze_text(text) + + assert sents == 100 + assert count > 0 + assert 0.0 <= rate <= 1.0 + assert 0.0 <= score <= 1.0 + + def test_multiple_transitions_same_sentence(self, analyzer): + """Test sentence with multiple transitions (should count once).""" + text = "However, the results were clear; moreover, they were significant." + count, sents, rate, score = analyzer.analyze_text(text) + + # Should only count one transition per sentence + assert count == 1 + assert sents == 1