ndi-cli 0.29.0__tar.gz → 0.30.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.
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/PKG-INFO +6 -3
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/README.md +4 -1
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/pyproject.toml +2 -2
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/pyproject.toml.orig +2 -2
- ndi_cli-0.30.0/src/ndi_cli/__init__.py +14 -0
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_cli.py +30 -2
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_docops.py +13 -3
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_entrypoint.py +8 -2
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_local.py +21 -2
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_render.py +200 -7
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_sources.py +7 -4
- ndi_cli-0.30.0/src/ndi_cli/_tools.py +455 -0
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_workspace.py +27 -2
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/commands.py +2 -0
- ndi_cli-0.29.0/src/ndi_cli/__init__.py +0 -9
- ndi_cli-0.29.0/src/ndi_cli/_tools.py +0 -264
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/LICENSE +0 -0
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_config.py +0 -0
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_jobs.py +0 -0
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_login.py +0 -0
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_output.py +0 -0
- {ndi_cli-0.29.0 → ndi_cli-0.30.0}/src/ndi_cli/_systemone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ndi-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.0
|
|
4
4
|
Summary: NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools
|
|
5
5
|
Keywords: ndi,cli,document-intelligence,coding-agents
|
|
6
6
|
Author: Nace AI
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: ndi-sdk>=0.
|
|
19
|
+
Requires-Dist: ndi-sdk>=0.51,<1
|
|
20
20
|
Requires-Dist: truststore>=0.10.4,<1
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Project-URL: Homepage, https://ndi-api.nace.ai
|
|
@@ -47,11 +47,12 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
|
|
|
47
47
|
| `ndi login` | Authorize and save the API key |
|
|
48
48
|
| `ndi version` | CLI + SDK versions |
|
|
49
49
|
| `ndi upload FILE` | Stage bytes; prints `ndi://upload/<id>` |
|
|
50
|
+
| `ndi upload-delete UPLOAD_ID` | Delete an unused upload; used uploads require deleting their jobs first |
|
|
50
51
|
| `ndi parse SOURCE` | Markdown / text / blocks |
|
|
51
52
|
| `ndi extract SOURCE -s SCHEMA` | Structured extract (`--validate` checks a schema with no job) |
|
|
52
53
|
| `ndi split SOURCE --class id:label` | Logical sections |
|
|
53
54
|
| `ndi classify SOURCE --class id:label` | Labels (refuses `jobid://`) |
|
|
54
|
-
| `ndi ground SOURCE --target id=TEXT` | Locate quoted text (
|
|
55
|
+
| `ndi ground SOURCE --target id=TEXT` | Locate quoted text (after search, poll any `grounding_job_ids`; when none are planned, use `ndi ground` explicitly) |
|
|
55
56
|
| `ndi job ID` / `ndi jobs` / `ndi cancel ID` | Inspect or cancel jobs |
|
|
56
57
|
| `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
|
|
57
58
|
| `ndi workspace global-context [--set TEXT\|--file PATH\|--clear]` | Show, replace, or remove (`--clear`) the background every query without `--context` runs with; `--no-context` on a query runs with none |
|
|
@@ -124,3 +125,5 @@ conflicts, and treats the multipart `Content-Type` as advisory.
|
|
|
124
125
|
|
|
125
126
|
See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
|
|
126
127
|
reference. `SKILL.md` is the agent-facing guide to reading a workspace with `ndi`.
|
|
128
|
+
|
|
129
|
+
To select existing customer storage, run `ndi storage-connections list`, then `ndi workspace create "Customer workspace" --storage-connection CONNECTION_ID`. Customer storage is not generally available; disabled deployments return `not_implemented` for every connection operation (including `storage-connections list`) and for creating a workspace with a connection, retries included; enabled deployments with incomplete configuration return `storage_unavailable`. Set up connections and rotate credentials through the console or Python/TypeScript SDK. The CLI does not accept cloud secrets as command-line arguments.
|
|
@@ -23,11 +23,12 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
|
|
|
23
23
|
| `ndi login` | Authorize and save the API key |
|
|
24
24
|
| `ndi version` | CLI + SDK versions |
|
|
25
25
|
| `ndi upload FILE` | Stage bytes; prints `ndi://upload/<id>` |
|
|
26
|
+
| `ndi upload-delete UPLOAD_ID` | Delete an unused upload; used uploads require deleting their jobs first |
|
|
26
27
|
| `ndi parse SOURCE` | Markdown / text / blocks |
|
|
27
28
|
| `ndi extract SOURCE -s SCHEMA` | Structured extract (`--validate` checks a schema with no job) |
|
|
28
29
|
| `ndi split SOURCE --class id:label` | Logical sections |
|
|
29
30
|
| `ndi classify SOURCE --class id:label` | Labels (refuses `jobid://`) |
|
|
30
|
-
| `ndi ground SOURCE --target id=TEXT` | Locate quoted text (
|
|
31
|
+
| `ndi ground SOURCE --target id=TEXT` | Locate quoted text (after search, poll any `grounding_job_ids`; when none are planned, use `ndi ground` explicitly) |
|
|
31
32
|
| `ndi job ID` / `ndi jobs` / `ndi cancel ID` | Inspect or cancel jobs |
|
|
32
33
|
| `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
|
|
33
34
|
| `ndi workspace global-context [--set TEXT\|--file PATH\|--clear]` | Show, replace, or remove (`--clear`) the background every query without `--context` runs with; `--no-context` on a query runs with none |
|
|
@@ -100,3 +101,5 @@ conflicts, and treats the multipart `Content-Type` as advisory.
|
|
|
100
101
|
|
|
101
102
|
See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
|
|
102
103
|
reference. `SKILL.md` is the agent-facing guide to reading a workspace with `ndi`.
|
|
104
|
+
|
|
105
|
+
To select existing customer storage, run `ndi storage-connections list`, then `ndi workspace create "Customer workspace" --storage-connection CONNECTION_ID`. Customer storage is not generally available; disabled deployments return `not_implemented` for every connection operation (including `storage-connections list`) and for creating a workspace with a connection, retries included; enabled deployments with incomplete configuration return `storage_unavailable`. Set up connections and rotate credentials through the console or Python/TypeScript SDK. The CLI does not accept cloud secrets as command-line arguments.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ndi-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.30.0"
|
|
4
4
|
description = "NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Typing :: Typed",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"ndi-sdk>=0.
|
|
27
|
+
"ndi-sdk>=0.51,<1",
|
|
28
28
|
"truststore>=0.10.4,<1",
|
|
29
29
|
]
|
|
30
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ndi-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.30.0"
|
|
4
4
|
description = "NDI platform CLI: document operations, jobs, workspaces, and the agent workspace tools"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Nace AI", email = "engineering@nace.ai" }
|
|
@@ -27,7 +27,7 @@ classifiers = [
|
|
|
27
27
|
"Typing :: Typed",
|
|
28
28
|
]
|
|
29
29
|
dependencies = [
|
|
30
|
-
"ndi-sdk>=0.
|
|
30
|
+
"ndi-sdk>=0.51,<1",
|
|
31
31
|
"truststore>=0.10.4,<1",
|
|
32
32
|
]
|
|
33
33
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""``ndi`` — the NDI platform CLI.
|
|
2
|
+
|
|
3
|
+
Document operations, jobs, and workspace lifecycle wrap ``ndi-sdk`` ``/v1``
|
|
4
|
+
methods. Inside an agent sandbox the workspace tools (``folder-metadata``,
|
|
5
|
+
``file-metadata``, ``read-file``, ``ask-file``, ``run-sql``, ``hybrid-search``, ``grep``)
|
|
6
|
+
are the whole surface; outside one only ``ask-file`` is.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.30.0"
|
|
10
|
+
|
|
11
|
+
#: Set (even empty) only inside an agent run: every command then goes to the worker over this socket.
|
|
12
|
+
SOCKET_ENV = "NDI_CLI_SOCKET"
|
|
13
|
+
#: Set by the worker when the deployment enables ``ndi grep`` (``NDI_GREP``); without it the verb does not exist.
|
|
14
|
+
GREP_ENV = "NDI_GREP"
|
|
@@ -36,7 +36,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
36
36
|
# agent run", and must not silently unlock the full platform CLI.
|
|
37
37
|
in_agent_run = socket_path is not None
|
|
38
38
|
parser = _build_parser(in_agent_run=in_agent_run)
|
|
39
|
-
args = parser.
|
|
39
|
+
args, extras = parser.parse_known_args(_bind_regexp(sys.argv[1:] if argv is None else list(argv)))
|
|
40
|
+
# argparse leaves a positional that follows an option unparsed (`grep PATTERN -i PATH`); a command whose
|
|
41
|
+
# positionals end in a list takes such strays into it, as grep and rg take paths after their flags.
|
|
42
|
+
extra_into = getattr(args, "extra_positionals", None)
|
|
43
|
+
if extras and extra_into and not any(extra.startswith("-") for extra in extras):
|
|
44
|
+
setattr(args, extra_into, [*(getattr(args, extra_into) or []), *extras])
|
|
45
|
+
args.folded_positionals = extras
|
|
46
|
+
elif extras:
|
|
47
|
+
parser.error(f"unrecognized arguments: {' '.join(extras)}")
|
|
40
48
|
if args.command is None:
|
|
41
49
|
parser.print_help()
|
|
42
50
|
return 2
|
|
@@ -98,6 +106,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
98
106
|
return 2
|
|
99
107
|
|
|
100
108
|
|
|
109
|
+
def _bind_regexp(argv: list[str]) -> list[str]:
|
|
110
|
+
"""``grep -e PATTERN``: the word after -e is the pattern even when it starts with "-" (that is what -e is for);
|
|
111
|
+
argparse would read ``-e -5%`` as two options."""
|
|
112
|
+
if "grep" not in argv:
|
|
113
|
+
return argv
|
|
114
|
+
bound: list[str] = []
|
|
115
|
+
words = iter(argv)
|
|
116
|
+
for word in words:
|
|
117
|
+
if word in ("-e", "--regexp") and (pattern := next(words, None)) is not None:
|
|
118
|
+
bound.append(f"--regexp={pattern}")
|
|
119
|
+
else:
|
|
120
|
+
bound.append(word)
|
|
121
|
+
return bound
|
|
122
|
+
|
|
123
|
+
|
|
101
124
|
def _run_tool(args: argparse.Namespace, tools: SyncTools, workspace_id: str, printer) -> int:
|
|
102
125
|
"""Run a tool once per positional target (``file-metadata a b``), each printed as its own call would print it.
|
|
103
126
|
|
|
@@ -106,6 +129,10 @@ def _run_tool(args: argparse.Namespace, tools: SyncTools, workspace_id: str, pri
|
|
|
106
129
|
the caller that path's error line, not every other path's answer.
|
|
107
130
|
"""
|
|
108
131
|
targets = getattr(args, "targets", [None])
|
|
132
|
+
names = getattr(args, "name_contains", None)
|
|
133
|
+
if isinstance(names, list) and len(names) > 1: # folder-metadata --name-contains A --name-contains B
|
|
134
|
+
targets = [(target, name) for target in targets for name in names]
|
|
135
|
+
args.label_targets = True
|
|
109
136
|
if len(targets) == 1:
|
|
110
137
|
args.target = targets[0]
|
|
111
138
|
return printer(args, args.run(tools, workspace_id, args))
|
|
@@ -125,7 +152,8 @@ def _run_tool(args: argparse.Namespace, tools: SyncTools, workspace_id: str, pri
|
|
|
125
152
|
if index:
|
|
126
153
|
print()
|
|
127
154
|
if getattr(args, "label_targets", False):
|
|
128
|
-
|
|
155
|
+
target = per_target.target
|
|
156
|
+
print(f"==> {f'{target[0]} --name-contains {target[1]}' if isinstance(target, tuple) else target} <==")
|
|
129
157
|
if isinstance(response, NdiError):
|
|
130
158
|
status = max(status, _report_error(response, target=per_target.target))
|
|
131
159
|
else:
|
|
@@ -37,10 +37,13 @@ DEFAULT_PARALLEL = 4
|
|
|
37
37
|
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
|
|
38
38
|
upload = sub.add_parser("upload", parents=[common], help="Stage a file for a document operation.")
|
|
39
39
|
upload.add_argument("file", help="Local file to stage.")
|
|
40
|
-
upload.add_argument("--ttl", type=int, default=None, dest="ttl_seconds", metavar="SECONDS")
|
|
41
40
|
upload.add_argument("--file-name", default=None, help="Override the uploaded name.")
|
|
42
41
|
upload.set_defaults(run=_run_upload, family="docops", needs_workspace=False, needs_client=True)
|
|
43
42
|
|
|
43
|
+
delete_upload = sub.add_parser("upload-delete", parents=[common], help="Erase an unused upload.")
|
|
44
|
+
delete_upload.add_argument("upload_id", help="Upload ID returned by upload; used uploads require deleting their jobs.")
|
|
45
|
+
delete_upload.set_defaults(run=_run_delete_upload, family="docops", needs_workspace=False, needs_client=True)
|
|
46
|
+
|
|
44
47
|
parse = sub.add_parser("parse", parents=[common], help="Turn a document into markdown, text, and blocks.")
|
|
45
48
|
_add_source_args(parse)
|
|
46
49
|
parse.add_argument("-p", "--pages", default=None, metavar="SPEC", help="1-based pages, e.g. 3 or 1-5,8.")
|
|
@@ -88,7 +91,9 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
|
88
91
|
|
|
89
92
|
|
|
90
93
|
def _add_source_args(parser: argparse.ArgumentParser, *, required: bool = True) -> None:
|
|
91
|
-
parser.add_argument(
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"source", nargs=None if required else "?", help="File, directory, URL, ndi://, jobid://, ws://FILE_ID, or -."
|
|
96
|
+
)
|
|
92
97
|
parser.add_argument("--file-name", default=None, help="Name for stdin bytes or a URL without a filename.")
|
|
93
98
|
parser.add_argument("-j", type=int, default=DEFAULT_PARALLEL, dest="parallel", help="Parallel jobs for a directory.")
|
|
94
99
|
add_job_output_args(parser)
|
|
@@ -99,12 +104,17 @@ def _add_class_args(parser: argparse.ArgumentParser) -> None:
|
|
|
99
104
|
parser.add_argument("--classes", dest="classes_file", default=None, help="JSON array of classes.")
|
|
100
105
|
|
|
101
106
|
|
|
107
|
+
def _run_delete_upload(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
108
|
+
client.documents.delete_upload(args.upload_id)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
102
112
|
def _run_upload(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
103
113
|
path = Path(args.file)
|
|
104
114
|
if not path.is_file():
|
|
105
115
|
print(f"error: file not found: {args.file}", file=sys.stderr)
|
|
106
116
|
return 2
|
|
107
|
-
upload = client.documents.create_upload(path, file_name=args.file_name or path.name
|
|
117
|
+
upload = client.documents.create_upload(path, file_name=args.file_name or path.name)
|
|
108
118
|
print(f"ndi://upload/{upload.upload_id}")
|
|
109
119
|
print(f"upload {upload.upload_id} {upload.file_name} {upload.size_bytes}B", file=sys.stderr)
|
|
110
120
|
return 0
|
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import io
|
|
6
|
+
import os
|
|
6
7
|
import sys
|
|
7
8
|
|
|
8
|
-
import
|
|
9
|
+
from ndi_cli import SOCKET_ENV
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def main(argv: list[str] | None = None) -> int:
|
|
12
13
|
"""Enable native certificate verification and UTF-8 stdio before loading the CLI and SDK."""
|
|
13
|
-
|
|
14
|
+
# An agent run's commands reach the worker over the local socket, never TLS, and the
|
|
15
|
+
# trust-store patch (it also loads urllib3 and requests) would slow every one of them.
|
|
16
|
+
if SOCKET_ENV not in os.environ:
|
|
17
|
+
import truststore # noqa: PLC0415
|
|
18
|
+
|
|
19
|
+
truststore.inject_into_ssl()
|
|
14
20
|
# Piped stdio otherwise follows the Windows ANSI code page, which cannot encode every character a result may hold.
|
|
15
21
|
for stream in (sys.stdout, sys.stderr):
|
|
16
22
|
if isinstance(stream, io.TextIOWrapper):
|
|
@@ -17,9 +17,9 @@ from ndi_sdk.errors import ErrorBody, NdiConnectionError, NdiTimeoutError, error
|
|
|
17
17
|
from ndi_sdk.models.tools import QaFileRequest
|
|
18
18
|
from pydantic import BaseModel, Field, TypeAdapter
|
|
19
19
|
|
|
20
|
+
from ndi_cli import SOCKET_ENV as SOCKET_ENV # re-exported: the worker imports it from here
|
|
20
21
|
from ndi_cli.commands import WorkspaceCommand
|
|
21
22
|
|
|
22
|
-
SOCKET_ENV = "NDI_CLI_SOCKET"
|
|
23
23
|
TOKEN_ENV = "NDI_CLI_TOKEN"
|
|
24
24
|
MAX_MESSAGE_BYTES = 16 * 1024 * 1024
|
|
25
25
|
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
@@ -55,6 +55,8 @@ class ReadFileRequest(BaseModel):
|
|
|
55
55
|
pages: list[int] | None = None
|
|
56
56
|
component: str | None = None
|
|
57
57
|
max_chars: int | None = None
|
|
58
|
+
line_start: int | None = None
|
|
59
|
+
line_end: int | None = None
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
class RunSqlRequest(BaseModel):
|
|
@@ -68,6 +70,18 @@ class SearchRequest(BaseModel):
|
|
|
68
70
|
categories: list[str] | None = None
|
|
69
71
|
|
|
70
72
|
|
|
73
|
+
class GrepRequest(BaseModel):
|
|
74
|
+
pattern: str
|
|
75
|
+
path_prefix: str | None = None
|
|
76
|
+
paths: list[str] | None = None
|
|
77
|
+
before: int = 0
|
|
78
|
+
after: int = 0
|
|
79
|
+
files_only: bool = False
|
|
80
|
+
ignore_case: bool = False
|
|
81
|
+
fixed_strings: bool = False
|
|
82
|
+
max_matches: int = 50
|
|
83
|
+
|
|
84
|
+
|
|
71
85
|
class FolderCommand(BaseModel):
|
|
72
86
|
command: Literal[WorkspaceCommand.FOLDER_METADATA] = WorkspaceCommand.FOLDER_METADATA
|
|
73
87
|
body: FolderMetadataRequest
|
|
@@ -98,8 +112,13 @@ class SearchCommand(BaseModel):
|
|
|
98
112
|
body: SearchRequest
|
|
99
113
|
|
|
100
114
|
|
|
115
|
+
class GrepCommand(BaseModel):
|
|
116
|
+
command: Literal[WorkspaceCommand.GREP] = WorkspaceCommand.GREP
|
|
117
|
+
body: GrepRequest
|
|
118
|
+
|
|
119
|
+
|
|
101
120
|
Command = Annotated[
|
|
102
|
-
FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand,
|
|
121
|
+
FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand | GrepCommand,
|
|
103
122
|
Field(discriminator="command"),
|
|
104
123
|
]
|
|
105
124
|
COMMAND = TypeAdapter(Command)
|
|
@@ -10,8 +10,10 @@ output limit (``_output.FULL_OUTPUT_LIMIT``); ``--json`` always prints it whole.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import os
|
|
13
14
|
import sys
|
|
14
15
|
import uuid
|
|
16
|
+
from collections import Counter
|
|
15
17
|
|
|
16
18
|
import html
|
|
17
19
|
import re
|
|
@@ -47,6 +49,8 @@ from ndi_sdk.models.workspaces import Workspace, WorkspaceStats
|
|
|
47
49
|
from ndi_sdk.models.tools import (
|
|
48
50
|
FileMetadataToolResponse,
|
|
49
51
|
FolderMetadataResponse,
|
|
52
|
+
GrepMatch,
|
|
53
|
+
GrepResponse,
|
|
50
54
|
QaFileResponse,
|
|
51
55
|
ReadFileResponse,
|
|
52
56
|
RunSqlResponse,
|
|
@@ -56,6 +60,19 @@ from ndi_sdk.models.tools import (
|
|
|
56
60
|
_SNIPPET_LIMIT = 500
|
|
57
61
|
|
|
58
62
|
|
|
63
|
+
#: What a document's rows table is and what it is for, printed once per table by file-metadata.
|
|
64
|
+
_ROWS_TABLE_NOTE = (
|
|
65
|
+
"every row this document's tables print, one SQL table for the document (a section's rows are the ones "
|
|
66
|
+
"printed on its pages). `page`, `table_no`, `row_no` place a row; the other columns are its cells, left to "
|
|
67
|
+
"right (`c1`, `c2`, … unless the tables repeat a header); a header printed once is among a table's first rows, "
|
|
68
|
+
"and `context` is the text printed above the table, followed by any system alignment warning. "
|
|
69
|
+
"Only the fixed Parse alignment sentence marks unverified columns; other lookalikes are printed source text. "
|
|
70
|
+
"Keep the warning separate from printed headings. "
|
|
71
|
+
"For counting, totalling or ranking rows; to read one "
|
|
72
|
+
"printed table, read-file its page or lines"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
59
76
|
def _size(num_bytes: int) -> str:
|
|
60
77
|
value = float(num_bytes)
|
|
61
78
|
for unit in ("B", "KB", "MB", "GB"):
|
|
@@ -143,22 +160,97 @@ def _locator(locator) -> str:
|
|
|
143
160
|
return locator.kind
|
|
144
161
|
|
|
145
162
|
|
|
163
|
+
#: Hits whose snippets agree on this many leading characters (digits masked) are one passage printed in several
|
|
164
|
+
#: files: a serial corpus restates its headings, tables of contents and boilerplate in every issue.
|
|
165
|
+
_SAME_PASSAGE_CHARS = 120
|
|
166
|
+
_DIGITS = re.compile(r"\d")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _passage_key(hit: Evidence) -> str | None:
|
|
170
|
+
text = _flatten_markup(hit.snippet or "")
|
|
171
|
+
return _DIGITS.sub("#", " ".join(text.split()))[:_SAME_PASSAGE_CHARS] or None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _same_passage_groups(hits: list[Evidence]) -> dict[int, list[Evidence]]:
|
|
175
|
+
"""For each hit (by position) that leads a run of hits sharing its passage, the later hits it stands for.
|
|
176
|
+
|
|
177
|
+
Only hits in **other files** fold under a leader: two hits in one file are two places to read. Folded hits
|
|
178
|
+
keep their rank order; the leader is the best-ranked."""
|
|
179
|
+
folded: dict[int, list[Evidence]] = {}
|
|
180
|
+
taken: set[int] = set()
|
|
181
|
+
for i, hit in enumerate(hits):
|
|
182
|
+
if i in taken or (key := _passage_key(hit)) is None:
|
|
183
|
+
continue
|
|
184
|
+
same = [
|
|
185
|
+
other
|
|
186
|
+
for j, other in enumerate(hits[i + 1 :], start=i + 1)
|
|
187
|
+
if j not in taken and other.path != hit.path and _passage_key(other) == key
|
|
188
|
+
]
|
|
189
|
+
if same:
|
|
190
|
+
folded[i] = same
|
|
191
|
+
taken.update(j for j, other in enumerate(hits) if other in same)
|
|
192
|
+
return folded
|
|
193
|
+
|
|
194
|
+
|
|
146
195
|
def _evidence_lines(hits: list[Evidence]) -> list[str]:
|
|
147
196
|
lines: list[str] = []
|
|
197
|
+
folded = _same_passage_groups(hits)
|
|
198
|
+
hidden = {id(other) for same in folded.values() for other in same}
|
|
148
199
|
for index, hit in enumerate(hits, start=1):
|
|
200
|
+
if id(hit) in hidden:
|
|
201
|
+
continue
|
|
149
202
|
where = f" [{hit.component}]" if hit.component else ""
|
|
150
203
|
page = f", page {hit.page}" if hit.page is not None else ""
|
|
151
204
|
at = f" @ {_locator(hit.locator)}" if hit.locator is not None else ""
|
|
152
|
-
|
|
205
|
+
span = ""
|
|
206
|
+
if hit.line_start is not None and hit.line_end is not None:
|
|
207
|
+
span = f" · lines {hit.line_start}-{hit.line_end}" if hit.line_end != hit.line_start else f" · line {hit.line_start}"
|
|
208
|
+
lines.append(f"{index}. {hit.path}{where} (score {hit.relevance_score:.3f}{page}){at}{span}")
|
|
153
209
|
if hit.sql_table:
|
|
154
210
|
lines.append(f" sql table: {hit.sql_table}")
|
|
155
211
|
if hit.snippet:
|
|
156
212
|
lines.append(f" {_clip(_flatten_markup(hit.snippet))}")
|
|
157
213
|
if hit.why:
|
|
158
214
|
lines.append(f" why: {_clip(hit.why, 300)}")
|
|
215
|
+
if same := folded.get(index - 1):
|
|
216
|
+
where = ", ".join(f"{other.path}" + (f" p.{other.page}" if other.page is not None else "") for other in same)
|
|
217
|
+
lines.append(f" same passage in {len(same)} more file(s): {where}")
|
|
159
218
|
return lines
|
|
160
219
|
|
|
161
220
|
|
|
221
|
+
def folder_names(response: FolderMetadataResponse) -> str:
|
|
222
|
+
"""``folder-metadata --names-only``: the paths, one per line, and a last line only when the list was cut."""
|
|
223
|
+
lines = [entry.path for entry in response.file_entries]
|
|
224
|
+
if response.files_truncated:
|
|
225
|
+
lines.append(f"... more files: narrow with a directory or --name-contains, or --start-after {lines[-1]}")
|
|
226
|
+
elif not lines:
|
|
227
|
+
lines.append("no files")
|
|
228
|
+
return "\n".join(lines)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
#: A truncated page is digested only when its names share this much of a common prefix: fewer and the names
|
|
232
|
+
#: are unrelated, and a row each says more than a prefix would.
|
|
233
|
+
_DIGEST_MIN_PREFIX = 4
|
|
234
|
+
#: ...and holds at least this many entries: a short page reads faster as rows.
|
|
235
|
+
_DIGEST_MIN_ENTRIES = 20
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _listing_digest(entries) -> str | None:
|
|
239
|
+
"""One line for a page of same-shaped names: ``treasury_bulletin_*.pdf, treasury_bulletin_1939_01.pdf ...
|
|
240
|
+
treasury_bulletin_1947_05.pdf, 2.2MB-7.5MB, ingested x100`` — or None when the names share no prefix."""
|
|
241
|
+
names = [entry.path.rsplit("/", 1)[-1] for entry in entries]
|
|
242
|
+
prefix = os.path.commonprefix(names)
|
|
243
|
+
if len(names) < _DIGEST_MIN_ENTRIES or len(prefix) < _DIGEST_MIN_PREFIX:
|
|
244
|
+
return None
|
|
245
|
+
suffixes = {name.rsplit(".", 1)[-1] for name in names if "." in name}
|
|
246
|
+
shape = prefix + "*" + (f".{suffixes.pop()}" if len(suffixes) == 1 else "")
|
|
247
|
+
sizes = [entry.size_bytes for entry in entries if entry.size_bytes is not None]
|
|
248
|
+
span = f", {_size(min(sizes))}-{_size(max(sizes))}" if sizes else ""
|
|
249
|
+
statuses = Counter(entry.ingestion_status for entry in entries)
|
|
250
|
+
status = ", ".join(f"{name} x{count}" for name, count in sorted(statuses.items()))
|
|
251
|
+
return f"({shape}): {entries[0].path} ... {entries[-1].path}{span}, {status}"
|
|
252
|
+
|
|
253
|
+
|
|
162
254
|
def folder_metadata(response: FolderMetadataResponse) -> str:
|
|
163
255
|
lines = [f"directory: {response.directory}", f"files in scope: {response.overall_files}"]
|
|
164
256
|
if response.categories:
|
|
@@ -172,7 +264,14 @@ def folder_metadata(response: FolderMetadataResponse) -> str:
|
|
|
172
264
|
failed = f" ({sub.failed_files} failed)" if sub.failed_files else ""
|
|
173
265
|
cats = f" categories: {_categories(sub.top_categories, limit=3)}" if sub.top_categories else ""
|
|
174
266
|
lines.append(f" {sub.name}/ {sub.overall_files} files{failed}{cats}")
|
|
175
|
-
if response.file_entries:
|
|
267
|
+
if response.file_entries and response.files_truncated and (digest := _listing_digest(response.file_entries)):
|
|
268
|
+
# A page of a larger listing: the agent cannot act on a hundred rows that stop short of the file it
|
|
269
|
+
# needs, so the page reads as what the names have in common, and how to list or narrow them.
|
|
270
|
+
lines.append(f"files listed ({len(response.file_entries)} of {response.overall_files}, truncated) {digest}")
|
|
271
|
+
lines.append(
|
|
272
|
+
" every path: --names-only (pipe to grep); by name: --name-contains TEXT; by content: hybrid-search or grep"
|
|
273
|
+
)
|
|
274
|
+
elif response.file_entries:
|
|
176
275
|
cut = " (truncated — resume with --start-after, or narrow)" if response.files_truncated else ""
|
|
177
276
|
lines.append(f"files listed ({len(response.file_entries)}){cut}:")
|
|
178
277
|
for entry in response.file_entries:
|
|
@@ -358,10 +457,7 @@ def file_metadata(response: FileMetadataToolResponse) -> str:
|
|
|
358
457
|
shared = comp.kind == "section" and comp.columns and any(col.name == "origin" for col in comp.columns)
|
|
359
458
|
shown = comp.table_name in shown_tables
|
|
360
459
|
if shared and not shown:
|
|
361
|
-
lines.append(
|
|
362
|
-
f" {comp.table_name}: one SQL table for every section that lists it; "
|
|
363
|
-
"a section's rows are the ones printed on its pages"
|
|
364
|
-
)
|
|
460
|
+
lines.append(f" {comp.table_name}: {_ROWS_TABLE_NOTE}")
|
|
365
461
|
if comp.columns and not (shared and shown):
|
|
366
462
|
lines.extend(_column_lines(comp.columns, bool(comp.columns_truncated)))
|
|
367
463
|
if comp.table_preamble and not shown:
|
|
@@ -371,6 +467,11 @@ def file_metadata(response: FileMetadataToolResponse) -> str:
|
|
|
371
467
|
shown_tables.add(comp.table_name)
|
|
372
468
|
if comp.error:
|
|
373
469
|
lines.append(f" error: {comp.error}")
|
|
470
|
+
if response.figures:
|
|
471
|
+
lines.append(f'figures ({len(response.figures)}) — ask one with: ask-file PATH "question" --component "<name>":')
|
|
472
|
+
for figure in response.figures:
|
|
473
|
+
seen = figure.description or (f"text: {figure.text}" if figure.text else "not described")
|
|
474
|
+
lines.append(f" [{figure.name}] page {figure.page} — {' '.join(seen.split())}")
|
|
374
475
|
return "\n".join(lines)
|
|
375
476
|
|
|
376
477
|
|
|
@@ -429,6 +530,11 @@ def _column_lines(columns: list, truncated: bool) -> list[str]:
|
|
|
429
530
|
|
|
430
531
|
def read_file_footer(response: ReadFileResponse) -> str:
|
|
431
532
|
parts = [f"read {response.path}"]
|
|
533
|
+
if response.line_count is not None:
|
|
534
|
+
if response.line_end is None:
|
|
535
|
+
parts.append(f"no lines (the text has {response.line_count})")
|
|
536
|
+
else:
|
|
537
|
+
parts.append(f"lines {response.line_start}-{response.line_end} of {response.line_count}")
|
|
432
538
|
if response.pages_returned:
|
|
433
539
|
parts.append(f"pages {_page_ranges(response.pages_returned)}")
|
|
434
540
|
if response.components_returned:
|
|
@@ -516,6 +622,90 @@ def search(response: SearchResponse) -> str:
|
|
|
516
622
|
return "\n".join(lines)
|
|
517
623
|
|
|
518
624
|
|
|
625
|
+
def grep(response: GrepResponse) -> str:
|
|
626
|
+
"""Matches grouped by file and component, ``LINE[ p.PAGE]: text`` like grep -n, with the follow-up names.
|
|
627
|
+
|
|
628
|
+
A tab, image or recording match has no line: it is printed bare, and a closing note says where its answer is.
|
|
629
|
+
A grep that found nothing shows its near misses the same way, under a line saying what they are.
|
|
630
|
+
"""
|
|
631
|
+
lines = _grep_lines(response.matches)
|
|
632
|
+
if not response.matches:
|
|
633
|
+
lines.append("no matches")
|
|
634
|
+
if response.near_misses:
|
|
635
|
+
lines.append("nearest lines (most of the pattern's words, not the pattern as written):")
|
|
636
|
+
lines += _grep_lines(response.near_misses)
|
|
637
|
+
tail = (
|
|
638
|
+
f"{len(response.matches)} matching lines shown; {response.files_matched} of {response.files_scanned} files read matched"
|
|
639
|
+
)
|
|
640
|
+
# Index mode reads every file whose indexed text holds the pattern's words; say so, or an agent takes the answer
|
|
641
|
+
# for a sample and searches again.
|
|
642
|
+
tail += " (the files holding the pattern's words; the rest of the scope lacks them)" if response.mode == "index" else ""
|
|
643
|
+
if response.truncated:
|
|
644
|
+
tail += f"; STOPPED at the limit — {response.files_unscanned} more files may match: raise -m or narrow the PATHs"
|
|
645
|
+
lines.append(tail)
|
|
646
|
+
lines += [f"note: {note}" for note in response.notes]
|
|
647
|
+
kinds = {match.kind for match in response.matches}
|
|
648
|
+
if "tab" in kinds:
|
|
649
|
+
note = (
|
|
650
|
+
"Tab matches are pointers: a tab's stored text is a preview of its rows (a parquet's, a column profile), "
|
|
651
|
+
"so the answer — and any row missing here — very likely comes from run-sql on the sql table shown."
|
|
652
|
+
)
|
|
653
|
+
if any(match.kind == "tab" and match.sql_table is None for match in response.matches):
|
|
654
|
+
note += " A tab shown without one has no table: read it with read-file --component."
|
|
655
|
+
lines.append(note)
|
|
656
|
+
if kinds & {"image", "media"}:
|
|
657
|
+
lines.append("Image and recording matches are pointers (an image's text or summary matched): read them with ask-file.")
|
|
658
|
+
return "\n".join(lines)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _grep_lines(matches: list[GrepMatch]) -> list[str]:
|
|
662
|
+
lines: list[str] = []
|
|
663
|
+
current: tuple[str, str | None] | None = None
|
|
664
|
+
current_table: tuple[str, str | None, int] | None = None
|
|
665
|
+
shown = 0 # last line printed in the current file/component, so overlapping context prints once
|
|
666
|
+
for match in matches:
|
|
667
|
+
if (match.path, match.component) != current:
|
|
668
|
+
current, shown = (match.path, match.component), 0
|
|
669
|
+
label = match.component or (match.kind if match.kind in {"image", "media"} else None)
|
|
670
|
+
where = f" [{label}]" if label else ""
|
|
671
|
+
table = f" (sql table: {match.sql_table})" if match.sql_table else ""
|
|
672
|
+
lines.append(f"{match.path}{where}{table}")
|
|
673
|
+
if match.line is None:
|
|
674
|
+
lines.append(f" {match.text}")
|
|
675
|
+
continue
|
|
676
|
+
page = f" p.{match.page}" if match.page is not None else ""
|
|
677
|
+
table = match.table
|
|
678
|
+
if table is not None and (match.path, match.component, table.first_line) != current_table:
|
|
679
|
+
# A row's cells mean nothing without their column names: each table's lines, title and header, once.
|
|
680
|
+
current_table, shown = (match.path, match.component, table.first_line), 0 # the block separates
|
|
681
|
+
if table.rows is not None: # a table stored as one HTML line: its header once, then each matching row
|
|
682
|
+
lines.append(
|
|
683
|
+
f" [table on line {table.first_line}, {table.rows} rows]" + (f" {table.title}" if table.title else "")
|
|
684
|
+
)
|
|
685
|
+
if table.header and match.text != table.header: # a match on the header itself is the header
|
|
686
|
+
lines.append(f" header: {table.header}")
|
|
687
|
+
lines.append(f" {match.line}{page}: {match.text}")
|
|
688
|
+
continue
|
|
689
|
+
span = f" [table lines {table.first_line}-{table.last_line}]"
|
|
690
|
+
if match.line < table.first_line: # the match is the table's title
|
|
691
|
+
lines += [f" {match.line}{page}: {match.text}", f"{span} {table.first_line}: {table.header}"]
|
|
692
|
+
continue
|
|
693
|
+
lines.append(span + (f" {table.title}" if table.title else ""))
|
|
694
|
+
if match.line != table.first_line and table.first_line < match.line - len(match.context_before):
|
|
695
|
+
lines.append(f" {table.first_line}: {table.header}")
|
|
696
|
+
if match.context_before or match.context_after: # grep -A/-B/-C: context lines print as N- text
|
|
697
|
+
first = match.line - len(match.context_before)
|
|
698
|
+
if shown and first > shown + 1:
|
|
699
|
+
lines.append(" --")
|
|
700
|
+
lines += [f" {n}- {text}" for n, text in enumerate(match.context_before, start=first) if n > shown]
|
|
701
|
+
lines.append(f" {match.line}{page}: {match.text}")
|
|
702
|
+
lines += [f" {n}- {text}" for n, text in enumerate(match.context_after, start=match.line + 1) if n > match.line]
|
|
703
|
+
shown = match.line + len(match.context_after)
|
|
704
|
+
continue
|
|
705
|
+
lines.append(f" {match.line}{page}: {match.text}")
|
|
706
|
+
return lines
|
|
707
|
+
|
|
708
|
+
|
|
519
709
|
def parse_job(job: Job) -> str:
|
|
520
710
|
result = job.result
|
|
521
711
|
if not isinstance(result, ParseResult):
|
|
@@ -741,7 +931,10 @@ def automatic_search_job(job: Job) -> str:
|
|
|
741
931
|
body = filtered_search_job(job.model_copy(update={"result": output}))
|
|
742
932
|
else:
|
|
743
933
|
body = search_job(job.model_copy(update={"result": output}))
|
|
744
|
-
|
|
934
|
+
routing = result.routing
|
|
935
|
+
# The reason explains the router's choice, so a hand-over names both routes.
|
|
936
|
+
route = routing.final if routing.selected == routing.final else f"{routing.selected} → {routing.final} (no fact answer)"
|
|
937
|
+
return f"route: {route} — {routing.reason}\noutput job: {result.output_job_id}\n{body}"
|
|
745
938
|
|
|
746
939
|
|
|
747
940
|
def search_job(job: Job) -> str:
|
|
@@ -18,6 +18,7 @@ from ndi_sdk.models.common import DocumentSource, ParseResultSource, UploadSourc
|
|
|
18
18
|
JOBID_PREFIX = "jobid://"
|
|
19
19
|
UPLOAD_PREFIX = "ndi://upload/"
|
|
20
20
|
WS_PREFIX = "ws://"
|
|
21
|
+
_WS_FILE_ID_HINT = "workspace files are ws://FILE_ID; find the id with `ndi files list`"
|
|
21
22
|
_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
|
22
23
|
SUPPORTED_SUFFIXES = frozenset(
|
|
23
24
|
{
|
|
@@ -165,9 +166,10 @@ def resolve_source(
|
|
|
165
166
|
if spec.startswith(WS_PREFIX):
|
|
166
167
|
if not workspace_id:
|
|
167
168
|
raise SourceError("ws:// sources need a workspace: set $NDI_WORKSPACE_ID or pass --workspace")
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
file_id = spec.removeprefix(WS_PREFIX)
|
|
170
|
+
if not _UUID.match(file_id):
|
|
171
|
+
raise SourceError(f"bad ws:// id: {file_id!r}; {_WS_FILE_ID_HINT}")
|
|
172
|
+
return WorkspaceFileSource(workspace_id=_uuid(workspace_id, "workspace"), file_id=uuid.UUID(file_id))
|
|
171
173
|
if spec.startswith(("https://", "http://")):
|
|
172
174
|
name = file_name or Path(spec.split("?", 1)[0]).name or "document"
|
|
173
175
|
return UrlSource(url=spec, file_name=name)
|
|
@@ -175,7 +177,8 @@ def resolve_source(
|
|
|
175
177
|
return _from_stdin(client, file_name=file_name, allow_parse_result=allow_parse_result, stdin=stdin)
|
|
176
178
|
path = Path(spec)
|
|
177
179
|
if not path.is_file():
|
|
178
|
-
|
|
180
|
+
hint = f"; {_WS_FILE_ID_HINT}" if spec.startswith("ws:") else ""
|
|
181
|
+
raise SourceError(f"file not found: {spec}{hint}")
|
|
179
182
|
try:
|
|
180
183
|
upload = client.documents.create_upload(path, file_name=file_name or path.name)
|
|
181
184
|
except OSError as exc:
|