Spaces:
Runtime error
Runtime error
| """Convert CUAD (extractive-QA format) into multi-label clause-classification | |
| training data. | |
| CUAD ships as SQuAD-style question/answer-span data: per contract, ~41 | |
| questions, each answered with text spans (or none). To *train a classifier* | |
| we need (clause_text -> set of category labels). This script: | |
| 1. segments each CUAD contract into clauses (same heading logic as the app), | |
| 2. labels a clause with category X if it overlaps a CUAD answer span for X's | |
| question (coverage >= --overlap), | |
| 3. emits negatives (clauses with no label) so the model learns "none too", | |
| 4. splits BY CONTRACT (never by clause) so the eval can't leak. | |
| python -m scripts.prepare_cuad (from backend/, needs data/cuad) | |
| -> data/cuad/train.jsonl, val.jsonl, labels.json ({"text":..., "labels":[...]}) | |
| Only categories with real CUAD ground truth are trained (see CUAD_QUESTION_MAP); | |
| the rest keep the rules/zero-shot path. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import pathlib | |
| import sys | |
| sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| from app.schema import Clause, SourceSpan # noqa: E402 | |
| from app.segmentation import CLAUSE_START # noqa: E402 | |
| ROOT = pathlib.Path(__file__).resolve().parents[2] | |
| CUAD_JSON = ROOT / "data" / "cuad" / "CUAD_v1.json" | |
| OUT_DIR = ROOT / "data" / "cuad" | |
| # our category key -> substring identifying the CUAD question for it. | |
| # (Only categories CUAD actually labels; indemnification/sla/payment etc. have | |
| # no CUAD ground truth, so they stay on the rules/zero-shot path.) | |
| CUAD_QUESTION_MAP = { | |
| "termination": "Termination For Convenience", | |
| "auto_renewal": "Renewal Term", | |
| "notice": "Notice Period To Terminate Renewal", | |
| "liability_cap": "Cap On Liability", | |
| "governing_law": "Governing Law", | |
| "exclusivity": "Exclusivity", | |
| "insurance": "Insurance", | |
| "audit_rights": "Audit Rights", | |
| "assignment": "Anti-Assignment", | |
| "ip_ownership": "Ip Ownership Assignment", | |
| "warranty": "Warranty Duration", | |
| "term": "Expiration Date", | |
| } | |
| LABELS = sorted(CUAD_QUESTION_MAP) | |
| def segment_plain_text(text: str) -> list[Clause]: | |
| lines = text.split("\n") | |
| offsets, pos = [], 0 | |
| for ln in lines: | |
| offsets.append(pos) | |
| pos += len(ln) + 1 | |
| starts = [i for i, ln in enumerate(lines) if CLAUSE_START.match(ln)] or [0] | |
| out = [] | |
| for n, li in enumerate(starts): | |
| s = offsets[li] | |
| e = offsets[starts[n + 1]] if n + 1 < len(starts) else len(text) | |
| out.append(Clause(id=f"c{n}", text=text[s:e], | |
| span=SourceSpan(start_char=s, end_char=e, page=0))) | |
| return out | |
| def coverage(gold: tuple[int, int], clause: tuple[int, int]) -> float: | |
| inter = max(0, min(gold[1], clause[1]) - max(gold[0], clause[0])) | |
| return inter / (gold[1] - gold[0]) if gold[1] > gold[0] else 0.0 | |
| def build(limit: int, overlap: float, val_frac: float): | |
| data = json.loads(CUAD_JSON.read_text())["data"][:limit] | |
| rows: list[tuple[int, dict]] = [] # (doc_index, example) | |
| for di, doc in enumerate(data): | |
| for para in doc["paragraphs"]: | |
| text = para["context"] | |
| clauses = segment_plain_text(text) | |
| gold: dict[str, list[tuple[int, int]]] = {k: [] for k in LABELS} | |
| for qa in para["qas"]: | |
| for key, q_sub in CUAD_QUESTION_MAP.items(): | |
| if q_sub in qa["question"]: | |
| for ans in qa["answers"]: | |
| s = ans["answer_start"] | |
| gold[key].append((s, s + len(ans["text"]))) | |
| for c in clauses: | |
| if len(c.text.strip()) < 25: | |
| continue | |
| cspan = (c.span.start_char, c.span.end_char) | |
| labels = [k for k in LABELS | |
| if any(coverage(g, cspan) >= overlap for g in gold[k])] | |
| rows.append((di, {"text": c.text.strip()[:4000], "labels": labels})) | |
| # contract-level split (deterministic — by doc index, no RNG) | |
| n_docs = len(data) | |
| val_docs = {i for i in range(n_docs) if i % max(1, round(1 / val_frac)) == 0} | |
| train = [ex for di, ex in rows if di not in val_docs] | |
| val = [ex for di, ex in rows if di in val_docs] | |
| return train, val | |
| def main() -> None: | |
| if not CUAD_JSON.exists(): | |
| sys.exit(f"CUAD not found at {CUAD_JSON}. Run scripts/download_cuad.py first.") | |
| limit, overlap, val_frac = 999, 0.3, 0.15 | |
| for a in sys.argv: | |
| if a.startswith("--limit="): | |
| limit = int(a.split("=", 1)[1]) | |
| if a.startswith("--overlap="): | |
| overlap = float(a.split("=", 1)[1]) | |
| train, val = build(limit, overlap, val_frac) | |
| OUT_DIR.mkdir(parents=True, exist_ok=True) | |
| (OUT_DIR / "train.jsonl").write_text("".join(json.dumps(r) + "\n" for r in train)) | |
| (OUT_DIR / "val.jsonl").write_text("".join(json.dumps(r) + "\n" for r in val)) | |
| (OUT_DIR / "labels.json").write_text(json.dumps(LABELS, indent=2)) | |
| pos = sum(1 for r in train if r["labels"]) | |
| print(f"wrote {len(train)} train / {len(val)} val clauses " | |
| f"({pos} train clauses carry >=1 label) over {len(LABELS)} categories") | |
| print(f" -> {OUT_DIR}/train.jsonl, val.jsonl, labels.json") | |
| if __name__ == "__main__": | |
| main() | |