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.
Files changed (73) hide show
  1. package/README.md +98 -306
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +91 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  5. package/docs/DEVELOPMENT.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/PERFORMANCE.md +106 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/kg_schema.py +11 -1
  12. package/knowledge_graph.py +12 -2
  13. package/knowledge_graph_api.py +11 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/proactive.py +583 -0
  16. package/lattice_brain/graph/retrieval.py +301 -8
  17. package/lattice_brain/graph/retrieval_vector.py +145 -0
  18. package/lattice_brain/ingestion.py +757 -16
  19. package/lattice_brain/quality.py +44 -9
  20. package/lattice_brain/runtime/multi_agent.py +38 -2
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/brain_intelligence.py +39 -2
  23. package/latticeai/api/change_proposals.py +32 -3
  24. package/latticeai/api/chat.py +17 -0
  25. package/latticeai/api/chat_helpers.py +48 -0
  26. package/latticeai/api/chat_stream.py +9 -1
  27. package/latticeai/api/local_files.py +120 -2
  28. package/latticeai/api/review_queue.py +66 -2
  29. package/latticeai/app_factory.py +3 -0
  30. package/latticeai/core/agent.py +193 -135
  31. package/latticeai/core/agent_eval.py +278 -4
  32. package/latticeai/core/legacy_compatibility.py +15 -1
  33. package/latticeai/core/marketplace.py +1 -1
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/runtime/router_registration.py +2 -0
  36. package/latticeai/services/architecture_readiness.py +1 -1
  37. package/latticeai/services/automation_intelligence.py +151 -14
  38. package/latticeai/services/brain_intelligence.py +169 -1
  39. package/latticeai/services/change_proposals.py +56 -4
  40. package/latticeai/services/product_readiness.py +1 -1
  41. package/latticeai/services/review_queue.py +43 -2
  42. package/llm_router.py +10 -1
  43. package/local_knowledge_api.py +10 -1
  44. package/ltcai_cli.py +11 -1
  45. package/mcp_registry.py +10 -1
  46. package/p_reinforce.py +10 -1
  47. package/package.json +1 -1
  48. package/scripts/brain_quality_eval.py +1 -1
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/profile_kg.py +360 -0
  51. package/setup_wizard.py +10 -1
  52. package/src-tauri/Cargo.lock +1 -1
  53. package/src-tauri/Cargo.toml +1 -1
  54. package/src-tauri/tauri.conf.json +1 -1
  55. package/static/app/asset-manifest.json +11 -11
  56. package/static/app/assets/{Act-BkOEmwBi.js → Act-Dd3z8AzF.js} +2 -1
  57. package/static/app/assets/Brain-BMkgdWnI.js +321 -0
  58. package/static/app/assets/Capture-D2Aw9gkv.js +1 -0
  59. package/static/app/assets/Library-Yreq-KW5.js +1 -0
  60. package/static/app/assets/System-CXNmmtEo.js +1 -0
  61. package/static/app/assets/{index-85wQvEie.css → index-7gY9t9Sd.css} +1 -1
  62. package/static/app/assets/index-CndfILiF.js +18 -0
  63. package/static/app/assets/primitives-DxsIXb6G.js +1 -0
  64. package/static/app/assets/textarea-DH7ne8VI.js +1 -0
  65. package/static/app/index.html +2 -2
  66. package/static/sw.js +1 -1
  67. package/static/app/assets/Brain-C9ITUsQ_.js +0 -321
  68. package/static/app/assets/Capture-C-ppTeud.js +0 -1
  69. package/static/app/assets/Library-CGQbgWTu.js +0 -1
  70. package/static/app/assets/System-djmj0n2_.js +0 -1
  71. package/static/app/assets/index-AF0-4XVv.js +0 -18
  72. package/static/app/assets/primitives-jbb2qv4Q.js +0 -1
  73. 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 (KG scale-up slice)."""
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 = "pending" # pending | running | done | failed
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(self, items: List[IngestionItem], *, incremental: bool = True) -> BackgroundIngestionJob:
74
- self._counter += 1
75
- job_id = f"bg_ingest_{self._counter:04d}"
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._jobs[job_id] = job
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 == "pending"]
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
- return {
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="indexed",
632
+ indexing_status=indexing_status,
286
633
  provenance_id=prov.get("id"),
287
- detail=provenance_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. This seam enables
301
- large-corpus scale without blocking user requests.
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 ""