Skip to content

Commit d5d2f7f

Browse files
committed
fix(demo,tests): address review threads #145 #146 #147
- nb03: add client.health() verification before 'Connected' print in both gRPC branches (COORDINODE_ADDR env and _port_open fallback) - nb03: scope find_related intermediate hops to session — use path variable p and WHERE ALL(x IN nodes(p) WHERE x.session = $sess); switch type(last(r)) to type(last(relationships(p))) - tests: add test_create_label_schema_mode_validated asserting schema_mode == 2 (VALIDATED) to complement flexible/invalid tests
1 parent 1aaca85 commit d5d2f7f

2 files changed

Lines changed: 144 additions & 3 deletions

File tree

demo/notebooks/03_langgraph_agent.ipynb

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,48 @@
5656
"id": "d4e5f6a7-0003-0000-0000-000000000005",
5757
"metadata": {},
5858
"outputs": [],
59-
"source": "import os, socket\n\n\ndef _port_open(port):\n try:\n with socket.create_connection((\"127.0.0.1\", port), timeout=1):\n return True\n except OSError:\n return False\n\n\nGRPC_PORT = int(os.environ.get(\"COORDINODE_PORT\", \"7080\"))\n\nif os.environ.get(\"COORDINODE_ADDR\"):\n COORDINODE_ADDR = os.environ[\"COORDINODE_ADDR\"]\n from coordinode import CoordinodeClient\n\n client = CoordinodeClient(COORDINODE_ADDR)\n print(f\"Connected to {COORDINODE_ADDR}\")\nelif _port_open(GRPC_PORT):\n COORDINODE_ADDR = f\"localhost:{GRPC_PORT}\"\n from coordinode import CoordinodeClient\n\n client = CoordinodeClient(COORDINODE_ADDR)\n print(f\"Connected to {COORDINODE_ADDR}\")\nelse:\n # No server available — use the embedded in-process engine.\n try:\n from coordinode_embedded import LocalClient\n except ImportError as exc:\n raise RuntimeError(\n \"coordinode-embedded is not installed. \"\n \"Run: pip install git+https://github.com/structured-world/coordinode-python.git@b23c833#subdirectory=coordinode-embedded\"\n \" — or start a CoordiNode server and set COORDINODE_ADDR.\"\n ) from exc\n\n client = LocalClient(\":memory:\")\n print(\"Using embedded LocalClient (in-process)\")"
59+
"source": [
60+
"import os, socket\n",
61+
"\n",
62+
"\n",
63+
"def _port_open(port):\n",
64+
" try:\n",
65+
" with socket.create_connection((\"127.0.0.1\", port), timeout=1):\n",
66+
" return True\n",
67+
" except OSError:\n",
68+
" return False\n",
69+
"\n",
70+
"\n",
71+
"GRPC_PORT = int(os.environ.get(\"COORDINODE_PORT\", \"7080\"))\n",
72+
"\n",
73+
"if os.environ.get(\"COORDINODE_ADDR\"):\n",
74+
" COORDINODE_ADDR = os.environ[\"COORDINODE_ADDR\"]\n",
75+
" from coordinode import CoordinodeClient\n",
76+
"\n",
77+
" client = CoordinodeClient(COORDINODE_ADDR)\n",
78+
" client.health()\n",
79+
" print(f\"Connected to {COORDINODE_ADDR}\")\n",
80+
"elif _port_open(GRPC_PORT):\n",
81+
" COORDINODE_ADDR = f\"localhost:{GRPC_PORT}\"\n",
82+
" from coordinode import CoordinodeClient\n",
83+
"\n",
84+
" client = CoordinodeClient(COORDINODE_ADDR)\n",
85+
" client.health()\n",
86+
" print(f\"Connected to {COORDINODE_ADDR}\")\n",
87+
"else:\n",
88+
" # No server available — use the embedded in-process engine.\n",
89+
" try:\n",
90+
" from coordinode_embedded import LocalClient\n",
91+
" except ImportError as exc:\n",
92+
" raise RuntimeError(\n",
93+
" \"coordinode-embedded is not installed. \"\n",
94+
" \"Run: pip install git+https://github.com/structured-world/coordinode-python.git@b23c833#subdirectory=coordinode-embedded\"\n",
95+
" \" — or start a CoordiNode server and set COORDINODE_ADDR.\"\n",
96+
" ) from exc\n",
97+
"\n",
98+
" client = LocalClient(\":memory:\")\n",
99+
" print(\"Using embedded LocalClient (in-process)\")"
100+
]
60101
},
61102
{
62103
"cell_type": "markdown",
@@ -75,7 +116,98 @@
75116
"id": "d4e5f6a7-0003-0000-0000-000000000007",
76117
"metadata": {},
77118
"outputs": [],
78-
"source": "import os, re, uuid\nfrom langchain_core.tools import tool\n\nSESSION = uuid.uuid4().hex[:8] # isolates this demo's data from other sessions\n\n_REL_TYPE_RE = re.compile(r\"[A-Z_][A-Z0-9_]*\")\n# Regex guards for query_facts (demo safety guard).\n_WRITE_CLAUSE_RE = re.compile(\n r\"\\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|CALL|LOAD)\\b\",\n re.IGNORECASE | re.DOTALL,\n)\n# NOTE: this guard checks that AT LEAST ONE node pattern carries session scope.\n# A Cartesian-product query such as `MATCH (n), (m {session: $sess}) RETURN n`\n# would pass yet return unscoped rows for `n`. A complete per-alias check would\n# require parsing the Cypher AST, which is out of scope for a demo safety guard.\n# In production code, use server-side row-level security instead of client regex.\n_SESSION_WHERE_SCOPE_RE = re.compile(\n r\"WHERE\\b[^;{}]*\\.session\\s*=\\s*\\$sess\",\n re.IGNORECASE | re.DOTALL,\n)\n_SESSION_NODE_SCOPE_RE = re.compile(\n r\"\\([^)]*\\{[^}]*session\\s*:\\s*\\$sess[^}]*\\}[^)]*\\)\",\n re.IGNORECASE | re.DOTALL,\n)\n\n\n@tool\ndef save_fact(subject: str, relation: str, obj: str) -> str:\n \"\"\"Save a fact (subject → relation → object) into the knowledge graph.\n Example: save_fact('Alice', 'WORKS_AT', 'Acme Corp')\"\"\"\n rel_type = relation.upper().replace(\" \", \"_\")\n # Validate rel_type before interpolating into Cypher to prevent injection.\n if not _REL_TYPE_RE.fullmatch(rel_type):\n return f\"Invalid relation type {relation!r}: only letters, digits, and underscores allowed\"\n client.cypher(\n f\"MERGE (a:Entity {{name: $s, session: $sess}}) \"\n f\"MERGE (b:Entity {{name: $o, session: $sess}}) \"\n f\"MERGE (a)-[r:{rel_type}]->(b)\",\n params={\"s\": subject, \"o\": obj, \"sess\": SESSION},\n )\n return f\"Saved: {subject} -[{rel_type}]-> {obj}\"\n\n\n@tool\ndef query_facts(cypher: str) -> str:\n \"\"\"Run a read-only Cypher MATCH query against the knowledge graph.\n Must scope reads via either WHERE <alias>.session = $sess\n or a node pattern {session: $sess}.\"\"\"\n q = cypher.strip()\n if _WRITE_CLAUSE_RE.search(q):\n return \"Only read-only Cypher is allowed in query_facts.\"\n # Require $sess in a WHERE clause or node pattern, not just anywhere.\n # Accepts both: WHERE n.session = $sess and MATCH (n {session: $sess})\n if not (_SESSION_WHERE_SCOPE_RE.search(q) or _SESSION_NODE_SCOPE_RE.search(q)):\n return \"Query must scope reads to the current session with either WHERE <alias>.session = $sess or {session: $sess}\"\n rows = client.cypher(q, params={\"sess\": SESSION})\n return str(rows[:20]) if rows else \"No results\"\n\n\n@tool\ndef find_related(entity_name: str, depth: int = 1) -> str:\n \"\"\"Find all entities reachable from entity_name within the given number of hops (max 3).\"\"\"\n safe_depth = max(1, min(int(depth), 3))\n rows = client.cypher(\n f\"MATCH (n:Entity {{name: $name, session: $sess}})-[r*1..{safe_depth}]->(m:Entity {{session: $sess}}) \"\n \"RETURN m.name AS related, type(last(r)) AS via LIMIT 20\",\n params={\"name\": entity_name, \"sess\": SESSION},\n )\n if not rows:\n return f\"No related entities found for {entity_name}\"\n return \"\\n\".join(f\"{r['via']} -> {r['related']}\" for r in rows)\n\n\n@tool\ndef list_all_facts() -> str:\n \"\"\"List every fact stored in the current session's knowledge graph.\"\"\"\n rows = client.cypher(\n \"MATCH (a:Entity {session: $sess})-[r]->(b:Entity {session: $sess}) \"\n \"RETURN a.name AS subject, type(r) AS relation, b.name AS object\",\n params={\"sess\": SESSION},\n )\n if not rows:\n return \"No facts stored yet\"\n return \"\\n\".join(f\"{r['subject']} -[{r['relation']}]-> {r['object']}\" for r in rows)\n\n\ntools = [save_fact, query_facts, find_related, list_all_facts]\nprint(f\"Session: {SESSION}\")\nprint(\"Tools:\", [t.name for t in tools])"
119+
"source": [
120+
"import os, re, uuid\n",
121+
"from langchain_core.tools import tool\n",
122+
"\n",
123+
"SESSION = uuid.uuid4().hex[:8] # isolates this demo's data from other sessions\n",
124+
"\n",
125+
"_REL_TYPE_RE = re.compile(r\"[A-Z_][A-Z0-9_]*\")\n",
126+
"# Regex guards for query_facts (demo safety guard).\n",
127+
"_WRITE_CLAUSE_RE = re.compile(\n",
128+
" r\"\\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|CALL|LOAD)\\b\",\n",
129+
" re.IGNORECASE | re.DOTALL,\n",
130+
")\n",
131+
"# NOTE: this guard checks that AT LEAST ONE node pattern carries session scope.\n",
132+
"# A Cartesian-product query such as `MATCH (n), (m {session: $sess}) RETURN n`\n",
133+
"# would pass yet return unscoped rows for `n`. A complete per-alias check would\n",
134+
"# require parsing the Cypher AST, which is out of scope for a demo safety guard.\n",
135+
"# In production code, use server-side row-level security instead of client regex.\n",
136+
"_SESSION_WHERE_SCOPE_RE = re.compile(\n",
137+
" r\"WHERE\\b[^;{}]*\\.session\\s*=\\s*\\$sess\",\n",
138+
" re.IGNORECASE | re.DOTALL,\n",
139+
")\n",
140+
"_SESSION_NODE_SCOPE_RE = re.compile(\n",
141+
" r\"\\([^)]*\\{[^}]*session\\s*:\\s*\\$sess[^}]*\\}[^)]*\\)\",\n",
142+
" re.IGNORECASE | re.DOTALL,\n",
143+
")\n",
144+
"\n",
145+
"\n",
146+
"@tool\n",
147+
"def save_fact(subject: str, relation: str, obj: str) -> str:\n",
148+
" \"\"\"Save a fact (subject → relation → object) into the knowledge graph.\n",
149+
" Example: save_fact('Alice', 'WORKS_AT', 'Acme Corp')\"\"\"\n",
150+
" rel_type = relation.upper().replace(\" \", \"_\")\n",
151+
" # Validate rel_type before interpolating into Cypher to prevent injection.\n",
152+
" if not _REL_TYPE_RE.fullmatch(rel_type):\n",
153+
" return f\"Invalid relation type {relation!r}: only letters, digits, and underscores allowed\"\n",
154+
" client.cypher(\n",
155+
" f\"MERGE (a:Entity {{name: $s, session: $sess}}) \"\n",
156+
" f\"MERGE (b:Entity {{name: $o, session: $sess}}) \"\n",
157+
" f\"MERGE (a)-[r:{rel_type}]->(b)\",\n",
158+
" params={\"s\": subject, \"o\": obj, \"sess\": SESSION},\n",
159+
" )\n",
160+
" return f\"Saved: {subject} -[{rel_type}]-> {obj}\"\n",
161+
"\n",
162+
"\n",
163+
"@tool\n",
164+
"def query_facts(cypher: str) -> str:\n",
165+
" \"\"\"Run a read-only Cypher MATCH query against the knowledge graph.\n",
166+
" Must scope reads via either WHERE <alias>.session = $sess\n",
167+
" or a node pattern {session: $sess}.\"\"\"\n",
168+
" q = cypher.strip()\n",
169+
" if _WRITE_CLAUSE_RE.search(q):\n",
170+
" return \"Only read-only Cypher is allowed in query_facts.\"\n",
171+
" # Require $sess in a WHERE clause or node pattern, not just anywhere.\n",
172+
" # Accepts both: WHERE n.session = $sess and MATCH (n {session: $sess})\n",
173+
" if not (_SESSION_WHERE_SCOPE_RE.search(q) or _SESSION_NODE_SCOPE_RE.search(q)):\n",
174+
" return \"Query must scope reads to the current session with either WHERE <alias>.session = $sess or {session: $sess}\"\n",
175+
" rows = client.cypher(q, params={\"sess\": SESSION})\n",
176+
" return str(rows[:20]) if rows else \"No results\"\n",
177+
"\n",
178+
"\n",
179+
"@tool\n",
180+
"def find_related(entity_name: str, depth: int = 1) -> str:\n",
181+
" \"\"\"Find all entities reachable from entity_name within the given number of hops (max 3).\"\"\"\n",
182+
" safe_depth = max(1, min(int(depth), 3))\n",
183+
" rows = client.cypher(\n",
184+
" f\"MATCH p=(n:Entity {{name: $name, session: $sess}})-[*1..{safe_depth}]->(m:Entity {{session: $sess}}) \"\n",
185+
" \"WHERE ALL(x IN nodes(p) WHERE x.session = $sess) \"\n",
186+
" \"RETURN m.name AS related, type(last(relationships(p))) AS via LIMIT 20\",\n",
187+
" params={\"name\": entity_name, \"sess\": SESSION},\n",
188+
" )\n",
189+
" if not rows:\n",
190+
" return f\"No related entities found for {entity_name}\"\n",
191+
" return \"\\n\".join(f\"{r['via']} -> {r['related']}\" for r in rows)\n",
192+
"\n",
193+
"\n",
194+
"@tool\n",
195+
"def list_all_facts() -> str:\n",
196+
" \"\"\"List every fact stored in the current session's knowledge graph.\"\"\"\n",
197+
" rows = client.cypher(\n",
198+
" \"MATCH (a:Entity {session: $sess})-[r]->(b:Entity {session: $sess}) \"\n",
199+
" \"RETURN a.name AS subject, type(r) AS relation, b.name AS object\",\n",
200+
" params={\"sess\": SESSION},\n",
201+
" )\n",
202+
" if not rows:\n",
203+
" return \"No facts stored yet\"\n",
204+
" return \"\\n\".join(f\"{r['subject']} -[{r['relation']}]-> {r['object']}\" for r in rows)\n",
205+
"\n",
206+
"\n",
207+
"tools = [save_fact, query_facts, find_related, list_all_facts]\n",
208+
"print(f\"Session: {SESSION}\")\n",
209+
"print(\"Tools:\", [t.name for t in tools])"
210+
]
79211
},
80212
{
81213
"cell_type": "markdown",
@@ -256,4 +388,4 @@
256388
},
257389
"nbformat": 4,
258390
"nbformat_minor": 5
259-
}
391+
}

tests/integration/test_sdk.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,15 @@ def test_create_label_schema_mode_flexible(client):
495495
assert info.schema_mode == 3 # FLEXIBLE
496496

497497

498+
def test_create_label_schema_mode_validated(client):
499+
"""create_label() with schema_mode='validated' is accepted and returns SchemaMode=2."""
500+
name = f"ValidatedLabel{uid()}"
501+
info = client.create_label(name, schema_mode="validated")
502+
assert isinstance(info, LabelInfo)
503+
assert info.name == name
504+
assert info.schema_mode == 2 # VALIDATED
505+
506+
498507
def test_create_label_invalid_schema_mode_raises(client):
499508
"""create_label() with unknown schema_mode raises ValueError locally."""
500509
with pytest.raises(ValueError, match="schema_mode"):

0 commit comments

Comments
 (0)