Install any skill in seconds. Free to start, no credit card required.
Get Started Free →NLP techniques for legal text analysis, case law mining, and contracts
.claude/skills/brycewang-stanford-legal-nlp-guide/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-08 | ✗→✓ | ▲ Improved | 50% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 26% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 68% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 59% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 61% | 0% |
A skill for applying natural language processing techniques to legal texts. Covers legal document classification, named entity recognition for legal entities, contract clause extraction, case law similarity search, and court opinion summarization using modern NLP tools.
Legal language presents unique NLP challenges:
pythonfrom transformers import AutoTokenizer, AutoModelForSequenceClassification import torch # Legal-BERT: domain-adapted BERT for legal text model_name = "nlpaueb/legal-bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=5 ) # Legal document categories labels = ["contract", "court_opinion", "statute", "regulation", "brief"] def classify_legal_document(text: str, max_length: int = 512) -> dict: """ Classify a legal document into predefined categories. For long documents, use the first 512 tokens (typically the preamble/introduction which contains strong classification signals). """ inputs = tokenizer( text, return_tensors="pt", max_length=max_length, truncation=True, padding=True ) with torch.no_grad(): logits = model(**inputs).logits probs = torch.softmax(logits, dim=-1).squeeze() predicted = labels[probs.argmax().item()] return { "predicted_class": predicted, "confidence": probs.max().item(), "all_scores": {l: p.item() for l, p in zip(labels, probs)}, }
Common topic taxonomies for legal research:
| Category | Examples | |----------|---------| | Constitutional Law | Due process, equal protection, First Amendment | | Criminal Law | Sentencing, evidence, plea bargaining | | Contract Law | Breach, formation, damages | | Tort Law | Negligence, product liability, defamation | | Property Law | Real property, intellectual property, zoning | | Administrative Law | Agency rulemaking, judicial review |
Legal NER extends standard NER with domain-specific entity types:
pythonimport spacy # Load a legal NER model (e.g., trained on the LegalNERo dataset) # or fine-tune spaCy on legal annotations nlp = spacy.load("en_legal_ner") legal_entity_types = { "COURT": "Court or tribunal name", "JUDGE": "Judge or justice name", "PARTY": "Plaintiff, defendant, petitioner, respondent", "STATUTE": "Statute or regulation citation", "CASE_CITATION": "Case name and reporter citation", "DATE": "Dates of decisions, filings, events", "JURISDICTION": "Geographic or subject matter jurisdiction", "PROVISION": "Specific section or clause reference", } def extract_legal_entities(text: str) -> list[dict]: """Extract legal named entities from text.""" doc = nlp(text) entities = [] for ent in doc.ents: entities.append({ "text": ent.text, "label": ent.label_, "start": ent.start_char, "end": ent.end_char, "description": legal_entity_types.get(ent.label_, ""), }) return entities
pythonimport re # US case citation patterns (simplified) CASE_CITE_PATTERN = re.compile( r"(?P<volume>\d+)\s+" r"(?P<reporter>U\.S\.|S\.\s?Ct\.|F\.\s?\d[dthsr]+|" r"F\.\s?Supp\.\s?\d*[dthsr]*)\s+" r"(?P<page>\d+)" r"(?:\s*,\s*(?P<pinpoint>\d+))?" r"(?:\s*\((?P<year>\d{4})\))?" ) def parse_citations(text: str) -> list[dict]: """Extract and parse legal citations from text.""" citations = [] for match in CASE_CITE_PATTERN.finditer(text): citations.append({ "full_match": match.group(), "volume": match.group("volume"), "reporter": match.group("reporter"), "page": match.group("page"), "pinpoint": match.group("pinpoint"), "year": match.group("year"), }) return citations
pythondef segment_contract_clauses(text: str) -> list[dict]: """ Segment a contract into numbered clauses and classify them. Uses section numbering patterns as primary segmentation cues. """ # Split on section/article numbering patterns section_pattern = re.compile( r"\n\s*(?:Section|Article|Clause|\d+\.)\s+\d+[\.\d]*\s*[:\.\-]?\s*", re.IGNORECASE, ) sections = section_pattern.split(text) headers = section_pattern.findall(text) clause_types = { "indemnification": ["indemnif", "hold harmless", "defend and indemnify"], "termination": ["terminat", "cancel", "expir"], "confidentiality": ["confidential", "non-disclosure", "proprietary"], "limitation_of_liability": ["limit of liabilit", "limitation of liabilit", "aggregate liability", "consequential damages"], "governing_law": ["governing law", "governed by", "jurisdiction"], "force_majeure": ["force majeure", "act of god", "beyond reasonable control"], "assignment": ["assign", "transfer", "delegate"], } clauses = [] for i, section in enumerate(sections[1:], 1): detected_type = "general" section_lower = section.lower() for ctype, keywords in clause_types.items(): if any(kw in section_lower for kw in keywords): detected_type = ctype break clauses.append({ "index": i, "header": headers[i - 1].strip() if i <= len(headers) else "", "type": detected_type, "text": section.strip()[:500], }) return clauses
pythonfrom sentence_transformers import SentenceTransformer import numpy as np # Legal domain sentence embeddings encoder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") def build_case_index(case_summaries: list[str]) -> np.ndarray: """Encode case summaries into dense vector representations.""" embeddings = encoder.encode(case_summaries, show_progress_bar=True) # L2 normalize for cosine similarity via dot product norms = np.linalg.norm(embeddings, axis=1, keepdims=True) return embeddings / norms def search_similar_cases(query: str, index: np.ndarray, case_ids: list[str], top_k: int = 10) -> list: """Find the most similar cases to a query.""" query_vec = encoder.encode([query]) query_vec = query_vec / np.linalg.norm(query_vec) scores = (index @ query_vec.T).squeeze() top_indices = np.argsort(scores)[::-1][:top_k] return [(case_ids[i], scores[i]) for i in top_indices]
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 19,273 | 30,535 | +58% | 1 | 1 | 0% | 4,059 | 6,508 | +60% | 0 | 0 | — |
case-02 | fail→fail | 22,058 | 28,567 | +30% | 1 | 1 | 0% | 4,729 | 7,141 | +51% | 0 | 0 | — |
case-03 | fail→fail | 14,352 | 15,748 | +10% | 1 | 1 | 0% | 2,858 | 5,072 | +77% | 0 | 0 | — |
case-04 | pass→pass | 17,237 | 15,587 | -10% | 1 | 1 | 0% | 2,833 | 4,877 | +72% | 0 | 0 | — |
case-05 | fail→fail | 8,403 | 9,124 | +9% | 1 | 1 | 0% | 1,394 | 3,848 | +176% | 0 | 0 | — |
case-06 | fail→fail | 13,086 | 16,904 | +29% | 1 | 1 | 0% | 1,836 | 4,784 | +161% | 0 | 0 | — |
case-07 | pass→pass | 14,917 | 13,042 | -13% | 1 | 1 | 0% | 2,348 | 4,328 | +84% | 0 | 0 | — |
case-08 | fail→pass | 15,613 | 9,222 | -41% | 1 | 1 | 0% | 2,358 | 3,542 | +50% | 0 | 0 | — |
case-09 | fail→pass | 18,373 | 10,127 | -45% | 1 | 1 | 0% | 3,027 | 3,799 | +26% | 0 | 0 | — |
case-10 | fail→pass | 13,967 | 10,561 | -24% | 1 | 1 | 0% | 2,518 | 4,239 | +68% | 0 | 0 | — |
case-11 | fail→fail | 13,590 | 10,287 | -24% | 1 | 1 | 0% | 1,964 | 3,768 | +92% | 0 | 0 | — |
case-12 | pass→pass | 11,111 | 10,253 | -8% | 1 | 1 | 0% | 1,762 | 3,769 | +114% | 0 | 0 | — |
case-13 | fail→pass | 11,590 | 4,787 | -59% | 1 | 1 | 0% | 1,955 | 3,100 | +59% | 0 | 0 | — |
case-14 | pass→pass | 18,020 | 9,315 | -48% | 1 | 1 | 0% | 2,568 | 3,651 | +42% | 0 | 0 | — |
case-15 | pass→pass | 13,218 | 15,307 | +16% | 1 | 1 | 0% | 2,319 | 4,692 | +102% | 0 | 0 | — |
case-16 | fail→pass | 14,038 | 8,780 | -37% | 1 | 1 | 0% | 2,155 | 3,476 | +61% | 0 | 0 | — |
case-17 | fail→fail | 16,991 | 16,490 | -3% | 1 | 1 | 0% | 2,650 | 4,912 | +85% | 0 | 0 | — |
case-18 | pass→pass | 4,665 | 2,579 | -45% | 1 | 1 | 0% | 665 | 2,516 | +278% | 0 | 0 | — |
case-19 | pass→pass | 18,234 | 2,632 | -86% | 1 | 1 | 0% | 2,895 | 2,540 | -12% | 0 | 0 | — |
case-20 | pass→pass | 9,060 | 3,324 | -63% | 1 | 1 | 0% | 1,263 | 2,613 | +107% | 0 | 0 | — |
case-21 | pass→pass | 4,435 | 2,723 | -39% | 1 | 1 | 0% | 556 | 2,518 | +353% | 0 | 0 | — |
case-22 | pass→pass | 13,073 | 17,346 | +33% | 1 | 1 | 0% | 2,436 | 4,925 | +102% | 0 | 0 | — |
case-23 | pass→pass | 11,278 | 4,946 | -56% | 1 | 1 | 0% | 1,902 | 2,895 | +52% | 0 | 0 | — |
case-24 | pass→pass | 7,318 | 6,088 | -17% | 1 | 1 | 0% | 1,152 | 3,062 | +166% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +21 percentage points is the difference between those two pass rates over the 24 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.