πŸ”§ v4.2: Critical bug fixes + performance optimizations (7 bugs, 4 perf improvements)

#3
by gaurv007 - opened
Files changed (6) hide show
  1. README.md +13 -2
  2. api/main.py +18 -11
  3. compare.py +1 -1
  4. compliance.py +7 -4
  5. extension/background.js +12 -5
  6. obligations.py +23 -8
README.md CHANGED
@@ -10,11 +10,22 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- # πŸ›‘οΈ ClauseGuard v4.0 β€” World's Best Open-Source Legal Contract Analysis
14
 
15
  **ClauseGuard** is the most comprehensive open-source AI-powered legal contract analysis tool. It analyzes contracts using state-of-the-art legal NLP models and provides actionable risk assessments, Q&A chatbot, clause redlining, and OCR for scanned PDFs.
16
 
17
- ## πŸ†• What's New in v4.0
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  | Feature | Description |
20
  |---------|-------------|
 
10
  pinned: false
11
  ---
12
 
13
+ # πŸ›‘οΈ ClauseGuard v4.2 β€” World's Best Open-Source Legal Contract Analysis
14
 
15
  **ClauseGuard** is the most comprehensive open-source AI-powered legal contract analysis tool. It analyzes contracts using state-of-the-art legal NLP models and provides actionable risk assessments, Q&A chatbot, clause redlining, and OCR for scanned PDFs.
16
 
17
+ ## πŸ†• What's New in v4.2
18
+
19
+ | Feature | Description |
20
+ |---------|-------------|
21
+ | **πŸ”§ NLI Fix** | Fixed contradiction detection β€” now uses `CrossEncoder.predict()` instead of broken `pipeline("text-classification")` dict input. Contradictions actually work now. |
22
+ | **πŸ”’ Thread Safety** | `BoundedCache` now uses `threading.RLock` to prevent race conditions under concurrent Gradio requests |
23
+ | **⚑ Pre-compiled Regex** | All regex patterns (clause classification, obligations, compliance negation) pre-compiled at module level β€” eliminates thousands of redundant compilations |
24
+ | **πŸ”— Extension Fix** | Chrome extension risk formula now matches backend (diminishing returns, not normalized by doc length). Fixed API_BASE URL. |
25
+ | **🏷️ Label Coverage** | Added missing regex-only labels (Indemnification, Confidentiality, Force Majeure, Penalties) to RISK_MAP and DESC_MAP |
26
+ | **πŸ›‘οΈ Security** | API CORS localhost origins now require explicit opt-in via `CORS_ALLOW_LOCALHOST=true` env var |
27
+
28
+ ### Previous: v4.0
29
 
30
  | Feature | Description |
31
  |---------|-------------|
api/main.py CHANGED
@@ -58,8 +58,9 @@ HF_API_TOKEN = os.environ.get("HF_API_TOKEN", "")
58
  SAULLM_ENDPOINT = os.environ.get("SAULLM_ENDPOINT", "")
59
  MAX_TEXT_LENGTH = int(os.environ.get("MAX_TEXT_LENGTH", "200000"))
60
 
61
- # ─── FIX v4.1: Sliding window rate limiter with proper IP extraction ───
62
  _rate_limits: dict[str, list[float]] = {}
 
63
  RATE_LIMIT_REQUESTS = 30
64
  RATE_LIMIT_WINDOW = 60 # seconds
65
 
@@ -71,8 +72,17 @@ def _get_client_ip(request: Request) -> str:
71
  return request.client.host if request.client else "unknown"
72
 
73
  def _check_rate_limit(client_ip: str) -> bool:
74
- """Sliding window rate limiter."""
 
75
  now = time.time()
 
 
 
 
 
 
 
 
76
  if client_ip not in _rate_limits:
77
  _rate_limits[client_ip] = []
78
 
@@ -85,13 +95,6 @@ def _check_rate_limit(client_ip: str) -> bool:
85
  return False
86
 
87
  _rate_limits[client_ip].append(now)
88
-
89
- # Periodic cleanup of stale IPs (every 100 requests)
90
- if len(_rate_limits) > 1000:
91
- stale = [ip for ip, ts in _rate_limits.items() if not ts or now - ts[-1] > RATE_LIMIT_WINDOW * 2]
92
- for ip in stale:
93
- del _rate_limits[ip]
94
-
95
  return True
