ndi-cli 0.6.0__tar.gz → 0.8.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.6.0
3
+ Version: 0.8.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.21,<1
19
+ Requires-Dist: ndi-sdk>=0.22,<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
@@ -56,6 +56,8 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
56
56
  | `ndi files upload\|list\|get\|delete` | Workspace files (`--ingest` uploads then queues ingestion) |
57
57
  | `ndi ingest` | Queue ingestion |
58
58
  | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search |
59
+ | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
60
+ | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--effort` for the level) |
59
61
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
60
62
 
61
63
  ## Sources
@@ -81,8 +83,17 @@ to **stderr**. `-o auto|md|json|payload|id` picks the shape. `--json` is an
81
83
  alias for `-o json`. `--save PATH` / `--out-dir DIR` write files. `--async`
82
84
  submits and prints the job id.
83
85
 
86
+ When a Parse result has external complete content, `-o auto` and `-o md` print
87
+ the inline preview. Use `-o json` or `-o payload` to inspect its disclosure and
88
+ authenticated full-content URL. A final-size preview carries
89
+ `content_truncated`; reduced-layout Office parsing carries `parse_fidelity`.
90
+ Documents use `document.content_url`; workbooks use the affected
91
+ `document.spreadsheet.sheets[].content_url`. The CLI does not download these
92
+ URLs automatically.
93
+
84
94
  API failures exit 1. Usage / config errors exit 2. Schema-validation
85
95
  failures also show up to five field constraints, each limited to 500
86
96
  characters; request input and unrelated error-detail fields are omitted.
87
97
 
88
- See `SKILL.md` for the agent-facing guide to the six workspace tools.
98
+ See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
99
+ reference. `SKILL.md` is the agent-facing guide to the six workspace tools.
@@ -32,6 +32,8 @@ config location. Workspace-scoped verbs also take `--workspace ID`.
32
32
  | `ndi files upload\|list\|get\|delete` | Workspace files (`--ingest` uploads then queues ingestion) |
33
33
  | `ndi ingest` | Queue ingestion |
34
34
  | `ndi deep-search QUERY` / `ndi fact-search QUERY` | Agentic / single-shot search |
35
+ | `ndi filtered-search QUERY` | Corpus filter/rank/count over the metadata catalog (`--cursor` pages an earlier run) |
36
+ | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--effort` for the level) |
35
37
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
36
38
 
37
39
  ## Sources
@@ -57,8 +59,17 @@ to **stderr**. `-o auto|md|json|payload|id` picks the shape. `--json` is an
57
59
  alias for `-o json`. `--save PATH` / `--out-dir DIR` write files. `--async`
58
60
  submits and prints the job id.
59
61
 
62
+ When a Parse result has external complete content, `-o auto` and `-o md` print
63
+ the inline preview. Use `-o json` or `-o payload` to inspect its disclosure and
64
+ authenticated full-content URL. A final-size preview carries
65
+ `content_truncated`; reduced-layout Office parsing carries `parse_fidelity`.
66
+ Documents use `document.content_url`; workbooks use the affected
67
+ `document.spreadsheet.sheets[].content_url`. The CLI does not download these
68
+ URLs automatically.
69
+
60
70
  API failures exit 1. Usage / config errors exit 2. Schema-validation
61
71
  failures also show up to five field constraints, each limited to 500
62
72
  characters; request input and unrelated error-detail fields are omitted.
63
73
 
64
- See `SKILL.md` for the agent-facing guide to the six workspace tools.
74
+ See the [CLI guide](https://docs.ndi.nace.ai/guides/cli) for the full command
75
+ 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.6.0"
3
+ version = "0.8.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.21,<1",
27
+ "ndi-sdk>=0.22,<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.6.0"
3
+ version = "0.8.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.21,<1",
30
+ "ndi-sdk>=0.22,<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.6.0"
9
+ __version__ = "0.8.0"
@@ -6,7 +6,7 @@ import sys
6
6
  from collections.abc import Callable
7
7
  from pathlib import Path
8
8
 
9
- from ndi_sdk.models.jobs import IngestionResult, Job, ParseResult
9
+ from ndi_sdk.models.jobs import IngestionResult, JetResult, Job, ParseResult
10
10
 
11
11
  OUTPUT_CHOICES = ("auto", "md", "json", "payload", "id")
12
12
 
@@ -108,6 +108,13 @@ def _content_failure(job: Job) -> int:
108
108
  file=sys.stderr,
109
109
  )
