|
| 1 | +"""Ontology Write POC — drive the real write tool against staging. |
| 2 | +
|
| 3 | +Loads the POC artifacts produced by ``poc_refund_setup.sh``, injects the |
| 4 | +ontology via the same bridge the CLI uses (monkeypatching the not-yet-shipped |
| 5 | +``EntitiesService.get_ontology_file_async``), builds the real Data Fabric |
| 6 | +tools, then runs the refund flow by invoking the write tool handler directly — |
| 7 | +the exact callable an agent's tool node calls. Proves: ontology compile + |
| 8 | +inject -> write validation -> EntitiesService CRUD -> records persisted -> |
| 9 | +verified by read-back. |
| 10 | +
|
| 11 | +This is the deterministic counterpart to ``run_agent_with_ontology.py`` (which |
| 12 | +puts an LLM in the loop). Use this to confirm the writes actually land. |
| 13 | +
|
| 14 | +Prereq: UIPATH_* env vars set (see scripts/POC_README.md), and |
| 15 | + ``poc_refund_setup.sh`` already run. |
| 16 | +Usage: uv run python scripts/poc_refund_drive.py [OUT_DIR] (default: ./poc_out) |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import asyncio |
| 22 | +import json |
| 23 | +import sys |
| 24 | +from pathlib import Path |
| 25 | + |
| 26 | +import uipath.platform.entities._entities_service as es_mod |
| 27 | + |
| 28 | +OUT = Path(sys.argv[1] if len(sys.argv) > 1 else "./poc_out") |
| 29 | +ONTOLOGY_TTL = (OUT / "refund_ontology.ttl").read_text() |
| 30 | + |
| 31 | + |
| 32 | +async def _get_ontology_file_async(self, file_type, *args, **kwargs): # noqa: ANN001 |
| 33 | + """Bridge: return the POC ontology for the 'owl' file type. |
| 34 | +
|
| 35 | + Stands in for the platform method that has not shipped yet, so the |
| 36 | + handler's own _maybe_compile_ontology picks it up naturally. |
| 37 | + """ |
| 38 | + return ONTOLOGY_TTL if file_type == "owl" else None |
| 39 | + |
| 40 | + |
| 41 | +es_mod.EntitiesService.get_ontology_file_async = _get_ontology_file_async |
| 42 | + |
| 43 | +from uipath.agent.models.agent import ( # noqa: E402 |
| 44 | + AgentContextResourceConfig, |
| 45 | + AgentContextType, |
| 46 | +) |
| 47 | +from uipath.platform.entities import DataFabricEntityItem # noqa: E402 |
| 48 | + |
| 49 | +from uipath_langchain.agent.tools.datafabric_tool import ( # noqa: E402 |
| 50 | + create_datafabric_tools, |
| 51 | +) |
| 52 | + |
| 53 | + |
| 54 | +def _load_ids() -> dict[str, str]: |
| 55 | + ids: dict[str, str] = {} |
| 56 | + for line in (OUT / "refund_ids.env").read_text().splitlines(): |
| 57 | + line = line.strip() |
| 58 | + if line and not line.startswith("#") and "=" in line: |
| 59 | + k, v = line.split("=", 1) |
| 60 | + ids[k] = v |
| 61 | + return ids |
| 62 | + |
| 63 | + |
| 64 | +class _NoLLM: |
| 65 | + """Stub LLM — only needed to build the read tool; never invoked here.""" |
| 66 | + |
| 67 | + def bind_tools(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 |
| 68 | + return self |
| 69 | + |
| 70 | + |
| 71 | +async def main() -> int: |
| 72 | + ids = _load_ids() |
| 73 | + items = [ |
| 74 | + DataFabricEntityItem.model_validate(d) |
| 75 | + for d in json.loads((OUT / "refund_entity_set.json").read_text()) |
| 76 | + ] |
| 77 | + by_suffix = {it.name.rsplit("_", 1)[1]: it.name for it in items} |
| 78 | + |
| 79 | + resource = AgentContextResourceConfig( |
| 80 | + name="refund_context", |
| 81 | + description="Refund processing context", |
| 82 | + context_type=AgentContextType.DATA_FABRIC_ENTITY_SET, |
| 83 | + entity_set=items, |
| 84 | + ) |
| 85 | + tools = create_datafabric_tools(resource, _NoLLM()) # type: ignore[arg-type] |
| 86 | + handler = next(t for t in tools if t.name.endswith("_write")).coroutine |
| 87 | + |
| 88 | + await handler._ensure_initialized() |
| 89 | + onto = handler._compiled_ontology |
| 90 | + print("=== ONTOLOGY STATUS ===") |
| 91 | + if onto and not onto.is_empty(): |
| 92 | + print( |
| 93 | + " ACTIVE — entity_access:", |
| 94 | + {k: sorted(v) for k, v in onto.entity_access.items()}, |
| 95 | + ) |
| 96 | + else: |
| 97 | + print(" INACTIVE (metadata-only)") |
| 98 | + |
| 99 | + # SOP decision is fixed for the seeded scenario (Delivered, score 2, $200). |
| 100 | + amt, score, ltv = 200.0, 2, 5000.0 |
| 101 | + print( |
| 102 | + f"\nDECIDE: order Delivered, risk {score} < 3, amount {amt} <= 500 -> APPROVE\n" |
| 103 | + ) |
| 104 | + |
| 105 | + async def write(**kw): # noqa: ANN003 |
| 106 | + out = json.loads(await handler(**kw)) |
| 107 | + print( |
| 108 | + f" {kw['operation']:6} {kw['entity_key'].rsplit('_', 1)[1]:14} -> success={out['success']}" |
| 109 | + ) |
| 110 | + return out |
| 111 | + |
| 112 | + print("=== WRITES (real handler, ontology validating) ===") |
| 113 | + await write( |
| 114 | + entity_key=by_suffix["RefundRequest"], |
| 115 | + operation="insert", |
| 116 | + fields={ |
| 117 | + "ApprovedAmount": amt, |
| 118 | + "Reason": "Auto-approved: low risk", |
| 119 | + "OrderRef": ids["ORDER_ID"], |
| 120 | + "CustomerRef": ids["CUSTOMER_ID"], |
| 121 | + "RefundStatus": "Pending", |
| 122 | + }, |
| 123 | + ) |
| 124 | + await write( |
| 125 | + entity_key=by_suffix["PurchaseOrder"], |
| 126 | + operation="update", |
| 127 | + record_id=ids["ORDER_ID"], |
| 128 | + fields={"OrderStatus": "Returned"}, |
| 129 | + ) |
| 130 | + await write( |
| 131 | + entity_key=by_suffix["CustomerRisk"], |
| 132 | + operation="update", |
| 133 | + record_id=ids["RISK_ID"], |
| 134 | + fields={"RiskScore": score + 1, "LifetimeValue": ltv - amt}, |
| 135 | + ) |
| 136 | + await write( |
| 137 | + entity_key=by_suffix["Contact"], |
| 138 | + operation="update", |
| 139 | + record_id=ids["CONTACT_ID"], |
| 140 | + fields={"Resolution": "Approved"}, |
| 141 | + ) |
| 142 | + |
| 143 | + # Verify by read-back through the resolved service. |
| 144 | + from uipath.platform import UiPath |
| 145 | + |
| 146 | + svc = (await UiPath().entities.resolve_entity_set_async(items)).entities_service |
| 147 | + |
| 148 | + def g(rec, k): # noqa: ANN001 |
| 149 | + return rec.get(k) if isinstance(rec, dict) else getattr(rec, k, None) |
| 150 | + |
| 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"]) |
| 154 | + |
| 155 | + print("\n=== VERIFY (read-back) ===") |
| 156 | + checks = [ |
| 157 | + ("Order.OrderStatus", g(order, "OrderStatus"), "Returned"), |
| 158 | + ("Risk.RiskScore", int(g(risk, "RiskScore")), 3), |
| 159 | + ("Risk.LifetimeValue", float(g(risk, "LifetimeValue")), 4800.0), |
| 160 | + ("Contact.Resolution", g(contact, "Resolution"), "Approved"), |
| 161 | + ] |
| 162 | + ok = 0 |
| 163 | + for label, actual, expected in checks: |
| 164 | + good = actual == expected |
| 165 | + ok += good |
| 166 | + print(f" {'OK ' if good else 'XX '} {label} = {actual} (expected {expected})") |
| 167 | + print(f"\n=== {ok}/{len(checks)} verified ===") |
| 168 | + return 0 if ok == len(checks) else 1 |
| 169 | + |
| 170 | + |
| 171 | +if __name__ == "__main__": |
| 172 | + raise SystemExit(asyncio.run(main())) |
0 commit comments