cortexdb-client 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cortexdb_client-0.4.0/PKG-INFO +155 -0
- cortexdb_client-0.4.0/README.md +131 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/__init__.py +5 -1
- cortexdb_client-0.4.0/cortexdb_client/tabular.py +284 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/pyproject.toml +3 -1
- cortexdb_client-0.4.0/tests/test_tabular.py +296 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/uv.lock +642 -22
- cortexdb_client-0.3.0/PKG-INFO +0 -86
- cortexdb_client-0.3.0/README.md +0 -64
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/.gitignore +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/MANIFEST.in +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/__init__.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/__init__.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/__init__.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/admin_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/admin_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/common_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/common_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/contract_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/contract_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/decision_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/decision_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graph_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graph_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graphrag_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/graphrag_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/knowledge_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/knowledge_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/memory_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/memory_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/tools_pb2.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/_pb/cortexdb/v1/tools_pb2_grpc.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/client.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/cortexdb_client/proto.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/examples/agent_memory.py +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/gen.sh +0 -0
- {cortexdb_client-0.3.0 → cortexdb_client-0.4.0}/tests/test_integration.py +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cortexdb-client
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Typed gRPC client for CortexDB (AI memory + knowledge graph sidecar)
|
|
5
|
+
Project-URL: Homepage, https://github.com/liliang-cn/cortexdb
|
|
6
|
+
Project-URL: Repository, https://github.com/liliang-cn/cortexdb
|
|
7
|
+
Author: Liang Li
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agent-memory,ai,cortexdb,grpc,knowledge-graph,rag
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Database
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: grpcio>=1.60
|
|
16
|
+
Requires-Dist: protobuf>=4.25
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build; extra == 'dev'
|
|
19
|
+
Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
|
|
20
|
+
Requires-Dist: twine; extra == 'dev'
|
|
21
|
+
Provides-Extra: pandas
|
|
22
|
+
Requires-Dist: pandas>=1.5; extra == 'pandas'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# cortexdb-client (Python)
|
|
26
|
+
|
|
27
|
+
Typed gRPC client for [CortexDB](https://github.com/liliang-cn/cortexdb) — a
|
|
28
|
+
pure-Go, single-file AI memory and knowledge graph database, served as a
|
|
29
|
+
sidecar (`cortexdb-grpc`). Give your Python agent (e.g. **Hermes**) durable
|
|
30
|
+
memory **and** a queryable knowledge graph.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install cortexdb-client # or: uv add cortexdb-client
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Start the sidecar (one binary, one SQLite file):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
go install github.com/liliang-cn/cortexdb/v2/cmd/cortexdb-grpc@latest
|
|
42
|
+
CORTEXDB_PATH=agent.db CORTEXDB_GRPC_TOKEN=s3cret cortexdb-grpc
|
|
43
|
+
# listening on 127.0.0.1:47821
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from cortexdb_client import CortexClient, proto
|
|
50
|
+
|
|
51
|
+
with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
|
|
52
|
+
client.knowledge.SaveKnowledge(proto.SaveKnowledgeRequest(
|
|
53
|
+
knowledge_id="note-1",
|
|
54
|
+
content="The user is building an autonomous research agent in Python.",
|
|
55
|
+
))
|
|
56
|
+
hits = client.knowledge.SearchKnowledge(proto.SearchKnowledgeRequest(
|
|
57
|
+
query="what is the user building?", top_k=3,
|
|
58
|
+
))
|
|
59
|
+
for h in hits.results:
|
|
60
|
+
print(h.knowledge_id, h.score, h.snippet)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Sub-clients mirror the Rust crate: `client.knowledge`, `client.memory`,
|
|
64
|
+
`client.graph` (SPARQL/RDF/SHACL/inference/ontology), `client.graphrag`,
|
|
65
|
+
`client.tools` (generic tool dispatch, same surface as MCP), `client.admin`.
|
|
66
|
+
|
|
67
|
+
## Results as DataFrames or CSV
|
|
68
|
+
|
|
69
|
+
`client.tools.CallTool` answers with JSON. `cortexdb_client.tabular` turns the
|
|
70
|
+
row-shaped part of it into flat records, a pandas DataFrame or a CSV file:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import json
|
|
74
|
+
from cortexdb_client import CortexClient, proto, tabular
|
|
75
|
+
|
|
76
|
+
with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
|
|
77
|
+
res = client.tools.CallTool(proto.CallToolRequest(
|
|
78
|
+
name="graph_cypher_query",
|
|
79
|
+
args_json=json.dumps({"query": "MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100"}),
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
rows = tabular.records(res) # list of flat dicts: {"a.id": ..., "r.type": ..., "b.id": ...}
|
|
83
|
+
df = tabular.to_pandas(res) # needs the extra: pip install "cortexdb-client[pandas]"
|
|
84
|
+
tabular.to_csv(res, "edges.csv") # standard library only; to_csv(res) returns the text
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
pandas is optional and imported only inside `to_pandas()`; the base install
|
|
88
|
+
stays `grpcio` + `protobuf`.
|
|
89
|
+
|
|
90
|
+
| Tool | Table(s) |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `graph_cypher_query` | `rows` (default) — `columns` zipped with each row |
|
|
93
|
+
| `aggregate_metadata` | `results` (default) — one row per group |
|
|
94
|
+
| `memory_search`, `knowledge_search` | `results` (default); `knowledge_search` may also carry `chunks` |
|
|
95
|
+
| `knowledge_memory_recall` | `memories`, `knowledge`, `chunks`, `graph_facts` — pass `table=` |
|
|
96
|
+
| `memory_list_all` | `memories` (default) |
|
|
97
|
+
| `graph_list_all` | `nodes`, `edges` — pass `table=` |
|
|
98
|
+
| `search_text`, `recall_log_list`, `graph_property_values`, … | the one top-level list of objects (`chunks`, `recalls`, `values`) |
|
|
99
|
+
|
|
100
|
+
`tabular.tables(res)` names the tables a result has. A result with more than one
|
|
101
|
+
and no `rows` / `results` raises `ValueError` until you pick:
|
|
102
|
+
`tabular.records(res, "edges")`.
|
|
103
|
+
|
|
104
|
+
How a row becomes columns:
|
|
105
|
+
|
|
106
|
+
- a scalar is a column under its key;
|
|
107
|
+
- a nested object becomes one column per leaf, keys joined with `.`
|
|
108
|
+
(`metadata.project`, `n.properties.city`); `sep="__"` changes the separator;
|
|
109
|
+
- a list becomes its JSON text, or stays a list with `lists="keep"`
|
|
110
|
+
(for `DataFrame.explode`);
|
|
111
|
+
- the `keys` of an aggregate group and the `memory` of a memory hit are lifted,
|
|
112
|
+
so their fields lead the row unprefixed (`team`, `year`, `value`, `count`;
|
|
113
|
+
`id`, `content`, …, `score`). A lifted name that collides keeps its prefix
|
|
114
|
+
(`keys.count`). Every other key of a group is a column whatever it is called,
|
|
115
|
+
so new measures need no client change;
|
|
116
|
+
- rows need not share columns: CSV leaves the cell empty, pandas gives `NaN`.
|
|
117
|
+
|
|
118
|
+
Listings page with a cursor; collect the pages, then tabulate:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
memories, cursor = [], ""
|
|
122
|
+
while True:
|
|
123
|
+
page = json.loads(client.tools.CallTool(proto.CallToolRequest(
|
|
124
|
+
name="memory_list_all", args_json=json.dumps({"cursor": cursor}))).result_json)
|
|
125
|
+
memories += tabular.records(page)
|
|
126
|
+
cursor = page.get("next_cursor", "")
|
|
127
|
+
if not cursor:
|
|
128
|
+
break
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
To analyse the SQLite file itself with DuckDB, pandas or `sqlite3`, see
|
|
132
|
+
"Data analysis" in the [guide](https://github.com/liliang-cn/cortexdb/blob/main/docs/GUIDE.md).
|
|
133
|
+
|
|
134
|
+
## Why a knowledge graph, not just vectors
|
|
135
|
+
|
|
136
|
+
Beyond semantic recall, the `graph` service lets an agent store and traverse
|
|
137
|
+
**entities and relations** — multi-hop questions like "who, among the people
|
|
138
|
+
Alice knows, works on X" — with SPARQL, RDFS-lite inference, and SHACL-lite
|
|
139
|
+
validation. That is the capability most agent-memory layers lack.
|
|
140
|
+
|
|
141
|
+
## Embeddings
|
|
142
|
+
|
|
143
|
+
Lexical mode needs no keys. Point the sidecar at any OpenAI-compatible
|
|
144
|
+
embeddings endpoint (e.g. Ollama) to enable vector retrieval:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
OPENAI_BASE_URL=http://localhost:11434/v1 \
|
|
148
|
+
CORTEXDB_EMBED_MODEL=embeddinggemma CORTEXDB_EMBED_DIM=768 \
|
|
149
|
+
cortexdb-grpc
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Regenerating the protobuf code
|
|
153
|
+
|
|
154
|
+
Generated code is committed under `cortexdb_client/_pb`. To regenerate after a
|
|
155
|
+
proto change: `uv run --extra dev ./gen.sh`.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# cortexdb-client (Python)
|
|
2
|
+
|
|
3
|
+
Typed gRPC client for [CortexDB](https://github.com/liliang-cn/cortexdb) — a
|
|
4
|
+
pure-Go, single-file AI memory and knowledge graph database, served as a
|
|
5
|
+
sidecar (`cortexdb-grpc`). Give your Python agent (e.g. **Hermes**) durable
|
|
6
|
+
memory **and** a queryable knowledge graph.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install cortexdb-client # or: uv add cortexdb-client
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Start the sidecar (one binary, one SQLite file):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
go install github.com/liliang-cn/cortexdb/v2/cmd/cortexdb-grpc@latest
|
|
18
|
+
CORTEXDB_PATH=agent.db CORTEXDB_GRPC_TOKEN=s3cret cortexdb-grpc
|
|
19
|
+
# listening on 127.0.0.1:47821
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from cortexdb_client import CortexClient, proto
|
|
26
|
+
|
|
27
|
+
with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
|
|
28
|
+
client.knowledge.SaveKnowledge(proto.SaveKnowledgeRequest(
|
|
29
|
+
knowledge_id="note-1",
|
|
30
|
+
content="The user is building an autonomous research agent in Python.",
|
|
31
|
+
))
|
|
32
|
+
hits = client.knowledge.SearchKnowledge(proto.SearchKnowledgeRequest(
|
|
33
|
+
query="what is the user building?", top_k=3,
|
|
34
|
+
))
|
|
35
|
+
for h in hits.results:
|
|
36
|
+
print(h.knowledge_id, h.score, h.snippet)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Sub-clients mirror the Rust crate: `client.knowledge`, `client.memory`,
|
|
40
|
+
`client.graph` (SPARQL/RDF/SHACL/inference/ontology), `client.graphrag`,
|
|
41
|
+
`client.tools` (generic tool dispatch, same surface as MCP), `client.admin`.
|
|
42
|
+
|
|
43
|
+
## Results as DataFrames or CSV
|
|
44
|
+
|
|
45
|
+
`client.tools.CallTool` answers with JSON. `cortexdb_client.tabular` turns the
|
|
46
|
+
row-shaped part of it into flat records, a pandas DataFrame or a CSV file:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import json
|
|
50
|
+
from cortexdb_client import CortexClient, proto, tabular
|
|
51
|
+
|
|
52
|
+
with CortexClient.connect("127.0.0.1:47821", token="s3cret") as client:
|
|
53
|
+
res = client.tools.CallTool(proto.CallToolRequest(
|
|
54
|
+
name="graph_cypher_query",
|
|
55
|
+
args_json=json.dumps({"query": "MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100"}),
|
|
56
|
+
))
|
|
57
|
+
|
|
58
|
+
rows = tabular.records(res) # list of flat dicts: {"a.id": ..., "r.type": ..., "b.id": ...}
|
|
59
|
+
df = tabular.to_pandas(res) # needs the extra: pip install "cortexdb-client[pandas]"
|
|
60
|
+
tabular.to_csv(res, "edges.csv") # standard library only; to_csv(res) returns the text
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
pandas is optional and imported only inside `to_pandas()`; the base install
|
|
64
|
+
stays `grpcio` + `protobuf`.
|
|
65
|
+
|
|
66
|
+
| Tool | Table(s) |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `graph_cypher_query` | `rows` (default) — `columns` zipped with each row |
|
|
69
|
+
| `aggregate_metadata` | `results` (default) — one row per group |
|
|
70
|
+
| `memory_search`, `knowledge_search` | `results` (default); `knowledge_search` may also carry `chunks` |
|
|
71
|
+
| `knowledge_memory_recall` | `memories`, `knowledge`, `chunks`, `graph_facts` — pass `table=` |
|
|
72
|
+
| `memory_list_all` | `memories` (default) |
|
|
73
|
+
| `graph_list_all` | `nodes`, `edges` — pass `table=` |
|
|
74
|
+
| `search_text`, `recall_log_list`, `graph_property_values`, … | the one top-level list of objects (`chunks`, `recalls`, `values`) |
|
|
75
|
+
|
|
76
|
+
`tabular.tables(res)` names the tables a result has. A result with more than one
|
|
77
|
+
and no `rows` / `results` raises `ValueError` until you pick:
|
|
78
|
+
`tabular.records(res, "edges")`.
|
|
79
|
+
|
|
80
|
+
How a row becomes columns:
|
|
81
|
+
|
|
82
|
+
- a scalar is a column under its key;
|
|
83
|
+
- a nested object becomes one column per leaf, keys joined with `.`
|
|
84
|
+
(`metadata.project`, `n.properties.city`); `sep="__"` changes the separator;
|
|
85
|
+
- a list becomes its JSON text, or stays a list with `lists="keep"`
|
|
86
|
+
(for `DataFrame.explode`);
|
|
87
|
+
- the `keys` of an aggregate group and the `memory` of a memory hit are lifted,
|
|
88
|
+
so their fields lead the row unprefixed (`team`, `year`, `value`, `count`;
|
|
89
|
+
`id`, `content`, …, `score`). A lifted name that collides keeps its prefix
|
|
90
|
+
(`keys.count`). Every other key of a group is a column whatever it is called,
|
|
91
|
+
so new measures need no client change;
|
|
92
|
+
- rows need not share columns: CSV leaves the cell empty, pandas gives `NaN`.
|
|
93
|
+
|
|
94
|
+
Listings page with a cursor; collect the pages, then tabulate:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
memories, cursor = [], ""
|
|
98
|
+
while True:
|
|
99
|
+
page = json.loads(client.tools.CallTool(proto.CallToolRequest(
|
|
100
|
+
name="memory_list_all", args_json=json.dumps({"cursor": cursor}))).result_json)
|
|
101
|
+
memories += tabular.records(page)
|
|
102
|
+
cursor = page.get("next_cursor", "")
|
|
103
|
+
if not cursor:
|
|
104
|
+
break
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
To analyse the SQLite file itself with DuckDB, pandas or `sqlite3`, see
|
|
108
|
+
"Data analysis" in the [guide](https://github.com/liliang-cn/cortexdb/blob/main/docs/GUIDE.md).
|
|
109
|
+
|
|
110
|
+
## Why a knowledge graph, not just vectors
|
|
111
|
+
|
|
112
|
+
Beyond semantic recall, the `graph` service lets an agent store and traverse
|
|
113
|
+
**entities and relations** — multi-hop questions like "who, among the people
|
|
114
|
+
Alice knows, works on X" — with SPARQL, RDFS-lite inference, and SHACL-lite
|
|
115
|
+
validation. That is the capability most agent-memory layers lack.
|
|
116
|
+
|
|
117
|
+
## Embeddings
|
|
118
|
+
|
|
119
|
+
Lexical mode needs no keys. Point the sidecar at any OpenAI-compatible
|
|
120
|
+
embeddings endpoint (e.g. Ollama) to enable vector retrieval:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
OPENAI_BASE_URL=http://localhost:11434/v1 \
|
|
124
|
+
CORTEXDB_EMBED_MODEL=embeddinggemma CORTEXDB_EMBED_DIM=768 \
|
|
125
|
+
cortexdb-grpc
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Regenerating the protobuf code
|
|
129
|
+
|
|
130
|
+
Generated code is committed under `cortexdb_client/_pb`. To regenerate after a
|
|
131
|
+
proto change: `uv run --extra dev ./gen.sh`.
|
|
@@ -16,10 +16,14 @@ Example::
|
|
|
16
16
|
The sub-client accessors mirror the Rust crate: ``client.knowledge``,
|
|
17
17
|
``client.memory``, ``client.graph``, ``client.graphrag``, ``client.tools``,
|
|
18
18
|
``client.admin``.
|
|
19
|
+
|
|
20
|
+
``cortexdb_client.tabular`` turns a tool result into flat records, a pandas
|
|
21
|
+
DataFrame or CSV.
|
|
19
22
|
"""
|
|
20
23
|
|
|
21
24
|
from .client import CortexClient
|
|
22
25
|
from . import proto
|
|
26
|
+
from . import tabular
|
|
23
27
|
|
|
24
|
-
__all__ = ["CortexClient", "proto"]
|
|
28
|
+
__all__ = ["CortexClient", "proto", "tabular"]
|
|
25
29
|
__version__ = "0.1.0"
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Tool results as tables: flat records, a pandas DataFrame, or CSV.
|
|
2
|
+
|
|
3
|
+
A tool called through ``client.tools.CallTool`` answers with one JSON object.
|
|
4
|
+
The row-shaped part of it — a Cypher query's rows, the groups of
|
|
5
|
+
``aggregate_metadata``, the hits of a search or a recall, a listing — is a
|
|
6
|
+
list under some key. :func:`records` picks that list and returns it as flat
|
|
7
|
+
``dict`` records, one per row, ready for ``csv``, pandas, polars or DuckDB::
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from cortexdb_client import CortexClient, proto, tabular
|
|
11
|
+
|
|
12
|
+
with CortexClient.connect("127.0.0.1:47821") as client:
|
|
13
|
+
res = client.tools.CallTool(proto.CallToolRequest(
|
|
14
|
+
name="graph_cypher_query",
|
|
15
|
+
args_json=json.dumps({"query": "MATCH (n) RETURN n LIMIT 100"}),
|
|
16
|
+
))
|
|
17
|
+
rows = tabular.records(res) # [{"n.id": "...", "n.labels": "[...]", ...}, ...]
|
|
18
|
+
df = tabular.to_pandas(res) # needs: pip install "cortexdb-client[pandas]"
|
|
19
|
+
tabular.to_csv(res, "nodes.csv")
|
|
20
|
+
|
|
21
|
+
Which list is the table
|
|
22
|
+
-----------------------
|
|
23
|
+
|
|
24
|
+
``records(result, table=None)`` chooses, in this order:
|
|
25
|
+
|
|
26
|
+
1. ``table``, when given — the name of a top-level key (``"memories"``,
|
|
27
|
+
``"knowledge"``, ``"chunks"``, ``"graph_facts"``, ``"nodes"``, ``"edges"``,
|
|
28
|
+
``"recalls"`` …). :func:`tables` lists the names a result has.
|
|
29
|
+
2. ``rows``, when the result also has ``columns`` (``graph_cypher_query``).
|
|
30
|
+
3. ``results``, when present (``aggregate_metadata``, ``memory_search``,
|
|
31
|
+
``knowledge_search``).
|
|
32
|
+
4. The only top-level list of objects, when there is exactly one
|
|
33
|
+
(``memory_list_all`` → ``memories``, ``search_text`` → ``chunks``).
|
|
34
|
+
|
|
35
|
+
Anything else raises ``ValueError`` naming the tables to choose from —
|
|
36
|
+
``graph_list_all`` has ``nodes`` and ``edges``, ``knowledge_memory_recall`` has
|
|
37
|
+
``memories``, ``knowledge``, ``chunks`` and ``graph_facts``.
|
|
38
|
+
|
|
39
|
+
How a row is flattened
|
|
40
|
+
----------------------
|
|
41
|
+
|
|
42
|
+
* A scalar (string, number, bool, ``None``) is a column under its key.
|
|
43
|
+
* A nested object becomes one column per leaf, the keys joined with ``.``:
|
|
44
|
+
``{"metadata": {"project": "x"}}`` → ``metadata.project``. An empty object
|
|
45
|
+
adds no column.
|
|
46
|
+
* A list becomes one column holding its compact JSON text (``lists="json"``,
|
|
47
|
+
the default — safe for CSV), or is kept as a Python list (``lists="keep"``,
|
|
48
|
+
for ``DataFrame.explode``).
|
|
49
|
+
* Two wrappers are lifted so their fields are columns in their own right:
|
|
50
|
+
the ``keys`` of an ``aggregate_metadata`` group (``{"keys": {"project":
|
|
51
|
+
"x"}, "count": 3}`` → ``project``, ``count``) and the ``memory`` of a memory
|
|
52
|
+
hit (``{"memory": {"id": "m1"}, "score": 0.4}`` → ``id``, ``score``).
|
|
53
|
+
Lifted columns come first. One that would collide with another column
|
|
54
|
+
keeps its prefix (``keys.count``). Every other key of a group — today
|
|
55
|
+
``value`` and ``count`` — is a column whatever it is called.
|
|
56
|
+
* A Cypher row is its ``columns`` zipped with its values; a node or
|
|
57
|
+
relationship value is flattened under the column name (``n.id``,
|
|
58
|
+
``n.properties.city``).
|
|
59
|
+
|
|
60
|
+
Rows need not share columns: a key absent from a row is absent from its
|
|
61
|
+
record. :func:`to_csv` and pandas fill the gap (empty cell, ``NaN``).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import csv
|
|
67
|
+
import io
|
|
68
|
+
import json
|
|
69
|
+
import os
|
|
70
|
+
from typing import IO, TYPE_CHECKING, Any, Dict, Iterable, List, Mapping, Optional, Union
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
73
|
+
import pandas
|
|
74
|
+
|
|
75
|
+
__all__ = ["Record", "flatten", "records", "tables", "columns", "to_pandas", "to_csv"]
|
|
76
|
+
|
|
77
|
+
#: One flat row: column name → scalar (or list, with ``lists="keep"``).
|
|
78
|
+
Record = Dict[str, Any]
|
|
79
|
+
|
|
80
|
+
# Wrappers whose fields are lifted to top-level columns (see module docstring).
|
|
81
|
+
_LIFTED = ("keys", "memory")
|
|
82
|
+
|
|
83
|
+
_LIST_MODES = ("json", "keep")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dumps(value: Any) -> str:
|
|
87
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True, default=str)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def flatten(obj: Mapping[str, Any], *, sep: str = ".", lists: str = "json", prefix: str = "") -> Record:
|
|
91
|
+
"""Flatten one nested object into a single-level record.
|
|
92
|
+
|
|
93
|
+
Nested objects become ``parent<sep>child`` columns, lists become compact
|
|
94
|
+
JSON text (``lists="json"``) or stay lists (``lists="keep"``), scalars are
|
|
95
|
+
copied. No wrapper is lifted here; see :func:`records` for that.
|
|
96
|
+
"""
|
|
97
|
+
if lists not in _LIST_MODES:
|
|
98
|
+
raise ValueError(f"lists must be one of {_LIST_MODES}, got {lists!r}")
|
|
99
|
+
out: Record = {}
|
|
100
|
+
_flatten_into(out, obj, prefix, sep, lists)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _flatten_into(out: Record, obj: Mapping[str, Any], prefix: str, sep: str, lists: str) -> None:
|
|
105
|
+
for key, value in obj.items():
|
|
106
|
+
name = f"{prefix}{sep}{key}" if prefix else str(key)
|
|
107
|
+
_put(out, name, value, sep, lists)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _put(out: Record, name: str, value: Any, sep: str, lists: str) -> None:
|
|
111
|
+
if isinstance(value, Mapping):
|
|
112
|
+
_flatten_into(out, value, name, sep, lists)
|
|
113
|
+
elif isinstance(value, (list, tuple)):
|
|
114
|
+
out[name] = _dumps(value) if lists == "json" else list(value)
|
|
115
|
+
else:
|
|
116
|
+
out[name] = value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load(result: Any) -> Any:
|
|
120
|
+
"""Decode what a tool call returned into plain Python data."""
|
|
121
|
+
payload = getattr(result, "result_json", None) # proto.CallToolResponse
|
|
122
|
+
if payload is not None:
|
|
123
|
+
result = payload
|
|
124
|
+
if isinstance(result, (bytes, bytearray)):
|
|
125
|
+
result = bytes(result).decode("utf-8")
|
|
126
|
+
if isinstance(result, str):
|
|
127
|
+
if not result.strip():
|
|
128
|
+
return {}
|
|
129
|
+
return json.loads(result)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_table(value: Any) -> bool:
|
|
134
|
+
return isinstance(value, list) and all(isinstance(item, Mapping) for item in value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _table_names(data: Mapping[str, Any]) -> List[str]:
|
|
138
|
+
names = [key for key, value in data.items() if key != "rows" and _is_table(value)]
|
|
139
|
+
if "columns" in data and "rows" in data:
|
|
140
|
+
names.insert(0, "rows")
|
|
141
|
+
return names
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tables(result: Any) -> List[str]:
|
|
145
|
+
"""Names of the tables in a tool result, in the order the result has them.
|
|
146
|
+
|
|
147
|
+
A table is a top-level list of objects, or the ``rows`` of a Cypher
|
|
148
|
+
answer. A list the server left out because it was empty is not named.
|
|
149
|
+
"""
|
|
150
|
+
data = _load(result)
|
|
151
|
+
if isinstance(data, list):
|
|
152
|
+
return []
|
|
153
|
+
if not isinstance(data, Mapping):
|
|
154
|
+
raise TypeError(f"expected a tool result object, got {type(data).__name__}")
|
|
155
|
+
return _table_names(data)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _pick(data: Mapping[str, Any], table: Optional[str]) -> str:
|
|
159
|
+
if table is not None:
|
|
160
|
+
if table not in data:
|
|
161
|
+
raise ValueError(f"no table {table!r} in this result; it has {_table_names(data) or 'none'}")
|
|
162
|
+
return table
|
|
163
|
+
if "columns" in data and "rows" in data:
|
|
164
|
+
return "rows"
|
|
165
|
+
if "results" in data:
|
|
166
|
+
return "results"
|
|
167
|
+
names = _table_names(data)
|
|
168
|
+
if len(names) == 1:
|
|
169
|
+
return names[0]
|
|
170
|
+
if not names:
|
|
171
|
+
raise ValueError("this result has no list of rows to tabulate")
|
|
172
|
+
raise ValueError(f"this result has several tables, pass table=: {names}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _lifted_row(row: Mapping[str, Any], sep: str, lists: str) -> Record:
|
|
176
|
+
rest = {k: v for k, v in row.items() if not (k in _LIFTED and isinstance(v, Mapping))}
|
|
177
|
+
own = flatten(rest, sep=sep, lists=lists)
|
|
178
|
+
out: Record = {}
|
|
179
|
+
for wrapper in _LIFTED: # lifted fields lead: group keys, then measures
|
|
180
|
+
inner = row.get(wrapper)
|
|
181
|
+
if not isinstance(inner, Mapping):
|
|
182
|
+
continue
|
|
183
|
+
for name, value in flatten(inner, sep=sep, lists=lists).items():
|
|
184
|
+
out[f"{wrapper}{sep}{name}" if name in own or name in out else name] = value
|
|
185
|
+
out.update(own)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def records(result: Any, table: Optional[str] = None, *, sep: str = ".", lists: str = "json") -> List[Record]:
|
|
190
|
+
"""The rows of a tool result as flat records.
|
|
191
|
+
|
|
192
|
+
``result`` is what ``client.tools.CallTool`` returned (a
|
|
193
|
+
``CallToolResponse``), its ``result_json`` text, or the decoded object.
|
|
194
|
+
A bare list of objects is taken as the table itself. See the module
|
|
195
|
+
docstring for how the table is chosen and how a row is flattened.
|
|
196
|
+
"""
|
|
197
|
+
if lists not in _LIST_MODES:
|
|
198
|
+
raise ValueError(f"lists must be one of {_LIST_MODES}, got {lists!r}")
|
|
199
|
+
data = _load(result)
|
|
200
|
+
if isinstance(data, list):
|
|
201
|
+
rows: Any = data
|
|
202
|
+
elif isinstance(data, Mapping):
|
|
203
|
+
name = _pick(data, table)
|
|
204
|
+
rows = data.get(name)
|
|
205
|
+
if name == "rows" and "columns" in data:
|
|
206
|
+
return _cypher_records(data.get("columns") or [], rows or [], sep, lists)
|
|
207
|
+
else:
|
|
208
|
+
raise TypeError(f"expected a tool result object, got {type(data).__name__}")
|
|
209
|
+
if rows is None: # a Go nil slice arrives as null
|
|
210
|
+
return []
|
|
211
|
+
if not _is_table(rows):
|
|
212
|
+
raise ValueError(f"table {table!r} is not a list of objects")
|
|
213
|
+
return [_lifted_row(row, sep, lists) for row in rows]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _cypher_records(cols: Iterable[Any], rows: Iterable[Any], sep: str, lists: str) -> List[Record]:
|
|
217
|
+
names = [str(c) for c in cols]
|
|
218
|
+
out: List[Record] = []
|
|
219
|
+
for row in rows:
|
|
220
|
+
record: Record = {}
|
|
221
|
+
for name, value in zip(names, row):
|
|
222
|
+
_put(record, name, value, sep, lists)
|
|
223
|
+
out.append(record)
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def columns(rows: Iterable[Mapping[str, Any]]) -> List[str]:
|
|
228
|
+
"""Every column of ``rows``, in the order each first appears."""
|
|
229
|
+
seen: Dict[str, None] = {}
|
|
230
|
+
for row in rows:
|
|
231
|
+
for name in row:
|
|
232
|
+
seen.setdefault(name, None)
|
|
233
|
+
return list(seen)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def to_pandas(result: Any, table: Optional[str] = None, *, sep: str = ".", lists: str = "json") -> "pandas.DataFrame":
|
|
237
|
+
"""The rows of a tool result as a ``pandas.DataFrame``.
|
|
238
|
+
|
|
239
|
+
pandas is optional: ``pip install "cortexdb-client[pandas]"``. Columns
|
|
240
|
+
come in first-seen order; a key a row lacks is ``NaN`` there.
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
import pandas as pd
|
|
244
|
+
except ImportError as exc:
|
|
245
|
+
raise ImportError(
|
|
246
|
+
"to_pandas() needs pandas, which cortexdb-client does not install by default. "
|
|
247
|
+
'Install it with: pip install "cortexdb-client[pandas]" (or: uv add "cortexdb-client[pandas]"). '
|
|
248
|
+
"records() returns the same rows as plain dicts without it."
|
|
249
|
+
) from exc
|
|
250
|
+
rows = records(result, table, sep=sep, lists=lists)
|
|
251
|
+
return pd.DataFrame.from_records(rows, columns=columns(rows))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def to_csv(
|
|
255
|
+
result: Any,
|
|
256
|
+
dest: Union[str, "os.PathLike[str]", IO[str], None] = None,
|
|
257
|
+
table: Optional[str] = None,
|
|
258
|
+
*,
|
|
259
|
+
sep: str = ".",
|
|
260
|
+
) -> Optional[str]:
|
|
261
|
+
"""Write the rows of a tool result as CSV, header first.
|
|
262
|
+
|
|
263
|
+
``dest`` is a path, an open text file, or ``None`` to get the CSV back as
|
|
264
|
+
a string. Lists are written as JSON text and a missing value as an empty
|
|
265
|
+
cell. Uses only the standard library. A path is written as UTF-8.
|
|
266
|
+
"""
|
|
267
|
+
rows = records(result, table, sep=sep, lists="json")
|
|
268
|
+
header = columns(rows)
|
|
269
|
+
|
|
270
|
+
def write(handle: IO[str]) -> None:
|
|
271
|
+
writer = csv.DictWriter(handle, fieldnames=header, restval="", lineterminator="\n")
|
|
272
|
+
writer.writeheader()
|
|
273
|
+
writer.writerows(rows)
|
|
274
|
+
|
|
275
|
+
if dest is None:
|
|
276
|
+
buffer = io.StringIO()
|
|
277
|
+
write(buffer)
|
|
278
|
+
return buffer.getvalue()
|
|
279
|
+
if isinstance(dest, (str, os.PathLike)):
|
|
280
|
+
with open(dest, "w", encoding="utf-8", newline="") as handle:
|
|
281
|
+
write(handle)
|
|
282
|
+
return None
|
|
283
|
+
write(dest)
|
|
284
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cortexdb-client"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Typed gRPC client for CortexDB (AI memory + knowledge graph sidecar)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -24,6 +24,8 @@ Repository = "https://github.com/liliang-cn/cortexdb"
|
|
|
24
24
|
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
dev = ["grpcio-tools>=1.60", "build", "twine"]
|
|
27
|
+
# tabular.to_pandas(); the rest of cortexdb_client.tabular needs nothing extra.
|
|
28
|
+
pandas = ["pandas>=1.5"]
|
|
27
29
|
|
|
28
30
|
[build-system]
|
|
29
31
|
requires = ["hatchling"]
|