featrix-shell 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.
- featrix_shell-0.1.0/PKG-INFO +8 -0
- featrix_shell-0.1.0/README.md +90 -0
- featrix_shell-0.1.0/featrix_shell.egg-info/PKG-INFO +8 -0
- featrix_shell-0.1.0/featrix_shell.egg-info/SOURCES.txt +14 -0
- featrix_shell-0.1.0/featrix_shell.egg-info/dependency_links.txt +1 -0
- featrix_shell-0.1.0/featrix_shell.egg-info/entry_points.txt +2 -0
- featrix_shell-0.1.0/featrix_shell.egg-info/requires.txt +3 -0
- featrix_shell-0.1.0/featrix_shell.egg-info/top_level.txt +1 -0
- featrix_shell-0.1.0/ffs/__init__.py +1 -0
- featrix_shell-0.1.0/ffs/cli.py +21 -0
- featrix_shell-0.1.0/ffs/client.py +38 -0
- featrix_shell-0.1.0/ffs/model_cmd.py +323 -0
- featrix_shell-0.1.0/ffs/output.py +34 -0
- featrix_shell-0.1.0/ffs/server_cmd.py +22 -0
- featrix_shell-0.1.0/pyproject.toml +20 -0
- featrix_shell-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# ffs-ai-shell
|
|
2
|
+
The Featrix Foundation Shell (ffs)
|
|
3
|
+
|
|
4
|
+
Transform any CSV into a production-ready ML model from the command line.
|
|
5
|
+
|
|
6
|
+
## CLI Grammar
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
ffs [global-options] <command> <subcommand> [options] [args]
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### Global Options
|
|
13
|
+
```
|
|
14
|
+
ffs --server URL # API server (default: https://sphere-api.featrix.com)
|
|
15
|
+
ffs --cluster NAME # Compute cluster (burrito, churro, etc.)
|
|
16
|
+
ffs --json # Output raw JSON instead of formatted tables
|
|
17
|
+
ffs --quiet # Minimal output
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Models (Foundational Models / Embedding Spaces)
|
|
21
|
+
```
|
|
22
|
+
ffs model create --name NAME --data FILE [--epochs N] [--ignore-columns COL,COL]
|
|
23
|
+
ffs model list [--prefix PREFIX]
|
|
24
|
+
ffs model show MODEL_ID
|
|
25
|
+
ffs model columns MODEL_ID
|
|
26
|
+
ffs model card MODEL_ID
|
|
27
|
+
ffs model wait MODEL_ID
|
|
28
|
+
ffs model extend MODEL_ID --data FILE [--epochs N]
|
|
29
|
+
ffs model encode MODEL_ID RECORD_JSON [--short]
|
|
30
|
+
ffs model publish MODEL_ID --org ORG --name NAME
|
|
31
|
+
ffs model unpublish MODEL_ID
|
|
32
|
+
ffs model deprecate MODEL_ID --message MSG --expires DATE
|
|
33
|
+
ffs model delete MODEL_ID
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Predictors
|
|
37
|
+
```
|
|
38
|
+
ffs predictor create MODEL_ID --target COLUMN --type {set,scalar} [--name NAME] [--data FILE]
|
|
39
|
+
ffs predictor list MODEL_ID
|
|
40
|
+
ffs predictor show MODEL_ID [--predictor-id ID]
|
|
41
|
+
ffs predictor metrics MODEL_ID [--predictor-id ID]
|
|
42
|
+
ffs predictor train-more MODEL_ID --epochs N [--predictor-id ID | --target COLUMN]
|
|
43
|
+
ffs predictor remove MODEL_ID {--predictor-id ID | --target COLUMN}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Predict
|
|
47
|
+
```
|
|
48
|
+
ffs predict MODEL_ID RECORD_JSON [--target COLUMN] [--predictor-id ID]
|
|
49
|
+
ffs predict MODEL_ID --file FILE [--target COLUMN] [--sample N]
|
|
50
|
+
ffs predict explain MODEL_ID RECORD_JSON [--target COLUMN]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Vector Database
|
|
54
|
+
```
|
|
55
|
+
ffs vectordb create MODEL_ID [--name NAME] [--records FILE]
|
|
56
|
+
ffs vectordb search MODEL_ID RECORD_JSON [-k N]
|
|
57
|
+
ffs vectordb add MODEL_ID --records FILE
|
|
58
|
+
ffs vectordb size MODEL_ID
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Server
|
|
62
|
+
```
|
|
63
|
+
ffs server health
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Usage Examples
|
|
67
|
+
```bash
|
|
68
|
+
# End-to-end: create model, train predictor, make prediction
|
|
69
|
+
ffs model create --name "customers" --data customers.csv
|
|
70
|
+
ffs model wait abc123
|
|
71
|
+
ffs predictor create abc123 --target churn --type set --rare-label "yes"
|
|
72
|
+
ffs model wait abc123
|
|
73
|
+
ffs predict abc123 '{"age": 35, "income": 50000}'
|
|
74
|
+
|
|
75
|
+
# Batch predict from CSV
|
|
76
|
+
ffs predict abc123 --file test_data.csv --target churn
|
|
77
|
+
|
|
78
|
+
# Similarity search
|
|
79
|
+
ffs vectordb create abc123 --name "customer_search"
|
|
80
|
+
ffs vectordb search abc123 '{"age": 35}' -k 10
|
|
81
|
+
|
|
82
|
+
# Pipe-friendly
|
|
83
|
+
ffs predict abc123 --file input.csv --json | jq '.predictions[].predicted_class'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Architecture
|
|
87
|
+
|
|
88
|
+
- `MODEL_ID` = `session_id` in the underlying Featrix Sphere API
|
|
89
|
+
- CLI wraps the `featrixsphere` Python package
|
|
90
|
+
- Built with Click
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
featrix_shell.egg-info/PKG-INFO
|
|
4
|
+
featrix_shell.egg-info/SOURCES.txt
|
|
5
|
+
featrix_shell.egg-info/dependency_links.txt
|
|
6
|
+
featrix_shell.egg-info/entry_points.txt
|
|
7
|
+
featrix_shell.egg-info/requires.txt
|
|
8
|
+
featrix_shell.egg-info/top_level.txt
|
|
9
|
+
ffs/__init__.py
|
|
10
|
+
ffs/cli.py
|
|
11
|
+
ffs/client.py
|
|
12
|
+
ffs/model_cmd.py
|
|
13
|
+
ffs/output.py
|
|
14
|
+
ffs/server_cmd.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ffs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from ffs.client import pass_client, ClientState
|
|
4
|
+
from ffs import model_cmd
|
|
5
|
+
from ffs import server_cmd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.option("--server", envvar="FFS_SERVER", default="https://sphere-api.featrix.com", help="API server URL")
|
|
10
|
+
@click.option("--cluster", envvar="FFS_CLUSTER", default=None, help="Compute cluster name")
|
|
11
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON")
|
|
12
|
+
@click.option("--quiet", is_flag=True, help="Minimal output")
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def main(ctx, server, cluster, output_json, quiet):
|
|
15
|
+
"""The Featrix Foundation Shell."""
|
|
16
|
+
ctx.ensure_object(dict)
|
|
17
|
+
ctx.obj = ClientState(server=server, cluster=cluster, output_json=output_json, quiet=quiet)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
main.add_command(model_cmd.model)
|
|
21
|
+
main.add_command(server_cmd.server)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from featrixsphere.api import FeatrixSphere
|
|
5
|
+
from featrixsphere import FeatrixSphereClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ClientState:
|
|
10
|
+
server: str
|
|
11
|
+
cluster: str | None
|
|
12
|
+
output_json: bool
|
|
13
|
+
quiet: bool
|
|
14
|
+
_client: FeatrixSphere | None = None
|
|
15
|
+
_low_client: FeatrixSphereClient | None = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def client(self) -> FeatrixSphere:
|
|
19
|
+
"""OO API client."""
|
|
20
|
+
if self._client is None:
|
|
21
|
+
self._client = FeatrixSphere(
|
|
22
|
+
base_url=self.server,
|
|
23
|
+
compute_cluster=self.cluster,
|
|
24
|
+
)
|
|
25
|
+
return self._client
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def low(self) -> FeatrixSphereClient:
|
|
29
|
+
"""Low-level client for operations not on the OO API."""
|
|
30
|
+
if self._low_client is None:
|
|
31
|
+
self._low_client = FeatrixSphereClient(
|
|
32
|
+
base_url=self.server,
|
|
33
|
+
compute_cluster=self.cluster,
|
|
34
|
+
)
|
|
35
|
+
return self._low_client
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
pass_client = click.make_pass_decorator(ClientState)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""ffs model subcommands."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from ffs.client import pass_client, ClientState
|
|
7
|
+
from ffs.output import print_json, print_kv, console
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def model():
|
|
12
|
+
"""Manage foundational models."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@model.command()
|
|
17
|
+
@click.option("--name", required=True, help="Model name")
|
|
18
|
+
@click.option("--data", "data_file", required=True, type=click.Path(exists=True), help="CSV/Parquet/JSON file")
|
|
19
|
+
@click.option("--epochs", type=int, default=None, help="Training epochs (auto if omitted)")
|
|
20
|
+
@click.option("--ignore-columns", default=None, help="Comma-separated columns to ignore")
|
|
21
|
+
@pass_client
|
|
22
|
+
def create(state: ClientState, name, data_file, epochs, ignore_columns):
|
|
23
|
+
"""Create a new foundational model from data."""
|
|
24
|
+
ignore = [c.strip() for c in ignore_columns.split(",")] if ignore_columns else None
|
|
25
|
+
fm = state.client.create_foundational_model(
|
|
26
|
+
name=name,
|
|
27
|
+
csv_file=data_file,
|
|
28
|
+
ignore_columns=ignore,
|
|
29
|
+
epochs=epochs,
|
|
30
|
+
session_name_prefix=name,
|
|
31
|
+
)
|
|
32
|
+
if state.output_json:
|
|
33
|
+
print_json({"model_id": fm.id, "status": fm.status})
|
|
34
|
+
else:
|
|
35
|
+
console.print(f"[green]Model created:[/green] {fm.id}")
|
|
36
|
+
console.print(f"Status: {fm.status}")
|
|
37
|
+
console.print(f"\nRun [bold]ffs model wait {fm.id}[/bold] to monitor training.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@model.command("list")
|
|
41
|
+
@click.option("--prefix", default="", help="Filter by name prefix")
|
|
42
|
+
@pass_client
|
|
43
|
+
def list_models(state: ClientState, prefix):
|
|
44
|
+
"""List models."""
|
|
45
|
+
sessions = state.client.list_sessions(name_prefix=prefix)
|
|
46
|
+
if state.output_json:
|
|
47
|
+
print_json(sessions)
|
|
48
|
+
elif not sessions:
|
|
49
|
+
console.print("No models found.")
|
|
50
|
+
else:
|
|
51
|
+
for sid in sessions:
|
|
52
|
+
console.print(sid)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@model.command()
|
|
56
|
+
@click.argument("model_id")
|
|
57
|
+
@pass_client
|
|
58
|
+
def show(state: ClientState, model_id):
|
|
59
|
+
"""Show model details."""
|
|
60
|
+
fm = state.client.foundational_model(model_id)
|
|
61
|
+
data = {
|
|
62
|
+
"model_id": fm.id,
|
|
63
|
+
"name": fm.name,
|
|
64
|
+
"status": fm.status,
|
|
65
|
+
"dimensions": fm.dimensions,
|
|
66
|
+
"epochs": fm.epochs,
|
|
67
|
+
"final_loss": fm.final_loss,
|
|
68
|
+
"compute_cluster": fm.compute_cluster,
|
|
69
|
+
}
|
|
70
|
+
if state.output_json:
|
|
71
|
+
print_json(data)
|
|
72
|
+
else:
|
|
73
|
+
print_kv({
|
|
74
|
+
"Model ID": fm.id,
|
|
75
|
+
"Name": fm.name or "(unnamed)",
|
|
76
|
+
"Status": fm.status,
|
|
77
|
+
"Dimensions": fm.dimensions or "—",
|
|
78
|
+
"Epochs": fm.epochs or "—",
|
|
79
|
+
"Final Loss": fm.final_loss or "—",
|
|
80
|
+
"Cluster": fm.compute_cluster or "—",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@model.command()
|
|
85
|
+
@click.argument("model_id")
|
|
86
|
+
@pass_client
|
|
87
|
+
def columns(state: ClientState, model_id):
|
|
88
|
+
"""Show columns in the model's embedding space."""
|
|
89
|
+
fm = state.client.foundational_model(model_id)
|
|
90
|
+
cols = fm.get_columns()
|
|
91
|
+
if state.output_json:
|
|
92
|
+
print_json(cols)
|
|
93
|
+
else:
|
|
94
|
+
for col in cols:
|
|
95
|
+
console.print(col)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@model.command()
|
|
99
|
+
@click.argument("model_id")
|
|
100
|
+
@pass_client
|
|
101
|
+
def card(state: ClientState, model_id):
|
|
102
|
+
"""Show the model card."""
|
|
103
|
+
fm = state.client.foundational_model(model_id)
|
|
104
|
+
print_json(fm.get_model_card())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _format_duration(seconds: int) -> str:
|
|
108
|
+
"""Format seconds into human-readable duration."""
|
|
109
|
+
if seconds < 60:
|
|
110
|
+
return f"{seconds}s"
|
|
111
|
+
minutes, secs = divmod(seconds, 60)
|
|
112
|
+
if minutes < 60:
|
|
113
|
+
return f"{minutes}m{secs:02d}s"
|
|
114
|
+
hours, minutes = divmod(minutes, 60)
|
|
115
|
+
return f"{hours}h{minutes:02d}m"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _job_status_lines(session_info) -> list[str]:
|
|
119
|
+
"""Build status lines from job plan."""
|
|
120
|
+
from datetime import datetime, timezone
|
|
121
|
+
now = datetime.now(timezone.utc)
|
|
122
|
+
lines = []
|
|
123
|
+
for job in session_info.job_plan:
|
|
124
|
+
jtype = job.get("job_type", "?")
|
|
125
|
+
jid = job.get("job_id")
|
|
126
|
+
if jid and jid in session_info.jobs:
|
|
127
|
+
j = session_info.jobs[jid]
|
|
128
|
+
status = j.get("status", "?")
|
|
129
|
+
progress = j.get("progress", 0)
|
|
130
|
+
created = j.get("created_at", "")
|
|
131
|
+
|
|
132
|
+
# Calculate how long this job has been in its current state
|
|
133
|
+
age = ""
|
|
134
|
+
if created:
|
|
135
|
+
try:
|
|
136
|
+
if isinstance(created, str):
|
|
137
|
+
created_dt = datetime.fromisoformat(created)
|
|
138
|
+
else:
|
|
139
|
+
created_dt = created
|
|
140
|
+
delta = int((now - created_dt).total_seconds())
|
|
141
|
+
age = f" ({_format_duration(delta)})"
|
|
142
|
+
except (ValueError, TypeError):
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
finished = j.get("finished_at", "")
|
|
146
|
+
duration = ""
|
|
147
|
+
if finished and created:
|
|
148
|
+
try:
|
|
149
|
+
if isinstance(finished, str):
|
|
150
|
+
finished_dt = datetime.fromisoformat(finished)
|
|
151
|
+
else:
|
|
152
|
+
finished_dt = finished
|
|
153
|
+
if isinstance(created, str):
|
|
154
|
+
created_dt = datetime.fromisoformat(created)
|
|
155
|
+
else:
|
|
156
|
+
created_dt = created
|
|
157
|
+
dur = int((finished_dt - created_dt).total_seconds())
|
|
158
|
+
duration = f" ({_format_duration(dur)})"
|
|
159
|
+
except (ValueError, TypeError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
queue = j.get("queue", "")
|
|
163
|
+
queue_str = f" [{queue}]" if queue and status != "done" else ""
|
|
164
|
+
|
|
165
|
+
if status == "done":
|
|
166
|
+
lines.append(f" [green]done[/green] {jtype}{duration}")
|
|
167
|
+
elif status == "running" and progress:
|
|
168
|
+
lines.append(f" [yellow]running {progress}%[/yellow] {jtype}{queue_str}{age}")
|
|
169
|
+
elif status == "running":
|
|
170
|
+
lines.append(f" [yellow]running[/yellow] {jtype}{queue_str}{age}")
|
|
171
|
+
else:
|
|
172
|
+
lines.append(f" [dim]{status}[/dim] {jtype}{queue_str}{age}")
|
|
173
|
+
else:
|
|
174
|
+
lines.append(f" [dim]pending[/dim] {jtype}")
|
|
175
|
+
return lines
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@model.command()
|
|
179
|
+
@click.argument("model_id")
|
|
180
|
+
@click.option("--poll-interval", type=int, default=10, help="Seconds between checks")
|
|
181
|
+
@click.option("--timeout", type=int, default=3600, help="Max wait time in seconds")
|
|
182
|
+
@pass_client
|
|
183
|
+
def wait(state: ClientState, model_id, poll_interval, timeout):
|
|
184
|
+
"""Wait for model training to complete."""
|
|
185
|
+
start = time.time()
|
|
186
|
+
first = True
|
|
187
|
+
while True:
|
|
188
|
+
session = state.low.get_session_status(model_id)
|
|
189
|
+
elapsed = int(time.time() - start)
|
|
190
|
+
|
|
191
|
+
if session.status == "done":
|
|
192
|
+
fm = state.client.foundational_model(model_id)
|
|
193
|
+
console.print(f"\n[green]Training complete.[/green]")
|
|
194
|
+
if not state.quiet:
|
|
195
|
+
print_kv({
|
|
196
|
+
"Model ID": fm.id,
|
|
197
|
+
"Status": fm.status,
|
|
198
|
+
"Dimensions": fm.dimensions or "—",
|
|
199
|
+
"Epochs": fm.epochs or "—",
|
|
200
|
+
"Final Loss": fm.final_loss or "—",
|
|
201
|
+
})
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if session.status == "error":
|
|
205
|
+
console.print(f"\n[red]Training failed.[/red]")
|
|
206
|
+
for j in session.jobs.values():
|
|
207
|
+
if j.get("status") == "error":
|
|
208
|
+
console.print(f" {j['job_type']}: {j.get('error', 'unknown error')}")
|
|
209
|
+
raise SystemExit(1)
|
|
210
|
+
|
|
211
|
+
if elapsed > timeout:
|
|
212
|
+
console.print(f"\n[red]Timeout after {timeout}s. Status: {session.status}[/red]")
|
|
213
|
+
raise SystemExit(1)
|
|
214
|
+
|
|
215
|
+
# Clear screen and redraw
|
|
216
|
+
if not first:
|
|
217
|
+
# Move cursor up to overwrite previous output
|
|
218
|
+
n_lines = len(session.job_plan) + 2 # header + blank + jobs
|
|
219
|
+
click.echo(f"\033[{n_lines}A\033[J", nl=False)
|
|
220
|
+
first = False
|
|
221
|
+
|
|
222
|
+
console.print(f"[bold]Waiting for {model_id}[/bold] ({_format_duration(elapsed)})")
|
|
223
|
+
for line in _job_status_lines(session):
|
|
224
|
+
console.print(line)
|
|
225
|
+
|
|
226
|
+
time.sleep(poll_interval)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@model.command()
|
|
231
|
+
@click.argument("model_id")
|
|
232
|
+
@click.option("--data", "data_file", required=True, type=click.Path(exists=True), help="New data file")
|
|
233
|
+
@click.option("--epochs", type=int, default=None, help="Additional epochs")
|
|
234
|
+
@pass_client
|
|
235
|
+
def extend(state: ClientState, model_id, data_file, epochs):
|
|
236
|
+
"""Extend a model with new data."""
|
|
237
|
+
fm = state.client.foundational_model(model_id)
|
|
238
|
+
kwargs = {}
|
|
239
|
+
if epochs:
|
|
240
|
+
kwargs["epochs"] = epochs
|
|
241
|
+
new_fm = fm.extend(new_data_file=data_file, **kwargs)
|
|
242
|
+
if state.output_json:
|
|
243
|
+
print_json({"model_id": new_fm.id, "parent_model_id": model_id, "status": new_fm.status})
|
|
244
|
+
else:
|
|
245
|
+
console.print(f"[green]Extended model created:[/green] {new_fm.id}")
|
|
246
|
+
console.print(f"Run [bold]ffs model wait {new_fm.id}[/bold] to monitor.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@model.command()
|
|
251
|
+
@click.argument("model_id")
|
|
252
|
+
@click.argument("record_json")
|
|
253
|
+
@click.option("--short", is_flag=True, help="Return 3D short embedding for visualization")
|
|
254
|
+
@pass_client
|
|
255
|
+
def encode(state: ClientState, model_id, record_json, short):
|
|
256
|
+
"""Encode a record into the embedding space."""
|
|
257
|
+
record = json.loads(record_json)
|
|
258
|
+
if short:
|
|
259
|
+
# OO API encode() doesn't support short — use low-level client
|
|
260
|
+
result = state.low.encode_records(model_id, record, short=True)
|
|
261
|
+
print_json(result)
|
|
262
|
+
else:
|
|
263
|
+
fm = state.client.foundational_model(model_id)
|
|
264
|
+
vectors = fm.encode(record)
|
|
265
|
+
print_json(vectors)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@model.command()
|
|
269
|
+
@click.argument("model_id")
|
|
270
|
+
@click.option("--org", required=True, help="Organization ID")
|
|
271
|
+
@click.option("--name", default=None, help="Published name")
|
|
272
|
+
@pass_client
|
|
273
|
+
def publish(state: ClientState, model_id, org, name):
|
|
274
|
+
"""Publish a model."""
|
|
275
|
+
fm = state.client.foundational_model(model_id)
|
|
276
|
+
result = fm.publish(org_id=org, name=name)
|
|
277
|
+
if state.output_json:
|
|
278
|
+
print_json(result)
|
|
279
|
+
else:
|
|
280
|
+
console.print(f"[green]Published:[/green] {result.get('published_path', model_id)}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@model.command()
|
|
284
|
+
@click.argument("model_id")
|
|
285
|
+
@pass_client
|
|
286
|
+
def unpublish(state: ClientState, model_id):
|
|
287
|
+
"""Unpublish a model."""
|
|
288
|
+
fm = state.client.foundational_model(model_id)
|
|
289
|
+
result = fm.unpublish()
|
|
290
|
+
if state.output_json:
|
|
291
|
+
print_json(result)
|
|
292
|
+
else:
|
|
293
|
+
console.print(f"[green]Unpublished:[/green] {model_id}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@model.command()
|
|
297
|
+
@click.argument("model_id")
|
|
298
|
+
@click.option("--message", required=True, help="Deprecation warning message")
|
|
299
|
+
@click.option("--expires", required=True, help="Expiration date (ISO format)")
|
|
300
|
+
@pass_client
|
|
301
|
+
def deprecate(state: ClientState, model_id, message, expires):
|
|
302
|
+
"""Deprecate a model with a warning and expiration date."""
|
|
303
|
+
fm = state.client.foundational_model(model_id)
|
|
304
|
+
result = fm.deprecate(warning_message=message, expiration_date=expires)
|
|
305
|
+
if state.output_json:
|
|
306
|
+
print_json(result)
|
|
307
|
+
else:
|
|
308
|
+
console.print(f"[yellow]Deprecated:[/yellow] {model_id}")
|
|
309
|
+
console.print(f"Expires: {expires}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@model.command()
|
|
313
|
+
@click.argument("model_id")
|
|
314
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this model?")
|
|
315
|
+
@pass_client
|
|
316
|
+
def delete(state: ClientState, model_id):
|
|
317
|
+
"""Delete a model."""
|
|
318
|
+
# Not on OO API yet — use low-level client
|
|
319
|
+
result = state.low.mark_for_deletion(model_id)
|
|
320
|
+
if state.output_json:
|
|
321
|
+
print_json(result)
|
|
322
|
+
else:
|
|
323
|
+
console.print(f"[red]Marked for deletion:[/red] {model_id}")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Output formatting helpers."""
|
|
2
|
+
import json
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def print_json(data):
|
|
11
|
+
"""Print raw JSON to stdout."""
|
|
12
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_kv(pairs: dict, title: str | None = None):
|
|
16
|
+
"""Print key-value pairs as a rich table."""
|
|
17
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
18
|
+
table.add_column(style="bold cyan")
|
|
19
|
+
table.add_column()
|
|
20
|
+
if title:
|
|
21
|
+
console.print(f"\n[bold]{title}[/bold]")
|
|
22
|
+
for k, v in pairs.items():
|
|
23
|
+
table.add_row(str(k), str(v))
|
|
24
|
+
console.print(table)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_list_table(rows: list[dict], columns: list[str], title: str | None = None):
|
|
28
|
+
"""Print a list of dicts as a rich table."""
|
|
29
|
+
table = Table(title=title)
|
|
30
|
+
for col in columns:
|
|
31
|
+
table.add_column(col, style="cyan" if col.endswith("id") or col.endswith("ID") else None)
|
|
32
|
+
for row in rows:
|
|
33
|
+
table.add_row(*[str(row.get(c, "")) for c in columns])
|
|
34
|
+
console.print(table)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""ffs server subcommands."""
|
|
2
|
+
import click
|
|
3
|
+
|
|
4
|
+
from ffs.client import pass_client, ClientState
|
|
5
|
+
from ffs.output import print_json, print_kv, console
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def server():
|
|
10
|
+
"""Server operations."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@server.command()
|
|
15
|
+
@pass_client
|
|
16
|
+
def health(state: ClientState):
|
|
17
|
+
"""Check API server health."""
|
|
18
|
+
result = state.client.health_check()
|
|
19
|
+
if state.output_json:
|
|
20
|
+
print_json(result)
|
|
21
|
+
else:
|
|
22
|
+
print_kv(result, title="Server Health")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "featrix-shell"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The Featrix Foundation Shell (ffs)"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.0",
|
|
12
|
+
"featrixsphere",
|
|
13
|
+
"rich",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
ffs = "ffs.cli:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
include = ["ffs*"]
|