Skip to content

Commit 90dffe6

Browse files
feat(datafabric ontology): first-class read-only entities, enforcement, debug, read-flow wiring
Extends the ontology layer per review feedback: 1. df:ReadableEntity is now first-class. CompiledOntology gains `known_entities` (every df:entityKey the ontology declares) plus is_known / is_writable / is_read_only helpers. Previously a read-only entity was indistinguishable from one the ontology never mentioned — both were merely absent from entity_access. 2. Read-only is enforced, not advisory. validate_mutation_intent rejects a write to an entity the ontology knows but grants no write ops; the write handler prunes such entities from write_schemas so they never appear in the write tool description. (Verified: Customer is excluded and a direct update is rejected.) 3. Debug output. CompiledOntology.to_human_readable() + module-level format_ontology_debug(owl, compiled) render the raw OWL Turtle and a human-readable IR (entities + access modes, measure/state/reference field semantics, relationships). Logged at DEBUG in the fetch/compile path and printed by both POC scripts during a run. 4. Ontology wired into the READ flow (reads still go through the existing NL-to-SQL path — ontology enriches, does not restrict). Shared maybe_fetch_and_compile_ontology helper used by both handlers; the read handler threads CompiledOntology into DataFabricGraph.create -> datafabric_prompt_builder, which emits an "## Ontology Context" section (access modes, relationships, FK/reference targets, state-value sources) for schema linking (P5). Also: poc_refund_drive.py verification read-back now addresses entities by GUID (get_record_async requires the id, not the name). Validated live on staging (dataservicetest/addyTest): debug IR shows Customer READ-ONLY; Customer pruned from writes + write rejected; refund flow insert + 3 updates persist, 4/4 verified. 752 tests pass, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8a10b1a commit 90dffe6

14 files changed

Lines changed: 558 additions & 49 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,4 @@ samples/**/entry-points.json
195195
samples/**/uv.lock
196196

197197
testcases/**/uv.lock
198+
poc_out/

scripts/poc_refund_drive.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ async def main() -> int:
9393
" ACTIVE — entity_access:",
9494
{k: sorted(v) for k, v in onto.entity_access.items()},
9595
)
96+
# Always dump the raw OWL + human-readable IR for the POC.
97+
from uipath_langchain.agent.tools.datafabric_tool.ontology_compiler import (
98+
format_ontology_debug,
99+
)
100+
101+
print()
102+
print(format_ontology_debug(ONTOLOGY_TTL, onto))
96103
else:
97104
print(" INACTIVE (metadata-only)")
98105

@@ -148,9 +155,12 @@ async def write(**kw): # noqa: ANN003
148155
def g(rec, k): # noqa: ANN001
149156
return rec.get(k) if isinstance(rec, dict) else getattr(rec, k, None)
150157

151-
order = await svc.get_record_async(by_suffix["PurchaseOrder"], ids["ORDER_ID"])
152-
risk = await svc.get_record_async(by_suffix["CustomerRisk"], ids["RISK_ID"])
153-
contact = await svc.get_record_async(by_suffix["Contact"], ids["CONTACT_ID"])
158+
# get_record_async addresses the entity by its GUID id, not its name
159+
# (same rule the write executor follows).
160+
id_by_suffix = {it.name.rsplit("_", 1)[1]: it.id for it in items}
161+
order = await svc.get_record_async(id_by_suffix["PurchaseOrder"], ids["ORDER_ID"])
162+
risk = await svc.get_record_async(id_by_suffix["CustomerRisk"], ids["RISK_ID"])
163+
contact = await svc.get_record_async(id_by_suffix["Contact"], ids["CONTACT_ID"])
154164

