deeptutor-knowledge 1.7.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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Incrementally add documents to an existing knowledge base."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import asyncio
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, List, Optional
|
|
14
|
+
|
|
15
|
+
from deeptutor.knowledge.staging import content_hash, stage_source
|
|
16
|
+
from deeptutor.services.config import resolve_llm_runtime_config
|
|
17
|
+
from deeptutor.services.file_io import atomic_write_json
|
|
18
|
+
from deeptutor.services.rag.factory import (
|
|
19
|
+
DEFAULT_PROVIDER,
|
|
20
|
+
LIGHTRAG_PROVIDER,
|
|
21
|
+
has_ready_provider_index,
|
|
22
|
+
normalize_provider_name,
|
|
23
|
+
)
|
|
24
|
+
from deeptutor.services.rag.index_probe import provider_failure_summary
|
|
25
|
+
from deeptutor.services.rag.index_versioning import list_kb_versions
|
|
26
|
+
from deeptutor.services.rag.provider_binding import resolve_bound_provider
|
|
27
|
+
from deeptutor.services.rag.service import RAGService
|
|
28
|
+
from deeptutor.services.setup.data_volume import (
|
|
29
|
+
DataVolumePermissionError,
|
|
30
|
+
ensure_data_volume_writable,
|
|
31
|
+
format_data_volume_permission_error,
|
|
32
|
+
)
|
|
33
|
+
from deeptutor.utils.file_types import FileTypeRouter
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
DEFAULT_BASE_DIR = "./data/knowledge_bases"
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from deeptutor.knowledge.manager import KnowledgeBaseManager
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class DocumentIndexFailure:
|
|
45
|
+
"""One file that could not be added to the provider index."""
|
|
46
|
+
|
|
47
|
+
file_path: Path
|
|
48
|
+
error: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DocumentIndexResult:
|
|
53
|
+
"""Structured incremental-index result.
|
|
54
|
+
|
|
55
|
+
A task can no longer infer success from "no exception": every staged file is
|
|
56
|
+
explicitly accounted for as either processed or failed.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
processed_files: list[Path]
|
|
60
|
+
failures: list[DocumentIndexFailure]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def processed_count(self) -> int:
|
|
64
|
+
return len(self.processed_files)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def failed_count(self) -> int:
|
|
68
|
+
return len(self.failures)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_failures(self) -> bool:
|
|
72
|
+
return bool(self.failures)
|
|
73
|
+
|
|
74
|
+
def failure_summary(self, *, limit: int = 3) -> str:
|
|
75
|
+
if not self.failures:
|
|
76
|
+
return ""
|
|
77
|
+
shown = [f"{failure.file_path.name}: {failure.error}" for failure in self.failures[:limit]]
|
|
78
|
+
remaining = len(self.failures) - len(shown)
|
|
79
|
+
if remaining > 0:
|
|
80
|
+
shown.append(f"... and {remaining} more file(s)")
|
|
81
|
+
return "; ".join(shown)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class RawDocumentRemoval:
|
|
86
|
+
"""Outcome of removing one raw document from a knowledge base."""
|
|
87
|
+
|
|
88
|
+
rel_path: str
|
|
89
|
+
was_indexed: bool
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_metadata(metadata_file: Path) -> dict:
|
|
93
|
+
"""Load a KB's metadata.json, returning {} when absent or unreadable."""
|
|
94
|
+
if not metadata_file.exists():
|
|
95
|
+
return {}
|
|
96
|
+
try:
|
|
97
|
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
|
98
|
+
data = json.load(f)
|
|
99
|
+
except Exception:
|
|
100
|
+
return {}
|
|
101
|
+
return data if isinstance(data, dict) else {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write_metadata(metadata_file: Path, metadata: dict) -> None:
|
|
105
|
+
"""Persist a KB's metadata.json atomically (pretty-printed, non-ASCII preserved)."""
|
|
106
|
+
atomic_write_json(metadata_file, metadata)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _raw_hash_key(file_path: Path, raw_dir: Path) -> str:
|
|
110
|
+
"""Stable ``file_hashes`` key for a staged file.
|
|
111
|
+
|
|
112
|
+
The key is the POSIX path relative to ``raw/`` (so folder uploads keep
|
|
113
|
+
distinct entries), falling back to the basename for anything that resolves
|
|
114
|
+
outside ``raw/``. Indexing and removal MUST share this rule, otherwise a
|
|
115
|
+
removed file's hash record would leak and silently skip a later re-add.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
return file_path.resolve().relative_to(raw_dir.resolve()).as_posix()
|
|
119
|
+
except ValueError:
|
|
120
|
+
return file_path.name
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def hashes_for_indexed_files(file_paths: list[str], raw_dir: Path) -> dict[str, str]:
|
|
124
|
+
"""Hash source files already confirmed in an index, keyed by their raw path."""
|
|
125
|
+
return {
|
|
126
|
+
_raw_hash_key(Path(file_path), raw_dir): content_hash(Path(file_path))
|
|
127
|
+
for file_path in file_paths
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def remove_raw_document(kb_dir: Path, file_path: Path) -> RawDocumentRemoval:
|
|
132
|
+
"""Delete one staged raw file and drop its indexed-hash record.
|
|
133
|
+
|
|
134
|
+
Deliberately decoupled from :class:`DocumentAdder` (whose constructor
|
|
135
|
+
requires a ready provider index): a KB stuck in an *error* state often has
|
|
136
|
+
no index at all, yet its raw/ files must still be removable so the user can
|
|
137
|
+
drop an unparseable document instead of deleting and rebuilding the whole
|
|
138
|
+
KB. Vectors are not touched here — the providers index whole KBs, so any
|
|
139
|
+
vectors from an already-indexed file are cleared by a subsequent re-index,
|
|
140
|
+
which the returned ``was_indexed`` flag lets the caller surface.
|
|
141
|
+
|
|
142
|
+
``file_path`` must already be sandbox-resolved under the KB's raw/ dir.
|
|
143
|
+
"""
|
|
144
|
+
raw_dir = kb_dir / "raw"
|
|
145
|
+
hash_key = _raw_hash_key(file_path, raw_dir)
|
|
146
|
+
|
|
147
|
+
target = file_path.resolve()
|
|
148
|
+
if target.exists():
|
|
149
|
+
target.unlink()
|
|
150
|
+
|
|
151
|
+
metadata_file = kb_dir / "metadata.json"
|
|
152
|
+
metadata = _read_metadata(metadata_file)
|
|
153
|
+
hashes = metadata.get("file_hashes")
|
|
154
|
+
was_indexed = isinstance(hashes, dict) and hash_key in hashes
|
|
155
|
+
if was_indexed:
|
|
156
|
+
del hashes[hash_key]
|
|
157
|
+
_write_metadata(metadata_file, metadata)
|
|
158
|
+
|
|
159
|
+
return RawDocumentRemoval(rel_path=hash_key, was_indexed=was_indexed)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class DocumentAdder:
|
|
163
|
+
"""Stage and index new files through a KB's bound RAG provider."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
kb_name: str,
|
|
168
|
+
base_dir: str = DEFAULT_BASE_DIR,
|
|
169
|
+
api_key: str | None = None,
|
|
170
|
+
base_url: str | None = None,
|
|
171
|
+
progress_tracker=None,
|
|
172
|
+
rag_provider: str | None = None,
|
|
173
|
+
accepted_indexing_snapshot=None,
|
|
174
|
+
):
|
|
175
|
+
self.kb_name = kb_name
|
|
176
|
+
self.base_dir = Path(base_dir)
|
|
177
|
+
self.kb_dir = self.base_dir / kb_name
|
|
178
|
+
|
|
179
|
+
if not self.kb_dir.exists():
|
|
180
|
+
raise ValueError(f"Knowledge base does not exist: {kb_name}")
|
|
181
|
+
|
|
182
|
+
self.raw_dir = self.kb_dir / "raw"
|
|
183
|
+
self.llamaindex_storage_dir = self.kb_dir / "llamaindex_storage"
|
|
184
|
+
self.legacy_rag_storage_dir = self.kb_dir / "rag_storage"
|
|
185
|
+
self.metadata_file = self.kb_dir / "metadata.json"
|
|
186
|
+
|
|
187
|
+
# Fail on UID-mismatched / unwritable volumes before the "not initialized"
|
|
188
|
+
# check, which otherwise hides Unraid bind-mount permission errors.
|
|
189
|
+
ensure_data_volume_writable(self.kb_dir)
|
|
190
|
+
|
|
191
|
+
# Incremental adds must use the engine DeepTutor has bound to this KB. An
|
|
192
|
+
# explicit rag_provider (from the API, already matched against the KB)
|
|
193
|
+
# wins; otherwise use the shared binding resolver.
|
|
194
|
+
if rag_provider:
|
|
195
|
+
self.rag_provider = normalize_provider_name(rag_provider)
|
|
196
|
+
else:
|
|
197
|
+
self.rag_provider = self._provider_from_binding()
|
|
198
|
+
|
|
199
|
+
has_provider_index = has_ready_provider_index(self.kb_dir, self.rag_provider)
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
self.rag_provider == DEFAULT_PROVIDER
|
|
203
|
+
and not has_provider_index
|
|
204
|
+
and self.legacy_rag_storage_dir.exists()
|
|
205
|
+
):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"Knowledge base '{kb_name}' uses legacy index format and requires reindex before incremental add"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Both pipelines create their first index on add; existing broken versions
|
|
211
|
+
# still require reindex instead of being silently replaced (#1458).
|
|
212
|
+
versions = list_kb_versions(self.kb_dir)
|
|
213
|
+
allows_bootstrap = (
|
|
214
|
+
self.rag_provider
|
|
215
|
+
in {
|
|
216
|
+
DEFAULT_PROVIDER,
|
|
217
|
+
LIGHTRAG_PROVIDER,
|
|
218
|
+
}
|
|
219
|
+
and not versions
|
|
220
|
+
)
|
|
221
|
+
if not has_provider_index and not allows_bootstrap:
|
|
222
|
+
if versions:
|
|
223
|
+
summary = provider_failure_summary(self.kb_dir, self.rag_provider)
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"Knowledge base has no ready {self.rag_provider} index; reindex required: "
|
|
226
|
+
f"{summary or 'stored index version is incomplete'}"
|
|
227
|
+
)
|
|
228
|
+
raise ValueError(f"Knowledge base not initialized ({self.rag_provider}): {kb_name}")
|
|
229
|
+
|
|
230
|
+
self.accepted_indexing_snapshot = accepted_indexing_snapshot
|
|
231
|
+
if self.rag_provider == LIGHTRAG_PROVIDER and self.accepted_indexing_snapshot is None:
|
|
232
|
+
from deeptutor.services.rag.pipelines.lightrag.indexing_policy import (
|
|
233
|
+
bind_target,
|
|
234
|
+
resolve_write_snapshot,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
self.accepted_indexing_snapshot = bind_target(
|
|
238
|
+
resolve_write_snapshot(
|
|
239
|
+
self.kb_dir, base_dir=str(self.base_dir), kb_name=self.kb_name
|
|
240
|
+
),
|
|
241
|
+
self.kb_dir,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
self.api_key = api_key
|
|
245
|
+
self.base_url = base_url
|
|
246
|
+
self.progress_tracker = progress_tracker
|
|
247
|
+
|
|
248
|
+
self.raw_dir.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
|
|
250
|
+
def _provider_from_binding(self) -> str:
|
|
251
|
+
return resolve_bound_provider(self.base_dir, self.kb_name)
|
|
252
|
+
|
|
253
|
+
def _get_file_hash(self, file_path: Path) -> str:
|
|
254
|
+
return content_hash(file_path)
|
|
255
|
+
|
|
256
|
+
def get_ingested_hashes(self) -> dict[str, str]:
|
|
257
|
+
if self.metadata_file.exists():
|
|
258
|
+
try:
|
|
259
|
+
with open(self.metadata_file, "r", encoding="utf-8") as f:
|
|
260
|
+
data = json.load(f)
|
|
261
|
+
return data.get("file_hashes", {})
|
|
262
|
+
except Exception:
|
|
263
|
+
return {}
|
|
264
|
+
return {}
|
|
265
|
+
|
|
266
|
+
def add_documents(
|
|
267
|
+
self,
|
|
268
|
+
source_files: List[str],
|
|
269
|
+
allow_duplicates: bool = False,
|
|
270
|
+
source_root: str | Path | None = None,
|
|
271
|
+
) -> List[Path]:
|
|
272
|
+
"""Validate and stage files into raw/ before indexing.
|
|
273
|
+
|
|
274
|
+
``source_root``, when given, is the root a caller is syncing from
|
|
275
|
+
(e.g. a linked folder). Any source file found under it is staged at
|
|
276
|
+
the same path *relative to that root*, instead of being flattened to
|
|
277
|
+
its bare filename: without this, every externally-sourced subfolder
|
|
278
|
+
collapses into raw/'s top level and same-named files in different
|
|
279
|
+
subfolders collide.
|
|
280
|
+
"""
|
|
281
|
+
logger.info(f"Validating documents for '{self.kb_name}'...")
|
|
282
|
+
|
|
283
|
+
ingested_hashes = self.get_ingested_hashes()
|
|
284
|
+
files_to_process: list[Path] = []
|
|
285
|
+
resolved_source_root = Path(source_root).resolve() if source_root else None
|
|
286
|
+
|
|
287
|
+
for source in source_files:
|
|
288
|
+
source_path = Path(source)
|
|
289
|
+
if not source_path.exists() or not source_path.is_file():
|
|
290
|
+
logger.warning(f"Missing file: {source}")
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
current_hash = self._get_file_hash(source_path)
|
|
294
|
+
if current_hash in ingested_hashes.values() and not allow_duplicates:
|
|
295
|
+
logger.info(f"Skipped (content already indexed): {source_path.name}")
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
resolved_source = source_path.resolve()
|
|
299
|
+
|
|
300
|
+
# Files already saved under raw/ (e.g. by the upload route, possibly
|
|
301
|
+
# inside a folder) are indexed in place — never flattened to the
|
|
302
|
+
# basename — so the uploaded folder structure is preserved verbatim.
|
|
303
|
+
if resolved_source.is_relative_to(self.raw_dir.resolve()):
|
|
304
|
+
files_to_process.append(source_path)
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
if resolved_source_root and resolved_source.is_relative_to(resolved_source_root):
|
|
308
|
+
dest_path = self.raw_dir / resolved_source.relative_to(resolved_source_root)
|
|
309
|
+
else:
|
|
310
|
+
dest_path = self.raw_dir / source_path.name
|
|
311
|
+
|
|
312
|
+
staged_path, copied = stage_source(source_path, dest_path, source_hash=current_hash)
|
|
313
|
+
if copied:
|
|
314
|
+
logger.info(f"Staged to raw: {staged_path.relative_to(self.raw_dir).as_posix()}")
|
|
315
|
+
else:
|
|
316
|
+
logger.info(f"Recovering staged file: {source_path.name}")
|
|
317
|
+
files_to_process.append(staged_path)
|
|
318
|
+
|
|
319
|
+
return files_to_process
|
|
320
|
+
|
|
321
|
+
async def process_new_documents(self, new_files: List[Path]) -> DocumentIndexResult:
|
|
322
|
+
"""Index staged files via the KB's bound provider."""
|
|
323
|
+
if not new_files:
|
|
324
|
+
return DocumentIndexResult(processed_files=[], failures=[])
|
|
325
|
+
|
|
326
|
+
if self.rag_provider == LIGHTRAG_PROVIDER:
|
|
327
|
+
return await self._process_lightrag_batch(new_files)
|
|
328
|
+
|
|
329
|
+
rag_service = RAGService(kb_base_dir=str(self.base_dir), provider=self.rag_provider)
|
|
330
|
+
processed_files: list[Path] = []
|
|
331
|
+
failures: list[DocumentIndexFailure] = []
|
|
332
|
+
total_files = len(new_files)
|
|
333
|
+
|
|
334
|
+
for idx, doc_file in enumerate(new_files, 1):
|
|
335
|
+
try:
|
|
336
|
+
if self.progress_tracker is not None:
|
|
337
|
+
from deeptutor.knowledge.progress_tracker import ProgressStage
|
|
338
|
+
|
|
339
|
+
self.progress_tracker.update(
|
|
340
|
+
ProgressStage.PROCESSING_FILE,
|
|
341
|
+
message_key="Indexing {{name}}",
|
|
342
|
+
message_params={"name": doc_file.name},
|
|
343
|
+
current=len(processed_files),
|
|
344
|
+
total=total_files,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
success = await rag_service.add_documents(self.kb_name, [str(doc_file)])
|
|
348
|
+
if success:
|
|
349
|
+
processed_files.append(doc_file)
|
|
350
|
+
# Re-hashes the whole file, so it stays off the event loop:
|
|
351
|
+
# this runs inside a FastAPI BackgroundTasks entry, which is
|
|
352
|
+
# awaited on the loop and would otherwise stall unrelated
|
|
353
|
+
# requests once per indexed file (#777).
|
|
354
|
+
try:
|
|
355
|
+
await asyncio.to_thread(self._record_successful_hash, doc_file)
|
|
356
|
+
except Exception:
|
|
357
|
+
if self.rag_provider != LIGHTRAG_PROVIDER:
|
|
358
|
+
raise
|
|
359
|
+
logger.warning(
|
|
360
|
+
"LightRAG published '%s' before its duplicate-detection hash "
|
|
361
|
+
"was recorded",
|
|
362
|
+
doc_file.name,
|
|
363
|
+
exc_info=True,
|
|
364
|
+
)
|
|
365
|
+
logger.info(f"Processed: {doc_file.name}")
|
|
366
|
+
if self.progress_tracker is not None:
|
|
367
|
+
self.progress_tracker.update(
|
|
368
|
+
ProgressStage.PROCESSING_FILE,
|
|
369
|
+
message_key="Indexed {{name}}",
|
|
370
|
+
message_params={"name": doc_file.name},
|
|
371
|
+
current=len(processed_files),
|
|
372
|
+
total=total_files,
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
error = "Provider returned failure without details."
|
|
376
|
+
failures.append(DocumentIndexFailure(doc_file, error))
|
|
377
|
+
logger.error(f"Failed to index: {doc_file.name}")
|
|
378
|
+
except PermissionError as e:
|
|
379
|
+
logger.exception("Permission denied while indexing %s: %s", doc_file.name, e)
|
|
380
|
+
raise DataVolumePermissionError(
|
|
381
|
+
format_data_volume_permission_error(self.kb_dir, cause=e)
|
|
382
|
+
) from e
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.exception(f"Failed {doc_file.name}: {e}")
|
|
385
|
+
failures.append(DocumentIndexFailure(doc_file, str(e)))
|
|
386
|
+
|
|
387
|
+
return DocumentIndexResult(processed_files=processed_files, failures=failures)
|
|
388
|
+
|
|
389
|
+
async def _process_lightrag_batch(self, files: List[Path]) -> DocumentIndexResult:
|
|
390
|
+
"""Hold native write ownership across the entire accepted batch."""
|
|
391
|
+
from deeptutor.services.rag.pipelines.lightrag.pipeline import LightRagBatchError
|
|
392
|
+
|
|
393
|
+
def prepare_publication(root: Path) -> None:
|
|
394
|
+
from deeptutor.knowledge.progress_tracker import ProgressStage, ProgressTracker
|
|
395
|
+
|
|
396
|
+
tracker = self.progress_tracker or ProgressTracker(self.kb_name, self.base_dir)
|
|
397
|
+
tracker.update(
|
|
398
|
+
ProgressStage.COMPLETED,
|
|
399
|
+
message="Document indexing complete",
|
|
400
|
+
current=len(files),
|
|
401
|
+
total=len(files),
|
|
402
|
+
indexed_count=len(files),
|
|
403
|
+
index_changed=True,
|
|
404
|
+
index_action="upload",
|
|
405
|
+
publication_version=root.name,
|
|
406
|
+
)
|
|
407
|
+
tracker.verify_terminal(
|
|
408
|
+
current=len(files),
|
|
409
|
+
total=len(files),
|
|
410
|
+
indexed_count=len(files),
|
|
411
|
+
index_action="upload",
|
|
412
|
+
publication_version=root.name,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
service = RAGService(kb_base_dir=str(self.base_dir), provider=LIGHTRAG_PROVIDER)
|
|
416
|
+
try:
|
|
417
|
+
success = await service.add_documents(
|
|
418
|
+
self.kb_name,
|
|
419
|
+
[str(path) for path in files],
|
|
420
|
+
accepted_indexing_snapshot=self.accepted_indexing_snapshot,
|
|
421
|
+
before_publish=prepare_publication,
|
|
422
|
+
)
|
|
423
|
+
if not success:
|
|
424
|
+
raise RuntimeError("LightRAG returned failure without details.")
|
|
425
|
+
processed = files
|
|
426
|
+
failures = []
|
|
427
|
+
except LightRagBatchError as exc:
|
|
428
|
+
processed = [path for path in files if path.name in exc.outcome.processed]
|
|
429
|
+
failures = [
|
|
430
|
+
DocumentIndexFailure(path, str(exc)) for path in files if path not in processed
|
|
431
|
+
]
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
return DocumentIndexResult([], [DocumentIndexFailure(path, str(exc)) for path in files])
|
|
434
|
+
for path in processed:
|
|
435
|
+
try:
|
|
436
|
+
await asyncio.to_thread(self._record_successful_hash, path)
|
|
437
|
+
except Exception:
|
|
438
|
+
logger.warning(
|
|
439
|
+
"Indexed document hash could not be recorded: %s", path.name, exc_info=True
|
|
440
|
+
)
|
|
441
|
+
return DocumentIndexResult(processed, failures)
|
|
442
|
+
|
|
443
|
+
def _record_successful_hash(self, file_path: Path) -> None:
|
|
444
|
+
file_hash = self._get_file_hash(file_path)
|
|
445
|
+
metadata = _read_metadata(self.metadata_file)
|
|
446
|
+
hash_key = _raw_hash_key(file_path, self.raw_dir)
|
|
447
|
+
metadata.setdefault("file_hashes", {})[hash_key] = file_hash
|
|
448
|
+
_write_metadata(self.metadata_file, metadata)
|
|
449
|
+
|
|
450
|
+
def update_metadata(self, added_count: int) -> None:
|
|
451
|
+
"""Update metadata after incremental add."""
|
|
452
|
+
metadata: dict = {}
|
|
453
|
+
if self.metadata_file.exists():
|
|
454
|
+
try:
|
|
455
|
+
with open(self.metadata_file, "r", encoding="utf-8") as f:
|
|
456
|
+
metadata = json.load(f)
|
|
457
|
+
except Exception:
|
|
458
|
+
metadata = {}
|
|
459
|
+
|
|
460
|
+
metadata["rag_provider"] = self.rag_provider
|
|
461
|
+
metadata["needs_reindex"] = False
|
|
462
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
463
|
+
metadata["last_updated"] = timestamp
|
|
464
|
+
if added_count > 0:
|
|
465
|
+
metadata["last_indexed_at"] = timestamp
|
|
466
|
+
metadata["last_indexed_count"] = added_count
|
|
467
|
+
metadata["last_indexed_action"] = "upload"
|
|
468
|
+
|
|
469
|
+
history = metadata.get("update_history", [])
|
|
470
|
+
history.append(
|
|
471
|
+
{
|
|
472
|
+
"timestamp": metadata["last_updated"],
|
|
473
|
+
"action": "incremental_add",
|
|
474
|
+
"count": added_count,
|
|
475
|
+
"provider": self.rag_provider,
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
metadata["update_history"] = history
|
|
479
|
+
|
|
480
|
+
_write_metadata(self.metadata_file, metadata)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
async def _bootstrap_index_from_files(
|
|
484
|
+
kb_name: str,
|
|
485
|
+
source_files: list[str],
|
|
486
|
+
base_dir: str,
|
|
487
|
+
manager: "KnowledgeBaseManager",
|
|
488
|
+
) -> int:
|
|
489
|
+
"""Create a fresh index for an empty KB from the given source files.
|
|
490
|
+
|
|
491
|
+
Called when :class:`DocumentAdder` rejects an add because the KB has no
|
|
492
|
+
existing index (it was created empty, e.g. via the no-files fast path or
|
|
493
|
+
a web/GitHub source sync before any documents were indexed). Uses
|
|
494
|
+
:meth:`RAGService.initialize` to build the index in one batch, then
|
|
495
|
+
records file hashes so subsequent incremental adds can detect duplicates.
|
|
496
|
+
"""
|
|
497
|
+
rag_service = RAGService(kb_base_dir=base_dir)
|
|
498
|
+
kb_dir = Path(base_dir) / kb_name
|
|
499
|
+
raw_dir = kb_dir / "raw"
|
|
500
|
+
metadata_file = kb_dir / "metadata.json"
|
|
501
|
+
|
|
502
|
+
requested = {str(Path(path).resolve()): str(Path(path)) for path in source_files}
|
|
503
|
+
confirmed: set[str] = set()
|
|
504
|
+
|
|
505
|
+
def on_indexed_file(paths: list[str]) -> None:
|
|
506
|
+
for path in paths:
|
|
507
|
+
canonical = str(Path(path).resolve())
|
|
508
|
+
if canonical in requested:
|
|
509
|
+
confirmed.add(canonical)
|
|
510
|
+
|
|
511
|
+
success = await rag_service.initialize(
|
|
512
|
+
kb_name=kb_name,
|
|
513
|
+
file_paths=source_files,
|
|
514
|
+
indexed_file_callback=on_indexed_file,
|
|
515
|
+
)
|
|
516
|
+
if not success:
|
|
517
|
+
raise RuntimeError(
|
|
518
|
+
f"Failed to initialize index for KB '{kb_name}' from {len(source_files)} file(s)"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
indexed_files = [requested[path] for path in sorted(confirmed)]
|
|
522
|
+
indexed = len(indexed_files)
|
|
523
|
+
provider = rag_service._resolve_provider(kb_name)
|
|
524
|
+
try:
|
|
525
|
+
# Record hashes so future syncs detect unchanged files.
|
|
526
|
+
metadata = _read_metadata(metadata_file)
|
|
527
|
+
metadata.setdefault("file_hashes", {}).update(
|
|
528
|
+
hashes_for_indexed_files(indexed_files, raw_dir)
|
|
529
|
+
)
|
|
530
|
+
metadata["rag_provider"] = provider
|
|
531
|
+
metadata["needs_reindex"] = False
|
|
532
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
533
|
+
metadata["last_updated"] = ts
|
|
534
|
+
metadata["last_indexed_at"] = ts
|
|
535
|
+
metadata["last_indexed_count"] = indexed
|
|
536
|
+
metadata["last_indexed_action"] = "create"
|
|
537
|
+
_write_metadata(metadata_file, metadata)
|
|
538
|
+
|
|
539
|
+
manager.update_kb_status(
|
|
540
|
+
name=kb_name,
|
|
541
|
+
status="ready",
|
|
542
|
+
progress={
|
|
543
|
+
"stage": "completed",
|
|
544
|
+
"message": f"Initialized index with {indexed} file(s).",
|
|
545
|
+
"percent": 100,
|
|
546
|
+
"current": indexed,
|
|
547
|
+
"total": max(indexed, 1),
|
|
548
|
+
"file_name": "",
|
|
549
|
+
"error": None,
|
|
550
|
+
"timestamp": datetime.now().isoformat(),
|
|
551
|
+
"indexed_count": indexed,
|
|
552
|
+
"index_changed": True,
|
|
553
|
+
"index_action": "create",
|
|
554
|
+
},
|
|
555
|
+
)
|
|
556
|
+
except Exception:
|
|
557
|
+
if provider != LIGHTRAG_PROVIDER:
|
|
558
|
+
raise
|
|
559
|
+
logger.warning(
|
|
560
|
+
"LightRAG bootstrap for '%s' published before bookkeeping failed",
|
|
561
|
+
kb_name,
|
|
562
|
+
exc_info=True,
|
|
563
|
+
)
|
|
564
|
+
logger.info("Bootstrapped index for empty KB '%s' with %d file(s)", kb_name, indexed)
|
|
565
|
+
return indexed
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
async def add_documents(
|
|
569
|
+
kb_name: str,
|
|
570
|
+
source_files: list[str],
|
|
571
|
+
base_dir: str = DEFAULT_BASE_DIR,
|
|
572
|
+
api_key: Optional[str] = None,
|
|
573
|
+
base_url: Optional[str] = None,
|
|
574
|
+
allow_duplicates: bool = False,
|
|
575
|
+
accepted_indexing_snapshot=None,
|
|
576
|
+
) -> int:
|
|
577
|
+
"""Convenience function used by CLI wrappers."""
|
|
578
|
+
from deeptutor.knowledge.manager import KnowledgeBaseManager
|
|
579
|
+
|
|
580
|
+
manager = KnowledgeBaseManager(base_dir=base_dir)
|
|
581
|
+
published_count = 0
|
|
582
|
+
try:
|
|
583
|
+
manager.update_kb_status(
|
|
584
|
+
name=kb_name,
|
|
585
|
+
status="processing",
|
|
586
|
+
progress={
|
|
587
|
+
"stage": "processing_documents",
|
|
588
|
+
"message": "Processing uploaded documents...",
|
|
589
|
+
"percent": 0,
|
|
590
|
+
"current": 0,
|
|
591
|
+
"total": max(len(source_files), 1),
|
|
592
|
+
"file_name": "",
|
|
593
|
+
"error": None,
|
|
594
|
+
"timestamp": datetime.now().isoformat(),
|
|
595
|
+
},
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
adder = DocumentAdder(
|
|
600
|
+
kb_name=kb_name,
|
|
601
|
+
base_dir=base_dir,
|
|
602
|
+
api_key=api_key,
|
|
603
|
+
base_url=base_url,
|
|
604
|
+
accepted_indexing_snapshot=accepted_indexing_snapshot,
|
|
605
|
+
)
|
|
606
|
+
except ValueError as exc:
|
|
607
|
+
if "not initialized" not in str(exc).lower() or not source_files:
|
|
608
|
+
raise
|
|
609
|
+
# Empty KB (created without initial documents): bootstrap the
|
|
610
|
+
# index from these files instead of erroring.
|
|
611
|
+
return await _bootstrap_index_from_files(
|
|
612
|
+
kb_name=kb_name,
|
|
613
|
+
source_files=source_files,
|
|
614
|
+
base_dir=base_dir,
|
|
615
|
+
manager=manager,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
new_files = adder.add_documents(source_files, allow_duplicates=allow_duplicates)
|
|
619
|
+
if not new_files:
|
|
620
|
+
manager.update_kb_status(
|
|
621
|
+
name=kb_name,
|
|
622
|
+
status="ready",
|
|
623
|
+
progress={
|
|
624
|
+
"stage": "completed",
|
|
625
|
+
"message": "No new unique documents to process.",
|
|
626
|
+
"percent": 100,
|
|
627
|
+
"current": 1,
|
|
628
|
+
"total": 1,
|
|
629
|
+
"file_name": "",
|
|
630
|
+
"error": None,
|
|
631
|
+
"timestamp": datetime.now().isoformat(),
|
|
632
|
+
},
|
|
633
|
+
)
|
|
634
|
+
return 0
|
|
635
|
+
result = await adder.process_new_documents(new_files)
|
|
636
|
+
if result.has_failures:
|
|
637
|
+
raise RuntimeError(
|
|
638
|
+
f"Failed to index {result.failed_count}/{len(new_files)} file(s): "
|
|
639
|
+
f"{result.failure_summary()}"
|
|
640
|
+
)
|
|
641
|
+
if adder.rag_provider == LIGHTRAG_PROVIDER:
|
|
642
|
+
published_count = result.processed_count
|
|
643
|
+
adder.update_metadata(result.processed_count)
|
|
644
|
+
|
|
645
|
+
manager.update_kb_status(
|
|
646
|
+
name=kb_name,
|
|
647
|
+
status="ready",
|
|
648
|
+
progress={
|
|
649
|
+
"stage": "completed",
|
|
650
|
+
"message": f"Successfully processed {result.processed_count} files!",
|
|
651
|
+
"percent": 100,
|
|
652
|
+
"current": result.processed_count,
|
|
653
|
+
"total": max(len(new_files), 1),
|
|
654
|
+
"file_name": "",
|
|
655
|
+
"error": None,
|
|
656
|
+
"timestamp": datetime.now().isoformat(),
|
|
657
|
+
"indexed_count": result.processed_count,
|
|
658
|
+
"index_changed": result.processed_count > 0,
|
|
659
|
+
"index_action": "upload",
|
|
660
|
+
},
|
|
661
|
+
)
|
|
662
|
+
return result.processed_count
|
|
663
|
+
except Exception as exc:
|
|
664
|
+
if published_count:
|
|
665
|
+
logger.warning(
|
|
666
|
+
"LightRAG append for '%s' published before bookkeeping failed",
|
|
667
|
+
kb_name,
|
|
668
|
+
exc_info=True,
|
|
669
|
+
)
|
|
670
|
+
return published_count
|
|
671
|
+
manager.update_kb_status(
|
|
672
|
+
name=kb_name,
|
|
673
|
+
status="error",
|
|
674
|
+
progress={
|
|
675
|
+
"stage": "error",
|
|
676
|
+
"message": "Document upload failed",
|
|
677
|
+
"percent": 0,
|
|
678
|
+
"current": 0,
|
|
679
|
+
"total": max(len(source_files), 1),
|
|
680
|
+
"file_name": "",
|
|
681
|
+
"error": str(exc),
|
|
682
|
+
"timestamp": datetime.now().isoformat(),
|
|
683
|
+
},
|
|
684
|
+
)
|
|
685
|
+
raise
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
async def main() -> None:
|
|
689
|
+
try:
|
|
690
|
+
llm_config = resolve_llm_runtime_config()
|
|
691
|
+
default_api_key = llm_config.api_key
|
|
692
|
+
default_base_url = llm_config.effective_url
|
|
693
|
+
except Exception:
|
|
694
|
+
default_api_key = ""
|
|
695
|
+
default_base_url = ""
|
|
696
|
+
|
|
697
|
+
parser = argparse.ArgumentParser(description="Incrementally add documents to a KB")
|
|
698
|
+
parser.add_argument("kb_name", help="KB Name")
|
|
699
|
+
parser.add_argument("--docs", nargs="+", help="Files")
|
|
700
|
+
parser.add_argument("--docs-dir", help="Directory")
|
|
701
|
+
parser.add_argument("--base-dir", default=DEFAULT_BASE_DIR)
|
|
702
|
+
parser.add_argument("--api-key", default=default_api_key)
|
|
703
|
+
parser.add_argument("--base-url", default=default_base_url)
|
|
704
|
+
parser.add_argument("--allow-duplicates", action="store_true")
|
|
705
|
+
|
|
706
|
+
args = parser.parse_args()
|
|
707
|
+
doc_files: list[str] = []
|
|
708
|
+
if args.docs:
|
|
709
|
+
doc_files.extend(args.docs)
|
|
710
|
+
if args.docs_dir:
|
|
711
|
+
p = Path(args.docs_dir)
|
|
712
|
+
doc_files.extend(str(f) for f in FileTypeRouter.collect_supported_files(p))
|
|
713
|
+
|
|
714
|
+
if not doc_files:
|
|
715
|
+
logger.error("No documents provided.")
|
|
716
|
+
return
|
|
717
|
+
|
|
718
|
+
processed_count = await add_documents(
|
|
719
|
+
kb_name=args.kb_name,
|
|
720
|
+
source_files=doc_files,
|
|
721
|
+
base_dir=args.base_dir,
|
|
722
|
+
api_key=args.api_key,
|
|
723
|
+
base_url=args.base_url,
|
|
724
|
+
allow_duplicates=args.allow_duplicates,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
if processed_count:
|
|
728
|
+
logger.info(f"Done! Successfully added {processed_count} documents.")
|
|
729
|
+
else:
|
|
730
|
+
logger.info("No new unique documents to add.")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
if __name__ == "__main__":
|
|
734
|
+
asyncio.run(main())
|