refactor-cli 0.2.0__py3-none-any.whl → 0.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.
- refactor/cli.py +135 -22
- refactor/config.py +1 -1
- refactor/gemini.py +53 -0
- refactor/hardware.py +63 -20
- refactor/plan_cache.py +66 -0
- refactor/setup.py +34 -4
- refactor/shim.py +279 -12
- refactor/text.py +103 -0
- {refactor_cli-0.2.0.dist-info → refactor_cli-0.3.0.dist-info}/METADATA +34 -10
- refactor_cli-0.3.0.dist-info/RECORD +24 -0
- refactor_cli-0.2.0.dist-info/RECORD +0 -22
- {refactor_cli-0.2.0.dist-info → refactor_cli-0.3.0.dist-info}/WHEEL +0 -0
- {refactor_cli-0.2.0.dist-info → refactor_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {refactor_cli-0.2.0.dist-info → refactor_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {refactor_cli-0.2.0.dist-info → refactor_cli-0.3.0.dist-info}/top_level.txt +0 -0
refactor/cli.py
CHANGED
|
@@ -11,18 +11,32 @@ from refactor.organizer import (
|
|
|
11
11
|
plan_moves,
|
|
12
12
|
save_managed_category_dirs,
|
|
13
13
|
)
|
|
14
|
+
from refactor.plan_cache import load_valid_plan, save_plan
|
|
14
15
|
from refactor.scanner import scan_directory
|
|
15
16
|
from refactor.setup import command_setup, ensure_backend_configured, maybe_run_first_time_setup
|
|
16
17
|
from refactor.shim import build_image_classifier, choose_category_folders
|
|
18
|
+
from refactor.text import (
|
|
19
|
+
TextContext,
|
|
20
|
+
classify_documents,
|
|
21
|
+
managed_text_dirs,
|
|
22
|
+
save_managed_text_dirs,
|
|
23
|
+
scan_documents,
|
|
24
|
+
)
|
|
25
|
+
from refactor.imagga import ImageContext
|
|
17
26
|
from refactor.tree import print_tree
|
|
18
27
|
from refactor.validation import validate_path
|
|
19
28
|
|
|
20
29
|
app = typer.Typer(no_args_is_help=True)
|
|
21
30
|
|
|
31
|
+
TEXT_PLAN_FILE = ".refactor_text_context.json"
|
|
32
|
+
|
|
22
33
|
|
|
23
34
|
@app.callback()
|
|
24
|
-
def bootstrap() -> None:
|
|
25
|
-
|
|
35
|
+
def bootstrap(ctx: typer.Context) -> None:
|
|
36
|
+
# `refactor setup` is itself the setup command. Running first-time setup in
|
|
37
|
+
# the callback as well caused that command to prompt twice on a new install.
|
|
38
|
+
if ctx.invoked_subcommand != "setup":
|
|
39
|
+
maybe_run_first_time_setup()
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
@app.command()
|
|
@@ -33,7 +47,7 @@ def version() -> None:
|
|
|
33
47
|
|
|
34
48
|
@app.command()
|
|
35
49
|
def setup() -> None:
|
|
36
|
-
"""Configure hosted or local multimodal backend."""
|
|
50
|
+
"""Configure hosted APIs or a local Ollama multimodal backend."""
|
|
37
51
|
command_setup()
|
|
38
52
|
|
|
39
53
|
|
|
@@ -87,7 +101,7 @@ def get_image_context(
|
|
|
87
101
|
category_root: str = typer.Option(
|
|
88
102
|
".",
|
|
89
103
|
"--category-root",
|
|
90
|
-
help="Directory whose existing folders
|
|
104
|
+
help="Directory whose existing folders the configured model may reuse",
|
|
91
105
|
),
|
|
92
106
|
write_context: bool = typer.Option(
|
|
93
107
|
True,
|
|
@@ -121,7 +135,7 @@ def get_image_context(
|
|
|
121
135
|
|
|
122
136
|
if not images:
|
|
123
137
|
typer.echo("No image files found in the specified directory.")
|
|
124
|
-
|
|
138
|
+
return []
|
|
125
139
|
|
|
126
140
|
typer.echo(f"Found {len(images)} image(s) in '{path}'")
|
|
127
141
|
|
|
@@ -148,9 +162,8 @@ def get_image_context(
|
|
|
148
162
|
try:
|
|
149
163
|
results = choose_category_folders(config, results, Path(category_root))
|
|
150
164
|
except Exception as exc:
|
|
151
|
-
# Folder selection is
|
|
152
|
-
|
|
153
|
-
typer.echo(f"Error: Gemini folder selection failed: {exc}", err=True)
|
|
165
|
+
# Folder selection is essential; do not silently fall back to a top tag.
|
|
166
|
+
typer.echo(f"Error: model folder selection failed: {exc}", err=True)
|
|
154
167
|
raise typer.Exit(code=1)
|
|
155
168
|
|
|
156
169
|
if verbose:
|
|
@@ -183,6 +196,71 @@ def get_image_context(
|
|
|
183
196
|
return results
|
|
184
197
|
|
|
185
198
|
|
|
199
|
+
@app.command(name="refactor-text")
|
|
200
|
+
def refactor_text(
|
|
201
|
+
path: str = typer.Argument(".", help="Directory containing documents to organize"),
|
|
202
|
+
output_dir: str = typer.Option(".", "--output-dir", "-d"),
|
|
203
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
204
|
+
move: bool = typer.Option(True, "--move/--copy"),
|
|
205
|
+
) -> list:
|
|
206
|
+
"""Use the configured backend to categorize .txt, .md, .pdf, and .docx files."""
|
|
207
|
+
from pathlib import Path
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
validate_path(path)
|
|
211
|
+
out = Path(output_dir)
|
|
212
|
+
if not out.exists() or not out.is_dir():
|
|
213
|
+
raise ValueError(f"Output directory is not a directory: {output_dir}")
|
|
214
|
+
config = ensure_backend_configured()
|
|
215
|
+
except (ValueError, EnvironmentError) as exc:
|
|
216
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
217
|
+
raise typer.Exit(code=1)
|
|
218
|
+
|
|
219
|
+
patterns = load_ignore_patterns()
|
|
220
|
+
excluded = managed_text_dirs(out) if Path(path).resolve() == out.resolve() else set()
|
|
221
|
+
documents = scan_documents(path, patterns, excluded)
|
|
222
|
+
if not documents:
|
|
223
|
+
typer.echo("No supported document files found in the specified directory.")
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
plan_path = Path(path) / TEXT_PLAN_FILE
|
|
227
|
+
contexts = load_valid_plan(
|
|
228
|
+
plan_path, "text", path, output_dir, documents, TextContext
|
|
229
|
+
)
|
|
230
|
+
if contexts is not None:
|
|
231
|
+
typer.echo(f"Reusing validated document context from {plan_path}.")
|
|
232
|
+
else:
|
|
233
|
+
typer.echo(f"Found {len(documents)} document(s) in '{path}'")
|
|
234
|
+
try:
|
|
235
|
+
contexts, failures = classify_documents(documents, out, config)
|
|
236
|
+
except EnvironmentError as exc:
|
|
237
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
238
|
+
raise typer.Exit(code=1)
|
|
239
|
+
for document, error in failures:
|
|
240
|
+
typer.echo(f" [!] Skipping {document.name}: {error}", err=True)
|
|
241
|
+
if contexts and len(contexts) == len(documents):
|
|
242
|
+
save_plan(plan_path, "text", path, output_dir, documents, contexts)
|
|
243
|
+
|
|
244
|
+
if not contexts:
|
|
245
|
+
typer.echo("No documents were categorized.")
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
moves = plan_moves(contexts, out)
|
|
249
|
+
if dry_run:
|
|
250
|
+
typer.echo(f"\n[Dry Run] Planned document moves ({len(moves)} files):")
|
|
251
|
+
for src, dst in moves:
|
|
252
|
+
typer.echo(f" {src} → {dst}")
|
|
253
|
+
return contexts
|
|
254
|
+
|
|
255
|
+
summary = execute_moves(moves, copy=not move)
|
|
256
|
+
folders = {destination.parent.name for _, destination in moves}
|
|
257
|
+
if not summary["failed"]:
|
|
258
|
+
save_managed_text_dirs(out, folders)
|
|
259
|
+
verb = "Moved" if move else "Copied"
|
|
260
|
+
typer.echo(f"{verb} {summary['moved'] if move else summary['copied']} document(s).")
|
|
261
|
+
return contexts
|
|
262
|
+
|
|
263
|
+
|
|
186
264
|
@app.command(name="refactor-images")
|
|
187
265
|
def refactor_images(
|
|
188
266
|
path: str = typer.Argument(".", help="Directory containing images to organize"),
|
|
@@ -211,12 +289,12 @@ def refactor_images(
|
|
|
211
289
|
min_confidence: float = typer.Option(
|
|
212
290
|
40.0,
|
|
213
291
|
"--min-confidence",
|
|
214
|
-
help="Minimum Imagga tag confidence to retain",
|
|
292
|
+
help="Minimum Imagga tag confidence to retain (hosted backend only)",
|
|
215
293
|
),
|
|
216
|
-
) ->
|
|
294
|
+
) -> list:
|
|
217
295
|
"""
|
|
218
|
-
Generate fresh
|
|
219
|
-
|
|
296
|
+
Generate fresh context with the configured image backend, then organise
|
|
297
|
+
images into model-selected subdirectories. The context cache is refreshed on every run.
|
|
220
298
|
|
|
221
299
|
By default, images are MOVED. Use --copy to preserve source files.
|
|
222
300
|
"""
|
|
@@ -238,19 +316,31 @@ def refactor_images(
|
|
|
238
316
|
if excluded:
|
|
239
317
|
typer.echo(f"Skipping {len(excluded)} folder(s) created by an earlier Refactor run.")
|
|
240
318
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
min_confidence=min_confidence,
|
|
246
|
-
verbose=False,
|
|
247
|
-
category_root=output_dir,
|
|
248
|
-
write_context=not dry_run,
|
|
319
|
+
patterns = load_ignore_patterns()
|
|
320
|
+
current_images = scan_images(path, patterns, excluded)
|
|
321
|
+
contexts = load_valid_plan(
|
|
322
|
+
context_path, "image", path, output_dir, current_images, ImageContext
|
|
249
323
|
)
|
|
324
|
+
if contexts is not None:
|
|
325
|
+
typer.echo(f"Reusing validated image context from {context_path}.")
|
|
326
|
+
else:
|
|
327
|
+
typer.echo("Generating image context with the configured backend...")
|
|
328
|
+
contexts = get_image_context(
|
|
329
|
+
path=path,
|
|
330
|
+
output=str(context_path),
|
|
331
|
+
min_confidence=min_confidence,
|
|
332
|
+
verbose=False,
|
|
333
|
+
category_root=output_dir,
|
|
334
|
+
write_context=False,
|
|
335
|
+
)
|
|
336
|
+
if contexts and len(contexts) == len(current_images):
|
|
337
|
+
save_plan(
|
|
338
|
+
context_path, "image", path, output_dir, current_images, contexts
|
|
339
|
+
)
|
|
250
340
|
|
|
251
341
|
if not contexts:
|
|
252
342
|
typer.echo("Context cache is empty. Nothing to organize.")
|
|
253
|
-
|
|
343
|
+
return []
|
|
254
344
|
|
|
255
345
|
moves = plan_moves(contexts, out)
|
|
256
346
|
|
|
@@ -265,7 +355,7 @@ def refactor_images(
|
|
|
265
355
|
for src, dst in moves:
|
|
266
356
|
typer.echo(f" {src.name:<40} {dst}")
|
|
267
357
|
typer.echo(f"\n No files were {verb_past}.")
|
|
268
|
-
|
|
358
|
+
return contexts
|
|
269
359
|
|
|
270
360
|
folders = {dst.parent.name for _, dst in moves}
|
|
271
361
|
typer.echo(f"\nWill {action_word.lower()} {len(moves)} image(s) into {len(folders)} folder(s):")
|
|
@@ -285,6 +375,29 @@ def refactor_images(
|
|
|
285
375
|
typer.echo(f" Failed : {summary['failed']} file(s)")
|
|
286
376
|
typer.echo(f" Output : {out.resolve()}")
|
|
287
377
|
typer.echo(f"{'─' * 52}")
|
|
378
|
+
return contexts
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@app.command(name="now")
|
|
382
|
+
def refactor_now(
|
|
383
|
+
path: str = typer.Argument(".", help="Directory containing documents and images"),
|
|
384
|
+
output_dir: str = typer.Option(".", "--output-dir", "-d"),
|
|
385
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
386
|
+
move: bool = typer.Option(True, "--move/--copy"),
|
|
387
|
+
min_confidence: float = typer.Option(40.0, "--min-confidence"),
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Categorize supported documents first, then images in a mixed folder."""
|
|
390
|
+
typer.echo("\n[1/2] Categorizing documents")
|
|
391
|
+
refactor_text(path=path, output_dir=output_dir, dry_run=dry_run, move=move)
|
|
392
|
+
typer.echo("\n[2/2] Categorizing images")
|
|
393
|
+
refactor_images(
|
|
394
|
+
path=path,
|
|
395
|
+
context=DEFAULT_CACHE_FILE,
|
|
396
|
+
output_dir=output_dir,
|
|
397
|
+
dry_run=dry_run,
|
|
398
|
+
move=move,
|
|
399
|
+
min_confidence=min_confidence,
|
|
400
|
+
)
|
|
288
401
|
|
|
289
402
|
|
|
290
403
|
if __name__ == "__main__":
|
refactor/config.py
CHANGED
refactor/gemini.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import base64
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
import requests
|
|
@@ -101,3 +102,55 @@ class GeminiFolderClassifier:
|
|
|
101
102
|
for index, context in enumerate(contexts):
|
|
102
103
|
context.primary_folder = folders[index]
|
|
103
104
|
return contexts
|
|
105
|
+
|
|
106
|
+
def choose_document_folder(
|
|
107
|
+
self,
|
|
108
|
+
file_path: Path,
|
|
109
|
+
extracted_text: str,
|
|
110
|
+
category_root: Path,
|
|
111
|
+
is_pdf: bool = False,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Choose one folder from a document's text or native PDF content."""
|
|
114
|
+
prompt = (
|
|
115
|
+
"Choose one concise, lowercase category folder for this document. "
|
|
116
|
+
"Use letters, numbers, spaces, underscores, or hyphens only; return "
|
|
117
|
+
"JSON only as {\"folder\":\"example\"}. Prefer an existing category "
|
|
118
|
+
"when it fits. Do not use a filename or path.\n\n"
|
|
119
|
+
f"Existing categories: {json.dumps(sorted(entry.name for entry in category_root.iterdir() if entry.is_dir()))}\n"
|
|
120
|
+
f"Filename: {file_path.name}\n"
|
|
121
|
+
)
|
|
122
|
+
parts = [{"text": prompt + ("The PDF is attached." if is_pdf else extracted_text)}]
|
|
123
|
+
if is_pdf:
|
|
124
|
+
parts.append(
|
|
125
|
+
{
|
|
126
|
+
"inlineData": {
|
|
127
|
+
"mimeType": "application/pdf",
|
|
128
|
+
"data": base64.b64encode(file_path.read_bytes()).decode("ascii"),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
payload = {
|
|
134
|
+
"contents": [{"parts": parts}],
|
|
135
|
+
"generationConfig": {
|
|
136
|
+
"responseMimeType": "application/json",
|
|
137
|
+
"temperature": 0,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
url = (
|
|
141
|
+
"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
142
|
+
f"{self.model}:generateContent"
|
|
143
|
+
)
|
|
144
|
+
response = requests.post(
|
|
145
|
+
url, headers={"x-goog-api-key": self.api_key}, json=payload, timeout=60
|
|
146
|
+
)
|
|
147
|
+
if not response.ok:
|
|
148
|
+
raise requests.HTTPError(
|
|
149
|
+
f"Gemini document classification returned {response.status_code}: {response.text}",
|
|
150
|
+
response=response,
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
text = response.json()["candidates"][0]["content"]["parts"][0]["text"]
|
|
154
|
+
return _safe_folder_name(str(json.loads(text)["folder"]))
|
|
155
|
+
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
|
156
|
+
raise ValueError("Gemini returned an invalid document-folder response") from exc
|
refactor/hardware.py
CHANGED
|
@@ -20,26 +20,60 @@ def _memory_gb() -> float:
|
|
|
20
20
|
return round((pagesize * pages) / (1024**3), 2)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
return None
|
|
26
|
-
|
|
23
|
+
def _run(command: list[str]) -> str | None:
|
|
24
|
+
"""Return command output without allowing hardware probes to fail setup."""
|
|
27
25
|
try:
|
|
28
26
|
result = subprocess.run(
|
|
29
|
-
|
|
30
|
-
"nvidia-smi",
|
|
31
|
-
"--query-gpu=memory.total",
|
|
32
|
-
"--format=csv,noheader,nounits",
|
|
33
|
-
],
|
|
27
|
+
command,
|
|
34
28
|
capture_output=True,
|
|
35
29
|
text=True,
|
|
36
30
|
check=True,
|
|
31
|
+
timeout=5,
|
|
37
32
|
)
|
|
38
|
-
except
|
|
33
|
+
except (OSError, subprocess.SubprocessError):
|
|
34
|
+
return None
|
|
35
|
+
return result.stdout
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_nvidia_gpu() -> bool:
|
|
39
|
+
"""Detect NVIDIA hardware even when the driver utility cannot report VRAM."""
|
|
40
|
+
if shutil.which("nvidia-smi") and _run(["nvidia-smi", "-L"]):
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
if os.name != "nt":
|
|
44
|
+
pci_devices = _run(["lspci"])
|
|
45
|
+
if pci_devices and "nvidia" in pci_devices.lower():
|
|
46
|
+
return True
|
|
47
|
+
nvidia_devices = "/proc/driver/nvidia/gpus"
|
|
48
|
+
try:
|
|
49
|
+
if os.path.isdir(nvidia_devices) and os.listdir(nvidia_devices):
|
|
50
|
+
return True
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
else:
|
|
54
|
+
output = _run(["wmic", "path", "win32_VideoController", "get", "name"])
|
|
55
|
+
if output and "nvidia" in output.lower():
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _nvidia_vram_gb() -> float | None:
|
|
62
|
+
if not shutil.which("nvidia-smi"):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
output = _run(
|
|
66
|
+
[
|
|
67
|
+
"nvidia-smi",
|
|
68
|
+
"--query-gpu=memory.total",
|
|
69
|
+
"--format=csv,noheader,nounits",
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
if output is None:
|
|
39
73
|
return None
|
|
40
74
|
|
|
41
75
|
values = []
|
|
42
|
-
for line in
|
|
76
|
+
for line in output.splitlines():
|
|
43
77
|
line = line.strip()
|
|
44
78
|
if not line:
|
|
45
79
|
continue
|
|
@@ -56,7 +90,7 @@ def detect_hardware() -> HardwareSpec:
|
|
|
56
90
|
return HardwareSpec(
|
|
57
91
|
cpu_cores=os.cpu_count() or 1,
|
|
58
92
|
memory_gb=_memory_gb(),
|
|
59
|
-
has_nvidia_gpu=
|
|
93
|
+
has_nvidia_gpu=_has_nvidia_gpu(),
|
|
60
94
|
gpu_vram_gb=vram,
|
|
61
95
|
)
|
|
62
96
|
|
|
@@ -64,23 +98,32 @@ def detect_hardware() -> HardwareSpec:
|
|
|
64
98
|
def recommend_local_model(spec: HardwareSpec) -> tuple[str, str]:
|
|
65
99
|
if spec.has_nvidia_gpu and (spec.gpu_vram_gb or 0) >= 8:
|
|
66
100
|
return (
|
|
67
|
-
"
|
|
68
|
-
"
|
|
101
|
+
"gemma3:4b",
|
|
102
|
+
"Multimodal instruction-following model suitable for both document and image organization.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if spec.has_nvidia_gpu:
|
|
106
|
+
return (
|
|
107
|
+
"gemma3:4b",
|
|
108
|
+
"A multimodal model is required for reliable structured document and image categorization; "
|
|
109
|
+
"verify available memory if NVIDIA VRAM cannot be read.",
|
|
69
110
|
)
|
|
70
111
|
|
|
71
112
|
if spec.memory_gb >= 16:
|
|
72
113
|
return (
|
|
73
|
-
"
|
|
74
|
-
"
|
|
114
|
+
"gemma3:4b",
|
|
115
|
+
"Multimodal model with enough context for document and image organization on 16GB+ RAM.",
|
|
75
116
|
)
|
|
76
117
|
|
|
77
118
|
if spec.memory_gb >= 8:
|
|
78
119
|
return (
|
|
79
|
-
"
|
|
80
|
-
"
|
|
120
|
+
"gemma3:4b",
|
|
121
|
+
"Smallest recommended multimodal option for structured document and image organization; "
|
|
122
|
+
"expect slower operation on CPU-only systems.",
|
|
81
123
|
)
|
|
82
124
|
|
|
83
125
|
return (
|
|
84
|
-
"
|
|
85
|
-
"
|
|
126
|
+
"gemma3:4b",
|
|
127
|
+
"Refactor requires a multimodal instruction-following model for all supported file types. "
|
|
128
|
+
"This system may not have enough memory; use hosted mode or add memory if the model cannot run.",
|
|
86
129
|
)
|
refactor/plan_cache.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Persistent, source-validated plans for dry-run reuse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _snapshot(files: list[Path]) -> list[dict[str, Any]]:
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
"path": str(file_path.resolve()),
|
|
15
|
+
"size": file_path.stat().st_size,
|
|
16
|
+
"mtime_ns": file_path.stat().st_mtime_ns,
|
|
17
|
+
}
|
|
18
|
+
for file_path in sorted(files)
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def save_plan(
|
|
23
|
+
output_path: Path,
|
|
24
|
+
kind: str,
|
|
25
|
+
source_dir: str,
|
|
26
|
+
output_dir: str,
|
|
27
|
+
files: list[Path],
|
|
28
|
+
contexts: list[Any],
|
|
29
|
+
) -> None:
|
|
30
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
payload = {
|
|
32
|
+
"kind": kind,
|
|
33
|
+
"source_dir": str(Path(source_dir).resolve()),
|
|
34
|
+
"output_dir": str(Path(output_dir).resolve()),
|
|
35
|
+
"snapshot": _snapshot(files),
|
|
36
|
+
"items": [asdict(context) for context in contexts],
|
|
37
|
+
}
|
|
38
|
+
output_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_valid_plan(
|
|
42
|
+
input_path: Path,
|
|
43
|
+
kind: str,
|
|
44
|
+
source_dir: str,
|
|
45
|
+
output_dir: str,
|
|
46
|
+
files: list[Path],
|
|
47
|
+
context_type: Type[Any],
|
|
48
|
+
) -> list[Any] | None:
|
|
49
|
+
"""Return cached contexts only if they exactly match the current source."""
|
|
50
|
+
if not input_path.exists():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
payload = json.loads(input_path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, json.JSONDecodeError):
|
|
55
|
+
return None
|
|
56
|
+
if (
|
|
57
|
+
payload.get("kind") != kind
|
|
58
|
+
or payload.get("source_dir") != str(Path(source_dir).resolve())
|
|
59
|
+
or payload.get("output_dir") != str(Path(output_dir).resolve())
|
|
60
|
+
or payload.get("snapshot") != _snapshot(files)
|
|
61
|
+
):
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return [context_type(**item) for item in payload["items"]]
|
|
65
|
+
except (KeyError, TypeError):
|
|
66
|
+
return None
|
refactor/setup.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
4
6
|
import sys
|
|
5
7
|
|
|
6
8
|
import typer
|
|
7
9
|
|
|
8
10
|
from refactor.config import config_exists, default_config, load_config, save_config, save_env_vars
|
|
9
11
|
from refactor.hardware import detect_hardware, recommend_local_model
|
|
12
|
+
from refactor.shim import ollama_model_available, ollama_server_available
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
def _interactive() -> bool:
|
|
@@ -80,7 +83,7 @@ def run_setup() -> dict:
|
|
|
80
83
|
typer.echo("\nRefactor setup")
|
|
81
84
|
typer.echo("Choose backend:")
|
|
82
85
|
typer.echo(" 1) Hosted model (bring your own API keys)")
|
|
83
|
-
typer.echo(" 2) Local multimodal
|
|
86
|
+
typer.echo(" 2) Local multimodal model with Ollama")
|
|
84
87
|
|
|
85
88
|
choice = typer.prompt("Select option", default="1").strip()
|
|
86
89
|
|
|
@@ -120,14 +123,41 @@ def run_setup() -> dict:
|
|
|
120
123
|
typer.echo(f"- Model: {model}")
|
|
121
124
|
typer.echo(f"- Why: {reason}")
|
|
122
125
|
typer.echo("- Suggested runtime: ollama")
|
|
123
|
-
typer.echo("-
|
|
126
|
+
typer.echo("- Model command: ollama pull " + model)
|
|
124
127
|
|
|
125
128
|
config["backend"] = "local"
|
|
126
129
|
config["setup_skipped"] = False
|
|
127
130
|
config["local"]["runtime"] = "ollama"
|
|
128
131
|
config["local"]["recommended_model"] = model
|
|
129
|
-
config["local"]["status"] = "
|
|
130
|
-
|
|
132
|
+
config["local"]["status"] = "not_ready"
|
|
133
|
+
|
|
134
|
+
if not shutil.which("ollama"):
|
|
135
|
+
typer.echo("\nOllama is not installed, so Refactor cannot use a local model yet.")
|
|
136
|
+
typer.echo("Install Ollama from: https://ollama.com/download")
|
|
137
|
+
typer.echo("Then run:")
|
|
138
|
+
typer.echo(f" ollama pull {model}")
|
|
139
|
+
typer.echo(" refactor setup")
|
|
140
|
+
elif not ollama_server_available():
|
|
141
|
+
typer.echo("\nOllama is installed but its local server is not running.")
|
|
142
|
+
typer.echo("Start it in another terminal with:")
|
|
143
|
+
typer.echo(" ollama serve")
|
|
144
|
+
typer.echo("Then rerun: refactor setup")
|
|
145
|
+
elif ollama_model_available(model):
|
|
146
|
+
config["local"]["status"] = "ready"
|
|
147
|
+
typer.echo(f"\nOllama model '{model}' is installed and ready to use.")
|
|
148
|
+
else:
|
|
149
|
+
typer.echo(f"\nOllama is ready, but '{model}' is not installed.")
|
|
150
|
+
if typer.confirm(f"Download '{model}' now?", default=True):
|
|
151
|
+
result = subprocess.run(["ollama", "pull", model], check=False)
|
|
152
|
+
if result.returncode == 0 and ollama_model_available(model):
|
|
153
|
+
config["local"]["status"] = "ready"
|
|
154
|
+
typer.echo(f"Local model '{model}' is ready to use.")
|
|
155
|
+
else:
|
|
156
|
+
typer.echo(
|
|
157
|
+
f"Could not verify '{model}'. Run `ollama pull {model}` and then `refactor setup` again."
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
typer.echo(f"Run `ollama pull {model}` and then `refactor setup` when ready.")
|
|
131
161
|
|
|
132
162
|
else:
|
|
133
163
|
raise typer.BadParameter("Invalid option. Choose 1 or 2.")
|
refactor/shim.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
4
6
|
from pathlib import Path
|
|
7
|
+
from urllib.error import URLError
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
5
9
|
|
|
6
10
|
from refactor.imagga import ImageContext, ImaggaClient
|
|
7
11
|
from refactor.gemini import GeminiFolderClassifier
|
|
8
12
|
|
|
9
13
|
|
|
14
|
+
def _safe_folder_name(value: str) -> str:
|
|
15
|
+
value = value.strip().lower()
|
|
16
|
+
safe = "".join(char for char in value if char.isalnum() or char in " -_")
|
|
17
|
+
return "_".join(safe.split()).strip("_-") or "uncategorized"
|
|
18
|
+
|
|
19
|
+
|
|
10
20
|
class ImageClassifier(ABC):
|
|
11
21
|
@abstractmethod
|
|
12
22
|
def classify_image(self, file_path: Path) -> ImageContext:
|
|
@@ -26,27 +36,251 @@ class HostedImageClassifier(ImageClassifier):
|
|
|
26
36
|
return GeminiFolderClassifier().choose_folders(contexts, category_root)
|
|
27
37
|
|
|
28
38
|
|
|
29
|
-
|
|
39
|
+
OLLAMA_URL = "http://127.0.0.1:11434"
|
|
40
|
+
|
|
41
|
+
# ``format: "json"`` ensures only syntactically valid JSON. A schema also
|
|
42
|
+
# constrains keys and array length, which is essential for small local models.
|
|
43
|
+
IMAGE_CONTEXT_SCHEMA = {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"caption": {"type": "string"},
|
|
47
|
+
"tags": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"items": {"type": "string"},
|
|
50
|
+
"minItems": 1,
|
|
51
|
+
"maxItems": 5,
|
|
52
|
+
},
|
|
53
|
+
"folder": {"type": "string"},
|
|
54
|
+
},
|
|
55
|
+
"required": ["caption", "tags", "folder"],
|
|
56
|
+
"additionalProperties": False,
|
|
57
|
+
}
|
|
58
|
+
FOLDER_SCHEMA = {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {"folder": {"type": "string"}},
|
|
61
|
+
"required": ["folder"],
|
|
62
|
+
"additionalProperties": False,
|
|
63
|
+
}
|
|
64
|
+
FOLDER_BATCH_SCHEMA = {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"folders": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {"id": {"type": "integer"}, "folder": {"type": "string"}},
|
|
72
|
+
"required": ["id", "folder"],
|
|
73
|
+
"additionalProperties": False,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"required": ["folders"],
|
|
78
|
+
"additionalProperties": False,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_ollama_json(response: dict, description: str) -> dict:
|
|
83
|
+
"""Return one JSON object without exposing unbounded model output on errors."""
|
|
84
|
+
if response.get("error"):
|
|
85
|
+
raise RuntimeError(f"Ollama {description} request failed: {response['error']}")
|
|
86
|
+
content = response.get("message", {}).get("content", "")
|
|
87
|
+
try:
|
|
88
|
+
parsed = json.loads(content)
|
|
89
|
+
except (TypeError, json.JSONDecodeError) as exc:
|
|
90
|
+
preview = str(content).replace("\n", " ")[:300]
|
|
91
|
+
reason = response.get("done_reason", "unknown")
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"Ollama returned invalid {description} JSON "
|
|
94
|
+
f"(done_reason={reason}, preview={preview!r})"
|
|
95
|
+
) from exc
|
|
96
|
+
if not isinstance(parsed, dict):
|
|
97
|
+
raise RuntimeError(f"Ollama returned a non-object {description} response")
|
|
98
|
+
return parsed
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ollama_request(path: str, payload: dict | None = None) -> dict:
|
|
102
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
103
|
+
request = Request(
|
|
104
|
+
f"{OLLAMA_URL}{path}",
|
|
105
|
+
data=data,
|
|
106
|
+
headers={"Content-Type": "application/json"},
|
|
107
|
+
method="POST" if data is not None else "GET",
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
with urlopen(request, timeout=120) as response:
|
|
111
|
+
return json.loads(response.read().decode("utf-8"))
|
|
112
|
+
except (URLError, OSError, json.JSONDecodeError) as exc:
|
|
113
|
+
raise EnvironmentError(
|
|
114
|
+
"Cannot reach Ollama at http://127.0.0.1:11434. Start it with `ollama serve`."
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ollama_server_available() -> bool:
|
|
119
|
+
try:
|
|
120
|
+
_ollama_request("/api/tags")
|
|
121
|
+
except EnvironmentError:
|
|
122
|
+
return False
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ollama_model_available(model_name: str) -> bool:
|
|
127
|
+
try:
|
|
128
|
+
response = _ollama_request("/api/tags")
|
|
129
|
+
except EnvironmentError:
|
|
130
|
+
return False
|
|
131
|
+
installed = {model.get("name", "") for model in response.get("models", [])}
|
|
132
|
+
return model_name in installed or f"{model_name}:latest" in installed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class OllamaImageClassifier(ImageClassifier):
|
|
30
136
|
def __init__(self, model_name: str) -> None:
|
|
31
137
|
self.model_name = model_name
|
|
32
138
|
|
|
33
139
|
def classify_image(self, file_path: Path) -> ImageContext:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
140
|
+
try:
|
|
141
|
+
encoded_image = base64.b64encode(file_path.read_bytes()).decode("ascii")
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
raise RuntimeError(f"Could not read image: {file_path}") from exc
|
|
144
|
+
|
|
145
|
+
prompt = (
|
|
146
|
+
"Analyze this image for file organization. Return one JSON object matching "
|
|
147
|
+
"the supplied schema: a short caption, 1 to 5 distinct concise lowercase "
|
|
148
|
+
"tags, and one broad reusable folder name. Do not repeat tags or add keys."
|
|
149
|
+
)
|
|
150
|
+
response = _ollama_request(
|
|
151
|
+
"/api/chat",
|
|
152
|
+
{
|
|
153
|
+
"model": self.model_name,
|
|
154
|
+
"stream": False,
|
|
155
|
+
"format": IMAGE_CONTEXT_SCHEMA,
|
|
156
|
+
"options": {"temperature": 0, "num_predict": 160},
|
|
157
|
+
"think": False,
|
|
158
|
+
"messages": [{"role": "user", "content": prompt, "images": [encoded_image]}],
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
result = _parse_ollama_json(response, "image classification")
|
|
162
|
+
|
|
163
|
+
raw_tags = result.get("tags")
|
|
164
|
+
if not isinstance(raw_tags, list) or not all(isinstance(tag, str) for tag in raw_tags):
|
|
165
|
+
raise RuntimeError("Ollama image classification did not return a string tag list")
|
|
166
|
+
tags = list(dict.fromkeys(tag.strip().lower() for tag in raw_tags if tag.strip()))[:5]
|
|
167
|
+
caption = str(result.get("caption", "")).strip()
|
|
168
|
+
primary = _safe_folder_name(str(result.get("folder", "")))
|
|
169
|
+
if not tags:
|
|
170
|
+
tags = ["unclassified"]
|
|
171
|
+
if not caption:
|
|
172
|
+
caption = "No caption returned by local model."
|
|
173
|
+
if primary == "uncategorized":
|
|
174
|
+
primary = tags[0]
|
|
37
175
|
|
|
38
176
|
return ImageContext(
|
|
39
177
|
file_path=str(file_path.resolve()),
|
|
40
|
-
upload_id="local
|
|
178
|
+
upload_id="ollama-local",
|
|
41
179
|
tags=tags,
|
|
42
180
|
confidences={tag: 0.0 for tag in tags},
|
|
43
|
-
caption=
|
|
44
|
-
f"Local stub classification using '{self.model_name}'. "
|
|
45
|
-
"Install and connect a local multimodal runtime in a future step."
|
|
46
|
-
),
|
|
181
|
+
caption=caption,
|
|
47
182
|
primary_folder=primary,
|
|
48
183
|
)
|
|
49
184
|
|
|
185
|
+
def choose_folders(
|
|
186
|
+
self, contexts: list[ImageContext], category_root: Path
|
|
187
|
+
) -> list[ImageContext]:
|
|
188
|
+
if not contexts:
|
|
189
|
+
return contexts
|
|
190
|
+
existing = (
|
|
191
|
+
sorted(entry.name for entry in category_root.iterdir() if entry.is_dir())
|
|
192
|
+
if category_root.exists()
|
|
193
|
+
else []
|
|
194
|
+
)
|
|
195
|
+
images = [
|
|
196
|
+
{"id": index, "filename": Path(context.file_path).name,
|
|
197
|
+
"tags": context.tags, "caption": context.caption}
|
|
198
|
+
for index, context in enumerate(contexts)
|
|
199
|
+
]
|
|
200
|
+
prompt = (
|
|
201
|
+
"Choose exactly one concise, lowercase category folder for every image. "
|
|
202
|
+
"Prefer an existing category when it accurately fits; otherwise create a "
|
|
203
|
+
"new, broad and reusable category. Folder names must be a single relative "
|
|
204
|
+
"directory name (letters, numbers, spaces, underscores, or hyphens only). "
|
|
205
|
+
"Return exactly one id/folder object for every image and no other keys.\n\n"
|
|
206
|
+
f"Existing categories: {json.dumps(existing)}\n"
|
|
207
|
+
f"Images and local semantic context: {json.dumps(images, ensure_ascii=False)}"
|
|
208
|
+
)
|
|
209
|
+
response = _ollama_request(
|
|
210
|
+
"/api/chat",
|
|
211
|
+
{
|
|
212
|
+
"model": self.model_name,
|
|
213
|
+
"stream": False,
|
|
214
|
+
"format": FOLDER_BATCH_SCHEMA,
|
|
215
|
+
"options": {"temperature": 0, "num_predict": max(80, len(contexts) * 40)},
|
|
216
|
+
"think": False,
|
|
217
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
result = _parse_ollama_json(response, "image folder-selection")
|
|
221
|
+
try:
|
|
222
|
+
choices = result["folders"]
|
|
223
|
+
folders = {
|
|
224
|
+
choice.get("id"): _safe_folder_name(str(choice.get("folder", "")))
|
|
225
|
+
for choice in choices
|
|
226
|
+
if isinstance(choice, dict) and isinstance(choice.get("id"), int)
|
|
227
|
+
}
|
|
228
|
+
except (KeyError, TypeError) as exc:
|
|
229
|
+
raise RuntimeError(
|
|
230
|
+
"Ollama image folder-selection response did not contain folders"
|
|
231
|
+
) from exc
|
|
232
|
+
missing = [str(index) for index in range(len(contexts)) if index not in folders]
|
|
233
|
+
if missing:
|
|
234
|
+
raise RuntimeError("Ollama did not choose a folder for image id(s): " + ", ".join(missing))
|
|
235
|
+
for index, context in enumerate(contexts):
|
|
236
|
+
context.primary_folder = folders[index]
|
|
237
|
+
return contexts
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class OllamaDocumentClassifier:
|
|
241
|
+
"""Choose a document folder through a local Ollama text-capable model."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, model_name: str) -> None:
|
|
244
|
+
self.model_name = model_name
|
|
245
|
+
|
|
246
|
+
def choose_document_folder(
|
|
247
|
+
self, file_path: Path, extracted_text: str, category_root: Path
|
|
248
|
+
) -> str:
|
|
249
|
+
existing = (
|
|
250
|
+
sorted(entry.name for entry in category_root.iterdir() if entry.is_dir())
|
|
251
|
+
if category_root.exists()
|
|
252
|
+
else []
|
|
253
|
+
)
|
|
254
|
+
prompt = (
|
|
255
|
+
"Choose one concise, lowercase category folder for this document. "
|
|
256
|
+
"Prefer an existing category when it fits. The folder must not be a filename "
|
|
257
|
+
"or path. Return one JSON object matching the supplied schema and no other keys.\n\n"
|
|
258
|
+
f"Existing categories: {json.dumps(existing)}\n"
|
|
259
|
+
f"Filename: {file_path.name}\n"
|
|
260
|
+
f"Document text:\n{extracted_text}"
|
|
261
|
+
)
|
|
262
|
+
response = _ollama_request(
|
|
263
|
+
"/api/chat",
|
|
264
|
+
{
|
|
265
|
+
"model": self.model_name,
|
|
266
|
+
"stream": False,
|
|
267
|
+
"format": FOLDER_SCHEMA,
|
|
268
|
+
"options": {"temperature": 0, "num_predict": 80},
|
|
269
|
+
"think": False,
|
|
270
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
result = _parse_ollama_json(response, "document classification")
|
|
274
|
+
try:
|
|
275
|
+
folder = result["folder"]
|
|
276
|
+
except KeyError as exc:
|
|
277
|
+
raise RuntimeError(
|
|
278
|
+
"Ollama document classification response did not contain a folder"
|
|
279
|
+
) from exc
|
|
280
|
+
if not isinstance(folder, str):
|
|
281
|
+
raise RuntimeError("Ollama document classification returned a non-string folder")
|
|
282
|
+
return _safe_folder_name(folder)
|
|
283
|
+
|
|
50
284
|
|
|
51
285
|
def build_image_classifier(config: dict, min_confidence: float) -> ImageClassifier:
|
|
52
286
|
backend = config.get("backend", "unconfigured")
|
|
@@ -56,9 +290,37 @@ def build_image_classifier(config: dict, min_confidence: float) -> ImageClassifi
|
|
|
56
290
|
|
|
57
291
|
if backend == "local":
|
|
58
292
|
local_cfg = config.get("local", {})
|
|
59
|
-
model_name = local_cfg.get("recommended_model")
|
|
60
|
-
|
|
293
|
+
model_name = local_cfg.get("recommended_model")
|
|
294
|
+
if not model_name:
|
|
295
|
+
raise EnvironmentError("No local Ollama model is configured. Run `refactor setup`.")
|
|
296
|
+
if not ollama_server_available():
|
|
297
|
+
raise EnvironmentError("Ollama is not running. Start it with `ollama serve`.")
|
|
298
|
+
if not ollama_model_available(model_name):
|
|
299
|
+
raise EnvironmentError(
|
|
300
|
+
f"Ollama model '{model_name}' is not installed. Run `ollama pull {model_name}`."
|
|
301
|
+
)
|
|
302
|
+
return OllamaImageClassifier(model_name=model_name)
|
|
303
|
+
|
|
304
|
+
raise EnvironmentError(
|
|
305
|
+
"Refactor setup is incomplete. Run 'refactor setup' to configure hosted or local backend."
|
|
306
|
+
)
|
|
61
307
|
|
|
308
|
+
|
|
309
|
+
def build_document_classifier(config: dict):
|
|
310
|
+
"""Return the configured backend's folder classifier for documents."""
|
|
311
|
+
if config.get("backend") == "hosted":
|
|
312
|
+
return GeminiFolderClassifier()
|
|
313
|
+
if config.get("backend") == "local":
|
|
314
|
+
model_name = config.get("local", {}).get("recommended_model")
|
|
315
|
+
if not model_name:
|
|
316
|
+
raise EnvironmentError("No local Ollama model is configured. Run `refactor setup`.")
|
|
317
|
+
if not ollama_server_available():
|
|
318
|
+
raise EnvironmentError("Ollama is not running. Start it with `ollama serve`.")
|
|
319
|
+
if not ollama_model_available(model_name):
|
|
320
|
+
raise EnvironmentError(
|
|
321
|
+
f"Ollama model '{model_name}' is not installed. Run `ollama pull {model_name}`."
|
|
322
|
+
)
|
|
323
|
+
return OllamaDocumentClassifier(model_name)
|
|
62
324
|
raise EnvironmentError(
|
|
63
325
|
"Refactor setup is incomplete. Run 'refactor setup' to configure hosted or local backend."
|
|
64
326
|
)
|
|
@@ -67,7 +329,12 @@ def build_image_classifier(config: dict, min_confidence: float) -> ImageClassifi
|
|
|
67
329
|
def choose_category_folders(
|
|
68
330
|
config: dict, contexts: list[ImageContext], category_root: Path
|
|
69
331
|
) -> list[ImageContext]:
|
|
70
|
-
"""Use
|
|
332
|
+
"""Use the configured model to consistently choose category folders."""
|
|
71
333
|
if config.get("backend") == "hosted":
|
|
72
334
|
return GeminiFolderClassifier().choose_folders(contexts, category_root)
|
|
335
|
+
if config.get("backend") == "local":
|
|
336
|
+
model_name = config.get("local", {}).get("recommended_model")
|
|
337
|
+
if not model_name:
|
|
338
|
+
raise EnvironmentError("No local Ollama model is configured. Run `refactor setup`.")
|
|
339
|
+
return OllamaImageClassifier(model_name).choose_folders(contexts, category_root)
|
|
73
340
|
return contexts
|
refactor/text.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Document discovery, text extraction, and configured-backend categorization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from xml.etree import ElementTree
|
|
10
|
+
|
|
11
|
+
from refactor.ignore import should_ignore
|
|
12
|
+
from refactor.shim import build_document_classifier
|
|
13
|
+
|
|
14
|
+
TEXT_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
|
|
15
|
+
TEXT_CATEGORY_MANIFEST = ".refactor_text_categories.json"
|
|
16
|
+
MAX_TEXT_CHARS = 60_000
|
|
17
|
+
MAX_PDF_BYTES = 50 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TextContext:
|
|
22
|
+
file_path: str
|
|
23
|
+
primary_folder: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def managed_text_dirs(output_dir: Path) -> set[Path]:
|
|
27
|
+
manifest = output_dir / TEXT_CATEGORY_MANIFEST
|
|
28
|
+
if not manifest.exists():
|
|
29
|
+
return set()
|
|
30
|
+
try:
|
|
31
|
+
folders = json.loads(manifest.read_text(encoding="utf-8")).get("folders", [])
|
|
32
|
+
except (OSError, json.JSONDecodeError):
|
|
33
|
+
return set()
|
|
34
|
+
return {
|
|
35
|
+
(output_dir / folder).resolve() for folder in folders
|
|
36
|
+
if isinstance(folder, str) and (output_dir / folder).is_dir()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_managed_text_dirs(output_dir: Path, folders: set[str]) -> None:
|
|
41
|
+
(output_dir / TEXT_CATEGORY_MANIFEST).write_text(
|
|
42
|
+
json.dumps({"folders": sorted(folders)}, indent=2) + "\n", encoding="utf-8"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scan_documents(path: str, patterns: list[str], excluded_dirs: set[Path]) -> list[Path]:
|
|
47
|
+
documents: list[Path] = []
|
|
48
|
+
excluded_dirs = {directory.resolve() for directory in excluded_dirs}
|
|
49
|
+
for item in Path(path).rglob("*"):
|
|
50
|
+
if item.is_dir() or item.suffix.lower() not in TEXT_EXTENSIONS:
|
|
51
|
+
continue
|
|
52
|
+
if any(directory in item.resolve().parents for directory in excluded_dirs):
|
|
53
|
+
continue
|
|
54
|
+
if not should_ignore(item.name, patterns):
|
|
55
|
+
documents.append(item)
|
|
56
|
+
return documents
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_text(file_path: Path) -> str:
|
|
60
|
+
"""Extract text locally from supported document formats."""
|
|
61
|
+
if file_path.suffix.lower() in {".txt", ".md"}:
|
|
62
|
+
return file_path.read_text(encoding="utf-8", errors="replace")[:MAX_TEXT_CHARS]
|
|
63
|
+
if file_path.suffix.lower() == ".docx":
|
|
64
|
+
with zipfile.ZipFile(file_path) as document:
|
|
65
|
+
root = ElementTree.fromstring(document.read("word/document.xml"))
|
|
66
|
+
text = " ".join(node.text or "" for node in root.iter() if node.tag.endswith("}t"))
|
|
67
|
+
return text[:MAX_TEXT_CHARS]
|
|
68
|
+
if file_path.suffix.lower() == ".pdf":
|
|
69
|
+
try:
|
|
70
|
+
from pypdf import PdfReader
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
raise EnvironmentError(
|
|
73
|
+
"PDF classification needs the `pypdf` package. Reinstall refactor-cli to include it."
|
|
74
|
+
) from exc
|
|
75
|
+
reader = PdfReader(file_path)
|
|
76
|
+
return "\n".join(page.extract_text() or "" for page in reader.pages)[:MAX_TEXT_CHARS]
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def classify_documents(
|
|
81
|
+
documents: list[Path], category_root: Path, config: dict
|
|
82
|
+
) -> tuple[list[TextContext], list[tuple[Path, Exception]]]:
|
|
83
|
+
classifier = build_document_classifier(config)
|
|
84
|
+
results: list[TextContext] = []
|
|
85
|
+
failures: list[tuple[Path, Exception]] = []
|
|
86
|
+
for document in documents:
|
|
87
|
+
try:
|
|
88
|
+
is_pdf = document.suffix.lower() == ".pdf"
|
|
89
|
+
if is_pdf and document.stat().st_size > MAX_PDF_BYTES:
|
|
90
|
+
raise ValueError("PDF exceeds Refactor's 50 MB document input limit")
|
|
91
|
+
if config.get("backend") == "hosted":
|
|
92
|
+
# Preserve the hosted flow's native PDF input capability.
|
|
93
|
+
folder = classifier.choose_document_folder(
|
|
94
|
+
document, extract_text(document), category_root, is_pdf=is_pdf
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
folder = classifier.choose_document_folder(
|
|
98
|
+
document, extract_text(document), category_root
|
|
99
|
+
)
|
|
100
|
+
results.append(TextContext(str(document.resolve()), folder))
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
failures.append((document, exc))
|
|
103
|
+
return results, failures
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactor-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI tool to organize files using AI
|
|
5
5
|
Requires-Python: >=3.8
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,6 +8,7 @@ License-File: LICENSE
|
|
|
8
8
|
Requires-Dist: typer
|
|
9
9
|
Requires-Dist: requests
|
|
10
10
|
Requires-Dist: python-dotenv
|
|
11
|
+
Requires-Dist: pypdf
|
|
11
12
|
Dynamic: license-file
|
|
12
13
|
|
|
13
14
|
# refactor.ai
|
|
@@ -22,11 +23,15 @@ The CLI entry point in [refactor/cli.py](refactor/cli.py) provides:
|
|
|
22
23
|
- `tree` — prints a recursive directory tree through [refactor/tree.py](refactor/tree.py).
|
|
23
24
|
- `scan` — collects basic file and directory metadata through [refactor/scanner.py](refactor/scanner.py) and [refactor/metadata.py](refactor/metadata.py).
|
|
24
25
|
- `setup` — configures a multimodal backend (`hosted` or `local`), persisted to `~/.refactor/config.json`.
|
|
25
|
-
- `get-image-context` — obtains
|
|
26
|
-
destination category,
|
|
27
|
-
- `refactor-images` — automatically refreshes image context
|
|
28
|
-
into
|
|
29
|
-
when the originals must be retained).
|
|
26
|
+
- `get-image-context` — obtains image tags/captions and asks the configured
|
|
27
|
+
backend to choose a destination category, then writes JSON context.
|
|
28
|
+
- `refactor-images` — automatically refreshes image context with the configured
|
|
29
|
+
backend, then moves images into model-selected category folders (`--copy` is
|
|
30
|
+
available when the originals must be retained).
|
|
31
|
+
- `refactor-text` — uses the configured backend to categorize `.txt`, `.md`,
|
|
32
|
+
`.pdf`, and `.docx` files.
|
|
33
|
+
- `now` — runs `refactor-text` and then `refactor-images`, so mixed folders are
|
|
34
|
+
organized in one invocation.
|
|
30
35
|
- Path checks through [refactor/validation.py](refactor/validation.py), including existence, directory type, and read access.
|
|
31
36
|
- Optional filename exclusions through [refactor/ignore.py](refactor/ignore.py).
|
|
32
37
|
|
|
@@ -39,10 +44,19 @@ After installing `refactor-cli`, the first CLI run prompts:
|
|
|
39
44
|
1. **Hosted model (bring your own API keys)**
|
|
40
45
|
- Prompts for `IMAGGA_API_KEY`, `IMAGGA_API_SECRET`, and `GEMINI_API_KEY`
|
|
41
46
|
- Saves credentials to `~/.refactor/.env` with restricted file permissions
|
|
42
|
-
2. **Local multimodal
|
|
43
|
-
- Detects
|
|
44
|
-
- Recommends
|
|
45
|
-
-
|
|
47
|
+
2. **Local multimodal model with Ollama**
|
|
48
|
+
- Detects CPU, memory, and NVIDIA hardware (even if VRAM is temporarily unavailable)
|
|
49
|
+
- Recommends a vision model and verifies the local Ollama runtime
|
|
50
|
+
- Guides the user to install Ollama or pulls the recommended model when Ollama is ready
|
|
51
|
+
- Sends images to the local Ollama API for captions, tags, and category selection; documents are text-extracted locally and categorized through the same Ollama model. No image or document data is uploaded to Refactor services.
|
|
52
|
+
|
|
53
|
+
For a local setup, install [Ollama](https://ollama.com/download), then run the exact command printed by setup, for example:
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
ollama pull gemma3:4b
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Run `refactor setup` again after the download finishes. Refactor verifies both the local Ollama service and the selected model before classifying images or documents.
|
|
46
60
|
|
|
47
61
|
For the hosted workflow, local images are uploaded to Imagga and classified with
|
|
48
62
|
the V3 `/tags` endpoint using `model=light` and `include_caption=true`. The
|
|
@@ -57,6 +71,16 @@ Run `refactor refactor-images ./incoming --output-dir ./organized` to generate
|
|
|
57
71
|
fresh context and move the images. Add `--dry-run` to inspect destinations or
|
|
58
72
|
`--copy` to retain the originals.
|
|
59
73
|
|
|
74
|
+
Run `refactor now ./incoming --output-dir ./organized` to organize supported
|
|
75
|
+
documents and images together. Hosted mode sends PDFs to Gemini as native
|
|
76
|
+
document input; local mode text-extracts PDFs, text, Markdown, and Word files
|
|
77
|
+
before sending their contents to Ollama.
|
|
78
|
+
|
|
79
|
+
`--dry-run` saves a source-validated organization plan. Running the same command
|
|
80
|
+
without `--dry-run` reuses that plan without another Imagga or Gemini request,
|
|
81
|
+
provided the source files and output directory are unchanged. Any added, removed,
|
|
82
|
+
or modified source file invalidates the plan and triggers fresh classification.
|
|
83
|
+
|
|
60
84
|
## Techniques used
|
|
61
85
|
|
|
62
86
|
- **Recursive traversal with `pathlib`** — [refactor/scanner.py](refactor/scanner.py) uses [`Path.rglob()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob) to discover nested paths. [refactor/tree.py](refactor/tree.py) recursively walks directory entries to render a text tree.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
refactor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
refactor/cli.py,sha256=8B2DFFoLiQDMGV3We1BQq9uvjmU-piiksUOjg0cQ5VE,13808
|
|
3
|
+
refactor/config.py,sha256=bE45IvJHhrIfyVi7MZW_OUl8TdbavDJhdq-PKUOgOOw,1958
|
|
4
|
+
refactor/context_store.py,sha256=LE7ypaOOxqTS7V8YIs1QIyDSqKZRB5_Y6B8RFg40q3g,1453
|
|
5
|
+
refactor/gemini.py,sha256=TH7NBn9erhkV42JJIRH658w2dxXeT_pfG8HNh2Y7ACw,6242
|
|
6
|
+
refactor/hardware.py,sha256=qhJUq0aOB6FwEIHxQ2LbckIZo0DEYf0Xi4uKoN8uB90,3589
|
|
7
|
+
refactor/ignore.py,sha256=KBvsrb1ptHWuX7VU2qXuX4lYVP97fis8zuJdGKHrq-4,549
|
|
8
|
+
refactor/image_scanner.py,sha256=KsJu_DWTazssmlGVO3pvl3QrzLvefRYsLWjS-PLnYzY,1055
|
|
9
|
+
refactor/imagga.py,sha256=jn6t7IV63MOfF1dOzrF3ORyjaVdilPrY5FG59Xs_u3E,6472
|
|
10
|
+
refactor/metadata.py,sha256=fE7L46j85S5TbcYXQTQrT2AaXW4ut_Yg4JT7z_ZgyVU,248
|
|
11
|
+
refactor/organizer.py,sha256=EeLQKqFfmJCPXO3wqy1Zjd7iroV9wxiqmL7srU4XgDQ,3183
|
|
12
|
+
refactor/plan_cache.py,sha256=iX3hMbeUNpiBQDY0PD2NiARXX03HpJ4uOoXsUNuJ-7A,1899
|
|
13
|
+
refactor/scanner.py,sha256=epuaACLR845I0cYw9wTt5WyqPaKe_Ic0mrcuH-SB2jA,306
|
|
14
|
+
refactor/setup.py,sha256=HsxjvI314DlWQhD9MI7Cw1BecUhi2zNO1NGngbKU3wY,7063
|
|
15
|
+
refactor/shim.py,sha256=hRNdhJZ-iOkee18wUzy5ERaEl2JhzOtdy1VC2VJttQ0,13349
|
|
16
|
+
refactor/text.py,sha256=PbKV6jKIfYklBxle-jYk2TgzuRI0y1kyaPs3sT7LSjM,3960
|
|
17
|
+
refactor/tree.py,sha256=y1p2FGvmQQ5eAmuncjTqOURa6wKQ3US3VGV6pfi0DLY,446
|
|
18
|
+
refactor/validation.py,sha256=SM9VaPA-VF1-UuRQqmDuYs19rgVKtHmRkIGyz4wJ_I8,374
|
|
19
|
+
refactor_cli-0.3.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
20
|
+
refactor_cli-0.3.0.dist-info/METADATA,sha256=Az5HHH4RQXWXfd9LVbKSh3L7sukcT3s5MhNGz5S-6zs,7884
|
|
21
|
+
refactor_cli-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
22
|
+
refactor_cli-0.3.0.dist-info/entry_points.txt,sha256=oXn5HKS9c_VwsJ-QNKFd2GKXNhlWxr_tkfueSAmWr3M,46
|
|
23
|
+
refactor_cli-0.3.0.dist-info/top_level.txt,sha256=jCNAQcJGcXPbLuqADBNCQxentGSDGWA08qs5wEtb_AU,9
|
|
24
|
+
refactor_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
refactor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
refactor/cli.py,sha256=T0HaNhFgZ8F9BcoWkrW76y6RbEzrVhIVsNcmQiP18GU,9331
|
|
3
|
-
refactor/config.py,sha256=1fQPV2f_MkP9nuwBbD3qMEWKQZ13TF1UA3q9wP315yI,1948
|
|
4
|
-
refactor/context_store.py,sha256=LE7ypaOOxqTS7V8YIs1QIyDSqKZRB5_Y6B8RFg40q3g,1453
|
|
5
|
-
refactor/gemini.py,sha256=tMiBHMUUt615uX4YznuIQOve-AQCKYuMBnUvTYN-2PM,4044
|
|
6
|
-
refactor/hardware.py,sha256=nZjLBON3I44MP458ojGuXZNXW_rtKyQHJDkhKlt8FLw,2101
|
|
7
|
-
refactor/ignore.py,sha256=KBvsrb1ptHWuX7VU2qXuX4lYVP97fis8zuJdGKHrq-4,549
|
|
8
|
-
refactor/image_scanner.py,sha256=KsJu_DWTazssmlGVO3pvl3QrzLvefRYsLWjS-PLnYzY,1055
|
|
9
|
-
refactor/imagga.py,sha256=jn6t7IV63MOfF1dOzrF3ORyjaVdilPrY5FG59Xs_u3E,6472
|
|
10
|
-
refactor/metadata.py,sha256=fE7L46j85S5TbcYXQTQrT2AaXW4ut_Yg4JT7z_ZgyVU,248
|
|
11
|
-
refactor/organizer.py,sha256=EeLQKqFfmJCPXO3wqy1Zjd7iroV9wxiqmL7srU4XgDQ,3183
|
|
12
|
-
refactor/scanner.py,sha256=epuaACLR845I0cYw9wTt5WyqPaKe_Ic0mrcuH-SB2jA,306
|
|
13
|
-
refactor/setup.py,sha256=O98KOtOU8V8_WyMY1zR08LRN2oKq-nIpHDpQbvIscmk,5490
|
|
14
|
-
refactor/shim.py,sha256=B0_I_jn3OTBm1dA7VI3--ECnARh8_jbcOZ2Yy1pk0Zk,2604
|
|
15
|
-
refactor/tree.py,sha256=y1p2FGvmQQ5eAmuncjTqOURa6wKQ3US3VGV6pfi0DLY,446
|
|
16
|
-
refactor/validation.py,sha256=SM9VaPA-VF1-UuRQqmDuYs19rgVKtHmRkIGyz4wJ_I8,374
|
|
17
|
-
refactor_cli-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
18
|
-
refactor_cli-0.2.0.dist-info/METADATA,sha256=Ydb0IKFPcDXl5Ew-M8KYuOB8Ry-X7dOJ3DpUtksonKM,6443
|
|
19
|
-
refactor_cli-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
-
refactor_cli-0.2.0.dist-info/entry_points.txt,sha256=oXn5HKS9c_VwsJ-QNKFd2GKXNhlWxr_tkfueSAmWr3M,46
|
|
21
|
-
refactor_cli-0.2.0.dist-info/top_level.txt,sha256=jCNAQcJGcXPbLuqADBNCQxentGSDGWA08qs5wEtb_AU,9
|
|
22
|
-
refactor_cli-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|