96
 
97
  # ─── Supabase helper ───
@@ -193,11 +196,15 @@ async def lifespan(app: FastAPI):
193
 
194
  app = FastAPI(title="ClauseGuard API", version="4.1.0", lifespan=lifespan)
195
 
 
 
196
  ALLOWED_ORIGINS = [
197
  "https://clauseguardweb.netlify.app",
198
- "http://localhost:3000",
199
- "http://localhost:3001",
200
  ]
 
 
 
 
201
  app.add_middleware(
202
  CORSMiddleware,
203
  allow_origins=ALLOWED_ORIGINS,
 
58
  SAULLM_ENDPOINT = os.environ.get("SAULLM_ENDPOINT", "")
59
  MAX_TEXT_LENGTH = int(os.environ.get("MAX_TEXT_LENGTH", "200000"))
60
 
61
+ # ─── FIX v4.2: Improved sliding window rate limiter with periodic cleanup ───
62
  _rate_limits: dict[str, list[float]] = {}
63
+ _rate_limits_last_cleanup: float = 0.0
64
  RATE_LIMIT_REQUESTS = 30
65
  RATE_LIMIT_WINDOW = 60 # seconds
66
 
 
72
  return request.client.host if request.client else "unknown"
73
 
74
  def _check_rate_limit(client_ip: str) -> bool:
75
+ """Sliding window rate limiter with periodic stale-IP cleanup."""
76
+ global _rate_limits_last_cleanup
77
  now = time.time()
78
+
79
+ # FIX v4.2: Periodic cleanup every 60s regardless of dict size
80
+ if now - _rate_limits_last_cleanup > 60:
81
+ stale = [ip for ip, ts in _rate_limits.items() if not ts or now - ts[-1] > RATE_LIMIT_WINDOW * 2]
82
+ for ip in stale:
83
+ del _rate_limits[ip]
84
+ _rate_limits_last_cleanup = now
85
+
86
  if client_ip not in _rate_limits:
87
  _rate_limits[client_ip] = []
88
 
 
95
  return False
96
 
97
  _rate_limits[client_ip].append(now)
 
 
 
 
 
 
 
98
  return True
99
 
100
  # ─── Supabase helper ───
 
196
 
197
  app = FastAPI(title="ClauseGuard API", version="4.1.0", lifespan=lifespan)
198
 
199
+ # FIX v4.2: CORS origins configurable via env var; localhost only in dev
200
+ _extra_origins = os.environ.get("CORS_EXTRA_ORIGINS", "").split(",")
201
  ALLOWED_ORIGINS = [
202
  "https://clauseguardweb.netlify.app",
 
 
203
  ]
204
+ # Only add localhost origins if explicitly enabled via env
205
+ if os.environ.get("CORS_ALLOW_LOCALHOST", "").lower() == "true":
206
+ ALLOWED_ORIGINS.extend(["http://localhost:3000", "http://localhost:3001"])
207
+ ALLOWED_ORIGINS.extend([o.strip() for o in _extra_origins if o.strip()])
208
  app.add_middleware(
209
  CORSMiddleware,
210
  allow_origins=ALLOWED_ORIGINS,
compare.py CHANGED
@@ -28,7 +28,7 @@ def _load_embedder():
28
  global _embedder
29
  if _HAS_EMBEDDINGS and _embedder is None:
30
  try:
31
- _embedder = SentenceTransformer("all-MiniLM-L6-v2")
32
  print("[ClauseGuard] Sentence embeddings loaded for comparison")
33
  except Exception as e:
34
  print(f"[ClauseGuard] Embeddings not available: {e}")
 
28
  global _embedder
29
  if _HAS_EMBEDDINGS and _embedder is None:
30
  try:
31
+ _embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
32
  print("[ClauseGuard] Sentence embeddings loaded for comparison")
33
  except Exception as e:
34
  print(f"[ClauseGuard] Embeddings not available: {e}")
compliance.py CHANGED
@@ -23,6 +23,9 @@ _NEGATION_PATTERNS = [
23
  r"notwithstanding.*(?:shall\s+not|does\s+not|is\s+not)",
24
  ]
25
 
 
 
 
26
  # Regulatory requirement definitions
27
  REGULATIONS = {
28
  "GDPR": {
@@ -214,13 +217,13 @@ def _check_negation(text_lower, keyword, window=200):
214
  wider_context = text_lower[start:end]
215
 
216
  # Check sentence first (higher confidence)
217
- for neg_pat in _NEGATION_PATTERNS:
218
- if re.search(neg_pat, sentence, re.IGNORECASE):
219
  return True
220
 
221
  # Then check wider window (lower confidence, still relevant)
222
- for neg_pat in _NEGATION_PATTERNS[:4]: # Only strong negation patterns for wider window
223
- if re.search(neg_pat, wider_context, re.IGNORECASE):
224
  return True
225
 
226
  return False
 
23
  r"notwithstanding.*(?:shall\s+not|does\s+not|is\s+not)",
24
  ]
25
 
26
+ # FIX v4.2: Pre-compile negation patterns at module level
27
+ _NEGATION_PATTERNS_COMPILED = [re.compile(p, re.IGNORECASE) for p in _NEGATION_PATTERNS]
28
+
29
  # Regulatory requirement definitions
30
  REGULATIONS = {
31
  "GDPR": {
 
217
  wider_context = text_lower[start:end]
218
 
219
  # Check sentence first (higher confidence)
220
+ for neg_pat in _NEGATION_PATTERNS_COMPILED:
221
+ if neg_pat.search(sentence):
222
  return True
223
 
224
  # Then check wider window (lower confidence, still relevant)
225
+ for neg_pat in _NEGATION_PATTERNS_COMPILED[:4]: # Only strong negation patterns for wider window
226
+ if neg_pat.search(wider_context):
227
  return True
228
 
229
  return False
extension/background.js CHANGED
@@ -4,7 +4,8 @@
4
  * FIXED: Error handling and retry logic
5
  */
6
 
7
- const API_BASE = "https://gaurv007-clauseguard-api.hf.space";
 
8
  const FREE_SCANS_PER_MONTH = 10;
9
  const API_TIMEOUT_MS = 45000;
10
 
@@ -181,13 +182,19 @@ function localAnalyze(text) {
181
  });
182
 
183
  const flagged = results.filter(r => r.categories.length > 0);
184
- const sev = { HIGH: 0, MEDIUM: 0, LOW: 0 };
185
- flagged.forEach(r => r.categories.forEach(c => sev[c.severity]++));
186
- const risk = Math.min(100, Math.round((sev.HIGH*20 + sev.MEDIUM*10 + sev.LOW*5) / Math.max(1, clauses.length) * 100));
 
 
 
 
 
 
187
 
188
  return {
189
  risk_score: risk,
190
- grade: risk >= 60 ? "F" : risk >= 40 ? "D" : risk >= 20 ? "C" : risk >= 10 ? "B" : "A",
191
  total_clauses: clauses.length, flagged_count: flagged.length, results,
192
  };
193
  }
 
4
  * FIXED: Error handling and retry logic
5
  */
6
 
7
+ // FIX v4.2: Corrected API_BASE URL to match the actual Gradio Space
8
+ const API_BASE = "https://gaurv007-clauseguard.hf.space";
9
  const FREE_SCANS_PER_MONTH = 10;
10
  const API_TIMEOUT_MS = 45000;
11
 
 
182
  });
183
 
184
  const flagged = results.filter(r => r.categories.length > 0);
185
+ const sev = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
186
+ flagged.forEach(r => r.categories.forEach(c => {
187
+ if (sev.hasOwnProperty(c.severity)) sev[c.severity]++;
188
+ else sev.MEDIUM++; // default for unknown severity
189
+ }));
190
+ // FIX v4.2: Use the same diminishing-returns formula as the backend (app.py)
191
+ // instead of normalizing by clause count (which gave different scores)
192
+ const weighted = sev.CRITICAL*40 + sev.HIGH*20 + sev.MEDIUM*10 + sev.LOW*3;
193
+ const risk = Math.min(100, Math.round(100 * (1 - (1 / (1 + weighted / 30)))));
194
 
195
  return {
196
  risk_score: risk,
197
+ grade: risk >= 70 ? "F" : risk >= 50 ? "D" : risk >= 30 ? "C" : risk >= 15 ? "B" : "A",
198
  total_clauses: clauses.length, flagged_count: flagged.length, results,
199
  };
200
  }
