pulse-ai-cli 0.1.0__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.
- pulse_ai_cli-0.1.0.dist-info/METADATA +49 -0
- pulse_ai_cli-0.1.0.dist-info/RECORD +27 -0
- pulse_ai_cli-0.1.0.dist-info/WHEEL +4 -0
- pulse_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pulse_cli/__init__.py +3 -0
- pulse_cli/__main__.py +4 -0
- pulse_cli/app.py +103 -0
- pulse_cli/client.py +197 -0
- pulse_cli/commands/__init__.py +0 -0
- pulse_cli/commands/auth.py +114 -0
- pulse_cli/commands/extract.py +199 -0
- pulse_cli/commands/jobs.py +53 -0
- pulse_cli/commands/open_platform.py +25 -0
- pulse_cli/commands/schema.py +124 -0
- pulse_cli/commands/split.py +63 -0
- pulse_cli/commands/tables.py +52 -0
- pulse_cli/commands/usage.py +109 -0
- pulse_cli/config.py +141 -0
- pulse_cli/errors.py +85 -0
- pulse_cli/oauth.py +95 -0
- pulse_cli/output.py +87 -0
- pulse_cli/repl.py +149 -0
- pulse_cli/runtime.py +61 -0
- pulse_cli/status.py +10 -0
- pulse_cli/tui/__init__.py +0 -0
- pulse_cli/tui/dashboard.py +153 -0
- pulse_cli/tui/logo.py +76 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""`pulse extract <file|url|dir> …` — document -> markdown (+ JSON result)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import mimetypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from pulse_cli.client import MAX_UPLOAD_BYTES
|
|
12
|
+
from pulse_cli.errors import PulseCliError
|
|
13
|
+
from pulse_cli.output import console, derive_output_stem, fail, print_json, write_result_files
|
|
14
|
+
from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
|
|
15
|
+
|
|
16
|
+
SUPPORTED_EXTS = {
|
|
17
|
+
".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".webp",
|
|
18
|
+
".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls", ".csv",
|
|
19
|
+
".html", ".txt", ".md",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _collect_sources(inputs: list[str]) -> list[str]:
|
|
24
|
+
"""Expand directories (recursive) into supported files; keep URLs/files as-is."""
|
|
25
|
+
sources: list[str] = []
|
|
26
|
+
for item in inputs:
|
|
27
|
+
if item.startswith(("http://", "https://")):
|
|
28
|
+
sources.append(item)
|
|
29
|
+
continue
|
|
30
|
+
p = Path(item).expanduser()
|
|
31
|
+
if p.is_dir():
|
|
32
|
+
found = sorted(
|
|
33
|
+
str(f) for f in p.rglob("*")
|
|
34
|
+
if f.is_file() and f.suffix.lower() in SUPPORTED_EXTS
|
|
35
|
+
)
|
|
36
|
+
if not found:
|
|
37
|
+
console.print(f"[muted]no supported documents in {p}[/muted]")
|
|
38
|
+
sources.extend(found)
|
|
39
|
+
elif p.is_file():
|
|
40
|
+
sources.append(str(p))
|
|
41
|
+
else:
|
|
42
|
+
raise fail(f"not found: {item}")
|
|
43
|
+
return sources
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_schema_arg(value: str) -> dict:
|
|
47
|
+
"""`--schema` accepts a JSON Schema file path or inline JSON."""
|
|
48
|
+
text = value.strip()
|
|
49
|
+
if text.startswith("{"):
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(text)
|
|
52
|
+
except json.JSONDecodeError as exc:
|
|
53
|
+
raise fail(f"--schema inline JSON is invalid ({exc})")
|
|
54
|
+
p = Path(text).expanduser()
|
|
55
|
+
if not p.is_file():
|
|
56
|
+
raise fail(f"--schema file not found: {p}")
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(p.read_text())
|
|
59
|
+
except json.JSONDecodeError as exc:
|
|
60
|
+
raise fail(f"--schema {p} is not valid JSON ({exc})")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_options(
|
|
64
|
+
pages: Optional[str],
|
|
65
|
+
model: Optional[str],
|
|
66
|
+
html: bool,
|
|
67
|
+
footnotes: bool,
|
|
68
|
+
figures: bool,
|
|
69
|
+
show_images: bool,
|
|
70
|
+
chunk_types: Optional[str],
|
|
71
|
+
chunk_size: Optional[int],
|
|
72
|
+
) -> dict:
|
|
73
|
+
"""Same payload shaping as the MCP extract tool (grouped extensions)."""
|
|
74
|
+
options: dict = {"async": True}
|
|
75
|
+
if pages:
|
|
76
|
+
options["pages"] = pages
|
|
77
|
+
if model:
|
|
78
|
+
options["model"] = model
|
|
79
|
+
|
|
80
|
+
extensions: dict = {}
|
|
81
|
+
if html:
|
|
82
|
+
extensions["alt_outputs"] = {"return_html": True}
|
|
83
|
+
if footnotes:
|
|
84
|
+
extensions["footnote_references"] = True
|
|
85
|
+
chunking: dict = {}
|
|
86
|
+
if chunk_types:
|
|
87
|
+
chunking["chunk_types"] = [c.strip() for c in chunk_types.split(",") if c.strip()]
|
|
88
|
+
if chunk_size:
|
|
89
|
+
chunking["chunk_size"] = chunk_size
|
|
90
|
+
if chunking:
|
|
91
|
+
extensions["chunking"] = chunking
|
|
92
|
+
if extensions:
|
|
93
|
+
options["extensions"] = extensions
|
|
94
|
+
|
|
95
|
+
figure_processing: dict = {}
|
|
96
|
+
if figures:
|
|
97
|
+
figure_processing["description"] = True
|
|
98
|
+
if show_images:
|
|
99
|
+
figure_processing["show_images"] = True
|
|
100
|
+
if figure_processing:
|
|
101
|
+
options["figure_processing"] = figure_processing
|
|
102
|
+
return options
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract(
|
|
106
|
+
inputs: list[str] = typer.Argument(..., help="File path(s), directory, or URL(s)."),
|
|
107
|
+
pages: Optional[str] = typer.Option(None, help='Page range, 1-indexed, e.g. "1-2,5".'),
|
|
108
|
+
model: Optional[str] = typer.Option(None, help='Model override: "default" or "pulse-ultra-2".'),
|
|
109
|
+
html: bool = typer.Option(False, "--html", help="Also return an HTML representation."),
|
|
110
|
+
footnotes: bool = typer.Option(False, "--footnotes", help="Link footnote markers to text."),
|
|
111
|
+
figures: bool = typer.Option(False, "--figure-descriptions", help="Caption figures/visuals."),
|
|
112
|
+
show_images: bool = typer.Option(False, "--show-images", help="Return image URLs for visuals."),
|
|
113
|
+
chunk_types: Optional[str] = typer.Option(None, "--chunking", help="Comma list: semantic,header,page,recursive."),
|
|
114
|
+
chunk_size: Optional[int] = typer.Option(None, help="Max characters per chunk."),
|
|
115
|
+
schema_arg: Optional[str] = typer.Option(
|
|
116
|
+
None, "--schema",
|
|
117
|
+
help="JSON Schema (file path or inline JSON). Pipelines: extract, then apply the schema for structured output.",
|
|
118
|
+
),
|
|
119
|
+
output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="Directory for result files."),
|
|
120
|
+
no_wait: bool = typer.Option(False, "--no-wait", help="Submit and print job_id without polling."),
|
|
121
|
+
json_out: bool = typer.Option(False, "--json", help="Print full JSON to stdout; skip file writing."),
|
|
122
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY", help="Override stored key."),
|
|
123
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Engine URL override."),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Extract markdown (and more) from documents. Writes <name>.pulse.md / .pulse.json."""
|
|
126
|
+
sources = _collect_sources(inputs)
|
|
127
|
+
if not sources:
|
|
128
|
+
raise fail("nothing to extract")
|
|
129
|
+
|
|
130
|
+
schema_obj = _parse_schema_arg(schema_arg) if schema_arg else None
|
|
131
|
+
if schema_obj and no_wait:
|
|
132
|
+
raise fail("--schema needs the extraction result; remove --no-wait")
|
|
133
|
+
|
|
134
|
+
options = _build_options(pages, model, html, footnotes, figures, show_images, chunk_types, chunk_size)
|
|
135
|
+
failures = 0
|
|
136
|
+
|
|
137
|
+
with get_client(api_key, base_url) as client:
|
|
138
|
+
for source in sources:
|
|
139
|
+
label = Path(source).name if not source.startswith("http") else source
|
|
140
|
+
try:
|
|
141
|
+
if source.startswith(("http://", "https://")):
|
|
142
|
+
submit = client.submit_extract({"file_url": source, **options})
|
|
143
|
+
else:
|
|
144
|
+
p = Path(source)
|
|
145
|
+
size = p.stat().st_size
|
|
146
|
+
if size > MAX_UPLOAD_BYTES:
|
|
147
|
+
raise PulseCliError(f"{p.name} is {size / 1e6:.0f} MB — exceeds the 50 MB upload limit.")
|
|
148
|
+
ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
|
|
149
|
+
submit = client.submit_extract_multipart((p.name, p.read_bytes(), ctype), options)
|
|
150
|
+
|
|
151
|
+
job = submit_and_poll(client, submit, label=f"extracting {label}", no_wait=no_wait)
|
|
152
|
+
|
|
153
|
+
if no_wait and job.get("job_id"):
|
|
154
|
+
console.print(f"[body]{label}[/body] [muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
|
|
155
|
+
continue
|
|
156
|
+
if job.get("status") == "failed":
|
|
157
|
+
raise PulseCliError(job.get("error") or "extraction failed")
|
|
158
|
+
|
|
159
|
+
job = resolve_offloaded_result(client, job)
|
|
160
|
+
result = job.get("result") if isinstance(job.get("result"), dict) else job
|
|
161
|
+
eid = result.get("extraction_id") or job.get("extraction_id")
|
|
162
|
+
|
|
163
|
+
# Optional second pipeline stage: structured extraction (-s).
|
|
164
|
+
structured = None
|
|
165
|
+
if schema_obj is not None:
|
|
166
|
+
if not eid:
|
|
167
|
+
raise PulseCliError("engine returned no extraction_id; cannot apply schema")
|
|
168
|
+
s_submit = client.apply_schema({
|
|
169
|
+
"async": True,
|
|
170
|
+
"extraction_id": eid,
|
|
171
|
+
"schema_config": {"input_schema": schema_obj},
|
|
172
|
+
})
|
|
173
|
+
s_job = submit_and_poll(client, s_submit, label=f"applying schema {label}")
|
|
174
|
+
if s_job.get("status") == "failed":
|
|
175
|
+
raise PulseCliError(s_job.get("error") or "schema extraction failed")
|
|
176
|
+
structured = resolve_offloaded_result(client, s_job)
|
|
177
|
+
|
|
178
|
+
if json_out:
|
|
179
|
+
print_json({"extract": job, "schema": structured} if structured is not None else job)
|
|
180
|
+
else:
|
|
181
|
+
stem = derive_output_stem(source, output_dir)
|
|
182
|
+
written = write_result_files(result, stem)
|
|
183
|
+
if structured is not None:
|
|
184
|
+
s_path = stem.parent / f"{stem.name}.schema.json"
|
|
185
|
+
s_path.write_text(json.dumps(
|
|
186
|
+
structured.get("result") or structured, indent=2, default=str
|
|
187
|
+
))
|
|
188
|
+
written.append(s_path)
|
|
189
|
+
console.print(
|
|
190
|
+
f"[ok]✓[/ok] [body]{label}[/body] [muted]→[/muted] "
|
|
191
|
+
+ ", ".join(str(w) for w in written)
|
|
192
|
+
+ (f" [muted]extraction_id=[/muted][brand]{eid}[/brand]" if eid else "")
|
|
193
|
+
)
|
|
194
|
+
except PulseCliError as exc:
|
|
195
|
+
failures += 1
|
|
196
|
+
console.print(f"[err]✗ {label}:[/err] {exc}")
|
|
197
|
+
|
|
198
|
+
if failures:
|
|
199
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""`pulse jobs <job_id>` — inspect / watch / cancel async jobs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pulse_cli.output import console, print_json
|
|
9
|
+
from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
|
|
10
|
+
from pulse_cli.status import is_terminal
|
|
11
|
+
|
|
12
|
+
jobs_app = typer.Typer(help="Inspect, watch, and cancel async jobs.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@jobs_app.command("get")
|
|
16
|
+
def get(
|
|
17
|
+
job_id: str = typer.Argument(..., help="Job id returned by extract/schema/split/tables."),
|
|
18
|
+
watch: bool = typer.Option(False, "--watch", "-w", help="Poll until the job is terminal."),
|
|
19
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
20
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
21
|
+
base_url: Optional[str] = typer.Option(None, "--base-url"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Show a job's status (and result when complete)."""
|
|
24
|
+
with get_client(api_key, base_url) as client:
|
|
25
|
+
job = client.get_job(job_id)
|
|
26
|
+
if watch and not is_terminal(job.get("status")):
|
|
27
|
+
job = submit_and_poll(client, {**job, "job_id": job_id}, label="job")
|
|
28
|
+
if is_terminal(job.get("status")):
|
|
29
|
+
job = resolve_offloaded_result(client, job)
|
|
30
|
+
|
|
31
|
+
if json_out:
|
|
32
|
+
print_json(job)
|
|
33
|
+
return
|
|
34
|
+
status = job.get("status", "unknown")
|
|
35
|
+
style = {"completed": "ok", "failed": "err", "canceled": "err", "expired": "err"}.get(status, "muted")
|
|
36
|
+
console.print(f"[muted]job[/muted] [brand]{job_id}[/brand] [muted]status =[/muted] [{style}]{status}[/{style}]")
|
|
37
|
+
print_json(job)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@jobs_app.command("cancel")
|
|
41
|
+
def cancel(
|
|
42
|
+
job_id: str = typer.Argument(...),
|
|
43
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
44
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
45
|
+
base_url: Optional[str] = typer.Option(None, "--base-url"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Cancel a pending/processing job (POST /cancel/{job_id})."""
|
|
48
|
+
with get_client(api_key, base_url) as client:
|
|
49
|
+
resp = client.cancel_job(job_id)
|
|
50
|
+
if json_out:
|
|
51
|
+
print_json(resp)
|
|
52
|
+
else:
|
|
53
|
+
console.print(f"[ok]✓[/ok] cancel requested for [brand]{job_id}[/brand]")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""`pulse open [extraction_id]` — jump to the Pulse platform in the browser."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import webbrowser
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from pulse_cli.config import PLATFORM_WEB_URL
|
|
10
|
+
from pulse_cli.output import console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def open_platform(
|
|
14
|
+
extraction_id: Optional[str] = typer.Argument(
|
|
15
|
+
None, help="Extraction/job id to view (omit for the dashboard)."
|
|
16
|
+
),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Open an extraction (or the dashboard) on platform.runpulse.com."""
|
|
19
|
+
if extraction_id:
|
|
20
|
+
url = f"{PLATFORM_WEB_URL}/dashboard/extractions/{extraction_id}"
|
|
21
|
+
else:
|
|
22
|
+
url = f"{PLATFORM_WEB_URL}/dashboard"
|
|
23
|
+
console.print(f"[muted]opening[/muted] [brand]{url}[/brand]")
|
|
24
|
+
if not webbrowser.open(url):
|
|
25
|
+
console.print("[muted]couldn't launch a browser — open the URL above manually[/muted]")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""`pulse schema apply` / `pulse schema generate` — structured extraction tooling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pulse_cli.output import console, fail, print_json
|
|
11
|
+
from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
|
|
12
|
+
|
|
13
|
+
schema_app = typer.Typer(help="JSON-schema structured extraction (apply / generate).")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_json_file(path: Path, what: str) -> dict:
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(path.read_text())
|
|
19
|
+
except FileNotFoundError:
|
|
20
|
+
raise fail(f"{what} file not found: {path}")
|
|
21
|
+
except json.JSONDecodeError as exc:
|
|
22
|
+
raise fail(f"{what} is not valid JSON ({exc})")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@schema_app.command("apply")
|
|
26
|
+
def apply(
|
|
27
|
+
extraction_id: Optional[str] = typer.Argument(None, help="Extraction to apply the schema to."),
|
|
28
|
+
extraction_ids: Optional[str] = typer.Option(None, "--extraction-ids", help="Comma list to combine several extractions."),
|
|
29
|
+
split_id: Optional[str] = typer.Option(None, "--split-id", help="Apply per-topic schemas to a split."),
|
|
30
|
+
schema_file: Optional[Path] = typer.Option(None, "--schema", help="JSON Schema file."),
|
|
31
|
+
schema_prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="Extra extraction guidance."),
|
|
32
|
+
schema_config_id: Optional[str] = typer.Option(None, "--schema-config-id", help="Saved schema config id."),
|
|
33
|
+
split_schema_config_file: Optional[Path] = typer.Option(None, "--split-schema-config", help="JSON file: per-topic schema config (with --split-id)."),
|
|
34
|
+
effort: bool = typer.Option(False, "--effort", help="Higher-effort extraction."),
|
|
35
|
+
pages: Optional[str] = typer.Option(None, help='Page range, e.g. "1-3".'),
|
|
36
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write result JSON to file."),
|
|
37
|
+
no_wait: bool = typer.Option(False, "--no-wait"),
|
|
38
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
39
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
40
|
+
base_url: Optional[str] = typer.Option(None, "--base-url"),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Apply a JSON schema to a prior extraction (or split) for structured output."""
|
|
43
|
+
# Mirrors mcp_server/tools/schema.py payload shaping exactly.
|
|
44
|
+
payload: dict = {"async": True}
|
|
45
|
+
if split_id:
|
|
46
|
+
payload["split_id"] = split_id
|
|
47
|
+
if split_schema_config_file:
|
|
48
|
+
payload["split_schema_config"] = _load_json_file(split_schema_config_file, "--split-schema-config")
|
|
49
|
+
else:
|
|
50
|
+
if extraction_ids:
|
|
51
|
+
payload["extraction_ids"] = [e.strip() for e in extraction_ids.split(",") if e.strip()]
|
|
52
|
+
elif extraction_id:
|
|
53
|
+
payload["extraction_id"] = extraction_id
|
|
54
|
+
else:
|
|
55
|
+
raise fail("provide an extraction_id, --extraction-ids, or --split-id")
|
|
56
|
+
if schema_config_id:
|
|
57
|
+
payload["schema_config_id"] = schema_config_id
|
|
58
|
+
else:
|
|
59
|
+
schema_config: dict = {}
|
|
60
|
+
if schema_file:
|
|
61
|
+
schema_config["input_schema"] = _load_json_file(schema_file, "--schema")
|
|
62
|
+
if schema_prompt:
|
|
63
|
+
schema_config["schema_prompt"] = schema_prompt
|
|
64
|
+
if effort:
|
|
65
|
+
schema_config["effort"] = True
|
|
66
|
+
if not schema_config:
|
|
67
|
+
raise fail("provide --schema, --prompt, or --schema-config-id")
|
|
68
|
+
payload["schema_config"] = schema_config
|
|
69
|
+
if pages:
|
|
70
|
+
payload["page_range"] = pages
|
|
71
|
+
|
|
72
|
+
with get_client(api_key, base_url) as client:
|
|
73
|
+
submit = client.apply_schema(payload)
|
|
74
|
+
job = submit_and_poll(client, submit, label="applying schema", no_wait=no_wait)
|
|
75
|
+
if no_wait and job.get("job_id"):
|
|
76
|
+
console.print(f"[muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
|
|
77
|
+
return
|
|
78
|
+
job = resolve_offloaded_result(client, job)
|
|
79
|
+
|
|
80
|
+
if output:
|
|
81
|
+
output.write_text(json.dumps(job, indent=2, default=str))
|
|
82
|
+
console.print(f"[ok]✓[/ok] wrote {output}")
|
|
83
|
+
elif json_out:
|
|
84
|
+
print_json(job)
|
|
85
|
+
else:
|
|
86
|
+
print_json(job.get("result") or job)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@schema_app.command("generate")
|
|
90
|
+
def generate(
|
|
91
|
+
prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="Describe the fields you want."),
|
|
92
|
+
from_schema: Optional[Path] = typer.Option(None, "--from", help="Existing schema JSON file to refine."),
|
|
93
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write schema to file."),
|
|
94
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
95
|
+
base_url: Optional[str] = typer.Option(None, "--base-url"),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""AI-generate or refine a JSON extraction schema (POST /ai-schema-helper)."""
|
|
98
|
+
if not prompt and not from_schema:
|
|
99
|
+
raise fail("provide --prompt and/or --from")
|
|
100
|
+
|
|
101
|
+
current = json.dumps(_load_json_file(from_schema, "--from")) if from_schema else None
|
|
102
|
+
with get_client(api_key, base_url) as client:
|
|
103
|
+
from pulse_cli.output import is_tty, spinner_status
|
|
104
|
+
|
|
105
|
+
if is_tty():
|
|
106
|
+
with spinner_status("generating schema"):
|
|
107
|
+
resp = client.generate_schema(prompt=prompt, current_schema=current)
|
|
108
|
+
else:
|
|
109
|
+
resp = client.generate_schema(prompt=prompt, current_schema=current)
|
|
110
|
+
|
|
111
|
+
# Engine returns the schema as a JSON string; parse for clean output.
|
|
112
|
+
raw = resp.get("schema") if isinstance(resp, dict) else None
|
|
113
|
+
if isinstance(raw, str):
|
|
114
|
+
try:
|
|
115
|
+
raw = json.loads(raw)
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
pass
|
|
118
|
+
schema = raw if raw is not None else resp
|
|
119
|
+
|
|
120
|
+
if output:
|
|
121
|
+
output.write_text(json.dumps(schema, indent=2))
|
|
122
|
+
console.print(f"[ok]✓[/ok] wrote {output}")
|
|
123
|
+
else:
|
|
124
|
+
print_json(schema)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`pulse split <extraction_id>` — partition an extraction into topic page-ranges."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pulse_cli.output import console, fail, print_json
|
|
11
|
+
from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def split(
|
|
15
|
+
extraction_id: str = typer.Argument(..., help="ID of a completed extraction."),
|
|
16
|
+
topic: list[str] = typer.Option(
|
|
17
|
+
[], "--topic", "-t",
|
|
18
|
+
help='Repeatable. "name" or "name:description", e.g. -t "invoice:billing pages".',
|
|
19
|
+
),
|
|
20
|
+
topics_file: Optional[Path] = typer.Option(None, "--topics-file", help="JSON file: [{name, description}, …]."),
|
|
21
|
+
split_config_id: Optional[str] = typer.Option(None, "--split-config-id", help="Saved split config id."),
|
|
22
|
+
no_wait: bool = typer.Option(False, "--no-wait"),
|
|
23
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
24
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
25
|
+
base_url: Optional[str] = typer.Option(None, "--base-url"),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Split a prior extraction into topics (run `pulse extract` first)."""
|
|
28
|
+
payload: dict = {"extraction_id": extraction_id, "async": True}
|
|
29
|
+
if split_config_id:
|
|
30
|
+
payload["split_config_id"] = split_config_id
|
|
31
|
+
elif topics_file:
|
|
32
|
+
try:
|
|
33
|
+
payload["split_config"] = {"split_input": json.loads(topics_file.read_text())}
|
|
34
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
35
|
+
raise fail(f"--topics-file: {exc}")
|
|
36
|
+
elif topic:
|
|
37
|
+
topics = []
|
|
38
|
+
for t in topic:
|
|
39
|
+
name, _, desc = t.partition(":")
|
|
40
|
+
entry: dict = {"name": name.strip()}
|
|
41
|
+
if desc.strip():
|
|
42
|
+
entry["description"] = desc.strip()
|
|
43
|
+
topics.append(entry)
|
|
44
|
+
payload["split_config"] = {"split_input": topics}
|
|
45
|
+
else:
|
|
46
|
+
raise fail("provide --topic (repeatable), --topics-file, or --split-config-id")
|
|
47
|
+
|
|
48
|
+
with get_client(api_key, base_url) as client:
|
|
49
|
+
submit = client.split(payload)
|
|
50
|
+
job = submit_and_poll(client, submit, label="splitting", no_wait=no_wait)
|
|
51
|
+
if no_wait and job.get("job_id"):
|
|
52
|
+
console.print(f"[muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
|
|
53
|
+
return
|
|
54
|
+
job = resolve_offloaded_result(client, job)
|
|
55
|
+
|
|
56
|
+
if json_out:
|
|
57
|
+
print_json(job)
|
|
58
|
+
return
|
|
59
|
+
result = job.get("result") if isinstance(job.get("result"), dict) else job
|
|
60
|
+
sid = result.get("split_id")
|
|
61
|
+
if sid:
|
|
62
|
+
console.print(f"[muted]split_id =[/muted] [brand]{sid}[/brand]")
|
|
63
|
+
print_json(result.get("split_output") or result)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""`pulse tables <extraction_id>` — extract tables from a completed extraction."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pulse_cli.output import console, print_json
|
|
11
|
+
from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tables(
|
|
15
|
+
extraction_id: str = typer.Argument(..., help="ID of a completed extraction."),
|
|
16
|
+
table_format: str = typer.Option("html", "--format", "-f", help='Table output format (e.g. "html", "json").'),
|
|
17
|
+
merge: bool = typer.Option(False, "--merge", help="Merge tables that span pages."),
|
|
18
|
+
charts: bool = typer.Option(False, "--charts-to-tables", help="Convert charts into tables too."),
|
|
19
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write result JSON to file."),
|
|
20
|
+
no_wait: bool = typer.Option(False, "--no-wait"),
|
|
21
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
22
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
23
|
+
base_url: Optional[str] = typer.Option(None, "--base-url"),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Extract tables (run `pulse extract` first to get an extraction_id)."""
|
|
26
|
+
# Format validation is left to the engine (valid set varies by deployment).
|
|
27
|
+
payload = {
|
|
28
|
+
"extraction_id": extraction_id,
|
|
29
|
+
"async": True,
|
|
30
|
+
"tables_config": {
|
|
31
|
+
"merge": merge,
|
|
32
|
+
"table_format": table_format,
|
|
33
|
+
"charts_to_tables": charts,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
with get_client(api_key, base_url) as client:
|
|
37
|
+
submit = client.tables(payload)
|
|
38
|
+
job = submit_and_poll(client, submit, label="extracting tables", no_wait=no_wait)
|
|
39
|
+
jid = job.get("job_id") or job.get("tables_id")
|
|
40
|
+
if no_wait and jid:
|
|
41
|
+
console.print(f"[muted]submitted →[/muted] [brand]{jid}[/brand]")
|
|
42
|
+
return
|
|
43
|
+
job = resolve_offloaded_result(client, job)
|
|
44
|
+
|
|
45
|
+
result = job.get("result") if isinstance(job.get("result"), dict) else job
|
|
46
|
+
if output:
|
|
47
|
+
output.write_text(json.dumps(result, indent=2, default=str))
|
|
48
|
+
console.print(f"[ok]✓[/ok] wrote {output}")
|
|
49
|
+
elif json_out:
|
|
50
|
+
print_json(job)
|
|
51
|
+
else:
|
|
52
|
+
print_json(result.get("tables_output") or result)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`pulse usage` — credit/page usage for the current billing period."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from pulse_cli import config as cfg
|
|
10
|
+
from pulse_cli.client import PlatformClient
|
|
11
|
+
from pulse_cli.errors import MissingApiKeyError
|
|
12
|
+
from pulse_cli.output import console, fail, is_tty, print_json, spinner_status
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt_int(v: object) -> str:
|
|
16
|
+
return f"{v:,}" if isinstance(v, (int, float)) else "—"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Bookkeeping rows in by_source that aren't actual document work.
|
|
20
|
+
_NOISE_SOURCES = {"get_job", "delete_job", "large_result_download"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def usage(
|
|
24
|
+
days: Optional[int] = typer.Option(
|
|
25
|
+
None, "--days", help="Breakdown window in days (default: current billing period)."
|
|
26
|
+
),
|
|
27
|
+
json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
|
|
28
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
29
|
+
platform_url: Optional[str] = typer.Option(
|
|
30
|
+
None, "--platform-url", envvar="PULSE_PLATFORM_URL", help="Platform API URL override."
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Show plan, pages used vs allowance, and a usage breakdown by source."""
|
|
34
|
+
conf = cfg.load(api_key_override=api_key)
|
|
35
|
+
if not conf.api_key:
|
|
36
|
+
raise MissingApiKeyError()
|
|
37
|
+
url = platform_url or conf.platform_url
|
|
38
|
+
if not url:
|
|
39
|
+
raise fail(
|
|
40
|
+
"No platform URL configured. Re-run login with it, e.g.\n"
|
|
41
|
+
" pulse login --platform-url https://platform.runpulse.com"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
start = end = None
|
|
45
|
+
if days:
|
|
46
|
+
now = datetime.now(timezone.utc)
|
|
47
|
+
start, end = (now - timedelta(days=days)).strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d")
|
|
48
|
+
|
|
49
|
+
with PlatformClient(conf.api_key, url) as pc:
|
|
50
|
+
if is_tty() and not json_out:
|
|
51
|
+
with spinner_status("fetching usage"):
|
|
52
|
+
period = pc.current_period_usage()
|
|
53
|
+
breakdown = pc.usage_breakdown(start, end)
|
|
54
|
+
else:
|
|
55
|
+
period = pc.current_period_usage()
|
|
56
|
+
breakdown = pc.usage_breakdown(start, end)
|
|
57
|
+
|
|
58
|
+
if json_out:
|
|
59
|
+
print_json({"current_period": period, "breakdown": breakdown})
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
plan = period.get("plan_name") or "—"
|
|
63
|
+
label = period.get("period_label") or period.get("period_type") or ""
|
|
64
|
+
used = period.get("pages_used")
|
|
65
|
+
allowance = period.get("page_allowance")
|
|
66
|
+
pct = period.get("usage_percentage")
|
|
67
|
+
|
|
68
|
+
console.print(f"\n[heading]plan[/heading] [body]{plan}[/body] [muted]{label}[/muted]")
|
|
69
|
+
if period.get("is_metered"):
|
|
70
|
+
rate = period.get("metered_rate")
|
|
71
|
+
console.print(
|
|
72
|
+
f"[heading]pages[/heading] [body]{_fmt_int(used)}[/body] "
|
|
73
|
+
f"[muted]metered{f' @ ${rate}/page' if rate else ''}[/muted]"
|
|
74
|
+
)
|
|
75
|
+
elif period.get("is_unlimited") or allowance is None:
|
|
76
|
+
console.print(f"[heading]pages[/heading] [body]{_fmt_int(used)}[/body] [muted]/ unlimited[/muted]")
|
|
77
|
+
else:
|
|
78
|
+
remaining = period.get("pages_remaining")
|
|
79
|
+
console.print(
|
|
80
|
+
f"[heading]pages[/heading] [body]{_fmt_int(used)}[/body] [muted]/[/muted] "
|
|
81
|
+
f"[body]{_fmt_int(allowance)}[/body]"
|
|
82
|
+
+ (f" [muted]({pct:.1f}% used, {_fmt_int(remaining)} left)[/muted]" if pct is not None else "")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
by_source = {
|
|
86
|
+
k: v for k, v in (breakdown.get("by_source") or {}).items() if k not in _NOISE_SOURCES
|
|
87
|
+
}
|
|
88
|
+
# Recompute totals from real work only (server totals include polling noise).
|
|
89
|
+
if by_source:
|
|
90
|
+
docs = sum(v.get("documents") or 0 for v in by_source.values())
|
|
91
|
+
pgs = sum(v.get("pages") or 0 for v in by_source.values())
|
|
92
|
+
else:
|
|
93
|
+
docs, pgs = breakdown.get("total_documents"), breakdown.get("total_pages")
|
|
94
|
+
|
|
95
|
+
window = f"last {days} days" if days else "this period"
|
|
96
|
+
console.print(
|
|
97
|
+
f"\n[heading]activity[/heading] [muted]{window}[/muted] "
|
|
98
|
+
f"[body]{_fmt_int(docs)}[/body] [muted]requests ·[/muted] "
|
|
99
|
+
f"[body]{_fmt_int(pgs)}[/body] [muted]pages[/muted]"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if by_source:
|
|
103
|
+
console.print("\n[heading]by source[/heading]")
|
|
104
|
+
for src, row in sorted(by_source.items(), key=lambda kv: -(kv[1].get("pages") or 0)):
|
|
105
|
+
console.print(
|
|
106
|
+
f" [body]{src:<14}[/body] [muted]{_fmt_int(row.get('documents'))} docs ·[/muted] "
|
|
107
|
+
f"[body]{_fmt_int(row.get('pages'))}[/body] [muted]pages[/muted]"
|
|
108
|
+
)
|
|
109
|
+
console.print()
|