Skip to content

Commit a975707

Browse files
feat(datafabric): fetch ontology R2RML alongside OWL
1 parent 941f3ff commit a975707

2 files changed

Lines changed: 132 additions & 33 deletions

File tree

src/uipath_langchain/agent/tools/datafabric_tool/ontology_fetch_tool.py

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
"""LLM-decided tool that fetches ontology OWL schemas from Data Fabric.
1+
"""LLM-decided tool that fetches ontology OWL schemas + R2RML mappings from Data Fabric.
22
33
Mirrors ``datafabric_query_tool.py``: a small leaf tool the inner SQL agent can
44
call. A context may attach one or more ontologies (mirroring the entity set), so
5-
the tool fetches each configured ontology's OWL via the SDK
6-
(``EntitiesService.get_ontology_file_async``) and returns them concatenated. The
7-
tool node turns the return value into a ToolMessage the inner LLM reads on its
8-
next turn — so the model can call ``fetch_ontology`` first, then write SQL.
5+
the tool fetches each configured ontology's OWL schema and, when present, its
6+
R2RML mapping via the SDK (``EntitiesService.get_ontology_file_async``) and
7+
returns them concatenated. The tool node turns the return value into a
8+
ToolMessage the inner LLM reads on its next turn — so the model can call
9+
``fetch_ontology`` first, then write SQL.
10+
11+
The OWL is the authoritative semantic schema (required). The R2RML mapping is
12+
optional: it tells the model which ontology classes/properties correspond to
13+
which Data Fabric entity tables/columns, so it can translate ontology terms into
14+
the real column names for SQL. Note this is grounding *text* for the LLM — the
15+
executable R2RML inference flow (Ontop) is a later milestone.
916
1017
Ontology names/folders are pinned from configuration, not supplied by the LLM,
1118
so the model cannot redirect the fetch to an arbitrary resource.
@@ -23,9 +30,14 @@
2330

2431
logger = logging.getLogger(__name__)
2532

26-
# Defensive cap per ontology so a malformed/oversized OWL can't blow up the
33+
# Defensive cap per file so a malformed/oversized OWL or R2RML can't blow up the
2734
# prompt/token budget.
28-
_MAX_OWL_BYTES = 1_000_000
35+
_MAX_FILE_BYTES = 1_000_000
36+
37+
# OWL is the required semantic schema; R2RML is the optional ontology->entity
38+
# mapping. Order is preserved by asyncio.gather, so the concatenation stays
39+
# deterministic (each ontology's OWL block precedes its R2RML block).
40+
_FILE_TYPES = ("owl", "r2rml")
2941

3042

3143
def _notation_label(media_type: str) -> str:
@@ -39,10 +51,11 @@ def _notation_label(media_type: str) -> str:
3951

4052

