jevy-graph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jevy_graph/__init__.py +13 -0
- jevy_graph/__main__.py +4 -0
- jevy_graph/cli.py +94 -0
- jevy_graph/compiler.py +87 -0
- jevy_graph/config.py +16 -0
- jevy_graph/demo/app.js +480 -0
- jevy_graph/demo/index.html +54 -0
- jevy_graph/demo/styles.css +238 -0
- jevy_graph/demo_server.py +200 -0
- jevy_graph/extract.py +2056 -0
- jevy_graph/jev.py +666 -0
- jevy_graph/models.py +51 -0
- jevy_graph/normalize.py +112 -0
- jevy_graph/rdf.py +131 -0
- jevy_graph/structured.py +742 -0
- jevy_graph-0.1.0.dist-info/METADATA +253 -0
- jevy_graph-0.1.0.dist-info/RECORD +20 -0
- jevy_graph-0.1.0.dist-info/WHEEL +4 -0
- jevy_graph-0.1.0.dist-info/entry_points.txt +3 -0
- jevy_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
jevy_graph/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Compile text into source-grounded RDF."""
|
|
2
|
+
|
|
3
|
+
from .compiler import Compilation, Thresholds, compile_text
|
|
4
|
+
from .models import CandidateTriple, RelationFrame, VerifiedTriple
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CandidateTriple",
|
|
8
|
+
"Compilation",
|
|
9
|
+
"RelationFrame",
|
|
10
|
+
"Thresholds",
|
|
11
|
+
"VerifiedTriple",
|
|
12
|
+
"compile_text",
|
|
13
|
+
]
|
jevy_graph/__main__.py
ADDED
jevy_graph/cli.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .compiler import Thresholds, compile_text
|
|
9
|
+
from .config import load_dotenv
|
|
10
|
+
from .jev import JevClient, JevError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="jevy-graph",
|
|
16
|
+
description="Compile UTF-8 text into source-grounded RDF/Turtle.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("input", nargs="?", default="-", help="text file, or - for stdin")
|
|
19
|
+
parser.add_argument("-o", "--output", help="output Turtle path; defaults to stdout")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--threshold",
|
|
22
|
+
type=float,
|
|
23
|
+
default=0.45,
|
|
24
|
+
help="minimum exact-triple support probability (default: 0.45)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--entity-threshold",
|
|
28
|
+
type=float,
|
|
29
|
+
default=0.10,
|
|
30
|
+
help="minimum RDF node-label quality probability (default: 0.10)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--joint-threshold",
|
|
34
|
+
type=float,
|
|
35
|
+
default=0.70,
|
|
36
|
+
help="minimum support plus entity-quality score (default: 0.70)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--no-verify",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="skip Jev and emit all deterministic candidates",
|
|
42
|
+
)
|
|
43
|
+
return parser
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_text(source: str) -> str:
|
|
47
|
+
if source == "-":
|
|
48
|
+
return sys.stdin.read()
|
|
49
|
+
return Path(source).read_text(encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
args = _parser().parse_args(argv)
|
|
54
|
+
if not 0 <= args.threshold <= 1:
|
|
55
|
+
raise SystemExit("--threshold must be between 0 and 1")
|
|
56
|
+
if not 0 <= args.entity_threshold <= 1:
|
|
57
|
+
raise SystemExit("--entity-threshold must be between 0 and 1")
|
|
58
|
+
if not 0 <= args.joint_threshold <= 2:
|
|
59
|
+
raise SystemExit("--joint-threshold must be between 0 and 2")
|
|
60
|
+
|
|
61
|
+
text = _read_text(args.input)
|
|
62
|
+
client = None
|
|
63
|
+
if not args.no_verify:
|
|
64
|
+
load_dotenv()
|
|
65
|
+
api_key = os.environ.get("TYPESAFE_API_KEY", "")
|
|
66
|
+
if not api_key:
|
|
67
|
+
raise SystemExit("TYPESAFE_API_KEY is missing; set it in the environment or .env")
|
|
68
|
+
client = JevClient(api_key)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
result = compile_text(
|
|
72
|
+
text,
|
|
73
|
+
client=client,
|
|
74
|
+
thresholds=Thresholds(
|
|
75
|
+
args.threshold, args.entity_threshold, args.joint_threshold
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
except JevError as error:
|
|
79
|
+
raise SystemExit(str(error)) from error
|
|
80
|
+
if args.output:
|
|
81
|
+
Path(args.output).write_text(result.turtle, encoding="utf-8")
|
|
82
|
+
else:
|
|
83
|
+
sys.stdout.write(result.turtle)
|
|
84
|
+
|
|
85
|
+
print(
|
|
86
|
+
f"frames={result.frames} resolved={result.resolved} accepted={result.accepted}"
|
|
87
|
+
f" singletons={result.singletons}",
|
|
88
|
+
file=sys.stderr,
|
|
89
|
+
)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
raise SystemExit(main())
|
jevy_graph/compiler.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .extract import candidates_from_frames, extract_frames
|
|
6
|
+
from .jev import JevClient
|
|
7
|
+
from .models import VerifiedTriple
|
|
8
|
+
from .normalize import graphable_node
|
|
9
|
+
from .rdf import render_turtle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Thresholds:
|
|
14
|
+
support: float = 0.45
|
|
15
|
+
entity: float = 0.10
|
|
16
|
+
joint: float = 0.70
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Compilation:
|
|
21
|
+
turtle: str
|
|
22
|
+
frames: int
|
|
23
|
+
resolved: int
|
|
24
|
+
accepted: int
|
|
25
|
+
singletons: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def accepts(item: VerifiedTriple, thresholds: Thresholds = Thresholds()) -> bool:
|
|
29
|
+
floors = {
|
|
30
|
+
"open_verb": Thresholds(0.50, 0.20, 0.80),
|
|
31
|
+
}.get(item.candidate.origin, Thresholds())
|
|
32
|
+
subject = item.candidate.subject
|
|
33
|
+
object_ = item.candidate.object
|
|
34
|
+
return (
|
|
35
|
+
graphable_node(subject)
|
|
36
|
+
and graphable_node(object_)
|
|
37
|
+
and item.support >= max(thresholds.support, floors.support)
|
|
38
|
+
and item.entity_quality >= max(thresholds.entity, floors.entity)
|
|
39
|
+
and item.support + item.entity_quality >= max(thresholds.joint, floors.joint)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def select(
|
|
44
|
+
items: list[VerifiedTriple],
|
|
45
|
+
thresholds: Thresholds = Thresholds(),
|
|
46
|
+
seen: set[tuple[str, str, str, str, str, str]] | None = None,
|
|
47
|
+
) -> list[VerifiedTriple]:
|
|
48
|
+
"""Filter scores and collapse duplicate readings of one source occurrence."""
|
|
49
|
+
seen = seen if seen is not None else set()
|
|
50
|
+
selected: list[VerifiedTriple] = []
|
|
51
|
+
for item in items:
|
|
52
|
+
candidate = item.candidate
|
|
53
|
+
key = (
|
|
54
|
+
candidate.subject.casefold(),
|
|
55
|
+
candidate.predicate,
|
|
56
|
+
candidate.object.casefold(),
|
|
57
|
+
candidate.evidence.casefold(),
|
|
58
|
+
candidate.modality or "",
|
|
59
|
+
candidate.polarity,
|
|
60
|
+
)
|
|
61
|
+
if accepts(item, thresholds) and key not in seen:
|
|
62
|
+
seen.add(key)
|
|
63
|
+
selected.append(item)
|
|
64
|
+
return selected
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compile_text(
|
|
68
|
+
text: str,
|
|
69
|
+
*,
|
|
70
|
+
client: JevClient | None = None,
|
|
71
|
+
thresholds: Thresholds = Thresholds(),
|
|
72
|
+
) -> Compilation:
|
|
73
|
+
"""Compile text to Turtle, using Jev when a client is supplied."""
|
|
74
|
+
frames = extract_frames(text)
|
|
75
|
+
if client is None:
|
|
76
|
+
candidates = candidates_from_frames(frames)
|
|
77
|
+
verified = [VerifiedTriple(candidate, 1.0, 1.0) for candidate in candidates]
|
|
78
|
+
else:
|
|
79
|
+
verified = client.score(frames)
|
|
80
|
+
accepted = select(verified, thresholds)
|
|
81
|
+
return Compilation(
|
|
82
|
+
turtle=render_turtle(text, accepted),
|
|
83
|
+
frames=len(frames),
|
|
84
|
+
resolved=len(verified),
|
|
85
|
+
accepted=len(accepted),
|
|
86
|
+
singletons=client.singleton_selections if client else 0,
|
|
87
|
+
)
|
jevy_graph/config.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_dotenv(path: Path = Path(".env")) -> None:
|
|
8
|
+
"""Load simple KEY=VALUE entries without overriding the environment."""
|
|
9
|
+
if not path.is_file():
|
|
10
|
+
return
|
|
11
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
12
|
+
line = raw_line.strip()
|
|
13
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
14
|
+
continue
|
|
15
|
+
name, value = line.split("=", 1)
|
|
16
|
+
os.environ.setdefault(name.strip(), value.strip().strip('"').strip("'"))
|
jevy_graph/demo/app.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
const drop = document.querySelector("#drop");
|
|
2
|
+
const fileInput = document.querySelector("#file-input");
|
|
3
|
+
const graph = document.querySelector("#cy");
|
|
4
|
+
const sourceText = document.querySelector("#source-text");
|
|
5
|
+
const tripleText = document.querySelector("#triple-text");
|
|
6
|
+
const edgeCount = document.querySelector("#edge-count");
|
|
7
|
+
const nodeCount = document.querySelector("#node-count");
|
|
8
|
+
const elapsedTime = document.querySelector("#elapsed-time");
|
|
9
|
+
const MEDIUM_GRAPH_NODES = 900;
|
|
10
|
+
const LARGE_GRAPH_NODES = 2500;
|
|
11
|
+
|
|
12
|
+
let activeRequest = 0;
|
|
13
|
+
let uploadController = null;
|
|
14
|
+
let selectedNodeId = null;
|
|
15
|
+
let overviewPositions = new Map();
|
|
16
|
+
|
|
17
|
+
const cy = cytoscape({
|
|
18
|
+
container: graph,
|
|
19
|
+
elements: [],
|
|
20
|
+
hideEdgesOnViewport: true,
|
|
21
|
+
textureOnViewport: true,
|
|
22
|
+
minZoom: 0.12,
|
|
23
|
+
maxZoom: 3,
|
|
24
|
+
style: [
|
|
25
|
+
{
|
|
26
|
+
selector: "node",
|
|
27
|
+
style: {
|
|
28
|
+
"background-color": "#fff",
|
|
29
|
+
"border-color": "#111",
|
|
30
|
+
"border-width": 1,
|
|
31
|
+
color: "#111",
|
|
32
|
+
content: "data(label)",
|
|
33
|
+
"font-family": "Inter, -apple-system, sans-serif",
|
|
34
|
+
"font-size": 11,
|
|
35
|
+
"font-weight": 600,
|
|
36
|
+
height: "data(height)",
|
|
37
|
+
width: "data(width)",
|
|
38
|
+
padding: 6,
|
|
39
|
+
shape: "round-rectangle",
|
|
40
|
+
"text-background-color": "#fff",
|
|
41
|
+
"text-background-opacity": 0.92,
|
|
42
|
+
"text-background-padding": 3,
|
|
43
|
+
"text-halign": "center",
|
|
44
|
+
"text-justification": "center",
|
|
45
|
+
"text-max-width": 160,
|
|
46
|
+
"text-valign": "center",
|
|
47
|
+
"text-wrap": "wrap",
|
|
48
|
+
"line-height": 1.15,
|
|
49
|
+
"z-index": 11
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
selector: "edge",
|
|
54
|
+
style: {
|
|
55
|
+
"curve-style": "bezier",
|
|
56
|
+
"line-color": "#c2c2c2",
|
|
57
|
+
"target-arrow-color": "#777",
|
|
58
|
+
"target-arrow-shape": "triangle",
|
|
59
|
+
"arrow-scale": 0.55,
|
|
60
|
+
width: 0.8
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
selector: "edge.focused",
|
|
65
|
+
style: {
|
|
66
|
+
"line-color": "#111",
|
|
67
|
+
"target-arrow-color": "#111",
|
|
68
|
+
width: 2,
|
|
69
|
+
label: "data(label)",
|
|
70
|
+
color: "#111",
|
|
71
|
+
"font-family": "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
72
|
+
"font-size": 11,
|
|
73
|
+
"text-background-color": "#fff",
|
|
74
|
+
"text-background-opacity": 1,
|
|
75
|
+
"text-background-padding": 4,
|
|
76
|
+
"text-max-width": 180,
|
|
77
|
+
"text-rotation": "none",
|
|
78
|
+
"text-wrap": "wrap",
|
|
79
|
+
"z-index": 10
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
selector: "node.focused",
|
|
84
|
+
style: {
|
|
85
|
+
"background-color": "#111",
|
|
86
|
+
"border-color": "#111",
|
|
87
|
+
color: "#fff",
|
|
88
|
+
"text-background-color": "#111",
|
|
89
|
+
"text-background-opacity": 1,
|
|
90
|
+
"z-index": 11
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
selector: "node.hovered",
|
|
95
|
+
style: {
|
|
96
|
+
"border-width": 3,
|
|
97
|
+
"z-index": 20
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
{ selector: ".faded", style: { opacity: 0 } },
|
|
101
|
+
{ selector: ".hidden", style: { display: "none" } }
|
|
102
|
+
],
|
|
103
|
+
layout: { name: "preset" }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
cy.layoutUtilities({
|
|
107
|
+
componentSpacing: 120,
|
|
108
|
+
desiredAspectRatio: 1.5,
|
|
109
|
+
polyominoGridSizeFactor: 0.75
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function idFor(value) {
|
|
113
|
+
let hash = 2166136261;
|
|
114
|
+
for (const character of value.toLowerCase()) {
|
|
115
|
+
hash ^= character.charCodeAt(0);
|
|
116
|
+
hash = Math.imul(hash, 16777619);
|
|
117
|
+
}
|
|
118
|
+
return `n-${(hash >>> 0).toString(36)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function streamPosition(index) {
|
|
122
|
+
const width = Math.max(600, graph.clientWidth);
|
|
123
|
+
const height = Math.max(500, graph.clientHeight);
|
|
124
|
+
const angle = index * Math.PI * (3 - Math.sqrt(5));
|
|
125
|
+
const radius = 150 * Math.sqrt(index);
|
|
126
|
+
return {
|
|
127
|
+
x: width / 2 + Math.cos(angle) * radius,
|
|
128
|
+
y: height / 2 + Math.sin(angle) * radius
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let renderFrame = null;
|
|
133
|
+
let pendingClaims = [];
|
|
134
|
+
let streamFinished = false;
|
|
135
|
+
let nextClaimIndex = 0;
|
|
136
|
+
let lastStreamFitAt = 0;
|
|
137
|
+
let lastStreamFitNodeCount = 0;
|
|
138
|
+
|
|
139
|
+
function updateGraphStats() {
|
|
140
|
+
edgeCount.textContent = String(cy.edges().length);
|
|
141
|
+
nodeCount.textContent = String(cy.nodes().length);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fitStreamGraph(force = false) {
|
|
145
|
+
const now = performance.now();
|
|
146
|
+
const nodes = cy.nodes().length;
|
|
147
|
+
const shouldFit =
|
|
148
|
+
force ||
|
|
149
|
+
nodes < MEDIUM_GRAPH_NODES ||
|
|
150
|
+
now - lastStreamFitAt >= 350 ||
|
|
151
|
+
nodes - lastStreamFitNodeCount >= 300;
|
|
152
|
+
if (!shouldFit) return;
|
|
153
|
+
|
|
154
|
+
cy.fit(cy.elements(), 54);
|
|
155
|
+
lastStreamFitAt = now;
|
|
156
|
+
lastStreamFitNodeCount = nodes;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function addClaims(items, requestId) {
|
|
160
|
+
const widthFor = (label) => Math.min(220, Math.max(68, label.length * 7));
|
|
161
|
+
const heightFor = (label) =>
|
|
162
|
+
Math.min(84, Math.max(32, Math.ceil(label.length / 26) * 15));
|
|
163
|
+
const nodeIds = new Set(cy.nodes().map((node) => node.id()));
|
|
164
|
+
const elements = [];
|
|
165
|
+
|
|
166
|
+
for (const { claim, index } of items) {
|
|
167
|
+
const subjectId = idFor(claim.subject);
|
|
168
|
+
const objectId = idFor(claim.object);
|
|
169
|
+
for (const [id, label] of [
|
|
170
|
+
[subjectId, claim.subject],
|
|
171
|
+
[objectId, claim.object]
|
|
172
|
+
]) {
|
|
173
|
+
if (nodeIds.has(id)) continue;
|
|
174
|
+
elements.push({
|
|
175
|
+
group: "nodes",
|
|
176
|
+
data: {
|
|
177
|
+
id,
|
|
178
|
+
label,
|
|
179
|
+
width: widthFor(label),
|
|
180
|
+
height: heightFor(label)
|
|
181
|
+
},
|
|
182
|
+
position: streamPosition(nodeIds.size)
|
|
183
|
+
});
|
|
184
|
+
nodeIds.add(id);
|
|
185
|
+
}
|
|
186
|
+
elements.push({
|
|
187
|
+
group: "edges",
|
|
188
|
+
data: {
|
|
189
|
+
id: `e-${requestId}-${index}`,
|
|
190
|
+
source: subjectId,
|
|
191
|
+
target: objectId,
|
|
192
|
+
label: claim.predicate.replaceAll("_", " "),
|
|
193
|
+
evidence: claim.evidence
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
cy.add(elements);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function scheduleRender(requestId) {
|
|
201
|
+
if (renderFrame !== null) return;
|
|
202
|
+
renderFrame = window.requestAnimationFrame(() => {
|
|
203
|
+
renderFrame = null;
|
|
204
|
+
if (requestId !== activeRequest) return;
|
|
205
|
+
|
|
206
|
+
const ready = pendingClaims;
|
|
207
|
+
pendingClaims = [];
|
|
208
|
+
if (ready.length) {
|
|
209
|
+
addClaims(ready, requestId);
|
|
210
|
+
updateGraphStats();
|
|
211
|
+
if (!selectedNodeId) fitStreamGraph();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (pendingClaims.length) {
|
|
215
|
+
scheduleRender(requestId);
|
|
216
|
+
} else if (streamFinished) {
|
|
217
|
+
streamFinished = false;
|
|
218
|
+
window.requestAnimationFrame(() => {
|
|
219
|
+
if (requestId === activeRequest) finishGraph(requestId);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function enqueueClaim(claim, requestId) {
|
|
226
|
+
enqueueClaims([claim], requestId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function enqueueClaims(claims, requestId) {
|
|
230
|
+
if (requestId !== activeRequest) return;
|
|
231
|
+
for (const claim of claims) {
|
|
232
|
+
pendingClaims.push({ claim, index: nextClaimIndex });
|
|
233
|
+
nextClaimIndex += 1;
|
|
234
|
+
}
|
|
235
|
+
scheduleRender(requestId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function finishGraph(requestId) {
|
|
239
|
+
const preserveOverview = () => {
|
|
240
|
+
if (requestId !== activeRequest) return;
|
|
241
|
+
selectedNodeId = null;
|
|
242
|
+
overviewPositions = new Map(
|
|
243
|
+
cy.nodes().map((node) => [node.id(), { ...node.position() }])
|
|
244
|
+
);
|
|
245
|
+
fitStreamGraph(true);
|
|
246
|
+
drop.classList.remove("busy");
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const nodes = cy.nodes().length;
|
|
250
|
+
if (nodes > LARGE_GRAPH_NODES) {
|
|
251
|
+
preserveOverview();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const mediumGraph = nodes > MEDIUM_GRAPH_NODES;
|
|
256
|
+
cy.layout({
|
|
257
|
+
name: "fcose",
|
|
258
|
+
quality: mediumGraph ? "default" : "proof",
|
|
259
|
+
randomize: true,
|
|
260
|
+
animate: false,
|
|
261
|
+
fit: true,
|
|
262
|
+
padding: 64,
|
|
263
|
+
nodeDimensionsIncludeLabels: !mediumGraph,
|
|
264
|
+
uniformNodeDimensions: false,
|
|
265
|
+
packComponents: true,
|
|
266
|
+
nodeSeparation: 110,
|
|
267
|
+
nodeRepulsion: () => 16000,
|
|
268
|
+
idealEdgeLength: () => 170,
|
|
269
|
+
edgeElasticity: () => 0.2,
|
|
270
|
+
tilingPaddingHorizontal: 70,
|
|
271
|
+
tilingPaddingVertical: 70,
|
|
272
|
+
gravity: 0.12,
|
|
273
|
+
gravityRange: 4.5,
|
|
274
|
+
initialEnergyOnIncremental: 0.5,
|
|
275
|
+
stop: preserveOverview
|
|
276
|
+
}).run();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function compile(file) {
|
|
280
|
+
uploadController?.abort();
|
|
281
|
+
uploadController = new AbortController();
|
|
282
|
+
activeRequest += 1;
|
|
283
|
+
const requestId = activeRequest;
|
|
284
|
+
const startedAt = performance.now();
|
|
285
|
+
const updateTimer = () => {
|
|
286
|
+
if (requestId === activeRequest) {
|
|
287
|
+
elapsedTime.textContent = `${((performance.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
elapsedTime.textContent = "0.0s";
|
|
291
|
+
const timer = window.setInterval(updateTimer, 100);
|
|
292
|
+
const stopTimer = () => {
|
|
293
|
+
window.clearInterval(timer);
|
|
294
|
+
updateTimer();
|
|
295
|
+
};
|
|
296
|
+
selectedNodeId = null;
|
|
297
|
+
overviewPositions = new Map();
|
|
298
|
+
pendingClaims = [];
|
|
299
|
+
streamFinished = false;
|
|
300
|
+
nextClaimIndex = 0;
|
|
301
|
+
lastStreamFitAt = 0;
|
|
302
|
+
lastStreamFitNodeCount = 0;
|
|
303
|
+
if (renderFrame !== null) {
|
|
304
|
+
window.cancelAnimationFrame(renderFrame);
|
|
305
|
+
renderFrame = null;
|
|
306
|
+
}
|
|
307
|
+
cy.elements().remove();
|
|
308
|
+
updateGraphStats();
|
|
309
|
+
setSourceText("Hover over an edge or leaf node to see its source sentence.");
|
|
310
|
+
drop.classList.add("busy");
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const response = await fetch("/api/compile", {
|
|
314
|
+
method: "POST",
|
|
315
|
+
headers: {
|
|
316
|
+
"Content-Type": "application/octet-stream",
|
|
317
|
+
"X-Filename": encodeURIComponent(file.name)
|
|
318
|
+
},
|
|
319
|
+
signal: uploadController.signal,
|
|
320
|
+
body: file
|
|
321
|
+
});
|
|
322
|
+
if (!response.ok || !response.body) throw new Error(await response.text());
|
|
323
|
+
|
|
324
|
+
const reader = response.body.getReader();
|
|
325
|
+
const decoder = new TextDecoder();
|
|
326
|
+
let buffer = "";
|
|
327
|
+
let receivedDone = false;
|
|
328
|
+
|
|
329
|
+
while (requestId === activeRequest) {
|
|
330
|
+
const { value, done } = await reader.read();
|
|
331
|
+
if (requestId !== activeRequest) break;
|
|
332
|
+
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
|
333
|
+
const lines = buffer.split("\n");
|
|
334
|
+
buffer = lines.pop() || "";
|
|
335
|
+
for (const line of lines) {
|
|
336
|
+
if (!line) continue;
|
|
337
|
+
const event = JSON.parse(line);
|
|
338
|
+
if (event.type === "claim") {
|
|
339
|
+
enqueueClaim(event.claim, requestId);
|
|
340
|
+
} else if (event.type === "graph") {
|
|
341
|
+
enqueueClaims(event.claims, requestId);
|
|
342
|
+
} else if (event.type === "error") {
|
|
343
|
+
throw new Error(event.message);
|
|
344
|
+
} else if (event.type === "done") {
|
|
345
|
+
stopTimer();
|
|
346
|
+
receivedDone = true;
|
|
347
|
+
streamFinished = true;
|
|
348
|
+
scheduleRender(requestId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (done) break;
|
|
352
|
+
}
|
|
353
|
+
if (requestId === activeRequest && !receivedDone) {
|
|
354
|
+
throw new Error("The compilation stream ended before completion.");
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (requestId !== activeRequest || error.name === "AbortError") return;
|
|
358
|
+
setSourceText(error.message || "The document could not be compiled.");
|
|
359
|
+
if (requestId === activeRequest) drop.classList.remove("busy");
|
|
360
|
+
} finally {
|
|
361
|
+
window.clearInterval(timer);
|
|
362
|
+
if (!streamFinished) updateTimer();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function handleFiles(files) {
|
|
367
|
+
const file = files?.[0];
|
|
368
|
+
if (file) compile(file);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function showEdgeDetails(edge) {
|
|
372
|
+
setSourceText(edge.data("evidence"), `${edge.source().data("label")} — ${edge.data("label")} → ${edge.target().data("label")}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function setSourceText(text, triple = "") {
|
|
376
|
+
tripleText.textContent = triple || "—";
|
|
377
|
+
sourceText.textContent = text;
|
|
378
|
+
sourceText.classList.toggle("long", text.length > 260);
|
|
379
|
+
sourceText.classList.toggle("very-long", text.length > 440);
|
|
380
|
+
window.requestAnimationFrame(() => cy.resize());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function focusNode(node, zoom = false) {
|
|
384
|
+
const allEdges = [...node.connectedEdges()];
|
|
385
|
+
const edges = cy.collection(allEdges);
|
|
386
|
+
const neighborhood = edges.union(edges.connectedNodes()).union(node);
|
|
387
|
+
const neighbors = neighborhood.nodes().not(node);
|
|
388
|
+
cy.elements().addClass("hidden").removeClass("focused");
|
|
389
|
+
neighborhood.removeClass("hidden faded");
|
|
390
|
+
node.addClass("focused");
|
|
391
|
+
neighborhood.edges().removeClass("faded hidden");
|
|
392
|
+
|
|
393
|
+
setSourceText(`${node.data("label")} · ${allEdges.length} relationships`);
|
|
394
|
+
if (zoom) {
|
|
395
|
+
cy.stop();
|
|
396
|
+
const positionedNeighbors = [...neighbors];
|
|
397
|
+
cy.batch(() => {
|
|
398
|
+
node.position({ x: 0, y: 0 });
|
|
399
|
+
let offset = 0;
|
|
400
|
+
let radius = 360;
|
|
401
|
+
while (offset < positionedNeighbors.length) {
|
|
402
|
+
const capacity = Math.max(8, Math.floor((Math.PI * 2 * radius) / 230));
|
|
403
|
+
const ring = positionedNeighbors.slice(offset, offset + capacity);
|
|
404
|
+
ring.forEach((neighbor, index) => {
|
|
405
|
+
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / ring.length;
|
|
406
|
+
neighbor.position({
|
|
407
|
+
x: Math.cos(angle) * radius,
|
|
408
|
+
y: Math.sin(angle) * radius
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
offset += ring.length;
|
|
412
|
+
radius += 280;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
window.setTimeout(() => cy.fit(neighborhood, 100), 150);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function restoreOverview() {
|
|
420
|
+
cy.nodes().positions((node) => overviewPositions.get(node.id()) || node.position());
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function clearFocus() {
|
|
424
|
+
cy.elements().removeClass("faded focused hidden");
|
|
425
|
+
if (selectedNodeId) {
|
|
426
|
+
const selected = cy.$id(selectedNodeId);
|
|
427
|
+
if (selected.length) {
|
|
428
|
+
focusNode(selected);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
setSourceText("Hover over an edge or leaf node to see its source sentence.");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
cy.on("mouseover", "edge", (event) => {
|
|
436
|
+
showEdgeDetails(event.target);
|
|
437
|
+
});
|
|
438
|
+
cy.on("mouseover", "node", (event) => {
|
|
439
|
+
const node = event.target;
|
|
440
|
+
const edges = node.connectedEdges();
|
|
441
|
+
if (edges.length === 1) {
|
|
442
|
+
showEdgeDetails(edges[0]);
|
|
443
|
+
} else {
|
|
444
|
+
setSourceText(`${node.data("label")} · ${edges.length} relationships. Hover over an edge to see its triple and source sentence.`);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
cy.on("tap", "edge", (event) => showEdgeDetails(event.target));
|
|
448
|
+
cy.on("tap", "node", (event) => {
|
|
449
|
+
selectedNodeId = event.target.id();
|
|
450
|
+
focusNode(event.target, true);
|
|
451
|
+
});
|
|
452
|
+
cy.on("tap", (event) => {
|
|
453
|
+
if (event.target === cy) {
|
|
454
|
+
selectedNodeId = null;
|
|
455
|
+
restoreOverview();
|
|
456
|
+
clearFocus();
|
|
457
|
+
cy.animate(
|
|
458
|
+
{ fit: { eles: cy.elements(), padding: 48 } },
|
|
459
|
+
{ duration: 320 }
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
fileInput.addEventListener("change", (event) => handleFiles(event.target.files));
|
|
465
|
+
|
|
466
|
+
for (const eventName of ["dragenter", "dragover"]) {
|
|
467
|
+
drop.addEventListener(eventName, (event) => {
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
drop.classList.add("dragging");
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
for (const eventName of ["dragleave", "drop"]) {
|
|
474
|
+
drop.addEventListener(eventName, (event) => {
|
|
475
|
+
event.preventDefault();
|
|
476
|
+
drop.classList.remove("dragging");
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
drop.addEventListener("drop", (event) => handleFiles(event.dataTransfer.files));
|