110
110
  return 1
111
+ # A degraded JET run is the same shape: succeeded, but the procedure broke
112
+ # before it could finalize an answer, so `figures` is empty and only the
113
+ # working survives. `exhausted` and `partially_performed` stay zero — both
114
+ # are documented as successful partial answers, not failed work.
115
+ if isinstance(result, JetResult) and result.degraded:
116
+ print(f"error: journal-entry testing degraded: {result.degraded}", file=sys.stderr)
117
+ return 1
111
118
  return 0
112
119
 
113
120
 
@@ -21,9 +21,11 @@ from ndi_sdk.models.jobs import (
21
21
  ClassifyResult,
22
22
  DeepSearchV2Result,
23
23
  ExtractResult,
24
+ FilteredSearchResult,
24
25
  GroundResult,
25
26
  IngestionResult,
26
27
  IntelligentSearchResult,
28
+ JetResult,
27
29
  Job,
28
30
  ParseResult,
29
31
  SplitResult,
@@ -662,6 +664,174 @@ def search_job(job: Job) -> str:
662
664
  return job_line(job)
663
665
 
664
666
 
667
+ def je_testing_job(job: Job) -> str:
668
+ """The answer, then the numbers behind it — a JET result read at a glance.
669
+
670
+ Unlike :func:`search_job` this leads with ``figures``: the answer is prose a
671
+ person reads, and the figures are the same content a reviewer can check.
672
+ Each one names the receipt its numbers are readable out of, so
673
+ ``ndi job <id> -o json`` is the next step rather than the only one.
674
+ """
675
+ result = job.result
676
+ if not isinstance(result, JetResult):
677
+ return job_line(job)
678
+ lines: list[str] = []
679
+ if result.degraded:
680
+ lines.append(f"degraded: {result.degraded}")
681
+ if result.answer:
682
+ lines.append(result.answer.strip())
683
+ if result.population_statement:
684
+ lines.append(f"population: {result.population_statement}")
685
+ if result.figures:
686
+ lines.append(f"figures ({len(result.figures)}):")
687
+ for figure in result.figures:
688
+ if figure.cannot_be_performed:
689
+ lines.append(f" {figure.criterion}: not performed")
690
+ else:
691
+ value = f" | {figure.value}" if figure.value else ""
692
+ lines.append(f" {figure.criterion}: {figure.count}/{figure.population}{value}")
693
+ # The exception listing is the receipt worth opening: an unfiltered
694
+ # count points at every row, a listing points at the rows that failed.
695
+ receipt = figure.exception_listing_result_id or next(iter(figure.result_ids), None)
696
+ if receipt:
697
+ lines.append(f" receipt {receipt}")
698
+ for discrepancy in result.discrepancies:
699
+ lines.append(f"discrepancy: {discrepancy}")
700
+ lines.append(f"evidences ({len(result.evidences)}) | receipts ({len(result.sql_receipts)})")
701
+ if result.execution_status and result.execution_status != "performed":
702
+ lines.append(f"execution: {result.execution_status}")
703
+ if result.quality and result.quality.status != "clean":
704
+ lines.append(f"quality: {result.quality.status}")
705
+ if result.exhausted:
706
+ lines.append("exhausted: step budget ran out before the procedure finished")
707
+ return "\n".join(lines)
708
+
709
+
710
+ #: What a cell's non-``found`` status means, glossed for a reader who has only
711
+ #: this text. A field nothing could be read from is not an absent one, and
712
+ #: neither is one that resisted normalizing — keeping them distinct is what
713
+ #: makes a thin row interpretable without opening the JSON.
714
+ _CELL_STATUS = {
715
+ "not_found": "examined; field absent",
716
+ "ambiguous": "present, not normalizable",
717
+ "unexamined": "extraction could not say",
718
+ }
719
+
720
+
721
+ def _cell_value(cell) -> str:
722
+ value = cell.value
723
+ if value is None:
724
+ return _CELL_STATUS.get(cell.status, cell.status)
725
+ match value.kind:
726
+ case "money":
727
+ # Amounts keep their own currency here because the server never
728
+ # converts one either; comparing across currencies is unsupported,
729
+ # so dropping the code would invent a comparison.
730
+ assumed = " (currency assumed)" if value.currency_source == "assumed" else ""
731
+ return f"{value.currency} {value.amount}{assumed}"
732
+ case "text":
733
+ return _clip(value.text)
734
+ case "date":
735
+ return value.date
736
+ case "number":
737
+ return value.number
738
+ case "boolean":
739
+ return "true" if value.value else "false"
740
+ return value.kind
741
+
742
+
743
+ def _page_range(row) -> str:
744
+ if row.page_start is None:
745
+ return ""
746
+ if row.page_end is not None and row.page_end != row.page_start:
747
+ return f" (pages {row.page_start}-{row.page_end})"
748
+ return f" (page {row.page_start})"
749
+
750
+
751
+ def _filtered_rows(result: FilteredSearchResult) -> list[str]:
752
+ """The rows, carrying the columns their membership hinges on.
753
+
754
+ A count-style question confirms ``total_matches`` and returns no rows at
755
+ all, so an empty listing is an answer rather than a gap — it prints nothing
756
+ instead of an empty header.
757
+ """
758
+ if not result.rows:
759
+ return []
760
+ # The server marks which columns its published query filtered or ranked on,
761
+ # and those are the values every row's membership depends on; `requested` is
762
+ # what the reader asked to see. Everything else is context, left for --json.
763
+ hinges = [column.field for column in result.columns if column.filtered]
764
+ shown = hinges + [column.field for column in result.columns if column.requested and column.field not in hinges]
765
+ lines = [f"rows ({len(result.rows)}):"]
766
+ for row in result.rows:
767
+ where = f" [{row.component}]" if row.component else ""
768
+ lines.append(f" {row.source_path}{where}{_page_range(row)}")
769
+ for field in shown or list(dict.fromkeys(cell.field for cell in row.cells)):
770
+ # A repeated column carries one cell per item, so join them rather
771
+ # than showing the first and implying the field held one value.
772
+ values = [_cell_value(cell) for cell in row.cells if cell.field == field]
773
+ if values:
774
+ lines.append(f" {field}: {'; '.join(values)}")
775
+ return lines
776
+
777
+
778
+ def _filtered_caveats(result: FilteredSearchResult) -> list[str]:
779
+ lines: list[str] = []
780
+ if not result.exhaustive:
781
+ lines.append("not exhaustive: the matches are valid, but total_matches is a floor and not a census")
782
+ if result.truncated:
783
+ lines.append("truncated: the published query matched more than this page carries")
784
+ if result.catalog_drifted:
785
+ lines.append("catalog drifted: the underlying data changed since the query first ran")
786
+ # `exhaustive` is scope-wide and almost always false, so it cannot say *what*
787
+ # was incomplete. Only a resolved value satisfies a predicate, so a field the
788
+ # extraction could not read drops its components silently — a correct query
789
+ # coming back thin for a reason unrelated to the question.
790
+ for entry in result.field_readability:
791
+ read = f"{entry.readable} of {entry.in_scope} values read"
792
+ if entry.readable == 0:
793
+ lines.append(f"unreadable: {entry.field} — {read}; a thin result here reflects that, not an absence")
794
+ else:
795
+ lines.append(f"partly readable: {entry.field} — {read}")
796
+ check = result.content_verification
797
+ if check and check.unverified_candidates:
798
+ lines.append(
799
+ f"content check: {check.unverified_candidates} of {check.candidates} candidates unverified "
800
+ f"for {_clip(check.condition, 120)!r} — unknown is not a non-match"
801
+ )
802
+ lines.extend(_coverage_lines(result.coverage))
803
+ if result.exhausted:
804
+ lines.append("exhausted: step budget ran out before the query finished")
805
+ return lines
806
+
807
+
808
+ def filtered_search_job(job: Job) -> str:
809
+ """The totals, then the rows behind them — a catalog answer read at a glance.
810
+
811
+ A filter/rank/count question is answered by a number, so this leads with the
812
+ counts and prints the published query under them: the figure and the
813
+ statement it came out of travel together, the way a JET figure names its
814
+ receipt. Rows carry the columns the matches hinge on; ``-o json`` has every
815
+ cell, citation and column the catalog returned.
816
+ """
817
+ result = job.result
818
+ if not isinstance(result, FilteredSearchResult):
819
+ return job_line(job)
820
+ lines: list[str] = []
821
+ if result.interpretation:
822
+ lines.append(f"interpretation: {_clip(result.interpretation)}")
823
+ files = f" ({result.total_files} files)" if result.total_files is not None else ""
824
+ confidence = f" | confidence: {result.confidence}" if result.confidence else ""
825
+ lines.append(f"matches: {result.total_matches}{files}{confidence}")
826
+ if result.applied_query:
827
+ lines.append(f"query: {_clip(result.applied_query, 300)}")
828
+ lines.extend(_filtered_rows(result))
829
+ lines.extend(_filtered_caveats(result))
830
+ if result.next_cursor:
831
+ lines.append(f"next: {result.next_cursor}")
832
+ return "\n".join(lines)
833
+
834
+
665
835
  def _search_result(answer: str | None, clarification: str | None, evidences: list, *, receipts: int) -> str:
666
836
  lines: list[str] = []
667
837
  if clarification:
@@ -95,6 +95,33 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
95
95
  add_job_output_args(fact)
96
96
  fact.set_defaults(run=_run_fact_search, family="workspace", needs_workspace=True, needs_client=True)
97
97
 
98
+ filtered = sub.add_parser(
99
+ "filtered-search", parents=[common], help="Run a filter/rank/count query over the metadata catalog."
100
+ )
101
+ filtered.add_argument("query", nargs="?", default=None)
102
+ filtered.add_argument(
103
+ "--cursor", default=None, help="Page an earlier run: paste its result's next_cursor. A page is a new job."
104
+ )
105
+ filtered.add_argument("--prefix", default=None, dest="path_prefix")
106
+ filtered.add_argument("--context", default=None)
107
+ # No --result-unit: the server deprecates the grouping override and returns
108
+ # catalog entries. No --allow-clarification: a clarification is answered by a
109
+ # new initial query, and a shell has nowhere to keep the thread — the reason
110
+ # deep-search omits --session-id.
111
+ add_job_output_args(filtered)
112
+ filtered.set_defaults(run=_run_filtered_search, family="workspace", needs_workspace=True, needs_client=True)
113
+
114
+ jet = sub.add_parser("je-testing", parents=[common], help="Run journal-entry testing over a ledger package.")
115
+ jet.add_argument("query")
116
+ jet.add_argument("--effort", choices=("low", "medium", "high"), default=None)
117
+ jet.add_argument("--prefix", default=None, dest="path_prefix")
118
+ jet.add_argument("--path", action="append", dest="paths", default=None, help="Scope to this file; repeatable.")
119
+ jet.add_argument("--context", default=None)
120
+ # No --session-id, for the reason deep-search omits it: a shell has nowhere
121
+ # to keep a thread. Omitting --effort leaves the deployment's own floor.
122
+ add_job_output_args(jet)
123
+ jet.set_defaults(run=_run_je_testing, family="workspace", needs_workspace=True, needs_client=True)
124
+
98
125
 
99
126
  def _workspace_id(args: argparse.Namespace, fallback: str) -> str:
100
127
  value = getattr(args, "workspace_id", None) or args.workspace or fallback
@@ -224,3 +251,40 @@ def _run_fact_search(client: NdiClient, workspace_id: str, args: argparse.Namesp
224
251
  top_k=args.top_k,
225
252
  )
226
253
  return _finish_job(client, job, args, _render.search_job, "fact-search")
254
+
255
+
256
+ def _run_filtered_search(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
257
+ if args.query and args.cursor:
258
+ print("error: pass a query or --cursor, not both", file=sys.stderr)
259
+ return 2
260
+ if not args.query and not args.cursor:
261
+ print("error: pass a query, or --cursor to page an earlier run", file=sys.stderr)
262
+ return 2
263
+ if args.cursor:
264
+ # A page re-executes the parent's own stored query, so the scoping flags
265
+ # cannot apply to it. Accepting and dropping them would quietly answer a
266
+ # different question than the one typed.
267
+ if args.path_prefix or args.context:
268
+ print("error: --prefix and --context belong to the initial query, not a page", file=sys.stderr)
269
+ return 2
270
+ job = client.search.filtered_page(workspace_id, cursor=args.cursor)
271
+ else:
272
+ job = client.search.filtered(
273
+ workspace_id,
274
+ query=args.query,
275
+ context=args.context,
276
+ path_prefix=args.path_prefix,
277
+ )
278
+ return _finish_job(client, job, args, _render.filtered_search_job, "filtered-search")
279
+
280
+
281
+ def _run_je_testing(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
282
+ job = client.search.je_testing(
283
+ workspace_id,
284
+ query=args.query,
285
+ context=args.context,
286
+ effort=args.effort,
287
+ path_prefix=args.path_prefix,
288
+ paths=args.paths,
289
+ )
290
+ return _finish_job(client, job, args, _render.je_testing_job, "je-testing")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes