framework-agents 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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- framework_agents-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1614 @@
|
|
|
1
|
+
"""Semantic graph layer. Parity: packages/typescript/src/semantic/index.ts +
|
|
2
|
+
entity-linking.ts + ontology-evolution.ts (merge into one module; ontology
|
|
3
|
+
review lives in ontology_review.py, remote SPARQL in remote_sparql.py).
|
|
4
|
+
|
|
5
|
+
In-memory store: RDFS-subset + OWL property axioms entailment with proofs,
|
|
6
|
+
SHACL-subset validation, versioned CONSTRUCT-style rules, SPARQL-subset SELECT
|
|
7
|
+
tools, entity linking + JSON-LD I/O, TBox evolution, and GraphMemory.
|
|
8
|
+
|
|
9
|
+
Pinned semantics (deviations from TS are deliberate, documented, and tested):
|
|
10
|
+
|
|
11
|
+
- Quad identity includes the named graph: the same s/p/o in two graphs are TWO
|
|
12
|
+
quads.
|
|
13
|
+
- add() validates provenance BEFORE writing: a missing source is a violation,
|
|
14
|
+
never a fact. Functional-property conflicts and SHACL violations are ERRORS
|
|
15
|
+
with rollback — a conflict is never committed as a second fact.
|
|
16
|
+
- Literals are written in the COMPACT Turtle form ("x"^^xsd:date, "x"@en) — the
|
|
17
|
+
TS store code uses single-quoted-less compact form with NO angle brackets;
|
|
18
|
+
the term parsers accept both compact and bracketed datatypes.
|
|
19
|
+
- TBox migration writes the new version's axioms to the version-named graph
|
|
20
|
+
``urn:framework:ontology:<to>`` (TS uses a personal-name prefix
|
|
21
|
+
a personal-name prefix — the Python port uses version-graph naming WITHOUT
|
|
22
|
+
any personal name).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import dataclasses
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any, Iterable, Literal, Mapping, Protocol, runtime_checkable
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
34
|
+
|
|
35
|
+
from .memory import Memory
|
|
36
|
+
from .errors import AgentError
|
|
37
|
+
from .tools import Tool, ToolContext, ToolResult, define_tool
|
|
38
|
+
from .types import ChatMessage, assistant
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Terms, quads, literals
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
RDF_TYPE = {"rdf:type", "a", "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}
|
|
46
|
+
SUB_CLASS = {"rdfs:subClassOf", "http://www.w3.org/2000/01/rdf-schema#subClassOf"}
|
|
47
|
+
SUB_PROP = {"rdfs:subPropertyOf", "http://www.w3.org/2000/01/rdf-schema#subPropertyOf"}
|
|
48
|
+
DOMAIN = {"rdfs:domain", "http://www.w3.org/2000/01/rdf-schema#domain"}
|
|
49
|
+
RANGE = {"rdfs:range", "http://www.w3.org/2000/01/rdf-schema#range"}
|
|
50
|
+
OWL_TRANSITIVE = {"owl:TransitiveProperty", "http://www.w3.org/2002/07/owl#TransitiveProperty"}
|
|
51
|
+
OWL_SYMMETRIC = {"owl:SymmetricProperty", "http://www.w3.org/2002/07/owl#SymmetricProperty"}
|
|
52
|
+
OWL_FUNCTIONAL = {"owl:FunctionalProperty", "http://www.w3.org/2002/07/owl#FunctionalProperty"}
|
|
53
|
+
OWL_INVERSE = {"owl:inverseOf", "http://www.w3.org/2002/07/owl#inverseOf"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Quad:
|
|
58
|
+
"""One statement. Identity is subject|predicate|object|graph."""
|
|
59
|
+
|
|
60
|
+
subject: str
|
|
61
|
+
predicate: str
|
|
62
|
+
object: str
|
|
63
|
+
graph: str | None = None
|
|
64
|
+
#: Provenance: who vouched / source URI. Required on explicit writes.
|
|
65
|
+
source: str | None = None
|
|
66
|
+
derived_from: str | None = None
|
|
67
|
+
rule: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Violation:
|
|
72
|
+
message: str
|
|
73
|
+
node: str | None = None
|
|
74
|
+
path: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ShapeReport:
|
|
79
|
+
valid: bool
|
|
80
|
+
violations: list[Violation] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Derivation:
|
|
85
|
+
"""Proof chain for an entailed fact: premises + rule id."""
|
|
86
|
+
|
|
87
|
+
premises: list[Quad]
|
|
88
|
+
rule: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_LITERAL_TERM_RE = re.compile(r"^(-?\d+(\.\d+)?|true|false)$")
|
|
92
|
+
_TYPED_RE = re.compile(r"\^\^<?([\w:/#.-]+)>?$", re.ASCII)
|
|
93
|
+
_LANG_RE = re.compile(r'^".*"@[\w-]+$', re.ASCII)
|
|
94
|
+
_PLAIN_RE = re.compile(r'^".*"$')
|
|
95
|
+
_INT_RE = re.compile(r"^-?\d+$")
|
|
96
|
+
_DECIMAL_RE = re.compile(r"^-?\d*\.\d+$")
|
|
97
|
+
_BOOL_RE = re.compile(r"^(true|false)$")
|
|
98
|
+
_PREFIXED_RE = re.compile(r"^[a-zA-Z][\w-]*:", re.ASCII)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_literal(term: str) -> bool:
|
|
102
|
+
"""A term is a literal when quoted, numeric, boolean, or un-IRI-able.
|
|
103
|
+
|
|
104
|
+
Mirrors the TS isLiteral: <iris> and prefixed names are IRIs; everything
|
|
105
|
+
else (an un-resolvable bare word) is treated as a literal.
|
|
106
|
+
"""
|
|
107
|
+
if term.startswith('"'):
|
|
108
|
+
return True
|
|
109
|
+
if _LITERAL_TERM_RE.match(term):
|
|
110
|
+
return True
|
|
111
|
+
if term.startswith("<") or _PREFIXED_RE.match(term):
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def term_datatype(term: str) -> str | None:
|
|
117
|
+
"""Datatype of a literal term (typed / langString / string / integer /
|
|
118
|
+
decimal / boolean). Accepts both ``"x"^^xsd:date`` and ``"x"^^<xsd:date>``."""
|
|
119
|
+
m = _TYPED_RE.search(term)
|
|
120
|
+
if m:
|
|
121
|
+
return m.group(1)
|
|
122
|
+
if _LANG_RE.match(term):
|
|
123
|
+
return "rdf:langString"
|
|
124
|
+
if _PLAIN_RE.match(term):
|
|
125
|
+
return "xsd:string"
|
|
126
|
+
if _INT_RE.match(term):
|
|
127
|
+
return "xsd:integer"
|
|
128
|
+
if _DECIMAL_RE.match(term):
|
|
129
|
+
return "xsd:decimal"
|
|
130
|
+
if _BOOL_RE.match(term):
|
|
131
|
+
return "xsd:boolean"
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@runtime_checkable
|
|
136
|
+
class GraphStore(Protocol):
|
|
137
|
+
"""Minimal quad-store contract — in-memory first, remote SPARQL later."""
|
|
138
|
+
|
|
139
|
+
async def add(self, quads: list[Quad]) -> None: ...
|
|
140
|
+
async def match(
|
|
141
|
+
self,
|
|
142
|
+
s: str | None = None,
|
|
143
|
+
p: str | None = None,
|
|
144
|
+
o: str | None = None,
|
|
145
|
+
g: str | None = None,
|
|
146
|
+
) -> list[Quad]: ...
|
|
147
|
+
async def validate(self, quads: list[Quad]) -> ShapeReport: ...
|
|
148
|
+
async def why(self, quad: Quad) -> Derivation | None: ...
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# SHACL-subset shapes + versioned CONSTRUCT-style rules
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class PropertyShape:
|
|
158
|
+
path: str
|
|
159
|
+
min_count: int | None = None
|
|
160
|
+
max_count: int | None = None
|
|
161
|
+
datatype: str | None = None
|
|
162
|
+
node_kind: Literal["iri", "literal"] | None = None
|
|
163
|
+
has_value: str | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class NodeShape:
|
|
168
|
+
target_class: str
|
|
169
|
+
properties: list[PropertyShape] = field(default_factory=list)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class TriplePattern:
|
|
174
|
+
subject: str
|
|
175
|
+
predicate: str
|
|
176
|
+
object: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class GraphRule:
|
|
181
|
+
"""Versioned CONSTRUCT-style rule over triple patterns (?vars)."""
|
|
182
|
+
|
|
183
|
+
id: str
|
|
184
|
+
where: list[TriplePattern] = field(default_factory=list)
|
|
185
|
+
construct: list[TriplePattern] = field(default_factory=list)
|
|
186
|
+
version: str | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class StoreOptions:
|
|
191
|
+
shapes: list[NodeShape] = field(default_factory=list)
|
|
192
|
+
rules: list[GraphRule] = field(default_factory=list)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class InMemoryGraphStore:
|
|
196
|
+
"""Closure-entailing store: RDFS subset + OWL axioms + versioned rules."""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
opts: StoreOptions | None = None,
|
|
201
|
+
*,
|
|
202
|
+
shapes: list[NodeShape] | None = None,
|
|
203
|
+
rules: list[GraphRule] | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
if opts is not None:
|
|
206
|
+
shapes = shapes if shapes is not None else opts.shapes
|
|
207
|
+
rules = rules if rules is not None else opts.rules
|
|
208
|
+
self.shapes: list[NodeShape] = list(shapes or [])
|
|
209
|
+
self.rules: list[GraphRule] = list(rules or [])
|
|
210
|
+
self.quads: list[Quad] = []
|
|
211
|
+
self.derivations: dict[str, Derivation] = {}
|
|
212
|
+
|
|
213
|
+
def add_shapes(self, *shapes: NodeShape) -> "InMemoryGraphStore":
|
|
214
|
+
self.shapes.extend(shapes)
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def add_rules(self, *rules: GraphRule) -> "InMemoryGraphStore":
|
|
218
|
+
self.rules.extend(rules)
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def ontology_shapes(self) -> list[NodeShape]:
|
|
222
|
+
"""Registered SHACL shapes (carried to new stores by TBox migrations)."""
|
|
223
|
+
return list(self.shapes)
|
|
224
|
+
|
|
225
|
+
def ontology_rules(self) -> list[GraphRule]:
|
|
226
|
+
"""Registered CONSTRUCT rules (versioned with the ontology they encode)."""
|
|
227
|
+
return list(self.rules)
|
|
228
|
+
|
|
229
|
+
def _key(self, q: Quad) -> str:
|
|
230
|
+
return f"{q.subject}|{q.predicate}|{q.object}|{q.graph or ''}"
|
|
231
|
+
|
|
232
|
+
async def add(self, quads: list[Quad]) -> None:
|
|
233
|
+
report = await self.validate(quads)
|
|
234
|
+
if not report.valid:
|
|
235
|
+
raise ValueError(f"SHACL violation: {'; '.join(v.message for v in report.violations)}")
|
|
236
|
+
known = {self._key(q) for q in self.quads}
|
|
237
|
+
added_keys: list[str] = []
|
|
238
|
+
for q in quads:
|
|
239
|
+
k = self._key(q)
|
|
240
|
+
if k not in known:
|
|
241
|
+
known.add(k)
|
|
242
|
+
added_keys.append(k)
|
|
243
|
+
self.quads.append(q)
|
|
244
|
+
|
|
245
|
+
def rollback() -> None:
|
|
246
|
+
doomed = set(added_keys)
|
|
247
|
+
self.quads = [q for q in self.quads if self._key(q) not in doomed]
|
|
248
|
+
for k in doomed:
|
|
249
|
+
self.derivations.pop(k, None)
|
|
250
|
+
|
|
251
|
+
conflicts = self._check_functional()
|
|
252
|
+
if conflicts:
|
|
253
|
+
rollback()
|
|
254
|
+
|
|
255
|
+
raise ValueError(f"Functional property violation: {'; '.join(conflicts)}")
|
|
256
|
+
shape_report = self.check_shapes(self.quads)
|
|
257
|
+
if not shape_report.valid:
|
|
258
|
+
rollback()
|
|
259
|
+
|
|
260
|
+
raise ValueError(f"SHACL violation: {'; '.join(v.message for v in shape_report.violations)}")
|
|
261
|
+
self._entail_to_fixpoint(known)
|
|
262
|
+
|
|
263
|
+
async def match(
|
|
264
|
+
self,
|
|
265
|
+
s: str | None = None,
|
|
266
|
+
p: str | None = None,
|
|
267
|
+
o: str | None = None,
|
|
268
|
+
g: str | None = None,
|
|
269
|
+
) -> list[Quad]:
|
|
270
|
+
"""Match by pattern; None = wildcard (including the graph slot).
|
|
271
|
+
|
|
272
|
+
Queries the entailment closure. A ``g`` value selects ONE named graph
|
|
273
|
+
— the same s/p/o CAN live in several graphs; identity includes it.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def keep(q: Quad) -> bool:
|
|
277
|
+
return (
|
|
278
|
+
(not s or q.subject == s)
|
|
279
|
+
and (not p or q.predicate == p)
|
|
280
|
+
and (not o or q.object == o)
|
|
281
|
+
and (not g or q.graph == g)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return [q for q in self.quads if keep(q)]
|
|
285
|
+
|
|
286
|
+
async def validate(self, quads: list[Quad]) -> ShapeReport:
|
|
287
|
+
"""Structural + provenance check: incomplete triples and source-less
|
|
288
|
+
writes are violations — a fact without provenance never enters."""
|
|
289
|
+
violations = [
|
|
290
|
+
Violation(message=f"Incomplete triple: {json.dumps(_quad_json(q))}")
|
|
291
|
+
for q in quads
|
|
292
|
+
if not q.subject or not q.predicate or not q.object
|
|
293
|
+
]
|
|
294
|
+
no_source = [
|
|
295
|
+
Violation(message=f"Missing provenance (source) on {q.subject} {q.predicate} {q.object}")
|
|
296
|
+
for q in quads
|
|
297
|
+
if not q.source
|
|
298
|
+
]
|
|
299
|
+
return ShapeReport(valid=not (violations or no_source), violations=[*violations, *no_source])
|
|
300
|
+
|
|
301
|
+
async def why(self, quad: Quad) -> Derivation | None:
|
|
302
|
+
"""Proof chain for an entailed fact: premises + rule id. None for explicit facts."""
|
|
303
|
+
return self.derivations.get(self._key(quad))
|
|
304
|
+
|
|
305
|
+
def _check_functional(self) -> list[str]:
|
|
306
|
+
"""Functional properties: at most one value per subject (a conflict is
|
|
307
|
+
an ERROR rolled back, never a second fact)."""
|
|
308
|
+
functional_props = {
|
|
309
|
+
q.subject for q in self.quads if q.predicate in RDF_TYPE and q.object in OWL_FUNCTIONAL
|
|
310
|
+
}
|
|
311
|
+
conflicts: list[str] = []
|
|
312
|
+
for prop in functional_props:
|
|
313
|
+
by_subject: dict[str, set[str]] = {}
|
|
314
|
+
for q in self.quads:
|
|
315
|
+
if q.predicate != prop:
|
|
316
|
+
continue
|
|
317
|
+
by_subject.setdefault(q.subject, set()).add(q.object)
|
|
318
|
+
for subject, values in by_subject.items():
|
|
319
|
+
if len(values) > 1:
|
|
320
|
+
conflicts.append(f"{subject} has {len(values)} values for functional {prop}")
|
|
321
|
+
return conflicts
|
|
322
|
+
|
|
323
|
+
def check_shapes(self, quads: list[Quad]) -> ShapeReport:
|
|
324
|
+
"""SHACL-subset validation over a quad set (pure — no entailment)."""
|
|
325
|
+
violations: list[Violation] = []
|
|
326
|
+
for shape in self.shapes:
|
|
327
|
+
targets = [q.subject for q in quads if q.predicate in RDF_TYPE and q.object == shape.target_class]
|
|
328
|
+
for node in dict.fromkeys(targets):
|
|
329
|
+
for prop in shape.properties:
|
|
330
|
+
values = [q.object for q in quads if q.subject == node and q.predicate == prop.path]
|
|
331
|
+
if prop.min_count is not None and len(values) < prop.min_count:
|
|
332
|
+
violations.append(
|
|
333
|
+
Violation(f"{node} needs >= {prop.min_count} {prop.path}", node, prop.path)
|
|
334
|
+
)
|
|
335
|
+
if prop.max_count is not None and len(values) > prop.max_count:
|
|
336
|
+
violations.append(
|
|
337
|
+
Violation(f"{node} allows <= {prop.max_count} {prop.path}", node, prop.path)
|
|
338
|
+
)
|
|
339
|
+
for v in values:
|
|
340
|
+
if prop.datatype and term_datatype(v) != prop.datatype and not (
|
|
341
|
+
prop.datatype == "xsd:string" and is_literal(v)
|
|
342
|
+
):
|
|
343
|
+
violations.append(
|
|
344
|
+
Violation(f"{node} {prop.path}={v} is not {prop.datatype}", node, prop.path)
|
|
345
|
+
)
|
|
346
|
+
if prop.node_kind == "iri" and is_literal(v):
|
|
347
|
+
violations.append(
|
|
348
|
+
Violation(f"{node} {prop.path}={v} must be an IRI", node, prop.path)
|
|
349
|
+
)
|
|
350
|
+
if prop.node_kind == "literal" and not is_literal(v):
|
|
351
|
+
violations.append(
|
|
352
|
+
Violation(f"{node} {prop.path}={v} must be a literal", node, prop.path)
|
|
353
|
+
)
|
|
354
|
+
if prop.has_value is not None and v != prop.has_value:
|
|
355
|
+
violations.append(
|
|
356
|
+
Violation(f"{node} {prop.path} must be {prop.has_value}", node, prop.path)
|
|
357
|
+
)
|
|
358
|
+
return ShapeReport(valid=not violations, violations=violations)
|
|
359
|
+
|
|
360
|
+
def _insert(self, q: Quad, premises: list[Quad], rule: str, known: set[str]) -> bool:
|
|
361
|
+
k = self._key(q)
|
|
362
|
+
if k in known:
|
|
363
|
+
return False
|
|
364
|
+
known.add(k)
|
|
365
|
+
self.quads.append(q)
|
|
366
|
+
self.derivations[k] = Derivation(premises=list(premises), rule=rule)
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
def _entail_to_fixpoint(self, known: set[str]) -> None:
|
|
370
|
+
"""RDFS subset + OWL property axioms + versioned rules, to fixpoint:
|
|
371
|
+
|
|
372
|
+
- RDFS-SUB-TRANS / RDFS-TYPE / RDFS-SUBPROP-TRANS / RDFS-SUBPROP /
|
|
373
|
+
RDFS-DOMAIN / RDFS-RANGE
|
|
374
|
+
- OWL-TRANSITIVE / OWL-SYMMETRIC / OWL-INVERSE (inverseOf both directions)
|
|
375
|
+
- ``<ruleId>@<version>`` for each applied GraphRule (premises -> why())
|
|
376
|
+
"""
|
|
377
|
+
while True:
|
|
378
|
+
added = False
|
|
379
|
+
snapshot = list(self.quads)
|
|
380
|
+
|
|
381
|
+
def by_pred(ps: set[str]) -> list[Quad]:
|
|
382
|
+
return [q for q in snapshot if q.predicate in ps]
|
|
383
|
+
|
|
384
|
+
def insert(q: Quad, premises: list[Quad], rule: str) -> bool:
|
|
385
|
+
nonlocal added
|
|
386
|
+
one = self._insert(q, premises, rule, known)
|
|
387
|
+
added = one or added
|
|
388
|
+
return one
|
|
389
|
+
|
|
390
|
+
for ab in by_pred(SUB_CLASS):
|
|
391
|
+
for bc in [q for q in by_pred(SUB_CLASS) if q.subject == ab.object]:
|
|
392
|
+
added = (
|
|
393
|
+
self._insert(
|
|
394
|
+
Quad(ab.subject, ab.predicate, bc.object, source="entailment", rule="RDFS-SUB-TRANS"),
|
|
395
|
+
[ab, bc],
|
|
396
|
+
"RDFS-SUB-TRANS",
|
|
397
|
+
known,
|
|
398
|
+
)
|
|
399
|
+
or added
|
|
400
|
+
)
|
|
401
|
+
for t in by_pred(RDF_TYPE):
|
|
402
|
+
for sc in [q for q in by_pred(SUB_CLASS) if q.subject == t.object]:
|
|
403
|
+
added = (
|
|
404
|
+
self._insert(
|
|
405
|
+
Quad(t.subject, t.predicate, sc.object, source="entailment", rule="RDFS-TYPE"),
|
|
406
|
+
[t, sc],
|
|
407
|
+
"RDFS-TYPE",
|
|
408
|
+
known,
|
|
409
|
+
)
|
|
410
|
+
or added
|
|
411
|
+
)
|
|
412
|
+
for ab in by_pred(SUB_PROP):
|
|
413
|
+
for bc in [q for q in by_pred(SUB_PROP) if q.subject == ab.object]:
|
|
414
|
+
added = (
|
|
415
|
+
self._insert(
|
|
416
|
+
Quad(
|
|
417
|
+
ab.subject, ab.predicate, bc.object, source="entailment", rule="RDFS-SUBPROP-TRANS"
|
|
418
|
+
),
|
|
419
|
+
[ab, bc],
|
|
420
|
+
"RDFS-SUBPROP-TRANS",
|
|
421
|
+
known,
|
|
422
|
+
)
|
|
423
|
+
or added
|
|
424
|
+
)
|
|
425
|
+
for use in [q for q in snapshot if q.predicate == ab.subject]:
|
|
426
|
+
added = (
|
|
427
|
+
self._insert(
|
|
428
|
+
Quad(ab.subject, ab.object, use.object, source="entailment", rule="RDFS-SUBPROP"),
|
|
429
|
+
[ab, use],
|
|
430
|
+
"RDFS-SUBPROP",
|
|
431
|
+
known,
|
|
432
|
+
)
|
|
433
|
+
or added
|
|
434
|
+
)
|
|
435
|
+
for d in by_pred(DOMAIN):
|
|
436
|
+
for use in [q for q in snapshot if q.predicate == d.subject]:
|
|
437
|
+
added = (
|
|
438
|
+
self._insert(
|
|
439
|
+
Quad(use.subject, "rdf:type", d.object, source="entailment", rule="RDFS-DOMAIN"),
|
|
440
|
+
[d, use],
|
|
441
|
+
"RDFS-DOMAIN",
|
|
442
|
+
known,
|
|
443
|
+
)
|
|
444
|
+
or added
|
|
445
|
+
)
|
|
446
|
+
for r in by_pred(RANGE):
|
|
447
|
+
for use in [q for q in snapshot if q.predicate == r.subject]:
|
|
448
|
+
added = (
|
|
449
|
+
self._insert(
|
|
450
|
+
Quad(use.object, "rdf:type", r.object, source="entailment", rule="RDFS-RANGE"),
|
|
451
|
+
[r, use],
|
|
452
|
+
"RDFS-RANGE",
|
|
453
|
+
known,
|
|
454
|
+
)
|
|
455
|
+
or added
|
|
456
|
+
)
|
|
457
|
+
added = self._apply_owl(snapshot, insert) or added
|
|
458
|
+
added = self._apply_rules(snapshot, insert) or added
|
|
459
|
+
if not added:
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
def _apply_owl(self, snapshot: list[Quad], insert: Any) -> bool:
|
|
463
|
+
added = False
|
|
464
|
+
transitive = {
|
|
465
|
+
q.subject for q in snapshot if q.predicate in RDF_TYPE and q.object in OWL_TRANSITIVE
|
|
466
|
+
}
|
|
467
|
+
symmetric = {q.subject for q in snapshot if q.predicate in RDF_TYPE and q.object in OWL_SYMMETRIC}
|
|
468
|
+
for p in transitive:
|
|
469
|
+
uses = [q for q in snapshot if q.predicate == p]
|
|
470
|
+
for xy in uses:
|
|
471
|
+
for yz in [q for q in uses if q.subject == xy.object]:
|
|
472
|
+
if insert(
|
|
473
|
+
Quad(xy.subject, p, yz.object, source="entailment", rule="OWL-TRANSITIVE"),
|
|
474
|
+
[xy, yz],
|
|
475
|
+
"OWL-TRANSITIVE",
|
|
476
|
+
):
|
|
477
|
+
added = True
|
|
478
|
+
for p in symmetric:
|
|
479
|
+
for xy in [q for q in snapshot if q.predicate == p]:
|
|
480
|
+
if insert(
|
|
481
|
+
Quad(xy.object, p, xy.subject, source="entailment", rule="OWL-SYMMETRIC"),
|
|
482
|
+
[xy],
|
|
483
|
+
"OWL-SYMMETRIC",
|
|
484
|
+
):
|
|
485
|
+
added = True
|
|
486
|
+
for inv in [q for q in snapshot if q.predicate in OWL_INVERSE]:
|
|
487
|
+
for use in [q for q in snapshot if q.predicate == inv.subject or q.predicate == inv.object]:
|
|
488
|
+
flipped = inv.object if use.predicate == inv.subject else inv.subject
|
|
489
|
+
if insert(
|
|
490
|
+
Quad(use.object, flipped, use.subject, source="entailment", rule="OWL-INVERSE"),
|
|
491
|
+
[inv, use],
|
|
492
|
+
"OWL-INVERSE",
|
|
493
|
+
):
|
|
494
|
+
added = True
|
|
495
|
+
return added
|
|
496
|
+
|
|
497
|
+
def _apply_rules(self, snapshot: list[Quad], insert: Any) -> bool:
|
|
498
|
+
added = False
|
|
499
|
+
for rule in self.rules:
|
|
500
|
+
tag = f"{rule.id}@{rule.version}" if rule.version else rule.id
|
|
501
|
+
for binding in match_patterns(rule.where, snapshot):
|
|
502
|
+
for pattern in rule.construct:
|
|
503
|
+
built = TriplePattern(
|
|
504
|
+
instantiate(pattern.subject, binding.vars),
|
|
505
|
+
instantiate(pattern.predicate, binding.vars),
|
|
506
|
+
instantiate(pattern.object, binding.vars),
|
|
507
|
+
)
|
|
508
|
+
if not built.subject or not built.predicate or not built.object:
|
|
509
|
+
continue
|
|
510
|
+
if insert(
|
|
511
|
+
Quad(
|
|
512
|
+
built.subject,
|
|
513
|
+
built.predicate,
|
|
514
|
+
built.object,
|
|
515
|
+
source="entailment",
|
|
516
|
+
rule=tag,
|
|
517
|
+
derived_from=tag,
|
|
518
|
+
),
|
|
519
|
+
binding.quads,
|
|
520
|
+
tag,
|
|
521
|
+
):
|
|
522
|
+
added = True
|
|
523
|
+
return added
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _quad_json(q: Quad) -> dict[str, Any]:
|
|
527
|
+
out = dataclasses.asdict(q)
|
|
528
|
+
return {k: v for k, v in out.items() if v is not None}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ---------------------------------------------------------------------------
|
|
532
|
+
# Pattern matching + SPARQL-subset (SELECT with conjunctive triple patterns)
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@dataclass
|
|
537
|
+
class PatternBinding:
|
|
538
|
+
vars: dict[str, str]
|
|
539
|
+
quads: list[Quad]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _is_var(term: str) -> bool:
|
|
543
|
+
return term.startswith("?")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _match_term(pattern: str, value: str, vars: dict[str, str]) -> dict[str, str] | None:
|
|
547
|
+
if _is_var(pattern):
|
|
548
|
+
bound = vars.get(pattern)
|
|
549
|
+
if bound is None:
|
|
550
|
+
nxt = dict(vars)
|
|
551
|
+
nxt[pattern] = value
|
|
552
|
+
return nxt
|
|
553
|
+
return vars if bound == value else None
|
|
554
|
+
return vars if pattern == value else None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _match_one(pattern: TriplePattern, quad: Quad, vars: dict[str, str]) -> dict[str, str] | None:
|
|
558
|
+
v = _match_term(pattern.subject, quad.subject, vars)
|
|
559
|
+
if v is None:
|
|
560
|
+
return None
|
|
561
|
+
v = _match_term(pattern.predicate, quad.predicate, v)
|
|
562
|
+
if v is None:
|
|
563
|
+
return None
|
|
564
|
+
return _match_term(pattern.object, quad.object, v)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def match_patterns(patterns: list[TriplePattern], quads: list[Quad]) -> list[PatternBinding]:
|
|
568
|
+
"""Conjunctive matching of patterns against quads (nested-loop join)."""
|
|
569
|
+
bindings: list[PatternBinding] = [PatternBinding(vars={}, quads=[])]
|
|
570
|
+
for pattern in patterns:
|
|
571
|
+
nxt: list[PatternBinding] = []
|
|
572
|
+
for b in bindings:
|
|
573
|
+
for q in quads:
|
|
574
|
+
vars = _match_one(pattern, q, b.vars)
|
|
575
|
+
if vars is not None:
|
|
576
|
+
nxt.append(PatternBinding(vars=vars, quads=[*b.quads, q]))
|
|
577
|
+
bindings = nxt
|
|
578
|
+
if not bindings:
|
|
579
|
+
break
|
|
580
|
+
return bindings
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def instantiate(term: str, vars: dict[str, str]) -> str:
|
|
584
|
+
return vars.get(term, term) if _is_var(term) else term
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@dataclass
|
|
588
|
+
class SparqlSelect:
|
|
589
|
+
variables: list[str]
|
|
590
|
+
patterns: list[TriplePattern]
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
_TOKEN_RE = re.compile(r'"[^"]*"(?:\^\^<?[\w:/#.-]+>?|@[\w-]+)?|<[^>]*>|[{}().;]|\S+', re.ASCII)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _tokenize_sparql(text: str) -> list[str]:
|
|
597
|
+
return [m.group(0) for m in _TOKEN_RE.finditer(text)]
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def parse_select(query: str) -> SparqlSelect:
|
|
601
|
+
"""Minimal SELECT parser: ``SELECT ?a ?b WHERE { s p o . ... }``.
|
|
602
|
+
|
|
603
|
+
Terms: ?vars, <iris>, prefixed names, "literals" (^^datatype/@lang ok).
|
|
604
|
+
Anything else raises ValueError — this is a documented subset, not SPARQL.
|
|
605
|
+
"""
|
|
606
|
+
tokens = _tokenize_sparql(query)
|
|
607
|
+
i = 0
|
|
608
|
+
|
|
609
|
+
def expect(want: str) -> None:
|
|
610
|
+
nonlocal i
|
|
611
|
+
got = tokens[i] if i < len(tokens) else "end"
|
|
612
|
+
if got.upper() != want:
|
|
613
|
+
raise ValueError(f"SPARQL: expected {want}, got {got}")
|
|
614
|
+
i += 1
|
|
615
|
+
|
|
616
|
+
expect("SELECT")
|
|
617
|
+
variables: list[str] = []
|
|
618
|
+
while i < len(tokens) and tokens[i].startswith("?"):
|
|
619
|
+
variables.append(tokens[i])
|
|
620
|
+
i += 1
|
|
621
|
+
if not variables:
|
|
622
|
+
raise ValueError("SPARQL: SELECT needs at least one variable")
|
|
623
|
+
expect("WHERE")
|
|
624
|
+
expect("{")
|
|
625
|
+
patterns: list[TriplePattern] = []
|
|
626
|
+
while i < len(tokens) and tokens[i] != "}":
|
|
627
|
+
s = tokens[i]
|
|
628
|
+
i += 1
|
|
629
|
+
p = tokens[i] if i < len(tokens) else None
|
|
630
|
+
i += 1
|
|
631
|
+
o = tokens[i] if i < len(tokens) else None
|
|
632
|
+
i += 1
|
|
633
|
+
if s is None or p is None or o is None or s == "." or p == "." or o == ".":
|
|
634
|
+
raise ValueError("SPARQL: malformed triple pattern")
|
|
635
|
+
patterns.append(TriplePattern(s, p, o))
|
|
636
|
+
if i < len(tokens) and tokens[i] == ".":
|
|
637
|
+
i += 1
|
|
638
|
+
expect("}")
|
|
639
|
+
return SparqlSelect(variables=variables, patterns=patterns)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
async def execute_select(store: GraphStore, query: str) -> list[dict[str, str]]:
|
|
643
|
+
"""Execute a SELECT against the store closure. Returns variable bindings."""
|
|
644
|
+
parsed = parse_select(query)
|
|
645
|
+
quads = await store.match()
|
|
646
|
+
return [
|
|
647
|
+
{v: b.vars.get(v, "") for v in parsed.variables}
|
|
648
|
+
for b in match_patterns(parsed.patterns, quads)
|
|
649
|
+
]
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
# Agent tools: read, write (autonomous, validated), verify
|
|
654
|
+
# ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
class _SparqlSelectArgs(BaseModel):
|
|
658
|
+
query: str = Field(min_length=1, max_length=4000)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def sparql_select_tool(store: GraphStore) -> Tool:
|
|
662
|
+
"""Read-only SPARQL SELECT over the entailment closure."""
|
|
663
|
+
|
|
664
|
+
async def execute(args: _SparqlSelectArgs, ctx: ToolContext | None):
|
|
665
|
+
# Same contract as graph_add: query errors are repair feedback (the
|
|
666
|
+
# model fixes the query), not a thrown tool error that kills the run.
|
|
667
|
+
try:
|
|
668
|
+
return await execute_select(store, args.query)
|
|
669
|
+
except AgentError:
|
|
670
|
+
raise
|
|
671
|
+
except Exception as err: # noqa: BLE001 - documented feedback contract
|
|
672
|
+
return f"Query error — fix the syntax and retry: {err}"
|
|
673
|
+
|
|
674
|
+
return define_tool(
|
|
675
|
+
name="sparql_select",
|
|
676
|
+
description="Query the knowledge graph. Subset: SELECT ?vars WHERE { s p o . ... } over the inferred closure.",
|
|
677
|
+
schema=_SparqlSelectArgs,
|
|
678
|
+
execute=execute,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class _OntologyLookupArgs(BaseModel):
|
|
683
|
+
term: str = Field(min_length=1, max_length=200)
|
|
684
|
+
limit: int = Field(default=8, ge=1, le=20)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def ontology_lookup_tool(store: GraphStore) -> Tool:
|
|
688
|
+
"""Ontology lookup: find classes/properties/labels matching a term."""
|
|
689
|
+
|
|
690
|
+
async def execute(args: _OntologyLookupArgs, ctx: ToolContext | None) -> list[dict[str, str]]:
|
|
691
|
+
needle = args.term.lower()
|
|
692
|
+
quads = await store.match()
|
|
693
|
+
return [
|
|
694
|
+
{"subject": q.subject, "predicate": q.predicate, "object": q.object}
|
|
695
|
+
for q in quads
|
|
696
|
+
if needle in f"{q.subject} {q.predicate} {q.object}".lower()
|
|
697
|
+
][: args.limit]
|
|
698
|
+
|
|
699
|
+
return define_tool(
|
|
700
|
+
name="ontology_lookup",
|
|
701
|
+
description="Look up ontology terms (classes, properties, labels) instead of inventing predicates.",
|
|
702
|
+
schema=_OntologyLookupArgs,
|
|
703
|
+
execute=execute,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class _TurtleQuadModel(BaseModel):
|
|
708
|
+
subject: str = Field(min_length=1)
|
|
709
|
+
predicate: str = Field(min_length=1)
|
|
710
|
+
object: str = Field(min_length=1)
|
|
711
|
+
source: str = Field(min_length=1, description="Provenance URI (required — writes without it are rejected)")
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class _GraphAddArgs(BaseModel):
|
|
715
|
+
quads: list[_TurtleQuadModel] = Field(min_length=1, max_length=50)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
async def _graph_add_to_result(output: Any) -> ToolResult:
|
|
719
|
+
"""Validation failures are model-visible repair feedback, not hidden artifacts."""
|
|
720
|
+
if isinstance(output, str):
|
|
721
|
+
return ToolResult(content=output)
|
|
722
|
+
return ToolResult(content=json.dumps(output), artifact={"committed": output["committed"]})
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def graph_add_tool(store: GraphStore) -> Tool:
|
|
726
|
+
"""Autonomous validated write: SHACL + provenance + functional checks run
|
|
727
|
+
inside the store. Violations return as tool results (not raises) so the
|
|
728
|
+
model repairs and retries on its own — autonomy without anarchy."""
|
|
729
|
+
|
|
730
|
+
async def execute(args: _GraphAddArgs, ctx: ToolContext | None) -> Any:
|
|
731
|
+
try:
|
|
732
|
+
await store.add([Quad(q.subject, q.predicate, q.object, source=q.source) for q in args.quads])
|
|
733
|
+
inferred = [q for q in await store.match() if q.source == "entailment"]
|
|
734
|
+
return {"committed": len(args.quads), "inferred": len(inferred)}
|
|
735
|
+
except Exception as err: # noqa: BLE001 - feedback contract
|
|
736
|
+
return f"Validation failed — fix and retry: {err}"
|
|
737
|
+
|
|
738
|
+
return define_tool(
|
|
739
|
+
name="graph_add",
|
|
740
|
+
description="Write facts to the knowledge graph. Validated (SHACL/provenance/functional); violations return repair feedback.",
|
|
741
|
+
schema=_GraphAddArgs,
|
|
742
|
+
execute=execute,
|
|
743
|
+
to_result=_graph_add_to_result,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
ClaimVerdict = Literal["grounded", "unknown", "contradicted"]
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
class _ClaimCheckArgs(BaseModel):
|
|
751
|
+
subject: str = Field(min_length=1)
|
|
752
|
+
predicate: str = Field(min_length=1)
|
|
753
|
+
object: str = Field(min_length=1)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def claim_check_tool(store: GraphStore) -> Tool:
|
|
757
|
+
"""Anti-hallucination primitive: is this triple in the closure (grounded),
|
|
758
|
+
absent (unknown — do not assert), or clashing with a functional value
|
|
759
|
+
(contradicted)? Ask before answering from memory."""
|
|
760
|
+
|
|
761
|
+
async def execute(args: _ClaimCheckArgs, ctx: ToolContext | None) -> dict[str, Any]:
|
|
762
|
+
direct = await store.match(args.subject, args.predicate, args.object)
|
|
763
|
+
if direct:
|
|
764
|
+
why = await store.why(direct[0])
|
|
765
|
+
return {
|
|
766
|
+
"verdict": "grounded",
|
|
767
|
+
"proof": None
|
|
768
|
+
if why is None
|
|
769
|
+
else {
|
|
770
|
+
"premises": [_quad_json(p) for p in why.premises],
|
|
771
|
+
"rule": why.rule,
|
|
772
|
+
},
|
|
773
|
+
}
|
|
774
|
+
siblings = await store.match(args.subject, args.predicate, None)
|
|
775
|
+
declarations = await store.match(args.predicate, None, None)
|
|
776
|
+
functional = any(q.predicate in RDF_TYPE and q.object in OWL_FUNCTIONAL for q in declarations)
|
|
777
|
+
clash = [q for q in siblings if q.object != args.object]
|
|
778
|
+
if functional and clash:
|
|
779
|
+
return {"verdict": "contradicted", "proof": {"existing": [q.object for q in clash]}}
|
|
780
|
+
return {
|
|
781
|
+
"verdict": "unknown",
|
|
782
|
+
"proof": {"related": [q.object for q in siblings]} if siblings else None,
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return define_tool(
|
|
786
|
+
name="claim_check",
|
|
787
|
+
description="Verify a fact against the graph before asserting it. Returns grounded | unknown | contradicted with proof.",
|
|
788
|
+
schema=_ClaimCheckArgs,
|
|
789
|
+
execute=execute,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# ---------------------------------------------------------------------------
|
|
794
|
+
# GraphMemory: closure-aware session memory + long-term store
|
|
795
|
+
# ---------------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def snake_tokenize(text: str) -> list[str]:
|
|
799
|
+
"""Local keyword tokenizer for the GraphMemory fact selector (TS skills tokenize)."""
|
|
800
|
+
return [w for w in re.split(r"[^a-z0-9]+", text.lower()) if len(w) > 1]
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@dataclass
|
|
804
|
+
class GraphMemory:
|
|
805
|
+
"""Session memory + long-term store in one:
|
|
806
|
+
|
|
807
|
+
- load(): recent messages + top-k closure facts relevant to the latest user
|
|
808
|
+
turn (keyword overlap), rendered as a knowledge message.
|
|
809
|
+
- save(): delegates to inner memory (extraction of triples from prose stays
|
|
810
|
+
model-driven via graph_add — never silent parsing).
|
|
811
|
+
"""
|
|
812
|
+
|
|
813
|
+
store: GraphStore
|
|
814
|
+
inner: Memory
|
|
815
|
+
facts: int = 8
|
|
816
|
+
|
|
817
|
+
async def load(self) -> list[ChatMessage]:
|
|
818
|
+
messages = await self.inner.load()
|
|
819
|
+
last_user = next((m for m in reversed(messages) if m.role == "user"), None)
|
|
820
|
+
if last_user is None:
|
|
821
|
+
return messages
|
|
822
|
+
content = last_user.content if isinstance(last_user.content, str) else ""
|
|
823
|
+
terms = set(snake_tokenize(content))
|
|
824
|
+
if not terms:
|
|
825
|
+
return messages
|
|
826
|
+
quads = await self.store.match()
|
|
827
|
+
scored = [
|
|
828
|
+
(q, score)
|
|
829
|
+
for q, score in (
|
|
830
|
+
(q, sum(1 for t in terms if t in f"{q.subject} {q.predicate} {q.object}".lower()))
|
|
831
|
+
for q in quads
|
|
832
|
+
)
|
|
833
|
+
if score > 0
|
|
834
|
+
]
|
|
835
|
+
if not scored:
|
|
836
|
+
return messages
|
|
837
|
+
scored.sort(key=lambda item: -item[1])
|
|
838
|
+
top = scored[: self.facts]
|
|
839
|
+
lines = "\n".join(f"- {q.subject} {q.predicate} {q.object}" for q, _ in top)
|
|
840
|
+
return [
|
|
841
|
+
*messages,
|
|
842
|
+
assistant(f"Relevant known facts (ground answers in these):\n{lines}"),
|
|
843
|
+
]
|
|
844
|
+
|
|
845
|
+
async def save(self, messages: list[ChatMessage]) -> None:
|
|
846
|
+
await self.inner.save(messages)
|
|
847
|
+
|
|
848
|
+
async def clear(self) -> None:
|
|
849
|
+
await self.inner.clear()
|
|
850
|
+
|
|
851
|
+
async def export_state(self) -> dict[str, list[Quad]]:
|
|
852
|
+
"""Serialize the closure for snapshots / long-term persistence."""
|
|
853
|
+
return {"quads": await self.store.match()}
|
|
854
|
+
|
|
855
|
+
async def import_state(self, state: Mapping[str, Any] | Any) -> None:
|
|
856
|
+
"""Restore a previously exported closure (facts become explicit again)."""
|
|
857
|
+
raw = state.get("quads", []) if hasattr(state, "get") else []
|
|
858
|
+
fresh: list[Quad] = []
|
|
859
|
+
for q in raw:
|
|
860
|
+
qq = q if isinstance(q, Quad) else Quad(**q)
|
|
861
|
+
if qq.source != "entailment":
|
|
862
|
+
fresh.append(qq)
|
|
863
|
+
if fresh:
|
|
864
|
+
await self.store.add(fresh)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# ---------------------------------------------------------------------------
|
|
868
|
+
# Entity linking + sameAs canonicalization (docs/07 rule 2)
|
|
869
|
+
# ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
LABEL_PREDICATES = {
|
|
872
|
+
"rdfs:label",
|
|
873
|
+
"http://www.w3.org/2000/01/rdf-schema#label",
|
|
874
|
+
"skos:prefLabel",
|
|
875
|
+
"http://www.w3.org/2004/02/skos/core#prefLabel",
|
|
876
|
+
"skos:altLabel",
|
|
877
|
+
"http://www.w3.org/2004/02/skos/core#altLabel",
|
|
878
|
+
}
|
|
879
|
+
SAME_AS = {"owl:sameAs", "http://www.w3.org/2002/07/owl#sameAs"}
|
|
880
|
+
|
|
881
|
+
#: Prefixes to expand/compact IRIs for JSON-LD I/O (nullable values pass through).
|
|
882
|
+
JSONLD_PREFIXES: dict[str, str] = {
|
|
883
|
+
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
884
|
+
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
|
885
|
+
"owl": "http://www.w3.org/2002/07/owl#",
|
|
886
|
+
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
|
887
|
+
"skos": "http://www.w3.org/2004/02/skos/core#",
|
|
888
|
+
"dct": "http://purl.org/dc/terms/",
|
|
889
|
+
"prov": "http://www.w3.org/ns/prov#",
|
|
890
|
+
"schema": "https://schema.org/",
|
|
891
|
+
"ex": "urn:example:",
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@dataclass
|
|
896
|
+
class LinkedEntity:
|
|
897
|
+
#: Canonical (sameAs-group representative) IRI, in the store's term form.
|
|
898
|
+
iri: str
|
|
899
|
+
label: str | None = None
|
|
900
|
+
score: float = 0
|
|
901
|
+
#: Other IRIs the same entity is known as (the sameAs group).
|
|
902
|
+
same_as_group: list[str] = field(default_factory=list)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def strip_literal(term: str) -> str:
|
|
906
|
+
m = re.match(r'^"(.*)"(?:\^\^<?[\w:/#.-]+>?|@[\w-]+)?$', term, re.ASCII)
|
|
907
|
+
return m.group(1) if m else term
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _local_name(iri: str) -> str:
|
|
911
|
+
tail = re.split(r"[#/:]", iri)[-1] if iri else ""
|
|
912
|
+
return tail.lower()
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
class EntityLinker:
|
|
916
|
+
"""Mention -> graph IRIs over the store closure, canonicalized through
|
|
917
|
+
owl:sameAs groups — a mention linked through any alias resolves to ONE
|
|
918
|
+
representative IRI."""
|
|
919
|
+
|
|
920
|
+
def __init__(self, store: GraphStore, label_predicates: Iterable[str] | None = None) -> None:
|
|
921
|
+
self.store = store
|
|
922
|
+
self.label_predicates = set(label_predicates) if label_predicates is not None else set(LABEL_PREDICATES)
|
|
923
|
+
|
|
924
|
+
async def _same_as_groups(self) -> dict[str, str]:
|
|
925
|
+
"""Union-find over owl:sameAs pairs: every alias cluster -> one representative."""
|
|
926
|
+
pairs = [q for q in await self.store.match() if q.predicate in SAME_AS]
|
|
927
|
+
parent: dict[str, str] = {}
|
|
928
|
+
|
|
929
|
+
def find(x: str) -> str:
|
|
930
|
+
root = x
|
|
931
|
+
while root in parent and parent[root] != root:
|
|
932
|
+
root = parent[root]
|
|
933
|
+
return root
|
|
934
|
+
|
|
935
|
+
def union(a: str, b: str) -> None:
|
|
936
|
+
ra, rb = find(a), find(b)
|
|
937
|
+
if ra != rb:
|
|
938
|
+
parent[ra] = rb
|
|
939
|
+
|
|
940
|
+
for q in pairs:
|
|
941
|
+
parent.setdefault(q.subject, q.subject)
|
|
942
|
+
parent.setdefault(q.object, q.object)
|
|
943
|
+
union(q.subject, q.object)
|
|
944
|
+
return {member: find(member) for member in list(parent.keys())}
|
|
945
|
+
|
|
946
|
+
async def link(self, mention: str, k: int = 5) -> list[LinkedEntity]:
|
|
947
|
+
"""Mention -> candidate entities, best first, canonicalized via sameAs."""
|
|
948
|
+
needle = mention.strip().lower()
|
|
949
|
+
if not needle:
|
|
950
|
+
return []
|
|
951
|
+
quads = await self.store.match()
|
|
952
|
+
canonical = await self._same_as_groups()
|
|
953
|
+
labels: dict[str, str] = {}
|
|
954
|
+
label_score: dict[str, int] = {}
|
|
955
|
+
|
|
956
|
+
candidates: list[dict[str, Any]] = []
|
|
957
|
+
for q in quads:
|
|
958
|
+
local_part = _local_name(q.subject)
|
|
959
|
+
if "/" in q.subject or "#" in q.subject:
|
|
960
|
+
full_tail = re.split(r"[#/]", q.subject)[-1] or ""
|
|
961
|
+
else:
|
|
962
|
+
full_tail = local_part
|
|
963
|
+
if local_part == needle:
|
|
964
|
+
candidates.append({"iri": q.subject, "label": None, "score": 40})
|
|
965
|
+
elif local_part.startswith(needle):
|
|
966
|
+
candidates.append({"iri": q.subject, "label": None, "score": 25})
|
|
967
|
+
elif full_tail.lower() == needle:
|
|
968
|
+
candidates.append({"iri": q.subject, "label": None, "score": 40})
|
|
969
|
+
|
|
970
|
+
if q.predicate in self.label_predicates:
|
|
971
|
+
label = strip_literal(q.object)
|
|
972
|
+
low = label.lower()
|
|
973
|
+
trust = 2 if "altLabel" in q.predicate else (0 if "prefLabel" in q.predicate else 1)
|
|
974
|
+
if low == needle:
|
|
975
|
+
candidates.append({"iri": q.subject, "label": label, "score": 100})
|
|
976
|
+
elif low.startswith(needle):
|
|
977
|
+
candidates.append({"iri": q.subject, "label": label, "score": 60})
|
|
978
|
+
elif needle in low:
|
|
979
|
+
candidates.append({"iri": q.subject, "label": label, "score": 30})
|
|
980
|
+
prev = label_score.get(q.subject)
|
|
981
|
+
if prev is None or trust < prev:
|
|
982
|
+
labels[q.subject] = label
|
|
983
|
+
label_score[q.subject] = trust
|
|
984
|
+
|
|
985
|
+
merged: dict[str, LinkedEntity] = {}
|
|
986
|
+
for c in candidates:
|
|
987
|
+
rep = canonical.get(c["iri"], c["iri"])
|
|
988
|
+
existing = merged.get(rep)
|
|
989
|
+
if existing is not None:
|
|
990
|
+
existing.score = max(existing.score, c["score"])
|
|
991
|
+
if c["label"] and not existing.label:
|
|
992
|
+
existing.label = c["label"]
|
|
993
|
+
if c["iri"] not in existing.same_as_group:
|
|
994
|
+
existing.same_as_group.append(c["iri"])
|
|
995
|
+
else:
|
|
996
|
+
merged[rep] = LinkedEntity(
|
|
997
|
+
iri=rep,
|
|
998
|
+
label=c["label"] or labels.get(rep),
|
|
999
|
+
score=c["score"],
|
|
1000
|
+
same_as_group=[c["iri"], *([rep] if rep != c["iri"] else [])],
|
|
1001
|
+
)
|
|
1002
|
+
for group in merged.values():
|
|
1003
|
+
if group.label is None:
|
|
1004
|
+
group.label = labels.get(group.iri)
|
|
1005
|
+
if len(group.same_as_group) < 2:
|
|
1006
|
+
members = [m for m in canonical if canonical[m] == canonical.get(group.iri)]
|
|
1007
|
+
if len(members) > 1:
|
|
1008
|
+
group.same_as_group = members
|
|
1009
|
+
ranked = sorted(merged.values(), key=lambda e: -e.score)
|
|
1010
|
+
return ranked[:k]
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
class _LinkEntityArgs(BaseModel):
|
|
1014
|
+
mention: str = Field(min_length=1, max_length=200)
|
|
1015
|
+
k: int = Field(default=5, ge=1, le=10)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def link_entity_tool(linker: EntityLinker) -> Tool:
|
|
1019
|
+
"""link_entity agent tool: mention -> canonical IRIs with scores."""
|
|
1020
|
+
|
|
1021
|
+
async def execute(args: _LinkEntityArgs, ctx: ToolContext | None) -> list[dict[str, Any]]:
|
|
1022
|
+
return [dataclasses.asdict(e) for e in await linker.link(args.mention, args.k)]
|
|
1023
|
+
|
|
1024
|
+
return define_tool(
|
|
1025
|
+
name="link_entity",
|
|
1026
|
+
description="Link a text mention to known graph entities (labels + sameAs canonicalized). Returns canonical IRIs with scores — never invent IRIs.",
|
|
1027
|
+
schema=_LinkEntityArgs,
|
|
1028
|
+
execute=execute,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# ---------------------------------------------------------------------------
|
|
1033
|
+
# Mapping proposals: LLM proposes; autonomy commits by default, review opts out
|
|
1034
|
+
# ---------------------------------------------------------------------------
|
|
1035
|
+
|
|
1036
|
+
MappingDecision = Literal["approved", "rejected"]
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
@dataclass
|
|
1040
|
+
class MappingProposal:
|
|
1041
|
+
id: str
|
|
1042
|
+
#: The source-side term: usually a raw column header / mention string.
|
|
1043
|
+
from_: str
|
|
1044
|
+
#: The proposed target: a canonical ontology IRI.
|
|
1045
|
+
to: str
|
|
1046
|
+
#: Provenance for the eventual commit (required by GraphStore.validate).
|
|
1047
|
+
source: str
|
|
1048
|
+
status: str = "pending"
|
|
1049
|
+
#: True when autonomy committed it on proposal (no human turn).
|
|
1050
|
+
committed_by_autonomy: bool | None = None
|
|
1051
|
+
proposed_at: str = ""
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
@dataclass
|
|
1055
|
+
class MappingProposals:
|
|
1056
|
+
"""Autonomy-first mapping commits. Autonomy: proposals commit IMMEDIATELY on
|
|
1057
|
+
proposal (default ON — the agent's whole write path is autonomous). Set
|
|
1058
|
+
``autonomy=False`` (or bind no store) to queue proposals for decide()."""
|
|
1059
|
+
|
|
1060
|
+
store: "GraphStore | None" = None
|
|
1061
|
+
autonomy: bool = True
|
|
1062
|
+
|
|
1063
|
+
def __post_init__(self) -> None:
|
|
1064
|
+
self.seq = 0 # per-instance sequence, like the TS private seq
|
|
1065
|
+
self.items: dict[str, MappingProposal] = {}
|
|
1066
|
+
|
|
1067
|
+
@property
|
|
1068
|
+
def is_autonomous(self) -> bool:
|
|
1069
|
+
"""True when proposals commit themselves (autonomy on AND a store bound)."""
|
|
1070
|
+
return self.autonomy and self.store is not None
|
|
1071
|
+
|
|
1072
|
+
async def _commit(self, store: GraphStore, proposal: MappingProposal) -> None:
|
|
1073
|
+
# Promotion = a provenance-carrying alias the linker can see forever.
|
|
1074
|
+
await store.add(
|
|
1075
|
+
[
|
|
1076
|
+
Quad(
|
|
1077
|
+
proposal.to,
|
|
1078
|
+
"skos:altLabel",
|
|
1079
|
+
f'"{proposal.from_}"',
|
|
1080
|
+
source=proposal.source,
|
|
1081
|
+
derived_from=f"mapping:{proposal.id}",
|
|
1082
|
+
)
|
|
1083
|
+
]
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
async def propose(self, from_: str, to: str, source: str) -> MappingProposal:
|
|
1087
|
+
"""Record an LLM-proposed mapping. With autonomy (default) + a bound
|
|
1088
|
+
store it commits right away, provenance required; with autonomy=False
|
|
1089
|
+
(or no store bound) it queues as pending until decide()."""
|
|
1090
|
+
existing = next(
|
|
1091
|
+
(
|
|
1092
|
+
p
|
|
1093
|
+
for p in self.items.values()
|
|
1094
|
+
if p.from_ == from_
|
|
1095
|
+
and p.to == to
|
|
1096
|
+
and (p.status == "pending" or (self.is_autonomous and p.status == "approved"))
|
|
1097
|
+
),
|
|
1098
|
+
None,
|
|
1099
|
+
)
|
|
1100
|
+
if existing is not None:
|
|
1101
|
+
return existing
|
|
1102
|
+
self.seq += 1
|
|
1103
|
+
proposal = MappingProposal(
|
|
1104
|
+
id=f"map-{self.seq}",
|
|
1105
|
+
from_=from_,
|
|
1106
|
+
to=to,
|
|
1107
|
+
source=source,
|
|
1108
|
+
status="pending",
|
|
1109
|
+
)
|
|
1110
|
+
proposal.proposed_at = _now_iso()
|
|
1111
|
+
if self.is_autonomous and self.store is not None:
|
|
1112
|
+
await self._commit(self.store, proposal)
|
|
1113
|
+
proposal.status = "approved"
|
|
1114
|
+
proposal.committed_by_autonomy = True
|
|
1115
|
+
self.items[proposal.id] = proposal
|
|
1116
|
+
return proposal
|
|
1117
|
+
|
|
1118
|
+
def list(self, status: str | None = None) -> list[MappingProposal]:
|
|
1119
|
+
return [p for p in self.items.values() if status is None or p.status == status]
|
|
1120
|
+
|
|
1121
|
+
async def decide(self, store: GraphStore, id: str, decision: MappingDecision) -> MappingProposal | None:
|
|
1122
|
+
"""Human decision. ``approved`` also commits the alias into the graph."""
|
|
1123
|
+
proposal = self.items.get(id)
|
|
1124
|
+
if proposal is None or proposal.status != "pending":
|
|
1125
|
+
return None
|
|
1126
|
+
proposal.status = decision
|
|
1127
|
+
if decision == "approved":
|
|
1128
|
+
await self._commit(store, proposal)
|
|
1129
|
+
return proposal
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class _ProposeMappingArgs(BaseModel):
|
|
1133
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
1134
|
+
|
|
1135
|
+
from_: str = Field(min_length=1, max_length=200, alias="from")
|
|
1136
|
+
to: str = Field(min_length=1, max_length=500)
|
|
1137
|
+
source: str = Field(min_length=1, description="Provenance URI for the committed alias")
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def propose_mapping_tool(proposals: MappingProposals) -> Tool:
|
|
1141
|
+
"""Autonomy mode commits the alias immediately (provenance required);
|
|
1142
|
+
review mode queues it for human approval. Validation failures are repair
|
|
1143
|
+
feedback, not a thrown tool error that would kill the run."""
|
|
1144
|
+
|
|
1145
|
+
async def execute(args: _ProposeMappingArgs, ctx: ToolContext | None) -> Any:
|
|
1146
|
+
try:
|
|
1147
|
+
p = await proposals.propose(args.from_, args.to, args.source)
|
|
1148
|
+
except Exception as err: # noqa: BLE001 - feedback contract
|
|
1149
|
+
return f"Commit rejected — fix and retry: {err}"
|
|
1150
|
+
if p.committed_by_autonomy:
|
|
1151
|
+
return {"id": p.id, "status": "approved", "committed": True}
|
|
1152
|
+
return {"id": p.id, "status": "pending", "note": "Review mode: awaits a human decide() before writing."}
|
|
1153
|
+
|
|
1154
|
+
return define_tool(
|
|
1155
|
+
name="propose_mapping",
|
|
1156
|
+
description="Map a raw term to a canonical ontology IRI. Autonomy mode commits the alias immediately (provenance required); review mode queues it for human approval.",
|
|
1157
|
+
schema=_ProposeMappingArgs,
|
|
1158
|
+
execute=execute,
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
# ---------------------------------------------------------------------------
|
|
1163
|
+
# JSON-LD expand/compact I/O
|
|
1164
|
+
# ---------------------------------------------------------------------------
|
|
1165
|
+
|
|
1166
|
+
_IRI_PREFIX_RE = re.compile(r"^([a-zA-Z][\w-]*):(.+)$", re.ASCII)
|
|
1167
|
+
_HTTP_RE = re.compile(r"^https?://")
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _resolve_iri(term: str, prefixes: Mapping[str, str]) -> str | None:
|
|
1171
|
+
m = _IRI_PREFIX_RE.match(term)
|
|
1172
|
+
base = prefixes.get(m.group(1)) if m else None
|
|
1173
|
+
if m and base:
|
|
1174
|
+
return f"{base}{m.group(2)}"
|
|
1175
|
+
if _HTTP_RE.match(term):
|
|
1176
|
+
return term
|
|
1177
|
+
return None # literal or unknown prefix
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _compact_iri(iri: str, prefixes: Mapping[str, str]) -> str:
|
|
1181
|
+
for prefix, base in prefixes.items():
|
|
1182
|
+
if iri.startswith(base):
|
|
1183
|
+
return f"{prefix}:{iri[len(base):]}"
|
|
1184
|
+
return iri
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _term_to_json_ld(term: str, prefixes: Mapping[str, str]) -> dict[str, Any]:
|
|
1188
|
+
iri = _resolve_iri(term, prefixes)
|
|
1189
|
+
if iri:
|
|
1190
|
+
return {"@id": iri}
|
|
1191
|
+
lang = re.match(r'^"(.*)"@([\w-]+)$', term, re.ASCII)
|
|
1192
|
+
if lang:
|
|
1193
|
+
return {"@value": lang.group(1), "@language": lang.group(2)}
|
|
1194
|
+
typed = re.match(r'^"(.*)"\^\^<?([\w:/#.-]+)>?$', term, re.ASCII)
|
|
1195
|
+
if typed:
|
|
1196
|
+
return {"@value": typed.group(1), "@type": _resolve_iri(typed.group(2), prefixes) or typed.group(2)}
|
|
1197
|
+
plain = re.match(r'^"(.*)"$', term)
|
|
1198
|
+
if plain:
|
|
1199
|
+
return {"@value": plain.group(1)}
|
|
1200
|
+
return {"@value": term}
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _json_ld_to_term(value: Any, prefixes: Mapping[str, str]) -> str | None:
|
|
1204
|
+
if not isinstance(value, dict):
|
|
1205
|
+
native = str(value)
|
|
1206
|
+
if _INT_RE.match(native) or _DECIMAL_RE.match(native) or native in ("true", "false"):
|
|
1207
|
+
return native
|
|
1208
|
+
return f'"{native}"'
|
|
1209
|
+
if value.get("@id"):
|
|
1210
|
+
return _compact_iri(str(value["@id"]), prefixes)
|
|
1211
|
+
if "@value" in value:
|
|
1212
|
+
if value.get("@language"):
|
|
1213
|
+
return f'"{value["@value"]}"@{value["@language"]}'
|
|
1214
|
+
if value.get("@type"):
|
|
1215
|
+
return f'"{value["@value"]}"^^{_compact_iri(str(value["@type"]), prefixes)}'
|
|
1216
|
+
return f'"{value["@value"]}"'
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def quads_to_json_ld(quads: list[Quad], prefixes: Mapping[str, str] | None = None) -> dict[str, Any]:
|
|
1221
|
+
"""Quads -> expanded JSON-LD. Named graphs round-trip: quads carrying a
|
|
1222
|
+
``graph`` become JSON-LD named-graph entries, graph-less quads land in the
|
|
1223
|
+
default graph. Provenance (source/rule) does not round-trip — JSON-LD
|
|
1224
|
+
carries the statements, the store keeps the evidence."""
|
|
1225
|
+
prefixes = dict(prefixes) if prefixes is not None else dict(JSONLD_PREFIXES)
|
|
1226
|
+
by_graph: dict[str, list[Quad]] = {}
|
|
1227
|
+
for q in quads:
|
|
1228
|
+
by_graph.setdefault(q.graph or "", []).append(q)
|
|
1229
|
+
|
|
1230
|
+
def build_nodes(group: list[Quad]) -> list[dict[str, Any]]:
|
|
1231
|
+
nodes: dict[str, dict[str, Any]] = {}
|
|
1232
|
+
|
|
1233
|
+
def node_of(id_: str) -> dict[str, Any]:
|
|
1234
|
+
if id_ not in nodes:
|
|
1235
|
+
nodes[id_] = {"@id": _resolve_iri(id_, prefixes) or id_}
|
|
1236
|
+
return nodes[id_]
|
|
1237
|
+
|
|
1238
|
+
for q in group:
|
|
1239
|
+
node = node_of(q.subject)
|
|
1240
|
+
if q.predicate in ("rdf:type", "a"):
|
|
1241
|
+
type_iri = _resolve_iri(q.object, prefixes) or q.object
|
|
1242
|
+
existing = node.get("@type")
|
|
1243
|
+
if isinstance(existing, list):
|
|
1244
|
+
if type_iri not in existing:
|
|
1245
|
+
node["@type"] = [*existing, type_iri]
|
|
1246
|
+
elif existing is not None:
|
|
1247
|
+
node["@type"] = [existing, type_iri]
|
|
1248
|
+
else:
|
|
1249
|
+
node["@type"] = type_iri
|
|
1250
|
+
continue
|
|
1251
|
+
key = _resolve_iri(q.predicate, prefixes) or q.predicate
|
|
1252
|
+
value = _term_to_json_ld(q.object, prefixes)
|
|
1253
|
+
existing = node.get(key)
|
|
1254
|
+
node[key] = (
|
|
1255
|
+
[*existing, value]
|
|
1256
|
+
if isinstance(existing, list)
|
|
1257
|
+
else ([existing, value] if existing is not None else value)
|
|
1258
|
+
)
|
|
1259
|
+
return list(nodes.values())
|
|
1260
|
+
|
|
1261
|
+
entries: list[Any] = []
|
|
1262
|
+
for g, group in by_graph.items():
|
|
1263
|
+
nodes = build_nodes(group)
|
|
1264
|
+
if g == "":
|
|
1265
|
+
entries.extend(nodes)
|
|
1266
|
+
else:
|
|
1267
|
+
entries.append({"@id": _resolve_iri(g, prefixes) or g, "@graph": nodes})
|
|
1268
|
+
return {"@context": prefixes, "@graph": entries}
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def json_ld_to_quads(
|
|
1272
|
+
doc: Mapping[str, Any], prefixes: Mapping[str, str] | None = None
|
|
1273
|
+
) -> list[Quad]:
|
|
1274
|
+
"""Expanded JSON-LD -> quads (named graphs preserved; prefixed terms
|
|
1275
|
+
restored where the prefix map covers them)."""
|
|
1276
|
+
prefixes = prefixes if prefixes is not None else JSONLD_PREFIXES
|
|
1277
|
+
out: list[Quad] = []
|
|
1278
|
+
blank = 0
|
|
1279
|
+
|
|
1280
|
+
def convert_node(node: Mapping[str, Any], graph_name: str | None) -> None:
|
|
1281
|
+
nonlocal blank
|
|
1282
|
+
if node.get("@id"):
|
|
1283
|
+
subject = _compact_iri(str(node["@id"]), prefixes)
|
|
1284
|
+
else:
|
|
1285
|
+
subject = f"_:b{blank}"
|
|
1286
|
+
blank += 1
|
|
1287
|
+
types = node.get("@type")
|
|
1288
|
+
for t in types if isinstance(types, list) else ([] if types is None else [types]):
|
|
1289
|
+
out.append(Quad(subject, "rdf:type", _compact_iri(str(t), prefixes), graph=graph_name))
|
|
1290
|
+
for key, raw in node.items():
|
|
1291
|
+
if key in ("@id", "@type"):
|
|
1292
|
+
continue
|
|
1293
|
+
resolved = _resolve_iri(key, prefixes) if ":" in key else key
|
|
1294
|
+
predicate = _compact_iri(resolved if resolved is not None else key, prefixes)
|
|
1295
|
+
for v in raw if isinstance(raw, list) else [raw]:
|
|
1296
|
+
obj = _json_ld_to_term(v, prefixes)
|
|
1297
|
+
if obj is not None:
|
|
1298
|
+
out.append(Quad(subject, predicate, obj, graph=graph_name))
|
|
1299
|
+
|
|
1300
|
+
for entry in doc.get("@graph", []):
|
|
1301
|
+
if isinstance(entry, dict) and isinstance(entry.get("@graph"), list):
|
|
1302
|
+
name = _compact_iri(str(entry["@id"]), prefixes) if entry.get("@id") else f"_:g{len(out)}"
|
|
1303
|
+
for node in entry["@graph"]:
|
|
1304
|
+
convert_node(node, name)
|
|
1305
|
+
continue
|
|
1306
|
+
if isinstance(entry, dict):
|
|
1307
|
+
convert_node(entry, None)
|
|
1308
|
+
return out
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
# ---------------------------------------------------------------------------
|
|
1312
|
+
# Conformance battery
|
|
1313
|
+
# ---------------------------------------------------------------------------
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
@dataclass
|
|
1317
|
+
class ConformanceCheck:
|
|
1318
|
+
id: str
|
|
1319
|
+
#: SPARQL-subset SELECT. Passes when the row count is within
|
|
1320
|
+
#: [min_rows, max_rows]. Default: at least one row (ask-style);
|
|
1321
|
+
#: min_rows=0, max_rows=0 asserts absence.
|
|
1322
|
+
ask: str | None = None
|
|
1323
|
+
min_rows: int | None = None
|
|
1324
|
+
max_rows: int | None = None
|
|
1325
|
+
#: An extra SHACL shape the closure must satisfy.
|
|
1326
|
+
shape: NodeShape | None = None
|
|
1327
|
+
#: Why this check matters — surfaces in failure reports.
|
|
1328
|
+
reason: str | None = None
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
@dataclass
|
|
1332
|
+
class ConformanceResult:
|
|
1333
|
+
id: str
|
|
1334
|
+
passed: bool
|
|
1335
|
+
detail: str
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
@dataclass
|
|
1339
|
+
class ConformanceReport:
|
|
1340
|
+
passed: bool
|
|
1341
|
+
results: list[ConformanceResult] = field(default_factory=list)
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
async def run_conformance(store: InMemoryGraphStore, battery: list[ConformanceCheck]) -> ConformanceReport:
|
|
1345
|
+
"""Run a battery of checks against a store's closure."""
|
|
1346
|
+
results: list[ConformanceResult] = []
|
|
1347
|
+
closure = await store.match()
|
|
1348
|
+
for check in battery:
|
|
1349
|
+
try:
|
|
1350
|
+
if check.ask:
|
|
1351
|
+
rows = await execute_select(store, check.ask)
|
|
1352
|
+
minimum = check.min_rows if check.min_rows is not None else 1
|
|
1353
|
+
ok = len(rows) >= minimum and (check.max_rows is None or len(rows) <= check.max_rows)
|
|
1354
|
+
detail = f"{len(rows)} rows (expected >= {minimum}"
|
|
1355
|
+
if check.max_rows is not None:
|
|
1356
|
+
detail += f", <= {check.max_rows}"
|
|
1357
|
+
detail += ")"
|
|
1358
|
+
results.append(ConformanceResult(check.id, ok, detail))
|
|
1359
|
+
elif check.shape:
|
|
1360
|
+
# Battery shapes aren't store-registered: validate with a
|
|
1361
|
+
# scratch store carrying ONLY this shape (check_shapes is pure).
|
|
1362
|
+
scratch = InMemoryGraphStore(shapes=[check.shape])
|
|
1363
|
+
violations = scratch.check_shapes(closure).violations
|
|
1364
|
+
results.append(
|
|
1365
|
+
ConformanceResult(
|
|
1366
|
+
check.id,
|
|
1367
|
+
not violations,
|
|
1368
|
+
"; ".join(v.message for v in violations) if violations else "shape holds",
|
|
1369
|
+
)
|
|
1370
|
+
)
|
|
1371
|
+
else:
|
|
1372
|
+
detail = "check has neither ask nor shape"
|
|
1373
|
+
if check.reason:
|
|
1374
|
+
detail += f" ({check.reason})"
|
|
1375
|
+
results.append(ConformanceResult(check.id, False, detail))
|
|
1376
|
+
except Exception as err: # noqa: BLE001 - battery reports, never throws
|
|
1377
|
+
results.append(ConformanceResult(check.id, False, str(err)))
|
|
1378
|
+
return ConformanceReport(passed=all(r.passed for r in results), results=results)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
# ---------------------------------------------------------------------------
|
|
1382
|
+
# TBox evolution
|
|
1383
|
+
# ---------------------------------------------------------------------------
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _term_key(q: Any) -> str:
|
|
1387
|
+
return f"{q.subject}|{q.predicate}|{q.object}"
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
#: Ontology-defining predicates: shared with the review auditor.
|
|
1391
|
+
TBOX_PREDICATES = {
|
|
1392
|
+
"rdfs:subClassOf",
|
|
1393
|
+
"http://www.w3.org/2000/01/rdf-schema#subClassOf",
|
|
1394
|
+
"rdfs:subPropertyOf",
|
|
1395
|
+
"http://www.w3.org/2000/01/rdf-schema#subPropertyOf",
|
|
1396
|
+
"rdfs:domain",
|
|
1397
|
+
"http://www.w3.org/2000/01/rdf-schema#domain",
|
|
1398
|
+
"rdfs:range",
|
|
1399
|
+
"http://www.w3.org/2000/01/rdf-schema#range",
|
|
1400
|
+
"owl:inverseOf",
|
|
1401
|
+
"http://www.w3.org/2002/07/owl#inverseOf",
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
#: rdf:type objects that mark a TERM declaration, not an instance fact.
|
|
1405
|
+
OWL_VOCAB = {
|
|
1406
|
+
"owl:TransitiveProperty",
|
|
1407
|
+
"http://www.w3.org/2002/07/owl#TransitiveProperty",
|
|
1408
|
+
"owl:SymmetricProperty",
|
|
1409
|
+
"http://www.w3.org/2002/07/owl#SymmetricProperty",
|
|
1410
|
+
"owl:FunctionalProperty",
|
|
1411
|
+
"http://www.w3.org/2002/07/owl#FunctionalProperty",
|
|
1412
|
+
"owl:Class",
|
|
1413
|
+
"http://www.w3.org/2002/07/owl#Class",
|
|
1414
|
+
"rdfs:Class",
|
|
1415
|
+
"http://www.w3.org/2000/01/rdf-schema#Class",
|
|
1416
|
+
"rdfs:Datatype",
|
|
1417
|
+
"http://www.w3.org/2000/01/rdf-schema#Datatype",
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
@dataclass
|
|
1422
|
+
class AxiomRef:
|
|
1423
|
+
"""Exact axiom to retract (matched on subject|predicate|object)."""
|
|
1424
|
+
|
|
1425
|
+
subject: str
|
|
1426
|
+
predicate: str
|
|
1427
|
+
object: str
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
@dataclass
|
|
1431
|
+
class Rename:
|
|
1432
|
+
from_: str
|
|
1433
|
+
to: str
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
@dataclass
|
|
1437
|
+
class Deprecation:
|
|
1438
|
+
term: str
|
|
1439
|
+
reason: str
|
|
1440
|
+
replacement: str | None = None
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
@dataclass
|
|
1444
|
+
class TBoxMigrationSpec:
|
|
1445
|
+
from_: str
|
|
1446
|
+
to: str
|
|
1447
|
+
#: New TBox axioms to add (provenance required by the store).
|
|
1448
|
+
added: list[Quad] = field(default_factory=list)
|
|
1449
|
+
#: Exact axioms to retract (matched on subject|predicate|object).
|
|
1450
|
+
removed: list[AxiomRef] = field(default_factory=list)
|
|
1451
|
+
#: Term renames applied to BOTH the TBox and ABox in all three positions.
|
|
1452
|
+
renames: list[Rename] = field(default_factory=list)
|
|
1453
|
+
#: Terms deprecated in this version; with replacement, uses are rewritten.
|
|
1454
|
+
deprecations: list[Deprecation] = field(default_factory=list)
|
|
1455
|
+
#: Shapes/rules carried by the NEW store (defaults to the old store's).
|
|
1456
|
+
shapes: list[NodeShape] | None = None
|
|
1457
|
+
rules: list[GraphRule] | None = None
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
@dataclass
|
|
1461
|
+
class MigrationOptions:
|
|
1462
|
+
battery: list[ConformanceCheck] | None = None
|
|
1463
|
+
factory: Any | None = None # (opts: StoreOptions) -> InMemoryGraphStore
|
|
1464
|
+
#: Version-named graph for the new TBox. True/None -> default
|
|
1465
|
+
#: ``urn:framework:ontology:<to>``; False opts out (graph-free).
|
|
1466
|
+
tbox_graph: str | None | bool = None
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
@dataclass
|
|
1470
|
+
class MigrationReport:
|
|
1471
|
+
from_: str
|
|
1472
|
+
to: str
|
|
1473
|
+
status: str = "migrated" # migrated | aborted | failed
|
|
1474
|
+
added_quads: int = 0
|
|
1475
|
+
removed_quads: int = 0
|
|
1476
|
+
rewritten_quads: int = 0
|
|
1477
|
+
#: Named graph the new TBox axioms were written to (match(..., g=tboxGraph)).
|
|
1478
|
+
tbox_graph: str | None = None
|
|
1479
|
+
deprecated_warnings: list[str] = field(default_factory=list)
|
|
1480
|
+
errors: list[str] = field(default_factory=list)
|
|
1481
|
+
pre_battery: ConformanceReport | None = None
|
|
1482
|
+
post_battery: ConformanceReport | None = None
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
@dataclass
|
|
1486
|
+
class MigrationResult:
|
|
1487
|
+
report: MigrationReport
|
|
1488
|
+
#: Present on success — the new-version store. The old store is untouched.
|
|
1489
|
+
store: InMemoryGraphStore | None = None
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _now_iso() -> str:
|
|
1493
|
+
import datetime
|
|
1494
|
+
|
|
1495
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _is_axiom(q: Quad, predicates_in_use: set[str]) -> bool:
|
|
1499
|
+
"""True when the quad is an ontology statement, not a fact."""
|
|
1500
|
+
if q.predicate in TBOX_PREDICATES:
|
|
1501
|
+
return True
|
|
1502
|
+
# rdf:type is TBox when it types a TERM (property/class used somewhere) or
|
|
1503
|
+
# carries an OWL vocabulary marker — ABox for plain instances.
|
|
1504
|
+
if q.predicate in RDF_TYPE:
|
|
1505
|
+
return q.subject in predicates_in_use or q.object in OWL_VOCAB
|
|
1506
|
+
return False
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
async def migrate_t_box(
|
|
1510
|
+
old_store: InMemoryGraphStore,
|
|
1511
|
+
spec: TBoxMigrationSpec,
|
|
1512
|
+
opts: MigrationOptions | None = None,
|
|
1513
|
+
) -> MigrationResult:
|
|
1514
|
+
"""Migrate a TBox. Order: pre-battery (abort on failure) -> rebuild ->
|
|
1515
|
+
post-battery (report failure; caller decides whether to adopt).
|
|
1516
|
+
|
|
1517
|
+
The store has no delete by design; migration therefore REBUILDS: explicit
|
|
1518
|
+
facts are exported from the old store, transformed (axiom add/remove, term
|
|
1519
|
+
renames, deprecations), re-validated and re-entailed into a fresh store —
|
|
1520
|
+
the old store survives untouched (rollback is "don't adopt").
|
|
1521
|
+
"""
|
|
1522
|
+
opts = opts or MigrationOptions()
|
|
1523
|
+
report = MigrationReport(from_=spec.from_, to=spec.to)
|
|
1524
|
+
if opts.battery:
|
|
1525
|
+
report.pre_battery = await run_conformance(old_store, opts.battery)
|
|
1526
|
+
if not report.pre_battery.passed:
|
|
1527
|
+
report.status = "aborted"
|
|
1528
|
+
report.errors.append("pre-migration conformance battery failed — ontology not migrated")
|
|
1529
|
+
return MigrationResult(report=report)
|
|
1530
|
+
|
|
1531
|
+
remove_keys = {_term_key(q) for q in (spec.removed or [])}
|
|
1532
|
+
rename_map = {r.from_: r.to for r in (spec.renames or [])}
|
|
1533
|
+
for d in spec.deprecations or []:
|
|
1534
|
+
if d.replacement:
|
|
1535
|
+
rename_map[d.term] = d.replacement
|
|
1536
|
+
report.deprecated_warnings.append(f"{d.term} deprecated ({d.reason}) — rewritten to {d.replacement}")
|
|
1537
|
+
else:
|
|
1538
|
+
report.deprecated_warnings.append(f"{d.term} deprecated ({d.reason}) — uses kept, flagged")
|
|
1539
|
+
|
|
1540
|
+
def rewrite(term: str) -> str:
|
|
1541
|
+
return rename_map.get(term, term)
|
|
1542
|
+
|
|
1543
|
+
factory = opts.factory or (lambda o: InMemoryGraphStore(o))
|
|
1544
|
+
new_store = factory(
|
|
1545
|
+
StoreOptions(
|
|
1546
|
+
shapes=spec.shapes if spec.shapes is not None else old_store.ontology_shapes(),
|
|
1547
|
+
rules=spec.rules if spec.rules is not None else old_store.ontology_rules(),
|
|
1548
|
+
)
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
explicit = [q for q in await old_store.match() if q.source != "entailment"]
|
|
1552
|
+
# Named-graph use: the new ontology is its OWN dataset inside the store —
|
|
1553
|
+
# every version stays addressable (match(None, None, None, tbox_graph)),
|
|
1554
|
+
# JSON-LD round-trips it as a named graph, and False opts out.
|
|
1555
|
+
if opts.tbox_graph is False:
|
|
1556
|
+
tbox_graph: str | None = None
|
|
1557
|
+
else:
|
|
1558
|
+
tbox_graph = opts.tbox_graph or f"urn:framework:ontology:{spec.to}"
|
|
1559
|
+
report.tbox_graph = tbox_graph
|
|
1560
|
+
predicates_in_use = {
|
|
1561
|
+
rewrite(q.predicate) for q in explicit if _term_key(q) not in remove_keys
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
migration_tag = f"migration:{spec.from_}->{spec.to}"
|
|
1565
|
+
rebuilt: list[Quad] = []
|
|
1566
|
+
for q in explicit:
|
|
1567
|
+
if _term_key(q) in remove_keys:
|
|
1568
|
+
report.removed_quads += 1
|
|
1569
|
+
continue
|
|
1570
|
+
next_q = Quad(
|
|
1571
|
+
rewrite(q.subject),
|
|
1572
|
+
rewrite(q.predicate),
|
|
1573
|
+
rewrite(q.object),
|
|
1574
|
+
graph=q.graph,
|
|
1575
|
+
source=q.source,
|
|
1576
|
+
derived_from=q.derived_from,
|
|
1577
|
+
)
|
|
1578
|
+
if _is_axiom(next_q, predicates_in_use) and tbox_graph:
|
|
1579
|
+
next_q.graph = tbox_graph
|
|
1580
|
+
if _term_key(next_q) != _term_key(q):
|
|
1581
|
+
report.rewritten_quads += 1
|
|
1582
|
+
next_q.derived_from = (
|
|
1583
|
+
f"{next_q.derived_from};{migration_tag}" if next_q.derived_from else migration_tag
|
|
1584
|
+
)
|
|
1585
|
+
rebuilt.append(next_q)
|
|
1586
|
+
for aq in spec.added or []:
|
|
1587
|
+
rebuilt.append(
|
|
1588
|
+
Quad(
|
|
1589
|
+
aq.subject,
|
|
1590
|
+
aq.predicate,
|
|
1591
|
+
aq.object,
|
|
1592
|
+
graph=tbox_graph if tbox_graph else aq.graph,
|
|
1593
|
+
source=aq.source,
|
|
1594
|
+
derived_from=migration_tag,
|
|
1595
|
+
)
|
|
1596
|
+
)
|
|
1597
|
+
report.added_quads = len(spec.added or [])
|
|
1598
|
+
|
|
1599
|
+
try:
|
|
1600
|
+
await new_store.add(rebuilt)
|
|
1601
|
+
except Exception as err: # noqa: BLE001 - migration reports, never throws
|
|
1602
|
+
report.status = "failed"
|
|
1603
|
+
report.errors.append(f"{spec.to} store rejected migrated facts: {err}")
|
|
1604
|
+
return MigrationResult(report=report)
|
|
1605
|
+
|
|
1606
|
+
if opts.battery:
|
|
1607
|
+
report.post_battery = await run_conformance(new_store, opts.battery)
|
|
1608
|
+
if not report.post_battery.passed:
|
|
1609
|
+
report.status = "failed"
|
|
1610
|
+
failed = [f"{r.id}: {r.detail}" for r in report.post_battery.results if not r.passed]
|
|
1611
|
+
report.errors.append(f"post-migration conformance battery failed — {'; '.join(failed)}")
|
|
1612
|
+
return MigrationResult(report=report)
|
|
1613
|
+
|
|
1614
|
+
return MigrationResult(report=report, store=new_store)
|