corecrux-client 0.1.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.
- corecrux_client-0.1.0/.gitignore +55 -0
- corecrux_client-0.1.0/PKG-INFO +133 -0
- corecrux_client-0.1.0/README.md +121 -0
- corecrux_client-0.1.0/pyproject.toml +20 -0
- corecrux_client-0.1.0/src/corecrux_client/__init__.py +32 -0
- corecrux_client-0.1.0/src/corecrux_client/client.py +632 -0
- corecrux_client-0.1.0/src/corecrux_client/errors.py +21 -0
- corecrux_client-0.1.0/src/corecrux_client/types.py +103 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Rust build artifacts
|
|
2
|
+
/target
|
|
3
|
+
**/*.rs.bk
|
|
4
|
+
*.pdb
|
|
5
|
+
|
|
6
|
+
# IDE
|
|
7
|
+
.idea/
|
|
8
|
+
.vscode/
|
|
9
|
+
*.swp
|
|
10
|
+
*.swo
|
|
11
|
+
*~
|
|
12
|
+
|
|
13
|
+
# OS
|
|
14
|
+
.DS_Store
|
|
15
|
+
Thumbs.db
|
|
16
|
+
|
|
17
|
+
# GPU logs
|
|
18
|
+
cufile.log
|
|
19
|
+
|
|
20
|
+
# Generated proto code
|
|
21
|
+
proto/gen/
|
|
22
|
+
|
|
23
|
+
# Environment
|
|
24
|
+
.env
|
|
25
|
+
.env.local
|
|
26
|
+
sdks/typescript/node_modules/
|
|
27
|
+
sdks/typescript/dist/
|
|
28
|
+
sdks/python/dist/
|
|
29
|
+
__pycache__/
|
|
30
|
+
*.pyc
|
|
31
|
+
|
|
32
|
+
# Daemon data — NEVER commit. The fact store, passport keys, sealed
|
|
33
|
+
# integration credentials, selected-repo lists and sync cursors all live
|
|
34
|
+
# under the data dir. Default location is `./data/` when CORECRUXD_DATA_DIR
|
|
35
|
+
# is unset (e.g. `cargo run` outside docker); the docker-compose stack uses
|
|
36
|
+
# a named volume which is already outside the repo. Belt-and-braces here so
|
|
37
|
+
# a stray `git add .` never pulls them in.
|
|
38
|
+
/data/
|
|
39
|
+
**/data/
|
|
40
|
+
*.jsonl
|
|
41
|
+
crux-data/
|
|
42
|
+
passport.key
|
|
43
|
+
passports/
|
|
44
|
+
**/credentials.json
|
|
45
|
+
**/selected_repos.json
|
|
46
|
+
sync_cursor.json
|
|
47
|
+
console/settings.json
|
|
48
|
+
LOCK
|
|
49
|
+
.install-uuid
|
|
50
|
+
|
|
51
|
+
# Claude Code subagent scratch worktrees (orphaned isolation:worktree dirs)
|
|
52
|
+
.claude/
|
|
53
|
+
|
|
54
|
+
# crux-console-ui build artifacts (WASM/JS bundles are generated, not source)
|
|
55
|
+
crates/crux-console-ui/dist/
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: corecrux-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Crux Daemon
|
|
5
|
+
Project-URL: Homepage, https://github.com/CueCrux/Crux
|
|
6
|
+
Project-URL: Documentation, https://github.com/CueCrux/Crux/tree/main/sdks/python
|
|
7
|
+
Project-URL: Repository, https://github.com/CueCrux/Crux
|
|
8
|
+
License-Expression: LicenseRef-CCL-1.0
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# CoreCrux Python Client
|
|
14
|
+
|
|
15
|
+
Python client for the [Crux Daemon](https://github.com/CueCrux/Crux) HTTP API.
|
|
16
|
+
|
|
17
|
+
Provides both synchronous and asynchronous interfaces using [httpx](https://www.python-httpx.org/).
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install corecrux-client
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install from source:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd sdks/python
|
|
29
|
+
pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick start (sync)
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from corecrux_client import CoreCruxClient, StoreFact
|
|
36
|
+
|
|
37
|
+
with CoreCruxClient("http://localhost:14800", token="my-token") as client:
|
|
38
|
+
# Health check
|
|
39
|
+
print(client.healthz())
|
|
40
|
+
|
|
41
|
+
# Store a fact
|
|
42
|
+
fact = client.store_fact(StoreFact(
|
|
43
|
+
entity="user::alice",
|
|
44
|
+
key="preferred_language",
|
|
45
|
+
value="Python",
|
|
46
|
+
))
|
|
47
|
+
print(fact.fact_id, fact.version)
|
|
48
|
+
|
|
49
|
+
# Query facts
|
|
50
|
+
result = client.query_facts("Python", top_k=5)
|
|
51
|
+
for f in result.facts:
|
|
52
|
+
print(f.entity, f.key, f.value)
|
|
53
|
+
|
|
54
|
+
# Text search
|
|
55
|
+
hits = client.text_search("my-tenant", "deployment guide")
|
|
56
|
+
for h in hits.results:
|
|
57
|
+
print(h.doc_id, h.score)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick start (async)
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from corecrux_client import AsyncCoreCruxClient, StoreFact
|
|
65
|
+
|
|
66
|
+
async def main():
|
|
67
|
+
async with AsyncCoreCruxClient("http://localhost:14800", token="my-token") as client:
|
|
68
|
+
fact = await client.store_fact(StoreFact(
|
|
69
|
+
entity="user::alice",
|
|
70
|
+
key="preferred_language",
|
|
71
|
+
value="Python",
|
|
72
|
+
))
|
|
73
|
+
print(fact.fact_id)
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Authentication
|
|
79
|
+
|
|
80
|
+
Pass a bearer token when constructing the client:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
client = CoreCruxClient(token="my-bearer-token")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The token is sent as `Authorization: Bearer <token>` on every request.
|
|
87
|
+
|
|
88
|
+
## Error handling
|
|
89
|
+
|
|
90
|
+
All non-2xx responses raise `CoreCruxError`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from corecrux_client import CoreCruxClient, CoreCruxError
|
|
94
|
+
|
|
95
|
+
with CoreCruxClient() as client:
|
|
96
|
+
try:
|
|
97
|
+
client.get_fact("nonexistent-id")
|
|
98
|
+
except CoreCruxError as e:
|
|
99
|
+
print(e.status_code) # 404
|
|
100
|
+
print(e.detail) # "fact 'nonexistent-id' not found"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Methods that naturally return "not found" (`get_fact`, `get_session`, `delete_fact`) return `None` or `False` instead of raising on 404.
|
|
104
|
+
|
|
105
|
+
## API coverage
|
|
106
|
+
|
|
107
|
+
| Endpoint | Sync | Async |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `GET /healthz` | `healthz()` | `healthz()` |
|
|
110
|
+
| `GET /readyz` | `readyz()` | `readyz()` |
|
|
111
|
+
| `GET /v1/version` | `version()` | `version()` |
|
|
112
|
+
| `PUT /v1/facts` | `store_fact()` | `store_fact()` |
|
|
113
|
+
| `PUT /v1/facts/bulk` | `store_facts()` | `store_facts()` |
|
|
114
|
+
| `GET /v1/facts/{id}` | `get_fact()` | `get_fact()` |
|
|
115
|
+
| `DELETE /v1/facts/{id}` | `delete_fact()` | `delete_fact()` |
|
|
116
|
+
| `GET /v1/facts/entity/{e}` | `get_facts_by_entity()` | `get_facts_by_entity()` |
|
|
117
|
+
| `GET /v1/facts` | `query_facts()` | `query_facts()` |
|
|
118
|
+
| `GET /v1/facts/export` | `export_facts()` | `export_facts()` |
|
|
119
|
+
| `PUT /v1/sessions/{id}/state` | `put_session()` | `put_session()` |
|
|
120
|
+
| `GET /v1/sessions/{id}/state` | `get_session()` | `get_session()` |
|
|
121
|
+
| `POST /v1/query/text-search` | `text_search()` | `text_search()` |
|
|
122
|
+
| `POST /v1/query/text-search/expand` | `text_search_expand()` | `text_search_expand()` |
|
|
123
|
+
| `POST /v1/query/graph-expand` | `graph_expand()` | `graph_expand()` |
|
|
124
|
+
| `POST /v1/query/time-range` | `time_range()` | `time_range()` |
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- Python 3.10+
|
|
129
|
+
- httpx >= 0.27
|
|
130
|
+
|
|
131
|
+
## Licence
|
|
132
|
+
|
|
133
|
+
CueCrux Community Licence (CCL v1.0). See [LICENCE.md](../../LICENCE.md).
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# CoreCrux Python Client
|
|
2
|
+
|
|
3
|
+
Python client for the [Crux Daemon](https://github.com/CueCrux/Crux) HTTP API.
|
|
4
|
+
|
|
5
|
+
Provides both synchronous and asynchronous interfaces using [httpx](https://www.python-httpx.org/).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install corecrux-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install from source:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd sdks/python
|
|
17
|
+
pip install -e .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start (sync)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from corecrux_client import CoreCruxClient, StoreFact
|
|
24
|
+
|
|
25
|
+
with CoreCruxClient("http://localhost:14800", token="my-token") as client:
|
|
26
|
+
# Health check
|
|
27
|
+
print(client.healthz())
|
|
28
|
+
|
|
29
|
+
# Store a fact
|
|
30
|
+
fact = client.store_fact(StoreFact(
|
|
31
|
+
entity="user::alice",
|
|
32
|
+
key="preferred_language",
|
|
33
|
+
value="Python",
|
|
34
|
+
))
|
|
35
|
+
print(fact.fact_id, fact.version)
|
|
36
|
+
|
|
37
|
+
# Query facts
|
|
38
|
+
result = client.query_facts("Python", top_k=5)
|
|
39
|
+
for f in result.facts:
|
|
40
|
+
print(f.entity, f.key, f.value)
|
|
41
|
+
|
|
42
|
+
# Text search
|
|
43
|
+
hits = client.text_search("my-tenant", "deployment guide")
|
|
44
|
+
for h in hits.results:
|
|
45
|
+
print(h.doc_id, h.score)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start (async)
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import asyncio
|
|
52
|
+
from corecrux_client import AsyncCoreCruxClient, StoreFact
|
|
53
|
+
|
|
54
|
+
async def main():
|
|
55
|
+
async with AsyncCoreCruxClient("http://localhost:14800", token="my-token") as client:
|
|
56
|
+
fact = await client.store_fact(StoreFact(
|
|
57
|
+
entity="user::alice",
|
|
58
|
+
key="preferred_language",
|
|
59
|
+
value="Python",
|
|
60
|
+
))
|
|
61
|
+
print(fact.fact_id)
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Authentication
|
|
67
|
+
|
|
68
|
+
Pass a bearer token when constructing the client:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
client = CoreCruxClient(token="my-bearer-token")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The token is sent as `Authorization: Bearer <token>` on every request.
|
|
75
|
+
|
|
76
|
+
## Error handling
|
|
77
|
+
|
|
78
|
+
All non-2xx responses raise `CoreCruxError`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from corecrux_client import CoreCruxClient, CoreCruxError
|
|
82
|
+
|
|
83
|
+
with CoreCruxClient() as client:
|
|
84
|
+
try:
|
|
85
|
+
client.get_fact("nonexistent-id")
|
|
86
|
+
except CoreCruxError as e:
|
|
87
|
+
print(e.status_code) # 404
|
|
88
|
+
print(e.detail) # "fact 'nonexistent-id' not found"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Methods that naturally return "not found" (`get_fact`, `get_session`, `delete_fact`) return `None` or `False` instead of raising on 404.
|
|
92
|
+
|
|
93
|
+
## API coverage
|
|
94
|
+
|
|
95
|
+
| Endpoint | Sync | Async |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `GET /healthz` | `healthz()` | `healthz()` |
|
|
98
|
+
| `GET /readyz` | `readyz()` | `readyz()` |
|
|
99
|
+
| `GET /v1/version` | `version()` | `version()` |
|
|
100
|
+
| `PUT /v1/facts` | `store_fact()` | `store_fact()` |
|
|
101
|
+
| `PUT /v1/facts/bulk` | `store_facts()` | `store_facts()` |
|
|
102
|
+
| `GET /v1/facts/{id}` | `get_fact()` | `get_fact()` |
|
|
103
|
+
| `DELETE /v1/facts/{id}` | `delete_fact()` | `delete_fact()` |
|
|
104
|
+
| `GET /v1/facts/entity/{e}` | `get_facts_by_entity()` | `get_facts_by_entity()` |
|
|
105
|
+
| `GET /v1/facts` | `query_facts()` | `query_facts()` |
|
|
106
|
+
| `GET /v1/facts/export` | `export_facts()` | `export_facts()` |
|
|
107
|
+
| `PUT /v1/sessions/{id}/state` | `put_session()` | `put_session()` |
|
|
108
|
+
| `GET /v1/sessions/{id}/state` | `get_session()` | `get_session()` |
|
|
109
|
+
| `POST /v1/query/text-search` | `text_search()` | `text_search()` |
|
|
110
|
+
| `POST /v1/query/text-search/expand` | `text_search_expand()` | `text_search_expand()` |
|
|
111
|
+
| `POST /v1/query/graph-expand` | `graph_expand()` | `graph_expand()` |
|
|
112
|
+
| `POST /v1/query/time-range` | `time_range()` | `time_range()` |
|
|
113
|
+
|
|
114
|
+
## Requirements
|
|
115
|
+
|
|
116
|
+
- Python 3.10+
|
|
117
|
+
- httpx >= 0.27
|
|
118
|
+
|
|
119
|
+
## Licence
|
|
120
|
+
|
|
121
|
+
CueCrux Community Licence (CCL v1.0). See [LICENCE.md](../../LICENCE.md).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "corecrux-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Crux Daemon"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "LicenseRef-CCL-1.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["httpx>=0.27"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/CueCrux/Crux"
|
|
16
|
+
Documentation = "https://github.com/CueCrux/Crux/tree/main/sdks/python"
|
|
17
|
+
Repository = "https://github.com/CueCrux/Crux"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/corecrux_client"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (c) 2026 CueCrux Ltd. All rights reserved.
|
|
2
|
+
# Licensed under the CueCrux Community Licence (CCL v1.0).
|
|
3
|
+
# See LICENCE.md in the repository root.
|
|
4
|
+
|
|
5
|
+
"""Crux Daemon Python client."""
|
|
6
|
+
|
|
7
|
+
from .client import AsyncCoreCruxClient, CoreCruxClient
|
|
8
|
+
from .errors import CoreCruxError
|
|
9
|
+
from .types import (
|
|
10
|
+
Fact,
|
|
11
|
+
FactQueryResult,
|
|
12
|
+
SessionState,
|
|
13
|
+
StoreFact,
|
|
14
|
+
TextSearchCoverage,
|
|
15
|
+
TextSearchHit,
|
|
16
|
+
TextSearchMeta,
|
|
17
|
+
TextSearchResult,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncCoreCruxClient",
|
|
22
|
+
"CoreCruxClient",
|
|
23
|
+
"CoreCruxError",
|
|
24
|
+
"Fact",
|
|
25
|
+
"FactQueryResult",
|
|
26
|
+
"SessionState",
|
|
27
|
+
"StoreFact",
|
|
28
|
+
"TextSearchCoverage",
|
|
29
|
+
"TextSearchHit",
|
|
30
|
+
"TextSearchMeta",
|
|
31
|
+
"TextSearchResult",
|
|
32
|
+
]
|
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
# Copyright (c) 2026 CueCrux Ltd. All rights reserved.
|
|
2
|
+
# Licensed under the CueCrux Community Licence (CCL v1.0).
|
|
3
|
+
# See LICENCE.md in the repository root.
|
|
4
|
+
|
|
5
|
+
"""Synchronous and asynchronous CoreCrux HTTP clients."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import CoreCruxError
|
|
14
|
+
from .types import (
|
|
15
|
+
Fact,
|
|
16
|
+
FactQueryResult,
|
|
17
|
+
SessionState,
|
|
18
|
+
StoreFact,
|
|
19
|
+
TextSearchCoverage,
|
|
20
|
+
TextSearchHit,
|
|
21
|
+
TextSearchMeta,
|
|
22
|
+
TextSearchResult,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Helpers
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _headers(token: str | None) -> dict[str, str]:
|
|
31
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
32
|
+
if token:
|
|
33
|
+
h["Authorization"] = f"Bearer {token}"
|
|
34
|
+
return h
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
38
|
+
if resp.status_code < 400:
|
|
39
|
+
return
|
|
40
|
+
ct = resp.headers.get("content-type", "")
|
|
41
|
+
if ct.startswith("application/json") or ct.startswith("application/problem+json"):
|
|
42
|
+
body = resp.json()
|
|
43
|
+
else:
|
|
44
|
+
body = {}
|
|
45
|
+
raise CoreCruxError(
|
|
46
|
+
resp.status_code,
|
|
47
|
+
body.get("detail", resp.text),
|
|
48
|
+
body.get("type", ""),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_json(resp: httpx.Response) -> dict[str, Any]:
|
|
53
|
+
_raise_for_status(resp)
|
|
54
|
+
if not resp.content:
|
|
55
|
+
return {}
|
|
56
|
+
return resp.json()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _to_fact(d: dict[str, Any]) -> Fact:
|
|
60
|
+
return Fact(
|
|
61
|
+
fact_id=d["fact_id"],
|
|
62
|
+
entity=d["entity"],
|
|
63
|
+
key=d["key"],
|
|
64
|
+
value=d["value"],
|
|
65
|
+
confidence=d["confidence"],
|
|
66
|
+
stored_at=d["stored_at"],
|
|
67
|
+
tokens=d["tokens"],
|
|
68
|
+
deleted=d["deleted"],
|
|
69
|
+
version=d.get("version", 1),
|
|
70
|
+
source_receipt=d.get("source_receipt"),
|
|
71
|
+
supersedes=d.get("supersedes"),
|
|
72
|
+
private=d.get("private", False),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _to_session(d: dict[str, Any]) -> SessionState:
|
|
77
|
+
return SessionState(
|
|
78
|
+
session_id=d["session_id"],
|
|
79
|
+
state=d["state"],
|
|
80
|
+
updated_at=d["updated_at"],
|
|
81
|
+
total_tokens=d["total_tokens"],
|
|
82
|
+
expires_at=d.get("expires_at"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _to_text_search_result(d: dict[str, Any]) -> TextSearchResult:
|
|
87
|
+
hits = [
|
|
88
|
+
TextSearchHit(
|
|
89
|
+
segment_index=h["segment_index"],
|
|
90
|
+
doc_id=h["doc_id"],
|
|
91
|
+
score=h["score"],
|
|
92
|
+
frame_offset=h["frame_offset"],
|
|
93
|
+
token_count=h["token_count"],
|
|
94
|
+
)
|
|
95
|
+
for h in d.get("results", [])
|
|
96
|
+
]
|
|
97
|
+
cov_raw = d.get("coverage", {})
|
|
98
|
+
coverage = TextSearchCoverage(
|
|
99
|
+
score=cov_raw.get("score", 0.0),
|
|
100
|
+
gaps=cov_raw.get("gaps", []),
|
|
101
|
+
below_floor=cov_raw.get("below_floor", 0),
|
|
102
|
+
)
|
|
103
|
+
meta_raw = d.get("meta", {})
|
|
104
|
+
meta = TextSearchMeta(
|
|
105
|
+
backend=meta_raw.get("backend", ""),
|
|
106
|
+
took_ms=meta_raw.get("took_ms", 0),
|
|
107
|
+
segments_searched=meta_raw.get("segments_searched", 0),
|
|
108
|
+
total_docs=meta_raw.get("total_docs", 0),
|
|
109
|
+
total_candidates=meta_raw.get("total_candidates", 0),
|
|
110
|
+
)
|
|
111
|
+
return TextSearchResult(
|
|
112
|
+
results=hits,
|
|
113
|
+
coverage=coverage,
|
|
114
|
+
meta=meta,
|
|
115
|
+
tokens_used=d.get("tokens_used"),
|
|
116
|
+
tokens_available=d.get("tokens_available"),
|
|
117
|
+
results_omitted=d.get("results_omitted"),
|
|
118
|
+
scan_mode=d.get("scan_mode", False),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _store_fact_payload(fact: StoreFact) -> dict[str, Any]:
|
|
123
|
+
d: dict[str, Any] = {
|
|
124
|
+
"entity": fact.entity,
|
|
125
|
+
"key": fact.key,
|
|
126
|
+
"value": fact.value,
|
|
127
|
+
"confidence": fact.confidence,
|
|
128
|
+
"private": fact.private,
|
|
129
|
+
}
|
|
130
|
+
if fact.source_receipt is not None:
|
|
131
|
+
d["source_receipt"] = fact.source_receipt
|
|
132
|
+
return d
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Synchronous client
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
class CoreCruxClient:
|
|
140
|
+
"""Synchronous CoreCrux HTTP client.
|
|
141
|
+
|
|
142
|
+
Usage::
|
|
143
|
+
|
|
144
|
+
with CoreCruxClient("http://localhost:14800", token="...") as client:
|
|
145
|
+
info = client.healthz()
|
|
146
|
+
fact = client.store_fact(StoreFact(entity="user", key="name", value="Alice"))
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
base_url: str = "http://localhost:14800",
|
|
152
|
+
token: str | None = None,
|
|
153
|
+
*,
|
|
154
|
+
timeout: float = 30.0,
|
|
155
|
+
):
|
|
156
|
+
self._client = httpx.Client(
|
|
157
|
+
base_url=base_url,
|
|
158
|
+
headers=_headers(token),
|
|
159
|
+
timeout=timeout,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# -- context manager --
|
|
163
|
+
|
|
164
|
+
def __enter__(self) -> CoreCruxClient:
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def __exit__(self, *args: object) -> None:
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
def close(self) -> None:
|
|
171
|
+
"""Close the underlying HTTP connection pool."""
|
|
172
|
+
self._client.close()
|
|
173
|
+
|
|
174
|
+
# -- internal --
|
|
175
|
+
|
|
176
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
177
|
+
resp = self._client.request(method, path, **kwargs)
|
|
178
|
+
return _parse_json(resp)
|
|
179
|
+
|
|
180
|
+
# -- health --
|
|
181
|
+
|
|
182
|
+
def healthz(self) -> dict[str, Any]:
|
|
183
|
+
"""GET /healthz -- node health status."""
|
|
184
|
+
return self._request("GET", "/healthz")
|
|
185
|
+
|
|
186
|
+
def readyz(self) -> dict[str, Any]:
|
|
187
|
+
"""GET /readyz -- node readiness checks."""
|
|
188
|
+
return self._request("GET", "/readyz")
|
|
189
|
+
|
|
190
|
+
def version(self) -> dict[str, Any]:
|
|
191
|
+
"""GET /v1/version -- build version and feature flags."""
|
|
192
|
+
return self._request("GET", "/v1/version")
|
|
193
|
+
|
|
194
|
+
# -- facts --
|
|
195
|
+
|
|
196
|
+
def store_fact(self, fact: StoreFact) -> Fact:
|
|
197
|
+
"""PUT /v1/facts -- create or update a single fact."""
|
|
198
|
+
data = self._request("PUT", "/v1/facts", json=_store_fact_payload(fact))
|
|
199
|
+
return _to_fact(data)
|
|
200
|
+
|
|
201
|
+
def store_facts(self, facts: list[StoreFact]) -> list[Fact]:
|
|
202
|
+
"""PUT /v1/facts/bulk -- create multiple facts at once."""
|
|
203
|
+
payload = [_store_fact_payload(f) for f in facts]
|
|
204
|
+
data = self._request("PUT", "/v1/facts/bulk", json=payload)
|
|
205
|
+
return [_to_fact(f) for f in data.get("facts", [])]
|
|
206
|
+
|
|
207
|
+
def get_fact(self, fact_id: str) -> Fact | None:
|
|
208
|
+
"""GET /v1/facts/{factId} -- retrieve a fact by ID.
|
|
209
|
+
|
|
210
|
+
Returns ``None`` if the fact does not exist (404).
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
data = self._request("GET", f"/v1/facts/{fact_id}")
|
|
214
|
+
return _to_fact(data)
|
|
215
|
+
except CoreCruxError as exc:
|
|
216
|
+
if exc.status_code == 404:
|
|
217
|
+
return None
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
def delete_fact(self, fact_id: str) -> bool:
|
|
221
|
+
"""DELETE /v1/facts/{factId} -- soft-delete a fact.
|
|
222
|
+
|
|
223
|
+
Returns ``True`` if deleted, ``False`` if the fact was not found.
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
data = self._request("DELETE", f"/v1/facts/{fact_id}")
|
|
227
|
+
return data.get("deleted", False)
|
|
228
|
+
except CoreCruxError as exc:
|
|
229
|
+
if exc.status_code == 404:
|
|
230
|
+
return False
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
def get_facts_by_entity(self, entity: str) -> list[Fact]:
|
|
234
|
+
"""GET /v1/facts/entity/{entity} -- list all facts for an entity."""
|
|
235
|
+
data = self._request("GET", f"/v1/facts/entity/{entity}")
|
|
236
|
+
return [_to_fact(f) for f in data.get("facts", [])]
|
|
237
|
+
|
|
238
|
+
def query_facts(
|
|
239
|
+
self,
|
|
240
|
+
query: str | None = None,
|
|
241
|
+
*,
|
|
242
|
+
entity: str | None = None,
|
|
243
|
+
entity_prefix: str | None = None,
|
|
244
|
+
top_k: int | None = None,
|
|
245
|
+
token_budget: int | None = None,
|
|
246
|
+
) -> FactQueryResult:
|
|
247
|
+
"""GET /v1/facts -- query facts with BM25 text search and filters."""
|
|
248
|
+
params: dict[str, Any] = {}
|
|
249
|
+
if query is not None:
|
|
250
|
+
params["query"] = query
|
|
251
|
+
if entity is not None:
|
|
252
|
+
params["entity"] = entity
|
|
253
|
+
if entity_prefix is not None:
|
|
254
|
+
params["entity_prefix"] = entity_prefix
|
|
255
|
+
if top_k is not None:
|
|
256
|
+
params["top_k"] = top_k
|
|
257
|
+
if token_budget is not None:
|
|
258
|
+
params["token_budget"] = token_budget
|
|
259
|
+
data = self._request("GET", "/v1/facts", params=params)
|
|
260
|
+
return FactQueryResult(
|
|
261
|
+
facts=[_to_fact(f) for f in data.get("facts", [])],
|
|
262
|
+
total_tokens=data.get("total_tokens", 0),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def export_facts(
|
|
266
|
+
self,
|
|
267
|
+
*,
|
|
268
|
+
since: str | None = None,
|
|
269
|
+
cursor: str | None = None,
|
|
270
|
+
limit: int | None = None,
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
"""GET /v1/facts/export -- paginated fact export (including tombstones)."""
|
|
273
|
+
params: dict[str, Any] = {}
|
|
274
|
+
if since is not None:
|
|
275
|
+
params["since"] = since
|
|
276
|
+
if cursor is not None:
|
|
277
|
+
params["cursor"] = cursor
|
|
278
|
+
if limit is not None:
|
|
279
|
+
params["limit"] = limit
|
|
280
|
+
return self._request("GET", "/v1/facts/export", params=params)
|
|
281
|
+
|
|
282
|
+
# -- sessions --
|
|
283
|
+
|
|
284
|
+
def put_session(self, session_id: str, state: dict[str, Any]) -> SessionState:
|
|
285
|
+
"""PUT /v1/sessions/{sessionId}/state -- store session state."""
|
|
286
|
+
data = self._request("PUT", f"/v1/sessions/{session_id}/state", json=state)
|
|
287
|
+
return _to_session(data)
|
|
288
|
+
|
|
289
|
+
def get_session(self, session_id: str) -> SessionState | None:
|
|
290
|
+
"""GET /v1/sessions/{sessionId}/state -- retrieve session state.
|
|
291
|
+
|
|
292
|
+
Returns ``None`` if the session does not exist (404).
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
data = self._request("GET", f"/v1/sessions/{session_id}/state")
|
|
296
|
+
return _to_session(data)
|
|
297
|
+
except CoreCruxError as exc:
|
|
298
|
+
if exc.status_code == 404:
|
|
299
|
+
return None
|
|
300
|
+
raise
|
|
301
|
+
|
|
302
|
+
# -- query --
|
|
303
|
+
|
|
304
|
+
def text_search(
|
|
305
|
+
self,
|
|
306
|
+
tenant_id: str,
|
|
307
|
+
query: str,
|
|
308
|
+
*,
|
|
309
|
+
limit: int = 10,
|
|
310
|
+
token_budget: int | None = None,
|
|
311
|
+
min_score: float | None = None,
|
|
312
|
+
mode: str | None = None,
|
|
313
|
+
) -> TextSearchResult:
|
|
314
|
+
"""POST /v1/query/text-search -- BM25 full-text search over segments."""
|
|
315
|
+
body: dict[str, Any] = {
|
|
316
|
+
"tenant_id": tenant_id,
|
|
317
|
+
"query": query,
|
|
318
|
+
"limit": limit,
|
|
319
|
+
}
|
|
320
|
+
if token_budget is not None:
|
|
321
|
+
body["token_budget"] = token_budget
|
|
322
|
+
if min_score is not None:
|
|
323
|
+
body["min_score"] = min_score
|
|
324
|
+
if mode is not None:
|
|
325
|
+
body["mode"] = mode
|
|
326
|
+
data = self._request("POST", "/v1/query/text-search", json=body)
|
|
327
|
+
return _to_text_search_result(data)
|
|
328
|
+
|
|
329
|
+
def text_search_expand(
|
|
330
|
+
self,
|
|
331
|
+
tenant_id: str,
|
|
332
|
+
result_ids: list[dict[str, int]],
|
|
333
|
+
) -> dict[str, Any]:
|
|
334
|
+
"""POST /v1/query/text-search/expand -- expand scan-mode results.
|
|
335
|
+
|
|
336
|
+
``result_ids`` should be a list of dicts with ``segment_index`` and ``doc_id`` keys.
|
|
337
|
+
"""
|
|
338
|
+
body: dict[str, Any] = {
|
|
339
|
+
"tenant_id": tenant_id,
|
|
340
|
+
"result_ids": result_ids,
|
|
341
|
+
}
|
|
342
|
+
return self._request("POST", "/v1/query/text-search/expand", json=body)
|
|
343
|
+
|
|
344
|
+
def graph_expand(
|
|
345
|
+
self,
|
|
346
|
+
tenant_id: str,
|
|
347
|
+
seed_artifact_ids: list[int],
|
|
348
|
+
*,
|
|
349
|
+
edge_types: list[str] | None = None,
|
|
350
|
+
max_hops: int = 2,
|
|
351
|
+
budget: int = 50,
|
|
352
|
+
min_confidence: float = 0.0,
|
|
353
|
+
include_state: bool = False,
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
"""POST /v1/query/graph-expand -- traverse the artifact relation graph."""
|
|
356
|
+
body: dict[str, Any] = {
|
|
357
|
+
"tenant_id": tenant_id,
|
|
358
|
+
"seed_artifact_ids": seed_artifact_ids,
|
|
359
|
+
"max_hops": max_hops,
|
|
360
|
+
"budget": budget,
|
|
361
|
+
"min_confidence": min_confidence,
|
|
362
|
+
"include_state": include_state,
|
|
363
|
+
}
|
|
364
|
+
if edge_types is not None:
|
|
365
|
+
body["edge_types"] = edge_types
|
|
366
|
+
return self._request("POST", "/v1/query/graph-expand", json=body)
|
|
367
|
+
|
|
368
|
+
def time_range(
|
|
369
|
+
self,
|
|
370
|
+
tenant_id: str,
|
|
371
|
+
start_micros: int,
|
|
372
|
+
end_micros: int,
|
|
373
|
+
*,
|
|
374
|
+
artifact_ids: list[int] | None = None,
|
|
375
|
+
include_relations: bool = False,
|
|
376
|
+
limit: int = 100,
|
|
377
|
+
) -> dict[str, Any]:
|
|
378
|
+
"""POST /v1/query/time-range -- query artifacts changed within a time window."""
|
|
379
|
+
body: dict[str, Any] = {
|
|
380
|
+
"tenant_id": tenant_id,
|
|
381
|
+
"start_micros": start_micros,
|
|
382
|
+
"end_micros": end_micros,
|
|
383
|
+
"include_relations": include_relations,
|
|
384
|
+
"limit": limit,
|
|
385
|
+
}
|
|
386
|
+
if artifact_ids is not None:
|
|
387
|
+
body["artifact_ids"] = artifact_ids
|
|
388
|
+
return self._request("POST", "/v1/query/time-range", json=body)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
# Asynchronous client
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
class AsyncCoreCruxClient:
|
|
396
|
+
"""Asynchronous CoreCrux HTTP client (uses ``httpx.AsyncClient``).
|
|
397
|
+
|
|
398
|
+
Usage::
|
|
399
|
+
|
|
400
|
+
async with AsyncCoreCruxClient("http://localhost:14800", token="...") as client:
|
|
401
|
+
info = await client.healthz()
|
|
402
|
+
fact = await client.store_fact(StoreFact(entity="user", key="name", value="Alice"))
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(
|
|
406
|
+
self,
|
|
407
|
+
base_url: str = "http://localhost:14800",
|
|
408
|
+
token: str | None = None,
|
|
409
|
+
*,
|
|
410
|
+
timeout: float = 30.0,
|
|
411
|
+
):
|
|
412
|
+
self._client = httpx.AsyncClient(
|
|
413
|
+
base_url=base_url,
|
|
414
|
+
headers=_headers(token),
|
|
415
|
+
timeout=timeout,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# -- context manager --
|
|
419
|
+
|
|
420
|
+
async def __aenter__(self) -> AsyncCoreCruxClient:
|
|
421
|
+
return self
|
|
422
|
+
|
|
423
|
+
async def __aexit__(self, *args: object) -> None:
|
|
424
|
+
await self.close()
|
|
425
|
+
|
|
426
|
+
async def close(self) -> None:
|
|
427
|
+
"""Close the underlying HTTP connection pool."""
|
|
428
|
+
await self._client.aclose()
|
|
429
|
+
|
|
430
|
+
# -- internal --
|
|
431
|
+
|
|
432
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
433
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
434
|
+
return _parse_json(resp)
|
|
435
|
+
|
|
436
|
+
# -- health --
|
|
437
|
+
|
|
438
|
+
async def healthz(self) -> dict[str, Any]:
|
|
439
|
+
"""GET /healthz -- node health status."""
|
|
440
|
+
return await self._request("GET", "/healthz")
|
|
441
|
+
|
|
442
|
+
async def readyz(self) -> dict[str, Any]:
|
|
443
|
+
"""GET /readyz -- node readiness checks."""
|
|
444
|
+
return await self._request("GET", "/readyz")
|
|
445
|
+
|
|
446
|
+
async def version(self) -> dict[str, Any]:
|
|
447
|
+
"""GET /v1/version -- build version and feature flags."""
|
|
448
|
+
return await self._request("GET", "/v1/version")
|
|
449
|
+
|
|
450
|
+
# -- facts --
|
|
451
|
+
|
|
452
|
+
async def store_fact(self, fact: StoreFact) -> Fact:
|
|
453
|
+
"""PUT /v1/facts -- create or update a single fact."""
|
|
454
|
+
data = await self._request("PUT", "/v1/facts", json=_store_fact_payload(fact))
|
|
455
|
+
return _to_fact(data)
|
|
456
|
+
|
|
457
|
+
async def store_facts(self, facts: list[StoreFact]) -> list[Fact]:
|
|
458
|
+
"""PUT /v1/facts/bulk -- create multiple facts at once."""
|
|
459
|
+
payload = [_store_fact_payload(f) for f in facts]
|
|
460
|
+
data = await self._request("PUT", "/v1/facts/bulk", json=payload)
|
|
461
|
+
return [_to_fact(f) for f in data.get("facts", [])]
|
|
462
|
+
|
|
463
|
+
async def get_fact(self, fact_id: str) -> Fact | None:
|
|
464
|
+
"""GET /v1/facts/{factId} -- retrieve a fact by ID."""
|
|
465
|
+
try:
|
|
466
|
+
data = await self._request("GET", f"/v1/facts/{fact_id}")
|
|
467
|
+
return _to_fact(data)
|
|
468
|
+
except CoreCruxError as exc:
|
|
469
|
+
if exc.status_code == 404:
|
|
470
|
+
return None
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
async def delete_fact(self, fact_id: str) -> bool:
|
|
474
|
+
"""DELETE /v1/facts/{factId} -- soft-delete a fact."""
|
|
475
|
+
try:
|
|
476
|
+
data = await self._request("DELETE", f"/v1/facts/{fact_id}")
|
|
477
|
+
return data.get("deleted", False)
|
|
478
|
+
except CoreCruxError as exc:
|
|
479
|
+
if exc.status_code == 404:
|
|
480
|
+
return False
|
|
481
|
+
raise
|
|
482
|
+
|
|
483
|
+
async def get_facts_by_entity(self, entity: str) -> list[Fact]:
|
|
484
|
+
"""GET /v1/facts/entity/{entity} -- list all facts for an entity."""
|
|
485
|
+
data = await self._request("GET", f"/v1/facts/entity/{entity}")
|
|
486
|
+
return [_to_fact(f) for f in data.get("facts", [])]
|
|
487
|
+
|
|
488
|
+
async def query_facts(
|
|
489
|
+
self,
|
|
490
|
+
query: str | None = None,
|
|
491
|
+
*,
|
|
492
|
+
entity: str | None = None,
|
|
493
|
+
entity_prefix: str | None = None,
|
|
494
|
+
top_k: int | None = None,
|
|
495
|
+
token_budget: int | None = None,
|
|
496
|
+
) -> FactQueryResult:
|
|
497
|
+
"""GET /v1/facts -- query facts with BM25 text search and filters."""
|
|
498
|
+
params: dict[str, Any] = {}
|
|
499
|
+
if query is not None:
|
|
500
|
+
params["query"] = query
|
|
501
|
+
if entity is not None:
|
|
502
|
+
params["entity"] = entity
|
|
503
|
+
if entity_prefix is not None:
|
|
504
|
+
params["entity_prefix"] = entity_prefix
|
|
505
|
+
if top_k is not None:
|
|
506
|
+
params["top_k"] = top_k
|
|
507
|
+
if token_budget is not None:
|
|
508
|
+
params["token_budget"] = token_budget
|
|
509
|
+
data = await self._request("GET", "/v1/facts", params=params)
|
|
510
|
+
return FactQueryResult(
|
|
511
|
+
facts=[_to_fact(f) for f in data.get("facts", [])],
|
|
512
|
+
total_tokens=data.get("total_tokens", 0),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
async def export_facts(
|
|
516
|
+
self,
|
|
517
|
+
*,
|
|
518
|
+
since: str | None = None,
|
|
519
|
+
cursor: str | None = None,
|
|
520
|
+
limit: int | None = None,
|
|
521
|
+
) -> dict[str, Any]:
|
|
522
|
+
"""GET /v1/facts/export -- paginated fact export (including tombstones)."""
|
|
523
|
+
params: dict[str, Any] = {}
|
|
524
|
+
if since is not None:
|
|
525
|
+
params["since"] = since
|
|
526
|
+
if cursor is not None:
|
|
527
|
+
params["cursor"] = cursor
|
|
528
|
+
if limit is not None:
|
|
529
|
+
params["limit"] = limit
|
|
530
|
+
return await self._request("GET", "/v1/facts/export", params=params)
|
|
531
|
+
|
|
532
|
+
# -- sessions --
|
|
533
|
+
|
|
534
|
+
async def put_session(self, session_id: str, state: dict[str, Any]) -> SessionState:
|
|
535
|
+
"""PUT /v1/sessions/{sessionId}/state -- store session state."""
|
|
536
|
+
data = await self._request("PUT", f"/v1/sessions/{session_id}/state", json=state)
|
|
537
|
+
return _to_session(data)
|
|
538
|
+
|
|
539
|
+
async def get_session(self, session_id: str) -> SessionState | None:
|
|
540
|
+
"""GET /v1/sessions/{sessionId}/state -- retrieve session state."""
|
|
541
|
+
try:
|
|
542
|
+
data = await self._request("GET", f"/v1/sessions/{session_id}/state")
|
|
543
|
+
return _to_session(data)
|
|
544
|
+
except CoreCruxError as exc:
|
|
545
|
+
if exc.status_code == 404:
|
|
546
|
+
return None
|
|
547
|
+
raise
|
|
548
|
+
|
|
549
|
+
# -- query --
|
|
550
|
+
|
|
551
|
+
async def text_search(
|
|
552
|
+
self,
|
|
553
|
+
tenant_id: str,
|
|
554
|
+
query: str,
|
|
555
|
+
*,
|
|
556
|
+
limit: int = 10,
|
|
557
|
+
token_budget: int | None = None,
|
|
558
|
+
min_score: float | None = None,
|
|
559
|
+
mode: str | None = None,
|
|
560
|
+
) -> TextSearchResult:
|
|
561
|
+
"""POST /v1/query/text-search -- BM25 full-text search over segments."""
|
|
562
|
+
body: dict[str, Any] = {
|
|
563
|
+
"tenant_id": tenant_id,
|
|
564
|
+
"query": query,
|
|
565
|
+
"limit": limit,
|
|
566
|
+
}
|
|
567
|
+
if token_budget is not None:
|
|
568
|
+
body["token_budget"] = token_budget
|
|
569
|
+
if min_score is not None:
|
|
570
|
+
body["min_score"] = min_score
|
|
571
|
+
if mode is not None:
|
|
572
|
+
body["mode"] = mode
|
|
573
|
+
data = await self._request("POST", "/v1/query/text-search", json=body)
|
|
574
|
+
return _to_text_search_result(data)
|
|
575
|
+
|
|
576
|
+
async def text_search_expand(
|
|
577
|
+
self,
|
|
578
|
+
tenant_id: str,
|
|
579
|
+
result_ids: list[dict[str, int]],
|
|
580
|
+
) -> dict[str, Any]:
|
|
581
|
+
"""POST /v1/query/text-search/expand -- expand scan-mode results."""
|
|
582
|
+
body: dict[str, Any] = {
|
|
583
|
+
"tenant_id": tenant_id,
|
|
584
|
+
"result_ids": result_ids,
|
|
585
|
+
}
|
|
586
|
+
return await self._request("POST", "/v1/query/text-search/expand", json=body)
|
|
587
|
+
|
|
588
|
+
async def graph_expand(
|
|
589
|
+
self,
|
|
590
|
+
tenant_id: str,
|
|
591
|
+
seed_artifact_ids: list[int],
|
|
592
|
+
*,
|
|
593
|
+
edge_types: list[str] | None = None,
|
|
594
|
+
max_hops: int = 2,
|
|
595
|
+
budget: int = 50,
|
|
596
|
+
min_confidence: float = 0.0,
|
|
597
|
+
include_state: bool = False,
|
|
598
|
+
) -> dict[str, Any]:
|
|
599
|
+
"""POST /v1/query/graph-expand -- traverse the artifact relation graph."""
|
|
600
|
+
body: dict[str, Any] = {
|
|
601
|
+
"tenant_id": tenant_id,
|
|
602
|
+
"seed_artifact_ids": seed_artifact_ids,
|
|
603
|
+
"max_hops": max_hops,
|
|
604
|
+
"budget": budget,
|
|
605
|
+
"min_confidence": min_confidence,
|
|
606
|
+
"include_state": include_state,
|
|
607
|
+
}
|
|
608
|
+
if edge_types is not None:
|
|
609
|
+
body["edge_types"] = edge_types
|
|
610
|
+
return await self._request("POST", "/v1/query/graph-expand", json=body)
|
|
611
|
+
|
|
612
|
+
async def time_range(
|
|
613
|
+
self,
|
|
614
|
+
tenant_id: str,
|
|
615
|
+
start_micros: int,
|
|
616
|
+
end_micros: int,
|
|
617
|
+
*,
|
|
618
|
+
artifact_ids: list[int] | None = None,
|
|
619
|
+
include_relations: bool = False,
|
|
620
|
+
limit: int = 100,
|
|
621
|
+
) -> dict[str, Any]:
|
|
622
|
+
"""POST /v1/query/time-range -- query artifacts changed within a time window."""
|
|
623
|
+
body: dict[str, Any] = {
|
|
624
|
+
"tenant_id": tenant_id,
|
|
625
|
+
"start_micros": start_micros,
|
|
626
|
+
"end_micros": end_micros,
|
|
627
|
+
"include_relations": include_relations,
|
|
628
|
+
"limit": limit,
|
|
629
|
+
}
|
|
630
|
+
if artifact_ids is not None:
|
|
631
|
+
body["artifact_ids"] = artifact_ids
|
|
632
|
+
return await self._request("POST", "/v1/query/time-range", json=body)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright (c) 2026 CueCrux Ltd. All rights reserved.
|
|
2
|
+
# Licensed under the CueCrux Community Licence (CCL v1.0).
|
|
3
|
+
# See LICENCE.md in the repository root.
|
|
4
|
+
|
|
5
|
+
"""CoreCrux error types."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoreCruxError(Exception):
|
|
9
|
+
"""Raised when the CoreCrux API returns a non-2xx response.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
status_code: HTTP status code from the server.
|
|
13
|
+
detail: Human-readable error detail string.
|
|
14
|
+
type: Optional problem type URI (RFC 7807).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, detail: str, type: str = ""):
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.detail = detail
|
|
20
|
+
self.type = type
|
|
21
|
+
super().__init__(f"CoreCrux error {status_code}: {detail}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright (c) 2026 CueCrux Ltd. All rights reserved.
|
|
2
|
+
# Licensed under the CueCrux Community Licence (CCL v1.0).
|
|
3
|
+
# See LICENCE.md in the repository root.
|
|
4
|
+
|
|
5
|
+
"""CoreCrux API data types."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Fact:
|
|
15
|
+
"""A stored fact returned by the CoreCrux API."""
|
|
16
|
+
|
|
17
|
+
fact_id: str
|
|
18
|
+
entity: str
|
|
19
|
+
key: str
|
|
20
|
+
value: str
|
|
21
|
+
confidence: float
|
|
22
|
+
stored_at: str
|
|
23
|
+
tokens: int
|
|
24
|
+
deleted: bool
|
|
25
|
+
version: int
|
|
26
|
+
source_receipt: str | None = None
|
|
27
|
+
supersedes: str | None = None
|
|
28
|
+
private: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class StoreFact:
|
|
33
|
+
"""Payload for creating a new fact via the CoreCrux API."""
|
|
34
|
+
|
|
35
|
+
entity: str
|
|
36
|
+
key: str
|
|
37
|
+
value: str
|
|
38
|
+
confidence: float = 1.0
|
|
39
|
+
private: bool = False
|
|
40
|
+
source_receipt: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TextSearchHit:
|
|
45
|
+
"""A single hit from a text-search query."""
|
|
46
|
+
|
|
47
|
+
segment_index: int
|
|
48
|
+
doc_id: int
|
|
49
|
+
score: float
|
|
50
|
+
frame_offset: int
|
|
51
|
+
token_count: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TextSearchCoverage:
|
|
56
|
+
"""Coverage metadata for a text-search query."""
|
|
57
|
+
|
|
58
|
+
score: float
|
|
59
|
+
gaps: list[dict[str, Any]] = field(default_factory=list)
|
|
60
|
+
below_floor: int = 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class TextSearchMeta:
|
|
65
|
+
"""Execution metadata for a text-search query."""
|
|
66
|
+
|
|
67
|
+
backend: str
|
|
68
|
+
took_ms: int
|
|
69
|
+
segments_searched: int
|
|
70
|
+
total_docs: int
|
|
71
|
+
total_candidates: int = 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class TextSearchResult:
|
|
76
|
+
"""Full response from a text-search query."""
|
|
77
|
+
|
|
78
|
+
results: list[TextSearchHit]
|
|
79
|
+
coverage: TextSearchCoverage
|
|
80
|
+
meta: TextSearchMeta
|
|
81
|
+
tokens_used: int | None = None
|
|
82
|
+
tokens_available: int | None = None
|
|
83
|
+
results_omitted: int | None = None
|
|
84
|
+
scan_mode: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class FactQueryResult:
|
|
89
|
+
"""Response from the query-facts endpoint."""
|
|
90
|
+
|
|
91
|
+
facts: list[Fact]
|
|
92
|
+
total_tokens: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class SessionState:
|
|
97
|
+
"""Stored session state returned by the CoreCrux API."""
|
|
98
|
+
|
|
99
|
+
session_id: str
|
|
100
|
+
state: dict[str, Any]
|
|
101
|
+
updated_at: str
|
|
102
|
+
total_tokens: int
|
|
103
|
+
expires_at: str | None = None
|