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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndi-cli
3
- Version: 0.9.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.22,<1
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.9.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.22,<1",
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.9.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.22,<1",
30
+ "ndi-sdk>=0.25,<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.10.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,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 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
@@ -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