obligations.py CHANGED
@@ -85,11 +85,26 @@ _PRIORITY_MAP = {
85
  "delivery": 1,
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  def _is_false_positive(sentence):
90
  """Check if a sentence is a common false positive (definition/interpretation, not obligation)."""
91
- for fp in _FALSE_POSITIVE_PATTERNS:
92
- if re.search(fp, sentence, re.IGNORECASE):
93
  return True
94
  return False
95
 
@@ -111,9 +126,9 @@ def extract_obligations(text):
111
  continue
112
 
113
  found_types = set()
114
- for otype, patterns in OBLIGATION_PATTERNS.items():
115
  for pat in patterns:
116
- if re.search(pat, sentence, re.IGNORECASE):
117
  found_types.add(otype)
118
  break
119
 
@@ -128,8 +143,8 @@ def extract_obligations(text):
128
  party = obligation_direction
129
  else:
130
  # Fallback to pattern matching within the sentence
131
- for pp in PARTY_PATTERNS:
132
- m = re.search(pp, sentence)
133
  if m:
134
  candidate = m.group(0).strip()
135
  # Fix 8: Reject party strings >40 chars (header bleed-through)
@@ -140,8 +155,8 @@ def extract_obligations(text):
140
  # Extract timeframe
141
  deadline = "Not specified"
142
  deadline_urgency = 0
143
- for pat, ptype in TIME_PATTERNS:
144
- m = re.search(pat, sentence, re.IGNORECASE)
145
  if m:
146
  if ptype == "relative":
147
  num = m.group(1)
 
85
  "delivery": 1,
86
  }
