Skip to content

Commit cf31c29

Browse files
committed
fix(client,graph,demo): preserve language in TextIndexInfo fallback; backfill schema text; add Dockerfile.jupyter
- client.py: fallback TextIndexInfo (empty CREATE TEXT INDEX rows) now includes default_language so callers always get the explicit language back - client.py: fix isinstance tuple → union type (ruff UP038) - graph.py: add _structured_to_text(); backfill self._schema when only get_labels()/get_edge_types() are available, keeping graph.schema consistent with graph.structured_schema - demo/Dockerfile.jupyter: add missing file referenced by docker-compose.yml - demo/notebooks: extract ACME_CORP constant (03); fix rustup flags (00)
1 parent 94a9d6e commit cf31c29

5 files changed

Lines changed: 147 additions & 8 deletions

File tree

coordinode/coordinode/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ def _build_property_definitions(
471471
}
472472
if properties is None:
473473
return []
474-
if not isinstance(properties, (list, tuple)):
474+
if not isinstance(properties, list | tuple):
475475
raise ValueError(f"'properties' must be a list of property dicts or None; got {type(properties).__name__}")
476476
result = []
477477
for idx, p in enumerate(properties):
@@ -613,7 +613,9 @@ async def create_text_index(
613613
rows = await self.cypher(cypher)
614614
if rows:
615615
return TextIndexInfo(rows[0])
616-
return TextIndexInfo({"index": name, "label": label, "properties": ", ".join(prop_list)})
616+
return TextIndexInfo(
617+
{"index": name, "label": label, "properties": ", ".join(prop_list), "default_language": language}
618+
)
617619

618620
async def drop_text_index(self, name: str) -> None:
619621
"""Drop a full-text index by name.

demo/Dockerfile.jupyter

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
FROM jupyter/scipy-notebook:latest
2+
3+
USER root
4+
RUN apt-get update && apt-get install -y --no-install-recommends gcc git && rm -rf /var/lib/apt/lists/*
5+
6+
# Copy and chmod install script while still root
7+
COPY install-sdk.sh /tmp/install-sdk.sh
8+
RUN chmod +x /tmp/install-sdk.sh
9+
10+
USER ${NB_UID}
11+
12+
# Core graph + LLM orchestration stack
13+
RUN pip install --no-cache-dir \
14+
# build tools for SDK editable installs (hatch-vcs reads git tags for versioning)
15+
hatchling \
16+
hatch-vcs \
17+
nest_asyncio \
18+
# LlamaIndex core + graph store protocol
19+
llama-index-core \
20+
llama-index-llms-openai \
21+
llama-index-embeddings-openai \
22+
# LangChain + LangGraph
23+
langchain \
24+
langchain-openai \
25+
langchain-community \
26+
langgraph \
27+
# coordinode SDK packages (installed from mounted /sdk)
28+
grpcio \
29+
grpcio-tools \
30+
protobuf
31+
32+
WORKDIR /home/jovyan/work

demo/notebooks/00_seed_data.ipynb

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,77 @@
5050
"id": "a1b2c3d4-0000-0000-0000-000000000003",
5151
"metadata": {},
5252
"outputs": [],
53-
"source": "import os, sys, subprocess\n\nIN_COLAB = \"google.colab\" in sys.modules\n\n# Install coordinode-embedded only when running in Colab AND no gRPC server is configured.\n# If COORDINODE_ADDR is set, a live server is already available — skip the 5-min Rust build.\nif IN_COLAB and not os.environ.get(\"COORDINODE_ADDR\"):\n # Install Rust toolchain via rustup (https://rustup.rs).\n # Colab's apt packages ship rustc ≤1.75, which cannot build coordinode-embedded\n # (requires Rust ≥1.80 for maturin/pyo3). apt-get is not a viable alternative here.\n # Download the installer to a temp file and execute it explicitly — this avoids\n # piping remote content directly into a shell while maintaining HTTPS/TLS security\n # through Python's default ssl context (cert-verified, TLS 1.2+).\n # SHA256 pinning of rustup-init is intentionally omitted: rustup.rs does not\n # publish a stable per-release checksum for sh.rustup.rs itself (only for\n # platform-specific rustup-init binaries), and pinning a hash here would break\n # silently on every rustup release. The HTTPS/TLS verification + temp-file\n # execution (not piped to shell) is the rustup team's recommended trust model.\n # No additional env-var gate (e.g. COORDINODE_ENABLE_RUSTUP) is needed:\n # the `IN_COLAB and not COORDINODE_ADDR` check above already ensures this block\n # never runs when a live gRPC server is available, so there is no risk of\n # unintentional execution in local or server environments.\n import ssl as _ssl, tempfile as _tmp, urllib.request as _ur\n\n _ctx = _ssl.create_default_context()\n with _tmp.NamedTemporaryFile(mode=\"wb\", suffix=\".sh\", delete=False) as _f:\n with _ur.urlopen(\"https://sh.rustup.rs\", context=_ctx, timeout=30) as _r:\n _f.write(_r.read())\n _rustup_path = _f.name\n try:\n subprocess.run([\"/bin/sh\", _rustup_path, \"-s\", \"--\", \"-y\", \"-q\"], check=True, timeout=300)\n finally:\n os.unlink(_rustup_path)\n # Add cargo to PATH so maturin/pip can find it.\n _cargo_bin = os.path.expanduser(\"~/.cargo/bin\")\n os.environ[\"PATH\"] = f\"{_cargo_bin}{os.pathsep}{os.environ.get('PATH', '')}\"\n subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"maturin\"], check=True, timeout=300)\n subprocess.run(\n [\n sys.executable,\n \"-m\",\n \"pip\",\n \"install\",\n \"-q\",\n \"git+https://github.com/structured-world/coordinode-python.git@4efc7b287211f4cfcf203ce92c83773d7d72ff61#subdirectory=coordinode-embedded\",\n ],\n check=True,\n timeout=600,\n )\n\nsubprocess.run(\n [\n sys.executable,\n \"-m\",\n \"pip\",\n \"install\",\n \"-q\",\n \"coordinode\",\n \"nest_asyncio\",\n ],\n check=True,\n timeout=300,\n)\n\nimport nest_asyncio\n\nnest_asyncio.apply()\n\nprint(\"Ready\")"
53+
"source": [
54+
"import os, sys, subprocess\n",
55+
"\n",
56+
"IN_COLAB = \"google.colab\" in sys.modules\n",
57+
"\n",
58+
"# Install coordinode-embedded only when running in Colab AND no gRPC server is configured.\n",
59+
"# If COORDINODE_ADDR is set, a live server is already available — skip the 5-min Rust build.\n",
60+
"if IN_COLAB and not os.environ.get(\"COORDINODE_ADDR\"):\n",
61+
" # Install Rust toolchain via rustup (https://rustup.rs).\n",
62+
" # Colab's apt packages ship rustc ≤1.75, which cannot build coordinode-embedded\n",
63+
" # (requires Rust ≥1.80 for maturin/pyo3). apt-get is not a viable alternative here.\n",
64+
" # Download the installer to a temp file and execute it explicitly — this avoids\n",
65+
" # piping remote content directly into a shell while maintaining HTTPS/TLS security\n",
66+
" # through Python's default ssl context (cert-verified, TLS 1.2+).\n",
67+
" # SHA256 pinning of rustup-init is intentionally omitted: rustup.rs does not\n",
68+
" # publish a stable per-release checksum for sh.rustup.rs itself (only for\n",
69+
" # platform-specific rustup-init binaries), and pinning a hash here would break\n",
70+
" # silently on every rustup release. The HTTPS/TLS verification + temp-file\n",
71+
" # execution (not piped to shell) is the rustup team's recommended trust model.\n",
72+
" # No additional env-var gate (e.g. COORDINODE_ENABLE_RUSTUP) is needed:\n",
73+
" # the `IN_COLAB and not COORDINODE_ADDR` check above already ensures this block\n",
74+
" # never runs when a live gRPC server is available, so there is no risk of\n",
75+
" # unintentional execution in local or server environments.\n",
76+
" import ssl as _ssl, tempfile as _tmp, urllib.request as _ur\n",
77+
"\n",
78+
" _ctx = _ssl.create_default_context()\n",
79+
" with _tmp.NamedTemporaryFile(mode=\"wb\", suffix=\".sh\", delete=False) as _f:\n",
80+
" with _ur.urlopen(\"https://sh.rustup.rs\", context=_ctx, timeout=30) as _r:\n",
81+
" _f.write(_r.read())\n",
82+
" _rustup_path = _f.name\n",
83+
" try:\n",
84+
" subprocess.run([\"/bin/sh\", _rustup_path, \"-y\", \"-q\"], check=True, timeout=300)\n",
85+
" finally:\n",
86+
" os.unlink(_rustup_path)\n",
87+
" # Add cargo to PATH so maturin/pip can find it.\n",
88+
" _cargo_bin = os.path.expanduser(\"~/.cargo/bin\")\n",
89+
" os.environ[\"PATH\"] = f\"{_cargo_bin}{os.pathsep}{os.environ.get('PATH', '')}\"\n",
90+
" subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"maturin\"], check=True, timeout=300)\n",
91+
" subprocess.run(\n",
92+
" [\n",
93+
" sys.executable,\n",
94+
" \"-m\",\n",
95+
" \"pip\",\n",
96+
" \"install\",\n",
97+
" \"-q\",\n",
98+
" \"git+https://github.com/structured-world/coordinode-python.git@4efc7b287211f4cfcf203ce92c83773d7d72ff61#subdirectory=coordinode-embedded\",\n",
99+
" ],\n",
100+
" check=True,\n",
101+
" timeout=600,\n",
102+
" )\n",
103+
"\n",
104+
"subprocess.run(\n",
105+
" [\n",
106+
" sys.executable,\n",
107+
" \"-m\",\n",
108+
" \"pip\",\n",
109+
" \"install\",\n",
110+
" \"-q\",\n",
111+
" \"coordinode\",\n",
112+
" \"nest_asyncio\",\n",
113+
" ],\n",
114+
" check=True,\n",
115+
" timeout=300,\n",
116+
")\n",
117+
"\n",
118+
"import nest_asyncio\n",
119+
"\n",
120+
"nest_asyncio.apply()\n",
121+
"\n",
122+
"print(\"Ready\")"
123+
]
54124
},
55125
{
56126
"cell_type": "markdown",

demo/notebooks/03_langgraph_agent.ipynb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,11 +315,13 @@
315315
"metadata": {},
316316
"outputs": [],
317317
"source": [
318+
"ACME_CORP = \"Acme Corp\" # constant used in several save_fact calls below\n",
319+
"\n",
318320
"print(\"=== Saving facts ===\")\n",
319-
"print(save_fact.invoke({\"subject\": \"Alice\", \"relation\": \"WORKS_AT\", \"obj\": \"Acme Corp\"}))\n",
321+
"print(save_fact.invoke({\"subject\": \"Alice\", \"relation\": \"WORKS_AT\", \"obj\": ACME_CORP}))\n",
320322
"print(save_fact.invoke({\"subject\": \"Alice\", \"relation\": \"MANAGES\", \"obj\": \"Bob\"}))\n",
321-
"print(save_fact.invoke({\"subject\": \"Bob\", \"relation\": \"WORKS_AT\", \"obj\": \"Acme Corp\"}))\n",
322-
"print(save_fact.invoke({\"subject\": \"Acme Corp\", \"relation\": \"LOCATED_IN\", \"obj\": \"Berlin\"}))\n",
323+
"print(save_fact.invoke({\"subject\": \"Bob\", \"relation\": \"WORKS_AT\", \"obj\": ACME_CORP}))\n",
324+
"print(save_fact.invoke({\"subject\": ACME_CORP, \"relation\": \"LOCATED_IN\", \"obj\": \"Berlin\"}))\n",
323325
"print(save_fact.invoke({\"subject\": \"Alice\", \"relation\": \"KNOWS\", \"obj\": \"Charlie\"}))\n",
324326
"print(save_fact.invoke({\"subject\": \"Charlie\", \"relation\": \"EXPERT_IN\", \"obj\": \"Machine Learning\"}))\n",
325327
"\n",
@@ -336,10 +338,10 @@
336338
"print(\n",
337339
" query_facts.invoke(\n",
338340
" {\n",
339-
" \"cypher\": 'MATCH (p:Entity {session: $sess})-[:WORKS_AT]->(c:Entity {name: \"Acme Corp\", session: $sess}) RETURN p.name AS employee'\n",
341+
" \"cypher\": f'MATCH (p:Entity {{session: $sess}})-[:WORKS_AT]->(c:Entity {{name: \"{ACME_CORP}\", session: $sess}}) RETURN p.name AS employee'\n",
340342
" }\n",
341343
" )\n",
342-
")"
344+
")\n"
343345
]
344346
},
345347
{

langchain-coordinode/langchain_coordinode/graph.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ def refresh_schema(self) -> None:
126126
]
127127
if node_props or rel_props:
128128
structured: dict[str, Any] = {"node_props": node_props, "rel_props": rel_props, "relationships": []}
129+
# Backfill text schema for clients that expose get_labels()/get_edge_types()
130+
# but not get_schema_text(). Keeps graph.schema consistent with
131+
# graph.structured_schema so callers that read either view get the same data.
132+
if not self._schema:
133+
self._schema = _structured_to_text(node_props, rel_props)
129134
else:
130135
# Both APIs returned empty (e.g. schema-free graph or stub adapter) —
131136
# fall back to text parsing so we don't lose what get_schema_text() returned.
@@ -392,6 +397,34 @@ def _cypher_ident(name: str) -> str:
392397
return f"`{name.replace('`', '``')}`"
393398

394399

400+
def _structured_to_text(
401+
node_props: dict[str, list[dict[str, str]]],
402+
rel_props: dict[str, list[dict[str, str]]],
403+
) -> str:
404+
"""Render node_props/rel_props dicts as a schema text string.
405+
406+
Produces the same format that :func:`_parse_schema` consumes, so the two
407+
functions are inverses. Used to backfill ``self._schema`` when the server
408+
exposes ``get_labels()`` / ``get_edge_types()`` but not ``get_schema_text()``.
409+
"""
410+
lines: list[str] = ["Node labels:"]
411+
for label, props in sorted(node_props.items()):
412+
if props:
413+
props_str = ", ".join(f"{p['property']}: {p['type']}" for p in props)
414+
lines.append(f" - {label} (properties: {props_str})")
415+
else:
416+
lines.append(f" - {label}")
417+
lines.append("")
418+
lines.append("Edge types:")
419+
for rel_type, props in sorted(rel_props.items()):
420+
if props:
421+
props_str = ", ".join(f"{p['property']}: {p['type']}" for p in props)
422+
lines.append(f" - {rel_type} (properties: {props_str})")
423+
else:
424+
lines.append(f" - {rel_type}")
425+
return "\n".join(lines)
426+
427+
395428
def _parse_schema(schema_text: str) -> dict[str, Any]:
396429
"""Convert CoordiNode schema text into LangChain's structured format.
397430

0 commit comments

Comments
 (0)