Spaces:
Running on Zero
Running on Zero
Download knowledge_graph/schema_knowledge_graph.py from cmuchancel/patent2sysml: direct link, hf CLI and curl.
- Browser
- Download file 32.1 kB
-
https://huggingface.co/spaces/cmuchancel/patent2sysml/resolve/main/knowledge_graph/schema_knowledge_graph.py
- Command line
-
hf download hf://spaces/cmuchancel/patent2sysml/knowledge_graph/schema_knowledge_graph.py
-
curl -L -o schema_knowledge_graph.py https://huggingface.co/spaces/cmuchancel/patent2sysml/resolve/main/knowledge_graph/schema_knowledge_graph.py
32.1 kB
| """Incremental, text-labelled SysML knowledge graphs. Import from a notebook; no CLI.""" | |
| from __future__ import annotations | |
| from copy import deepcopy | |
| from datetime import datetime, timezone | |
| import html | |
| import importlib.metadata | |
| import json | |
| import math | |
| import os | |
| from pathlib import Path | |
| import re | |
| ROOT = Path(__file__).resolve().parent | |
| MODEL_NAME = "knowledgator/gliner-relex-large-v1.0" | |
| # These are editable extraction interpretations, not descriptions supplied by the schema. | |
| # Values are (span prompt, directed relation prompt). | |
| PROPERTY_PROMPTS = { | |
| "actionDefinition": ("action type or named procedure", "is defined by action"), | |
| "behavior": ("behavior or operation", "has behavior"), | |
| "receiverArgument": ("receiver of an incoming item or signal", "is received by"), | |
| "payloadArgument": ("incoming item message or signal", "accepts payload"), | |
| "payloadParameter": ("parameter holding received payload", "stores payload in"), | |
| "input": ("input item or information", "has input"), | |
| "output": ("output item or information", "has output"), | |
| "parameter": ("action parameter", "has parameter"), | |
| "nestedAction": ("subaction or subprocess", "includes action"), | |
| "nestedConstraint": ("condition or limitation", "has constraint"), | |
| "nestedRequirement": ("required capability", "has requirement"), | |
| "nestedState": ("operating state", "has state"), | |
| "nestedPart": ("physical component", "contains part"), | |
| "nestedPort": ("port or interface point", "has port"), | |
| "nestedAttribute": ("property or characteristic", "has attribute"), | |
| "action": ("action or operation", "includes action"), | |
| "ownedAction": ("subaction or subprocess", "owns action"), | |
| "ownedPart": ("physical component", "owns part"), | |
| "ownedAttribute": ("property or characteristic", "owns attribute"), | |
| "ownedConstraint": ("condition or limitation", "owns constraint"), | |
| "ownedRequirement": ("required capability", "owns requirement"), | |
| "subjectParameter": ("subject of a requirement", "has subject"), | |
| "result": ("result or outcome", "has result"), | |
| "text": ("requirement statement", "has requirement text"), | |
| "body": ("comment or description", "has description"), | |
| } | |
| ANCHOR_PROMPTS = { | |
| "AcceptActionUsage": "accepting or receiving action", | |
| "ActionDefinition": "action type or procedure definition", | |
| "ActionUsage": "action or operation", | |
| } | |
| FOCUSED = { | |
| "AcceptActionUsage": ["actionDefinition", "behavior", "receiverArgument", "payloadArgument", | |
| "payloadParameter", "input", "output", "parameter", "nestedAction", | |
| "nestedConstraint"], | |
| "ActionDefinition": ["action", "input", "output", "parameter", "ownedAction", | |
| "ownedConstraint", "ownedRequirement"], | |
| "ActionUsage": ["actionDefinition", "behavior", "input", "output", "parameter", | |
| "nestedAction", "nestedConstraint", "nestedState"], | |
| } | |
| IDENTITY_FIELDS = {"@id", "@type", "aliasIds", "elementId"} | |
| ENUM_CONTEXT = { | |
| "FeatureDirectionKind": {"in": "input feature direction", "inout": "bidirectional feature direction", "out": "output feature direction"}, | |
| "PortionKind": {"timeslice": "time interval portion of an occurrence", "snapshot": "instantaneous snapshot of an occurrence"}, | |
| "RequirementConstraintKind": {"assumption": "assumed condition", "requirement": "required condition"}, | |
| "StateSubactionKind": {"entry": "action on entering a state", "do": "action performed while in a state", "exit": "action on leaving a state"}, | |
| "TransitionFeatureKind": {"trigger": "event triggering a state transition", "guard": "condition permitting a state transition", "effect": "effect of a state transition"}, | |
| "TriggerKind": {"when": "change condition triggering an event", "at": "absolute time triggering an event", "after": "elapsed duration triggering an event"}, | |
| "VisibilityKind": {"private": "private member visibility", "protected": "protected member visibility", "public": "public member visibility"}, | |
| } | |
| def humanize(name): | |
| return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name).strip() | |
| def object_shape(definition): | |
| if definition.get("type") == "object": | |
| return definition | |
| return next((b for b in definition.get("anyOf", []) if b.get("type") == "object"), None) | |
| def _identity(name): | |
| return name in IDENTITY_FIELDS or name.endswith("Id") or name.endswith("Ids") | |
| def _targets(schema): | |
| result = [] | |
| if isinstance(schema, dict): | |
| if "$comment" in schema: | |
| result.append(schema["$comment"].rsplit("/", 1)[-1]) | |
| for value in schema.values(): | |
| result.extend(_targets(value)) | |
| elif isinstance(schema, list): | |
| for value in schema: | |
| result.extend(_targets(value)) | |
| return list(dict.fromkeys(result)) | |
| def load_model(*, model_path=None, offline=True, device="cpu"): | |
| """Load a local export/pickle, or the original base model from cache. | |
| Pickles execute Python when loaded: only use a trusted release file. | |
| """ | |
| if model_path is not None: | |
| path = Path(model_path).expanduser().resolve() | |
| if not path.exists(): | |
| raise FileNotFoundError(f"Model does not exist: {path}") | |
| if path.suffix == ".pkl": | |
| import pickle | |
| with path.open("rb") as stream: | |
| model = pickle.load(stream) | |
| model.to(device).eval() | |
| else: | |
| from gliner import GLiNER | |
| model = GLiNER.from_pretrained( | |
| str(path), local_files_only=True, map_location=device, | |
| ).eval() | |
| model.knowledge_graph_checkpoint = str(path) | |
| return model | |
| os.environ["HF_HOME"] = str(ROOT / ".cache" / "huggingface") | |
| if offline: | |
| os.environ["HF_HUB_OFFLINE"] = "1" | |
| os.environ["TRANSFORMERS_OFFLINE"] = "1" | |
| from gliner import GLiNER | |
| model = GLiNER.from_pretrained( | |
| MODEL_NAME, cache_dir=str(ROOT / ".cache" / "huggingface" / "hub"), | |
| local_files_only=offline, map_location=device, | |
| ) | |
| model.eval() | |
| model.knowledge_graph_checkpoint = MODEL_NAME | |
| return model | |
| def _score(value): | |
| value = float(value) | |
| if not math.isfinite(value) or not 0 <= value <= 1: | |
| raise ValueError("Scores and thresholds must be finite values between zero and one.") | |
| return value | |
| def _chunks(text, tokenizer, prompts, limit, overlap): | |
| """Reserve prompt space; never silently truncate input. Offsets index the original string.""" | |
| cost = sum(len(tokenizer.encode(p, add_special_tokens=False)) + 2 for p in prompts) + 12 | |
| budget = limit - cost | |
| if budget < 24: | |
| raise ValueError("Prompts exceed the context budget. Reduce properties_per_batch or prompt lengths.") | |
| words = list(re.finditer(r"\w+|[^\w\s]", text)) | |
| i = 0 | |
| while i < len(words): | |
| lo, hi, best = i + 1, min(len(words), i + budget), None | |
| while lo <= hi: | |
| j = (lo + hi) // 2 | |
| snippet = text[words[i].start():words[j-1].end()] | |
| if len(tokenizer.encode(snippet, add_special_tokens=False)) <= budget: | |
| best, lo = j, j + 1 | |
| else: | |
| hi = j - 1 | |
| if best is None: | |
| raise ValueError("A single input token exceeds the available context budget.") | |
| start, end = words[i].start(), words[best-1].end() | |
| yield {"start": start, "end": end, "text": text[start:end]} | |
| if best == len(words): | |
| break | |
| i = max(i + 1, best - overlap) | |
| class SchemaKnowledgeGraph: | |
| """One cleaned string, with independently rerunnable definition passes. | |
| Public nodes and edge endpoints are text, never SysML IDs. Exact text is the | |
| concept key: identical phrases merge; synonyms and pronouns do not. Evidence | |
| retains occurrences so users can inspect ambiguous repeated phrases. | |
| """ | |
| def __init__(self, text, schema_path=ROOT / "sysml.schema.json"): | |
| if not isinstance(text, str) or not text.strip(): | |
| raise ValueError("Provide a nonempty cleaned Python string.") | |
| self.text = text | |
| self.schema_path = Path(schema_path).resolve() | |
| self.schema = json.loads(self.schema_path.read_text(encoding="utf-8-sig")) | |
| self.definitions = list(self.schema["$defs"]) | |
| self.passes = {} | |
| self.source_document = None | |
| def next_definition(self): | |
| return next((n for n in self.definitions if n not in self.passes), None) | |
| def property_catalog(self, definition): | |
| """All fields, their reference targets, and whether excluded as identity metadata.""" | |
| if definition not in self.definitions: | |
| raise ValueError(f"{definition!r} is not a schema definition.") | |
| shape = object_shape(self.schema["$defs"][definition]) | |
| return [{"property": p, "label": humanize(p), "target_types": _targets(s), | |
| "identity_metadata": _identity(p)} for p, s in (shape or {}).get("properties", {}).items()] | |
| def pass_catalog(self): | |
| """One ordered, inspectable pass for every schema definition, including helper types.""" | |
| rows = [] | |
| for number, name in enumerate(self.definitions, 1): | |
| plan = self.plan(name) | |
| saved = self.passes.get(name) | |
| rows.append({"pass": number, "definition": name, "kind": plan["kind"], | |
| "status": saved.get("status", "completed") if saved else "pending", | |
| "properties": [p["property"] for p in plan["properties"]], | |
| "enum_values": plan.get("enum_values", []), | |
| "mentions": len(saved["mentions"]) if saved else 0, | |
| "connections": len(saved["edges"]) if saved else 0}) | |
| return rows | |
| def plan(self, definition=None, *, properties=None, prompts=None, anchor=None): | |
| """Inspect/edit a pass without loading a model. properties='all' includes all non-ID fields.""" | |
| definition = definition or self.next_definition | |
| if definition is None: | |
| raise ValueError("All schema definitions have been processed.") | |
| catalog = self.property_catalog(definition) | |
| schema = self.schema["$defs"][definition] | |
| if "enum" in schema: | |
| if properties is not None and properties != "all": | |
| raise ValueError("Enumeration passes have values, not selectable object properties.") | |
| if anchor is not None: | |
| raise ValueError("Enumeration passes use value prompts rather than an anchor.") | |
| overrides = prompts or {} | |
| if set(overrides) - set(schema["enum"]): | |
| raise ValueError("Enumeration prompt keys must be declared enum values.") | |
| enum_prompts = {value: overrides.get(value, ENUM_CONTEXT.get(definition, {}).get( | |
| value, humanize(definition).lower() + " " + str(value))) for value in schema["enum"]} | |
| if any(not isinstance(p, str) or not p.strip() for p in enum_prompts.values()) or len(set(enum_prompts.values())) != len(enum_prompts): | |
| raise ValueError("Enumeration value prompts must be distinct nonempty strings.") | |
| return {"definition": definition, "kind": "enumeration", "properties": [], | |
| "enum_values": list(schema["enum"]), "enum_prompts": enum_prompts, | |
| "omitted_properties": [], "anchor_prompt": None, | |
| "prompt_origin": "Contextual span classifications mapped to the schema's enum values."} | |
| if catalog and all(p["identity_metadata"] for p in catalog): | |
| if (properties is not None and properties != "all") or prompts or anchor: | |
| raise ValueError("The identity-only pass has no semantic extraction prompts or properties.") | |
| return {"definition": definition, "kind": "identity", "properties": [], | |
| "omitted_properties": [p["property"] for p in catalog], "anchor_prompt": None, | |
| "note": "This definition contains only identity metadata. Its pass records coverage without creating ID nodes.", | |
| "prompt_origin": "Schema inspection; no text extraction or model call."} | |
| available = {row["property"]: row for row in catalog} | |
| if properties == "all": | |
| selected = [p for p in available if not _identity(p)] | |
| elif properties is None: | |
| preferred = FOCUSED.get(definition, list(PROPERTY_PROMPTS)) | |
| selected = [p for p in preferred if p in available] | |
| if not selected: | |
| selected = [p for p in available if not _identity(p) and p not in { | |
| "name", "declaredName", "shortName", "declaredShortName", "qualifiedName", | |
| }] | |
| elif isinstance(properties, str): | |
| raise ValueError("properties must be a list of field names or 'all'.") | |
| else: | |
| selected = list(dict.fromkeys(properties)) | |
| if not selected or any(p not in available or _identity(p) for p in selected): | |
| raise ValueError("Select existing, non-ID properties from property_catalog().") | |
| overrides = prompts or {} | |
| if set(overrides) - set(selected): | |
| raise ValueError("Prompt overrides must refer to selected properties.") | |
| shape = object_shape(schema) or {} | |
| declared_properties = shape.get("properties", {}) | |
| rows = [] | |
| for p in selected: | |
| declared = declared_properties.get(p, {}) | |
| schema_prompt = (declared.get("entity_prompt"), declared.get("relation_prompt")) | |
| fallback = PROPERTY_PROMPTS.get(p, (humanize(p).replace("_", " ").lower(), | |
| "has " + humanize(p).replace("_", " ").lower())) | |
| entity, relation = overrides.get(p, schema_prompt if all(schema_prompt) else fallback) | |
| if not isinstance(entity, str) or not entity.strip() or not isinstance(relation, str) or not relation.strip(): | |
| raise ValueError("Each prompt must contain a nonempty span label and relation label.") | |
| rows.append({**available[p], "entity_prompt": entity, "relation_prompt": relation}) | |
| return {"definition": definition, "kind": "object", | |
| "anchor_prompt": anchor or schema.get("anchor_prompt") or | |
| ANCHOR_PROMPTS.get(definition, humanize(definition).lower()), | |
| "properties": rows, | |
| "omitted_properties": [p for p in available if p not in selected], | |
| "prompt_origin": "Editable extraction interpretations; the schema supplies field names and types."} | |
| def run_next(self, model, **kwargs): | |
| """Run exactly one unfinished definition in schema order.""" | |
| if self.next_definition is None: | |
| raise ValueError("All schema definitions have been processed.") | |
| return self.run_pass(model, self.next_definition, **kwargs) | |
| def run_pass(self, model, definition="AcceptActionUsage", *, properties=None, prompts=None, | |
| anchor=None, threshold=0.4, relation_threshold=0.6, adjacency_threshold=0.5, | |
| properties_per_batch=4, max_tokens=384, overlap_tokens=24, progress=None): | |
| """Run a complete definition locally, using the resumable inference iterator.""" | |
| steps = self.iter_pass(model, definition, properties=properties, prompts=prompts, | |
| anchor=anchor, threshold=threshold, | |
| relation_threshold=relation_threshold, | |
| adjacency_threshold=adjacency_threshold, | |
| properties_per_batch=properties_per_batch, | |
| max_tokens=max_tokens, overlap_tokens=overlap_tokens, | |
| progress=progress) | |
| predictions = None | |
| while True: | |
| try: | |
| requests = steps.send(predictions) | |
| except StopIteration as completed: | |
| return completed.value | |
| predictions = [model.inference(request["texts"], **{ | |
| key: value for key, value in request.items() if key != "texts" | |
| }) for request in requests] | |
| def iter_pass(self, model, definition="AcceptActionUsage", *, properties=None, prompts=None, | |
| anchor=None, threshold=0.4, relation_threshold=0.6, adjacency_threshold=0.5, | |
| properties_per_batch=4, max_tokens=384, overlap_tokens=24, progress=None, | |
| request_batch_size=32): | |
| """Classify spans by property and predict directed anchor -> property-value links. | |
| Yield at most 32 inference request dictionaries at a time. Each request has | |
| a ``texts`` list and the remaining ``model.inference`` keyword arguments. | |
| Send back one prediction per request in the same order. Planning and all | |
| validation/merging happen outside inference, so a caller can release its | |
| GPU allocation between yields. The final result is StopIteration.value. | |
| Rerunning a definition replaces that pass atomically. Other passes survive. | |
| Co-occurrence alone never creates edges. An unlinked classification remains visible. | |
| Enumeration passes classify declared values; identity-only passes record coverage. | |
| """ | |
| threshold, relation_threshold, adjacency_threshold = map( | |
| _score, (threshold, relation_threshold, adjacency_threshold)) | |
| if not isinstance(properties_per_batch, int) or properties_per_batch < 1: | |
| raise ValueError("properties_per_batch must be a positive integer.") | |
| if not isinstance(overlap_tokens, int) or overlap_tokens < 0 or max_tokens < 64: | |
| raise ValueError("Use max_tokens >= 64 and a nonnegative integer overlap_tokens.") | |
| if not isinstance(request_batch_size, int) or not 1 <= request_batch_size <= 32: | |
| raise ValueError("request_batch_size must be an integer between 1 and 32.") | |
| plan = self.plan(definition, properties=properties, prompts=prompts, anchor=anchor) | |
| if plan["kind"] != "object": | |
| return (yield from self._iter_value_pass(model, plan, threshold, relation_threshold, | |
| adjacency_threshold, properties_per_batch, max_tokens, overlap_tokens, | |
| progress, request_batch_size)) | |
| tokenizer = model.data_processor.transformer_tokenizer | |
| limit = min(max_tokens, int(getattr(model.config, "max_len", max_tokens) or max_tokens)) | |
| mentions, links = {}, {} | |
| chunk_count = 0 | |
| rejected = 0 | |
| rows = plan["properties"] | |
| for first in range(0, len(rows), properties_per_batch): | |
| batch = rows[first:first + properties_per_batch] | |
| # Colliding role prompts must remain distinguishable from the anchor and each other. | |
| labels = [plan["anchor_prompt"]] | |
| entity_to_property, relation_to_property = {}, {} | |
| for row in batch: | |
| label = row["entity_prompt"] | |
| if label in labels: | |
| label += " as " + humanize(row["property"]).lower() | |
| labels.append(label) | |
| entity_to_property[label] = row["property"] | |
| relation = row["relation_prompt"] | |
| if relation in relation_to_property: | |
| relation += " (" + humanize(row["property"]).lower() + ")" | |
| relation_to_property[relation] = row["property"] | |
| chunks = list(_chunks(self.text, tokenizer, labels + list(relation_to_property), limit, overlap_tokens)) | |
| for chunk_first in range(0, len(chunks), request_batch_size): | |
| pending = chunks[chunk_first:chunk_first + request_batch_size] | |
| requests = [{"texts": [chunk["text"]], "labels": labels, | |
| "relations": list(relation_to_property), "threshold": threshold, | |
| "relation_threshold": relation_threshold, | |
| "adjacency_threshold": adjacency_threshold, "flat_ner": False, | |
| "multi_label": True, "batch_size": 1, "return_relations": True} | |
| for chunk in pending] | |
| predictions = yield requests | |
| if not isinstance(predictions, list) or len(predictions) != len(pending): | |
| raise ValueError("Send one inference prediction per requested text window.") | |
| for number, chunk, prediction in zip( | |
| range(chunk_first, chunk_first + len(pending)), pending, predictions): | |
| rejected += self._merge_object_window(prediction, chunk, labels, entity_to_property, | |
| relation_to_property, threshold, relation_threshold, mentions, links) | |
| chunk_count += 1 | |
| if progress: | |
| progress(f"{definition}: properties {first+1}-{first+len(batch)}/{len(rows)}, " | |
| f"text window {number+1}/{len(chunks)}") | |
| result = {"definition": definition, "plan": plan, | |
| "completed_at": datetime.now(timezone.utc).isoformat(), | |
| "settings": {"threshold": threshold, "relation_threshold": relation_threshold, | |
| "adjacency_threshold": adjacency_threshold, | |
| "properties_per_batch": properties_per_batch, "max_tokens": limit, | |
| "overlap_tokens": overlap_tokens}, | |
| "model": getattr(model, "knowledge_graph_checkpoint", type(model).__name__), | |
| "package_versions": {name: importlib.metadata.version(name) | |
| for name in ("gliner", "torch", "transformers")}, | |
| "mentions": sorted(mentions.values(), key=lambda m: (m["start"], m["end"], m["property"] or "")), | |
| "edges": list(links.values()), "inference_windows": chunk_count, | |
| "rejected_relations": rejected} | |
| self.passes[definition] = result | |
| return deepcopy(result) | |
| def _merge_object_window(self, prediction, chunk, labels, entity_to_property, | |
| relation_to_property, threshold, relation_threshold, mentions, links): | |
| """Merge a prediction into an unfinished pass using original-document offsets.""" | |
| rejected = 0 | |
| if not isinstance(prediction, tuple) or len(prediction) != 2: | |
| raise ValueError("Load a GLiNER RelEx checkpoint with joint relation extraction.") | |
| entity_batches, relation_batches = prediction | |
| if len(entity_batches) != 1 or len(relation_batches) != 1: | |
| raise ValueError("Unexpected model output batch lengths.") | |
| entities, relations = entity_batches[0], relation_batches[0] | |
| local = {} | |
| for entity in entities: | |
| a, b, label = entity["start"], entity["end"], entity["label"] | |
| score = _score(entity["score"]) | |
| if label not in labels or not 0 <= a < b <= len(chunk["text"]) or chunk["text"][a:b] != entity["text"]: | |
| raise ValueError("Model entity label or character offsets do not match the input.") | |
| if score < threshold: | |
| continue | |
| role = entity_to_property.get(label) # None denotes the definition's anchor. | |
| key = (chunk["start"] + a, chunk["start"] + b, role) | |
| value = {"text": entity["text"], "start": key[0], "end": key[1], | |
| "property": role, "score": score} | |
| local[(a, b, label)] = value | |
| if key not in mentions or score > mentions[key]["score"]: | |
| mentions[key] = value | |
| for relation in relations: | |
| score = _score(relation["score"]) | |
| prop = relation_to_property.get(relation["relation"]) | |
| if prop is None: | |
| raise ValueError("Unexpected relation prompt in model output.") | |
| endpoints = [] | |
| for side in ("head", "tail"): | |
| endpoint = relation[side] | |
| # The API provides exact spans and labels; do not infer links from text | |
| # proximity or assume entity_idx indexes a postprocessed entity list. | |
| a, b = endpoint["start"], endpoint["end"] | |
| if not 0 <= a < b <= len(chunk["text"]) or chunk["text"][a:b] != endpoint["text"]: | |
| raise ValueError("Relation endpoint does not match its source passage.") | |
| endpoints.append(local.get((a, b, endpoint["type"]))) | |
| head, tail = endpoints | |
| if (score < relation_threshold or head is None or tail is None or | |
| head["property"] is not None or tail["property"] != prop): | |
| rejected += 1 | |
| continue | |
| key = (head["text"], prop, tail["text"]) | |
| edge = links.setdefault(key, {"source": head["text"], "property": prop, | |
| "target": tail["text"], "label": humanize(prop).lower(), "score": score, | |
| "evidence": []}) | |
| edge["score"] = max(edge["score"], score) | |
| evidence = {**chunk, "source_span": [head["start"], head["end"]], | |
| "target_span": [tail["start"], tail["end"]], "score": score} | |
| if evidence not in edge["evidence"]: | |
| edge["evidence"].append(evidence) | |
| return rejected | |
| def _iter_value_pass(self, model, plan, threshold, relation_threshold, adjacency_threshold, | |
| properties_per_batch, max_tokens, overlap_tokens, progress, | |
| request_batch_size): | |
| """Enum passes classify contextual text; the Identified pass records metadata-only coverage.""" | |
| limit = min(max_tokens, int(getattr(getattr(model, "config", None), "max_len", max_tokens) or max_tokens)) | |
| mentions, windows = {}, 0 | |
| if plan["kind"] == "enumeration": | |
| labels = {prompt: value for value, prompt in plan["enum_prompts"].items()} | |
| tokenizer = model.data_processor.transformer_tokenizer | |
| chunks = list(_chunks(self.text, tokenizer, list(labels), limit, overlap_tokens)) | |
| for first in range(0, len(chunks), request_batch_size): | |
| pending = chunks[first:first + request_batch_size] | |
| predictions = yield [{"texts": [chunk["text"]], "labels": list(labels), | |
| "relations": [], "threshold": threshold, "flat_ner": False, | |
| "multi_label": True, "batch_size": 1, "return_relations": False} | |
| for chunk in pending] | |
| if not isinstance(predictions, list) or len(predictions) != len(pending): | |
| raise ValueError("Send one inference prediction per requested text window.") | |
| for number, chunk, batches in zip( | |
| range(first + 1, first + len(pending) + 1), pending, predictions): | |
| if not isinstance(batches, list) or len(batches) != 1: | |
| raise ValueError("Unexpected enumeration span output.") | |
| for entity in batches[0]: | |
| a, b, label, score = entity["start"], entity["end"], entity["label"], _score(entity["score"]) | |
| if label not in labels or not 0 <= a < b <= len(chunk["text"]) or chunk["text"][a:b] != entity["text"]: | |
| raise ValueError("Enumeration prediction does not match its source text or declared labels.") | |
| if score < threshold: | |
| continue | |
| key = (chunk["start"] + a, chunk["start"] + b, labels[label]) | |
| if key not in mentions or score > mentions[key]["score"]: | |
| mentions[key] = {"text": entity["text"], "start": key[0], "end": key[1], | |
| "property": None, "enum_value": labels[label], "score": score} | |
| windows += 1 | |
| if progress: | |
| progress(f"{plan['definition']}: enum text window {number}/{len(chunks)}") | |
| elif progress: | |
| progress(f"{plan['definition']}: metadata-only pass; no ID nodes created") | |
| result = {"definition": plan["definition"], "plan": plan, | |
| "status": "metadata_only" if plan["kind"] == "identity" else "completed", | |
| "completed_at": datetime.now(timezone.utc).isoformat(), | |
| "settings": {"threshold": threshold, "relation_threshold": relation_threshold, | |
| "adjacency_threshold": adjacency_threshold, "properties_per_batch": properties_per_batch, | |
| "max_tokens": limit, "overlap_tokens": overlap_tokens}, | |
| "model": getattr(model, "knowledge_graph_checkpoint", type(model).__name__), | |
| "package_versions": {name: importlib.metadata.version(name) for name in ("gliner", "torch", "transformers")}, | |
| "mentions": sorted(mentions.values(), key=lambda m: (m["start"], m["end"], m["enum_value"])), | |
| "edges": [], "inference_windows": windows, "rejected_relations": 0} | |
| self.passes[plan["definition"]] = result | |
| return deepcopy(result) | |
| def graph(self): | |
| nodes, edges = {}, [] | |
| for definition, result in self.passes.items(): | |
| for mention in result["mentions"]: | |
| node = nodes.setdefault(mention["text"], {"text": mention["text"], "classifications": []}) | |
| node["classifications"].append({"definition": definition, **mention}) | |
| edges.extend({"definition": definition, **deepcopy(e)} for e in result["edges"]) | |
| return {"nodes": list(nodes.values()), "edges": edges, "passes": list(self.passes)} | |
| def triples(self): | |
| """Readable statements; no opaque node identifiers.""" | |
| return [{"source": e["source"], "property": e["property"], "target": e["target"], | |
| "definition": e["definition"], "score": e["score"]} for e in self.graph()["edges"]] | |
| def save(self, path): | |
| path = Path(path) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(json.dumps({"format": "schema-text-knowledge-graph-v1", "text": self.text, | |
| "passes": self.passes, "source_document": self.source_document}, | |
| ensure_ascii=False, indent=2), encoding="utf-8") | |
| return path | |
| def load(cls, path, schema_path=ROOT / "sysml.schema.json"): | |
| data = json.loads(Path(path).read_text(encoding="utf-8")) | |
| if data.get("format") != "schema-text-knowledge-graph-v1": | |
| raise ValueError("Unsupported knowledge graph checkpoint.") | |
| instance = cls(data["text"], schema_path) | |
| if set(data["passes"]) - set(instance.definitions): | |
| raise ValueError("Checkpoint contains definitions absent from this schema.") | |
| instance.passes = data["passes"] | |
| for result in instance.passes.values(): | |
| # Older checkpoints contained only object passes and did not name their kind. | |
| result["plan"].setdefault("kind", "object") | |
| instance.source_document = data.get("source_document") | |
| return instance | |
| def html(self): | |
| template = (ROOT / "knowledge_graph_view.html").read_text(encoding="utf-8") | |
| data = {**self.graph(), "text": self.text, "document": self.source_document} | |
| payload = json.dumps(data, ensure_ascii=False).replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e") | |
| return template.replace("__GRAPH_DATA__", payload) | |
| def show(self, height=850): | |
| from IPython.display import HTML, display | |
| display(HTML(f'<iframe title="Knowledge graph" style="width:100%;height:{int(height)}px;border:0" ' | |
| f'srcdoc="{html.escape(self.html(), quote=True)}"></iframe>')) | |
| def export_html(self, path): | |
| path = Path(path) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(self.html(), encoding="utf-8") | |
| return path | |