File size: 7,675 Bytes
70b2a71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""Portable runtime foundation for the NLL deterministic editor."""
from __future__ import annotations

import os
import re
import tempfile
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import spacy


@dataclass
class Config:
    batch: int = int(os.getenv("NLL_BATCH_SIZE", "32"))
    out: str = os.getenv("NLL_OUTPUT_DIR", tempfile.gettempdir())
    stanza: bool = False
    sts: bool = False
    nli: bool = False
    mlm: bool = False
    lt: bool = os.getenv("NLL_ENABLE_LANGUAGETOOL", "false").lower() == "true"
    online: bool = False
    min_sts: float = 0.72
    min_nli: float = 0.45

CFG = Config()
STATE: dict[str, Any] = {"warnings": [], "start": None}
DISCLAIMER = "The Writing Pattern Score describes observable structural and stylistic characteristics only."


def _load_spacy():
    try:
        return spacy.load("en_core_web_sm")
    except Exception as exc:
        if os.getenv("NLL_REQUIRE_SPACY_MODEL", "true").lower() == "true":
            raise RuntimeError("spaCy model en_core_web_sm is required for deployment.") from exc
        pipe = spacy.blank("en")
        if "sentencizer" not in pipe.pipe_names:
            pipe.add_pipe("sentencizer")
        return pipe

_NLP = _load_spacy()

def nlp():
    return _NLP


def sent_split(text):
    value = str(text or "").strip()
    if not value:
        return []
    try:
        doc = _NLP(value)
        parts = [s.text.strip() for s in doc.sents if s.text.strip()]
        if parts:
            return parts
    except Exception:
        pass
    return [x.strip() for x in re.split(r"(?<=[.!?])\s+", value) if x.strip()]


def sub(token):
    try:
        items = list(token.subtree)
        if not items:
            return token.text
        start = min(x.i for x in items)
        end = max(x.i for x in items) + 1
        return token.doc[start:end].text
    except Exception:
        return getattr(token, "text", str(token))


@dataclass
class Candidate:
    text: str
    lane: str
    family: str
    reason: str
    priority: float = .5
    score: float = 0.0
    checks: dict = field(default_factory=dict)

@dataclass
class Analysis:
    text: str
    families: list[str] = field(default_factory=list)
    enumerations: list[list[str]] = field(default_factory=list)
    notes: list[str] = field(default_factory=list)
    metadata: dict = field(default_factory=dict)

@dataclass
class Profile:
    name: str
    keywords: set[str] = field(default_factory=set)
    phrases: set[str] = field(default_factory=set)
    immutable: set[str] = field(default_factory=set)

@dataclass
class Finding:
    sentence: int
    category: str
    text: str
    score: float = 0.0
    message: str = ""

@dataclass
class SentenceResult:
    original: str
    revised: str
    status: str
    families: list[str]
    family: str | None
    reason: str
    alternatives: list[str]
    checks: dict
    diagnostics: dict

    @property
    def trace(self):
        return self.diagnostics
    @trace.setter
    def trace(self, value):
        self.diagnostics = value

@dataclass
class DocumentResult:
    original: str
    revised: str
    workflow: str
    mode: str
    discipline: str
    evidence: list[str]
    pattern_score: float
    band: str
    findings: list
    sentences: list[SentenceResult]
    warnings: list[str]
    stats: dict


PROFILES = {
    "General Academic": Profile(
        "General Academic",
        {"study", "research", "analysis", "method", "results", "framework"},
        {"this study", "the results"},
        set(),
    ),
    "Technical": Profile(
        "Technical",
        {"system", "service", "handler", "configuration", "deployment", "model"},
        {"environment variable", "source code"},
        set(),
    ),
    "Business": Profile(
        "Business",
        {"supplier", "schedule", "invoice", "budget", "manager", "procurement"},
        {"implementation schedule", "cost ledger"},
        set(),
    ),
    "Contract": Profile(
        "Contract",
        {"contractor", "shall", "owner", "payment", "reimbursement", "eligible"},
        {"for purposes of", "approved work"},
        set(),
    ),
}


def _extract_enumerations(text):
    groups = []
    for match in re.finditer(
        r"(?is)([^.;:]{1,120}?,\s*[^.;:]{1,120}?(?:,\s*[^.;:]{1,120}?)*,?\s+(?:and|or)\s+[^.;:]{1,120}?)(?=[.;:]|$)",
        text,
    ):
        raw = match.group(1)
        parts = [x.strip(" ,") for x in re.split(r",\s*|\s+(?:and|or)\s+", raw) if x.strip(" ,")]
        if len(parts) >= 3:
            groups.append(parts)
    return groups


class Analyzer:
    def analyze(self, sentence, profile, user_terms):
        text = str(sentence or "").strip()
        families = set()
        enumerations = _extract_enumerations(text)
        low = text.lower()
        if enumerations:
            families.update({"ENUMERATION", "COORDINATED_OBJECTS_OR_ENUMERATION"})
        if re.match(r"^(?:Applying|Using|Evaluating|Comparing|Assessing|Examining|Analyzing|Implementing|Testing|Measuring)\b", text, re.I):
            families.add("GERUND_SUBJECT")
        if re.search(r"\b(?:is|are|was|were|has been|have been|had been)\s+\w+(?:ed|en)\b", low):
            families.add("PASSIVE")
        try:
            doc = _NLP(text)
            dep = doc.has_annotation("DEP")
        except Exception:
            dep = False
        return Analysis(
            text=text,
            families=sorted(families),
            enumerations=enumerations,
            metadata={"dependency_available": dep, "base_analyzer": "portable"},
        )


class Scanner:
    def scan(self, text):
        sentences = sent_split(text)
        findings = []
        for idx, sentence in enumerate(sentences):
            words = sentence.split()
            commas = sentence.count(",")
            if len(words) >= 32 or commas >= 4:
                findings.append(Finding(idx, "sentence complexity", sentence, .6, "Long or list-dense sentence may be complex."))
        score = round(min(100.0, sum((len(s.split()) / 35.0) + (s.count(",") * .45) for s in sentences)), 2)
        band = "Low" if score < 20 else "Moderate" if score < 50 else "High"
        features = {"sentences": len(sentences), "long_sentences": sum(len(s.split()) >= 32 for s in sentences)}
        return findings, score, band, features


_RESOURCES: dict[str, Any] = {}

def optional(name):
    if name in {"sts", "nli", "mlm"}:
        return None
    if name == "lt":
        if not CFG.lt:
            return None
        if "lt" not in _RESOURCES:
            try:
                import language_tool_python
                _RESOURCES["lt"] = language_tool_python.LanguageTool("en-US")
            except Exception as exc:
                STATE.setdefault("warnings", []).append(f"LanguageTool unavailable: {exc}")
                _RESOURCES["lt"] = None
        return _RESOURCES["lt"]
    return None


def protect(text, hard_terms):
    value = str(text or "")
    found = []
    patterns = {
        "url": r"https?://[^\s)\]]+",
        "citation": r"\b[A-Z][A-Za-z-]+\s+et\s+al\.\s*\(\d{4}[a-z]?\)|\([A-Z][A-Za-z-]+(?:\s+et\s+al\.)?,?\s*\d{4}[a-z]?\)",
        "quantity": r"(?<!\w)\d+(?:\.\d+)?%?(?!\w)",
        "equation": r"\b[A-Za-z]\s*=\s*[^,.;]+",
        "code": r"`[^`]+`",
    }
    for kind, pattern in patterns.items():
        for match in re.finditer(pattern, value):
            found.append((kind, match.group(0)))
    for term in set(hard_terms or set()):
        if term and re.search(r"(?<!\w)" + re.escape(term) + r"(?!\w)", value, re.I):
            found.append(("term", term))
    return found