ragjson 0.1.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.
ragjson/commands.py ADDED
@@ -0,0 +1,478 @@
1
+ """CLI command implementations.
2
+
3
+ Each function corresponds to a `rag` subcommand defined in spec Section 6.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import tarfile
9
+ from concurrent.futures import as_completed
10
+ from pathlib import Path
11
+
12
+ from ragjson import RAGJSON_VERSION
13
+ from ragjson.atomic_ops import (
14
+ DirectoryLock,
15
+ clean_tmp_files,
16
+ ensure_rag_dirs,
17
+ )
18
+ from ragjson.config import RagConfig, load_config, require_embedding_config
19
+ from ragjson.doc_parser import is_supported_file, parse_document
20
+ from ragjson.embedding import EmbeddingClient
21
+ from ragjson.ragjson_file import (
22
+ STATUS_DELETED,
23
+ STATUS_EMBEDDED,
24
+ STATUS_EMBEDDING,
25
+ STATUS_INITIAL,
26
+ STATUS_SLICED,
27
+ STATUS_SLICING,
28
+ SliceData,
29
+ create_initial_ragjson,
30
+ create_ragjson_filename,
31
+ is_source_changed,
32
+ populate_source_info,
33
+ ragjson_path_for,
34
+ read_ragjson,
35
+ update_status,
36
+ write_ragjson,
37
+ )
38
+ from ragjson.splitter import split_text
39
+ from ragjson.utils import bold, green, red, yellow
40
+
41
+
42
+ # ============================================================
43
+ # rag init <path>
44
+ # ============================================================
45
+
46
+ def cmd_init(path: str) -> None:
47
+ """Initialize .rag/ and .rag/tmp/ directories at the given path.
48
+
49
+ Per spec Section 6.1: rag init <path>
50
+ Creates the target directory if it does not exist.
51
+ """
52
+ target = Path(path).resolve()
53
+ if target.exists() and not target.is_dir():
54
+ print(f"Error: Path is not a directory: {target}", file=sys.stderr)
55
+ sys.exit(1)
56
+
57
+ # Create target directory if needed
58
+ target.mkdir(parents=True, exist_ok=True)
59
+
60
+ rag_dir, tmp_dir = ensure_rag_dirs(target)
61
+ print(f"Initialized RAG directory: {target}")
62
+ print(f" Created: {rag_dir}")
63
+ print(f" Created: {tmp_dir}")
64
+
65
+
66
+ # ============================================================
67
+ # rag status [path] [-r]
68
+ # ============================================================
69
+
70
+ def cmd_status(path: str, recursive: bool = False) -> None:
71
+ """Show knowledge base status with MD5 comparison.
72
+
73
+ Per spec Section 6.2:
74
+ - Green: EMBEDDED and MD5 match
75
+ - Red: MD5 mismatch or no ragjson (needs update)
76
+ - Yellow: intermediate states
77
+ """
78
+ target = Path(path).resolve()
79
+ if not target.is_dir():
80
+ print(f"Error: Path is not a directory: {target}", file=sys.stderr)
81
+ sys.exit(1)
82
+
83
+ dirs_to_scan = _collect_dirs(target, recursive)
84
+ has_any = False
85
+
86
+ for scan_dir in dirs_to_scan:
87
+ rag_dir = scan_dir / ".rag"
88
+ files = _list_supported_files(scan_dir)
89
+ if not files:
90
+ continue
91
+
92
+ has_any = True
93
+ print(f"\n{bold(str(scan_dir))}")
94
+
95
+ for f in files:
96
+ ragjson_path = ragjson_path_for(scan_dir, f.name)
97
+ data = read_ragjson(ragjson_path)
98
+
99
+ if data is None:
100
+ # No ragjson file — needs processing
101
+ print(f" {red('NEW'):30s} {f.name}")
102
+ elif data.processing.status == STATUS_EMBEDDED:
103
+ if is_source_changed(data, f):
104
+ # MD5 changed — needs reprocessing
105
+ print(f" {red('CHANGED'):30s} {f.name}")
106
+ else:
107
+ # Up to date
108
+ print(f" {green('EMBEDDED'):30s} {f.name}")
109
+ elif data.processing.status == STATUS_DELETED:
110
+ print(f" {yellow('DELETED'):30s} {f.name}")
111
+ else:
112
+ # Intermediate state
113
+ status = data.processing.status
114
+ print(f" {yellow(status):30s} {f.name}")
115
+
116
+ # Check for orphaned ragjson files (source deleted)
117
+ if rag_dir.exists():
118
+ for rj in rag_dir.glob("*.ragjson"):
119
+ source_name = rj.name.removesuffix(".ragjson")
120
+ source_path = scan_dir / source_name
121
+ if not source_path.exists():
122
+ data = read_ragjson(rj)
123
+ if data and data.processing.status != STATUS_DELETED:
124
+ print(f" {yellow('ORPHANED'):30s} {source_name}")
125
+
126
+ if not has_any:
127
+ print("No supported files found.")
128
+
129
+
130
+ # ============================================================
131
+ # rag update [path] [-r] [-j N] [--splitter TYPE] [--parser TYPE]
132
+ # ============================================================
133
+
134
+ def cmd_update(
135
+ path: str,
136
+ recursive: bool = False,
137
+ jobs: int = 0,
138
+ splitter_type: str = "recursive",
139
+ parser_type: str = "markitdown",
140
+ ) -> None:
141
+ """Incremental update: process files with changed MD5.
142
+
143
+ Per spec Section 6.1: rag update [path] [-r]
144
+ Supports -j for parallel directory processing.
145
+ """
146
+ target = Path(path).resolve()
147
+ if not target.is_dir():
148
+ print(f"Error: Path is not a directory: {target}", file=sys.stderr)
149
+ sys.exit(1)
150
+
151
+ config = load_config()
152
+ require_embedding_config(config)
153
+
154
+ dirs_to_scan = _collect_dirs(target, recursive)
155
+
156
+ # Collect directories that have files needing updates
157
+ update_tasks = []
158
+ for scan_dir in dirs_to_scan:
159
+ files_needing_update = _files_needing_update(scan_dir)
160
+ if files_needing_update:
161
+ update_tasks.append((scan_dir, files_needing_update))
162
+
163
+ if not update_tasks:
164
+ print("All files are up to date.")
165
+ return
166
+
167
+ total_files = sum(len(files) for _, files in update_tasks)
168
+ print(f"Found {total_files} file(s) to update in {len(update_tasks)} directory(ies)")
169
+
170
+ if jobs == 0:
171
+ jobs = os.cpu_count() or 1
172
+
173
+ if len(update_tasks) == 1:
174
+ # Single directory — no need for parallelism
175
+ scan_dir, files = update_tasks[0]
176
+ _process_directory(scan_dir, files, config, splitter_type, parser_type)
177
+ else:
178
+ # Multiple directories — process in parallel
179
+ # Note: We use ThreadPoolExecutor instead of ProcessPoolExecutor
180
+ # because the heavy work (API calls) is I/O-bound, and threads
181
+ # share the config without pickling issues.
182
+ from concurrent.futures import ThreadPoolExecutor
183
+
184
+ with ThreadPoolExecutor(max_workers=min(jobs, len(update_tasks))) as pool:
185
+ futures = {}
186
+ for scan_dir, files in update_tasks:
187
+ future = pool.submit(
188
+ _process_directory,
189
+ scan_dir, files, config, splitter_type, parser_type,
190
+ )
191
+ futures[future] = scan_dir
192
+
193
+ for future in as_completed(futures):
194
+ scan_dir = futures[future]
195
+ try:
196
+ future.result()
197
+ except Exception as e:
198
+ print(f"Error processing {scan_dir}: {e}", file=sys.stderr)
199
+
200
+
201
+ def _process_directory(
202
+ scan_dir: Path,
203
+ files: list[Path],
204
+ config: RagConfig,
205
+ splitter_type: str,
206
+ parser_type: str,
207
+ ) -> None:
208
+ """Process all files in a single directory with locking.
209
+
210
+ Implements the full update pipeline per spec Section 5.
211
+ """
212
+ rag_dir = scan_dir / ".rag"
213
+ ensure_rag_dirs(scan_dir)
214
+
215
+ # Acquire directory lock (spec Section 3.3)
216
+ with DirectoryLock(rag_dir):
217
+ # Clean up any residual .tmp files (spec Section 3.2)
218
+ cleaned = clean_tmp_files(rag_dir)
219
+ if cleaned:
220
+ print(f" Cleaned {len(cleaned)} residual temp file(s) in {rag_dir}")
221
+
222
+ # Initialize embedding client
223
+ embed_client = EmbeddingClient(config)
224
+ model_name, dimension = embed_client.get_model_info()
225
+
226
+ for source_path in files:
227
+ filename = source_path.name
228
+ ragjson_name = create_ragjson_filename(filename)
229
+ ragjson_path = ragjson_path_for(scan_dir, filename)
230
+
231
+ print(f" Processing: {filename}")
232
+
233
+ # Read existing or create new ragjson
234
+ data = read_ragjson(ragjson_path)
235
+ if data is None:
236
+ data = create_initial_ragjson(source_path)
237
+ write_ragjson(data, scan_dir, ragjson_name)
238
+ else:
239
+ # Reset to INITIAL if source changed
240
+ if data.processing.status not in (STATUS_INITIAL,):
241
+ update_status(data, STATUS_INITIAL)
242
+ populate_source_info(data, source_path)
243
+ write_ragjson(data, scan_dir, ragjson_name)
244
+
245
+ try:
246
+ # --- SLICING phase ---
247
+ update_status(data, STATUS_SLICING)
248
+ write_ragjson(data, scan_dir, ragjson_name)
249
+
250
+ # Parse document to markdown
251
+ print(f" Parsing with {parser_type}...")
252
+ md_text = parse_document(source_path, parser_type, config)
253
+
254
+ if not md_text.strip():
255
+ print(f" Warning: Empty content from {filename}, skipping")
256
+ update_status(data, STATUS_INITIAL)
257
+ write_ragjson(data, scan_dir, ragjson_name)
258
+ continue
259
+
260
+ # Split text into chunks
261
+ chunks = split_text(
262
+ md_text,
263
+ splitter_type=splitter_type,
264
+ chunk_size=config.splitter.chunk_size,
265
+ chunk_overlap=config.splitter.chunk_overlap,
266
+ )
267
+
268
+ if not chunks:
269
+ print(f" Warning: No chunks produced for {filename}, skipping")
270
+ update_status(data, STATUS_INITIAL)
271
+ write_ragjson(data, scan_dir, ragjson_name)
272
+ continue
273
+
274
+ # Store slices (without vectors yet)
275
+ data.slices = [
276
+ SliceData(index=i, content=chunk, vector_base64="")
277
+ for i, chunk in enumerate(chunks)
278
+ ]
279
+ update_status(data, STATUS_SLICED)
280
+ write_ragjson(data, scan_dir, ragjson_name)
281
+
282
+ # --- EMBEDDING phase ---
283
+ update_status(data, STATUS_EMBEDDING)
284
+ write_ragjson(data, scan_dir, ragjson_name)
285
+
286
+ # Generate embeddings
287
+ print(f" Embedding {len(chunks)} chunk(s)...")
288
+ vector_b64_list = embed_client.embed_and_encode(chunks)
289
+
290
+ # Attach vectors to slices
291
+ for i, vec_b64 in enumerate(vector_b64_list):
292
+ data.slices[i].vector_base64 = vec_b64
293
+
294
+ # Update embedding info
295
+ data.embedding.model_name = model_name
296
+ data.embedding.dimension = dimension
297
+
298
+ # Final write: EMBEDDED
299
+ update_status(data, STATUS_EMBEDDED)
300
+ write_ragjson(data, scan_dir, ragjson_name)
301
+ print(f" {green('Done')}: {len(chunks)} slices, {dimension}-dim vectors")
302
+
303
+ except Exception as e:
304
+ print(f" {red('Error')}: {filename}: {e}", file=sys.stderr)
305
+ # Reset to INITIAL so it can be retried
306
+ try:
307
+ update_status(data, STATUS_INITIAL)
308
+ write_ragjson(data, scan_dir, ragjson_name)
309
+ except Exception:
310
+ pass
311
+
312
+
313
+ # ============================================================
314
+ # rag clean [path] [-r]
315
+ # ============================================================
316
+
317
+ def cmd_clean(path: str, recursive: bool = False) -> None:
318
+ """Clean residual .tmp files from .rag/tmp/ directories.
319
+
320
+ Per spec Section 6.1: rag clean [path] [-r]
321
+ """
322
+ target = Path(path).resolve()
323
+ if not target.is_dir():
324
+ print(f"Error: Path is not a directory: {target}", file=sys.stderr)
325
+ sys.exit(1)
326
+
327
+ dirs_to_scan = _collect_dirs(target, recursive)
328
+ total_cleaned = 0
329
+
330
+ for scan_dir in dirs_to_scan:
331
+ rag_dir = scan_dir / ".rag"
332
+ if not rag_dir.exists():
333
+ continue
334
+
335
+ cleaned = clean_tmp_files(rag_dir)
336
+ if cleaned:
337
+ print(f" Cleaned {len(cleaned)} temp file(s) in {rag_dir / 'tmp'}")
338
+ total_cleaned += len(cleaned)
339
+
340
+ if total_cleaned == 0:
341
+ print("No residual temp files found.")
342
+ else:
343
+ print(f"Total: cleaned {total_cleaned} temp file(s).")
344
+
345
+
346
+ # ============================================================
347
+ # rag cleanup [path] [-r]
348
+ # ============================================================
349
+
350
+ def cmd_cleanup(path: str, recursive: bool = False) -> None:
351
+ """Physically delete .ragjson files with DELETED status.
352
+
353
+ Per spec Section 6.1: rag cleanup [path] [-r]
354
+ """
355
+ target = Path(path).resolve()
356
+ if not target.is_dir():
357
+ print(f"Error: Path is not a directory: {target}", file=sys.stderr)
358
+ sys.exit(1)
359
+
360
+ dirs_to_scan = _collect_dirs(target, recursive)
361
+ total_removed = 0
362
+
363
+ for scan_dir in dirs_to_scan:
364
+ rag_dir = scan_dir / ".rag"
365
+ if not rag_dir.exists():
366
+ continue
367
+
368
+ for rj_path in rag_dir.glob("*.ragjson"):
369
+ data = read_ragjson(rj_path)
370
+ if data and data.processing.status == STATUS_DELETED:
371
+ rj_path.unlink()
372
+ print(f" Removed: {rj_path}")
373
+ total_removed += 1
374
+
375
+ # Also check for orphaned ragjson (source file missing, not DELETED)
376
+ for rj_path in rag_dir.glob("*.ragjson"):
377
+ source_name = rj_path.name.removesuffix(".ragjson")
378
+ source_path = scan_dir / source_name
379
+ if not source_path.exists():
380
+ data = read_ragjson(rj_path)
381
+ if data and data.processing.status != STATUS_DELETED:
382
+ # Mark as DELETED first, then remove
383
+ update_status(data, STATUS_DELETED)
384
+ write_ragjson(data, scan_dir, rj_path.name)
385
+ rj_path.unlink()
386
+ print(f" Removed orphaned: {rj_path}")
387
+ total_removed += 1
388
+
389
+ if total_removed == 0:
390
+ print("No DELETED or orphaned ragjson files found.")
391
+ else:
392
+ print(f"Total: removed {total_removed} ragjson file(s).")
393
+
394
+
395
+ # ============================================================
396
+ # rag export <path> <output>
397
+ # ============================================================
398
+
399
+ def cmd_export(path: str, output: str) -> None:
400
+ """Export all .ragjson files into a tar.gz archive.
401
+
402
+ Per spec Section 6.1: rag export <path> <output>
403
+ """
404
+ target = Path(path).resolve()
405
+ if not target.is_dir():
406
+ print(f"Error: Path is not a directory: {target}", file=sys.stderr)
407
+ sys.exit(1)
408
+
409
+ output_path = Path(output).resolve()
410
+
411
+ # Collect all .ragjson files recursively
412
+ ragjson_files = list(target.rglob(".rag/*.ragjson"))
413
+
414
+ if not ragjson_files:
415
+ print("No .ragjson files found to export.")
416
+ return
417
+
418
+ # Create tar.gz archive preserving relative directory structure
419
+ with tarfile.open(output_path, "w:gz") as tar:
420
+ for rj_file in ragjson_files:
421
+ # Store path relative to the target directory
422
+ arcname = rj_file.relative_to(target)
423
+ tar.add(rj_file, arcname=arcname)
424
+
425
+ print(f"Exported {len(ragjson_files)} ragjson file(s) to: {output_path}")
426
+
427
+
428
+ # ============================================================
429
+ # Helper functions
430
+ # ============================================================
431
+
432
+ def _collect_dirs(base: Path, recursive: bool) -> list[Path]:
433
+ """Collect directories to scan, optionally recursive."""
434
+ dirs = [base]
435
+ if recursive:
436
+ for child in sorted(base.rglob("*")):
437
+ if child.is_dir() and child.name != ".rag":
438
+ dirs.append(child)
439
+ return dirs
440
+
441
+
442
+ def _list_supported_files(scan_dir: Path) -> list[Path]:
443
+ """List supported files directly in a directory (not recursive)."""
444
+ files = []
445
+ for child in sorted(scan_dir.iterdir()):
446
+ if child.is_file() and is_supported_file(child):
447
+ files.append(child)
448
+ return files
449
+
450
+
451
+ def _files_needing_update(scan_dir: Path) -> list[Path]:
452
+ """Find files in a directory that need processing.
453
+
454
+ A file needs update if:
455
+ - No corresponding .ragjson exists
456
+ - The .ragjson status is INITIAL (or stuck intermediate state)
457
+ - The source MD5 has changed
458
+ """
459
+ rag_dir = scan_dir / ".rag"
460
+ result = []
461
+
462
+ for f in _list_supported_files(scan_dir):
463
+ ragjson_path = ragjson_path_for(scan_dir, f.name)
464
+ data = read_ragjson(ragjson_path)
465
+
466
+ if data is None:
467
+ result.append(f)
468
+ elif data.processing.status == STATUS_EMBEDDED:
469
+ if is_source_changed(data, f):
470
+ result.append(f)
471
+ elif data.processing.status in (STATUS_SLICING, STATUS_EMBEDDING):
472
+ # Stuck intermediate state — needs recovery
473
+ result.append(f)
474
+ elif data.processing.status in (STATUS_INITIAL, STATUS_SLICED):
475
+ result.append(f)
476
+ # DELETED status: skip (will be handled by cleanup)
477
+
478
+ return result
ragjson/config.py ADDED
@@ -0,0 +1,113 @@
1
+ """Configuration loader for ~/.rag.config (JSON format)."""
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class EmbeddingModelConfig:
11
+ """Embedding model service configuration."""
12
+ base_url: str = ""
13
+ api_key: str = ""
14
+ model_name: str = "text-embedding-3-small"
15
+ dimension: int = 1536
16
+
17
+
18
+ @dataclass
19
+ class MinerUConfig:
20
+ """MinerU document parser service configuration."""
21
+ backend: str = "http-client"
22
+ server_url: str = ""
23
+ image_dir: str = "/tmp/mineru_output"
24
+ image_url_prefix: str = ""
25
+
26
+
27
+ @dataclass
28
+ class SplitterConfig:
29
+ """Text splitter configuration."""
30
+ chunk_size: int = 1000
31
+ chunk_overlap: int = 200
32
+
33
+
34
+ @dataclass
35
+ class RagConfig:
36
+ """Top-level configuration combining all sections."""
37
+ embedding_model: EmbeddingModelConfig = field(default_factory=EmbeddingModelConfig)
38
+ mineru: MinerUConfig = field(default_factory=MinerUConfig)
39
+ splitter: SplitterConfig = field(default_factory=SplitterConfig)
40
+
41
+
42
+ def get_config_path() -> Path:
43
+ """Return the path to the configuration file."""
44
+ return Path.home() / ".rag.config"
45
+
46
+
47
+ def load_config() -> RagConfig:
48
+ """Load and parse the configuration from ~/.rag.config.
49
+
50
+ Returns:
51
+ RagConfig instance with parsed values.
52
+
53
+ Raises:
54
+ SystemExit: If config file is missing or malformed.
55
+ """
56
+ config_path = get_config_path()
57
+
58
+ if not config_path.exists():
59
+ print(
60
+ f"Error: Configuration file not found: {config_path}\n"
61
+ f"Please create {config_path} with the required settings.",
62
+ file=sys.stderr,
63
+ )
64
+ sys.exit(1)
65
+
66
+ try:
67
+ raw = json.loads(config_path.read_text(encoding="utf-8"))
68
+ except json.JSONDecodeError as e:
69
+ print(f"Error: Invalid JSON in {config_path}: {e}", file=sys.stderr)
70
+ sys.exit(1)
71
+
72
+ config = RagConfig()
73
+
74
+ # Parse embedding_model section
75
+ if "embedding_model" in raw:
76
+ emb = raw["embedding_model"]
77
+ config.embedding_model = EmbeddingModelConfig(
78
+ base_url=emb.get("base_url", ""),
79
+ api_key=emb.get("api_key", ""),
80
+ model_name=emb.get("model_name", "text-embedding-3-small"),
81
+ dimension=emb.get("dimension", 1536),
82
+ )
83
+
84
+ # Parse MinerU section
85
+ if "mineru" in raw:
86
+ mineru = raw["mineru"]
87
+ config.mineru = MinerUConfig(
88
+ backend=mineru.get("backend", "http-client"),
89
+ server_url=mineru.get("server_url", ""),
90
+ image_dir=mineru.get("image_dir", "/tmp/mineru_output"),
91
+ image_url_prefix=mineru.get("image_url_prefix", ""),
92
+ )
93
+
94
+ # Parse splitter section
95
+ if "splitter" in raw:
96
+ splitter = raw["splitter"]
97
+ config.splitter = SplitterConfig(
98
+ chunk_size=splitter.get("chunk_size", 1000),
99
+ chunk_overlap=splitter.get("chunk_overlap", 200),
100
+ )
101
+
102
+ return config
103
+
104
+
105
+ def require_embedding_config(config: RagConfig) -> None:
106
+ """Validate that embedding model configuration is present."""
107
+ if not config.embedding_model.base_url or not config.embedding_model.api_key:
108
+ print(
109
+ "Error: Embedding model configuration missing.\n"
110
+ f"Please set 'embedding_model.base_url' and 'embedding_model.api_key' in {get_config_path()}",
111
+ file=sys.stderr,
112
+ )
113
+ sys.exit(1)