offagent 0.10.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.
- offagent/__init__.py +3 -0
- offagent/__main__.py +5 -0
- offagent/adapters/__init__.py +1 -0
- offagent/adapters/docx_adapter.py +1237 -0
- offagent/adapters/embedding_provider.py +132 -0
- offagent/adapters/pptx_adapter.py +940 -0
- offagent/adapters/xlsx_adapter.py +1266 -0
- offagent/app/__init__.py +1 -0
- offagent/app/progress.py +52 -0
- offagent/app/services.py +4267 -0
- offagent/config.py +287 -0
- offagent/domain/__init__.py +1 -0
- offagent/domain/locators.py +444 -0
- offagent/domain/models.py +477 -0
- offagent/domain/text_fragments.py +136 -0
- offagent/errors.py +29 -0
- offagent/indexing/__init__.py +1 -0
- offagent/indexing/store.py +795 -0
- offagent/interfaces/__init__.py +1 -0
- offagent/interfaces/cli.py +438 -0
- offagent/interfaces/cli_output.py +139 -0
- offagent/interfaces/cli_progress.py +120 -0
- offagent/interfaces/mcp.py +1145 -0
- offagent/interfaces/mcp_converters.py +80 -0
- offagent/interfaces/mcp_models.py +923 -0
- offagent/objects/__init__.py +3 -0
- offagent/objects/base.py +26 -0
- offagent/objects/docx_objects.py +951 -0
- offagent/objects/pptx_objects.py +895 -0
- offagent/objects/xlsx_objects.py +962 -0
- offagent/path_policy.py +42 -0
- offagent/storage/__init__.py +1 -0
- offagent/storage/versioning.py +31 -0
- offagent-0.10.0.dist-info/METADATA +546 -0
- offagent-0.10.0.dist-info/RECORD +39 -0
- offagent-0.10.0.dist-info/WHEEL +5 -0
- offagent-0.10.0.dist-info/entry_points.txt +2 -0
- offagent-0.10.0.dist-info/licenses/LICENSE +21 -0
- offagent-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Interface adapters."""
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from offagent.app.progress import NullProgressReporter
|
|
8
|
+
from offagent.app.services import (
|
|
9
|
+
AppServices,
|
|
10
|
+
)
|
|
11
|
+
from offagent.config import load_config
|
|
12
|
+
from offagent.errors import (
|
|
13
|
+
InvalidArgumentsError,
|
|
14
|
+
PolicyRefusedError,
|
|
15
|
+
StaleLocatorError,
|
|
16
|
+
TargetNotEditableError,
|
|
17
|
+
TargetNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
from offagent.interfaces.cli_output import (
|
|
20
|
+
emit_output,
|
|
21
|
+
render_doctor_report,
|
|
22
|
+
render_document,
|
|
23
|
+
render_documents,
|
|
24
|
+
render_index_summary,
|
|
25
|
+
render_item,
|
|
26
|
+
render_items,
|
|
27
|
+
render_patch_result,
|
|
28
|
+
render_search_hits,
|
|
29
|
+
render_text_result,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import typer
|
|
34
|
+
except ModuleNotFoundError: # pragma: no cover - covered indirectly by doctor
|
|
35
|
+
typer = None
|
|
36
|
+
|
|
37
|
+
if typer is not None:
|
|
38
|
+
CONFIG_OPTION = typer.Option(
|
|
39
|
+
"--config",
|
|
40
|
+
help="Optional path to an office-agent TOML configuration file.",
|
|
41
|
+
dir_okay=False,
|
|
42
|
+
resolve_path=False,
|
|
43
|
+
)
|
|
44
|
+
else: # pragma: no cover - exercised only when typer is unavailable
|
|
45
|
+
CONFIG_OPTION = None
|
|
46
|
+
|
|
47
|
+
if typer is not None:
|
|
48
|
+
JSON_OPTION = typer.Option(
|
|
49
|
+
"--json",
|
|
50
|
+
help="Emit machine-readable JSON with no extra text.",
|
|
51
|
+
)
|
|
52
|
+
QUIET_OPTION = typer.Option(
|
|
53
|
+
"--quiet",
|
|
54
|
+
help="Suppress successful command output.",
|
|
55
|
+
)
|
|
56
|
+
else: # pragma: no cover - exercised only when typer is unavailable
|
|
57
|
+
JSON_OPTION = None
|
|
58
|
+
QUIET_OPTION = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> None:
|
|
62
|
+
if typer is None:
|
|
63
|
+
raise SystemExit(
|
|
64
|
+
"Typer is required to run the office-agent CLI. Install project dependencies first."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
build_app()(prog_name="office-agent")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_app():
|
|
71
|
+
if typer is None:
|
|
72
|
+
raise RuntimeError("Typer is unavailable.")
|
|
73
|
+
|
|
74
|
+
app = typer.Typer(help="Local-first Office document tooling.")
|
|
75
|
+
|
|
76
|
+
@app.callback()
|
|
77
|
+
def main_callback() -> None:
|
|
78
|
+
"""Root command group for office-agent."""
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def doctor(
|
|
82
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
83
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
84
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
settings = load_config(config)
|
|
87
|
+
services = AppServices(settings)
|
|
88
|
+
report = _run_command(
|
|
89
|
+
lambda: services.run_doctor(), as_json=as_json, quiet=quiet
|
|
90
|
+
)
|
|
91
|
+
emit_output(
|
|
92
|
+
report,
|
|
93
|
+
as_json=as_json,
|
|
94
|
+
quiet=quiet,
|
|
95
|
+
human_renderer=render_doctor_report,
|
|
96
|
+
echo=typer.echo,
|
|
97
|
+
)
|
|
98
|
+
raise typer.Exit(code=0 if report.ok else 1)
|
|
99
|
+
|
|
100
|
+
@app.command("index")
|
|
101
|
+
def index_command(
|
|
102
|
+
path: Path,
|
|
103
|
+
with_embeddings: Annotated[bool, typer.Option("--with-embeddings")] = False,
|
|
104
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
105
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
106
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
services = AppServices(load_config(config))
|
|
109
|
+
|
|
110
|
+
def runner() -> None:
|
|
111
|
+
with _build_index_reporter(as_json=as_json, quiet=quiet) as reporter:
|
|
112
|
+
summary = services.index_path(
|
|
113
|
+
path,
|
|
114
|
+
with_embeddings=with_embeddings,
|
|
115
|
+
reporter=reporter,
|
|
116
|
+
)
|
|
117
|
+
emit_output(
|
|
118
|
+
{
|
|
119
|
+
"path": path.resolve(),
|
|
120
|
+
"summary": summary,
|
|
121
|
+
},
|
|
122
|
+
as_json=as_json,
|
|
123
|
+
quiet=quiet,
|
|
124
|
+
human_renderer=render_index_summary,
|
|
125
|
+
echo=typer.echo,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
_run_command(
|
|
129
|
+
runner,
|
|
130
|
+
as_json=as_json,
|
|
131
|
+
quiet=quiet,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@app.command("reindex")
|
|
135
|
+
def reindex_command(
|
|
136
|
+
path: Path,
|
|
137
|
+
with_embeddings: Annotated[bool, typer.Option("--with-embeddings")] = False,
|
|
138
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
139
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
140
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
services = AppServices(load_config(config))
|
|
143
|
+
|
|
144
|
+
def runner() -> None:
|
|
145
|
+
with _build_index_reporter(as_json=as_json, quiet=quiet) as reporter:
|
|
146
|
+
summary = services.reindex_path(
|
|
147
|
+
path,
|
|
148
|
+
with_embeddings=with_embeddings,
|
|
149
|
+
reporter=reporter,
|
|
150
|
+
)
|
|
151
|
+
emit_output(
|
|
152
|
+
{
|
|
153
|
+
"path": path.resolve(),
|
|
154
|
+
"summary": summary,
|
|
155
|
+
},
|
|
156
|
+
as_json=as_json,
|
|
157
|
+
quiet=quiet,
|
|
158
|
+
human_renderer=render_index_summary,
|
|
159
|
+
echo=typer.echo,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
_run_command(
|
|
163
|
+
runner,
|
|
164
|
+
as_json=as_json,
|
|
165
|
+
quiet=quiet,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def search(
|
|
170
|
+
query: str,
|
|
171
|
+
file_type: Annotated[str | None, typer.Option("--type")] = None,
|
|
172
|
+
doc: Annotated[Path | None, typer.Option("--doc")] = None,
|
|
173
|
+
mode: Annotated[str, typer.Option("--mode")] = "keyword",
|
|
174
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
175
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
176
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
177
|
+
) -> None:
|
|
178
|
+
services = AppServices(load_config(config))
|
|
179
|
+
|
|
180
|
+
def runner() -> None:
|
|
181
|
+
hits = services.search_corpus(
|
|
182
|
+
query,
|
|
183
|
+
file_type=file_type,
|
|
184
|
+
document_path=doc,
|
|
185
|
+
mode=mode,
|
|
186
|
+
)
|
|
187
|
+
emit_output(
|
|
188
|
+
{"hits": hits},
|
|
189
|
+
as_json=as_json,
|
|
190
|
+
quiet=quiet,
|
|
191
|
+
human_renderer=lambda payload: render_search_hits(payload["hits"]),
|
|
192
|
+
echo=typer.echo,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
_run_command(runner, as_json=as_json, quiet=quiet)
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def locate(
|
|
199
|
+
doc: Annotated[Path, typer.Option("--doc")],
|
|
200
|
+
paragraph: Annotated[int | None, typer.Option("--paragraph")] = None,
|
|
201
|
+
slide: Annotated[int | None, typer.Option("--slide")] = None,
|
|
202
|
+
shape: Annotated[int | None, typer.Option("--shape")] = None,
|
|
203
|
+
sheet: Annotated[str | None, typer.Option("--sheet")] = None,
|
|
204
|
+
cell: Annotated[str | None, typer.Option("--cell")] = None,
|
|
205
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
206
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
207
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
208
|
+
) -> None:
|
|
209
|
+
services = AppServices(load_config(config))
|
|
210
|
+
|
|
211
|
+
def runner() -> None:
|
|
212
|
+
items = services.locate_items(
|
|
213
|
+
doc,
|
|
214
|
+
paragraph_index=paragraph,
|
|
215
|
+
slide_number=slide,
|
|
216
|
+
shape_id=shape,
|
|
217
|
+
sheet_name=sheet,
|
|
218
|
+
cell_coordinate=cell,
|
|
219
|
+
)
|
|
220
|
+
emit_output(
|
|
221
|
+
{"items": items},
|
|
222
|
+
as_json=as_json,
|
|
223
|
+
quiet=quiet,
|
|
224
|
+
human_renderer=lambda payload: render_items(payload["items"]),
|
|
225
|
+
echo=typer.echo,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
_run_command(runner, as_json=as_json, quiet=quiet)
|
|
229
|
+
|
|
230
|
+
@app.command()
|
|
231
|
+
def read(
|
|
232
|
+
doc: Annotated[Path, typer.Option("--doc")],
|
|
233
|
+
item: Annotated[str, typer.Option("--item")],
|
|
234
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
235
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
236
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
237
|
+
) -> None:
|
|
238
|
+
services = AppServices(load_config(config))
|
|
239
|
+
_run_command(
|
|
240
|
+
lambda: emit_output(
|
|
241
|
+
{
|
|
242
|
+
"document_path": doc.resolve(),
|
|
243
|
+
"item_id": item,
|
|
244
|
+
"text": services.read_item(doc, item),
|
|
245
|
+
},
|
|
246
|
+
as_json=as_json,
|
|
247
|
+
quiet=quiet,
|
|
248
|
+
human_renderer=render_text_result,
|
|
249
|
+
echo=typer.echo,
|
|
250
|
+
),
|
|
251
|
+
as_json=as_json,
|
|
252
|
+
quiet=quiet,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def replace(
|
|
257
|
+
doc: Annotated[Path, typer.Option("--doc")],
|
|
258
|
+
item: Annotated[str, typer.Option("--item")],
|
|
259
|
+
text: Annotated[str, typer.Option("--text")],
|
|
260
|
+
output_mode: Annotated[str, typer.Option("--output-mode")] = "versioned",
|
|
261
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
262
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
263
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
264
|
+
) -> None:
|
|
265
|
+
services = AppServices(load_config(config))
|
|
266
|
+
_run_command(
|
|
267
|
+
lambda: emit_output(
|
|
268
|
+
services.replace_item_text(doc, item, text, output_mode=output_mode),
|
|
269
|
+
as_json=as_json,
|
|
270
|
+
quiet=quiet,
|
|
271
|
+
human_renderer=render_patch_result,
|
|
272
|
+
echo=typer.echo,
|
|
273
|
+
),
|
|
274
|
+
as_json=as_json,
|
|
275
|
+
quiet=quiet,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def append(
|
|
280
|
+
doc: Annotated[Path, typer.Option("--doc")],
|
|
281
|
+
item: Annotated[str, typer.Option("--item")],
|
|
282
|
+
text: Annotated[str, typer.Option("--text")],
|
|
283
|
+
output_mode: Annotated[str, typer.Option("--output-mode")] = "versioned",
|
|
284
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
285
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
286
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
287
|
+
) -> None:
|
|
288
|
+
services = AppServices(load_config(config))
|
|
289
|
+
_run_command(
|
|
290
|
+
lambda: emit_output(
|
|
291
|
+
services.append_item_text(doc, item, text, output_mode=output_mode),
|
|
292
|
+
as_json=as_json,
|
|
293
|
+
quiet=quiet,
|
|
294
|
+
human_renderer=render_patch_result,
|
|
295
|
+
echo=typer.echo,
|
|
296
|
+
),
|
|
297
|
+
as_json=as_json,
|
|
298
|
+
quiet=quiet,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@app.command("write-cell")
|
|
302
|
+
def write_cell(
|
|
303
|
+
doc: Annotated[Path, typer.Option("--doc")],
|
|
304
|
+
sheet: Annotated[str, typer.Option("--sheet")],
|
|
305
|
+
cell: Annotated[str, typer.Option("--cell")],
|
|
306
|
+
value: Annotated[str, typer.Option("--value")],
|
|
307
|
+
output_mode: Annotated[str, typer.Option("--output-mode")] = "versioned",
|
|
308
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
309
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
310
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
311
|
+
) -> None:
|
|
312
|
+
services = AppServices(load_config(config))
|
|
313
|
+
_run_command(
|
|
314
|
+
lambda: emit_output(
|
|
315
|
+
services.write_cell_value(
|
|
316
|
+
doc, sheet, cell, value, output_mode=output_mode
|
|
317
|
+
),
|
|
318
|
+
as_json=as_json,
|
|
319
|
+
quiet=quiet,
|
|
320
|
+
human_renderer=render_patch_result,
|
|
321
|
+
echo=typer.echo,
|
|
322
|
+
),
|
|
323
|
+
as_json=as_json,
|
|
324
|
+
quiet=quiet,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
@app.command("list")
|
|
328
|
+
def list_command(
|
|
329
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
330
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
331
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
332
|
+
) -> None:
|
|
333
|
+
services = AppServices(load_config(config))
|
|
334
|
+
_run_command(
|
|
335
|
+
lambda: emit_output(
|
|
336
|
+
{"documents": services.list_documents()},
|
|
337
|
+
as_json=as_json,
|
|
338
|
+
quiet=quiet,
|
|
339
|
+
human_renderer=lambda payload: render_documents(payload["documents"]),
|
|
340
|
+
echo=typer.echo,
|
|
341
|
+
),
|
|
342
|
+
as_json=as_json,
|
|
343
|
+
quiet=quiet,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
@app.command()
|
|
347
|
+
def show(
|
|
348
|
+
doc: Annotated[Path, typer.Option("--doc")],
|
|
349
|
+
item: Annotated[str | None, typer.Option("--item")] = None,
|
|
350
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
351
|
+
as_json: Annotated[bool, JSON_OPTION] = False,
|
|
352
|
+
quiet: Annotated[bool, QUIET_OPTION] = False,
|
|
353
|
+
) -> None:
|
|
354
|
+
services = AppServices(load_config(config))
|
|
355
|
+
|
|
356
|
+
def runner() -> None:
|
|
357
|
+
if item is None:
|
|
358
|
+
emit_output(
|
|
359
|
+
services.show_document(doc),
|
|
360
|
+
as_json=as_json,
|
|
361
|
+
quiet=quiet,
|
|
362
|
+
human_renderer=render_document,
|
|
363
|
+
echo=typer.echo,
|
|
364
|
+
)
|
|
365
|
+
return
|
|
366
|
+
emit_output(
|
|
367
|
+
services.show_item(doc, item),
|
|
368
|
+
as_json=as_json,
|
|
369
|
+
quiet=quiet,
|
|
370
|
+
human_renderer=render_item,
|
|
371
|
+
echo=typer.echo,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
_run_command(runner, as_json=as_json, quiet=quiet)
|
|
375
|
+
|
|
376
|
+
@app.command()
|
|
377
|
+
def mcp(
|
|
378
|
+
config: Annotated[Path | None, CONFIG_OPTION] = None,
|
|
379
|
+
) -> None:
|
|
380
|
+
settings = load_config(config)
|
|
381
|
+
|
|
382
|
+
def runner() -> None:
|
|
383
|
+
from offagent.interfaces.mcp import run_mcp_server
|
|
384
|
+
|
|
385
|
+
run_mcp_server(settings)
|
|
386
|
+
|
|
387
|
+
_run_command(runner)
|
|
388
|
+
|
|
389
|
+
return app
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _run_command(callback, *, as_json: bool = False, quiet: bool = False):
|
|
393
|
+
if typer is None:
|
|
394
|
+
raise RuntimeError("Typer is unavailable.")
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
_validate_output_flags(as_json, quiet)
|
|
398
|
+
return callback()
|
|
399
|
+
except InvalidArgumentsError as exc:
|
|
400
|
+
typer.echo(str(exc), err=True)
|
|
401
|
+
raise typer.Exit(code=2) from exc
|
|
402
|
+
except (FileNotFoundError, TargetNotFoundError, StaleLocatorError) as exc:
|
|
403
|
+
typer.echo(str(exc), err=True)
|
|
404
|
+
raise typer.Exit(code=3) from exc
|
|
405
|
+
except TargetNotEditableError as exc:
|
|
406
|
+
typer.echo(str(exc), err=True)
|
|
407
|
+
raise typer.Exit(code=4) from exc
|
|
408
|
+
except PolicyRefusedError as exc:
|
|
409
|
+
typer.echo(str(exc), err=True)
|
|
410
|
+
raise typer.Exit(code=5) from exc
|
|
411
|
+
except RuntimeError as exc:
|
|
412
|
+
typer.echo(str(exc), err=True)
|
|
413
|
+
raise typer.Exit(code=1) from exc
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _validate_output_flags(as_json: bool, quiet: bool) -> None:
|
|
417
|
+
if as_json and quiet:
|
|
418
|
+
raise InvalidArgumentsError("Choose either --json or --quiet, not both.")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _build_index_reporter(*, as_json: bool, quiet: bool):
|
|
422
|
+
if quiet or as_json or not _stderr_supports_live_progress():
|
|
423
|
+
return NullProgressReporter()
|
|
424
|
+
return _rich_progress_reporter_class()()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _stderr_supports_live_progress() -> bool:
|
|
428
|
+
return sys.stderr.isatty()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _rich_progress_reporter_class():
|
|
432
|
+
try:
|
|
433
|
+
from offagent.interfaces.cli_progress import RichProgressReporter
|
|
434
|
+
except ModuleNotFoundError as exc:
|
|
435
|
+
raise RuntimeError(
|
|
436
|
+
"Rich is required to render indexing progress. Install project dependencies first."
|
|
437
|
+
) from exc
|
|
438
|
+
return RichProgressReporter
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, is_dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Sequence
|
|
7
|
+
|
|
8
|
+
from offagent.app.services import DoctorReport, IndexSummary, PatchResult
|
|
9
|
+
from offagent.domain.models import DocumentRef, ItemRef, SearchHit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def emit_output(
|
|
13
|
+
payload: Any,
|
|
14
|
+
*,
|
|
15
|
+
as_json: bool,
|
|
16
|
+
quiet: bool,
|
|
17
|
+
human_renderer: Callable[[Any], str],
|
|
18
|
+
echo: Callable[[str], None],
|
|
19
|
+
) -> None:
|
|
20
|
+
if quiet:
|
|
21
|
+
return
|
|
22
|
+
if as_json:
|
|
23
|
+
echo(json.dumps(to_jsonable(payload), indent=2, sort_keys=True))
|
|
24
|
+
return
|
|
25
|
+
echo(human_renderer(payload))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_jsonable(value: Any) -> Any:
|
|
29
|
+
if isinstance(value, DoctorReport):
|
|
30
|
+
return {"ok": value.ok, "checks": to_jsonable(value.checks)}
|
|
31
|
+
if is_dataclass(value):
|
|
32
|
+
return to_jsonable(asdict(value))
|
|
33
|
+
if isinstance(value, Path):
|
|
34
|
+
return str(value)
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return {key: to_jsonable(item) for key, item in value.items()}
|
|
37
|
+
if isinstance(value, (list, tuple)):
|
|
38
|
+
return [to_jsonable(item) for item in value]
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_doctor_report(report: DoctorReport) -> str:
|
|
43
|
+
lines = ["Doctor Report"]
|
|
44
|
+
for check in report.checks:
|
|
45
|
+
status = "PASS" if check.ok else "FAIL"
|
|
46
|
+
lines.append(f"[{status}] {check.name}: {check.detail}")
|
|
47
|
+
lines.append("All checks passed." if report.ok else "One or more checks failed.")
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_index_summary(payload: dict[str, Any]) -> str:
|
|
52
|
+
path = payload["path"]
|
|
53
|
+
summary: IndexSummary = payload["summary"]
|
|
54
|
+
return (
|
|
55
|
+
f"path={path}\tfiles_scanned={summary.files_scanned}\tfiles_indexed={summary.files_indexed}"
|
|
56
|
+
f"\tfiles_skipped={summary.files_skipped}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_search_hits(hits: Sequence[SearchHit]) -> str:
|
|
61
|
+
if not hits:
|
|
62
|
+
return "No matches found."
|
|
63
|
+
lines: list[str] = []
|
|
64
|
+
for hit in hits:
|
|
65
|
+
mode = f"\tmode={hit.match_mode}" if hit.match_mode is not None else ""
|
|
66
|
+
lines.append(
|
|
67
|
+
f"{hit.item_id}\tscore={hit.score:.3f}{mode}\tdoc={hit.display_name or hit.document_path}"
|
|
68
|
+
)
|
|
69
|
+
if hit.scores:
|
|
70
|
+
score_parts = ", ".join(
|
|
71
|
+
f"{name}={value:.3f}" for name, value in sorted(hit.scores.items())
|
|
72
|
+
)
|
|
73
|
+
lines.append(f"scores: {score_parts}")
|
|
74
|
+
if hit.metadata:
|
|
75
|
+
metadata_parts = ", ".join(
|
|
76
|
+
f"{name}={value}" for name, value in sorted(hit.metadata.items())
|
|
77
|
+
)
|
|
78
|
+
lines.append(f"metadata: {metadata_parts}")
|
|
79
|
+
lines.append(hit.preview)
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_items(items: Sequence[ItemRef]) -> str:
|
|
84
|
+
if not items:
|
|
85
|
+
return "No items found."
|
|
86
|
+
return "\n".join(
|
|
87
|
+
f"{item.item_id}\t{item.locator}\t{item.preview}" for item in items
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_patch_result(result: PatchResult) -> str:
|
|
92
|
+
return f"{result.item.item_id}\tupdated\t{result.output_path}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_documents(documents: Sequence[DocumentRef]) -> str:
|
|
96
|
+
if not documents:
|
|
97
|
+
return "No indexed documents."
|
|
98
|
+
return "\n".join(
|
|
99
|
+
(
|
|
100
|
+
f"{document.document_id}\ttype={document.file_type}\titems={document.item_count or 0}"
|
|
101
|
+
f"\tpath={document.path}"
|
|
102
|
+
)
|
|
103
|
+
for document in documents
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_document(document: DocumentRef) -> str:
|
|
108
|
+
lines = [
|
|
109
|
+
f"document_id: {document.document_id}",
|
|
110
|
+
f"path: {document.path}",
|
|
111
|
+
f"file_type: {document.file_type}",
|
|
112
|
+
f"display_name: {document.display_name}",
|
|
113
|
+
f"modified_time: {document.modified_time}",
|
|
114
|
+
f"item_count: {document.item_count or 0}",
|
|
115
|
+
]
|
|
116
|
+
if document.content_hash is not None:
|
|
117
|
+
lines.append(f"content_hash: {document.content_hash}")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_item(item: ItemRef) -> str:
|
|
122
|
+
lines = [
|
|
123
|
+
f"document_id: {item.document_id}",
|
|
124
|
+
f"item_id: {item.item_id}",
|
|
125
|
+
f"item_type: {item.item_type}",
|
|
126
|
+
f"locator: {item.locator}",
|
|
127
|
+
f"preview: {item.preview}",
|
|
128
|
+
]
|
|
129
|
+
if item.content_text is not None:
|
|
130
|
+
lines.append(f"content_text: {item.content_text}")
|
|
131
|
+
if item.metadata:
|
|
132
|
+
lines.append("metadata:")
|
|
133
|
+
for key, value in sorted(item.metadata.items()):
|
|
134
|
+
lines.append(f" {key}: {value}")
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def render_text_result(payload: dict[str, Any]) -> str:
|
|
139
|
+
return str(payload["text"])
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.progress import (
|
|
7
|
+
BarColumn,
|
|
8
|
+
MofNCompleteColumn,
|
|
9
|
+
Progress,
|
|
10
|
+
SpinnerColumn,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TimeElapsedColumn,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RichProgressReporter:
|
|
17
|
+
"""Rich-based progress reporter for index and reindex commands."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, *, console: Console | None = None, transient: bool = True
|
|
21
|
+
) -> None:
|
|
22
|
+
self._progress = Progress(
|
|
23
|
+
SpinnerColumn(),
|
|
24
|
+
TextColumn("{task.description}"),
|
|
25
|
+
BarColumn(bar_width=24),
|
|
26
|
+
MofNCompleteColumn(),
|
|
27
|
+
TimeElapsedColumn(),
|
|
28
|
+
console=console or Console(stderr=True),
|
|
29
|
+
transient=transient,
|
|
30
|
+
)
|
|
31
|
+
self._file_task_id: int | None = None
|
|
32
|
+
self._embed_task_id: int | None = None
|
|
33
|
+
self._current_file_index = 0
|
|
34
|
+
|
|
35
|
+
def __enter__(self) -> "RichProgressReporter":
|
|
36
|
+
self._progress.__enter__()
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, *args: object) -> None:
|
|
40
|
+
self._progress.__exit__(*args)
|
|
41
|
+
|
|
42
|
+
def on_index_start(self, total_files: int) -> None:
|
|
43
|
+
self._file_task_id = self._progress.add_task(
|
|
44
|
+
"Indexing", total=total_files, completed=0
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def on_file_start(self, path: Path, index: int, total: int) -> None:
|
|
48
|
+
self._current_file_index = index
|
|
49
|
+
self._hide_embedding_task()
|
|
50
|
+
if self._file_task_id is None:
|
|
51
|
+
self._file_task_id = self._progress.add_task(
|
|
52
|
+
f"Indexing {path.name}",
|
|
53
|
+
total=total,
|
|
54
|
+
completed=max(index - 1, 0),
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
self._progress.update(
|
|
58
|
+
self._file_task_id,
|
|
59
|
+
description=f"Indexing {path.name}",
|
|
60
|
+
total=total,
|
|
61
|
+
completed=max(index - 1, 0),
|
|
62
|
+
visible=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def on_embedding_start(self, path: Path, item_count: int) -> None:
|
|
66
|
+
if item_count <= 0:
|
|
67
|
+
self._hide_embedding_task()
|
|
68
|
+
return
|
|
69
|
+
description = f"Embedding {item_count} items"
|
|
70
|
+
if self._embed_task_id is None:
|
|
71
|
+
self._embed_task_id = self._progress.add_task(
|
|
72
|
+
description, total=item_count, completed=0
|
|
73
|
+
)
|
|
74
|
+
return
|
|
75
|
+
self._progress.update(
|
|
76
|
+
self._embed_task_id,
|
|
77
|
+
description=description,
|
|
78
|
+
total=item_count,
|
|
79
|
+
completed=0,
|
|
80
|
+
visible=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def on_embedding_item(self, done: int, total: int) -> None:
|
|
84
|
+
if self._embed_task_id is None:
|
|
85
|
+
self._embed_task_id = self._progress.add_task(
|
|
86
|
+
f"Embedding {total} items",
|
|
87
|
+
total=total,
|
|
88
|
+
completed=done,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
self._progress.update(
|
|
92
|
+
self._embed_task_id,
|
|
93
|
+
description=f"Embedding {total} items",
|
|
94
|
+
total=total,
|
|
95
|
+
completed=done,
|
|
96
|
+
visible=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def on_file_done(self, path: Path, items_indexed: int) -> None:
|
|
100
|
+
if self._file_task_id is not None:
|
|
101
|
+
self._progress.update(
|
|
102
|
+
self._file_task_id,
|
|
103
|
+
description=f"Indexing {path.name}",
|
|
104
|
+
completed=self._current_file_index,
|
|
105
|
+
)
|
|
106
|
+
self._hide_embedding_task()
|
|
107
|
+
|
|
108
|
+
def on_index_done(self, files_indexed: int, files_skipped: int) -> None:
|
|
109
|
+
self._hide_embedding_task()
|
|
110
|
+
if self._file_task_id is not None:
|
|
111
|
+
self._progress.update(
|
|
112
|
+
self._file_task_id,
|
|
113
|
+
description="Indexing complete",
|
|
114
|
+
completed=files_indexed + files_skipped,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _hide_embedding_task(self) -> None:
|
|
118
|
+
if self._embed_task_id is None:
|
|
119
|
+
return
|
|
120
|
+
self._progress.update(self._embed_task_id, visible=False)
|