Spaces:
Running
Running
fix(v4.3): app.py — bug report fixes (10 issues)
Browse files
app.py
CHANGED
|
@@ -617,12 +617,51 @@ _LABEL_GUARDRAILS = {
|
|
| 617 |
r'uncapped|unlimited.{0,10}liabilit|no.{0,10}(limit|cap).{0,10}liabilit',
|
| 618 |
re.IGNORECASE
|
| 619 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
}
|
| 621 |
|
| 622 |
def _apply_guardrails(label, text, confidence):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
guard = _LABEL_GUARDRAILS.get(label)
|
| 624 |
if guard and not guard.search(text):
|
| 625 |
return "Other", confidence * 0.3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
return label, confidence
|
| 627 |
|
| 628 |
def _text_hash(text):
|
|
@@ -951,6 +990,47 @@ def extract_entities(text):
|
|
| 951 |
else:
|
| 952 |
entities = _extract_entities_regex(text)
|
| 953 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
# Always supplement with regex patterns for things NER often misses
|
| 955 |
regex_ents = _extract_entities_regex(text)
|
| 956 |
ml_spans = set()
|
|
@@ -1176,14 +1256,26 @@ def compute_risk_score(clause_results, total_clauses):
|
|
| 1176 |
if total_clauses == 0:
|
| 1177 |
return 0, "A", sev_counts
|
| 1178 |
|
| 1179 |
-
# FIX v4.
|
| 1180 |
-
#
|
| 1181 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
weighted = sum(sev_counts[s] * RISK_WEIGHTS[s] for s in sev_counts)
|
| 1183 |
|
| 1184 |
-
#
|
| 1185 |
-
|
| 1186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1187 |
|
| 1188 |
if risk >= 70: grade = "F"
|
| 1189 |
elif risk >= 50: grade = "D"
|
|
|
|
| 617 |
r'uncapped|unlimited.{0,10}liabilit|no.{0,10}(limit|cap).{0,10}liabilit',
|
| 618 |
re.IGNORECASE
|
| 619 |
),
|
| 620 |
+
# FIX v4.3: ROFR fires on "right, title, and interest" in IP clauses — require ROFR-specific phrases
|
| 621 |
+
"ROFR/ROFO/ROFN": re.compile(
|
| 622 |
+
r'right\s+of\s+first\s+(?:refusal|offer|negotiation)|ROFR|ROFO|ROFN',
|
| 623 |
+
re.IGNORECASE
|
| 624 |
+
),
|
| 625 |
+
# FIX v4.3: Renewal Term fires on "twelve (12) months" in liability caps — require renewal-specific phrases
|
| 626 |
+
"Renewal Term": re.compile(
|
| 627 |
+
r'renew(?:al)?|successive\s+term|auto(?:matic(?:ally)?)?\s*[\-\s]?renew|non[\-\s]?renewal',
|
| 628 |
+
re.IGNORECASE
|
| 629 |
+
),
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
# FIX v4.3: Exclusion patterns — even if guardrail passes, exclude if contra-indicators present
|
| 633 |
+
_LABEL_EXCLUSIONS = {
|
| 634 |
+
"ROFR/ROFO/ROFN": re.compile(
|
| 635 |
+
r'assigns?\s+to|irrevocab(?:ly|le)\s+assign|all\s+right,?\s+title,?\s+and\s+interest|work[\-\s]for[\-\s]hire',
|
| 636 |
+
re.IGNORECASE
|
| 637 |
+
),
|
| 638 |
+
"Renewal Term": re.compile(
|
| 639 |
+
r'limitation\s+of\s+liabilit|shall\s+not\s+be\s+liable|indemnif|hold\s+harmless|defend\s+and',
|
| 640 |
+
re.IGNORECASE
|
| 641 |
+
),
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
# FIX v4.3: Minimum confidence thresholds per label (overrides the per-class _CUAD_THRESHOLDS)
|
| 645 |
+
_LABEL_MIN_CONFIDENCE = {
|
| 646 |
+
"ROFR/ROFO/ROFN": 0.65,
|
| 647 |
+
"Renewal Term": 0.70,
|
| 648 |
}
|
| 649 |
|
| 650 |
def _apply_guardrails(label, text, confidence):
|
| 651 |
+
# Check minimum confidence for specific labels
|
| 652 |
+
min_conf = _LABEL_MIN_CONFIDENCE.get(label)
|
| 653 |
+
if min_conf and confidence < min_conf:
|
| 654 |
+
return "Other", confidence * 0.2
|
| 655 |
+
|
| 656 |
+
# Check required keywords (must be present)
|
| 657 |
guard = _LABEL_GUARDRAILS.get(label)
|
| 658 |
if guard and not guard.search(text):
|
| 659 |
return "Other", confidence * 0.3
|
| 660 |
+
|
| 661 |
+
# Check exclusion patterns (must NOT be present)
|
| 662 |
+
exclusion = _LABEL_EXCLUSIONS.get(label)
|
| 663 |
+
if exclusion and exclusion.search(text):
|
| 664 |
+
return "Other", confidence * 0.2
|
| 665 |
return label, confidence
|
| 666 |
|
| 667 |
def _text_hash(text):
|
|
|
|
| 990 |
else:
|
| 991 |
entities = _extract_entities_regex(text)
|
| 992 |
|
| 993 |
+
# FIX v4.3: Post-process ML entities to clean up WordPiece artefacts
|
| 994 |
+
cleaned_entities = []
|
| 995 |
+
for e in entities:
|
| 996 |
+
text_val = e.get("text", "")
|
| 997 |
+
# Strip WordPiece subword tokens (## prefix)
|
| 998 |
+
if "##" in text_val:
|
| 999 |
+
text_val = re.sub(r'##\w*', '', text_val).strip()
|
| 1000 |
+
text_val = re.sub(r'\s+', ' ', text_val).strip()
|
| 1001 |
+
# Discard entities that are too short, start/end with hyphens, or are garbled
|
| 1002 |
+
if len(text_val) < 2:
|
| 1003 |
+
continue
|
| 1004 |
+
if text_val.startswith("-") or text_val.endswith("-"):
|
| 1005 |
+
continue
|
| 1006 |
+
# Discard low-confidence MISC entities (almost always tokenisation artefacts)
|
| 1007 |
+
if e.get("type") == "MISC" and e.get("score", 1.0) < 0.6:
|
| 1008 |
+
continue
|
| 1009 |
+
# Discard entities that are mostly punctuation/symbols
|
| 1010 |
+
alpha_ratio = sum(1 for c in text_val if c.isalnum()) / max(len(text_val), 1)
|
| 1011 |
+
if alpha_ratio < 0.4:
|
| 1012 |
+
continue
|
| 1013 |
+
e["text"] = text_val
|
| 1014 |
+
cleaned_entities.append(e)
|
| 1015 |
+
entities = cleaned_entities
|
| 1016 |
+
|
| 1017 |
+
# FIX v4.3: Split concatenated MONEY/QUANTITY entities
|
| 1018 |
+
# e.g., "usd $ 485, 000,usd $ 72, 000" → separate entities
|
| 1019 |
+
_CURRENCY_SPLIT = re.compile(r'(?<=[\d,])\s*(?=(?:USD|usd|EUR|GBP|\$|£|€))', re.IGNORECASE)
|
| 1020 |
+
split_entities = []
|
| 1021 |
+
for e in entities:
|
| 1022 |
+
if e.get("type") in ("MONEY", "QUANTITY") and _CURRENCY_SPLIT.search(e["text"]):
|
| 1023 |
+
parts = _CURRENCY_SPLIT.split(e["text"])
|
| 1024 |
+
for part in parts:
|
| 1025 |
+
part = part.strip().strip(",").strip()
|
| 1026 |
+
if len(part) >= 2:
|
| 1027 |
+
new_ent = dict(e)
|
| 1028 |
+
new_ent["text"] = re.sub(r'\s+', '', part) if "$" in part or "USD" in part.upper() else part
|
| 1029 |
+
split_entities.append(new_ent)
|
| 1030 |
+
else:
|
| 1031 |
+
split_entities.append(e)
|
| 1032 |
+
entities = split_entities
|
| 1033 |
+
|
| 1034 |
# Always supplement with regex patterns for things NER often misses
|
| 1035 |
regex_ents = _extract_entities_regex(text)
|
| 1036 |
ml_spans = set()
|
|
|
|
| 1256 |
if total_clauses == 0:
|
| 1257 |
return 0, "A", sev_counts
|
| 1258 |
|
| 1259 |
+
# FIX v4.3: Revised risk formula — scale denominator with clause count
|
| 1260 |
+
# to prevent small contracts from always scoring 80+.
|
| 1261 |
+
# The old formula used a fixed /30 denominator which meant even 2 CRITICAL
|
| 1262 |
+
# flags scored 73, making almost every contract grade F.
|
| 1263 |
+
#
|
| 1264 |
+
# New approach: dynamic denominator based on total clauses analysed.
|
| 1265 |
+
# This means risk is relative to document complexity.
|
| 1266 |
+
# - 1 CRITICAL in 5 clauses = high risk
|
| 1267 |
+
# - 1 CRITICAL in 50 clauses = moderate risk (proportionally less of the contract)
|
| 1268 |
weighted = sum(sev_counts[s] * RISK_WEIGHTS[s] for s in sev_counts)
|
| 1269 |
|
| 1270 |
+
# Dynamic max: what if every clause were CRITICAL?
|
| 1271 |
+
max_possible = total_clauses * RISK_WEIGHTS["CRITICAL"]
|
| 1272 |
+
if max_possible == 0:
|
| 1273 |
+
max_possible = 1
|
| 1274 |
+
|
| 1275 |
+
# Blend: 60% absolute (diminishing returns) + 40% relative (to total clauses)
|
| 1276 |
+
absolute_risk = 100 * (1 - (1 / (1 + weighted / 50))) # /50 instead of /30 = softer curve
|
| 1277 |
+
relative_risk = min(100, (weighted / max_possible) * 100)
|
| 1278 |
+
risk = min(100, round(0.6 * absolute_risk + 0.4 * relative_risk))
|
| 1279 |
|
| 1280 |
if risk >= 70: grade = "F"
|
| 1281 |
elif risk >= 50: grade = "D"
|