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/__init__.py +4 -0
- ragjson/__main__.py +5 -0
- ragjson/atomic_ops.py +118 -0
- ragjson/cli.py +178 -0
- ragjson/commands.py +478 -0
- ragjson/config.py +113 -0
- ragjson/doc_parser.py +171 -0
- ragjson/embedding.py +76 -0
- ragjson/ragjson_file.py +262 -0
- ragjson/splitter.py +51 -0
- ragjson/utils.py +63 -0
- ragjson-0.1.0.dist-info/METADATA +259 -0
- ragjson-0.1.0.dist-info/RECORD +15 -0
- ragjson-0.1.0.dist-info/WHEEL +4 -0
- ragjson-0.1.0.dist-info/entry_points.txt +2 -0
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)
|