ltcai 9.6.0 → 9.8.0
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.
- package/README.md +98 -306
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +91 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/PERFORMANCE.md +106 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/kg_schema.py +11 -1
- package/knowledge_graph.py +12 -2
- package/knowledge_graph_api.py +11 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +583 -0
- package/lattice_brain/graph/retrieval.py +301 -8
- package/lattice_brain/graph/retrieval_vector.py +145 -0
- package/lattice_brain/ingestion.py +757 -16
- package/lattice_brain/quality.py +44 -9
- package/lattice_brain/runtime/multi_agent.py +38 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +39 -2
- package/latticeai/api/change_proposals.py +32 -3
- package/latticeai/api/chat.py +17 -0
- package/latticeai/api/chat_helpers.py +48 -0
- package/latticeai/api/chat_stream.py +9 -1
- package/latticeai/api/local_files.py +120 -2
- package/latticeai/api/review_queue.py +66 -2
- package/latticeai/app_factory.py +3 -0
- package/latticeai/core/agent.py +193 -135
- package/latticeai/core/agent_eval.py +278 -4
- package/latticeai/core/legacy_compatibility.py +15 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/router_registration.py +2 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +151 -14
- package/latticeai/services/brain_intelligence.py +169 -1
- package/latticeai/services/change_proposals.py +56 -4
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +43 -2
- package/llm_router.py +10 -1
- package/local_knowledge_api.py +10 -1
- package/ltcai_cli.py +11 -1
- package/mcp_registry.py +10 -1
- package/p_reinforce.py +10 -1
- package/package.json +1 -1
- package/scripts/brain_quality_eval.py +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/profile_kg.py +360 -0
- package/setup_wizard.py +10 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-BkOEmwBi.js → Act-Dd3z8AzF.js} +2 -1
- package/static/app/assets/Brain-BMkgdWnI.js +321 -0
- package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
- package/static/app/assets/Library-Yreq-KW5.js +1 -0
- package/static/app/assets/System-CXNmmtEo.js +1 -0
- package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
- package/static/app/assets/index-CndfILiF.js +18 -0
- package/static/app/assets/primitives-DxsIXb6G.js +1 -0
- package/static/app/assets/textarea-DH7ne8VI.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Brain-C9ITUsQ_.js +0 -321
- package/static/app/assets/Capture-C-ppTeud.js +0 -1
- package/static/app/assets/Library-CGQbgWTu.js +0 -1
- package/static/app/assets/System-djmj0n2_.js +0 -1
- package/static/app/assets/index-AF0-4XVv.js +0 -18
- package/static/app/assets/primitives-jbb2qv4Q.js +0 -1
- package/static/app/assets/textarea-CFoo0OxJ.js +0 -1
|
@@ -15,14 +15,37 @@ ingestion exactly as they do on tool calls. The heavy graph construction lives i
|
|
|
15
15
|
:class:`knowledge_graph.KnowledgeGraphStore` (``ingest_document`` for files,
|
|
16
16
|
``ingest_source`` for text/web), which this module composes rather than
|
|
17
17
|
re-implements.
|
|
18
|
+
|
|
19
|
+
Web ingestion seam
|
|
20
|
+
------------------
|
|
21
|
+
The graph layer never fetches or parses the web. Fetching, rendering,
|
|
22
|
+
readability extraction, and parse quality are the responsibility of the
|
|
23
|
+
*upstream* capture surfaces (browser extension, tools layer, MCP servers):
|
|
24
|
+
they hand this module already-extracted text. :meth:`IngestionPipeline.
|
|
25
|
+
ingest_web_page` is the convenience wrapper for that hand-off — it normalizes
|
|
26
|
+
``(url, extracted_text)`` into an ``IngestionItem(source_type="web_url")`` and
|
|
27
|
+
routes it through the exact same :meth:`IngestionPipeline.ingest` door as every
|
|
28
|
+
other source. If the extracted text is bad, fix the extractor upstream; the
|
|
29
|
+
pipeline will not attempt network access or HTML parsing.
|
|
30
|
+
|
|
31
|
+
Folder ingestion (:meth:`IngestionPipeline.ingest_folder`) walks a local
|
|
32
|
+
directory, honors a gitignore-like ``.latticeignore`` file at the root
|
|
33
|
+
(blank lines, ``#`` comments, ``fnmatch`` glob patterns, ``dir/`` suffix for
|
|
34
|
+
directories), always skips common noise (``.git``, ``node_modules``,
|
|
35
|
+
``__pycache__``, virtualenvs, ``dist``, hidden entries by default), applies
|
|
36
|
+
size/extension filters, and either ingests inline or schedules through the
|
|
37
|
+
existing :class:`BackgroundIngestionQueue`.
|
|
18
38
|
"""
|
|
19
39
|
|
|
20
40
|
from __future__ import annotations
|
|
21
41
|
|
|
42
|
+
import fnmatch
|
|
22
43
|
import hashlib
|
|
44
|
+
import os
|
|
45
|
+
import threading
|
|
23
46
|
from dataclasses import dataclass, field
|
|
24
47
|
from pathlib import Path
|
|
25
|
-
from typing import Any, Dict, List, Optional
|
|
48
|
+
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
|
26
49
|
|
|
27
50
|
from .runtime.hooks import dispatch_tool
|
|
28
51
|
from .utils import utc_now_iso
|
|
@@ -45,18 +68,278 @@ _MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "works
|
|
|
45
68
|
|
|
46
69
|
DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
|
|
47
70
|
|
|
71
|
+
# ── Folder ingestion (ingest_folder) filters ─────────────────────────────────
|
|
72
|
+
# Directories that are always pruned regardless of .latticeignore.
|
|
73
|
+
FOLDER_DEFAULT_SKIP_DIRS = frozenset(
|
|
74
|
+
{
|
|
75
|
+
".git",
|
|
76
|
+
"node_modules",
|
|
77
|
+
"__pycache__",
|
|
78
|
+
".venv",
|
|
79
|
+
"venv",
|
|
80
|
+
"env",
|
|
81
|
+
".pytest_cache",
|
|
82
|
+
".mypy_cache",
|
|
83
|
+
".ruff_cache",
|
|
84
|
+
"dist",
|
|
85
|
+
"build",
|
|
86
|
+
".next",
|
|
87
|
+
"target",
|
|
88
|
+
".cache",
|
|
89
|
+
".idea",
|
|
90
|
+
".vscode",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
# Extension filter matching FILE_SOURCE_TYPES conventions: text/markdown/code
|
|
94
|
+
# are read inline (extracted content → chunks); .pdf routes as source_type
|
|
95
|
+
# "pdf" through ingest_document (content extraction is upstream's concern).
|
|
96
|
+
FOLDER_TEXT_EXTENSIONS = frozenset(
|
|
97
|
+
{".txt", ".md", ".markdown", ".rst", ".csv", ".json", ".yaml", ".yml", ".toml", ".ini"}
|
|
98
|
+
)
|
|
99
|
+
FOLDER_CODE_EXTENSIONS = frozenset(
|
|
100
|
+
{
|
|
101
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".go", ".rs",
|
|
102
|
+
".java", ".c", ".h", ".cpp", ".hpp", ".rb", ".php", ".swift", ".kt",
|
|
103
|
+
".sh", ".sql",
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
FOLDER_DOCUMENT_EXTENSIONS = frozenset({".pdf"})
|
|
107
|
+
DEFAULT_FOLDER_EXTENSIONS = (
|
|
108
|
+
FOLDER_TEXT_EXTENSIONS | FOLDER_CODE_EXTENSIONS | FOLDER_DOCUMENT_EXTENSIONS
|
|
109
|
+
)
|
|
110
|
+
DEFAULT_MAX_FILE_BYTES = 4_000_000 # matches the local-index text/code budget
|
|
111
|
+
LATTICEIGNORE_FILENAME = ".latticeignore"
|
|
112
|
+
# Opt-out escape hatch for the post-ingest incremental vector sync.
|
|
113
|
+
AUTO_VECTOR_INDEX_ENV = "LATTICEAI_AUTO_VECTOR_INDEX"
|
|
114
|
+
|
|
115
|
+
# ── Extraction quality heuristics (v9.8.0 A1) ────────────────────────────────
|
|
116
|
+
# Pure heuristics over the extracted text — no model calls, no network. The
|
|
117
|
+
# score is *advisory*: it never blocks an ingest, it only annotates the result
|
|
118
|
+
# so capture surfaces (browser, folder scan) can surface low-quality warnings.
|
|
119
|
+
QUALITY_HIGH_THRESHOLD = 0.7
|
|
120
|
+
QUALITY_LOW_THRESHOLD = 0.4
|
|
121
|
+
QUALITY_LOW_WARNING = "추출 품질이 낮습니다 — 원문 확인을 권장합니다."
|
|
122
|
+
_WEB_SOURCE_TYPES = frozenset({"web_url", "browser_tab"})
|
|
123
|
+
# Standalone short lines that smell like leftover site chrome (nav/menu/footer).
|
|
124
|
+
_BOILERPLATE_LINE_MARKERS = frozenset(
|
|
125
|
+
{
|
|
126
|
+
"home", "menu", "nav", "navigation", "login", "log in", "sign in",
|
|
127
|
+
"sign up", "register", "subscribe", "search", "about", "about us",
|
|
128
|
+
"contact", "contact us", "privacy policy", "terms of service",
|
|
129
|
+
"cookie policy", "accept cookies", "accept all cookies", "share",
|
|
130
|
+
"skip to content", "copyright", "all rights reserved", "sitemap",
|
|
131
|
+
"back to top", "footer", "read more", "next", "previous",
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _quality_level(score: float) -> str:
|
|
137
|
+
if score >= QUALITY_HIGH_THRESHOLD:
|
|
138
|
+
return "high"
|
|
139
|
+
if score >= QUALITY_LOW_THRESHOLD:
|
|
140
|
+
return "medium"
|
|
141
|
+
return "low"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def assess_extraction_quality(
|
|
145
|
+
text: Optional[str],
|
|
146
|
+
*,
|
|
147
|
+
source_type: Optional[str] = None,
|
|
148
|
+
upstream_confidence: Optional[Any] = None,
|
|
149
|
+
) -> Dict[str, Any]:
|
|
150
|
+
"""Score extracted text 0..1 with reasons (pure heuristic, deterministic).
|
|
151
|
+
|
|
152
|
+
Signals: text length, whitespace ratio, character/word diversity
|
|
153
|
+
(repetition), sentence structure, and — for web sources — leftover
|
|
154
|
+
nav/menu boilerplate. When the upstream extractor supplies its own
|
|
155
|
+
confidence (``upstream_confidence``), that value wins verbatim: the
|
|
156
|
+
extractor saw the raw document, this function only sees its output.
|
|
157
|
+
"""
|
|
158
|
+
if upstream_confidence is not None:
|
|
159
|
+
try:
|
|
160
|
+
score = max(0.0, min(1.0, float(upstream_confidence)))
|
|
161
|
+
except (TypeError, ValueError):
|
|
162
|
+
score = None
|
|
163
|
+
if score is not None:
|
|
164
|
+
return {
|
|
165
|
+
"score": round(score, 4),
|
|
166
|
+
"level": _quality_level(score),
|
|
167
|
+
"reasons": ["upstream_confidence"],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
raw = str(text or "")
|
|
171
|
+
stripped = raw.strip()
|
|
172
|
+
if not stripped:
|
|
173
|
+
return {"score": 0.0, "level": "low", "reasons": ["empty_text"]}
|
|
174
|
+
|
|
175
|
+
reasons: List[str] = []
|
|
176
|
+
length = len(stripped)
|
|
177
|
+
sample = stripped[:4000]
|
|
178
|
+
lines = [ln.strip() for ln in stripped.splitlines() if ln.strip()]
|
|
179
|
+
words = stripped.split()
|
|
180
|
+
|
|
181
|
+
# 1) Length — very short extractions rarely carry recall value.
|
|
182
|
+
if length < 40:
|
|
183
|
+
length_factor = 0.35
|
|
184
|
+
reasons.append("very_short_text")
|
|
185
|
+
elif length < 120:
|
|
186
|
+
length_factor = 0.6
|
|
187
|
+
reasons.append("short_text")
|
|
188
|
+
elif length < 300:
|
|
189
|
+
length_factor = 0.85
|
|
190
|
+
else:
|
|
191
|
+
length_factor = 1.0
|
|
192
|
+
|
|
193
|
+
# 2) Sentence structure — prose has sentence-ending punctuation.
|
|
194
|
+
sentence_marks = sum(sample.count(mark) for mark in (".", "!", "?", "…", "。", "!", "?"))
|
|
195
|
+
if sentence_marks > 0:
|
|
196
|
+
structure_factor = 1.0
|
|
197
|
+
elif length < 200:
|
|
198
|
+
structure_factor = 0.75 # titles/snippets legitimately lack periods
|
|
199
|
+
else:
|
|
200
|
+
structure_factor = 0.45
|
|
201
|
+
reasons.append("no_sentence_structure")
|
|
202
|
+
|
|
203
|
+
# 3) Diversity — repeated characters/lines/words indicate extraction junk.
|
|
204
|
+
diversity_factor = 1.0
|
|
205
|
+
distinct_chars = len(set(sample.lower()))
|
|
206
|
+
if distinct_chars < 10:
|
|
207
|
+
diversity_factor *= 0.2
|
|
208
|
+
reasons.append("low_character_diversity")
|
|
209
|
+
elif distinct_chars < 20:
|
|
210
|
+
diversity_factor *= 0.7
|
|
211
|
+
if len(lines) >= 6:
|
|
212
|
+
top_count = max(lines.count(ln) for ln in set(lines))
|
|
213
|
+
if top_count >= max(3, len(lines) // 4):
|
|
214
|
+
diversity_factor *= 0.5
|
|
215
|
+
reasons.append("repetitive_lines")
|
|
216
|
+
if len(words) >= 30 and (len(set(w.lower() for w in words)) / len(words)) < 0.25:
|
|
217
|
+
diversity_factor *= 0.5
|
|
218
|
+
reasons.append("repetitive_words")
|
|
219
|
+
|
|
220
|
+
# 4) Cleanliness — whitespace floods, fragmented lines, site chrome.
|
|
221
|
+
cleanliness_factor = 1.0
|
|
222
|
+
whitespace_ratio = sum(1 for ch in raw if ch.isspace()) / max(1, len(raw))
|
|
223
|
+
if whitespace_ratio > 0.45:
|
|
224
|
+
cleanliness_factor *= 0.6
|
|
225
|
+
reasons.append("high_whitespace_ratio")
|
|
226
|
+
if len(lines) >= 8:
|
|
227
|
+
short_lines = sum(1 for ln in lines if len(ln.split()) <= 3)
|
|
228
|
+
if short_lines / len(lines) > 0.6:
|
|
229
|
+
cleanliness_factor *= 0.6
|
|
230
|
+
reasons.append("fragmented_lines")
|
|
231
|
+
boilerplate_hits = sum(
|
|
232
|
+
1 for ln in lines if ln.lower().strip(" .:>|•·-–—*") in _BOILERPLATE_LINE_MARKERS
|
|
233
|
+
)
|
|
234
|
+
if lines and boilerplate_hits >= 3 and (boilerplate_hits / len(lines)) > 0.2:
|
|
235
|
+
cleanliness_factor *= 0.35
|
|
236
|
+
if str(source_type or "").lower() in _WEB_SOURCE_TYPES:
|
|
237
|
+
reasons.append("nav_menu_remnants")
|
|
238
|
+
else:
|
|
239
|
+
reasons.append("boilerplate_markers")
|
|
240
|
+
|
|
241
|
+
score = length_factor * structure_factor * diversity_factor * cleanliness_factor
|
|
242
|
+
score = max(0.0, min(1.0, score))
|
|
243
|
+
if not reasons:
|
|
244
|
+
reasons.append("clean_extraction")
|
|
245
|
+
return {"score": round(score, 4), "level": _quality_level(score), "reasons": reasons}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _load_latticeignore(root: Path) -> List[str]:
|
|
249
|
+
"""Parse ``root/.latticeignore`` → glob patterns (gitignore-like subset)."""
|
|
250
|
+
ignore_file = root / LATTICEIGNORE_FILENAME
|
|
251
|
+
patterns: List[str] = []
|
|
252
|
+
if not ignore_file.is_file():
|
|
253
|
+
return patterns
|
|
254
|
+
try:
|
|
255
|
+
lines = ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
256
|
+
except OSError:
|
|
257
|
+
return patterns
|
|
258
|
+
for raw in lines:
|
|
259
|
+
line = raw.strip()
|
|
260
|
+
if not line or line.startswith("#"):
|
|
261
|
+
continue
|
|
262
|
+
patterns.append(line)
|
|
263
|
+
return patterns
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _matches_ignore(
|
|
267
|
+
rel_posix: str, name: str, *, is_dir: bool, patterns: Iterable[str]
|
|
268
|
+
) -> bool:
|
|
269
|
+
"""fnmatch-based .latticeignore matching.
|
|
270
|
+
|
|
271
|
+
- ``pattern/`` matches directories only (files under it never appear
|
|
272
|
+
because ignored directories are pruned during the walk).
|
|
273
|
+
- Patterns match against both the root-relative posix path and the
|
|
274
|
+
basename, so ``*.log`` and ``docs/draft.md`` both behave as expected.
|
|
275
|
+
"""
|
|
276
|
+
for raw in patterns:
|
|
277
|
+
pattern = raw
|
|
278
|
+
if pattern.endswith("/"):
|
|
279
|
+
if not is_dir:
|
|
280
|
+
continue
|
|
281
|
+
pattern = pattern.rstrip("/")
|
|
282
|
+
pattern = pattern.lstrip("/")
|
|
283
|
+
if not pattern:
|
|
284
|
+
continue
|
|
285
|
+
if fnmatch.fnmatch(rel_posix, pattern) or fnmatch.fnmatch(name, pattern):
|
|
286
|
+
return True
|
|
287
|
+
return False
|
|
288
|
+
|
|
48
289
|
|
|
49
290
|
# --- Large candidate 1 slice: incremental / background ingestion support ---
|
|
291
|
+
JOB_ERRORS_CAP = 50 # per-job error records kept (failed count keeps counting)
|
|
292
|
+
|
|
293
|
+
|
|
50
294
|
@dataclass
|
|
51
295
|
class BackgroundIngestionJob:
|
|
52
|
-
"""Job descriptor for background/incremental indexing
|
|
296
|
+
"""Job descriptor + progress state for background/incremental indexing.
|
|
297
|
+
|
|
298
|
+
``done_indices`` tracks per-item completion so an interrupted or partially
|
|
299
|
+
failed job can be *resumed* from the remaining items instead of restarting.
|
|
300
|
+
``errors`` is capped at ``max_errors`` records; ``failed`` keeps counting.
|
|
301
|
+
"""
|
|
53
302
|
job_id: str
|
|
54
303
|
items: List[IngestionItem]
|
|
55
|
-
status: str = "
|
|
304
|
+
status: str = "queued" # queued | running | completed | failed | partial
|
|
56
305
|
created_at: str = field(default_factory=utc_now_iso)
|
|
306
|
+
updated_at: str = field(default_factory=utc_now_iso)
|
|
57
307
|
processed: int = 0
|
|
308
|
+
failed: int = 0
|
|
58
309
|
total: int = 0
|
|
59
|
-
errors: List[str] = field(default_factory=list)
|
|
310
|
+
errors: List[Dict[str, Any]] = field(default_factory=list)
|
|
311
|
+
incremental: bool = True
|
|
312
|
+
user_email: Optional[str] = None
|
|
313
|
+
max_errors: int = JOB_ERRORS_CAP
|
|
314
|
+
done_indices: Set[int] = field(default_factory=set)
|
|
315
|
+
|
|
316
|
+
def touch(self) -> None:
|
|
317
|
+
self.updated_at = utc_now_iso()
|
|
318
|
+
|
|
319
|
+
def record_error(self, index: int, item: IngestionItem, detail: Any) -> None:
|
|
320
|
+
self.failed += 1
|
|
321
|
+
if len(self.errors) < self.max_errors:
|
|
322
|
+
self.errors.append({
|
|
323
|
+
"index": index,
|
|
324
|
+
"source": item.source_uri or item.path or item.title or item.source_type,
|
|
325
|
+
"detail": str(detail)[:500],
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
def remaining_indices(self) -> List[int]:
|
|
329
|
+
return [i for i in range(len(self.items)) if i not in self.done_indices]
|
|
330
|
+
|
|
331
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
332
|
+
"""Frozen job schema consumed by ``/api/ingestion/jobs*``."""
|
|
333
|
+
return {
|
|
334
|
+
"job_id": self.job_id,
|
|
335
|
+
"status": self.status,
|
|
336
|
+
"total": self.total,
|
|
337
|
+
"processed": self.processed,
|
|
338
|
+
"failed": self.failed,
|
|
339
|
+
"errors": list(self.errors),
|
|
340
|
+
"created_at": self.created_at,
|
|
341
|
+
"updated_at": self.updated_at,
|
|
342
|
+
}
|
|
60
343
|
|
|
61
344
|
|
|
62
345
|
class BackgroundIngestionQueue:
|
|
@@ -69,27 +352,45 @@ class BackgroundIngestionQueue:
|
|
|
69
352
|
def __init__(self) -> None:
|
|
70
353
|
self._jobs: Dict[str, BackgroundIngestionJob] = {}
|
|
71
354
|
self._counter = 0
|
|
355
|
+
self._lock = threading.Lock()
|
|
72
356
|
|
|
73
|
-
def schedule(
|
|
74
|
-
self
|
|
75
|
-
|
|
357
|
+
def schedule(
|
|
358
|
+
self,
|
|
359
|
+
items: List[IngestionItem],
|
|
360
|
+
*,
|
|
361
|
+
incremental: bool = True,
|
|
362
|
+
user_email: Optional[str] = None,
|
|
363
|
+
) -> BackgroundIngestionJob:
|
|
364
|
+
with self._lock:
|
|
365
|
+
self._counter += 1
|
|
366
|
+
job_id = f"bg_ingest_{self._counter:04d}"
|
|
76
367
|
job = BackgroundIngestionJob(
|
|
77
368
|
job_id=job_id,
|
|
78
369
|
items=items,
|
|
79
370
|
total=len(items),
|
|
371
|
+
incremental=incremental,
|
|
372
|
+
user_email=user_email,
|
|
80
373
|
)
|
|
81
374
|
# annotate items for downstream
|
|
82
375
|
for it in job.items:
|
|
83
376
|
# attach flag without breaking dataclass defaults (use metadata)
|
|
84
377
|
it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
|
|
85
|
-
self.
|
|
378
|
+
with self._lock:
|
|
379
|
+
self._jobs[job_id] = job
|
|
86
380
|
return job
|
|
87
381
|
|
|
88
382
|
def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
89
383
|
return self._jobs.get(job_id)
|
|
90
384
|
|
|
91
385
|
def list_pending(self) -> List[BackgroundIngestionJob]:
|
|
92
|
-
return [j for j in self._jobs.values() if j.status == "
|
|
386
|
+
return [j for j in self._jobs.values() if j.status == "queued"]
|
|
387
|
+
|
|
388
|
+
def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
|
|
389
|
+
"""Most recent jobs first (insertion order is schedule order)."""
|
|
390
|
+
limit = max(1, int(limit))
|
|
391
|
+
with self._lock:
|
|
392
|
+
jobs = list(self._jobs.values())
|
|
393
|
+
return list(reversed(jobs))[:limit]
|
|
93
394
|
|
|
94
395
|
|
|
95
396
|
@dataclass
|
|
@@ -129,9 +430,13 @@ class IngestionResult:
|
|
|
129
430
|
indexing_status: str = "pending" # indexed | skipped | failed | pending
|
|
130
431
|
provenance_id: Optional[str] = None
|
|
131
432
|
detail: Optional[str] = None
|
|
433
|
+
# v9.8.0 additive quality fields — advisory only, never gate behavior.
|
|
434
|
+
extraction_quality: Optional[Dict[str, Any]] = None
|
|
435
|
+
warnings: List[str] = field(default_factory=list)
|
|
436
|
+
quality_gate: Optional[Dict[str, Any]] = None
|
|
132
437
|
|
|
133
438
|
def as_dict(self) -> Dict[str, Any]:
|
|
134
|
-
|
|
439
|
+
payload = {
|
|
135
440
|
"status": self.status,
|
|
136
441
|
"source_type": self.source_type,
|
|
137
442
|
"node_id": self.node_id,
|
|
@@ -146,6 +451,14 @@ class IngestionResult:
|
|
|
146
451
|
"provenance_id": self.provenance_id,
|
|
147
452
|
"detail": self.detail,
|
|
148
453
|
}
|
|
454
|
+
# Additive keys only when populated so pre-v9.8 payloads are unchanged.
|
|
455
|
+
if self.extraction_quality is not None:
|
|
456
|
+
payload["extraction_quality"] = self.extraction_quality
|
|
457
|
+
if self.warnings:
|
|
458
|
+
payload["warnings"] = list(self.warnings)
|
|
459
|
+
if self.quality_gate is not None:
|
|
460
|
+
payload["quality_gate"] = self.quality_gate
|
|
461
|
+
return payload
|
|
149
462
|
|
|
150
463
|
|
|
151
464
|
class IngestionPipeline:
|
|
@@ -161,6 +474,7 @@ class IngestionPipeline:
|
|
|
161
474
|
max_text_bytes: int = DEFAULT_MAX_TEXT_BYTES,
|
|
162
475
|
pipeline_name: str = "unified-ingestion",
|
|
163
476
|
bg_queue: Optional[BackgroundIngestionQueue] = None,
|
|
477
|
+
auto_vector_index: bool = True,
|
|
164
478
|
) -> None:
|
|
165
479
|
self._kg = knowledge_graph
|
|
166
480
|
self._hooks = hooks
|
|
@@ -169,6 +483,13 @@ class IngestionPipeline:
|
|
|
169
483
|
self._max_text_bytes = int(max_text_bytes)
|
|
170
484
|
self._pipeline_name = pipeline_name
|
|
171
485
|
self._bg_queue = bg_queue or BackgroundIngestionQueue()
|
|
486
|
+
# Incremental vector sync after each successful non-duplicate ingest.
|
|
487
|
+
# Constructor opt-out AND env opt-out (LATTICEAI_AUTO_VECTOR_INDEX=0)
|
|
488
|
+
# both disable it; a vector failure never fails the ingest.
|
|
489
|
+
env_flag = os.getenv(AUTO_VECTOR_INDEX_ENV, "1").strip().lower() not in {
|
|
490
|
+
"0", "false", "no", "off",
|
|
491
|
+
}
|
|
492
|
+
self._auto_vector_index = bool(auto_vector_index) and env_flag
|
|
172
493
|
|
|
173
494
|
def available(self) -> bool:
|
|
174
495
|
return self._enable and self._kg is not None
|
|
@@ -204,6 +525,14 @@ class IngestionPipeline:
|
|
|
204
525
|
return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
205
526
|
return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
206
527
|
|
|
528
|
+
# v9.8.0 observation-only quality gate: computed *before* the write so
|
|
529
|
+
# the search never matches the node we are about to create. It is
|
|
530
|
+
# recorded on the result and never skips an ingest (behavior unchanged).
|
|
531
|
+
quality_text = self._extractable_text(item)
|
|
532
|
+
quality_gate = self._observe_quality_gate(
|
|
533
|
+
item, source_type=source_type, text=quality_text,
|
|
534
|
+
)
|
|
535
|
+
|
|
207
536
|
try:
|
|
208
537
|
raw = dispatch_tool(
|
|
209
538
|
self._hooks, tool_name, args, _run,
|
|
@@ -228,9 +557,19 @@ class IngestionPipeline:
|
|
|
228
557
|
node_id = raw.get("node_id")
|
|
229
558
|
content_hash = raw.get("content_hash") or raw.get("sha256")
|
|
230
559
|
chunk_ids = list(raw.get("chunk_ids") or [])
|
|
231
|
-
embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
|
|
232
560
|
title = raw.get("title") or item.title
|
|
233
561
|
|
|
562
|
+
# Incremental vector-index sync (opt-in via auto_vector_index +
|
|
563
|
+
# LATTICEAI_AUTO_VECTOR_INDEX). Exception-safe by contract: the graph
|
|
564
|
+
# write above already landed, so a vector failure only downgrades
|
|
565
|
+
# indexing_status to "pending" — index_status()/rebuild_vector_index()
|
|
566
|
+
# discover the same node as backlog and pick it up later.
|
|
567
|
+
indexing_status = "indexed"
|
|
568
|
+
vector_detail: Optional[str] = None
|
|
569
|
+
if node_id and self._auto_vector_index and not bool(raw.get("duplicate")):
|
|
570
|
+
indexing_status, vector_detail = self._sync_vector_index(node_id)
|
|
571
|
+
embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
|
|
572
|
+
|
|
234
573
|
# Provenance capture must never turn an already-persisted ingest into a
|
|
235
574
|
# caller-visible failure: the graph write above succeeded, so a broken
|
|
236
575
|
# provenance table degrades the result instead of raising.
|
|
@@ -271,6 +610,14 @@ class IngestionPipeline:
|
|
|
271
610
|
except Exception: # noqa: BLE001 — audit must never break ingestion
|
|
272
611
|
pass
|
|
273
612
|
|
|
613
|
+
extraction_quality = self._assess_item_quality(
|
|
614
|
+
item, source_type=source_type, text=quality_text, chunk_ids=chunk_ids,
|
|
615
|
+
)
|
|
616
|
+
warnings: List[str] = []
|
|
617
|
+
if extraction_quality is not None and extraction_quality.get("level") == "low":
|
|
618
|
+
warnings.append(QUALITY_LOW_WARNING)
|
|
619
|
+
|
|
620
|
+
details = [d for d in (provenance_detail, vector_detail) if d]
|
|
274
621
|
return IngestionResult(
|
|
275
622
|
status="ok",
|
|
276
623
|
source_type=source_type,
|
|
@@ -282,31 +629,425 @@ class IngestionPipeline:
|
|
|
282
629
|
chunk_count=len(chunk_ids),
|
|
283
630
|
duplicate=bool(raw.get("duplicate")),
|
|
284
631
|
embedded=embedded,
|
|
285
|
-
indexing_status=
|
|
632
|
+
indexing_status=indexing_status,
|
|
286
633
|
provenance_id=prov.get("id"),
|
|
287
|
-
detail=
|
|
634
|
+
detail="; ".join(details) if details else None,
|
|
635
|
+
extraction_quality=extraction_quality,
|
|
636
|
+
warnings=warnings,
|
|
637
|
+
quality_gate=quality_gate,
|
|
288
638
|
)
|
|
289
639
|
|
|
640
|
+
# ── extraction quality (v9.8.0 A1 — advisory, never gates) ───────────────
|
|
641
|
+
@staticmethod
|
|
642
|
+
def _extractable_text(item: IngestionItem) -> Optional[str]:
|
|
643
|
+
"""Best available extracted text for quality scoring/gating."""
|
|
644
|
+
if item.text is not None:
|
|
645
|
+
return item.text
|
|
646
|
+
extracted = (item.metadata or {}).get("extracted")
|
|
647
|
+
if isinstance(extracted, dict):
|
|
648
|
+
content = extracted.get("content") or extracted.get("text")
|
|
649
|
+
if content is not None:
|
|
650
|
+
return str(content)
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
@staticmethod
|
|
654
|
+
def _upstream_confidence(item: IngestionItem) -> Optional[Any]:
|
|
655
|
+
"""Upstream extractor confidence, if the capture surface supplied one."""
|
|
656
|
+
meta = item.metadata or {}
|
|
657
|
+
extracted = meta.get("extracted")
|
|
658
|
+
if isinstance(extracted, dict) and extracted.get("confidence") is not None:
|
|
659
|
+
return extracted.get("confidence")
|
|
660
|
+
if meta.get("extraction_confidence") is not None:
|
|
661
|
+
return meta.get("extraction_confidence")
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
def _assess_item_quality(
|
|
665
|
+
self,
|
|
666
|
+
item: IngestionItem,
|
|
667
|
+
*,
|
|
668
|
+
source_type: str,
|
|
669
|
+
text: Optional[str],
|
|
670
|
+
chunk_ids: List[str],
|
|
671
|
+
) -> Optional[Dict[str, Any]]:
|
|
672
|
+
"""Quality annotation for document-like sources (not chat/memory)."""
|
|
673
|
+
if source_type in CHAT_SOURCE_TYPES or source_type in MEMORY_SOURCE_TYPES:
|
|
674
|
+
return None
|
|
675
|
+
confidence = self._upstream_confidence(item)
|
|
676
|
+
if text is not None or confidence is not None:
|
|
677
|
+
return assess_extraction_quality(
|
|
678
|
+
text, source_type=source_type, upstream_confidence=confidence,
|
|
679
|
+
)
|
|
680
|
+
# File door without inline extraction (e.g. PDF): the pipeline never saw
|
|
681
|
+
# the text, so score honestly from the chunk output instead of guessing.
|
|
682
|
+
if chunk_ids:
|
|
683
|
+
return {
|
|
684
|
+
"score": 0.5,
|
|
685
|
+
"level": "medium",
|
|
686
|
+
"reasons": ["content_extracted_upstream_not_scored"],
|
|
687
|
+
}
|
|
688
|
+
return {"score": 0.0, "level": "low", "reasons": ["no_extracted_text"]}
|
|
689
|
+
|
|
690
|
+
def _observe_quality_gate(
|
|
691
|
+
self,
|
|
692
|
+
item: IngestionItem,
|
|
693
|
+
*,
|
|
694
|
+
source_type: str,
|
|
695
|
+
text: Optional[str],
|
|
696
|
+
) -> Optional[Dict[str, Any]]:
|
|
697
|
+
"""Observation-mode ``gate_ingest_candidate`` wiring.
|
|
698
|
+
|
|
699
|
+
Records what the proactive gate *would* decide (ingest /
|
|
700
|
+
skip_duplicate / review) without ever acting on it. Any failure —
|
|
701
|
+
import, search, gate — yields ``None``; the ingest proceeds untouched.
|
|
702
|
+
"""
|
|
703
|
+
if source_type in CHAT_SOURCE_TYPES or source_type in MEMORY_SOURCE_TYPES:
|
|
704
|
+
return None
|
|
705
|
+
body = str(text or "").strip()
|
|
706
|
+
if not body:
|
|
707
|
+
return None
|
|
708
|
+
try:
|
|
709
|
+
from .graph.proactive import gate_ingest_candidate
|
|
710
|
+
except Exception: # noqa: BLE001 — optional observation, never required
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
def _search(query: str) -> Any:
|
|
714
|
+
snippet = str(query or "")[:400]
|
|
715
|
+
try:
|
|
716
|
+
if item.workspace_id:
|
|
717
|
+
return self._kg.search(
|
|
718
|
+
snippet, 20, allowed_workspaces={item.workspace_id},
|
|
719
|
+
)
|
|
720
|
+
return self._kg.search(snippet, 20)
|
|
721
|
+
except TypeError:
|
|
722
|
+
# Older store without workspace-scoped search.
|
|
723
|
+
return self._kg.search(snippet, 20)
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
gate = gate_ingest_candidate(body, _search)
|
|
727
|
+
except Exception: # noqa: BLE001 — observation must never fail the ingest
|
|
728
|
+
return None
|
|
729
|
+
parts = [str(gate.get("reason") or "")]
|
|
730
|
+
if gate.get("similarity") is not None:
|
|
731
|
+
parts.append(f"similarity={gate.get('similarity')}")
|
|
732
|
+
if gate.get("match_id"):
|
|
733
|
+
parts.append(f"match={gate.get('match_id')}")
|
|
734
|
+
return {
|
|
735
|
+
"action": str(gate.get("action") or "review"),
|
|
736
|
+
"detail": "; ".join(p for p in parts if p),
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
def _sync_vector_index(self, node_id: str) -> Tuple[str, Optional[str]]:
|
|
740
|
+
"""Best-effort incremental vector sync → (indexing_status, detail).
|
|
741
|
+
|
|
742
|
+
Any failure — missing method on older stores, embedding provider down,
|
|
743
|
+
storage error — yields ``("pending", detail)`` so a later
|
|
744
|
+
``rebuild_vector_index`` run picks the node up from the backlog.
|
|
745
|
+
"""
|
|
746
|
+
sync = getattr(self._kg, "index_node_incremental", None)
|
|
747
|
+
if not callable(sync):
|
|
748
|
+
# Older store without the incremental path: the write-side already
|
|
749
|
+
# embeds inline, so nothing extra to do.
|
|
750
|
+
return "indexed", None
|
|
751
|
+
try:
|
|
752
|
+
outcome = sync(node_id) or {}
|
|
753
|
+
except Exception as exc: # noqa: BLE001 — vector sync must never fail the ingest
|
|
754
|
+
return "pending", f"vector index sync failed: {exc}"
|
|
755
|
+
if str(outcome.get("status") or "") == "failed":
|
|
756
|
+
reason = outcome.get("detail") or "unknown error"
|
|
757
|
+
return "pending", f"vector index sync failed: {reason}"
|
|
758
|
+
return "indexed", None
|
|
759
|
+
|
|
290
760
|
# --- Large candidate #1: background / incremental scheduling (slice) ---
|
|
291
761
|
def schedule_background(
|
|
292
762
|
self,
|
|
293
763
|
items: List[IngestionItem],
|
|
294
764
|
*,
|
|
295
765
|
incremental: bool = True,
|
|
766
|
+
user_email: Optional[str] = None,
|
|
296
767
|
) -> BackgroundIngestionJob:
|
|
297
768
|
"""Schedule items for background incremental indexing.
|
|
298
769
|
|
|
299
770
|
Returns a job handle. Actual execution can be driven by caller
|
|
300
|
-
(or future worker) calling pipeline.ingest on each
|
|
301
|
-
large-corpus scale
|
|
771
|
+
(or future worker) calling pipeline.ingest on each — or through
|
|
772
|
+
:meth:`run_background_job`. This seam enables large-corpus scale
|
|
773
|
+
without blocking user requests.
|
|
302
774
|
"""
|
|
303
|
-
job = self._bg_queue.schedule(items, incremental=incremental)
|
|
775
|
+
job = self._bg_queue.schedule(items, incremental=incremental, user_email=user_email)
|
|
304
776
|
# mark initial status on results concept (jobs track)
|
|
305
777
|
return job
|
|
306
778
|
|
|
307
779
|
def get_background_job(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
308
780
|
return self._bg_queue.get(job_id)
|
|
309
781
|
|
|
782
|
+
def list_background_jobs(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
783
|
+
"""Recent jobs (newest first) in the frozen ``/api/ingestion`` schema."""
|
|
784
|
+
return [job.as_dict() for job in self._bg_queue.list_recent(limit=limit)]
|
|
785
|
+
|
|
786
|
+
def run_background_job(
|
|
787
|
+
self, job_id: str, *, user_email: Optional[str] = None
|
|
788
|
+
) -> Dict[str, Any]:
|
|
789
|
+
"""Execute a queued/interrupted job's remaining items.
|
|
790
|
+
|
|
791
|
+
Per-item errors are recorded (capped) and never abort the job. The
|
|
792
|
+
final status is ``completed`` (all done), ``partial`` (some done),
|
|
793
|
+
or ``failed`` (nothing done). Already-completed items are skipped, so
|
|
794
|
+
the same method safely powers both first-run and resume.
|
|
795
|
+
"""
|
|
796
|
+
job = self._bg_queue.get(job_id)
|
|
797
|
+
if job is None:
|
|
798
|
+
return {"status": "not_found", "job_id": job_id}
|
|
799
|
+
if job.status == "running":
|
|
800
|
+
return job.as_dict()
|
|
801
|
+
return self._execute_background_job(job, user_email=user_email)
|
|
802
|
+
|
|
803
|
+
def resume_background_job(
|
|
804
|
+
self, job_id: str, *, user_email: Optional[str] = None
|
|
805
|
+
) -> Dict[str, Any]:
|
|
806
|
+
"""Resume an interrupted/partial/failed job from its remaining items."""
|
|
807
|
+
return self.run_background_job(job_id, user_email=user_email)
|
|
808
|
+
|
|
809
|
+
def _execute_background_job(
|
|
810
|
+
self, job: BackgroundIngestionJob, *, user_email: Optional[str] = None
|
|
811
|
+
) -> Dict[str, Any]:
|
|
812
|
+
job.status = "running"
|
|
813
|
+
# Retried items get a fresh verdict: reset failure state for this run.
|
|
814
|
+
job.failed = 0
|
|
815
|
+
job.errors = []
|
|
816
|
+
job.touch()
|
|
817
|
+
runner_email = user_email or job.user_email
|
|
818
|
+
for index in job.remaining_indices():
|
|
819
|
+
item = job.items[index]
|
|
820
|
+
try:
|
|
821
|
+
result = self.ingest(item, user_email=runner_email or item.owner)
|
|
822
|
+
status, detail = result.status, result.detail
|
|
823
|
+
except Exception as exc: # noqa: BLE001 — per-item isolation: keep going
|
|
824
|
+
status, detail = "failed", str(exc)
|
|
825
|
+
if status == "ok":
|
|
826
|
+
job.done_indices.add(index)
|
|
827
|
+
else:
|
|
828
|
+
job.record_error(index, item, detail or status)
|
|
829
|
+
job.processed = len(job.done_indices)
|
|
830
|
+
job.touch()
|
|
831
|
+
job.processed = len(job.done_indices)
|
|
832
|
+
if job.total == 0 or job.processed >= job.total:
|
|
833
|
+
job.status = "completed"
|
|
834
|
+
elif job.processed > 0:
|
|
835
|
+
job.status = "partial"
|
|
836
|
+
else:
|
|
837
|
+
job.status = "failed"
|
|
838
|
+
job.touch()
|
|
839
|
+
return job.as_dict()
|
|
840
|
+
|
|
841
|
+
def ingest_web_page(
|
|
842
|
+
self,
|
|
843
|
+
url: str,
|
|
844
|
+
extracted_text: str,
|
|
845
|
+
*,
|
|
846
|
+
title: Optional[str] = None,
|
|
847
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
848
|
+
owner: Optional[str] = None,
|
|
849
|
+
workspace_id: Optional[str] = None,
|
|
850
|
+
captured_at: Optional[str] = None,
|
|
851
|
+
user_email: Optional[str] = None,
|
|
852
|
+
) -> IngestionResult:
|
|
853
|
+
"""Ingest an *already-extracted* web page (see module docstring seam).
|
|
854
|
+
|
|
855
|
+
Fetching/parsing is upstream's responsibility (browser extension /
|
|
856
|
+
tools layer); this wrapper only normalizes ``(url, extracted_text)``
|
|
857
|
+
into an ``IngestionItem(source_type="web_url")`` and routes it through
|
|
858
|
+
the standard :meth:`ingest` door.
|
|
859
|
+
"""
|
|
860
|
+
url = str(url or "").strip()
|
|
861
|
+
if not url:
|
|
862
|
+
return IngestionResult(
|
|
863
|
+
status="failed", source_type="web_url",
|
|
864
|
+
indexing_status="skipped", detail="url required",
|
|
865
|
+
)
|
|
866
|
+
text = str(extracted_text or "")
|
|
867
|
+
if not text.strip():
|
|
868
|
+
return IngestionResult(
|
|
869
|
+
status="failed", source_type="web_url",
|
|
870
|
+
indexing_status="skipped",
|
|
871
|
+
detail=(
|
|
872
|
+
"extracted_text required — the graph layer does not fetch or "
|
|
873
|
+
"parse the web; extraction happens upstream."
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
item = IngestionItem(
|
|
877
|
+
source_type="web_url",
|
|
878
|
+
title=title or url,
|
|
879
|
+
text=text,
|
|
880
|
+
source_uri=url,
|
|
881
|
+
owner=owner,
|
|
882
|
+
workspace_id=workspace_id,
|
|
883
|
+
captured_at=captured_at,
|
|
884
|
+
metadata=dict(metadata or {}),
|
|
885
|
+
)
|
|
886
|
+
return self.ingest(item, user_email=user_email or owner)
|
|
887
|
+
|
|
888
|
+
def ingest_folder(
|
|
889
|
+
self,
|
|
890
|
+
root_path: Any,
|
|
891
|
+
*,
|
|
892
|
+
recursive: bool = True,
|
|
893
|
+
background: bool = False,
|
|
894
|
+
extensions: Optional[Iterable[str]] = None,
|
|
895
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
896
|
+
include_hidden: bool = False,
|
|
897
|
+
max_files: int = 1000,
|
|
898
|
+
max_errors: int = 25,
|
|
899
|
+
owner: Optional[str] = None,
|
|
900
|
+
workspace_id: Optional[str] = None,
|
|
901
|
+
user_email: Optional[str] = None,
|
|
902
|
+
) -> Dict[str, Any]:
|
|
903
|
+
"""Walk ``root_path`` and ingest every eligible file through the pipeline.
|
|
904
|
+
|
|
905
|
+
Filtering, in order: hard skip-list directories (``.git`` …), hidden
|
|
906
|
+
entries (unless ``include_hidden``), root ``.latticeignore`` patterns
|
|
907
|
+
(fnmatch globs; ``dir/`` suffix prunes directories), extension
|
|
908
|
+
allow-list, then ``max_file_bytes``. Text/code files are read inline so
|
|
909
|
+
their content is chunked; ``.pdf`` routes through the file door without
|
|
910
|
+
inline extraction.
|
|
911
|
+
|
|
912
|
+
``background=True`` schedules the built items on the existing
|
|
913
|
+
:class:`BackgroundIngestionQueue` instead of ingesting inline.
|
|
914
|
+
Returns a summary dict with counts and per-file errors (capped at
|
|
915
|
+
``max_errors``).
|
|
916
|
+
"""
|
|
917
|
+
summary: Dict[str, Any] = {
|
|
918
|
+
"root": str(root_path),
|
|
919
|
+
"recursive": bool(recursive),
|
|
920
|
+
"background": bool(background),
|
|
921
|
+
"scanned": 0,
|
|
922
|
+
"matched": 0,
|
|
923
|
+
"ingested": 0,
|
|
924
|
+
"duplicate": 0,
|
|
925
|
+
"failed": 0,
|
|
926
|
+
"skipped": {"ignored": 0, "extension": 0, "too_large": 0, "hidden": 0},
|
|
927
|
+
"truncated": False,
|
|
928
|
+
"errors": [],
|
|
929
|
+
}
|
|
930
|
+
try:
|
|
931
|
+
root = Path(root_path).expanduser()
|
|
932
|
+
except TypeError:
|
|
933
|
+
summary.update(status="failed", detail=f"invalid root path: {root_path!r}")
|
|
934
|
+
return summary
|
|
935
|
+
if not root.is_dir():
|
|
936
|
+
summary.update(status="failed", detail=f"not a directory: {root}")
|
|
937
|
+
return summary
|
|
938
|
+
if not self.available():
|
|
939
|
+
summary.update(
|
|
940
|
+
status="unavailable",
|
|
941
|
+
detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
|
|
942
|
+
)
|
|
943
|
+
return summary
|
|
944
|
+
summary["root"] = str(root)
|
|
945
|
+
max_files = max(1, int(max_files))
|
|
946
|
+
max_errors = max(0, int(max_errors))
|
|
947
|
+
max_file_bytes = max(1, int(max_file_bytes))
|
|
948
|
+
allowed_exts = (
|
|
949
|
+
frozenset(str(e).lower() if str(e).startswith(".") else f".{str(e).lower()}" for e in extensions)
|
|
950
|
+
if extensions
|
|
951
|
+
else DEFAULT_FOLDER_EXTENSIONS
|
|
952
|
+
)
|
|
953
|
+
patterns = _load_latticeignore(root)
|
|
954
|
+
errors: List[Dict[str, Any]] = summary["errors"]
|
|
955
|
+
skipped = summary["skipped"]
|
|
956
|
+
items: List[IngestionItem] = []
|
|
957
|
+
|
|
958
|
+
def _record_error(path: Path, detail: str, status: str = "failed") -> None:
|
|
959
|
+
summary["failed"] += 1
|
|
960
|
+
if len(errors) < max_errors:
|
|
961
|
+
errors.append({"path": str(path), "status": status, "detail": detail})
|
|
962
|
+
|
|
963
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
964
|
+
current = Path(dirpath)
|
|
965
|
+
rel_dir = current.relative_to(root)
|
|
966
|
+
kept_dirs: List[str] = []
|
|
967
|
+
for name in sorted(dirnames):
|
|
968
|
+
if name in FOLDER_DEFAULT_SKIP_DIRS:
|
|
969
|
+
continue
|
|
970
|
+
if name.startswith(".") and not include_hidden:
|
|
971
|
+
continue
|
|
972
|
+
rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
|
|
973
|
+
if _matches_ignore(rel, name, is_dir=True, patterns=patterns):
|
|
974
|
+
skipped["ignored"] += 1
|
|
975
|
+
continue
|
|
976
|
+
kept_dirs.append(name)
|
|
977
|
+
dirnames[:] = kept_dirs if recursive else []
|
|
978
|
+
|
|
979
|
+
for name in sorted(filenames):
|
|
980
|
+
if name == LATTICEIGNORE_FILENAME:
|
|
981
|
+
continue
|
|
982
|
+
summary["scanned"] += 1
|
|
983
|
+
path = current / name
|
|
984
|
+
rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
|
|
985
|
+
if name.startswith(".") and not include_hidden:
|
|
986
|
+
skipped["hidden"] += 1
|
|
987
|
+
continue
|
|
988
|
+
if _matches_ignore(rel, name, is_dir=False, patterns=patterns):
|
|
989
|
+
skipped["ignored"] += 1
|
|
990
|
+
continue
|
|
991
|
+
ext = path.suffix.lower()
|
|
992
|
+
if ext not in allowed_exts:
|
|
993
|
+
skipped["extension"] += 1
|
|
994
|
+
continue
|
|
995
|
+
try:
|
|
996
|
+
size = path.stat().st_size
|
|
997
|
+
except OSError as exc:
|
|
998
|
+
_record_error(path, f"stat failed: {exc}")
|
|
999
|
+
continue
|
|
1000
|
+
if size > max_file_bytes:
|
|
1001
|
+
skipped["too_large"] += 1
|
|
1002
|
+
continue
|
|
1003
|
+
if len(items) >= max_files:
|
|
1004
|
+
summary["truncated"] = True
|
|
1005
|
+
break
|
|
1006
|
+
item_metadata: Dict[str, Any] = {"relative_path": rel}
|
|
1007
|
+
if ext in FOLDER_DOCUMENT_EXTENSIONS:
|
|
1008
|
+
source_type = "pdf"
|
|
1009
|
+
else:
|
|
1010
|
+
source_type = "file"
|
|
1011
|
+
try:
|
|
1012
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
1013
|
+
except OSError as exc:
|
|
1014
|
+
_record_error(path, f"read failed: {exc}")
|
|
1015
|
+
continue
|
|
1016
|
+
item_metadata["extracted"] = {"content": content, "chars": len(content)}
|
|
1017
|
+
items.append(
|
|
1018
|
+
IngestionItem(
|
|
1019
|
+
source_type=source_type,
|
|
1020
|
+
title=name,
|
|
1021
|
+
path=str(path),
|
|
1022
|
+
source_uri=str(path),
|
|
1023
|
+
owner=owner,
|
|
1024
|
+
workspace_id=workspace_id,
|
|
1025
|
+
metadata=item_metadata,
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
1028
|
+
if summary["truncated"]:
|
|
1029
|
+
break
|
|
1030
|
+
|
|
1031
|
+
summary["matched"] = len(items)
|
|
1032
|
+
if background:
|
|
1033
|
+
job = self.schedule_background(
|
|
1034
|
+
items, incremental=True, user_email=user_email or owner,
|
|
1035
|
+
)
|
|
1036
|
+
summary.update(status="scheduled", job_id=job.job_id, scheduled=len(items))
|
|
1037
|
+
return summary
|
|
1038
|
+
|
|
1039
|
+
for item in items:
|
|
1040
|
+
result = self.ingest(item, user_email=user_email or owner)
|
|
1041
|
+
if result.status == "ok":
|
|
1042
|
+
if result.duplicate:
|
|
1043
|
+
summary["duplicate"] += 1
|
|
1044
|
+
else:
|
|
1045
|
+
summary["ingested"] += 1
|
|
1046
|
+
else:
|
|
1047
|
+
_record_error(Path(item.path or ""), result.detail or result.status, result.status)
|
|
1048
|
+
summary["status"] = "ok" if summary["failed"] == 0 else "partial"
|
|
1049
|
+
return summary
|
|
1050
|
+
|
|
310
1051
|
# ── routing helpers ──────────────────────────────────────────────────────
|
|
311
1052
|
def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
312
1053
|
text = item.text or ""
|