beaver-db 2.0rc2__py3-none-any.whl
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.
- beaver/__init__.py +16 -0
- beaver/blobs.py +223 -0
- beaver/bridge.py +167 -0
- beaver/cache.py +274 -0
- beaver/channels.py +249 -0
- beaver/cli/__init__.py +133 -0
- beaver/cli/blobs.py +225 -0
- beaver/cli/channels.py +166 -0
- beaver/cli/collections.py +500 -0
- beaver/cli/dicts.py +171 -0
- beaver/cli/lists.py +244 -0
- beaver/cli/locks.py +202 -0
- beaver/cli/logs.py +248 -0
- beaver/cli/queues.py +215 -0
- beaver/client.py +392 -0
- beaver/core.py +646 -0
- beaver/dicts.py +314 -0
- beaver/docs.py +459 -0
- beaver/events.py +155 -0
- beaver/graphs.py +212 -0
- beaver/lists.py +337 -0
- beaver/locks.py +186 -0
- beaver/logs.py +187 -0
- beaver/manager.py +203 -0
- beaver/queries.py +66 -0
- beaver/queues.py +215 -0
- beaver/security.py +144 -0
- beaver/server.py +452 -0
- beaver/sketches.py +307 -0
- beaver/types.py +32 -0
- beaver/vectors.py +198 -0
- beaver_db-2.0rc2.dist-info/METADATA +149 -0
- beaver_db-2.0rc2.dist-info/RECORD +36 -0
- beaver_db-2.0rc2.dist-info/WHEEL +4 -0
- beaver_db-2.0rc2.dist-info/entry_points.txt +2 -0
- beaver_db-2.0rc2.dist-info/licenses/LICENSE +21 -0
beaver/cli/channels.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typer
|
|
3
|
+
import rich
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from beaver import BeaverDB
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="channel",
|
|
12
|
+
help="Interact with pub/sub channels. (e.g., beaver channel events publish 'msg')",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_db(ctx: typer.Context) -> BeaverDB:
|
|
17
|
+
"""Helper to get the DB instance from the main context."""
|
|
18
|
+
return ctx.find_object(dict)["db"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_value(value: str):
|
|
22
|
+
"""
|
|
23
|
+
Intelligently parses the input string.
|
|
24
|
+
- Tries to parse as JSON if it starts with '{' or '['.
|
|
25
|
+
- Tries to parse as int, then float.
|
|
26
|
+
- Checks for 'true'/'false'/'null'.
|
|
27
|
+
- Defaults to a plain string.
|
|
28
|
+
"""
|
|
29
|
+
# 1. Try JSON object or array
|
|
30
|
+
if value.startswith("{") or value.startswith("["):
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(value)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
# It's not valid JSON, so treat it as a string
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
# 2. Try boolean
|
|
38
|
+
if value.lower() == "true":
|
|
39
|
+
return True
|
|
40
|
+
if value.lower() == "false":
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
# 3. Try null
|
|
44
|
+
if value.lower() == "null":
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
# 4. Try int
|
|
48
|
+
try:
|
|
49
|
+
return int(value)
|
|
50
|
+
except ValueError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# 5. Try float
|
|
54
|
+
try:
|
|
55
|
+
return float(value)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# 6. Default to string (remove quotes if user added them)
|
|
60
|
+
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
|
61
|
+
return value[1:-1]
|
|
62
|
+
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.callback(invoke_without_command=True)
|
|
67
|
+
def channel_main(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
name: Annotated[
|
|
70
|
+
Optional[str], typer.Argument(help="The name of the channel to interact with.")
|
|
71
|
+
] = None,
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
Manage pub/sub channels.
|
|
75
|
+
|
|
76
|
+
If no name is provided, lists all channels that have a message history.
|
|
77
|
+
"""
|
|
78
|
+
db = _get_db(ctx)
|
|
79
|
+
|
|
80
|
+
if name is None:
|
|
81
|
+
# No name given, so list all channels
|
|
82
|
+
rich.print("[bold]Available Channels (with history):[/bold]")
|
|
83
|
+
try:
|
|
84
|
+
channel_names = db.channels
|
|
85
|
+
if not channel_names:
|
|
86
|
+
rich.print(" (No channels with messages found in log)")
|
|
87
|
+
else:
|
|
88
|
+
for channel_name in channel_names:
|
|
89
|
+
rich.print(f" • {channel_name}")
|
|
90
|
+
rich.print(
|
|
91
|
+
"\n[bold]Usage:[/bold] beaver channel [bold]<NAME>[/bold] [COMMAND]"
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
except Exception as e:
|
|
95
|
+
rich.print(f"[bold red]Error querying channels:[/] {e}")
|
|
96
|
+
raise typer.Exit(code=1)
|
|
97
|
+
|
|
98
|
+
# A name was provided, store it in the context for subcommands
|
|
99
|
+
ctx.obj = {"name": name, "db": db}
|
|
100
|
+
|
|
101
|
+
if ctx.invoked_subcommand is None:
|
|
102
|
+
# A name was given, but no command
|
|
103
|
+
rich.print(f"Channel '[bold]{name}[/bold]'.")
|
|
104
|
+
rich.print("\n[bold]Commands:[/bold]")
|
|
105
|
+
rich.print(" publish, listen")
|
|
106
|
+
rich.print(
|
|
107
|
+
f"\nRun [bold]beaver channel {name} --help[/bold] for command-specific options."
|
|
108
|
+
)
|
|
109
|
+
raise typer.Exit()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def publish(
|
|
114
|
+
ctx: typer.Context,
|
|
115
|
+
message: Annotated[
|
|
116
|
+
str, typer.Argument(help="The message to publish (JSON, string, number, etc.).")
|
|
117
|
+
],
|
|
118
|
+
):
|
|
119
|
+
"""
|
|
120
|
+
Publish a message to the channel.
|
|
121
|
+
"""
|
|
122
|
+
db = ctx.obj["db"]
|
|
123
|
+
name = ctx.obj["name"]
|
|
124
|
+
try:
|
|
125
|
+
parsed_message = _parse_value(message)
|
|
126
|
+
db.channel(name).publish(parsed_message)
|
|
127
|
+
rich.print(f"[green]Success:[/] Message published to channel '{name}'.")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
130
|
+
raise typer.Exit(code=1)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def listen(ctx: typer.Context):
|
|
135
|
+
"""
|
|
136
|
+
Listen for new messages on the channel in real-time.
|
|
137
|
+
"""
|
|
138
|
+
db = ctx.obj["db"]
|
|
139
|
+
name = ctx.obj["name"]
|
|
140
|
+
|
|
141
|
+
rich.print(
|
|
142
|
+
f"[cyan]Listening to channel '[bold]{name}[/bold]'... Press Ctrl+C to stop.[/cyan]"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
with db.channel(name).subscribe() as listener:
|
|
147
|
+
for message in listener.listen():
|
|
148
|
+
now = datetime.now().strftime("%H:%M:%S")
|
|
149
|
+
|
|
150
|
+
if isinstance(message, (dict, list)):
|
|
151
|
+
message_str = json.dumps(message)
|
|
152
|
+
rich.print(
|
|
153
|
+
f"[dim]{now}[/dim] [bold yellow]►[/bold yellow] {message_str}"
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
message_str = str(message)
|
|
157
|
+
rich.print(
|
|
158
|
+
f"[dim]{now}[/dim] [bold yellow]►[/bold yellow] {message_str}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
except KeyboardInterrupt:
|
|
162
|
+
rich.print("\n[cyan]Stopping listener...[/cyan]")
|
|
163
|
+
raise typer.Exit()
|
|
164
|
+
except Exception as e:
|
|
165
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
166
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import typer
|
|
4
|
+
import rich
|
|
5
|
+
import rich.table
|
|
6
|
+
from typing_extensions import Annotated
|
|
7
|
+
from typing import Optional, List, Any
|
|
8
|
+
|
|
9
|
+
# Import Document and WalkDirection for graph commands
|
|
10
|
+
from beaver import BeaverDB, Document
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="collection",
|
|
14
|
+
help="Interact with document collections (vector, text, graph). (e.g., beaver collection articles match 'python')",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# --- Helper Functions ---
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_db(ctx: typer.Context) -> BeaverDB:
|
|
21
|
+
"""Helper to get the DB instance from the main context."""
|
|
22
|
+
return ctx.find_object(dict)["db"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_json_or_file(input_str: str) -> Any:
|
|
26
|
+
"""
|
|
27
|
+
Tries to read as a file path. If that fails, tries to parse as JSON.
|
|
28
|
+
If both fail, raises an error.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
if os.path.exists(input_str):
|
|
32
|
+
with open(input_str, "r") as f:
|
|
33
|
+
return json.load(f)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass # Not a valid file path, or can't be read
|
|
36
|
+
|
|
37
|
+
# Not a file, try to parse as JSON string
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(input_str)
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
# Re-try by wrapping in quotes, in case it's a plain string
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(f'"{input_str}"')
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
raise typer.BadParameter(
|
|
46
|
+
f"Input '{input_str}' is not a valid file path or a valid JSON string."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _truncate(text: str, length: int = 100) -> str:
|
|
51
|
+
"""Truncates a string and adds an ellipsis if needed."""
|
|
52
|
+
if len(text) > length:
|
|
53
|
+
return text[:length] + "..."
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- Main Callback and Commands ---
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.callback(invoke_without_command=True)
|
|
61
|
+
def collection_main(
|
|
62
|
+
ctx: typer.Context,
|
|
63
|
+
name: Annotated[
|
|
64
|
+
Optional[str],
|
|
65
|
+
typer.Argument(help="The name of the collection to interact with."),
|
|
66
|
+
] = None,
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Manage document collections.
|
|
70
|
+
|
|
71
|
+
If no name is provided, lists all available collections.
|
|
72
|
+
"""
|
|
73
|
+
db = _get_db(ctx)
|
|
74
|
+
|
|
75
|
+
if name is None:
|
|
76
|
+
# No name given, so list all collections
|
|
77
|
+
rich.print("[bold]Available Collections:[/bold]")
|
|
78
|
+
try:
|
|
79
|
+
collection_names = db.collections
|
|
80
|
+
if not collection_names:
|
|
81
|
+
rich.print(" (No collections found)")
|
|
82
|
+
else:
|
|
83
|
+
for col_name in collection_names:
|
|
84
|
+
rich.print(f" • {col_name}")
|
|
85
|
+
rich.print(
|
|
86
|
+
"\n[bold]Usage:[/bold] beaver collection [bold]<NAME>[/bold] [COMMAND]"
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
except Exception as e:
|
|
90
|
+
rich.print(f"[bold red]Error querying collections:[/] {e}")
|
|
91
|
+
raise typer.Exit(code=1)
|
|
92
|
+
|
|
93
|
+
# A name was provided, store it in the context for subcommands
|
|
94
|
+
ctx.obj = {"name": name, "db": db}
|
|
95
|
+
|
|
96
|
+
if ctx.invoked_subcommand is None:
|
|
97
|
+
# A name was given, but no command
|
|
98
|
+
try:
|
|
99
|
+
count = len(db.collection(name))
|
|
100
|
+
rich.print(f"Collection '[bold]{name}[/bold]' contains {count} documents.")
|
|
101
|
+
rich.print("\n[bold]Commands:[/bold]")
|
|
102
|
+
rich.print(
|
|
103
|
+
" get, index, drop, items, match, search, connect, neighboors, walk, dump"
|
|
104
|
+
)
|
|
105
|
+
rich.print(
|
|
106
|
+
f"\nRun [bold]beaver collection {name} --help[/bold] for command-specific options."
|
|
107
|
+
)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
110
|
+
raise typer.Exit(code=1)
|
|
111
|
+
raise typer.Exit()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def get(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
doc_id: Annotated[str, typer.Argument(help="The ID of the document to retrieve.")],
|
|
118
|
+
):
|
|
119
|
+
"""
|
|
120
|
+
Get and print a single document by its ID.
|
|
121
|
+
"""
|
|
122
|
+
db = ctx.obj["db"]
|
|
123
|
+
name = ctx.obj["name"]
|
|
124
|
+
try:
|
|
125
|
+
# The CollectionManager doesn't have a .get(), so we query manually.
|
|
126
|
+
cursor = db.connection.cursor()
|
|
127
|
+
cursor.execute(
|
|
128
|
+
"SELECT item_id, item_vector, metadata FROM beaver_collections WHERE collection = ? AND item_id = ?",
|
|
129
|
+
(name, doc_id),
|
|
130
|
+
)
|
|
131
|
+
row = cursor.fetchone()
|
|
132
|
+
cursor.close()
|
|
133
|
+
|
|
134
|
+
if row is None:
|
|
135
|
+
rich.print(f"[bold red]Error:[/] Document not found: '{doc_id}'")
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
|
|
138
|
+
# Reconstruct the full document for printing
|
|
139
|
+
doc_data = json.loads(row["metadata"])
|
|
140
|
+
doc_data["id"] = row["item_id"]
|
|
141
|
+
|
|
142
|
+
# Handle vector decoding if numpy is available
|
|
143
|
+
try:
|
|
144
|
+
import numpy as np
|
|
145
|
+
|
|
146
|
+
doc_data["embedding"] = (
|
|
147
|
+
list(map(float, np.frombuffer(row["item_vector"], dtype=np.float32)))
|
|
148
|
+
if row["item_vector"]
|
|
149
|
+
else None
|
|
150
|
+
)
|
|
151
|
+
except ImportError:
|
|
152
|
+
doc_data["embedding"] = "N/A (requires numpy)"
|
|
153
|
+
|
|
154
|
+
rich.print_json(data=doc_data)
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
158
|
+
raise typer.Exit(code=1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def index(
|
|
163
|
+
ctx: typer.Context,
|
|
164
|
+
json_or_file: Annotated[
|
|
165
|
+
str, typer.Argument(help="A JSON string or a file path to a JSON file.")
|
|
166
|
+
],
|
|
167
|
+
fts_on: Annotated[
|
|
168
|
+
Optional[str],
|
|
169
|
+
typer.Option(
|
|
170
|
+
"--fts",
|
|
171
|
+
help="Comma-separated list of fields for FTS (e.g., 'title,body'). Default: all string fields.",
|
|
172
|
+
),
|
|
173
|
+
] = None,
|
|
174
|
+
no_fts: Annotated[
|
|
175
|
+
bool, typer.Option("--no-fts", help="Disable FTS indexing for this document.")
|
|
176
|
+
] = False,
|
|
177
|
+
fuzzy: Annotated[
|
|
178
|
+
bool, typer.Option("--fuzzy/--no-fuzzy", help="Enable fuzzy search indexing.")
|
|
179
|
+
] = False,
|
|
180
|
+
):
|
|
181
|
+
"""
|
|
182
|
+
Index a new document from a JSON string or file.
|
|
183
|
+
|
|
184
|
+
The JSON object's top-level 'id' and 'embedding' keys are used for the
|
|
185
|
+
document ID and vector. All other keys are stored as metadata.
|
|
186
|
+
"""
|
|
187
|
+
db = ctx.obj["db"]
|
|
188
|
+
name = ctx.obj["name"]
|
|
189
|
+
try:
|
|
190
|
+
doc_dict = _parse_json_or_file(json_or_file)
|
|
191
|
+
if not isinstance(doc_dict, dict):
|
|
192
|
+
raise typer.BadParameter("Input must be a JSON object (a dictionary).")
|
|
193
|
+
|
|
194
|
+
doc_id = doc_dict.pop("id", None)
|
|
195
|
+
doc_embedding = doc_dict.pop("embedding", None)
|
|
196
|
+
|
|
197
|
+
# The rest of the dict is metadata
|
|
198
|
+
doc = Document(id=doc_id, embedding=doc_embedding, **doc_dict)
|
|
199
|
+
|
|
200
|
+
# Determine FTS argument
|
|
201
|
+
if no_fts:
|
|
202
|
+
fts_arg = False
|
|
203
|
+
elif fts_on:
|
|
204
|
+
fts_arg = fts_on.split(",")
|
|
205
|
+
else:
|
|
206
|
+
fts_arg = True
|
|
207
|
+
|
|
208
|
+
db.collection(name).index(doc, fts=fts_arg, fuzzy=fuzzy)
|
|
209
|
+
rich.print(
|
|
210
|
+
f"[green]Success:[/] Document indexed with ID: [bold]{doc.id}[/bold]"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
except Exception as e:
|
|
214
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
215
|
+
raise typer.Exit(code=1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command(name="drop")
|
|
219
|
+
def drop_doc(
|
|
220
|
+
ctx: typer.Context,
|
|
221
|
+
doc_id: Annotated[str, typer.Argument(help="The ID of the document to delete.")],
|
|
222
|
+
):
|
|
223
|
+
"""
|
|
224
|
+
Delete a document from the collection by its ID.
|
|
225
|
+
"""
|
|
226
|
+
db = ctx.obj["db"]
|
|
227
|
+
name = ctx.obj["name"]
|
|
228
|
+
try:
|
|
229
|
+
db.collection(name).drop(Document(id=doc_id))
|
|
230
|
+
rich.print(
|
|
231
|
+
f"[green]Success:[/] Document '{doc_id}' dropped from collection '{name}'."
|
|
232
|
+
)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
235
|
+
raise typer.Exit(code=1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.command()
|
|
239
|
+
def items(ctx: typer.Context):
|
|
240
|
+
"""
|
|
241
|
+
List all items in the collection.
|
|
242
|
+
"""
|
|
243
|
+
db = ctx.obj["db"]
|
|
244
|
+
name = ctx.obj["name"]
|
|
245
|
+
try:
|
|
246
|
+
all_items = list(db.collection(name))
|
|
247
|
+
if not all_items:
|
|
248
|
+
rich.print(f"Collection '{name}' is empty.")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
table = rich.table.Table(title=f"Documents in Collection: [bold]{name}[/bold]")
|
|
252
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
253
|
+
table.add_column("Embedding", style="magenta", justify="center")
|
|
254
|
+
table.add_column("Metadata (Summary)")
|
|
255
|
+
|
|
256
|
+
for doc in all_items:
|
|
257
|
+
metadata_dict = doc.to_dict() # This returns only metadata
|
|
258
|
+
metadata_str = json.dumps(metadata_dict)
|
|
259
|
+
embedding_str = (
|
|
260
|
+
f"{len(doc.embedding)}d" if doc.embedding is not None else "None"
|
|
261
|
+
)
|
|
262
|
+
table.add_row(doc.id, embedding_str, _truncate(metadata_str))
|
|
263
|
+
|
|
264
|
+
rich.print(table)
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
268
|
+
raise typer.Exit(code=1)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@app.command()
|
|
272
|
+
def match(
|
|
273
|
+
ctx: typer.Context,
|
|
274
|
+
query: Annotated[str, typer.Argument(help="The search query text.")],
|
|
275
|
+
on: Annotated[
|
|
276
|
+
Optional[str],
|
|
277
|
+
typer.Option(
|
|
278
|
+
help="Comma-separated list of fields to search (e.g., 'title,body')."
|
|
279
|
+
),
|
|
280
|
+
] = None,
|
|
281
|
+
fuzziness: Annotated[
|
|
282
|
+
int, typer.Option(help="Fuzziness level (0=exact, 1-2=typos).")
|
|
283
|
+
] = 0,
|
|
284
|
+
top_k: Annotated[int, typer.Option("--k", help="Number of results to return.")] = 5,
|
|
285
|
+
):
|
|
286
|
+
"""
|
|
287
|
+
Perform a full-text (or fuzzy) search.
|
|
288
|
+
"""
|
|
289
|
+
db = ctx.obj["db"]
|
|
290
|
+
name = ctx.obj["name"]
|
|
291
|
+
try:
|
|
292
|
+
on_list = on.split(",") if on else None
|
|
293
|
+
results = db.collection(name).match(
|
|
294
|
+
query, on=on_list, top_k=top_k, fuzziness=fuzziness
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not results:
|
|
298
|
+
rich.print("No results found.")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
score_title = "Distance" if fuzziness > 0 else "Rank"
|
|
302
|
+
table = rich.table.Table(title=f"Search Results for: [bold]'{query}'[/bold]")
|
|
303
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
304
|
+
table.add_column(score_title, style="magenta", justify="right")
|
|
305
|
+
table.add_column("Metadata (Summary)")
|
|
306
|
+
|
|
307
|
+
for doc, score in results:
|
|
308
|
+
metadata_str = json.dumps(doc.to_dict())
|
|
309
|
+
table.add_row(doc.id, f"{score:.4f}", _truncate(metadata_str))
|
|
310
|
+
|
|
311
|
+
rich.print(table)
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
315
|
+
raise typer.Exit(code=1)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command()
|
|
319
|
+
def search(
|
|
320
|
+
ctx: typer.Context,
|
|
321
|
+
vector_json: Annotated[
|
|
322
|
+
str,
|
|
323
|
+
typer.Argument(help="The query vector as a JSON list (e.g., '[0.1, 0.2]')."),
|
|
324
|
+
],
|
|
325
|
+
top_k: Annotated[int, typer.Option("--k", help="Number of results to return.")] = 5,
|
|
326
|
+
):
|
|
327
|
+
"""
|
|
328
|
+
Perform an approximate nearest neighbor (vector) search.
|
|
329
|
+
Requires 'beaver-db[vector]' to be installed.
|
|
330
|
+
"""
|
|
331
|
+
db = ctx.obj["db"]
|
|
332
|
+
name = ctx.obj["name"]
|
|
333
|
+
try:
|
|
334
|
+
vector_list = _parse_json_or_file(vector_json)
|
|
335
|
+
if not isinstance(vector_list, list):
|
|
336
|
+
raise typer.BadParameter(
|
|
337
|
+
"Input must be a JSON list (e.g., '[0.1, 0.2, 0.3]')."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
results = db.collection(name).search(vector_list, top_k=top_k)
|
|
341
|
+
|
|
342
|
+
if not results:
|
|
343
|
+
rich.print("No results found.")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
table = rich.table.Table(title="Vector Search Results")
|
|
347
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
348
|
+
table.add_column("Distance", style="magenta", justify="right")
|
|
349
|
+
table.add_column("Metadata (Summary)")
|
|
350
|
+
|
|
351
|
+
for doc, distance in results:
|
|
352
|
+
metadata_str = json.dumps(doc.to_dict())
|
|
353
|
+
table.add_row(doc.id, f"{distance:.4f}", _truncate(metadata_str))
|
|
354
|
+
|
|
355
|
+
rich.print(table)
|
|
356
|
+
|
|
357
|
+
except ImportError:
|
|
358
|
+
rich.print("[bold red]Error:[/] Vector search requires 'beaver-db[vector]'.")
|
|
359
|
+
rich.print('Please install it with: pip install "beaver-db[vector]"')
|
|
360
|
+
raise typer.Exit(code=1)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
363
|
+
raise typer.Exit(code=1)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command()
|
|
367
|
+
def connect(
|
|
368
|
+
ctx: typer.Context,
|
|
369
|
+
source_id: Annotated[str, typer.Argument(help="The ID of the source document.")],
|
|
370
|
+
target_id: Annotated[str, typer.Argument(help="The ID of the target document.")],
|
|
371
|
+
label: Annotated[
|
|
372
|
+
str, typer.Argument(help="The label for the relationship (e.g., 'FOLLOWS').")
|
|
373
|
+
],
|
|
374
|
+
metadata: Annotated[
|
|
375
|
+
Optional[str], typer.Option(help="Optional metadata as a JSON string.")
|
|
376
|
+
] = None,
|
|
377
|
+
):
|
|
378
|
+
"""
|
|
379
|
+
Connect two documents with a directed, labeled relationship.
|
|
380
|
+
"""
|
|
381
|
+
db = ctx.obj["db"]
|
|
382
|
+
name = ctx.obj["name"]
|
|
383
|
+
try:
|
|
384
|
+
parsed_metadata = _parse_json_or_file(metadata) if metadata else None
|
|
385
|
+
source_doc = Document(id=source_id)
|
|
386
|
+
target_doc = Document(id=target_id)
|
|
387
|
+
|
|
388
|
+
db.collection(name).connect(
|
|
389
|
+
source_doc, target_doc, label, metadata=parsed_metadata
|
|
390
|
+
)
|
|
391
|
+
rich.print(
|
|
392
|
+
f"[green]Success:[/] Connected '{source_id}' -> '{target_id}' with label '{label}'."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
397
|
+
raise typer.Exit(code=1)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@app.command()
|
|
401
|
+
def neighbors(
|
|
402
|
+
ctx: typer.Context,
|
|
403
|
+
doc_id: Annotated[str, typer.Argument(help="The ID of the source document.")],
|
|
404
|
+
label: Annotated[
|
|
405
|
+
Optional[str], typer.Option(help="Filter by edge label (e.g., 'FOLLOWS').")
|
|
406
|
+
] = None,
|
|
407
|
+
):
|
|
408
|
+
"""
|
|
409
|
+
Find the 1-hop outgoing neighbors of a document.
|
|
410
|
+
"""
|
|
411
|
+
db = ctx.obj["db"]
|
|
412
|
+
name = ctx.obj["name"]
|
|
413
|
+
try:
|
|
414
|
+
source_doc = Document(id=doc_id)
|
|
415
|
+
results = db.collection(name).neighbors(source_doc, label=label)
|
|
416
|
+
|
|
417
|
+
if not results:
|
|
418
|
+
rich.print(
|
|
419
|
+
f"No neighbors found for '{doc_id}'"
|
|
420
|
+
+ (f" with label '{label}'." if label else ".")
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
table = rich.table.Table(title=f"Neighbors of: [bold]'{doc_id}'[/bold]")
|
|
425
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
426
|
+
table.add_column("Metadata (Summary)")
|
|
427
|
+
|
|
428
|
+
for doc in results:
|
|
429
|
+
metadata_str = json.dumps(doc.to_dict())
|
|
430
|
+
table.add_row(doc.id, _truncate(metadata_str))
|
|
431
|
+
|
|
432
|
+
rich.print(table)
|
|
433
|
+
|
|
434
|
+
except Exception as e:
|
|
435
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
436
|
+
raise typer.Exit(code=1)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@app.command()
|
|
440
|
+
def walk(
|
|
441
|
+
ctx: typer.Context,
|
|
442
|
+
doc_id: Annotated[str, typer.Argument(help="The ID of the starting document.")],
|
|
443
|
+
labels: Annotated[
|
|
444
|
+
str,
|
|
445
|
+
typer.Option(
|
|
446
|
+
help="Comma-separated list of labels to follow (e.g., 'FOLLOWS,MENTIONS')."
|
|
447
|
+
),
|
|
448
|
+
],
|
|
449
|
+
depth: Annotated[int, typer.Option(help="How many steps to walk.")] = 1,
|
|
450
|
+
direction: Annotated[
|
|
451
|
+
str, typer.Option(case_sensitive=False, help="Direction of the walk.")
|
|
452
|
+
] = "outgoing",
|
|
453
|
+
):
|
|
454
|
+
"""
|
|
455
|
+
Perform a multi-hop graph walk (BFS) from a document.
|
|
456
|
+
"""
|
|
457
|
+
db = ctx.obj["db"]
|
|
458
|
+
name = ctx.obj["name"]
|
|
459
|
+
try:
|
|
460
|
+
source_doc = Document(id=doc_id)
|
|
461
|
+
label_list = labels.split(",")
|
|
462
|
+
|
|
463
|
+
results = db.collection(name).walk(
|
|
464
|
+
source=source_doc, labels=label_list, depth=depth, direction=direction
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if not results:
|
|
468
|
+
rich.print(f"No results found for walk from '{doc_id}'.")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
table = rich.table.Table(
|
|
472
|
+
title=f"Walk Results from: [bold]'{doc_id}'[/bold] (Depth: {depth})"
|
|
473
|
+
)
|
|
474
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
475
|
+
table.add_column("Metadata (Summary)")
|
|
476
|
+
|
|
477
|
+
for doc in results:
|
|
478
|
+
metadata_str = json.dumps(doc.to_dict())
|
|
479
|
+
table.add_row(doc.id, _truncate(metadata_str))
|
|
480
|
+
|
|
481
|
+
rich.print(table)
|
|
482
|
+
|
|
483
|
+
except Exception as e:
|
|
484
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
485
|
+
raise typer.Exit(code=1)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.command()
|
|
489
|
+
def dump(ctx: typer.Context):
|
|
490
|
+
"""
|
|
491
|
+
Dump the entire collection as JSON.
|
|
492
|
+
"""
|
|
493
|
+
db = ctx.obj["db"]
|
|
494
|
+
name = ctx.obj["name"]
|
|
495
|
+
try:
|
|
496
|
+
dump_data = db.collection(name).dump()
|
|
497
|
+
rich.print_json(data=dump_data)
|
|
498
|
+
except Exception as e:
|
|
499
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
500
|
+
raise typer.Exit(code=1)
|