Spaces:
Runtime error
Runtime error
| """Clause classification into CUAD-aligned categories. | |
| Two backends behind one entry point (`classify_all`), selected by the | |
| CLASSIFIER env var: | |
| rules transparent keyword/regex classifier — fast, zero ML deps, | |
| easy to debug live | |
| zeroshot cascade: the regexes below act as a high-recall *candidate* | |
| generator, and a local DeBERTa-v3 zero-shot NLI model | |
| (see zeroshot.py) confirms or rejects each (clause, category) | |
| candidate by meaning. Kills the classic keyword false | |
| positives ("audited financial statements" -> audit_rights) | |
| without paying full O(clauses x categories) inference. | |
| finetuned a DeBERTa-v3 classifier fine-tuned on CUAD (see finetuned.py / | |
| scripts/train_classifier.py) — direct multi-label prediction, | |
| highest accuracy when trained weights are present. | |
| legalbert/fusion fine-tuned sentence classifier (legalbert_clf.py) over its | |
| 12 CUAD categories, with zero-shot/rules filling the rest. Pick the | |
| shipped model via LEGALBERT_MODEL_DIR (DeBERTa 0.67 / LegalBERT 0.70). | |
| Falls back to the base classifier if no fine-tuned weights present. | |
| auto (default) zeroshot when its deps + cached weights are | |
| present, else rules — mirrors CORE_LLM_BACKEND=auto. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| from .schema import Clause | |
| # category key -> (CUAD label name, [patterns]) | |
| CATEGORY_PATTERNS: dict[str, tuple[str, list[str]]] = { | |
| "termination": ("Termination For Convenience", [ | |
| r"\bterminat(e|ion|ed)\b", r"\bterminate for convenience\b", | |
| ]), | |
| "auto_renewal": ("Renewal Term", [ | |
| r"\bautomatic(ally)? renew", r"\brenewal term\b", r"\bsuccessive (renewal )?(terms?|periods?)\b", | |
| ]), | |
| "term": ("Agreement Date / Expiration Date", [ | |
| r"\binitial term\b", r"\bterm of this agreement\b", r"\bshall (commence|remain in (full )?force)\b", | |
| ]), | |
| "liability_cap": ("Cap On Liability", [ | |
| r"\blimitation of liability\b", r"\bliab(le|ility)\b.*\b(cap|limit|exceed|aggregate)\b", | |
| r"\bin no event\b.*\bliab", r"\bunlimited liability\b", | |
| ]), | |
| "indemnification": ("Indemnification", [ | |
| r"\bindemnif(y|ies|ication)\b", r"\bhold harmless\b", r"\bdefend\b.*\bclaims?\b", | |
| ]), | |
| "governing_law": ("Governing Law", [ | |
| r"\bgoverning law\b", r"\bgoverned by (the )?laws?\b", r"\bjurisdiction\b", | |
| ]), | |
| "confidentiality": ("Confidentiality", [ | |
| r"\bconfidential(ity)?\b", r"\bnon-?disclosure\b", r"\btrade secrets?\b", | |
| ]), | |
| "payment_terms": ("Payment Terms", [ | |
| r"\bpayment\b", r"\binvoice[sd]?\b", r"\bnet\s+\d{1,3}\b", r"\bfees?\b.*\bpayable\b", | |
| r"\blate (payment|fee)\b", r"\binterest\b.*\b(per month|per annum|%)", | |
| ]), | |
| "sla": ("Service Levels", [ | |
| r"\bservice level\b", r"\buptime\b", r"\bavailability\b.*\b\d{2}(\.\d+)?%", | |
| r"\bservice credits?\b", r"\bresponse time\b", | |
| ]), | |
| "data_protection": ("Data Protection / Privacy", [ | |
| r"\bdata protection\b", r"\bpersonal (data|information)\b", r"\bGDPR\b", | |
| r"\bsecurity (breach|incident)\b", r"\bprocess(ing|or)\b.*\bdata\b", | |
| ]), | |
| "ip_ownership": ("IP Ownership Assignment", [ | |
| r"\bintellectual property\b", r"\bownership\b.*\b(work product|deliverables)\b", | |
| r"\bwork[- ]for[- ]hire\b", r"\blicen[cs]e[sd]?\b", | |
| ]), | |
| "insurance": ("Insurance", [ | |
| r"\binsurance\b", r"\bpolicy limits?\b", r"\bcommercial general liability\b", | |
| ]), | |
| "audit_rights": ("Audit Rights", [ | |
| r"\baudit\b", r"\bbooks and records\b", r"\binspect(ion)?\b.*\brecords\b", | |
| ]), | |
| "assignment": ("Anti-Assignment", [ | |
| r"\bassign(ment)?\b.*\b(consent|prior written)\b", r"\bnot (be )?assign(able|ed)?\b", | |
| ]), | |
| "exclusivity": ("Exclusivity", [ | |
| # NOT a bare \bexclusive\b — "exclusive remedy", "exclusive jurisdiction" | |
| # and "exclusive of taxes" are everywhere and are not exclusivity clauses | |
| r"\bexclusivity\b", r"\bexclusively from\b", | |
| r"\bsole(ly)? (supplier|provider|source)\b", | |
| r"\bnon-?compete\b", r"\bshall not\b[^.]{0,80}\bengage any third party\b", | |
| ]), | |
| "force_majeure": ("Force Majeure", [ | |
| r"\bforce majeure\b", r"\bbeyond (its|the) reasonable control\b", | |
| ]), | |
| "warranty": ("Warranty Duration", [ | |
| r"\bwarrant(s|y|ies)\b", r"\bas is\b.*\bwithout warrant", | |
| ]), | |
| "notice": ("Notice Period", [ | |
| r"\bnotices?\b.*\b(writing|written|address)\b", r"\bwritten notice\b", | |
| ]), | |
| "deliverables": ("Deliverables", [ | |
| r"\bdeliverables?\b", r"\bstatement of work\b", r"\bmilestones?\b", | |
| ]), | |
| } | |
| # Extra candidate-only patterns for the zero-shot cascade. Too noisy to be | |
| # rules-mode classifications on their own (bare "exclusive" matches "exclusive | |
| # remedy" everywhere), but as *candidates* the NLI model rejects the noise, so | |
| # here recall is all that matters. | |
| BROAD_PATTERNS: dict[str, list[str]] = { | |
| # Bare \bexclusive\b is too noisy ("exclusive remedy", "exclusive | |
| # jurisdiction", "exclusive of taxes" appear in almost every contract). | |
| # The negative lookahead drops those known false-positive contexts so the | |
| # zeroshot confirm stage sees fewer spurious candidates. | |
| "exclusivity": [r"\bexclusive\b(?!\s*(?:of\b|remedy\b|remedies\b|jurisdiction\b))"], | |
| "assignment": [r"\bassign(s|ed|ment)?\b"], | |
| "termination": [r"\bexpir(e|es|ation)\b", r"\bcancel(lation)?\b"], | |
| "liability_cap": [r"\bconsequential\b", r"\bindirect\b.*\bdamages\b"], | |
| "auto_renewal": [r"\brenew(s|al|ed)?\b", r"\bextend(ed|s)?\b.*\bterm\b"], | |
| } | |
| _COMPILED = { | |
| key: (label, [re.compile(p, re.IGNORECASE) for p in pats]) | |
| for key, (label, pats) in CATEGORY_PATTERNS.items() | |
| } | |
| _COMPILED_BROAD = { | |
| key: [re.compile(p, re.IGNORECASE) for p in pats] | |
| for key, pats in BROAD_PATTERNS.items() | |
| } | |
| MAX_CATEGORIES = 4 | |
| # chars of clause text around the first pattern hit fed to the NLI model as | |
| # the premise (the model truncates at 512 tokens, so feeding the whole clause | |
| # could push the matched language out of the window) | |
| _CTX_BEFORE, _CTX_AFTER = 300, 900 | |
| def classify_clause(clause: Clause) -> list[str]: | |
| """Rules backend: return category keys. Heading matches count double.""" | |
| text = clause.text | |
| heading = (clause.heading or "") | |
| cats: list[tuple[str, int]] = [] | |
| for key, (_label, pats) in _COMPILED.items(): | |
| score = 0 | |
| for p in pats: | |
| if p.search(heading): | |
| score += 2 | |
| if p.search(text): | |
| score += 1 | |
| if score >= 1: | |
| cats.append((key, score)) | |
| cats.sort(key=lambda kv: -kv[1]) | |
| return [k for k, _ in cats[:MAX_CATEGORIES]] | |
| def _candidates(clause: Clause) -> list[tuple[str, str]]: | |
| """(category_key, premise_text) candidates for the zero-shot confirm | |
| stage — every category whose strict or broad patterns hit, uncapped.""" | |
| text = clause.text | |
| heading = (clause.heading or "") | |
| out: list[tuple[str, str]] = [] | |
| for key, (_label, pats) in _COMPILED.items(): | |
| all_pats = pats + _COMPILED_BROAD.get(key, []) | |
| first = None | |
| for p in all_pats: | |
| m = p.search(text) | |
| if m and (first is None or m.start() < first): | |
| first = m.start() | |
| if first is None and not any(p.search(heading) for p in all_pats): | |
| continue | |
| start = max(0, (first or 0) - _CTX_BEFORE) | |
| premise = (heading + "\n" if heading else "") + text[start:(first or 0) + _CTX_AFTER] | |
| out.append((key, premise)) | |
| return out | |
| def classify_all(clauses: list[Clause]) -> str: | |
| """Classify every clause in place; returns the backend name used.""" | |
| mode = os.environ.get("CLASSIFIER", "auto").lower() | |
| if mode == "finetuned": | |
| from . import finetuned | |
| fclf = finetuned.get_classifier() | |
| if fclf is None: | |
| raise RuntimeError( | |
| "CLASSIFIER=finetuned but no trained model was found. Train one " | |
| "with scripts/train_classifier.py (or set FINETUNED_MODEL_DIR), " | |
| "or use CLASSIFIER=zeroshot / rules.") | |
| fclf.classify(clauses) | |
| return "finetuned" | |
| if mode in ("legalbert", "fusion"): | |
| # Fine-tuned sentence classifier (LegalBERT 0.70 / DeBERTa 0.67 via | |
| # LEGALBERT_MODEL_DIR). It owns its 12 CUAD categories; the base | |
| # classifier (zero-shot DeBERTa, else rules) fills the rest, so every | |
| # category is covered and the dynamic baseline runs downstream unchanged. | |
| # Falls back gracefully to the base alone if the fine-tuned weights | |
| # aren't present — so the pipeline always works. | |
| from . import legalbert_clf | |
| lb = legalbert_clf.get_classifier() | |
| base = _zeroshot_or_rules(clauses, "auto") | |
| if lb is None: | |
| return base # no fine-tuned weights yet -> base classifier only | |
| for c in clauses: | |
| ft = legalbert_clf.predict(lb, c) | |
| kept = [k for k in c.categories if k not in legalbert_clf.MODEL_CATS_SET] | |
| c.categories = (sorted(ft) + kept)[:MAX_CATEGORIES] | |
| return f"fusion({base}+finetuned)" | |
| return _zeroshot_or_rules(clauses, mode) | |
| def _zeroshot_or_rules(clauses: list[Clause], mode: str) -> str: | |
| """Zero-shot DeBERTa cascade if available, else the regex classifier.""" | |
| clf = None | |
| if mode in ("zeroshot", "auto"): | |
| from . import zeroshot | |
| clf = zeroshot.get_classifier(allow_download=(mode == "zeroshot")) | |
| if clf is None and mode == "zeroshot": | |
| raise RuntimeError( | |
| "CLASSIFIER=zeroshot but the model could not be loaded — " | |
| "install backend/requirements-ml.txt (torch, transformers, " | |
| "sentencepiece, protobuf) or set CLASSIFIER=rules.") | |
| if clf is None: | |
| for c in clauses: | |
| c.categories = classify_clause(c) | |
| return "rules" | |
| pairs: list[tuple[str, str]] = [] # (premise, key) for the model | |
| index: list[tuple[int, str]] = [] # (clause_idx, key) aligned with pairs | |
| for ci, c in enumerate(clauses): | |
| for key, premise in _candidates(c): | |
| pairs.append((premise, key)) | |
| index.append((ci, key)) | |
| probs = clf.entail_probs(pairs) | |
| from .zeroshot import THRESHOLD | |
| confirmed: dict[int, list[tuple[str, float]]] = {} | |
| for (ci, key), p in zip(index, probs): | |
| if p >= THRESHOLD: | |
| confirmed.setdefault(ci, []).append((key, p)) | |
| for ci, c in enumerate(clauses): | |
| cats = sorted(confirmed.get(ci, []), key=lambda kp: -kp[1]) | |
| c.categories = [k for k, _ in cats[:MAX_CATEGORIES]] | |
| return "zeroshot" | |
| def cuad_label(category_key: str) -> str: | |
| return _COMPILED[category_key][0] if category_key in _COMPILED else category_key | |