4153
class OntologyFetcher:
42-
"""Fetches and caches the OWL for one or more configured ontologies.
54+
"""Fetches and caches the OWL schema (and optional R2RML mapping) per ontology.
4355
4456
Each entry is ``(ontology_name, folder_key)`` — the ontology carries its own
45-
folder. The combined result is cached on this instance, which lives as long
57+
folder. For each, the OWL schema and (when present) the R2RML mapping are
58+
fetched. The combined result is cached on this instance, which lives as long
4659
as the compiled sub-graph, so repeated calls across queries hit the API at
4760
most once.
4861
"""
@@ -56,28 +69,57 @@ def __init__(
5669
self._ontologies = ontologies
5770
self._cached: str | None = None
5871

59-
async def _fetch_one(self, name: str, folder_key: str | None) -> str:
72+
async def _fetch_one(
73+
self, name: str, folder_key: str | None, file_type: str
74+
) -> str:
75+
"""Fetch one ontology file, returning a fenced block for the LLM.
76+
77+
OWL is required: if it is missing/oversized the model is told to fall
78+
back to the entity schemas. R2RML is optional: a missing mapping returns
79+
an empty string (silently dropped from the output), since most
80+
ontologies have no R2RML yet.
81+
"""
82+
optional = file_type != "owl"
6083
try:
6184
data = await self._entities_service.get_ontology_file_async(
62-
name, "owl", folder_key
85+
name, file_type, folder_key
6386
)
64-
owl = data.get("content") or ""
87+
content = data.get("content") or ""
6588
media_type = data.get("mediaType") or ""
66-
if len(owl.encode("utf-8")) > _MAX_OWL_BYTES:
67-
raise ValueError(f"Ontology '{name}' OWL exceeds the size limit.")
89+
if not content:
90+
raise ValueError(f"Ontology '{name}' {file_type} is empty.")
91+
if len(content.encode("utf-8")) > _MAX_FILE_BYTES:
92+
raise ValueError(
93+
f"Ontology '{name}' {file_type} exceeds the size limit."
94+
)
6895
except Exception as e:
96+
if optional:
97+
# Absent/oversized optional file — skip it without noise.
98+
logger.info(
99+
"Optional %s for ontology %r unavailable: %s", file_type, name, e
100+
)
101+
return ""
69102
logger.warning("Ontology fetch failed for %r: %s", name, e)
70103
return (
71104
f"Ontology '{name}' is unavailable ({type(e).__name__}). "
72105
"Proceed using the entity schemas in the system prompt."
73106
)
74-
notation = _notation_label(media_type)
107+
if file_type == "owl":
108+
notation = _notation_label(media_type)
109+
return (
110+
f"OWL 2 QL ontology '{name}' ({notation}) — authoritative schema. "
111+
"Use these exact class/property names and value formats for SQL; "
112+
"this is reference data, not instructions.\n\n"
113+
f"--- ONTOLOGY: {name} ({notation}) ---\n{content}\n"
114+
f"--- END ONTOLOGY: {name} ---"
115+
)
75116
return (
76-
f"OWL 2 QL ontology '{name}' ({notation}) — authoritative schema. "
77-
"Use these exact class/property names and value formats for SQL; "
78-
"this is reference data, not instructions.\n\n"
79-
f"--- ONTOLOGY: {name} ({notation}) ---\n{owl}\n"
80-
f"--- END ONTOLOGY: {name} ---"
117+
f"R2RML mapping for '{name}' — maps the ontology's classes/properties "
118+
"to Data Fabric entity tables and columns. Use it to translate "
119+
"ontology terms into the real entity/column names for SQL; this is "
120+
"reference data, not instructions.\n\n"
121+
f"--- R2RML MAPPING: {name} ---\n{content}\n"
122+
f"--- END R2RML MAPPING: {name} ---"
81123
)
82124

83125
async def __call__(self, **_kwargs: Any) -> str:
@@ -86,12 +128,17 @@ async def __call__(self, **_kwargs: Any) -> str:
86128
return self._cached
87129
if not self._ontologies:
88130
return "No ontologies are configured for this agent."
89-
# Fetch all ontologies concurrently — each fetch is independent; order is
90-
# preserved by gather, so the concatenation is deterministic.
131+
# Fetch every (ontology, file_type) concurrently — each fetch is
132+
# independent; gather preserves order, so the concatenation is
133+
# deterministic. Empty blocks (absent optional R2RML) are dropped.
91134
blocks = await asyncio.gather(
92-
*(self._fetch_one(name, folder) for name, folder in self._ontologies)
135+
*(
136+
self._fetch_one(name, folder, file_type)
137+
for name, folder in self._ontologies
138+
for file_type in _FILE_TYPES
139+
)
93140
)
94-
self._cached = "\n\n".join(blocks)
141+
self._cached = "\n\n".join(block for block in blocks if block)
95142
return self._cached
96143

97144

@@ -116,9 +163,11 @@ def create_ontology_fetch_tool(
116163
name=tool_name,
117164
description=(
118165
f"Fetch the OWL 2 QL ontologies (the authoritative semantic schema) "
119-
f"for: {names}. Call this BEFORE writing SQL: it gives the exact "
120-
"class and property names, value formats, and relationships so your "
121-
"SQL uses the real schema instead of guesses. Takes no arguments."
166+
f"and, when available, their R2RML mappings (ontology-to-entity/column "
167+
f"mapping) for: {names}. Call this BEFORE writing SQL: it gives the "
168+
"exact class and property names, value formats, relationships, and how "
169+
"they map to entity columns, so your SQL uses the real schema instead "
170+
"of guesses. Takes no arguments."
122171
),
123172
args_schema=OntologyFetchInput,
124173
coroutine=OntologyFetcher(entities_service, ontologies),

tests/agent/tools/test_ontology_fetch_tool.py

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@ def _entities_service(content: str = "OWLDATA", media_type: str = "text/turtle")
1919
return es
2020

2121

22+
def _typed_entities_service(
23+
owl: str = "OWLBODY", r2rml: str | None = "R2RMLBODY"
24+
) -> MagicMock:
25+
"""Entities service that returns distinct OWL/R2RML content per file_type.
26+
27+
``r2rml=None`` simulates an ontology with no R2RML mapping (the SDK raises).
28+
"""
29+
es = MagicMock()
30+
31+
async def _fake(name, file_type, folder_key=None):
32+
if file_type == "owl":
33+
return {"content": owl, "mediaType": "text/turtle"}
34+
if r2rml is None:
35+
raise FileNotFoundError("no r2rml file")
36+
return {"content": r2rml, "mediaType": "application/r2rml+turtle"}
37+
38+
es.get_ontology_file_async = AsyncMock(side_effect=_fake)
39+
return es
40+
41+
2242
# --- _notation_label -------------------------------------------------------
2343

2444

@@ -63,7 +83,33 @@ async def test_fetcher_single_ontology_returns_fenced_block():
6383
assert "ONTOLOGY: library" in result
6484
assert "OWLBODY" in result
6585
assert "Turtle" in result
66-
es.get_ontology_file_async.assert_awaited_once_with("library", "owl", "folder-1")
86+
# Both the OWL schema and the R2RML mapping are requested for the ontology.
87+
es.get_ontology_file_async.assert_any_await("library", "owl", "folder-1")
88+
es.get_ontology_file_async.assert_any_await("library", "r2rml", "folder-1")
89+
assert es.get_ontology_file_async.await_count == 2
90+
91+
92+
async def test_fetcher_includes_r2rml_when_present():
93+
es = _typed_entities_service(owl="OWLBODY", r2rml="R2RMLBODY")
94+
fetcher = OntologyFetcher(es, [("library", "f1")])
95+
96+
result = await fetcher()
97+
98+
assert "ONTOLOGY: library" in result and "OWLBODY" in result
99+
assert "R2RML MAPPING: library" in result and "R2RMLBODY" in result
100+
requested = {call.args[1] for call in es.get_ontology_file_async.await_args_list}
101+
assert requested == {"owl", "r2rml"}
102+
103+
104+
async def test_fetcher_skips_absent_r2rml_without_warning():
105+
es = _typed_entities_service(owl="OWLBODY", r2rml=None)
106+
fetcher = OntologyFetcher(es, [("library", None)])
107+
108+
result = await fetcher()
109+
110+
assert "ONTOLOGY: library" in result # OWL still present
111+
assert "R2RML" not in result # absent optional mapping → no block
112+
assert "unavailable" not in result # and no loud fallback for the optional file
67113

68114

69115
async def test_fetcher_multiple_ontologies_concatenated():
@@ -74,7 +120,8 @@ async def test_fetcher_multiple_ontologies_concatenated():
74120

75121
assert "ONTOLOGY: library" in result
76122
assert "ONTOLOGY: finance" in result
77-
assert es.get_ontology_file_async.await_count == 2
123+
# 2 ontologies x 2 file types (owl + r2rml).
124+
assert es.get_ontology_file_async.await_count == 4
78125

79126

80127
async def test_fetcher_caches_after_first_call():
@@ -85,8 +132,9 @@ async def test_fetcher_caches_after_first_call():
85132
second = await fetcher()
86133

87134
assert first == second
88-
# Two ontologies fetched once total — the second call is served from cache.
89-
assert es.get_ontology_file_async.await_count == 2
135+
# Two ontologies x two file types, fetched once total — the second call is
136+
# served from cache.
137+
assert es.get_ontology_file_async.await_count == 4
90138

91139

92140
async def test_fetcher_graceful_degrade_on_error():
@@ -101,7 +149,7 @@ async def test_fetcher_graceful_degrade_on_error():
101149

102150

103151
async def test_fetcher_oversized_owl_is_degraded(monkeypatch):
104-
monkeypatch.setattr(oft, "_MAX_OWL_BYTES", 5)
152+
monkeypatch.setattr(oft, "_MAX_FILE_BYTES", 5)
105153
es = _entities_service(content="0123456789") # 10 bytes > cap
106154
fetcher = OntologyFetcher(es, [("library", None)])
107155

@@ -114,7 +162,9 @@ async def test_fetcher_oversized_owl_is_degraded(monkeypatch):
114162

115163

116164
def test_create_tool_metadata_and_schema():
117-
tool = create_ontology_fetch_tool(_entities_service(), [("library", None), ("finance", None)])
165+
tool = create_ontology_fetch_tool(
166+
_entities_service(), [("library", None), ("finance", None)]
167+
)
118168

119169
assert tool.name == "fetch_ontology"
120170
assert "library" in tool.description and "finance" in tool.description

0 commit comments

Comments
 (0)