← Files LegalQuants TransactionalARCHIVED FILE

skills/diligence/scripts/shared/block_candidates.py

5.98 KB · Oct 3, 2026 · 06:34 UTC

↓ Download file

#!/usr/bin/env python3
"""Form candidate blocks for /diligence relationship resolution.

Reads manifest.json plus the metadata dir, normalizes party names (casefold,
strip punctuation and entity suffixes), and groups documents linked by any of:
shared normalized party, shared title token trigram, shared filename or
index-number prefix, or an explicit cross-reference (a reference string
matching another document's title tokens within tolerance). Emits
candidates.json: blocks with basis labels and candidate pairs within blocks
only. Block ids b001, b002... are ordered by sorted member ids.

Usage:
    python3 block_candidates.py --manifest manifest.json \
        --metadata metadata/ --out candidates.json
"""

import argparse
import itertools
import json
import os
import re
import sys

ENTITY_SUFFIXES = {"inc", "llc", "ltd", "corp", "co", "lp", "llp", "plc", "gmbh", "pty"}
XREF_TITLE_OVERLAP = 0.6


def tokens(s):
    return re.sub(r"[^\w\s]+", " ", (s or "").casefold()).split()


def norm_party(name):
    toks = tokens(name)
    while toks and toks[-1] in ENTITY_SUFFIXES:
        toks.pop()
    return " ".join(toks)


def index_prefix(path):
    parts = path.split("/")
    stem = re.sub(r"\.[^.]+$", "", parts[-1])
    m = re.match(r"(\d+(?:\.\d+)+)", stem)
    if m:
        return ".".join(m.group(1).split(".")[:-1])
    for part in reversed(parts[:-1]):
        m = re.match(r"(\d+(?:\.\d+)+)", part)
        if m:
            return m.group(1)
    return None


def load_metadata(mdir):
    meta = {}
    for name in sorted(os.listdir(mdir)):
        if not name.endswith(".json"):
            continue
        with open(os.path.join(mdir, name), encoding="utf-8") as f:
            rec = json.load(f)
        if rec.get("id"):
            meta[rec["id"]] = rec
    return meta


def main():
    ap = argparse.ArgumentParser(
        description="Form candidate blocks from manifest plus metadata."
    )
    ap.add_argument("--manifest", required=True, help="Path to manifest.json.")
    ap.add_argument("--metadata", required=True, help="Metadata directory.")
    ap.add_argument("--out", required=True, help="candidates.json output path.")
    args = ap.parse_args()

    try:
        with open(args.manifest, encoding="utf-8") as f:
            manifest = json.load(f)
        meta = load_metadata(args.metadata)
    except (OSError, json.JSONDecodeError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        sys.exit(1)

    deferred = sum(
        1 for record in meta.values() if record.get("status") == "deferred-to-review"
    )
    if deferred:
        noun = "document" if deferred == 1 else "documents"
        print(
            f"metadata was deferred for {deferred} {noun}; family assembly "
            "requires canonical metadata; rerun the read plan without deferral "
            "for these IDs",
            file=sys.stderr,
        )
        sys.exit(1)

    # A byte-duplicate file yields two manifest rows sharing one id;
    # collapse to unique ids so no self pair (a == b) can ever form.
    doc_ids = sorted({d["id"] for d in manifest["documents"]})
    paths = {d["id"]: d["path"] for d in manifest["documents"]}

    groups = {}  # label -> set of doc ids sharing that feature

    def add(label, doc_id):
        groups.setdefault(label, set()).add(doc_id)

    for did in doc_ids:
        m = meta.get(did, {})
        path = paths[did]
        for p in m.get("parties") or []:
            np = norm_party(p)
            if np:
                add("party:" + np, did)
        ttoks = tokens(m.get("title"))
        for i in range(len(ttoks) - 2):
            add("title:" + " ".join(ttoks[i : i + 3]), did)
        ipfx = index_prefix(path)
        if ipfx:
            add("index:" + ipfx, did)
        stoks = tokens(re.sub(r"\.[^.]+$", "", path.rsplit("/", 1)[-1]))
        while stoks and stoks[0].isdigit():
            stoks.pop(0)
        if len(stoks) >= 2:
            add("file:" + " ".join(stoks[:2]), did)

    # explicit cross-references: reference text vs other documents' titles
    xrefs = []
    for src in doc_ids:
        for ref in meta.get(src, {}).get("references") or []:
            rtoks = set(tokens(ref.get("text")))
            if not rtoks:
                continue
            for dst in doc_ids:
                if dst == src:
                    continue
                ttoks = set(tokens(meta.get(dst, {}).get("title")))
                if not ttoks:
                    continue
                if len(ttoks & rtoks) / len(ttoks) >= XREF_TITLE_OVERLAP:
                    xrefs.append((src, dst))

    parent = {did: did for did in doc_ids}

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[max(ra, rb)] = min(ra, rb)

    for members in groups.values():
        ms = sorted(members)
        for other in ms[1:]:
            union(ms[0], other)
    for src, dst in xrefs:
        union(src, dst)

    components = {}
    for did in doc_ids:
        components.setdefault(find(did), []).append(did)
    member_lists = sorted(sorted(ms) for ms in components.values() if len(ms) >= 2)

    blocks = []
    pairs = []
    for n, members in enumerate(member_lists, 1):
        block_id = f"b{n:03d}"
        mset = set(members)
        basis = sorted(
            {label for label, g in groups.items() if len(g & mset) >= 2}
            | {f"xref:{s}->{d}" for s, d in xrefs if s in mset and d in mset}
        )
        blocks.append({"block_id": block_id, "basis": basis, "members": members})
        for a, b in itertools.combinations(members, 2):
            pairs.append({"a": a, "b": b, "block": block_id})

    out = {"blocks": blocks, "pairs": pairs}
    with open(args.out, "w", encoding="utf-8") as f:
        f.write(json.dumps(out, sort_keys=True, indent=2) + "\n")
    print(
        f"{args.out}: {len(blocks)} block(s), {len(pairs)} pair(s) "
        f"over {len(doc_ids)} document(s)"
    )


if __name__ == "__main__":
    main()

SHA-256: 88371e516f8ac6c019fe80cc3b0a44d63b0e874e86420c135e6ef94e66faf85c