ndi-cli 0.9.0__tar.gz → 0.11.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndi-cli
3
- Version: 0.9.0
3
+ Version: 0.11.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.22,<1
19
+ Requires-Dist: ndi-sdk>=0.26,<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
@@ -55,9 +55,11 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
55
55
  | `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
56
56
  | `ndi files upload\|list\|get\|delete` | Workspace files (a directory walks nested files; `--ingest` then queues ingestion) |
57
57
  | `ndi ingest` | Queue ingestion |
58
+ | `ndi automatic-search QUERY` | Service chooses fact, deep, or filtered search; prints the route and the output job id |
58
59
  | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search; then `ndi job` on each printed `grounding_job_ids` entry |
59
60
  | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
60
61
  | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
62
+ | `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
61
63
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
62
64
 
63
65
  ## Sources
@@ -75,6 +77,10 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
75
77
  ```bash
76
78
  ndi parse a.pdf -o id | ndi extract - -s schema.json
77
79
  ndi files upload ./corpus --ingest
80
+ ndi ledger-understanding start # --force to re-scan; --selection-id after confirm
81
+ ndi ledger-understanding get
82
+ ndi ledger-understanding sources --review-reading-id READING
83
+ ndi ledger-understanding select --primary SRC --supporting SRC --revision REV
78
84
  ```
79
85
 