87
 
88
+ # FIX v4.2: Pre-compile obligation patterns at module level (was recompiling per sentence)
89
+ _OBLIGATION_PATTERNS_COMPILED = {
90
+ otype: [re.compile(p, re.IGNORECASE) for p in patterns]
91
+ for otype, patterns in OBLIGATION_PATTERNS.items()
92
+ }
93
+
94
+ # FIX v4.2: Pre-compile false positive patterns
95
+ _FALSE_POSITIVE_PATTERNS_COMPILED = [re.compile(p, re.IGNORECASE) for p in _FALSE_POSITIVE_PATTERNS]
96
+
97
+ # FIX v4.2: Pre-compile time patterns
98
+ _TIME_PATTERNS_COMPILED = [(re.compile(p, re.IGNORECASE), ptype) for p, ptype in TIME_PATTERNS]
99
+
100
+ # FIX v4.2: Pre-compile party patterns
101
+ _PARTY_PATTERNS_COMPILED = [re.compile(p) for p in PARTY_PATTERNS]
102
+
103
 
104
  def _is_false_positive(sentence):
105
  """Check if a sentence is a common false positive (definition/interpretation, not obligation)."""
106
+ for fp in _FALSE_POSITIVE_PATTERNS_COMPILED:
107
+ if fp.search(sentence):
108
  return True
109
  return False
110
 
 
126
  continue
127
 
128
  found_types = set()
129
+ for otype, patterns in _OBLIGATION_PATTERNS_COMPILED.items():
130
  for pat in patterns:
131
+ if pat.search(sentence):
132
  found_types.add(otype)
133
  break
134
 
 
143
  party = obligation_direction
144
  else:
145
  # Fallback to pattern matching within the sentence
146
+ for pp in _PARTY_PATTERNS_COMPILED:
147
+ m = pp.search(sentence)
148
  if m:
149
  candidate = m.group(0).strip()
150
  # Fix 8: Reject party strings >40 chars (header bleed-through)
 
155
  # Extract timeframe
156
  deadline = "Not specified"
157
  deadline_urgency = 0
158
+ for pat, ptype in _TIME_PATTERNS_COMPILED:
159
+ m = pat.search(sentence)
160
  if m:
161
  if ptype == "relative":
162
  num = m.group(1)