"""Sentence type classification — spaCy-driven when available.""" from __future__ import annotations import re from functools import lru_cache from app.pipeline.nlp import get_nlp _REWRITEABLE = frozenset( {"simple_declarative", "compound", "because_clause"} ) _CITATION = re.compile( r"(?:\[[0-9]+(?:\s*[-,]\s*[0-9]+)*\]" r"|\([A-Z][A-Za-z'-]+(?:\s+et\s+al\.)?,?\s+(?:19|20)\d{2}[a-z]?\)" r"|\bdoi:\s*10\.\d{4,9}/\S+)", re.I, ) @lru_cache(maxsize=4096) def classify_sentence(text: str) -> str: """Tag sentence type for rewrite eligibility.""" t = (text or "").strip() if not t: return "empty" if re.match(r"^#{1,6}\s", t) or (len(t.split()) <= 6 and t.isupper()): return "heading" if re.match(r"^(\d+[\.\)]\s+|[-*•]\s+)", t): return "list_item" if t.startswith(('"', "'", "\u201c", "\u2018")) and ( t.count('"') >= 2 or t.count("\u201c") or t.count("'") >= 2 ): return "quoted" if _CITATION.search(t): return "citation" if "?" in t or t.endswith("?"): return "question" if len(t.split()) < 3: return "too_short" # Allow longer prose through paraphrase/lexical; only extreme length is skipped. if len(t.split()) > 70: return "too_long" nlp = get_nlp() if nlp is not None: return _classify_spacy(t, nlp) return _classify_regex(t) def _classify_spacy(text: str, nlp) -> str: doc = nlp(text) # because as mark / SCONJ for t in doc: if t.lemma_.lower() == "because" and t.pos_ in {"SCONJ", "ADP"}: return "because_clause" # Subordinate clauses via mark / advcl / SCONJ for t in doc: if t.dep_ == "mark" and t.head.dep_ in {"advcl", "acl"}: if t.lemma_.lower() != "because": return "complex" if t.pos_ == "SCONJ" and t.lemma_.lower() != "because": if t.head.dep_ in {"advcl", "acl", "ROOT"} or t.dep_ == "mark": return "complex" # Relative clauses on longer sentences are unsafe to slot-rebuild if any(t.dep_ == "relcl" for t in doc) and len(text.split()) >= 10: return "complex" # Coordinating compound with comma if "," in text and any(t.dep_ == "cc" and t.head.dep_ in {"conj", "ROOT"} for t in doc): if len(text.split()) <= 28: return "compound" return "complex" return "simple_declarative" def _classify_regex(text: str) -> str: low = text.lower() if re.search(r"\bbecause\b", low): return "because_clause" if re.search(r"\b(and|but|or|so|yet)\b", low) and "," in text: if re.search( r"\b(although|though|while|whilst|whereas|unless|until|since|if|when|" r"whenever|wherever|whether|before|after)\b", low, ): return "complex" return "compound" if len(text.split()) <= 28 else "complex" if re.search( r"\b(although|though|while|whilst|whereas|unless|until|since|if|when|" r"whenever|wherever|whether|before|after)\b", low, ): return "complex" if re.search(r"\b(who|whom|whose|which|that)\b", low) and len(text.split()) > 12: return "complex" return "simple_declarative" def is_rewriteable_type(sentence_type: str) -> bool: return sentence_type in _REWRITEABLE