80
86
  `ndi files upload DIR` keeps relative paths under `--path` (default: the
@@ -104,6 +110,8 @@ characters; request input and unrelated error-detail fields are omitted.
104
110
  Unsupported inputs print `error: 422 [unsupported_file_type] ...` to stderr and
105
111
  exit 1. `ndi upload` prints no handle, document operations print no job ID, and
106
112
  `ndi files upload --ingest` creates no workspace file or ingestion job.
113
+ The server checks a bounded byte prefix, rejects conclusive filename/content
114
+ conflicts, and treats the multipart `Content-Type` as advisory.
107
115
 
108
116
  See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
109
117
  reference. `SKILL.md` is the agent-facing guide to the six workspace tools.
@@ -31,9 +31,11 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
31
31
  | `ndi workspace create\|list\|get\|stats\|delete\|use` | Workspace lifecycle |
32
32
  | `ndi files upload\|list\|get\|delete` | Workspace files (a directory walks nested files; `--ingest` then queues ingestion) |
33
33
  | `ndi ingest` | Queue ingestion |
34
+ | `ndi automatic-search QUERY` | Service chooses fact, deep, or filtered search; prints the route and the output job id |
34
35
  | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search; then `ndi job` on each printed `grounding_job_ids` entry |
35
36
  | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
36
37
  | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--reasoning-effort` for the thinking budget) |
38
+ | `ndi ledger-understanding start\|get\|sources\|select\|selection` | Measure the ledger package, confirm sources, or read the latest report |
37
39
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
38
40
 
39
41
  ## Sources
@@ -51,6 +53,10 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
51
53
  ```bash
52
54
  ndi parse a.pdf -o id | ndi extract - -s schema.json
53
55
  ndi files upload ./corpus --ingest
56
+ ndi ledger-understanding start # --force to re-scan; --selection-id after confirm
57
+ ndi ledger-understanding get
58
+ ndi ledger-understanding sources --review-reading-id READING
59
+ ndi ledger-understanding select --primary SRC --supporting SRC --revision REV
54
60
  ```
55
61
 
56
62
  `ndi files upload DIR` keeps relative paths under `--path` (default: the
@@ -80,6 +86,8 @@ characters; request input and unrelated error-detail fields are omitted.
80
86
  Unsupported inputs print `error: 422 [unsupported_file_type] ...` to stderr and
81
87
  exit 1. `ndi upload` prints no handle, document operations print no job ID, and
82
88
  `ndi files upload --ingest` creates no workspace file or ingestion job.
89
+ The server checks a bounded byte prefix, rejects conclusive filename/content
90
+ conflicts, and treats the multipart `Content-Type` as advisory.
83
91
 
84
92
  See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
85
93
  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.9.0"
3
+ version = "0.11.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.22,<1",
27
+ "ndi-sdk>=0.26,<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.9.0"
3
+ version = "0.11.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.22,<1",
30
+ "ndi-sdk>=0.26,<1",
31
31
  "truststore>=0.10.4,<1",
32
32
  ]
33
33
 
@@ -6,4 +6,4 @@ methods. The six workspace tools (``folder-metadata``, ``file-metadata``,
6
6
  in-sandbox surface.
7
7
  """
8
8
 
9
- __version__ = "0.9.0"
9
+ __version__ = "0.11.0"
@@ -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
- parser = _build_parser()
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 | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand, Field(discriminator="command")
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
- body = kwargs.get("body")
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,11 +14,13 @@ 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
20
21
  from ndi_sdk.models.files import FileDetail
21
22
  from ndi_sdk.models.jobs import (
23
+ AutomaticSearchResult,
22
24
  ClassifyResult,
23
25
  DeepSearchV2Result,
24
26
  ExtractResult,
@@ -28,13 +30,26 @@ from ndi_sdk.models.jobs import (
28
30
  IntelligentSearchResult,
29
31
  JetResult,
30
32
  Job,
33
+ LedgerUnderstandingResult,
31
34
  ParseResult,
32
35
  SplitResult,
33
36
  )
37
+ from ndi_sdk.models.ledger_understanding import (
38
+ LedgerUnderstandingReport,
39
+ SelectionDetails,
40
+ SelectionSummary,
41
+ SourceCatalog,
42
+ )
34
43
  from ndi_sdk.models.workspaces import Workspace, WorkspaceStats
35
44
  from ndi_sdk.models.tools import (
36
45
  FileMetadataToolResponse,
37
46
  FolderMetadataResponse,
47
+ GraphEdge,
48
+ GraphFact,
49
+ GraphNode,
50
+ KgInfo,
51
+ KgSearchResponse,
52
+ KgWalkResponse,
38
53
  QaFileResponse,
39
54
  ReadFileResponse,
40
55
  RunSqlResponse,
@@ -477,6 +492,214 @@ def run_sql(response: RunSqlResponse) -> str:
477
492
  return "\n".join(lines)
478
493
 
479
494
 
495
+ #: Source paths listed per entity in "sample" mode before the rest become a
496
+ #: count. A corpus hub is cited by hundreds of files, which would bury the
497
+ #: structure the caller asked for; the count stays exact and read-file still
498
+ #: takes any path.
499
+ FILE_SAMPLE = 5
500
+
501
+ #: Matched facts printed per entity by kg-search. The server returns every fact
502
+ #: the query hit on the node; the first few are the best-ranked and are what says
503
+ #: WHY the entity matched — the rest, and the entity's own stated facts, are one
504
+ #: kg-node away.
505
+ FACT_SAMPLE = 5
506
+
507
+ #: How much provenance a node row carries: none (kg-export, structure only),
508
+ #: a sample (kg-search, a foothold) or every file (kg-node, the detail lookup
509
+ #: that answers "which documents carry this entity").
510
+ FilesMode = Literal["none", "sample", "all"]
511
+
512
+
513
+ def _fact_row(fact: GraphFact) -> dict[str, object]:
514
+ row: dict[str, object] = {"predicate": fact.predicate, "value": fact.value, "source_file": fact.evidence.path}
515
+ if fact.evidence.page is not None:
516
+ row["page"] = fact.evidence.page
517
+ return row
518
+
519
+
520
+ def _node_row(node: GraphNode, *, files: FilesMode) -> dict[str, object]:
521
+ row: dict[str, object] = {
522
+ "node_id": node.node_id,
523
+ "node_type": node.node_type,
524
+ "title": node.title,
525
+ "degree": node.degree,
526
+ "neighbor_count": node.neighbor_count,
527
+ "source_file_count": len(node.source_files),
528
+ }
529
+ if node.aliases:
530
+ row["aliases"] = list(node.aliases)
531
+ if len(node.stated_types) > 1:
532
+ row["stated_types"] = [{"node_type": role.node_type, "mention_count": role.mention_count} for role in node.stated_types]
533
+ match files:
534
+ case "sample":
535
+ row["source_files"] = [ref.path for ref in node.source_files[:FILE_SAMPLE]]
536
+ case "all":
537
+ row["source_files"] = [ref.path for ref in node.source_files]
538
+ return row
539
+
540
+
541
+ def kg_info(response: KgInfo) -> str:
542
+ """The graph's shape and its entry points — the orient step before any kg-search.
543
+
544
+ Answers what a query cannot: whether the kind of thing being looked for is in
545
+ this graph at all, and which entities to seed from.
546
+ """
547
+ payload: dict[str, object] = {
548
+ "counts": {"nodes": response.node_total, "edges": response.edge_total},
549
+ "node_types": [{"type": item.type, "count": item.count} for item in response.node_types],
550
+ "edge_types": [{"type": item.type, "count": item.count} for item in response.edge_types],
551
+ "hubs": [
552
+ {
553
+ "node_id": hub.node_id,
554
+ "node_type": hub.node_type,
555
+ "title": hub.title,
556
+ "degree": hub.degree,
557
+ "neighbor_count": hub.neighbor_count,
558
+ }
559
+ for hub in response.hubs
560
+ ],
561
+ }
562
+ if response.path_prefix:
563
+ payload["path_prefix"] = response.path_prefix
564
+ payload["note"] = (
565
+ "node_types names every kind of entity this graph holds — a term matching none of them has nothing to find. "
566
+ "hubs are the entities connected to the most distinct things (neighbor_count; degree counts relation records, "
567
+ "one per file restating a relation) and valid kg-export seeds: a seed's own neighbours are always "
568
+ "returned, but hubs met further out are not walked through, so narrow a large neighbourhood with "
569
+ "--node-type / --edge-type rather than reading it whole."
570
+ )
571
+ return json.dumps(payload, ensure_ascii=False)
572
+
573
+
574
+ def kg_entities(response: KgSearchResponse) -> str:
575
+ """Entities with a few files to open next — kg-search's foothold.
576
+
577
+ JSON, not prose: the caller is a `python3 -c` on the other side of a pipe.
578
+ """
579
+ nodes: list[dict[str, object]] = []
580
+ for node in response.nodes:
581
+ row = _node_row(node, files="sample")
582
+ # The facts that matched the query, best first — why the entity is here.
583
+ row["facts"] = [_fact_row(fact) for fact in node.facts[:FACT_SAMPLE]]
584
+ if len(node.facts) > FACT_SAMPLE:
585
+ row["more_facts"] = len(node.facts) - FACT_SAMPLE
586
+ nodes.append(row)
587
+ payload: dict[str, object] = {"nodes": nodes}
588
+ if not response.nodes:
589
+ # A bare empty list says nothing about WHY, so a miss reads as a near miss
590
+ # worth one more try — which is how a run ends up walking an identifier
591
+ # space a guess at a time instead of asking what the graph contains.
592
+ payload["note"] = (
593
+ "No entities matched. Run `ndi kg-info` for the node types and hub entities this graph actually holds, "
594
+ "and search a term from that list — guessing or incrementing identifiers will not find one."
595
+ )
596
+ return json.dumps(payload, ensure_ascii=False)
597
+
598
+
599
+ def kg_node_detail(response: KgWalkResponse) -> str:
600
+ """Everything the graph holds on the chosen entities — kg-node's answer.
601
+
602
+ Per requested entity: the facts its documents state, its relations with the
603
+ other end named and the wording each was read from, and every file that
604
+ carries it. The export counts provenance and search samples it; this is the
605
+ one place the full list is printed, because "which documents mention X" is
606
+ closed here or not at all. The requested entities are the walk's seeds — the
607
+ nodes no path lands on; the rest of the response is the one-hop ring the
608
+ runner fetched so the relations can name their other end.
609
+ """
610
+ by_id = {node.node_id: node for node in response.nodes}
611
+ reached = {path.node_ids[-1] for path in response.paths}
612
+ incident: dict[str, list[GraphEdge]] = {}
613
+ for edge in response.edges:
614
+ for end in (edge.from_node_id, edge.to_node_id):
615
+ incident.setdefault(end, []).append(edge)
616
+ nodes: list[dict[str, object]] = []
617
+ for node in response.nodes:
618
+ if node.node_id in reached:
619
+ continue
620
+ row = _node_row(node, files="all")
621
+ row["facts"] = [_fact_row(fact) for fact in node.facts]
622
+ # One row per relation, not per record: a relation restated in another
623
+ # file is another edge, and a hub restates each of its hundreds of times.
624
+ # The first record's wording and file stand for it; ``records`` says how
625
+ # many files state it.
626
+ relations: dict[tuple[str, str, str], dict[str, object]] = {}
627
+ for edge in incident.get(node.node_id, []):
628
+ direction = "out" if edge.from_node_id == node.node_id else "in"
629
+ other_id = edge.to_node_id if direction == "out" else edge.from_node_id
630
+ key = (edge.edge_type, direction, other_id)
631
+ relation = relations.get(key)
632
+ if relation is not None:
633
+ relation["records"] = int(relation.get("records", 1)) + 1
634
+ continue
635
+ relation = {"edge_type": edge.edge_type, "direction": direction, "node_id": other_id}
636
+ other = by_id.get(other_id)
637
+ if other is not None:
638
+ relation["node_type"] = other.node_type
639
+ relation["title"] = other.title
640
+ if edge.evidence is not None:
641
+ relation["statement"] = edge.evidence.snippet
642
+ relation["source_file"] = edge.evidence.path
643
+ if edge.evidence.page is not None:
644
+ relation["page"] = edge.evidence.page
645
+ relations[key] = relation
646
+ row["relations"] = list(relations.values())
647
+ # neighbor_count is the distinct entities connected; fewer other ends here
648
+ # means the ring cap (or the walk's own) cut the rest.
649
+ if len({other_id for _, _, other_id in relations}) < node.neighbor_count:
650
+ row["relations_truncated"] = True
651
+ nodes.append(row)
652
+ payload: dict[str, object] = {"nodes": nodes}
653
+ if any(row.get("relations_truncated") for row in nodes):
654
+ payload["note"] = (
655
+ "relations_truncated: the entity connects to more than is shown; `kg-export --seed NODE_ID` prints "
656
+ "the whole ring, narrowed with --node-type / --edge-type."
657
+ )
658
+ return json.dumps(payload, ensure_ascii=False)
659
+
660
+
661
+ def kg_export(response: KgWalkResponse) -> str:
662
+ """The whole neighbourhood as structure, with the prose left behind.
663
+
664
+ Sized to survive a pipe, not to be read: facts and relation wording would
665
+ multiply the payload by the number of edges and are what ``kg-node`` prints
666
+ per entity once the caller's own code has picked its few. Edge ids stay out
667
+ too — no command takes one, and each cost as much as a node id.
668
+ """
669
+ edges: list[dict[str, object]] = []
670
+ for edge in response.edges:
671
+ row: dict[str, object] = {
672
+ "src": edge.from_node_id,
673
+ "dst": edge.to_node_id,
674
+ "edge_type": edge.edge_type,
675
+ }
676
+ if edge.evidence is not None:
677
+ row["source_file"] = edge.evidence.path
678
+ if edge.evidence.page is not None:
679
+ row["page"] = edge.evidence.page
680
+ edges.append(row)
681
+ nodes = [_node_row(node, files="none") for node in response.nodes]
682
+ payload: dict[str, object] = {
683
+ "counts": {"nodes": len(nodes), "edges": len(edges)},
684
+ "truncated": response.truncated,
685
+ "nodes": nodes,
686
+ "edges": edges,
687
+ }
688
+ # Every node but a seed arrives over an edge, so an edgeless node is a seed
689
+ # with no relation the caller can see (none at all, or none left after the
690
+ # type filters). Unsaid, that reads as a walk that silently failed.
691
+ touched = {end for edge in response.edges for end in (edge.from_node_id, edge.to_node_id)}
692
+ unexpanded = [node.node_id for node in response.nodes if node.node_id not in touched]
693
+ if unexpanded:
694
+ payload["unexpanded"] = unexpanded
695
+ payload["note"] = (
696
+ "These seeds have no visible relations: a seed's own neighbours are always returned, so either the "
697
+ "entity has none here or --node-type / --edge-type filtered them all. Read its facts and files with "
698
+ "kg-node, or seed from another entity found via kg-search."
699
+ )
700
+ return json.dumps(payload, ensure_ascii=False)
701
+
702
+
480
703
  def search(response: SearchResponse) -> str:
481
704
  if not response.hits:
482
705
  lines = ["no hits"]
@@ -656,6 +879,20 @@ def ingestion_job(job: Job) -> str:
656
879
  return "\n".join(lines)
657
880
 
658
881
 
882
+ def automatic_search_job(job: Job) -> str:
883
+ """The chosen route, then the selected pipeline's own rendering.
884
+
885
+ A filtered output's ``next:`` cursor pages through ``ndi filtered-search
886
+ --cursor``; ``output job`` is the id that owns receipts and that cursor.
887
+ """
888
+ result = job.result
889
+ if not isinstance(result, AutomaticSearchResult):
890
+ return job_line(job)
891
+ inner = job.model_copy(update={"result": result.output})
892
+ body = filtered_search_job(inner) if isinstance(result.output, FilteredSearchResult) else search_job(inner)
893
+ return f"route: {result.routing.final} — {result.routing.reason}\noutput job: {result.output_job_id}\n{body}"
894
+
895
+
659
896
  def search_job(job: Job) -> str:
660
897
  result = job.result
661
898
  if isinstance(result, DeepSearchV2Result):
@@ -721,6 +958,76 @@ def je_testing_job(job: Job) -> str:
721
958
  return "\n".join(lines)
722
959
 
723
960
 
961
+ def ledger_understanding_report(report: LedgerUnderstandingReport) -> str:
962
+ """Tables, integrity, open questions, and parse counts — the report at a glance."""
963
+ flags = [name for name, on in (("stale", report.stale), ("blocking", report.blocking)) if on]
964
+ head = "ledger understanding"
965
+ if flags:
966
+ head += f" ({', '.join(flags)})"
967
+ lines = [head, f"tables ({len(report.tables)}):"]
968
+ for table in report.tables:
969
+ key = f" key {', '.join(table.journal_key)}" if table.journal_key else ""
970
+ share = f" balanced {table.key_balanced_share:.2f}" if table.key_balanced_share is not None else ""
971
+ lines.append(f" {table.role} {table.table_name} {table.rows} rows{key}{share}")
972
+ lines.append(f"integrity: {len(report.integrity.findings)} findings, {len(report.integrity.skipped)} skipped")
973
+ for finding in report.integrity.findings:
974
+ lines.append(f" {finding.severity} {finding.check}: {finding.message}")
975
+ for skipped in report.integrity.skipped:
976
+ lines.append(f" skipped {skipped.check}: {skipped.reason}")
977
+ lines.append(f"open questions ({len(report.open_questions)}):")
978
+ for question in report.open_questions:
979
+ lines.append(f" {question.id}: {question.question}")
980
+ parsing = report.parsing
981
+ if parsing is None:
982
+ lines.append("parsing: none")
983
+ elif parsing.skipped_reason:
984
+ lines.append(f"parsing: skipped ({parsing.skipped_reason})")
985
+ else:
986
+ lines.append(
987
+ f"parsing: {parsing.journals_total} journals, {parsing.parsed} parsed, "
988
+ f"{parsing.identity} identity, {parsing.hub} hub, {parsing.unbalanced} unbalanced"
989
+ )
990
+ return "\n".join(lines)
991
+
992
+
993
+ def ledger_understanding_job(job: Job) -> str:
994
+ result = job.result
995
+ if isinstance(result, LedgerUnderstandingResult):
996
+ return ledger_understanding_report(result.report)
997
+ return job_line(job)
998
+
999
+
1000
+ def ledger_source_catalog(catalog: SourceCatalog) -> str:
1001
+ lines = [f"revision {catalog.revision}", f"sources ({len(catalog.sources)}):"]
1002
+ for source in catalog.sources:
1003
+ flags = []
1004
+ if source.recommended_as_primary:
1005
+ flags.append("recommended")
1006
+ if source.review_eligible is False:
1007
+ flags.append("ineligible")
1008
+ flag = f" {', '.join(flags)}" if flags else ""
1009
+ lines.append(f" {source.label} {source.rows} rows {source.file_count} files {source.source_id}{flag}")
1010
+ if catalog.warnings:
1011
+ lines.append("warnings:")
1012
+ lines.extend(f" {warning}" for warning in catalog.warnings)
1013
+ return "\n".join(lines)
1014
+
1015
+
1016
+ def ledger_selection(summary: SelectionSummary) -> str:
1017
+ lines = [
1018
+ f"selection {summary.selection_id}",
1019
+ f"primary {summary.primary}",
1020
+ f"supporting ({len(summary.supporting)}): {', '.join(summary.supporting) or 'none'}",
1021
+ f"files {summary.file_count}",
1022
+ ]
1023
+ if isinstance(summary, SelectionDetails):
1024
+ lines.append(f"package {summary.package}")
1025
+ lines.append(f"primary_source_id {summary.primary_source_id}")
1026
+ if summary.stale:
1027
+ lines.append("stale")
1028
+ return "\n".join(lines)
1029
+
1030
+
724
1031
  #: What a cell's non-``found`` status means, glossed for a reader who has only
725
1032
  #: this text. A field nothing could be read from is not an absent one, and
726
1033
  #: neither is one that resisted normalizing — keeping them distinct is what
@@ -1,4 +1,4 @@
1
- """The six workspace-tool verbs. Shared with the in-sandbox agent CLI."""
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
- def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
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
@@ -116,6 +116,20 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
116
116
  add_job_output_args(fact)
117
117
  fact.set_defaults(run=_run_fact_search, family="workspace", needs_workspace=True, needs_client=True)
118
118
 
119
+ # No --effort, --session-id, or --allow-clarification: the route is
120
+ # single-shot and the service chooses the pipeline.
121
+ automatic = sub.add_parser(
122
+ "automatic-search",
123
+ parents=[common],
124
+ help="Let the service choose fact, deep, or filtered search for one query.",
125
+ )
126
+ automatic.add_argument("query")
127
+ automatic.add_argument("--prefix", default=None, dest="path_prefix")
128
+ automatic.add_argument("--top-k", type=int, default=None, help="Cap the citation list when the fact pipeline answers.")
129
+ automatic.add_argument("--context", default=None)
130
+ add_job_output_args(automatic)
131
+ automatic.set_defaults(run=_run_automatic_search, family="workspace", needs_workspace=True, needs_client=True)
132
+
119
133
  filtered = sub.add_parser(
120
134
  "filtered-search", parents=[common], help="Run a filter/rank/count query over the metadata catalog."
121
135
  )
@@ -143,6 +157,53 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
143
157
  add_job_output_args(jet)
144
158
  jet.set_defaults(run=_run_je_testing, family="workspace", needs_workspace=True, needs_client=True)
145
159
 
160
+ ledger = sub.add_parser("ledger-understanding", help="Start or read a ledger-understanding report.")
161
+ lu = ledger.add_subparsers(dest="ledger_command", required=True)
162
+
163
+ lu_start = lu.add_parser("start", parents=[common], help="Start a ledger-understanding run.")
164
+ lu_start.add_argument("--force", action="store_true", help="Re-measure even when a report for this package exists.")
165
+ _add_ledger_scope_args(lu_start)
166
+ add_job_output_args(lu_start)
167
+ lu_start.set_defaults(run=_run_ledger_understanding_start, family="workspace", needs_workspace=True, needs_client=True)
168
+
169
+ lu_get = lu.add_parser("get", parents=[common], help="Read the latest ledger-understanding report.")
170
+ _add_ledger_scope_args(lu_get)
171
+ lu_get.set_defaults(run=_run_ledger_understanding_get, family="workspace", needs_workspace=True, needs_client=True)
172
+
173
+ lu_sources = lu.add_parser("sources", parents=[common], help="List reviewable ledger sources.")
174
+ _add_ledger_scope_args(lu_sources)
175
+ lu_sources.add_argument("--review-reading-id", default=None, help="Bind the catalog to this reading's review.")
176
+ lu_sources.set_defaults(run=_run_ledger_sources, family="workspace", needs_workspace=True, needs_client=True)
177
+
178
+ lu_select = lu.add_parser("select", parents=[common], help="Confirm a primary population and supporting sources.")
179
+ lu_select.add_argument(
180
+ "--primary", required=True, dest="primary_source_id", help="Catalog handle for the primary population."
181
+ )
182
+ lu_select.add_argument(
183
+ "--supporting",
184
+ action="append",
185
+ dest="supporting_source_ids",
186
+ default=None,
187
+ help="Supporting source handle; repeatable.",
188
+ )
189
+ lu_select.add_argument("--revision", required=True, dest="expected_catalog_revision", help="Catalog revision last listed.")
190
+ lu_select.add_argument("--review-reading-id", default=None, help="Reading that requested this source review.")
191
+ lu_select.add_argument("--review-prefix", default=None, dest="review_path_prefix")
192
+ lu_select.add_argument("--review-path", action="append", dest="review_paths", default=None)
193
+ lu_select.add_argument("--review-selection-id", default=None)
194
+ lu_select.add_argument("--previous-selection-id", default=None)
195
+ lu_select.add_argument(
196
+ "--confirm-unrecognized",
197
+ action="store_true",
198
+ dest="confirm_unrecognized_primary",
199
+ help="Confirm a primary that structural recognition did not recommend.",
200
+ )
201
+ lu_select.set_defaults(run=_run_ledger_select, family="workspace", needs_workspace=True, needs_client=True)
202
+
203
+ lu_selection = lu.add_parser("selection", parents=[common], help="Read a confirmed source selection.")
204
+ lu_selection.add_argument("selection_id")
205
+ lu_selection.set_defaults(run=_run_ledger_selection, family="workspace", needs_workspace=True, needs_client=True)
206
+
146
207
 
147
208
  def _workspace_id(args: argparse.Namespace, fallback: str) -> str:
148
209
  value = getattr(args, "workspace_id", None) or args.workspace or fallback
@@ -438,6 +499,17 @@ def _run_fact_search(client: NdiClient, workspace_id: str, args: argparse.Namesp
438
499
  return _finish_job(client, job, args, _render.search_job, "fact-search")
439
500
 
440
501
 
502
+ def _run_automatic_search(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
503
+ job = client.search.automatic(
504
+ workspace_id,
505
+ query=args.query,
506
+ context=args.context,
507
+ path_prefix=args.path_prefix,
508
+ top_k=args.top_k,
509
+ )
510
+ return _finish_job(client, job, args, _render.automatic_search_job, "automatic-search")
511
+
512
+
441
513
  def _run_filtered_search(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
442
514
  if args.query and args.cursor:
443
515
  print("error: pass a query or --cursor, not both", file=sys.stderr)
@@ -473,3 +545,68 @@ def _run_je_testing(client: NdiClient, workspace_id: str, args: argparse.Namespa
473
545
  paths=args.paths,
474
546
  )
475
547
  return _finish_job(client, job, args, _render.je_testing_job, "je-testing")
548
+
549
+
550
+ def _add_ledger_scope_args(parser: argparse.ArgumentParser) -> None:
551
+ parser.add_argument("--prefix", default=None, dest="path_prefix")
552
+ parser.add_argument("--path", action="append", dest="paths", default=None, help="Scope to this file; repeatable.")
553
+ parser.add_argument(
554
+ "--selection-id", default=None, help="Confirmed source selection; cannot be combined with path selectors."
555
+ )
556
+
557
+
558
+ def _ledger_scope(args: argparse.Namespace) -> dict[str, object]:
559
+ return {
560
+ "path_prefix": getattr(args, "path_prefix", None),
561
+ "paths": getattr(args, "paths", None),
562
+ "selection_id": getattr(args, "selection_id", None),
563
+ }
564
+
565
+
566
+ def _run_ledger_understanding_start(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
567
+ job = client.workspaces.start_ledger_understanding(workspace_id, force=args.force, **_ledger_scope(args))
568
+ return _finish_job(client, job, args, _render.ledger_understanding_job, "ledger-understanding")
569
+
570
+
571
+ def _run_ledger_understanding_get(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
572
+ report = client.workspaces.get_ledger_understanding(workspace_id, **_ledger_scope(args))
573
+ return _dump_or_text(report, args, text=_render.ledger_understanding_report(report), stem="ledger-understanding")
574
+
575
+
576
+ def _run_ledger_sources(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
577
+ catalog = client.workspaces.list_ledger_sources(
578
+ workspace_id,
579
+ review_reading_id=args.review_reading_id,
580
+ **_ledger_scope(args),
581
+ )
582
+ return _dump_or_text(catalog, args, text=_render.ledger_source_catalog(catalog), stem="ledger-sources")
583
+
584
+
585
+ def _run_ledger_select(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
586
+ from ndi_sdk.models.ledger_understanding import LedgerPackageScope, SourceReviewReference
587
+
588
+ review = None
589
+ if args.review_reading_id:
590
+ review = SourceReviewReference(
591
+ reading_id=args.review_reading_id,
592
+ scope=LedgerPackageScope(
593
+ path_prefix=args.review_path_prefix,
594
+ paths=args.review_paths or [],
595
+ selection_id=args.review_selection_id,
596
+ ),
597
+ )
598
+ summary = client.workspaces.select_ledger_sources(
599
+ workspace_id,
600
+ primary_source_id=args.primary_source_id,
601
+ supporting_source_ids=args.supporting_source_ids,
602
+ expected_catalog_revision=args.expected_catalog_revision,
603
+ review=review,
604
+ previous_selection_id=args.previous_selection_id,
605
+ confirm_unrecognized_primary=args.confirm_unrecognized_primary,
606
+ )
607
+ return _dump_or_text(summary, args, text=_render.ledger_selection(summary), stem="ledger-selection")
608
+
609
+
610
+ def _run_ledger_selection(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
611
+ details = client.workspaces.get_ledger_selection(workspace_id, args.selection_id)
612
+ 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