ndi-cli 0.9.0__tar.gz → 0.10.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.9.0 → ndi_cli-0.10.0}/PKG-INFO +9 -2
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/README.md +7 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/pyproject.toml +2 -2
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/pyproject.toml.orig +2 -2
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/__init__.py +1 -1
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_cli.py +10 -8
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_local.py +97 -10
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_render.py +292 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_tools.py +171 -2
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_workspace.py +112 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/commands.py +8 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/LICENSE +0 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_config.py +0 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_docops.py +0 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_entrypoint.py +0 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_jobs.py +0 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_login.py +0 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_output.py +0 -0
- {ndi_cli-0.9.0 → ndi_cli-0.10.0}/src/ndi_cli/_sources.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ndi-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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.25,<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
|
|
@@ -58,6 +58,7 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
|
|
|
58
58
|
| `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search; then `ndi job` on each printed `grounding_job_ids` entry |
|
|
59
59
|
| `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
|
|
60
60
|
| `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
|
|
61
|
+
| `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
|
|
61
62
|
| `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
|
|
62
63
|
|
|
63
64
|
## Sources
|
|
@@ -75,6 +76,10 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
|
|
|
75
76
|
```bash
|
|
76
77
|
ndi parse a.pdf -o id | ndi extract - -s schema.json
|
|
77
78
|
ndi files upload ./corpus --ingest
|
|
79
|
+
ndi ledger-understanding start # --force to re-scan; --selection-id after confirm
|
|
80
|
+
ndi ledger-understanding get
|
|
81
|
+
ndi ledger-understanding sources --review-reading-id READING
|
|
82
|
+
ndi ledger-understanding select --primary SRC --supporting SRC --revision REV
|
|
78
83
|
```
|
|
79
84
|
|
|
80
85
|
`ndi files upload DIR` keeps relative paths under `--path` (default: the
|
|
@@ -104,6 +109,8 @@ characters; request input and unrelated error-detail fields are omitted.
|
|
|
104
109
|
Unsupported inputs print `error: 422 [unsupported_file_type] ...` to stderr and
|
|
105
110
|
exit 1. `ndi upload` prints no handle, document operations print no job ID, and
|
|
106
111
|
`ndi files upload --ingest` creates no workspace file or ingestion job.
|
|
112
|
+
The server checks a bounded byte prefix, rejects conclusive filename/content
|
|
113
|
+
conflicts, and treats the multipart `Content-Type` as advisory.
|
|
107
114
|
|
|
108
115
|
See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
|
|
109
116
|
reference. `SKILL.md` is the agent-facing guide to the six workspace tools.
|
|
@@ -34,6 +34,7 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
|
|
|
34
34
|
| `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search; then `ndi job` on each printed `grounding_job_ids` entry |
|
|
35
35
|
| `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
|
|
36
36
|
| `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
|
|
37
|
+
| `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
|
|
37
38
|
| `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
|
|
38
39
|
|
|
39
40
|
## Sources
|
|
@@ -51,6 +52,10 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
|
|
|
51
52
|
```bash
|
|
52
53
|
ndi parse a.pdf -o id | ndi extract - -s schema.json
|
|
53
54
|
ndi files upload ./corpus --ingest
|
|
55
|
+
ndi ledger-understanding start # --force to re-scan; --selection-id after confirm
|
|
56
|
+
ndi ledger-understanding get
|
|
57
|
+
ndi ledger-understanding sources --review-reading-id READING
|
|
58
|
+
ndi ledger-understanding select --primary SRC --supporting SRC --revision REV
|
|
54
59
|
```
|
|
55
60
|
|
|
56
61
|
`ndi files upload DIR` keeps relative paths under `--path` (default: the
|
|
@@ -80,6 +85,8 @@ characters; request input and unrelated error-detail fields are omitted.
|
|
|
80
85
|
Unsupported inputs print `error: 422 [unsupported_file_type] ...` to stderr and
|
|
81
86
|
exit 1. `ndi upload` prints no handle, document operations print no job ID, and
|
|
82
87
|
`ndi files upload --ingest` creates no workspace file or ingestion job.
|
|
88
|
+
The server checks a bounded byte prefix, rejects conclusive filename/content
|
|
89
|
+
conflicts, and treats the multipart `Content-Type` as advisory.
|
|
83
90
|
|
|
84
91
|
See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
|
|
85
92
|
reference. `SKILL.md` is the agent-facing guide to the six workspace tools.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ndi-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.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.25,<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.10.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.25,<1",
|
|
31
31
|
"truststore>=0.10.4,<1",
|
|
32
32
|
]
|
|
33
33
|
|
|
@@ -16,7 +16,7 @@ from ndi_sdk.resources.tools import SyncTools
|
|
|
16
16
|
|
|
17
17
|
from ndi_cli import _docops, _jobs, _login, _tools, _workspace
|
|
18
18
|
from ndi_cli._config import ConfigError, resolve_settings
|
|
19
|
-
from ndi_cli._local import SOCKET_ENV, TOKEN_ENV, LocalTransport
|
|
19
|
+
from ndi_cli._local import KG_ENV, SOCKET_ENV, TOKEN_ENV, LocalTransport
|
|
20
20
|
from ndi_cli._tools import parse_pages
|
|
21
21
|
|
|
22
22
|
# qa-file runs a durable workflow that legitimately takes minutes; the metadata,
|
|
@@ -27,16 +27,18 @@ _VALIDATION_ERROR_CHARS = 500
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def main(argv: list[str] | None = None) -> int:
|
|
30
|
-
|
|
30
|
+
socket_path = os.environ.get(SOCKET_ENV)
|
|
31
|
+
# Presence, not truthiness: an empty $NDI_CLI_SOCKET still means "inside an
|
|
32
|
+
# agent run", and must not silently unlock the full platform CLI.
|
|
33
|
+
in_agent_run = socket_path is not None
|
|
34
|
+
# An agent run gets the graph commands only when the worker granted them; a
|
|
35
|
+
# run whose guide never mentions kg-* must not find them in --help either.
|
|
36
|
+
parser = _build_parser(graph=not in_agent_run or bool(os.environ.get(KG_ENV)))
|
|
31
37
|
args = parser.parse_args(argv)
|
|
32
38
|
if args.command is None:
|
|
33
39
|
parser.print_help()
|
|
34
40
|
return 2
|
|
35
41
|
family = getattr(args, "family", None)
|
|
36
|
-
socket_path = os.environ.get(SOCKET_ENV)
|
|
37
|
-
# Presence, not truthiness: an empty $NDI_CLI_SOCKET still means "inside an
|
|
38
|
-
# agent run", and must not silently unlock the full platform CLI.
|
|
39
|
-
in_agent_run = socket_path is not None
|
|
40
42
|
workspace_flag = getattr(args, "workspace", None)
|
|
41
43
|
if workspace_flag is not None and not workspace_flag.strip():
|
|
42
44
|
print("error: --workspace needs a workspace id", file=sys.stderr)
|
|
@@ -130,7 +132,7 @@ def _validation_error_lines(detail: object) -> list[str]:
|
|
|
130
132
|
return lines
|
|
131
133
|
|
|
132
134
|
|
|
133
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
135
|
+
def _build_parser(*, graph: bool = True) -> argparse.ArgumentParser:
|
|
134
136
|
parser = argparse.ArgumentParser(
|
|
135
137
|
prog="ndi",
|
|
136
138
|
description=(
|
|
@@ -151,7 +153,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
151
153
|
)
|
|
152
154
|
sub = parser.add_subparsers(dest="command")
|
|
153
155
|
_login.add_parsers(sub)
|
|
154
|
-
_tools.add_parsers(sub, common)
|
|
156
|
+
_tools.add_parsers(sub, common, graph=graph)
|
|
155
157
|
_docops.add_parsers(sub, common)
|
|
156
158
|
_jobs.add_parsers(sub, common)
|
|
157
159
|
_workspace.add_parsers(sub, common)
|
|
@@ -17,6 +17,8 @@ from ndi_sdk.errors import ErrorBody, NdiConnectionError, NdiTimeoutError, error
|
|
|
17
17
|
from ndi_sdk.models.tools import (
|
|
18
18
|
FileMetadataRequest,
|
|
19
19
|
FolderMetadataRequest,
|
|
20
|
+
KgSearchRequest,
|
|
21
|
+
KgWalkRequest,
|
|
20
22
|
QaFileRequest,
|
|
21
23
|
ReadFileRequest,
|
|
22
24
|
RunSqlRequest,
|
|
@@ -28,6 +30,9 @@ from ndi_cli.commands import WorkspaceCommand
|
|
|
28
30
|
|
|
29
31
|
SOCKET_ENV = "NDI_CLI_SOCKET"
|
|
30
32
|
TOKEN_ENV = "NDI_CLI_TOKEN"
|
|
33
|
+
#: Set (to any non-empty value) by the worker for an agent run that has the graph
|
|
34
|
+
#: surface; inside an agent run the ``kg-*`` commands are registered only when it is.
|
|
35
|
+
KG_ENV = "NDI_CLI_KG"
|
|
31
36
|
MAX_MESSAGE_BYTES = 16 * 1024 * 1024
|
|
32
37
|
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
33
38
|
|
|
@@ -62,12 +67,102 @@ class SearchCommand(BaseModel):
|
|
|
62
67
|
body: SearchRequest
|
|
63
68
|
|
|
64
69
|
|
|
70
|
+
class KgInfoRequest(BaseModel):
|
|
71
|
+
"""``kg-info``'s scope as a socket body.
|
|
72
|
+
|
|
73
|
+
Defined here rather than imported from ``ndi_sdk.models.tools`` like its
|
|
74
|
+
siblings: on the wire ``kg-info`` is a GET carrying query parameters, so it has
|
|
75
|
+
no request model to reuse. Only the scope crosses — the drawable sample is for
|
|
76
|
+
a client that renders a graph, and ``_kg_info_command`` refuses it.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
path_prefix: str | None = None
|
|
80
|
+
hub_node_type: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class KgInfoCommand(BaseModel):
|
|
84
|
+
command: Literal[WorkspaceCommand.KG_INFO] = WorkspaceCommand.KG_INFO
|
|
85
|
+
body: KgInfoRequest
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class KgSearchCommand(BaseModel):
|
|
89
|
+
command: Literal[WorkspaceCommand.KG_SEARCH] = WorkspaceCommand.KG_SEARCH
|
|
90
|
+
body: KgSearchRequest
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class KgWalkCommand(BaseModel):
|
|
94
|
+
command: Literal[WorkspaceCommand.KG_WALK] = WorkspaceCommand.KG_WALK
|
|
95
|
+
body: KgWalkRequest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class KgAnswerRequest(BaseModel):
|
|
99
|
+
"""``kg-answer``'s question as a socket body.
|
|
100
|
+
|
|
101
|
+
The agent passes its verbatim question; the server runs the graph pass and
|
|
102
|
+
returns the rendered outcome (answered text + citations, or evidence + gap).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
query: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class KgAnswerResponse(BaseModel):
|
|
109
|
+
"""The rendered outcome of one graph pass, ready to print or inject as a tool result."""
|
|
110
|
+
|
|
111
|
+
text: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class KgAnswerCommand(BaseModel):
|
|
115
|
+
command: Literal[WorkspaceCommand.KG_ANSWER] = WorkspaceCommand.KG_ANSWER
|
|
116
|
+
body: KgAnswerRequest
|
|
117
|
+
|
|
118
|
+
|
|
65
119
|
Command = Annotated[
|
|
66
|
-
FolderCommand
|
|
120
|
+
FolderCommand
|
|
121
|
+
| FileCommand
|
|
122
|
+
| ReadCommand
|
|
123
|
+
| AskCommand
|
|
124
|
+
| SqlCommand
|
|
125
|
+
| SearchCommand
|
|
126
|
+
| KgInfoCommand
|
|
127
|
+
| KgSearchCommand
|
|
128
|
+
| KgWalkCommand
|
|
129
|
+
| KgAnswerCommand,
|
|
130
|
+
Field(discriminator="command"),
|
|
67
131
|
]
|
|
68
132
|
COMMAND = TypeAdapter(Command)
|
|
69
133
|
|
|
70
134
|
|
|
135
|
+
def _kg_info_command(params: object) -> Command:
|
|
136
|
+
"""Build the ``kg-info`` command from the SDK method's query parameters.
|
|
137
|
+
|
|
138
|
+
Refuses ``include_graph`` instead of dropping it: the sample is the whole point
|
|
139
|
+
of that flag, and answering without one would read as a graph with no structure.
|
|
140
|
+
"""
|
|
141
|
+
if not isinstance(params, dict):
|
|
142
|
+
raise ValueError("The internal CLI accepts workspace commands only.")
|
|
143
|
+
if params.get("include_graph"):
|
|
144
|
+
raise ValueError("The internal CLI does not serve kg-info's drawable graph sample.")
|
|
145
|
+
prefix = params.get("path_prefix")
|
|
146
|
+
if prefix is not None and not isinstance(prefix, str):
|
|
147
|
+
raise ValueError("kg-info's path_prefix must be a string.")
|
|
148
|
+
hub_node_type = params.get("hub_node_type")
|
|
149
|
+
if hub_node_type is not None and not isinstance(hub_node_type, str):
|
|
150
|
+
raise ValueError("kg-info's hub_node_type must be a string.")
|
|
151
|
+
return KgInfoCommand(body=KgInfoRequest(path_prefix=prefix, hub_node_type=hub_node_type))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _command(method: str, path: str, kwargs: dict[str, object]) -> Command:
|
|
155
|
+
"""One socket command per SDK call, named by the endpoint the method addresses."""
|
|
156
|
+
name = path.rsplit("/", 1)[-1]
|
|
157
|
+
if method == "GET" and name == WorkspaceCommand.KG_INFO:
|
|
158
|
+
return _kg_info_command(kwargs.get("params"))
|
|
159
|
+
body = kwargs.get("body")
|
|
160
|
+
if method != "POST" or not isinstance(body, BaseModel):
|
|
161
|
+
raise ValueError("The internal CLI accepts workspace commands only.")
|
|
162
|
+
command = WorkspaceCommand.ASK_FILE if name == "qa-file" else WorkspaceCommand(name)
|
|
163
|
+
return COMMAND.validate_python({"command": command, "body": body.model_dump(exclude_none=True)})
|
|
164
|
+
|
|
165
|
+
|
|
71
166
|
class CommandEnvelope(BaseModel):
|
|
72
167
|
"""One request line on the socket: the owning run's token plus its command."""
|
|
73
168
|
|
|
@@ -98,15 +193,7 @@ class LocalTransport:
|
|
|
98
193
|
self.token = token
|
|
99
194
|
|
|
100
195
|
def request_model(self, model: type[ModelT], method: str, path: str, **kwargs: object) -> ModelT:
|
|
101
|
-
|
|
102
|
-
if method != "POST" or not isinstance(body, BaseModel):
|
|
103
|
-
raise ValueError("The internal CLI accepts workspace commands only.")
|
|
104
|
-
name = path.rsplit("/", 1)[-1]
|
|
105
|
-
command = WorkspaceCommand.ASK_FILE if name == "qa-file" else WorkspaceCommand(name)
|
|
106
|
-
request = CommandEnvelope(
|
|
107
|
-
token=self.token,
|
|
108
|
-
command=COMMAND.validate_python({"command": command, "body": body.model_dump(exclude_none=True)}),
|
|
109
|
-
)
|
|
196
|
+
request = CommandEnvelope(token=self.token, command=_command(method, path, kwargs))
|
|
110
197
|
try:
|
|
111
198
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
112
199
|
connection.settimeout(self.timeout)
|
|
@@ -14,6 +14,7 @@ import uuid
|
|
|
14
14
|
|
|
15
15
|
import html
|
|
16
16
|
import re
|
|
17
|
+
from typing import Literal
|
|
17
18
|
|
|
18
19
|
from ndi_sdk.models.common import Coverage, Evidence, File
|
|
19
20
|
from ndi_sdk.models.document_ops import ExtractSchemaValidationResponse
|
|
@@ -28,13 +29,26 @@ from ndi_sdk.models.jobs import (
|
|
|
28
29
|
IntelligentSearchResult,
|
|
29
30
|
JetResult,
|
|
30
31
|
Job,
|
|
32
|
+
LedgerUnderstandingResult,
|
|
31
33
|
ParseResult,
|
|
32
34
|
SplitResult,
|
|
33
35
|
)
|
|
36
|
+
from ndi_sdk.models.ledger_understanding import (
|
|
37
|
+
LedgerUnderstandingReport,
|
|
38
|
+
SelectionDetails,
|
|
39
|
+
SelectionSummary,
|
|
40
|
+
SourceCatalog,
|
|
41
|
+
)
|
|
34
42
|
from ndi_sdk.models.workspaces import Workspace, WorkspaceStats
|
|
35
43
|
from ndi_sdk.models.tools import (
|
|
36
44
|
FileMetadataToolResponse,
|
|
37
45
|
FolderMetadataResponse,
|
|
46
|
+
GraphEdge,
|
|
47
|
+
GraphFact,
|
|
48
|
+
GraphNode,
|
|
49
|
+
KgInfo,
|
|
50
|
+
KgSearchResponse,
|
|
51
|
+
KgWalkResponse,
|
|
38
52
|
QaFileResponse,
|
|
39
53
|
ReadFileResponse,
|
|
40
54
|
RunSqlResponse,
|
|
@@ -477,6 +491,214 @@ def run_sql(response: RunSqlResponse) -> str:
|
|
|
477
491
|
return "\n".join(lines)
|
|
478
492
|
|
|
479
493
|
|
|
494
|
+
#: Source paths listed per entity in "sample" mode before the rest become a
|
|
495
|
+
#: count. A corpus hub is cited by hundreds of files, which would bury the
|
|
496
|
+
#: structure the caller asked for; the count stays exact and read-file still
|
|
497
|
+
#: takes any path.
|
|
498
|
+
FILE_SAMPLE = 5
|
|
499
|
+
|
|
500
|
+
#: Matched facts printed per entity by kg-search. The server returns every fact
|
|
501
|
+
#: the query hit on the node; the first few are the best-ranked and are what says
|
|
502
|
+
#: WHY the entity matched — the rest, and the entity's own stated facts, are one
|
|
503
|
+
#: kg-node away.
|
|
504
|
+
FACT_SAMPLE = 5
|
|
505
|
+
|
|
506
|
+
#: How much provenance a node row carries: none (kg-export, structure only),
|
|
507
|
+
#: a sample (kg-search, a foothold) or every file (kg-node, the detail lookup
|
|
508
|
+
#: that answers "which documents carry this entity").
|
|
509
|
+
FilesMode = Literal["none", "sample", "all"]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _fact_row(fact: GraphFact) -> dict[str, object]:
|
|
513
|
+
row: dict[str, object] = {"predicate": fact.predicate, "value": fact.value, "source_file": fact.evidence.path}
|
|
514
|
+
if fact.evidence.page is not None:
|
|
515
|
+
row["page"] = fact.evidence.page
|
|
516
|
+
return row
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _node_row(node: GraphNode, *, files: FilesMode) -> dict[str, object]:
|
|
520
|
+
row: dict[str, object] = {
|
|
521
|
+
"node_id": node.node_id,
|
|
522
|
+
"node_type": node.node_type,
|
|
523
|
+
"title": node.title,
|
|
524
|
+
"degree": node.degree,
|
|
525
|
+
"neighbor_count": node.neighbor_count,
|
|
526
|
+
"source_file_count": len(node.source_files),
|
|
527
|
+
}
|
|
528
|
+
if node.aliases:
|
|
529
|
+
row["aliases"] = list(node.aliases)
|
|
530
|
+
if len(node.stated_types) > 1:
|
|
531
|
+
row["stated_types"] = [{"node_type": role.node_type, "mention_count": role.mention_count} for role in node.stated_types]
|
|
532
|
+
match files:
|
|
533
|
+
case "sample":
|
|
534
|
+
row["source_files"] = [ref.path for ref in node.source_files[:FILE_SAMPLE]]
|
|
535
|
+
case "all":
|
|
536
|
+
row["source_files"] = [ref.path for ref in node.source_files]
|
|
537
|
+
return row
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def kg_info(response: KgInfo) -> str:
|
|
541
|
+
"""The graph's shape and its entry points — the orient step before any kg-search.
|
|
542
|
+
|
|
543
|
+
Answers what a query cannot: whether the kind of thing being looked for is in
|
|
544
|
+
this graph at all, and which entities to seed from.
|
|
545
|
+
"""
|
|
546
|
+
payload: dict[str, object] = {
|
|
547
|
+
"counts": {"nodes": response.node_total, "edges": response.edge_total},
|
|
548
|
+
"node_types": [{"type": item.type, "count": item.count} for item in response.node_types],
|
|
549
|
+
"edge_types": [{"type": item.type, "count": item.count} for item in response.edge_types],
|
|
550
|
+
"hubs": [
|
|
551
|
+
{
|
|
552
|
+
"node_id": hub.node_id,
|
|
553
|
+
"node_type": hub.node_type,
|
|
554
|
+
"title": hub.title,
|
|
555
|
+
"degree": hub.degree,
|
|
556
|
+
"neighbor_count": hub.neighbor_count,
|
|
557
|
+
}
|
|
558
|
+
for hub in response.hubs
|
|
559
|
+
],
|
|
560
|
+
}
|
|
561
|
+
if response.path_prefix:
|
|
562
|
+
payload["path_prefix"] = response.path_prefix
|
|
563
|
+
payload["note"] = (
|
|
564
|
+
"node_types names every kind of entity this graph holds — a term matching none of them has nothing to find. "
|
|
565
|
+
"hubs are the entities connected to the most distinct things (neighbor_count; degree counts relation records, "
|
|
566
|
+
"one per file restating a relation) and valid kg-export seeds: a seed's own neighbours are always "
|
|
567
|
+
"returned, but hubs met further out are not walked through, so narrow a large neighbourhood with "
|
|
568
|
+
"--node-type / --edge-type rather than reading it whole."
|
|
569
|
+
)
|
|
570
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def kg_entities(response: KgSearchResponse) -> str:
|
|
574
|
+
"""Entities with a few files to open next — kg-search's foothold.
|
|
575
|
+
|
|
576
|
+
JSON, not prose: the caller is a `python3 -c` on the other side of a pipe.
|
|
577
|
+
"""
|
|
578
|
+
nodes: list[dict[str, object]] = []
|
|
579
|
+
for node in response.nodes:
|
|
580
|
+
row = _node_row(node, files="sample")
|
|
581
|
+
# The facts that matched the query, best first — why the entity is here.
|
|
582
|
+
row["facts"] = [_fact_row(fact) for fact in node.facts[:FACT_SAMPLE]]
|
|
583
|
+
if len(node.facts) > FACT_SAMPLE:
|
|
584
|
+
row["more_facts"] = len(node.facts) - FACT_SAMPLE
|
|
585
|
+
nodes.append(row)
|
|
586
|
+
payload: dict[str, object] = {"nodes": nodes}
|
|
587
|
+
if not response.nodes:
|
|
588
|
+
# A bare empty list says nothing about WHY, so a miss reads as a near miss
|
|
589
|
+
# worth one more try — which is how a run ends up walking an identifier
|
|
590
|
+
# space a guess at a time instead of asking what the graph contains.
|
|
591
|
+
payload["note"] = (
|
|
592
|
+
"No entities matched. Run `ndi kg-info` for the node types and hub entities this graph actually holds, "
|
|
593
|
+
"and search a term from that list — guessing or incrementing identifiers will not find one."
|
|
594
|
+
)
|
|
595
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def kg_node_detail(response: KgWalkResponse) -> str:
|
|
599
|
+
"""Everything the graph holds on the chosen entities — kg-node's answer.
|
|
600
|
+
|
|
601
|
+
Per requested entity: the facts its documents state, its relations with the
|
|
602
|
+
other end named and the wording each was read from, and every file that
|
|
603
|
+
carries it. The export counts provenance and search samples it; this is the
|
|
604
|
+
one place the full list is printed, because "which documents mention X" is
|
|
605
|
+
closed here or not at all. The requested entities are the walk's seeds — the
|
|
606
|
+
nodes no path lands on; the rest of the response is the one-hop ring the
|
|
607
|
+
runner fetched so the relations can name their other end.
|
|
608
|
+
"""
|
|
609
|
+
by_id = {node.node_id: node for node in response.nodes}
|
|
610
|
+
reached = {path.node_ids[-1] for path in response.paths}
|
|
611
|
+
incident: dict[str, list[GraphEdge]] = {}
|
|
612
|
+
for edge in response.edges:
|
|
613
|
+
for end in (edge.from_node_id, edge.to_node_id):
|
|
614
|
+
incident.setdefault(end, []).append(edge)
|
|
615
|
+
nodes: list[dict[str, object]] = []
|
|
616
|
+
for node in response.nodes:
|
|
617
|
+
if node.node_id in reached:
|
|
618
|
+
continue
|
|
619
|
+
row = _node_row(node, files="all")
|
|
620
|
+
row["facts"] = [_fact_row(fact) for fact in node.facts]
|
|
621
|
+
# One row per relation, not per record: a relation restated in another
|
|
622
|
+
# file is another edge, and a hub restates each of its hundreds of times.
|
|
623
|
+
# The first record's wording and file stand for it; ``records`` says how
|
|
624
|
+
# many files state it.
|
|
625
|
+
relations: dict[tuple[str, str, str], dict[str, object]] = {}
|
|
626
|
+
for edge in incident.get(node.node_id, []):
|
|
627
|
+
direction = "out" if edge.from_node_id == node.node_id else "in"
|
|
628
|
+
other_id = edge.to_node_id if direction == "out" else edge.from_node_id
|
|
629
|
+
key = (edge.edge_type, direction, other_id)
|
|
630
|
+
relation = relations.get(key)
|
|
631
|
+
if relation is not None:
|
|
632
|
+
relation["records"] = int(relation.get("records", 1)) + 1
|
|
633
|
+
continue
|
|
634
|
+
relation = {"edge_type": edge.edge_type, "direction": direction, "node_id": other_id}
|
|
635
|
+
other = by_id.get(other_id)
|
|
636
|
+
if other is not None:
|
|
637
|
+
relation["node_type"] = other.node_type
|
|
638
|
+
relation["title"] = other.title
|
|
639
|
+
if edge.evidence is not None:
|
|
640
|
+
relation["statement"] = edge.evidence.snippet
|
|
641
|
+
relation["source_file"] = edge.evidence.path
|
|
642
|
+
if edge.evidence.page is not None:
|
|
643
|
+
relation["page"] = edge.evidence.page
|
|
644
|
+
relations[key] = relation
|
|
645
|
+
row["relations"] = list(relations.values())
|
|
646
|
+
# neighbor_count is the distinct entities connected; fewer other ends here
|
|
647
|
+
# means the ring cap (or the walk's own) cut the rest.
|
|
648
|
+
if len({other_id for _, _, other_id in relations}) < node.neighbor_count:
|
|
649
|
+
row["relations_truncated"] = True
|
|
650
|
+
nodes.append(row)
|
|
651
|
+
payload: dict[str, object] = {"nodes": nodes}
|
|
652
|
+
if any(row.get("relations_truncated") for row in nodes):
|
|
653
|
+
payload["note"] = (
|
|
654
|
+
"relations_truncated: the entity connects to more than is shown; `kg-export --seed NODE_ID` prints "
|
|
655
|
+
"the whole ring, narrowed with --node-type / --edge-type."
|
|
656
|
+
)
|
|
657
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def kg_export(response: KgWalkResponse) -> str:
|
|
661
|
+
"""The whole neighbourhood as structure, with the prose left behind.
|
|
662
|
+
|
|
663
|
+
Sized to survive a pipe, not to be read: facts and relation wording would
|
|
664
|
+
multiply the payload by the number of edges and are what ``kg-node`` prints
|
|
665
|
+
per entity once the caller's own code has picked its few. Edge ids stay out
|
|
666
|
+
too — no command takes one, and each cost as much as a node id.
|
|
667
|
+
"""
|
|
668
|
+
edges: list[dict[str, object]] = []
|
|
669
|
+
for edge in response.edges:
|
|
670
|
+
row: dict[str, object] = {
|
|
671
|
+
"src": edge.from_node_id,
|
|
672
|
+
"dst": edge.to_node_id,
|
|
673
|
+
"edge_type": edge.edge_type,
|
|
674
|
+
}
|
|
675
|
+
if edge.evidence is not None:
|
|
676
|
+
row["source_file"] = edge.evidence.path
|
|
677
|
+
if edge.evidence.page is not None:
|
|
678
|
+
row["page"] = edge.evidence.page
|
|
679
|
+
edges.append(row)
|
|
680
|
+
nodes = [_node_row(node, files="none") for node in response.nodes]
|
|
681
|
+
payload: dict[str, object] = {
|
|
682
|
+
"counts": {"nodes": len(nodes), "edges": len(edges)},
|
|
683
|
+
"truncated": response.truncated,
|
|
684
|
+
"nodes": nodes,
|
|
685
|
+
"edges": edges,
|
|
686
|
+
}
|
|
687
|
+
# Every node but a seed arrives over an edge, so an edgeless node is a seed
|
|
688
|
+
# with no relation the caller can see (none at all, or none left after the
|
|
689
|
+
# type filters). Unsaid, that reads as a walk that silently failed.
|
|
690
|
+
touched = {end for edge in response.edges for end in (edge.from_node_id, edge.to_node_id)}
|
|
691
|
+
unexpanded = [node.node_id for node in response.nodes if node.node_id not in touched]
|
|
692
|
+
if unexpanded:
|
|
693
|
+
payload["unexpanded"] = unexpanded
|
|
694
|
+
payload["note"] = (
|
|
695
|
+
"These seeds have no visible relations: a seed's own neighbours are always returned, so either the "
|
|
696
|
+
"entity has none here or --node-type / --edge-type filtered them all. Read its facts and files with "
|
|
697
|
+
"kg-node, or seed from another entity found via kg-search."
|
|
698
|
+
)
|
|
699
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
700
|
+
|
|
701
|
+
|
|
480
702
|
def search(response: SearchResponse) -> str:
|
|
481
703
|
if not response.hits:
|
|
482
704
|
lines = ["no hits"]
|
|
@@ -721,6 +943,76 @@ def je_testing_job(job: Job) -> str:
|
|
|
721
943
|
return "\n".join(lines)
|
|
722
944
|
|
|
723
945
|
|
|
946
|
+
def ledger_understanding_report(report: LedgerUnderstandingReport) -> str:
|
|
947
|
+
"""Tables, integrity, open questions, and parse counts — the report at a glance."""
|
|
948
|
+
flags = [name for name, on in (("stale", report.stale), ("blocking", report.blocking)) if on]
|
|
949
|
+
head = "ledger understanding"
|
|
950
|
+
if flags:
|
|
951
|
+
head += f" ({', '.join(flags)})"
|
|
952
|
+
lines = [head, f"tables ({len(report.tables)}):"]
|
|
953
|
+
for table in report.tables:
|
|
954
|
+
key = f" key {', '.join(table.journal_key)}" if table.journal_key else ""
|
|
955
|
+
share = f" balanced {table.key_balanced_share:.2f}" if table.key_balanced_share is not None else ""
|
|
956
|
+
lines.append(f" {table.role} {table.table_name} {table.rows} rows{key}{share}")
|
|
957
|
+
lines.append(f"integrity: {len(report.integrity.findings)} findings, {len(report.integrity.skipped)} skipped")
|
|
958
|
+
for finding in report.integrity.findings:
|
|
959
|
+
lines.append(f" {finding.severity} {finding.check}: {finding.message}")
|
|
960
|
+
for skipped in report.integrity.skipped:
|
|
961
|
+
lines.append(f" skipped {skipped.check}: {skipped.reason}")
|
|
962
|
+
lines.append(f"open questions ({len(report.open_questions)}):")
|
|
963
|
+
for question in report.open_questions:
|
|
964
|
+
lines.append(f" {question.id}: {question.question}")
|
|
965
|
+
parsing = report.parsing
|
|
966
|
+
if parsing is None:
|
|
967
|
+
lines.append("parsing: none")
|
|
968
|
+
elif parsing.skipped_reason:
|
|
969
|
+
lines.append(f"parsing: skipped ({parsing.skipped_reason})")
|
|
970
|
+
else:
|
|
971
|
+
lines.append(
|
|
972
|
+
f"parsing: {parsing.journals_total} journals, {parsing.parsed} parsed, "
|
|
973
|
+
f"{parsing.identity} identity, {parsing.hub} hub, {parsing.unbalanced} unbalanced"
|
|
974
|
+
)
|
|
975
|
+
return "\n".join(lines)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def ledger_understanding_job(job: Job) -> str:
|
|
979
|
+
result = job.result
|
|
980
|
+
if isinstance(result, LedgerUnderstandingResult):
|
|
981
|
+
return ledger_understanding_report(result.report)
|
|
982
|
+
return job_line(job)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def ledger_source_catalog(catalog: SourceCatalog) -> str:
|
|
986
|
+
lines = [f"revision {catalog.revision}", f"sources ({len(catalog.sources)}):"]
|
|
987
|
+
for source in catalog.sources:
|
|
988
|
+
flags = []
|
|
989
|
+
if source.recommended_as_primary:
|
|
990
|
+
flags.append("recommended")
|
|
991
|
+
if source.review_eligible is False:
|
|
992
|
+
flags.append("ineligible")
|
|
993
|
+
flag = f" {', '.join(flags)}" if flags else ""
|
|
994
|
+
lines.append(f" {source.label} {source.rows} rows {source.file_count} files {source.source_id}{flag}")
|
|
995
|
+
if catalog.warnings:
|
|
996
|
+
lines.append("warnings:")
|
|
997
|
+
lines.extend(f" {warning}" for warning in catalog.warnings)
|
|
998
|
+
return "\n".join(lines)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def ledger_selection(summary: SelectionSummary) -> str:
|
|
1002
|
+
lines = [
|
|
1003
|
+
f"selection {summary.selection_id}",
|
|
1004
|
+
f"primary {summary.primary}",
|
|
1005
|
+
f"supporting ({len(summary.supporting)}): {', '.join(summary.supporting) or 'none'}",
|
|
1006
|
+
f"files {summary.file_count}",
|
|
1007
|
+
]
|
|
1008
|
+
if isinstance(summary, SelectionDetails):
|
|
1009
|
+
lines.append(f"package {summary.package}")
|
|
1010
|
+
lines.append(f"primary_source_id {summary.primary_source_id}")
|
|
1011
|
+
if summary.stale:
|
|
1012
|
+
lines.append("stale")
|
|
1013
|
+
return "\n".join(lines)
|
|
1014
|
+
|
|
1015
|
+
|
|
724
1016
|
#: What a cell's non-``found`` status means, glossed for a reader who has only
|
|
725
1017
|
#: this text. A field nothing could be read from is not an absent one, and
|
|
726
1018
|
#: neither is one that resisted normalizing — keeping them distinct is what
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The
|
|
1
|
+
"""The workspace-tool verbs: six file commands plus the graph commands. Shared with the in-sandbox agent CLI."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -8,10 +8,27 @@ import sys
|
|
|
8
8
|
from ndi_sdk.resources.tools import SyncTools
|
|
9
9
|
|
|
10
10
|
from ndi_cli import _render
|
|
11
|
+
from ndi_cli._local import KgAnswerRequest, KgAnswerResponse
|
|
11
12
|
from ndi_cli.commands import WorkspaceCommand
|
|
12
13
|
|
|
14
|
+
#: Breadth the export defaults to: the seed's own relations. The agent's shell
|
|
15
|
+
#: caps what it can see of any one command, and across the kg-walk eval runs no
|
|
16
|
+
#: export ever asked for a hop count while two in five came back cut at that
|
|
17
|
+
#: cap — a real seed's outer rings are larger than the caller can read, piped or
|
|
18
|
+
#: not. One ring fits for any non-hub seed; ``--hops`` reaches further on purpose.
|
|
19
|
+
#: The node ceiling stays under the walk endpoint's own limit.
|
|
20
|
+
DEFAULT_EXPORT_HOPS = 1
|
|
21
|
+
DEFAULT_EXPORT_NODES = 500
|
|
13
22
|
|
|
14
|
-
|
|
23
|
+
#: Neighbours kg-node fetches alongside the requested entities so their relations
|
|
24
|
+
#: print with the other end named. A record-like entity (invoice, PO, payment)
|
|
25
|
+
#: has a handful; a hub has hundreds, and those are kg-export's job — the render
|
|
26
|
+
#: says so when the cap cut them.
|
|
27
|
+
NODE_DETAIL_NEIGHBORS = 25
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser, *, graph: bool = True) -> None:
|
|
31
|
+
"""Register the tool verbs; ``graph=False`` leaves the ``kg-*`` commands out entirely."""
|
|
15
32
|
folder = sub.add_parser(
|
|
16
33
|
WorkspaceCommand.FOLDER_METADATA,
|
|
17
34
|
parents=[common],
|
|
@@ -125,6 +142,108 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
|
125
142
|
search.add_argument("--path-prefix", default=None, help="Only files under this directory prefix.")
|
|
126
143
|
search.add_argument("--category", action="append", default=None, dest="categories", help="Category filter; repeatable.")
|
|
127
144
|
search.set_defaults(run=_run_search, render=_render.search, family="tool", needs_workspace=True, needs_client=True)
|
|
145
|
+
if not graph:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
kg_info = sub.add_parser(
|
|
149
|
+
WorkspaceCommand.KG_INFO,
|
|
150
|
+
parents=[common],
|
|
151
|
+
help="Orient in the knowledge graph: what kinds of entities it holds, and the busiest ones.",
|
|
152
|
+
description=(
|
|
153
|
+
"The graph's first call, the way folder-metadata is the corpus's. Returns JSON: node_types and "
|
|
154
|
+
"edge_types with counts, totals, and the best-connected hubs as node_ids kg-export takes. Read the "
|
|
155
|
+
"types before searching — a term naming none of them has nothing to match."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
kg_info.add_argument("--path-prefix", default=None, help="Scope the counts to entities sourced from under this prefix.")
|
|
159
|
+
kg_info.add_argument(
|
|
160
|
+
"--node-type",
|
|
161
|
+
default=None,
|
|
162
|
+
dest="hub_node_type",
|
|
163
|
+
help="Rank the hubs within this node type only: the best-connected entities of that kind. Counts are unaffected.",
|
|
164
|
+
)
|
|
165
|
+
kg_info.set_defaults(run=_run_kg_info, render=_render.kg_info, family="tool", needs_workspace=True, needs_client=True)
|
|
166
|
+
|
|
167
|
+
kg_search = sub.add_parser(
|
|
168
|
+
WorkspaceCommand.KG_SEARCH,
|
|
169
|
+
parents=[common],
|
|
170
|
+
help="Find entities the knowledge graph resolved across the corpus.",
|
|
171
|
+
description=(
|
|
172
|
+
"One entity per thing, however many files mention it. Returns JSON: each entity with the facts that "
|
|
173
|
+
"matched the query and a sample of its files; its node_id is what kg-node and kg-export --seed take."
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
kg_search.add_argument("query", help="A name, identifier, title, role, or relationship, in plain words.")
|
|
177
|
+
kg_search.add_argument("-k", type=int, default=10, help="Number of entities (default 10).")
|
|
178
|
+
kg_search.add_argument("--node-type", action="append", default=None, dest="node_types", help="Type filter; repeatable.")
|
|
179
|
+
kg_search.add_argument("--path-prefix", default=None, help="Only entities sourced from under this prefix.")
|
|
180
|
+
kg_search.set_defaults(run=_run_kg_search, render=_render.kg_entities, family="tool", needs_workspace=True, needs_client=True)
|
|
181
|
+
|
|
182
|
+
kg_export = sub.add_parser(
|
|
183
|
+
"kg-export",
|
|
184
|
+
parents=[common],
|
|
185
|
+
help="Print a seed's relations as JSON, for piping into python3.",
|
|
186
|
+
description=(
|
|
187
|
+
"The graph as data instead of one question at a time: the nodes and edges around your seeds, ids and "
|
|
188
|
+
"structure only. By default the seeds' own relations; --hops reaches the next rings, which grow fast — "
|
|
189
|
+
"narrow them with --node-type / --edge-type. Pipe it into python3 and do the loop, filter, aggregation "
|
|
190
|
+
"or walk yourself in one call. Quotes and explanations are deliberately absent — fetch them per entity "
|
|
191
|
+
"with kg-node."
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
kg_export.add_argument("--seed", action="append", required=True, dest="seeds", metavar="NODE_ID", help="Seed; repeatable.")
|
|
195
|
+
kg_export.add_argument(
|
|
196
|
+
"--hops",
|
|
197
|
+
type=int,
|
|
198
|
+
default=DEFAULT_EXPORT_HOPS,
|
|
199
|
+
help=f"Relation steps out from the seeds (default {DEFAULT_EXPORT_HOPS}: their own relations).",
|
|
200
|
+
)
|
|
201
|
+
kg_export.add_argument(
|
|
202
|
+
"--max-nodes", type=int, default=DEFAULT_EXPORT_NODES, help=f"Node ceiling (default {DEFAULT_EXPORT_NODES})."
|
|
203
|
+
)
|
|
204
|
+
kg_export.add_argument(
|
|
205
|
+
"--node-type",
|
|
206
|
+
action="append",
|
|
207
|
+
default=None,
|
|
208
|
+
dest="node_types",
|
|
209
|
+
help="Return only entities of this type, plus the seeds and the path that reaches each one; repeatable.",
|
|
210
|
+
)
|
|
211
|
+
kg_export.add_argument(
|
|
212
|
+
"--edge-type", action="append", default=None, dest="edge_types", help="Follow only relations of this type; repeatable."
|
|
213
|
+
)
|
|
214
|
+
kg_export.set_defaults(run=_run_kg_export, render=_render.kg_export, family="tool", needs_workspace=True, needs_client=True)
|
|
215
|
+
|
|
216
|
+
kg_node = sub.add_parser(
|
|
217
|
+
"kg-node",
|
|
218
|
+
parents=[common],
|
|
219
|
+
help="Everything the graph holds on a few entities: stated facts, relations with their wording, every source file.",
|
|
220
|
+
description=(
|
|
221
|
+
"The detail half of kg-export: pass the handful of node_ids kg-search or your own code picked and get, "
|
|
222
|
+
"per entity, the facts its documents state (amounts, dates, identifiers, descriptions, each with the "
|
|
223
|
+
"file and page it was read from), its relations with the other end named and the sentence each was "
|
|
224
|
+
"read from, and EVERY source file naming it — 'what is X', 'what is X connected to' and 'which "
|
|
225
|
+
"documents mention X' are answered here. The graph is extraction output, not evidence: before citing "
|
|
226
|
+
"a fact, open the file it names with read-file or ask-file and quote the document."
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
kg_node.add_argument("ids", nargs="+", metavar="NODE_ID", help="Entity node_ids from kg-search or kg-export.")
|
|
230
|
+
kg_node.set_defaults(run=_run_kg_node, render=_render.kg_node_detail, family="tool", needs_workspace=True, needs_client=True)
|
|
231
|
+
|
|
232
|
+
kg_answer = sub.add_parser(
|
|
233
|
+
WorkspaceCommand.KG_ANSWER,
|
|
234
|
+
parents=[common],
|
|
235
|
+
help="Answer a question from the workspace knowledge graph in one call.",
|
|
236
|
+
description=(
|
|
237
|
+
"Runs the full graph pass: locates the entities the question names, reads the pages they cite, "
|
|
238
|
+
"drafts an answer, and gates it against the passages. Two outcomes: "
|
|
239
|
+
"(1) answered — prints the answer and its citations; verify at most one, then call final_result; "
|
|
240
|
+
"(2) evidence + gap — the graph found entities and read their pages but could not settle the answer; "
|
|
241
|
+
"start from the pages it names rather than repeating the same reads. "
|
|
242
|
+
"Call this first with the question verbatim."
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
kg_answer.add_argument("query", help="The question, verbatim.")
|
|
246
|
+
kg_answer.set_defaults(run=_run_kg_answer, render=_render_kg_answer, family="tool", needs_workspace=True, needs_client=True)
|
|
128
247
|
|
|
129
248
|
|
|
130
249
|
def _run_folder_metadata(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
@@ -208,3 +327,53 @@ def _run_search(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
|
208
327
|
path_prefix=args.path_prefix,
|
|
209
328
|
categories=args.categories,
|
|
210
329
|
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _run_kg_info(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
333
|
+
# No include_graph: the drawable sample serves a client that renders a graph,
|
|
334
|
+
# and it is orders of magnitude larger than the counts a caller reads here.
|
|
335
|
+
return tools.kg_info(workspace_id, path_prefix=args.path_prefix, hub_node_type=args.hub_node_type)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _run_kg_search(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
339
|
+
return tools.kg_search(workspace_id, query=args.query, k=args.k, node_types=args.node_types, path_prefix=args.path_prefix)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _run_kg_export(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
343
|
+
return tools.kg_walk(
|
|
344
|
+
workspace_id,
|
|
345
|
+
start_node_ids=args.seeds,
|
|
346
|
+
hops=args.hops,
|
|
347
|
+
max_nodes=args.max_nodes,
|
|
348
|
+
node_types=args.node_types,
|
|
349
|
+
edge_types=args.edge_types,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _run_kg_node(tools: SyncTools, workspace_id: str, args: argparse.Namespace):
|
|
354
|
+
# One hop, with room for the requested ids plus a ring of neighbours: the
|
|
355
|
+
# requested entities' facts ride on their own nodes, and their relations need
|
|
356
|
+
# the other end returned to be named. The requested ids come first, in the
|
|
357
|
+
# order asked; the renderer tells them apart from the ring by the walk's
|
|
358
|
+
# paths (a seed is reached by none).
|
|
359
|
+
response = tools.kg_walk(workspace_id, start_node_ids=args.ids, hops=1, max_nodes=len(args.ids) + NODE_DETAIL_NEIGHBORS)
|
|
360
|
+
found = {node.node_id: node for node in response.nodes}
|
|
361
|
+
requested = [found[id_] for id_ in args.ids if id_ in found]
|
|
362
|
+
ring = [node for node in response.nodes if node.node_id not in set(args.ids)]
|
|
363
|
+
return response.model_copy(update={"nodes": [*requested, *ring]})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _run_kg_answer(tools: SyncTools, workspace_id: str, args: argparse.Namespace) -> KgAnswerResponse:
|
|
367
|
+
# kg-answer has no public HTTP route; it routes exclusively through the
|
|
368
|
+
# in-agent socket (LocalTransport). We send the command directly through
|
|
369
|
+
# the underlying transport so no SDK method needs to exist for this verb.
|
|
370
|
+
return tools._transport.request_model( # type: ignore[attr-defined]
|
|
371
|
+
KgAnswerResponse,
|
|
372
|
+
"POST",
|
|
373
|
+
f"/v1/workspaces/{workspace_id}/tools/{WorkspaceCommand.KG_ANSWER}",
|
|
374
|
+
body=KgAnswerRequest(query=args.query),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _render_kg_answer(response: KgAnswerResponse) -> str:
|
|
379
|
+
return response.text
|
|
@@ -143,6 +143,53 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
|
143
143
|
add_job_output_args(jet)
|
|
144
144
|
jet.set_defaults(run=_run_je_testing, family="workspace", needs_workspace=True, needs_client=True)
|
|
145
145
|
|
|
146
|
+
ledger = sub.add_parser("ledger-understanding", help="Start or read a ledger-understanding report.")
|
|
147
|
+
lu = ledger.add_subparsers(dest="ledger_command", required=True)
|
|
148
|
+
|
|
149
|
+
lu_start = lu.add_parser("start", parents=[common], help="Start a ledger-understanding run.")
|
|
150
|
+
lu_start.add_argument("--force", action="store_true", help="Re-measure even when a report for this package exists.")
|
|
151
|
+
_add_ledger_scope_args(lu_start)
|
|
152
|
+
add_job_output_args(lu_start)
|
|
153
|
+
lu_start.set_defaults(run=_run_ledger_understanding_start, family="workspace", needs_workspace=True, needs_client=True)
|
|
154
|
+
|
|
155
|
+
lu_get = lu.add_parser("get", parents=[common], help="Read the latest ledger-understanding report.")
|
|
156
|
+
_add_ledger_scope_args(lu_get)
|
|
157
|
+
lu_get.set_defaults(run=_run_ledger_understanding_get, family="workspace", needs_workspace=True, needs_client=True)
|
|
158
|
+
|
|
159
|
+
lu_sources = lu.add_parser("sources", parents=[common], help="List reviewable ledger sources.")
|
|
160
|
+
_add_ledger_scope_args(lu_sources)
|
|
161
|
+
lu_sources.add_argument("--review-reading-id", default=None, help="Bind the catalog to this reading's review.")
|
|
162
|
+
lu_sources.set_defaults(run=_run_ledger_sources, family="workspace", needs_workspace=True, needs_client=True)
|
|
163
|
+
|
|
164
|
+
lu_select = lu.add_parser("select", parents=[common], help="Confirm a primary population and supporting sources.")
|
|
165
|
+
lu_select.add_argument(
|
|
166
|
+
"--primary", required=True, dest="primary_source_id", help="Catalog handle for the primary population."
|
|
167
|
+
)
|
|
168
|
+
lu_select.add_argument(
|
|
169
|
+
"--supporting",
|
|
170
|
+
action="append",
|
|
171
|
+
dest="supporting_source_ids",
|
|
172
|
+
default=None,
|
|
173
|
+
help="Supporting source handle; repeatable.",
|
|
174
|
+
)
|
|
175
|
+
lu_select.add_argument("--revision", required=True, dest="expected_catalog_revision", help="Catalog revision last listed.")
|
|
176
|
+
lu_select.add_argument("--review-reading-id", default=None, help="Reading that requested this source review.")
|
|
177
|
+
lu_select.add_argument("--review-prefix", default=None, dest="review_path_prefix")
|
|
178
|
+
lu_select.add_argument("--review-path", action="append", dest="review_paths", default=None)
|
|
179
|
+
lu_select.add_argument("--review-selection-id", default=None)
|
|
180
|
+
lu_select.add_argument("--previous-selection-id", default=None)
|
|
181
|
+
lu_select.add_argument(
|
|
182
|
+
"--confirm-unrecognized",
|
|
183
|
+
action="store_true",
|
|
184
|
+
dest="confirm_unrecognized_primary",
|
|
185
|
+
help="Confirm a primary that structural recognition did not recommend.",
|
|
186
|
+
)
|
|
187
|
+
lu_select.set_defaults(run=_run_ledger_select, family="workspace", needs_workspace=True, needs_client=True)
|
|
188
|
+
|
|
189
|
+
lu_selection = lu.add_parser("selection", parents=[common], help="Read a confirmed source selection.")
|
|
190
|
+
lu_selection.add_argument("selection_id")
|
|
191
|
+
lu_selection.set_defaults(run=_run_ledger_selection, family="workspace", needs_workspace=True, needs_client=True)
|
|
192
|
+
|
|
146
193
|
|
|
147
194
|
def _workspace_id(args: argparse.Namespace, fallback: str) -> str:
|
|
148
195
|
value = getattr(args, "workspace_id", None) or args.workspace or fallback
|
|
@@ -473,3 +520,68 @@ def _run_je_testing(client: NdiClient, workspace_id: str, args: argparse.Namespa
|
|
|
473
520
|
paths=args.paths,
|
|
474
521
|
)
|
|
475
522
|
return _finish_job(client, job, args, _render.je_testing_job, "je-testing")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _add_ledger_scope_args(parser: argparse.ArgumentParser) -> None:
|
|
526
|
+
parser.add_argument("--prefix", default=None, dest="path_prefix")
|
|
527
|
+
parser.add_argument("--path", action="append", dest="paths", default=None, help="Scope to this file; repeatable.")
|
|
528
|
+
parser.add_argument(
|
|
529
|
+
"--selection-id", default=None, help="Confirmed source selection; cannot be combined with path selectors."
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _ledger_scope(args: argparse.Namespace) -> dict[str, object]:
|
|
534
|
+
return {
|
|
535
|
+
"path_prefix": getattr(args, "path_prefix", None),
|
|
536
|
+
"paths": getattr(args, "paths", None),
|
|
537
|
+
"selection_id": getattr(args, "selection_id", None),
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _run_ledger_understanding_start(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
542
|
+
job = client.workspaces.start_ledger_understanding(workspace_id, force=args.force, **_ledger_scope(args))
|
|
543
|
+
return _finish_job(client, job, args, _render.ledger_understanding_job, "ledger-understanding")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _run_ledger_understanding_get(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
547
|
+
report = client.workspaces.get_ledger_understanding(workspace_id, **_ledger_scope(args))
|
|
548
|
+
return _dump_or_text(report, args, text=_render.ledger_understanding_report(report), stem="ledger-understanding")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _run_ledger_sources(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
552
|
+
catalog = client.workspaces.list_ledger_sources(
|
|
553
|
+
workspace_id,
|
|
554
|
+
review_reading_id=args.review_reading_id,
|
|
555
|
+
**_ledger_scope(args),
|
|
556
|
+
)
|
|
557
|
+
return _dump_or_text(catalog, args, text=_render.ledger_source_catalog(catalog), stem="ledger-sources")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _run_ledger_select(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
561
|
+
from ndi_sdk.models.ledger_understanding import LedgerPackageScope, SourceReviewReference
|
|
562
|
+
|
|
563
|
+
review = None
|
|
564
|
+
if args.review_reading_id:
|
|
565
|
+
review = SourceReviewReference(
|
|
566
|
+
reading_id=args.review_reading_id,
|
|
567
|
+
scope=LedgerPackageScope(
|
|
568
|
+
path_prefix=args.review_path_prefix,
|
|
569
|
+
paths=args.review_paths or [],
|
|
570
|
+
selection_id=args.review_selection_id,
|
|
571
|
+
),
|
|
572
|
+
)
|
|
573
|
+
summary = client.workspaces.select_ledger_sources(
|
|
574
|
+
workspace_id,
|
|
575
|
+
primary_source_id=args.primary_source_id,
|
|
576
|
+
supporting_source_ids=args.supporting_source_ids,
|
|
577
|
+
expected_catalog_revision=args.expected_catalog_revision,
|
|
578
|
+
review=review,
|
|
579
|
+
previous_selection_id=args.previous_selection_id,
|
|
580
|
+
confirm_unrecognized_primary=args.confirm_unrecognized_primary,
|
|
581
|
+
)
|
|
582
|
+
return _dump_or_text(summary, args, text=_render.ledger_selection(summary), stem="ledger-selection")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _run_ledger_selection(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
|
|
586
|
+
details = client.workspaces.get_ledger_selection(workspace_id, args.selection_id)
|
|
587
|
+
return _dump_or_text(details, args, text=_render.ledger_selection(details), stem="ledger-selection")
|
|
@@ -10,6 +10,10 @@ class WorkspaceCommand(StrEnum):
|
|
|
10
10
|
ASK_FILE = "ask-file"
|
|
11
11
|
RUN_SQL = "run-sql"
|
|
12
12
|
HYBRID_SEARCH = "hybrid-search"
|
|
13
|
+
KG_INFO = "kg-info"
|
|
14
|
+
KG_SEARCH = "kg-search"
|
|
15
|
+
KG_WALK = "kg-walk"
|
|
16
|
+
KG_ANSWER = "kg-answer"
|
|
13
17
|
|
|
14
18
|
@property
|
|
15
19
|
def activity_label(self) -> str:
|
|
@@ -20,4 +24,8 @@ class WorkspaceCommand(StrEnum):
|
|
|
20
24
|
self.ASK_FILE: "Asking about a file",
|
|
21
25
|
self.RUN_SQL: "Querying tables with SQL",
|
|
22
26
|
self.HYBRID_SEARCH: "Searching workspace files",
|
|
27
|
+
self.KG_INFO: "Sizing up the knowledge graph",
|
|
28
|
+
self.KG_SEARCH: "Searching the knowledge graph",
|
|
29
|
+
self.KG_WALK: "Following graph relations",
|
|
30
|
+
self.KG_ANSWER: "Answering from the knowledge graph",
|
|
23
31
|
}[self]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|