155165
print("\n=== VERIFY (read-back) ===")
156166
checks = [

scripts/run_agent_with_ontology.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,30 @@ def _fmt_set_map(m: dict[str, Any]) -> str:
141141
print(_fmt_set_map(compiled.entity_relationships))
142142

143143

144+
def _print_ontology_debug(
145+
owl_turtle: str, compiled: Any, *, debug_ontology: bool
146+
) -> None:
147+
"""Print the human-readable IR and, when enabled, the raw OWL Turtle.
148+
149+
Uses ``ontology_compiler.format_ontology_debug`` so the CLI output mirrors
150+
exactly what the runtime emits to debug logs.
151+
"""
152+
if compiled is None:
153+
return
154+
from uipath_langchain.agent.tools.datafabric_tool.ontology_compiler import (
155+
format_ontology_debug,
156+
)
157+
158+
if debug_ontology:
159+
# Full block: raw OWL + human-readable IR.
160+
print()
161+
print(format_ontology_debug(owl_turtle, compiled))
162+
else:
163+
# Human-readable IR only (no raw-OWL dump).
164+
print("\n=== COMPILED ONTOLOGY (human-readable IR) ===")
165+
print(compiled.to_human_readable())
166+
167+
144168
def _load_entity_set(path: Path) -> list[Any]:
145169
"""Load a JSON list of DataFabricEntityItem dicts into model instances."""
146170
import json
@@ -201,6 +225,10 @@ async def _async_main(args: argparse.Namespace) -> int:
201225
_print_ontology_facts(
202226
standalone_compiled, header="ONTOLOGY FACTS (standalone compile)"
203227
)
228+
# Richer human-readable IR (and, when --debug-ontology, the raw OWL too).
229+
_print_ontology_debug(
230+
ttl_text, standalone_compiled, debug_ontology=args.debug_ontology
231+
)
204232

205233
# --- Step 2: load entity set. --------------------------------------------
206234
try:
@@ -309,6 +337,7 @@ async def _async_main(args: argparse.Namespace) -> int:
309337
):
310338
print("\nontology ACTIVE -- handler compiled and is using the ontology.")
311339
_print_ontology_facts(compiled, header="ONTOLOGY FACTS (active in handler)")
340+
_print_ontology_debug(ttl_text, compiled, debug_ontology=args.debug_ontology)
312341
else:
313342
print(
314343
"\nontology INACTIVE (fell back to metadata-only). The handler did "
@@ -414,6 +443,13 @@ def _build_arg_parser() -> argparse.ArgumentParser:
414443
default="datafabric",
415444
help="Name for the Data Fabric context resource (default: datafabric).",
416445
)
446+
parser.add_argument(
447+
"--debug-ontology",
448+
action=argparse.BooleanOptionalAction,
449+
default=True,
450+
help="Dump the raw OWL Turtle alongside the human-readable compiled IR "
451+
"(default: on). Use --no-debug-ontology to print only the IR.",
452+
)
417453
parser.add_argument(
418454
"--dry-run",
419455
action="store_true",

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

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ class CompiledOntology(BaseModel):
3232
valid (if sparse) ``CompiledOntology`` rather than raising.
3333
"""
3434

35+
known_entities: set[str] = Field(default_factory=set)
36+
"""Every entity_key the ontology declares (has ``df:entityKey``), readable
37+
or writable. A read-only ``df:ReadableEntity`` is "known but not in
38+
``entity_access``"; an entity absent here is unknown to the ontology."""
39+
3540
entity_access: dict[str, set[str]] = Field(default_factory=dict)
3641
"""entity_key -> set of allowed operations, e.g. ``{"insert", "update"}``."""
3742

@@ -53,10 +58,87 @@ class CompiledOntology(BaseModel):
5358
def is_empty(self) -> bool:
5459
"""True if no ontology facts were extracted (graceful-fallback signal)."""
5560
return not (
56-
self.entity_access
61+
self.known_entities
62+
or self.entity_access
5763
or self.measure_fields
5864
or self.state_fields
5965
or self.reference_fields
6066
or self.hitl_operations
6167
or self.entity_relationships
6268
)
69+
70+
def is_known(self, entity_key: str) -> bool:
71+
"""True if the ontology declares this entity (readable or writable)."""
72+
return entity_key in self.known_entities
73+
74+
def is_writable(self, entity_key: str) -> bool:
75+
"""True if the ontology grants any write access to this entity."""
76+
return entity_key in self.entity_access
77+
78+
def is_read_only(self, entity_key: str) -> bool:
79+
"""True if the entity is declared by the ontology but grants no writes.
80+
81+
A ``df:ReadableEntity`` is "known but not in ``entity_access``".
82+
"""
83+
return (
84+
entity_key in self.known_entities and entity_key not in self.entity_access
85+
)
86+
87+
def to_human_readable(self) -> str:
88+
"""Render a compact, grouped, human-readable summary of the IR.
89+
90+
Sections: entity access modes (with HITL ops), field semantics
91+
(measure / state / reference), and entity relationships. Intended
92+
for debug logs and the ontology CLI scripts.
93+
"""
94+
lines: list[str] = []
95+
96+
# -- Entities (access mode + HITL) --
97+
lines.append("Entities:")
98+
if self.known_entities:
99+
for ek in sorted(self.known_entities):
100+
ops = self.entity_access.get(ek)
101+
if ops is not None:
102+
mode = (
103+
f"WRITABLE [{','.join(sorted(ops))}]"
104+
if ops
105+
else "WRITABLE [no ops declared]"
106+
)
107+
else:
108+
mode = "READ-ONLY"
109+
line = f" - {ek}: {mode}"
110+
hitl = self.hitl_operations.get(ek)
111+
if hitl:
112+
line += f" (HITL: {','.join(sorted(hitl))})"
113+
lines.append(line)
114+
else:
115+
lines.append(" (none)")
116+
117+
# -- Field semantics --
118+
lines.append("Field semantics:")
119+
if self.measure_fields:
120+
lines.append(" Measure fields (additive / replacement):")
121+
for k in sorted(self.measure_fields):
122+
lines.append(f" - {k}: {self.measure_fields[k]}")
123+
if self.state_fields:
124+
lines.append(" State fields (choiceset / state-machine):")
125+
for k in sorted(self.state_fields):
126+
src = self.state_fields[k] or "(unspecified)"
127+
lines.append(f" - {k}: {src}")
128+
if self.reference_fields:
129+
lines.append(" Reference fields (-> target entity):")
130+
for k in sorted(self.reference_fields):
131+
lines.append(f" - {k} -> {self.reference_fields[k]}")
132+
if not (self.measure_fields or self.state_fields or self.reference_fields):
133+
lines.append(" (none)")
134+
135+
# -- Relationships --
136+
lines.append("Relationships:")
137+
if self.entity_relationships:
138+
for ek in sorted(self.entity_relationships):
139+
targets = ", ".join(self.entity_relationships[ek])
140+
lines.append(f" - {ek} -> {targets}")
141+
else:
142+
lines.append(" (none)")
143+
144+
return "\n".join(lines)

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from uipath.platform.entities import Entity
1515

16+
from .compiled_ontology import CompiledOntology
1617
from .datafabric_prompts import SQL_CONSTRAINTS
1718
from .models import (
1819
EntitySchema,
@@ -149,11 +150,71 @@ def build_entity_context(entity: Entity) -> EntitySQLContext:
149150
return EntitySQLContext(entity_schema=schema, query_patterns=query_patterns)
150151

151152

153+
def format_ontology_context(compiled_ontology: CompiledOntology) -> str:
154+
"""Render read-side schema-linking enrichment from a compiled ontology.
155+
156+
This is informational context for the NL-to-SQL model (the P5 goal). It
157+
surfaces per-entity access modes, entity relationships, reference/FK fields
158+
(to guide join-path selection), and state fields (with their valid-value
159+
source). It does NOT restrict reads.
160+
161+
Args:
162+
compiled_ontology: The compiled OWL ontology IR.
163+
164+
Returns:
165+
A markdown ``## Ontology Context`` section, or an empty string when the
166+
ontology carries no facts.
167+
"""
168+
if compiled_ontology is None or compiled_ontology.is_empty():
169+
return ""
170+
171+
lines: list[str] = ["## Ontology Context", ""]
172+
lines.append(
173+
"Ontology-derived schema-linking hints (informational; does not "
174+
"restrict what you may read):"
175+
)
176+
lines.append("")
177+
178+
# Per-entity access mode (purely informational for the read model).
179+
if compiled_ontology.known_entities:
180+
lines.append("**Entity access modes:**")
181+
for ek in sorted(compiled_ontology.known_entities):
182+
mode = "WRITABLE" if compiled_ontology.is_writable(ek) else "READ-ONLY"
183+
lines.append(f"- {ek}: {mode}")
184+
lines.append("")
185+
186+
# Entity relationships (entity -> related entities).
187+
if compiled_ontology.entity_relationships:
188+
lines.append("**Entity relationships (entity -> related entities):**")
189+
for ek in sorted(compiled_ontology.entity_relationships):
190+
targets = ", ".join(compiled_ontology.entity_relationships[ek])
191+
lines.append(f"- {ek} -> {targets}")
192+
lines.append("")
193+
194+
# Reference / FK fields (guide join-path selection).
195+
if compiled_ontology.reference_fields:
196+
lines.append("**Reference / foreign-key fields (field -> target entity):**")
197+
for k in sorted(compiled_ontology.reference_fields):
198+
lines.append(f"- {k} -> {compiled_ontology.reference_fields[k]}")
199+
lines.append("")
200+
201+
# State fields and their valid-value source.
202+
if compiled_ontology.state_fields:
203+
lines.append("**State fields (field -> valid-value source):**")
204+
for k in sorted(compiled_ontology.state_fields):
205+
src = compiled_ontology.state_fields[k] or "(unspecified)"
206+
lines.append(f"- {k} -> {src}")
207+
lines.append("")
208+
209+
return "\n".join(lines).rstrip() + "\n"
210+
211+
152212
def build_sql_context(
153213
entities: list[Entity],
154214
resource_description: str = "",
155215
base_system_prompt: str = "",
156216
prompt_version: str | None = None,
217+
compiled_ontology: CompiledOntology | None = None,
157218
) -> SQLContext:
158219
"""Build the full SQL context from entities, prompts, and constraints.
159220
@@ -173,11 +234,18 @@ def build_sql_context(
173234
)
174235
rendered_prompt = version.render(ctx)
175236

237+
ontology_context = (
238+
format_ontology_context(compiled_ontology)
239+
if compiled_ontology is not None
240+
else ""
241+
)
242+
176243
return SQLContext(
177244
base_system_prompt=base_system_prompt or None,
178245
resource_description=None,
179246
sql_expert_system_prompt=rendered_prompt,
180247
constraints=SQL_CONSTRAINTS,
248+
ontology_context=ontology_context or None,
181249
entity_contexts=[build_entity_context(e) for e in entities],
182250
)
183251

@@ -210,6 +278,10 @@ def format_sql_context(ctx: SQLContext) -> str:
210278
lines.append(ctx.resource_description)
211279
lines.append("")
212280

281+
if ctx.ontology_context:
282+
lines.append(ctx.ontology_context.rstrip())
283+
lines.append("")
284+
213285
lines.append("## All available Data Fabric Entities")
214286
lines.append("")
215287

@@ -245,6 +317,7 @@ def build(
245317
resource_description: str = "",
246318
base_system_prompt: str = "",
247319
prompt_version: str | None = None,
320+
compiled_ontology: CompiledOntology | None = None,
248321
) -> str:
249322
"""Build the full SQL prompt text for the inner sub-graph LLM.
250323
@@ -258,6 +331,10 @@ def build(
258331
base_system_prompt: Optional system prompt from the outer agent.
259332
prompt_version: Optional version key (e.g. ``"v0"``, ``"v1"``).
260333
Defaults to the registry's default.
334+
compiled_ontology: Optional compiled OWL ontology. When present an
335+
``## Ontology Context`` section is appended with read-side
336+
schema-linking enrichment (relationships, FK targets, state-value
337+
sources, access modes). Purely informational — never restricts reads.
261338
262339
Returns:
263340
Formatted prompt string for the inner LLM system message.
@@ -270,6 +347,7 @@ def build(
270347
resource_description,
271348
base_system_prompt,
272349
prompt_version=prompt_version,
350+
compiled_ontology=compiled_ontology,
273351
)
274352
return format_sql_context(ctx)
275353

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from ..datafabric_query_tool import DataFabricQueryTool
3535
from . import datafabric_prompt_builder
36+
from .compiled_ontology import CompiledOntology
3637
from .models import DataFabricExecuteSqlInput
3738

3839
logger = logging.getLogger(__name__)
@@ -88,14 +89,18 @@ def __init__(
8889
max_iterations: int = 25,
8990
resource_description: str = "",
9091
base_system_prompt: str = "",
92+
compiled_ontology: CompiledOntology | None = None,
9193
) -> None:
9294
self._max_iterations = max_iterations
9395
self._execute_sql_tool = self._create_execute_sql_tool(
9496
entities_service, entities
9597
)
9698
self._system_message = SystemMessage(
9799
content=datafabric_prompt_builder.build(
98-
entities, resource_description, base_system_prompt
100+
entities,
101+
resource_description,
102+
base_system_prompt,
103+
compiled_ontology=compiled_ontology,
99104
)
100105
)
101106
self._inner_llm = llm.model_copy(update={"disable_streaming": True}).bind_tools(
@@ -226,14 +231,21 @@ def create(
226231
max_iterations: int = 25,
227232
resource_description: str = "",
228233
base_system_prompt: str = "",
234+
compiled_ontology: CompiledOntology | None = None,
229235
) -> CompiledStateGraph[Any]:
230-
"""Create and return a compiled Data Fabric sub-graph."""
236+
"""Create and return a compiled Data Fabric sub-graph.
237+
238+
When *compiled_ontology* is supplied it enriches the read prompt with
239+
schema-linking context (relationships, FK targets, state-value sources,
240+
access modes). It never restricts reads.
241+
"""
231242
graph = DataFabricGraph(
232243
llm,
233244
entities,
234245
entities_service,
235246
max_iterations,
236247
resource_description,
237248
base_system_prompt,
249+
compiled_ontology=compiled_ontology,
238250
)
239251
return graph.compiled_graph

0 commit comments

Comments
 (0)