| """ |
| Legal Concept Graph — evolving knowledge backbone of TELEN. |
| |
| Nodes: law entities + key terms extracted via TF-IDF |
| Edges: agency, temporal, semantic, cross-reference, term-document |
| GNN: Multi-layer sparse graph convolution |
| """ |
| import re |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from sklearn.feature_extraction.text import TfidfVectorizer |
|
|
|
|
| |
| |
| |
| class GCNLayer(nn.Module): |
| def __init__(self, in_dim, out_dim, dropout=0.1): |
| super().__init__() |
| self.linear = nn.Linear(in_dim, out_dim) |
| self.dropout = nn.Dropout(dropout) |
| self.norm = nn.LayerNorm(out_dim) |
|
|
| def forward(self, x, adj): |
| deg = adj.sum(dim=1).clamp(min=1) |
| deg_inv_sqrt = deg.pow(-0.5) |
| norm_adj = deg_inv_sqrt.unsqueeze(1) * adj * deg_inv_sqrt.unsqueeze(0) |
| x = norm_adj @ x |
| x = self.linear(x) |
| x = F.relu(x) |
| x = self.dropout(x) |
| x = self.norm(x) |
| return x |
|
|
|
|
| class GNNEncoder(nn.Module): |
| def __init__(self, dim, n_layers=3, dropout=0.1): |
| super().__init__() |
| self.layers = nn.ModuleList([GCNLayer(dim, dim, dropout) for _ in range(n_layers)]) |
|
|
| def forward(self, x, adj): |
| for layer in self.layers: |
| x = layer(x, adj) + x |
| return x |
|
|
|
|
| |
| |
| |
| class LegalConceptGraph(nn.Module): |
| def __init__(self, config): |
| super().__init__() |
| self.config = config |
| self.hidden_dim = config.graph.hidden_dim |
|
|
| self.node_ids = [] |
| self.node_embeddings = None |
| self.edges = {"cross_ref": [], "agency": [], "temporal": [], "semantic": []} |
| self._adj_cached = None |
| self._adj_dirty = True |
|
|
| self.gnn = GNNEncoder(config.graph.hidden_dim, config.graph.gnn_layers, config.graph.gnn_dropout) |
|
|
| @property |
| def num_nodes(self): |
| return len(self.node_ids) |
|
|
| @property |
| def device(self): |
| return self.gnn.layers[0].linear.weight.device |
|
|
| def add_nodes(self, node_ids, embeddings): |
| if self.node_embeddings is None: |
| self.node_embeddings = embeddings |
| else: |
| self.node_embeddings = torch.cat([self.node_embeddings, embeddings], dim=0) |
| self.node_ids.extend(node_ids) |
| self._adj_dirty = True |
|
|
| def add_edges(self, edge_type, edges): |
| self.edges[edge_type].extend(edges) |
| self._adj_dirty = True |
|
|
| def build_adjacency(self): |
| if not self._adj_dirty and self._adj_cached is not None: |
| return self._adj_cached |
| N = self.num_nodes |
| adj = torch.zeros(N, N, device=self.device) |
|
|
| for edge_type, use in [("cross_ref", self.config.graph.use_cross_ref_edges), |
| ("agency", self.config.graph.use_agency_edges), |
| ("temporal", self.config.graph.use_temporal_edges), |
| ("semantic", self.config.graph.use_semantic_edges)]: |
| if not use or not self.edges[edge_type]: |
| continue |
| valid = [(s, d, w) for s, d, w in self.edges[edge_type] if s < N and d < N] |
| if not valid: |
| continue |
| src = torch.tensor([e[0] for e in valid], device=self.device, dtype=torch.long) |
| dst = torch.tensor([e[1] for e in valid], device=self.device, dtype=torch.long) |
| wgt = torch.tensor([e[2] for e in valid], device=self.device, dtype=torch.float) |
| adj.index_put_((src, dst), wgt, accumulate=True) |
| adj.index_put_((dst, src), wgt, accumulate=True) |
|
|
| adj = adj + torch.eye(N, device=self.device) |
| self._adj_cached = adj |
| self._adj_dirty = False |
| return adj |
|
|
| def forward(self): |
| dev = self.device |
| if self.node_embeddings.device != dev: |
| self.node_embeddings = self.node_embeddings.to(dev) |
| adj = self.build_adjacency() |
| return self.gnn(self.node_embeddings, adj) |
|
|
| def to(self, device): |
| super().to(device) |
| if self.node_embeddings is not None: |
| self.node_embeddings = self.node_embeddings.to(device) |
| return self |
|
|
|
|
| |
| |
| |
| CROSS_REF_PATTERNS = [ |
| (re.compile(r"(?:theo|theo quy định tại|căn cứ vào|căn cứ)\s+Điều\s+(\d+)\s+(?:của\s+)?(Luật|Bộ luật|Nghị định|Thông tư|Pháp lệnh)\s+([^,.;]+)"), "citation"), |
| (re.compile(r"(Luật|Bộ luật|Nghị định|Thông tư|Pháp lệnh|Quyết định)\s+(?:số\s+)?([\d]+/[\d]+/[\w-]+)"), "reference"), |
| (re.compile(r"sửa đổi[,,]\s*bổ sung\s+(?:một số điều của\s+)?(Luật|Nghị định|Thông tư)\s+([^,.;]+)"), "amendment"), |
| (re.compile(r"(?:thay thế|bãi bỏ)\s+(?:Điều\s+(\d+)\s+(?:của\s+)?)?(Luật|Nghị định|Thông tư)\s+([^,.;]+)"), "replacement"), |
| ] |
|
|
|
|
| def extract_key_terms(df, max_terms=200): |
| texts = [(f"{row['title']} {row['text'][:500]}").replace("_", " ") |
| for _, row in df.iterrows()] |
| vectorizer = TfidfVectorizer(max_features=max_terms, ngram_range=(1, 2), |
| min_df=3, max_df=0.8, token_pattern=r'(?u)\b\w+\b') |
| tfidf = vectorizer.fit_transform(texts) |
| scores = tfidf.max(axis=0).toarray().flatten() |
| return list(vectorizer.get_feature_names_out()[scores.argsort()[::-1][:max_terms]]) |
|
|
|
|
| def _law_matches_ref(law_id, ref_text): |
| law_lower = law_id.lower().replace("_", " ").replace("-", " ") |
| ref_lower = ref_text.lower().replace("_", " ").replace("-", " ") |
| parts = law_id.split("/") |
| if len(parts) >= 3: |
| if parts[2].replace("_", " ") in ref_lower: return True |
| if len(parts) >= 2 and parts[1] in ref_lower: return True |
| return False |
|
|
|
|
| def build_concept_graph(df, encode_fn, config): |
| """Build enhanced concept graph from training data.""" |
| graph = LegalConceptGraph(config) |
| law_groups = df.groupby("law_id") |
| law_ids = sorted(law_groups.groups.keys()) |
| N_laws = len(law_ids) |
| print(f" Building graph: {N_laws} law nodes...") |
|
|
| |
| embs = [] |
| for lid in law_ids: |
| group = law_groups.get_group(lid) |
| texts = [f"{t}: {txt[:300]}" for t, txt in zip(group["title"], group["text"])] |
| embs.append(torch.stack([encode_fn(t) for t in texts[:5]]).mean(dim=0)) |
| law_embs = torch.stack(embs) |
| graph.add_nodes(law_ids, law_embs) |
| law_id_to_idx = {lid: i for i, lid in enumerate(law_ids)} |
|
|
| |
| print(" Extracting key terms...") |
| key_terms = extract_key_terms(df, max_terms=200) |
| term_embs = torch.stack([encode_fn(t) for t in key_terms]) |
| graph.add_nodes([f"TERM:{t}" for t in key_terms], term_embs) |
| print(f" {len(key_terms)} key terms") |
|
|
| |
| agency_edges = [] |
| for _, group in df.groupby("law_type"): |
| same = group["law_id"].unique() |
| for i in range(len(same)): |
| for j in range(i + 1, len(same)): |
| if same[i] in law_id_to_idx and same[j] in law_id_to_idx: |
| agency_edges.append((law_id_to_idx[same[i]], law_id_to_idx[same[j]], 0.3)) |
| graph.add_edges("agency", agency_edges) |
| print(f" Agency edges: {len(agency_edges)}") |
|
|
| |
| temporal_edges = [] |
| for _, group in df.groupby("law_type"): |
| yl = group.groupby("year")["law_id"].unique() |
| for y1, y2 in zip(sorted(yl.keys()), sorted(yl.keys())[1:]): |
| for l1 in yl[y1]: |
| for l2 in yl[y2]: |
| if l1 in law_id_to_idx and l2 in law_id_to_idx: |
| temporal_edges.append((law_id_to_idx[l1], law_id_to_idx[l2], 0.2)) |
| graph.add_edges("temporal", temporal_edges) |
| print(f" Temporal edges: {len(temporal_edges)}") |
|
|
| |
| semantic_k = min(config.graph.semantic_knn, N_laws - 1) |
| semantic_edges = [] |
| if N_laws > 1: |
| chunk = 64 |
| for i in range(0, N_laws, chunk): |
| end = min(i + chunk, N_laws) |
| sim = F.cosine_similarity(law_embs[i:end].unsqueeze(1), law_embs.unsqueeze(0), dim=2) |
| for j in range(sim.shape[0]): |
| sim[j, i + j] = float("-inf") |
| vals, idx = sim.topk(k=semantic_k, dim=1) |
| for j in range(sim.shape[0]): |
| for kk in range(semantic_k): |
| semantic_edges.append((i + j, idx[j, kk].item(), vals[j, kk].item())) |
| graph.add_edges("semantic", semantic_edges) |
| print(f" Semantic edges: {len(semantic_edges)}") |
|
|
| |
| cross_ref_edges = [] |
| for _, row in df.iterrows(): |
| src = row["law_id"] |
| if src not in law_id_to_idx: continue |
| for pattern, etype in CROSS_REF_PATTERNS: |
| for match in pattern.findall(row["text"]): |
| match_str = " ".join(match).lower() if isinstance(match, tuple) else str(match).lower() |
| for tgt in law_ids: |
| if tgt != src and _law_matches_ref(tgt, match_str): |
| cross_ref_edges.append((law_id_to_idx[src], law_id_to_idx[tgt], 0.5)) |
| break |
| graph.add_edges("cross_ref", cross_ref_edges) |
| print(f" Cross-ref edges: {len(cross_ref_edges)}") |
|
|
| |
| term_doc_edges = [] |
| law_texts = [(f"{row['title']} {row['text'][:300]}").replace("_", " ") |
| for _, row in df.iterrows()] |
| vec = TfidfVectorizer(vocabulary=key_terms if key_terms else None) |
| try: |
| tfidf = vec.fit_transform(law_texts) |
| for ti, term in enumerate(key_terms): |
| if ti < tfidf.shape[1]: |
| col = tfidf[:, ti].toarray().flatten() |
| for lp in col.argsort()[::-1][:10]: |
| if col[lp] > 0.1 and lp < N_laws: |
| term_doc_edges.append((N_laws + ti, lp, float(col[lp]))) |
| except ValueError: |
| pass |
| graph.add_edges("semantic", term_doc_edges) |
| print(f" Term-doc edges: {len(term_doc_edges)}") |
| print(f" Total: {graph.num_nodes} nodes ({N_laws} laws + {len(key_terms)} terms)") |
|
|
| return graph, law_id_to_idx |
|
|