ndi-cli 0.7.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.7.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
@@ -56,6 +56,7 @@ 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) |
59
60
  | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--effort` for the level) |
60
61
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
61
62
 
@@ -82,6 +83,14 @@ to **stderr**. `-o auto|md|json|payload|id` picks the shape. `--json` is an
82
83
  alias for `-o json`. `--save PATH` / `--out-dir DIR` write files. `--async`
83
84
  submits and prints the job id.
84
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
+
85
94
  API failures exit 1. Usage / config errors exit 2. Schema-validation
86
95
  failures also show up to five field constraints, each limited to 500
87
96
  characters; request input and unrelated error-detail fields are omitted.
@@ -32,6 +32,7 @@ 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) |
35
36
  | `ndi je-testing QUERY` | Journal-entry testing over a ledger package (`--path` to scope it, `--effort` for the level) |
36
37
  | `ndi folder-metadata` / `file-metadata` / `read-file` / `ask-file` / `run-sql` / `hybrid-search` | Read-only workspace tools (the sandbox surface) |
37
38
 
@@ -58,6 +59,14 @@ to **stderr**. `-o auto|md|json|payload|id` picks the shape. `--json` is an
58
59
  alias for `-o json`. `--save PATH` / `--out-dir DIR` write files. `--async`
59
60
  submits and prints the job id.
60
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
+
61
70
  API failures exit 1. Usage / config errors exit 2. Schema-validation
62
71
  failures also show up to five field constraints, each limited to 500
63
72
  characters; request input and unrelated error-detail fields are omitted.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.7.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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ndi-cli"
3
- version = "0.7.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" }
@@ -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.7.0"
9
+ __version__ = "0.8.0"
@@ -21,6 +21,7 @@ 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,
@@ -706,6 +707,131 @@ def je_testing_job(job: Job) -> str:
706
707
  return "\n".join(lines)
707
708
 
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
+
709
835
  def _search_result(answer: str | None, clarification: str | None, evidences: list, *, receipts: int) -> str:
710
836
  lines: list[str] = []
711
837
  if clarification:
@@ -95,6 +95,22 @@ 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
+
98
114
  jet = sub.add_parser("je-testing", parents=[common], help="Run journal-entry testing over a ledger package.")
99
115
  jet.add_argument("query")
100
116
  jet.add_argument("--effort", choices=("low", "medium", "high"), default=None)
@@ -237,6 +253,31 @@ def _run_fact_search(client: NdiClient, workspace_id: str, args: argparse.Namesp
237
253
  return _finish_job(client, job, args, _render.search_job, "fact-search")
238
254
 
239
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
+
240
281
  def _run_je_testing(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
241
282
  job = client.search.je_testing(
242
283
  workspace_id,
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
File without changes