smartpipe-cli 1.3.0__py3-none-any.whl

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.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
smartpipe/verbs/map.py ADDED
@@ -0,0 +1,315 @@
1
+ """The ``map`` verb: transform each item with a prompt (spec §3.1).
2
+
3
+ Orchestration only — the parsing, planning, schema work, and ordering all live in
4
+ the pure engine; this is the imperative shell that wires a resolved model to the
5
+ runner and streams results out. The one bit of per-verb cleverness is the single
6
+ repair retry: a structured reply that fails validation is re-asked once with the
7
+ validator's complaint before the item is skipped.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from functools import partial
16
+ from typing import TYPE_CHECKING, Protocol
17
+
18
+ from smartpipe.core.errors import ExitCode, ItemError, UsageFault
19
+ from smartpipe.engine.prompts import (
20
+ build_map_request,
21
+ build_repair_request,
22
+ parse_prompt,
23
+ plan_map,
24
+ to_instruction,
25
+ )
26
+ from smartpipe.engine.runner import Done, FailurePolicy, run_ordered
27
+ from smartpipe.engine.schema import load_schema, validate_and_coerce
28
+ from smartpipe.io import diagnostics, readers
29
+ from smartpipe.io.inputs import STDIN
30
+ from smartpipe.io.items import describe_source
31
+ from smartpipe.io.progress import make_stderr_spinner
32
+ from smartpipe.models.base import AudioData, VideoData
33
+ from smartpipe.verbs.common import (
34
+ WindowGate,
35
+ interrupted_exit_code,
36
+ outcome_exit_code,
37
+ resolve_schema,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from pathlib import Path
42
+ from typing import TextIO
43
+
44
+ from smartpipe.engine.prompts import MapPlan
45
+ from smartpipe.io.inputs import InputSpec
46
+ from smartpipe.io.items import Item
47
+ from smartpipe.io.writers import OutputFormat, ResultWriter
48
+ from smartpipe.models.base import ChatModel, MediaData, ModelRef
49
+
50
+ __all__ = ["MapContext", "MapRequest", "map_one", "run_map"]
51
+
52
+ _PROMPT_OVERHEAD_TOKENS = 500 # instruction + wrapper + reply headroom
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class MapRequest:
57
+ prompt: str
58
+ schema_path: Path | None
59
+ model_flag: str | None
60
+ output: OutputFormat
61
+ concurrency_flag: int | None
62
+ input: InputSpec = STDIN
63
+ fields: tuple[str, ...] | None = None # --fields: project structured output
64
+ schema_dsl: str | None = None # --schema-from (rung 3, D22)
65
+ tally_field: str | None = None # --tally FIELD: live distribution on stderr
66
+ explode_field: str | None = None # --explode FIELD: one row per list element
67
+ frame_every: float | None = None # --frame-every SECONDS: video density guarantee (D43)
68
+ max_frames: int | None = None # --max-frames N: video frame budget (D43)
69
+
70
+
71
+ class MapContext(Protocol):
72
+ """The slice of the container ``map`` needs — a DI seam so tests inject fakes."""
73
+
74
+ async def chat_model(self, flag: str | None = None) -> ChatModel: ...
75
+ async def context_window(self, ref: ModelRef) -> int | None: ...
76
+ def concurrency(self, flag: int | None = None) -> int: ...
77
+ def writer(
78
+ self,
79
+ output_flag: OutputFormat,
80
+ *,
81
+ structured: bool,
82
+ stdout: TextIO,
83
+ fields: tuple[str, ...] | None = None,
84
+ ) -> ResultWriter: ...
85
+
86
+
87
+ async def run_map(
88
+ request: MapRequest,
89
+ context: MapContext,
90
+ *,
91
+ stdin: TextIO,
92
+ stdout: TextIO,
93
+ stop: asyncio.Event | None = None,
94
+ ) -> ExitCode:
95
+ tokens = parse_prompt(request.prompt, allow_descriptions=True) # rung 2 (D22)
96
+ schema = resolve_schema(request.schema_path, request.schema_dsl, loader=load_schema)
97
+ plan = plan_map(tokens, schema=schema)
98
+ instruction = to_instruction(tokens)
99
+ items_iter, total = readers.resolve_items(request.input, stdin, stop=stop)
100
+ model = await context.chat_model(request.model_flag) # may emit a note / SetupFault
101
+ structured = plan.mode == "structured"
102
+ writer = context.writer(
103
+ request.output, structured=structured, stdout=stdout, fields=request.fields
104
+ )
105
+ concurrency = context.concurrency(request.concurrency_flag)
106
+
107
+ tally = None
108
+ if request.tally_field is not None:
109
+ if not structured:
110
+ raise UsageFault(
111
+ "--tally needs structured output — name fields in braces or pass --schema\n"
112
+ ' Example: smartpipe map "Extract {label}" --tally label'
113
+ )
114
+ from smartpipe.engine.tally import Tally
115
+
116
+ tally = Tally(request.tally_field)
117
+ if request.explode_field is not None and not structured:
118
+ raise UsageFault(
119
+ "--explode needs structured output — name fields in braces or pass --schema\n"
120
+ ' Example: smartpipe map "Extract {risks}" --explode risks'
121
+ )
122
+
123
+ spinner = make_stderr_spinner()
124
+ spinner.start(total=total)
125
+
126
+ log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
127
+ gate = WindowGate(
128
+ provider=model.ref.provider,
129
+ model_name=model.ref.name,
130
+ overhead=_PROMPT_OVERHEAD_TOKENS,
131
+ window=partial(context.context_window, model.ref),
132
+ )
133
+
134
+ async def worker(item: Item) -> str | Mapping[str, object]:
135
+ budget = await gate.budget_for_oversized(item.text)
136
+ if budget is not None:
137
+ # D26: silently chunking would change what was asked — teach the pipeline
138
+ raise ItemError(gate.refusal(item.text, budget))
139
+ return await map_one(
140
+ model,
141
+ plan,
142
+ instruction,
143
+ item,
144
+ log,
145
+ frame_every=request.frame_every,
146
+ max_frames=request.max_frames,
147
+ )
148
+
149
+ done = 0
150
+ skipped = 0
151
+ outcomes = run_ordered(
152
+ items_iter,
153
+ worker,
154
+ concurrency=concurrency,
155
+ failure_policy=FailurePolicy(),
156
+ stop=stop,
157
+ )
158
+ try:
159
+ async for outcome in outcomes:
160
+ if isinstance(outcome, Done):
161
+ for row in _rows(outcome.value, request.explode_field):
162
+ _write(writer, structured=structured, value=row)
163
+ if tally is not None and isinstance(row, Mapping):
164
+ tally.add(row)
165
+ spinner.extra = tally.live_segment()
166
+ done += 1
167
+ else: # Skipped — the union has no third case
168
+ diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
169
+ skipped += 1
170
+ spinner.advance()
171
+ finally:
172
+ spinner.finish()
173
+ writer.flush()
174
+ log.finish()
175
+ if tally is not None and tally.counts:
176
+ diagnostics.note(tally.final_line())
177
+ if stop is not None and stop.is_set():
178
+ diagnostics.interrupted_summary(processed=done, skipped=skipped)
179
+ return interrupted_exit_code(done=done, skipped=skipped)
180
+ return outcome_exit_code(done=done, skipped=skipped)
181
+
182
+
183
+ async def map_one(
184
+ model: ChatModel,
185
+ plan: MapPlan,
186
+ instruction: str,
187
+ item: Item,
188
+ log: diagnostics.DegradationLog,
189
+ *,
190
+ frame_every: float | None = None,
191
+ max_frames: int | None = None,
192
+ ) -> str | Mapping[str, object]:
193
+ video = next((part for part in item.media if isinstance(part, VideoData)), None)
194
+ if video is not None:
195
+ return await _map_video(
196
+ model,
197
+ plan,
198
+ instruction,
199
+ item,
200
+ video,
201
+ log,
202
+ frame_every=frame_every,
203
+ max_frames=max_frames,
204
+ )
205
+ try:
206
+ return await _attempt(model, plan, instruction, item.text, item.media)
207
+ except ItemError as native_failure:
208
+ audio = next((part for part in item.media if isinstance(part, AudioData)), None)
209
+ if audio is None:
210
+ raise
211
+ # the ladder's middle rung (D20 §5): the model can't hear it — transcribe
212
+ # if the extra is there (MissingExtra keeps the two-fix skip), retry as text
213
+ transcript = await asyncio.to_thread(_transcribe_or_skip, audio, native_failure)
214
+ log.note(describe_source(item.source), "audio → text", _whisper_note())
215
+ spoken = f"{item.text}\n\n{transcript}".strip() if item.text else transcript
216
+ remaining = tuple(part for part in item.media if not isinstance(part, AudioData))
217
+ return await _attempt(model, plan, instruction, spoken, remaining)
218
+
219
+
220
+ async def _map_video(
221
+ model: ChatModel,
222
+ plan: MapPlan,
223
+ instruction: str,
224
+ item: Item,
225
+ video: VideoData,
226
+ log: diagnostics.DegradationLog,
227
+ *,
228
+ frame_every: float | None = None,
229
+ max_frames: int | None = None,
230
+ ) -> str | Mapping[str, object]:
231
+ """Video ladder (D27/D34): the real thing where the wire watches it (gemini
232
+ native accepts video; every other adapter refuses pre-send at zero cost),
233
+ then frames + heard track, then frames + transcript."""
234
+ try:
235
+ return await _attempt(model, plan, instruction, item.text, (video,))
236
+ except ItemError:
237
+ pass # this wire can't watch — convert (the refusal cost nothing)
238
+ from functools import partial as _partial
239
+
240
+ from smartpipe.parsing.extract import video_to_parts
241
+
242
+ sample = _partial(
243
+ video_to_parts,
244
+ video,
245
+ max_frames=max_frames if max_frames is not None else 24,
246
+ every_seconds=frame_every,
247
+ )
248
+ parts = await asyncio.to_thread(sample)
249
+ track = parts.track
250
+ detail = f"{len(parts.frames)} frames" + (" + audio" if track is not None else ", silent")
251
+ log.note(describe_source(item.source), "video → frames+audio", detail)
252
+ media: tuple[MediaData, ...] = (*parts.frames, track) if track is not None else parts.frames
253
+ try:
254
+ return await _attempt(model, plan, instruction, item.text, media)
255
+ except ItemError as native_failure:
256
+ if track is None:
257
+ raise
258
+ # the model saw frames but couldn't hear — transcribe the track, retry
259
+ transcript = await asyncio.to_thread(_transcribe_or_skip, track, native_failure)
260
+ log.note(describe_source(item.source), "video audio → text", _whisper_note())
261
+ spoken = f"{item.text}\n\n[audio track transcript]\n{transcript}".strip()
262
+ return await _attempt(model, plan, instruction, spoken, parts.frames)
263
+
264
+
265
+ def _whisper_note() -> str:
266
+ import os
267
+
268
+ from smartpipe.parsing.extract import whisper_size
269
+
270
+ return f"whisper {whisper_size(os.environ)}"
271
+
272
+
273
+ async def _attempt(
274
+ model: ChatModel,
275
+ plan: MapPlan,
276
+ instruction: str,
277
+ text: str,
278
+ media: tuple[MediaData, ...],
279
+ ) -> str | Mapping[str, object]:
280
+ request = build_map_request(plan, instruction, text, media=media)
281
+ reply = await model.complete(request)
282
+ if plan.schema is None:
283
+ return reply.rstrip() # plain mode: keep the reply, only trim trailing whitespace
284
+ try:
285
+ return validate_and_coerce(reply, plan.schema)
286
+ except ItemError as first_error:
287
+ repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
288
+ repaired = await model.complete(repair)
289
+ return validate_and_coerce(repaired, plan.schema) # a second failure → Skipped
290
+
291
+
292
+ def _transcribe_or_skip(audio: AudioData, native_failure: ItemError) -> str:
293
+ from smartpipe.parsing.extract import MissingExtra, transcribe_audio
294
+
295
+ try:
296
+ return transcribe_audio(audio)
297
+ except MissingExtra:
298
+ raise native_failure from None # the adapter's message already names both fixes
299
+
300
+
301
+ def _rows(
302
+ value: str | Mapping[str, object], explode_field: str | None
303
+ ) -> list[str | Mapping[str, object]]:
304
+ if explode_field is None or not isinstance(value, Mapping):
305
+ return [value]
306
+ from smartpipe.engine.tally import explode_record
307
+
308
+ return list(explode_record(value, explode_field))
309
+
310
+
311
+ def _write(writer: ResultWriter, *, structured: bool, value: str | Mapping[str, object]) -> None:
312
+ if structured and not isinstance(value, str):
313
+ writer.write_record(value)
314
+ else:
315
+ writer.write_text(value if isinstance(value, str) else str(value))
@@ -0,0 +1,119 @@
1
+ """The ``outliers`` verb: the items least like the rest (D38/04).
2
+
3
+ top_k's mirror — "farthest from everything" instead of "nearest to the
4
+ query". Embeddings only; the weirdness score is mean cosine distance to the
5
+ k nearest neighbors, which stays honest on multi-cluster corpora.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from smartpipe.core.errors import ExitCode, UsageFault
15
+ from smartpipe.engine.clustering import knn_mean_distance
16
+ from smartpipe.engine.runner import Done, FailurePolicy
17
+ from smartpipe.io import diagnostics, readers
18
+ from smartpipe.io.inputs import STDIN
19
+ from smartpipe.io.items import describe_source
20
+ from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
21
+ from smartpipe.verbs.common import embed_in_batches
22
+ from smartpipe.verbs.convert import make_converter
23
+ from smartpipe.verbs.distinct import DistinctContext
24
+ from smartpipe.verbs.embed import optional_chat
25
+
26
+ if TYPE_CHECKING:
27
+ from typing import TextIO
28
+
29
+ from smartpipe.io.inputs import InputSpec
30
+ from smartpipe.io.items import Item
31
+
32
+ __all__ = ["OutliersRequest", "run_outliers"]
33
+
34
+ _NEIGHBORS = 5 # kNN depth — internal, not a knob
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class OutliersRequest:
39
+ count: int = 5
40
+ model_flag: str | None = None
41
+ concurrency_flag: int | None = None
42
+ allow_captions: bool = False
43
+ input: InputSpec = STDIN
44
+
45
+
46
+ async def run_outliers(
47
+ request: OutliersRequest,
48
+ context: DistinctContext,
49
+ *,
50
+ stdin: TextIO,
51
+ stdout: TextIO,
52
+ stop: asyncio.Event | None = None,
53
+ ) -> ExitCode:
54
+ if request.count < 1:
55
+ raise UsageFault("outliers needs a positive count")
56
+ model = await context.embedding_model(request.model_flag)
57
+ items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
58
+ items = [item async for item in items_iter]
59
+ if len(items) < 3:
60
+ raise UsageFault("outliers needs at least 3 items to know what normal looks like")
61
+
62
+ log = diagnostics.DegradationLog()
63
+ converter_chat = await optional_chat(context)
64
+ converter = make_converter(
65
+ converter_chat,
66
+ allow_paid=request.allow_captions,
67
+ log=log,
68
+ stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
69
+ )
70
+ scored_items: list[Item] = []
71
+ vectors: list[tuple[float, ...]] = []
72
+ position = 0
73
+ outcomes = embed_in_batches(
74
+ model,
75
+ items,
76
+ failure_policy=FailurePolicy(),
77
+ stop=stop,
78
+ log=log,
79
+ converter=converter,
80
+ )
81
+ async for outcome in outcomes:
82
+ if isinstance(outcome, Done):
83
+ embedded, vector = outcome.value
84
+ scored_items.append(embedded)
85
+ vectors.append(vector)
86
+ else: # an unexamined item can't be scored — excluded, disclosed
87
+ diagnostics.warn(f"excluded: {describe_source(outcome.source)} ({outcome.reason})")
88
+ position += 1
89
+ log.finish()
90
+ if len(vectors) < 3:
91
+ raise UsageFault("outliers needs at least 3 embeddable items")
92
+
93
+ distances = knn_mean_distance(vectors, k=_NEIGHBORS)
94
+ ranked = sorted(range(len(distances)), key=lambda index: -distances[index])
95
+ top = ranked[: request.count]
96
+ median = sorted(distances)[len(distances) // 2]
97
+ if median > 0:
98
+ low = distances[top[-1]] / median
99
+ high = distances[top[0]] / median
100
+ diagnostics.note(
101
+ f"outliers: median neighbor distance {median:.2f} — these are "
102
+ f"{low:.1f}x-{high:.1f}x out"
103
+ )
104
+
105
+ writer = make_writer(
106
+ WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=None), stdout
107
+ )
108
+ for index in top:
109
+ item = scored_items[index]
110
+ record: dict[str, object]
111
+ if item.data is not None:
112
+ record = {key: value for key, value in item.data.items() if key != "vector"}
113
+ else:
114
+ record = {"text": item.raw}
115
+ record["_distance"] = round(distances[index], 4)
116
+ record.setdefault("source", describe_source(item.source))
117
+ writer.write_record(record)
118
+ writer.flush()
119
+ return ExitCode.OK